编程 Rust + Tauri 跨平台系统音频捕获的五个诡异 Bug

2026-09-05 19:13:42

Rust + Tauri 跨平台系统音频捕获的五个诡异 Bug

"系统音频"听起来应该是一个 API。它不是。macOS 给你 CoreAudio Process Tap(只有 Swift,没有 Rust 绑定),Windows 给你 WASAPI,而且要以一种没人宣传为"回环模式"的方式打开。

以下是在 macOS 和 Windows 上让系统音频捕获以同样方式工作时,遇到的五个只有真正跑起来才会出现的 Bug。

Bug 1:二进制 PCM 流让行缓冲读取器死锁

macOS 的 Process Tap API 只有 Swift,所以 macOS 端作为一个小的 Swift 侧车二进制运行,通过 stdout 向 Rust 流式传输原始音频——纯交错 32 位浮点采样,没有其他东西。

Tauri 的 shell 插件默认按文本行读取子进程的 stdout,按 \n(0x0A)分割。这对 JSON-over-stdout 没问题。但对原始浮点采样就不行了,因为 0x0A 会随机出现在任意音频数据中——更糟的是,在静音期间(全零字节),它永远不会出现。

一个行缓冲读取器等待静音永远不会产生的"行",就会一直等下去。管道填满,子进程的写入阻塞,辅助进程在流式传输任何真实采样之前就挂起了。

修复只需要一个标志:

let (mut rx, child) = app.shell()
    .sidecar("syntaxcue-audiotap")?
    .set_raw_out(true)  // <- 这个
    .spawn()?;

set_raw_out(true) 把 stdout 当作原始字节处理,而不是扫描换行符。踩过一次之后很明显,但在踩过之前完全看不见——因为失败看起来就像"整个东西就是挂起了",而不是"读取 stdout 的方式不对"。

Bug 2:被杀死的进程留下一个看起来正常但什么都不捕获的 Tap

macOS 的 Process Tap 必须先包装在一个私有聚合设备中,然后才能通过正常的 IO 循环拉取音频——AudioHardwareCreateProcessTap,然后 AudioHardwareCreateAggregateDevice 包装它。两者都是有真实生命周期的 CoreAudio 对象。

在开发中强制杀死侧车进程(这经常发生),不给它清理的机会,CoreAudio 并不总是及时回收 Tap 和聚合设备。下一次运行成功创建了新的 Tap——没有错误,没有权限提示,什么都没有——然后恰好传递零字节音频。从外部看,这与真正的捕获 Bug 无法区分,作者花了比愿意承认的更长时间调试"捕获"逻辑,而它对着一个孤儿设备工作得很好。

修复是在每个退出路径上显式拆除,包括信号——而且必须通过 DispatchSource,不能用原始的 signal() 处理器,因为拆除 CoreAudio 对象会分配内存并与 XPC 通信,这在真正的信号处理器中不安全:

signal(SIGTERM, SIG_IGN)
let sigtermSource = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main)
sigtermSource.setEventHandler {
    AudioDeviceStop(aggregateID, procID)
    AudioDeviceDestroyIOProcID(aggregateID, procID)
    AudioHardwareDestroyAggregateDevice(aggregateID)
    AudioHardwareDestroyProcessTap(tapID)
    exit(0)
}
sigtermSource.resume()

现在杀死进程确实会拆除 Tap,而不是仅仅结束持有它的进程。

Bug 3:WASAPI 的空闲端点不发送静音——它什么都不发送

Windows 回环捕获通过以 Capture 方向打开默认播放设备来工作——没有单独的"正在播放什么"设备可以枚举。到目前为止没问题。

没有在任何明显地方记录的部分是:一个当前没有播放任何东西的渲染端点不会给你静音缓冲区。它什么缓冲区都不给。get_next_packet_size() 一直返回 0,只要通话是安静的。

事件驱动设计假设事件句柄在有东西可读时触发。在空闲端点上它永远不触发——所以事件驱动循环会在通话对方停止说话的那一刻挂起,而这,很不巧,是通话的大部分时间。

修复是轮询而不是等待事件,并手动合成经过的静音,这样下游的语音活动检测逻辑仍然能看到时间在流逝:

if frames == 0 {
    let now = Instant::now();
    if let Some(since) = idle_since.replace(now) {
        let elapsed = now.duration_since(since).as_secs_f64();
        let samples = (elapsed * TARGET_RATE as f64) as usize;
        vad.push(app, &vec![0u8; samples * BYTES_PER_FRAME]);
    }
    std::thread::sleep(poll_interval);
    continue;
}

没有这个,一个恰好在线路变安静时结束的话语会一直坐在缓冲区里,等着被合并到接下来要说的任何东西中——两个独立的回答被转录成一个连写句。

Bug 4:"静音"标志并不意味着缓冲区已清零

相关的、更小的、更阴险的:当 WASAPI 确实传递一个标记为 AUDCLNT_BUFFERFLAGS_SILENT 的缓冲区时,那个标志意味着"把这当作静音"——它并不意味着缓冲区的内存实际上已清零。此时内容是显式未定义的。

跳过这个检查,你捕获的就不是静音,而是恰好坐在那块内存里的任何东西——whisper.cpp 会愉快地尝试把它当作真实音频转录。

if info.flags.silent {
    buffer.fill(0);
}

一行,很容易跳过,而如果你跳过它,失败模式是"偶尔出现没有音频支撑的垃圾短语"——这看起来完全像转录模型幻觉,而不是缓冲区处理 Bug,除非你已经知道要怀疑这个标志。

Bug 5:一个不能安全获取所需锁的 atexit 处理器

最后一个,这是关于一个约束而不是一个发布的 Bug:应用需要保证 macOS 侧车在整个应用从菜单退出时(不仅仅是用户点击"停止"时)收到 SIGTERM,这样它不会留下一个孤儿 Tap 让 Bug 2 在下一次运行时发作。

那个清理从 atexit 处理器运行——它没有 AppHandle,运行在一个你不能假设任何其他线程在做什么的时刻,而且不能阻塞试图获取另一个线程在关闭期间可能已经持有的互斥锁。

所以侧车的 PID 故意存在两个地方:真正的 Option<CommandChild> 在互斥锁后面用于正常控制流,以及一个普通的 AtomicI32 镜像,atexit 守卫无锁读取:

static SIDECAR_PID: AtomicI32 = AtomicI32::new(0);

pub(crate) fn kill_sidecar_on_exit() {
    let pid = SIDECAR_PID.swap(0, Ordering::SeqCst);
    if pid > 0 {
        unsafe { kill(pid, SIGTERM); }
    }
}

不优雅——它确实是一个 PID 的两个真相来源——但这是对"我能从关闭处理器安全读取什么"这个问题的朴素、正确的回答:尽可能少,而且永远不要锁。

同样的问题,两种完全不同的失败形态

这些单独来看都不是难 Bug。值得写下来的是,macOS 和 Windows 对于名义上相同的任务——捕获系统音频,返回相同的 PCM 格式——以相反的方向失败。

macOS 通过撒谎失败:Tap 看起来正常,权限已授予,它只是安静地什么都不给你。Windows 通过遗漏失败:它什么都不给你,而缺失本身就是你必须围绕设计的信号。

跨平台音频捕获不是一个有两层皮肤的 API——它是两个穿着相同输出格式的完全不同的失败表面。

原文链接:https://dev.to/baurzhan_zhetenov_442c4cd/5-weird-bugs-i-hit-capturing-system-audio-cross-platform-rust-tauri-5el8

推荐文章

程序员茄子在线接单