From 18dbc9c8d3552062774169753db6b0fcba94f034 Mon Sep 17 00:00:00 2001 From: Wang Zichong Date: Mon, 12 Oct 2026 11:40:59 +0800 Subject: [PATCH] feat: add real-time caption CLI prototype with Qwen3-ASR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 验证文档。 --- .gitignore | 7 + CMakeLists.txt | 84 ++++++ README.md | 56 ++++ docs/phase0-verification.md | 105 ++++++++ docs/phase2-streaming.md | 52 ++++ experiments/phase0/README.md | 72 ++++++ experiments/phase0/bench.sh | 39 +++ experiments/phase0/gguf_head.py | 48 ++++ experiments/phase0/loopback_test.sh | 25 ++ experiments/phase1/README.md | 73 ++++++ experiments/phase1/live_test.sh | 29 +++ src/audio.cpp | 381 ++++++++++++++++++++++++++++ src/audio.h | 40 +++ src/main.cpp | 316 +++++++++++++++++++++++ src/qwen3asr.cpp | 202 +++++++++++++++ src/qwen3asr.h | 54 ++++ src/transcript.cpp | 79 ++++++ src/transcript.h | 20 ++ 18 files changed, 1682 insertions(+) create mode 100644 .gitignore create mode 100644 CMakeLists.txt create mode 100644 README.md create mode 100644 docs/phase0-verification.md create mode 100644 docs/phase2-streaming.md create mode 100644 experiments/phase0/README.md create mode 100755 experiments/phase0/bench.sh create mode 100644 experiments/phase0/gguf_head.py create mode 100755 experiments/phase0/loopback_test.sh create mode 100644 experiments/phase1/README.md create mode 100755 experiments/phase1/live_test.sh create mode 100644 src/audio.cpp create mode 100644 src/audio.h create mode 100644 src/main.cpp create mode 100644 src/qwen3asr.cpp create mode 100644 src/qwen3asr.h create mode 100644 src/transcript.cpp create mode 100644 src/transcript.h diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f5d45e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +# build 产物与本地模型 +/build/ +/models/ + +# 编辑器 +.cache/ +compile_commands.json diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..bd3cc11 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,84 @@ +cmake_minimum_required(VERSION 3.24) +project(live-caption LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) +if(NOT CMAKE_BUILD_TYPE) + set(CMAKE_BUILD_TYPE Release CACHE STRING "" FORCE) +endif() + +# --------------------------------------------------------------------------- +# llama.cpp / libmtmd 的搜索路径 +# +# Debian/Deepin 把 llama 的 CMake config 放在非标准目录 cmake-private/, +# 而 pkg-config 的 llama.pc 又漏了 -lmtmd。所以这里把两个路径都塞进搜索前缀, +# 并单独用 find_library 找 mtmd。 +# --------------------------------------------------------------------------- +foreach(_p + /usr/lib/x86_64-linux-gnu/llama + /usr/lib/x86_64-linux-gnu/llama/cmake-private + /usr/lib/x86_64-linux-gnu/llama/pkgconfig) + if(EXISTS "${_p}") + list(APPEND CMAKE_PREFIX_PATH "${_p}") + set(ENV{PKG_CONFIG_PATH} "${_p}:$ENV{PKG_CONFIG_PATH}") + endif() +endforeach() + +find_package(Qt6 REQUIRED COMPONENTS Core) +find_package(PkgConfig REQUIRED) +find_package(Threads REQUIRED) + +pkg_check_modules(SAMPLERATE REQUIRED IMPORTED_TARGET samplerate) +pkg_check_modules(PULSE REQUIRED IMPORTED_TARGET libpulse) + +# --- llama:优先系统 pkg-config,其次 CMake config,最后 FetchContent 兜底 --- +pkg_check_modules(LLAMA QUIET IMPORTED_TARGET llama) +if(LLAMA_FOUND) + set(LC_LLAMA_TARGET PkgConfig::LLAMA) + message(STATUS "llama: 使用系统 pkg-config (${LLAMA_VERSION})") +else() + message(STATUS "llama: 系统未找到,改用 FetchContent 拉取固定版本") + include(FetchContent) + set(FETCHCONTENT_TRY_FIND_PACKAGE_MODE ALWAYS) # 先 find_package,失败再拉源码 + FetchContent_Declare(llama + GIT_REPOSITORY https://github.com/ggml-org/llama.cpp.git + GIT_TAG b9951 + FIND_PACKAGE_ARGS NAMES llama CONFIG) + set(LLAMA_BUILD_TESTS OFF CACHE BOOL "" FORCE) + set(LLAMA_BUILD_SERVER OFF CACHE BOOL "" FORCE) + set(LLAMA_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) + FetchContent_MakeAvailable(llama) + set(LC_LLAMA_TARGET llama) +endif() + +# --- mtmd:llama.pc 里没有,单独找 --- +find_library(LC_MTMD_LIB NAMES mtmd + HINTS /usr/lib/x86_64-linux-gnu/llama) +if(NOT LC_MTMD_LIB) + message(FATAL_ERROR "找不到 libmtmd(应随 libllama0 一起安装)") +endif() +message(STATUS "mtmd: ${LC_MTMD_LIB}") + +# --------------------------------------------------------------------------- +add_executable(live-caption-cli + src/main.cpp + src/transcript.cpp + src/qwen3asr.cpp + src/audio.cpp +) + +target_include_directories(live-caption-cli PRIVATE src) + +target_link_libraries(live-caption-cli PRIVATE + Qt6::Core + Threads::Threads + PkgConfig::SAMPLERATE + PkgConfig::PULSE + ${LC_LLAMA_TARGET} + ${LC_MTMD_LIB} +) + +if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") + target_compile_options(live-caption-cli PRIVATE -Wall -Wextra -Wno-unused-parameter) +endif() diff --git a/README.md b/README.md new file mode 100644 index 0000000..e9d62de --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# live-caption + +Linux 桌面实时字幕:采集系统正在播放的声音,实时转写成字幕显示(类似 Chrome 实时字幕)。 +技术栈:C++ / Qt6 / CMake / Qwen3-ASR(llama.cpp,纯 C++,无 Python 依赖)。 + +## 当前进度 + +| 阶段 | 内容 | 状态 | +|---|---|---| +| Phase 0 | 可行性验证(采集 / 模型 / CPU 实时性) | ✅ 见 [docs/phase0-verification.md](docs/phase0-verification.md) | +| Phase 1 | 命令行原型(进程内推理 + monitor 采集 + 滑窗 + 稳定前缀) | ✅ 见 [experiments/phase1/README.md](experiments/phase1/README.md) | +| Phase 2 | 原生流式解码(前缀回滚 + 滑窗,去掉 VAD) | 待做,见 [docs/phase2-streaming.md](docs/phase2-streaming.md) | +| Phase 3 | Qt 悬浮字幕窗(折叠/展开、语言标签、置顶) | 待做 | +| Phase 4 | 设置界面(模型切换、语言、字体、位置) | 待做 | +| Phase 5 | Wayland 支持、打包、模型分发 | 待做 | + +## 关键结论(Phase 0 实测) + +- 系统播放音频可从默认输出的 monitor 源采集(PipeWire + pipewire-pulse,走 libpulse,兼容 PulseAudio)。 +- `Qwen3-ASR-0.6B` 在本机(i5-13420H,无独显)纯推理 RTF ≈ **0.19**,实时有充足余量。 +- 模型与推理运行时**必须锁版本**:只有 ggml-org 官方 GGUF(`general.architecture = qwen3vl`)与系统 llama.cpp 兼容;社区的 Q4 量化用的是 `qwen3asr` / `qwen3_asr`,装不进去。 +- 转写原始输出为 `language <正文>`,语言名正好用于 UI 的语言标签。 + +## 快速开始 + +```bash +# 1. 准备模型(只需一次;网络可达 hf-mirror.com) +B=https://hf-mirror.com/ggml-org/Qwen3-ASR-0.6B-GGUF/resolve/main +aria2c -x16 -s16 -k4M -o Qwen3-ASR-0.6B-Q8_0.gguf "$B/Qwen3-ASR-0.6B-Q8_0.gguf" +aria2c -x16 -s16 -k4M -o mmproj-Qwen3-ASR-0.6B-Q8_0.gguf "$B/mmproj-Qwen3-ASR-0.6B-Q8_0.gguf" + +# 2. 构建 +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build -j + +# 3. 单文件转写 +./build/live-caption-cli \ + --model Qwen3-ASR-0.6B-Q8_0.gguf \ + --mmproj mmproj-Qwen3-ASR-0.6B-Q8_0.gguf \ + --file some_16k_mono.wav + +# 4. 实时字幕 +./build/live-caption-cli \ + --model Qwen3-ASR-0.6B-Q8_0.gguf \ + --mmproj mmproj-Qwen3-ASR-0.6B-Q8_0.gguf +``` + +## 目录 + +``` +├─ src/ # 原型源码 +├─ docs/ # 各阶段验证报告 +└─ experiments/ + ├─ phase0/ # 可行性验证脚本(采集/RTF/模型元数据) + └─ phase1/ # 命令行原型说明与实机测试脚本 +``` diff --git a/docs/phase0-verification.md b/docs/phase0-verification.md new file mode 100644 index 0000000..20f7ea9 --- /dev/null +++ b/docs/phase0-verification.md @@ -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不排除购房者可以在互联网上下单。` | +| 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 EnglishHe hoped there would be stew for dinner... +language Chinese不排除购房者可以在互联网上下单。 +``` + +解析规则:`language <正文>`。`` 直接喂给 UI 左上角的语言标签,正文才算字幕。注意 `` 在中文时是 `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 注意: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][text]` 三段,前缀属于第三段)。 +- **吞吐**:前缀越长,每步 prefill 越贵;滑窗是必须的,不是优化项。 diff --git a/experiments/phase0/README.md b/experiments/phase0/README.md new file mode 100644 index 0000000..a0580f4 --- /dev/null +++ b/experiments/phase0/README.md @@ -0,0 +1,72 @@ +# Phase 0 验证实验 + +目标:在动手写 C++/Qt 之前,先证实三个关键假设。 + +1. **系统播放音频可采集**:默认 sink 的 `monitor` 源能被读到。 +2. **Qwen3-ASR 能在这套系统库上跑起来**:发行版自带的 llama.cpp 支持 Qwen3-ASR。 +3. **CPU 实时性够用**:0.6B 量化模型在这台没有独显的机器上能否做到近实时。 + +## 环境(实测) + +| 项 | 值 | +|---|---| +| 系统 | Deepin 25 (crimson), X11 | +| CPU | i5-13420H, 8 核 12 线程 | +| GPU | Intel UHD(无 CUDA) | +| 音频 | PipeWire 1.6.4 + pipewire-pulse,`libpulse` 17.0 | +| llama.cpp | 系统包 `9951+dfsg-1deepin1`(`libllama0` / `libmtmd` / `libggml0`)| + +## 关键结论(细节见 ../../docs/phase0-verification.md) + +- 主模型 GGUF 的 `general.architecture = qwen3vl` → 系统 `libllama` 有 `llama_model_qwen3vl`,**能加载**。 +- mmproj 的 `clip.audio.projector_type = qwen3a` → 系统 `libmtmd` 有 `clip_graph_qwen3a` / `mtmd_audio_preprocessor_qwen3a`,**音频编码器也支持**。 +- 转写中英均正确,`language ...` 前缀里带语言识别。 +- 稳态 RTF ≈ 0.19(纯推理),实时绰绰有余。 + +## 复现步骤 + +### 1. 拿到 llama.cpp 的 CLI(不需要 root) + +系统只装了库,没装工具,且本机没有免密 sudo。直接从 deb 解包: + +```bash +cd /tmp/opencode +apt-get download llama.cpp-tools +mkdir -p llama-tools && dpkg-deb -x llama.cpp-tools_*.deb llama-tools +# 运行时必须指定 LD_LIBRARY_PATH(包含 /usr/lib/x86_64-linux-gnu/llama) +``` + +### 2. 下载模型 + +网络只通了 `hf-mirror.com`,用 aria2c 多连接(单连接约 3MB/s,多连接可到 19MB/s): + +```bash +B=https://hf-mirror.com/ggml-org/Qwen3-ASR-0.6B-GGUF/resolve/main +aria2c -x16 -s16 -k4M -o Qwen3-ASR-0.6B-Q8_0.gguf "$B/Qwen3-ASR-0.6B-Q8_0.gguf" +aria2c -x16 -s16 -k4M -o mmproj-Qwen3-ASR-0.6B-Q8_0.gguf "$B/mmproj-Qwen3-ASR-0.6B-Q8_0.gguf" +``` + +> 换模型时 **主模型 + mmproj 必须成对替换**。 + +### 3. 验证 + +```bash +# 音频采集链路(播放的同时录 monitor) +./loopback_test.sh <某个音频> /tmp/captured_48k.wav +ffmpeg -y -i /tmp/captured_48k.wav -ar 16000 -ac 1 -c:a pcm_s16le /tmp/captured_16k.wav + +# 转写 + 测 RTF +./bench.sh /tmp/captured_16k.wav + +# 换量化模型对比 +MODEL=/tmp/opencode/models/Qwen3-ASR-0.6B-Q4_K_M.gguf ./bench.sh some.wav + +# 只读 GGUF 头部元数据(不下载整个文件) +python3 gguf_head.py +``` + +## 已知坑 + +- **`llama-cli` 会挂住**:不给 `-st` / 不让 stdin EOF 时,生成完会回到交互读取循环。`bench.sh` 已用 `-st --simple-io 正文`,接入时要把语言名与正文分开(语言名正好喂给 UI 的语言标签)。 +- **发行版 CMake config 路径非标准**:`/usr/lib/x86_64-linux-gnu/llama/cmake-private/llama-config.cmake`,`find_package(llama)` 默认找不到;它有配套的 `pkgconfig/llama.pc`,走 pkg-config 更稳。 diff --git a/experiments/phase0/bench.sh b/experiments/phase0/bench.sh new file mode 100755 index 0000000..c9ca595 --- /dev/null +++ b/experiments/phase0/bench.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Phase 0 验证脚本:用 llama.cpp + Qwen3-ASR 转写一段音频并测 RTF +# +# 依赖(本机验证环境:Deepin 25 / Qt 6.8 / x86_64): +# libllama0, libllama-dev, libggml0, libggml-dev, libmtmd (系统自带 9951+dfsg-1deepin1) +# llama.cpp-tools 的二进制(本脚本从 deb 解包到 LLAMA_TOOLS,不需要 root 安装) +# +# 用法: ./bench.sh [prompt] # prompt 默认 <__media__> +# 可选环境变量: THREADS / MODEL / MMPROJ / LLAMA_TOOLS / LLAMA_LIBDIR / TMO +set -uo pipefail + +LLAMA_TOOLS="${LLAMA_TOOLS:-/tmp/opencode/llama-tools}" +LLAMA_LIBDIR="${LLAMA_LIBDIR:-/usr/lib/x86_64-linux-gnu/llama}" +MODEL="${MODEL:-/tmp/opencode/models/Qwen3-ASR-0.6B-Q8_0.gguf}" +MMPROJ="${MMPROJ:-/tmp/opencode/models/mmproj-Qwen3-ASR-0.6B-Q8_0.gguf}" +AUDIO="${1:?usage: bench.sh