跳到主要内容

创建日期:2026-10-13 | 最近更新:2026-10-13 事实核对基于 sherpa-onnx 1.13.8(Python 包:PyPI 默认 wheel 与官方 CUDA wheel 1.13.8+cuda12.cudnn9.onnxruntime1.28.2),素材为三段真实的金融评论/编程培训视频。文中所有 CER、RTF、显存、内存均为本机实测,脚本与原始结果留档于 source/sherpa-onnx-subtitle-lab/。

sherpa-onnx 101(第 6 篇 · 视频转字幕):把几十小时的口播视频变成能用的字幕

前五篇都在讲"语音怎么进前端",这一篇换个方向:手上一堆视频,要字幕。金融评论、编程培训这类内容动辄几十分钟,人工打字不现实,云端 API 又贵又要把内容传出去。本篇回答的是工程问题,不是 API 问题:

  • 同样的音频,七个模型差多少?(准确率、速度、显存)
  • CPU 还是 GPU?(结论和直觉相反)
  • 标点、断句、时间戳、术语——字幕好不好用,其实由这四件事决定,而不是识别准确率。

0. 先给结论​

#结论证据
1准确率几乎拉不开差距,速度差四十多倍:内容口径 CER 都在 12%~14%(Dolphin 20%、Whisper 25%),而 RTF 从 0.009 到 1.480。长口播字幕选第一梯队里最快的,并且把权重格式选对§4.1、§6.1
2"模型大"和"更准"没关系:Whisper large-v3 体积最大,CER 却垫底(25.3%),输出一个字一个字地丢,而且换 fp32 后输出逐字相同§4.1
3GPU 有用,但只对 fp32 权重有用:官方发布的中文模型多是 int8 量化版,那 279 个 MatMulInteger 在 CUDA 上跑不了、回落 CPU,于是「int8 + GPU」比纯 CPU 还慢;换 fp32 权重,同一张 3090 的逐秒计算快 14 倍§4.6
4标点不在模型里:SenseVoice / FireRedASR2-CTC / Paraformer 一个标点都不吐,要外挂标点模型;而已经吐标点的输出再过一遍会叠标点(聊,,)§5.1
5"逐字转写"不等于"字幕":严格逐字比,CER 20.6%;把语气词、数字写法、口播插入语、同音字抹平后是 13.1%——三分之一的"错"其实是字幕编辑差异§4.4
6时间戳有没有,对字幕切点精度影响很小:四个模型的中位误差都在 0.43~0.58 秒,和参照稿本身的 1 秒精度同量级§2.3、§5.3
7两个坑值得单独记:max_speech_duration 不是硬上限(源码里它只是把静音判定放宽到 0.1 秒);PyPI 装的是 CPU 版,CUDA wheel 的版本号官方文档还没同步§3.1、§8.3

1. 场景:金融评论与编程培训,难在哪儿​

两种内容的难点不一样:

金融评论编程培训
音频形态单人连续口播,语速快屏幕录制 + 讲解,常有键盘声
术语降息、加息、通胀、LPR、量化宽松Redis、String、Hash、ZSet、GitHub Pages
数字百分比、点位、年份密集版本号、端口、内存大小
中英混说少(偶有 INTEREST RATE)多(术语几乎全英文)
字幕来源绝大多数没有字幕绝大多数没有字幕

另外还有一条现实:这两类视频几乎都没有字幕。金融口播里有些 UP 主会烧字幕,编程教程 (尤其是屏幕录制那种)绝大多数没有。这正是"需要自己转写"的原因,也决定了评测方法—— 有字幕的用字幕当基准,没字幕的只能看术语命中和人工抽检(§4.3)。

先说清楚目标:"能用"的字幕不等于"字全对"。它至少满足三条——

  1. 断句成行,一条字幕 10~30 字,不是一大段糊在一起;
  2. 有标点,读起来是句子不是词串;
  3. 时间轴贴着说话节奏,不是均匀铺开。

准确率是第四条,也最容易量化:用字符错误率 CER(错字数 ÷ 参照稿字数)。

1.1 参照稿从哪来:烧进画面的字幕​

要算 CER 就得有"标准答案"。这里有两条路,一条是错的:

  • ❌ 用平台字幕(YouTube CC、B 站 AI 字幕)当参照——那些字幕本身就是语音模型生成的,拿它评语音模型是循环论证。
  • ✅ 用烧进画面的字幕(硬字幕)。这类字幕是人工写的、逐帧烤进视频的,跟音频无关,可以用 OCR 逐帧读出来当参照。

所以素材选的都是带硬字幕的视频(见 source/sherpa-onnx-subtitle-lab/media/SOURCES.md)。OCR 参照稿本身也有误差,做法和核对见 §4.3。

2. 流水线:视频到 SRT 要做几步​

视频.mp4
├─[1] ffmpeg 抽音轨 → 16 kHz 单声道 wav
├─[2] Silero VAD 切语音段(静音处断开,超长段兜底再切)
├─[3] 离线识别 每个语音段跑一遍模型 → 文本(有的带 token 时间戳)
├─[4] 标点恢复 模型不吐标点就外挂 CT-Transformer
├─[5] 术语替换 按词表把听错的专有名词纠回来
└─[6] 断句成行 按标点切句 → 套时长/行长约束 → 停留到下一句之前 → 写 SRT

完整实现是 scripts/05-make-srt.py,一条命令:

python 05-make-srt.py 视频.mp4 -o out.srt --model sensevoice --provider cpu

下面只讲每一步为什么这么做。

2.1 抽音轨:唯一必须用 ffmpeg 的一步​

ffmpeg -i 视频.mp4 -vn -ac 1 -ar 16000 -c:a pcm_s16le out.wav

三个参数都不是可选的:

  • -ac 1:所有离线识别模型都按单声道训练。双声道直接喂进去,左右声道的采样会交错成一个 32 kHz 的假波形,识别结果会烂掉——而且是那种"看起来在出字、其实全是错的"的烂。同一段音频实测:

    立体声交错样本直接喂:剧绿呢它是一个是一个特别大的话,其实金确忙于希望先挑捧代理扰。咱先一起李清户底层逻辑…
    先降成单声道 :利率呢它是一个是一个特别大的话题所以今天我就希望先挑主干聊咱先一起捋清楚底层逻辑…
  • -ar 16000:训练采样率就是 16 kHz(SenseVoice、Paraformer、FireRedASR 全是)。ffmpeg 重采样比在 Python 里做快得多。

  • -c:a pcm_s16le:给后续读取一个确定的格式。

2.2 为什么要"逐段识别"而不是"整段识别"​

因为字幕要按段出。段边界就是字幕的锚点:模型只给文本,什么时候说的靠 VAD。 另外离线模型对输入长度都有隐性上限(Whisper 是 30 秒窗),长音频硬塞进去要么被截断要么被内部切得莫名其妙。

2.3 时间戳:模型之间的一个关键差异​

有的模型会返回 token 级时间戳(段内每个 token 的秒数),有的返回空数组:

模型时间戳影响
SenseVoice✅断句切点能落在字上
FireRedASR2-CTC✅同上
Dolphin✅同上
FunASR Nano✅同上
Paraformer❌ 空只能按字数摊时间
Qwen3-ASR❌ 空同上
Whisper large-v3❌ 空同上

(本机实测:result.timestamps 的长度)

看起来这条差异很重要,但实测下来对字幕时间轴的影响很小(§5.2 有量化)。 真正决定字幕切点在哪儿的是 VAD 段边界和断句规则,token 时间戳只负责段内那点微调。 所以:有更好,没有也不用为它换模型。

3. VAD:字幕的时间轴地基​

为什么不能把整段音频直接丢给模型? 三个原因,一个比一个硬:

  1. 离线识别模型有输入长度限制(Whisper 的输入窗就是 30 秒);
  2. 长音频一次性编码,显存/内存按长度线性涨,没有任何好处;
  3. 字幕的时间轴来自切分。模型只给你一段文本,什么时候说的,得靠 VAD 的段落边界。

所以 VAD 不是"预处理",它是字幕时间轴的地基。

3.1 一个坑:max_speech_duration 不是硬上限​

直觉上 max_speech_duration: 20 意味着"每段最多 20 秒"。实测不是:

max_speech= 5.0 → 22 段, 最长 32.92s, 超限段数 16
max_speech= 10.0 → 15 段, 最长 40.37s, 超限段数 11
max_speech= 20.0 → 12 段, 最长 40.37s, 超限段数 4

(同一段 200 秒口播音频,只有 max_speech_duration 变化)

翻源码能看到原因——它根本不是"到了就切",而是切换判定参数:

// sherpa-onnx/csrc/voice-activity-detector.cc:50
void AcceptWaveform(const float *samples, int32_t n) {
if (buffer_.Size() > max_utterance_length_) {
model_->SetMinSilenceDuration(new_min_silence_duration_s_); // 0.1 秒
model_->SetThreshold(new_threshold_); // 0.90
} else {
... // 恢复用户配置的 min_silence_duration / threshold

也就是说:缓冲超过上限后,它把"多长的静音才算断句"从 0.6 秒放宽到 0.1 秒、把语音判定阈值从 0.5 提到 0.90, 指望"更容易判成静音"来把段断开。它并没有在 20 秒处动刀。 连续口播(比如小Lin说这种一口气讲 40 秒的) 就可能一直不满足断开条件,于是段越来越长。

对照证据:silero-vad-model.cc 里只读了 min_silence_duration 和 min_speech_duration, 没有任何一处读 max_speech_duration(源码留档见 official-sources/)。

做法:自己兜底切。本工程在 VAD 之后加了一步 split_long_segments()—— 超过 25 秒的段,在"目标切点 ±2.5 秒内能量最低的 20 毫秒窗"处下刀:

def split_long_segments(seg, max_len=25.0):
n_parts = int(np.ceil(seg.duration / max_len))
rms = ... # 20ms 一格的短时能量
cuts = []
for k in range(1, n_parts):
target = int(len(s) * k / n_parts)
lo, hi = (target - 2.5*sr)//win, (target + 2.5*sr)//win
cuts.append((lo + int(np.argmin(rms[lo:hi]))) * win) # 落在能量谷

切在能量谷而不是切在正中,是因为能量谷大概率就是换气/停顿处,切出来的段落边界更贴句子边界。 实测 17 分钟音频:51 段 → 65 段,最长段从 43.5 秒降到 24.8 秒。

3.2 参数怎么定​

本工程用的:

参数值为什么
threshold0.5Silero 官方默认;调高会漏掉气声起头
min_speech_duration0.25 s挡掉键盘声、翻页声这类瞬态
min_silence_duration0.6 s口播里句子之间的停顿通常 0.3~0.8 秒,取 0.6 兼顾"别切太碎"和"别粘一起"
max_speech_duration20 s如上,只是触发"更容易断开"的开关,不是上限
max_segment_len(本工程补充)25 s真正的硬上限,留给 Whisper 的 30 秒输入窗余量

VAD 本身的开销可以忽略:17 分钟(1025 秒)音频,VAD + 兜底切分一共 13.6 秒 (RTF 0.0132,约 75 倍实时),峰值内存 228 MB。切出 65 段、语音占 95.2%。 真正贵的是后面的识别。

4. 模型横评​

4.1 怎么测的​

固定条件,只换模型:

  • 音频:同一条 16 kHz 单声道 wav,同一套 VAD 切分给所有模型共用; CER 在 120 秒切片上测(9 段,够快、能让 7 个模型背靠背跑完),吞吐在 17 分钟全片上复测;
  • 模型:sherpa-onnx 1.13.8 自带的 7 个离线中文/多语种模型,num_threads=8 固定;
  • 指标:CER、RTF(处理耗时 ÷ 音频时长)、峰值内存、显存;
  • 参照:烧录字幕的 OCR 稿,只统计覆盖率 ≥ 70% 且参照稿可信的语音段。

CER 给三个口径,因为"和字幕差多少"和"听对没有"是两回事:

口径做了什么回答的问题
严格只去标点和字幕的逐字差异
风格化再去语气词、中文数字归一排除字幕编辑和数字写法差异
内容再抹掉口播插入语(那/这个/就是)和同音字(他/她/它)内容到底听对没有

结果(同一段 120 秒金融口播,9 个语音段,RTX 3090 + 32 核 CPU,num_threads=8):

模型体积CER 严格CER 风格化CER 内容RTF int8-CPURTF int8-GPURTF fp32-GPU
Dolphin base104 / 317 MB27.8%20.7%20.0%0.0350.0300.009
Paraformer238 / 865 MB21.5%13.9%12.9%0.0460.0550.014
SenseVoice237 / 929 MB20.6%13.3%12.1%0.0560.0680.015
FireRedASR2-CTC int8776 MB21.7%14.9%14.0%0.4070.386—
Qwen3-ASR 0.6B int8983 MB21.2%13.6%12.4%0.6530.798—
FunASR Nano int8994 MB20.2%12.5%11.9%0.6360.781—
Whisper large-v31.78 / 6.2 GB25.3%24.7%24.6%—1.4800.353

体积列写「int8 / fp32」两套权重。fp32-GPU 那一列才是这台机器上最快的配置—— 为什么差别这么大,是 §4.6 的全部内容。只看 CPU 的话,int8 版就够(还更小)。

读数说明:同一台机器、同一段音频、num_threads=8,CPU 与 GPU 背靠背各跑一遍; 显存/进程内存取 GPU 版。这台机器上还跑着别人的 clickhouse / ollama,所以绝对数字会有几个 百分点的抖动,但同一批次的横向比较是可靠的。

顺带一个教训:最初我在机器被自己压满的时候测过一轮,得到的 RTF 全是错的 (FunASR Nano 被算成 5.567,实际不到 1.0,差了近 6 倍)。测性能的时候一定要确认机器是空的—— 这篇里凡是"跨批次比较"的数字,我都重新背靠背测了一遍。

准确率的差距,远远小于速度的差距。

内容口径下五个模型挤在 11.9% ~ 14.0% 这个窄带里(Dolphin 20.0% 和 Whisper 24.6% 是两个例外), 而 RTF 从 0.035 到 1.480,差了四十多倍:

  • 17 分钟的视频,最快的配置要 22 秒,最慢的要 25 分钟(§6.1 有完整对照);

  • 两个"垫底"的模型各有各的问题:Dolphin 小而无脑(104 MB,准确率掉 8 个点), Whisper 大而无当——体积是别人的 8 倍,准确率却最差。它的输出长这样:

    参照:我们都知道利率很重要 央行 控制利率降息就能 刺激经济 你就感觉各国 央行 最大的动作就是 调整利率
    模型:我们都知道利率很重要 行 控制利率降息就能 经 你就感觉各国 行 最大的动作就是 整利率

    一个字一个字地丢——而且换 fp32 权重后输出逐字相同(CER 25.3% → 26.5%), 所以这不是量化造成的,是 sherpa-onnx 的 Whisper 路径本身在这条中文口播上不行。 "模型大"和"更准"在这里没有关系。

  • 唯一"贵得有理由"的是 Qwen3-ASR 和 FunASR Nano:内容口径 CER 最低(11.9% / 12.4%), 代价是 10~15 倍的耗时。它们贵在 LLM 解码——每出一个 token 就要跑一遍 decode。

所以选型结论很朴素:长口播字幕选第一梯队里最快的那几个(本机是 Dolphin 之外的三兄弟: Paraformer / SenseVoice / FireRedASR2),不要为了一两个百分点的 CER 付十倍的吞吐代价。

中英混说(编程教程)要不要换大模型?答案是"不划算":Qwen3-ASR 确实能不经词表就把 Vue 认成英文单词,但慢 34 倍,而且版本号照样要修(Vue 三)。给快模型配一份 针对这条视频的词表,又快又准——完整对照见 §5.4.1。

4.2 换个视频还成立吗​

一张表最怕的是"换个样本就翻车"。拿第二条金融口播(9 分钟、纯口播、字幕覆盖率更高)再跑一遍:

模型内容口径 CER(第一条 17 分钟)内容口径 CER(第二条 9.5 分钟)
SenseVoice12.1%15.6%
Paraformer12.9%16.7%
FireRedASR2-CTC14.0%16.7%
Dolphin20.0%19.3%

(第二条:31 个语音段、参照覆盖率 93%)

排序基本没变(Dolphin 依然垫底,其余三个依然挤在一起),绝对值整体高了 2~4 个点—— 换条视频 CER 会漂,所以别把"12.1%"当成模型的能力指标,它只是"这条视频上的表现"。 真正稳的是排序和量级。

4.3 参照稿本身有多准:能当基准,但要先学会丢掉它​

这套评测的基准是"用 OCR 读画面上的字幕",所以先说清楚它自己的成色。

人工核对:随机抽三帧逐字读,和 OCR 结果完全一致——

时间我肉眼读的OCR 读的
101s投资等等投资等等 ✅
107s好多人就会贷款买房好多人就会贷款买房 ✅
113s卖包就给我钱了卖包就给我钱了 ✅

它自己的两类错误:

  1. 数字和符号会读错。有一行的真实字幕是「这样相对于刚才利率是 2% 的时候」, OCR 读成了「利率是**己%**的时候」——中文数字/百分号是 OCR 的弱项。
  2. 画面里其它文字会混进来,而且这一类比第一类严重得多。

第 2 类值得单独讲,因为它一度让整份评测数字失真。17 分钟的视频里有大段是图表, 图表上的标签也在"字幕带"那个高度上,于是 OCR 把它们一起读了进来:

358s 框数9/8 6个月1年 2年3年5年7月10年30年时间t ← 图上坐标轴,不是字幕
370s 框数1/1 这个利率曲线就是另外一个话题了 ← 这才是字幕

被污染的段落 CER 会飙到 70%~98%,但错的是基准,不是模型。识别它的办法很便宜:

一行字幕在一帧里只该有 12 个文本框;图表那一带会检出 68 个。

于是给每一秒记一个"框数",超过阈值就把这段参照标成不可信,评测时整段丢掉。 实测效果:参照稿 532 行里丢掉 51 行(9.6%),全片 CER 从 34% 回到 21%, 和只取前 120 秒(图表少)单独测出来的 20.6% 对上了——说明这个过滤是对的。

覆盖率:532 条字幕行覆盖 883 秒 / 总共 1025 秒(86%); 算 CER 时进一步只取"参照覆盖率 ≥ 70%"的语音段,最终参与比较的是 47 段 / 90% 的语音时长。

结论:烧录字幕当基准够用,但必须配两个过滤器(重复字符、框数异常), 否则图表一多的视频会把数字带偏一大截。这也是为什么 §4.1 的每个数字后面都要写"段数/覆盖率"。

4.4 错在哪:四类,词表能救两类​

拿 17 分钟金融口播做逐字比对,把"参照 → 模型"的差异统计出来,最扎眼的是这个:

模型比字幕「多出来」的词:
那×37 这个×9 那个×9 就是×4 就是×2 …

排在第一位的那 37 个「那」,一个都不是听错。 说话人确实说了"那",字幕编辑把它删掉了。 把语气词、数字写法、口播插入语、同音字依次抹平,CER 从 20.6% 一路降到 13.1%—— 也就是说严格口径里有 7.5 个百分点(约三分之一)根本不是听错,是"逐字转写"和"编辑过的字幕"之间的差。

顺带说一句:模型和字幕的总量几乎一样(SenseVoice 6.49 字/秒 vs 参照字幕 6.39 字/秒), 差别不在"说多了说少了",而在"哪些字被保留"。

剩下的才是真错,按「能不能救」分成四类:

类型例子(参照 → 模型)能不能救
同音词通货膨胀 → 通过膨胀✅ 词表 + 同音词替换
同音字它 → 他 / 她⚠️ 声学上完全一样,只能靠上下文或后处理
专名Lin → 林✅ 热词
语气误判才 → 刚❌ 语义级错误,无解

第二类里的 它/他/她 值得单独说:这三个字普通话读音完全相同,任何声学模型都区分不了。 字幕里写"它"是因为编辑知道在说利率;模型写"他"没有任何错。评测时干脆把它们归一成同一个符号。

4.5 错误不是均匀分布的​

平均值会骗人。把 47 个语音段各自的 CER 单独算出来(风格化口径):

模型中位均值CER < 5% 的段CER > 30% 的段
SenseVoice11.8%15.1%30%13%
Paraformer12.8%15.8%15%13%
Dolphin17.8%19.8%6%15%

三分之一的段落几乎全对,十分之一的段落错得离谱,中间地带反而少。翻回去看那些"错得离谱"的段, 基本是同一类:语速突然加快、连着报一串数字、或者夹了一段语速更快的画外音。 这决定了使用方法——校对时不用逐句听,按 CER 排序只看最差的那十几个段落就够了。 本工程的 04-eval.py --show N 就是干这个的。

4.6 GPU 还是 CPU:int8 和 fp32 是两个世界​

这一节要纠正我自己的一个错误结论。 本篇第一版写的是"GPU 帮不上忙,甚至更慢"——那个结论是错的, 错在选模型:我下载权重时图体积小,清一色拿了 int8 量化版。同款模型换 fp32 权重,结论完全反过来。

先看最干净的一组证据。把开销拆成"每次调用的固定开销"和"逐秒计算"(SenseVoice,音频长度 3~40 秒拟合):

配置每次调用固定开销逐秒计算相对 CPU
int8 + CPU13 ms55.7 ms/秒1.0×
int8 + GPU161 ms48.2 ms/秒1.2×
fp32 + GPU86 ms3.9 ms/秒14.3×

int8 在 GPU 上,逐秒计算几乎没变快(55.7 → 48.2 ms/秒),却每次调用多付 161 毫秒—— 所以"int8 + cuda"必然比纯 CPU 慢。换成 fp32,逐秒计算直接快 14 倍。

同一个模型、同一段音频、同一份代码,只换权重精度在 120 秒切片上的总账(num_threads=8):

模型int8 CPUint8 GPUfp32 CPUfp32 GPUfp32 GPU 的实时倍率
Dolphin base0.0350.0300.0330.009115×
SenseVoice0.0560.0680.0460.01564.5×
Paraformer0.0460.0550.0350.01473.3×

四者的识别文本完全一致——不是拿精度换速度。

准确率也没有变好:同一份 CER 对照里,SenseVoice 的 int8 与 fp32 逐位相同 (严格 20.6% / 风格化 13.3%),Paraformer 的 fp32 略好(13.9% → 13.6%)、 Dolphin 的 fp32 反而略差(20.7% → 23.8%)——差异都在 9 个语音段的噪声里。 换 fp32 买到的是速度,不是准确率。

为什么:把两个模型文件的算子数出来就清楚了(scripts/08-op-inventory.py,用 ONNX 解析,不是字符串匹配):

SenseVoice节点总数MatMulMatMulIntegerDynamicQuantizeLinear
int8 版(227 MB)10180140279279
fp32 版(887 MB)906441900

140 + 279 = 419:int8 版把 419 个矩阵乘里的 279 个换成了 MatMulInteger + DynamicQuantizeLinear。 这两个算子在 ONNX Runtime 的 CUDA EP 上没有实现,全部回落到 CPU 执行—— 于是"int8 + cuda"实际是"CPU 做大部分计算 + 每次调用多付一遍显存拷贝"。Paraformer(282/267)、 Dolphin(74/57)同构。这也终于解释了那个一直没想通的数字:GPU 利用率只有 5%~7%—— GPU 确实在跑,只是没多少活给它干。

怎么自查(不改代码):

  1. 解码时看 nvidia-smi --query-gpu=utilization.gpu:能上 30% 才是真在用,长期 5% 就是在空转;
  2. 看 /proc/<pid>/maps 里有没有 libcudnn——有只说明 EP 加载了,不代表算子在 GPU 上;
  3. 最直接:换成不带 .int8 的权重再跑一遍,变快就说明之前白开了。

结论:

  • 有 GPU → 下 fp32 权重(文件名不带 .int8)。本机最好的一档是 Dolphin fp32 + 3090: 17 分钟视频 22 秒,115 倍实时;
  • 只有 CPU → int8 更划算:体积小一半、加载快,准确率还一样;
  • int8 + GPU 是最差组合:占着显存,比纯 CPU 还慢。

大模型要单独说:Whisper large-v3 的 int8 版在 GPU 上本来就能跑(RTF 1.480), 换 fp32 再快 4.2 倍(→ 0.353,2.8 倍实时)。也就是说模型越大, 越多的算子本来就落在 GPU 上,int8 的损失越小。但对本机这批中文模型(1001000 MB), 选对权重格式才是决定性的一步——同一张 3090,差 34 倍(逐秒计算差 14 倍)。

5. 让字幕"能用"的四件事​

识别准确率只决定字幕的"下限"。真正决定"能不能看"的是下面四件:

5.1 标点:模型不给你,而且外挂也有讲究​

先看事实——不是所有模型都吐标点。同一段音频(30~50 秒),各模型的原始输出:

模型原始输出里有没有标点
SenseVoice(use_itn 开或关都一样)❌ 一个标点都没有
FireRedASR2-CTC❌
Paraformer❌
Dolphin🔸 只有逗号
FunASR Nano✅ 完整标点
Qwen3-ASR✅ 完整标点

没有标点的模型,接一个外挂标点模型(CT-Transformer,294 MB)就行:

punct = sherpa_onnx.OfflinePunctuation(sherpa_onnx.OfflinePunctuationConfig(
model=sherpa_onnx.OfflinePunctuationModelConfig(
ct_transformer="v-punct/model.onnx", num_threads=4, provider="cpu")))

punct.add_punctuation("利率呢它是一个特别大的话题所以今天呢我就希望先挑主干聊咱先一起捋清楚底层逻辑")
# → 利率呢它是一个特别大的话题。所以今天呢我就希望先挑主干聊,咱先一起捋清楚底层逻辑,

坑在于:已经带标点的输出不能再过一遍。 Dolphin 的逗号会被加成两个:

Dolphin 原始 : ...聊清楚底层逻辑和大家平时比较长混淆的一些概念,顺带着着也能挖不少坑,慢慢天。
再过标点模型之后 : ...聊清楚底层逻辑和大家平时比较长混淆的一些概念,顺带着着也能挖不少坑,慢慢天。
↑ ",," ↑ ","

所以流水线里的判断顺序是:先看这段文本里有没有标点,有就跳过标点模型(--punct auto)。

5.2 断句与时间轴:别把"说话时长"当成"显示时长"​

先看人工字幕长什么样(把参照稿的统计量当作目标):

每行字数每行时长字/秒
参照字幕(人工烧录)中位 10(最大 21)中位 2.00 s中位 6.5,90 分位 10.0

第一版流水线直接拿"这句话说了多久"当"这条字幕显示多久"、行宽也没限制,结果:

每行字数每条时长字/秒
第一版中位 30中位 2.26 s中位 11.3

每行 30 个字、每秒 11 个字,快到读不过来。 三个原因,分开修:

  1. 每行太长 → 限 max_cue_chars=20、max_line_chars=20(一条字幕一行,不折行);
  2. 显示太短 → 加"停留"规则:每条字幕的结束时间延到下一句出现之前 (留 80 毫秒空隙,最多延长 2.5 秒)。口播里"说完这句话"和"下一句开始"之间往往有停顿, 这段时间字幕应该继续挂在屏幕上,而不是提前消失。
  3. 切点落在词中间 → 超长句切分时优先切在标点上;找不到标点又不算太长的,宁可不切。

改完(同一条 120 秒切片):

每行字数每条时长字/秒两行条数
第一版302.26 s11.3—
改后13(最大 20)2.01 s7.406 / 52
参照字幕9(最大 21)1.00 s6.50—

每秒字数已经贴住参照了(7.40 vs 6.50),剩下的差别是参照把句子切得更碎 (同样 120 秒,参照 71 条、本工程 52 条)——那是"人工字幕更愿意频繁换行"的编辑习惯, 不是流水线的问题。

5.2.1 两个中文折行的小规矩​

生成 SRT 时最容易忽略、观众却一眼能看出来的两件事:

  • 避头尾:标点和「的了呢吧啊」这类收尾字不能出现在行首。按固定字数硬切很容易切出 ……很热 / 门的。 这种,得把切点往前挪一两个字符;
  • 均分优先于顶满:折行时如果"顶满行宽再切",第二行常常只剩一两个字 (……离前进发 / 展?)。改成优先在中间附近的标点处切,两行长度接近,读起来舒服得多。

顺带一个反直觉的结论:总字符密度两边几乎一样(模型 6.49 字/秒 vs 参照 6.39 字/秒)。 差别不在"说多说少",而在"每行放多少、每行挂多久"。

5.3 时间戳对切点精度的影响:实测比预期小​

上一节列了"谁给时间戳、谁不给"。那给了到底有多大用?把四个模型各自生成一遍字幕, 拿每一条的起止时间和参照稿(烧录字幕)对齐比:

模型有 token 时间戳起点误差中位起点误差均值终点误差中位≤0.5 秒的比例
Dolphin✅0.43 s0.55 s0.67 s53%
SenseVoice✅0.54 s0.65 s0.50 s46%
FireRedASR2-CTC✅0.54 s0.70 s0.54 s46%
Paraformer❌ 空0.58 s0.67 s0.65 s43%

结论和直觉相反:没有时间戳的 Paraformer(中位 0.58 秒)和有时间戳的 SenseVoice(0.54 秒) 几乎没差别,差距在测量噪声量级。

原因不难想:一条字幕的起点是由"这句从哪一秒开始说"决定的,那是 VAD 段边界和断句点的事; token 时间戳只在段内做微调。而参照稿本身是 1 fps 逐帧 OCR 出来的, 它的时间精度就只有 1 秒——中位误差 0.5 秒,基本就是这个尺子的刻度。

这条结论的实用价值:选模型时不用把"给不给时间戳"当成硬指标。 真正要盯的是段尾那条字幕的停留时间(§5.2 的 --hold),那才是观众能感知到的差别。

5.4 术语:金融这档基本不用担心,编程那档才是重灾区​

先看数据。拿 69 条金融术语的清单去比对 17 分钟的视频,只用参照稿里真正出现过的术语当分母:

模型术语召回漏掉的
SenseVoice19/20 = 95%A股
Paraformer19/20 = 95%A股
Dolphin18/20 = 90%A股、通货膨胀(只命中 1 次,另两个模型 9~11 次)

这个结果有点反直觉:金融视频术语密集,但主流模型对"利率/加息/通胀/美联储/量化宽松"这类 高频词几乎不丢。真正的错是成句的、低频的:

参照:...长期会产生通货膨胀 → 模型:...长期会一直通过膨胀
参照:...它又怎么影响汇率 → 模型:...它又怎么影响规律

换句话说:术语表能救的是"专有名词",救不了"整句听岔"。金融口播里前者的比例很低, 所以词表的收益有限(实测:把上面几条做成替换规则回填,CER 只降了零点几个百分点)。

编程培训正好相反:术语几乎全是英文(Redis / String / Hash / ZSet / MySQL / GitHub Pages), 而中文模型对英文词的解码能力参差不齐——这一档才是热词真正值钱的地方。

实测一期讲 Redis 的教程(12 分钟,中英混说),把每个模型输出里的英文片段全抓出来数一遍:

模型输出里的英文片段“Redis”被听成
SenseVoice89 个,其中 REDIC 37 次、REDIIS 4 次、READIC 3 次、RADIC 2 次只有 2 次是对的
Dolphin56 个,reradi 12 次、reradice 7 次、reradise 5 次、radi 5 次一次都不对
Paraformer0 个整个英文词表被丢掉了,一个拉丁字母都没输出

同一期里的「MySQL」,三个模型分别听成 买 SIPL、m y s s t l l、mccle。

这就是编程视频的真实难度:不是听不懂中文,是英文术语全靠猜。 而且三个模型的错法完全不同——写词表替换时,得按"你用的那个模型听成什么样"来写, 没法通用。这也是热词(在解码阶段做上下文偏置)比事后替换更值钱的地方。

补救手段有三个层次:

手段怎么做适合
热词(hotwords)构造函数里传词表,模型侧做上下文偏置Qwen3-ASR / FunASR Nano 支持
词表替换识别完按 错=>对 回填所有模型,最通用
ITN 规则rule_fsts / rule_fars数字、单位

热词到底管不管用?实测了一组对照(同一段 70 秒编程口播,Qwen3-ASR,只有它支持热词):

词表输出总字数分段字数结果
不给热词37798 / 116 / 119 / 44正常
379 字符(约 100 个词)908 / 12 / 26 / 44解码被搞崩,输出只剩四分之一
7 个词(Redis/MySQL/String/Hash/List/ZSet/缓存)37299 / 116 / 113 / 44正常,但 Redis 的命中数没变

两个结论都不太"爽"但很有用:

  1. 热词表不能长。把整份编程词表塞进去,Qwen3-ASR 的输出直接被截断——它把热词当成 解码提示,提示一长就带偏了生成。要热词就得按视频挑十来个词,不是把词表倒进去。
  2. 在这个例子上热词没带来可测收益:Qwen3-ASR 本来就把 Redis 认对了 (对比前面表里那三个模型全军覆没),热词没有改进空间。

5.4.1 换个模型,还是修词表?——一期 Vue3 教程的完整对照​

上面那组是"热词"。但读者(一位拿真实教程踩过坑的)反馈了一个更要紧的问题: 中英混说的编程内容,SenseVoice 连 Vue3 都认不出。于是拿他那期 Vue3 教程 (12 分钟,764 秒)把五条路都跑了一遍:

方案耗时(764 秒视频)Vue3 渲染成setup丢字
SenseVoice fp32 + 词表20 秒Vue3 ✅✅无
SenseVoice fp32 裸跑20 秒VI三 ❌SET UP ❌无
Qwen3-ASR 0.6B686 秒Vue 三(只差数字格式)✅无
sherpa-onnx Whisper large-v3 fp32283 秒View3 ❌✅严重丢字
faster-whisper large-v3(读者原有方案)—Viu3 / View3 ❌SiteUp ❌无

这张表里有三件事值得单独说:

  1. SenseVoice 的英文是系统性弱,不是个别词。它的输出里英文只有大写、且没有子词表, 遇到英文词就拆成碎片:setup→SET UP、data→DATA、title→TILE、 Vue3→VI三。所以"某个术语认不出"不是运气问题,选模型时就得预期到。
  2. faster-whisper 也没认对 Vue3(Viu3/View3),只是它错得"像个英文单词", 观感上比 VI三 舒服。两条路都得靠词表收尾。
  3. Qwen3-ASR 是唯一不需要词表就把 Vue 认成英文单词的,只把版本号写成了中文数字 (Vue 三)——一条 Vue\s*([一二三])=>Vue\d 就能收。但它慢 34 倍。

所以结论要改:中英混说该做的不是"换个大模型",而是给快模型配一份针对这条视频的词表。 词表是一次性人工投入,做完之后又快又准;换模型是每次都要付的时间税,而且照样要收尾。

顺带一个坑:Python 正则里的 \b 在中英混排的文本上不可靠——中文字符也算 \w, 所以 \bD IV\b 匹配不上 D IV呢。写词表时要换成 (?<![A-Za-z]) / (?![A-Za-z])。

现成的两份词表(同一视频、不同模型各一份,左边一律写"模型听成的样子"):

文件用在
terms/vue3-sensevoice.txtSenseVoice / Dolphin / Paraformer 这类中文快模型
terms/vue3-faster-whisper.txtfaster-whisper / Whisper large-v3 —— 不换工具也能用,填进 Whisper-WebUI 的字幕校正即可

怎么给自己的视频攒词表:跑一遍裸模型,把输出里的英文片段和术语过一眼 (grep -oiE "[A-Za-z]{2,}" 输出.srt | sort | uniq -c | sort -rn), 错得多的写成 错=>对,同一词的多种错法用 re: 正则一条兜住。Vue3 那期一共 16 条规则、 修掉 98 处。

热词的正确期待是"锦上添花",不是"起死回生"。

词表也可以用逗号分隔的热词格式(terms/finance.txt、terms/programming.txt), 那种是喂给 Qwen3-ASR / FunASR Nano 的;替换规则用 错=>对,05-make-srt.py --terms 读。

6. 本机的账:吞吐、显存、成本​

预算就是这台机器:32 核 CPU、125 GB 内存、一张 RTX 3090(24 GB)+ 两张 V100(16 GB)。 没有任何云账单——整套流水线跑完的边际成本是电费。

6.1 一小时视频要跑多久​

按实测 RTF 折算(含 VAD;语音占比按 95% 算,num_threads=8)。 注意配置那一列——同一张机器,最快的和最慢的差 400 倍:

配置17 分钟视频1 小时视频8 小时能转
Dolphin base fp32 + GPU22 秒77 秒373 小时
Paraformer fp32 + GPU27 秒1.6 分钟306 小时
SenseVoice fp32 + GPU29 秒1.7 分钟287 小时
Whisper large-v3 fp32 + GPU6.4 分钟22 分钟21 小时
Dolphin base int8 + CPU48 秒2.8 分钟171 小时
Paraformer int8 + CPU58 秒3.4 分钟142 小时
SenseVoice int8 + CPU68 秒4.0 分钟121 小时
FireRedASR2-CTC int8 + CPU6.9 分钟24 分钟20 小时
Qwen3-ASR 0.6B int8 + CPU10.9 分钟38 分钟13 小时
FunASR Nano int8 + CPU10.7 分钟37.5 分钟13 小时

一句话:第一梯队是"秒级",最慢的那一档是"十几分钟",差四十多倍。 而第一梯队内部,有没有用对 fp32 权重又差了 3~4 倍——Dolphin fp32 + 3090 一天能转 373 小时视频, 接近"一台机器顶一个团队"的量级,代价只是一次 317 MB 的下载。

6.2 内存与显存​

模型模型体积显存占用进程峰值内存
Dolphin base int8104 MB792 MB1059 MB
SenseVoice int8237 MB534 MB1048 MB
Paraformer int8238 MB346 MB992 MB
FireRedASR2-CTC int8776 MB538 MB1419 MB
FunASR Nano int8994 MB2912 MB2275 MB
Qwen3-ASR 0.6B int8983 MB3464 MB2297 MB

两点值得注意:

  1. 显存占用和模型体积不成正比:SenseVoice 的模型只有 237 MB,却占 534 MB 显存, 而 Paraformer 差不多体积只占 346 MB;FunASR Nano 近 1 GB 的模型占了 2.9 GB—— 多出来的是 LLM 解码的 KV cache。
  2. 常驻内存都是 GB 级,但这不是瓶颈。本机 125 GB 内存,按段并行开 8 个进程也才 8~18 GB。 真正的成本是时间,不是内存。全流程的中间产物(16 kHz wav)约 115 MB/小时,也不占地方。

6.3 批量怎么排​

同一台机器上跑批,有几条从实测里得出的经验:

  1. VAD 和识别分开跑。VAD 是 RTF 0.0X 的东西,可以一次把所有视频先切成段并缓存, 识别阶段就能按段并行、断点续跑。
  2. 按段并行比按文件并行划算。一条 2 小时的视频切成几百段,多进程吃掉多核, 比"一条视频一个进程"利用率高得多(后者在长段的尾部会有大量空闲核)。
  3. 显存不是瓶颈,CPU 侧才是——GPU 利用率实测只有 5%~7%,说明瓶颈在特征提取和 逐段调用开销上,多开几个进程反而能把 GPU 喂饱。
  4. 磁盘要留够:16 kHz 单声道 wav 约 115 MB / 小时,多出来的中间产物记得清。

7. 收尾:这套东西该怎么用​

把上面所有结论折成一条可执行的流程:

1. 抽音轨 ffmpeg -ac 1 -ar 16000 ← 必须,双声道直接烂掉
2. VAD 切分 Silero VAD + 自己兜底切到 ≤25 秒 ← max_speech_duration 不是硬上限
3. 识别 中文口播:SenseVoice / Paraformer
**有 GPU 就下 fp32 权重**(快 3~4 倍);只有 CPU 用 int8 就够
中英混说:SenseVoice / Paraformer + **针对这条视频的术语词表**
(比换 Qwen3-ASR 划算:快 34 倍,且术语能修到全对,见 §5.4.1)
4. 标点 模型不吐标点就挂 CT-Transformer;已有标点就跳过,否则会叠成 "聊,,"
5. 领域修正 术语用词表回填;热词只在"按视频挑十来个词"时才有意义
6. 断句成行 按标点切句 → 20 字/行、14 字行宽 → 停留延到下一句之前
7. 写 SRT token timestamps 有时间戳就用,没有就按字数摊(差别不大,见 §5.3)

代码在 source/sherpa-onnx-subtitle-lab/,开箱即用就一条命令:

cd source/sherpa-onnx-subtitle-lab
bash download-models.sh # 一次性:拉模型(最小集约 1.2 GB)
bash video2srt.sh 我的视频.mp4 # 出「我的视频.srt」

video2srt.sh 会自动挑本机最快的配置(SenseVoice fp32 + GPU,没有 fp32 权重就回落 CPU + int8),并把参数透传给底层的 scripts/05-make-srt.py:

# 换模型 / 换设备
bash video2srt.sh 视频.mp4 --model paraformer-fp32 --provider cuda

# 带领域词表(金融/编程各有一份现成的)
bash video2srt.sh 视频.mp4 --terms terms/finance.txt

# 中英混说的编程教程(Qwen3-ASR 是唯一认得出英文术语的)
bash video2srt.sh 视频.mp4 --model qwen3-asr --provider cpu

# 本机实测:9.5 分钟的视频 17 秒出字幕(570 秒音频 / 33 段)

单独用底层的脚本也行(比如你已经有一套自己的 VAD 切分):

python scripts/05-make-srt.py 视频.mp4 -o out.srt --model sensevoice-fp32 --provider cuda

什么时候该上大模型:不是"想更准"的时候——表里已经看得很清楚,大模型在中文口播上 并不多赚几个点。真正该上的场景只有两个:

  1. 中英混说(编程培训)。实测 Qwen3-ASR 是唯一把 Redis 认对的模型, 其余三个全军覆没(REDIC / reradi / 干脆不输出拉丁字母)。这一档值得付 10 倍时间。
  2. 要顺便做字幕改写,把逐字转写压成字幕体(§5.2 那个"11 字/秒 vs 6.5 字/秒"的差距, 只有语言模型能补)。

除此之外,把省下来的时间拿来做第 5 步的词表,收益高得多。

8. 踩过的坑​

按"踩到时有多懵"排序。

8.1 int8 权重配 GPU:占着显存,比纯 CPU 还慢​

这条最坑,因为它看起来一切正常:模型加载成功、显存占上了、provider="cuda" 没报错, /proc/<pid>/maps 里也有 libcudnn——但实际算子在 CPU 上跑。

$ nvidia-smi --query-gpu=utilization.gpu --format=csv,noheader
5 % ← 解码全程都是这个数

原因:官方发布的中文模型大多是 int8 量化版,图里有几百个 MatMulInteger + DynamicQuantizeLinear(SenseVoice 各 279 个),这两个算子 CUDA EP 没实现,回落 CPU。 GPU 只负责拷贝,所以「int8 + cuda」= CPU 计算 + 每次调用多付一遍显存拷贝。

修法:换 fp32 权重。同一张 3090,逐秒计算从 48.2 ms/秒 → 3.9 ms/秒。

完整数据与自查方法见 §4.6。

8.2 Python 正则的 \b 在中英混排上不成立​

写术语替换规则时很容易写成 \bD IV\b=>div,结果在中文旁边一条都匹配不上:

>>> re.search(r"\bD IV\b", "个D IV呢我就不要了")
None # 匹配不上!因为「呢」在 Python 里也算 \w,\b 不成立
>>> re.search(r"(?<![A-Za-z])D\s*IV(?![A-Za-z])", "个D IV呢我就不要了")
<re.Match ...> # 这样才对

原因:Python 3 的 \w 默认匹配 Unicode 词字符,中文汉字也算。所以英文单词紧挨着中文时, 两侧都不存在 \b。规则里的边界一律写成 (?<![A-Za-z]) / (?![A-Za-z])——只看英文字母。

8.3 PyPI 装的是 CPU 版,而 CUDA wheel 的版本号文档没同步​

pip install sherpa-onnx # ← CPU only
pip install sherpa-onnx==1.13.8+cuda12.cudnn9 -f https://k2-fsa.github.io/sherpa/onnx/cuda-cn.html
# ERROR: Could not find a version that satisfies the requirement sherpa-onnx==1.13.8+cuda12.cudnn9

官方 Python 安装文档里给的版本字符串是 1.13.8+cuda12.cudnn9,但索引里实际存在的是:

1.13.8+cuda12.cudnn9.onnxruntime1.28.2
1.13.8+cuda13.cudnn9.onnxruntime1.28.2

照文档抄会直接装不上。 去 cuda-cn.html 里搜一眼真实版本号再用。

8.4 Failed to load shared library 的真实原因藏在 LD_DEBUG 里​

装好 CUDA wheel 后,只要传 provider="cuda" 就报:

RuntimeError: OrtSessionOptionsAppendExecutionProvider_Cuda: Failed to load shared library

这句话没有任何信息量。用 LD_DEBUG=libs 跑一次,才看到真正的原因:

libonnxruntime_providers_cuda.so: error: symbol lookup error:
undefined symbol: cudaLibraryGetKernel, version libcudart.so.12 (fatal)

cudaLibraryGetKernel 是 CUDA 12.9 才有的符号。本机 PATH 里同时存在三个 libcudart.so.12:

来源版本有 cudaLibraryGetKernel
某个 conda 环境里 pip 装的 nvidia-cuda-runtime-cu1212.6❌
系统 /usr/local/cuda-12.9/lib6412.9✅

只要 LD_LIBRARY_PATH 里 12.6 那个排在前面,加载就失败——而报错完全不会提到版本。

最后能跑通的 LD_LIBRARY_PATH 是三个目录缺一不可:

LD_LIBRARY_PATH=<sherpa_onnx 包的 lib 目录>:/usr/local/cuda-12.9/lib64:<cuDNN 9 的 lib 目录>
  • 第一个:libonnxruntime_providers_cuda.so 在这里,ORT 是 dlopen 找它的;
  • 第二个:系统 CUDA 12.9,提供上面那个符号;
  • 第三个:cuDNN 9(本机复用别的环境里已装好的 nvidia-cudnn-cu12)。

8.5 provider="cuda" 永远用 GPU 0​

这点官方文档写得很直白:

This method installs a version of sherpa-onnx supporting both CUDA and CPU. You need to pass the argument provider=cuda to use NVIDIA GPU, which always uses GPU 0. Please use the environment variable CUDA_VISIBLE_DEVICES to control which GPU is mapped to GPU 0.

本机有三张卡(3090 + 两张 V100),没法在代码里指定用哪张,只能用 CUDA_VISIBLE_DEVICES 在进程外挑。 多进程并行时这一点要注意:每个进程都得自己设这个变量。

8.6 已经带标点的输出,再过一遍标点模型会叠标点​

Dolphin 的输出自带逗号,再过一遍 CT-Transformer:

原始 : ...咱先一起捋清楚底层逻辑和大家平时比较长混淆的一些概念,顺带着着也能挖不少坑,慢慢天。
加标点: ...咱先一起捋清楚底层逻辑和大家平时比较长混淆的一些概念,顺带着着也能挖不少坑,慢慢天。
↑ 变成了 ",," 和 ","

做法:先判断这段文本里有没有标点,有就跳过标点模型(05-make-srt.py 的 --punct auto 就是这个逻辑)。

8.7 OCR 参照稿的坑:画面里全是字,怎么只认字幕​

用烧录字幕造参照稿,第一版直接把"画面底部 25%"整块 OCR,结果惨不忍睹:

'KEYPOINTSInaddition,thecentralbankoutlinedaprograminwhichiteventuallywillreduceits...'
'最新贷款市场报价利率(LPR)为4.60%。贷款市场报价利率简介新报3.70%,上'

新闻截图、图表标签、坐标轴文字全被读进来了。两道过滤才干净:

  1. 位置带:字幕永远在同一高度。统计所有文本框的"y 中心 ÷ 帧高"直方图,取主峰所在的窄带 (本例落在 0.85~0.883);
  2. 白字特征:烧录字幕是"白字 + 描边",文本框里亮像素占比落在中间区间; 黑字白底的新闻截图占比过高、纯图形标签占比过低,都被这条挡掉。

这两条阈值是按视频调的,换视频就得重看一遍。 本机踩到的例子:讲 Redis 那期教程 是"白字压浅色背景",字幕框的亮像素占比是 0.660.74,而第一条视频(白字压暗背景)只有 0.10.3—— 上界卡在 0.5 的话,那期视频的 721 帧里只会认出 22 行字幕(等于全废)。 所以脚本里把上下界做成了参数(--max-bright / --min-dark),换素材先扫一眼分布。

改完之后同样的帧输出:

5 8 '你就感觉各国央行最大的动作就是调整利率'
13 14 '但是你有想过吗'
18 19 '降息为什么能刺激经济'

还有一条没解决的:视频里有大段画面是 B-roll(没有字幕),参照稿在这些位置是空的。 所以算 CER 时只统计"参照稿覆盖率 ≥ 70%"的语音段(见 §4.3)。