Files
live-caption/apps/gui/main.cpp
T

329 lines
13 KiB
C++
Raw Normal View History

// 图形界面版:悬浮字幕窗 + 托盘 + 设置对话框。
// 完全通过 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;
}