用 Whisper 转录前,你不用再离开 Rust

Whisper 把语音转文字的门槛降得很低,Rust 生态也有仍在维护的 whisper-rs 绑定库,candle、ort 这些推理框架也在快速发展。但只要你的输入不是恰好 16 kHz 单声道 PCM,而是一个普通的 MP4,今天 ML 音频生态给出的第一步几乎都是同一行 shell 命令:

bash 复制代码
ffmpeg -i in.mp4 -ar 16000 -ac 1 -c:a pcm_s16le out.wav

这个方向本身热得很------"FFmpeg 8.0 加入 Whisper 支持"能冲上 HN 首页------但那条新闻解决的是 CLI 用户的问题;在 Rust 程序里,你要的是把解码数据留在自己进程的内存里,而不是再 shell 出去执行一条命令。所谓"纯 Rust 转录流水线"(pipeline),第一步却要先离开 Rust,别扭就在这里。这篇要做的是:用 Rust crate ez-ffmpeg(别和 JS 那个 "Ez FFmpeg" 混淆,两者无关)把这一步收回到进程内。读完你能拿到一个完整可编译的两阶段程序------一行代码把 MP4 变成 whisper-rs 能吃的 Vec<f32>,直接得到转录文本,全程不起子进程、不落临时文件;顺带避开旧教程里普遍存在的编译坑。文末附上真实运行的计时和逐字转录输出。

老办法:先 shell 出去,再把 wav 读回来

先看进程内方案出现前,MP4 → Whisper 的常规路线。whisper 系模型的输入契约是死的:16 kHz、单声道、f32 归一化样本,whisper-rs 的 full() 签名要求传入的 &[f32] 就是这种输入。而你手上的 MP4 里装着的多半是 44.1 或 48 kHz 双声道的 AAC。于是所有教程都让你先跑上面那行命令,再在 Rust 里收拾残局:

rust 复制代码
// The "leave Rust first" pipeline: subprocess + temp file + wav parsing.
let status = std::process::Command::new("ffmpeg")
    .args(["-i", "in.mp4", "-ar", "16000", "-ac", "1",
           "-c:a", "pcm_s16le", "-y", "out.wav"])
    .status()?; // hope ffmpeg is on PATH, hope the flags are right
// ...then open out.wav, skip the header, convert i16 -> f32 by hand

能跑,但每一环都有代价。部署上,目标机器和每个 Docker 镜像都得带一个 ffmpeg 可执行文件,版本差异也得自己处理。错误处理上,子进程挂了,你拿到的是退出码加一坨 stderr 文本,想区分"文件不存在"和"没有音轨"只能靠正则,拿不到类型明确的错误。文件管理上,中间多一个临时 wav 要落盘、要起名、要清理,并发一多还得防撞名。格式上,pcm_s16le 读回来是 i16,还得自己除以 32768 转成 f32。有 crate 把这套子进程封装得很顺手(比如 ffmpeg-sidecar),但前三项代价依然存在------"运行时依赖一个外部二进制"这件事没有变。

一行收回来

text 复制代码
in.mp4 ─▶ 解封装 ─▶ 解码(AAC) ─▶ 重采样(16 kHz 单声道 f32) ─▶ Vec<f32>
          └────── SampleExtractor::for_whisper() 一次调用 ──────┘
Vec<f32> ─▶ whisper-rs full() ─▶ 转录文本      (无子进程,无临时文件)

ez-ffmpeg 0.14 的 SampleExtractor 把解封装、解码、重采样、声道下混合整合成一次进程内调用------底层仍是一条正常的 FFmpeg 流水线,只是末端接了一个 sink,把样本返回给调用方。for_whisper 预设正好符合上面的输入约定:

rust 复制代码
let pcm: Vec<f32> = SampleExtractor::for_whisper("in.mp4").collect_samples()?;

先交代两句实话。第一,安装 FFmpeg、链接 FFmpeg 的麻烦,它一点没帮你省------底层就是 libav,该装还得装(FFmpeg 7.1--8.x);它省掉的是子进程、临时 wav 和 stderr 文本解析,不是 FFmpeg 依赖本身。第二,这组导出 API 在 README 里标着 experimental,后续小版本可能还会调整。

下面是完整可运行的两阶段程序------先抽取 PCM,再喂给 whisper-rs(0.16):

toml 复制代码
[package]
name = "mp4-to-text"
version = "0.1.0"
edition = "2021"

[dependencies]
ez-ffmpeg = "0.14"    # needs FFmpeg 7.1-8.x installed (links libav)
whisper-rs = "0.16"   # needs cmake + C/C++ toolchain (builds whisper.cpp)
rust 复制代码
use std::time::Instant;

use ez_ffmpeg::frame_export::SampleExtractor;
use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Phase 1: decode the video's audio track into 16 kHz mono f32 PCM.
    let t_extract = Instant::now();
    let pcm: Vec<f32> = SampleExtractor::for_whisper("jfk.mp4").collect_samples()?;
    let extract_ms = t_extract.elapsed().as_secs_f64() * 1000.0;

    println!("pcm samples : {}", pcm.len());
    println!(
        "pcm duration: {:.3} s (len / 16000.0, mono)",
        pcm.len() as f64 / 16_000.0
    );
    println!("extract time: {extract_ms:.1} ms");

    // Phase 2: transcribe the PCM with whisper.cpp via whisper-rs.
    let t_whisper = Instant::now();
    let ctx = WhisperContext::new_with_params(
        "ggml-tiny.en.bin",
        WhisperContextParameters::default(),
    )?;
    let mut state = ctx.create_state()?;

    let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 });
    params.set_language(Some("en"));
    params.set_print_special(false);
    params.set_print_progress(false);
    params.set_print_realtime(false);
    params.set_print_timestamps(false);

    state.full(params, &pcm)?;

    let mut transcript = String::new();
    let n_segments = state.full_n_segments();
    for i in 0..n_segments {
        if let Some(segment) = state.get_segment(i) {
            transcript.push_str(&segment.to_str_lossy()?);
        }
    }
    let whisper_ms = t_whisper.elapsed().as_secs_f64() * 1000.0;

    println!("whisper time: {whisper_ms:.1} ms");
    println!("transcript  : {}", transcript.trim());
    Ok(())
}

下面分段说说几个值得留意的点。

for_whisper 只是个预设,没有魔法。 它等价于 SampleExtractor::new(...).sample_rate(16000).channels(Mono)。基础 builder 默认保留源的采样率和声道,只将样本格式设为 f32------重采样和下混都需要显式开启。这个设计是为音乐制作、音频分析等场景保留的:这些场景需要源数据,不该被 ASR 预设"悄悄改成 16k"。你要 whisper 的输入格式,就显式写出来。

collect_samples() 一次性取回整条音轨,长音频则改用流式处理。 它返回交错排列的 Vec<f32>;单声道下样本数等于帧数,所以 len() / 16000.0 就是时长,程序里正是这么算的。整段音频会驻留内存,几小时的播客不要这么做------在同一个 builder 上改用 .samples()? 即可得到流式迭代器:按块产出,每块都带有采样率、声道和时间戳元数据,同时在途的块数有上限(默认 4),内存占用不随时长增长;提前 drop 迭代器会干净地终止解码。错误的产生时机也很明确:选项错误、找不到音频流这类问题,在调用 samples() 时就会报出;解码中途的失败则作为迭代器的最后一项返回------相比 shell-out 那坨 stderr 文本,这样处理更省心。配套还有 start_time_us / duration_us 可以圈定时间窗,长音频切段喂模型时很有用。

whisper-rs 侧,五个 set_print_* 不是摆设。 whisper.cpp 默认把转录进度直接输出到 stderr,使用这个库时看起来像个 CLI,这五行会将其关闭。模型加载时还有约 30 行日志走另一条路径,whisper_rs::install_logging_hooks 可以接管这些日志。Greedy { best_of: 1 } 是开销最低的解码策略;set_language(Some("en")) 与 tiny.en 这个英文专用模型匹配。

踩坑:坑全在 whisper-rs 那半边

ez-ffmpeg 这一侧没有坑可写:文档里的那行代码照抄下来,第一次编译就过,第一次运行就出结果。真正碰壁的是 whisper-rs 一侧,两个坑都值得点名。

坑 1:whisper-rs 0.16 的 API 变动,旧博客代码编译不过

笔者的第一版程序照着一篇旧教程写,cargo build 直接红了一片------一处 method not found,一处 ? 用在不该用的地方。对照 0.16 文档逐项修改后才发现,这不只是笔者一个人的问题:搜索引擎前几页的 whisper-rs 示例,大多停留在旧 API 上。0.16 的三个事实:

  1. full() 返回 Result<(), _>------用 ? 传播错误即可,不要指望它返回数字。
  2. full_n_segments() 返回裸 i32------后面再加 ? 会编译失败。
  3. full_get_segment_text() 已移除------改用 get_segment(i) 获取 Option<WhisperSegment>,再通过 to_str() / to_str_lossy() 读取文本。

上面代码中的段落循环就是 0.16 的正确写法,照抄即可编译。这个坑给笔者的通用教训是:接入迭代较快的 crate 时,方法签名以 docs.rs 上锁定的版本为准,博客仅作思路参考。顺带一句:whisper-rs 的 GitHub 仓库已归档,但项目仍在维护------已迁至 Codeberg;旧博客大多写于这些改动之前,以本文为准。

坑 2:构建前置------cmake 与 PEP 668

whisper-rs 要在本地编译 whisper.cpp:需要 cmake 和 C/C++ 工具链,第一次构建要几分钟,不要误以为卡住了。Ubuntu 24.04 起,这类遵循 PEP 668 的发行版上 pip install --user cmake 会被系统拒绝------开个 venv 装,或者直接用发行版的 cmake 包。

跑给你看

测试文件是将 11 秒 JFK "ask not" 演讲片段封装成的 MP4(h264 视频 + AAC 音轨);模型 ggml-tiny.en.bin(77,704,715 字节)。连续运行两次,样本数与转录文本完全一致,下面是第二次的输出:

text 复制代码
pcm samples : 176128
pcm duration: 11.008 s (len / 16000.0, mono)
extract time: 7.8 ms
whisper time: 635.6 ms
transcript  : And so my fellow Americans ask not what your country can do for you ask what you can do for your country.

转录内容正确,一遍就得到完整句子。数值也吻合:176,128 ÷ 16,000 = 11.008 s。源 wav 是 11.00 s,多出的约 8 ms 是转封装时 AAC 编码器的 priming/padding------编码器在音轨首尾补上的对齐样本,属于正常编解码行为。因此校验时长应写 ≈,不要写 ==;否则这 8 ms 就足以让断言平白失败。

计时说明:8 核 16 线程的 Ryzen 9 5900HX,纯 CPU,在同一台机器上连续运行两次,不是 benchmark:

阶段 第一次(冷) 第二次(热)
抽 PCM(SampleExtractor) 16.3 ms 7.8 ms
whisper 阶段(含从磁盘加载 77 MB 模型 + 转录) 1463.9 ms 635.6 ms

两点解读。第一,whisper 列的冷热差主要来自 77 MB 模型文件第二次已进入系统缓存,不要误以为推理本身变快了。第二,对这段 11 秒音频,取 PCM 只需毫秒级时间,主要耗时始终在模型侧;抽取步骤在整条流水线中已经不值得优化。这里没有测试批量吞吐和长文件表现,不凭空编数字。

什么时候别用它

纯 Rust 能从 MP4/MKV 里解出音轨并重采样到 16 kHz(symphonia + rubato,symphonium 一次调用搞定;mutter 五行就能喂给 whisper-rs)。但要做到"任意视频、任意编码、一行到位",就会撞上编解码墙:symphonia 的 HE-AAC、Opus 仍标注为未完成,AC-3 根本不在支持表里------这正是 FFmpeg 后端仍不可替代的地方。

所以取舍要看场景,三条路各有适用之处。手头只有一个文件、转完即走:开终端敲那行 ffmpeg 命令,专门写 Rust 程序反而绕远。输入格式可控(WAV/FLAC/普通 AAC 之类)、又希望零 C 依赖:symphonia + rubato 是正路,接入 FFmpeg 纯属多余。输入不可控------用户可能上传各种容器和编码------FFmpeg 的编解码覆盖才是你需要的,这就是选择 ez-ffmpeg 这类 FFmpeg 后端的理由。还要注意:就算音频侧做到零 C 依赖,whisper-rs 自己也要 cmake 编 whisper.cpp,"完全不碰 C 工具链"在这条流水线上本来就不成立。

写在最后

现在你的转录流水线少了一个子进程、一个临时 wav 和一段手写的 i16→f32,错误也变成了类型明确的 Result。接下来可以:长音频用 samples() 流式处理,再按 start_time_us/duration_us 切窗分段送入模型;也可以把同一个 Vec<f32> 交给 candle / ort 的其他音频模型------16 kHz 单声道 f32 是这类模型通用的输入格式。

完整示例在仓库的 examples/extract_whisper_pcm 目录,项目地址:github.com/YeautyYE/ez-ffmpeg

相关推荐
电子云与长程纠缠2 小时前
UE中使用TGuardValue与TInlineComponentArray数据结构
开发语言·数据结构·学习·ue5·游戏引擎
程序员爱钓鱼2 小时前
Rust HashMap 详解:键值存储、查询、更新与统计
后端·面试·rust
呜喵王阿尔萨斯2 小时前
C/C++ const -- 多义混乱
c语言·开发语言·c++
__log2 小时前
幂等性设计:从“重复提交“到“稳如磐石“的系统防护
java·开发语言·spring boot
spider_xcxc2 小时前
Helm 部署 K8s 集群完整笔记
java·开发语言·kubernetes
海清河晏1112 小时前
Qt实战:从零构建美化登录界面
开发语言·c++·qt
一只小灿灿3 小时前
C++ 各类特殊符号、运算符
开发语言·c++
Fu_Lin_3 小时前
《Qt嵌入式从零基础到精通》前言与阅读指南
开发语言·qt
名字还没想好☜3 小时前
Go 的 select 实战:超时、非阻塞收发与优雅退出的三个套路
开发语言·数据库·golang·go·并发