#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.
This commit is contained in:
Loïc Wisniewski 2026-08-29 13:05:47 +02:00
commit 940540d60c
14 changed files with 433 additions and 0 deletions

96
audio/Sfx.gd Normal file
View file

@ -0,0 +1,96 @@
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"
const BUILDING_DEATH: String = "building_death"
const SOUNDS: Dictionary = {
SHOT: "res://audio/sfx_shot.wav",
SHIP_DEATH: "res://audio/sfx_ship_death.wav",
BUILDING_DEATH: "res://audio/sfx_building_death.wav",
}
# 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 = {
SHOT: -22.0,
SHIP_DEATH: -6.0,
BUILDING_DEATH: -4.0,
}
# 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
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
var stream: AudioStream = load(String(SOUNDS[id]))
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))
voice.pitch_scale = 1.0
if id == SHOT:
voice.pitch_scale = 1.0 + randf_range(-SHOT_PITCH_SPREAD, SHOT_PITCH_SPREAD)
voice.play()

1
audio/Sfx.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://nhg0c87ie053

88
audio/make_sfx.py Normal file
View file

@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Generates the game's sound effects, from scratch.
Forgejo #146 asked for free SFX sources, and the ones worth using are listed
in the ticket - Kenney's CC0 packs, OpenGameArt's CC0 sets, Freesound's CC0
filter. What ships here is synthesised instead, for one reason: a generated
sound has no licence, no attribution to keep track of, and no binary in the
repository whose provenance nobody can check a year from now.
It also means the sounds are DATA rather than assets. If a shot is too sharp
or an explosion too short, the fix is a number in this file and a re-run,
not a hunt through a sound library.
Run: python3 audio/make_sfx.py
"""
import math
import random
import struct
import wave
RATE = 44100
def _write(name, samples):
peak = max(1e-9, max(abs(s) for s in samples))
frames = b"".join(struct.pack("<h", int(max(-1.0, min(1.0, s / peak * 0.89)) * 32767))
for s in samples)
with wave.open("audio/%s.wav" % name, "w") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(RATE)
w.writeframes(frames)
print("audio/%s.wav %5.2fs %6d bytes" % (name, len(samples) / RATE, len(frames)))
def _env(i, n, attack, decay_power):
"""Fast attack, curved decay. `attack` is a fraction of the whole."""
a = max(1, int(n * attack))
if i < a:
return i / a
return (1.0 - (i - a) / max(1, n - a)) ** decay_power
def shot(seconds=0.16, start_hz=1500.0, end_hz=320.0):
"""A short descending zap. The fleet's guns are energy weapons, so this
is a swept tone rather than a crack - it has to sit under a stream of
itself without turning into noise."""
n = int(RATE * seconds)
out = []
phase = 0.0
for i in range(n):
t = i / n
hz = start_hz * (end_hz / start_hz) ** t
phase += 2.0 * math.pi * hz / RATE
# A little square in it, so it reads as synthetic rather than as a flute.
tone = 0.7 * math.sin(phase) + 0.3 * (1.0 if math.sin(phase) > 0 else -1.0)
out.append(tone * _env(i, n, 0.02, 2.2))
return out
def explosion(seconds, low_hz, brightness, rumble_hz):
"""Noise through a one-pole low-pass, plus a falling sine underneath it.
The sine is what gives it a size: without it a burst of noise is a hiss,
however loud."""
n = int(RATE * seconds)
out = []
last = 0.0
phase = 0.0
rng = random.Random(7)
for i in range(n):
t = i / n
# The filter opens for an instant and closes as it decays, which is
# what a real blast does - the crack first, the roar after.
cutoff = brightness * (1.0 - t) ** 1.6 + 0.02
last += cutoff * (rng.uniform(-1.0, 1.0) - last)
hz = low_hz * (rumble_hz / low_hz) ** t
phase += 2.0 * math.pi * hz / RATE
body = 0.55 * math.sin(phase)
out.append((last * 1.4 + body) * _env(i, n, 0.004, 1.7))
return out
if __name__ == "__main__":
_write("sfx_shot", shot())
# A vessel: sharp, bright, over quickly.
_write("sfx_ship_death", explosion(0.85, 220.0, 0.55, 45.0))
# A structure: slower, deeper, and it keeps falling down afterwards.
_write("sfx_building_death", explosion(1.6, 130.0, 0.34, 26.0))

Binary file not shown.

View file

@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://cucu8do3xu62b"
path="res://.godot/imported/sfx_building_death.wav-5a0672915182205beec566265df53340.sample"
[deps]
source_file="res://audio/sfx_building_death.wav"
dest_files=["res://.godot/imported/sfx_building_death.wav-5a0672915182205beec566265df53340.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2

BIN
audio/sfx_ship_death.wav Normal file

Binary file not shown.

View file

@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://cr6bt4w8n6d24"
path="res://.godot/imported/sfx_ship_death.wav-0969b7a92e0e9fb802408222617228e0.sample"
[deps]
source_file="res://audio/sfx_ship_death.wav"
dest_files=["res://.godot/imported/sfx_ship_death.wav-0969b7a92e0e9fb802408222617228e0.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2

BIN
audio/sfx_shot.wav Normal file

Binary file not shown.

24
audio/sfx_shot.wav.import Normal file
View file

@ -0,0 +1,24 @@
[remap]
importer="wav"
type="AudioStreamWAV"
uid="uid://c0le6ax67g411"
path="res://.godot/imported/sfx_shot.wav-cdb41c709bd7357ff93382562f01a47e.sample"
[deps]
source_file="res://audio/sfx_shot.wav"
dest_files=["res://.godot/imported/sfx_shot.wav-cdb41c709bd7357ff93382562f01a47e.sample"]
[params]
force/8_bit=false
force/mono=false
force/max_rate=false
force/max_rate_hz=44100
edit/trim=false
edit/normalize=false
edit/loop_mode=0
edit/loop_begin=0
edit/loop_end=-1
compress/mode=2