Files
Sylpheed/port/scripts/menu_audio.gd
Sylpheed port agent 2ad839460c port: P6 -- the menu has sound, and the BGM I "chose" was decoded all along
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
2026-08-29 12:29:13 +00:00

135 lines
5.2 KiB
GDScript

# The menu's sound: three cues and one music bed.
#
# EVERYTHING THIS CLASS PLAYS IS AUTHORED OR MEASURED, and the two are not the
# same. `authored/audio.json` carries the distinction and the exporter copies it
# into `manifest.json` alongside each file, so a reader of the export tree sees
# it without having to find this project:
#
# * WHICH WAVE a menu event plays was MEASURED off the running game (HANDOFF
# Q8) -- it is on the disc in no findable form. `Static.slb` has no RIFF, no
# seek chunk and no container.
# * WHICH TRACK the menu plays is CHOSEN. HANDOFF Q10 is a negative: all 32
# banks are named BGM_001..BGM_109 and nothing on the disc says which one a
# menu uses.
# * WHEN a cue fires is authored here, and §"When a cue fires" below says
# exactly which parts of that nobody has watched the game do.
#
# The wall (MISSION §2): this class reads **Ogg Vorbis**. It has never heard of
# XMA, of `sound.pak` or of `Static.slb`, and it must not learn. The exporter
# converts; the runtime plays.
class_name MenuAudio
extends Node
## Cue name -> stream, from `manifest.json`'s `audio` entries of kind `se`.
var cues: Dictionary = {}
## Role -> {stream, loop}, from the entries of kind `bgm`.
var beds: Dictionary = {}
var error: String = ""
## One player per cue name, so a move and a confirm can overlap rather than
## cutting each other off. Three cues is not worth a pool.
var _players: Dictionary = {}
var _bed: AudioStreamPlayer = null
var _bed_role := ""
## Load every audio entry the manifest declares.
##
## Missing audio is NOT an error and does not stop a run: every milestone before
## P6 exported none, and `--menu` must stay usable against one of those trees.
## A cue that is listed but unreadable IS an error, because that is a broken
## export rather than an old one.
func configure(tree: ExportTree) -> bool:
var manifest := tree.manifest()
if manifest.is_empty():
error = tree.error
return false
for entry: Dictionary in manifest.get("audio", []):
var path := tree.root.path_join(String(entry.get("file", "")))
var stream := AudioStreamOggVorbis.load_from_file(path)
if stream == null:
error = "manifest lists audio %s but %s is not a readable Ogg Vorbis file" \
% [entry.get("name", "?"), path]
return false
match String(entry.get("kind", "")):
"se":
# A cue ends. Nothing measured says otherwise, and a looping
# cue would be a bug you hear rather than one you read.
stream.loop = false
cues[String(entry["name"])] = stream
"bgm":
# AUTHORED, and audibly imperfect on purpose. HANDOFF Q10: no
# loop-point field has been identified, so `restart` replays
# from sample 0 -- the listener hears the track's own fade-out
# and its trailing silence before the music returns. Trimming to
# the fade would sound better and would INVENT a loop point,
# which is worse: an invented one is indistinguishable from a
# decoded one a month later. See authored/audio.json loop_why.
stream.loop = String(entry.get("loop_mode", "")) == "restart"
beds[String(entry["name"])] = stream
_:
push_warning("manifest audio entry %s has kind %s, which this build does not play"
% [entry.get("name", "?"), entry.get("kind", "?")])
return true
## True when this export carries no audio at all -- an export taken before P6.
func silent() -> bool:
return cues.is_empty() and beds.is_empty()
# --- When a cue fires ---------------------------------------------------------
#
# MEASURED (HANDOFF Q5 + Q8): a d-pad press that MOVES the cursor plays the move
# cue, and left/right play nothing at all. `MenuFlow.move()` returns whether the
# cursor actually moved for exactly this reason, so a press at the end of a
# non-wrapping list cannot click.
#
# NOT MEASURED, and authored here: whether Ⓐ or Ⓑ click when nothing is bound to
# them. Nobody has watched the game take a dead press. This class stays silent in
# that case, which is the choice that invents the least -- a sound the game does
# not make is a wrong fact you can hear, whereas a missing one is a gap. Ask the
# RE agent before relying on it either way.
func play(cue: String) -> void:
if not cues.has(cue):
return
if not _players.has(cue):
var p := AudioStreamPlayer.new()
p.stream = cues[cue]
add_child(p)
_players[cue] = p
(_players[cue] as AudioStreamPlayer).play()
## Start the music bed for a role, or do nothing if it is already playing.
##
## Idempotent because the menu re-enters screens constantly -- Ⓑ back to the main
## menu must not restart the music, and a bed that restarts on every navigation
## is the kind of wrong that reads as "the audio works".
func play_bed(role: String) -> void:
if not beds.has(role) or _bed_role == role:
return
if _bed == null:
_bed = AudioStreamPlayer.new()
add_child(_bed)
_bed.stream = beds[role]
_bed_role = role
_bed.play()
func stop_bed() -> void:
if _bed != null:
_bed.stop()
_bed_role = ""
## What the audio server is actually doing, for a run's write-up.
##
## `docs/port/AUDIO-VERIFICATION.md`: "recorded under a dummy driver" is a
## weaker claim than "heard", and the difference matters -- so the claim is
## printed by the run that makes it rather than assumed by the person reading it.
static func driver() -> String:
return AudioServer.get_driver_name()