Files
Sylpheed/docs/re/audio-capture-alsa-file-tee.md
sylph-decoder 371cd0d610 re: the ALSA tee measured on Canary -- faithful now, but the guest still pads
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
2026-08-29 16:49:17 +00:00

6.8 KiB
Raw Blame History

✅ 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

  1. ALSA_CONFIG_PATH REPLACES the entire ALSA config. Without </usr/share/alsa/alsa.conf> 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
}
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.