MP4 转 WAV 音频转码方案详解(互联网医院病历AI实战-JAVE2方案)

场景: 互联网医院 · 腾讯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?

  1. 兼容性最强:WAV 格式几乎被所有语音识别平台支持,未来更换 ASR 供应商成本为零
  2. 文件更小 :MP4 包含视频轨道,转为纯音频 WAV(单声道 16kHz)后文件通常缩小 80% 以上
  3. 无损音质:PCM 编码的 WAV 是无损格式,不会因多次转码导致音质下降,保证识别精度
  4. 腾讯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_PATHvolatile 修饰,保证启动线程写入后,转码线程立即可见
  • 启动失败只打 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

整体方案经过生产环境验证,稳定可靠。如有问题欢迎评论交流 🙏


参考资料:

相关推荐
凸头2 小时前
从聊天机器人到业务执行者:Agentic Orchestration 如何重构 Java 后端体系
java·开发语言·重构
希望永不加班2 小时前
SpringBoot 跨域问题(CORS)彻底解决方案
java·spring boot·后端·spring
爱丽_2 小时前
AQS 的 `state`:volatile + CAS 如何撑起原子性与可见性
java·前端·算法
zxfBdd2 小时前
idea + spark 报错:object hy is not a member of package com.cmcc
java·ide·intellij-idea
攒了一袋星辰2 小时前
10万级用户数据日更与定向推送系统的可靠性设计
java·数据库·算法
凸头2 小时前
从“搜了就答”到“智能决策”:拥抱 RAG 2.0 时代的架构演进 ——Java 后端工程师视角下的 AI 应用工程化落地
java·人工智能·架构·rag
DJ斯特拉2 小时前
JUC基础
java·jvm·juc
小江的记录本2 小时前
【端口号】计算机领域常见端口号汇总(完整版)
java·前端·windows·spring boot·后端·sql·spring
色空大师2 小时前
网站搭建实操(二)后台管理(1)登录
java·linux·数据库·搭建网站·论坛