Files
live-caption/docs/phase2-streaming.md
blumia d234b9658d docs: refresh docs to match the current state, add GUI/CLI/troubleshooting
The README still listed the settings UI as "todo" and its quick start told
users to download models into the working directory; phase 3 still described
the caption window's close button as "quit" and used the wrong binary name.
Bring the docs up to date and fill the gaps: a GUI guide (first run, settings
tabs, tray, download, verify), a full CLI option reference marking the
test-only flags, a troubleshooting page, the model registry's size/sha256
fields plus a licence/size table, the RTF measurement caveat (llama-cli 0.19
vs the in-process 0.56), and a note that the streaming-ONNX backend has its
own code path. phase0/phase1 experiment docs are marked as historical.

文档与现状对齐:README 的 Phase 4 从「待做」改为完成、快速开始不再让人把模型下到当前
目录;phase3 的 ✕ 说明改为「隐藏到托盘」、用法改用 live-caption;新增 GUI/CLI/排错三份
文档;asr-backends 补上注册表的 size/sha256 字段与模型许可/体积表;phase0 补上 RTF 口径
说明(llama-cli 的 0.19 与程序内的 0.56);phase2 补上「流式 ONNX 走另一条分支」的说明。
2026-10-12 17:29:45 +08:00

8.5 KiB
Raw Permalink Blame History

Phase 2:原生流式解码

状态:完成。准确率与离线转写一致,单步计算稳定在 2s 预算内。

本文讲的这套「切块 + 前缀回滚 + 增量 KV 复用」是 llama 后端(Qwen3-ASR) 的路径 (IAsr::AsrKind::PrefixRollback)。Phase 3.5 加的流式 ONNX 后端走的是另一条分支 (NativeStreaming:模型自己有状态,上层只喂音频、取当前整句), 见 asr-backends.md。GUI 会按所选模型自动选这两条路径,不用手动切。

两种流式模式(实测对比)

代码里实现了两条流式路径,用 --stream-mode 切换。它们对应一个无法同时满足的三选二:

目标 incremental(默认) append(追加式)
成本不随说话时长增长 ❌ ✅
CPU 效率 RTF 1.06(无段落上限时) RTF 0.26
1–2s 就出字 ✅ ❌ 需 8s chunk
准确率 ✅(2s 提交 + 8s 编码切片) ✅(仅 chunk≈8s 时)

同一段 62.6s 连续语音:

配置 墙钟 RTF 平均步 最慢步
incremental chunk=2 segment=20 35.5s 0.57 1.09s 1.86s
incremental chunk=2 不限段长 67.7s 1.08 2.10s 4.30s
append chunk=5 18.1s 0.29 1.33s 1.56s
append chunk=8 15.2s 0.24 1.80s 1.91s

append 的每步耗时几乎不随总长变化;incremental 则线性增长。

为什么 append 必须用大 chunk

准确率对 chunk 长度极其敏感,因为编码器(AuT)用的是 8 秒注意力窗口(n_window_infer = 800 帧 @100Hz)。每块只用自己的长度去编码时:

chunk 中文实测结果
2s ❌ 不排除购房者可以在互。(每块都被截断,互联网上下单 全丢)
4s ⚠️ 不排除购房者可以在互联网上下单,不排除。(基本对,边界多一个词)
8s ✅ 与离线一致

所以「便宜/平坦」和「1–2s 出字」对 Qwen3-ASR 是互斥的:要准确就得给编码器 8s 上下文,给了就必然 8s 才出一次字。

追加式为什么便宜

append 把音频切成定长段,每段独立转写后按顺序拼接(对应 sglang 给 Qwen3-ASR 定的 segment 流式模式):

  • 不做任何前缀重算;
  • 旧音频不重编码;
  • KV 每段重置(成本与音频总长无关)。

而 incremental 每步都要把「音频尾部 + 到目前为止的全部文本前缀」按新的音频上下文重算一遍(因果注意力决定了:把新音频插到文本之前,文本之后的 KV 全部作废)。实测拆解显示这一步的代价是 O(N²):

累计音频 音频(编码+写KV) 文本前缀 prefill 生成 合计
2.0s 0.35s 0.03s 0.19s 0.58s
46.0s 1.23s 1.20s 0.38s 2.81s
62.6s 1.46s 1.53s 0.16s 3.15s

「文本前缀 prefill」从 0.03s 涨到 1.53s,占末尾步耗时的一半。

踩坑记录:最初把各段实现成「接在上一轮之后的对话轮」(携带上文),结果模型会抄上一轮的文本 —— 音频内容明显变了,它还是把上一轮的句子原样再吐一遍(实测:第 1 段是「英文尾 2.4s + 中文 5.6s」,却输出了整句英文)。改成每段独立转写后消失。所以这里的「追加」指的是结果按顺序拼接,不是「上下文一直累积」。

追加式的残留问题:段边界重复

各段互不重叠,所以边界处会把上一段的尾巴再写一遍,例如中文实测:

+ 不排除购房者可以在互联网上下单。不排除购房者可以在。
+ 在互联网上下单,不排除购房者可以在互联网上下单。     ← "在互联网上下单," 是上一段的尾巴

要消除它需要重叠窗口 + 去重,那就是 incremental 的做法(也因此贵)。所以:

  • 要字幕质量 → incremental(默认);
  • 要省 CPU / 转写长音频 → append,chunk 取 5–8s。

结论

数据 结果
准确率 流式输出与离线转写逐字一致(中/英各自验证)
单步计算(incremental,segment≤20s) 最慢 1.84s(预算 2.0s,提交粒度 2s)
整体 RTF 约 0.59
实现 纯 C++,libllama + libmtmd,无 Python

算法

官方文档说「streaming 只支持 vLLM 后端」,容易误解为依赖 vLLM 特性。实际上流式只是一套解码策略(qwen_asr/inference/qwen3_asr.py,antirez/qwen-asr 的 MODEL.md 有逐行记录):

  1. 音频按 2s 累积;
  2. 把音频(连同已有的历史)过编码器;
  3. 解码器 prompt 里带上上一轮文本减去最后 unfixed_token_num 个 token 作为 assistant 前缀,从那里继续生成;
  4. 前 unfixed_chunk_num 个 chunk 不带前缀(冷启动);
  5. 输出 = 前缀 + 新生成部分。

参数取代码默认值:chunk_size_sec=2.0、unfixed_token_num=5、unfixed_chunk_num=2。

关键发现:编码必须按 8 秒切片对齐

这是决定准确率的核心。编码器(AuT)按 n_window_infer = 800 帧(8s @100Hz)做窗口注意力,并按 100 帧一组做独立卷积。把音频切得太碎去编码会明显掉准确率:

编码粒度 结果
2s ❌ dinner, or dinner, / boiled bruised potatoes / thick, thick
4s ⚠️ 一处错(browned bruised、thick, black)
8s ✅ 与离线逐字一致

所以实现上按 8s 对齐切片:排满 8s 的切片固化进 KV 永久复用;尾部不满 8s 的部分每步重编码(临时的,下一步丢弃)。

实现:增量 KV 复用

与「每步重编码整个窗口」(早期做法)不同:

  • 新音频只编码一次;
  • 已解码的音频留在 KV cache 里,不再重编码、也不再重新 prefill;
  • 每步只做:llama_memory_seq_rm 回退到音频末尾 → 追加新音频(mtmd_helper_eval_chunk_single,它内部处理 M-RoPE 与分批)→ 解码音频之后的 prompt 文本与前缀 → 贪心生成。

因此单步成本与音频总长无关,只与当前段的长度有关。

对比(62.6s 英文音频,chunk=2s):

做法 墙钟 RTF 最慢单步
每步重编码整个窗口,window=4s 32.9s 0.53 1.39s(但准确率在窗口滑动时抖动)
每步重编码整个窗口,window=16s 89.0s 1.42 3.93s ❌
增量 KV 复用 + 8s 切片 41.2s 0.66 1.89s ✅

单段时长上限

解码器对 KV 的注意力随总长增长,所以段不能无限长:

单段上限 RTF 最慢单步 超预算步数
20s 0.66 1.89s 0/32 ✅
30s 0.63 2.23s 1/32 ⚠️
不限 1.04 3.62s 15/32 ❌

真实字幕场景里句子之间本来就有静音,应该由静音检测分段(Phase 3 接入实时链路时做)。当前用 --stream-segment(默认 30s)作为兜底上界,超过即提交并重置上下文,历史正文会保留并继续拼接。

过程中修掉的问题

  1. 拼接错:Result::text 只是新生成的部分,忘了接回前缀 —— 看起来像"算法不工作",其实只是显示拼接错。
  2. 缝隙缺空格:parseAsrOutput 的 trim() 吃掉了续写开头那个空格(potatoesand)。改用未 trim 的 raw 拼接。
  3. 分段后重复:多段拼接时 parseAsrOutput 用 rfind 定位,只保留最后一个语言头之后的内容,导致前面的正文被丢掉。改为逐段剥离头部再拼接正文。

用法

# 流式转写(默认 incremental:8s 切片 + 前缀回滚 + 2s 提交)
./build/live-caption-cli --model MODEL --mmproj MMPROJ \
    --stream-file audio_16k_mono.wav [--stream-chunk 2] [--stream-segment 30]

# 追加式:成本平坦、省 CPU,但需要大 chunk 才准确
./build/live-caption-cli --model MODEL --mmproj MMPROJ \
    --stream-file audio_16k_mono.wav --stream-mode append --stream-chunk 8

# 对比用:最早的"重编码整个窗口"实现
./build/live-caption-cli ... --stream-mode window --stream-window 4

相关参数:--stream-mode(incremental / append / window)、--stream-chunk(提交粒度)、--stream-segment(单段上限)、--stream-window(仅 window 模式)、--unfixed-tokens、--unfixed-chunks。

遗留

  • 实时链路接入时用静音检测代替固定 --stream-segment 分段。
  • 目前每段结束都会重置语言检测;跨段语言切换的表现待观察。
  • 边界标点偶有差异(分段处 —/, 之类),不影响内容。