Split applying settings from closing the dialog, so appearance changes (opacity, font size, line counts) can be previewed without dismissing it. The caption window now listens to settingsApplied and updates immediately; only model, language or capture-device changes restart the ASR backend. 设置对话框新增「应用」按钮:应用设置但不关闭对话框,方便实时预览(不透明度、 字号等立刻可见)。字幕窗改为监听 settingsApplied 即时更新;只有模型/识别语言/ 采集设备变化才重启识别后端,外观调整不会打断正在进行的转写。
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)", "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;
|
||
}
|