# ✅ Capturing the emulator's audio: the ALSA `file` tee, and why PulseAudio's monitor cannot do it **Classification: measured**, on the capture chain. Supersedes the tuning advice in [`audio-capture-channel-map-trap.md`](audio-capture-channel-map-trap.md), which chased the wrong subsystem. ## The root cause of every bad capture so far A PulseAudio null sink's **monitor is sampled on a wall clock**. When the client is late, PulseAudio does not wait — it **emits silence to keep its own timeline**. So the 39.3 % silence measured in the take-2 capture was never audio that went missing; it was silence PulseAudio *invented*. That is why `PULSE_LATENCY_MSEC` produced a non-monotonic curve (39.3 % → 15.6 % → 50.1 % silence at 5.3 / 200 / 500 ms) and never won: **the buffer size trades gap count against gap size, and no setting escapes a clock the capture point does not share.** The instrument was wrong, not mistuned. **ALSA's `file` plugin has no clock at all.** It tees exactly what the client writes. A slow producer yields a *shorter file*, not a gap-riddled one — turning a data-loss problem into a time-base problem, which is the right trade when the question is "is the correct audio playing". ## ✅ Control — 6 distinct tones, byte-exact | | | |---|---| | source | 12.000 s, 6 channels at 400 / 800 / 200 / 1600 / 3200 / 6400 Hz | | captured | **12.000 s**, **0.00 % silence**, **0 gaps**, no duplicate channels | ⚠️ **Channel order is ALSA's, not WAV's.** Captured channel *i* holds source channel `[0,1,4,5,2,3]` — i.e. `FL FR BL BR FC LFE` where the WAV file had `FL FR FC LFE BL BR`. Deterministic, invertible, and **not** data loss; do not mistake it for the remap corruption documented in the companion page. 🔴 **But this permutation does NOT travel — measure it per capture.** It was measured on *this* chain, with these tones. A later Canary capture through the same recipe came out as the **identity**, and labelling its channels from this table put the silent channel on `BR` when it was `LFE` ([`structures/intro-audio-decomposed.md`](structures/intro-audio-decomposed.md)). A 6×6 correlation against a known reference costs nothing and is **its own control**: if every row's maximum falls on a distinct source channel, the mapping is a genuine permutation and you have measured it rather than assumed it. ## The three configuration traps, in the order they bite 1. **`ALSA_CONFIG_PATH` REPLACES the entire ALSA config.** Without `` the named `null` device is undefined and the client fails with `Input/output error`. 2. 🔴 **But with that include, overriding `pcm.!default` silently does not take** — no error, no file, the client runs happily to completion. Both the full inline form and the `pcm.!default "name"` alias failed this way. **The fix is to drop the include** and declare the slave with an *inline plugin type* (`{ type null }`, `{ type pulse }`), which needs no named reference. Xenia hardcodes `snd_pcm_open(..., "default", ...)` (`alsa_audio_driver.cc:150`), so `default` **must** be the tee — a named device is not reachable. 3. **`| head -N` kills the producer.** An `ffmpeg … | head -3` SIGPIPEs ffmpeg before it writes, and the symptom is "no file" with no error — indisting- uishable from a broken config. ## 🔴 And the reason a bare `file` tee is NOT enough for Xenia Xenia's ALSA driver runs a writer thread that **pads silence whenever its ring buffer is empty** (`alsa_audio_driver.cc:359`). Against a device that never blocks, `snd_pcm_avail_update` always reports space, so the thread spins: > **Measured: ~250× real time — 7.34 GB, 12 746 s of nominal audio, in ~50 s of > wall clock**, nearly all of it driver-generated silence. It was killed and the > file deleted; it would have filled the disk. ⚠️ This is exactly the limit the route's proposer flagged — it had been verified with `ffmpeg` as the client, which is self-paced, and **not** with Canary, which pads. ## ✅ The configuration that satisfies both constraints **Tee in front of a paced slave.** The file plugin captures what the client writes; the slave supplies the clock that stops the driver free-running. The wall-clock silence-insertion then happens *downstream of the capture point* rather than inside it. ``` # NO include: `type pulse` is an inline plugin type, and the include is what # makes a pcm.!default override fail to take. pcm.!default { type file slave.pcm { type pulse } file "/path/to/capture.raw" format raw } ``` ```bash ALSA_CONFIG_PATH=…/asound-tee-pulse.conf PULSE_SINK=cap \ run-canary --apu=alsa --mute=false … # "$@" is last, so both win ffmpeg -f s16le -ar 48000 -ac 6 -i capture.raw out.wav ``` ✅ Control through this exact config: **12.000 s, 0.00 % silence, 0 gaps.** ⚠️ **`--apu=alsa` is a third option neither agent had tried.** The note that `--apu=nop` stalls the guest in the intro movie still stands and is why `--mute=true` was there; it says nothing about the ALSA backend. ## ✅ Measured on Canary — and the residual silence changes meaning 150 s boot, `--apu=alsa --mute=false`, tee in front of the paced pulse slave. ⚠️ **Xenia's ALSA driver is `SND_PCM_FORMAT_FLOAT_LE`** (`alsa_audio_driver.cc:173`) and its log confirms `ALSA initialized: 48000 Hz, 6 channels (output: 6), period: 512, buffer: 2048`. The raw tee is therefore **float32, 6 channels** — reading it as `s16` produces a plausible-looking file with a giveaway signature: peaks alternating exactly `−0.00 / −4.82 / −0.00 / −4.82 / −0.00 / −4.82`, which is the two halves of each float landing in alternate "channels". | capture route | silence | gaps/s | notes | |---|---|---|---| | PulseAudio monitor, xenia default (~5.3 ms) | 39.3 % | 30.5 | | | PulseAudio monitor, `PULSE_LATENCY_MSEC=200` | 15.6 % | 3.5 | | | PulseAudio monitor, `PULSE_LATENCY_MSEC=500` | 50.1 % | 1.3 | | | **ALSA tee → paced pulse slave** | **9.98 %** | 8.37 | 106.2 s captured over ~151 s wall = **0.70× real time** | **The file is short rather than gap-riddled, which is the intended trade** — and six distinct channels, no duplicates, sensible peaks (−4.41 / −3.96 / −4.41 / −11.65 / −8.09 / −6.53 dBFS). 🔴 **But the residual ~10 % silence is NOT removed, and its meaning has changed.** It is no longer invented by PulseAudio's monitor — the tee records exactly what Xenia wrote, and **Xenia wrote silence**, because its writer thread pads whenever the guest has not filled the ring (`alsa_audio_driver.cc:359`). So: * ✅ the capture is now **faithful** — every sample in it is a sample the emulator emitted; * ❔ the emulator is still emitting padding, because the guest runs at ~0.7× real time here, and **no capture method can remove that**. Fixing it needs the guest to keep up, or a change to the driver's padding behaviour. ⚠️ **So this is a 3.9× improvement in silence and a change of attribution, not a clean capture.** At 9.98 % / 8.37 gaps/s it sits right on the port's "≥10 % silent *and* ≥1 gap/s" fail bar. **Do not treat it as an oracle without saying which side of that line it fell on.** ## ✅ SOLVED — `--gpu=null` removes the residual, and the capture is clean The residual padding was the guest running at **0.70× real time**, and the dominant load is **llvmpipe software rendering** — which an *audio* capture does not need at all. | configuration | silence | gaps/s | |---|---|---| | PulseAudio monitor, xenia default | 39.3 % | 30.5 | | ALSA tee → paced slave, rendered (llvmpipe) | 9.98 % | 8.37 | | **ALSA tee → paced slave, `--gpu=null`** | **0.31 %** | **0.01** | **One gap in 67.7 s.** Six distinct channels, no duplicates, peaks −5.15 / −4.55 / −4.47 / −11.65 / −6.73 / −6.40 dBFS. For scale, the port's *genuine music bed* control measures 1.1 % silence at 3.3 gaps/s — **this capture is cleaner than their known-good reference.** ✅ **Control that the run is still comparable:** `ADV`'s three XMA contexts (1 294 336 / 1 118 208 / 1 171 456) appear in the `--gpu=null` log, so the movie's voice is decoding exactly as in a rendered boot. That is also better provenance for an *audio* question than screenshots were — it evidences the thing being recorded rather than what was on screen. ### The full working recipe ```bash MAP=front-left,front-right,front-center,lfe,rear-left,rear-right pactl load-module module-null-sink sink_name=cap channels=6 channel_map=$MAP # asound.conf: NO include; tee in front of a PACED slave # pcm.!default { type file slave.pcm { type pulse } file "…" format raw } ALSA_CONFIG_PATH=…/asound.conf PULSE_SINK=cap \ run-canary --apu=alsa --mute=false --gpu=null … ffmpeg -f f32le -ar 48000 -ac 6 -i capture.raw out.wav # float32, not s16 ``` ⚠️ `--gpu=null` means **no video**, so screen-based provenance is unavailable — use the XMA probe instead. And it is only appropriate when the question is about audio; it changes what the guest is doing. ## ✅ The distinction that makes even an imperfect tee capture usable Contributed by the port, and it is sharper than the framing this page had: * **PulseAudio's monitor SUBSTITUTES.** Audio that existed is *replaced* by silence to keep the wall clock. Information is destroyed, and deleting the holes cannot recover it — it only compresses time unevenly. * **Xenia's padding is ADDITIVE.** The silence is *inserted between* samples the guest emitted. **Nothing is lost.** Every real sample is present and in order. So **stripping all-channel-zero runs from an ALSA-tee capture is exact, not a repair** — it returns a contiguous stream of everything the guest produced. That means even the 0.70×-real-time rendered capture (9.98 % padded) is usable for correlation, where none of the PulseAudio-monitor captures ever were, however they were tuned. ✅ **VERIFIED 2026-08-29.** The port controlled it rather than relying on the reasoning: a real music+SFX bed (137.37 s, carrying **454 genuine zero runs of its own**, which is what makes it an honest control) had **1 149 holes inserted at 8.37 gaps/s to +9.9 % length** — matching the measured ALSA profile — then stripped: | | r | lag | margin | |---|---|---|---| | original vs itself (ceiling) | 1.000 | 0.0 s | +0.141 | | **padded** vs original | **0.436** | −12.2 s | **+0.006** | | **stripped** vs original | **1.000** | **0.0 s** | **+0.142** | Two things beyond the yes: * ✅ **It runs the inference forwards.** Padding at this profile puts correlation squarely in the known-absent regime (margin +0.006) on a file whose contents are controlled — so the earlier captures were unusable *for the reason claimed* rather than for some other one. Until now that was reasoning backwards from a failure to a cause. * ✅ **Only ONE side needs stripping.** The stripped capture matches the **unstripped** source at the ceiling, so a capture needs no preprocessing at all before being handed over — no shared step for two parties to get out of sync on. 🔴 **And the danger, which is the part to repeat:** stripping removes *genuine* silence too and cannot tell the two apart. It is **exact on additive ALSA padding and vandalism on a PulseAudio monitor capture**, where the silence replaced real audio. On mostly-silent material the genuine runs would cost something measurable — here they totalled 0.71 s in 137 s and cost nothing. ⚠️ **Running it on the wrong artefact would look like it worked.** ⚠️ **Consequence for `check-capture`:** its silence/gap-rate rule was built when only *damage* existed, and **cannot distinguish genuine emulator padding from capture damage.** A FAIL on an ALSA-tee capture is a statement about the recording path, not about the file's usability. ## Consequences for verification 🔴 **Short file becomes the failure mode**, so a capture check needs an **expected-duration** test alongside silence fraction and gap rate. And a **runaway guard** is not optional: abort if the file exceeds ~3× real time, or one misconfiguration writes 7 GB before anyone looks.