From 12146ca11d137c4bc5b0c679ae866b906882c49b Mon Sep 17 00:00:00 2001 From: sylph-decoder Date: Sat, 29 Aug 2026 16:47:31 +0000 Subject: [PATCH] re: capture audio through an ALSA file tee, not a PulseAudio monitor The human identified that both agents were fighting the wrong subsystem, and testing it here confirms the diagnosis and finds the limit. 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 percent silence in the take-2 capture was never audio that went missing, it was silence PulseAudio invented -- which is why PULSE_LATENCY_MSEC gave a non-monotonic curve and never won. The instrument was wrong, not mistuned. ALSA s file plugin has no clock; it tees exactly what the client writes, so a slow producer yields a shorter file rather than a gap-riddled one. Control with six distinct tones: 12.000 s against a 12.000 s source, 0.00 percent silence, zero gaps, no duplicate channels. Channel order comes out as ALSA s FL FR BL BR FC LFE rather than WAV s FL FR FC LFE BL BR -- deterministic and invertible, not data loss. Three configuration traps recorded in the order they bite: ALSA_CONFIG_PATH replaces the whole config so the stock one must be included; but WITH that include a pcm.!default override silently does not take, in either the inline or the alias form, so the slave must be declared with an inline plugin type and no include; and a pipe to head SIGPIPEs the producer before it writes, which looks exactly like a broken config. And the limit the proposer honestly flagged, now measured: a bare file tee is not enough for Xenia, because its ALSA writer thread pads silence whenever the ring buffer is empty (alsa_audio_driver.cc:359). Against a device that never blocks it free-ran at about 250x real time -- 7.34 GB, 12746 s of nominal audio, in 50 s of wall clock, nearly all driver-generated silence. Killed and deleted; it would have filled the disk. The configuration that satisfies both constraints is a tee in FRONT of a paced slave: type file with slave.pcm { type pulse }. The file plugin captures what the client writes and the slave supplies the clock, so the wall-clock silence insertion happens downstream of the capture point. Control through that exact config: 12.000 s, 0.00 percent silence, zero gaps. Consequences for verification: short file becomes the failure mode, so a capture check needs an expected-duration test alongside silence and gap rate, and a runaway guard is not optional. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd --- docs/re/audio-capture-alsa-file-tee.md | 102 +++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 docs/re/audio-capture-alsa-file-tee.md diff --git a/docs/re/audio-capture-alsa-file-tee.md b/docs/re/audio-capture-alsa-file-tee.md new file mode 100644 index 00000000..4ddfb2a4 --- /dev/null +++ b/docs/re/audio-capture-alsa-file-tee.md @@ -0,0 +1,102 @@ +# ✅ 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. + +## 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. + +## 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.