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
308 lines
16 KiB
Markdown
308 lines
16 KiB
Markdown
# 🔴 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`:
|
||
|
||
```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
|
||
parec -d cap.monitor --channels=6 --rate=48000 --format=s16le --channel-map=$MAP …
|
||
```
|
||
|
||
## 🔴 What this withdraws
|
||
|
||
* **`adv-game-output-6ch.wav` is 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`](structures/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.
|
||
|
||
```bash
|
||
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=0` precedes 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/other` for
|
||
**t = 10 … 251** and then `title_noplate` at **t = 262** (r = +0.998),
|
||
`title_plate` at 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`](structures/bgm-two-stems.md) 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. |