feat(stream): add incremental streaming decode with 8s slice reuse

Add native streaming ASR: 8s-aligned audio slices are cached in the KV
cache, only new audio is encoded, and the previous text is fed back as
an assistant prefix with token rollback. Text matches offline results.
Streaming keeps each step within the 2s budget for segments up to 20s.

新增原生流式解码:8s 对齐的音频切片存入 KV 复用,只编码新音频,并把
上一轮文本作为 assistant 前缀回喂(带 token 回滚)。转写结果与离线
一致;20s 以内的段落每步计算都在 2s 预算内。
This commit is contained in:
blumia committed 2026-10-12 13:01:26 +08:00
1 parent 18dbc9c8d3
commit 21d68e2294
8 files changed
+678 -34

No files matched your search

+1
View File
@@ -65,6 +65,7 @@ add_executable(live-caption-cli
src/main.cpp
src/transcript.cpp
src/qwen3asr.cpp
src/streamingasr.cpp
src/audio.cpp
)
+1 -1
View File
@@ -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 支持、打包、模型分发 | 待做 |
+71 -32
View File
@@ -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]<media>[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` 分段。
- 目前每段结束都会重置语言检测;跨段语言切换的表现待观察。
- 边界标点偶有差异(分段处 `—`/`,` 之类),不影响内容。
+70
View File
@@ -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<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) 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);
}
+282 -1
View File
@@ -30,6 +30,14 @@ struct Qwen3Asr::Impl {
const llama_vocab * vocab = nullptr;
int audioRate = 16000;
// 流式(增量)状态
bool streamReady = false;
std::vector<llama_token> headTokens; // 音频之前的 prompt 文本
std::vector<llama_token> tailTokens; // 音频之后的 prompt 文本(含 assistant 头)
std::vector<float> 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<llama_token> 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:让 <asr_text> 这类特殊标记以字面形式保留,前缀才能原样回喂
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<float> 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<llama_token> & 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<llama_token> 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<double>(tEnc - t0).count();
res.inferSeconds = std::chrono::duration<double>(t1 - t0).count();
res.newText = out;
res.ok = res.error.empty();
return res;
}
} // namespace lc
+28
View File
@@ -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;
};
+143
View File
@@ -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<void(const Step &)> & 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<float> 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<void(const Step &)> & onStep)
{
if (pending_.empty()) return;
std::vector<float> chunk;
chunk.swap(pending_);
// 流结束:没有更多音频可以修正尾部了,这一步全部提交
runStep(chunk, true, onStep);
}
void StreamingAsr::runStep(const std::vector<float> & audio, bool finalStep,
const std::function<void(const Step &)> & 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
+82
View File
@@ -0,0 +1,82 @@
#pragma once
#include <cstddef>
#include <functional>
#include <string>
#include <vector>
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<void(const Step &)> & onStep);
// 处理不足一块的残留音频,并作为最后一步提交全部文本
void flush(const std::function<void(const Step &)> & 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<float> & audio, bool finalStep,
const std::function<void(const Step &)> & onStep);
Qwen3Asr & asr_;
StreamOptions opt_;
std::vector<float> buffer_; // window 模式:当前窗口音频
std::vector<float> pending_; // 尚未凑够一块的音频
double totalSeconds_ = 0.0;
std::string committedRaw_; // 当前段的原始已提交文本(含 language 头,用于回喂)
std::string historyText_; // 此前各段已提交的正文(已剥离 language 头)
std::string language_;
int chunkIndex_ = 0;
};
} // namespace lc