# 🔴 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.