Files
Sylpheed/docs/re/audio-capture-alsa-file-tee.md
sylph-decoder ac1b76ee39 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

12 KiB
Raw Permalink Blame History

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, 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). 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
}
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

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.