Skip to content
vicansoPublic

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

4 Commits

Folders and files

Repository files navigation

zwhisper

基于 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 3

CLI 默认开了 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 起;跨文件识别「是谁」需要另外注册声纹比对,不在本库范围。

中文转写的几个坑

  1. 显式指定语言 zh。自动检测容易把中文判成日语或粤语。
  2. initial prompt 引导简体。默认 以下是普通话的句子。,不带 prompt 时 large-v3-turbo 会输出繁体。
  3. 用 beam search。large-v3-turbo + greedy 转写短片段会在标点位置吐乱码字节(B、�), beam=5 完全干净,且 GPU 上耗时几乎无差别。库默认 beam=5,与 whisper.cpp 一致。
  4. --translate 是译成英文,whisper 只支持译到英文;large-v3-turbo 不支持翻译; 翻译时不要带中文 prompt,否则模型会复读 prompt。
  5. 模型选型看内存: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 -- --nocapture

GPU

cargo 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 频率。

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages