Applying my own METHOD entry one iteration after writing it found four refuted
statements still asserted unmarked where a reader lands:
* envelope correlation 'has no resolving power' -- in three places including
HANDOFF. The port controlled the same estimator on a single track and got
r=1.0000 at zero offset; the saturation needs CONCURRENT streams sharing
timing. I agreed to this in a message and never landed it.
* '8 of 10 three-chunk regions' -- still asserted in HANDOFF in a different
section from its own correction.
* 'r9 is a wild pointer, never a guest address' -- still asserted inside the
kept-for-the-record section.
* the ALSA channel permutation, stated without scope, when a later capture
measured the identity and labelling from it put the silent channel on the
wrong name.
All four marked in place, striking the sentence and pointing forward.
Two lessons added: a 'kept for the record' section still asserts, so labelling
the heading is not enough; and naming a refuted claim keeps it greppable, so the
audit returns its own corrections as hits and every hit needs reading.
The first item is the one worth admitting: I acknowledged that correction in a
message, wrote the entry about corrections that never land, and then did not land
my own for a full iteration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
240 lines
12 KiB
Markdown
240 lines
12 KiB
Markdown
# ✅ 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.
|
||
|
||
🔴 **But this permutation does NOT travel — measure it per capture.** It was
|
||
measured on *this* chain, with these tones. A later Canary capture through the same
|
||
recipe came out as the **identity**, and labelling its channels from this table put
|
||
the silent channel on `BR` when it was `LFE`
|
||
([`structures/intro-audio-decomposed.md`](structures/intro-audio-decomposed.md)).
|
||
A 6×6 correlation against a known reference costs nothing and is **its own control**:
|
||
if every row's maximum falls on a distinct source channel, the mapping is a genuine
|
||
permutation and you have measured it rather than assumed it.
|
||
|
||
## 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.
|
||
|
||
## ✅ 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.**
|
||
|
||
## ✅ SOLVED — `--gpu=null` removes the residual, and the capture is clean
|
||
|
||
The residual padding was the guest running at **0.70× real time**, and the
|
||
dominant load is **llvmpipe software rendering** — which an *audio* capture does
|
||
not need at all.
|
||
|
||
| configuration | silence | gaps/s |
|
||
|---|---|---|
|
||
| PulseAudio monitor, xenia default | 39.3 % | 30.5 |
|
||
| ALSA tee → paced slave, rendered (llvmpipe) | 9.98 % | 8.37 |
|
||
| **ALSA tee → paced slave, `--gpu=null`** | **0.31 %** | **0.01** |
|
||
|
||
**One gap in 67.7 s.** Six distinct channels, no duplicates, peaks −5.15 / −4.55
|
||
/ −4.47 / −11.65 / −6.73 / −6.40 dBFS. For scale, the port's *genuine music bed*
|
||
control measures 1.1 % silence at 3.3 gaps/s — **this capture is cleaner than
|
||
their known-good reference.**
|
||
|
||
✅ **Control that the run is still comparable:** `ADV`'s three XMA contexts
|
||
(1 294 336 / 1 118 208 / 1 171 456) appear in the `--gpu=null` log, so the
|
||
movie's voice is decoding exactly as in a rendered boot. That is also better
|
||
provenance for an *audio* question than screenshots were — it evidences the thing
|
||
being recorded rather than what was on screen.
|
||
|
||
### The full working recipe
|
||
|
||
```bash
|
||
MAP=front-left,front-right,front-center,lfe,rear-left,rear-right
|
||
pactl load-module module-null-sink sink_name=cap channels=6 channel_map=$MAP
|
||
# asound.conf: NO include; tee in front of a PACED slave
|
||
# pcm.!default { type file slave.pcm { type pulse } file "…" format raw }
|
||
ALSA_CONFIG_PATH=…/asound.conf PULSE_SINK=cap \
|
||
run-canary --apu=alsa --mute=false --gpu=null …
|
||
ffmpeg -f f32le -ar 48000 -ac 6 -i capture.raw out.wav # float32, not s16
|
||
```
|
||
|
||
⚠️ `--gpu=null` means **no video**, so screen-based provenance is unavailable —
|
||
use the XMA probe instead. And it is only appropriate when the question is about
|
||
audio; it changes what the guest is doing.
|
||
|
||
## ✅ The distinction that makes even an imperfect tee capture usable
|
||
|
||
Contributed by the port, and it is sharper than the framing this page had:
|
||
|
||
* **PulseAudio's monitor SUBSTITUTES.** Audio that existed is *replaced* by
|
||
silence to keep the wall clock. Information is destroyed, and deleting the
|
||
holes cannot recover it — it only compresses time unevenly.
|
||
* **Xenia's padding is ADDITIVE.** The silence is *inserted between* samples the
|
||
guest emitted. **Nothing is lost.** Every real sample is present and in order.
|
||
|
||
So **stripping all-channel-zero runs from an ALSA-tee capture is exact, not a
|
||
repair** — it returns a contiguous stream of everything the guest produced. That
|
||
means even the 0.70×-real-time rendered capture (9.98 % padded) is usable for
|
||
correlation, where none of the PulseAudio-monitor captures ever were, however
|
||
they were tuned.
|
||
|
||
✅ **VERIFIED 2026-08-29.** The port controlled it rather than relying on the
|
||
reasoning: a real music+SFX bed (137.37 s, carrying **454 genuine zero runs of
|
||
its own**, which is what makes it an honest control) had **1 149 holes inserted
|
||
at 8.37 gaps/s to +9.9 % length** — matching the measured ALSA profile — then
|
||
stripped:
|
||
|
||
| | r | lag | margin |
|
||
|---|---|---|---|
|
||
| original vs itself (ceiling) | 1.000 | 0.0 s | +0.141 |
|
||
| **padded** vs original | **0.436** | −12.2 s | **+0.006** |
|
||
| **stripped** vs original | **1.000** | **0.0 s** | **+0.142** |
|
||
|
||
Two things beyond the yes:
|
||
|
||
* ✅ **It runs the inference forwards.** Padding at this profile puts correlation
|
||
squarely in the known-absent regime (margin +0.006) on a file whose contents
|
||
are controlled — so the earlier captures were unusable *for the reason claimed*
|
||
rather than for some other one. Until now that was reasoning backwards from a
|
||
failure to a cause.
|
||
* ✅ **Only ONE side needs stripping.** The stripped capture matches the
|
||
**unstripped** source at the ceiling, so a capture needs no preprocessing at
|
||
all before being handed over — no shared step for two parties to get out of
|
||
sync on.
|
||
|
||
🔴 **And the danger, which is the part to repeat:** stripping removes *genuine*
|
||
silence too and cannot tell the two apart. It is **exact on additive ALSA
|
||
padding and vandalism on a PulseAudio monitor capture**, where the silence
|
||
replaced real audio. On mostly-silent material the genuine runs would cost
|
||
something measurable — here they totalled 0.71 s in 137 s and cost nothing.
|
||
⚠️ **Running it on the wrong artefact would look like it worked.**
|
||
|
||
⚠️ **Consequence for `check-capture`:** its silence/gap-rate rule was built when
|
||
only *damage* existed, and **cannot distinguish genuine emulator padding from
|
||
capture damage.** A FAIL on an ALSA-tee capture is a statement about the
|
||
recording path, not about the file's usability.
|
||
|
||
## 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.
|