diff --git a/CMakeLists.txt b/CMakeLists.txt index bd3cc11..f6a3561 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -65,6 +65,7 @@ add_executable(live-caption-cli src/main.cpp src/transcript.cpp src/qwen3asr.cpp + src/streamingasr.cpp src/audio.cpp ) diff --git a/README.md b/README.md index e9d62de..5d992bc 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Linux 桌面实时字幕:采集系统正在播放的声音,实时转写成 |---|---|---| | 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 2 | 原生流式解码(8s 切片 + 增量 KV 复用 + 前缀回滚) | ✅ 见 [docs/phase2-streaming.md](docs/phase2-streaming.md) | | Phase 3 | Qt 悬浮字幕窗(折叠/展开、语言标签、置顶) | 待做 | | Phase 4 | 设置界面(模型切换、语言、字体、位置) | 待做 | | Phase 5 | Wayland 支持、打包、模型分发 | 待做 | diff --git a/docs/phase2-streaming.md b/docs/phase2-streaming.md index ebece68..2ba5a08 100644 --- a/docs/phase2-streaming.md +++ b/docs/phase2-streaming.md @@ -1,52 +1,91 @@ -# Phase 2 计划:原生流式解码 +# Phase 2:原生流式解码 -目标:把「窗口重转写 + 事后取最长公共前缀」换成模型官方的流式解码 —— **前缀回滚(prefix rollback)**,消除 Phase 1 里「窗口在词中间被切断时模型自己编结尾」的问题。 +状态:**完成**。准确率与离线转写一致,单步计算稳定在 2s 预算内。 -实现方式:**在 libllama / libmtmd 上自己实现**(只保留一个推理运行时,复用已有的 CMake、模型管理、采集与解析代码)。 -参考实现:`antirez/qwen-asr`(纯 C,已实现 `--stream` 的前缀回滚与滑窗),其 MODEL.md 逐行记录了官方 Python 实现 `qwen_asr/inference/qwen3_asr.py` 的流式算法,用作对齐参数的对照。 +## 结论 -## 为什么不需要 vLLM +| 指标 | 结果 | +|---|---| +| 准确率 | 流式输出与离线转写逐字一致(中/英各自验证) | +| 单步计算 | 最慢 1.83s(预算 2.0s,提交粒度 2s) | +| 整体 RTF | 约 0.66 | +| 实现 | 纯 C++,libllama + libmtmd,无 Python | -官方文档写「streaming 只支持 vLLM 后端」,容易误解为依赖 vLLM 特性。实际上流式只是一套**解码策略**: +## 算法 + +官方文档说「streaming 只支持 vLLM 后端」,容易误解为依赖 vLLM 特性。实际上流式只是一套**解码策略**(`qwen_asr/inference/qwen3_asr.py`,`antirez/qwen-asr` 的 MODEL.md 有逐行记录): 1. 音频按 2s 累积; -2. 每次触发时把**已累积的全部音频重新过一遍编码器**(O(n²),被 2s 节奏限制住); -3. 解码器 prompt 里带上**上一轮转写文本(去掉最后 5 个 token)**作为 assistant 前缀,从那里继续生成; +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`。 -| 参数 | 值 | 说明 | -|---|---|---| -| `chunk_size_sec` | 2.0 s | 音频累积粒度 | -| `unfixed_token_num` | 5 | 前缀回滚:丢掉最后 5 个 token 再续写 | -| `unfixed_chunk_num` | 2 | 冷启动不开前缀;论文写 4,取保守值 | -| 滑窗上限 | ~16 s | 丢弃「文本已提交」对应的音频,把成本从 O(n²) 压到 O(n·窗口) | +## 关键发现:编码必须按 8 秒切片对齐 -## 与 Phase 1 原型的差距 +这是决定准确率的核心。编码器(AuT)按 `n_window_infer = 800` 帧(8s @100Hz)做窗口注意力,并按 100 帧一组做独立卷积。**把音频切得太碎去编码会明显掉准确率**: -| 已有 | 缺 | +| 编码粒度 | 结果 | |---|---| -| 2s 节奏的窗口重转写 | 把「上一轮文本 − 最后 5 token」拼进 assistant 前缀 | -| mtmd tokenize + eval 的完整路径 | 按 **token** 而非字符回滚(需跟踪生成的 token 序列) | -| 语言识别、输出解析、稳定前缀显示 | 滑窗:丢弃已提交文本对应的音频 | -| monitor 采集 + 重采样 | 用「已提交 token 数」定义稳定边界,替换事后取交集 | +| 2s | ❌ `dinner, or dinner,` / `boiled bruised potatoes` / `thick, thick` | +| 4s | ⚠️ 一处错(`browned bruised`、`thick, black`) | +| **8s** | ✅ 与离线逐字一致 | -> 注意:Phase 1 的「字符最长公共前缀」是错的替代品,官方的定义是「丢最后 5 个 token」。 +所以实现上**按 8s 对齐切片**:排满 8s 的切片固化进 KV 永久复用;尾部不满 8s 的部分每步重编码(临时的,下一步丢弃)。 -## 顺带简化:去掉 VAD +## 实现:增量 KV 复用 -官方明确不使用 VAD(模型自己处理静音;只有非流式长音频才用「在切点 ±5s 内找 100ms 最低能量点」切段)。流式模式下可以整个删掉能量 VAD,少一个需要调参的组件。 +与「每步重编码整个窗口」(早期做法)不同: -## Spike(先验证,再改产品代码) +- 新音频只编码一次; +- 已解码的音频留在 KV cache 里,不再重编码、也不再重新 prefill; +- 每步只做:`llama_memory_seq_rm` 回退到音频末尾 → 追加新音频(`mtmd_helper_eval_chunk_single`,它内部处理 M-RoPE 与分批)→ 解码音频之后的 prompt 文本与前缀 → 贪心生成。 -1. **效果**:同一段音频,对比「离线转写」与「带前缀回喂的流式」输出,确认流式结果连贯、不再编结尾。 -2. **延迟**:实测 2s 节奏下的单步耗时,重点看窗口涨到 16s 时是否仍跟得上(每步预算 < 2s)。 -3. 以上两条通过后,才把它落进 `Qwen3Asr`,并补一个流式模式的实机测试脚本。 +因此单步成本与音频总长无关,只与**当前段的长度**有关。 -## 风险 +对比(62.6s 英文音频,chunk=2s): -- **成本**:每步重编码累积音频,窗口越大越慢;若 16s 窗口跟不上,需改为「复用 KV cache + 只编码新 chunk」,并用 `llama_memory_seq_rm` 做回滚。 -- **前缀注入方式**:需要确认把文本前缀拼在 chat template 的 assistant 头之后,mtmd 能否正确切分(预期:`[text][text]` 三段,前缀属于第三段)。 -- **吞吐**:前缀越长,每步 prefill 越贵;滑窗是必须的,不是优化项。 +| 做法 | 墙钟 | 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 +# 流式转写(增量 + 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` 分段。 +- 目前每段结束都会重置语言检测;跨段语言切换的表现待观察。 +- 边界标点偶有差异(分段处 `—`/`,` 之类),不影响内容。 diff --git a/src/main.cpp b/src/main.cpp index c72b1e6..0f0b484 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -14,6 +14,7 @@ #include "audio.h" #include "qwen3asr.h" +#include "streamingasr.h" #include "transcript.h" namespace { @@ -237,6 +238,49 @@ int runFile(lc::Qwen3Asr & asr, const QString & path) return 0; } +int runStreamFile(lc::Qwen3Asr & asr, const QString & path, const lc::StreamOptions & sop) +{ + std::vector 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) chunk=%.1fs window=%.1fs " + "unfixed_token=%d unfixed_chunk=%d\n", + qUtf8Printable(path), dur, sop.chunkSec, sop.windowSec, + sop.unfixedTokenNum, sop.unfixedChunkNum); + + lc::StreamingAsr stream(asr, sop); + const size_t chunkSamples = (size_t) (sop.chunkSec * asr.sampleRate()); + + const auto onStep = [](const lc::StreamingAsr::Step & s) { + std::printf("\n[chunk %2d] 窗口 %5.1fs / 编码 %5.2fs / 本步 %5.2fs%s\n", + s.chunkIndex, s.windowSeconds, s.encodeSeconds, s.inferSeconds, + s.ok ? "" : " <失败>"); + if (!s.ok) { + std::printf(" error: %s\n", s.error.c_str()); + } + std::printf(" 已提交: %s\n", s.committed.c_str()); + std::printf(" 含尾部: %s\n", s.full.c_str()); + std::fflush(stdout); + }; + + // 模拟音频按 chunk 到达 + for (size_t off = 0; off < pcm.size(); off += chunkSamples) { + const size_t n = std::min(chunkSamples, pcm.size() - off); + stream.feed(pcm.data() + off, n, onStep); + } + stream.flush(onStep); + + std::printf("\n==== 最终提交文本 (%s) ====\n%s\n", + lc::languageDisplayName(stream.language()).c_str(), + stream.committedText().c_str()); + return 0; +} + int runLive(lc::Qwen3Asr & asr) { LiveTranscriber live(asr, asr.sampleRate()); @@ -275,6 +319,13 @@ int main(int argc, char ** argv) QCommandLineOption optMaxTokens("max-tokens", "单段最多生成 token 数", "n", "256"); QCommandLineOption optWarmup("warmup", "mtmd 初始化后做一次预热编码"); QCommandLineOption optVerbose("verbose", "输出 llama.cpp/mtmd 的详细日志"); + QCommandLineOption optStreamFile("stream-file", "以流式模式(chunk + 前缀回滚)转写 wav", "path"); + QCommandLineOption optStreamChunk("stream-chunk", "流式 chunk 秒数", "sec", "2.0"); + QCommandLineOption optStreamWindow("stream-window", "流式滑窗上限秒数(0=不滑窗)", "sec", "16"); + QCommandLineOption optUnfixedTokens("unfixed-tokens", "前缀回滚丢弃的 token 数", "n", "5"); + QCommandLineOption optUnfixedChunks("unfixed-chunks", "冷启动不带前缀的 chunk 数", "n", "2"); + QCommandLineOption optStreamMode("stream-mode", "流式后端: incremental(增量 KV 复用)或 window(重编码整个窗口)", "mode", "incremental"); + QCommandLineOption optStreamSegment("stream-segment", "单段最长秒数,超过则提交并重置上下文(0=不限)", "sec", "30"); parser.addOption(optModel); parser.addOption(optMmproj); @@ -283,6 +334,13 @@ int main(int argc, char ** argv) parser.addOption(optMaxTokens); parser.addOption(optWarmup); parser.addOption(optVerbose); + parser.addOption(optStreamFile); + parser.addOption(optStreamChunk); + parser.addOption(optStreamWindow); + parser.addOption(optUnfixedTokens); + parser.addOption(optUnfixedChunks); + parser.addOption(optStreamMode); + parser.addOption(optStreamSegment); parser.process(app); lc::AsrOptions asrOpt; @@ -312,5 +370,17 @@ int main(int argc, char ** argv) if (parser.isSet(optFile)) { return runFile(asr, parser.value(optFile)); } + + if (parser.isSet(optStreamFile)) { + lc::StreamOptions sop; + sop.chunkSec = parser.value(optStreamChunk).toDouble(); + sop.windowSec = parser.value(optStreamWindow).toDouble(); + sop.unfixedTokenNum = parser.value(optUnfixedTokens).toInt(); + sop.unfixedChunkNum = parser.value(optUnfixedChunks).toInt(); + sop.incremental = parser.value(optStreamMode) != QLatin1String("window"); + sop.maxUtteranceSec = parser.value(optStreamSegment).toDouble(); + return runStreamFile(asr, parser.value(optStreamFile), sop); + } + return runLive(asr); } diff --git a/src/qwen3asr.cpp b/src/qwen3asr.cpp index cc0a55f..73a02d7 100644 --- a/src/qwen3asr.cpp +++ b/src/qwen3asr.cpp @@ -30,6 +30,14 @@ struct Qwen3Asr::Impl { const llama_vocab * vocab = nullptr; int audioRate = 16000; + // 流式(增量)状态 + bool streamReady = false; + std::vector headTokens; // 音频之前的 prompt 文本 + std::vector tailTokens; // 音频之后的 prompt 文本(含 assistant 头) + std::vector streamAudio; // 当前这段的全部音频(不滑动) + size_t stableSamples = 0; // 已固化进 KV 的音频样本数(8s 对齐) + llama_pos nPastStable = 0; // stable 音频末尾的 KV 位置 + std::string buildPrompt(const std::string & marker) const { const char * tmpl = llama_model_chat_template(model, nullptr); @@ -43,6 +51,67 @@ struct Qwen3Asr::Impl { } return defaultQwenChatPrompt(marker); } + + // 用显式位置解码一批 token(便于在增量流式里精确控制位置) + bool decodeTokens(const llama_token * tokens, int32_t n, llama_pos startPos, bool wantLogits) + { + if (n <= 0) return true; + llama_batch b = llama_batch_init(n, 0, 1); + for (int32_t i = 0; i < n; ++i) { + b.token[i] = tokens[i]; + b.pos[i] = startPos + i; + b.n_seq_id[i] = 1; + b.seq_id[i][0] = 0; + b.logits[i] = (wantLogits && i == n - 1) ? 1 : 0; + } + b.n_tokens = n; + const int32_t rc = llama_decode(ctx, b); + llama_batch_free(b); + return rc == 0; + } + + // 把一段音频编码后追加到 KV 的 nPast 处 + bool appendAudio(const float * pcm, size_t nSamples, llama_pos & nPast, std::string * err) + { + if (nSamples == 0) return true; + + mtmd_bitmap * bmp = mtmd_bitmap_init_from_audio(nSamples, pcm); + if (!bmp) { if (err) *err = "构造音频 bitmap 失败"; return false; } + + mtmd_input_chunks * chunks = mtmd_input_chunks_init(); + const std::string marker = mtmd_default_marker(); + const mtmd_input_text itext{ marker.c_str(), false, true }; + const mtmd_bitmap * bms[1] = { bmp }; + + if (mtmd_tokenize(mctx, chunks, &itext, bms, 1) != 0) { + mtmd_bitmap_free(bmp); + mtmd_input_chunks_free(chunks); + if (err) *err = "音频 mtmd_tokenize 失败"; + return false; + } + + const mtmd_input_chunk * audioChunk = nullptr; + for (size_t i = 0; i < mtmd_input_chunks_size(chunks); ++i) { + const mtmd_input_chunk * c = mtmd_input_chunks_get(chunks, i); + if (mtmd_input_chunk_get_type(c) == MTMD_INPUT_CHUNK_TYPE_AUDIO) { audioChunk = c; break; } + } + if (!audioChunk) { + mtmd_bitmap_free(bmp); + mtmd_input_chunks_free(chunks); + if (err) *err = "tokenize 结果里没有音频块"; + return false; + } + + llama_pos np = nPast; + const int32_t rc = mtmd_helper_eval_chunk_single(mctx, ctx, audioChunk, nPast, /*seq_id*/ 0, + opt.nBatch, /*logits_last*/ false, &np); + mtmd_bitmap_free(bmp); + mtmd_input_chunks_free(chunks); + if (rc != 0) { if (err) *err = "音频编码/解码失败"; return false; } + + nPast = np; + return true; + } }; Qwen3Asr::Qwen3Asr() : d(new Impl) {} @@ -118,6 +187,41 @@ bool Qwen3Asr::load(const AsrOptions & opt, std::string * error) } Qwen3Asr::Result Qwen3Asr::transcribe(const float * pcm, size_t nSamples) +{ + return transcribeWithPrefix(pcm, nSamples, std::string()); +} + +std::string Qwen3Asr::dropLastTokens(const std::string & text, int dropLast) const +{ + if (!d->vocab || dropLast <= 0 || text.empty()) return text; + + std::vector tokens(text.size() + 16); + int32_t n = llama_tokenize(d->vocab, text.c_str(), (int32_t) text.size(), + tokens.data(), (int32_t) tokens.size(), + /*add_special*/ false, /*parse_special*/ true); + if (n < 0) { // 缓冲区不够,按返回的所需长度重来 + tokens.resize((size_t) -n); + n = llama_tokenize(d->vocab, text.c_str(), (int32_t) text.size(), + tokens.data(), (int32_t) tokens.size(), false, true); + if (n < 0) return text; + } + tokens.resize((size_t) n); + if ((int) tokens.size() <= dropLast) return {}; + + tokens.resize(tokens.size() - (size_t) dropLast); + + // special=true:让 这类特殊标记以字面形式保留,前缀才能原样回喂 + std::string out; + char buf[512]; + for (llama_token id : tokens) { + const int32_t m = llama_token_to_piece(d->vocab, id, buf, sizeof(buf), 0, /*special*/ true); + if (m > 0) out.append(buf, (size_t) m); + } + return out; +} + +Qwen3Asr::Result Qwen3Asr::transcribeWithPrefix(const float * pcm, size_t nSamples, + const std::string & prefix) { Result res; @@ -136,7 +240,8 @@ Qwen3Asr::Result Qwen3Asr::transcribe(const float * pcm, size_t nSamples) llama_memory_clear(llama_get_memory(d->ctx), true); const std::string marker = mtmd_default_marker(); - const std::string prompt = d->buildPrompt(marker); + // 关键:assistant 头之后接上"已生成文本",让模型从那里继续生成(前缀回滚) + const std::string prompt = d->buildPrompt(marker) + prefix; mtmd_bitmap * bitmap = mtmd_bitmap_init_from_audio(nSamples, pcm); if (!bitmap) { @@ -199,4 +304,180 @@ Qwen3Asr::Result Qwen3Asr::transcribe(const float * pcm, size_t nSamples) return res; } +// --------------------------------------------------------------------------- +// 流式(增量 KV 复用) +// --------------------------------------------------------------------------- + +void Qwen3Asr::streamReset() +{ + if (!d->ctx) return; + llama_memory_clear(llama_get_memory(d->ctx), true); + d->streamReady = false; + d->headTokens.clear(); + d->tailTokens.clear(); + d->streamAudio.clear(); + d->stableSamples = 0; + d->nPastStable = 0; +} + +bool Qwen3Asr::prepareStream(std::string * error) +{ + if (d->streamReady) return true; + + // 用一个占位音频 tokenize 一次,只为切出「音频之前」和「音频之后」的 prompt 文本 token。 + // 占位音频本身不会被解码。 + std::vector dummy(1600, 0.0f); // 0.1s + mtmd_bitmap * bmp = mtmd_bitmap_init_from_audio(dummy.size(), dummy.data()); + mtmd_input_chunks * chunks = mtmd_input_chunks_init(); + + const std::string marker = mtmd_default_marker(); + const std::string prompt = d->buildPrompt(marker); + const mtmd_input_text itext{ prompt.c_str(), true, true }; + const mtmd_bitmap * bms[1] = { bmp }; + + if (mtmd_tokenize(d->mctx, chunks, &itext, bms, 1) != 0) { + mtmd_bitmap_free(bmp); + mtmd_input_chunks_free(chunks); + if (error) *error = "prepareStream: mtmd_tokenize 失败"; + return false; + } + mtmd_bitmap_free(bmp); + + bool seenAudio = false; + for (size_t i = 0; i < mtmd_input_chunks_size(chunks); ++i) { + const mtmd_input_chunk * c = mtmd_input_chunks_get(chunks, i); + if (mtmd_input_chunk_get_type(c) == MTMD_INPUT_CHUNK_TYPE_AUDIO) { + seenAudio = true; + continue; + } + size_t n = 0; + const llama_token * toks = mtmd_input_chunk_get_tokens_text(c, &n); + if (!toks || n == 0) continue; + std::vector & dst = seenAudio ? d->tailTokens : d->headTokens; + dst.insert(dst.end(), toks, toks + n); + } + mtmd_input_chunks_free(chunks); + + if (d->headTokens.empty() || d->tailTokens.empty()) { + if (error) *error = "prepareStream: 没能切出音频前后的 prompt 文本"; + return false; + } + + llama_memory_clear(llama_get_memory(d->ctx), true); + if (!d->decodeTokens(d->headTokens.data(), (int32_t) d->headTokens.size(), 0, false)) { + if (error) *error = "prepareStream: 解码 prompt 头部失败"; + return false; + } + d->nPastStable = (llama_pos) d->headTokens.size(); + d->streamReady = true; + return true; +} + +// 8 秒对齐的音频切片:这是编码器的注意力窗口(n_window_infer = 800 帧 @100Hz)。 +// 按这个粒度切,编码结果才和「整段音频一次编码」一致;切小了会明显掉准确率。 +static constexpr double kSliceSeconds = 8.0; + +Qwen3Asr::StreamStep Qwen3Asr::streamAppend(const float * pcm, size_t nSamples, + const std::string & prefix, int maxTokens) +{ + StreamStep res; + if (!d->model || !d->ctx || !d->mctx) { res.error = "模型未加载"; return res; } + + std::string err; + if (!prepareStream(&err)) { res.error = err; return res; } + + const auto t0 = std::chrono::steady_clock::now(); + + d->streamAudio.insert(d->streamAudio.end(), pcm, pcm + nSamples); + + const size_t sliceSamples = (size_t) (kSliceSeconds * d->audioRate); + const size_t targetStable = (d->streamAudio.size() / sliceSamples) * sliceSamples; + + const llama_pos nPastBefore = d->nPastStable; + + // 1) 已排满 8s 的切片固化进 KV,之后永远复用 + if (targetStable > d->stableSamples) { + llama_memory_seq_rm(llama_get_memory(d->ctx), 0, d->nPastStable, -1); + llama_pos np = d->nPastStable; + if (!d->appendAudio(d->streamAudio.data() + d->stableSamples, + targetStable - d->stableSamples, np, &err)) { + res.error = "固化音频切片失败: " + err; + return res; + } + d->nPastStable = np; + d->stableSamples = targetStable; + } + + // 2) 尾部不满 8s 的部分:每次重编码,但只是临时的(下一步会被丢掉) + llama_memory_seq_rm(llama_get_memory(d->ctx), 0, d->nPastStable, -1); + llama_pos nPast = d->nPastStable; + if (d->streamAudio.size() > d->stableSamples) { + if (!d->appendAudio(d->streamAudio.data() + d->stableSamples, + d->streamAudio.size() - d->stableSamples, nPast, &err)) { + res.error = "编码尾部音频失败: " + err; + return res; + } + } + res.audioTokens = (int) (nPast - nPastBefore); + const auto tEnc = std::chrono::steady_clock::now(); + + // 3) 音频之后的 prompt 文本 + 前缀 + std::vector prefixToks; + if (!prefix.empty()) { + prefixToks.resize(prefix.size() + 16); + int32_t n = llama_tokenize(d->vocab, prefix.c_str(), (int32_t) prefix.size(), + prefixToks.data(), (int32_t) prefixToks.size(), + /*add_special*/ false, /*parse_special*/ true); + if (n < 0) { + prefixToks.resize((size_t) -n); + n = llama_tokenize(d->vocab, prefix.c_str(), (int32_t) prefix.size(), + prefixToks.data(), (int32_t) prefixToks.size(), false, true); + } + if (n < 0) { res.error = "前缀分词失败"; return res; } + prefixToks.resize((size_t) n); + } + + llama_pos p = nPast; + const bool prefixLast = prefixToks.empty(); + if (!d->decodeTokens(d->tailTokens.data(), (int32_t) d->tailTokens.size(), p, prefixLast)) { + res.error = "解码 tail 失败"; + return res; + } + p += (llama_pos) d->tailTokens.size(); + + if (!prefixToks.empty()) { + if (!d->decodeTokens(prefixToks.data(), (int32_t) prefixToks.size(), p, true)) { + res.error = "解码前缀失败"; + return res; + } + p += (llama_pos) prefixToks.size(); + } + + // 4) 贪心生成(每个 token 的 logits 都要留给下一次采样) + 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 < 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.newTokens; + + if (!d->decodeTokens(&id, 1, p, true)) { res.error = "生成时解码失败"; break; } + ++p; + } + llama_sampler_free(smpl); + + const auto t1 = std::chrono::steady_clock::now(); + res.encodeSeconds = std::chrono::duration(tEnc - t0).count(); + res.inferSeconds = std::chrono::duration(t1 - t0).count(); + res.newText = out; + res.ok = res.error.empty(); + return res; +} + } // namespace lc diff --git a/src/qwen3asr.h b/src/qwen3asr.h index 967e986..ddbe220 100644 --- a/src/qwen3asr.h +++ b/src/qwen3asr.h @@ -46,7 +46,35 @@ public: // pcm: 单声道 float32 [-1,1],采样率为 sampleRate() Result transcribe(const float * pcm, size_t nSamples); + // 流式单步:把 prefix 当作"已经生成的 assistant 文本"接在 assistant 头之后, + // 让模型从 prefix 末尾继续生成。官方流式算法的核心(前缀回滚)就靠它。 + Result transcribeWithPrefix(const float * pcm, size_t nSamples, const std::string & prefix); + + // 用模型词表把 text 编码成 token、丢掉最后 dropLast 个、再还原成文本。 + // 对应官方的 unfixed_token_num 回滚。 + std::string dropLastTokens(const std::string & text, int dropLast) const; + + // ---- 流式(增量 KV 复用)---- + // 与上面每次重编码整个窗口的做法不同:这里只对新到的音频做一次编码, + // 已经解码过的音频留在 KV cache 里复用,因此每步成本与音频总长无关。 + void streamReset(); // 清空 KV 与流式状态 + + struct StreamStep { + bool ok = false; + std::string error; + std::string newText; // 本步新生成的文本(不含前缀,未 trim) + int newTokens = 0; + int audioTokens = 0; // 本步新追加的音频 token 数 + double encodeSeconds = 0.0; + double inferSeconds = 0.0; + }; + // 追加一段音频,在 assistant 头之后接上 prefix 作为"已生成文本"继续生成。 + StreamStep streamAppend(const float * pcm, size_t nSamples, + const std::string & prefix, int maxTokens); + private: + bool prepareStream(std::string * error); + struct Impl; Impl * d = nullptr; }; diff --git a/src/streamingasr.cpp b/src/streamingasr.cpp new file mode 100644 index 0000000..4aa918d --- /dev/null +++ b/src/streamingasr.cpp @@ -0,0 +1,143 @@ +#include "streamingasr.h" + +#include "qwen3asr.h" +#include "transcript.h" + +namespace lc { + +StreamingAsr::StreamingAsr(Qwen3Asr & asr, StreamOptions opt) + : asr_(asr), opt_(opt) +{ + asr_.streamReset(); +} + +void StreamingAsr::reset() +{ + asr_.streamReset(); + buffer_.clear(); + pending_.clear(); + totalSeconds_ = 0.0; + committedRaw_.clear(); + language_.clear(); + chunkIndex_ = 0; +} + +void StreamingAsr::resetAll() +{ + reset(); + historyText_.clear(); +} + +std::string StreamingAsr::committedText() const +{ + return historyText_ + parseAsrOutput(committedRaw_).text; +} + +void StreamingAsr::feed(const float * pcm, size_t n, + const std::function & onStep) +{ + pending_.insert(pending_.end(), pcm, pcm + n); + + const size_t chunkSamples = (size_t) (opt_.chunkSec * asr_.sampleRate()); + if (chunkSamples == 0) return; + + while (pending_.size() >= chunkSamples) { + std::vector chunk(pending_.begin(), pending_.begin() + (long) chunkSamples); + pending_.erase(pending_.begin(), pending_.begin() + (long) chunkSamples); + runStep(chunk, false, onStep); + } +} + +void StreamingAsr::flush(const std::function & onStep) +{ + if (pending_.empty()) return; + + std::vector chunk; + chunk.swap(pending_); + // 流结束:没有更多音频可以修正尾部了,这一步全部提交 + runStep(chunk, true, onStep); +} + +void StreamingAsr::runStep(const std::vector & audio, bool finalStep, + const std::function & onStep) +{ + // 冷启动阶段不给前缀;之后把"已稳定文本"回喂给模型 + const std::string prefix = + (chunkIndex_ >= opt_.unfixedChunkNum) ? committedRaw_ : std::string(); + + Step s; + s.chunkIndex = chunkIndex_; + + std::string newText; + if (opt_.incremental) { + // 只把这一块交给增量引擎;音频总长不影响单步成本 + Qwen3Asr::StreamStep r = + asr_.streamAppend(audio.data(), audio.size(), prefix, opt_.maxTokensPerStep); + s.ok = r.ok; + s.error = r.error; + s.encodeSeconds = r.encodeSeconds; + s.inferSeconds = r.inferSeconds; + s.audioTokens = r.audioTokens; + newText = r.newText; + totalSeconds_ += (double) audio.size() / asr_.sampleRate(); + s.windowSeconds = totalSeconds_; + } else { + // window 模式:累积到 buffer,滑动窗口后重编码整个窗口 + buffer_.insert(buffer_.end(), audio.begin(), audio.end()); + if (opt_.windowSec > 0) { + const size_t maxSamples = (size_t) (opt_.windowSec * asr_.sampleRate()); + if (buffer_.size() > maxSamples) { + buffer_.erase(buffer_.begin(), + buffer_.begin() + (long) (buffer_.size() - maxSamples)); + } + } + Qwen3Asr::Result r = + asr_.transcribeWithPrefix(buffer_.data(), buffer_.size(), prefix); + s.ok = r.ok; + s.error = r.error; + s.inferSeconds = r.inferSeconds; + s.windowSeconds = (double) buffer_.size() / asr_.sampleRate(); + newText = r.raw; + } + + if (s.ok) { + // 拼接要用未经 trim 的文本:流式续写时开头那个空格是有意义的 + s.rawFull = prefix + newText; + + const ParsedTranscript pSeg = parseAsrOutput(s.rawFull); + if (!pSeg.language.empty()) language_ = pSeg.language; + + // 这一段是否到此为止(流结束,或超过单段时长上限) + const bool endSegment = + finalStep || (opt_.maxUtteranceSec > 0 && totalSeconds_ >= opt_.maxUtteranceSec); + + if (endSegment) { + // 没有更多音频可以修正尾部了,全部提交,并把正文并入历史 + committedRaw_ = s.rawFull; + historyText_ += pSeg.text; + s.full = historyText_; + s.committed = historyText_; + } else { + // 最后 unfixedTokenNum 个 token 视为不稳定,不提交也不回喂 + committedRaw_ = asr_.dropLastTokens(s.rawFull, opt_.unfixedTokenNum); + s.full = historyText_ + pSeg.text; + s.committed = historyText_ + parseAsrOutput(committedRaw_).text; + } + s.language = language_; + } + + ++chunkIndex_; + if (onStep) onStep(s); + + if (s.ok && !finalStep && + opt_.maxUtteranceSec > 0 && totalSeconds_ >= opt_.maxUtteranceSec) { + // 收束这一段:保留历史正文,清空 KV 重新开始 + asr_.streamReset(); + buffer_.clear(); + totalSeconds_ = 0.0; + committedRaw_.clear(); + chunkIndex_ = 0; + } +} + +} // namespace lc diff --git a/src/streamingasr.h b/src/streamingasr.h new file mode 100644 index 0000000..02c731f --- /dev/null +++ b/src/streamingasr.h @@ -0,0 +1,82 @@ +#pragma once + +#include +#include +#include +#include + +namespace lc { + +class Qwen3Asr; + +// 官方流式解码的参数(见 docs/phase2-streaming.md) +struct StreamOptions { + double chunkSec = 2.0; // 累积多少秒音频跑一步 + double windowSec = 16.0; // 仅 window 模式使用:滑窗上限,<=0 表示不滑窗 + int unfixedTokenNum = 5; // 前缀回滚:丢掉最后 N 个 token + int unfixedChunkNum = 2; // 前 N 个 chunk 不带前缀(冷启动) + int maxTokensPerStep = 256; + + // true = 增量 KV 复用:只编码新音频,已解码音频留在 KV(推荐) + // false = 每次重编码整个窗口(Phase 2 早期的做法,仅用于对比) + bool incremental = true; + + // 单段最长时长(秒):超过就提交并重置上下文(0 = 不限)。 + // 真实场景应由静音检测决定分段;这里是一个兜底上界, + // 因为解码器对 KV 的注意力随总长增长,段太长单步会超预算。 + double maxUtteranceSec = 30.0; +}; + +// 把「累积音频 → 带前缀继续生成 → 前缀回滚」这套策略封装起来。 +// 输出分两部分: +// committed —— 已稳定文本(就是下一步要回喂的前缀) +// full —— 含不稳定尾部(最后 unfixedTokenNum 个 token) +class StreamingAsr { +public: + struct Step { + int chunkIndex = 0; + std::string language; + std::string committed; // 稳定部分 + std::string full; // 含不稳定尾部 + std::string rawFull; // 未 trim 的完整文本(含 language 头) + double windowSeconds = 0.0; + double encodeSeconds = 0.0; + double inferSeconds = 0.0; + int audioTokens = 0; + bool ok = true; + std::string error; + }; + + StreamingAsr(Qwen3Asr & asr, StreamOptions opt = {}); + + // 喂入 16k 单声道音频;每凑够 chunkSec 就跑一步并回调 + void feed(const float * pcm, size_t n, const std::function & onStep); + // 处理不足一块的残留音频,并作为最后一步提交全部文本 + void flush(const std::function & onStep); + + // 结束当前这段(例如检测到静音):清空 KV 与状态,下一段重新开始 + void reset(); + // 重置全部状态(含历史文本) + void resetAll(); + + // 全部已提交文本(含此前各段,已剥离 language 头) + std::string committedText() const; + const std::string & language() const { return language_; } + int chunkCount() const { return chunkIndex_; } + +private: + void runStep(const std::vector & audio, bool finalStep, + const std::function & onStep); + + Qwen3Asr & asr_; + StreamOptions opt_; + std::vector buffer_; // window 模式:当前窗口音频 + std::vector pending_; // 尚未凑够一块的音频 + double totalSeconds_ = 0.0; + std::string committedRaw_; // 当前段的原始已提交文本(含 language 头,用于回喂) + std::string historyText_; // 此前各段已提交的正文(已剥离 language 头) + std::string language_; + int chunkIndex_ = 0; +}; + +} // namespace lc