Two corrections to my own recent work, both prompted by the port checking it. First, the settle-time page argued the plate delay discrepancy was an instrument artefact on two legs, and one of them is withdrawn. It said the plate pulse period acts as an internal clock for presentation rate and measured 2.369 s against the corpus s 2.3. That estimate rests on ONE interval between two distinct troughs at a 125 ms sample interval -- uncertainty 0.177 s or 6.7 percent -- and trough-picking on a noisy plateau is fragile enough that re-running it gives 2.628 s, because an adjacent local minimum had been counted as a separate trough. Against the corpus s 2.24 that is +17.3 percent, about two sigma. So the pulse period does not show the run at normal speed; it is too weak to show anything, and cannot resolve a real-time factor below about 7 percent. The conclusion survives on the other leg, which is the sound one: the content-measured 2.247 s agrees with three independent prior readings (2.13 / 2.132 / 2.138), and both its landmarks are sharp content transitions rather than a trough on a plateau. A 17 percent slowdown would have put it at 2.49 s. What that leaves open matters because the port authors from these numbers: the run carries an unmeasured real-time factor under about 7 percent. The plate delay is anchored by agreement with prior runs; the menu build-in and B-to-title are anchored by nothing, so that is a second reason to treat them as provisional. Second, the shared capture is worse than truncated: parec writes the WAV header with zero sizes and patches them on clean exit, so the mid-write copy has RIFF size 8 and data size 0 against 183 MB of actual bytes. Python s wave module refuses to open it; ffmpeg and ffprobe recover by scanning and report a plausible duration, which is exactly why it went unnoticed -- the lenient reader hid it. Also corrects the attribution of the starvation numbers: 39.3 percent and 16680453 frames were measured on the finished local recording, not on the shared artefact. The port measured the shared copy and got 35.6 percent and 15289876 frames, with burst and gap medians agreeing to 0.1 ms. The diagnosis is unaffected but a number must say which artefact it came from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QsEPXWVaEpyfudtR6re1Pd
265 lines
14 KiB
Markdown
265 lines
14 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 monitor-sink route cannot fix this
|
||
|
||
`parec` reads a sink **monitor**, which advances at wall-clock rate and
|
||
substitutes silence whenever nothing is written. So any moment the emulator runs
|
||
below real time becomes a hole in the file, and the timebase is warped
|
||
non-uniformly — deleting the silences does not repair it, it just compresses time
|
||
unevenly.
|
||
|
||
❔ **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. |