The 6-channel capture I spent an iteration refuting was corrupt, and the Decoder found the cause: PulseAudio remapping between two mismatched channel maps, where a 6-channel remap SILENTLY DROPS AND DUPLICATES. Its control -- six channels, six tones, same sink, same parec -- came back 400/3200/200/800/800/200 for an input of 400/800/200/1600/3200/6400. Two source channels gone entirely. So the negative was right, and the byte-identical pair I reported was the thread that unravelled it. Worth recording precisely, because it nearly went unreported: it began as an idle look at two channels whose peak AND RMS matched to six decimals, and it only became evidence because a coincidence at six decimals is cheaper to hash than to explain. `tools/port/check-capture` makes that check one command -- split the file, hash every channel, fail on any duplicate pair -- and AUDIO-VERIFICATION.md gains a section 5 saying to run it FIRST, every time, plus the two conditions the same incident produced: start the recorder before the process, and log what was on screen against the recording's own clock so a miss is diagnosable. Controlled both directions, because a checker nobody controlled is what this incident is about: six distinct tones PASS; the remap's own output pattern FAILS naming all four pairs; the corrupt capture FAILS on ch2 == ch5. THE KNOWN-BAD CONTROL IS THE POINT. All six of its channels report a peak of -18.063656 dB, identical to six decimals, while containing three duplicate pairs. A level check cannot see this failure. That is why the tool hashes rather than measures, and why the corrupt capture's "plausible per-channel levels" were never evidence. The tool says of itself that it is necessary, not sufficient. Withdrawn with the file, both the Decoder's: "all six channels carry signal", and the non-zero-surround observation offered as weak support for 5.1. Unaffected: the three-XMA-context concurrency result, read from the emulator's log rather than the audio path, on two independent boots. The corrupt file is dropped from the exchange so the next agent cannot pick it up and repeat the work. Nothing in the export changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N7FiFFFwbvG2uxdcEh8HyF
9.5 KiB
Verifying audio without an audio device
Neither container has a sound card, so "does it actually play?" cannot be answered by listening. It can be answered by measurement, and the two things usually meant by that question need different measurements.
Separate them before reaching for a tool:
| question | needs Godot? | needs a device? |
|---|---|---|
| Is the transcoded file faithful to the source? | no | no |
| Does Godot actually route it to an output? | yes | no |
| What does the game play on a menu move? | no (Canary) | a virtual one |
1. Transcode fidelity — file against file
This is the question P4 actually raised, and it needs neither an engine nor a device. Decode both, subtract, and measure what is left.
# Source, for a reference level
ffmpeg -hide_banner -t 25 -i ADV.wmv \
-af "aformat=channel_layouts=stereo,astats=measure_perchannel=none" -f null - 2>&1 \
| grep "RMS level"
# The difference signal: source minus transcode
ffmpeg -hide_banner -t 25 -i ADV.wmv -t 25 -i ADV.ogv -filter_complex \
"[0:a]aformat=channel_layouts=stereo[a];\
[1:a]aformat=channel_layouts=stereo,volume=-1[b];\
[a][b]amix=inputs=2:normalize=0,astats=measure_perchannel=none" -f null - 2>&1 \
| grep "RMS level"
A faithful transcode puts the difference 40 dB or more below the source.
Three ways this measurement lies
Run it wrong and it reports a disaster that is not there. All three of these were hit on the first attempt:
- Alignment. A one-sample offset makes the difference nearly as loud as the source. Cross-correlate and compensate before subtracting, or the number is meaningless. A first run gave source −25.3 dB against difference −34.2 dB — only 9 dB down, which looks catastrophic and proves nothing.
- Channel layout. The source and the transcode do not have the same channel
count. You are not comparing like with like unless both sides are downmixed
the same way, and
astatswill give you a confident number regardless. Seemovie-audio-channelsfor which profile a given movie is in — that is a disc fact and lives in the RE corpus, not here. - A file still being written.
ffprobereported the.ogvas 33 s against the source's 137 s — apparent catastrophic truncation, actually a transcode in progress. Checkmtimeand packet count before believing a duration, and write to a temp name and rename on completion so a reader cannot see a partial file at all.
⚠️ The downmix is an unrecorded decision, and it is not ours to make quietly. Nothing in the manifest says a fold happened or on what weighting; it is whatever ffmpeg defaulted to, and that default can change between versions. Centre-channel dialogue folds into L/R, so this changes how speech sits against music — an aesthetic judgement, not a container detail. Pin it explicitly and record it, exactly as MISSION §6 requires of the transcode command itself.
2. Engine routing — Godot writes a WAV instead of a device
Godot does not need a sound card to produce audio you can inspect. Put an
AudioEffectRecord on the Master bus and it captures the mixed output from
inside a headless run:
var bus := AudioServer.get_bus_index("Master")
var rec := AudioEffectRecord.new()
AudioServer.add_bus_effect(bus, rec)
rec.set_recording_active(true)
# ... play the scene ...
rec.set_recording_active(false)
rec.get_recording().save_to_wav("user://master.wav")
This is implemented. godot --path port -- --menu … --audio=/tmp/p6.wav
installs the effect, records for the whole run, and saves on exit — in
_exit_tree rather than beside each quit(), because there are eight of those
and the one that would get missed is an error path, i.e. exactly the run whose
audio somebody wants to look at. The run prints the driver name beside the file
it wrote.
Then feed that WAV through §1 against the source. That closes the loop: it proves the asset is right and that the engine reached it, which no amount of file comparison can show on its own.
Confirm the dummy driver is what is actually in use rather than assuming it —
AudioServer.get_driver_name() — and say so in the write-up, because "recorded
under a dummy driver" is a weaker claim than "heard", and the difference matters.
3. A virtual device, when something insists on a real one
For anything that opens a device rather than a bus — the emulator, most
obviously — a PulseAudio null sink is a real device that records to a file.
pulseaudio-utils is in both images, and audio-capture wraps it:
audio-capture run /tmp/menu.wav -- run-canary # start sink, run, record
audio-capture start # or drive it by hand
PULSE_SINK=cap godot --path port
audio-capture record /tmp/out.wav &
This is the route to capturing what the game plays — the menu move and confirm cues behind HANDOFF Q8 — rather than what we believe it should play. Those bindings are currently a name match against the authors' own identifiers; a capture turns them into a measurement.
⚠️ audio-capture run reports the peak level and warns when the result is
silent, because silence is the failure that looks like success: a WAV of
exactly the right duration, full of zeroes, because the application opened a
different sink. A duration check alone would pass it.
5. A multichannel capture must pass a provenance check BEFORE it is analysed
tools/port/check-capture FILE.wav — run it first, every time.
⚠️ This section exists because a capture of the game's own 6-channel output was analysed at length and the file was corrupt. It got three controls, a drift test and a written-up negative, and every one of those was sound; none of them could see that channels were missing, because the corruption was upstream of everything they tested.
PulseAudio was remapping between two mismatched channel maps, and a 6-channel
remap silently drops and duplicates. The Decoder proved it with a control that
needs no emulator and no disc — six channels each carrying a different tone,
through the same sink and the same parec invocation
(docs/re/audio-capture-channel-map-trap.md):
| ch | played | recorded |
|---|---|---|
| 0 | 400 | 400 |
| 1 | 800 | 3200 |
| 2 | 200 | 200 |
| 3 | 1600 | 800 |
| 4 | 3200 | 800 |
| 5 | 6400 | 200 |
Two source channels were gone entirely and two were duplicates. Setting the
sink's channel_map to the guest's own (FL,FR,FC,LFE,RL,RR) and passing the
same map to parec returns all six.
The signature is an exact duplicate pair, and only a hash finds it
Duration is right. Channel count is right. Corked: no. There is no error
anywhere, and the per-channel levels look entirely reasonable — which is the
whole difficulty. In the tool's own known-bad control, all six channels report a
peak of −18.063656 dB, identical to six decimals, while containing three
duplicate pairs. A level check cannot see this. Hashing each channel can.
Two channels of a real surround mix are never byte-identical over tens of seconds. On the corrupt game capture the tool reports:
ch2 peak -4.466272 ba497de78217c438a3e430c5ef6b951b
ch5 peak -4.466272 ba497de78217c438a3e430c5ef6b951b
🔴 ch2 and ch5 are BYTE-IDENTICAL
⚠️ It is a necessary check, not a sufficient one. Passing says the file has no duplicated channels. It says nothing about whether the right thing was recorded — that is what §1's correlation against a known source is for, and a capture should survive both before anything is concluded from it.
Two more conditions, learned the same way
- Start the recorder before the process you are capturing, so
t = 0precedes it and the window certainly contains the moment of interest. - Log what was on screen, with timestamps keyed to the recording's own clock. A capture that matches nothing is then diagnosable rather than ambiguous; the corrupt one could not be told apart from "recorded the wrong phase of the boot" by any amount of analysis at this end.
And the failure this page already warns about, in a second costume:
run-canary is silent twice over — SDL_AUDIODRIVER=dummy and
--mute=true. Fix only the first and Canary attaches a healthy 6-channel stream
at 100 % volume, reports Corked: no, and emits a 19 MB WAV of zeroes.
What none of this establishes
That it sounds right. Every method here shows correspondence to a source, not that the source is the audio the game plays at that moment, and not that levels are sane in a mix. A ten-second human listen still answers something no measurement above does — so when a result rests on one of these, say which one.
4. What the exporter checks, so nobody has to remember to
sylpheed-export measures peak level and duration of every audio file it
writes and records both in manifest.json; sylpheed-export check refuses a
tree whose peak is ≤ −90 dBFS (silent) or ≥ 0 dBFS (clipping).
Those are content checks in a format validator on purpose. Silence is the failure this page opens by naming — right duration, right channel count, right size, full of zeroes — and every structural check passes it. Clipping is the other one, and the BGM can produce it, because a music bank is two stems summed at unity gain (HANDOFF Q10).
⚠️ Neither says the audio is the right audio. docs/port/BLOCKED.md says
which bindings are measured and which are still authored, and no measurement on
this page can move a row there.