Files
live-caption/docs/phase2-streaming.md
T
blumia 6d64987d64 fix(stream): transcribe each append-mode chunk independently
The append mode used to feed chunks as successive conversation turns with
the context carried over. The model then copied the previous turn's text
verbatim instead of transcribing the new audio, so a continuous video
kept showing the same sentence forever. Transcribe each chunk
independently instead, matching sglang's segment streaming mode for
Qwen3-ASR; the fixed mode also stays flat at RTF 0.24 versus 0.57-1.08
for the incremental mode.

修复追加式的一个真 bug:原先把各段接成「携带上文的对话轮」,模型会抄上
一轮的文本 —— 连续视频里字幕永远重复同一句。改为每段独立转写(对应
sglang 给 Qwen3-ASR 的 segment 流式模式),内容随之正确变化;成本仍保持
平坦,RTF 0.24,而增量模式是 0.57~1.08。
2026-10-12 14:32:07 +08:00

164 lines
8.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 2:原生流式解码
状态:**完成**。准确率与离线转写一致,单步计算稳定在 2s 预算内。
## 两种流式模式(实测对比)
代码里实现了两条流式路径,用 `--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` 定位,只保留最后一个语言头之后的内容,导致前面的正文被丢掉。改为逐段剥离头部再拼接正文。
## 用法
```bash
# 流式转写(默认 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` 分段。
- 目前每段结束都会重置语言检测;跨段语言切换的表现待观察。
- 边界标点偶有差异(分段处 `—`/`,` 之类),不影响内容。