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; + } +}