spaceship/audio/Sfx.gd
Claude 37e2e89cd3 Stop the first play of each sound paying for its own load
Reported from the game: sounds sometimes start a bit late. The samples were
the suspect, so they were measured first - and they are clean. True digital
silence at the head of each file:

  seven of nine start on sample 1
  sfx_gas_blast    0.2ms
  sfx_black_hole   1.5ms, then a deliberate swell

The black hole's slow attack is the sound it was written to be - make_sfx.py
says it "swells across most of its length instead of decaying from the first
sample" - so there is nothing to trim without breaking it.

The delay was in the playback path, and "sometimes" was the clue:

  play() called load() every time. A cache hit after the first, but the
  FIRST play of each sound was a synchronous read and decode on the frame
  the sound was asked for. Ten sounds, ten occasional late starts.

  _pool() built its twelve AudioStreamPlayer nodes lazily, on the first
  play() call - one bigger stall, every session.

The catalog is preloaded now, so play() never touches the disk, and
Sfx.warm() builds the voices next to Music.ensure() at startup.

Case F pins the thing that was originally suspected, since make_sfx.py
regenerates these and a lazy envelope would hide the delay somewhere nobody
would look. It reads the source .wav's RIFF chunks rather than the imported
stream: these import as QOA (compress/mode=2), so AudioStreamWAV.data is not
PCM. The first version of that check was vacuous for exactly that reason and
passed at a 0.5ms threshold; it now reports "9 samples checked, worst attack
sfx_black_hole.wav at 1.5ms" and fails when tightened.

Not touched: project.godot has no [audio] section, so output_latency is at
its default. On Android that buffer is usually the dominant latency, but
lowering it risks crackle on real hardware - worth doing with someone
listening, not blind.

172/172.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
2026-09-09 11:40:28 +02:00

146 lines
5.6 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,
}
# 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
_pool(tree)
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 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()