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

+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