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:
Claude 2026-09-09 11:40:28 +02:00
commit 37e2e89cd3
4 changed files with 130 additions and 14 deletions

View file

@ -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")