Stop the first play of each sound paying for its own load
Reported from the game: sounds sometimes start a bit late. The samples were the suspect, so they were measured first - and they are clean. True digital silence at the head of each file: seven of nine start on sample 1 sfx_gas_blast 0.2ms sfx_black_hole 1.5ms, then a deliberate swell The black hole's slow attack is the sound it was written to be - make_sfx.py says it "swells across most of its length instead of decaying from the first sample" - so there is nothing to trim without breaking it. The delay was in the playback path, and "sometimes" was the clue: play() called load() every time. A cache hit after the first, but the FIRST play of each sound was a synchronous read and decode on the frame the sound was asked for. Ten sounds, ten occasional late starts. _pool() built its twelve AudioStreamPlayer nodes lazily, on the first play() call - one bigger stall, every session. The catalog is preloaded now, so play() never touches the disk, and Sfx.warm() builds the voices next to Music.ensure() at startup. Case F pins the thing that was originally suspected, since make_sfx.py regenerates these and a lazy envelope would hide the delay somewhere nobody would look. It reads the source .wav's RIFF chunks rather than the imported stream: these import as QOA (compress/mode=2), so AudioStreamWAV.data is not PCM. The first version of that check was vacuous for exactly that reason and passed at a 0.5ms threshold; it now reports "9 samples checked, worst attack sfx_black_hole.wav at 1.5ms" and fails when tightened. Not touched: project.godot has no [audio] section, so output_latency is at its default. On Android that buffer is usually the dominant latency, but lowering it risks crackle on real hardware - worth doing with someone listening, not blind. 172/172. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
This commit is contained in:
parent
aaf8b400ef
commit
37e2e89cd3
4 changed files with 130 additions and 14 deletions
38
audio/Sfx.gd
38
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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue