150 s boot with --apu=alsa --mute=false, tee in front of the paced pulse slave. PulseAudio monitor, default 5.3 ms 39.3 percent silence 30.5 gaps/s PulseAudio monitor, 200 ms 15.6 percent silence 3.5 gaps/s PulseAudio monitor, 500 ms 50.1 percent silence 1.3 gaps/s ALSA tee to paced pulse slave 9.98 percent silence 8.37 gaps/s 106.2 s captured over about 151 s of wall clock, i.e. 0.70x real time: the file is SHORT rather than gap-riddled, which is the intended trade. Six distinct channels, no duplicates, sensible peaks. A format trap worth recording: xenia s ALSA driver is SND_PCM_FORMAT_FLOAT_LE and its log confirms 6 channels, so the raw tee is float32 and not s16. Reading it as s16 yields a plausible-looking file whose giveaway is peaks alternating exactly -0.00 / -4.82 across channels -- the two halves of each float landing in alternate channels. I measured it wrongly that way first. The residual 10 percent silence is not removed, but 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. So the capture is faithful -- every sample in it is a sample the emulator emitted -- while the emulator is still padding, because the guest runs at about 0.7x real time here. No capture method can remove that. So this is a 3.9x improvement in silence and a change of attribution, not a clean capture. At 9.98 percent and 8.37 gaps/s it sits right on the port s fail bar, and should not be treated as an oracle without saying which side of the line it fell on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
6.8 KiB
✅ 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,
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.
The three configuration traps, in the order they bite
ALSA_CONFIG_PATHREPLACES the entire ALSA config. Without</usr/share/alsa/alsa.conf>the namednulldevice is undefined and the client fails withInput/output error.- 🔴 But with that include, overriding
pcm.!defaultsilently does not take — no error, no file, the client runs happily to completion. Both the full inline form and thepcm.!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 hardcodessnd_pcm_open(..., "default", ...)(alsa_audio_driver.cc:150), sodefaultmust be the tee — a named device is not reachable. | head -Nkills the producer. Anffmpeg … | head -3SIGPIPEs 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
}
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.
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.