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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
This commit is contained in:
sylph-decoder
2026-08-29 16:47:31 +00:00
parent d75b3a752d
commit 12146ca11d

View File

@@ -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
`</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
}
```
```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.