2026-10-12 13:43:50 +08:00
|
|
|
|
#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));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-12 14:22:29 +08:00
|
|
|
|
// 追加式:每块音频作为新一轮追加,文本立即定稿,不回喂任何前缀
|
|
|
|
|
|
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"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-12 13:43:50 +08:00
|
|
|
|
// 同一次 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"
|