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

+152
View File
@@ -0,0 +1,152 @@
// 命令行版:转写文件、流式转写文件、或纯命令行实时字幕(不开窗口)。
// 只依赖 core,不链接 Qt Widgets。
#include <algorithm>
#include <atomic>
#include <chrono>
#include <csignal>
#include <cstdio>
#include <string>
#include <thread>
#include <vector>
#include <QCommandLineOption>
#include <QCommandLineParser>
#include "appcli.h"
#include "audio.h"
#include "livepipeline.h"
#include "qwen3asr.h"
#include "streamingasr.h"
#include "transcript.h"
namespace {
std::atomic<bool> g_stop{false};
extern "C" void handleSigInt(int) { g_stop = true; }
int runFile(lc::Qwen3Asr & asr, const QString & path)
{
std::vector<float> pcm;
std::string err;
if (!lc::readWavMono(path.toStdString(), asr.sampleRate(), pcm, &err)) {
std::fprintf(stderr, "读取音频失败: %s\n", err.c_str());
return 1;
}
const double dur = (double) pcm.size() / asr.sampleRate();
std::fprintf(stderr, "音频: %s (%.2fs)\n", qUtf8Printable(path), dur);
const auto t0 = std::chrono::steady_clock::now();
lc::AsrResult r = asr.transcribe(pcm.data(), pcm.size());
const double wall = std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
if (!r.ok) {
std::fprintf(stderr, "转写失败: %s\n", r.error.c_str());
return 1;
}
std::printf("语言: %s (%s)\n", r.transcript.language.c_str(),
lc::languageDisplayName(r.transcript.language).c_str());
std::printf("文本: %s\n", r.transcript.text.c_str());
std::fprintf(stderr, "推理: %.2fs / 音频 %.2fs (rtf %.3f) 生成 %d tokens, 墙钟 %.2fs\n",
r.inferSeconds, dur, dur > 0 ? r.inferSeconds / dur : 0, r.tokenCount, wall);
return 0;
}
int runStreamFile(lc::Qwen3Asr & asr, const QString & path, const lc::StreamOptions & sop)
{
std::vector<float> pcm;
std::string err;
if (!lc::readWavMono(path.toStdString(), asr.sampleRate(), pcm, &err)) {
std::fprintf(stderr, "读取音频失败: %s\n", err.c_str());
return 1;
}
const double dur = (double) pcm.size() / asr.sampleRate();
std::fprintf(stderr, "流式转写: %s (%.2fs) chunk=%.1fs segment=%.1fs mode=%s\n",
qUtf8Printable(path), dur, sop.chunkSec, sop.maxUtteranceSec,
sop.incremental ? "incremental" : "window");
lc::StreamingAsr stream(asr, sop);
const size_t chunkSamples = (size_t) (sop.chunkSec * asr.sampleRate());
const auto onStep = [](const lc::StreamingAsr::Step & s) {
std::printf("\n[chunk %2d] 窗口 %5.1fs / 编码 %5.2fs / 本步 %5.2fs%s\n",
s.chunkIndex, s.windowSeconds, s.encodeSeconds, s.inferSeconds,
s.ok ? "" : " <失败>");
if (!s.ok) std::printf(" error: %s\n", s.error.c_str());
std::printf(" 已提交: %s\n", s.committed.c_str());
std::printf(" 含尾部: %s\n", s.full.c_str());
std::fflush(stdout);
};
for (size_t off = 0; off < pcm.size(); off += chunkSamples) {
const size_t n = std::min(chunkSamples, pcm.size() - off);
stream.feed(pcm.data() + off, n, onStep);
}
stream.flush(onStep);
std::printf("\n==== 最终提交文本 (%s) ====\n%s\n",
lc::languageDisplayName(stream.language()).c_str(),
stream.committedText().c_str());
return 0;
}
int runCliLive(lc::Qwen3Asr & asr, const lc::StreamOptions & sop)
{
lc::LivePipelineOptions popt;
popt.stream = sop;
lc::LivePipeline pipeline(asr, popt);
pipeline.setCaptionCallback([](const std::string & committed, const std::string & tail,
const std::string & lang) {
// 原地刷新:稳定部分正常显示,尾部用暗色表示未定
std::printf("\r\033[K[%s] %s\033[2m%s\033[0m",
lc::languageDisplayName(lang).c_str(), committed.c_str(), tail.c_str());
std::fflush(stdout);
});
pipeline.setErrorCallback([](const std::string & m) {
std::fprintf(stderr, "\n采集失败: %s\n", m.c_str());
});
std::string err;
if (!pipeline.start(&err)) {
std::fprintf(stderr, "启动采集失败: %s\n", err.c_str());
return 1;
}
std::fprintf(stderr, "正在采集: %s (Ctrl+C 退出)\n", pipeline.deviceName().c_str());
std::signal(SIGINT, handleSigInt);
while (!g_stop) std::this_thread::sleep_for(std::chrono::milliseconds(100));
pipeline.stop();
return 0;
}
} // namespace
int main(int argc, char ** argv)
{
QCoreApplication app(argc, argv);
QCommandLineParser parser;
parser.setApplicationDescription(QStringLiteral("实时字幕(命令行版)"));
parser.addHelpOption();
lc::app::addModelOptions(parser);
lc::app::addStreamOptions(parser);
parser.addOption(QCommandLineOption("file", "转写一个 wav 文件后退出", "path"));
parser.addOption(QCommandLineOption("stream-file", "以流式模式转写 wav 文件后退出", "path"));
parser.addOption(QCommandLineOption("cli-live", "实时转写字幕,只用命令行输出(不开窗口)"));
parser.process(app);
const lc::AsrOptions asrOpt = lc::app::asrOptions(parser);
const lc::StreamOptions sop = lc::app::streamOptions(parser);
lc::Qwen3Asr asr;
if (!lc::app::loadModel(asr, asrOpt)) return 1;
if (parser.isSet("file")) return runFile(asr, parser.value("file"));
if (parser.isSet("stream-file")) return runStreamFile(asr, parser.value("stream-file"), sop);
return runCliLive(asr, sop);
}
+73
View File
@@ -0,0 +1,73 @@
#pragma once
#include <cstdio>
#include <string>
#include <QCommandLineOption>
#include <QCommandLineParser>
#include "qwen3asr.h"
#include "streamingasr.h"
// 两个可执行程序(GUI / CLI)共用的命令行选项定义与解析。
namespace lc::app {
inline void addModelOptions(QCommandLineParser & p)
{
p.addOption(QCommandLineOption("model", "主模型 GGUF 路径(架构须为 qwen3vl)", "path"));
p.addOption(QCommandLineOption("mmproj", "mmproj GGUF 路径(clip + qwen3a)", "path"));
p.addOption(QCommandLineOption("threads", "推理线程数", "n", "8"));
p.addOption(QCommandLineOption("max-tokens", "单段最多生成 token 数", "n", "256"));
p.addOption(QCommandLineOption("verbose", "输出 llama.cpp/mtmd 的详细日志"));
}
inline void addStreamOptions(QCommandLineParser & p)
{
p.addOption(QCommandLineOption("stream-chunk", "流式 chunk 秒数", "sec", "2.0"));
p.addOption(QCommandLineOption("stream-window", "滑窗上限秒数(仅 window 模式)", "sec", "16"));
p.addOption(QCommandLineOption("stream-segment", "单段最长秒数(0=不限)", "sec", "30"));
p.addOption(QCommandLineOption("unfixed-tokens", "前缀回滚丢弃的 token 数", "n", "5"));
p.addOption(QCommandLineOption("unfixed-chunks", "冷启动不带前缀的 chunk 数", "n", "2"));
p.addOption(QCommandLineOption("stream-mode", "流式后端: incremental 或 window", "mode",
"incremental"));
}
inline AsrOptions asrOptions(const QCommandLineParser & p)
{
AsrOptions o;
o.modelPath = p.value("model").toStdString();
o.mmprojPath = p.value("mmproj").toStdString();
o.threads = p.value("threads").toInt();
o.maxTokens = p.value("max-tokens").toInt();
o.verbose = p.isSet("verbose");
return o;
}
inline StreamOptions streamOptions(const QCommandLineParser & p)
{
StreamOptions o;
o.chunkSec = p.value("stream-chunk").toDouble();
o.windowSec = p.value("stream-window").toDouble();
o.maxUtteranceSec = p.value("stream-segment").toDouble();
o.unfixedTokenNum = p.value("unfixed-tokens").toInt();
o.unfixedChunkNum = p.value("unfixed-chunks").toInt();
o.maxTokensPerStep = p.value("max-tokens").toInt();
o.incremental = p.value("stream-mode") != QLatin1String("window");
return o;
}
inline bool loadModel(Qwen3Asr & asr, const AsrOptions & opt)
{
if (opt.modelPath.empty() || opt.mmprojPath.empty()) {
std::fprintf(stderr, "必须提供 --model 和 --mmproj\n");
return false;
}
std::string err;
if (!asr.load(opt, &err)) {
std::fprintf(stderr, "加载失败: %s\n", err.c_str());
return false;
}
return true;
}
} // namespace lc::app
+102
View File
@@ -0,0 +1,102 @@
// 图形界面版:悬浮字幕窗。
#include <cstdio>
#include <QApplication>
#include <QCommandLineOption>
#include <QCommandLineParser>
#include <QGuiApplication>
#include <QTimer>
#include "appcli.h"
#include "captionwindow.h"
#include "livetranscriber.h"
#include "qwen3asr.h"
#include "streamingasr.h"
#include "transcript.h"
namespace {
int runGui(lc::Qwen3Asr & asr, const lc::StreamOptions & sop,
const QString & screenshotPath, int screenshotDelaySec, int opacityPercent)
{
lc::CaptionWindow window;
window.setPanelOpacity(opacityPercent);
lc::LiveTranscriber live(asr);
QObject::connect(&live, &lc::LiveTranscriber::caption, &window,
[&window](const QString & committed, const QString & tail,
const QString & lang) {
window.setLanguage(
QString::fromStdString(lc::languageDisplayName(lang.toStdString())));
window.setCaption(committed, tail);
});
QObject::connect(&live, &lc::LiveTranscriber::failed, &window, [](const QString & m) {
qWarning("采集失败: %s", qUtf8Printable(m));
});
QObject::connect(&window, &lc::CaptionWindow::closed, qApp, &QCoreApplication::quit);
QObject::connect(&window, &lc::CaptionWindow::languageClicked, &window, [] {
qInfo() << "语言标签被点击(设置界面将在 Phase 4 接入)";
});
QString err;
if (!live.start(sop, &err)) {
qCritical("启动采集失败: %s", qUtf8Printable(err));
return 1;
}
qInfo("正在采集: %s", qUtf8Printable(live.deviceName()));
window.show();
if (!screenshotPath.isEmpty()) {
// 自截图:用于验证渲染(不依赖外部截图工具)
QTimer::singleShot(screenshotDelaySec * 1000, &window, [&window, screenshotPath] {
const bool ok1 = window.grab().save(screenshotPath);
window.setCollapsed(false);
QString expandedPath = screenshotPath;
expandedPath.replace(QStringLiteral(".png"), QStringLiteral("-expanded.png"));
const bool ok2 = window.grab().save(expandedPath);
std::fprintf(stderr, "截图: 折叠态 %s / 展开态 %s\n",
ok1 ? "成功" : "失败", ok2 ? "成功" : "失败");
QCoreApplication::quit();
});
}
return QCoreApplication::exec();
}
} // namespace
int main(int argc, char ** argv)
{
// 说明意图:DPI 取整策略必须在 QGuiApplication 之前定。
// (Deepin 的会话环境已经设了 QT_SCALE_FACTOR_ROUNDING_POLICY,
// 所以本机仍会看到 Qt 的那条提醒,那不是本程序的问题。)
QGuiApplication::setHighDpiScaleFactorRoundingPolicy(
Qt::HighDpiScaleFactorRoundingPolicy::PassThrough);
QApplication app(argc, argv);
QCoreApplication::setApplicationName(QStringLiteral("live-caption"));
QCommandLineParser parser;
parser.setApplicationDescription(QStringLiteral("实时字幕(悬浮窗)"));
parser.addHelpOption();
lc::app::addModelOptions(parser);
lc::app::addStreamOptions(parser);
parser.addOption(QCommandLineOption("opacity", "字幕背景不透明度(20-100,百分比)", "n", "84"));
parser.addOption(QCommandLineOption("screenshot", "启动后自动截图并退出(验证渲染用)", "path"));
parser.addOption(QCommandLineOption("screenshot-delay", "截图前等待秒数", "sec", "6"));
parser.process(app);
const lc::AsrOptions asrOpt = lc::app::asrOptions(parser);
const lc::StreamOptions sop = lc::app::streamOptions(parser);
lc::Qwen3Asr asr;
if (!lc::app::loadModel(asr, asrOpt)) return 1;
return runGui(asr, sop,
parser.value("screenshot"),
parser.value("screenshot-delay").toInt(),
parser.value("opacity").toInt());
}