spaceship/tests/test_sfx.gd
Claude 37e2e89cd3 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
2026-09-09 11:40:28 +02:00

328 lines
12 KiB
GDScript

extends SceneTree
# Headless test: run with
# godot --headless -s res://tests/test_sfx.gd
# Forgejo #146: shots and destruction should be heard. Enemy destruction and
# incoming narration are covered too, so visual events cannot silently miss
# the shared sound pool.
#
# The interesting failures here are not "no sound came out" - they are the
# ones a play-through would not reveal for weeks. A voice pool that runs dry
# under a firing fleet and silently drops every shot after the twelfth. A
# sound started by a node that is being freed on the same frame, which is
# every death in the game. So those are what is checked.
#
# Covers:
# A. every sound the catalog names actually exists and loads
# B. firing plays a shot, and it survives a fleet firing together - no
# voice is lost, none is left stuck
# C. a dying ship is heard, from a node that is freed on the same frame
# D. enemy and structure destruction are heard
# E. levels are in proportion: a shot fires constantly and a death happens
# 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
var stage: int = 0
var elapsed: float = 0.0
func _initialize() -> void:
# GameState is an autoload: a bare GameState.<instance member> does not
# compile in a SceneTree script, so the node is fetched by name instead.
game_state = root.get_node("GameState")
game_state.reset_new_game()
game_state.intro_active = false
game_state.reset_quests()
game_state.rocks = 500
func _fail(message: String) -> bool:
print("TEST_SFX: FAIL (%s)" % message)
quit(1)
return true
func _pool() -> Node:
return root.get_node_or_null(Sfx.NODE_NAME)
func _sounding() -> int:
var pool: Node = _pool()
if pool == null:
return 0
var n: int = 0
for voice in pool.get_children():
if (voice as AudioStreamPlayer).playing:
n += 1
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:
# 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 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()])
if not Sfx.LEVELS.has(id):
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")
print("shot at %.0fdB, ship death at %.0fdB, structure at %.0fdB" \
% [Sfx.LEVELS[Sfx.SHOT], Sfx.LEVELS[Sfx.SHIP_DEATH],
Sfx.LEVELS[Sfx.BUILDING_DEATH]])
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
current_scene = scene
fleet = scene.get_node("Fleet")
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.4:
return false
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
if _pool() != null and _sounding() > 0:
return _fail("something was already sounding before anything happened")
# --- B. a shot, and then far more than there are voices --
Sfx.play(self, Sfx.SHOT)
if _sounding() != 1:
return _fail("firing played %d sounds, expected 1" % _sounding())
if _pool().get_parent() != root:
return _fail("the sound pool hangs off the scene - a death would take it with it")
for i in range(Sfx.VOICES * 3):
Sfx.play(self, Sfx.SHOT)
var busy: int = _sounding()
if busy != Sfx.VOICES:
return _fail("%d voices sounding after %d shots, the pool holds %d" \
% [busy, Sfx.VOICES * 3, Sfx.VOICES])
if _pool().get_child_count() != Sfx.VOICES:
return _fail("the pool grew to %d voices" % _pool().get_child_count())
print("%d shots into a %d-voice pool: %d sounding, none leaked" \
% [Sfx.VOICES * 3, Sfx.VOICES, busy])
# --- F. and a dialog does not silence it -----------------
for voice in _pool().get_children():
(voice as AudioStreamPlayer).stop()
var narration: Control = scene.get_node("UI/NarrationBanner")
narration.say("Everything stops but this.", "TEST")
if _sounding() == 0:
return _fail("a narration line appeared in silence")
if not paused:
return _fail("the dialog did not stop the game, so this proves nothing")
for voice in _pool().get_children():
if not (voice as AudioStreamPlayer).can_process():
return _fail("sound stops whenever a dialog is up")
print("a dialog stops the game and sound carries on")
while narration.dismiss():
pass
for voice in _pool().get_children():
(voice as AudioStreamPlayer).stop()
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 0.3:
return false
# --- C. a death, from a node freed on the same frame -----
fleet.spawn_ships(2, Vector2.ZERO,
PackedStringArray([GameState.VESSEL_INTERCEPTOR,
GameState.VESSEL_INTERCEPTOR]))
for voice in _pool().get_children():
(voice as AudioStreamPlayer).stop()
var doomed: Node2D = fleet.ships[0]
doomed.take_damage(1)
if not is_instance_valid(doomed) or not doomed.is_queued_for_deletion():
return _fail("the ship was not freed, so this is not the case that matters")
if _sounding() == 0:
return _fail("a vessel died in silence")
print("a vessel freed on the frame it dies is still heard")
# An enemy uses the same persistent pool. Its own node is freed on
# this frame too, so the call must happen before queue_free().
for voice in _pool().get_children():
(voice as AudioStreamPlayer).stop()
var enemy: Node2D = load("res://enemies/Enemy.tscn").instantiate()
scene.add_child(enemy)
enemy.take_damage(enemy.hp)
if not enemy.is_queued_for_deletion():
return _fail("the enemy was not destroyed")
if _sounding() == 0:
return _fail("an enemy vessel exploded in silence")
print("an enemy vessel explosion is heard")
# --- D. a structure ------------------------------------
for voice in _pool().get_children():
(voice as AudioStreamPlayer).stop()
var field: Node2D = scene.get_node("BuildingField")
var id: int = game_state.add_building(GameState.BUILDING_SHIP_CRAFTER,
Vector2(400.0, 0.0))
field.rebuild_all()
game_state.destroy_building(id)
if _sounding() == 0:
return _fail("a structure came down in silence")
print("a structure coming down is heard")
# --- G. the things #210/#213/#214 asked to be able to hear -----
for voice in _pool().get_children():
(voice as AudioStreamPlayer).stop()
var rock: Node2D = load("res://world/Asteroid.tscn").instantiate()
scene.add_child(rock)
rock.shatter(false)
if _sounding() == 0:
return _fail("an asteroid broke apart in silence")
print("a destroyed asteroid is heard")
for voice in _pool().get_children():
(voice as AudioStreamPlayer).stop()
var bomb: Node2D = load("res://vfx/GasBombEffect.gd").launch(
scene, Vector2.ZERO, Vector2(300.0, 0.0), 6)
if _sounding() != 0:
return _fail("the gas bomb made its noise on the throw, not the blast")
bomb._explode()
if _sounding() == 0:
return _fail("a gas bomb bloomed in silence")
print("a gas bomb is heard when it blooms, and not before")
# The hold has to be able to take the deposit, or there is no
# arrival to hear.
game_state.fleet_inventory.total_capacity = -1
for voice in _pool().get_children():
(voice as AudioStreamPlayer).stop()
if game_state.deposit_fleet_resource(GameState.RESOURCE_ORE, 1) != 1:
return _fail("the fleet hold refused a single rock")
var after_first: int = _sounding()
if after_first == 0:
return _fail("a rock reached the fleet hold in silence")
# Mining lands one unit at a time, so the window is the whole of
# what stops this being a machine-gun.
game_state.deposit_fleet_resource(GameState.RESOURCE_ORE, 1)
game_state.deposit_fleet_resource(GameState.RESOURCE_ORE, 1)
if _sounding() != after_first:
return _fail("a burst of pickups opened %d voices, not the %d one blip needs" \
% [_sounding(), after_first])
print("a rock arriving in the hold is heard, and a burst of them only once")
# The bank is a number moving in a building the player is not in.
for voice in _pool().get_children():
(voice as AudioStreamPlayer).stop()
game_state._pickup_sound_at = 0
game_state.deposit_bank_resource(GameState.RESOURCE_ORE, 5)
if _sounding() != 0:
return _fail("a deposit into the bank made a pickup noise")
print("banked resources stay silent")
print("TEST_SFX: PASS")
quit(0)
return true
return false