spaceship/audio/Sfx.gd
Claude 885f977768 #229, #234: one voice per volley, and gas within reach of the spawn
#229 - a volley got louder for every ship in it.

Every hull owns its gun and asks for its own shot sound, so eight ships
firing on the same cooldown started eight voices playing the SAME sample in
the same frame. Those amplitudes add, so a full fleet was the loudest thing
in the game by a wide margin.

play() now takes a per-sound coalescing window. A volley is one event to the
ear, so it gets one voice. 70ms, which the test checks is inside the fastest
gun's 500ms cooldown - it collapses "six guns at once", never "six shots in
a row". PICKUP and ASTEROID_BREAK get one too: ore arrives in bursts when a
rock breaks and that is one pickup to the ear. Deaths and the boss scream
are left alone deliberately - several things dying together IS several
events.

test_sfx's pool-saturation case was driven by shots, which can no longer
saturate anything by design. It is driven by deaths now, which is what the
pool actually has to absorb, and it gained a case asserting a volley takes
one voice - measured against the real pool rather than the gate.

#234 - gas was not reliably in reach of the opening.

Gas giants are gated behind two independent noise fields, the SPACE biome
AND the planet roll, so whether any sat near the spawn was luck. Measured,
this seed lost: ZERO gas planets within four chunks, the nearest five away
past five rocky ones. Gas is an early need - the burner, ammunition - so the
opening cannot depend on the noise being kind.

SPAWN_GAS_GIANT_CHUNK at (2, -2), authored like OCTOPUS_BIOME_CENTER rather
than found by searching outward: a fixed chunk is reproducible from anywhere
without walking the map, which is what the rest of WorldGen is built on.

The override is applied AFTER every rng draw, for the same reason the dev
planting is - forcing one chunk must not shift what any other chunk rolls.
Confirmed by probe: the five other planets within four chunks are identical
before and after, and the distant gas giants at (-5,-4) and (0,-5) are still
where they were.

Both verified in reverse: without the window, "8 ships firing together
started 8 shot sounds"; without the chunk, "no gas planet within 4 chunks of
the spawn".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
2026-09-15 23:44:15 -04:00

188 lines
7.3 KiB
GDScript

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 ENEMY_DEATH: String = "enemy_death"
const BUILDING_DEATH: String = "building_death"
const DIALOG: String = "dialog"
const BOSS_SCREAM: String = "boss_scream"
# #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"
# 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.
const SOUNDS: Dictionary = {
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"),
}
# 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: -26.0,
SHIP_DEATH: -23.0,
ENEMY_DEATH: -23.0,
BUILDING_DEATH: -23.0,
DIALOG: -19.0,
BOSS_SCREAM: -13.0,
# 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,
}
# Forgejo #229: how long a sound blocks another of its own kind.
#
# Every hull fires its own gun and asks for its own shot sound, so a fleet
# firing a volley started one voice per ship - the same sample, in the same
# frame, at the same level. Those amplitudes ADD, so the volley got louder
# for every ship added, and a full fleet was the loudest thing in the game
# by a wide margin.
#
# A volley is one event to the ear, so it gets one voice. The window is
# shorter than any weapon's cooldown, so consecutive volleys are still heard
# individually - it collapses "six guns at once", never "six shots in a row".
#
# Deaths and the boss scream are absent deliberately: several things dying
# together IS several events, and each is rare enough not to stack.
const COALESCE_MSEC: Dictionary = {
SHOT: 70,
# Ore comes in bursts when a rock breaks, and a burst is one pickup to
# the ear. Quieter than a shot but far more frequent (see LEVELS).
PICKUP: 60,
# Rocks break in twos and threes - that is in the pitch spread comment
# below, and it is the same stacking problem.
ASTEROID_BREAK: 60,
}
# When each sound last started, keyed by id. Static because play() is, and
# the pool outlives every scene that uses it.
static var _last_started_msec: Dictionary = {}
# 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
# 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,
}
# 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
# 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()
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 = SOUNDS[id] as AudioStream
if stream == null:
return
var window: int = int(COALESCE_MSEC.get(id, 0))
if window > 0:
var now: int = Time.get_ticks_msec()
var last: int = int(_last_started_msec.get(id, -1000000))
if now - last < window:
return
_last_started_msec[id] = now
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))
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)
voice.play()