fix(gui): stop quitting when the last window closes; add device and verify

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 与设置里的「校验完整性」按钮)。
This commit is contained in:
blumia committed 2026-10-12 17:03:20 +08:00
1 parent a7a6f3e6b6
commit 0fa0bcb44a
19 files changed
+460 -55

No files matched your search

+117 -13
View File
@@ -149,7 +149,106 @@ bool readWavMono(const std::string & path, int targetRate,
}
// ---------------------------------------------------------------------------
// 实时采集:默认 sink 的 monitor
// 枚举输出设备
// ---------------------------------------------------------------------------
namespace {
// 枚举时用的上下文。注意:回调里**必须** signal 主循环,
// 否则 pa_threaded_mainloop_wait 会一直等下去(这里踩过一次坑)。
struct SinkListCtx {
std::vector<AudioOutputDevice> * out = nullptr;
std::string * defaultSink = nullptr;
pa_threaded_mainloop * ml = nullptr;
};
void sinkInfoCb(pa_context * /*c*/, const pa_sink_info * info, int /*eol*/, void * userdata)
{
auto * ctx = static_cast<SinkListCtx *>(userdata);
if (info && ctx->out) {
AudioOutputDevice d;
d.sink = info->name ? info->name : "";
d.description = info->description ? info->description : d.sink;
ctx->out->push_back(d);
}
if (ctx->ml) pa_threaded_mainloop_signal(ctx->ml, 0);
}
void serverInfoCb(pa_context * /*c*/, const pa_server_info * info, void * userdata)
{
auto * ctx = static_cast<SinkListCtx *>(userdata);
if (info && info->default_sink_name && ctx->defaultSink) {
*ctx->defaultSink = info->default_sink_name;
}
if (ctx->ml) pa_threaded_mainloop_signal(ctx->ml, 0);
}
// 有界等待:避免任何情况下卡死
void waitOp(pa_threaded_mainloop * ml, pa_operation * op)
{
for (int i = 0; i < 200 && pa_operation_get_state(op) == PA_OPERATION_RUNNING; ++i) {
pa_threaded_mainloop_wait(ml);
}
}
} // namespace
std::vector<AudioOutputDevice> listOutputDevices(std::string * error)
{
std::vector<AudioOutputDevice> out;
pa_threaded_mainloop * ml = pa_threaded_mainloop_new();
if (!ml) { if (error) *error = "pa_threaded_mainloop_new 失败"; return out; }
pa_context * ctx = pa_context_new(pa_threaded_mainloop_get_api(ml), "live-caption-list");
if (!ctx) { pa_threaded_mainloop_free(ml); if (error) *error = "pa_context_new 失败"; return out; }
pa_context_set_state_callback(ctx, [](pa_context * c, void * ud) {
auto * m = static_cast<pa_threaded_mainloop *>(ud);
const pa_context_state_t st = pa_context_get_state(c);
if (st == PA_CONTEXT_READY || st == PA_CONTEXT_FAILED || st == PA_CONTEXT_TERMINATED)
pa_threaded_mainloop_signal(m, 0);
}, ml);
pa_threaded_mainloop_start(ml);
pa_threaded_mainloop_lock(ml);
if (pa_context_connect(ctx, nullptr, PA_CONTEXT_NOFLAGS, nullptr) < 0) {
pa_threaded_mainloop_unlock(ml);
pa_context_unref(ctx); pa_threaded_mainloop_stop(ml); pa_threaded_mainloop_free(ml);
if (error) *error = "连接 PulseAudio/PipeWire 失败";
return out;
}
for (;;) {
const pa_context_state_t st = pa_context_get_state(ctx);
if (st == PA_CONTEXT_READY) break;
if (!PA_CONTEXT_IS_GOOD(st)) {
pa_threaded_mainloop_unlock(ml);
pa_context_unref(ctx); pa_threaded_mainloop_stop(ml); pa_threaded_mainloop_free(ml);
if (error) *error = "无法连接到音频服务";
return out;
}
pa_threaded_mainloop_wait(ml);
}
std::string defaultSink;
SinkListCtx lctx{&out, &defaultSink, ml};
pa_operation * opDefault = pa_context_get_server_info(ctx, serverInfoCb, &lctx);
waitOp(ml, opDefault);
pa_operation_unref(opDefault);
pa_operation * opSinks = pa_context_get_sink_info_list(ctx, sinkInfoCb, &lctx);
waitOp(ml, opSinks);
pa_operation_unref(opSinks);
for (auto & d : out) d.isDefault = (d.sink == defaultSink);
pa_context_disconnect(ctx);
pa_context_unref(ctx);
pa_threaded_mainloop_unlock(ml);
pa_threaded_mainloop_stop(ml);
pa_threaded_mainloop_free(ml);
return out;
}
// ---------------------------------------------------------------------------
// 实时采集:某个输出设备的 monitor(默认跟随系统默认输出)
// ---------------------------------------------------------------------------
struct MonitorCapture::Impl {
pa_threaded_mainloop * ml = nullptr;
@@ -250,7 +349,8 @@ MonitorCapture::~MonitorCapture()
std::string MonitorCapture::deviceName() const { return d->deviceName; }
bool MonitorCapture::start(int targetRate, ChunkCallback callback, std::string * error)
bool MonitorCapture::start(int targetRate, ChunkCallback callback, std::string * error,
const std::string & device)
{
const auto fail = [error](const std::string & m) {
if (error) *error = m;
@@ -288,17 +388,21 @@ bool MonitorCapture::start(int targetRate, ChunkCallback callback, std::string *
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("拿不到默认输出设备名");
if (device.empty()) {
// 跟随系统默认输出
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("拿不到默认输出设备名");
}
} else {
// 指定设备(sink 名)
d->deviceName = device + ".monitor";
}
// 以设备实际速率请求(默认 48k)
+13 -1
View File
@@ -7,6 +7,16 @@
namespace lc {
// 一个可选的采集源(输出设备的 monitor)
struct AudioOutputDevice {
std::string sink; // 设备名
std::string description; // 人类可读描述
bool isDefault = false;
};
// 列出所有输出设备(供设置界面选择采集源)
std::vector<AudioOutputDevice> listOutputDevices(std::string * error = nullptr);
// 读取 WAV(PCM 16/24/32 位或 float32),降混为单声道并重采样到 targetRate。
bool readWavMono(const std::string & path, int targetRate,
std::vector<float> & out, std::string * error);
@@ -23,7 +33,9 @@ public:
MonitorCapture(const MonitorCapture &) = delete;
MonitorCapture & operator=(const MonitorCapture &) = delete;
bool start(int targetRate, ChunkCallback callback, std::string * error);
// device: 空 = 跟随系统默认输出;否则是 sink 名(见 listOutputDevices)
bool start(int targetRate, ChunkCallback callback, std::string * error,
const std::string & device = std::string());
void stop();
// 实际采集的 monitor 源名(start 成功后有效)
+2 -1
View File
@@ -32,7 +32,8 @@ bool LivePipeline::start(std::string * error)
}
cv_.notify_one();
},
error);
error,
opt_.captureDevice);
if (!ok) return false;
worker_ = std::thread([this] { workerLoop(); });
+1
View File
@@ -17,6 +17,7 @@ namespace lc {
struct LivePipelineOptions {
StreamOptions stream;
std::string captureDevice; // 空 = 跟随系统默认输出;否则是 sink 名
double silenceTimeoutSec = 0.8; // 静音多久算一句结束
double prerollSec = 0.25; // 语音开始前保留的一段,避免吃掉词头
};