spaceship/tests/test_sfx.gd

328 lines
12 KiB
GDScript3
Raw Normal View History

#146: shots, deaths and collapses are heard Three events get a sound: a shot leaving a gun, a vessel dying, a structure coming down. The sounds are SYNTHESISED rather than downloaded - audio/make_sfx.py generates all three from scratch, and is committed alongside them. The ticket asked for free sources and the good ones are in its comments (Kenney's CC0 packs, OpenGameArt's CC0 sets, Freesound filtered to CC0), but a generated sound has no licence to track, no attribution to keep, and no binary in the repository whose provenance nobody can check a year from now. It also makes the sounds DATA: too sharp a shot is a number in that file and a re-run, not a hunt through a library. Dropping in a pack file later means replacing a .wav and nothing else. A voice POOL, not a player, because these overlap constantly - a fleet firing is several shots inside one shot's length. Twelve voices, and when they are all busy the one furthest through its own sound is recycled, which is the one with the least left to lose. Parented to the tree root, for the reason that matters: every one of these sounds is started by something that is being freed on the same frame. A dying ship cannot own the sound of its own death. Levels are per sound rather than shared - a shot fires constantly and a death happens once, so equal volumes are not proportionate ones - and shots get a little pitch spread so a stream of them does not flatten into one machine-gun note. The deaths get none: a wobbling explosion sounds broken rather than varied. The test goes at the failures a play-through would hide for weeks: firing three times the pool size must leave exactly the pool's worth sounding and no leaked voices, and the ship-death case is checked from a node that really has been freed on that frame.
2026-08-29 13:05:47 +02:00
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.
#146: shots, deaths and collapses are heard Three events get a sound: a shot leaving a gun, a vessel dying, a structure coming down. The sounds are SYNTHESISED rather than downloaded - audio/make_sfx.py generates all three from scratch, and is committed alongside them. The ticket asked for free sources and the good ones are in its comments (Kenney's CC0 packs, OpenGameArt's CC0 sets, Freesound filtered to CC0), but a generated sound has no licence to track, no attribution to keep, and no binary in the repository whose provenance nobody can check a year from now. It also makes the sounds DATA: too sharp a shot is a number in that file and a re-run, not a hunt through a library. Dropping in a pack file later means replacing a .wav and nothing else. A voice POOL, not a player, because these overlap constantly - a fleet firing is several shots inside one shot's length. Twelve voices, and when they are all busy the one furthest through its own sound is recycled, which is the one with the least left to lose. Parented to the tree root, for the reason that matters: every one of these sounds is started by something that is being freed on the same frame. A dying ship cannot own the sound of its own death. Levels are per sound rather than shared - a shot fires constantly and a death happens once, so equal volumes are not proportionate ones - and shots get a little pitch spread so a stream of them does not flatten into one machine-gun note. The deaths get none: a wobbling explosion sounds broken rather than varied. The test goes at the failures a play-through would hide for weeks: firing three times the pool size must leave exactly the pool's worth sounding and no leaked voices, and the ship-death case is checked from a node that really has been freed on that frame.
2026-08-29 13:05:47 +02:00
#
# 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
#146: shots, deaths and collapses are heard Three events get a sound: a shot leaving a gun, a vessel dying, a structure coming down. The sounds are SYNTHESISED rather than downloaded - audio/make_sfx.py generates all three from scratch, and is committed alongside them. The ticket asked for free sources and the good ones are in its comments (Kenney's CC0 packs, OpenGameArt's CC0 sets, Freesound filtered to CC0), but a generated sound has no licence to track, no attribution to keep, and no binary in the repository whose provenance nobody can check a year from now. It also makes the sounds DATA: too sharp a shot is a number in that file and a re-run, not a hunt through a library. Dropping in a pack file later means replacing a .wav and nothing else. A voice POOL, not a player, because these overlap constantly - a fleet firing is several shots inside one shot's length. Twelve voices, and when they are all busy the one furthest through its own sound is recycled, which is the one with the least left to lose. Parented to the tree root, for the reason that matters: every one of these sounds is started by something that is being freed on the same frame. A dying ship cannot own the sound of its own death. Levels are per sound rather than shared - a shot fires constantly and a death happens once, so equal volumes are not proportionate ones - and shots get a little pitch spread so a stream of them does not flatten into one machine-gun note. The deaths get none: a wobbling explosion sounds broken rather than varied. The test goes at the failures a play-through would hide for weeks: firing three times the pool size must leave exactly the pool's worth sounding and no leaked voices, and the ship-death case is checked from a node that really has been freed on that frame.
2026-08-29 13:05:47 +02:00
# 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)
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
# 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
#146: shots, deaths and collapses are heard Three events get a sound: a shot leaving a gun, a vessel dying, a structure coming down. The sounds are SYNTHESISED rather than downloaded - audio/make_sfx.py generates all three from scratch, and is committed alongside them. The ticket asked for free sources and the good ones are in its comments (Kenney's CC0 packs, OpenGameArt's CC0 sets, Freesound filtered to CC0), but a generated sound has no licence to track, no attribution to keep, and no binary in the repository whose provenance nobody can check a year from now. It also makes the sounds DATA: too sharp a shot is a number in that file and a re-run, not a hunt through a library. Dropping in a pack file later means replacing a .wav and nothing else. A voice POOL, not a player, because these overlap constantly - a fleet firing is several shots inside one shot's length. Twelve voices, and when they are all busy the one furthest through its own sound is recycled, which is the one with the least left to lose. Parented to the tree root, for the reason that matters: every one of these sounds is started by something that is being freed on the same frame. A dying ship cannot own the sound of its own death. Levels are per sound rather than shared - a shot fires constantly and a death happens once, so equal volumes are not proportionate ones - and shots get a little pitch spread so a stream of them does not flatten into one machine-gun note. The deaths get none: a wobbling explosion sounds broken rather than varied. The test goes at the failures a play-through would hide for weeks: firing three times the pool size must leave exactly the pool's worth sounding and no leaked voices, and the ship-death case is checked from a node that really has been freed on that frame.
2026-08-29 13:05:47 +02:00
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
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
# 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
#146: shots, deaths and collapses are heard Three events get a sound: a shot leaving a gun, a vessel dying, a structure coming down. The sounds are SYNTHESISED rather than downloaded - audio/make_sfx.py generates all three from scratch, and is committed alongside them. The ticket asked for free sources and the good ones are in its comments (Kenney's CC0 packs, OpenGameArt's CC0 sets, Freesound filtered to CC0), but a generated sound has no licence to track, no attribution to keep, and no binary in the repository whose provenance nobody can check a year from now. It also makes the sounds DATA: too sharp a shot is a number in that file and a re-run, not a hunt through a library. Dropping in a pack file later means replacing a .wav and nothing else. A voice POOL, not a player, because these overlap constantly - a fleet firing is several shots inside one shot's length. Twelve voices, and when they are all busy the one furthest through its own sound is recycled, which is the one with the least left to lose. Parented to the tree root, for the reason that matters: every one of these sounds is started by something that is being freed on the same frame. A dying ship cannot own the sound of its own death. Levels are per sound rather than shared - a shot fires constantly and a death happens once, so equal volumes are not proportionate ones - and shots get a little pitch spread so a stream of them does not flatten into one machine-gun note. The deaths get none: a wobbling explosion sounds broken rather than varied. The test goes at the failures a play-through would hide for weeks: firing three times the pool size must leave exactly the pool's worth sounding and no leaked voices, and the ship-death case is checked from a node that really has been freed on that frame.
2026-08-29 13:05:47 +02:00
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
# --- A. the catalog is real -----------------------------
for id in Sfx.SOUNDS:
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
# 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
#146: shots, deaths and collapses are heard Three events get a sound: a shot leaving a gun, a vessel dying, a structure coming down. The sounds are SYNTHESISED rather than downloaded - audio/make_sfx.py generates all three from scratch, and is committed alongside them. The ticket asked for free sources and the good ones are in its comments (Kenney's CC0 packs, OpenGameArt's CC0 sets, Freesound filtered to CC0), but a generated sound has no licence to track, no attribution to keep, and no binary in the repository whose provenance nobody can check a year from now. It also makes the sounds DATA: too sharp a shot is a number in that file and a re-run, not a hunt through a library. Dropping in a pack file later means replacing a .wav and nothing else. A voice POOL, not a player, because these overlap constantly - a fleet firing is several shots inside one shot's length. Twelve voices, and when they are all busy the one furthest through its own sound is recycled, which is the one with the least left to lose. Parented to the tree root, for the reason that matters: every one of these sounds is started by something that is being freed on the same frame. A dying ship cannot own the sound of its own death. Levels are per sound rather than shared - a shot fires constantly and a death happens once, so equal volumes are not proportionate ones - and shots get a little pitch spread so a stream of them does not flatten into one machine-gun note. The deaths get none: a wobbling explosion sounds broken rather than varied. The test goes at the failures a play-through would hide for weeks: firing three times the pool size must leave exactly the pool's worth sounding and no leaked voices, and the ship-death case is checked from a node that really has been freed on that frame.
2026-08-29 13:05:47 +02:00
if stream == null:
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
return _fail("the \"%s\" sound is not a preloaded stream" % id)
#146: shots, deaths and collapses are heard Three events get a sound: a shot leaving a gun, a vessel dying, a structure coming down. The sounds are SYNTHESISED rather than downloaded - audio/make_sfx.py generates all three from scratch, and is committed alongside them. The ticket asked for free sources and the good ones are in its comments (Kenney's CC0 packs, OpenGameArt's CC0 sets, Freesound filtered to CC0), but a generated sound has no licence to track, no attribution to keep, and no binary in the repository whose provenance nobody can check a year from now. It also makes the sounds DATA: too sharp a shot is a number in that file and a re-run, not a hunt through a library. Dropping in a pack file later means replacing a .wav and nothing else. A voice POOL, not a player, because these overlap constantly - a fleet firing is several shots inside one shot's length. Twelve voices, and when they are all busy the one furthest through its own sound is recycled, which is the one with the least left to lose. Parented to the tree root, for the reason that matters: every one of these sounds is started by something that is being freed on the same frame. A dying ship cannot own the sound of its own death. Levels are per sound rather than shared - a shot fires constantly and a death happens once, so equal volumes are not proportionate ones - and shots get a little pitch spread so a stream of them does not flatten into one machine-gun note. The deaths get none: a wobbling explosion sounds broken rather than varied. The test goes at the failures a play-through would hide for weeks: firing three times the pool size must leave exactly the pool's worth sounding and no leaked voices, and the ship-death case is checked from a node that really has been freed on that frame.
2026-08-29 13:05:47 +02:00
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())
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
# --- 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])
#146: shots, deaths and collapses are heard Three events get a sound: a shot leaving a gun, a vessel dying, a structure coming down. The sounds are SYNTHESISED rather than downloaded - audio/make_sfx.py generates all three from scratch, and is committed alongside them. The ticket asked for free sources and the good ones are in its comments (Kenney's CC0 packs, OpenGameArt's CC0 sets, Freesound filtered to CC0), but a generated sound has no licence to track, no attribution to keep, and no binary in the repository whose provenance nobody can check a year from now. It also makes the sounds DATA: too sharp a shot is a number in that file and a re-run, not a hunt through a library. Dropping in a pack file later means replacing a .wav and nothing else. A voice POOL, not a player, because these overlap constantly - a fleet firing is several shots inside one shot's length. Twelve voices, and when they are all busy the one furthest through its own sound is recycled, which is the one with the least left to lose. Parented to the tree root, for the reason that matters: every one of these sounds is started by something that is being freed on the same frame. A dying ship cannot own the sound of its own death. Levels are per sound rather than shared - a shot fires constantly and a death happens once, so equal volumes are not proportionate ones - and shots get a little pitch spread so a stream of them does not flatten into one machine-gun note. The deaths get none: a wobbling explosion sounds broken rather than varied. The test goes at the failures a play-through would hide for weeks: firing three times the pool size must leave exactly the pool's worth sounding and no leaked voices, and the ship-death case is checked from a node that really has been freed on that frame.
2026-08-29 13:05:47 +02:00
# --- 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()
#146: shots, deaths and collapses are heard Three events get a sound: a shot leaving a gun, a vessel dying, a structure coming down. The sounds are SYNTHESISED rather than downloaded - audio/make_sfx.py generates all three from scratch, and is committed alongside them. The ticket asked for free sources and the good ones are in its comments (Kenney's CC0 packs, OpenGameArt's CC0 sets, Freesound filtered to CC0), but a generated sound has no licence to track, no attribution to keep, and no binary in the repository whose provenance nobody can check a year from now. It also makes the sounds DATA: too sharp a shot is a number in that file and a re-run, not a hunt through a library. Dropping in a pack file later means replacing a .wav and nothing else. A voice POOL, not a player, because these overlap constantly - a fleet firing is several shots inside one shot's length. Twelve voices, and when they are all busy the one furthest through its own sound is recycled, which is the one with the least left to lose. Parented to the tree root, for the reason that matters: every one of these sounds is started by something that is being freed on the same frame. A dying ship cannot own the sound of its own death. Levels are per sound rather than shared - a shot fires constantly and a death happens once, so equal volumes are not proportionate ones - and shots get a little pitch spread so a stream of them does not flatten into one machine-gun note. The deaths get none: a wobbling explosion sounds broken rather than varied. The test goes at the failures a play-through would hide for weeks: firing three times the pool size must leave exactly the pool's worth sounding and no leaked voices, and the ship-death case is checked from a node that really has been freed on that frame.
2026-08-29 13:05:47 +02:00
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")
#146: shots, deaths and collapses are heard Three events get a sound: a shot leaving a gun, a vessel dying, a structure coming down. The sounds are SYNTHESISED rather than downloaded - audio/make_sfx.py generates all three from scratch, and is committed alongside them. The ticket asked for free sources and the good ones are in its comments (Kenney's CC0 packs, OpenGameArt's CC0 sets, Freesound filtered to CC0), but a generated sound has no licence to track, no attribution to keep, and no binary in the repository whose provenance nobody can check a year from now. It also makes the sounds DATA: too sharp a shot is a number in that file and a re-run, not a hunt through a library. Dropping in a pack file later means replacing a .wav and nothing else. A voice POOL, not a player, because these overlap constantly - a fleet firing is several shots inside one shot's length. Twelve voices, and when they are all busy the one furthest through its own sound is recycled, which is the one with the least left to lose. Parented to the tree root, for the reason that matters: every one of these sounds is started by something that is being freed on the same frame. A dying ship cannot own the sound of its own death. Levels are per sound rather than shared - a shot fires constantly and a death happens once, so equal volumes are not proportionate ones - and shots get a little pitch spread so a stream of them does not flatten into one machine-gun note. The deaths get none: a wobbling explosion sounds broken rather than varied. The test goes at the failures a play-through would hide for weeks: firing three times the pool size must leave exactly the pool's worth sounding and no leaked voices, and the ship-death case is checked from a node that really has been freed on that frame.
2026-08-29 13:05:47 +02:00
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")
#146: shots, deaths and collapses are heard Three events get a sound: a shot leaving a gun, a vessel dying, a structure coming down. The sounds are SYNTHESISED rather than downloaded - audio/make_sfx.py generates all three from scratch, and is committed alongside them. The ticket asked for free sources and the good ones are in its comments (Kenney's CC0 packs, OpenGameArt's CC0 sets, Freesound filtered to CC0), but a generated sound has no licence to track, no attribution to keep, and no binary in the repository whose provenance nobody can check a year from now. It also makes the sounds DATA: too sharp a shot is a number in that file and a re-run, not a hunt through a library. Dropping in a pack file later means replacing a .wav and nothing else. A voice POOL, not a player, because these overlap constantly - a fleet firing is several shots inside one shot's length. Twelve voices, and when they are all busy the one furthest through its own sound is recycled, which is the one with the least left to lose. Parented to the tree root, for the reason that matters: every one of these sounds is started by something that is being freed on the same frame. A dying ship cannot own the sound of its own death. Levels are per sound rather than shared - a shot fires constantly and a death happens once, so equal volumes are not proportionate ones - and shots get a little pitch spread so a stream of them does not flatten into one machine-gun note. The deaths get none: a wobbling explosion sounds broken rather than varied. The test goes at the failures a play-through would hide for weeks: firing three times the pool size must leave exactly the pool's worth sounding and no leaked voices, and the ship-death case is checked from a node that really has been freed on that frame.
2026-08-29 13:05:47 +02:00
# --- 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")
#146: shots, deaths and collapses are heard Three events get a sound: a shot leaving a gun, a vessel dying, a structure coming down. The sounds are SYNTHESISED rather than downloaded - audio/make_sfx.py generates all three from scratch, and is committed alongside them. The ticket asked for free sources and the good ones are in its comments (Kenney's CC0 packs, OpenGameArt's CC0 sets, Freesound filtered to CC0), but a generated sound has no licence to track, no attribution to keep, and no binary in the repository whose provenance nobody can check a year from now. It also makes the sounds DATA: too sharp a shot is a number in that file and a re-run, not a hunt through a library. Dropping in a pack file later means replacing a .wav and nothing else. A voice POOL, not a player, because these overlap constantly - a fleet firing is several shots inside one shot's length. Twelve voices, and when they are all busy the one furthest through its own sound is recycled, which is the one with the least left to lose. Parented to the tree root, for the reason that matters: every one of these sounds is started by something that is being freed on the same frame. A dying ship cannot own the sound of its own death. Levels are per sound rather than shared - a shot fires constantly and a death happens once, so equal volumes are not proportionate ones - and shots get a little pitch spread so a stream of them does not flatten into one machine-gun note. The deaths get none: a wobbling explosion sounds broken rather than varied. The test goes at the failures a play-through would hide for weeks: firing three times the pool size must leave exactly the pool's worth sounding and no leaked voices, and the ship-death case is checked from a node that really has been freed on that frame.
2026-08-29 13:05:47 +02:00
print("TEST_SFX: PASS")
quit(0)
return true
return false