diff --git a/pom.xml b/pom.xml
index 6014e24..b368858 100644
--- a/pom.xml
+++ b/pom.xml
@@ -135,6 +135,24 @@
commons-net
${commons-net.version}
+
+
+ org.bytedeco
+ ffmpeg
+ 5.1.2-1.5.8
+
+
+ org.bytedeco
+ ffmpeg
+ 5.1.2-1.5.8
+ linux-x86_64
+
+
+ org.bytedeco
+ javacpp
+ 1.5.8
+ linux-x86_64
+
org.springframework.boot
diff --git a/src/main/java/com/threecloud/dataserviceyy/service/VaaSyncService.java b/src/main/java/com/threecloud/dataserviceyy/service/VaaSyncService.java
index 535ed5b..77241fb 100644
--- a/src/main/java/com/threecloud/dataserviceyy/service/VaaSyncService.java
+++ b/src/main/java/com/threecloud/dataserviceyy/service/VaaSyncService.java
@@ -52,6 +52,8 @@ public class VaaSyncService {
private VaaHttpUtil vaaHttpUtil;
@Autowired
private OssFileService ossFileService;
+ @Autowired
+ private G729Converter g729Converter;
@Value("${vaa-sync.download-path:./vaa-recordings}")
private String downloadPath;
@@ -193,7 +195,7 @@ public class VaaSyncService {
// 8. 上传OSS(路径与原记录一致,覆盖同名文件,业务表无需更新)
String ossPath = "voice/" + cityCode + "/" + new SimpleDateFormat("yyyy-MM-dd").format(callStartTime) + "/";
- OssFileResponse ossResponse = ossFileService.uploadFile(localFile.toFile(), fileName, ossPath);
+ OssFileResponse ossResponse = uploadRecording(localFile.toFile(), fileName, ossPath);
String previewUrl = ossResponse.getFileUrl();
if (previewUrl == null || previewUrl.trim().isEmpty()) {
throw new IllegalStateException("OSS上传成功但未返回文件地址: " + fileName);
@@ -214,6 +216,41 @@ public class VaaSyncService {
}
}
+ /**
+ * 上传录音文件到 OSS;G.729 格式自动转码为 PCM 后上传
+ *
+ * 【降级】ffmpeg 不存在或转码失败时,回退为上传原文件,保证数据不丢失。
+ * 转码生成的临时文件在上传后清理。
+ *
+ * @param localFile 本地录音文件
+ * @param fileName OSS 上的文件名(保持原文件名,业务表无需更新)
+ * @param ossPath OSS 存储目录
+ * @return OSS 上传结果
+ */
+ private OssFileResponse uploadRecording(File localFile, String fileName, String ossPath) {
+ File uploadFile = localFile;
+ File transcodedFile = null;
+ try {
+ if (g729Converter.isG729(localFile)) {
+ try {
+ logger.info("【录音】检测到 G.729 编码,转码为 PCM: {}", fileName);
+ transcodedFile = g729Converter.convertToPcm(localFile);
+ uploadFile = transcodedFile;
+ logger.info("【录音】转码完成: {} ({} -> {} 字节)",
+ fileName, localFile.length(), transcodedFile.length());
+ } catch (Exception e) {
+ logger.warn("【录音】G.729 转码失败,回退上传原文件: {}, 原因={}", fileName, e.getMessage());
+ uploadFile = localFile;
+ }
+ }
+ return ossFileService.uploadFile(uploadFile, fileName, ossPath);
+ } finally {
+ if (transcodedFile != null && uploadFile == transcodedFile && !transcodedFile.delete()) {
+ transcodedFile.deleteOnExit();
+ }
+ }
+ }
+
/**
* 从 call_record_id(格式:设备编号_录音ID)中提取录音盒上的录音ID
*/
@@ -449,7 +486,7 @@ public class VaaSyncService {
// 上传到OSS(地市+日期目录);上传失败时整条处理失败,下轮重新拉取。
String ossPath = "voice/" + cityCode + "/" + new SimpleDateFormat("yyyy-MM-dd").format(callStartTime) + "/";
- OssFileResponse ossResponse = ossFileService.uploadFile(localFile.toFile(), fileName, ossPath);
+ OssFileResponse ossResponse = uploadRecording(localFile.toFile(), fileName, ossPath);
String previewUrl = ossResponse.getFileUrl();
if (previewUrl == null || previewUrl.trim().isEmpty()) {
throw new IllegalStateException("OSS上传成功但未返回文件地址: " + fileName);
diff --git a/src/main/java/com/threecloud/dataserviceyy/util/G729Converter.java b/src/main/java/com/threecloud/dataserviceyy/util/G729Converter.java
new file mode 100644
index 0000000..ddff2c3
--- /dev/null
+++ b/src/main/java/com/threecloud/dataserviceyy/util/G729Converter.java
@@ -0,0 +1,188 @@
+package com.threecloud.dataserviceyy.util;
+
+import org.bytedeco.javacpp.Loader;
+import org.slf4j.Logger;
+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.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * G.729 录音转码工具
+ *
+ * 【背景】录音盒导出的 WAV 部分为 G.729 压缩编码(wFormatTag=0x729a),
+ * 浏览器无法直接播放,需转码为标准 PCM WAV。
+ * 【方式】调用系统 ffmpeg 命令完成转码(ffmpeg 内置 G.729 解码器,无需额外库)。
+ * 【降级】ffmpeg 不存在或转码失败时抛出异常,由调用方决定是否回退原文件。
+ */
+@Component
+public class G729Converter {
+
+ private static final Logger logger = LoggerFactory.getLogger(G729Converter.class);
+
+ /** G.729 的 WAV 格式标签(FFmpeg TwoCC,0x729a) */
+ private static final int WAVE_FORMAT_G729 = 0x729a;
+
+ /** 转码超时时间(秒),防止异常文件卡死 */
+ private static final long TRANSCODE_TIMEOUT_SECONDS = 600;
+
+ /** 转码方式:system=系统ffmpeg(默认),javacv=bytedeco内置ffmpeg */
+ @Value("${ffmpeg.mode:javacv}")
+ private String ffmpegMode;
+
+ /** 系统 ffmpeg 可执行文件路径(mode=system 时生效),默认从 PATH 中查找 */
+ @Value("${ffmpeg.path:ffmpeg}")
+ private String ffmpegPath;
+
+ /**
+ * 判断文件是否为 G.729 编码的 WAV
+ *
+ * @param file 音频文件
+ * @return true 表示为 G.729(wFormatTag=0x729a),需要转码
+ */
+ public boolean isG729(File file) {
+ if (file == null || !file.isFile()) {
+ return false;
+ }
+ try {
+ Integer format = readWavAudioFormat(file);
+ return format != null && format == WAVE_FORMAT_G729;
+ } catch (Exception e) {
+ logger.warn("判断 WAV 编码失败: {}, 原因={}", file.getName(), e.getMessage());
+ return false;
+ }
+ }
+
+ /**
+ * 将 G.729 WAV 转码为 PCM WAV(16bit 单声道 8kHz)
+ *
+ * @param src 源 G.729 文件
+ * @return 转码后的 PCM 文件(生成在源文件同目录,文件名为源文件名 + ".pcm.wav")
+ * @throws IOException IO 异常或 ffmpeg 转码失败
+ * @throws InterruptedException 等待被中断
+ */
+ public File convertToPcm(File src) throws IOException, InterruptedException {
+ if (src == null || !src.isFile()) {
+ throw new IOException("待转码文件不存在: " + (src == null ? "null" : src.getPath()));
+ }
+ File dest = new File(src.getParentFile(), src.getName() + ".pcm.wav");
+
+ List command = new ArrayList<>();
+ command.add(resolveFfmpegPath());
+ command.add("-y");
+ command.add("-i");
+ command.add(src.getAbsolutePath());
+ command.add("-ar");
+ command.add("8000");
+ command.add("-ac");
+ command.add("1");
+ command.add("-c:a");
+ command.add("pcm_s16le");
+ command.add(dest.getAbsolutePath());
+
+ logger.info("执行 ffmpeg 转码: {}", String.join(" ", command));
+ ProcessBuilder pb = new ProcessBuilder(command);
+ pb.redirectErrorStream(true);
+ Process process = pb.start();
+
+ boolean finished = process.waitFor(TRANSCODE_TIMEOUT_SECONDS, TimeUnit.SECONDS);
+ if (!finished) {
+ process.destroyForcibly();
+ 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();
+ throw new IOException("ffmpeg 转码失败(exit=" + exitCode + "): " + src.getName() + " -> " + err);
+ }
+ if (!dest.isFile() || dest.length() == 0) {
+ throw new IOException("ffmpeg 转码未生成有效文件: " + dest.getAbsolutePath());
+ }
+ return dest;
+ }
+
+ /**
+ * 根据配置解析 ffmpeg 可执行文件路径
+ *
+ * mode=system:使用系统安装的 ffmpeg(ffmpeg.path 配置)
+ * mode=javacv:使用 bytedeco 打包在依赖中的 ffmpeg(无需服务器安装)
+ *
+ * @return ffmpeg 可执行文件路径
+ */
+ private String resolveFfmpegPath() {
+ if ("javacv".equalsIgnoreCase(ffmpegMode)) {
+ return Loader.load(org.bytedeco.ffmpeg.ffmpeg.class);
+ }
+ return ffmpegPath;
+ }
+
+ /**
+ * 读取 WAV 文件的音频格式标签(wFormatTag)
+ *
+ * 遍历 RIFF chunk,定位 fmt chunk 后读取前 2 字节(小端)。
+ *
+ * @param file WAV 文件
+ * @return wFormatTag;非 WAV 或解析失败返回 null
+ */
+ private Integer readWavAudioFormat(File file) throws IOException {
+ try (RandomAccessFile raf = new RandomAccessFile(file, "r")) {
+ if (raf.length() < 12) {
+ return null;
+ }
+ byte[] riff = new byte[12];
+ raf.readFully(riff);
+ // RIFF....WAVE
+ 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 null;
+ }
+
+ long offset = 12;
+ while (offset + 8 <= raf.length()) {
+ raf.seek(offset);
+ byte[] chunkHeader = new byte[8];
+ raf.readFully(chunkHeader);
+ String chunkId = new String(chunkHeader, 0, 4, StandardCharsets.US_ASCII);
+ long chunkSize = (chunkHeader[4] & 0xFFL)
+ | ((chunkHeader[5] & 0xFFL) << 8)
+ | ((chunkHeader[6] & 0xFFL) << 16)
+ | ((chunkHeader[7] & 0xFFL) << 24);
+ if ("fmt ".equals(chunkId)) {
+ if (chunkSize < 2) {
+ return null;
+ }
+ raf.seek(offset + 8);
+ int b0 = raf.readUnsignedByte();
+ int b1 = raf.readUnsignedByte();
+ return b0 | (b1 << 8);
+ }
+ offset += 8 + chunkSize;
+ // RIFF chunk 按 2 字节对齐
+ if (chunkSize % 2 == 1) {
+ offset += 1;
+ }
+ }
+ }
+ return null;
+ }
+}