feat(asr): add pluggable ONNX streaming backend and model registry
Add a second backend (sherpa-onnx) alongside llama.cpp, so native streaming models can be selected by ID from a JSON registry. All such models share one adapter: only the model files and a few options differ. Models now live under the app data directory and are fetched by scripts/fetch-models.sh. Adding a model is a data change, not code. Measured on the same audio: the streaming backend is ~35x cheaper in CPU than Qwen3-ASR (0.10-0.12 vs 4.27 core-seconds per audio second), shows the first caption at 0.48-1.44s, and matches its accuracy for zh/en. 新增 sherpa-onnx 流式后端,与 llama.cpp 并存;模型按注册表 id 从应用 数据目录加载。同族模型共用一个适配器,新增模型只改 data/models.json。 实测流式后端 CPU 约为 Qwen3-ASR 的 1/35,首个字幕 0.48~1.44s,中英 准确率持平。注意预编译库是旧 C++ ABI,必须走其 C API。
This commit is contained in:
1 parent
6d64987d64
commit
bd921b1ac5
15 files changed
+869
-18
No files matched your search
@@ -30,15 +30,29 @@ struct AsrStreamStep {
|
||||
double inferSeconds = 0.0; // 以上合计
|
||||
};
|
||||
|
||||
// 推理后端的种类,决定上层用哪条流式路径
|
||||
enum class AsrKind {
|
||||
PrefixRollback, // 离线模型 + 前缀回滚(Qwen3-ASR / llama.cpp)
|
||||
NativeStreaming, // 有状态流式识别器(sherpa-onnx 的 transducer 系)
|
||||
};
|
||||
|
||||
// 把推理后端抽象出来,好处是流式策略(StreamingAsr)和实时管线(LivePipeline)
|
||||
// 可以用一个假的实现来测,不必加载真模型。
|
||||
class IAsr {
|
||||
public:
|
||||
virtual ~IAsr() = default;
|
||||
|
||||
virtual AsrKind kind() const { return AsrKind::PrefixRollback; }
|
||||
|
||||
// 期望的输入采样率(Qwen3-ASR 为 16000)
|
||||
virtual int sampleRate() const = 0;
|
||||
|
||||
// ---- NativeStreaming 专用 ----
|
||||
// 喂入音频,返回"这一句到目前为止"的完整文本(识别器会随时修正前面的词)
|
||||
virtual std::string streamFeed(const float * /*pcm*/, size_t /*n*/) { return {}; }
|
||||
// 结束这一句(把尾部 flush 出来),返回最终文本
|
||||
virtual std::string streamFinish() { return {}; }
|
||||
|
||||
// 整段转写:把 prefix 当作"已经生成的 assistant 文本",从那里继续生成
|
||||
virtual AsrResult transcribeWithPrefix(const float * pcm, size_t nSamples,
|
||||
const std::string & prefix) = 0;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
#include "sherpaasr.h"
|
||||
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
#include "sherpa-onnx/c-api/c-api.h"
|
||||
|
||||
namespace lc {
|
||||
|
||||
struct SherpaAsr::Impl {
|
||||
Options opt;
|
||||
const SherpaOnnxOnlineRecognizer * rec = nullptr;
|
||||
const SherpaOnnxOnlineStream * stream = nullptr;
|
||||
|
||||
std::string resultText() const
|
||||
{
|
||||
if (!rec || !stream) return {};
|
||||
const SherpaOnnxOnlineRecognizerResult * r =
|
||||
SherpaOnnxGetOnlineStreamResult(rec, stream);
|
||||
if (!r) return {};
|
||||
std::string text = r->text ? r->text : "";
|
||||
SherpaOnnxDestroyOnlineRecognizerResult(r);
|
||||
return text;
|
||||
}
|
||||
|
||||
void drain()
|
||||
{
|
||||
while (rec && stream && SherpaOnnxIsOnlineStreamReady(rec, stream)) {
|
||||
SherpaOnnxDecodeOnlineStream(rec, stream);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
SherpaAsr::SherpaAsr() : d(new Impl) {}
|
||||
|
||||
SherpaAsr::~SherpaAsr()
|
||||
{
|
||||
if (!d) return;
|
||||
if (d->stream) SherpaOnnxDestroyOnlineStream(d->stream);
|
||||
if (d->rec) SherpaOnnxDestroyOnlineRecognizer(d->rec);
|
||||
delete d;
|
||||
}
|
||||
|
||||
bool SherpaAsr::load(const Options & opt, std::string * error)
|
||||
{
|
||||
const auto fail = [error](const std::string & msg) {
|
||||
if (error) *error = msg;
|
||||
return false;
|
||||
};
|
||||
|
||||
d->opt = opt;
|
||||
|
||||
SherpaOnnxOnlineRecognizerConfig cfg;
|
||||
std::memset(&cfg, 0, sizeof(cfg));
|
||||
cfg.feat_config.sample_rate = 16000;
|
||||
cfg.feat_config.feature_dim = 80;
|
||||
cfg.model_config.transducer.encoder = opt.encoder.c_str();
|
||||
cfg.model_config.transducer.decoder = opt.decoder.c_str();
|
||||
cfg.model_config.transducer.joiner = opt.joiner.c_str();
|
||||
cfg.model_config.tokens = opt.tokens.c_str();
|
||||
cfg.model_config.num_threads = opt.threads;
|
||||
cfg.model_config.provider = "cpu";
|
||||
cfg.model_config.modeling_unit = "";
|
||||
cfg.decoding_method = "greedy_search";
|
||||
cfg.max_active_paths = 4;
|
||||
cfg.enable_endpoint = 0; // 分段由我们的 VAD 负责
|
||||
|
||||
d->rec = SherpaOnnxCreateOnlineRecognizer(&cfg);
|
||||
if (!d->rec) return fail("创建 sherpa-onnx 识别器失败(检查模型文件)");
|
||||
|
||||
d->stream = SherpaOnnxCreateOnlineStream(d->rec);
|
||||
if (!d->stream) return fail("创建 sherpa-onnx 流失败");
|
||||
if (!opt.language.empty()) {
|
||||
SherpaOnnxOnlineStreamSetOption(d->stream, "language", opt.language.c_str());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string SherpaAsr::streamFeed(const float * pcm, size_t n)
|
||||
{
|
||||
if (!d->rec || !d->stream) return {};
|
||||
SherpaOnnxOnlineStreamAcceptWaveform(d->stream, 16000, pcm, (int32_t) n);
|
||||
d->drain();
|
||||
return d->resultText();
|
||||
}
|
||||
|
||||
std::string SherpaAsr::streamFinish()
|
||||
{
|
||||
if (!d->rec || !d->stream) return {};
|
||||
// 先补一小段静音再结束,否则最后一个字常常来不及输出
|
||||
const size_t pad = (size_t) (16000 * 0.3);
|
||||
const std::vector<float> silence(pad, 0.0f);
|
||||
SherpaOnnxOnlineStreamAcceptWaveform(d->stream, 16000, silence.data(), (int32_t) pad);
|
||||
d->drain();
|
||||
// 通知不再有输入,把尾部一并解出来
|
||||
SherpaOnnxOnlineStreamInputFinished(d->stream);
|
||||
d->drain();
|
||||
return d->resultText();
|
||||
}
|
||||
|
||||
void SherpaAsr::streamReset()
|
||||
{
|
||||
if (!d->rec || !d->stream) return;
|
||||
SherpaOnnxOnlineStreamReset(d->rec, d->stream);
|
||||
}
|
||||
|
||||
AsrResult SherpaAsr::transcribeWithPrefix(const float *, size_t, const std::string &)
|
||||
{
|
||||
AsrResult r;
|
||||
r.ok = false;
|
||||
r.error = "该后端只支持原生流式(NativeStreaming),不支持整段/前缀回滚模式";
|
||||
return r;
|
||||
}
|
||||
|
||||
AsrStreamStep SherpaAsr::streamAppend(const float *, size_t, const std::string &, int)
|
||||
{
|
||||
AsrStreamStep s;
|
||||
s.ok = false;
|
||||
s.error = "该后端只支持原生流式(NativeStreaming),请用 streamFeed()";
|
||||
return s;
|
||||
}
|
||||
|
||||
AsrStreamStep SherpaAsr::streamAppendTurn(const float *, size_t, int)
|
||||
{
|
||||
AsrStreamStep s;
|
||||
s.ok = false;
|
||||
s.error = "该后端只支持原生流式(NativeStreaming),请用 streamFeed()";
|
||||
return s;
|
||||
}
|
||||
|
||||
} // namespace lc
|
||||
@@ -0,0 +1,56 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "iasr.h"
|
||||
|
||||
struct SherpaOnnxOnlineRecognizer;
|
||||
struct SherpaOnnxOnlineStream;
|
||||
|
||||
namespace lc {
|
||||
|
||||
// sherpa-onnx 的流式识别器(X-ASR / zipformer / Nemotron …)。
|
||||
//
|
||||
// 用的是它的 **C API** 而不是 C++ API:预编译库是用旧 C++ ABI
|
||||
// (_GLIBCXX_USE_CXX11_ABI=0)编的,和 Qt 的新 ABI 不兼容;C API 是 extern "C",
|
||||
// 不经过 C++ 名字改写,没有这个问题。
|
||||
//
|
||||
// 这类模型是**有状态流式**:喂音频 → 随时取当前整句文本(会修正前面的词)。
|
||||
class SherpaAsr : public IAsr {
|
||||
public:
|
||||
struct Options {
|
||||
std::string encoder;
|
||||
std::string decoder;
|
||||
std::string joiner;
|
||||
std::string tokens;
|
||||
int threads = 1; // 注意:这类模型单线程反而最快
|
||||
std::string language; // 多语言模型(如 Nemotron)用;空 = 不设置
|
||||
};
|
||||
|
||||
SherpaAsr();
|
||||
~SherpaAsr() override;
|
||||
|
||||
SherpaAsr(const SherpaAsr &) = delete;
|
||||
SherpaAsr & operator=(const SherpaAsr &) = delete;
|
||||
|
||||
bool load(const Options & opt, std::string * error);
|
||||
|
||||
AsrKind kind() const override { return AsrKind::NativeStreaming; }
|
||||
int sampleRate() const override { return 16000; }
|
||||
|
||||
std::string streamFeed(const float * pcm, size_t n) override;
|
||||
std::string streamFinish() override;
|
||||
void streamReset() override;
|
||||
|
||||
// 这些只对 PrefixRollback 后端有意义;这里给出明确的失败信息
|
||||
AsrResult transcribeWithPrefix(const float *, size_t, const std::string &) override;
|
||||
AsrStreamStep streamAppend(const float *, size_t, const std::string &, int) override;
|
||||
AsrStreamStep streamAppendTurn(const float *, size_t, int) override;
|
||||
std::string dropLastTokens(const std::string & text, int) const override { return text; }
|
||||
|
||||
private:
|
||||
struct Impl;
|
||||
Impl * d = nullptr;
|
||||
};
|
||||
|
||||
} // namespace lc
|
||||
@@ -1,9 +1,37 @@
|
||||
#include "streamingasr.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <chrono>
|
||||
|
||||
#include "transcript.h"
|
||||
|
||||
namespace lc {
|
||||
|
||||
namespace {
|
||||
|
||||
// 去掉首尾空白(含末尾空格),用于 sherpa 这类可能带前导空格的输出
|
||||
std::string trimAscii(const std::string & s)
|
||||
{
|
||||
const auto notSpace = [](unsigned char c) { return !std::isspace(c); };
|
||||
auto b = std::find_if(s.begin(), s.end(), notSpace);
|
||||
auto e = std::find_if(s.rbegin(), s.rend(), notSpace).base();
|
||||
if (b >= e) return {};
|
||||
return std::string(b, e);
|
||||
}
|
||||
|
||||
// 两个字符串的最长公共前缀,切在 UTF-8 字符边界上
|
||||
size_t utf8Lcp(const std::string & a, const std::string & b)
|
||||
{
|
||||
const size_t n = std::min(a.size(), b.size());
|
||||
size_t i = 0;
|
||||
while (i < n && a[i] == b[i]) ++i;
|
||||
while (i > 0 && (static_cast<unsigned char>(a[i]) & 0xC0) == 0x80) --i;
|
||||
return i;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
StreamingAsr::StreamingAsr(IAsr & asr, StreamOptions opt)
|
||||
: asr_(asr), opt_(opt)
|
||||
{
|
||||
@@ -18,6 +46,7 @@ void StreamingAsr::reset()
|
||||
totalSeconds_ = 0.0;
|
||||
committedRaw_.clear();
|
||||
lastRawFull_.clear();
|
||||
previousFull_.clear();
|
||||
language_.clear();
|
||||
chunkIndex_ = 0;
|
||||
}
|
||||
@@ -50,6 +79,14 @@ void StreamingAsr::feed(const float * pcm, size_t n,
|
||||
|
||||
void StreamingAsr::flush(const std::function<void(const Step &)> & onStep)
|
||||
{
|
||||
// 原生流式:把尾部 flush 出来即收尾(streamFinish 会补齐最后一个字)
|
||||
if (asr_.kind() == AsrKind::NativeStreaming) {
|
||||
std::vector<float> chunk;
|
||||
chunk.swap(pending_);
|
||||
runStep(chunk, true, onStep);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pending_.empty()) {
|
||||
std::vector<float> chunk;
|
||||
chunk.swap(pending_);
|
||||
@@ -86,6 +123,44 @@ void StreamingAsr::flush(const std::function<void(const Step &)> & onStep)
|
||||
void StreamingAsr::runStep(const std::vector<float> & audio, bool finalStep,
|
||||
const std::function<void(const Step &)> & onStep)
|
||||
{
|
||||
// ---- 原生流式(sherpa-onnx 这类有状态识别器)----
|
||||
// 它自己维护状态并随时给出整句文本,会修正前面的词;我们只负责:
|
||||
// 1) 把音频喂进去
|
||||
// 2) 用"与上一次的公共前缀"作为已稳定部分,其余算不稳定尾部
|
||||
if (asr_.kind() == AsrKind::NativeStreaming) {
|
||||
Step s;
|
||||
s.chunkIndex = chunkIndex_;
|
||||
|
||||
const auto t0 = std::chrono::steady_clock::now();
|
||||
std::string cur;
|
||||
if (!audio.empty()) cur = asr_.streamFeed(audio.data(), audio.size());
|
||||
if (finalStep) cur = asr_.streamFinish();
|
||||
const auto t1 = std::chrono::steady_clock::now();
|
||||
|
||||
s.ok = true;
|
||||
s.inferSeconds = std::chrono::duration<double>(t1 - t0).count();
|
||||
s.full = trimAscii(cur);
|
||||
|
||||
const std::string stable = s.full.substr(0, utf8Lcp(previousFull_, s.full));
|
||||
previousFull_ = s.full;
|
||||
s.committed = stable;
|
||||
s.tail = s.full.substr(stable.size());
|
||||
s.language = language_;
|
||||
|
||||
totalSeconds_ += (double) audio.size() / asr_.sampleRate();
|
||||
s.windowSeconds = totalSeconds_;
|
||||
|
||||
++chunkIndex_;
|
||||
if (onStep) onStep(s);
|
||||
|
||||
if (finalStep) {
|
||||
// 这一句结束:并入历史,等下一次 reset()
|
||||
historyText_ += s.full;
|
||||
previousFull_.clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 冷启动阶段不给前缀;之后把"已稳定文本"回喂给模型
|
||||
const std::string prefix =
|
||||
(chunkIndex_ >= opt_.unfixedChunkNum) ? committedRaw_ : std::string();
|
||||
|
||||
@@ -85,6 +85,7 @@ private:
|
||||
double totalSeconds_ = 0.0;
|
||||
std::string committedRaw_; // 当前段的原始已提交文本(含 language 头,用于回喂)
|
||||
std::string lastRawFull_; // 上一次完整的原始文本(含不稳定尾部)
|
||||
std::string previousFull_; // 原生流式:上一次的完整文本(用来算已稳定的前缀)
|
||||
std::string historyText_; // 此前各段已提交的正文(已剥离 language 头)
|
||||
std::string language_;
|
||||
int chunkIndex_ = 0;
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
#include "modelstore.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QStandardPaths>
|
||||
|
||||
namespace lc::models {
|
||||
|
||||
namespace {
|
||||
|
||||
QString g_rootOverride;
|
||||
|
||||
const std::vector<ModelInfo> & parseRegistry()
|
||||
{
|
||||
static std::vector<ModelInfo> cache;
|
||||
static bool loaded = false;
|
||||
if (loaded) return cache;
|
||||
loaded = true;
|
||||
|
||||
QFile f(QStringLiteral(":/data/models.json"));
|
||||
if (!f.open(QIODevice::ReadOnly)) {
|
||||
qWarning("无法读取内嵌的模型注册表");
|
||||
return cache;
|
||||
}
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(f.readAll());
|
||||
for (const QJsonValue & v : doc.object().value(QStringLiteral("models")).toArray()) {
|
||||
const QJsonObject o = v.toObject();
|
||||
ModelInfo m;
|
||||
m.id = o.value(QStringLiteral("id")).toString();
|
||||
m.name = o.value(QStringLiteral("name")).toString();
|
||||
m.license = o.value(QStringLiteral("license")).toString();
|
||||
m.note = o.value(QStringLiteral("note")).toString();
|
||||
m.backend = (o.value(QStringLiteral("backend")).toString() == QLatin1String("llama-cpp"))
|
||||
? Backend::LlamaCpp
|
||||
: Backend::SherpaOnline;
|
||||
for (const QJsonValue & l : o.value(QStringLiteral("languages")).toArray()) {
|
||||
m.languages << l.toString();
|
||||
}
|
||||
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()});
|
||||
}
|
||||
cache.push_back(std::move(m));
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void ModelStore::setRootOverride(const QString & dir) { g_rootOverride = dir; }
|
||||
|
||||
QString ModelStore::root()
|
||||
{
|
||||
if (!g_rootOverride.isEmpty()) return g_rootOverride;
|
||||
// 用 GenericDataLocation(~/.local/share)+ 固定名,保证 CLI/GUI 一致,
|
||||
// 也和 scripts/fetch-models.sh 的目录规则对得上。
|
||||
const QString base = QStandardPaths::writableLocation(QStandardPaths::GenericDataLocation);
|
||||
return base + QStringLiteral("/live-caption/models");
|
||||
}
|
||||
|
||||
QString ModelStore::dirFor(const QString & id) { return root() + QLatin1Char('/') + id; }
|
||||
|
||||
QString ModelInfo::filePath(const QString & key) const
|
||||
{
|
||||
for (const ModelFile & f : files) {
|
||||
if (f.key == key) return ModelStore::dirFor(id) + QLatin1Char('/') + f.name;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
const std::vector<ModelInfo> & ModelStore::registry() { return parseRegistry(); }
|
||||
|
||||
const ModelInfo * ModelStore::find(const QString & id)
|
||||
{
|
||||
for (const ModelInfo & m : registry()) {
|
||||
if (m.id == id) return &m;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
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) {
|
||||
ok = false;
|
||||
if (missing) *missing << f.name;
|
||||
}
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
const ModelInfo * ModelStore::pickForLanguage(const QString & lang)
|
||||
{
|
||||
// 先找语言列表里明确包含该语言、且已安装的
|
||||
for (const ModelInfo & m : registry()) {
|
||||
if (m.languages.contains(lang) && isInstalled(m)) return &m;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
} // namespace lc::models
|
||||
@@ -0,0 +1,56 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
#include <vector>
|
||||
|
||||
// 模型注册表与存储。
|
||||
//
|
||||
// 存储位置遵循 XDG:<AppDataLocation>/models/<id>/,即
|
||||
// ~/.local/share/live-caption/models/<id>/
|
||||
// 注册表是 data/models.json(编译进二进制),新增模型只加一条数据,不改代码。
|
||||
namespace lc::models {
|
||||
|
||||
enum class Backend {
|
||||
SherpaOnline, // sherpa-onnx 的流式识别器(X-ASR / zipformer / Nemotron …)
|
||||
LlamaCpp, // ggml / llama.cpp(GGUF,例如 Qwen3-ASR)
|
||||
};
|
||||
|
||||
struct ModelFile {
|
||||
QString key; // encoder / decoder / joiner / tokens / model / mmproj
|
||||
QString name; // 文件名
|
||||
QString url; // 下载地址
|
||||
};
|
||||
|
||||
struct ModelInfo {
|
||||
QString id;
|
||||
QString name;
|
||||
QString license;
|
||||
QString note;
|
||||
Backend backend = Backend::SherpaOnline;
|
||||
QStringList languages;
|
||||
std::vector<ModelFile> files;
|
||||
|
||||
QString filePath(const QString & key) const; // <store>/<id>/<name>
|
||||
};
|
||||
|
||||
class ModelStore {
|
||||
public:
|
||||
static QString root(); // 模型根目录
|
||||
static QString dirFor(const QString & id);
|
||||
|
||||
static const std::vector<ModelInfo> & registry();
|
||||
static const ModelInfo * find(const QString & id);
|
||||
|
||||
// 文件是否齐全;missing 返回缺失文件名
|
||||
static bool isInstalled(const ModelInfo & m, QStringList * missing = nullptr);
|
||||
|
||||
// 按语言挑一个已安装的模型(优先语言列表里排前的)
|
||||
static const ModelInfo * pickForLanguage(const QString & lang);
|
||||
|
||||
// 手工指定模型目录时,用这个覆盖 root()
|
||||
static void setRootOverride(const QString & dir);
|
||||
};
|
||||
|
||||
} // namespace lc::models
|
||||
Reference in new issue
Block a user