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 边界时 只提交了稳定部分,最后几个词被丢掉,已修。
This commit is contained in:
1 parent
4ed4e23282
commit
736ec14d5b
32 files changed
+1502
-655
No files matched your search
@@ -0,0 +1,15 @@
|
||||
# 单元测试:只依赖 core,不需要模型文件、不需要显示环境。
|
||||
# 需要真模型的集成测试不放在这里(会另外打 label)。
|
||||
find_package(Qt6 REQUIRED COMPONENTS Test)
|
||||
|
||||
function(lc_add_test name)
|
||||
add_executable(${name} ${name}.cpp)
|
||||
target_link_libraries(${name} PRIVATE livecaption_core Qt6::Test)
|
||||
target_include_directories(${name} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
add_test(NAME ${name} COMMAND ${name})
|
||||
endfunction()
|
||||
|
||||
lc_add_test(tst_transcript)
|
||||
lc_add_test(tst_vad)
|
||||
lc_add_test(tst_streamingasr)
|
||||
lc_add_test(tst_livepipeline)
|
||||
@@ -0,0 +1,75 @@
|
||||
#pragma once
|
||||
|
||||
#include <cctype>
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "iasr.h"
|
||||
|
||||
// 可脚本化的假推理后端:让流式策略与实时管线可以脱离真模型测试。
|
||||
//
|
||||
// dropLastTokens 用"空格分词"模拟词表:丢掉最后 N 个词,并保留原有格式
|
||||
// (这点很重要 —— 续写开头那个空格是有意义的)。
|
||||
class FakeAsr : public lc::IAsr {
|
||||
public:
|
||||
// 每次 streamAppend 依次返回这里的文本;用完后返回空串
|
||||
std::vector<std::string> scripted;
|
||||
|
||||
// 记录(供断言用)
|
||||
std::vector<std::string> prefixesSeen;
|
||||
std::vector<size_t> samplesSeen;
|
||||
int resets = 0;
|
||||
|
||||
int sampleRate() const override { return 16000; }
|
||||
|
||||
lc::AsrResult transcribeWithPrefix(const float *, size_t,
|
||||
const std::string &) override
|
||||
{
|
||||
lc::AsrResult r;
|
||||
r.ok = true;
|
||||
if (call_ < scripted.size()) {
|
||||
r.raw = scripted[call_];
|
||||
r.transcript = lc::parseAsrOutput(r.raw);
|
||||
}
|
||||
++call_;
|
||||
return r;
|
||||
}
|
||||
|
||||
lc::AsrStreamStep streamAppend(const float *, size_t nSamples,
|
||||
const std::string & prefix, int) override
|
||||
{
|
||||
prefixesSeen.push_back(prefix);
|
||||
samplesSeen.push_back(nSamples);
|
||||
|
||||
lc::AsrStreamStep s;
|
||||
s.ok = true;
|
||||
if (call_ < scripted.size()) s.newText = scripted[call_];
|
||||
++call_;
|
||||
s.newTokens = 1;
|
||||
s.encodeSeconds = 0.001;
|
||||
s.inferSeconds = 0.001;
|
||||
return s;
|
||||
}
|
||||
|
||||
void streamReset() override { ++resets; }
|
||||
|
||||
std::string dropLastTokens(const std::string & text, int dropLast) const override
|
||||
{
|
||||
if (dropLast <= 0 || text.empty()) return text;
|
||||
|
||||
const auto isSpace = [](char c) { return std::isspace((unsigned char) c) != 0; };
|
||||
|
||||
size_t pos = text.size();
|
||||
for (int i = 0; i < dropLast; ++i) {
|
||||
while (pos > 0 && isSpace(text[pos - 1])) --pos; // 跳过尾部空白
|
||||
while (pos > 0 && !isSpace(text[pos - 1])) --pos; // 跳过一个词
|
||||
if (pos == 0) return {};
|
||||
}
|
||||
while (pos > 0 && isSpace(text[pos - 1])) --pos;
|
||||
return text.substr(0, pos);
|
||||
}
|
||||
|
||||
private:
|
||||
size_t call_ = 0;
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
#include <QtTest>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
|
||||
#include "fakeasr.h"
|
||||
#include "livepipeline.h"
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr double kPi = 3.14159265358979323846;
|
||||
|
||||
std::vector<float> tone(size_t samples, float amplitude)
|
||||
{
|
||||
std::vector<float> v(samples);
|
||||
for (size_t i = 0; i < samples; ++i) {
|
||||
v[i] = amplitude * (float) std::sin(2.0 * kPi * 440.0 * (double) i / 16000.0);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
size_t totalSamples(const std::vector<size_t> & v)
|
||||
{
|
||||
size_t n = 0;
|
||||
for (size_t s : v) n += s;
|
||||
return n;
|
||||
}
|
||||
|
||||
lc::LivePipelineOptions options()
|
||||
{
|
||||
lc::LivePipelineOptions o;
|
||||
o.stream.chunkSec = 0.5; // 0.5s 一块,测试里能快些触发
|
||||
return o;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// 实时管线的回归测试(用 FakeAsr + 直接喂音频,不碰 PulseAudio)。
|
||||
class TstLivePipeline : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
// 静音不能送进模型。
|
||||
// 曾经把采集到的静音也喂了进去,模型对静音产生幻觉(英文语音前冒出一个"嗯。")。
|
||||
void silenceIsNotForwarded()
|
||||
{
|
||||
FakeAsr fake;
|
||||
lc::LivePipeline pipeline(fake, options());
|
||||
|
||||
const auto silence = tone(16000 * 3, 0.001f); // 3s 背景噪声
|
||||
pipeline.process(silence.data(), silence.size());
|
||||
|
||||
QCOMPARE(fake.samplesSeen.size(), size_t(0));
|
||||
}
|
||||
|
||||
// 不丢样本。
|
||||
// 曾经每次只处理 floor(可用/320) 帧并丢掉余数,每轮丢约 160 个样本,
|
||||
// 造成假静音和句子被截断。
|
||||
void noSamplesDroppedAcrossIrregularChunks()
|
||||
{
|
||||
FakeAsr fake;
|
||||
lc::LivePipeline pipeline(fake, options());
|
||||
|
||||
const auto speech = tone(16000 * 10, 0.1f); // 10s 语音
|
||||
const size_t sizes[] = {1000, 7, 3333, 999, 12345, 1};
|
||||
|
||||
size_t off = 0;
|
||||
int i = 0;
|
||||
while (off < speech.size()) {
|
||||
const size_t n = std::min(sizes[i++ % 6], speech.size() - off);
|
||||
pipeline.process(speech.data() + off, n);
|
||||
off += n;
|
||||
}
|
||||
|
||||
const size_t forwarded = totalSamples(fake.samplesSeen);
|
||||
// 允许剩下的不足一块留在引擎里(< 0.5s),其余都必须送到
|
||||
QVERIFY2(forwarded >= 16000 * 9,
|
||||
qPrintable(QString("只送到了 %1 个样本(共 %2)")
|
||||
.arg(forwarded).arg(speech.size())));
|
||||
}
|
||||
|
||||
// 静音够久要收束这一段(提交文本并重置上下文)
|
||||
void silenceEndsSegment()
|
||||
{
|
||||
FakeAsr fake;
|
||||
fake.scripted = {"a b c d e f"};
|
||||
|
||||
lc::LivePipeline pipeline(fake, options());
|
||||
QString lastCommitted;
|
||||
pipeline.setCaptionCallback(
|
||||
[&lastCommitted](const std::string & committed, const std::string &,
|
||||
const std::string &) {
|
||||
lastCommitted = QString::fromStdString(committed);
|
||||
});
|
||||
|
||||
const auto speech = tone(16000 * 1, 0.1f);
|
||||
pipeline.process(speech.data(), speech.size());
|
||||
const int resetsAfterSpeech = fake.resets;
|
||||
|
||||
const auto silence = tone(16000 * 2, 0.001f);
|
||||
pipeline.process(silence.data(), silence.size());
|
||||
|
||||
QVERIFY2(fake.resets > resetsAfterSpeech, "静音后没有收束这一段");
|
||||
QVERIFY(!pipeline.inSegment());
|
||||
}
|
||||
|
||||
// 语音之后继续有语音:不应该被切断成很多段
|
||||
void continuousSpeechStaysInOneSegment()
|
||||
{
|
||||
FakeAsr fake;
|
||||
lc::LivePipeline pipeline(fake, options());
|
||||
const int resetsAfterConstruct = fake.resets; // 构造 StreamingAsr 时会 reset 一次
|
||||
|
||||
const auto speech = tone(16000 * 4, 0.1f);
|
||||
for (size_t off = 0; off < speech.size(); off += 4000) {
|
||||
const size_t n = std::min(size_t(4000), speech.size() - off);
|
||||
pipeline.process(speech.data() + off, n);
|
||||
}
|
||||
|
||||
QVERIFY(pipeline.inSegment());
|
||||
QCOMPARE(fake.resets, resetsAfterConstruct);
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_MAIN(TstLivePipeline)
|
||||
#include "tst_livepipeline.moc"
|
||||
@@ -0,0 +1,174 @@
|
||||
#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"
|
||||
@@ -0,0 +1,48 @@
|
||||
#include <QtTest>
|
||||
|
||||
#include "transcript.h"
|
||||
|
||||
// 输出解析:Qwen3-ASR 的原始输出形如 "language English<asr_text>正文"
|
||||
class TstTranscript : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void splitsLanguageAndText()
|
||||
{
|
||||
const auto r = lc::parseAsrOutput("language English<asr_text>He hoped there would be stew.");
|
||||
QCOMPARE(QString::fromStdString(r.language), QString("English"));
|
||||
QCOMPARE(QString::fromStdString(r.text), QString("He hoped there would be stew."));
|
||||
}
|
||||
|
||||
void handlesChinese()
|
||||
{
|
||||
const auto r = lc::parseAsrOutput("language Chinese<asr_text>不排除购房者可以在互联网上下单。");
|
||||
QCOMPARE(QString::fromStdString(r.language), QString("Chinese"));
|
||||
QCOMPARE(QString::fromStdString(r.text), QString("不排除购房者可以在互联网上下单。"));
|
||||
}
|
||||
|
||||
void withoutMarkerKeepsWholeText()
|
||||
{
|
||||
const auto r = lc::parseAsrOutput(" just text ");
|
||||
QVERIFY(r.language.empty());
|
||||
QCOMPARE(QString::fromStdString(r.text), QString("just text"));
|
||||
}
|
||||
|
||||
void trimsSurroundingWhitespaceOfBody()
|
||||
{
|
||||
const auto r = lc::parseAsrOutput("language English<asr_text> hi there ");
|
||||
QCOMPARE(QString::fromStdString(r.text), QString("hi there"));
|
||||
}
|
||||
|
||||
void displayNameMapping()
|
||||
{
|
||||
QCOMPARE(QString::fromStdString(lc::languageDisplayName("English")), QString("英语"));
|
||||
QCOMPARE(QString::fromStdString(lc::languageDisplayName("Chinese")), QString("中文"));
|
||||
// 未收录的语言原样返回
|
||||
QCOMPARE(QString::fromStdString(lc::languageDisplayName("Klingon")), QString("Klingon"));
|
||||
QVERIFY(lc::languageDisplayName("").empty());
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_MAIN(TstTranscript)
|
||||
#include "tst_transcript.moc"
|
||||
@@ -0,0 +1,83 @@
|
||||
#include <QtTest>
|
||||
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
|
||||
#include "vad.h"
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr size_t kFrame = 320; // 20ms @16k
|
||||
constexpr double kPi = 3.14159265358979323846;
|
||||
|
||||
// 生成 amplitude 幅值的 440Hz 正弦,用来模拟"有内容"的音频
|
||||
std::vector<float> tone(size_t samples, float amplitude)
|
||||
{
|
||||
std::vector<float> v(samples);
|
||||
for (size_t i = 0; i < samples; ++i) {
|
||||
v[i] = amplitude * (float) std::sin(2.0 * kPi * 440.0 * (double) i / 16000.0);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
int countSpeech(lc::Vad & vad, const std::vector<float> & audio)
|
||||
{
|
||||
int speech = 0;
|
||||
for (size_t off = 0; off + kFrame <= audio.size(); off += kFrame) {
|
||||
if (vad.feed(audio.data() + off, kFrame)) ++speech;
|
||||
}
|
||||
return speech;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// VAD 的回归测试。
|
||||
//
|
||||
// 关键教训:噪声底必须用"最小值跟踪"。曾经用均值跟踪,遇到本来就很轻的音频
|
||||
// (峰值 RMS 只有 0.03 量级的录音)会把阈值抬到语音电平之上,把绝大部分话音
|
||||
// 判成静音 —— 实时字幕于是每句都被切断。
|
||||
class TstVad : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
private slots:
|
||||
void backgroundStaysSilent()
|
||||
{
|
||||
lc::Vad vad;
|
||||
const auto bg = tone(kFrame * 200, 0.001f); // rms ≈ 0.0007
|
||||
QCOMPARE(countSpeech(vad, bg), 0);
|
||||
}
|
||||
|
||||
// 轻语音必须被判成语音:这里语音 rms ≈ 0.0028,
|
||||
// 旧的"均值跟踪 + 0.004 绝对下限"实现会全部漏掉。
|
||||
void quietSpeechIsDetected()
|
||||
{
|
||||
lc::Vad vad;
|
||||
const auto bg = tone(kFrame * 40, 0.001f); // rms ≈ 0.0007
|
||||
countSpeech(vad, bg);
|
||||
|
||||
const auto sp = tone(kFrame * 100, 0.004f); // rms ≈ 0.0028
|
||||
const int speech = countSpeech(vad, sp);
|
||||
QVERIFY2(speech >= 95,
|
||||
qPrintable(QString("轻语音只被识别出 %1/100 帧").arg(speech)));
|
||||
}
|
||||
|
||||
void loudSpeechIsDetected()
|
||||
{
|
||||
lc::Vad vad;
|
||||
const auto sp = tone(kFrame * 50, 0.2f);
|
||||
QVERIFY(countSpeech(vad, sp) >= 48);
|
||||
}
|
||||
|
||||
void floorFollowsQuietInput()
|
||||
{
|
||||
lc::Vad vad;
|
||||
const auto bg = tone(kFrame * 200, 0.001f);
|
||||
countSpeech(vad, bg);
|
||||
// 噪声底应该已经下探到背景噪声量级,而不是停在初值
|
||||
QVERIFY2(vad.noiseFloor() < 0.002f,
|
||||
qPrintable(QString("噪声底仍为 %1").arg(vad.noiseFloor())));
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_MAIN(TstVad)
|
||||
#include "tst_vad.moc"
|
||||
Reference in new issue
Block a user