feat: add real-time caption CLI prototype with Qwen3-ASR

Add a C++/Qt6/CMake CLI prototype that captures the default sink
monitor through libpulse and transcribes it in-process with Qwen3-ASR
via libllama/libmtmd. Add phase 0/1 verification docs.

新增实时字幕命令行原型:libpulse 采集默认输出 monitor,进程内用
libllama/libmtmd 跑 Qwen3-ASR 推理。附阶段 0/1 验证文档。
This commit is contained in:
blumia committed 2026-10-12 11:40:59 +08:00
commit 18dbc9c8d3
18 files changed
+1682

No files matched your search

+381
View File
@@ -0,0 +1,381 @@
#include "audio.h"
#include <cstdint>
#include <cstring>
#include <cstdio>
#include <fstream>
#include <samplerate.h>
#include <pulse/pulseaudio.h>
namespace lc {
namespace {
// 单声道流式重采样器(libsamplerate)
class Resampler {
public:
~Resampler() { if (st_) src_delete(st_); }
bool init(int fromRate, int toRate)
{
int err = 0;
st_ = src_new(SRC_SINC_FASTEST, 1, &err);
ratio_ = (double) toRate / (double) fromRate;
return st_ != nullptr;
}
// 输入 frames 个单声道样本,追加到 out
bool process(const float * in, size_t frames, std::vector<float> & out)
{
if (!st_) return false;
if (frames == 0) return true;
// 输出上限:比例 + 余量
const size_t maxOut = (size_t) (frames * ratio_) + 64;
const size_t base = out.size();
out.resize(base + maxOut);
SRC_DATA d{};
d.data_in = in;
d.input_frames = (long) frames;
d.data_out = out.data() + base;
d.output_frames = (long) maxOut;
d.src_ratio = ratio_;
d.end_of_input = 0;
if (src_process(st_, &d) != 0) return false;
out.resize(base + (size_t) d.output_frames_gen);
return true;
}
private:
SRC_STATE * st_ = nullptr;
double ratio_ = 1.0;
};
// 读小端整数
uint32_t rd32(const uint8_t * p) { return p[0] | (p[1] << 8) | (p[2] << 16) | ((uint32_t) p[3] << 24); }
uint16_t rd16(const uint8_t * p) { return (uint16_t) (p[0] | (p[1] << 8)); }
} // namespace
// ---------------------------------------------------------------------------
// WAV 读取
// ---------------------------------------------------------------------------
bool readWavMono(const std::string & path, int targetRate,
std::vector<float> & out, std::string * error)
{
const auto fail = [error](const std::string & m) {
if (error) *error = m;
return false;
};
std::ifstream f(path, std::ios::binary);
if (!f) return fail("无法打开文件: " + path);
std::vector<uint8_t> buf((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
if (buf.size() < 44 || std::memcmp(buf.data(), "RIFF", 4) != 0 || std::memcmp(buf.data() + 8, "WAVE", 4) != 0) {
return fail("不是合法的 WAV 文件: " + path);
}
uint16_t audioFormat = 0, channels = 0, bitsPerSample = 0;
uint32_t sampleRate = 0;
const uint8_t * dataPtr = nullptr;
size_t dataLen = 0;
size_t pos = 12;
while (pos + 8 <= buf.size()) {
const uint8_t * chunk = buf.data() + pos;
const uint32_t chunkSize = rd32(chunk + 4);
const uint8_t * body = chunk + 8;
if (std::memcmp(chunk, "fmt ", 4) == 0) {
if (chunkSize < 16) return fail("fmt 块过短");
audioFormat = rd16(body + 0);
channels = rd16(body + 2);
sampleRate = rd32(body + 4);
bitsPerSample = rd16(body + 14);
if (audioFormat == 0xFFFE && chunkSize >= 40) {
audioFormat = rd16(body + 24); // WAVE_FORMAT_EXTENSIBLE 的 subformat 前两字节
}
} else if (std::memcmp(chunk, "data", 4) == 0) {
dataPtr = body;
dataLen = chunkSize;
if (pos + 8 + chunkSize > buf.size()) dataLen = buf.size() - (pos + 8);
}
pos += 8 + chunkSize + (chunkSize & 1); // 块按偶数字节对齐
}
if (!dataPtr || channels == 0 || sampleRate == 0) return fail("WAV 缺少 fmt 或 data 块");
const bool isFloat = (audioFormat == 3);
const int bytesPerSample = bitsPerSample / 8;
if (bytesPerSample <= 0) return fail("不支持的位深");
const size_t frameBytes = (size_t) bytesPerSample * channels;
const size_t frames = dataLen / frameBytes;
// 降混为单声道 float
std::vector<float> mono(frames);
for (size_t i = 0; i < frames; ++i) {
double acc = 0.0;
const uint8_t * p = dataPtr + i * frameBytes;
for (uint16_t c = 0; c < channels; ++c, p += bytesPerSample) {
if (isFloat) {
float v;
std::memcpy(&v, p, 4);
acc += v;
} else if (bitsPerSample == 16) {
acc += (int16_t) rd16(p) / 32768.0;
} else if (bitsPerSample == 24) {
int32_t v = p[0] | (p[1] << 8) | (p[2] << 16);
if (v & 0x800000) v |= ~0xFFFFFF;
acc += v / 8388608.0;
} else if (bitsPerSample == 32) {
acc += (int32_t) rd32(p) / 2147483648.0;
}
}
mono[i] = (float) (acc / channels);
}
if ((int) sampleRate == targetRate) {
out = std::move(mono);
return true;
}
Resampler rs;
if (!rs.init((int) sampleRate, targetRate)) return fail("重采样器初始化失败");
out.clear();
return rs.process(mono.data(), mono.size(), out);
}
// ---------------------------------------------------------------------------
// 实时采集:默认 sink 的 monitor
// ---------------------------------------------------------------------------
struct MonitorCapture::Impl {
pa_threaded_mainloop * ml = nullptr;
pa_context * ctx = nullptr;
pa_stream * stream = nullptr;
ChunkCallback callback;
int targetRate = 16000;
int deviceRate = 48000;
int channels = 2;
Resampler resampler;
std::string deviceName;
bool ready = false;
};
namespace {
void storeMonitor(pa_context * /*c*/, const pa_server_info * info, void * userdata)
{
auto * d = static_cast<MonitorCapture::Impl *>(userdata);
if (!info || !info->default_sink_name) return;
d->deviceName = std::string(info->default_sink_name) + ".monitor";
pa_threaded_mainloop_signal(d->ml, 0);
}
void streamState(pa_stream * s, void * userdata)
{
auto * d = static_cast<MonitorCapture::Impl *>(userdata);
switch (pa_stream_get_state(s)) {
case PA_STREAM_READY:
case PA_STREAM_FAILED:
case PA_STREAM_TERMINATED:
pa_threaded_mainloop_signal(d->ml, 0);
break;
default: break;
}
}
void streamRead(pa_stream * s, size_t /*nbytes*/, void * userdata)
{
auto * d = static_cast<MonitorCapture::Impl *>(userdata);
while (pa_stream_readable_size(s) > 0) {
const void * data = nullptr;
size_t bytes = 0;
if (pa_stream_peek(s, &data, &bytes) < 0 || !data || bytes == 0) {
pa_stream_drop(s);
continue;
}
const float * interleaved = static_cast<const float *>(data);
const size_t frames = bytes / (sizeof(float) * (size_t) d->channels);
// 降混单声道
std::vector<float> mono(frames);
for (size_t i = 0; i < frames; ++i) {
double acc = 0.0;
for (int c = 0; c < d->channels; ++c) acc += interleaved[i * d->channels + c];
mono[i] = (float) (acc / d->channels);
}
// 重采样到目标采样率
std::vector<float> resampled;
if (d->deviceRate == d->targetRate) {
resampled = std::move(mono);
} else if (!d->resampler.process(mono.data(), mono.size(), resampled)) {
pa_stream_drop(s);
continue;
}
if (d->callback && !resampled.empty()) {
d->callback(resampled.data(), resampled.size());
}
pa_stream_drop(s);
}
}
void contextState(pa_context * c, void * userdata)
{
auto * d = static_cast<MonitorCapture::Impl *>(userdata);
switch (pa_context_get_state(c)) {
case PA_CONTEXT_READY: d->ready = true; pa_threaded_mainloop_signal(d->ml, 0); break;
case PA_CONTEXT_FAILED:
case PA_CONTEXT_TERMINATED:
pa_threaded_mainloop_signal(d->ml, 0);
break;
default: break;
}
}
} // namespace
MonitorCapture::MonitorCapture() : d(new Impl) {}
MonitorCapture::~MonitorCapture()
{
stop();
delete d;
}
std::string MonitorCapture::deviceName() const { return d->deviceName; }
bool MonitorCapture::start(int targetRate, ChunkCallback callback, std::string * error)
{
const auto fail = [error](const std::string & m) {
if (error) *error = m;
return false;
};
d->targetRate = targetRate;
d->callback = std::move(callback);
d->ml = pa_threaded_mainloop_new();
if (!d->ml) return fail("pa_threaded_mainloop_new 失败");
d->ctx = pa_context_new(pa_threaded_mainloop_get_api(d->ml), "live-caption");
if (!d->ctx) return fail("pa_context_new 失败");
pa_context_set_state_callback(d->ctx, contextState, d);
if (pa_threaded_mainloop_start(d->ml) < 0) return fail("pa_threaded_mainloop_start 失败");
pa_threaded_mainloop_lock(d->ml);
if (pa_context_connect(d->ctx, nullptr, PA_CONTEXT_NOFLAGS, nullptr) < 0) {
pa_threaded_mainloop_unlock(d->ml);
return fail("pa_context_connect 失败(PulseAudio/PipeWire 未运行?)");
}
// 等 context READY
for (;;) {
const pa_context_state_t st = pa_context_get_state(d->ctx);
if (st == PA_CONTEXT_READY) break;
if (!PA_CONTEXT_IS_GOOD(st)) {
pa_threaded_mainloop_unlock(d->ml);
return fail("无法连接到 PulseAudio/PipeWire 服务");
}
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("拿不到默认输出设备名");
}
// 以设备实际速率请求(默认 48k)
pa_sample_spec ss{};
ss.format = PA_SAMPLE_FLOAT32LE;
ss.rate = 48000;
ss.channels = 2;
d->stream = pa_stream_new(d->ctx, "live-caption-monitor", &ss, nullptr);
if (!d->stream) {
pa_threaded_mainloop_unlock(d->ml);
return fail("pa_stream_new 失败");
}
pa_stream_set_read_callback(d->stream, streamRead, d);
pa_stream_set_state_callback(d->stream, streamState, d);
pa_buffer_attr attr{};
attr.maxlength = (uint32_t) -1;
attr.tlength = (uint32_t) -1;
attr.prebuf = (uint32_t) -1;
attr.minreq = (uint32_t) -1;
attr.fragsize = (uint32_t) (pa_frame_size(&ss) * 1024); // ~21ms
if (pa_stream_connect_record(d->stream, d->deviceName.c_str(), &attr, PA_STREAM_ADJUST_LATENCY) < 0) {
pa_threaded_mainloop_unlock(d->ml);
return fail("连接 monitor 源失败: " + d->deviceName);
}
// 等 stream READY,才能确认 monitor 源确实可用
for (;;) {
const pa_stream_state_t st = pa_stream_get_state(d->stream);
if (st == PA_STREAM_READY) break;
if (st == PA_STREAM_FAILED || st == PA_STREAM_TERMINATED) {
pa_threaded_mainloop_unlock(d->ml);
return fail("monitor 源不可用: " + d->deviceName + " (" +
std::string(pa_strerror(pa_context_errno(d->ctx))) + ")");
}
pa_threaded_mainloop_wait(d->ml);
}
// 更新为实际生效的格式
const pa_sample_spec * actual = pa_stream_get_sample_spec(d->stream);
if (actual) {
d->deviceRate = (int) actual->rate;
d->channels = (int) actual->channels;
}
if (!d->resampler.init(d->deviceRate, d->targetRate)) {
pa_threaded_mainloop_unlock(d->ml);
return fail("重采样器初始化失败");
}
pa_threaded_mainloop_unlock(d->ml);
return true;
}
void MonitorCapture::stop()
{
if (!d->ml) return;
pa_threaded_mainloop_lock(d->ml);
if (d->stream) {
pa_stream_disconnect(d->stream);
pa_stream_unref(d->stream);
d->stream = nullptr;
}
if (d->ctx) {
pa_context_disconnect(d->ctx);
pa_context_unref(d->ctx);
d->ctx = nullptr;
}
pa_threaded_mainloop_unlock(d->ml);
pa_threaded_mainloop_stop(d->ml);
pa_threaded_mainloop_free(d->ml);
d->ml = nullptr;
d->callback = nullptr;
}
} // namespace lc
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include <cstddef>
#include <functional>
#include <string>
#include <vector>
namespace lc {
// 读取 WAV(PCM 16/24/32 位或 float32),降混为单声道并重采样到 targetRate。
bool readWavMono(const std::string & path, int targetRate,
std::vector<float> & out, std::string * error);
// 采集默认输出设备的 monitor(即"系统正在播放的声音"),
// 输出 targetRate 单声道 float32。回调在 PulseAudio 线程上调用,必须快速返回。
class MonitorCapture {
public:
using ChunkCallback = std::function<void(const float * mono, size_t nSamples)>;
MonitorCapture();
~MonitorCapture();
MonitorCapture(const MonitorCapture &) = delete;
MonitorCapture & operator=(const MonitorCapture &) = delete;
bool start(int targetRate, ChunkCallback callback, std::string * error);
void stop();
// 实际采集的 monitor 源名(start 成功后有效)
std::string deviceName() const;
// 前向声明(定义在 .cpp)。公开仅为让文件内的 C 回调能引用该类型,
// 头文件里它始终是不完整类型。
struct Impl;
private:
Impl * d = nullptr;
};
} // namespace lc
+316
View File
@@ -0,0 +1,316 @@
#include <atomic>
#include <algorithm>
#include <chrono>
#include <cmath>
#include <condition_variable>
#include <cstdio>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include <QCommandLineParser>
#include <QCoreApplication>
#include "audio.h"
#include "qwen3asr.h"
#include "transcript.h"
namespace {
constexpr int kFrameSamples = 320; // 20ms @ 16k
constexpr float kSilenceTimeout = 0.8f; // 静音多久算一句结束
constexpr float kMinSpeech = 0.5f; // 一句话至少多长才值得转写
constexpr float kMaxWindow = 20.0f; // 窗口上限,超过强制收尾
// 两个字符串的最长公共前缀,且保证切在 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;
// 回退到不是 UTF-8 续字节(10xxxxxx)的位置
while (i > 0 && (static_cast<unsigned char>(a[i]) & 0xC0) == 0x80) --i;
return i;
}
// 简单的能量 VAD。
// 噪声底必须用"最小值跟踪"(快降慢升),不能用均值:
// 否则遇到本来就很轻的音频(例如 AISHELL 峰值 RMS 只有 0.03),
// 噪声底会一路抬升、阈值超过语音电平,把话音全判成静音。
class Vad {
public:
// 返回该帧是否为语音
bool feed(const float * frame, size_t n)
{
double acc = 0.0;
for (size_t i = 0; i < n; ++i) acc += (double) frame[i] * frame[i];
const float rms = std::sqrt(acc / (double) n);
if (rms < floor_) {
floor_ = rms; // 快速下探
} else {
floor_ = floor_ * 0.9999f + rms * 0.0001f; // 极慢回升
}
const float threshold = std::max(floor_ * 3.0f, 0.0015f);
speech_ = rms > threshold;
return speech_;
}
bool speaking() const { return speech_; }
private:
float floor_ = 0.01f;
bool speech_ = false;
};
// 实时转写:采集线程 -> 队列 -> 工作线程(VAD + 滑窗 + 转写)
class LiveTranscriber {
public:
LiveTranscriber(lc::Qwen3Asr & asr, int sampleRate) : asr_(asr), sampleRate_(sampleRate) {}
void onAudio(const float * samples, size_t n)
{
std::lock_guard<std::mutex> lk(mutex_);
pending_.insert(pending_.end(), samples, samples + n);
cv_.notify_one();
}
void start() { worker_ = std::thread([this] { run(); }); }
void stop()
{
{
std::lock_guard<std::mutex> lk(mutex_);
quit_ = true;
}
cv_.notify_all();
if (worker_.joinable()) worker_.join();
}
private:
void emitPartial(const lc::Qwen3Asr::Result & r)
{
// 只把"跨多次转写都不变"的前缀当成已定文本,尾部标为不稳定预览。
// 窗口在词中间被切断时,模型会给尾部编一个结尾,这样能避免它上屏。
committed_ = r.transcript.text.substr(0, utf8Lcp(committed_, r.transcript.text));
const std::string tail = r.transcript.text.substr(committed_.size());
std::lock_guard<std::mutex> lk(printMutex_);
std::printf("\r\033[K~ [%s] %s\033[2m%s\033[0m",
lc::languageDisplayName(r.transcript.language).c_str(),
committed_.c_str(), tail.c_str());
std::fflush(stdout);
}
void emitFinal(const lc::Qwen3Asr::Result & r)
{
std::lock_guard<std::mutex> lk(printMutex_);
std::printf("\r\033[K= [%s] %s\n",
lc::languageDisplayName(r.transcript.language).c_str(),
r.transcript.text.c_str());
std::fflush(stdout);
committed_.clear();
}
lc::Qwen3Asr::Result transcribeWindow(const std::vector<float> & window)
{
lc::Qwen3Asr::Result r = asr_.transcribe(window.data(), window.size());
const double dur = (double) window.size() / sampleRate_;
std::lock_guard<std::mutex> lk(printMutex_);
std::fprintf(stderr, " [infer] %.2fs 音频 / %.2fs 计算 (rtf %.3f)\n",
dur, r.inferSeconds, dur > 0 ? r.inferSeconds / dur : 0.0);
return r;
}
void run()
{
Vad vad;
std::vector<float> window; // 当前这句话累积的音频
std::vector<float> buffer; // 跨批次残留,保证不丢样本
float silenceAcc = 0.0f;
float lastPartialAt = 0.0f; // 上次做 partial 时窗口长度(秒)
for (;;) {
std::vector<float> chunk;
{
std::unique_lock<std::mutex> lk(mutex_);
cv_.wait_for(lk, std::chrono::milliseconds(50), [this] { return quit_ || !pending_.empty(); });
if (quit_ && pending_.empty()) break;
chunk.swap(pending_);
}
// 关键:把上一轮不足一帧的残留接上,否则会持续丢样本
buffer.insert(buffer.end(), chunk.begin(), chunk.end());
size_t off = 0;
for (; off + kFrameSamples <= buffer.size(); off += kFrameSamples) {
const float * frame = buffer.data() + off;
const bool speech = vad.feed(frame, kFrameSamples);
if (speech) {
if (window.empty()) lastPartialAt = 0.0f;
window.insert(window.end(), frame, frame + kFrameSamples);
silenceAcc = 0.0f;
} else if (!window.empty()) {
window.insert(window.end(), frame, frame + kFrameSamples);
silenceAcc += (float) kFrameSamples / sampleRate_;
}
if (window.empty()) continue;
const float windowSec = (float) window.size() / sampleRate_;
const float newAudioSincePartial = windowSec - lastPartialAt;
const float needed = std::max(0.8f, 0.35f * windowSec);
// 静音够久 -> 收尾
if (silenceAcc >= kSilenceTimeout && windowSec >= kMinSpeech) {
emitFinal(transcribeWindow(window));
window.clear();
silenceAcc = 0.0f;
lastPartialAt = 0.0f;
continue;
}
// 窗口过长 -> 强制收尾,避免延迟无限增长
if (windowSec >= kMaxWindow) {
emitFinal(transcribeWindow(window));
window.clear();
silenceAcc = 0.0f;
lastPartialAt = 0.0f;
continue;
}
// 定期输出 partial(预览)
if (newAudioSincePartial >= needed) {
emitPartial(transcribeWindow(window));
lastPartialAt = windowSec;
}
}
if (off > 0) buffer.erase(buffer.begin(), buffer.begin() + (long) off);
}
if (!window.empty() && (float) window.size() / sampleRate_ >= kMinSpeech) {
emitFinal(transcribeWindow(window));
}
}
lc::Qwen3Asr & asr_;
int sampleRate_;
std::thread worker_;
std::mutex mutex_;
std::condition_variable cv_;
std::vector<float> pending_;
bool quit_ = false;
std::mutex printMutex_;
std::string committed_; // 当前这句已提交的稳定前缀
};
int runFile(lc::Qwen3Asr & asr, const QString & path)
{
std::vector<float> pcm;
std::string err;
if (!lc::readWavMono(path.toStdString(), asr.sampleRate(), pcm, &err)) {
std::fprintf(stderr, "读取音频失败: %s\n", err.c_str());
return 1;
}
const double dur = (double) pcm.size() / asr.sampleRate();
std::fprintf(stderr, "音频: %s (%.2fs)\n", qUtf8Printable(path), dur);
const auto t0 = std::chrono::steady_clock::now();
lc::Qwen3Asr::Result r = asr.transcribe(pcm.data(), pcm.size());
const double wall = std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count();
if (!r.ok) {
std::fprintf(stderr, "转写失败: %s\n", r.error.c_str());
return 1;
}
std::printf("语言: %s (%s)\n", r.transcript.language.c_str(),
lc::languageDisplayName(r.transcript.language).c_str());
std::printf("文本: %s\n", r.transcript.text.c_str());
std::fprintf(stderr, "推理: %.2fs / 音频 %.2fs (rtf %.3f) 生成 %d tokens, 墙钟 %.2fs\n",
r.inferSeconds, dur, dur > 0 ? r.inferSeconds / dur : 0, r.tokenCount, wall);
return 0;
}
int runLive(lc::Qwen3Asr & asr)
{
LiveTranscriber live(asr, asr.sampleRate());
lc::MonitorCapture capture;
std::string err;
if (!capture.start(asr.sampleRate(), [&live](const float * s, size_t n) { live.onAudio(s, n); }, &err)) {
std::fprintf(stderr, "启动采集失败: %s\n", err.c_str());
return 1;
}
std::fprintf(stderr, "正在采集: %s (%d Hz)\n按 Ctrl+C 退出\n\n",
capture.deviceName().c_str(), asr.sampleRate());
live.start();
// 简单起见:主线程等待,Ctrl+C 结束进程
for (;;) std::this_thread::sleep_for(std::chrono::seconds(1));
return 0;
}
} // namespace
int main(int argc, char ** argv)
{
QCoreApplication app(argc, argv);
QCoreApplication::setApplicationName("live-caption");
QCommandLineParser parser;
parser.setApplicationDescription("实时字幕原型(Qwen3-ASR + llama.cpp)");
parser.addHelpOption();
QCommandLineOption optModel("model", "主模型 GGUF 路径(架构须为 qwen3vl)", "path");
QCommandLineOption optMmproj("mmproj", "mmproj GGUF 路径(clip + qwen3a)", "path");
QCommandLineOption optFile("file", "转写一个 wav 文件后退出(不开启实时采集)", "path");
QCommandLineOption optThreads("threads", "推理线程数", "n", "8");
QCommandLineOption optMaxTokens("max-tokens", "单段最多生成 token 数", "n", "256");
QCommandLineOption optWarmup("warmup", "mtmd 初始化后做一次预热编码");
QCommandLineOption optVerbose("verbose", "输出 llama.cpp/mtmd 的详细日志");
parser.addOption(optModel);
parser.addOption(optMmproj);
parser.addOption(optFile);
parser.addOption(optThreads);
parser.addOption(optMaxTokens);
parser.addOption(optWarmup);
parser.addOption(optVerbose);
parser.process(app);
lc::AsrOptions asrOpt;
asrOpt.modelPath = parser.value(optModel).toStdString();
asrOpt.mmprojPath = parser.value(optMmproj).toStdString();
asrOpt.threads = parser.value(optThreads).toInt();
asrOpt.maxTokens = parser.value(optMaxTokens).toInt();
asrOpt.warmup = parser.isSet(optWarmup);
asrOpt.verbose = parser.isSet(optVerbose);
if (asrOpt.modelPath.empty() || asrOpt.mmprojPath.empty()) {
std::fprintf(stderr, "必须提供 --model 和 --mmproj\n");
return 1;
}
lc::Qwen3Asr asr;
std::string err;
const auto tl0 = std::chrono::steady_clock::now();
if (!asr.load(asrOpt, &err)) {
std::fprintf(stderr, "加载失败: %s\n", err.c_str());
return 1;
}
std::fprintf(stderr, "模型加载完成: %.2fs (采样率 %d)\n",
std::chrono::duration<double>(std::chrono::steady_clock::now() - tl0).count(),
asr.sampleRate());
if (parser.isSet(optFile)) {
return runFile(asr, parser.value(optFile));
}
return runLive(asr);
}
+202
View File
@@ -0,0 +1,202 @@
#include "qwen3asr.h"
#include <chrono>
#include <cstdio>
#include <vector>
#include <llama.h>
#include <mtmd.h>
#include <mtmd-helper.h>
namespace lc {
namespace {
std::string defaultQwenChatPrompt(const std::string & content)
{
// Qwen3-ASR 的 chat template 就是标准 ChatML(见模型 GGUF 的 tokenizer.chat_template)
return "<|im_start|>user\n" + content + "<|im_end|>\n<|im_start|>assistant\n";
}
void silentLog(ggml_log_level /*level*/, const char * /*text*/, void * /*user*/) {}
} // namespace
struct Qwen3Asr::Impl {
AsrOptions opt;
llama_model * model = nullptr;
llama_context * ctx = nullptr;
mtmd_context * mctx = nullptr;
const llama_vocab * vocab = nullptr;
int audioRate = 16000;
std::string buildPrompt(const std::string & marker) const
{
const char * tmpl = llama_model_chat_template(model, nullptr);
if (tmpl) {
llama_chat_message msg{"user", marker.c_str()};
std::vector<char> buf(marker.size() * 2 + 4096);
const int32_t n = llama_chat_apply_template(tmpl, &msg, 1, true, buf.data(), (int32_t) buf.size());
if (n > 0 && (size_t) n <= buf.size()) {
return std::string(buf.data(), (size_t) n);
}
}
return defaultQwenChatPrompt(marker);
}
};
Qwen3Asr::Qwen3Asr() : d(new Impl) {}
Qwen3Asr::~Qwen3Asr()
{
if (!d) return;
if (d->mctx) mtmd_free(d->mctx);
if (d->ctx) llama_free(d->ctx);
if (d->model) llama_model_free(d->model);
delete d;
}
int Qwen3Asr::sampleRate() const { return d->audioRate; }
bool Qwen3Asr::load(const AsrOptions & opt, std::string * error)
{
const auto fail = [error](const std::string & msg) {
if (error) *error = msg;
return false;
};
d->opt = opt;
if (!opt.verbose) {
llama_log_set(silentLog, nullptr);
mtmd_log_set(silentLog, nullptr);
}
llama_backend_init();
llama_model_params mparams = llama_model_default_params();
mparams.n_gpu_layers = 0; // 本机无独显,强制 CPU
mparams.use_mmap = true;
d->model = llama_model_load_from_file(opt.modelPath.c_str(), mparams);
if (!d->model) {
return fail("加载主模型失败: " + opt.modelPath);
}
d->vocab = llama_model_get_vocab(d->model);
llama_context_params cparams = llama_context_default_params();
cparams.n_ctx = (uint32_t) opt.nCtx;
cparams.n_batch = (uint32_t) opt.nBatch;
cparams.n_ubatch = (uint32_t) opt.nUbatch;
cparams.n_threads = opt.threads;
cparams.n_threads_batch = opt.threads;
d->ctx = llama_init_from_model(d->model, cparams);
if (!d->ctx) {
return fail("创建 llama 上下文失败");
}
mtmd_context_params mctxParams = mtmd_context_params_default();
mctxParams.use_gpu = false;
mctxParams.n_threads = opt.threads;
mctxParams.print_timings = false;
mctxParams.warmup = opt.warmup;
d->mctx = mtmd_init_from_file(opt.mmprojPath.c_str(), d->model, mctxParams);
if (!d->mctx) {
return fail("加载 mmproj 失败: " + opt.mmprojPath);
}
if (!mtmd_support_audio(d->mctx)) {
return fail("该 mmproj 不支持音频输入(需要 clip.projector_type = qwen3a 的版本)");
}
const int rate = mtmd_get_audio_sample_rate(d->mctx);
if (rate > 0) d->audioRate = rate;
return true;
}
Qwen3Asr::Result Qwen3Asr::transcribe(const float * pcm, size_t nSamples)
{
Result res;
if (!d->model || !d->ctx || !d->mctx) {
res.error = "模型未加载";
return res;
}
if (nSamples == 0) {
res.error = "音频为空";
return res;
}
const auto t0 = std::chrono::steady_clock::now();
// 每段独立转写,先清空 KV cache
llama_memory_clear(llama_get_memory(d->ctx), true);
const std::string marker = mtmd_default_marker();
const std::string prompt = d->buildPrompt(marker);
mtmd_bitmap * bitmap = mtmd_bitmap_init_from_audio(nSamples, pcm);
if (!bitmap) {
res.error = "构造音频 bitmap 失败";
return res;
}
mtmd_input_chunks * chunks = mtmd_input_chunks_init();
const mtmd_input_text text{ prompt.c_str(), /*add_special*/ true, /*parse_special*/ true };
const mtmd_bitmap * bitmaps[1] = { bitmap };
const int32_t tokRes = mtmd_tokenize(d->mctx, chunks, &text, bitmaps, 1);
if (tokRes != 0) {
mtmd_bitmap_free(bitmap);
mtmd_input_chunks_free(chunks);
res.error = "mtmd_tokenize 失败 (code " + std::to_string(tokRes) + ")";
return res;
}
llama_pos nPast = 0;
const int32_t evalRes = mtmd_helper_eval_chunks(
d->mctx, d->ctx, chunks,
/*n_past*/ 0, /*seq_id*/ 0, /*n_batch*/ d->opt.nBatch,
/*logits_last*/ true, &nPast);
mtmd_bitmap_free(bitmap);
if (evalRes != 0) {
mtmd_input_chunks_free(chunks);
res.error = "mtmd_helper_eval_chunks 失败 (code " + std::to_string(evalRes) + ")";
return res;
}
mtmd_input_chunks_free(chunks);
// 贪心解码
llama_sampler * smpl = llama_sampler_chain_init(llama_sampler_chain_default_params());
llama_sampler_chain_add(smpl, llama_sampler_init_greedy());
std::string out;
for (int i = 0; i < d->opt.maxTokens; ++i) {
llama_token id = llama_sampler_sample(smpl, d->ctx, -1);
if (llama_vocab_is_eog(d->vocab, id)) break;
char buf[256];
const int32_t n = llama_token_to_piece(d->vocab, id, buf, sizeof(buf), 0, /*special*/ true);
if (n > 0) out.append(buf, (size_t) n);
++res.tokenCount;
llama_batch batch = llama_batch_get_one(&id, 1);
if (llama_decode(d->ctx, batch) != 0) {
res.error = "llama_decode 失败";
break;
}
}
llama_sampler_free(smpl);
const auto t1 = std::chrono::steady_clock::now();
res.inferSeconds = std::chrono::duration<double>(t1 - t0).count();
res.raw = out;
res.transcript = parseAsrOutput(out);
res.ok = res.error.empty();
return res;
}
} // namespace lc
+54
View File
@@ -0,0 +1,54 @@
#pragma once
#include <cstddef>
#include <string>
#include "transcript.h"
namespace lc {
struct AsrOptions {
std::string modelPath; // Qwen3-ASR 主模型 GGUF(架构须为 qwen3vl)
std::string mmprojPath; // 音频编码器 mmproj GGUF(clip + qwen3a)
int threads = 8;
int nCtx = 4096;
int nBatch = 4096;
int nUbatch = 1024;
int maxTokens = 256; // 单段最多生成多少 token
bool warmup = false; // mtmd 初始化后是否跑一次预热编码
bool verbose = false;
};
// 进程内 Qwen3-ASR 推理封装(libllama + libmtmd)。
// 模型常驻,可反复 transcribe();每次调用会清空 KV cache。
class Qwen3Asr {
public:
Qwen3Asr();
~Qwen3Asr();
Qwen3Asr(const Qwen3Asr &) = delete;
Qwen3Asr & operator=(const Qwen3Asr &) = delete;
bool load(const AsrOptions & opt, std::string * error);
// 期望的输入采样率(Qwen3-ASR 为 16000)
int sampleRate() const;
struct Result {
bool ok = false;
std::string error;
ParsedTranscript transcript; // 已拆出 language / text
std::string raw; // 模型原始输出,便于排查
double inferSeconds = 0.0;
int tokenCount = 0;
};
// pcm: 单声道 float32 [-1,1],采样率为 sampleRate()
Result transcribe(const float * pcm, size_t nSamples);
private:
struct Impl;
Impl * d = nullptr;
};
} // namespace lc
+79
View File
@@ -0,0 +1,79 @@
#include "transcript.h"
#include <algorithm>
#include <cctype>
namespace lc {
namespace {
std::string trim(const std::string & s)
{
const auto notSpace = [](unsigned char c) { return !std::isspace(c); };
auto begin = std::find_if(s.begin(), s.end(), notSpace);
auto end = std::find_if(s.rbegin(), s.rend(), notSpace).base();
if (begin >= end) return {};
return std::string(begin, end);
}
} // namespace
ParsedTranscript parseAsrOutput(const std::string & raw)
{
ParsedTranscript out;
static const std::string kMarker = "<asr_text>";
const std::string trimmed = trim(raw);
const auto markerPos = trimmed.rfind(kMarker);
std::string head;
if (markerPos != std::string::npos) {
head = trimmed.substr(0, markerPos);
out.text = trimmed.substr(markerPos + kMarker.size());
} else {
head = trimmed;
out.text = trimmed;
}
// 头部里找 "language <Name>"
// 允许前面有空白,语言名只取连续的字母
const std::string key = "language";
auto pos = head.find(key);
if (pos != std::string::npos) {
pos += key.size();
while (pos < head.size() && std::isspace(static_cast<unsigned char>(head[pos]))) ++pos;
size_t end = pos;
while (end < head.size() && std::isalpha(static_cast<unsigned char>(head[end]))) ++end;
out.language = head.substr(pos, end - pos);
}
out.text = trim(out.text);
return out;
}
std::string languageDisplayName(const std::string & lang)
{
if (lang.empty()) return {};
static const struct { const char * key; const char * display; } kMap[] = {
{ "English", "英语" },
{ "Chinese", "中文" },
{ "Japanese", "日语" },
{ "Korean", "韩语" },
{ "French", "法语" },
{ "German", "德语" },
{ "Spanish", "西班牙语" },
{ "Russian", "俄语" },
{ "Italian", "意大利语" },
{ "Portuguese", "葡萄牙语" },
{ "Arabic", "阿拉伯语" },
{ "Cantonese", "粤语" },
};
for (const auto & e : kMap) {
if (lang == e.key) return e.display;
}
return lang;
}
} // namespace lc
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include <string>
namespace lc {
// Qwen3-ASR 原始输出形如:
// language English<asr_text>He hoped there would be stew for dinner...
// language Chinese<asr_text>不排除购房者可以在互联网上下单。
// 本结构把语言名和正文分开:语言名给 UI 的语言标签,正文才上屏。
struct ParsedTranscript {
std::string language; // 原始语言名,如 "English" / "Chinese";无则空
std::string text; // 转写正文
};
ParsedTranscript parseAsrOutput(const std::string & raw);
// "English" -> "英语","Chinese" -> "中文";未知则原样返回
std::string languageDisplayName(const std::string & lang);
} // namespace lc