2026-10-12 13:43:50 +08:00
|
|
|
|
#pragma once
|
|
|
|
|
|
|
|
|
|
|
|
#include <cstdio>
|
2026-10-12 16:06:51 +08:00
|
|
|
|
#include <memory>
|
2026-10-12 13:43:50 +08:00
|
|
|
|
#include <string>
|
|
|
|
|
|
|
|
|
|
|
|
#include <QCommandLineOption>
|
|
|
|
|
|
#include <QCommandLineParser>
|
|
|
|
|
|
|
2026-10-12 16:06:51 +08:00
|
|
|
|
#include "iasr.h"
|
|
|
|
|
|
#include "modelstore.h"
|
2026-10-12 13:43:50 +08:00
|
|
|
|
#include "streamingasr.h"
|
2026-10-12 16:06:51 +08:00
|
|
|
|
#include "qwen3asr.h"
|
|
|
|
|
|
#if defined(LC_HAVE_SHERPA)
|
|
|
|
|
|
#include "sherpaasr.h"
|
|
|
|
|
|
#endif
|
2026-10-12 13:43:50 +08:00
|
|
|
|
|
|
|
|
|
|
// 两个可执行程序(GUI / CLI)共用的命令行选项定义与解析。
|
|
|
|
|
|
namespace lc::app {
|
|
|
|
|
|
|
2026-10-12 13:54:04 +08:00
|
|
|
|
// 构造命令行选项。
|
|
|
|
|
|
//
|
|
|
|
|
|
// Qt 自己不会把默认值印进 --help(构造函数里传的默认值只作为 value() 的兜底),
|
|
|
|
|
|
// 所以这里在给了默认值时把「(默认 X)」拼到说明后面。
|
|
|
|
|
|
inline QCommandLineOption makeOption(const QString & name, const QString & description,
|
|
|
|
|
|
const QString & valueName = QString(),
|
|
|
|
|
|
const QString & defaultValue = QString())
|
|
|
|
|
|
{
|
|
|
|
|
|
QString desc = description;
|
|
|
|
|
|
if (!defaultValue.isEmpty()) {
|
|
|
|
|
|
desc += QStringLiteral("(默认 %1)").arg(defaultValue);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (valueName.isEmpty()) return QCommandLineOption(name, desc);
|
|
|
|
|
|
return QCommandLineOption(name, desc, valueName, defaultValue);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-12 13:43:50 +08:00
|
|
|
|
inline void addModelOptions(QCommandLineParser & p)
|
|
|
|
|
|
{
|
2026-10-12 13:54:04 +08:00
|
|
|
|
p.addOption(makeOption("model", "主模型 GGUF 路径(架构须为 qwen3vl)", "path"));
|
|
|
|
|
|
p.addOption(makeOption("mmproj", "mmproj GGUF 路径(clip + qwen3a)", "path"));
|
|
|
|
|
|
p.addOption(makeOption("threads", "推理线程数", "n", "8"));
|
|
|
|
|
|
p.addOption(makeOption("max-tokens", "单段最多生成 token 数", "n", "256"));
|
|
|
|
|
|
p.addOption(makeOption("verbose", "输出 llama.cpp/mtmd 的详细日志"));
|
2026-10-12 13:43:50 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
inline void addStreamOptions(QCommandLineParser & p)
|
|
|
|
|
|
{
|
2026-10-12 13:54:04 +08:00
|
|
|
|
p.addOption(makeOption("stream-chunk", "流式 chunk / 提交粒度,单位秒", "sec", "2.0"));
|
|
|
|
|
|
p.addOption(makeOption("stream-window", "滑窗上限,单位秒;仅 window 模式,0=不滑窗", "sec", "16"));
|
|
|
|
|
|
p.addOption(makeOption("stream-segment", "单段最长时长,单位秒;0=不限", "sec", "30"));
|
|
|
|
|
|
p.addOption(makeOption("unfixed-tokens", "前缀回滚丢弃的 token 数", "n", "5"));
|
|
|
|
|
|
p.addOption(makeOption("unfixed-chunks", "冷启动不带前缀的 chunk 数", "n", "2"));
|
2026-10-12 14:22:29 +08:00
|
|
|
|
p.addOption(makeOption("stream-mode", "流式后端: incremental / append / window", "mode",
|
|
|
|
|
|
"incremental"));
|
2026-10-12 13:43:50 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
inline AsrOptions asrOptions(const QCommandLineParser & p)
|
|
|
|
|
|
{
|
|
|
|
|
|
AsrOptions o;
|
|
|
|
|
|
o.modelPath = p.value("model").toStdString();
|
|
|
|
|
|
o.mmprojPath = p.value("mmproj").toStdString();
|
|
|
|
|
|
o.threads = p.value("threads").toInt();
|
|
|
|
|
|
o.maxTokens = p.value("max-tokens").toInt();
|
|
|
|
|
|
o.verbose = p.isSet("verbose");
|
|
|
|
|
|
return o;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
inline StreamOptions streamOptions(const QCommandLineParser & p)
|
|
|
|
|
|
{
|
|
|
|
|
|
StreamOptions o;
|
|
|
|
|
|
o.chunkSec = p.value("stream-chunk").toDouble();
|
|
|
|
|
|
o.windowSec = p.value("stream-window").toDouble();
|
|
|
|
|
|
o.maxUtteranceSec = p.value("stream-segment").toDouble();
|
|
|
|
|
|
o.unfixedTokenNum = p.value("unfixed-tokens").toInt();
|
|
|
|
|
|
o.unfixedChunkNum = p.value("unfixed-chunks").toInt();
|
|
|
|
|
|
o.maxTokensPerStep = p.value("max-tokens").toInt();
|
2026-10-12 14:22:29 +08:00
|
|
|
|
o.incremental = p.value("stream-mode") == QLatin1String("incremental");
|
|
|
|
|
|
o.appendOnly = p.value("stream-mode") == QLatin1String("append");
|
2026-10-12 13:43:50 +08:00
|
|
|
|
return o;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
inline bool loadModel(Qwen3Asr & asr, const AsrOptions & opt)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (opt.modelPath.empty() || opt.mmprojPath.empty()) {
|
|
|
|
|
|
std::fprintf(stderr, "必须提供 --model 和 --mmproj\n");
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
std::string err;
|
|
|
|
|
|
if (!asr.load(opt, &err)) {
|
|
|
|
|
|
std::fprintf(stderr, "加载失败: %s\n", err.c_str());
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-12 16:06:51 +08:00
|
|
|
|
// ---- 推理后端选择 ----
|
|
|
|
|
|
//
|
|
|
|
|
|
// llama 后端:Qwen3-ASR 的 GGUF 模型(--model / --mmproj)
|
|
|
|
|
|
// sherpa 后端:流式 ONNX 模型,按注册表 id 从模型存储目录取(--asr-model <id>)
|
|
|
|
|
|
inline void addBackendOptions(QCommandLineParser & p)
|
|
|
|
|
|
{
|
|
|
|
|
|
p.addOption(makeOption("asr-backend", "推理后端: llama(Qwen3-ASR GGUF)或 sherpa(流式 ONNX)",
|
|
|
|
|
|
"kind", "llama"));
|
|
|
|
|
|
p.addOption(makeOption("asr-model", "模型注册表里的 id(sherpa 后端用它定位文件)", "id"));
|
|
|
|
|
|
p.addOption(makeOption("model-dir", "覆盖模型存储目录(默认 ~/.local/share/live-caption/models)", "dir"));
|
|
|
|
|
|
p.addOption(makeOption("language", "语言提示,多语言模型用(zh / en / ja …);留空=自动", "lang"));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-12 16:30:00 +08:00
|
|
|
|
// 按注册表里的模型直接构造后端(GUI 用;不依赖任何命令行参数)
|
|
|
|
|
|
inline std::unique_ptr<IAsr> makeAsrForModel(const models::ModelInfo & m,
|
|
|
|
|
|
const QString & language,
|
2026-10-12 17:36:43 +08:00
|
|
|
|
int threads, int maxTokens, bool verbose,
|
2026-10-12 16:30:00 +08:00
|
|
|
|
std::string * err)
|
|
|
|
|
|
{
|
|
|
|
|
|
QStringList missing;
|
|
|
|
|
|
if (!models::ModelStore::isInstalled(m, &missing)) {
|
|
|
|
|
|
if (err) {
|
|
|
|
|
|
*err = "模型未安装,缺失: " + missing.join(QStringLiteral(", ")).toStdString();
|
|
|
|
|
|
}
|
|
|
|
|
|
return nullptr;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (m.backend == models::Backend::LlamaCpp) {
|
|
|
|
|
|
AsrOptions o;
|
|
|
|
|
|
o.modelPath = m.filePath(QStringLiteral("model")).toStdString();
|
|
|
|
|
|
o.mmprojPath = m.filePath(QStringLiteral("mmproj")).toStdString();
|
|
|
|
|
|
o.threads = threads;
|
2026-10-12 17:36:43 +08:00
|
|
|
|
if (maxTokens > 0) o.maxTokens = maxTokens;
|
2026-10-12 16:30:00 +08:00
|
|
|
|
o.verbose = verbose;
|
|
|
|
|
|
auto a = std::make_unique<Qwen3Asr>();
|
|
|
|
|
|
if (!a->load(o, err)) return nullptr;
|
|
|
|
|
|
return a;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
#if defined(LC_HAVE_SHERPA)
|
|
|
|
|
|
SherpaAsr::Options o;
|
|
|
|
|
|
o.encoder = m.filePath(QStringLiteral("encoder")).toStdString();
|
|
|
|
|
|
o.decoder = m.filePath(QStringLiteral("decoder")).toStdString();
|
|
|
|
|
|
o.joiner = m.filePath(QStringLiteral("joiner")).toStdString();
|
|
|
|
|
|
o.tokens = m.filePath(QStringLiteral("tokens")).toStdString();
|
|
|
|
|
|
o.threads = 1; // 这类模型单线程最快
|
|
|
|
|
|
if (m.languages.size() > 1 && !language.isEmpty()) o.language = language.toStdString();
|
|
|
|
|
|
auto a = std::make_unique<SherpaAsr>();
|
|
|
|
|
|
if (!a->load(o, err)) return nullptr;
|
|
|
|
|
|
return a;
|
|
|
|
|
|
#else
|
|
|
|
|
|
if (err) *err = "编译时未找到 sherpa-onnx";
|
|
|
|
|
|
return nullptr;
|
|
|
|
|
|
#endif
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-12 16:06:51 +08:00
|
|
|
|
// 按命令行选择并加载后端;失败时返回 nullptr 并把原因写进 err
|
|
|
|
|
|
inline std::unique_ptr<IAsr> makeAsr(const QCommandLineParser & p,
|
|
|
|
|
|
const AsrOptions & asrOpt, std::string * err)
|
|
|
|
|
|
{
|
|
|
|
|
|
const QString dir = p.value("model-dir");
|
|
|
|
|
|
if (!dir.isEmpty()) models::ModelStore::setRootOverride(dir);
|
|
|
|
|
|
|
|
|
|
|
|
const QString backend = p.value("asr-backend");
|
|
|
|
|
|
const QString modelId = p.value("asr-model");
|
|
|
|
|
|
const QString lang = p.value("language");
|
|
|
|
|
|
|
|
|
|
|
|
const models::ModelInfo * info =
|
|
|
|
|
|
modelId.isEmpty() ? nullptr : models::ModelStore::find(modelId);
|
|
|
|
|
|
if (!modelId.isEmpty() && !info) {
|
|
|
|
|
|
if (err) *err = "注册表里没有模型: " + modelId.toStdString();
|
|
|
|
|
|
return nullptr;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const bool useSherpa = (backend == QLatin1String("sherpa"))
|
|
|
|
|
|
|| (info && info->backend == models::Backend::SherpaOnline);
|
|
|
|
|
|
|
|
|
|
|
|
if (useSherpa) {
|
|
|
|
|
|
#if defined(LC_HAVE_SHERPA)
|
|
|
|
|
|
if (!info) {
|
|
|
|
|
|
if (err) *err = "sherpa 后端需要 --asr-model <id>(见 data/models.json)";
|
|
|
|
|
|
return nullptr;
|
|
|
|
|
|
}
|
|
|
|
|
|
QStringList missing;
|
|
|
|
|
|
if (!models::ModelStore::isInstalled(*info, &missing)) {
|
|
|
|
|
|
if (err) {
|
|
|
|
|
|
*err = "模型未安装,缺失: " + missing.join(QStringLiteral(", ")).toStdString()
|
|
|
|
|
|
+ "\n 可运行: scripts/fetch-models.sh " + info->id.toStdString();
|
|
|
|
|
|
}
|
|
|
|
|
|
return nullptr;
|
|
|
|
|
|
}
|
|
|
|
|
|
SherpaAsr::Options o;
|
|
|
|
|
|
o.encoder = info->filePath(QStringLiteral("encoder")).toStdString();
|
|
|
|
|
|
o.decoder = info->filePath(QStringLiteral("decoder")).toStdString();
|
|
|
|
|
|
o.joiner = info->filePath(QStringLiteral("joiner")).toStdString();
|
|
|
|
|
|
o.tokens = info->filePath(QStringLiteral("tokens")).toStdString();
|
|
|
|
|
|
o.threads = 1; // 这类模型单线程最快(多线程反而被同步开销拖慢)
|
|
|
|
|
|
if (info->languages.size() > 1 && !lang.isEmpty()) {
|
|
|
|
|
|
o.language = lang.toStdString(); // 多语言模型(Nemotron)需要语言提示
|
|
|
|
|
|
}
|
|
|
|
|
|
auto a = std::make_unique<SherpaAsr>();
|
|
|
|
|
|
if (!a->load(o, err)) return nullptr;
|
|
|
|
|
|
return a;
|
|
|
|
|
|
#else
|
|
|
|
|
|
if (err) *err = "编译时未找到 sherpa-onnx(-DSHERPA_ONNX_ROOT=...)";
|
|
|
|
|
|
return nullptr;
|
|
|
|
|
|
#endif
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
auto a = std::make_unique<Qwen3Asr>();
|
|
|
|
|
|
if (asrOpt.modelPath.empty() || asrOpt.mmprojPath.empty()) {
|
|
|
|
|
|
if (err) *err = "llama 后端需要 --model 和 --mmproj(或改用 --asr-backend sherpa --asr-model <id>)";
|
|
|
|
|
|
return nullptr;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!a->load(asrOpt, err)) return nullptr;
|
|
|
|
|
|
return a;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-12 13:43:50 +08:00
|
|
|
|
} // namespace lc::app
|