The three Static.slb cues and the menu bed now export to Ogg Vorbis and play.
`sylpheed_formats::media` does the assembly; nothing in port/ has heard of XMA.
Three things this milestone got wrong before it got right, all recorded in
docs/port/DECISIONS.md because the corrections are the useful part:
1. The cue offsets were a Rust `const` in the exporter. They are MEASURED, not
decoded -- a measured value compiled into the exporter is a measurement
wearing the costume of a decoded field, and nobody deletes it because nobody
can see it. They are authored/audio.json now.
2. I picked BGM_001 and wrote a careful `why` calling the choice arbitrary. The
menu's music is BGM_103, and it is in HANDOFF at 9ca1eb5 -- the exact commit
BLOCKED.md says that row was reconciled against. Not stale: wrong when
written. I had summarised a negative without its reach, so "the TABLES cannot
say which BGM a screen plays" became "it is not on the disc". One word of
scope was the whole answer, and the export failed only because BGM_001
without its .slb extension hashes to nothing. That is luck, not design.
3. The comment above the BGM sum argued for unity gain "because halving is a mix
decision nobody made". It clipped at +1.8 dBFS. 1/n is the smallest constant
that provably cannot clip -- the same reasoning video.rs already carried for
its 5.1 downmix, in this repository, unread.
Unsettled and shipped as such: media::sound_bank_riffs returns THREE sub-waves
for BGM_103.slb where HANDOFF Q10's census says exactly two (the third is the
leading headerless region slb.rs emits for the voice path). The exporter sums all
three and writes a manifest warning, because which bytes belong together is the
decoders' question, not this exporter's -- and dropping one would destroy the
evidence, since a corrected export looks exactly like a correct one. Raised with
the Decoder; row in BLOCKED.md.
The gate is a null control, not a peak reading. A master-bus WAV that is
non-silent proves nothing -- the bed alone would look identical. So the same
scripted walk was run with <- in place of <v>, which fires no cue (Q5, measured),
and the difference is one 0.55 s burst at t=1.10 s and silence everywhere else.
The first attempt at that control returned bit-identical zero and I nearly filed
it as "cues never reach the bus": both runs ended at 1.115 s and the first press
lands at 1.17 s. A null result from an instrument that was not running is not a
null result.
Refutation attempt: HANDOFF Q8's three cue durations. They looked attackable --
0.133/0.172/0.169 s per packet, no shared rate -- but an XMA1 packet carries a
variable number of 512-sample frames, and the three come to 50.0/32.3/95.3
frames. Measured off the decoded Ogg: 0.533, 0.344, 1.016 s, every published
digit. SURVIVES, with its reach stated -- it confirms the assembly path and my
transcription, not the event bindings, which only an oracle can retake.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WM5XL4HfrHuxz8RiMWdCMC
141 lines
6.5 KiB
Markdown
141 lines
6.5 KiB
Markdown
# 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.
|
||
|
||
```bash
|
||
# 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 `astats` will give you a confident number regardless. See
|
||
[`movie-audio-channels`][mac] for 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.** `ffprobe` reported the `.ogv` as 33 s against
|
||
the source's 137 s — apparent catastrophic truncation, actually a transcode in
|
||
progress. Check `mtime` and 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.
|
||
|
||
[mac]: https://git.mc02.dev/fabi/Syplheed-Reborn/src/branch/main/docs/re/structures/movie-audio-channels.md
|
||
|
||
## 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:
|
||
|
||
```gdscript
|
||
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:
|
||
|
||
```bash
|
||
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.
|
||
|
||
## 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.
|