Add native streaming ASR: 8s-aligned audio slices are cached in the KV cache, only new audio is encoded, and the previous text is fed back as an assistant prefix with token rollback. Text matches offline results. Streaming keeps each step within the 2s budget for segments up to 20s. 新增原生流式解码:8s 对齐的音频切片存入 KV 复用,只编码新音频,并把 上一轮文本作为 assistant 前缀回喂(带 token 回滚)。转写结果与离线 一致;20s 以内的段落每步计算都在 2s 预算内。
4.3 KiB
4.3 KiB
Phase 2:原生流式解码
状态:完成。准确率与离线转写一致,单步计算稳定在 2s 预算内。
结论
| 指标 | 结果 |
|---|---|
| 准确率 | 流式输出与离线转写逐字一致(中/英各自验证) |
| 单步计算 | 最慢 1.83s(预算 2.0s,提交粒度 2s) |
| 整体 RTF | 约 0.66 |
| 实现 | 纯 C++,libllama + libmtmd,无 Python |
算法
官方文档说「streaming 只支持 vLLM 后端」,容易误解为依赖 vLLM 特性。实际上流式只是一套解码策略(qwen_asr/inference/qwen3_asr.py,antirez/qwen-asr 的 MODEL.md 有逐行记录):
- 音频按 2s 累积;
- 把音频(连同已有的历史)过编码器;
- 解码器 prompt 里带上上一轮文本减去最后
unfixed_token_num个 token 作为 assistant 前缀,从那里继续生成; - 前
unfixed_chunk_num个 chunk 不带前缀(冷启动); - 输出 = 前缀 + 新生成部分。
参数取代码默认值: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)作为兜底上界,超过即提交并重置上下文,历史正文会保留并继续拼接。
过程中修掉的问题
- 拼接错:
Result::text只是新生成的部分,忘了接回前缀 —— 看起来像"算法不工作",其实只是显示拼接错。 - 缝隙缺空格:
parseAsrOutput的trim()吃掉了续写开头那个空格(potatoesand)。改用未 trim 的raw拼接。 - 分段后重复:多段拼接时
parseAsrOutput用rfind定位,只保留最后一个语言头之后的内容,导致前面的正文被丢掉。改为逐段剥离头部再拼接正文。
用法
# 流式转写(增量 + 8s 切片 + 2s 提交)
./build/live-caption-cli --model MODEL --mmproj MMPROJ \
--stream-file audio_16k_mono.wav [--stream-chunk 2] [--stream-segment 30]
# 对比用:旧的"重编码整个窗口"实现
./build/live-caption-cli ... --stream-mode window --stream-window 4
相关参数:--stream-chunk(提交粒度)、--stream-window(仅 window 模式)、--stream-segment(单段上限)、--unfixed-tokens、--unfixed-chunks。
遗留
- 实时链路接入时用静音检测代替固定
--stream-segment分段。 - 目前每段结束都会重置语言检测;跨段语言切换的表现待观察。
- 边界标点偶有差异(分段处
—/,之类),不影响内容。