Files
live-caption/tests/tst_streamingasr.cpp
T
blumia 85b206f1cd feat(stream): add append-only streaming mode with flat cost
Add an append-only streaming path that feeds each audio chunk as a new
conversation turn, so nothing is ever recomputed: the KV grows by
append, no prefix is re-decoded and no audio is re-encoded. Per-step
cost becomes flat instead of growing with the segment (RTF 1.06 -> 0.26
on 62.6s of continuous speech), but accuracy requires ~8s chunks because
the audio encoder needs its 8s attention window, so text only appears
every 8s. The incremental mode stays the default.

新增追加式流式:每块音频作为一个新的对话轮追加,前缀不重算、旧音频不重
编码,单步成本不再随段落增长(连续 62.6s 语音 RTF 从 1.06 降到 0.26)。
但准确率要求 chunk≈8s,否则会被截断,所以出字粒度是 8s;增量式仍为默认。
2026-10-12 14:22:29 +08:00

202 lines
6.9 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include <QtTest>
#include <string>
#include <vector>
#include "fakeasr.h"
#include "streamingasr.h"
namespace {
std::vector<float> chunk(size_t samples, float value = 0.1f)
{
return std::vector<float>(samples, value);
}
QString q(const std::string & s) { return QString::fromStdString(s); }
} // namespace
// 流式策略的回归测试(用 FakeAsr,不加载真模型)。
class TstStreamingAsr : public QObject {
Q_OBJECT
private:
static lc::StreamOptions baseOptions()
{
lc::StreamOptions o;
o.chunkSec = 1.0; // 1s 一块,方便构造
o.unfixedTokenNum = 1; // 回滚 1 个 token,断言更直观
o.unfixedChunkNum = 0; // 默认就回喂前缀
o.incremental = true;
return o;
}
private slots:
// 冷启动阶段(前 unfixedChunkNum 块)不回喂前缀
void coldStartHasNoPrefix()
{
FakeAsr fake;
fake.scripted = {"a b c d", "e f", "g h"};
lc::StreamOptions o = baseOptions();
o.unfixedChunkNum = 2;
lc::StreamingAsr stream(fake, o);
const auto cb = [](const lc::StreamingAsr::Step &) {};
const auto c = chunk(16000);
stream.feed(c.data(), c.size(), cb);
stream.feed(c.data(), c.size(), cb);
stream.feed(c.data(), c.size(), cb);
QCOMPARE(fake.prefixesSeen.size(), size_t(3));
QCOMPARE(q(fake.prefixesSeen[0]), QString(""));
QCOMPARE(q(fake.prefixesSeen[1]), QString(""));
QVERIFY(!fake.prefixesSeen[2].empty());
}
// 续写开头那个空格不能被吃掉。
// 曾经用 trim 过的文本去拼接,导致 "potatoesand" 这种缺空格。
void keepsJoiningSpace()
{
FakeAsr fake;
fake.scripted = {"language English<asr_text>He hoped there",
" would be stew"};
lc::StreamingAsr stream(fake, baseOptions());
std::vector<lc::StreamingAsr::Step> steps;
const auto cb = [&steps](const lc::StreamingAsr::Step & s) { steps.push_back(s); };
const auto c = chunk(16000);
stream.feed(c.data(), c.size(), cb);
stream.feed(c.data(), c.size(), cb);
QCOMPARE(steps.size(), size_t(2));
QCOMPARE(q(steps[0].full), QString("He hoped there"));
QVERIFY2(q(steps[1].full) == QString("He hoped would be stew"),
qPrintable(QString("实际为 \"%1\"").arg(q(steps[1].full))));
}
// 已提交文本 = 完整文本去掉最后 unfixedTokenNum 个 token;
// 尾部单独作为不稳定部分给出。
void committedDropsUnstableTail()
{
FakeAsr fake;
fake.scripted = {"language English<asr_text>He hoped there",
" would be stew"};
lc::StreamingAsr stream(fake, baseOptions());
std::vector<lc::StreamingAsr::Step> steps;
const auto cb = [&steps](const lc::StreamingAsr::Step & s) { steps.push_back(s); };
const auto c = chunk(16000);
stream.feed(c.data(), c.size(), cb);
stream.feed(c.data(), c.size(), cb);
QCOMPARE(q(steps[1].committed), QString("He hoped would be"));
QCOMPARE(q(steps[1].tail), QString(" stew"));
// 已提交 + 尾部 = 完整文本
QCOMPARE(q(steps[1].committed + steps[1].tail), q(steps[1].full));
}
// 流结束时要全部提交,不能把尾部丢掉
void flushCommitsEverything()
{
FakeAsr fake;
fake.scripted = {"language English<asr_text>one two three"};
lc::StreamOptions o = baseOptions();
o.chunkSec = 100.0; // 特意设大:让音频留在 pending 里,由 flush 处理
lc::StreamingAsr stream(fake, o);
std::vector<lc::StreamingAsr::Step> steps;
const auto cb = [&steps](const lc::StreamingAsr::Step & s) { steps.push_back(s); };
const auto c = chunk(16000);
stream.feed(c.data(), c.size(), cb);
QCOMPARE(steps.size(), size_t(0)); // 还没凑够一块
stream.flush(cb);
QCOMPARE(steps.size(), size_t(1));
QCOMPARE(q(steps[0].committed), QString("one two three"));
QVERIFY(steps[0].tail.empty());
}
// 多段:每段各自的 language 头都要剥掉,前面几段的正文不能丢。
// 曾经用整段拼接后再解析,rfind 只保留最后一个头之后的内容,前面的正文全没了。
void segmentsAccumulateWithoutLosingEarlierText()
{
FakeAsr fake;
fake.scripted = {"language Chinese<asr_text>ni hao shi jie",
" language English<asr_text> hello there world"};
lc::StreamOptions o = baseOptions();
o.chunkSec = 1.0;
lc::StreamingAsr stream(fake, o);
const auto cb = [](const lc::StreamingAsr::Step &) {};
const auto c = chunk(16000);
stream.feed(c.data(), c.size(), cb); // 第 1 段
stream.flush(cb); // 收束第 1 段
stream.feed(c.data(), c.size(), cb); // 第 2 段
stream.flush(cb);
const QString all = q(stream.committedText());
QVERIFY2(all.contains("ni hao"), qPrintable(all));
QVERIFY2(all.contains("hello there world"), qPrintable(all));
}
// 追加式:每块音频作为新一轮追加,文本立即定稿,不回喂任何前缀
void appendModeCommitsEachChunkImmediately()
{
FakeAsr fake;
fake.scripted = {"language English<asr_text>one two",
" language English<asr_text> three four"};
lc::StreamOptions o = baseOptions();
o.appendOnly = true;
lc::StreamingAsr stream(fake, o);
std::vector<lc::StreamingAsr::Step> steps;
const auto cb = [&steps](const lc::StreamingAsr::Step & s) { steps.push_back(s); };
const auto c = chunk(16000);
stream.feed(c.data(), c.size(), cb);
stream.feed(c.data(), c.size(), cb);
QCOMPARE(fake.turnsSeen.size(), size_t(2));
QVERIFY2(fake.prefixesSeen.empty(), "追加式不应该回喂前缀");
QCOMPARE(steps.size(), size_t(2));
QCOMPARE(q(steps[0].committed), QString("one two"));
QVERIFY(steps[0].tail.empty()); // 追加式没有"不稳定尾部"
QVERIFY(q(steps[1].committed).contains("three four"));
}
// 同一次 feed 里跨多块也要正确切分
void splitsMultipleChunksInOneFeed()
{
FakeAsr fake;
fake.scripted = {"a b c", "d e f", "g h i"};
lc::StreamOptions o = baseOptions();
lc::StreamingAsr stream(fake, o);
int steps = 0;
const auto cb = [&steps](const lc::StreamingAsr::Step &) { ++steps; };
const auto c = chunk(16000 * 3);
stream.feed(c.data(), c.size(), cb);
QCOMPARE(steps, 3);
QCOMPARE(fake.samplesSeen.size(), size_t(3));
for (size_t n : fake.samplesSeen) QCOMPARE(n, size_t(16000));
}
};
QTEST_MAIN(TstStreamingAsr)
#include "tst_streamingasr.moc"