# 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()