diff --git a/audio/Sfx.gd b/audio/Sfx.gd index 04be32c..a748599 100644 --- a/audio/Sfx.gd +++ b/audio/Sfx.gd @@ -34,17 +34,23 @@ const ASTEROID_BREAK: String = "asteroid_break" const GAS_BLAST: String = "gas_blast" const BLACK_HOLE: String = "black_hole" +# PRELOADED, not paths. play() used to load() on every call, which is a +# cache hit after the first time but a synchronous disk read and decode the +# first time - on the frame the sound was asked for. Ten sounds meant ten +# occasional late starts, one per sound, which is what "sometimes starting a +# bit late" was. Swapping in a different file is still just replacing the +# .wav; nothing here knows or cares which it is. const SOUNDS: Dictionary = { - SHOT: "res://audio/sfx_shot.wav", - SHIP_DEATH: "res://audio/sfx_ship_death.wav", - ENEMY_DEATH: "res://audio/sfx_ship_death.wav", - BUILDING_DEATH: "res://audio/sfx_building_death.wav", - DIALOG: "res://audio/sfx_dialog.wav", - BOSS_SCREAM: "res://audio/sfx_boss_scream.wav", - PICKUP: "res://audio/sfx_pickup.wav", - ASTEROID_BREAK: "res://audio/sfx_asteroid_break.wav", - GAS_BLAST: "res://audio/sfx_gas_blast.wav", - BLACK_HOLE: "res://audio/sfx_black_hole.wav", + SHOT: preload("res://audio/sfx_shot.wav"), + SHIP_DEATH: preload("res://audio/sfx_ship_death.wav"), + ENEMY_DEATH: preload("res://audio/sfx_ship_death.wav"), + BUILDING_DEATH: preload("res://audio/sfx_building_death.wav"), + DIALOG: preload("res://audio/sfx_dialog.wav"), + BOSS_SCREAM: preload("res://audio/sfx_boss_scream.wav"), + PICKUP: preload("res://audio/sfx_pickup.wav"), + ASTEROID_BREAK: preload("res://audio/sfx_asteroid_break.wav"), + GAS_BLAST: preload("res://audio/sfx_gas_blast.wav"), + BLACK_HOLE: preload("res://audio/sfx_black_hole.wav"), } # Per-sound level. A shot is fired constantly and a death happens once, so @@ -81,6 +87,16 @@ const PITCH_SPREAD: Dictionary = { } +# Builds the voice pool before anything needs it. _pool() is lazy, so without +# this the first sound in a session paid for twelve AudioStreamPlayer nodes +# being created on the frame it was asked for - the worst late start of all, +# and the one that happens every session. +static func warm(tree: SceneTree) -> void: + if tree == null: + return + _pool(tree) + + static func _pool(tree: SceneTree) -> Node: var existing: Node = tree.root.get_node_or_null(NODE_NAME) if existing != null: @@ -103,7 +119,7 @@ static func _pool(tree: SceneTree) -> Node: static func play(tree: SceneTree, id: String) -> void: if tree == null or not SOUNDS.has(id): return - var stream: AudioStream = load(String(SOUNDS[id])) + var stream: AudioStream = SOUNDS[id] as AudioStream if stream == null: return var pool: Node = _pool(tree) diff --git a/controllers/GameController.gd b/controllers/GameController.gd index 6eefed9..b15b5f5 100644 --- a/controllers/GameController.gd +++ b/controllers/GameController.gd @@ -114,6 +114,9 @@ func _ready() -> void: # This script is the root of BOTH the space scene and the planet # surface, so one call here covers the whole game. Music.ensure(get_tree()) + # Same moment, same reason: build the sfx voices before the first + # sound needs them rather than during it. + Sfx.warm(get_tree()) if joystick != null: joystick.direction_changed.connect(_on_joystick_direction) diff --git a/tests/test_sfx.gd b/tests/test_sfx.gd index ea90a10..fac9813 100644 --- a/tests/test_sfx.gd +++ b/tests/test_sfx.gd @@ -22,6 +22,10 @@ extends SceneTree # once, so a shot must be the quieter of the two # F. sound keeps working while a dialog has the game stopped (#128) +# 20ms is well clear of what the generator actually produces (1.5ms at +# worst, on the black hole) while still catching a real lazy attack. +const MAX_LEAD_SILENCE_MS: float = 20.0 + var game_state: Node var scene: Node var fleet: Node2D @@ -60,16 +64,76 @@ func _sounding() -> int: return n +# Every sample committed in audio/, not just the ones the catalog names. +func _wav_file_names() -> Array: + var names: Array = [] + var dir: DirAccess = DirAccess.open("res://audio") + if dir == null: + return names + for file_name in dir.get_files(): + # The editor leaves .import siblings; take the real files only. + if file_name.ends_with(".wav"): + names.append(file_name) + names.sort() + return names + + +# Leading samples that are exactly zero, in milliseconds. Negative if the +# file could not be parsed, which the caller treats as a failure rather than +# quietly passing. +func _leading_silence_ms(path: String) -> float: + var f: FileAccess = FileAccess.open(path, FileAccess.READ) + if f == null: + return -1.0 + if f.get_buffer(4).get_string_from_ascii() != "RIFF": + return -1.0 + f.get_32() + if f.get_buffer(4).get_string_from_ascii() != "WAVE": + return -1.0 + var channels: int = 0 + var rate: int = 0 + var bits: int = 0 + while f.get_position() + 8 <= f.get_length(): + var chunk_id: String = f.get_buffer(4).get_string_from_ascii() + var chunk_size: int = f.get_32() + var next: int = f.get_position() + chunk_size + (chunk_size & 1) + if chunk_id == "fmt ": + f.get_16() + channels = f.get_16() + rate = f.get_32() + f.get_32() + f.get_16() + bits = f.get_16() + elif chunk_id == "data": + if bits != 16 or channels <= 0 or rate <= 0: + return -1.0 + var silent_frames: int = 0 + var data_end: int = f.get_position() + chunk_size + while f.get_position() + 2 * channels <= data_end: + var quiet: bool = true + for c in range(channels): + if f.get_16() != 0: + quiet = false + if not quiet: + break + silent_frames += 1 + return float(silent_frames) / float(rate) * 1000.0 + f.seek(next) + return -1.0 + + func _process(delta: float) -> bool: elapsed += delta match stage: 0: # --- A. the catalog is real ----------------------------- for id in Sfx.SOUNDS: - var path: String = String(Sfx.SOUNDS[id]) - var stream: AudioStream = load(path) + # Preloaded, so this asserts the catalog is already resident + # rather than merely loadable - play() must never touch the + # disk on the frame a sound is asked for. + var stream: AudioStream = Sfx.SOUNDS[id] as AudioStream if stream == null: - return _fail("the \"%s\" sound (%s) does not load" % [id, path]) + return _fail("the \"%s\" sound is not a preloaded stream" % id) if stream.get_length() <= 0.02: return _fail("the \"%s\" sound is %.3fs long - that is silence" \ % [id, stream.get_length()]) @@ -77,6 +141,36 @@ func _process(delta: float) -> bool: return _fail("the \"%s\" sound has no level set" % id) print("%d sounds, all loading, all with a level" % Sfx.SOUNDS.size()) + # --- F. and none of them opens on silence --------------- + # Reported from the game as sounds "sometimes starting a bit + # late". Measured, the samples were clean and the cause was + # elsewhere - play() used to load() on the frame the sound was + # asked for. But make_sfx.py regenerates these, and an envelope + # that eased in from zero would put the delay back somewhere + # nobody would think to look. + # + # Reads the source .wav rather than the imported stream: these + # import as QOA (compress/mode=2), so AudioStreamWAV.data is + # not PCM and cannot be inspected sample by sample. True digital + # silence only - the black hole deliberately SWELLS (see its + # docstring in make_sfx.py) and a soft attack is a choice. + var checked: int = 0 + var worst_id: String = "" + var worst_ms: float = 0.0 + for file_name in _wav_file_names(): + var lead: float = _leading_silence_ms("res://audio/" + file_name) + if lead < 0.0: + return _fail("could not read %s to check its attack" % file_name) + checked += 1 + if lead > worst_ms: + worst_ms = lead + worst_id = file_name + if lead > MAX_LEAD_SILENCE_MS: + return _fail("%s opens with %.1fms of dead air" % [file_name, lead]) + if checked < Sfx.SOUNDS.size() - 1: + return _fail("only %d wav files checked for dead air - that proves nothing" % checked) + print("%d samples checked, worst attack %s at %.1fms" % [checked, worst_id, worst_ms]) + # --- E. and in proportion ------------------------------- if float(Sfx.LEVELS[Sfx.SHOT]) >= float(Sfx.LEVELS[Sfx.SHIP_DEATH]): return _fail("a shot is as loud as a death - one happens constantly, the other once") diff --git a/ui/MainMenu.gd b/ui/MainMenu.gd index 5b99822..ef0eaaf 100644 --- a/ui/MainMenu.gd +++ b/ui/MainMenu.gd @@ -56,6 +56,9 @@ func _ready() -> void: # #148: the theme starts here, at the first screen, and keeps playing # through every scene after it. Music.ensure(get_tree()) + # Same moment, same reason: build the sfx voices before the first + # sound needs them rather than during it. + Sfx.warm(get_tree()) play_button.pressed.connect(_on_play_pressed) editor_button.pressed.connect(_on_editor_pressed) editor_button.enabled = true