diff --git a/apps/common/appcli.h b/apps/common/appcli.h index 639a660..fe4261e 100644 --- a/apps/common/appcli.h +++ b/apps/common/appcli.h @@ -109,7 +109,7 @@ inline void addBackendOptions(QCommandLineParser & p) // 按注册表里的模型直接构造后端(GUI 用;不依赖任何命令行参数) inline std::unique_ptr makeAsrForModel(const models::ModelInfo & m, const QString & language, - int threads, bool verbose, + int threads, int maxTokens, bool verbose, std::string * err) { QStringList missing; @@ -125,6 +125,7 @@ inline std::unique_ptr makeAsrForModel(const models::ModelInfo & m, o.modelPath = m.filePath(QStringLiteral("model")).toStdString(); o.mmprojPath = m.filePath(QStringLiteral("mmproj")).toStdString(); o.threads = threads; + if (maxTokens > 0) o.maxTokens = maxTokens; o.verbose = verbose; auto a = std::make_unique(); if (!a->load(o, err)) return nullptr; diff --git a/apps/gui/main.cpp b/apps/gui/main.cpp index 78636fa..90f0f7f 100644 --- a/apps/gui/main.cpp +++ b/apps/gui/main.cpp @@ -13,7 +13,9 @@ #include #include #include +#include #include +#include #include #include "appcli.h" @@ -66,6 +68,25 @@ bool hasUsableModel() return m && ModelStore::isInstalled(*m); } +// 后端相关设置的「指纹」:这些值变了就必须重建后端(外观类改动不需要)。 +QString backendKeyOf(const lc::AppSettings & c) +{ + return QStringList{ c.modelId, + c.language, + c.captureDevice, + c.modelDir, + c.streamMode, + QString::number(c.chunkSec), + QString::number(c.streamWindowSec), + QString::number(c.streamSegmentSec), + QString::number(c.unfixedTokens), + QString::number(c.unfixedChunks), + QString::number(c.maxTokens), + QString::number(c.threads), + QString::number(c.verbose ? 1 : 0) } + .join(QLatin1Char('|')); +} + } // namespace int main(int argc, char ** argv) @@ -95,6 +116,7 @@ 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-tab", "截图设置对话框时先切到某个页签(0-2 或 模型/显示/高级)", "tab")); parser.addOption(QCommandLineOption("settings-autoclose", "复现用:自动操作设置对话框(reject|close|accept|ok|apply)", "how")); parser.addOption(QCommandLineOption("quit-after", "运行若干秒后优雅退出(测试退出路径用)", "sec")); parser.process(app); @@ -102,12 +124,28 @@ int main(int argc, char ** argv) 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("model-dir")) cfg.modelDir = parser.value("model-dir"); + if (!cfg.modelDir.isEmpty()) lc::models::ModelStore::setRootOverride(cfg.modelDir); // 仅用于验证渲染:打开设置对话框截图后退出 if (parser.isSet("screenshot-settings")) { const QString path = parser.value("screenshot-settings"); lc::SettingsDialog dlg(!hasUsableModel()); + // 可选:先切页签,便于给某个页单独截图 + const QString tab = parser.value("settings-tab"); + if (!tab.isEmpty()) { + if (auto * tw = dlg.findChild()) { + bool numeric = false; + const int n = tab.toInt(&numeric); + if (numeric) { + tw->setCurrentIndex(qBound(0, n, tw->count() - 1)); + } else { + for (int i = 0; i < tw->count(); ++i) { + if (tw->tabText(i) == tab) { tw->setCurrentIndex(i); break; } + } + } + } + } dlg.show(); QTimer::singleShot(1200, &dlg, [&dlg, path] { dlg.grab().save(path); @@ -173,14 +211,24 @@ int main(int argc, char ** argv) return false; } std::string err; - asr = lc::app::makeAsrForModel(*m, cfg.language, cfg.threads, - parser.isSet("verbose"), &err); + asr = lc::app::makeAsrForModel(*m, cfg.language, cfg.threads, cfg.maxTokens, + cfg.verbose || parser.isSet("verbose"), &err); if (!asr) { qCritical("加载模型失败: %s", err.c_str()); return false; } + // 流式参数:只有 llama 后端(Qwen3-ASR)用得到大部分参数, + // 流式 ONNX 只关心「更新粒度」。 lc::StreamOptions sop; + sop.windowSec = cfg.streamWindowSec; + sop.maxUtteranceSec = cfg.streamSegmentSec; + sop.unfixedTokenNum = cfg.unfixedTokens; + sop.unfixedChunkNum = cfg.unfixedChunks; + sop.maxTokensPerStep = cfg.maxTokens; + sop.incremental = (cfg.streamMode != QLatin1String("append") + && cfg.streamMode != QLatin1String("window")); + sop.appendOnly = (cfg.streamMode == QLatin1String("append")); if (asr->kind() == lc::AsrKind::NativeStreaming) { sop.chunkSec = (cfg.chunkSec > 0.0) ? cfg.chunkSec : 0.32; } else if (cfg.chunkSec > 0.0) { @@ -207,28 +255,27 @@ int main(int argc, char ** argv) qWarning("采集失败: %s", qUtf8Printable(m)); }); - // 后端相关项(模型/语言/采集设备)变化才需要重启;外观类改动即时生效 - const auto backendKey = [&cfg] { - return cfg.modelId + QLatin1Char('|') + cfg.language + QLatin1Char('|') + cfg.captureDevice; - }; - QString activeKey = backendKey(); + // 后端相关项(模型/语言/采集设备/流式参数)变化才需要重启;外观类改动即时生效 + QString activeKey = backendKeyOf(cfg); const auto openSettings = [&] { lc::SettingsDialog dlg(false); QObject::connect(&dlg, &lc::SettingsDialog::settingsApplied, &window, [&] { + lc::models::ModelStore::setRootOverride(cfg.modelDir); applyAppearance(window, cfg); // 透明度/字号等立刻可见 - if (backendKey() != activeKey) { - if (startTranscriber()) activeKey = backendKey(); + if (backendKeyOf(cfg) != activeKey) { + if (startTranscriber()) activeKey = backendKeyOf(cfg); } }); if (dlg.exec() == QDialog::Accepted) { + lc::models::ModelStore::setRootOverride(cfg.modelDir); applyAppearance(window, cfg); - if (backendKey() != activeKey) { + if (backendKeyOf(cfg) != activeKey) { if (!startTranscriber()) { QMessageBox::warning(&window, QObject::tr("启动失败"), QObject::tr("模型加载失败,请检查设置。")); } else { - activeKey = backendKey(); + activeKey = backendKeyOf(cfg); } } } diff --git a/docs/cli.md b/docs/cli.md index 527031b..88cf826 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -28,6 +28,7 @@ 参数的选择与实测见 [docs/phase2-streaming.md](phase2-streaming.md)。 **这些参数只影响 `llama` 后端**(Qwen3-ASR);`sherpa` 的流式模型只用到「提交粒度」一个参数。 +同一组参数在 GUI 的「高级 → 流式」里也有(选了流式 ONNX 模型时那一组会置灰)。 | 选项 | 默认 | 说明 | |---|---|---| @@ -84,6 +85,7 @@ | `--screenshot ` | — | 启动后自动截图并退出;同时截折叠态与展开态(`-expanded.png`) | | `--screenshot-delay ` | `6` | 截图前等待秒数 | | `--screenshot-settings ` | — | 打开设置对话框、截图后退出 | +| `--settings-tab ` | — | 截图设置对话框时先切到某个页签(`0`–`2` 或 `模型`/`显示`/`高级`) | | `--settings-autoclose ` | — | 自动操作设置对话框:`reject` / `close` / `accept` / `ok` / `apply`(`ok`/`apply` 是真的去点按钮) | | `--quit-after ` | — | 运行若干秒后优雅退出(测退出路径) | diff --git a/docs/gui.md b/docs/gui.md index 2e6d249..88c605f 100644 --- a/docs/gui.md +++ b/docs/gui.md @@ -47,8 +47,9 @@ | 取消 | 放弃本次改动(已经用「应用」生效过的改动不会被回滚) | | 确定 | 应用并关闭 | -**哪些改动要重启识别后端?** 只有**模型 / 识别语言 / 采集设备**这三项变化时才会重建后端并重启转写; -透明度、字号、行数、灰色尾部这类外观改动是即时生效的,不会打断正在进行的转写。 +**哪些改动要重启识别后端?** 只有**模型 / 识别语言 / 采集设备**,以及高级页里的**流式参数、 +线程数、模型目录**变化时才会重建后端并重启转写;透明度、字号、行数、灰色尾部这类外观改动是 +即时生效的,不会打断正在进行的转写。 ### 模型页 @@ -80,10 +81,31 @@ ### 高级页 +**通用** + | 项 | 说明 | |---|---| | 更新粒度(秒) | 累积多少秒音频跑一步;「自动」= 由后端决定(流式模型 0.32s) | -| 线程数 | 推理线程数;**只影响 Qwen3-ASR**,流式模型固定单线程(多线程反而更慢) | + +**流式(只影响 Qwen3-ASR)** —— 这一组对应 llama 后端的流式参数,改动会重启识别后端。 +选了流式 ONNX 模型(zipformer / X-ASR / Nemotron)时整组会**置灰**,因为那些模型只用「更新粒度」。 + +| 项 | 对应命令行 | 说明 | +|---|---|---| +| 策略 | `--stream-mode` | `incremental`(增量复用,1~2 秒就出字,推荐)/ `append`(追加式,成本平坦但需要大 chunk)/ `window`(早期的对比实现) | +| 滑窗上限(秒) | `--stream-window` | 仅 window 模式;「不限」= 不滑窗 | +| 单段最长时长(秒) | `--stream-segment` | 超过就提交并重置上下文;「不限」= 不设上限 | +| 前缀回滚 token 数 | `--unfixed-tokens` | 回滚丢弃最后 N 个 token —— 它们会显示成灰色的不稳定尾部 | +| 冷启动 chunk 数 | `--unfixed-chunks` | 前 N 个 chunk 不带前缀,避免开头重复 | +| 单段最多生成 token | `--max-tokens` | 单段生成的 token 上限 | +| 线程数 | `--threads` | 只影响 Qwen3-ASR;流式模型固定单线程(多线程反而更慢) | + +**调试** + +| 项 | 对应命令行 | 说明 | +|---|---|---| +| 输出详细日志 | `--verbose` | 打印 llama.cpp / mtmd 的详细日志(排查加载/推理问题时用) | +| 模型目录 | `--model-dir` | 留空 = 默认目录(占位符会显示实际路径);一般不需要改 | ## 配置文件 @@ -101,6 +123,10 @@ windowX=179 windowY=725 ; 拖动后记录的位置 mirrorBase=https://hf-mirror.com proxyMode=0 ; 0=不使用 1=跟随系统 2=手动 chunkSec=0 threads=8 ; 高级 +streamMode=incremental ; 高级:流式参数(只影响 Qwen3-ASR) +streamWindowSec=16 streamSegmentSec=30 +unfixedTokens=5 unfixedChunks=2 maxTokens=256 +verbose=false modelDir= ; 高级:调试 ``` 删掉这个文件(或其中某个键)就回到默认值。模型文件在 diff --git a/src/ui/appsettings.cpp b/src/ui/appsettings.cpp index 868931b..04b8fde 100644 --- a/src/ui/appsettings.cpp +++ b/src/ui/appsettings.cpp @@ -42,6 +42,15 @@ void AppSettings::load() chunkSec = s.value(QStringLiteral("chunkSec"), 0.0).toDouble(); threads = s.value(QStringLiteral("threads"), 8).toInt(); + + streamMode = s.value(QStringLiteral("streamMode"), QStringLiteral("incremental")).toString(); + streamWindowSec = s.value(QStringLiteral("streamWindowSec"), 16.0).toDouble(); + streamSegmentSec = s.value(QStringLiteral("streamSegmentSec"), 30.0).toDouble(); + unfixedTokens = s.value(QStringLiteral("unfixedTokens"), 5).toInt(); + unfixedChunks = s.value(QStringLiteral("unfixedChunks"), 2).toInt(); + maxTokens = s.value(QStringLiteral("maxTokens"), 256).toInt(); + verbose = s.value(QStringLiteral("verbose"), false).toBool(); + modelDir = s.value(QStringLiteral("modelDir")).toString(); s.endGroup(); } @@ -74,6 +83,14 @@ void AppSettings::save() s.setValue(QStringLiteral("chunkSec"), chunkSec); s.setValue(QStringLiteral("threads"), threads); + s.setValue(QStringLiteral("streamMode"), streamMode); + s.setValue(QStringLiteral("streamWindowSec"), streamWindowSec); + s.setValue(QStringLiteral("streamSegmentSec"), streamSegmentSec); + s.setValue(QStringLiteral("unfixedTokens"), unfixedTokens); + s.setValue(QStringLiteral("unfixedChunks"), unfixedChunks); + s.setValue(QStringLiteral("maxTokens"), maxTokens); + s.setValue(QStringLiteral("verbose"), verbose); + s.setValue(QStringLiteral("modelDir"), modelDir); s.endGroup(); s.sync(); } diff --git a/src/ui/appsettings.h b/src/ui/appsettings.h index 2194c55..585a639 100644 --- a/src/ui/appsettings.h +++ b/src/ui/appsettings.h @@ -42,6 +42,18 @@ public: double chunkSec = 0.0; // 0 = 由后端决定 int threads = 8; + // 高级:流式参数(只影响 llama 后端的 Qwen3-ASR;流式 ONNX 只用 chunkSec) + QString streamMode = QStringLiteral("incremental"); // incremental | append | window + double streamWindowSec = 16.0; // 仅 window 模式:滑窗上限;0 = 不限 + double streamSegmentSec = 30.0; // 单段最长时长;0 = 不限 + int unfixedTokens = 5; // 前缀回滚丢弃的 token 数(算作不稳定尾部) + int unfixedChunks = 2; // 冷启动不带前缀的 chunk 数 + int maxTokens = 256; // 单段最多生成 token 数 + + // 高级:调试 + bool verbose = false; // 输出 llama.cpp / mtmd 的详细日志 + QString modelDir; // 空 = 默认模型存储目录 + void load(); void save(); diff --git a/src/ui/settingsdialog.cpp b/src/ui/settingsdialog.cpp index 10ec1d4..2cab5e0 100644 --- a/src/ui/settingsdialog.cpp +++ b/src/ui/settingsdialog.cpp @@ -77,6 +77,7 @@ SettingsDialog::SettingsDialog(bool firstRun, QWidget * parent) refreshModelTable(); refreshActiveCombo(); + updateStreamGroupEnabled(); } SettingsDialog::~SettingsDialog() = default; @@ -208,6 +209,7 @@ QWidget * SettingsDialog::buildModelPage() AppSettings::instance().modelId = activeCombo_->itemData(idx).toString(); AppSettings::instance().save(); } + updateStreamGroupEnabled(); }); connect(downloader_, &ModelDownloader::status, this, [this](const QString & m) { @@ -282,30 +284,125 @@ QWidget * SettingsDialog::buildDisplayPage() QWidget * SettingsDialog::buildAdvancedPage() { auto * page = new QWidget(this); - auto * form = new QFormLayout(page); + auto * root = new QVBoxLayout(page); AppSettings & cfg = AppSettings::instance(); + // ---- 通用 ---- + auto * generalForm = new QFormLayout; + chunkSpin_ = new QDoubleSpinBox(page); chunkSpin_->setRange(0.0, 5.0); chunkSpin_->setSingleStep(0.1); chunkSpin_->setDecimals(2); chunkSpin_->setSpecialValueText(tr("自动")); chunkSpin_->setValue(cfg.chunkSec); - form->addRow(tr("更新粒度(秒)"), chunkSpin_); + chunkSpin_->setToolTip(tr("累积多少秒音频跑一步。流式模型默认 0.32 秒。")); + generalForm->addRow(tr("更新粒度(秒)"), chunkSpin_); - threadsSpin_ = new QSpinBox(page); + root->addLayout(generalForm); + + // ---- 流式:只影响 llama 后端的 Qwen3-ASR ---- + streamGroup_ = new QGroupBox(tr("流式(只影响 Qwen3-ASR)"), page); + auto * streamForm = new QFormLayout(streamGroup_); + + streamModeCombo_ = new QComboBox(streamGroup_); + streamModeCombo_->addItem(tr("incremental — 增量复用,1~2 秒就出字(推荐)"), + QStringLiteral("incremental")); + streamModeCombo_->addItem(tr("append — 追加式,成本平坦但需要大 chunk"), + QStringLiteral("append")); + streamModeCombo_->addItem(tr("window — 每次重编码整个窗口(早期的对比实现)"), + QStringLiteral("window")); + { + const int i = streamModeCombo_->findData(cfg.streamMode); + if (i >= 0) streamModeCombo_->setCurrentIndex(i); + } + streamForm->addRow(tr("策略"), streamModeCombo_); + + windowSpin_ = new QDoubleSpinBox(streamGroup_); + windowSpin_->setRange(0.0, 120.0); + windowSpin_->setDecimals(1); + windowSpin_->setSpecialValueText(tr("不限")); + windowSpin_->setValue(cfg.streamWindowSec); + windowSpin_->setToolTip(tr("仅 window 模式使用:滑窗上限。")); + streamForm->addRow(tr("滑窗上限(秒)"), windowSpin_); + + segmentSpin_ = new QDoubleSpinBox(streamGroup_); + segmentSpin_->setRange(0.0, 300.0); + segmentSpin_->setDecimals(1); + segmentSpin_->setSpecialValueText(tr("不限")); + segmentSpin_->setValue(cfg.streamSegmentSec); + segmentSpin_->setToolTip(tr("超过就提交并重置上下文。真实场景由静音检测分段,这是兜底上界。")); + streamForm->addRow(tr("单段最长时长(秒)"), segmentSpin_); + + unfixedTokenSpin_ = new QSpinBox(streamGroup_); + unfixedTokenSpin_->setRange(0, 64); + unfixedTokenSpin_->setValue(cfg.unfixedTokens); + unfixedTokenSpin_->setToolTip(tr("回滚丢弃最后 N 个 token —— 它们会显示成灰色的不稳定尾部。")); + streamForm->addRow(tr("前缀回滚 token 数"), unfixedTokenSpin_); + + unfixedChunkSpin_ = new QSpinBox(streamGroup_); + unfixedChunkSpin_->setRange(0, 32); + unfixedChunkSpin_->setValue(cfg.unfixedChunks); + unfixedChunkSpin_->setToolTip(tr("前 N 个 chunk 不带前缀(冷启动),用于避免开头重复。")); + streamForm->addRow(tr("冷启动 chunk 数"), unfixedChunkSpin_); + + maxTokenSpin_ = new QSpinBox(streamGroup_); + maxTokenSpin_->setRange(16, 4096); + maxTokenSpin_->setValue(cfg.maxTokens); + maxTokenSpin_->setToolTip(tr("单段最多生成的 token 数。")); + streamForm->addRow(tr("单段最多生成 token"), maxTokenSpin_); + + threadsSpin_ = new QSpinBox(streamGroup_); threadsSpin_->setRange(1, 32); threadsSpin_->setValue(cfg.threads); - form->addRow(tr("线程数"), threadsSpin_); + threadsSpin_->setToolTip(tr("只影响 Qwen3-ASR;流式模型固定单线程,因为这类模型多线程反而更慢。")); + streamForm->addRow(tr("线程数"), threadsSpin_); - auto * note = new QLabel( - tr("说明:流式模型(zipformer / X-ASR / Nemotron)会自动使用单线程," - "因为这类模型多线程反而更慢;线程数只影响 Qwen3-ASR。"), page); - note->setWordWrap(true); - form->addRow(QString(), note); + streamNote_ = new QLabel(streamGroup_); + streamNote_->setWordWrap(true); + streamForm->addRow(QString(), streamNote_); + + root->addWidget(streamGroup_); + + // ---- 调试 ---- + auto * debugForm = new QFormLayout; + + verboseCheck_ = new QCheckBox(tr("输出详细日志(llama.cpp / mtmd)"), page); + verboseCheck_->setChecked(cfg.verbose); + debugForm->addRow(QString(), verboseCheck_); + + modelDirEdit_ = new QLineEdit(page); + modelDirEdit_->setText(cfg.modelDir); + modelDirEdit_->setPlaceholderText(ModelStore::root()); + modelDirEdit_->setToolTip(tr("留空 = 默认目录(占位符所示)。一般不需要改。")); + debugForm->addRow(tr("模型目录"), modelDirEdit_); + + root->addLayout(debugForm); + root->addStretch(1); + + updateStreamGroupEnabled(); return page; } +// 流式那一组参数只对 llama 后端(Qwen3-ASR)有意义; +// 选了流式 ONNX 模型时置灰,免得改了没效果。 +void SettingsDialog::updateStreamGroupEnabled() +{ + if (!streamGroup_) return; + const QString id = (activeCombo_ && activeCombo_->currentIndex() >= 0) + ? activeCombo_->currentData().toString() + : QString(); + const ModelInfo * m = id.isEmpty() ? nullptr : ModelStore::find(id); + const bool llamaBackend = m ? (m->backend == models::Backend::LlamaCpp) : true; + + streamGroup_->setEnabled(llamaBackend); + streamNote_->setText(llamaBackend + ? tr("这些参数对应命令行里的 --stream-mode / --stream-window / --stream-segment / " + "--unfixed-tokens / --unfixed-chunks / --max-tokens / --threads。改动会重启识别后端。") + : tr("当前模型是流式 ONNX(zipformer / X-ASR / Nemotron),这些参数不适用;" + "它只用上面那个「更新粒度」。")); +} + // --------------------------------------------------------------------------- void SettingsDialog::refreshModelTable() { @@ -480,6 +577,15 @@ void SettingsDialog::applySettings() cfg.chunkSec = chunkSpin_->value(); cfg.threads = threadsSpin_->value(); + cfg.streamMode = streamModeCombo_->currentData().toString(); + cfg.streamWindowSec = windowSpin_->value(); + cfg.streamSegmentSec = segmentSpin_->value(); + cfg.unfixedTokens = unfixedTokenSpin_->value(); + cfg.unfixedChunks = unfixedChunkSpin_->value(); + cfg.maxTokens = maxTokenSpin_->value(); + cfg.verbose = verboseCheck_->isChecked(); + cfg.modelDir = modelDirEdit_->text().trimmed(); + if (activeCombo_->currentIndex() >= 0) { cfg.modelId = activeCombo_->currentData().toString(); } diff --git a/src/ui/settingsdialog.h b/src/ui/settingsdialog.h index 79ce810..174a560 100644 --- a/src/ui/settingsdialog.h +++ b/src/ui/settingsdialog.h @@ -5,6 +5,7 @@ class QComboBox; class QCheckBox; class QDoubleSpinBox; +class QGroupBox; class QLabel; class QLineEdit; class QProgressBar; @@ -37,6 +38,7 @@ private: void refreshModelTable(); void refreshActiveCombo(); + void updateStreamGroupEnabled(); // 流式参数只在 llama 后端下可用 void onDownload(); void onVerify(); void onDelete(); @@ -73,6 +75,20 @@ private: QDoubleSpinBox * chunkSpin_ = nullptr; QSpinBox * threadsSpin_ = nullptr; + // 高级:流式参数(只影响 llama 后端) + QGroupBox * streamGroup_ = nullptr; + QLabel * streamNote_ = nullptr; + QComboBox * streamModeCombo_ = nullptr; + QDoubleSpinBox * windowSpin_ = nullptr; + QDoubleSpinBox * segmentSpin_ = nullptr; + QSpinBox * unfixedTokenSpin_ = nullptr; + QSpinBox * unfixedChunkSpin_ = nullptr; + QSpinBox * maxTokenSpin_ = nullptr; + + // 高级:调试 + QCheckBox * verboseCheck_ = nullptr; + QLineEdit * modelDirEdit_ = nullptr; + models::ModelDownloader * downloader_ = nullptr; bool firstRun_ = false; };