Files
Sylpheed/docs/re/audio-capture-alsa-file-tee.md
sylph-decoder 4cce44a64d re: run 'grep the corpus for the claim' on this corpus -- four still standing
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
2026-08-30 10:35:27 +00:00

240 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ✅ 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.