Full disclosure up front: I'm building SyntaxCue, a desktop app that listens to a live call and helps you think through the answer in real time. None of that matters for this post — this is the engineering diary from getting system-audio capture working the same way on macOS and Windows.
"System audio" sounds like it should be one API. It isn't. macOS gives you the CoreAudio Process Tap (Swift-only, no Rust bindings), and Windows gives you WASAPI opened in a mode nobody advertises as "the loopback mode." I already wrote up the full architecture — this post is the part that doesn't fit an architecture writeup: the five bugs that only show up once you actually run the thing.
Bug 1: a binary PCM stream deadlocked a line-buffered reader
The Process Tap API is Swift-only, so the macOS side runs as a small Swift sidecar binary that streams raw audio to Rust over stdout — plain interleaved 32-bit float samples, nothing else. Tauri's shell plugin reads a child process's stdout as text lines by default, splitting on \n (0x0A).
That's fine for JSON-over-stdout. It is not fine for raw float samples, because 0x0A shows up by chance inside arbitrary audio data — and, worse, during silence (all-zero bytes), it never shows up at all. A line-buffered reader waiting for a "line" that silence will never produce just waits. Forever. The pipe backs up, the child's writes block, and the helper hangs before it ever streams a single real sample.
Fix was one flag:
let (mut rx, child) = app
.shell()
.sidecar("syntaxcue-audiotap")?
.set_raw_out(true) // <- this
.spawn()?;
set_raw_out(true) treats stdout as raw bytes instead of scanning for line breaks. Obvious once you've hit it; invisible until you have, because the failure looks exactly like "the whole thing is just hanging," not "reading stdout wrong."
Bug 2: a killed process left a tap that looked fine and captured nothing
macOS's Process Tap has to be wrapped in a private aggregate device before you can pull audio through a normal IO cycle — AudioHardwareCreateProcessTap, then AudioHardwareCreateAggregateDevice wrapping it. Both are real CoreAudio objects with real lifecycles.
Force-kill the sidecar mid-test (which happens constantly in development) without giving it a chance to clean up, and CoreAudio doesn't always reclaim the tap and aggregate device promptly. The next run creates a new tap successfully — no error, no permission prompt, nothing — and then delivers exactly zero bytes of audio. From the outside this is indistinguishable from a real capture bug, and I spent longer than I'd like to admit debugging "capture" logic that was working fine against an orphaned device.
The fix is explicit teardown on every exit path, including signals — and it has to go through DispatchSource, not a raw signal() handler, because tearing down CoreAudio objects allocates and talks to XPC, which isn't safe from an actual signal handler:
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()
Now killing the process actually tears the tap down instead of just ending the process that was holding it.
Bug 3: WASAPI's idle endpoint doesn't send silence — it sends nothing
Windows loopback capture works by opening the default playback device in the Capture direction — there's no separate "what's playing" device to enumerate. Fine so far. The part that isn't documented anywhere obvious: a render endpoint that isn't currently playing anything doesn't hand you silent buffers. It hands you no buffers at all. get_next_packet_size() just returns 0, indefinitely, for as long as the call is quiet.
An event-driven design assumes the event handle fires when there's something to read. On an idle endpoint it never fires — so an event-driven loop would just hang the moment the other person on the call stopped talking, which is, unhelpfully, most of a call.
The fix is polling instead of waiting on the event, and manually synthesizing the elapsed silence so the voice-activity logic downstream still sees time passing:
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;
}
Without this, an utterance that ended right as the line went quiet would just sit in the buffer, waiting to get merged into whatever gets said next — two separate answers transcribed as one run-on sentence.
Bug 4: the "silent" flag isn't telling you the buffer is zeroed
Related, smaller, and sneakier: when WASAPI does deliver a buffer flagged AUDCLNT_BUFFERFLAGS_SILENT, that flag means "treat this as silence" — it does not mean the buffer's memory is actually zeroed. The contents are explicitly undefined at that point.
Skip that check and you're not capturing silence, you're capturing whatever happened to be sitting in that memory — which whisper.cpp will happily attempt to transcribe as if it were real audio.
if info.flags.silent {
buffer.fill(0);
}
One line, easy to skip, and the failure mode if you do skip it is "occasional garbage phrases with no audio behind them" — which looks exactly like a transcription-model hallucination, not a buffer-handling bug, unless you already know to suspect this flag.
Bug 5: an atexit handler that can't safely take the lock it needs
Last one, and it's about a constraint rather than a bug that shipped: the app needs to guarantee the macOS sidecar gets a SIGTERM when the whole app quits from the menu (not just when the user clicks "stop"), so it doesn't leave an orphaned tap for Bug 2 to bite the next run. That cleanup runs from an atexit handler — which has no AppHandle, runs at a point where you can't assume anything about what other threads are doing, and must not block trying to acquire a mutex another thread might already be holding during shutdown.
So the sidecar's PID lives in two places on purpose: the real Option<CommandChild> behind a Mutex for normal control flow, and a plain AtomicI32 mirror that the atexit guard reads lock-free:
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) };
}
}
Not elegant — it's genuinely two sources of truth for one PID — but it's the boring, correct answer to "what can I safely read from a shutdown handler," which is: as little as possible, and never a lock.
Same problem, two completely different failure shapes
None of these are hard bugs individually. What made them worth writing down is that macOS and Windows failed in opposite directions for what's nominally the same task — capture system audio, hand back the same PCM format. macOS fails by lying: the tap looks fine, permission is granted, and it just quietly hands you nothing. Windows fails by omission: it doesn't hand you anything at all, and the absence itself is the signal you have to design around. Cross-platform audio capture isn't one API with two skins on it — it's two completely different failure surfaces wearing the same output format.
If you want the full pipeline this feeds into — VAD segmentation, local whisper.cpp transcription, streaming the answer back from the user's own LLM key — that's the architecture write-up. And if you're curious what it's actually for: syntaxcue.com.
Top comments (0)