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:
102
docs/re/audio-capture-alsa-file-tee.md
Normal file
102
docs/re/audio-capture-alsa-file-tee.md
Normal 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.
|
||||
Reference in New Issue
Block a user