// 图形界面版:悬浮字幕窗 + 托盘 + 设置对话框。 // 完全通过 GUI 使用;命令行参数保留,仅用于自动化测试(显式给出时覆盖设置)。 #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #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); } // 后端相关设置的「指纹」:这些值变了就必须重建后端(外观类改动不需要)。 QString backendKeyOf(const lc::AppSettings & c) { return QStringList{ c.modelId, c.language, c.captureDevice, c.modelDir, c.streamMode, QString::number(c.chunkSec), QString::number(c.streamWindowSec), QString::number(c.streamSegmentSec), QString::number(c.unfixedTokens), QString::number(c.unfixedChunks), QString::number(c.maxTokens), QString::number(c.threads), QString::number(c.verbose ? 1 : 0) } .join(QLatin1Char('|')); } } // 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-tab", "截图设置对话框时先切到某个页签(0-2 或 模型/显示/高级)", "tab")); 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")) cfg.modelDir = parser.value("model-dir"); if (!cfg.modelDir.isEmpty()) lc::models::ModelStore::setRootOverride(cfg.modelDir); // 仅用于验证渲染:打开设置对话框截图后退出 if (parser.isSet("screenshot-settings")) { const QString path = parser.value("screenshot-settings"); lc::SettingsDialog dlg(!hasUsableModel()); // 可选:先切页签,便于给某个页单独截图 const QString tab = parser.value("settings-tab"); if (!tab.isEmpty()) { if (auto * tw = dlg.findChild()) { bool numeric = false; const int n = tab.toInt(&numeric); if (numeric) { tw->setCurrentIndex(qBound(0, n, tw->count() - 1)); } else { for (int i = 0; i < tw->count(); ++i) { if (tw->tabText(i) == tab) { tw->setCurrentIndex(i); break; } } } } } 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()) { if (b->text() == QObject::tr("确定")) { b->click(); break; } } } else if (autoclose == QLatin1String("apply")) { // 点「应用」,应保持打开 for (auto * b : dlg.findChildren()) { 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 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, cfg.maxTokens, cfg.verbose || parser.isSet("verbose"), &err); if (!asr) { qCritical("加载模型失败: %s", err.c_str()); return false; } // 流式参数:只有 llama 后端(Qwen3-ASR)用得到大部分参数, // 流式 ONNX 只关心「更新粒度」。 lc::StreamOptions sop; sop.windowSec = cfg.streamWindowSec; sop.maxUtteranceSec = cfg.streamSegmentSec; sop.unfixedTokenNum = cfg.unfixedTokens; sop.unfixedChunkNum = cfg.unfixedChunks; sop.maxTokensPerStep = cfg.maxTokens; sop.incremental = (cfg.streamMode != QLatin1String("append") && cfg.streamMode != QLatin1String("window")); sop.appendOnly = (cfg.streamMode == QLatin1String("append")); 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)); }); // 后端相关项(模型/语言/采集设备/流式参数)变化才需要重启;外观类改动即时生效 QString activeKey = backendKeyOf(cfg); const auto openSettings = [&] { lc::SettingsDialog dlg(false); QObject::connect(&dlg, &lc::SettingsDialog::settingsApplied, &window, [&] { lc::models::ModelStore::setRootOverride(cfg.modelDir); applyAppearance(window, cfg); // 透明度/字号等立刻可见 if (backendKeyOf(cfg) != activeKey) { if (startTranscriber()) activeKey = backendKeyOf(cfg); } }); if (dlg.exec() == QDialog::Accepted) { lc::models::ModelStore::setRootOverride(cfg.modelDir); applyAppearance(window, cfg); if (backendKeyOf(cfg) != activeKey) { if (!startTranscriber()) { QMessageBox::warning(&window, QObject::tr("启动失败"), QObject::tr("模型加载失败,请检查设置。")); } else { activeKey = backendKeyOf(cfg); } } } }; // 语言 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; }