diff --git a/README.md b/README.md index b5bb6b1..29ebc24 100644 --- a/README.md +++ b/README.md @@ -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 # 指定采集源 +./build/live-caption-cli --verify-model # 校验模型完整性 ``` ## 代码结构 diff --git a/apps/cli/main.cpp b/apps/cli/main.cpp index e0c9c1c..48a86ed 100644 --- a/apps/cli/main.cpp +++ b/apps/cli/main.cpp @@ -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 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")); } diff --git a/apps/gui/main.cpp b/apps/gui/main.cpp index 3abd15d..30d4518 100644 --- a/apps/gui/main.cpp +++ b/apps/gui/main.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -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()) { + 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(); diff --git a/data/models.json b/data/models.json index 0e7420e..aa7b905 100644 --- a/data/models.json +++ b/data/models.json @@ -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": {} } ] -} +} \ No newline at end of file diff --git a/docs/asr-backends.md b/docs/asr-backends.md index 0ee2b69..04c7c9d 100644 --- a/docs/asr-backends.md +++ b/docs/asr-backends.md @@ -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 `):逐个**重算 sha256**,损坏的文件名会列出来。 + +```bash +./build/live-caption-cli --verify-model zipformer-zh-2025 +``` + ## 两个坑 1. **sherpa-onnx 的预编译库是旧 C++ ABI**(`_GLIBCXX_USE_CXX11_ABI=0`),和 Qt 的新 ABI 冲突, diff --git a/src/core/audio.cpp b/src/core/audio.cpp index 112a930..58cd165 100644 --- a/src/core/audio.cpp +++ b/src/core/audio.cpp @@ -149,7 +149,106 @@ bool readWavMono(const std::string & path, int targetRate, } // --------------------------------------------------------------------------- -// 实时采集:默认 sink 的 monitor +// 枚举输出设备 +// --------------------------------------------------------------------------- +namespace { + +// 枚举时用的上下文。注意:回调里**必须** signal 主循环, +// 否则 pa_threaded_mainloop_wait 会一直等下去(这里踩过一次坑)。 +struct SinkListCtx { + std::vector * 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(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(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 listOutputDevices(std::string * error) +{ + std::vector 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(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) diff --git a/src/core/audio.h b/src/core/audio.h index 4be3d07..4abbc5b 100644 --- a/src/core/audio.h +++ b/src/core/audio.h @@ -7,6 +7,16 @@ namespace lc { +// 一个可选的采集源(输出设备的 monitor) +struct AudioOutputDevice { + std::string sink; // 设备名 + std::string description; // 人类可读描述 + bool isDefault = false; +}; + +// 列出所有输出设备(供设置界面选择采集源) +std::vector listOutputDevices(std::string * error = nullptr); + // 读取 WAV(PCM 16/24/32 位或 float32),降混为单声道并重采样到 targetRate。 bool readWavMono(const std::string & path, int targetRate, std::vector & 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 成功后有效) diff --git a/src/core/livepipeline.cpp b/src/core/livepipeline.cpp index 18ffc7c..af4af2a 100644 --- a/src/core/livepipeline.cpp +++ b/src/core/livepipeline.cpp @@ -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(); }); diff --git a/src/core/livepipeline.h b/src/core/livepipeline.h index dc26370..9f037ce 100644 --- a/src/core/livepipeline.h +++ b/src/core/livepipeline.h @@ -17,6 +17,7 @@ namespace lc { struct LivePipelineOptions { StreamOptions stream; + std::string captureDevice; // 空 = 跟随系统默认输出;否则是 sink 名 double silenceTimeoutSec = 0.8; // 静音多久算一句结束 double prerollSec = 0.25; // 语音开始前保留的一段,避免吃掉词头 }; diff --git a/src/models/modeldownloader.cpp b/src/models/modeldownloader.cpp index 042bc4d..b8cccaa 100644 --- a/src/models/modeldownloader.cpp +++ b/src/models/modeldownloader.cpp @@ -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 diff --git a/src/models/modelstore.cpp b/src/models/modelstore.cpp index 0a422fa..7c56bf5 100644 --- a/src/models/modelstore.cpp +++ b/src/models/modelstore.cpp @@ -1,5 +1,6 @@ #include "modelstore.h" +#include #include #include #include @@ -43,9 +44,13 @@ const std::vector & 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 & 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) { // 先找语言列表里明确包含该语言、且已安装的 diff --git a/src/models/modelstore.h b/src/models/modelstore.h index 3e33ae0..618d1fc 100644 --- a/src/models/modelstore.h +++ b/src/models/modelstore.h @@ -3,6 +3,7 @@ #include #include +#include #include // 模型注册表与存储。 @@ -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 & 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 & progress = {}); + // 按语言挑一个已安装的模型(优先语言列表里排前的) static const ModelInfo * pickForLanguage(const QString & lang); diff --git a/src/ui/appsettings.cpp b/src/ui/appsettings.cpp index e866667..868931b 100644 --- a/src/ui/appsettings.cpp +++ b/src/ui/appsettings.cpp @@ -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); diff --git a/src/ui/appsettings.h b/src/ui/appsettings.h index aa8b8f1..2194c55 100644 --- a/src/ui/appsettings.h +++ b/src/ui/appsettings.h @@ -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=手动 diff --git a/src/ui/captionwindow.cpp b/src/ui/captionwindow.cpp index d27f0e7..135e73a 100644 --- a/src/ui/captionwindow.cpp +++ b/src/ui/captionwindow.cpp @@ -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(); diff --git a/src/ui/livetranscriber.cpp b/src/ui/livetranscriber.cpp index 76b7bed..208676f 100644 --- a/src/ui/livetranscriber.cpp +++ b/src/ui/livetranscriber.cpp @@ -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(asr, popt); diff --git a/src/ui/livetranscriber.h b/src/ui/livetranscriber.h index 70398ca..4745a1c 100644 --- a/src/ui/livetranscriber.h +++ b/src/ui/livetranscriber.h @@ -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 pipeline_; }; diff --git a/src/ui/settingsdialog.cpp b/src/ui/settingsdialog.cpp index 5cebc2c..6de4ef3 100644 --- a/src/ui/settingsdialog.cpp +++ b/src/ui/settingsdialog.cpp @@ -1,5 +1,6 @@ #include "settingsdialog.h" +#include #include #include #include @@ -24,6 +25,7 @@ #include #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(); diff --git a/src/ui/settingsdialog.h b/src/ui/settingsdialog.h index fcbec7f..3ddff85 100644 --- a/src/ui/settingsdialog.h +++ b/src/ui/settingsdialog.h @@ -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;