feat: add real-time caption CLI prototype with Qwen3-ASR

Add a C++/Qt6/CMake CLI prototype that captures the default sink
monitor through libpulse and transcribes it in-process with Qwen3-ASR
via libllama/libmtmd. Add phase 0/1 verification docs.

新增实时字幕命令行原型:libpulse 采集默认输出 monitor,进程内用
libllama/libmtmd 跑 Qwen3-ASR 推理。附阶段 0/1 验证文档。
This commit is contained in:
blumia committed 2026-10-12 11:40:59 +08:00
commit 18dbc9c8d3
18 files changed
+1682

No files matched your search

+105
View File
@@ -0,0 +1,105 @@
# Phase 0 验证报告
结论:**方案成立**。系统播放音频可采集、Qwen3-ASR 可在这套系统库上运行、CPU 实时性有充足余量。可以进入实现阶段。
验证日期:2026-10-12 设备:i5-13420H / 15GB / Intel UHD(无独显)/ Deepin 25 / X11
---
## 1. 验证结论一览
| # | 假设 | 结论 | 证据 |
|---|---|---|---|
| 1 | 系统播放的声音可采集 | ✅ | 默认 sink 的 `.monitor` 源录到真实音频(mean −29.6dB / max −6.7dB)|
| 2 | Qwen3-ASR 能在本机跑 | ✅ | 主模型 arch `qwen3vl` ← `libllama` 的 `llama_model_qwen3vl`;mmproj `clip`+`qwen3a` ← `libmtmd` 的 `clip_graph_qwen3a` |
| 3 | 端到端链路可用 | ✅ | 播放 → monitor 采集 → 16k 单声道 → 转写,文本正确 |
| 4 | 中文可用 + 语言自动检测 | ✅ | `language Chinese<asr_text>不排除购房者可以在互联网上下单。` |
| 5 | CPU 实时性够用 | ✅ | 纯推理 RTF ≈ **0.19**;2s 音频约 0.3s |
| 6 | 纯 C++、无 Python | ✅ | 全程 llama.cpp C/C++ 库 |
## 2. 关键实测数据
环境:`-t 8`(i5-13420H 8 核),模型 `Qwen3-ASR-0.6B-Q8_0` + `mmproj-...-Q8_0`。
| 项目 | 结果 |
|---|---|
| 模型加载(一次性) | ≈ 4.6 s |
| 纯推理 RTF | ≈ 0.19(约 3.8× 实时)|
| 62.6s 音频总耗时 | 16.48 s(含加载)|
| 单块 2s 音频 | 墙钟 ≈ 4.9 s − 加载 4.6 s ⇒ 计算 ≈ **0.3 s** |
| 英文准确率 | 与 LibriSpeech 标准答案逐词一致 |
| 中文准确率 | 与 AISHELL-1 标准答案一致(仅多了标点)|
> 流式含义:模型常驻内存时,处理 2 秒窗口约 0.3 s,滑动窗口 + 提交策略下端到端延迟可做到 **~1 s 级**,与 Chrome 实时字幕同量级。
### 2.1 0.6B vs 1.7B 实测对比
均为 Q8_0 主模型 + Q8_0 mmproj,`-t 8`。
| 指标 | 0.6B | 1.7B |
|---|---|---|
| 加载耗时 | 4.6 s | 6.2 s |
| 纯推理 RTF | **0.19**(≈3.8× 实时)| **0.385**(≈2.6× 实时)|
| 62.6s 音频墙钟 | 16.5 s | 30.2 s |
| 单块 2s 计算量 | ≈0.3 s | ≈0.77 s |
| 内存(主+mmproj) | ≈0.97 GB | ≈2.42 GB |
| 中文 | ✅ 正确 | ✅ 正确 |
| 英文 | `flour-fatted sauce` ✅ | `flour-fattened sauce` ❌ |
**结论**:
- 两个尺寸在本机都**能实时**,1.7B 余量更小(2.6× 实时)。
- **1.7B 在这段英文上反而不如 0.6B**(`flour-fatted` → `flour-fattened`)。单样本不能定论,但足以说明默认应选 0.6B,1.7B 作为可选并加性能/内存提示。
- 若某台机器连 0.6B 都吃力,可自行用 `llama-quantize` 从官方 Q8_0/bf16 转 Q4_K_M(注意:**不能直接用社区量化**,架构标记不兼容)。
## 3. 转写输出格式(接入时必须清洗)
原始输出形如:
```
language English<asr_text>He hoped there would be stew for dinner...
language Chinese<asr_text>不排除购房者可以在互联网上下单。
```
解析规则:`language <LANG><asr_text><正文>`。`<LANG>` 直接喂给 UI 左上角的语言标签,正文才算字幕。注意 `<LANG>` 在中文时是 `Chinese`,需做 `中文` 一类的显示映射。
## 4. 兼容性风险(重要)
Qwen3-ASR 的 GGUF **架构标记正在变动**,各家不一致:
| 来源 | `general.architecture` | 发行版 b9951 |
|---|---|---|
| **ggml-org/Qwen3-ASR-0.6B / 1.7B**(官方转换) | `qwen3vl` | ✅ 可加载 |
| cstr/qwen3-asr-0.6b-q4_k | `qwen3asr` | ❌ `unknown model architecture` |
| handy-computer/Qwen3-ASR-0.6B-* | `qwen3_asr` | ❌ `unknown model architecture` |
**结论:模型与推理运行时必须成对锁定版本。**只使用 ggml-org 官方 GGUF,或自行用同版本的 `llama-quantize` 从官方 GGUF 转换。
Q4_K_M(590MB)这类小体积量化目前只有社区版,不兼容;`llama-quantize` 可用(在 `llama.cpp-tools` 里),需要时自行从官方 Q8_0/bf16 转换。当前 Q8_0 的 RTF 0.19 已足够,**Q4 非必需**。
## 5. 环境与工具链实测
- **音频**:PipeWire 1.6.4 + pipewire-pulse;`libpulse` 17.0 开发包齐全。用 libpulse 而非 PipeWire 原生 API → 同时兼容 PipeWire / PulseAudio。
- **llama.cpp**:系统包 `9951+dfsg-1deepin1`(`libllama0` 含 `libllama` / `libmtmd` / `libllama-common`;`libllama-dev` 含 `llama.h` / `mtmd.h` / `mtmd-helper.h`)。
- **无免密 sudo**:不能装包。但 `llama.cpp-tools` 的 deb 可直接 `dpkg-deb -x` 解包使用,无需 root。开发期靠这个。
- **CMake 集成注意**:
- `llama-config.cmake` 在非标准路径 `/usr/lib/x86_64-linux-gnu/llama/cmake-private/`(`find_package(llama)` 默认找不到)。
- 同目录有 `pkgconfig/llama.pc`,**但只列了 `-lggml -lggml-base -lllama`,缺 `-lmtmd`**,需要手动补。
- `ggml` 的 cmake config 和 `ggml.pc` 在标准位置,正常可找到。
- **网络**:只有 `hf-mirror.com` 可达(huggingface.co / github.com 不通)。下载用 **aria2c 多连接**:19 MiB/s vs 单连接 3 MiB/s。
## 6. 踩过的坑
1. **`llama-cli` 会挂住**:不给 `-st` 且不让 stdin EOF 时,生成完会回到交互读取循环。解法:`-st --simple-io </dev/null` + 外层 `timeout`。
2. **Debian 的 `llama-cli` 内部其实跑的是 server**(错误日志里出现 `srv load_model`、`llama_server exited`)。这提示:Phase 1 若不想自己驱动 libllama 的解码循环,用 `llama-server` 子进程 + 本地接口也是合理选项。
3. 下载大文件时**不要开两个 curl 并行**(互相拖累到 ~0.3MB/s)。
## 7. 对后续设计的影响
- 采集层:`libpulse` + 默认 sink 的 monitor,需订阅设备变更事件跟随默认输出。
- 推理层:`AsrBackend` 接口;先用 ggml-org 的 `qwen3vl` 版 GGUF;模型+mmproj 成对管理;输出解析要处理 `language/asr_text` 前缀。
- 延迟策略:模型常驻,2s 滑窗,每 ~0.5–1s 提交一次;目标是端到端 ~1s。
- 打包:模型体积(Q8_0 768MB + mmproj 205MB)需要下载或按需获取策略;不能默认内置进包。
## 8. 复现
见 `../../experiments/phase0/README.md`。
+52
View File
@@ -0,0 +1,52 @@
# Phase 2 计划:原生流式解码
目标:把「窗口重转写 + 事后取最长公共前缀」换成模型官方的流式解码 —— **前缀回滚(prefix rollback)**,消除 Phase 1 里「窗口在词中间被切断时模型自己编结尾」的问题。
实现方式:**在 libllama / libmtmd 上自己实现**(只保留一个推理运行时,复用已有的 CMake、模型管理、采集与解析代码)。
参考实现:`antirez/qwen-asr`(纯 C,已实现 `--stream` 的前缀回滚与滑窗),其 MODEL.md 逐行记录了官方 Python 实现 `qwen_asr/inference/qwen3_asr.py` 的流式算法,用作对齐参数的对照。
## 为什么不需要 vLLM
官方文档写「streaming 只支持 vLLM 后端」,容易误解为依赖 vLLM 特性。实际上流式只是一套**解码策略**:
1. 音频按 2s 累积;
2. 每次触发时把**已累积的全部音频重新过一遍编码器**(O(n²),被 2s 节奏限制住);
3. 解码器 prompt 里带上**上一轮转写文本(去掉最后 5 个 token)**作为 assistant 前缀,从那里继续生成;
4. 前 `unfixed_chunk_num` 个 chunk 不带前缀(冷启动);
5. 输出 = 前缀 + 新生成部分。
## 参数(官方代码默认值)
| 参数 | 值 | 说明 |
|---|---|---|
| `chunk_size_sec` | 2.0 s | 音频累积粒度 |
| `unfixed_token_num` | 5 | 前缀回滚:丢掉最后 5 个 token 再续写 |
| `unfixed_chunk_num` | 2 | 冷启动不开前缀;论文写 4,取保守值 |
| 滑窗上限 | ~16 s | 丢弃「文本已提交」对应的音频,把成本从 O(n²) 压到 O(n·窗口) |
## 与 Phase 1 原型的差距
| 已有 | 缺 |
|---|---|
| 2s 节奏的窗口重转写 | 把「上一轮文本 − 最后 5 token」拼进 assistant 前缀 |
| mtmd tokenize + eval 的完整路径 | 按 **token** 而非字符回滚(需跟踪生成的 token 序列) |
| 语言识别、输出解析、稳定前缀显示 | 滑窗:丢弃已提交文本对应的音频 |
| monitor 采集 + 重采样 | 用「已提交 token 数」定义稳定边界,替换事后取交集 |
> 注意:Phase 1 的「字符最长公共前缀」是错的替代品,官方的定义是「丢最后 5 个 token」。
## 顺带简化:去掉 VAD
官方明确不使用 VAD(模型自己处理静音;只有非流式长音频才用「在切点 ±5s 内找 100ms 最低能量点」切段)。流式模式下可以整个删掉能量 VAD,少一个需要调参的组件。
## Spike(先验证,再改产品代码)
1. **效果**:同一段音频,对比「离线转写」与「带前缀回喂的流式」输出,确认流式结果连贯、不再编结尾。
2. **延迟**:实测 2s 节奏下的单步耗时,重点看窗口涨到 16s 时是否仍跟得上(每步预算 < 2s)。
3. 以上两条通过后,才把它落进 `Qwen3Asr`,并补一个流式模式的实机测试脚本。
## 风险
- **成本**:每步重编码累积音频,窗口越大越慢;若 16s 窗口跟不上,需改为「复用 KV cache + 只编码新 chunk」,并用 `llama_memory_seq_rm` 做回滚。
- **前缀注入方式**:需要确认把文本前缀拼在 chat template 的 assistant 头之后,mtmd 能否正确切分(预期:`[text]<media>[text]` 三段,前缀属于第三段)。
- **吞吐**:前缀越长,每步 prefill 越贵;滑窗是必须的,不是优化项。