Part 03 — Sound effects at scale
You will build: a SoundPool — a fixed set of players that survives thirty hits a
second · a limiter on the SFX bus that survives the mix · and the measurement that decides
how big the pool has to be.
You'll learn: why one player per enemy is the shape that breaks · round-robin as the
entire scheduler · clipping as a bus problem, not a file problem · the pool size as an
arithmetic answer, not a taste answer.
Why this exists#
Part 01 met the pile-up at one player: a sound that can't finish. At scale the pile-up has a
second face — quantity. The survivors game at stage 08 has 300 enemies; a swinging weapon
and a contact-damage field put easily thirty damage events in a second, each one a hit sound.
The instinct is one AudioStreamPlayer per enemy — the enemy that gets hit plays its own
sound, the node dies with the body, symmetric and tidy. It's also 300 mix sources, 300
nodes instantiated and freed with the crowd, and a pool you didn't mean to build that the
physics server now has to schedule.
The fix is the pool, and the pool is the performance shelf's instantiate-and-free part wearing an audio costume: a fixed number of long-lived workers, a cheap dispatch, and a reset. This part builds it and then sizes it with a number instead of a feeling.
Build it#
A — Why not one player per enemy#
Before the pool, the cost model, so the pool's shape is earned rather than asserted:
- Every
AudioStreamPlayeris a mix source. The audio server mixes all active sources into the buses every frame — the work scales with active sources, and 300 enemies with 300 players is 300 sources whether or not they're playing (a stopped player still costs a scheduling slot; the server doesn't know your design, only its node count). - The crowd's churn is the pool's job description. Stage 08's enemies spawn and die
constantly — a player per enemy means
instantiate/freeriding the enemy's lifetime, which is exactly the allocation churn the float-text pool removed from the render side (stage 08, part D). The audio server gets the same treatment: the workers' lifetimes are the game's, not the enemy's. - Symmetry is a seduction. "The thing that gets hit owns the sound that plays" is the shape that reads best in a diagram and costs the most in the mix. The sound is an effect of the event, and the event already has a home — the damage path (part 01). The sound follows the event to a shared worker, the way the float text does.
B — The pool#
class_name SoundPool
extends Node
## A fixed set of players. play() dispatches to the next one, round-robin.
var _players: Array[AudioStreamPlayer] = []
var _next := 0
func _init(count: int = 8) -> void:
for i in count:
var p := AudioStreamPlayer.new()
p.name = "Voice%d" % i
p.bus = "SFX"
add_child(p)
_players.append(p)
## Play a stream on the next voice. Cuts the oldest voice if all are busy — by design.
func play(stream: AudioStream, volume_db: float = -6.0) -> void:
var p := _players[_next]
_next = (_next + 1) % _players.size()
p.stream = stream
p.volume_db = volume_db
p.play()
Eight voices, created in _init (before any _ready ordering questions), all routed to the
SFX bus (part 02's board), and play() is three lines with no branches: take the next
voice, wrap around, play. The round-robin is the entire scheduler — no priorities, no
free-list, no queue. When all eight are busy, the ninth hit cuts the oldest voice off mid-
tail and plays fresh: at thirty hits a second with 0.2 s sounds, each voice is reused every
~0.27 s, so a cut lands mid-decay at most a few times a second, on a sound whose tail is
already 30% faded. The cut is inaudible at this ratio — and that ratio is the number the
next part measures, not a hope.
Two deliberate absences:
- No
stop()on the cut voice.play()on a busy voice restarts it — the cut is the stop. An explicitstop()first would be a frame of silence on the voice, which at thirty per second is a thirty-per-second flutter. - No per-voice
volume_dbstate between plays. Everyplay()sets the volume fresh; a voice carries no memory of its last sound. The reset lesson — a pooled worker must end every job in a state the next job can start from — applied to a fader.
Wire it where the events are: the EnemyStats._on_damage_received that part 01 put the hit
sound in now calls Globals.sound_pool.play(HIT_STREAM) instead of owning a player. The
pool is an autoload (one per game, the workers outlive every enemy), and the streams are
preload constants at the call site — the sound is still data at the event (part 01's
stretch: per-species hits pass the species' stream into the same play).
C — The limiter: clipping is a bus problem#
Thirty sounds on eight voices is still thirty sounds' worth of amplitude negotiating for one bus. Watch the SFX bus's meter in the Monitor's audio view while farming a clump: the peak goes red — the mix exceeds 0 dBFS, and the speaker (or the headphone amp, or the OS mixer) clips it. Clipping is not "loud"; it's distorted, and it's the one audio defect players describe as "the sound is crackling" while you hear "it's fine on my machine" — because their OS mixer is clipping a signal yours isn't.
The fix is a Limiter effect on the SFX bus (the bus's Effect rack in the Audio tab —
part 02's board, the effects column): a lookahead limiter at −1 dB ceiling. It catches the
sum before it leaves the bus, so the mix can be loud without being red. (A Compressor
is the gentler sibling — it rides the level down dynamically; the Limiter is the hard floor
you want on a bus that has spikes. Start with the Limiter; the SFX bus's spikes are the
problem, and a limiter solves exactly that shape.)
The measurement the ceiling earns: with the limiter on, farm the same clump and the peak sits at −1 dB, every time — the red is gone and the mix is as loud as the limiter allows. Without it, the same clump is red on some machines and not others, and "not others" is the player's machine.
D — Size the pool with a number#
The pool's size is arithmetic, and the arithmetic is: how many sounds can be alive at once?
concurrent = rate × duration
Thirty hits a second × 0.2 s tails = 6 concurrent — and the pool has 8, so the headroom is 2, and a clump-death spike (stage 08: thirty golems in one swing) is 30 sounds in ~0.1 s = 30 concurrent for one frame, which the 8-voice pool will cut heavily. That spike is the honest finding: the steady state fits, the spike doesn't, and the spike's cuts land on a moment the player is looking at anyway (thirty deaths is a visual event; the audio is allowed to be a representative of the hits, not a transcription).
So the sizing procedure, the part to keep:
- Measure the steady rate (hits per second in normal play — the Monitor's frame counter and a damage-event counter, or the solo-SFX test from part 02 with your ears).
- Multiply by the longest tail in the mix.
- Add headroom for the designed spike — and decide, out loud, whether the spike is a transcription (pool it fully: 30 voices) or a representative (8 voices, cuts allowed).
- Re-measure after the first content change. The rate is not a constant; it's a function of
the weapons, and stage 07's
faster_swingcards change it by design.
A pool sized by taste is a pool that's wrong on the day the weapons get faster. A pool sized by the arithmetic is wrong predictably, and the predictability is what the cuts-sound decision in step 3 is for.
E — Commit#
git add . && git commit -m "audio 03: sound pool, SFX limiter, pool sizing arithmetic"
Checkpoint — definition of done#
- Thirty hits a second play through 8 voices with no flutter — the solo-SFX test hears continuous thocks, not a buzz (the part-01 single-player symptom, gone)
- The SFX bus peak sits at the limiter's ceiling under a clump, red before the limiter, clean after — the before/after is the measurement, run both
- The pool size is written down as
rate × duration + headroomwith your game's actual numbers — and the spike decision (transcription vs representative) is a sentence, not a vibe - A per-species hit (part 01's stretch) passes its stream into the shared pool — the worker is shared, the sound is data
- No
AudioStreamPlayeris a child of an enemy — the workers live with the pool, and the enemy count no longer schedules the mix - Zero warnings; committed
Stretch (no instructions)#
A priority voice: the player's own hurt sound (part 01's contact damage, stage 04) should never be the one cut. Give it a voice of its own (a 9th, dedicated, not in the round-robin) and say out loud why "the sound that tells you you are hit" is not allowed to be a representative. (The answer is the information column of the overview: a cut thock is missing colour; a cut hurt is missing a warning.)
If you get stuck#
- The pool cuts audibly at steady state (a flutter where there shouldn't be one) → the rate × duration arithmetic is wrong, usually because the duration is the file's length and not the tail you hear (a 0.5 s file with a 0.2 s audible tail needs a 0.2 in the math, and the 0.3 s of silent file is what's getting cut — cut the file's silence, or size to the file and accept the bigger pool).
- Adding the limiter made everything quieter → the limiter's ceiling is set and the bus's fader is too: the mix now tops at −1 dB and the bus fader at −6 is compounding. Raise the bus fader to 0 and let the limiter own the ceiling — one loudness authority per bus.
- One voice is always the one cut (the round-robin isn't rotating) →
_nextisn't wrapping (missing% _players.size(), so_nextwalks off the array and the "next voice" is voice 0 every time). The wrap is the scheduler; without it there's no pool, just a favourite. - The pool works in the editor and the cuts are different in the export → the export's audio latency and mix path differ from the editor's (the editor mixes through the OS device live; the export does too, but the timing of the dispatch relative to the frame shifts). Part 04 is where the timing argument lives; for now, size the pool with the export's measured rate, not the editor's.