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:
blumia committed 2026-10-12 13:43:50 +08:00
1 parent 4ed4e23282
commit 736ec14d5b
32 files changed
+1502 -655

No files matched your search

+147
View File
@@ -0,0 +1,147 @@
#include "livepipeline.h"
#include <chrono>
namespace lc {
namespace {
constexpr int kFrameSamples = 320; // 20ms @16k
} // namespace
LivePipeline::LivePipeline(IAsr & asr, LivePipelineOptions opt)
: asr_(asr), opt_(std::move(opt)), stream_(asr, opt_.stream) {}
LivePipeline::~LivePipeline()
{
stop();
}
std::string LivePipeline::deviceName() const
{
return capture_.deviceName();
}
bool LivePipeline::start(std::string * error)
{
const bool ok = capture_.start(
asr_.sampleRate(),
[this](const float * s, size_t n) {
{
std::lock_guard<std::mutex> lk(mutex_);
pending_.insert(pending_.end(), s, s + n);
}
cv_.notify_one();
},
error);
if (!ok) return false;
worker_ = std::thread([this] { workerLoop(); });
return true;
}
void LivePipeline::stop()
{
{
std::lock_guard<std::mutex> lk(mutex_);
quit_ = true;
}
cv_.notify_all();
if (worker_.joinable()) worker_.join();
capture_.stop();
}
void LivePipeline::workerLoop()
{
for (;;) {
std::vector<float> chunk;
{
std::unique_lock<std::mutex> lk(mutex_);
cv_.wait_for(lk, std::chrono::milliseconds(50),
[this] { return quit_ || !pending_.empty(); });
if (quit_ && pending_.empty()) break;
chunk.swap(pending_);
}
if (!chunk.empty()) process(chunk.data(), chunk.size());
}
if (inSegment_) finishSegment();
}
void LivePipeline::emitStep(const StreamingAsr::Step & s)
{
if (!s.ok || !onCaption_) return;
onCaption_(s.committed, s.tail, s.language);
}
void LivePipeline::process(const float * pcm, size_t n)
{
buffer_.insert(buffer_.end(), pcm, pcm + n);
const size_t prerollSamples = (size_t) (opt_.prerollSec * asr_.sampleRate());
const float frameSec = (float) kFrameSamples / asr_.sampleRate();
std::vector<float> segAudio; // 本段要送进引擎的音频
bool flushNow = false;
size_t off = 0;
for (; off + kFrameSamples <= buffer_.size(); off += kFrameSamples) {
const float * frame = buffer_.data() + off;
const bool speech = vad_.feed(frame, kFrameSamples);
if (!inSegment_) {
// 静音期间什么都不送给模型:否则模型会对静音产生幻觉
if (speech) {
inSegment_ = true;
silenceAcc_ = 0.0f;
segAudio.insert(segAudio.end(), preroll_.begin(), preroll_.end());
preroll_.clear();
segAudio.insert(segAudio.end(), frame, frame + kFrameSamples);
} else {
preroll_.insert(preroll_.end(), frame, frame + kFrameSamples);
if (preroll_.size() > prerollSamples) {
preroll_.erase(preroll_.begin(),
preroll_.begin() + (long) (preroll_.size() - prerollSamples));
}
}
} else {
segAudio.insert(segAudio.end(), frame, frame + kFrameSamples);
if (speech) silenceAcc_ = 0.0f;
else silenceAcc_ += frameSec;
if (silenceAcc_ >= (float) opt_.silenceTimeoutSec) {
flushNow = true;
off += kFrameSamples; // 这一帧已经并入 segAudio
break;
}
}
}
if (!segAudio.empty()) {
const auto cb = [this](const StreamingAsr::Step & s) { emitStep(s); };
stream_.feed(segAudio.data(), segAudio.size(), cb);
}
if (flushNow) {
const auto cb = [this](const StreamingAsr::Step & s) { emitStep(s); };
stream_.flush(cb);
stream_.reset();
inSegment_ = false;
silenceAcc_ = 0.0f;
preroll_.clear();
}
if (off > 0) buffer_.erase(buffer_.begin(), buffer_.begin() + (long) off);
}
void LivePipeline::finishSegment()
{
if (!inSegment_) return;
const auto cb = [this](const StreamingAsr::Step & s) { emitStep(s); };
stream_.flush(cb);
stream_.reset();
inSegment_ = false;
silenceAcc_ = 0.0f;
preroll_.clear();
}
} // namespace lc