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
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
# godot --path port -- --menu=extras # ...starting somewhere else
|
||||
# godot --path port -- --boot --play # boot, then hand over to P5
|
||||
# godot --path port -- --menu --script=down,down,accept,cancel --shots=/tmp/p5
|
||||
# godot --path port -- --menu --script=down,accept --audio=/tmp/p6.wav
|
||||
#
|
||||
# `--menu` is the P5 mode: the d-pad moves the cursor, (A) opens, (B) goes back.
|
||||
# `--script` drives the SAME input path with synthetic events -- it does not call
|
||||
@@ -21,6 +22,14 @@
|
||||
# nothing about whether a human's press arrives. `--shots` writes one PNG per
|
||||
# scripted step, after the screen it produced has settled.
|
||||
#
|
||||
# `--audio=` records the MASTER BUS to a WAV for the whole run. Neither container
|
||||
# has a sound card, so "does it actually play?" cannot be answered by listening --
|
||||
# but it can be answered by measurement, and an `AudioEffectRecord` on Master
|
||||
# captures the mixed output from inside a headless run with no device at all.
|
||||
# `docs/port/AUDIO-VERIFICATION.md` §2. The run PRINTS the audio driver it used,
|
||||
# because "recorded under a dummy driver" is a weaker claim than "heard" and the
|
||||
# write-up has to be able to say which one it is making.
|
||||
#
|
||||
# `--time` is in SECONDS and freezes the timeline there; without it the screen
|
||||
# animates in real time from t=0. `--pose=rest` draws the export's declared
|
||||
# resting pose instead of the timeline -- what the reference renderer draws, so
|
||||
@@ -38,6 +47,7 @@ const DEFAULT_SCREEN := "main_menu"
|
||||
|
||||
var view: ScreenView = null
|
||||
var viewport: SubViewport = null
|
||||
var audio: MenuAudio = null
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -60,6 +70,21 @@ func _ready() -> void:
|
||||
if args.has("boot"):
|
||||
for step: Dictionary in _flow["boot"]:
|
||||
_sequence.append(step)
|
||||
# P6. Audio is loaded even for a static `--screen` run: it costs nothing when
|
||||
# the export has none, and a mode that silently cannot play sound is a mode
|
||||
# that hides the failure this milestone is about.
|
||||
audio = MenuAudio.new()
|
||||
add_child(audio)
|
||||
if not audio.configure(export_tree):
|
||||
push_error(audio.error)
|
||||
get_tree().quit(2)
|
||||
return
|
||||
if audio.silent():
|
||||
print("this export carries no audio -- run the exporter against a disc for P6")
|
||||
_record_to = args.get("audio", "")
|
||||
if _record_to != "":
|
||||
_start_recording()
|
||||
|
||||
_film = args.get("film", "")
|
||||
_shots = args.get("shots", "")
|
||||
if args.has("script"):
|
||||
@@ -302,22 +327,35 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
# are different lines of code.
|
||||
pass
|
||||
elif event.is_action_pressed("ui_accept"):
|
||||
_menu_activate(_menu.accept(buttons))
|
||||
_menu_activate(_menu.accept(buttons), "confirm")
|
||||
elif event.is_action_pressed("ui_cancel"):
|
||||
_menu_activate(_menu.cancel())
|
||||
_menu_activate(_menu.cancel(), "back")
|
||||
|
||||
|
||||
func _menu_move(step: int, buttons: Array) -> void:
|
||||
# MEASURED, HANDOFF Q8 + Q5: the cue fires on a press that MOVES the cursor.
|
||||
# `move()` returns whether it did, so a press that changes nothing cannot
|
||||
# click -- which also means left/right stay silent by construction rather
|
||||
# than by a rule written twice.
|
||||
if _menu.move(step, buttons):
|
||||
view.focused_id = _menu.focus()
|
||||
view.queue_redraw()
|
||||
audio.play("move")
|
||||
print(" focus -> %s" % view.focused_id)
|
||||
|
||||
|
||||
## Act on what the flow returned. A destination starts the screen playing itself
|
||||
## out; the arrival happens in `_process` when the exit ramp is done, so the
|
||||
## fade is the transition HANDOFF Q7 measured and not a cut.
|
||||
func _menu_activate(action: Dictionary) -> void:
|
||||
func _menu_activate(action: Dictionary, cue: String = "") -> void:
|
||||
# AUTHORED, NOT MEASURED: the cue fires when the press does something, and
|
||||
# not when nothing is bound to it. Nobody has watched the game take a dead
|
||||
# press. Silence invents the less of the two -- a sound the game does not
|
||||
# make is a wrong fact you can hear. `blocked` counts as doing something:
|
||||
# that destination WAS measured off the running game and is missing from
|
||||
# this export, not from the game. See port/scripts/menu_audio.gd.
|
||||
if cue != "" and String(action.get("kind", "none")) != "none":
|
||||
audio.play(cue)
|
||||
match String(action.get("kind", "none")):
|
||||
"enter":
|
||||
print(" (%s) -> %s" % [action.get("label", ""), action["goto"]])
|
||||
@@ -342,6 +380,12 @@ func _menu_enter(name: String, fresh: bool) -> void:
|
||||
_menu.enter(name, view.screen.get("buttons", []))
|
||||
view.focused_id = _menu.focus()
|
||||
view.queue_redraw()
|
||||
# AUTHORED, and the weakest thing in P6: HANDOFF Q10 says nothing on the disc
|
||||
# names which track a menu plays, so `authored/audio.json` picks one. It
|
||||
# starts when the menu becomes live and CARRIES ACROSS submenus -- `play_bed`
|
||||
# is idempotent, because music that restarts every time you press (B) is the
|
||||
# kind of wrong that reads as "the audio works".
|
||||
audio.play_bed("main_menu")
|
||||
print(" menu on %s, focus %s" % [name, _focus_label(view.focused_id)])
|
||||
if not _script.is_empty() and not _script_started:
|
||||
_script_started = true
|
||||
@@ -539,3 +583,63 @@ func _shoot(label: String) -> void:
|
||||
return
|
||||
DirAccess.rename_absolute(tmp, path)
|
||||
print(" shot %s (%s, focus %s)" % [path, _menu.current(), _focus_label(view.focused_id)])
|
||||
|
||||
|
||||
# ── Recording the master bus ──────────────────────────────────────────────────
|
||||
#
|
||||
# `docs/port/AUDIO-VERIFICATION.md` §2. This is what closes the loop that file
|
||||
# opens: comparing an exported Ogg against the disc proves the ASSET is right and
|
||||
# says nothing about whether the engine ever reached it. A WAV captured off the
|
||||
# Master bus proves both, and needs no sound card to do it.
|
||||
#
|
||||
# It is saved 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 -- exactly
|
||||
# the run whose audio somebody wants to look at.
|
||||
|
||||
var _record_to := ""
|
||||
var _record: AudioEffectRecord = null
|
||||
|
||||
|
||||
func _start_recording() -> void:
|
||||
var bus := AudioServer.get_bus_index("Master")
|
||||
_record = AudioEffectRecord.new()
|
||||
AudioServer.add_bus_effect(bus, _record)
|
||||
_record.set_recording_active(true)
|
||||
print("recording the Master bus to %s (audio driver: %s)" % [_record_to, MenuAudio.driver()])
|
||||
|
||||
|
||||
func _exit_tree() -> void:
|
||||
if _record == null:
|
||||
return
|
||||
_record.set_recording_active(false)
|
||||
var wav := _record.get_recording()
|
||||
_record = null
|
||||
if wav == null:
|
||||
push_error("--audio: the Master bus recorded nothing at all")
|
||||
return
|
||||
# Write to a temp name and rename on completion, as everything else in this
|
||||
# project does: another agent probing a file still being written gets a
|
||||
# confident wrong duration rather than an error.
|
||||
#
|
||||
# ⚠️ The temp name ends in `.wav`, and that is not cosmetic. `save_to_wav`
|
||||
# APPENDS `.wav` when the path does not already end in it, so `p6.wav.part`
|
||||
# silently became `p6.wav.part.wav` -- and the rename below then failed to
|
||||
# find its source and returned an error nobody read, leaving a run that
|
||||
# printed success beside a file that was not there. This is the same bug the
|
||||
# exporter's `run_ffmpeg` had in a different dialect: a temp-name convention
|
||||
# must preserve the extension, because tools dispatch on it.
|
||||
var tmp := _record_to + ".part.wav"
|
||||
if wav.save_to_wav(tmp) != OK:
|
||||
push_error("--audio: cannot write %s" % tmp)
|
||||
return
|
||||
var moved := DirAccess.rename_absolute(tmp, _record_to)
|
||||
if moved != OK:
|
||||
# Say so rather than print the success line below. A rename that fails
|
||||
# quietly is worse than one that fails loudly: the caller measures a
|
||||
# path that does not exist and reads "no such file" as "no audio".
|
||||
push_error("--audio: wrote %s but could not rename it to %s (%d)"
|
||||
% [tmp, _record_to, moved])
|
||||
return
|
||||
print("recorded %.3f s of Master bus -> %s (driver %s)"
|
||||
% [float(wav.data.size()) / float(wav.mix_rate * 2 * (2 if wav.stereo else 1)),
|
||||
_record_to, MenuAudio.driver()])
|
||||
|
||||
134
port/scripts/menu_audio.gd
Normal file
134
port/scripts/menu_audio.gd
Normal file
@@ -0,0 +1,134 @@
|
||||
# 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()
|
||||
1
port/scripts/menu_audio.gd.uid
Normal file
1
port/scripts/menu_audio.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://badw3pulb0xpt
|
||||
Reference in New Issue
Block a user