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.