#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
|
|
|
class_name Sfx
|
|
|
|
|
extends RefCounted
|
|
|
|
|
|
|
|
|
|
# Forgejo #146: the three things that happen to something and should be
|
|
|
|
|
# heard - a shot leaving a gun, a vessel dying, a structure coming down.
|
|
|
|
|
#
|
|
|
|
|
# The sounds themselves are synthesised (see audio/make_sfx.py) rather than
|
|
|
|
|
# downloaded. The ticket asked for free sources and the good ones are in its
|
|
|
|
|
# comments, but a generated sound has no licence to track, no attribution to
|
|
|
|
|
# keep, and no binary in the repository whose provenance nobody can check
|
|
|
|
|
# later. Swapping in a file from one of those packs is a matter of replacing
|
|
|
|
|
# the .wav - nothing here knows or cares which it is.
|
|
|
|
|
#
|
|
|
|
|
# Parented to the tree root for the same reason Music is: a sound started by
|
|
|
|
|
# something that is about to be freed - which is every one of these, a dying
|
|
|
|
|
# ship being the obvious case - cannot live on the thing that started it.
|
|
|
|
|
#
|
|
|
|
|
# A POOL rather than one player, because these overlap constantly: a fleet
|
|
|
|
|
# firing is several shots inside one shot's length. The oldest is recycled
|
|
|
|
|
# when they are all busy, which is the right thing to lose.
|
|
|
|
|
|
|
|
|
|
const NODE_NAME: String = "SfxPlayers"
|
|
|
|
|
const VOICES: int = 12
|
|
|
|
|
|
|
|
|
|
const SHOT: String = "shot"
|
|
|
|
|
const SHIP_DEATH: String = "ship_death"
|
2026-08-31 18:15:37 +02:00
|
|
|
const ENEMY_DEATH: String = "enemy_death"
|
#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
|
|
|
const BUILDING_DEATH: String = "building_death"
|
2026-08-31 18:15:37 +02:00
|
|
|
const DIALOG: String = "dialog"
|
2026-09-03 22:00:26 +02:00
|
|
|
const BOSS_SCREAM: String = "boss_scream"
|
2026-09-05 12:16:17 +02:00
|
|
|
# #210, #213, #214, #208.
|
|
|
|
|
const PICKUP: String = "pickup"
|
|
|
|
|
const ASTEROID_BREAK: String = "asteroid_break"
|
|
|
|
|
const GAS_BLAST: String = "gas_blast"
|
|
|
|
|
const BLACK_HOLE: String = "black_hole"
|
#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
|
|
|
|
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, 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.
|
#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
|
|
|
const SOUNDS: Dictionary = {
|
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
|
|
|
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"),
|
#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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Per-sound level. A shot is fired constantly and a death happens once, so
|
|
|
|
|
# they cannot share a volume and stay in proportion.
|
|
|
|
|
const LEVELS: Dictionary = {
|
2026-08-30 16:53:08 +02:00
|
|
|
SHOT: -26.0,
|
|
|
|
|
SHIP_DEATH: -23.0,
|
2026-08-31 18:15:37 +02:00
|
|
|
ENEMY_DEATH: -23.0,
|
2026-08-30 16:53:08 +02:00
|
|
|
BUILDING_DEATH: -23.0,
|
2026-08-31 18:15:37 +02:00
|
|
|
DIALOG: -19.0,
|
2026-09-03 22:00:26 +02:00
|
|
|
BOSS_SCREAM: -13.0,
|
2026-09-05 12:16:17 +02:00
|
|
|
# Quieter than everything else by a clear margin: it is the only sound
|
|
|
|
|
# here that can happen several times a second for minutes at a time, and
|
|
|
|
|
# a pickup blip at shot volume would be the loudest thing in the game.
|
|
|
|
|
PICKUP: -32.0,
|
|
|
|
|
ASTEROID_BREAK: -24.0,
|
|
|
|
|
GAS_BLAST: -21.0,
|
|
|
|
|
# The prologue's one irreversible moment, and nothing else is playing
|
|
|
|
|
# under it.
|
|
|
|
|
BLACK_HOLE: -11.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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Shots vary a little in pitch so a stream of them does not turn into one
|
|
|
|
|
# flat machine-gun note. The deaths do not: they are events, and a wobbling
|
|
|
|
|
# explosion sounds broken rather than varied.
|
|
|
|
|
const SHOT_PITCH_SPREAD: float = 0.12
|
2026-09-05 12:16:17 +02:00
|
|
|
# Pickups repeat harder than shots do, so they vary too - but less, because a
|
|
|
|
|
# pickup is a short bright tone and a wide spread on one reads as wrong notes
|
|
|
|
|
# rather than as variety. Rocks also break in twos and threes.
|
|
|
|
|
const PITCH_SPREAD: Dictionary = {
|
|
|
|
|
SHOT: SHOT_PITCH_SPREAD,
|
|
|
|
|
PICKUP: 0.07,
|
|
|
|
|
ASTEROID_BREAK: 0.09,
|
|
|
|
|
}
|
#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
|
|
|
|
|
|
|
|
|
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
|
|
|
# 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
|
#224: refuse to build a structure on top of another
BuildingField.place() dropped a structure at the fleet centroid with no
overlap check of any kind, so they could be stacked.
It reads worse than it looks. BuildingField decides which structure the
fleet is standing on by testing the centroid against interact_radius and
returning the FIRST match, so two structures inside one another make that a
question of list order rather than of where the player flew - you could fly
to your workshop and open the crafter.
The separation is interact_radius itself (150) rather than a new number,
because that is exactly the circle the ambiguity comes from. Buildings draw
to a radius of roughly 60, so this leaves a visible gap rather than letting
them touch.
Gated in three places: the info popup (the button greys out and says which
structure is in the way), _confirm_build, and _try_place - the preview goes
stale as the fleet keeps moving.
Ordering matters in one case. A unique structure replaces itself by
demolishing the old one BEFORE placing the new one, so a placement refused
afterwards would leave the player with neither. The check runs before the
demolition and excludes the structure being replaced. Case D pins it.
Two existing tests encoded the old permissive behaviour and now fly clear
first; neither is about placement:
test_build_info_popup builds every menu option from one position, so
each structure blocked the next
test_payments_use_fleet_hold builds a mothership on the ship lab that
an earlier case injects at the origin
Also fixes a bug from 37e2e89: Sfx.warm() called add_child() from _ready(),
which Godot refuses outright - it printed an error and never pre-built the
pool, defeating the point of warming it. Deferred, like Music.ensure().
173/173.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
2026-09-09 12:57:28 +02:00
|
|
|
# Deferred, like Music.ensure(): this is called from _ready(), where the
|
|
|
|
|
# parent is still setting up children and a direct add_child() is
|
|
|
|
|
# refused outright. The lazy path in play() runs during an ordinary
|
|
|
|
|
# frame and does not need it.
|
|
|
|
|
var build: Callable = func() -> void:
|
|
|
|
|
_pool(tree)
|
|
|
|
|
build.call_deferred()
|
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
|
|
|
|
|
|
|
|
|
#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
|
|
|
static func _pool(tree: SceneTree) -> Node:
|
|
|
|
|
var existing: Node = tree.root.get_node_or_null(NODE_NAME)
|
|
|
|
|
if existing != null:
|
|
|
|
|
return existing
|
|
|
|
|
var pool := Node.new()
|
|
|
|
|
pool.name = NODE_NAME
|
|
|
|
|
# #128 stops the tree for dialogs. A sound already in flight should
|
|
|
|
|
# finish rather than freeze mid-bang.
|
|
|
|
|
pool.process_mode = Node.PROCESS_MODE_ALWAYS
|
|
|
|
|
tree.root.add_child(pool)
|
|
|
|
|
for i in range(VOICES):
|
|
|
|
|
var voice := AudioStreamPlayer.new()
|
|
|
|
|
voice.process_mode = Node.PROCESS_MODE_ALWAYS
|
|
|
|
|
pool.add_child(voice)
|
|
|
|
|
return pool
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Plays one sound. Safe from a node on its way out of the tree, and safe to
|
|
|
|
|
# call far more often than there are voices.
|
|
|
|
|
static func play(tree: SceneTree, id: String) -> void:
|
|
|
|
|
if tree == null or not SOUNDS.has(id):
|
|
|
|
|
return
|
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
|
|
|
var stream: AudioStream = 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:
|
|
|
|
|
return
|
|
|
|
|
var pool: Node = _pool(tree)
|
|
|
|
|
var voice: AudioStreamPlayer = null
|
|
|
|
|
for child in pool.get_children():
|
|
|
|
|
if not (child as AudioStreamPlayer).playing:
|
|
|
|
|
voice = child
|
|
|
|
|
break
|
|
|
|
|
if voice == null:
|
|
|
|
|
# All busy. The oldest is the one furthest through its own sound, so
|
|
|
|
|
# it is the one with the least left to lose.
|
|
|
|
|
var most: float = -1.0
|
|
|
|
|
for child in pool.get_children():
|
|
|
|
|
var at: float = (child as AudioStreamPlayer).get_playback_position()
|
|
|
|
|
if at > most:
|
|
|
|
|
most = at
|
|
|
|
|
voice = child
|
|
|
|
|
if voice == null:
|
|
|
|
|
return
|
|
|
|
|
voice.stream = stream
|
|
|
|
|
voice.volume_db = float(LEVELS.get(id, -10.0))
|
2026-09-05 12:16:17 +02:00
|
|
|
var spread: float = float(PITCH_SPREAD.get(id, 0.0))
|
|
|
|
|
voice.pitch_scale = 1.0 if spread <= 0.0 else 1.0 + randf_range(-spread, spread)
|
#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
|
|
|
voice.play()
|