Files
live-caption/tests/tst_streamingasr.cpp
T
blumia 736ec14d5b refactor: split core/ui/apps targets and add unit tests
Move all business logic into liblivecaption_core (no Qt dependency) and
the widgets into liblivecaption_ui, leaving two executables: the caption
window and the CLI, which no longer links Qt Widgets. Abstract the ASR
backend behind IAsr so the streaming policy and the live pipeline can be
tested with a scripted fake backend instead of a real model.

把全部业务逻辑下沉到不依赖 Qt 的 liblivecaption_core,界面独立成
liblivecaption_ui,拆出悬浮窗与命令行两个可执行程序(CLI 不再链接 Qt
Widgets);并用 IAsr 抽象推理后端,使流式策略与实时管线可脱离真模型
用假实现做单元测试。测试还抓到一个真 bug:段恰好结束在 chunk 边界时
只提交了稳定部分,最后几个词被丢掉,已修。
2026-10-12 13:43:50 +08:00

175 lines
5.8 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));
}
// 同一次 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"