2026-10-12 13:43:50 +08:00
|
|
|
// 命令行版:转写文件、流式转写文件、或纯命令行实时字幕(不开窗口)。
|
|
|
|
|
// 只依赖 core,不链接 Qt Widgets。
|
|
|
|
|
#include <algorithm>
|
|
|
|
|
#include <atomic>
|
|
|
|
|
#include <chrono>
|
|
|
|
|
#include <csignal>
|
|
|
|
|
#include <cstdio>
|
|
|
|
|
#include <string>
|
|
|
|
|
#include <thread>
|
|
|
|
|
#include <vector>
|
|
|
|
|
|
|
|
|
|
#include <QCommandLineOption>
|
|
|
|
|
#include <QCommandLineParser>
|
|
|
|
|
|
|
|
|
|
#include "appcli.h"
|
|
|
|
|
#include "audio.h"
|
|
|
|
|
#include "livepipeline.h"
|
|
|
|
|
#include "qwen3asr.h"
|
|
|
|
|
#include "streamingasr.h"
|
|
|
|
|
#include "transcript.h"
|
|
|
|
|
|
|
|
|
|
namespace {
|
|
|
|
|
|
|
|
|
|
std::atomic<bool> g_stop{false};
|
|
|
|
|
|
|
|
|
|
extern "C" void handleSigInt(int) { g_stop = true; }
|
|
|
|
|
|
|
|
|
|
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::AsrResult 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 runStreamFile(lc::Qwen3Asr & asr, const QString & path, const lc::StreamOptions & sop)
|
|
|
|
|
{
|
|
|
|
|
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) chunk=%.1fs segment=%.1fs mode=%s\n",
|
|
|
|
|
qUtf8Printable(path), dur, sop.chunkSec, sop.maxUtteranceSec,
|
|
|
|
|
sop.incremental ? "incremental" : "window");
|
|
|
|
|
|
|
|
|
|
lc::StreamingAsr stream(asr, sop);
|
|
|
|
|
const size_t chunkSamples = (size_t) (sop.chunkSec * asr.sampleRate());
|
|
|
|
|
|
|
|
|
|
const auto onStep = [](const lc::StreamingAsr::Step & s) {
|
2026-10-12 14:22:29 +08:00
|
|
|
std::printf("\n[chunk %2d] 累计 %5.1fs | 音频 %5.2fs | 前缀 %5.2fs | 生成 %5.2fs (%d tok) "
|
|
|
|
|
"| 合计 %5.2fs%s\n",
|
|
|
|
|
s.chunkIndex, s.windowSeconds, s.encodeSeconds, s.prefillSeconds,
|
|
|
|
|
s.genSeconds, s.newTokens, s.inferSeconds,
|
2026-10-12 13:43:50 +08:00
|
|
|
s.ok ? "" : " <失败>");
|
|
|
|
|
if (!s.ok) std::printf(" error: %s\n", s.error.c_str());
|
|
|
|
|
std::printf(" 已提交: %s\n", s.committed.c_str());
|
|
|
|
|
std::fflush(stdout);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
for (size_t off = 0; off < pcm.size(); off += chunkSamples) {
|
|
|
|
|
const size_t n = std::min(chunkSamples, pcm.size() - off);
|
|
|
|
|
stream.feed(pcm.data() + off, n, onStep);
|
|
|
|
|
}
|
|
|
|
|
stream.flush(onStep);
|
|
|
|
|
|
|
|
|
|
std::printf("\n==== 最终提交文本 (%s) ====\n%s\n",
|
|
|
|
|
lc::languageDisplayName(stream.language()).c_str(),
|
|
|
|
|
stream.committedText().c_str());
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int runCliLive(lc::Qwen3Asr & asr, const lc::StreamOptions & sop)
|
|
|
|
|
{
|
|
|
|
|
lc::LivePipelineOptions popt;
|
|
|
|
|
popt.stream = sop;
|
|
|
|
|
|
|
|
|
|
lc::LivePipeline pipeline(asr, popt);
|
|
|
|
|
pipeline.setCaptionCallback([](const std::string & committed, const std::string & tail,
|
|
|
|
|
const std::string & lang) {
|
|
|
|
|
// 原地刷新:稳定部分正常显示,尾部用暗色表示未定
|
|
|
|
|
std::printf("\r\033[K[%s] %s\033[2m%s\033[0m",
|
|
|
|
|
lc::languageDisplayName(lang).c_str(), committed.c_str(), tail.c_str());
|
|
|
|
|
std::fflush(stdout);
|
|
|
|
|
});
|
|
|
|
|
pipeline.setErrorCallback([](const std::string & m) {
|
|
|
|
|
std::fprintf(stderr, "\n采集失败: %s\n", m.c_str());
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
std::string err;
|
|
|
|
|
if (!pipeline.start(&err)) {
|
|
|
|
|
std::fprintf(stderr, "启动采集失败: %s\n", err.c_str());
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::fprintf(stderr, "正在采集: %s (Ctrl+C 退出)\n", pipeline.deviceName().c_str());
|
|
|
|
|
std::signal(SIGINT, handleSigInt);
|
|
|
|
|
while (!g_stop) std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
|
|
|
|
pipeline.stop();
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} // namespace
|
|
|
|
|
|
|
|
|
|
int main(int argc, char ** argv)
|
|
|
|
|
{
|
|
|
|
|
QCoreApplication app(argc, argv);
|
|
|
|
|
|
|
|
|
|
QCommandLineParser parser;
|
|
|
|
|
parser.setApplicationDescription(QStringLiteral("实时字幕(命令行版)"));
|
|
|
|
|
parser.addHelpOption();
|
|
|
|
|
lc::app::addModelOptions(parser);
|
|
|
|
|
lc::app::addStreamOptions(parser);
|
|
|
|
|
parser.addOption(QCommandLineOption("file", "转写一个 wav 文件后退出", "path"));
|
|
|
|
|
parser.addOption(QCommandLineOption("stream-file", "以流式模式转写 wav 文件后退出", "path"));
|
|
|
|
|
parser.addOption(QCommandLineOption("cli-live", "实时转写字幕,只用命令行输出(不开窗口)"));
|
|
|
|
|
parser.process(app);
|
|
|
|
|
|
|
|
|
|
const lc::AsrOptions asrOpt = lc::app::asrOptions(parser);
|
|
|
|
|
const lc::StreamOptions sop = lc::app::streamOptions(parser);
|
|
|
|
|
|
|
|
|
|
lc::Qwen3Asr asr;
|
|
|
|
|
if (!lc::app::loadModel(asr, asrOpt)) return 1;
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|