From d33fdec960031773ec1514b2f99f4250fd6ea7ff Mon Sep 17 00:00:00 2001 From: wang Date: Thu, 1 Oct 2026 10:44:39 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=BD=AC=E7=A0=81=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dataserviceyy/service/VaaSyncService.java | 16 +-- .../dataserviceyy/util/G729Converter.java | 108 +++++++++++++++--- 2 files changed, 101 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/threecloud/dataserviceyy/service/VaaSyncService.java b/src/main/java/com/threecloud/dataserviceyy/service/VaaSyncService.java index 787ddbf..cf6e8a3 100644 --- a/src/main/java/com/threecloud/dataserviceyy/service/VaaSyncService.java +++ b/src/main/java/com/threecloud/dataserviceyy/service/VaaSyncService.java @@ -250,15 +250,17 @@ public class VaaSyncService { cityCode, fileName, localFile.length()); } else if (g729Converter.isG729(localFile)) { try { - transcodedFile = new File(localFile.getParentFile(), localFile.getName() + ".pcm.wav"); - if (transcodedFile.isFile()) { - try { - vaaHttpUtil.validateAudioFile(transcodedFile); + File cachedTranscodedFile = new File( + localFile.getParentFile(), localFile.getName() + ".pcm.wav"); + if (cachedTranscodedFile.isFile()) { + if (g729Converter.isValidPcmWav(cachedTranscodedFile)) { + transcodedFile = cachedTranscodedFile; logger.info("【录音】复用上次已完成的PCM转码文件: {} ({} 字节)", fileName, transcodedFile.length()); - } catch (Exception invalidCache) { - Files.deleteIfExists(transcodedFile.toPath()); - transcodedFile = null; + } else { + logger.warn("【录音】删除无效的历史PCM转码文件并重新转码: {} ({} 字节)", + cachedTranscodedFile.getAbsolutePath(), cachedTranscodedFile.length()); + Files.deleteIfExists(cachedTranscodedFile.toPath()); } } if (transcodedFile == null) { diff --git a/src/main/java/com/threecloud/dataserviceyy/util/G729Converter.java b/src/main/java/com/threecloud/dataserviceyy/util/G729Converter.java index ddff2c3..2693a38 100644 --- a/src/main/java/com/threecloud/dataserviceyy/util/G729Converter.java +++ b/src/main/java/com/threecloud/dataserviceyy/util/G729Converter.java @@ -6,12 +6,13 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; -import java.io.BufferedReader; import java.io.File; import java.io.IOException; -import java.io.InputStreamReader; import java.io.RandomAccessFile; import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; @@ -75,10 +76,17 @@ public class G729Converter { throw new IOException("待转码文件不存在: " + (src == null ? "null" : src.getPath())); } File dest = new File(src.getParentFile(), src.getName() + ".pcm.wav"); + File tempDest = new File(dest.getAbsolutePath() + ".part"); + File ffmpegLog = new File(dest.getAbsolutePath() + ".ffmpeg.log"); + Files.deleteIfExists(tempDest.toPath()); + Files.deleteIfExists(ffmpegLog.toPath()); List command = new ArrayList<>(); command.add(resolveFfmpegPath()); command.add("-y"); + command.add("-nostdin"); + // 部分录音盒生成的 G.729 WAV 中 fact.sample_count 填的是压缩字节数, + // 与实际采样数不一致。FFmpeg 会按数据流解码,这里不依赖该字段计算输出。 command.add("-i"); command.add(src.getAbsolutePath()); command.add("-ar"); @@ -87,40 +95,108 @@ public class G729Converter { command.add("1"); command.add("-c:a"); command.add("pcm_s16le"); - command.add(dest.getAbsolutePath()); + command.add("-f"); + command.add("wav"); + command.add(tempDest.getAbsolutePath()); logger.info("执行 ffmpeg 转码: {}", String.join(" ", command)); ProcessBuilder pb = new ProcessBuilder(command); pb.redirectErrorStream(true); + // FFmpeg 日志可能较多,必须在进程运行期间持续排出,否则管道写满会导致转码卡死。 + pb.redirectOutput(ffmpegLog); Process process = pb.start(); boolean finished = process.waitFor(TRANSCODE_TIMEOUT_SECONDS, TimeUnit.SECONDS); if (!finished) { process.destroyForcibly(); + Files.deleteIfExists(tempDest.toPath()); throw new IOException("ffmpeg 转码超时(>" + TRANSCODE_TIMEOUT_SECONDS + "s): " + src.getName()); } - // 进程已结束,读取输出用于日志与错误排查 - StringBuilder output = new StringBuilder(); - try (BufferedReader reader = new BufferedReader( - new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { - String line; - while ((line = reader.readLine()) != null) { - output.append(line).append('\n'); - } - } - int exitCode = process.exitValue(); if (exitCode != 0) { - String err = output.length() > 500 ? output.substring(output.length() - 500) : output.toString(); + String err = readLogTail(ffmpegLog, 1000); + Files.deleteIfExists(tempDest.toPath()); throw new IOException("ffmpeg 转码失败(exit=" + exitCode + "): " + src.getName() + " -> " + err); } - if (!dest.isFile() || dest.length() == 0) { - throw new IOException("ffmpeg 转码未生成有效文件: " + dest.getAbsolutePath()); + if (!isValidPcmWav(tempDest)) { + Files.deleteIfExists(tempDest.toPath()); + throw new IOException("ffmpeg 转码结果不是有效PCM WAV: " + tempDest.getAbsolutePath() + + ", 日志=" + readLogTail(ffmpegLog, 1000)); + } + try { + Files.move(tempDest.toPath(), dest.toPath(), + StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException e) { + Files.move(tempDest.toPath(), dest.toPath(), StandardCopyOption.REPLACE_EXISTING); + } finally { + Files.deleteIfExists(ffmpegLog.toPath()); } return dest; } + /** 判断文件是否为具有非空 data 块的标准 PCM WAV,防止复用上次遗留的半成品。 */ + public boolean isValidPcmWav(File file) { + if (file == null || !file.isFile() || file.length() <= 44) { + return false; + } + try (RandomAccessFile raf = new RandomAccessFile(file, "r")) { + if (raf.length() < 12) { + return false; + } + byte[] riff = new byte[12]; + raf.readFully(riff); + if (!(riff[0] == 'R' && riff[1] == 'I' && riff[2] == 'F' && riff[3] == 'F' + && riff[8] == 'W' && riff[9] == 'A' && riff[10] == 'V' && riff[11] == 'E')) { + return false; + } + boolean pcm = false; + boolean hasAudioData = false; + long offset = 12; + while (offset + 8 <= raf.length()) { + raf.seek(offset); + byte[] header = new byte[8]; + raf.readFully(header); + String chunkId = new String(header, 0, 4, StandardCharsets.US_ASCII); + long chunkSize = littleEndianUInt32(header, 4); + long dataOffset = offset + 8; + if (chunkSize > raf.length() - dataOffset) { + return false; + } + if ("fmt ".equals(chunkId) && chunkSize >= 2) { + raf.seek(dataOffset); + pcm = (raf.readUnsignedByte() | (raf.readUnsignedByte() << 8)) == 1; + } else if ("data".equals(chunkId)) { + hasAudioData = chunkSize > 0; + } + offset = dataOffset + chunkSize + (chunkSize & 1L); + } + return pcm && hasAudioData; + } catch (IOException e) { + return false; + } + } + + private long littleEndianUInt32(byte[] bytes, int offset) { + return (bytes[offset] & 0xFFL) + | ((bytes[offset + 1] & 0xFFL) << 8) + | ((bytes[offset + 2] & 0xFFL) << 16) + | ((bytes[offset + 3] & 0xFFL) << 24); + } + + private String readLogTail(File file, int maxChars) { + if (file == null || !file.isFile()) { + return ""; + } + try { + byte[] bytes = Files.readAllBytes(file.toPath()); + String text = new String(bytes, StandardCharsets.UTF_8); + return text.length() > maxChars ? text.substring(text.length() - maxChars) : text; + } catch (IOException e) { + return "读取FFmpeg日志失败: " + e.getMessage(); + } + } + /** * 根据配置解析 ffmpeg 可执行文件路径 *