Compare commits

...

2 Commits

  1. 18
      pom.xml
  2. 68
      src/main/java/com/threecloud/dataserviceyy/service/VaaSyncService.java
  3. 188
      src/main/java/com/threecloud/dataserviceyy/util/G729Converter.java
  4. 2
      src/main/java/com/threecloud/dataserviceyy/util/OssFileService.java

18
pom.xml

@ -135,6 +135,24 @@
<artifactId>commons-net</artifactId>
<version>${commons-net.version}</version>
</dependency>
<!-- JavaCV/FFmpeg(可选转码方式:内置FFmpeg,无需服务器安装) -->
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>ffmpeg</artifactId>
<version>5.1.2-1.5.8</version>
</dependency>
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>ffmpeg</artifactId>
<version>5.1.2-1.5.8</version>
<classifier>linux-x86_64</classifier>
</dependency>
<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>javacpp</artifactId>
<version>1.5.8</version>
<classifier>linux-x86_64</classifier>
</dependency>
<!-- 测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>

68
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;
@ -191,12 +193,13 @@ public class VaaSyncService {
vaaHttpUtil.httpDown(fileUrl, localPath, authToken);
logger.info("【重新同步】录音下载完成: {}", fileName);
// 8. 上传OSS(路径与原记录一致,覆盖同名文件,业务表无需更新)
// 8. 上传OSS(复用原OSS文件名覆盖,业务表无需更新)
String ossFileName = extractOssFileName(record.getRecordingFilePath(), fileName);
String ossPath = "voice/" + cityCode + "/" + new SimpleDateFormat("yyyy-MM-dd").format(callStartTime) + "/";
OssFileResponse ossResponse = ossFileService.uploadFile(localFile.toFile(), fileName, ossPath);
OssFileResponse ossResponse = uploadRecording(localFile.toFile(), ossFileName, ossPath);
String previewUrl = ossResponse.getFileUrl();
if (previewUrl == null || previewUrl.trim().isEmpty()) {
throw new IllegalStateException("OSS上传成功但未返回文件地址: " + fileName);
throw new IllegalStateException("OSS上传成功但未返回文件地址: " + ossFileName);
}
ossFilePath = previewUrl;
@ -214,6 +217,41 @@ public class VaaSyncService {
}
}
/**
* 上传录音文件到 OSSG.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
*/
@ -224,6 +262,28 @@ public class VaaSyncService {
return callRecordId;
}
/**
* 从原录音的 OSS 地址中提取 OSS 文件名URL 最后一段用于重新同步时覆盖原文件
* 若地址为空或无法提取则回退为录音盒原始文件名
*/
private String extractOssFileName(String recordingFilePath, String fallback) {
if (StringUtils.hasText(recordingFilePath)) {
String path = recordingFilePath;
int queryIdx = path.indexOf('?');
if (queryIdx >= 0) {
path = path.substring(0, queryIdx);
}
int slashIdx = path.lastIndexOf('/');
if (slashIdx >= 0 && slashIdx < path.length() - 1) {
String name = path.substring(slashIdx + 1);
if (StringUtils.hasText(name)) {
return name;
}
}
}
return fallback;
}
/**
* 保存重新同步操作记录到 mid_voice_resync_log
*/
@ -449,7 +509,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);

188
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.729wFormatTag=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 WAV16bit 单声道 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<String> 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使用系统安装的 ffmpegffmpeg.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;
}
}

2
src/main/java/com/threecloud/dataserviceyy/util/OssFileService.java

@ -89,6 +89,8 @@ public class OssFileService {
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("file", resource);
body.add("path", path);
// 指定最终文件名,OSS 服务端按此名字落盘;同名文件覆盖
body.add("fileName", fileName);
HttpEntity<MultiValueMap<String, Object>> requestEntity = new HttpEntity<>(body, headers);

Loading…
Cancel
Save