The README still listed the settings UI as "todo" and its quick start told users to download models into the working directory; phase 3 still described the caption window's close button as "quit" and used the wrong binary name. Bring the docs up to date and fill the gaps: a GUI guide (first run, settings tabs, tray, download, verify), a full CLI option reference marking the test-only flags, a troubleshooting page, the model registry's size/sha256 fields plus a licence/size table, the RTF measurement caveat (llama-cli 0.19 vs the in-process 0.56), and a note that the streaming-ONNX backend has its own code path. phase0/phase1 experiment docs are marked as historical. 文档与现状对齐:README 的 Phase 4 从「待做」改为完成、快速开始不再让人把模型下到当前 目录;phase3 的 ✕ 说明改为「隐藏到托盘」、用法改用 live-caption;新增 GUI/CLI/排错三份 文档;asr-backends 补上注册表的 size/sha256 字段与模型许可/体积表;phase0 补上 RTF 口径 说明(llama-cli 的 0.19 与程序内的 0.56);phase2 补上「流式 ONNX 走另一条分支」的说明。
330 lines
13 KiB
C++
330 lines
13 KiB
C++
// 图形界面版:悬浮字幕窗 + 托盘 + 设置对话框。
|
||
// 完全通过 GUI 使用;命令行参数保留,仅用于自动化测试(显式给出时覆盖设置)。
|
||
#include <cstdio>
|
||
#include <memory>
|
||
|
||
#include <QAction>
|
||
#include <QApplication>
|
||
#include <QCommandLineOption>
|
||
#include <QCommandLineParser>
|
||
#include <QIcon>
|
||
#include <QMenu>
|
||
#include <QMessageBox>
|
||
#include <QPainter>
|
||
#include <QPixmap>
|
||
#include <QPushButton>
|
||
#include <QSystemTrayIcon>
|
||
#include <QTimer>
|
||
|
||
#include "appcli.h"
|
||
#include "appsettings.h"
|
||
#include "captionwindow.h"
|
||
#include "iasr.h"
|
||
#include "livetranscriber.h"
|
||
#include "modelstore.h"
|
||
#include "qwen3asr.h"
|
||
#include "settingsdialog.h"
|
||
#include "streamingasr.h"
|
||
#include "transcript.h"
|
||
|
||
namespace {
|
||
|
||
// 托盘图标:直接画一个,免得依赖图标资源文件
|
||
QIcon makeTrayIcon()
|
||
{
|
||
QPixmap pm(64, 64);
|
||
pm.fill(Qt::transparent);
|
||
QPainter p(&pm);
|
||
p.setRenderHint(QPainter::Antialiasing);
|
||
p.setBrush(QColor(20, 20, 20, 235));
|
||
p.setPen(Qt::NoPen);
|
||
p.drawRoundedRect(2, 12, 60, 40, 10, 10);
|
||
p.setPen(QColor(240, 240, 240));
|
||
QFont f = p.font();
|
||
f.setPixelSize(26);
|
||
f.setBold(true);
|
||
p.setFont(f);
|
||
p.drawText(pm.rect(), Qt::AlignCenter, QStringLiteral("字"));
|
||
return QIcon(pm);
|
||
}
|
||
|
||
// 把设置应用到字幕窗(字号/不透明度/行数/位置)
|
||
void applyAppearance(lc::CaptionWindow & window, const lc::AppSettings & cfg)
|
||
{
|
||
window.setPanelOpacity(cfg.opacityPercent);
|
||
window.setTextFontSize(cfg.fontSize);
|
||
window.setLineCounts(cfg.collapsedLines, cfg.expandedLines);
|
||
if (cfg.hasWindowPos) window.move(cfg.windowPos);
|
||
}
|
||
|
||
bool hasUsableModel()
|
||
{
|
||
using namespace lc::models;
|
||
const lc::AppSettings & cfg = lc::AppSettings::instance();
|
||
if (cfg.modelId.isEmpty()) return false;
|
||
const ModelInfo * m = ModelStore::find(cfg.modelId);
|
||
return m && ModelStore::isInstalled(*m);
|
||
}
|
||
|
||
} // namespace
|
||
|
||
int main(int argc, char ** argv)
|
||
{
|
||
QGuiApplication::setHighDpiScaleFactorRoundingPolicy(
|
||
Qt::HighDpiScaleFactorRoundingPolicy::PassThrough);
|
||
QApplication app(argc, argv);
|
||
// 关键:本程序常驻托盘,"最后一个窗口关闭"不等于退出。
|
||
// 否则首次启动时设置对话框一关(那时它是唯一窗口),Qt 就会把主事件循环也结束掉,
|
||
// 表现为"点了确定程序就自己退出"。
|
||
QApplication::setQuitOnLastWindowClosed(false);
|
||
QCoreApplication::setApplicationName(QStringLiteral("live-caption"));
|
||
QCoreApplication::setOrganizationName(QStringLiteral("live-caption"));
|
||
|
||
lc::AppSettings & cfg = lc::AppSettings::instance();
|
||
cfg.load();
|
||
|
||
// ---- 命令行(仅用于自动化测试,显式给出时覆盖设置)----
|
||
QCommandLineParser parser;
|
||
parser.setApplicationDescription(QStringLiteral("实时字幕(悬浮窗)"));
|
||
parser.addHelpOption();
|
||
lc::app::addModelOptions(parser);
|
||
lc::app::addStreamOptions(parser);
|
||
lc::app::addBackendOptions(parser);
|
||
parser.addOption(QCommandLineOption("opacity", "背景不透明度(20-100)", "n"));
|
||
parser.addOption(QCommandLineOption("screenshot", "启动后自动截图并退出", "path"));
|
||
parser.addOption(QCommandLineOption("screenshot-delay", "截图前等待秒数", "sec", "6"));
|
||
parser.addOption(QCommandLineOption("no-tray", "不创建托盘图标(测试用)"));
|
||
parser.addOption(QCommandLineOption("screenshot-settings", "打开设置对话框截图后退出", "path"));
|
||
parser.addOption(QCommandLineOption("settings-autoclose", "复现用:自动操作设置对话框(reject|close|accept|ok|apply)", "how"));
|
||
parser.addOption(QCommandLineOption("quit-after", "运行若干秒后优雅退出(测试退出路径用)", "sec"));
|
||
parser.process(app);
|
||
|
||
if (parser.isSet("asr-model")) cfg.modelId = parser.value("asr-model");
|
||
if (parser.isSet("language")) cfg.language = parser.value("language");
|
||
if (parser.isSet("opacity")) cfg.opacityPercent = parser.value("opacity").toInt();
|
||
if (parser.isSet("model-dir")) lc::models::ModelStore::setRootOverride(parser.value("model-dir"));
|
||
|
||
// 仅用于验证渲染:打开设置对话框截图后退出
|
||
if (parser.isSet("screenshot-settings")) {
|
||
const QString path = parser.value("screenshot-settings");
|
||
lc::SettingsDialog dlg(!hasUsableModel());
|
||
dlg.show();
|
||
QTimer::singleShot(1200, &dlg, [&dlg, path] {
|
||
dlg.grab().save(path);
|
||
std::fprintf(stderr, "设置对话框截图: %s\n", qUtf8Printable(path));
|
||
dlg.accept();
|
||
});
|
||
dlg.exec();
|
||
return 0;
|
||
}
|
||
|
||
// 复现用:把"点 ✕ / 点确定"自动化,用来定位崩溃
|
||
const QString autoclose = parser.value("settings-autoclose");
|
||
const auto scheduleAutoclose = [&autoclose](QDialog & dlg) {
|
||
if (autoclose.isEmpty()) return;
|
||
QTimer::singleShot(700, &dlg, [&dlg, &autoclose] {
|
||
if (autoclose == QLatin1String("reject")) dlg.reject();
|
||
else if (autoclose == QLatin1String("close")) dlg.close(); // 标题栏 ✕
|
||
else if (autoclose == QLatin1String("accept")) dlg.accept();
|
||
else if (autoclose == QLatin1String("ok")) { // 真的点「确定」
|
||
for (auto * b : dlg.findChildren<QPushButton *>()) {
|
||
if (b->text() == QObject::tr("确定")) { b->click(); break; }
|
||
}
|
||
}
|
||
else if (autoclose == QLatin1String("apply")) { // 点「应用」,应保持打开
|
||
for (auto * b : dlg.findChildren<QPushButton *>()) {
|
||
if (b->text() == QObject::tr("应用")) { b->click(); break; }
|
||
}
|
||
QTimer::singleShot(400, &dlg, [&dlg] {
|
||
std::fprintf(stderr, "点「应用」后对话框仍可见: %s\n",
|
||
dlg.isVisible() ? "是 ✅" : "否 ❌");
|
||
dlg.reject();
|
||
});
|
||
}
|
||
});
|
||
};
|
||
|
||
// ---- 启动:没有可用模型就先开设置 ----
|
||
if (!hasUsableModel()) {
|
||
lc::SettingsDialog dlg(true);
|
||
scheduleAutoclose(dlg);
|
||
if (dlg.exec() != QDialog::Accepted || !hasUsableModel()) return 0;
|
||
}
|
||
|
||
lc::CaptionWindow window;
|
||
applyAppearance(window, cfg);
|
||
// 语言 pill:流式模型不提供语言识别,就显示设置里选的语言
|
||
if (!cfg.language.isEmpty()) {
|
||
window.setLanguage(QString::fromStdString(lc::languageDisplayName(cfg.language.toStdString())));
|
||
}
|
||
|
||
// 注意声明顺序:asr 必须在 live 之前声明,
|
||
// 这样析构时 live(会 join 工作线程)先销毁、asr 后销毁,避免 use-after-free。
|
||
std::unique_ptr<lc::IAsr> asr;
|
||
lc::LiveTranscriber live; // 后端在 startTranscriber() 里注入
|
||
const auto startTranscriber = [&]() -> bool {
|
||
live.stop();
|
||
asr.reset();
|
||
live.setCaptureDevice(cfg.captureDevice);
|
||
|
||
const lc::models::ModelInfo * m = lc::models::ModelStore::find(cfg.modelId);
|
||
if (!m) {
|
||
qCritical("找不到模型 %s", qUtf8Printable(cfg.modelId));
|
||
return false;
|
||
}
|
||
std::string err;
|
||
asr = lc::app::makeAsrForModel(*m, cfg.language, cfg.threads,
|
||
parser.isSet("verbose"), &err);
|
||
if (!asr) {
|
||
qCritical("加载模型失败: %s", err.c_str());
|
||
return false;
|
||
}
|
||
|
||
lc::StreamOptions sop;
|
||
if (asr->kind() == lc::AsrKind::NativeStreaming) {
|
||
sop.chunkSec = (cfg.chunkSec > 0.0) ? cfg.chunkSec : 0.32;
|
||
} else if (cfg.chunkSec > 0.0) {
|
||
sop.chunkSec = cfg.chunkSec;
|
||
}
|
||
QString qerr;
|
||
if (!live.start(*asr, sop, &qerr)) {
|
||
qCritical("启动采集失败: %s", qUtf8Printable(qerr));
|
||
return false;
|
||
}
|
||
return true;
|
||
};
|
||
|
||
QObject::connect(&live, &lc::LiveTranscriber::caption, &window,
|
||
[&window](const QString & committed, const QString & tail,
|
||
const QString & lang) {
|
||
if (!lang.isEmpty()) {
|
||
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));
|
||
});
|
||
|
||
// 后端相关项(模型/语言/采集设备)变化才需要重启;外观类改动即时生效
|
||
const auto backendKey = [&cfg] {
|
||
return cfg.modelId + QLatin1Char('|') + cfg.language + QLatin1Char('|') + cfg.captureDevice;
|
||
};
|
||
QString activeKey = backendKey();
|
||
|
||
const auto openSettings = [&] {
|
||
lc::SettingsDialog dlg(false);
|
||
QObject::connect(&dlg, &lc::SettingsDialog::settingsApplied, &window, [&] {
|
||
applyAppearance(window, cfg); // 透明度/字号等立刻可见
|
||
if (backendKey() != activeKey) {
|
||
if (startTranscriber()) activeKey = backendKey();
|
||
}
|
||
});
|
||
if (dlg.exec() == QDialog::Accepted) {
|
||
applyAppearance(window, cfg);
|
||
if (backendKey() != activeKey) {
|
||
if (!startTranscriber()) {
|
||
QMessageBox::warning(&window, QObject::tr("启动失败"),
|
||
QObject::tr("模型加载失败,请检查设置。"));
|
||
} else {
|
||
activeKey = backendKey();
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
// 语言 pill → 设置
|
||
QObject::connect(&window, &lc::CaptionWindow::languageClicked, &window, openSettings);
|
||
|
||
// ---- 托盘 ----
|
||
QSystemTrayIcon * tray = nullptr;
|
||
QMenu * trayMenu = nullptr;
|
||
if (!parser.isSet("no-tray")) {
|
||
tray = new QSystemTrayIcon(makeTrayIcon(), &app);
|
||
tray->setToolTip(QObject::tr("实时字幕"));
|
||
trayMenu = new QMenu(&window); // 必须有归属,否则退出时是悬空顶层窗口
|
||
auto * showAct = trayMenu->addAction(QObject::tr("显示/隐藏字幕"));
|
||
auto * setAct = trayMenu->addAction(QObject::tr("设置…"));
|
||
trayMenu->addSeparator();
|
||
auto * quitAct = trayMenu->addAction(QObject::tr("退出"));
|
||
tray->setContextMenu(trayMenu);
|
||
|
||
QObject::connect(showAct, &QAction::triggered, &window,
|
||
[&window] { window.setVisible(!window.isVisible()); });
|
||
QObject::connect(setAct, &QAction::triggered, &window, openSettings);
|
||
QObject::connect(quitAct, &QAction::triggered, &app, &QApplication::quit);
|
||
QObject::connect(tray, &QSystemTrayIcon::activated, &window,
|
||
[&window](QSystemTrayIcon::ActivationReason r) {
|
||
if (r == QSystemTrayIcon::Trigger) {
|
||
window.setVisible(!window.isVisible());
|
||
}
|
||
});
|
||
tray->show();
|
||
}
|
||
|
||
// 关闭按钮 = 隐藏到托盘(否则关掉就没法再打开了)
|
||
QObject::connect(&window, &lc::CaptionWindow::closed, &window, [&] {
|
||
if (tray) {
|
||
window.hide();
|
||
} else {
|
||
qApp->quit();
|
||
}
|
||
});
|
||
|
||
// 记住拖动后的位置
|
||
QObject::connect(&window, &lc::CaptionWindow::moved, &window, [&](const QPoint & p) {
|
||
cfg.windowPos = p;
|
||
cfg.hasWindowPos = true;
|
||
cfg.save();
|
||
});
|
||
|
||
if (!startTranscriber()) {
|
||
QMessageBox::critical(nullptr, QObject::tr("启动失败"),
|
||
QObject::tr("无法加载模型,请检查设置。"));
|
||
return 1;
|
||
}
|
||
|
||
window.show();
|
||
|
||
// 复现用:程序起来后再开一次设置并自动关闭(模拟从语言标签/托盘进设置)
|
||
if (!autoclose.isEmpty() && hasUsableModel()) {
|
||
QTimer::singleShot(2500, &window, [&] {
|
||
lc::SettingsDialog dlg(false);
|
||
scheduleAutoclose(dlg);
|
||
if (dlg.exec() == QDialog::Accepted) {
|
||
applyAppearance(window, cfg);
|
||
if (!startTranscriber()) qWarning("重启转写失败");
|
||
}
|
||
QTimer::singleShot(500, qApp, &QCoreApplication::quit);
|
||
});
|
||
}
|
||
|
||
// 自截图(验证渲染用)
|
||
if (parser.isSet("screenshot")) {
|
||
const QString path = parser.value("screenshot");
|
||
QTimer::singleShot(parser.value("screenshot-delay").toInt() * 1000, &window,
|
||
[&window, path] {
|
||
const bool ok1 = window.grab().save(path);
|
||
window.setCollapsed(false);
|
||
QString expanded = path;
|
||
expanded.replace(QStringLiteral(".png"), QStringLiteral("-expanded.png"));
|
||
const bool ok2 = window.grab().save(expanded);
|
||
std::fprintf(stderr, "截图: 折叠态 %s / 展开态 %s\n",
|
||
ok1 ? "成功" : "失败", ok2 ? "成功" : "失败");
|
||
QCoreApplication::quit();
|
||
});
|
||
}
|
||
|
||
if (parser.isSet("quit-after")) {
|
||
QTimer::singleShot(parser.value("quit-after").toInt() * 1000, qApp,
|
||
&QCoreApplication::quit);
|
||
}
|
||
|
||
const int rc = QCoreApplication::exec();
|
||
// 显式收尾,确保工作线程停下来之后再释放后端
|
||
live.stop();
|
||
asr.reset();
|
||
return rc;
|
||
}
|