基于 whisper.cpp(通过 whisper-rs)的中文语音转写库,附命令行工具。
crates/zwhisper 库:一次性转写 / 分段上传的增量转写 / 完整音频校验修正
crates/zwhisper-cli 命令行 demo
- 分段上传、session 式增量转写。音频分片可以任意长短、切在任何位置(包括一个字中间),
库内部按静音切分,跨分片的字自动拼回。每次
push返回 已确定 的分段和 临时 的分段。 - 完整音频校验修正。
finalize()拿到全部音频后整体重跑一遍,产出权威结果, 并给出和增量结果的字级 diff,调用方能看到具体改了哪里。 - 噪声环境可用。切分依赖的 VAD 默认用自适应阈值(噪声底 + 余量),实测在 5–20 dB SNR 的白噪声、 低频轰鸣、背景人声下都能正常找到停顿;也可切换成 Silero 神经网络 VAD。
- 说话人分离(可选 feature)。录音文件转写时标出每段是谁说的:pyannote 分割 + 3D-Speaker 中文声纹, 纯 CPU 一分钟录音约 4 秒。真人四人录音实测全对,见下文「说话人分离」。
- 直接吃视频和压缩音频(可选 feature)。mp4 / mov / mkv / m4a / mp3 / flac / ogg 纯 Rust 解码,只取音轨, 带抗混叠重采样;不支持的编码(opus 等)自动回落到 ffmpeg。8 分钟 1080p 视频解音轨 0.4 秒。
- 长文件有进度和取消。进度回调按 30 秒窗口回调,取消令牌从别的线程一拨就停。
- 导出对话体 / SRT / VTT / JSON,带说话人标签。
- 纯 CPU 可运行,GPU(Metal / CUDA / Vulkan)可选。M4 Pro 纯 CPU 上 large-v3-turbo 约 7–15 倍实时。
- 跨平台。库本身没有平台相关代码;CI 在 macOS / Linux / Windows 三平台构建。macOS 已实测。
Engine和Session都是Send + Sync,可直接放进服务端的并发结构里。
whisper-rs 会在本地编译 whisper.cpp,需要 cmake 和 C/C++ 编译器;bindgen 需要 libclang。
| 平台 | 命令 |
|---|---|
| macOS | brew install cmake(Xcode Command Line Tools 自带 clang) |
| Ubuntu / Debian | sudo apt install cmake clang libclang-dev |
| Windows | Visual Studio Build Tools(C++ 工作负载)+ CMake + LLVM,设置 LIBCLANG_PATH=C:\Program Files\LLVM\bin |
Rust 1.88+(edition 2024;decode feature 依赖的 rubato 要 1.87,as_chunks 要 1.88)。
说话人分离走 sherpa-onnx 的预编译静态库(含 onnxruntime),首次编译时
sherpa-onnx-sys 会从 GitHub release 下载约 20 MB 的压缩包,不需要额外装东西。离线环境先把对应平台的
sherpa-onnx-v<版本>-<平台>-static-lib.tar.bz2 下好,设 SHERPA_ONNX_ARCHIVE_DIR 指向所在目录;
完全不需要这个功能就 cargo build -p zwhisper-cli --no-default-features。
# 1. 下载模型(中文推荐 large-v3-turbo,1.5 GB;机器紧张可用 small,465 MB)
./scripts/download-model.sh large-v3-turbo
# 国内网络走镜像:HF_ENDPOINT=https://hf-mirror.com ./scripts/download-model.sh large-v3-turbo
# 2. 一次性转写。输入可以是 wav,也可以直接给 mp4 / mov / mkv 视频或 mp3 / m4a(只取音轨)
cargo run --release -- models/ggml-large-v3-turbo.bin input.wav
cargo run --release -- models/ggml-large-v3-turbo.bin lecture.mp4 -o lecture.srt -f srt # -f: lines/txt/dialogue/srt/vtt/json
# 3. 模拟分段上传:按 2.5 秒切片依次 push,展示增量转写 + 最后的校验修正
cargo run --release -- models/ggml-large-v3-turbo.bin input.wav --chunk 2.5
# 可选:Silero VAD 模型(885 KB),用于 --vad silero
./scripts/download-model.sh silero
# 4. 说话人分离:下载两个模型(46 MB),每段标出 [说话人N];人数已知就 --speakers N,未知用 --diarize
./scripts/download-model.sh diarize
cargo run --release -- models/ggml-large-v3-turbo.bin meeting.wav --speakers 3CLI 默认开了 decode feature,wav / mp3 / m4a / flac / ogg / mp4 / mov / mkv 都能直接喂,采样率和声道数自动转换。
symphonia 不支持的编码(opus、HE-AAC)会回落到 PATH 里的 ffmpeg;没有 ffmpeg 就先转一下:
ffmpeg -i input.webm -ar 16000 -ac 1 -c:a pcm_s16le input.wav
afconvert -f WAVE -d LEI16@16000 -c 1 input.m4a input.wav # macOS 自带use zwhisper::{Engine, SessionOptions, FinalizeOptions, RevisionKind};
let engine = Engine::new("models/ggml-large-v3-turbo.bin")?; // 加载一次,多处共享
let mut session = engine.session(SessionOptions::default())?;
// 分片到达时逐个 push;音频要求 16 kHz 单声道 f32,其他格式用 push_pcm(samples, rate, channels)
for chunk in incoming_chunks {
let update = session.push(&chunk)?;
for seg in &update.committed { println!("✔ {}", seg.text); } // 已确定,不会再变
for seg in &update.tentative { println!("… {}", seg.text); } // 临时,下次 push 会整体替换
}
// 结束:flush 剩余音频,整体重跑一遍做校验修正
let done = session.finalize(FinalizeOptions::default())?;
println!("{}", done.text()); // 权威结果
if done.revised() {
for r in &done.revisions { // 字级 diff:Equal / Delete / Insert
if r.kind != RevisionKind::Equal { println!("{:?} {}", r.kind, r.text); }
}
}一次性转写:engine.transcribe(&pcm, &DecodeOptions::default())。
带说话人:engine.transcribe_with_speakers(&pcm, &DecodeOptions::default(), &diarizer),见下文「说话人分离」。
use zwhisper::{audio, export, CancelToken, DecodeOptions, Engine, Hooks};
// decode feature:任意媒体 → 16 kHz 单声道,视频只取音轨;长文件可用 audio::Decoder 一块一块出
let pcm = audio::decode("lecture.mp4")?;
// 进度回调按 30 秒窗口回调;取消令牌 clone 一份给别的线程,cancel() 后转写返回 Error::Cancelled
let cancel = CancelToken::new();
let opts = DecodeOptions::default().with_hooks(
Hooks::default()
.with_progress(|pct| eprintln!("{pct}%"))
.with_cancel(cancel.clone()),
);
let segments = engine.transcribe(&pcm, &opts)?;
// 导出:join_text 纯文本,dialogue 对话体(按说话人合并轮次),srt / vtt 字幕;serde feature 下 Vec<Segment> 可直接转 JSON
std::fs::write("lecture.srt", export::srt(&segments))?;
println!("{}", export::dialogue(&segments, export::speaker_name));Session 走的也是 DecodeOptions,所以取消令牌同样能让漫长的 finalize 整体重跑中途停下。
音频采集这类流式输入采样率多半是 48 kHz,用 audio::Resampler(resample feature,带抗混叠、可分块喂)转成 16 kHz 再 push;
normalize / push_pcm 的线性插值没有低通,干净人声够用,高频丰富或嘈杂的素材会有混叠。
push(片1) ─┐
push(片2) ─┼─▶ pending 缓冲 ──▶ VAD 找最后一个 ≥300ms 的静音 ──┬─▶ 静音之前 ──▶ whisper ──▶ committed(定稿)
push(片3) ─┘ └─▶ 静音之后 ──▶ whisper ──▶ tentative(临时)
│
└─ 留在 pending,和下一片拼接后再处理
为什么能处理被劈开的字:字不会被切在静音里。上传分片的边界如果落在一个字中间,
这个字的两半都在最后一个静音之后,会一直留在 pending,直到下一片把它补完整。
whisper 永远只看到完整的字。
为什么要 finalize:whisper 用前文作为上下文,增量模式下每次只看到几秒音频 + 最近 60 字的已确定文本作为 prompt,整体重跑时它能看到全部上下文,标点和用词更一致。
几个关键参数(SessionOptions):
| 参数 | 默认 | 说明 |
|---|---|---|
vad.min_silence |
200ms | 静音多长才算切分点。下限 ~150ms(清塞音闭塞段一般 50–120ms),设太高对快语速说话人会一直切不了 |
vad.detector |
Energy(自适应阈值) |
怎么判断一帧是不是语音,见下文"真实环境的噪声" |
min_commit |
1s | 切分点前至少要有这么多音频才 commit,避免喂给 whisper 太短的片段 |
max_pending |
20s | 一直没静音时的强制切分上限(whisper 窗口 30s) |
tentative |
true | 关掉可省近一半算力,只要定稿结果时用 |
context_chars |
60 | 回喂给 prompt 的已确定文本字数,保持前后用词一致 |
keep_audio |
true | 保留全部音频供 finalize,约 230 MB/小时 |
M4 Pro(Metal),large-v3-turbo,12 秒三句话的测试音频(crates/zwhisper/tests/fixtures/),按 2.5 秒切片模拟分段上传:
$ zwhisper models/ggml-large-v3-turbo.bin zh_three_sentences.wav --chunk 2.5
▶ push #1 [ 0.00s → 2.50s] 耗时 0.64s 已commit 1.7s pending 0.8s
✔ [00:00:00.000 --> 00:00:01.740] 今天天气很好。
▶ push #2 [ 2.50s → 5.00s] 耗时 0.66s 已commit 1.7s pending 3.3s
… [00:00:01.740 --> 00:00:05.000] 我们一起去公园散步。下午有一个
▶ push #3 [ 5.00s → 7.50s] 耗时 1.33s 已commit 6.1s pending 1.4s
✔ [00:00:01.740 --> 00:00:06.060] 我们一起去公园散步。下午有一个重要的会议。
… [00:00:06.080 --> 00:00:07.500] 请大家准时采访。 ← 音频只到一半,临时结果听错了
▶ push #4 [ 7.50s → 10.00s] 耗时 0.68s 已commit 9.7s pending 0.3s
✔ [00:00:06.080 --> 00:00:09.680] 请大家准时参加。会议结束以后。 ← 拼上后一片,自动修正并定稿
▶ push #5 [ 10.00s → 12.23s] 耗时 0.66s 已commit 9.7s pending 2.6s
… [00:00:09.680 --> 00:00:12.220] 记得把纪要发给所有同事。
━━ finalize(整体重跑) 耗时 1.51s ━━
增量结果: 今天天气很好。我们一起去公园散步。下午有一个重要的会议。请大家准时参加。会议结束以后。记得把纪要发给所有同事。
整体重跑: 今天天气很好,我们一起去公园散步。下午有一个重要的会议,请大家准时参加。会议结束以后,记得把纪要发给所有同事。
修订对照: 今天天气很好「-。」「+,」我们一起去公园散步。下午有一个重要的会议「-。」「+,」请大家准时参加。会议结束以后「-。」「+,」记得把纪要发给所有同事。
相似度: 94.5%
✔ 是已定稿的分段,… 是临时分段。push #2 的切片正好把"下午有一个重要的会议"劈开,被劈开的部分留在 pending,
push #3 补完后才 commit。finalize 的 3 处修订都是标点:增量模式下每次只看到一个短片段,whisper 倾向于用句号收尾;
整体重跑时看到完整上下文,知道句子还没结束,改成了逗号。
纯 CPU 下同一台机器:large-v3-turbo 单路约 7 倍实时,12 核跑满约 15 倍实时总吞吐。
切分机制和语言无关 —— 任何语言的音节内部都不会出现 200ms 以上的静音。英文只需改两个解码参数:
DecodeOptions { language: Some("en".into()), prompt: None, ..Default::default() }$ zwhisper models/ggml-large-v3-turbo.bin en.wav --chunk 2.5 -l en -p ""
▶ push #1 [ 0.00s → 2.50s] 已commit 1.3s pending 1.2s
✔ [00:00:00.000 --> 00:00:00.900] Good morning everyone.
▶ push #2 [ 2.50s → 5.00s] 已commit 4.3s pending 0.7s
✔ [00:00:01.320 --> 00:00:04.300] Today we are going to review the quarterly results.
▶ push #3 [ 5.00s → 7.50s] 已commit 6.8s pending 0.7s
✔ [00:00:04.300 --> 00:00:06.540] And then discuss the roadmap for next year.
min_silence 要按说话人节奏调,这比语言更重要。上面这段英文 TTS 的句间停顿只有 220–240ms,
用最初的 300ms 默认值时一次都切不了(全部音频一直堆在 pending 里,直到 finalize 才出结果),
所以默认值改成了 200ms,并且在触发 20s 强制切分时会打 warn 日志提示调参。
真人说话句间停顿一般 300–800ms,不会有这个问题;快语速播音、TTS 需要注意。
拿不准就统计一下录音里静音段的长度分布,取比"最短的句间停顿"略小、比 150ms 大的值。
TTS 生成的测试音频句间是数字零(-89 dBFS),太理想。真实录音的底噪往往在 -45 ~ -30 dBFS, 安静办公室就有 -40 左右 —— 一个固定的 -40 dBFS 阈值会把每一帧都判成语音,一次都切不了。
给测试音频叠加不同噪声后的对比(turbo,2.5s 切片,统计 commit 次数;越多说明切分越正常):
| 噪声条件 | 固定阈值 -40 | 自适应能量(默认) | Silero |
|---|---|---|---|
| 白噪声 20 dB SNR(安静办公室) | 0 | 4 | 3 |
| 白噪声 10 dB SNR(嘈杂办公室 / 车内) | 0 | 4 | 2 |
| 低频轰鸣 10 dB SNR(空调 / 马路) | 0 | 4 | 1 |
| 背景人声 10 dB SNR | 0 | 4 | 0 |
| 背景人声 5 dB SNR(咖啡馆) | 0 | 5 | 0 |
| 36 秒白噪声 15 dB SNR | 2(强制切分 2 次) | 12 | 9 |
所有条件下最终转写文本相似度 0.96–1.00 —— whisper 本身很抗噪,VAD 只影响"什么时候能 commit",不影响对错。
三种检测器怎么选:
Threshold::Fixed:只适合录音棚 / TTS 这种底噪极低的来源。Threshold::Adaptive(默认):以窗口内能量的第 10 百分位为噪声底,高出 8 dB 算语音。 对稳态噪声(嘶声、轰鸣、线路噪声)鲁棒,对背景人声反而比 Silero 好 —— 因为它看的是相对能量, 前景说话人停顿时能量会掉回背景电平。局限:分不清"响的稳态噪声"和"持续说话"(-30 dBFS 以上的平坦信号会整段判语音), 音乐、键盘、开关门这类非稳态非语音噪声会被当成语音。Detector::Silero:神经网络,能区分语音和非语音(音乐、敲击、门声、风声),对这类噪声明显更好。 但它判断的是"有没有人在说话",背景人声也是人说话,所以在 babble 噪声下看到的是连续语音、找不到间隙。
这些数据来自合成噪声。真实录音还有混响、自动增益(AGC 会把停顿处的底噪抬起来)、电话编解码等因素,
建议用自己的真实录音跑一遍 --chunk 模式看 commit 情况,CLI 提供了全部调参入口:
zwhisper model.bin real.wav --chunk 3 # 默认自适应能量
zwhisper model.bin real.wav --chunk 3 --vad-margin 12 # 噪声起伏大时加大余量
zwhisper model.bin real.wav --chunk 3 --vad silero # 换 Silero
zwhisper model.bin real.wav --chunk 3 --min-silence 150 # 快语速
RUST_LOG=debug zwhisper ... 2>&1 | grep commit # 看每次切分的位置和原因如果日志里出现"强制切分"警告,说明 20 秒内没找到任何静音,需要调参。
whisper 只输出「说了什么」,不输出「谁说的」。说话人分离(speaker diarization)是另一个模型干的活, 这里用 sherpa-onnx 跑 pyannote segmentation-3.0(找说话区间和换人点)
- 3D-Speaker eres2net 中文声纹(每段一个向量)+ 聚类(归并成说话人),再和 whisper 的分段按时间重叠对齐: 每个转写分段归给与它重叠时长最长的说话人。
use zwhisper::{DecodeOptions, DiarizeOptions, Diarizer, Engine};
let engine = Engine::new("models/ggml-large-v3-turbo.bin")?;
let diarizer = Diarizer::new(
DiarizeOptions::new(
"models/pyannote-segmentation-3-0.onnx",
"models/3dspeaker_speech_eres2net_base_sv_zh-cn_3dspeaker_16k.onnx",
)
.with_num_speakers(3), // 人数已知一定填,准很多;不填按 threshold 自动判断
)?;
// 转写和分离互不依赖,内部并行跑,最后给每个 Segment::speaker 赋值
for seg in engine.transcribe_with_speakers(&pcm, &DecodeOptions::default(), &diarizer)? {
match seg.speaker {
Some(n) => println!("说话人{}: {}", n + 1, seg.text),
None => println!("(无人说话) {}", seg.text), // 和任何说话区间都不重叠:多半是 whisper 的幻觉
}
}想分别拿耗时和说话人数,就 engine.transcribe + diarizer.run(&pcm) + diarization.label(&mut segments) 三步自己拼;
分段上传模式下对 finalize 之后的完整音频跑一次 run 再 label 即可,CLI 的 --chunk --diarize 就是这么做的。
数据类型和对齐逻辑(SpeakerSegment / Diarization / assign_speakers)不依赖 diarize feature,说话人区间来自别的系统也能用。
需要 diarize feature:库默认不开,CLI 默认开。Diarizer 是 Send + Sync,run 只要 &self。
M4 Pro,纯 CPU 4 线程(DiarizeOptions::threads,默认 1 会慢 3 倍):
| 音频 | 配置 | 结果 | 分离耗时 |
|---|---|---|---|
| sherpa-onnx 官方真人四人中文录音,57s | num_speakers = 4 或 threshold = 0.9 |
旁白 + 三段引用全部分对;末尾 whisper 复读的幻觉段没有说话人(None) |
3.4s |
say 合成,女 + 男两人对话,51s |
num_speakers = 2 或自动 |
8/8 分段正确 | 4.6s |
say 合成,四人(两男两女),57s |
num_speakers = 4 |
8/9,一段老年男声并入另一个男声 | 5.0s |
say 合成,两个女声(Tingting + Meijia) |
任何配置 | 完全分不开,判成一人 | — |
- 人数已知一定传
num_speakers。自动判断人数靠聚类阈值threshold,sherpa-onnx 自己的默认值 0.5 明显偏低 (真人四人录音拆成 7 人);本库默认 0.8,真人录音在 0.9 才刚好、合成四人在 0.7 才刚好,要用自己的录音标定。 越大越倾向合并成同一人。 - 同性别、音色接近的说话人是主要错误来源。上面两个合成女声完全分不开,两个合成男声也会串。合成语音本身不是好的测试集 (共用一套声码器),真实录音要再验。
- 两人同时说话的重叠段,分离和转写都会明显变差,目前没有开源方案能稳定处理。
- 一个 whisper 分段里换了人(两句话之间没停顿、被 whisper 合成一段)时,整段会归给说得多的那个人。 要精确到字需要词级时间戳,本库暂没做。
- 分割模型不喜欢纯数字静音(全 0 采样):轮次之间是数字静音时 pyannote 会把多轮合并成一段。真实录音有底噪没这个问题, 但经过降噪 / 静音抹零处理的音频要留意;给合成音频加 -55 dBFS 底噪后分割全对。
- 说话人编号只在同一次结果内有意义,按首次出现顺序从 0 起;跨文件识别「是谁」需要另外注册声纹比对,不在本库范围。
- 显式指定语言
zh。自动检测容易把中文判成日语或粤语。 - initial prompt 引导简体。默认
以下是普通话的句子。,不带 prompt 时 large-v3-turbo 会输出繁体。 - 用 beam search。large-v3-turbo + greedy 转写短片段会在标点位置吐乱码字节(
B、�), beam=5 完全干净,且 GPU 上耗时几乎无差别。库默认 beam=5,与 whisper.cpp 一致。 --translate是译成英文,whisper 只支持译到英文;large-v3-turbo 不支持翻译; 翻译时不要带中文 prompt,否则模型会复读 prompt。- 模型选型看内存:turbo 需 ~2 GB,small ~800 MB。tiny / base 对中文基本不可用。
cargo test --workspace # 单元测试,不需要模型
ZWHISPER_TEST_MODEL=$PWD/models/ggml-small.bin \
cargo test -p zwhisper --release --test session -- --nocapture # 集成测试:跨分片拼接、时间戳、finalize 修订等
# 媒体解码 + 进度 / 取消:AAC 解出来转写结果和 wav 一致、37s 音频进度回调到 100、中途取消立即停下
ZWHISPER_TEST_MODEL=$PWD/models/ggml-small.bin \
cargo test -p zwhisper --features decode --release --test media -- --nocapture
# 说话人分离集成测试:两人对话 fixture,验证分离区间和「按说话人拼出的文本」都对
./scripts/download-model.sh diarize
ZWHISPER_TEST_MODEL=$PWD/models/ggml-small.bin \
ZWHISPER_TEST_SEG_MODEL=$PWD/models/pyannote-segmentation-3-0.onnx \
ZWHISPER_TEST_SPK_MODEL=$PWD/models/3dspeaker_speech_eres2net_base_sv_zh-cn_3dspeaker_16k.onnx \
cargo test -p zwhisper --features diarize --release --test diarize -- --nocapturecargo build --release # macOS 自动启用 Metal
cargo build --release -F cuda # NVIDIA
cargo build --release -F vulkan # Vulkan库本身不默认开任何 GPU feature;CLI 通过 target-specific dependency 在 macOS 上自动加 metal。
- 能量 VAD 分不清响的稳态噪声和持续说话;Silero 分不清前景人声和背景人声。两者对"背景音乐 + 说话"都不理想。 说话人分离能标出谁在说,但不能替 VAD 把背景人声去掉;更进一步要靠降噪前处理,不在本库范围。
- 说话人分离只有离线整段模式,没有流式;重叠说话、同性别相近音色是主要错误来源,见「说话人分离」一节。
- 媒体解码不支持 opus 和 HE-AAC(webm、部分直播录制常见),这类靠 ffmpeg 回落;mp4 里的 AAC 不做 gapless 裁剪, 开头多出约 0.13 秒编码器延迟,字幕时间戳会整体晚这么多。
- whisper-rs 0.16 的
set_abort_callback_safe有类型错位 bug,库里绕过了(见engine.rs);升级 whisper-rs 时留意。 - 重采样是线性插值,没有抗混叠;采样率不是 16 kHz 的话建议调用方先转好。
- 实时流式(每几百毫秒一片)场景下
tentative每次 push 都会跑一遍 whisper,算力开销大,可以关掉或降低 push 频率。