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:
commit
18dbc9c8d3
18 files changed
+1682
No files matched your search
@@ -0,0 +1,7 @@
|
|||||||
|
# build 产物与本地模型
|
||||||
|
/build/
|
||||||
|
/models/
|
||||||
|
|
||||||
|
# 编辑器
|
||||||
|
.cache/
|
||||||
|
compile_commands.json
|
||||||
@@ -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()
|
||||||
@@ -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 <LANG><asr_text><正文>`,语言名正好用于 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/ # 命令行原型说明与实机测试脚本
|
||||||
|
```
|
||||||
@@ -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`。
|
||||||
@@ -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 越贵;滑窗是必须的,不是优化项。
|
||||||
@@ -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 <X><asr_text>...` 前缀里带语言识别。
|
||||||
|
- 稳态 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 </dev/null` + 外层 `timeout` 处理。
|
||||||
|
- **输出需清洗**:结果形如 `language English<asr_text>正文`,接入时要把语言名与正文分开(语言名正好喂给 UI 的语言标签)。
|
||||||
|
- **发行版 CMake config 路径非标准**:`/usr/lib/x86_64-linux-gnu/llama/cmake-private/llama-config.cmake`,`find_package(llama)` 默认找不到;它有配套的 `pkgconfig/llama.pc`,走 pkg-config 更稳。
|
||||||
Executable
+39
@@ -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 <audio.wav> [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 <audio> [prompt]}"
|
||||||
|
PROMPT="${2:-<__media__>}"
|
||||||
|
THREADS="${THREADS:-8}"
|
||||||
|
CLI="$LLAMA_TOOLS/usr/bin/llama-cli"
|
||||||
|
|
||||||
|
export LD_LIBRARY_PATH="$LLAMA_LIBDIR:$LLAMA_TOOLS/usr/lib/x86_64-linux-gnu/llama"
|
||||||
|
|
||||||
|
dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$AUDIO")
|
||||||
|
printf '音频: %s (%ss) 模型: %s 线程: %s\n' "$AUDIO" "$dur" "$(basename "$MODEL")" "$THREADS"
|
||||||
|
|
||||||
|
# 关键: stdin 必须接 /dev/null 并在外层套 timeout
|
||||||
|
# 否则 llama-cli 生成完会回到 stdin 读取循环而挂住
|
||||||
|
t0=$(date +%s.%N)
|
||||||
|
timeout "${TMO:-300}" "$CLI" -m "$MODEL" --mmproj "$MMPROJ" --audio "$AUDIO" \
|
||||||
|
-p "$PROMPT" -n 256 --temp 0 -t "$THREADS" -st --simple-io \
|
||||||
|
</dev/null >/tmp/lc_out.log 2>/tmp/lc_err.log
|
||||||
|
rc=$?
|
||||||
|
t1=$(date +%s.%N)
|
||||||
|
|
||||||
|
[ "$rc" = 124 ] && echo "(警告: 被 timeout 杀掉)"
|
||||||
|
echo "---- 转写结果 ----"
|
||||||
|
cat /tmp/lc_out.log
|
||||||
|
echo "---- 计时 ----"
|
||||||
|
python3 -c "print(f'墙钟总耗时: {$t1-$t0:.2f}s 音频: $dur s → RTF(含加载) ≈ {($t1-$t0)/$dur:.3f}')"
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""只抓 GGUF 头部(HTTP Range)读元数据,不下载整个文件。
|
||||||
|
用法: gguf_head.py <url> [<url> ...]"""
|
||||||
|
import sys, struct, urllib.request
|
||||||
|
|
||||||
|
TYPES = {0:1,1:1,2:2,3:2,4:4,5:4,6:4,7:1,8:None,9:None,10:8,11:8,12:8}
|
||||||
|
NAMES = {0:"u8",1:"i8",2:"u16",3:"i16",4:"u32",5:"i32",6:"f32",7:"bool",8:"str",9:"arr",10:"u64",11:"i64",12:"f64"}
|
||||||
|
|
||||||
|
def readstr(b,o):
|
||||||
|
n,=struct.unpack_from("<Q",b,o); o+=8
|
||||||
|
return b[o:o+n].decode("utf-8","replace"), o+n
|
||||||
|
|
||||||
|
def peek(url, limit=4_000_000):
|
||||||
|
req=urllib.request.Request(url, headers={"Range":f"bytes=0-{limit}"})
|
||||||
|
b=urllib.request.urlopen(req, timeout=60).read()
|
||||||
|
assert b[:4]==b"GGUF", "not a GGUF"
|
||||||
|
n_kv,=struct.unpack_from("<Q",b,16); o=24
|
||||||
|
out={}
|
||||||
|
for _ in range(n_kv):
|
||||||
|
k,o=readstr(b,o)
|
||||||
|
t,=struct.unpack_from("<I",b,o); o+=4
|
||||||
|
if t==8:
|
||||||
|
v,o=readstr(b,o)
|
||||||
|
elif t==9:
|
||||||
|
et,=struct.unpack_from("<I",b,o); o+=4
|
||||||
|
n,=struct.unpack_from("<Q",b,o); o+=8
|
||||||
|
if et==8:
|
||||||
|
v=[readstr(b,o)[0] for _ in range(min(n,4))]
|
||||||
|
for _ in range(n): _,o=readstr(b,o)
|
||||||
|
else:
|
||||||
|
o+=n*TYPES[et]; v=f"<{n}x{NAMES[et]}>"
|
||||||
|
else:
|
||||||
|
sz=TYPES[t]; v=int.from_bytes(b[o:o+sz],"little",signed=t in (1,3,5,11)); o+=sz
|
||||||
|
out[k]=v
|
||||||
|
if k=="general.architecture":
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
for url in sys.argv[1:]:
|
||||||
|
print(f"=== {url.split('/resolve/')[0].split('/')[-2] + '/' + url.split('/')[-1]} ===")
|
||||||
|
try:
|
||||||
|
m=peek(url)
|
||||||
|
print(" architecture =", m.get("general.architecture"), "| name =", m.get("general.name"))
|
||||||
|
for k,v in m.items():
|
||||||
|
if "clip" in k or "projector" in k or "audio" in k:
|
||||||
|
print(f" {k} = {str(v)[:70]}")
|
||||||
|
except Exception as e:
|
||||||
|
print(" ERR", e)
|
||||||
Executable
+25
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Phase 0: 验证"系统播放音频 → monitor 源采集"链路
|
||||||
|
# 用法: ./loopback_test.sh <要播放的音频文件> <输出wav>
|
||||||
|
set -euo pipefail
|
||||||
|
SRC="${1:?usage: loopback_test.sh <file> <out.wav>}"
|
||||||
|
OUT="${2:?usage: loopback_test.sh <file> <out.wav>}"
|
||||||
|
|
||||||
|
echo "默认 sink : $(pactl get-default-sink)"
|
||||||
|
echo "默认 sink 的 monitor: $(pactl get-default-sink).monitor"
|
||||||
|
|
||||||
|
# 1) 后台开始录制默认输出的 monitor(48k 立体声 float32,与设备速率一致)
|
||||||
|
parec -d @DEFAULT_MONITOR@ --format=float32le --rate=48000 --channels=2 \
|
||||||
|
--file-format=wav "$OUT" &
|
||||||
|
REC=$!
|
||||||
|
sleep 0.6 # 等录制真正开始
|
||||||
|
|
||||||
|
# 2) 播放测试音频到默认输出
|
||||||
|
paplay --device="$(pactl get-default-sink)" "$SRC"
|
||||||
|
|
||||||
|
sleep 0.6
|
||||||
|
kill "$REC" 2>/dev/null || true
|
||||||
|
wait "$REC" 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "采集完成: $OUT"
|
||||||
|
ffprobe -v error -show_entries format=duration -of csv=p=0 "$OUT" | awk '{printf "采集时长: %.2f s\n",$1}'
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# Phase 1:命令行原型
|
||||||
|
|
||||||
|
目标:把 `docs/phase0-verification.md` 验证过的链路写成一个真正的 C++/Qt/CMake 项目骨架,并在命令行跑通「采集系统输出 → 实时转写 → 打印」。
|
||||||
|
UI 悬浮窗留到 Phase 2。
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├─ main.cpp # CLI 入口、VAD、滑窗策略、稳定前缀提交
|
||||||
|
├─ qwen3asr.{h,cpp}# 进程内 Qwen3-ASR 封装(libllama + libmtmd)
|
||||||
|
├─ audio.{h,cpp} # WAV 读取 + 默认输出 monitor 采集 + libsamplerate 重采样
|
||||||
|
└─ transcript.{h,cpp} # 解析 `language X<asr_text>正文`,语言显示名映射
|
||||||
|
```
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
|
||||||
|
cmake --build build -j
|
||||||
|
```
|
||||||
|
|
||||||
|
依赖:Qt6 Core、libpulse、libsamplerate、系统 llama.cpp(`libllama0`/`libllama-dev`,见 Phase 0 报告里的链接注意事项)。
|
||||||
|
|
||||||
|
## 运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
M=/tmp/opencode/models/Qwen3-ASR-0.6B-Q8_0.gguf
|
||||||
|
MM=/tmp/opencode/models/mmproj-Qwen3-ASR-0.6B-Q8_0.gguf
|
||||||
|
|
||||||
|
# 转写单个文件(不需要采集)
|
||||||
|
./build/live-caption-cli --model $M --mmproj $MM --file test_16k.wav
|
||||||
|
|
||||||
|
# 实时字幕(采集默认输出设备的 monitor)
|
||||||
|
./build/live-caption-cli --model $M --mmproj $MM --threads 8
|
||||||
|
|
||||||
|
# 需要看 llama.cpp 日志时加 --verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
实时输出约定:`~` 是预览行(其中稳定前缀为正常色、不稳定尾部为暗色),`=` 是提交行。
|
||||||
|
|
||||||
|
## 实测结果
|
||||||
|
|
||||||
|
文件模式(16k 单声道):
|
||||||
|
|
||||||
|
| 音频 | 结果 |
|
||||||
|
|---|---|
|
||||||
|
| LibriSpeech 英文 10.4s | `He hoped there would be stew for dinner—turnips and carrots and bruised potatoes and fat mutton pieces—to be ladled out in thick peppered flour-fatted sauce.` ✅ |
|
||||||
|
| AISHELL 中文 3.35s | `不排除购房者可以在互联网上下单。` ✅ |
|
||||||
|
|
||||||
|
实时模式(播放上述音频到默认输出,经 monitor 采集转写):
|
||||||
|
|
||||||
|
- 英文/中文均实时正确,语言标签正确显示「英语」「中文」。
|
||||||
|
- 每次推理耗时(0.6B Q8_0,8 线程):
|
||||||
|
|
||||||
|
| 窗口时长 | 计算耗时 | RTF |
|
||||||
|
|---|---|---|
|
||||||
|
| 0.8 s | ≈0.30 s | 0.42 |
|
||||||
|
| 2.5 s | ≈0.55 s | 0.22 |
|
||||||
|
| 5.9 s | ≈1.06 s | 0.18 |
|
||||||
|
| 20 s | ≈3.59 s | 0.18 |
|
||||||
|
|
||||||
|
短窗口 RTF 差是因为有固定开销(prompt 处理 + 音频编码 + 分词),窗口越长越划算。
|
||||||
|
|
||||||
|
## 过程中发现并修复的两个 bug
|
||||||
|
|
||||||
|
1. **丢样本**:工作线程每轮只处理 `floor(可用样本/320)` 帧并丢弃余数,每次唤醒丢约 160 个样本(~20% 音频),造成假静音和句子被截断。修复:跨批次保留残帧。
|
||||||
|
2. **VAD 噪声底跟错**:原先用均值跟踪噪声底,遇到本身就很轻的音频(AISHELL 峰值 RMS 仅 0.028、中位 0.006)会一路抬升阈值,把 77% 的话音判成静音。修复:改为「快降慢升」的最小值跟踪,阈值 = `max(floor*3, 0.0015)`。
|
||||||
|
|
||||||
|
## 已知问题(留给后续)
|
||||||
|
|
||||||
|
- **强制切窗会诱发模型编结尾**:窗口在词中间被切断(到达 max window 或静音点)时,模型会给尾部补一个看似合理的结尾(例如 `...mutton pieces too.`)。已在显示层用「稳定前缀提交」缓解(只把跨多次转写一致的前缀当已定文本),但**根治要靠模型原生的流式解码**:按官方论文的做法,用动态注意力窗口 + 只固定到倒数第 4 个 chunk,尾部 token 不提交。
|
||||||
|
- 目前每段独立转写、每次清空 KV cache,因此段与段之间没有上下文。原生流式(复用 KV cache、滑动删除已提交部分)能同时降低延迟和提升连贯性,是 Phase 2 的核心工作(见 [../../docs/phase2-streaming.md](../../docs/phase2-streaming.md))。
|
||||||
Executable
+29
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Phase 1 实机验证:实时采集默认输出 + 转写
|
||||||
|
set -uo pipefail
|
||||||
|
BIN=/home/blumia/Sources/live-caption/build/live-caption-cli
|
||||||
|
M=${M:-/tmp/opencode/models/Qwen3-ASR-0.6B-Q8_0.gguf}
|
||||||
|
MM=${MM:-/tmp/opencode/models/mmproj-Qwen3-ASR-0.6B-Q8_0.gguf}
|
||||||
|
PLAY=${PLAY:-/tmp/opencode/samples/play_48k.wav}
|
||||||
|
LOG=/tmp/opencode/live.log
|
||||||
|
ERR=/tmp/opencode/live.err
|
||||||
|
TIMEOUT=${TIMEOUT:-60}
|
||||||
|
|
||||||
|
"$BIN" --model "$M" --mmproj "$MM" --threads 8 > "$LOG" 2> "$ERR" &
|
||||||
|
PID=$!
|
||||||
|
echo "live-caption-cli pid=$PID, 等待模型加载..."
|
||||||
|
sleep 4
|
||||||
|
|
||||||
|
echo "开始播放: $PLAY"
|
||||||
|
paplay --device="$(pactl get-default-sink)" "$PLAY"
|
||||||
|
|
||||||
|
echo "播放结束,等 4s 让最后一段收尾..."
|
||||||
|
sleep 4
|
||||||
|
kill "$PID" 2>/dev/null
|
||||||
|
wait "$PID" 2>/dev/null
|
||||||
|
|
||||||
|
echo "================ 实时输出 ================"
|
||||||
|
tr '\r' '\n' < "$LOG" | grep -vE '^\s*$' | sed 's/\x1b\[[0-9;]*m//g'
|
||||||
|
echo
|
||||||
|
echo "================ 每次推理耗时 ================"
|
||||||
|
grep '\[infer\]' "$ERR" || true
|
||||||
+381
@@ -0,0 +1,381 @@
|
|||||||
|
#include "audio.h"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstring>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <fstream>
|
||||||
|
|
||||||
|
#include <samplerate.h>
|
||||||
|
#include <pulse/pulseaudio.h>
|
||||||
|
|
||||||
|
namespace lc {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// 单声道流式重采样器(libsamplerate)
|
||||||
|
class Resampler {
|
||||||
|
public:
|
||||||
|
~Resampler() { if (st_) src_delete(st_); }
|
||||||
|
|
||||||
|
bool init(int fromRate, int toRate)
|
||||||
|
{
|
||||||
|
int err = 0;
|
||||||
|
st_ = src_new(SRC_SINC_FASTEST, 1, &err);
|
||||||
|
ratio_ = (double) toRate / (double) fromRate;
|
||||||
|
return st_ != nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 输入 frames 个单声道样本,追加到 out
|
||||||
|
bool process(const float * in, size_t frames, std::vector<float> & out)
|
||||||
|
{
|
||||||
|
if (!st_) return false;
|
||||||
|
if (frames == 0) return true;
|
||||||
|
|
||||||
|
// 输出上限:比例 + 余量
|
||||||
|
const size_t maxOut = (size_t) (frames * ratio_) + 64;
|
||||||
|
const size_t base = out.size();
|
||||||
|
out.resize(base + maxOut);
|
||||||
|
|
||||||
|
SRC_DATA d{};
|
||||||
|
d.data_in = in;
|
||||||
|
d.input_frames = (long) frames;
|
||||||
|
d.data_out = out.data() + base;
|
||||||
|
d.output_frames = (long) maxOut;
|
||||||
|
d.src_ratio = ratio_;
|
||||||
|
d.end_of_input = 0;
|
||||||
|
|
||||||
|
if (src_process(st_, &d) != 0) return false;
|
||||||
|
out.resize(base + (size_t) d.output_frames_gen);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
SRC_STATE * st_ = nullptr;
|
||||||
|
double ratio_ = 1.0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 读小端整数
|
||||||
|
uint32_t rd32(const uint8_t * p) { return p[0] | (p[1] << 8) | (p[2] << 16) | ((uint32_t) p[3] << 24); }
|
||||||
|
uint16_t rd16(const uint8_t * p) { return (uint16_t) (p[0] | (p[1] << 8)); }
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// WAV 读取
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
bool readWavMono(const std::string & path, int targetRate,
|
||||||
|
std::vector<float> & out, std::string * error)
|
||||||
|
{
|
||||||
|
const auto fail = [error](const std::string & m) {
|
||||||
|
if (error) *error = m;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::ifstream f(path, std::ios::binary);
|
||||||
|
if (!f) return fail("无法打开文件: " + path);
|
||||||
|
|
||||||
|
std::vector<uint8_t> buf((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
|
||||||
|
if (buf.size() < 44 || std::memcmp(buf.data(), "RIFF", 4) != 0 || std::memcmp(buf.data() + 8, "WAVE", 4) != 0) {
|
||||||
|
return fail("不是合法的 WAV 文件: " + path);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16_t audioFormat = 0, channels = 0, bitsPerSample = 0;
|
||||||
|
uint32_t sampleRate = 0;
|
||||||
|
const uint8_t * dataPtr = nullptr;
|
||||||
|
size_t dataLen = 0;
|
||||||
|
|
||||||
|
size_t pos = 12;
|
||||||
|
while (pos + 8 <= buf.size()) {
|
||||||
|
const uint8_t * chunk = buf.data() + pos;
|
||||||
|
const uint32_t chunkSize = rd32(chunk + 4);
|
||||||
|
const uint8_t * body = chunk + 8;
|
||||||
|
if (std::memcmp(chunk, "fmt ", 4) == 0) {
|
||||||
|
if (chunkSize < 16) return fail("fmt 块过短");
|
||||||
|
audioFormat = rd16(body + 0);
|
||||||
|
channels = rd16(body + 2);
|
||||||
|
sampleRate = rd32(body + 4);
|
||||||
|
bitsPerSample = rd16(body + 14);
|
||||||
|
if (audioFormat == 0xFFFE && chunkSize >= 40) {
|
||||||
|
audioFormat = rd16(body + 24); // WAVE_FORMAT_EXTENSIBLE 的 subformat 前两字节
|
||||||
|
}
|
||||||
|
} else if (std::memcmp(chunk, "data", 4) == 0) {
|
||||||
|
dataPtr = body;
|
||||||
|
dataLen = chunkSize;
|
||||||
|
if (pos + 8 + chunkSize > buf.size()) dataLen = buf.size() - (pos + 8);
|
||||||
|
}
|
||||||
|
pos += 8 + chunkSize + (chunkSize & 1); // 块按偶数字节对齐
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dataPtr || channels == 0 || sampleRate == 0) return fail("WAV 缺少 fmt 或 data 块");
|
||||||
|
|
||||||
|
const bool isFloat = (audioFormat == 3);
|
||||||
|
const int bytesPerSample = bitsPerSample / 8;
|
||||||
|
if (bytesPerSample <= 0) return fail("不支持的位深");
|
||||||
|
const size_t frameBytes = (size_t) bytesPerSample * channels;
|
||||||
|
const size_t frames = dataLen / frameBytes;
|
||||||
|
|
||||||
|
// 降混为单声道 float
|
||||||
|
std::vector<float> mono(frames);
|
||||||
|
for (size_t i = 0; i < frames; ++i) {
|
||||||
|
double acc = 0.0;
|
||||||
|
const uint8_t * p = dataPtr + i * frameBytes;
|
||||||
|
for (uint16_t c = 0; c < channels; ++c, p += bytesPerSample) {
|
||||||
|
if (isFloat) {
|
||||||
|
float v;
|
||||||
|
std::memcpy(&v, p, 4);
|
||||||
|
acc += v;
|
||||||
|
} else if (bitsPerSample == 16) {
|
||||||
|
acc += (int16_t) rd16(p) / 32768.0;
|
||||||
|
} else if (bitsPerSample == 24) {
|
||||||
|
int32_t v = p[0] | (p[1] << 8) | (p[2] << 16);
|
||||||
|
if (v & 0x800000) v |= ~0xFFFFFF;
|
||||||
|
acc += v / 8388608.0;
|
||||||
|
} else if (bitsPerSample == 32) {
|
||||||
|
acc += (int32_t) rd32(p) / 2147483648.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mono[i] = (float) (acc / channels);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) sampleRate == targetRate) {
|
||||||
|
out = std::move(mono);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Resampler rs;
|
||||||
|
if (!rs.init((int) sampleRate, targetRate)) return fail("重采样器初始化失败");
|
||||||
|
out.clear();
|
||||||
|
return rs.process(mono.data(), mono.size(), out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 实时采集:默认 sink 的 monitor
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
struct MonitorCapture::Impl {
|
||||||
|
pa_threaded_mainloop * ml = nullptr;
|
||||||
|
pa_context * ctx = nullptr;
|
||||||
|
pa_stream * stream = nullptr;
|
||||||
|
ChunkCallback callback;
|
||||||
|
int targetRate = 16000;
|
||||||
|
int deviceRate = 48000;
|
||||||
|
int channels = 2;
|
||||||
|
Resampler resampler;
|
||||||
|
std::string deviceName;
|
||||||
|
bool ready = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
void storeMonitor(pa_context * /*c*/, const pa_server_info * info, void * userdata)
|
||||||
|
{
|
||||||
|
auto * d = static_cast<MonitorCapture::Impl *>(userdata);
|
||||||
|
if (!info || !info->default_sink_name) return;
|
||||||
|
d->deviceName = std::string(info->default_sink_name) + ".monitor";
|
||||||
|
pa_threaded_mainloop_signal(d->ml, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void streamState(pa_stream * s, void * userdata)
|
||||||
|
{
|
||||||
|
auto * d = static_cast<MonitorCapture::Impl *>(userdata);
|
||||||
|
switch (pa_stream_get_state(s)) {
|
||||||
|
case PA_STREAM_READY:
|
||||||
|
case PA_STREAM_FAILED:
|
||||||
|
case PA_STREAM_TERMINATED:
|
||||||
|
pa_threaded_mainloop_signal(d->ml, 0);
|
||||||
|
break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void streamRead(pa_stream * s, size_t /*nbytes*/, void * userdata)
|
||||||
|
{
|
||||||
|
auto * d = static_cast<MonitorCapture::Impl *>(userdata);
|
||||||
|
while (pa_stream_readable_size(s) > 0) {
|
||||||
|
const void * data = nullptr;
|
||||||
|
size_t bytes = 0;
|
||||||
|
if (pa_stream_peek(s, &data, &bytes) < 0 || !data || bytes == 0) {
|
||||||
|
pa_stream_drop(s);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const float * interleaved = static_cast<const float *>(data);
|
||||||
|
const size_t frames = bytes / (sizeof(float) * (size_t) d->channels);
|
||||||
|
|
||||||
|
// 降混单声道
|
||||||
|
std::vector<float> mono(frames);
|
||||||
|
for (size_t i = 0; i < frames; ++i) {
|
||||||
|
double acc = 0.0;
|
||||||
|
for (int c = 0; c < d->channels; ++c) acc += interleaved[i * d->channels + c];
|
||||||
|
mono[i] = (float) (acc / d->channels);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重采样到目标采样率
|
||||||
|
std::vector<float> resampled;
|
||||||
|
if (d->deviceRate == d->targetRate) {
|
||||||
|
resampled = std::move(mono);
|
||||||
|
} else if (!d->resampler.process(mono.data(), mono.size(), resampled)) {
|
||||||
|
pa_stream_drop(s);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d->callback && !resampled.empty()) {
|
||||||
|
d->callback(resampled.data(), resampled.size());
|
||||||
|
}
|
||||||
|
pa_stream_drop(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void contextState(pa_context * c, void * userdata)
|
||||||
|
{
|
||||||
|
auto * d = static_cast<MonitorCapture::Impl *>(userdata);
|
||||||
|
switch (pa_context_get_state(c)) {
|
||||||
|
case PA_CONTEXT_READY: d->ready = true; pa_threaded_mainloop_signal(d->ml, 0); break;
|
||||||
|
case PA_CONTEXT_FAILED:
|
||||||
|
case PA_CONTEXT_TERMINATED:
|
||||||
|
pa_threaded_mainloop_signal(d->ml, 0);
|
||||||
|
break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
MonitorCapture::MonitorCapture() : d(new Impl) {}
|
||||||
|
|
||||||
|
MonitorCapture::~MonitorCapture()
|
||||||
|
{
|
||||||
|
stop();
|
||||||
|
delete d;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string MonitorCapture::deviceName() const { return d->deviceName; }
|
||||||
|
|
||||||
|
bool MonitorCapture::start(int targetRate, ChunkCallback callback, std::string * error)
|
||||||
|
{
|
||||||
|
const auto fail = [error](const std::string & m) {
|
||||||
|
if (error) *error = m;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
d->targetRate = targetRate;
|
||||||
|
d->callback = std::move(callback);
|
||||||
|
|
||||||
|
d->ml = pa_threaded_mainloop_new();
|
||||||
|
if (!d->ml) return fail("pa_threaded_mainloop_new 失败");
|
||||||
|
|
||||||
|
d->ctx = pa_context_new(pa_threaded_mainloop_get_api(d->ml), "live-caption");
|
||||||
|
if (!d->ctx) return fail("pa_context_new 失败");
|
||||||
|
|
||||||
|
pa_context_set_state_callback(d->ctx, contextState, d);
|
||||||
|
|
||||||
|
if (pa_threaded_mainloop_start(d->ml) < 0) return fail("pa_threaded_mainloop_start 失败");
|
||||||
|
|
||||||
|
pa_threaded_mainloop_lock(d->ml);
|
||||||
|
|
||||||
|
if (pa_context_connect(d->ctx, nullptr, PA_CONTEXT_NOFLAGS, nullptr) < 0) {
|
||||||
|
pa_threaded_mainloop_unlock(d->ml);
|
||||||
|
return fail("pa_context_connect 失败(PulseAudio/PipeWire 未运行?)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等 context READY
|
||||||
|
for (;;) {
|
||||||
|
const pa_context_state_t st = pa_context_get_state(d->ctx);
|
||||||
|
if (st == PA_CONTEXT_READY) break;
|
||||||
|
if (!PA_CONTEXT_IS_GOOD(st)) {
|
||||||
|
pa_threaded_mainloop_unlock(d->ml);
|
||||||
|
return fail("无法连接到 PulseAudio/PipeWire 服务");
|
||||||
|
}
|
||||||
|
pa_threaded_mainloop_wait(d->ml);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查默认 sink,拼出它的 monitor 源
|
||||||
|
d->deviceName.clear();
|
||||||
|
pa_operation * op = pa_context_get_server_info(d->ctx, storeMonitor, d);
|
||||||
|
while (pa_operation_get_state(op) == PA_OPERATION_RUNNING) {
|
||||||
|
pa_threaded_mainloop_wait(d->ml);
|
||||||
|
}
|
||||||
|
pa_operation_unref(op);
|
||||||
|
|
||||||
|
if (d->deviceName.empty()) {
|
||||||
|
pa_threaded_mainloop_unlock(d->ml);
|
||||||
|
return fail("拿不到默认输出设备名");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 以设备实际速率请求(默认 48k)
|
||||||
|
pa_sample_spec ss{};
|
||||||
|
ss.format = PA_SAMPLE_FLOAT32LE;
|
||||||
|
ss.rate = 48000;
|
||||||
|
ss.channels = 2;
|
||||||
|
|
||||||
|
d->stream = pa_stream_new(d->ctx, "live-caption-monitor", &ss, nullptr);
|
||||||
|
if (!d->stream) {
|
||||||
|
pa_threaded_mainloop_unlock(d->ml);
|
||||||
|
return fail("pa_stream_new 失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
pa_stream_set_read_callback(d->stream, streamRead, d);
|
||||||
|
pa_stream_set_state_callback(d->stream, streamState, d);
|
||||||
|
|
||||||
|
pa_buffer_attr attr{};
|
||||||
|
attr.maxlength = (uint32_t) -1;
|
||||||
|
attr.tlength = (uint32_t) -1;
|
||||||
|
attr.prebuf = (uint32_t) -1;
|
||||||
|
attr.minreq = (uint32_t) -1;
|
||||||
|
attr.fragsize = (uint32_t) (pa_frame_size(&ss) * 1024); // ~21ms
|
||||||
|
|
||||||
|
if (pa_stream_connect_record(d->stream, d->deviceName.c_str(), &attr, PA_STREAM_ADJUST_LATENCY) < 0) {
|
||||||
|
pa_threaded_mainloop_unlock(d->ml);
|
||||||
|
return fail("连接 monitor 源失败: " + d->deviceName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 等 stream READY,才能确认 monitor 源确实可用
|
||||||
|
for (;;) {
|
||||||
|
const pa_stream_state_t st = pa_stream_get_state(d->stream);
|
||||||
|
if (st == PA_STREAM_READY) break;
|
||||||
|
if (st == PA_STREAM_FAILED || st == PA_STREAM_TERMINATED) {
|
||||||
|
pa_threaded_mainloop_unlock(d->ml);
|
||||||
|
return fail("monitor 源不可用: " + d->deviceName + " (" +
|
||||||
|
std::string(pa_strerror(pa_context_errno(d->ctx))) + ")");
|
||||||
|
}
|
||||||
|
pa_threaded_mainloop_wait(d->ml);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新为实际生效的格式
|
||||||
|
const pa_sample_spec * actual = pa_stream_get_sample_spec(d->stream);
|
||||||
|
if (actual) {
|
||||||
|
d->deviceRate = (int) actual->rate;
|
||||||
|
d->channels = (int) actual->channels;
|
||||||
|
}
|
||||||
|
if (!d->resampler.init(d->deviceRate, d->targetRate)) {
|
||||||
|
pa_threaded_mainloop_unlock(d->ml);
|
||||||
|
return fail("重采样器初始化失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
pa_threaded_mainloop_unlock(d->ml);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MonitorCapture::stop()
|
||||||
|
{
|
||||||
|
if (!d->ml) return;
|
||||||
|
|
||||||
|
pa_threaded_mainloop_lock(d->ml);
|
||||||
|
if (d->stream) {
|
||||||
|
pa_stream_disconnect(d->stream);
|
||||||
|
pa_stream_unref(d->stream);
|
||||||
|
d->stream = nullptr;
|
||||||
|
}
|
||||||
|
if (d->ctx) {
|
||||||
|
pa_context_disconnect(d->ctx);
|
||||||
|
pa_context_unref(d->ctx);
|
||||||
|
d->ctx = nullptr;
|
||||||
|
}
|
||||||
|
pa_threaded_mainloop_unlock(d->ml);
|
||||||
|
|
||||||
|
pa_threaded_mainloop_stop(d->ml);
|
||||||
|
pa_threaded_mainloop_free(d->ml);
|
||||||
|
d->ml = nullptr;
|
||||||
|
d->callback = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace lc
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <functional>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace lc {
|
||||||
|
|
||||||
|
// 读取 WAV(PCM 16/24/32 位或 float32),降混为单声道并重采样到 targetRate。
|
||||||
|
bool readWavMono(const std::string & path, int targetRate,
|
||||||
|
std::vector<float> & out, std::string * error);
|
||||||
|
|
||||||
|
// 采集默认输出设备的 monitor(即"系统正在播放的声音"),
|
||||||
|
// 输出 targetRate 单声道 float32。回调在 PulseAudio 线程上调用,必须快速返回。
|
||||||
|
class MonitorCapture {
|
||||||
|
public:
|
||||||
|
using ChunkCallback = std::function<void(const float * mono, size_t nSamples)>;
|
||||||
|
|
||||||
|
MonitorCapture();
|
||||||
|
~MonitorCapture();
|
||||||
|
|
||||||
|
MonitorCapture(const MonitorCapture &) = delete;
|
||||||
|
MonitorCapture & operator=(const MonitorCapture &) = delete;
|
||||||
|
|
||||||
|
bool start(int targetRate, ChunkCallback callback, std::string * error);
|
||||||
|
void stop();
|
||||||
|
|
||||||
|
// 实际采集的 monitor 源名(start 成功后有效)
|
||||||
|
std::string deviceName() const;
|
||||||
|
|
||||||
|
// 前向声明(定义在 .cpp)。公开仅为让文件内的 C 回调能引用该类型,
|
||||||
|
// 头文件里它始终是不完整类型。
|
||||||
|
struct Impl;
|
||||||
|
|
||||||
|
private:
|
||||||
|
Impl * d = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace lc
|
||||||
+316
@@ -0,0 +1,316 @@
|
|||||||
|
#include <atomic>
|
||||||
|
#include <algorithm>
|
||||||
|
#include <chrono>
|
||||||
|
#include <cmath>
|
||||||
|
#include <condition_variable>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <mutex>
|
||||||
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QCommandLineParser>
|
||||||
|
#include <QCoreApplication>
|
||||||
|
|
||||||
|
#include "audio.h"
|
||||||
|
#include "qwen3asr.h"
|
||||||
|
#include "transcript.h"
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr int kFrameSamples = 320; // 20ms @ 16k
|
||||||
|
constexpr float kSilenceTimeout = 0.8f; // 静音多久算一句结束
|
||||||
|
constexpr float kMinSpeech = 0.5f; // 一句话至少多长才值得转写
|
||||||
|
constexpr float kMaxWindow = 20.0f; // 窗口上限,超过强制收尾
|
||||||
|
|
||||||
|
// 两个字符串的最长公共前缀,且保证切在 UTF-8 字符边界上
|
||||||
|
size_t utf8Lcp(const std::string & a, const std::string & b)
|
||||||
|
{
|
||||||
|
const size_t n = std::min(a.size(), b.size());
|
||||||
|
size_t i = 0;
|
||||||
|
while (i < n && a[i] == b[i]) ++i;
|
||||||
|
// 回退到不是 UTF-8 续字节(10xxxxxx)的位置
|
||||||
|
while (i > 0 && (static_cast<unsigned char>(a[i]) & 0xC0) == 0x80) --i;
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 简单的能量 VAD。
|
||||||
|
// 噪声底必须用"最小值跟踪"(快降慢升),不能用均值:
|
||||||
|
// 否则遇到本来就很轻的音频(例如 AISHELL 峰值 RMS 只有 0.03),
|
||||||
|
// 噪声底会一路抬升、阈值超过语音电平,把话音全判成静音。
|
||||||
|
class Vad {
|
||||||
|
public:
|
||||||
|
// 返回该帧是否为语音
|
||||||
|
bool feed(const float * frame, size_t n)
|
||||||
|
{
|
||||||
|
double acc = 0.0;
|
||||||
|
for (size_t i = 0; i < n; ++i) acc += (double) frame[i] * frame[i];
|
||||||
|
const float rms = std::sqrt(acc / (double) n);
|
||||||
|
|
||||||
|
if (rms < floor_) {
|
||||||
|
floor_ = rms; // 快速下探
|
||||||
|
} else {
|
||||||
|
floor_ = floor_ * 0.9999f + rms * 0.0001f; // 极慢回升
|
||||||
|
}
|
||||||
|
|
||||||
|
const float threshold = std::max(floor_ * 3.0f, 0.0015f);
|
||||||
|
speech_ = rms > threshold;
|
||||||
|
return speech_;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool speaking() const { return speech_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
float floor_ = 0.01f;
|
||||||
|
bool speech_ = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 实时转写:采集线程 -> 队列 -> 工作线程(VAD + 滑窗 + 转写)
|
||||||
|
class LiveTranscriber {
|
||||||
|
public:
|
||||||
|
LiveTranscriber(lc::Qwen3Asr & asr, int sampleRate) : asr_(asr), sampleRate_(sampleRate) {}
|
||||||
|
|
||||||
|
void onAudio(const float * samples, size_t n)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(mutex_);
|
||||||
|
pending_.insert(pending_.end(), samples, samples + n);
|
||||||
|
cv_.notify_one();
|
||||||
|
}
|
||||||
|
|
||||||
|
void start() { worker_ = std::thread([this] { run(); }); }
|
||||||
|
|
||||||
|
void stop()
|
||||||
|
{
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(mutex_);
|
||||||
|
quit_ = true;
|
||||||
|
}
|
||||||
|
cv_.notify_all();
|
||||||
|
if (worker_.joinable()) worker_.join();
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
void emitPartial(const lc::Qwen3Asr::Result & r)
|
||||||
|
{
|
||||||
|
// 只把"跨多次转写都不变"的前缀当成已定文本,尾部标为不稳定预览。
|
||||||
|
// 窗口在词中间被切断时,模型会给尾部编一个结尾,这样能避免它上屏。
|
||||||
|
committed_ = r.transcript.text.substr(0, utf8Lcp(committed_, r.transcript.text));
|
||||||
|
const std::string tail = r.transcript.text.substr(committed_.size());
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lk(printMutex_);
|
||||||
|
std::printf("\r\033[K~ [%s] %s\033[2m%s\033[0m",
|
||||||
|
lc::languageDisplayName(r.transcript.language).c_str(),
|
||||||
|
committed_.c_str(), tail.c_str());
|
||||||
|
std::fflush(stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
void emitFinal(const lc::Qwen3Asr::Result & r)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(printMutex_);
|
||||||
|
std::printf("\r\033[K= [%s] %s\n",
|
||||||
|
lc::languageDisplayName(r.transcript.language).c_str(),
|
||||||
|
r.transcript.text.c_str());
|
||||||
|
std::fflush(stdout);
|
||||||
|
committed_.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
lc::Qwen3Asr::Result transcribeWindow(const std::vector<float> & window)
|
||||||
|
{
|
||||||
|
lc::Qwen3Asr::Result r = asr_.transcribe(window.data(), window.size());
|
||||||
|
const double dur = (double) window.size() / sampleRate_;
|
||||||
|
std::lock_guard<std::mutex> lk(printMutex_);
|
||||||
|
std::fprintf(stderr, " [infer] %.2fs 音频 / %.2fs 计算 (rtf %.3f)\n",
|
||||||
|
dur, r.inferSeconds, dur > 0 ? r.inferSeconds / dur : 0.0);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
void run()
|
||||||
|
{
|
||||||
|
Vad vad;
|
||||||
|
std::vector<float> window; // 当前这句话累积的音频
|
||||||
|
std::vector<float> buffer; // 跨批次残留,保证不丢样本
|
||||||
|
float silenceAcc = 0.0f;
|
||||||
|
float lastPartialAt = 0.0f; // 上次做 partial 时窗口长度(秒)
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
std::vector<float> chunk;
|
||||||
|
{
|
||||||
|
std::unique_lock<std::mutex> lk(mutex_);
|
||||||
|
cv_.wait_for(lk, std::chrono::milliseconds(50), [this] { return quit_ || !pending_.empty(); });
|
||||||
|
if (quit_ && pending_.empty()) break;
|
||||||
|
chunk.swap(pending_);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关键:把上一轮不足一帧的残留接上,否则会持续丢样本
|
||||||
|
buffer.insert(buffer.end(), chunk.begin(), chunk.end());
|
||||||
|
|
||||||
|
size_t off = 0;
|
||||||
|
for (; off + kFrameSamples <= buffer.size(); off += kFrameSamples) {
|
||||||
|
const float * frame = buffer.data() + off;
|
||||||
|
const bool speech = vad.feed(frame, kFrameSamples);
|
||||||
|
|
||||||
|
if (speech) {
|
||||||
|
if (window.empty()) lastPartialAt = 0.0f;
|
||||||
|
window.insert(window.end(), frame, frame + kFrameSamples);
|
||||||
|
silenceAcc = 0.0f;
|
||||||
|
} else if (!window.empty()) {
|
||||||
|
window.insert(window.end(), frame, frame + kFrameSamples);
|
||||||
|
silenceAcc += (float) kFrameSamples / sampleRate_;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.empty()) continue;
|
||||||
|
|
||||||
|
const float windowSec = (float) window.size() / sampleRate_;
|
||||||
|
const float newAudioSincePartial = windowSec - lastPartialAt;
|
||||||
|
const float needed = std::max(0.8f, 0.35f * windowSec);
|
||||||
|
|
||||||
|
// 静音够久 -> 收尾
|
||||||
|
if (silenceAcc >= kSilenceTimeout && windowSec >= kMinSpeech) {
|
||||||
|
emitFinal(transcribeWindow(window));
|
||||||
|
window.clear();
|
||||||
|
silenceAcc = 0.0f;
|
||||||
|
lastPartialAt = 0.0f;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 窗口过长 -> 强制收尾,避免延迟无限增长
|
||||||
|
if (windowSec >= kMaxWindow) {
|
||||||
|
emitFinal(transcribeWindow(window));
|
||||||
|
window.clear();
|
||||||
|
silenceAcc = 0.0f;
|
||||||
|
lastPartialAt = 0.0f;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定期输出 partial(预览)
|
||||||
|
if (newAudioSincePartial >= needed) {
|
||||||
|
emitPartial(transcribeWindow(window));
|
||||||
|
lastPartialAt = windowSec;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (off > 0) buffer.erase(buffer.begin(), buffer.begin() + (long) off);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.empty() && (float) window.size() / sampleRate_ >= kMinSpeech) {
|
||||||
|
emitFinal(transcribeWindow(window));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lc::Qwen3Asr & asr_;
|
||||||
|
int sampleRate_;
|
||||||
|
std::thread worker_;
|
||||||
|
std::mutex mutex_;
|
||||||
|
std::condition_variable cv_;
|
||||||
|
std::vector<float> pending_;
|
||||||
|
bool quit_ = false;
|
||||||
|
std::mutex printMutex_;
|
||||||
|
std::string committed_; // 当前这句已提交的稳定前缀
|
||||||
|
};
|
||||||
|
|
||||||
|
int runFile(lc::Qwen3Asr & asr, const QString & path)
|
||||||
|
{
|
||||||
|
std::vector<float> pcm;
|
||||||
|
std::string err;
|
||||||
|
if (!lc::readWavMono(path.toStdString(), asr.sampleRate(), pcm, &err)) {
|
||||||
|
std::fprintf(stderr, "读取音频失败: %s\n", err.c_str());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const double dur = (double) pcm.size() / asr.sampleRate();
|
||||||
|
std::fprintf(stderr, "音频: %s (%.2fs)\n", qUtf8Printable(path), dur);
|
||||||
|
|
||||||
|
const auto t0 = std::chrono::steady_clock::now();
|
||||||
|
lc::Qwen3Asr::Result r = asr.transcribe(pcm.data(), pcm.size());
|
||||||
|
const double wall = std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
|
||||||
|
|
||||||
|
if (!r.ok) {
|
||||||
|
std::fprintf(stderr, "转写失败: %s\n", r.error.c_str());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::printf("语言: %s (%s)\n", r.transcript.language.c_str(),
|
||||||
|
lc::languageDisplayName(r.transcript.language).c_str());
|
||||||
|
std::printf("文本: %s\n", r.transcript.text.c_str());
|
||||||
|
std::fprintf(stderr, "推理: %.2fs / 音频 %.2fs (rtf %.3f) 生成 %d tokens, 墙钟 %.2fs\n",
|
||||||
|
r.inferSeconds, dur, dur > 0 ? r.inferSeconds / dur : 0, r.tokenCount, wall);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int runLive(lc::Qwen3Asr & asr)
|
||||||
|
{
|
||||||
|
LiveTranscriber live(asr, asr.sampleRate());
|
||||||
|
lc::MonitorCapture capture;
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
if (!capture.start(asr.sampleRate(), [&live](const float * s, size_t n) { live.onAudio(s, n); }, &err)) {
|
||||||
|
std::fprintf(stderr, "启动采集失败: %s\n", err.c_str());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fprintf(stderr, "正在采集: %s (%d Hz)\n按 Ctrl+C 退出\n\n",
|
||||||
|
capture.deviceName().c_str(), asr.sampleRate());
|
||||||
|
|
||||||
|
live.start();
|
||||||
|
// 简单起见:主线程等待,Ctrl+C 结束进程
|
||||||
|
for (;;) std::this_thread::sleep_for(std::chrono::seconds(1));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main(int argc, char ** argv)
|
||||||
|
{
|
||||||
|
QCoreApplication app(argc, argv);
|
||||||
|
QCoreApplication::setApplicationName("live-caption");
|
||||||
|
|
||||||
|
QCommandLineParser parser;
|
||||||
|
parser.setApplicationDescription("实时字幕原型(Qwen3-ASR + llama.cpp)");
|
||||||
|
parser.addHelpOption();
|
||||||
|
|
||||||
|
QCommandLineOption optModel("model", "主模型 GGUF 路径(架构须为 qwen3vl)", "path");
|
||||||
|
QCommandLineOption optMmproj("mmproj", "mmproj GGUF 路径(clip + qwen3a)", "path");
|
||||||
|
QCommandLineOption optFile("file", "转写一个 wav 文件后退出(不开启实时采集)", "path");
|
||||||
|
QCommandLineOption optThreads("threads", "推理线程数", "n", "8");
|
||||||
|
QCommandLineOption optMaxTokens("max-tokens", "单段最多生成 token 数", "n", "256");
|
||||||
|
QCommandLineOption optWarmup("warmup", "mtmd 初始化后做一次预热编码");
|
||||||
|
QCommandLineOption optVerbose("verbose", "输出 llama.cpp/mtmd 的详细日志");
|
||||||
|
|
||||||
|
parser.addOption(optModel);
|
||||||
|
parser.addOption(optMmproj);
|
||||||
|
parser.addOption(optFile);
|
||||||
|
parser.addOption(optThreads);
|
||||||
|
parser.addOption(optMaxTokens);
|
||||||
|
parser.addOption(optWarmup);
|
||||||
|
parser.addOption(optVerbose);
|
||||||
|
parser.process(app);
|
||||||
|
|
||||||
|
lc::AsrOptions asrOpt;
|
||||||
|
asrOpt.modelPath = parser.value(optModel).toStdString();
|
||||||
|
asrOpt.mmprojPath = parser.value(optMmproj).toStdString();
|
||||||
|
asrOpt.threads = parser.value(optThreads).toInt();
|
||||||
|
asrOpt.maxTokens = parser.value(optMaxTokens).toInt();
|
||||||
|
asrOpt.warmup = parser.isSet(optWarmup);
|
||||||
|
asrOpt.verbose = parser.isSet(optVerbose);
|
||||||
|
|
||||||
|
if (asrOpt.modelPath.empty() || asrOpt.mmprojPath.empty()) {
|
||||||
|
std::fprintf(stderr, "必须提供 --model 和 --mmproj\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
lc::Qwen3Asr asr;
|
||||||
|
std::string err;
|
||||||
|
const auto tl0 = std::chrono::steady_clock::now();
|
||||||
|
if (!asr.load(asrOpt, &err)) {
|
||||||
|
std::fprintf(stderr, "加载失败: %s\n", err.c_str());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
std::fprintf(stderr, "模型加载完成: %.2fs (采样率 %d)\n",
|
||||||
|
std::chrono::duration<double>(std::chrono::steady_clock::now() - tl0).count(),
|
||||||
|
asr.sampleRate());
|
||||||
|
|
||||||
|
if (parser.isSet(optFile)) {
|
||||||
|
return runFile(asr, parser.value(optFile));
|
||||||
|
}
|
||||||
|
return runLive(asr);
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
#include "qwen3asr.h"
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <llama.h>
|
||||||
|
#include <mtmd.h>
|
||||||
|
#include <mtmd-helper.h>
|
||||||
|
|
||||||
|
namespace lc {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
std::string defaultQwenChatPrompt(const std::string & content)
|
||||||
|
{
|
||||||
|
// Qwen3-ASR 的 chat template 就是标准 ChatML(见模型 GGUF 的 tokenizer.chat_template)
|
||||||
|
return "<|im_start|>user\n" + content + "<|im_end|>\n<|im_start|>assistant\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
void silentLog(ggml_log_level /*level*/, const char * /*text*/, void * /*user*/) {}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
struct Qwen3Asr::Impl {
|
||||||
|
AsrOptions opt;
|
||||||
|
llama_model * model = nullptr;
|
||||||
|
llama_context * ctx = nullptr;
|
||||||
|
mtmd_context * mctx = nullptr;
|
||||||
|
const llama_vocab * vocab = nullptr;
|
||||||
|
int audioRate = 16000;
|
||||||
|
|
||||||
|
std::string buildPrompt(const std::string & marker) const
|
||||||
|
{
|
||||||
|
const char * tmpl = llama_model_chat_template(model, nullptr);
|
||||||
|
if (tmpl) {
|
||||||
|
llama_chat_message msg{"user", marker.c_str()};
|
||||||
|
std::vector<char> buf(marker.size() * 2 + 4096);
|
||||||
|
const int32_t n = llama_chat_apply_template(tmpl, &msg, 1, true, buf.data(), (int32_t) buf.size());
|
||||||
|
if (n > 0 && (size_t) n <= buf.size()) {
|
||||||
|
return std::string(buf.data(), (size_t) n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultQwenChatPrompt(marker);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Qwen3Asr::Qwen3Asr() : d(new Impl) {}
|
||||||
|
|
||||||
|
Qwen3Asr::~Qwen3Asr()
|
||||||
|
{
|
||||||
|
if (!d) return;
|
||||||
|
if (d->mctx) mtmd_free(d->mctx);
|
||||||
|
if (d->ctx) llama_free(d->ctx);
|
||||||
|
if (d->model) llama_model_free(d->model);
|
||||||
|
delete d;
|
||||||
|
}
|
||||||
|
|
||||||
|
int Qwen3Asr::sampleRate() const { return d->audioRate; }
|
||||||
|
|
||||||
|
bool Qwen3Asr::load(const AsrOptions & opt, std::string * error)
|
||||||
|
{
|
||||||
|
const auto fail = [error](const std::string & msg) {
|
||||||
|
if (error) *error = msg;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
d->opt = opt;
|
||||||
|
|
||||||
|
if (!opt.verbose) {
|
||||||
|
llama_log_set(silentLog, nullptr);
|
||||||
|
mtmd_log_set(silentLog, nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
llama_backend_init();
|
||||||
|
|
||||||
|
llama_model_params mparams = llama_model_default_params();
|
||||||
|
mparams.n_gpu_layers = 0; // 本机无独显,强制 CPU
|
||||||
|
mparams.use_mmap = true;
|
||||||
|
|
||||||
|
d->model = llama_model_load_from_file(opt.modelPath.c_str(), mparams);
|
||||||
|
if (!d->model) {
|
||||||
|
return fail("加载主模型失败: " + opt.modelPath);
|
||||||
|
}
|
||||||
|
d->vocab = llama_model_get_vocab(d->model);
|
||||||
|
|
||||||
|
llama_context_params cparams = llama_context_default_params();
|
||||||
|
cparams.n_ctx = (uint32_t) opt.nCtx;
|
||||||
|
cparams.n_batch = (uint32_t) opt.nBatch;
|
||||||
|
cparams.n_ubatch = (uint32_t) opt.nUbatch;
|
||||||
|
cparams.n_threads = opt.threads;
|
||||||
|
cparams.n_threads_batch = opt.threads;
|
||||||
|
|
||||||
|
d->ctx = llama_init_from_model(d->model, cparams);
|
||||||
|
if (!d->ctx) {
|
||||||
|
return fail("创建 llama 上下文失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
mtmd_context_params mctxParams = mtmd_context_params_default();
|
||||||
|
mctxParams.use_gpu = false;
|
||||||
|
mctxParams.n_threads = opt.threads;
|
||||||
|
mctxParams.print_timings = false;
|
||||||
|
mctxParams.warmup = opt.warmup;
|
||||||
|
|
||||||
|
d->mctx = mtmd_init_from_file(opt.mmprojPath.c_str(), d->model, mctxParams);
|
||||||
|
if (!d->mctx) {
|
||||||
|
return fail("加载 mmproj 失败: " + opt.mmprojPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mtmd_support_audio(d->mctx)) {
|
||||||
|
return fail("该 mmproj 不支持音频输入(需要 clip.projector_type = qwen3a 的版本)");
|
||||||
|
}
|
||||||
|
|
||||||
|
const int rate = mtmd_get_audio_sample_rate(d->mctx);
|
||||||
|
if (rate > 0) d->audioRate = rate;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Qwen3Asr::Result Qwen3Asr::transcribe(const float * pcm, size_t nSamples)
|
||||||
|
{
|
||||||
|
Result res;
|
||||||
|
|
||||||
|
if (!d->model || !d->ctx || !d->mctx) {
|
||||||
|
res.error = "模型未加载";
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
if (nSamples == 0) {
|
||||||
|
res.error = "音频为空";
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto t0 = std::chrono::steady_clock::now();
|
||||||
|
|
||||||
|
// 每段独立转写,先清空 KV cache
|
||||||
|
llama_memory_clear(llama_get_memory(d->ctx), true);
|
||||||
|
|
||||||
|
const std::string marker = mtmd_default_marker();
|
||||||
|
const std::string prompt = d->buildPrompt(marker);
|
||||||
|
|
||||||
|
mtmd_bitmap * bitmap = mtmd_bitmap_init_from_audio(nSamples, pcm);
|
||||||
|
if (!bitmap) {
|
||||||
|
res.error = "构造音频 bitmap 失败";
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
mtmd_input_chunks * chunks = mtmd_input_chunks_init();
|
||||||
|
const mtmd_input_text text{ prompt.c_str(), /*add_special*/ true, /*parse_special*/ true };
|
||||||
|
const mtmd_bitmap * bitmaps[1] = { bitmap };
|
||||||
|
|
||||||
|
const int32_t tokRes = mtmd_tokenize(d->mctx, chunks, &text, bitmaps, 1);
|
||||||
|
if (tokRes != 0) {
|
||||||
|
mtmd_bitmap_free(bitmap);
|
||||||
|
mtmd_input_chunks_free(chunks);
|
||||||
|
res.error = "mtmd_tokenize 失败 (code " + std::to_string(tokRes) + ")";
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
llama_pos nPast = 0;
|
||||||
|
const int32_t evalRes = mtmd_helper_eval_chunks(
|
||||||
|
d->mctx, d->ctx, chunks,
|
||||||
|
/*n_past*/ 0, /*seq_id*/ 0, /*n_batch*/ d->opt.nBatch,
|
||||||
|
/*logits_last*/ true, &nPast);
|
||||||
|
mtmd_bitmap_free(bitmap);
|
||||||
|
if (evalRes != 0) {
|
||||||
|
mtmd_input_chunks_free(chunks);
|
||||||
|
res.error = "mtmd_helper_eval_chunks 失败 (code " + std::to_string(evalRes) + ")";
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
mtmd_input_chunks_free(chunks);
|
||||||
|
|
||||||
|
// 贪心解码
|
||||||
|
llama_sampler * smpl = llama_sampler_chain_init(llama_sampler_chain_default_params());
|
||||||
|
llama_sampler_chain_add(smpl, llama_sampler_init_greedy());
|
||||||
|
|
||||||
|
std::string out;
|
||||||
|
for (int i = 0; i < d->opt.maxTokens; ++i) {
|
||||||
|
llama_token id = llama_sampler_sample(smpl, d->ctx, -1);
|
||||||
|
if (llama_vocab_is_eog(d->vocab, id)) break;
|
||||||
|
|
||||||
|
char buf[256];
|
||||||
|
const int32_t n = llama_token_to_piece(d->vocab, id, buf, sizeof(buf), 0, /*special*/ true);
|
||||||
|
if (n > 0) out.append(buf, (size_t) n);
|
||||||
|
++res.tokenCount;
|
||||||
|
|
||||||
|
llama_batch batch = llama_batch_get_one(&id, 1);
|
||||||
|
if (llama_decode(d->ctx, batch) != 0) {
|
||||||
|
res.error = "llama_decode 失败";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
llama_sampler_free(smpl);
|
||||||
|
|
||||||
|
const auto t1 = std::chrono::steady_clock::now();
|
||||||
|
res.inferSeconds = std::chrono::duration<double>(t1 - t0).count();
|
||||||
|
res.raw = out;
|
||||||
|
res.transcript = parseAsrOutput(out);
|
||||||
|
res.ok = res.error.empty();
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace lc
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "transcript.h"
|
||||||
|
|
||||||
|
namespace lc {
|
||||||
|
|
||||||
|
struct AsrOptions {
|
||||||
|
std::string modelPath; // Qwen3-ASR 主模型 GGUF(架构须为 qwen3vl)
|
||||||
|
std::string mmprojPath; // 音频编码器 mmproj GGUF(clip + qwen3a)
|
||||||
|
int threads = 8;
|
||||||
|
int nCtx = 4096;
|
||||||
|
int nBatch = 4096;
|
||||||
|
int nUbatch = 1024;
|
||||||
|
int maxTokens = 256; // 单段最多生成多少 token
|
||||||
|
bool warmup = false; // mtmd 初始化后是否跑一次预热编码
|
||||||
|
bool verbose = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 进程内 Qwen3-ASR 推理封装(libllama + libmtmd)。
|
||||||
|
// 模型常驻,可反复 transcribe();每次调用会清空 KV cache。
|
||||||
|
class Qwen3Asr {
|
||||||
|
public:
|
||||||
|
Qwen3Asr();
|
||||||
|
~Qwen3Asr();
|
||||||
|
|
||||||
|
Qwen3Asr(const Qwen3Asr &) = delete;
|
||||||
|
Qwen3Asr & operator=(const Qwen3Asr &) = delete;
|
||||||
|
|
||||||
|
bool load(const AsrOptions & opt, std::string * error);
|
||||||
|
|
||||||
|
// 期望的输入采样率(Qwen3-ASR 为 16000)
|
||||||
|
int sampleRate() const;
|
||||||
|
|
||||||
|
struct Result {
|
||||||
|
bool ok = false;
|
||||||
|
std::string error;
|
||||||
|
ParsedTranscript transcript; // 已拆出 language / text
|
||||||
|
std::string raw; // 模型原始输出,便于排查
|
||||||
|
double inferSeconds = 0.0;
|
||||||
|
int tokenCount = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// pcm: 单声道 float32 [-1,1],采样率为 sampleRate()
|
||||||
|
Result transcribe(const float * pcm, size_t nSamples);
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct Impl;
|
||||||
|
Impl * d = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace lc
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
#include "transcript.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
|
|
||||||
|
namespace lc {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
std::string trim(const std::string & s)
|
||||||
|
{
|
||||||
|
const auto notSpace = [](unsigned char c) { return !std::isspace(c); };
|
||||||
|
auto begin = std::find_if(s.begin(), s.end(), notSpace);
|
||||||
|
auto end = std::find_if(s.rbegin(), s.rend(), notSpace).base();
|
||||||
|
if (begin >= end) return {};
|
||||||
|
return std::string(begin, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
ParsedTranscript parseAsrOutput(const std::string & raw)
|
||||||
|
{
|
||||||
|
ParsedTranscript out;
|
||||||
|
|
||||||
|
static const std::string kMarker = "<asr_text>";
|
||||||
|
const std::string trimmed = trim(raw);
|
||||||
|
|
||||||
|
const auto markerPos = trimmed.rfind(kMarker);
|
||||||
|
std::string head;
|
||||||
|
if (markerPos != std::string::npos) {
|
||||||
|
head = trimmed.substr(0, markerPos);
|
||||||
|
out.text = trimmed.substr(markerPos + kMarker.size());
|
||||||
|
} else {
|
||||||
|
head = trimmed;
|
||||||
|
out.text = trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 头部里找 "language <Name>"
|
||||||
|
// 允许前面有空白,语言名只取连续的字母
|
||||||
|
const std::string key = "language";
|
||||||
|
auto pos = head.find(key);
|
||||||
|
if (pos != std::string::npos) {
|
||||||
|
pos += key.size();
|
||||||
|
while (pos < head.size() && std::isspace(static_cast<unsigned char>(head[pos]))) ++pos;
|
||||||
|
size_t end = pos;
|
||||||
|
while (end < head.size() && std::isalpha(static_cast<unsigned char>(head[end]))) ++end;
|
||||||
|
out.language = head.substr(pos, end - pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
out.text = trim(out.text);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string languageDisplayName(const std::string & lang)
|
||||||
|
{
|
||||||
|
if (lang.empty()) return {};
|
||||||
|
|
||||||
|
static const struct { const char * key; const char * display; } kMap[] = {
|
||||||
|
{ "English", "英语" },
|
||||||
|
{ "Chinese", "中文" },
|
||||||
|
{ "Japanese", "日语" },
|
||||||
|
{ "Korean", "韩语" },
|
||||||
|
{ "French", "法语" },
|
||||||
|
{ "German", "德语" },
|
||||||
|
{ "Spanish", "西班牙语" },
|
||||||
|
{ "Russian", "俄语" },
|
||||||
|
{ "Italian", "意大利语" },
|
||||||
|
{ "Portuguese", "葡萄牙语" },
|
||||||
|
{ "Arabic", "阿拉伯语" },
|
||||||
|
{ "Cantonese", "粤语" },
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const auto & e : kMap) {
|
||||||
|
if (lang == e.key) return e.display;
|
||||||
|
}
|
||||||
|
return lang;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace lc
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace lc {
|
||||||
|
|
||||||
|
// Qwen3-ASR 原始输出形如:
|
||||||
|
// language English<asr_text>He hoped there would be stew for dinner...
|
||||||
|
// language Chinese<asr_text>不排除购房者可以在互联网上下单。
|
||||||
|
// 本结构把语言名和正文分开:语言名给 UI 的语言标签,正文才上屏。
|
||||||
|
struct ParsedTranscript {
|
||||||
|
std::string language; // 原始语言名,如 "English" / "Chinese";无则空
|
||||||
|
std::string text; // 转写正文
|
||||||
|
};
|
||||||
|
|
||||||
|
ParsedTranscript parseAsrOutput(const std::string & raw);
|
||||||
|
|
||||||
|
// "English" -> "英语","Chinese" -> "中文";未知则原样返回
|
||||||
|
std::string languageDisplayName(const std::string & lang);
|
||||||
|
|
||||||
|
} // namespace lc
|
||||||
Reference in new issue
Block a user