The page recorded the substituted-versus-additive distinction as unverified, with the port controlling it. It passes. A real music and SFX bed of 137.37 s, itself carrying 454 genuine zero runs -- which is what makes it an honest control -- had 1149 holes inserted at 8.37 gaps/s to +9.9 percent length, matching the measured ALSA profile, then stripped: original vs itself (ceiling) r 1.000 lag 0.0 s margin +0.141 padded vs original r 0.436 lag -12.2 margin +0.006 stripped vs original r 1.000 lag 0.0 margin +0.142 Two things beyond the yes. It runs the inference forwards: padding at this profile puts correlation squarely in the known-absent regime 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. And only one side needs stripping, since the stripped capture matches the UNSTRIPPED source at the ceiling, so a capture needs no preprocessing before being handed over and there is no shared step to get out of sync on. The danger is recorded as the part to repeat: stripping removes genuine silence too and cannot tell the two apart, so it is exact on additive ALSA padding and vandalism on a PulseAudio monitor capture where the silence replaced real audio. Running it on the wrong artefact would look like it worked. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
11 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.
✅ 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
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.