Files
Sylpheed/port/scripts/menu_audio.gd
Sylpheed port agent b71c35acf4 port: the menu bed plays under the cutscene -- announced, deliberately not fixed
MenuAudio.stop_bed() exists and is called from nowhere, so the bed started on
the main menu runs through S00A and loops on past it, putting two unrelated
music tracks on the bus at once. Established from the source and authored data,
not from measurement.

NOT silenced: MISSION says leave an unmeasured detail plainly wrong rather than
plausibly invented, and music over a cutscene is caught by any listener in a
second where ducking would sound right and be a guess. _play_video announces it
instead, and stop_bed is kept as the one line to change.

Also records that the envelope correlator is unreliable for music under music --
0.15-0.42 for every candidate, peaks moving with window and template. I was
drafting '46 s of unexplained audio' when the cause was the authored
loop: restart. A margin needs a control at the SAME SNR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7FiFFFwbvG2uxdcEh8HyF
2026-08-29 22:55:02 +00:00

206 lines
8.3 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 = {}
## Movie name -> stream, from the entries of kind `voice`.
##
## A cutscene's dialogue is NOT in its `.ogv`. On this disc a movie carries music
## and effects only and the voice is a separate continuous XMA stream in
## `sound.pak`, bound by the movie manifest -- so playing a movie means starting
## two streams together, and a port that plays only the video is silently missing
## every line of dialogue. That is what a human play-test heard.
var voices: 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", []):
# Through the resolver, so a mod can replace a cue or the music bed by
# dropping a file at the same relative path (MODDING rule 4). Reading
# `tree.root` directly here would have made audio the one asset kind a
# mod could not touch, for no reason a modder could have guessed.
var path := tree.resolve(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
"voice":
# A cutscene's voice-over ends with the cutscene. It is keyed by
# MOVIE NAME, not by a role: the binding came off the disc's own
# movie manifest, so unlike the music bed there is nothing
# authored about which recording belongs to which picture.
stream.loop = false
voices[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() and voices.is_empty()
# --- The cutscene voice -------------------------------------------------------
var _voice: AudioStreamPlayer = null
## Start a movie's dialogue, or do nothing when the export carries none.
##
## **No offset, and none is authored.** The voice plays from the video's first
## frame, so the two streams are started together and nothing here compensates
## for anything. If they ever drift, that is a fact about the export, not a
## constant to be tuned in this file.
##
## Returns whether a stream was found, so the caller can SAY that a movie is
## unvoiced rather than leave silence looking like success.
func play_voice(movie: String) -> bool:
if not voices.has(movie):
return false
if _voice == null:
_voice = AudioStreamPlayer.new()
add_child(_voice)
_voice.stream = voices[movie]
_voice.play()
return true
## Stop the dialogue. Called when the movie ends OR is skipped -- a voice that
## outlived a skipped intro would play over the title screen.
func stop_voice() -> void:
if _voice != null:
_voice.stop()
# --- 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()
## 🔴 DEAD CODE, and that is the finding rather than a tidiness note.
##
## Nothing in the port calls this. The bed therefore starts when the main menu
## goes live and never stops -- through the cutscene, and on to the title after
## it. Nobody chose that; it is what happens when the only way to stop something
## is a function no caller remembers.
##
## It is the mirror of `ScreenView.skipped`, which was written every frame and
## read by nobody. One is a fact recorded and never surfaced, the other a
## capability provided and never used, and both were invisible for the same
## reason: nothing fails when they are missed.
##
## Kept, not deleted. The day a capture says whether the game's menu music ducks
## under a movie, this is the one line that has to change.
func stop_bed() -> void:
if _bed != null:
_bed.stop()
_bed_role = ""
## Is the music bed sounding right now? Used by the boot to ANNOUNCE that it is
## still playing under a movie, rather than to stop it.
func bed_playing() -> bool:
return _bed != null and _bed.playing
## 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()