fix(gui): stop quitting when the last window closes; add device and verify
Accepting the settings dialog quit the app: with a tray-resident program "last window closed" must not mean "quit", but QApplication's default made it so, and on first run the dialog is the only window. Disable quitOnLastWindowClosed. Also fix listOutputDevices hanging forever (its callbacks never signalled the PulseAudio main loop), enlarge the caption window's close button, and add capture-device selection (enumerated sinks, settings combo, --list-devices/--device) plus model integrity verification (size + sha256 in the registry, --verify-model and a "校验完整性" button). 点「确定」后程序自己退出:本程序常驻托盘,"最后一个窗口关闭"不该等于退出, 但 QApplication 默认如此,而首次启动时设置对话框正是唯一窗口。已关闭该默认 行为。同时修复 listOutputDevices 永久卡死(回调没有 signal 主循环)、放大 字幕窗关闭按钮,并新增采集设备选择与模型完整性校验(注册表含 size/sha256, 提供 --verify-model 与设置里的「校验完整性」按钮)。
This commit is contained in:
1 parent
a7a6f3e6b6
commit
0fa0bcb44a
19 files changed
+460
-55
No files matched your search
@@ -68,6 +68,11 @@ ctest --test-dir build --output-on-failure
|
||||
# 流式后端
|
||||
./build/live-caption-cli --asr-backend sherpa --asr-model zipformer-zh-2025
|
||||
./build/live-caption-cli --asr-backend sherpa --asr-model nemotron35-560ms --language ja
|
||||
|
||||
# 辅助
|
||||
./build/live-caption-cli --list-devices # 列出可选采集设备
|
||||
./build/live-caption-cli --cli-live --device <sink> # 指定采集源
|
||||
./build/live-caption-cli --verify-model <id> # 校验模型完整性
|
||||
```
|
||||
|
||||
## 代码结构
|
||||
|
||||
+28
-2
@@ -95,11 +95,12 @@ int runStreamFile(lc::IAsr & asr, const QString & path, const lc::StreamOptions
|
||||
return 0;
|
||||
}
|
||||
|
||||
int runCliLive(lc::IAsr & asr, const lc::StreamOptions & sop)
|
||||
int runCliLive(lc::IAsr & asr, const lc::StreamOptions & sop, const QString & device)
|
||||
{
|
||||
lc::LivePipelineOptions popt;
|
||||
popt.stream = sop;
|
||||
|
||||
popt.captureDevice = device.toStdString();
|
||||
lc::LivePipeline pipeline(asr, popt);
|
||||
pipeline.setCaptionCallback([](const std::string & committed, const std::string & tail,
|
||||
const std::string & lang) {
|
||||
@@ -140,11 +141,36 @@ int main(int argc, char ** argv)
|
||||
parser.addOption(QCommandLineOption("file", "转写一个 wav 文件后退出", "path"));
|
||||
parser.addOption(QCommandLineOption("stream-file", "以流式模式转写 wav 文件后退出", "path"));
|
||||
parser.addOption(QCommandLineOption("cli-live", "实时转写字幕,只用命令行输出(不开窗口)"));
|
||||
parser.addOption(QCommandLineOption("device", "采集源(sink 名);留空=跟随系统默认输出", "sink"));
|
||||
parser.addOption(QCommandLineOption("verify-model", "校验模型文件完整性后退出", "id"));
|
||||
parser.addOption(QCommandLineOption("list-devices", "列出可选的采集设备后退出"));
|
||||
parser.process(app);
|
||||
|
||||
const lc::AsrOptions asrOpt = lc::app::asrOptions(parser);
|
||||
lc::StreamOptions sop = lc::app::streamOptions(parser);
|
||||
|
||||
if (parser.isSet("list-devices")) {
|
||||
for (const auto & d : lc::listOutputDevices()) {
|
||||
std::printf(" %-60s %s%s\n", d.sink.c_str(), d.description.c_str(),
|
||||
d.isDefault ? " [默认]" : "");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (parser.isSet("verify-model")) {
|
||||
const auto * m = lc::models::ModelStore::find(parser.value("verify-model"));
|
||||
if (!m) { std::fprintf(stderr, "注册表里没有模型: %s\n", qUtf8Printable(parser.value("verify-model"))); return 1; }
|
||||
QStringList bad;
|
||||
const bool ok = lc::models::ModelStore::verify(*m, &bad, [](int done, int total) {
|
||||
std::fprintf(stderr, "\r校验中 %d/%d", done, total);
|
||||
});
|
||||
std::fprintf(stderr, "\n");
|
||||
if (ok) { std::printf("✅ %s 校验通过\n", qUtf8Printable(m->name)); return 0; }
|
||||
std::printf("❌ %s 校验失败,损坏: %s\n", qUtf8Printable(m->name),
|
||||
qUtf8Printable(bad.join(QStringLiteral(", "))));
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::string err;
|
||||
std::unique_ptr<lc::IAsr> asr = lc::app::makeAsr(parser, asrOpt, &err);
|
||||
if (!asr) {
|
||||
@@ -159,5 +185,5 @@ int main(int argc, char ** argv)
|
||||
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);
|
||||
return runCliLive(*asr, sop, parser.value("device"));
|
||||
}
|
||||
+51
-7
@@ -12,6 +12,7 @@
|
||||
#include <QMessageBox>
|
||||
#include <QPainter>
|
||||
#include <QPixmap>
|
||||
#include <QPushButton>
|
||||
#include <QSystemTrayIcon>
|
||||
#include <QTimer>
|
||||
|
||||
@@ -72,6 +73,10 @@ 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"));
|
||||
|
||||
@@ -90,6 +95,8 @@ int main(int argc, char ** argv)
|
||||
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");
|
||||
@@ -100,19 +107,37 @@ int main(int argc, char ** argv)
|
||||
// 仅用于验证渲染:打开设置对话框截图后退出
|
||||
if (parser.isSet("screenshot-settings")) {
|
||||
const QString path = parser.value("screenshot-settings");
|
||||
auto * dlg = new lc::SettingsDialog(!hasUsableModel());
|
||||
dlg->show();
|
||||
QTimer::singleShot(1200, dlg, [dlg, path] {
|
||||
dlg->grab().save(path);
|
||||
lc::SettingsDialog dlg(!hasUsableModel());
|
||||
dlg.show();
|
||||
QTimer::singleShot(1200, &dlg, [&dlg, path] {
|
||||
dlg.grab().save(path);
|
||||
std::fprintf(stderr, "设置对话框截图: %s\n", qUtf8Printable(path));
|
||||
QCoreApplication::quit();
|
||||
dlg.accept();
|
||||
});
|
||||
return QCoreApplication::exec();
|
||||
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; }
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// ---- 启动:没有可用模型就先开设置 ----
|
||||
if (!hasUsableModel()) {
|
||||
lc::SettingsDialog dlg(true);
|
||||
scheduleAutoclose(dlg);
|
||||
if (dlg.exec() != QDialog::Accepted || !hasUsableModel()) return 0;
|
||||
}
|
||||
|
||||
@@ -130,6 +155,7 @@ int main(int argc, char ** argv)
|
||||
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) {
|
||||
@@ -189,7 +215,7 @@ int main(int argc, char ** argv)
|
||||
if (!parser.isSet("no-tray")) {
|
||||
tray = new QSystemTrayIcon(makeTrayIcon(), &app);
|
||||
tray->setToolTip(QObject::tr("实时字幕"));
|
||||
trayMenu = new QMenu;
|
||||
trayMenu = new QMenu(&window); // 必须有归属,否则退出时是悬空顶层窗口
|
||||
auto * showAct = trayMenu->addAction(QObject::tr("显示/隐藏字幕"));
|
||||
auto * setAct = trayMenu->addAction(QObject::tr("设置…"));
|
||||
trayMenu->addSeparator();
|
||||
@@ -239,6 +265,19 @@ int main(int argc, char ** argv)
|
||||
|
||||
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");
|
||||
@@ -255,6 +294,11 @@ int main(int argc, char ** argv)
|
||||
});
|
||||
}
|
||||
|
||||
if (parser.isSet("quit-after")) {
|
||||
QTimer::singleShot(parser.value("quit-after").toInt() * 1000, qApp,
|
||||
&QCoreApplication::quit);
|
||||
}
|
||||
|
||||
const int rc = QCoreApplication::exec();
|
||||
// 显式收尾,确保工作线程停下来之后再释放后端
|
||||
live.stop();
|
||||
|
||||
+81
-20
@@ -6,17 +6,32 @@
|
||||
"id": "qwen3-asr-0.6b-q8",
|
||||
"name": "Qwen3-ASR 0.6B(通用,52 语言)",
|
||||
"backend": "llama-cpp",
|
||||
"languages": ["zh", "en", "ja", "ko", "fr", "de", "es", "ru", "ar", "auto"],
|
||||
"languages": [
|
||||
"zh",
|
||||
"en",
|
||||
"ja",
|
||||
"ko",
|
||||
"fr",
|
||||
"de",
|
||||
"es",
|
||||
"ru",
|
||||
"ar",
|
||||
"auto"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"note": "973MB,准确率高、语言最全、自带语言识别;但每步成本随句子增长(CPU 约为流式后端的 35 倍)",
|
||||
"files": {
|
||||
"model": {
|
||||
"name": "Qwen3-ASR-0.6B-Q8_0.gguf",
|
||||
"url": "https://hf-mirror.com/ggml-org/Qwen3-ASR-0.6B-GGUF/resolve/main/Qwen3-ASR-0.6B-Q8_0.gguf"
|
||||
"url": "https://hf-mirror.com/ggml-org/Qwen3-ASR-0.6B-GGUF/resolve/main/Qwen3-ASR-0.6B-Q8_0.gguf",
|
||||
"size": 804749248,
|
||||
"sha256": "bca259818b50ca7c4c05e9bdb35a5dc04fa039653a6d6f3f0f331f96f6aa1971"
|
||||
},
|
||||
"mmproj": {
|
||||
"name": "mmproj-Qwen3-ASR-0.6B-Q8_0.gguf",
|
||||
"url": "https://hf-mirror.com/ggml-org/Qwen3-ASR-0.6B-GGUF/resolve/main/mmproj-Qwen3-ASR-0.6B-Q8_0.gguf"
|
||||
"url": "https://hf-mirror.com/ggml-org/Qwen3-ASR-0.6B-GGUF/resolve/main/mmproj-Qwen3-ASR-0.6B-Q8_0.gguf",
|
||||
"size": 214392480,
|
||||
"sha256": "41a342b5e4c514e968cb756de6cd1b7be39eff43c44c57a2ef5fc6522e36603d"
|
||||
}
|
||||
},
|
||||
"options": {}
|
||||
@@ -25,25 +40,35 @@
|
||||
"id": "zipformer-zh-2025",
|
||||
"name": "中文流式(k2-fsa 官方 2025-06-30)",
|
||||
"backend": "sherpa-online",
|
||||
"languages": ["zh"],
|
||||
"languages": [
|
||||
"zh"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"note": "154MB,实测中文逐字正确;首个字幕 0.48s,约 0.10 核·秒/秒音频",
|
||||
"files": {
|
||||
"encoder": {
|
||||
"name": "encoder.int8.onnx",
|
||||
"url": "https://hf-mirror.com/csukuangfj/sherpa-onnx-streaming-zipformer-zh-int8-2025-06-30/resolve/main/encoder.int8.onnx"
|
||||
"url": "https://hf-mirror.com/csukuangfj/sherpa-onnx-streaming-zipformer-zh-int8-2025-06-30/resolve/main/encoder.int8.onnx",
|
||||
"size": 161141793,
|
||||
"sha256": "5ac51e27981bb4dab01bb9be4958453ba50c3b61c063ddda0eab23fd3671aa4f"
|
||||
},
|
||||
"decoder": {
|
||||
"name": "decoder.onnx",
|
||||
"url": "https://hf-mirror.com/csukuangfj/sherpa-onnx-streaming-zipformer-zh-int8-2025-06-30/resolve/main/decoder.onnx"
|
||||
"url": "https://hf-mirror.com/csukuangfj/sherpa-onnx-streaming-zipformer-zh-int8-2025-06-30/resolve/main/decoder.onnx",
|
||||
"size": 5165083,
|
||||
"sha256": "06522ad63cec0fdf6809f4e1db9bb4f7d710c34582e3b35db62ac60eccafac7e"
|
||||
},
|
||||
"joiner": {
|
||||
"name": "joiner.int8.onnx",
|
||||
"url": "https://hf-mirror.com/csukuangfj/sherpa-onnx-streaming-zipformer-zh-int8-2025-06-30/resolve/main/joiner.int8.onnx"
|
||||
"url": "https://hf-mirror.com/csukuangfj/sherpa-onnx-streaming-zipformer-zh-int8-2025-06-30/resolve/main/joiner.int8.onnx",
|
||||
"size": 1033416,
|
||||
"sha256": "b34584dc6f561089e1d747fedebb3765f2caa72c927ef54d7ca55e5ae40a814b"
|
||||
},
|
||||
"tokens": {
|
||||
"name": "tokens.txt",
|
||||
"url": "https://hf-mirror.com/csukuangfj/sherpa-onnx-streaming-zipformer-zh-int8-2025-06-30/resolve/main/tokens.txt"
|
||||
"url": "https://hf-mirror.com/csukuangfj/sherpa-onnx-streaming-zipformer-zh-int8-2025-06-30/resolve/main/tokens.txt",
|
||||
"size": 20628,
|
||||
"sha256": "6193c7ea1c96d0d9a1e9652789b40d13a8a913b434a5451e93158f5a09fd6652"
|
||||
}
|
||||
},
|
||||
"options": {}
|
||||
@@ -52,25 +77,36 @@
|
||||
"id": "xasr-zh-en-480",
|
||||
"name": "中英流式(X-ASR-zh-en, 480ms)",
|
||||
"backend": "sherpa-online",
|
||||
"languages": ["zh", "en"],
|
||||
"languages": [
|
||||
"zh",
|
||||
"en"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"note": "587MB,实测中英准确率与 Qwen3-ASR 持平;首个字幕 1.44s",
|
||||
"files": {
|
||||
"encoder": {
|
||||
"name": "encoder-480ms.onnx",
|
||||
"url": "https://hf-mirror.com/GilgameshWind/X-ASR-zh-en/resolve/main/deployment/models/chunk-480ms-model/encoder-480ms.onnx"
|
||||
"url": "https://hf-mirror.com/GilgameshWind/X-ASR-zh-en/resolve/main/deployment/models/chunk-480ms-model/encoder-480ms.onnx",
|
||||
"size": 592968361,
|
||||
"sha256": "0c3454033d249081df124ddcd7adaf3deca07d0b999b26f2ee5d2475d37abc74"
|
||||
},
|
||||
"decoder": {
|
||||
"name": "decoder-480ms.onnx",
|
||||
"url": "https://hf-mirror.com/GilgameshWind/X-ASR-zh-en/resolve/main/deployment/models/chunk-480ms-model/decoder-480ms.onnx"
|
||||
"url": "https://hf-mirror.com/GilgameshWind/X-ASR-zh-en/resolve/main/deployment/models/chunk-480ms-model/decoder-480ms.onnx",
|
||||
"size": 11309084,
|
||||
"sha256": "3658368d274a5d5fd39a7ac20c46bed0ad9cfea1f0feddef30d5d89797c1f499"
|
||||
},
|
||||
"joiner": {
|
||||
"name": "joiner-480ms.onnx",
|
||||
"url": "https://hf-mirror.com/GilgameshWind/X-ASR-zh-en/resolve/main/deployment/models/chunk-480ms-model/joiner-480ms.onnx"
|
||||
"url": "https://hf-mirror.com/GilgameshWind/X-ASR-zh-en/resolve/main/deployment/models/chunk-480ms-model/joiner-480ms.onnx",
|
||||
"size": 10260467,
|
||||
"sha256": "03781c98165a2385024c9cecdd2b6b13310d81db23a62c7da420782c2915cf81"
|
||||
},
|
||||
"tokens": {
|
||||
"name": "tokens.txt",
|
||||
"url": "https://hf-mirror.com/GilgameshWind/X-ASR-zh-en/resolve/main/deployment/models/chunk-480ms-model/tokens.txt"
|
||||
"url": "https://hf-mirror.com/GilgameshWind/X-ASR-zh-en/resolve/main/deployment/models/chunk-480ms-model/tokens.txt",
|
||||
"size": 58806,
|
||||
"sha256": "b818a60878b9aae978cbb8ad594acbd403d76d1af2e31ef4197c84e2dbdba27c"
|
||||
}
|
||||
},
|
||||
"options": {}
|
||||
@@ -79,29 +115,54 @@
|
||||
"id": "nemotron35-560ms",
|
||||
"name": "多语言流式(Nemotron 3.5, 560ms, 含日语)",
|
||||
"backend": "sherpa-online",
|
||||
"languages": ["en", "ja", "ko", "vi", "ru", "ar", "hi", "fr", "de", "es", "it", "pt", "nl", "tr", "uk", "zh"],
|
||||
"languages": [
|
||||
"en",
|
||||
"ja",
|
||||
"ko",
|
||||
"vi",
|
||||
"ru",
|
||||
"ar",
|
||||
"hi",
|
||||
"fr",
|
||||
"de",
|
||||
"es",
|
||||
"it",
|
||||
"pt",
|
||||
"nl",
|
||||
"tr",
|
||||
"uk",
|
||||
"zh"
|
||||
],
|
||||
"license": "OpenMDW-1.1",
|
||||
"note": "651MB,40 种语言 + 内置语言识别;实测中英日都可用但都有瑕疵",
|
||||
"note": "651MB,40 种语言 + 内置语言识别;实测中英日都可用但都有瑕疵",
|
||||
"optionsHint": "language",
|
||||
"files": {
|
||||
"encoder": {
|
||||
"name": "encoder.int8.onnx",
|
||||
"url": "https://hf-mirror.com/csukuangfj2/sherpa-onnx-nemotron-3.5-asr-streaming-0.6b-560ms-int8-2026-06-11/resolve/main/encoder.int8.onnx"
|
||||
"url": "https://hf-mirror.com/csukuangfj2/sherpa-onnx-nemotron-3.5-asr-streaming-0.6b-560ms-int8-2026-06-11/resolve/main/encoder.int8.onnx",
|
||||
"size": 657601403,
|
||||
"sha256": "012e9321373af99021415e0b0eb3ec827b4be3153be6f30d9b448fe65e896e68"
|
||||
},
|
||||
"decoder": {
|
||||
"name": "decoder.int8.onnx",
|
||||
"url": "https://hf-mirror.com/csukuangfj2/sherpa-onnx-nemotron-3.5-asr-streaming-0.6b-560ms-int8-2026-06-11/resolve/main/decoder.int8.onnx"
|
||||
"url": "https://hf-mirror.com/csukuangfj2/sherpa-onnx-nemotron-3.5-asr-streaming-0.6b-560ms-int8-2026-06-11/resolve/main/decoder.int8.onnx",
|
||||
"size": 14978075,
|
||||
"sha256": "19f9c98fc6d0a2c33a65a43b36fdb2e914c26c0aa9764be3aebc502a1e982fb0"
|
||||
},
|
||||
"joiner": {
|
||||
"name": "joiner.int8.onnx",
|
||||
"url": "https://hf-mirror.com/csukuangfj2/sherpa-onnx-nemotron-3.5-asr-streaming-0.6b-560ms-int8-2026-06-11/resolve/main/joiner.int8.onnx"
|
||||
"url": "https://hf-mirror.com/csukuangfj2/sherpa-onnx-nemotron-3.5-asr-streaming-0.6b-560ms-int8-2026-06-11/resolve/main/joiner.int8.onnx",
|
||||
"size": 9504438,
|
||||
"sha256": "4101c7c679a0bc30483794b27a059e34e79232aa2068d78d51231a22c8b0d7ce"
|
||||
},
|
||||
"tokens": {
|
||||
"name": "tokens.txt",
|
||||
"url": "https://hf-mirror.com/csukuangfj2/sherpa-onnx-nemotron-3.5-asr-streaming-0.6b-560ms-int8-2026-06-11/resolve/main/tokens.txt"
|
||||
"url": "https://hf-mirror.com/csukuangfj2/sherpa-onnx-nemotron-3.5-asr-streaming-0.6b-560ms-int8-2026-06-11/resolve/main/tokens.txt",
|
||||
"size": 131440,
|
||||
"sha256": "729cc103155bafa785f9cd45746cd41cabe97eab7182fc04d594129587958f8a"
|
||||
}
|
||||
},
|
||||
"options": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,26 @@ IAsr(src/core/iasr.h)
|
||||
> 同步/自旋开销会让它更慢(8 线程比 1 线程慢 2.6 倍、CPU 高 28 倍)。所以适配器里固定
|
||||
> `threads = 1`。
|
||||
|
||||
## 采集设备选择
|
||||
|
||||
默认跟随系统默认输出;也可以在设置里指定某个输出设备(`--list-devices` 可列出):
|
||||
|
||||
```bash
|
||||
./build/live-caption-cli --list-devices
|
||||
./build/live-caption-cli --cli-live --device alsa_output...HiFi__HDMI1__sink
|
||||
```
|
||||
|
||||
## 完整性校验
|
||||
|
||||
注册表里每个文件都记了 `size` 与 `sha256`:
|
||||
|
||||
- 快速检查(打开/选择模型时):文件存在且**大小**对得上;
|
||||
- 完整校验(设置里「校验完整性」或 `--verify-model <id>`):逐个**重算 sha256**,损坏的文件名会列出来。
|
||||
|
||||
```bash
|
||||
./build/live-caption-cli --verify-model zipformer-zh-2025
|
||||
```
|
||||
|
||||
## 两个坑
|
||||
|
||||
1. **sherpa-onnx 的预编译库是旧 C++ ABI**(`_GLIBCXX_USE_CXX11_ABI=0`),和 Qt 的新 ABI 冲突,
|
||||
|
||||
+117
-13
@@ -149,7 +149,106 @@ bool readWavMono(const std::string & path, int targetRate,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 实时采集:默认 sink 的 monitor
|
||||
// 枚举输出设备
|
||||
// ---------------------------------------------------------------------------
|
||||
namespace {
|
||||
|
||||
// 枚举时用的上下文。注意:回调里**必须** signal 主循环,
|
||||
// 否则 pa_threaded_mainloop_wait 会一直等下去(这里踩过一次坑)。
|
||||
struct SinkListCtx {
|
||||
std::vector<AudioOutputDevice> * out = nullptr;
|
||||
std::string * defaultSink = nullptr;
|
||||
pa_threaded_mainloop * ml = nullptr;
|
||||
};
|
||||
|
||||
void sinkInfoCb(pa_context * /*c*/, const pa_sink_info * info, int /*eol*/, void * userdata)
|
||||
{
|
||||
auto * ctx = static_cast<SinkListCtx *>(userdata);
|
||||
if (info && ctx->out) {
|
||||
AudioOutputDevice d;
|
||||
d.sink = info->name ? info->name : "";
|
||||
d.description = info->description ? info->description : d.sink;
|
||||
ctx->out->push_back(d);
|
||||
}
|
||||
if (ctx->ml) pa_threaded_mainloop_signal(ctx->ml, 0);
|
||||
}
|
||||
|
||||
void serverInfoCb(pa_context * /*c*/, const pa_server_info * info, void * userdata)
|
||||
{
|
||||
auto * ctx = static_cast<SinkListCtx *>(userdata);
|
||||
if (info && info->default_sink_name && ctx->defaultSink) {
|
||||
*ctx->defaultSink = info->default_sink_name;
|
||||
}
|
||||
if (ctx->ml) pa_threaded_mainloop_signal(ctx->ml, 0);
|
||||
}
|
||||
|
||||
// 有界等待:避免任何情况下卡死
|
||||
void waitOp(pa_threaded_mainloop * ml, pa_operation * op)
|
||||
{
|
||||
for (int i = 0; i < 200 && pa_operation_get_state(op) == PA_OPERATION_RUNNING; ++i) {
|
||||
pa_threaded_mainloop_wait(ml);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<AudioOutputDevice> listOutputDevices(std::string * error)
|
||||
{
|
||||
std::vector<AudioOutputDevice> out;
|
||||
pa_threaded_mainloop * ml = pa_threaded_mainloop_new();
|
||||
if (!ml) { if (error) *error = "pa_threaded_mainloop_new 失败"; return out; }
|
||||
pa_context * ctx = pa_context_new(pa_threaded_mainloop_get_api(ml), "live-caption-list");
|
||||
if (!ctx) { pa_threaded_mainloop_free(ml); if (error) *error = "pa_context_new 失败"; return out; }
|
||||
pa_context_set_state_callback(ctx, [](pa_context * c, void * ud) {
|
||||
auto * m = static_cast<pa_threaded_mainloop *>(ud);
|
||||
const pa_context_state_t st = pa_context_get_state(c);
|
||||
if (st == PA_CONTEXT_READY || st == PA_CONTEXT_FAILED || st == PA_CONTEXT_TERMINATED)
|
||||
pa_threaded_mainloop_signal(m, 0);
|
||||
}, ml);
|
||||
|
||||
pa_threaded_mainloop_start(ml);
|
||||
pa_threaded_mainloop_lock(ml);
|
||||
if (pa_context_connect(ctx, nullptr, PA_CONTEXT_NOFLAGS, nullptr) < 0) {
|
||||
pa_threaded_mainloop_unlock(ml);
|
||||
pa_context_unref(ctx); pa_threaded_mainloop_stop(ml); pa_threaded_mainloop_free(ml);
|
||||
if (error) *error = "连接 PulseAudio/PipeWire 失败";
|
||||
return out;
|
||||
}
|
||||
for (;;) {
|
||||
const pa_context_state_t st = pa_context_get_state(ctx);
|
||||
if (st == PA_CONTEXT_READY) break;
|
||||
if (!PA_CONTEXT_IS_GOOD(st)) {
|
||||
pa_threaded_mainloop_unlock(ml);
|
||||
pa_context_unref(ctx); pa_threaded_mainloop_stop(ml); pa_threaded_mainloop_free(ml);
|
||||
if (error) *error = "无法连接到音频服务";
|
||||
return out;
|
||||
}
|
||||
pa_threaded_mainloop_wait(ml);
|
||||
}
|
||||
|
||||
std::string defaultSink;
|
||||
SinkListCtx lctx{&out, &defaultSink, ml};
|
||||
|
||||
pa_operation * opDefault = pa_context_get_server_info(ctx, serverInfoCb, &lctx);
|
||||
waitOp(ml, opDefault);
|
||||
pa_operation_unref(opDefault);
|
||||
|
||||
pa_operation * opSinks = pa_context_get_sink_info_list(ctx, sinkInfoCb, &lctx);
|
||||
waitOp(ml, opSinks);
|
||||
pa_operation_unref(opSinks);
|
||||
|
||||
for (auto & d : out) d.isDefault = (d.sink == defaultSink);
|
||||
|
||||
pa_context_disconnect(ctx);
|
||||
pa_context_unref(ctx);
|
||||
pa_threaded_mainloop_unlock(ml);
|
||||
pa_threaded_mainloop_stop(ml);
|
||||
pa_threaded_mainloop_free(ml);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 实时采集:某个输出设备的 monitor(默认跟随系统默认输出)
|
||||
// ---------------------------------------------------------------------------
|
||||
struct MonitorCapture::Impl {
|
||||
pa_threaded_mainloop * ml = nullptr;
|
||||
@@ -250,7 +349,8 @@ MonitorCapture::~MonitorCapture()
|
||||
|
||||
std::string MonitorCapture::deviceName() const { return d->deviceName; }
|
||||
|
||||
bool MonitorCapture::start(int targetRate, ChunkCallback callback, std::string * error)
|
||||
bool MonitorCapture::start(int targetRate, ChunkCallback callback, std::string * error,
|
||||
const std::string & device)
|
||||
{
|
||||
const auto fail = [error](const std::string & m) {
|
||||
if (error) *error = m;
|
||||
@@ -288,17 +388,21 @@ bool MonitorCapture::start(int targetRate, ChunkCallback callback, std::string *
|
||||
pa_threaded_mainloop_wait(d->ml);
|
||||
}
|
||||
|
||||
// 查默认 sink,拼出它的 monitor 源
|
||||
d->deviceName.clear();
|
||||
pa_operation * op = pa_context_get_server_info(d->ctx, storeMonitor, d);
|
||||
while (pa_operation_get_state(op) == PA_OPERATION_RUNNING) {
|
||||
pa_threaded_mainloop_wait(d->ml);
|
||||
}
|
||||
pa_operation_unref(op);
|
||||
|
||||
if (d->deviceName.empty()) {
|
||||
pa_threaded_mainloop_unlock(d->ml);
|
||||
return fail("拿不到默认输出设备名");
|
||||
if (device.empty()) {
|
||||
// 跟随系统默认输出
|
||||
d->deviceName.clear();
|
||||
pa_operation * op = pa_context_get_server_info(d->ctx, storeMonitor, d);
|
||||
while (pa_operation_get_state(op) == PA_OPERATION_RUNNING) {
|
||||
pa_threaded_mainloop_wait(d->ml);
|
||||
}
|
||||
pa_operation_unref(op);
|
||||
if (d->deviceName.empty()) {
|
||||
pa_threaded_mainloop_unlock(d->ml);
|
||||
return fail("拿不到默认输出设备名");
|
||||
}
|
||||
} else {
|
||||
// 指定设备(sink 名)
|
||||
d->deviceName = device + ".monitor";
|
||||
}
|
||||
|
||||
// 以设备实际速率请求(默认 48k)
|
||||
|
||||
+13
-1
@@ -7,6 +7,16 @@
|
||||
|
||||
namespace lc {
|
||||
|
||||
// 一个可选的采集源(输出设备的 monitor)
|
||||
struct AudioOutputDevice {
|
||||
std::string sink; // 设备名
|
||||
std::string description; // 人类可读描述
|
||||
bool isDefault = false;
|
||||
};
|
||||
|
||||
// 列出所有输出设备(供设置界面选择采集源)
|
||||
std::vector<AudioOutputDevice> listOutputDevices(std::string * error = nullptr);
|
||||
|
||||
// 读取 WAV(PCM 16/24/32 位或 float32),降混为单声道并重采样到 targetRate。
|
||||
bool readWavMono(const std::string & path, int targetRate,
|
||||
std::vector<float> & out, std::string * error);
|
||||
@@ -23,7 +33,9 @@ public:
|
||||
MonitorCapture(const MonitorCapture &) = delete;
|
||||
MonitorCapture & operator=(const MonitorCapture &) = delete;
|
||||
|
||||
bool start(int targetRate, ChunkCallback callback, std::string * error);
|
||||
// device: 空 = 跟随系统默认输出;否则是 sink 名(见 listOutputDevices)
|
||||
bool start(int targetRate, ChunkCallback callback, std::string * error,
|
||||
const std::string & device = std::string());
|
||||
void stop();
|
||||
|
||||
// 实际采集的 monitor 源名(start 成功后有效)
|
||||
|
||||
@@ -32,7 +32,8 @@ bool LivePipeline::start(std::string * error)
|
||||
}
|
||||
cv_.notify_one();
|
||||
},
|
||||
error);
|
||||
error,
|
||||
opt_.captureDevice);
|
||||
if (!ok) return false;
|
||||
|
||||
worker_ = std::thread([this] { workerLoop(); });
|
||||
|
||||
@@ -17,6 +17,7 @@ namespace lc {
|
||||
|
||||
struct LivePipelineOptions {
|
||||
StreamOptions stream;
|
||||
std::string captureDevice; // 空 = 跟随系统默认输出;否则是 sink 名
|
||||
double silenceTimeoutSec = 0.8; // 静音多久算一句结束
|
||||
double prerollSec = 0.25; // 语音开始前保留的一段,避免吃掉词头
|
||||
};
|
||||
|
||||
@@ -44,7 +44,11 @@ void ModelDownloader::start(const ModelInfo & model)
|
||||
void ModelDownloader::cancel()
|
||||
{
|
||||
cancel_ = true;
|
||||
running_ = false;
|
||||
if (reply_) {
|
||||
// 先彻底断开,再 abort:否则 abort 会同步触发 finished,
|
||||
// 而这时对话框可能正在析构、那些控件已经没了。
|
||||
disconnect(reply_, nullptr, this, nullptr);
|
||||
reply_->abort();
|
||||
reply_->deleteLater();
|
||||
reply_ = nullptr;
|
||||
@@ -144,4 +148,5 @@ void ModelDownloader::cleanup()
|
||||
Q_EMIT finished(false, QStringLiteral("已取消"));
|
||||
}
|
||||
|
||||
|
||||
} // namespace lc::models
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "modelstore.h"
|
||||
|
||||
#include <QCryptographicHash>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
@@ -43,9 +44,13 @@ const std::vector<ModelInfo> & parseRegistry()
|
||||
const QJsonObject files = o.value(QStringLiteral("files")).toObject();
|
||||
for (auto it = files.begin(); it != files.end(); ++it) {
|
||||
const QJsonObject fo = it.value().toObject();
|
||||
m.files.push_back({it.key(),
|
||||
fo.value(QStringLiteral("name")).toString(),
|
||||
fo.value(QStringLiteral("url")).toString()});
|
||||
ModelFile mf;
|
||||
mf.key = it.key();
|
||||
mf.name = fo.value(QStringLiteral("name")).toString();
|
||||
mf.url = fo.value(QStringLiteral("url")).toString();
|
||||
mf.size = (qint64) fo.value(QStringLiteral("size")).toDouble();
|
||||
mf.sha256 = fo.value(QStringLiteral("sha256")).toString();
|
||||
m.files.push_back(std::move(mf));
|
||||
}
|
||||
cache.push_back(std::move(m));
|
||||
}
|
||||
@@ -90,7 +95,9 @@ bool ModelStore::isInstalled(const ModelInfo & m, QStringList * missing)
|
||||
bool ok = true;
|
||||
for (const ModelFile & f : m.files) {
|
||||
const QString p = ModelStore::dirFor(m.id) + QLatin1Char('/') + f.name;
|
||||
if (!QFileInfo::exists(p) || QFileInfo(p).size() == 0) {
|
||||
const QFileInfo fi(p);
|
||||
const bool badSize = (f.size > 0) && (fi.size() != f.size);
|
||||
if (!fi.exists() || fi.size() == 0 || badSize) {
|
||||
ok = false;
|
||||
if (missing) *missing << f.name;
|
||||
}
|
||||
@@ -98,6 +105,41 @@ bool ModelStore::isInstalled(const ModelInfo & m, QStringList * missing)
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool ModelStore::verify(const ModelInfo & m, QStringList * bad,
|
||||
const std::function<void(int, int)> & progress)
|
||||
{
|
||||
bool ok = true;
|
||||
int i = 0;
|
||||
for (const ModelFile & f : m.files) {
|
||||
if (progress) progress(i, (int) m.files.size());
|
||||
++i;
|
||||
const QString p = ModelStore::dirFor(m.id) + QLatin1Char('/') + f.name;
|
||||
QFile file(p);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
ok = false;
|
||||
if (bad) *bad << f.name;
|
||||
continue;
|
||||
}
|
||||
if (!f.sha256.isEmpty()) {
|
||||
QCryptographicHash h(QCryptographicHash::Sha256);
|
||||
if (!h.addData(&file)) {
|
||||
ok = false;
|
||||
if (bad) *bad << f.name;
|
||||
continue;
|
||||
}
|
||||
if (QString::fromLatin1(h.result().toHex()) != f.sha256) {
|
||||
ok = false;
|
||||
if (bad) *bad << f.name;
|
||||
}
|
||||
} else if (f.size > 0 && file.size() != f.size) {
|
||||
ok = false;
|
||||
if (bad) *bad << f.name;
|
||||
}
|
||||
}
|
||||
if (progress) progress(i, (int) m.files.size());
|
||||
return ok;
|
||||
}
|
||||
|
||||
const ModelInfo * ModelStore::pickForLanguage(const QString & lang)
|
||||
{
|
||||
// 先找语言列表里明确包含该语言、且已安装的
|
||||
|
||||
+11
-4
@@ -3,6 +3,7 @@
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
#include <functional>
|
||||
#include <vector>
|
||||
|
||||
// 模型注册表与存储。
|
||||
@@ -18,9 +19,11 @@ enum class Backend {
|
||||
};
|
||||
|
||||
struct ModelFile {
|
||||
QString key; // encoder / decoder / joiner / tokens / model / mmproj
|
||||
QString name; // 文件名
|
||||
QString url; // 下载地址
|
||||
QString key; // encoder / decoder / joiner / tokens / model / mmproj
|
||||
QString name; // 文件名
|
||||
QString url; // 下载地址
|
||||
qint64 size = 0; // 字节数(0 = 注册表没写)
|
||||
QString sha256; // 期望的 sha256(空 = 不校验)
|
||||
};
|
||||
|
||||
struct ModelInfo {
|
||||
@@ -43,9 +46,13 @@ public:
|
||||
static const std::vector<ModelInfo> & registry();
|
||||
static const ModelInfo * find(const QString & id);
|
||||
|
||||
// 文件是否齐全;missing 返回缺失文件名
|
||||
// 快速检查:文件齐全且大小对得上(注册表里写了 size 时)
|
||||
static bool isInstalled(const ModelInfo & m, QStringList * missing = nullptr);
|
||||
|
||||
// 完整校验:逐个重算 sha256。bad 返回有问题的文件;progress(done,total) 可选
|
||||
static bool verify(const ModelInfo & m, QStringList * bad = nullptr,
|
||||
const std::function<void(int, int)> & progress = {});
|
||||
|
||||
// 按语言挑一个已安装的模型(优先语言列表里排前的)
|
||||
static const ModelInfo * pickForLanguage(const QString & lang);
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ void AppSettings::load()
|
||||
s.value(QStringLiteral("windowY")).toInt());
|
||||
}
|
||||
|
||||
captureDevice = s.value(QStringLiteral("captureDevice")).toString();
|
||||
|
||||
mirrorBase = s.value(QStringLiteral("mirrorBase"),
|
||||
QStringLiteral("https://hf-mirror.com")).toString();
|
||||
proxyMode = s.value(QStringLiteral("proxyMode"), 0).toInt();
|
||||
@@ -62,6 +64,7 @@ void AppSettings::save()
|
||||
s.setValue(QStringLiteral("windowY"), windowPos.y());
|
||||
}
|
||||
|
||||
s.setValue(QStringLiteral("captureDevice"), captureDevice);
|
||||
s.setValue(QStringLiteral("mirrorBase"), mirrorBase);
|
||||
s.setValue(QStringLiteral("proxyMode"), proxyMode);
|
||||
s.setValue(QStringLiteral("proxyHost"), proxyHost);
|
||||
|
||||
@@ -27,6 +27,9 @@ public:
|
||||
QPoint windowPos; // 空 = 默认(屏幕下方居中)
|
||||
bool hasWindowPos = false;
|
||||
|
||||
// 采集
|
||||
QString captureDevice; // 空 = 跟随系统默认输出
|
||||
|
||||
// 下载
|
||||
QString mirrorBase = QStringLiteral("https://hf-mirror.com");
|
||||
int proxyMode = 0; // 0=不使用 1=跟随系统 2=手动
|
||||
|
||||
@@ -18,6 +18,7 @@ namespace {
|
||||
constexpr int kCollapsedLines = 2;
|
||||
constexpr int kExpandedLines = 8;
|
||||
constexpr int kMargin = 12;
|
||||
constexpr int kTopRowHeight = 30; // 与关闭按钮一致
|
||||
constexpr int kRadius = 10;
|
||||
|
||||
QString escapeHtml(const QString & s)
|
||||
@@ -52,9 +53,10 @@ CaptionWindow::CaptionWindow(QWidget * parent)
|
||||
connect(langPill_, &QPushButton::clicked, this, &CaptionWindow::languageClicked);
|
||||
|
||||
closeBtn_ = new QPushButton(QStringLiteral("\u2715"), this); // ✕
|
||||
closeBtn_->setObjectName(QStringLiteral("closeBtn"));
|
||||
closeBtn_->setCursor(Qt::PointingHandCursor);
|
||||
closeBtn_->setFocusPolicy(Qt::NoFocus);
|
||||
closeBtn_->setFixedSize(22, 22);
|
||||
closeBtn_->setFixedSize(30, 30); // 原来 22×22 太小,不好点
|
||||
connect(closeBtn_, &QPushButton::clicked, this, &CaptionWindow::closed);
|
||||
|
||||
top->addWidget(langPill_);
|
||||
@@ -124,6 +126,13 @@ void CaptionWindow::applyStyle()
|
||||
padding: 1px 10px;
|
||||
}
|
||||
#toggleBtn { color: #bbbbbb; font-size: 15px; padding: 0px; }
|
||||
#closeBtn {
|
||||
color: #e8e8e8;
|
||||
font-size: 16px;
|
||||
padding: 0px;
|
||||
border-radius: 15px;
|
||||
}
|
||||
#closeBtn:hover { background-color: rgba(255,90,90,120); }
|
||||
QTextEdit {
|
||||
color: #ffffff;
|
||||
background: transparent;
|
||||
@@ -192,7 +201,8 @@ void CaptionWindow::relayout()
|
||||
toggleBtn_->setText(collapsed_ ? QStringLiteral("\u2304") // ⌄
|
||||
: QStringLiteral("\u2303")); // ⌃
|
||||
toggleBtn_->setToolTip(collapsed_ ? tr("展开") : tr("收起"));
|
||||
const int h = kMargin * 2 + 22 + 6 + textHeight() + 6 + 24;
|
||||
// 顶部那行的高度要跟着关闭按钮走,否则会挤到字幕
|
||||
const int h = kMargin * 2 + kTopRowHeight + 6 + textHeight() + 6 + 24;
|
||||
setFixedHeight(h);
|
||||
setMinimumWidth(320);
|
||||
scrollToEnd();
|
||||
|
||||
@@ -21,7 +21,8 @@ bool LiveTranscriber::start(IAsr & asr, const StreamOptions & opts, QString * er
|
||||
stop();
|
||||
|
||||
LivePipelineOptions popt;
|
||||
popt.stream = opts;
|
||||
popt.stream = opts;
|
||||
popt.captureDevice = captureDevice_;
|
||||
|
||||
pipeline_ = std::make_unique<LivePipeline>(asr, popt);
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ public:
|
||||
~LiveTranscriber() override;
|
||||
|
||||
// 后端在 start 时注入:设置里换了模型就 stop() 再 start()
|
||||
// 采集源:空 = 跟随系统默认输出
|
||||
void setCaptureDevice(const QString & sink) { captureDevice_ = sink.toStdString(); }
|
||||
|
||||
bool start(IAsr & asr, const StreamOptions & opts, QString * error = nullptr);
|
||||
void stop();
|
||||
|
||||
@@ -33,6 +36,7 @@ Q_SIGNALS:
|
||||
void failed(const QString & message);
|
||||
|
||||
private:
|
||||
std::string captureDevice_;
|
||||
std::unique_ptr<LivePipeline> pipeline_;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#include "settingsdialog.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QCheckBox>
|
||||
#include <QComboBox>
|
||||
#include <QDesktopServices>
|
||||
@@ -24,6 +25,7 @@
|
||||
#include <QVBoxLayout>
|
||||
|
||||
#include "appsettings.h"
|
||||
#include "audio.h"
|
||||
#include "modeldownloader.h"
|
||||
#include "modelstore.h"
|
||||
|
||||
@@ -98,14 +100,17 @@ QWidget * SettingsDialog::buildModelPage()
|
||||
auto * btnRow = new QHBoxLayout;
|
||||
downloadBtn_ = new QPushButton(tr("下载"), page);
|
||||
deleteBtn_ = new QPushButton(tr("删除"), page);
|
||||
verifyBtn_ = new QPushButton(tr("校验完整性"), page);
|
||||
auto * openBtn = new QPushButton(tr("打开模型目录"), page);
|
||||
btnRow->addWidget(downloadBtn_);
|
||||
btnRow->addWidget(verifyBtn_);
|
||||
btnRow->addWidget(deleteBtn_);
|
||||
btnRow->addWidget(openBtn);
|
||||
btnRow->addStretch(1);
|
||||
root->addLayout(btnRow);
|
||||
|
||||
connect(downloadBtn_, &QPushButton::clicked, this, &SettingsDialog::onDownload);
|
||||
connect(verifyBtn_, &QPushButton::clicked, this, &SettingsDialog::onVerify);
|
||||
connect(deleteBtn_, &QPushButton::clicked, this, &SettingsDialog::onDelete);
|
||||
connect(openBtn, &QPushButton::clicked, this, &SettingsDialog::onOpenDir);
|
||||
|
||||
@@ -116,6 +121,22 @@ QWidget * SettingsDialog::buildModelPage()
|
||||
root->addWidget(statusLabel_);
|
||||
|
||||
auto * form = new QFormLayout;
|
||||
captureCombo_ = new QComboBox(page);
|
||||
captureCombo_->addItem(tr("跟随系统默认输出"), QString());
|
||||
{
|
||||
std::string derr;
|
||||
for (const auto & dev : lc::listOutputDevices(&derr)) {
|
||||
captureCombo_->addItem(QString::fromStdString(dev.description) +
|
||||
(dev.isDefault ? tr("(默认)") : QString()),
|
||||
QString::fromStdString(dev.sink));
|
||||
}
|
||||
}
|
||||
{
|
||||
const int idx = captureCombo_->findData(AppSettings::instance().captureDevice);
|
||||
if (idx >= 0) captureCombo_->setCurrentIndex(idx);
|
||||
}
|
||||
form->addRow(tr("采集设备"), captureCombo_);
|
||||
|
||||
activeCombo_ = new QComboBox(page);
|
||||
form->addRow(tr("当前使用"), activeCombo_);
|
||||
|
||||
@@ -372,6 +393,35 @@ void SettingsDialog::onDelete()
|
||||
refreshActiveCombo();
|
||||
}
|
||||
|
||||
void SettingsDialog::onVerify()
|
||||
{
|
||||
const int row = table_->currentRow();
|
||||
if (row < 0) { statusLabel_->setText(tr("请先在上面选一个模型")); return; }
|
||||
const QString id = table_->item(row, 0)->data(Qt::UserRole).toString();
|
||||
const ModelInfo * m = ModelStore::find(id);
|
||||
if (!m) return;
|
||||
|
||||
statusLabel_->setText(tr("正在校验 %1 …").arg(m->name));
|
||||
progress_->setVisible(true);
|
||||
progress_->setRange(0, 100);
|
||||
QApplication::setOverrideCursor(Qt::WaitCursor);
|
||||
|
||||
QStringList bad;
|
||||
const bool ok = ModelStore::verify(*m, &bad, [this](int done, int total) {
|
||||
progress_->setValue(total > 0 ? done * 100 / total : 0);
|
||||
QApplication::processEvents();
|
||||
});
|
||||
|
||||
QApplication::restoreOverrideCursor();
|
||||
progress_->setVisible(false);
|
||||
if (ok) {
|
||||
statusLabel_->setText(tr("✅ %1 校验通过").arg(m->name));
|
||||
} else {
|
||||
statusLabel_->setText(tr("❌ 校验失败,损坏的文件: %1(可删除后重新下载)")
|
||||
.arg(bad.join(QStringLiteral(", "))));
|
||||
}
|
||||
}
|
||||
|
||||
void SettingsDialog::onOpenDir()
|
||||
{
|
||||
QDir().mkpath(ModelStore::root());
|
||||
@@ -401,6 +451,9 @@ void SettingsDialog::onApply()
|
||||
cfg.proxyUser = proxyUser_->text();
|
||||
cfg.proxyPass = proxyPass_->text();
|
||||
cfg.language = languageCombo_->currentData().toString();
|
||||
if (captureCombo_->currentIndex() >= 0) {
|
||||
cfg.captureDevice = captureCombo_->currentData().toString();
|
||||
}
|
||||
|
||||
cfg.fontSize = fontSpin_->value();
|
||||
cfg.showUnstableTail = showTailCheck_->isChecked();
|
||||
|
||||
@@ -38,6 +38,7 @@ private:
|
||||
void refreshModelTable();
|
||||
void refreshActiveCombo();
|
||||
void onDownload();
|
||||
void onVerify();
|
||||
void onDelete();
|
||||
void onOpenDir();
|
||||
void onApply();
|
||||
@@ -46,6 +47,8 @@ private:
|
||||
QTableWidget * table_ = nullptr;
|
||||
QComboBox * activeCombo_ = nullptr;
|
||||
QComboBox * languageCombo_ = nullptr;
|
||||
QComboBox * captureCombo_ = nullptr;
|
||||
QPushButton * verifyBtn_ = nullptr;
|
||||
QPushButton * downloadBtn_ = nullptr;
|
||||
QPushButton * deleteBtn_ = nullptr;
|
||||
QProgressBar * progress_ = nullptr;
|
||||
|
||||
Reference in new issue
Block a user