Extends the latency finding with a third point, and it changes the advice. xenia default (~5.3 ms) 347.5 s 39.3 percent silence 30.5 gaps/s 3.94 ms median PULSE_LATENCY_MSEC=200 88.0 s 15.6 percent silence 3.5 gaps/s 37.33 ms median PULSE_LATENCY_MSEC=500 87.9 s 50.1 percent silence 1.3 gaps/s 346.67 ms median 200 ms is 2.5x better than the default; 500 ms is worse than either. Raising the buffer keeps cutting the gap RATE while total silence bottoms out at 200 ms and then doubles, because an over-large buffer starves in a few enormous holes rather than many small ones. That is also a warning about the metric. The port s check-capture bar is 20 gaps/s, derived from sound controls -- starved 32.9, genuine music bed 3.3, voice track 0.03. The 500 ms file scores 1.3 gaps/s, better than a real music bed, while being 50 percent silence: a gap-rate test alone would pass the worst capture of the three. It needs a total-silence companion. Same shape as the defect that made a per-channel level table useless -- one number that cannot see the failure mode next door. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
16 KiB
🔴 A 6-channel PulseAudio capture SCRAMBLES AND DUPLICATES channels unless the maps match
Classification: measured, on the capture chain itself rather than on the game. Recorded because a capture taken with this defect was handed to the port as evidence, cost them a full controlled analysis, and the negative they correctly reported was my instrument, not the guest.
What happened
adv-game-output-6ch.wav was captured from Canary through a PulseAudio null
sink and shared as "what the game emits during ADV". The port could not match
it against anything — the ADV bed, any of the three XMA voice streams,
BGM_103, S00A — with best-vs-runner-up margins of 0.001–0.016 everywhere,
i.e. plateaux rather than peaks. They controlled that three ways (their
instrument finds bed vs bed at r=1.000 margin +0.115; their .ogv reference
matches the disc's .wmv at r=1.000 margin +0.114; and drift was excluded by
windowed lags scattering across the movie). They also observed that capture
channels 3 and 6 were byte-identical, same MD5.
That duplicate pair is the tell, and it is reproducible without the emulator.
The control I should have run first
Six channels, each a different tone, so any reorder, drop or duplication
shows up as a wrong frequency. Played to the sink with paplay, recorded from
its monitor with the same parec invocation the ADV capture used.
Sink map FL,FR,RL,RR,FC,LFE, i.e. NOT the stream's map — the original setup:
| channel | expected | captured | |
|---|---|---|---|
| 0 | 400 Hz | 400 Hz | ok |
| 1 | 800 Hz | 3200 Hz | wrong |
| 2 | 200 Hz | 200 Hz | ok |
| 3 | 1600 Hz | 800 Hz | wrong |
| 4 | 3200 Hz | 800 Hz | wrong |
| 5 | 6400 Hz | 200 Hz | wrong |
ch2 == ch5, byte-identical — the same artefact the port found. The 6400 Hz
and 1600 Hz channels are gone entirely, replaced by duplicates.
Sink map made identical to Canary's own stream map
(front-left,front-right,front-center,lfe,rear-left,rear-right), and the same
map given to parec explicitly:
| channel | expected | captured |
|---|---|---|
| 0–5 | 400 / 800 / 200 / 1600 / 3200 / 6400 | 400 / 800 / 200 / 1600 / 3200 / 6400 |
No duplicates. CONTROL PASSED.
🔴 And a LEVEL CHECK cannot see this failure — by construction
The port made this point while building a checker for it, and it refutes something written above.
In the known-bad control, all six channels report a peak of −18.063656 dB, identical to six decimals, while the file contains three duplicate pairs. Equal tone amplitudes make the peak table uniform no matter how the channels are permuted or duplicated — and on real content the peaks simply differ from each other, which looks equally healthy. Either way the table is uninformative.
⚠️ So "the WAV has plausible per-channel levels" was not weak evidence that the
capture was sound; it was no evidence, and this page said otherwise. The
per-channel peak table is the natural thing to eyeball after a capture and it is
blind to remap corruption. What detects it is hashing each channel and
comparing — the port's tools/port/check-capture, controlled in both directions
(six distinct tones → PASS; this page's known-bad pattern → FAIL naming all four
pairs; the withdrawn capture → FAIL on ch2 == ch5).
The rule
⚠️ A null sink whose channel_map differs from the client's makes PulseAudio
remap, and a 6-channel remap silently loses channels and duplicates others.
There is no error, no warning, and the WAV has the right length, the right
channel count and plausible per-channel levels. Set the sink's map to the
client's, and pass the same map to parec:
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
parec -d cap.monitor --channels=6 --rate=48000 --format=s16le --channel-map=$MAP …
🔴 What this withdraws
adv-game-output-6ch.wavis withdrawn as evidence. Its channels are scrambled and one pair is a duplicate. Nothing should be concluded from it, in either direction — it is not evidence that the game emits something unexpected, and the port's inability to match it is fully explained.- "All six channels carry signal" — withdrawn. One of the six was a copy of another.
- "The surround and LFE channels are not zero, which a stereo guest padded
into a 6-channel frame would give" — withdrawn. It was offered as weak
support for the 5.1 reading of a voice cue's three streams
(
voice-three-streams-are-concurrent.md), and it is worth nothing. The port said a duplicated channel is not an independent one, and they were right before this control existed.
✅ Unaffected: the three-XMA-context concurrency result. That is read from the emulator's own log, not from the audio path, and it reproduced on two independent boots.
The lesson, in the form it should have been applied
The corpus's own rule is run your instrument through a control first. Here the control needed no emulator, no disc and 30 seconds: play a known signal through the capture chain and check that it comes back. It was not run, an artefact was published, and the person who found the defect was the one who could not see the instrument. ⚠️ A capture is an instrument, not just an output — the same scrutiny a parser or an estimator gets.
✅ The capture that passes — recipe, and how it proves itself
Take 2, 2026-08-29. Verified with the port's independent
tools/port/check-capture (six distinct channel MD5s → PASS) before being
shared, deliberately using their tool rather than the hand that made the file.
MAP=front-left,front-right,front-center,lfe,rear-left,rear-right # Canary's own
pactl load-module module-null-sink sink_name=cap channels=6 channel_map=$MAP
parec -d cap.monitor --channels=6 --rate=48000 --format=s16le \
--channel-map=$MAP --file-format=wav out.wav & # recorder FIRST
PULSE_SINK=cap SDL_AUDIODRIVER=pulseaudio \
run-canary --mute=false … # both mutes off
Two properties make it self-checking, and both were the port's asks:
- The recorder starts before the emulator, so WAV
t=0precedes process launch and the movie cannot fall outside the window by accident. - A screenshot every ~11 s, keyed to the recording's own clock. Classified
against the committed references afterwards, this run reads
movie/otherfor t = 10 … 251 and thentitle_noplateat t = 262 (r = +0.998),title_plateat 277/289. So the 253 s of audio sits wholly inside the movie, with the title arriving just after it ends. A miss would now be diagnosable instead of ambiguous — which is the whole difference from take 1.
⚠️ It is still the full mix — the movie's own WMA bed plus the voice streams. Nothing at this boundary separates them.
❔ New, unexplained: this run decoded FIVE XMA streams, not three
--xma_param_probe on the take-2 boot logs five distinct byte_size values:
ADV's three (1 294 336 / 1 118 208 / 1 171 456) plus 1 150 976 and
1 269 760. The extra pair belongs to some other cue and is unidentified —
they are not BGM_103's two waves (3 876 864 / 3 930 112). A pair is the shape
bgm-two-stems documents for music banks, so a
second bank is the first guess and it is untested.
🔴 TAKE 2 IS ALSO UNUSABLE — the sink is being STARVED, 39.3 % digital silence
Take 2 passed the duplicate-channel check and carried a verified screen log, and the port still could not find either the movie's WMA bed or the cutscene voice in it — this time with a calibrated correlator (they had retracted their first one: it scored 0.415 hunting a bed inside a synthetic mix that certainly contained it, so it could not have found the target even when present). Their rebuilt instrument passes both directions, and their negative on take 2 stands.
They named the two readings: the capture path is still losing the guest's mix,
or the guest is not emitting these sources. ⚠️ They flagged the second as
landing on them hard — if the game never plays the .wmv's WMA track, the port's
intro audio has been wrong since P4.
It is the first, and the capture says so on its face. Take 2, measured directly:
| frames that are digital silence on all six channels | 6 557 892 / 16 680 453 = 39.3 % |
| non-silent runs | 10 595, median 13.60 ms, longest 1.19 s |
| silent runs | 10 596, median 3.94 ms |
| burst + gap period | ≈17.5 ms → 57 Hz, duty cycle 60.7 % |
The recording is chopped into ~13 ms fragments separated by ~4 ms holes, ten thousand times over. That is a starved sink — PulseAudio filling underruns with silence because the guest is not keeping the driver fed — and it destroys envelope correlation by construction: the envelope is dominated by a 57 Hz chop that has nothing to do with the content.
✅ So the port's alarming hypothesis is NOT supported by this capture. Nothing
here says the game fails to play the movie's audio track. What it says is that
this capture cannot answer the question either way, and the earlier
autocorrelation hint pointed the same way — the file's strongest periodicity is
at 5.2 s (r = 0.449), not at BGM_102's 37.487 s loop, and 5.2 s is a beat of
the dropout schedule rather than anything musical. (Estimator controlled: it
recovers a synthetic 37.487 s loop as 37.480 s, and scores non-repeating noise
at r = 0.019.)
Why the sink starves — and 🔴 why "it cannot be fixed by configuration" was WRONG
parec reads a sink monitor, which advances at wall-clock rate and
substitutes silence whenever nothing is written. And every sink in this
container is a null sink, because there is no audio hardware at all — no
/proc/asound/cards, no /dev/snd, no /etc/asound.conf — so PulseAudio's
stock default.pa:109 module-always-sink supplies one, whose stated purpose is
to "make sure we always have a sink around, even if it is a null sink". A
null sink has no hardware clock: it is driven on a timer, and anything the client
fails to write in time becomes silence in the monitor.
From that I concluded the route "cannot be fixed by configuration" and that only
an in-emulator tap would work. That was wrong, and it was wrong because I
assumed the holes meant the guest was running below real time without testing the
alternative — that the client buffer is simply too small. Xenia asks SDL for
channel_samples_ = 256, i.e. 5.33 ms at 6 channels, and daemon.conf here
is stock with no fragment tuning at all.
PULSE_LATENCY_MSEC overrides what SDL's PulseAudio backend requests. Measured,
same title, same sink, same parec invocation:
| client buffer | duration | silence | gaps/s | median gap |
|---|---|---|---|---|
| xenia default (~5.3 ms) | 347.5 s | 39.3 % | 30.5 | 3.94 ms |
PULSE_LATENCY_MSEC=200 (114.7 ms reported) |
88.0 s | 15.6 % | 3.5 | 37.33 ms |
PULSE_LATENCY_MSEC=500 |
87.9 s | 50.1 % | 1.3 | 346.67 ms |
200 ms is 2.5× better than the default. 500 ms is worse than either. The relationship is not monotonic: raising the buffer keeps cutting the gap rate (30.5 → 3.5 → 1.3) while the total silence bottoms out at 200 ms and then doubles, because an over-large buffer starves in a few enormous holes instead of many small ones — a 346 ms median gap at 500 ms against 37 ms at 200.
🔴 And that is a warning about the metric, not just the setting. The port's
check-capture bar is 20 gaps/s, derived from good controls (starved 32.9,
genuine music bed 3.3, voice track 0.03). The 500 ms file scores 1.3 gaps/s —
better than a real music bed — while being 50 % silence. A gap-rate test
alone would pass the worst capture of the three. It needs a total-silence
companion, and this is the same shape as the defect that made a level table
useless: one number that cannot see the failure mode next door.
⚠️ Not yet a clean bill of health. The two runs are not like-for-like: 88 s against 347 s, and the short one covers the splash logos, where silence between cards is real. What is established is the direction and scale — the dropouts were substantially a client-buffer problem, not proof that the guest runs below real time.
✅ Consequence: the in-emulator tap may not be needed. The capture route is worth retrying at a raised latency before anyone spends a session on a Canary rebuild.
❔ The route that would work is an internal tap, and it does not exist yet.
SDLAudioDriver::SubmitFrame(float* frame)
(/canary/src/xenia/apu/sdl/sdl_audio_driver.cc) receives exactly frame_size_
bytes — sizeof(float) × frame_channels_ × channel_samples_ — of the guest's own
frame, in guest order, with no wall clock in the loop. A cvar-gated WAV
writer there is the same shape as xma_param_probe: additive, default-off,
read-only. It would produce a gap-free recording however slowly the emulator
runs, because it records what the guest produced rather than what a device
consumed.
🔴 Blocked on the build, not on the change — and this is a container fact
worth knowing before anyone plans around it. build-canary builds
${PROJECT_DIR:-/work}/xenia-canary, which does not exist here; the source is at
/canary. The warm 235 MB tree at /sylph-home/re/canary-build is configured
with CMAKE_HOME_DIRECTORY=/work/xenia-canary — also missing — and its
build-Release.ninja has no per-file rules, so it re-runs CMake first and that
reconfigure fails on the absent root. Any Canary change is therefore a full
reconfigure plus a full compile, at SYLPH_JOBS=4 on a box sitting at ~700 MB
free with a documented history of parallel builds OOM-killing the host.
Not attempted, deliberately — that is a whole session's risk for one probe, and the rule here is not to improvise around a blocker. Recorded so the next session can decide with the cost in front of it rather than discovering it halfway through a build.
🔴 And a provenance number I got wrong
I told the port take 2 was 253.3 s. The file is 318.5 s, and the full
recording on disk is 349 s. I read ffprobe while the recorder was still
writing, quoted the partial length, and copied the file before it finished — so
the shared artefact is itself a truncation of the run.
The corrected provenance, from the same screen log: movie/other t = 10 … 251,
title_noplate at 262, title_plate 277 … 318, back to movie/other at
329 (the documented title idle timeout). ⚠️ So the shared file includes the
title screen, which contradicts what I told them — I had said it sat wholly
inside the movie window.
A length in a provenance claim must be read from the finished artefact. Measuring a file that is still being written is the same class of error as reading a level table that cannot see the defect.
🔴 And it is worse than "truncated" — the shared file declares itself EMPTY.
parec writes the WAV header with zero sizes and only patches them on a clean
exit, so a copy taken mid-recording has:
| field | shared artefact | the finished local recording |
|---|---|---|
RIFF size |
8 | 200 165 472 |
data size |
0 | 200 165 436 |
| actual bytes | 183 478 556 | 200 165 480 |
Python's wave module refuses to open it (fmt chunk and/or data chunk missing). ffmpeg and ffprobe recover by scanning, report a plausible
duration, and that is exactly why the defect went unnoticed — the lenient
reader hid it from me and the strict one would have caught it instantly.
⚠️ Attribution of the starvation numbers, corrected. The 39.3 % / 16 680 453 frames / 10 595 runs above were measured on the finished local recording (347.5 s), not on the artefact that was shared (318.5 s). The port measured the shared copy independently and got 35.6 % / 15 289 876 frames / 10 482 runs; median burst 13.5 ms vs 13.6, gap 3.9 vs 3.9, period 17.4 ms vs ≈17.5. The diagnosis is unaffected — both files are starved — but a number must say which artefact it came from, and these did not.