Accepting the settings dialog quit the app: with a tray-resident program "last window closed" must not mean "quit", but QApplication's default made it so, and on first run the dialog is the only window. Disable quitOnLastWindowClosed. Also fix listOutputDevices hanging forever (its callbacks never signalled the PulseAudio main loop), enlarge the caption window's close button, and add capture-device selection (enumerated sinks, settings combo, --list-devices/--device) plus model integrity verification (size + sha256 in the registry, --verify-model and a "校验完整性" button). 点「确定」后程序自己退出:本程序常驻托盘,"最后一个窗口关闭"不该等于退出, 但 QApplication 默认如此,而首次启动时设置对话框正是唯一窗口。已关闭该默认 行为。同时修复 listOutputDevices 永久卡死(回调没有 signal 主循环)、放大 字幕窗关闭按钮,并新增采集设备选择与模型完整性校验(注册表含 size/sha256, 提供 --verify-model 与设置里的「校验完整性」按钮)。
190 lines
7.3 KiB
C++
190 lines
7.3 KiB
C++
// 命令行版:转写文件、流式转写文件、或纯命令行实时字幕(不开窗口)。
|
|
// 只依赖 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 "iasr.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::IAsr & 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::IAsr & 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) {
|
|
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,
|
|
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::IAsr & asr, const lc::StreamOptions & sop, const QString & device)
|
|
{
|
|
lc::LivePipelineOptions popt;
|
|
popt.stream = sop;
|
|
|
|
popt.captureDevice = device.toStdString();
|
|
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);
|
|
lc::app::addBackendOptions(parser);
|
|
parser.addOption(QCommandLineOption("file", "转写一个 wav 文件后退出", "path"));
|
|
parser.addOption(QCommandLineOption("stream-file", "以流式模式转写 wav 文件后退出", "path"));
|
|
parser.addOption(QCommandLineOption("cli-live", "实时转写字幕,只用命令行输出(不开窗口)"));
|
|
parser.addOption(QCommandLineOption("device", "采集源(sink 名);留空=跟随系统默认输出", "sink"));
|
|
parser.addOption(QCommandLineOption("verify-model", "校验模型文件完整性后退出", "id"));
|
|
parser.addOption(QCommandLineOption("list-devices", "列出可选的采集设备后退出"));
|
|
parser.process(app);
|
|
|
|
const lc::AsrOptions asrOpt = lc::app::asrOptions(parser);
|
|
lc::StreamOptions sop = lc::app::streamOptions(parser);
|
|
|
|
if (parser.isSet("list-devices")) {
|
|
for (const auto & d : lc::listOutputDevices()) {
|
|
std::printf(" %-60s %s%s\n", d.sink.c_str(), d.description.c_str(),
|
|
d.isDefault ? " [默认]" : "");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
if (parser.isSet("verify-model")) {
|
|
const auto * m = lc::models::ModelStore::find(parser.value("verify-model"));
|
|
if (!m) { std::fprintf(stderr, "注册表里没有模型: %s\n", qUtf8Printable(parser.value("verify-model"))); return 1; }
|
|
QStringList bad;
|
|
const bool ok = lc::models::ModelStore::verify(*m, &bad, [](int done, int total) {
|
|
std::fprintf(stderr, "\r校验中 %d/%d", done, total);
|
|
});
|
|
std::fprintf(stderr, "\n");
|
|
if (ok) { std::printf("✅ %s 校验通过\n", qUtf8Printable(m->name)); return 0; }
|
|
std::printf("❌ %s 校验失败,损坏: %s\n", qUtf8Printable(m->name),
|
|
qUtf8Printable(bad.join(QStringLiteral(", "))));
|
|
return 1;
|
|
}
|
|
|
|
std::string err;
|
|
std::unique_ptr<lc::IAsr> asr = lc::app::makeAsr(parser, asrOpt, &err);
|
|
if (!asr) {
|
|
std::fprintf(stderr, "%s\n", err.c_str());
|
|
return 1;
|
|
}
|
|
// 原生流式识别器很便宜,用更细的更新粒度换低延迟
|
|
if (asr->kind() == lc::AsrKind::NativeStreaming && !parser.isSet("stream-chunk")) {
|
|
sop.chunkSec = 0.32;
|
|
}
|
|
|
|
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, parser.value("device"));
|
|
}
|