场景: 互联网医院 · 腾讯IM音视频通话 · 病历AI语音识别
关键词: Java、FFmpeg、JAVE2、MP4转WAV、音频转码、语音识别、腾讯云VOD、moov atom
一、背景与需求
在我们的互联网医院项目中,医生与患者通过**腾讯IM(TRTC)**进行视频问诊,通话结束后腾讯云会自动将通话录制为 MP4 文件存储到 VOD(云点播)中。
现在有一个新需求:接入病历AI,通过语音识别技术,将医患对话自动转录为文字,辅助 AI 生成病历摘要。
整体链路如下:
腾讯云 TRTC 视频通话录制 (MP4)
↓
下载 MP4 到服务器本地
↓
FFmpeg 转码为 WAV(16kHz,单声道,PCM)
↓
上传至 MinIO 对象存储
↓
语音识别模型 (ASR) 转文字
↓
病历 AI 总结生成
二、为什么需要转码?MP4 不能直接识别吗?
2.1 主流语音识别(ASR)平台对音频格式的要求
这是很多人会疑惑的地方,先说结论:大多数语音识别服务的核心格式要求是 PCM/WAV,而不是 MP4。
| 平台 | 实时流式识别 | 离线文件识别 | 推荐格式 |
|---|---|---|---|
| 科大讯飞(语音听写) | 仅支持 pcm/wav | wav、flac、opus、m4a、mp3 | pcm/wav(16bit,16kHz,单声道) |
| 科大讯飞(实时转写) | WebSocket 输入 PCM 流 | wav | pcm |
| 阿里云(实时语音识别) | 仅支持 PCM/WAV | wav、mp3、flac 等 | pcm/wav(16bit,单声道) |
| 腾讯云(语音识别 ASR) | 仅支持 pcm/wav/silk | pcm、wav、mp3、silk | pcm/wav |
| 百度(短语音识别) | pcm、wav、amr | pcm、wav | pcm/wav |
| Whisper(OpenAI) | --- | wav、mp3、flac、mp4 等 | wav/flac |
关键结论:
- 对于实时流式语音识别,几乎所有平台只支持 PCM/WAV
- 对于离线文件识别,部分平台(如 Whisper)支持 MP4,但科大讯飞、阿里云、腾讯云等国内主流平台仍以 WAV/PCM 为主
- WAV(PCM 编码,16kHz,16bit,单声道)是兼容性最强、最稳定的格式,切换任何 ASR 供应商无需改动格式
2.2 为什么我们选择转为 WAV 而不是直接用 MP4?
- 兼容性最强:WAV 格式几乎被所有语音识别平台支持,未来更换 ASR 供应商成本为零
- 文件更小 :MP4 包含视频轨道,转为纯音频 WAV(单声道 16kHz)后文件通常缩小 80% 以上
- 无损音质:PCM 编码的 WAV 是无损格式,不会因多次转码导致音质下降,保证识别精度
- 腾讯IM 转码套餐限制 :腾讯IM 本身支持云端转码,但需要升级付费套餐,现有套餐不支持,自行转码更经济
三、转码方案选型
3.1 常见方案对比
方案一:ProcessBuilder + FFmpeg(推荐)
通过 Java 的 ProcessBuilder 直接调用 FFmpeg 可执行文件。
优点:
- 功能完整,支持所有 FFmpeg 参数
- 精确控制输出参数(采样率、声道数、编码格式)
- 无额外运行时依赖,部署灵活
- 适合 Docker/K8s 容器化部署
缺点:
- 需要服务器上有 FFmpeg 可执行文件
方案二:JAVE2(推荐,本文采用)
JAVE2 是对 FFmpeg 的 Java 封装库,内置了 FFmpeg 二进制文件,通过 Maven 依赖即可使用,无需服务器单独安装 FFmpeg。
XML
<dependency>
<groupId>ws.schild</groupId>
<artifactId>jave-core</artifactId>
<version>3.5.0</version>
</dependency>
<dependency>
<groupId>ws.schild</groupId>
<artifactId>jave-all-deps</artifactId>
<version>3.5.0</version>
</dependency>
优点:
- 零环境依赖:内置多平台 FFmpeg 二进制,启动时自动解压,不需要运维单独部署 FFmpeg
- 跨平台:Windows/Linux/Mac 自动选择对应平台的二进制文件
- 换环境无感知:部署到新服务器或新容器,不需要任何额外配置
缺点:
- JAR 包体积较大(内置多平台二进制,约 100MB+)
- FFmpeg 版本更新滞后于官方
适用场景: 希望减少运维成本、换环境不需要手动部署 FFmpeg 的 Spring Boot 服务 ✅
方案三:ffmpeg-cli-wrapper
XML
<dependency>
<groupId>net.bramp.ffmpeg</groupId>
<artifactId>ffmpeg</artifactId>
<version>0.8.0</version>
</dependency>
API 风格较现代,但需要系统预装 FFmpeg,社区活跃度一般,不推荐。
3.2 方案对比总结
| 方案 | 是否需要安装FFmpeg | 包体积 | 稳定性 | 推荐场景 |
|---|---|---|---|---|
| ProcessBuilder + FFmpeg | 需要(运维部署) | 轻量 | ⭐⭐⭐⭐⭐ | 已有 FFmpeg 环境的生产服务器 |
| JAVE2(本文方案) | 不需要,自动解压 | 重(~100MB) | ⭐⭐⭐⭐ | 换环境无感,推荐 ✅ |
| ffmpeg-cli-wrapper | 需要 | 轻量 | ⭐⭐⭐ | 一般,不推荐 |
本文选择 JAVE2 方案 ,核心理由:通过 DefaultFFMPEGLocator 在 Spring Boot 启动时自动解压并初始化 FFmpeg,换任何环境(开发/测试/生产)都不需要单独部署 FFmpeg,对运维友好。
四、核心难题:moov atom 导致流式转码失败
4.1 现象
直接让 FFmpeg 拉取腾讯云 VOD 的 MP4 URL 进行流式转码,报错如下:
bash
[mov,mp4,m4a,3gp,3g2,mj2] moov atom not found
http://1500002198.vod2.myqcloud.com/.../f0.mp4: Invalid data found when processing input
4.2 根本原因
MP4 文件结构中有一个关键的元数据块叫 moov atom,它包含了音视频的时长、采样率、编解码信息等索引数据。
情况A - moov 在文件头部(faststart 模式):
[moov][mdat...]
→ FFmpeg 读取开头少量数据即可开始解码,支持流式转码 ✅
情况B - moov 在文件末尾(腾讯云录制默认行为):
[mdat...][moov]
→ FFmpeg 必须读到文件末尾才能找到索引,流式读取时报 moov atom not found ❌
腾讯云 TRTC 录制生成的 MP4,moov atom 默认写在文件末尾,这是大多数录制平台的默认行为(先录制数据,最后写入索引)。
4.3 验证过程
通过本地测试代码验证了三种流式方案和一种下载方案:
| 方案 | 参数 | 结果 |
|---|---|---|
| 流式(基础) | -http_seekable 1 -i URL |
❌ 失败(FFmpeg 8.x 已移除该参数) |
| 流式(增强) | -reconnect 1 -http_seekable 1 |
❌ 失败 |
| 流式(禁seek) | -seekable 0 -i URL |
✅ 本地成功,但服务器 FFmpeg 4.4.1 行为不稳定 |
| 先下载再转码 | 下载到本地 → 本地文件转码 | ✅ 稳定成功,推荐 |
同时通过 HTTP HEAD 请求确认:
Accept-Ranges: bytes ← 腾讯云 VOD 服务器支持 Range 请求
服务器本身支持 Range,但 JAVE2 内置的 FFmpeg 4.4.1 静态版本对 -seekable 参数支持不够稳定,最终选择先下载再转码的方案,稳定可靠。
4.4 最终解决方案
❌ 流式方案(不稳定):FFmpeg 直接拉 URL → moov atom not found
✅ 正确方案:先下载完整 MP4 到本地 → 本地 FFmpeg 转码 → 清理临时文件
关于性能: 先下载再转码看似多了一步,但实际上流式读取 moov 在末尾的文件,FFmpeg 内部也需要下载完整文件再处理,两者的实际耗时差别不大。真正提速的方案是让腾讯云录制开启 faststart 模式(把 moov 写到文件头部),届时可切换为真正的流式转码。
五、完整实现
5.1 启动类:FFmpeg 自动初始化
利用 JAVE2 的 DefaultFFMPEGLocator 在应用启动时自动解压 FFmpeg 并完成路径初始化,后续转码业务直接使用,无需关心 FFmpeg 的部署问题。
java
package com.titan;
import lombok.extern.slf4j.Slf4j;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
import ws.schild.jave.process.ffmpeg.DefaultFFMPEGLocator;
import java.io.BufferedReader;
import java.io.File;
import java.io.InputStreamReader;
import java.util.concurrent.TimeUnit;
@SpringBootApplication
@EnableScheduling
@Slf4j
public class BootstrapImApplication {
/**
* FFmpeg 可执行文件的绝对路径
* volatile 保证多线程可见性:main 线程写入后,wav-convert 线程立即可见
*/
public static volatile String FFMPEG_PATH;
public static void main(String[] args) {
String workDir = System.getProperty("user.dir");
log.info("[启动] 运行目录: {}", workDir);
log.info("[启动] wav临时目录: {}", workDir + "/im/wav/");
// JAVE2 在启动时自动解压 FFmpeg 到 /tmp/jave/ 目录
// 好处:换环境不需要单独安装 FFmpeg,jar 包内置了 FFmpeg 二进制
if (!initFfmpeg()) {
log.warn("------------FFmpeg 初始化失败,音视频转码功能不可用------------------------");
}
SpringApplication.run(BootstrapImApplication.class, args);
}
private static boolean initFfmpeg() {
try {
// DefaultFFMPEGLocator 构造时自动触发 JAVE2 解压 FFmpeg
// JAVE2 3.5.0 内置 FFmpeg 4.4.1-static,解压到 /tmp/jave/ 目录
DefaultFFMPEGLocator locator = new DefaultFFMPEGLocator();
String ffmpegPath = locator.getExecutablePath();
log.info("[启动] FFmpeg实际路径: {}", ffmpegPath);
File ffmpegFile = new File(ffmpegPath);
if (!ffmpegFile.exists()) {
log.error("[启动] FFmpeg文件不存在: {}", ffmpegPath);
return false;
}
// 设置执行权限(Java 方式 + chmod 双保险,防止 Linux 权限问题)
ffmpegFile.setExecutable(true, false);
ffmpegFile.setReadable(true, false);
Process chmod = Runtime.getRuntime().exec(new String[]{"chmod", "755", ffmpegPath});
boolean chmodOk = chmod.waitFor(5, TimeUnit.SECONDS);
if (!chmodOk) {
chmod.destroyForcibly();
log.error("[启动] chmod超时");
} else {
log.info("[启动] chmod结果: exitCode={}", chmod.exitValue());
}
// 验证 FFmpeg 可用性:执行 ffmpeg -version
Process testProcess = new ProcessBuilder(ffmpegPath, "-version")
.redirectErrorStream(true)
.start();
// 消费输出,防止缓冲区满导致进程阻塞
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(testProcess.getInputStream()))) {
while (reader.readLine() != null) { }
}
boolean finished = testProcess.waitFor(10, TimeUnit.SECONDS);
if (!finished) {
testProcess.destroyForcibly();
log.error("[启动] FFmpeg验证超时");
return false;
}
FFMPEG_PATH = ffmpegPath;
log.info("[启动] FFmpeg验证成功: {}", ffmpegPath);
return true;
} catch (Exception e) {
log.error("[启动] FFmpeg初始化异常", e);
return false;
}
}
}
设计亮点:
FFMPEG_PATH用volatile修饰,保证启动线程写入后,转码线程立即可见- 启动失败只打 warn 日志,不阻断 Spring 启动,降低影响范围
- 用数组形式调用
exec,避免路径含空格时解析出错
5.2 自定义 MultipartFile 实现
生产代码中不应依赖 spring-test 包中的 MockMultipartFile(该包在某些 CI/CD 环境中会被排除导致运行时报错)。自定义实现基于本地文件流,不会把整个文件加载进内存:
java
package com.titan.im.service.util;
import org.springframework.web.multipart.MultipartFile;
import java.io.*;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
/**
* 生产环境用的 MultipartFile 实现
* 基于本地文件流,不依赖 spring-test 包,不会全量加载进内存
*/
public class LocalFileMultipartFile implements MultipartFile {
private final File file;
private final String originalFilename;
private final String contentType;
public LocalFileMultipartFile(File file, String originalFilename) {
this.file = file;
this.originalFilename = originalFilename;
this.contentType = "audio/wav";
}
@Override public String getName() { return "file"; }
@Override public String getOriginalFilename() { return originalFilename; }
@Override public String getContentType() { return contentType; }
@Override public boolean isEmpty() { return !file.exists() || file.length() == 0; }
@Override public long getSize() { return file.length(); }
@Override
public byte[] getBytes() throws IOException {
return Files.readAllBytes(file.toPath());
}
@Override
public InputStream getInputStream() throws IOException {
return new FileInputStream(file); // 流式读取,不占内存
}
@Override
public void transferTo(File dest) throws IOException {
Files.copy(file.toPath(), dest.toPath(), StandardCopyOption.REPLACE_EXISTING);
}
}
5.3 核心转码服务
java
package com.titan.im.service;
import cn.hutool.core.date.DateUtil;
import com.alibaba.fastjson.JSON;
import com.baomidou.mybatisplus.core.toolkit.ObjectUtils;
import com.titan.BootstrapImApplication;
import com.titan.common.result.ResultVO;
import com.titan.im.feign.CMSFeign;
import com.titan.im.service.dto.UploadFileDTO;
import com.titan.im.service.util.LocalFileMultipartFile;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.PreDestroy;
import javax.annotation.Resource;
import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.*;
/**
* IM 音视频转码服务:MP4 → WAV
* 方案:先下载 MP4 到本地,再用 FFmpeg 转码(解决 moov atom 在末尾导致流式转码失败的问题)
*/
@Service
@Slf4j
public class AudioConvertService {
@Resource
private CMSFeign cmsFeign;
/** 转码超时(分钟) */
private static final int CONVERT_TIMEOUT_MINUTES = 10;
/** 下载连接超时 30s */
private static final int DOWNLOAD_CONNECT_TIMEOUT_MS = 30_000;
/** 下载读取超时 5min */
private static final int DOWNLOAD_READ_TIMEOUT_MS = 300_000;
/** 下载缓冲区 64KB */
private static final int DOWNLOAD_BUFFER_SIZE = 65536;
/** 临时文件目录(放到运行目录下,避免 /tmp noexec 权限问题) */
private static String getTmpDir() {
return System.getProperty("user.dir") + "/im/wav/";
}
/**
* 转码专用线程池
* 不使用 ForkJoinPool.commonPool(),避免 FFmpeg 重操作占满公共线程池导致其他异步任务阻塞
*/
private static final org.slf4j.Logger STATIC_LOG =
org.slf4j.LoggerFactory.getLogger(AudioConvertService.class);
private final ExecutorService CONVERT_EXECUTOR = new ThreadPoolExecutor(
2,
Math.max(2, Math.min(4, Runtime.getRuntime().availableProcessors())),
60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(10),
r -> {
Thread t = new Thread(r, "wav-convert");
t.setDaemon(true);
return t;
},
new ThreadPoolExecutor.DiscardPolicy() {
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
if (e.isShutdown()) {
throw new RejectedExecutionException("转码线程池已关闭");
}
STATIC_LOG.error("[WAV] 转码队列已满,拒绝任务, activeCount={}, queueSize={}",
e.getActiveCount(), e.getQueue().size());
throw new RejectedExecutionException("转码队列已满");
}
}
);
/**
* 异步将 MP4 URL 转为 WAV 并上传 MinIO
* 不阻塞主流程,主流程正常返回 MP4 URL
*
* @param mp4Url 腾讯云 VOD 的 MP4 地址
* @param streamId 流 ID(用作临时文件名)
* @param groupId 群组 ID
* @param roomId 房间 ID
* @param proxyHost 代理主机(可为 null)
* @param proxyPort 代理端口(可为 null)
*/
public CompletableFuture<String> asyncConvertAndUpload(
String mp4Url, String streamId, String groupId, String roomId,
String proxyHost, Integer proxyPort) {
log.info("[WAV] 异步转码开始, streamId={}, mp4Url={}", streamId, mp4Url);
try {
return CompletableFuture.supplyAsync(() -> {
String tempMp4Path = getTmpDir() + streamId + ".mp4";
String wavPath = getTmpDir() + streamId + ".wav";
try {
// Step1:下载 MP4 到本地(解决 moov atom 在末尾的问题)
if (!downloadMp4(mp4Url, tempMp4Path, proxyHost, proxyPort)) {
log.error("[WAV] MP4下载失败, streamId={}", streamId);
return null;
}
// Step2:本地文件转码(不再受 moov atom 位置影响)
if (!convertLocalMp4ToWav(tempMp4Path, wavPath)) {
log.error("[WAV] 转码失败, streamId={}", streamId);
return null;
}
// Step3:上传 WAV 到 MinIO
String wavUrl = uploadWavFile(wavPath, mp4Url, streamId, groupId, roomId);
log.info("[WAV] 全流程完成, streamId={}, wavUrl={}", streamId, wavUrl);
return wavUrl;
} catch (Exception e) {
log.error("[WAV] 处理异常, streamId={}", streamId, e);
return null;
} finally {
// Step4:无论成功失败,清理临时文件
deleteLocalFile(tempMp4Path);
deleteLocalFile(wavPath);
}
}, CONVERT_EXECUTOR);
} catch (RejectedExecutionException e) {
log.error("[WAV] 转码队列已满,任务被拒绝, streamId={}", streamId);
CompletableFuture<String> failed = new CompletableFuture<>();
failed.completeExceptionally(e);
return failed;
}
}
/**
* 下载 MP4 到本地(支持代理)
*/
private boolean downloadMp4(String mp4Url, String savePath,
String proxyHost, Integer proxyPort) {
HttpURLConnection conn = null;
try {
ensureParentDir(savePath);
URL url = new URL(mp4Url);
Proxy proxy = buildProxy(proxyHost, proxyPort);
conn = (HttpURLConnection) url.openConnection(proxy);
conn.setConnectTimeout(DOWNLOAD_CONNECT_TIMEOUT_MS);
conn.setReadTimeout(DOWNLOAD_READ_TIMEOUT_MS);
conn.setRequestProperty("User-Agent", "Mozilla/5.0");
int code = conn.getResponseCode();
if (code != HttpURLConnection.HTTP_OK) {
log.error("[WAV] 下载HTTP状态码异常, code={}, url={}", code, mp4Url);
return false;
}
long contentLength = conn.getContentLengthLong();
String sizeStr = contentLength > 0
? String.format("%.2f MB", contentLength / 1024.0 / 1024.0)
: "unknown";
log.info("[WAV] 开始下载MP4, size={}, url={}", sizeStr, mp4Url);
long startTime = System.currentTimeMillis();
try (InputStream in = new BufferedInputStream(conn.getInputStream());
FileOutputStream out = new FileOutputStream(savePath)) {
byte[] buffer = new byte[DOWNLOAD_BUFFER_SIZE];
long totalRead = 0;
int bytesRead;
while ((bytesRead = in.read(buffer)) != -1) {
out.write(buffer, 0, bytesRead);
totalRead += bytesRead;
}
log.info("[WAV] MP4下载完成, size={}, elapsed={}ms",
String.format("%.2f MB", totalRead / 1024.0 / 1024.0),
System.currentTimeMillis() - startTime);
}
File file = new File(savePath);
if (!file.exists() || file.length() == 0) {
log.error("[WAV] 下载文件异常(空文件), path={}", savePath);
return false;
}
return true;
} catch (SocketTimeoutException e) {
log.error("[WAV] 下载超时, url={}", mp4Url, e);
return false;
} catch (Exception e) {
log.error("[WAV] 下载异常, url={}", mp4Url, e);
return false;
} finally {
if (conn != null) conn.disconnect();
}
}
/**
* 将本地 MP4 文件转为 WAV
* FFmpeg 命令:ffmpeg -y -i input.mp4 -vn -acodec pcm_s16le -ar 16000 -ac 1 output.wav
*/
private boolean convertLocalMp4ToWav(String mp4Path, String wavPath) {
try {
String ffmpegPath = BootstrapImApplication.FFMPEG_PATH;
if (ffmpegPath == null) {
log.error("[WAV] FFmpeg未就绪,转码不可用");
return false;
}
File mp4File = new File(mp4Path);
if (!mp4File.exists() || mp4File.length() == 0) {
log.error("[WAV] MP4源文件不存在或为空, path={}", mp4Path);
return false;
}
List<String> command = Arrays.asList(
ffmpegPath,
"-y", // 覆盖已有输出文件
"-i", mp4Path, // 输入:本地 MP4(不再受 moov 位置影响)
"-vn", // 丢弃视频流
"-acodec", "pcm_s16le", // WAV 标准编码(PCM 16bit 小端)
"-ar", "16000", // 16kHz 采样率(语音识别标准)
"-ac", "1", // 单声道
wavPath
);
log.info("[WAV] 开始转码, mp4Size={}, command={}",
String.format("%.2f MB", mp4File.length() / 1024.0 / 1024.0), command);
long startTime = System.currentTimeMillis();
ProcessBuilder pb = new ProcessBuilder(command);
pb.redirectErrorStream(true);
Process process = pb.start();
// 独立线程消费 FFmpeg 输出,防止管道缓冲区满导致进程阻塞
List<String> logLines = Collections.synchronizedList(new ArrayList<>());
Thread logThread = new Thread(() -> {
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
logLines.add(line);
if (log.isDebugEnabled()) {
log.debug("[FFMPEG] {}", line);
}
}
} catch (Exception ignored) {
// destroyForcibly 后管道关闭,属正常现象
}
}, "ffmpeg-log-reader");
logThread.setDaemon(true);
logThread.start();
boolean finished = process.waitFor(CONVERT_TIMEOUT_MINUTES, TimeUnit.MINUTES);
if (!finished) {
process.destroyForcibly();
logThread.join(3000);
log.error("[WAV] 转码超时({}min), mp4={}", CONVERT_TIMEOUT_MINUTES, mp4Path);
return false;
}
logThread.join(5000);
int exitCode = process.exitValue();
long elapsed = System.currentTimeMillis() - startTime;
if (exitCode != 0) {
log.error("[WAV] FFmpeg失败, exitCode={}, elapsed={}ms, log=\n{}",
exitCode, elapsed, String.join("\n", logLines));
return false;
}
File wavFile = new File(wavPath);
if (!wavFile.exists() || wavFile.length() == 0) {
log.error("[WAV] 输出文件异常, path={}", wavPath);
return false;
}
log.info("[WAV] 转码完成, wavSize={}, elapsed={}ms",
String.format("%.2f MB", wavFile.length() / 1024.0 / 1024.0), elapsed);
return true;
} catch (Exception e) {
log.error("[WAV] 转码异常, mp4={}", mp4Path, e);
return false;
}
}
/**
* 上传 WAV 文件到 MinIO
*/
private String uploadWavFile(String localWavPath, String mp4Url,
String streamId, String groupId, String roomId) {
try {
File wavFile = new File(localWavPath);
MultipartFile multipartFile = new LocalFileMultipartFile(wavFile, streamId + ".wav");
UploadFileDTO uploadFileDTO = new UploadFileDTO();
uploadFileDTO.setSourceUrl(mp4Url);
uploadFileDTO.setModule("imConRoomVideo");
uploadFileDTO.setAuthValue(groupId);
uploadFileDTO.setUserId(Long.valueOf(roomId));
uploadFileDTO.setPath(DateUtil.today());
ResultVO<String> result = cmsFeign.uploadFile(multipartFile, JSON.toJSONString(uploadFileDTO));
log.info("[WAV] 上传结果: {}", result);
if (ObjectUtils.isNotEmpty(result)) {
return result.getData();
}
return null;
} catch (Exception e) {
log.error("[WAV] 上传异常, roomId={}", roomId, e);
return null;
}
}
/**
* Spring 容器关闭时优雅关闭线程池
* 使用 @PreDestroy 比 ShutdownHook 更早执行,此时 CMSFeign 等 Bean 还存活
*/
@PreDestroy
public void destroy() {
log.info("[WAV] 转码线程池开始优雅关闭...");
CONVERT_EXECUTOR.shutdown();
try {
if (!CONVERT_EXECUTOR.awaitTermination(CONVERT_TIMEOUT_MINUTES, TimeUnit.MINUTES)) {
log.warn("[WAV] 等待超时,强制终止剩余任务");
CONVERT_EXECUTOR.shutdownNow();
} else {
log.info("[WAV] 转码线程池已正常关闭");
}
} catch (InterruptedException e) {
CONVERT_EXECUTOR.shutdownNow();
Thread.currentThread().interrupt();
}
}
private Proxy buildProxy(String proxyHost, Integer proxyPort) {
if (proxyHost != null && !proxyHost.isEmpty() && proxyPort != null) {
return new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyHost, proxyPort));
}
return Proxy.NO_PROXY;
}
private void ensureParentDir(String filePath) {
File parentDir = new File(filePath).getParentFile();
if (parentDir != null && !parentDir.exists()) {
parentDir.mkdirs();
}
}
private void deleteLocalFile(String path) {
if (path == null) return;
try {
File file = new File(path);
if (file.exists()) {
if (file.delete()) {
log.info("[WAV] 临时文件已删除: {}", path);
} else {
log.warn("[WAV] 临时文件删除失败: {}", path);
}
}
} catch (Exception e) {
log.warn("[WAV] 临时文件删除异常: {}", path, e);
}
}
}
5.4 调用方(异步触发,不阻塞主流程)
java
// 判断是否开启病历 AI(通过缓存标识)
String cacheValue = getRoomCache(groupId).getData();
if (cacheValue != null) {
try {
// 异步转码 + 上传 MinIO,不阻塞当前返回,主流程继续返回 MP4 URL
audioConvertService.asyncConvertAndUpload(
templateUrl, streamId, groupId, roomId, proxyHost, proxyPort)
.thenAccept(wavUrl -> {
if (wavUrl != null) {
log.info("[WAV] 上传完成, groupId={}, roomId={}, wavUrl={}",
groupId, roomId, wavUrl);
}
deleteRoomCacheWithTenant(roomId, tenantId); // 清除标识
})
.exceptionally(e -> {
Throwable cause = e.getCause();
if (cause instanceof RejectedExecutionException) {
// 队列满,任务未执行,不需要删缓存
log.error("[WAV] 转码队列已满,跳过, groupId={}, roomId={}",
groupId, roomId);
} else {
log.error("[WAV] 异步处理失败, groupId={}, roomId={}",
groupId, roomId, e);
deleteRoomCacheWithTenant(roomId, tenantId);
}
return null;
});
} catch (Exception e) {
// 启动异步任务本身失败,只打日志,不影响主流程
log.error("[WAV] 异步任务启动失败, groupId={}, roomId={}", groupId, roomId, e);
}
} else {
log.info("[病历AI] 未开启, roomId={}", roomId);
}
六、关键设计决策详解
6.1 为什么选先下载再转码,而不是流式转码?
| 流式转码(URL直接传给FFmpeg) | 先下载再转码(本文方案) | |
|---|---|---|
| 条件 | moov 必须在文件头部 | 无要求 |
| 腾讯云录制默认 | moov 在末尾,流式失败 | ✅ 正常 |
| 实际耗时 | moov 在末尾时需全量下载,与先下载相当 | 下载 + 转码,稳定可预期 |
| 稳定性 | 受 FFmpeg 版本和服务器配置影响 | ✅ 稳定 |
如果腾讯云后续支持配置 faststart 模式(moov 写到头部),可直接切换回流式转码,速度更快。
6.2 为什么用独立线程池而不是 ForkJoinPool.commonPool()?
CompletableFuture.supplyAsync() 默认使用公共线程池(CPU 核数 - 1 个线程),该线程池设计目标是计算密集型小任务。
FFmpeg 转码是 CPU + IO 双密集型重操作,单次可能耗时数分钟。并发时占满公共线程池,会导致整个应用所有异步任务全部阻塞。
独立线程池(核心 2 线程,最多 4 线程,队列 10)隔离了转码资源,不影响业务主流程。
6.3 为什么要异步消费 FFmpeg 输出?
FFmpeg 的标准输出写入操作系统管道缓冲区,缓冲区默认大小约 64KB。转码时日志输出较多,若不及时读取:
管道缓冲区满 → FFmpeg 进程阻塞等待读取 → waitFor() 永久等待 → 超时后 destroyForcibly()
独立线程持续消费输出,彻底规避这个问题。
6.4 为什么用 @PreDestroy 而不是 ShutdownHook?
| @PreDestroy | JVM ShutdownHook | |
|---|---|---|
| 触发时机 | Spring 容器关闭时(更早) | JVM 退出时(最晚) |
| 此时其他 Bean 状态 | 还存活 | 可能已销毁 |
使用 @PreDestroy 保证线程池在等待当前转码任务完成期间,CMSFeign 等上传 Bean 还未销毁,避免空指针。
6.5 为什么不用 MockMultipartFile?
MockMultipartFile 来自 spring-test 包,设计用途是单元测试。
XML
<!-- spring-test 通常是 test scope,打包后生产环境没有这个类 -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<scope>test</scope>
</dependency>
在某些 CI/CD 环境中 test scope 会被排除,运行时报 ClassNotFoundException。自定义 LocalFileMultipartFile 基于文件流,无此风险。
七、踩坑记录
坑1:JAVE2 2.x 升级到 3.x 包路径变更
java
// 2.x 旧包路径(编译报错)
import ws.schild.jave.AudioAttributes;
import ws.schild.jave.EncodingAttributes;
// 3.x 新包路径(需修改)
import ws.schild.jave.encode.AudioAttributes;
import ws.schild.jave.encode.EncodingAttributes;
升级 JAVE2 版本后,需要全局搜索相关 import 并更新。
坑2:ExceptionInInitializerError 导致 Bean 创建失败
java
// ❌ 错误:static 字段初始化时,匿名类里引用了实例变量 log(@Slf4j 生成的)
private static final ExecutorService EXECUTOR = new ThreadPoolExecutor(
...,
new DiscardPolicy() {
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
log.error("队列满"); // static 上下文引用实例变量 → 编译/运行时报错
}
}
);
// ✅ 正确:使用静态 Logger
private static final org.slf4j.Logger STATIC_LOG =
org.slf4j.LoggerFactory.getLogger(AudioConvertService.class);
// 匿名类里使用 STATIC_LOG
这个问题会导致 Spring 启动时 Bean 创建失败,报 ExceptionInInitializerError。
坑3:jave.nativeDir 属性在 JAVE2 2.6.0 中不生效
java
// ❌ 无效:JAVE2 2.6.0 源码中读取的属性名不是这个
System.setProperty("jave.nativeDir", ffmpegDir);
JAVE2 2.6.0 会忽略该属性,始终解压到 /tmp/jave/。升级到 3.x 后使用 DefaultFFMPEGLocator.getExecutablePath() 直接获取路径,不需要也不应该手动设置。
坑4:腾讯云 API 代理不能用于下载 VOD 文件
现有腾讯云 API 代理(如 132.xx.xx.xx:19007)是腾讯云 API 网关,不是 HTTP 正向代理。
用它访问 MP4 URL 会得到:
{"Response":{"Error":{"Code":"MissingParameter","Message":"...missing required parameter Timestamp"}}}
下载 VOD 文件需要真正的 HTTP 正向代理(如 Squid),与 API 代理分开配置。
坑5:磁盘空间检查必须在目录创建之后
// ❌ 错误:目录不存在时 getFreeSpace() 返回 0,误报磁盘不足
long freeSpace = new File(getTmpDir()).getFreeSpace();
ensureParentDir(savePath);
// ✅ 正确:先建目录,再检查
ensureParentDir(savePath);
long freeSpace = new File(getTmpDir()).getFreeSpace();
八、JAVE2 版本与 FFmpeg 版本对应关系
| JAVE2 版本 | 内置 FFmpeg 版本 | 备注 |
|---|---|---|
| 2.6.0 | 4.1.3-static | -http_seekable 不支持 |
| 3.3.1 | 4.4.x | 支持 -http_seekable |
| 3.5.0 | 4.4.1-static | 本文使用版本 ✅ |
九、总结
| 问题 | 解决方案 |
|---|---|
| 语音识别需要 WAV 格式 | FFmpeg 转码:pcm_s16le,16kHz,单声道 |
| 不想手动部署 FFmpeg | JAVE2 内置二进制,启动时自动解压 |
| moov atom 在文件末尾导致流式失败 | 先完整下载 MP4,再本地转码 |
| 转码阻塞主线程 | 独立线程池 + CompletableFuture 异步处理 |
| 临时文件残留 | finally 块统一清理(MP4 + WAV) |
| 应用关闭时转码任务中断 | @PreDestroy 优雅关闭线程池 |
| MockMultipartFile 生产风险 | 自定义 LocalFileMultipartFile |
| Bean 创建失败(ExceptionInInitializerError) | static 字段用 static Logger,不用 @Slf4j |
整体方案经过生产环境验证,稳定可靠。如有问题欢迎评论交流 🙏
参考资料: