Skip to content

SFX play frequency + overhaul batches (2026-10-12)

SFX play frequency + overhaul batches (2026-10-12)

Section titled “SFX play frequency + overhaul batches (2026-10-12)”

Task 5 of the SFX organic overhaul (spec docs/superpowers/specs/2026-10-12-sfx-organic-overhaul-design.md). Which of the 52 sound keys (audio/sfx_tiers.json) the player actually hears most, measured in headless skilled-autopilot survival runs through the real AudioManager routing: per-frame cap, retrigger cooldowns, camera gating of enemy cues, off-screen death handling. The order of the replacement batches comes from these counts.

Tool: tools/sfx_freq/sfx_freq.gd (dev only).

godot --headless --path . -s res://tools/sfx_freq/sfx_freq.gd -- --runs=4 --ticks=36000
# options: --invuln, --force_boss=warden|funzo|graviton|eye, --area=<id>, --seed0=<n> (default 2000)

How it counts: SoakRun’s on_tick observer feeds each tick’s sim.fx_events to a CountingAudio subclass of AudioManager (a 1280x720 view centred on the player), and counts every play() / UI play. It also mirrors the one-shots main.gd fires straight from sim state (warp = dash_cd edge, hurt = player_hard_hits change, one level_up per level gained, defeat on game over), so warp_dash, king_hit, level_up and defeat are counted at their real rate. orbit_ambience is a loop, not a one-shot, so it is reported as the share of ticks it sounds. The observer draws no RNG and does not mutate the sim (pinned by tests/test_soak_run.gd::test_on_tick_observer_sees_every_tick_and_keeps_determinism).

Ship cobalt, single player, no meta-shop state, player: skilled, seeds 2000-2003.

  • Mortal runs are short. With a fresh save the skilled autopilot dies in 0.7-1.9 min, so the mortal table is early-game only. The invuln pass (20 min per run) is the better picture of a full run’s mix, but it has no hits, so king_hit and defeat are 0 there.
  • The skilled autopilot’s weapon picks decide which weapon cues appear. Pulse (pulse_zap) and Turret (turret_impact) were never picked in any run, so they read 0 here. They are not dead code (sim/weapon_pulse.gd, sim/weapon_turret.gd emit them).
  • Weapon-load attenuation (set_weapon_load) only changes volume, not counts, so the tool skips it.
  • Menu/UI clicks (ui_tap), Story room clears (wave_clear) and dead paths (pickups, powerups NUKE/FREEZE) never happen in a survival soak.

cfg: --runs=4 --ticks=36000 (invuln off). All four runs died: 1.1 / 0.7 / 1.9 / 1.7 min, waves 3-5, levels 4-9, 5.4 game-min total.

sound key plays/min total
aim_fire 226.0 1212
blaster_fire 125.7 674
beam_hum 46.2 248
enemy_death_light_1 41.4 222
enemy_death_light_2 37.8 203
enemy_death_light_3 37.5 201
enemy_death_light_4 36.4 195
drone_pulse 25.9 139
warp_dash 25.0 134
enemy_ranged_fire 15.1 81
enemy_dash_telegraph 8.6 46
king_hit 8.4 45
enemy_dash_release 7.8 42
scatter_explosion 7.5 40
level_up 4.3 23
melee_swish 3.0 16
special_barrage 2.6 14
drone_summon 2.4 13
enemy_death_heavy_1 0.9 5
defeat 0.7 4
enemy_death_heavy_2 0.7 4
enemy_death_heavy_4 0.6 3
enemy_death_heavy_3 0.6 3
orbit_ambience (loop) 0.5% of ticks

Table B: reachability (full-length invuln runs)

Section titled “Table B: reachability (full-length invuln runs)”

cfg: --runs=4 --ticks=72000 --invuln. 4 x 20 min, waves 14-32, levels 53-61, 80 game-min total.

sound key plays/min total
aim_fire 279.9 22389
blaster_fire 200.8 16061
beam_hum 126.3 10102
enemy_ranged_fire 43.6 3486
warp_dash 36.7 2935
enemy_dash_telegraph 29.5 2362
drone_pulse 26.5 2123
enemy_dash_release 24.8 1980
special_barrage 17.2 1380
melee_swish 16.8 1340
enemy_death_light_1 14.2 1137
enemy_death_light_3 13.9 1114
enemy_death_light_2 13.8 1102
enemy_death_light_4 13.8 1101
nova_whoosh 9.7 779
scatter_explosion 8.8 707
level_up 2.8 226
boss_missile_launch 2.4 191
drone_summon 1.9 152
enemy_death_heavy_1 1.9 149
enemy_death_heavy_2 1.7 135
enemy_death_heavy_4 1.5 122
enemy_death_heavy_3 1.4 116
funzo_zone 0.7 53
ghost_telegraph 0.5 43
accumulator_warn 0.5 40
funzo_confetti 0.4 30
boss2_summon 0.3 22
funzo_jester 0.2 13
graviton_singularity 0.1 11
victory 0.1 9
wave_start 0.1 7
eye_laser <0.1 2
graviton_pull <0.1 1
eye_blink <0.1 1
orbit_ambience (loop) 1.0% of ticks

Table C: forced bosses (boss-cue reachability)

Section titled “Table C: forced bosses (boss-cue reachability)”

cfg: --runs=2 --ticks=10800 --invuln --force_boss=<boss> (2 x 3 min each). Boss-specific keys only; the background mix matches Table B’s early waves.

boss boss keys heard (plays/min)
warden special_barrage 24.0, boss_missile_launch 6.5, boss2_summon 0.5
funzo funzo_zone 29.7, funzo_jester 9.7, funzo_confetti 9.5
graviton none (a forced spawn skips the GRAVITON announce; no SINGULARITY within 3 min)
eye beam_hum 194.0, eye_laser 10.3, eye_blink 4.0

boss_swing_impact and boss_missile_impact (both sim/boss_warden.gd) never sounded even with a forced Warden: the swing hit and missile impacts did not land within these runs (or landed on an invuln pilot whose hit block is skipped). Not investigated further here.

pulse_zap, turret_impact, special_frost, enemy_lob, boss_swing_impact, boss_missile_impact, boss2_artillery_land, boss2_cutter_beam, wave_clear, ui_tap, special_freeze, special_rally, special_aegis, special_stomp. They still go in a batch below.

Rule, applied in order (frequency = the higher of Table A and Table B):

  1. Frequent-tier keys with >= 5 plays/min, plus aim_fire.
  2. Remaining weapon-fire keys (every SOUND_FOR_FX value for a WEAPON_FIRE_KINDS kind).
  3. The 11 synth-tone keys, minus any already in batch 1 or 2.
  4. Telegraph tier.
  5. Everything left (boss tier + stragglers), split 5a/5b by frequency to stay within 12.

Amendment (the spec’s approach is frequency-ranked; the batch-1 tier filter was only a proxy for that): plus any non-frequent-tier key at >= 10 plays/min in either pass, placed in the earliest batch with room. That pulls warp_dash (36.7/min) into batch 1, which fills it at 12, and special_barrage (17.2/min) into batch 2. Telegraph-tier keys above 10/min already have their own batch 4 and stay there.

key tier plays/min (A / B)
aim_fire player 226.0 / 279.9
blaster_fire frequent 125.7 / 200.8
beam_hum frequent 46.2 / 126.3
enemy_death_light_1 frequent 41.4 / 14.2
enemy_death_light_2 frequent 37.8 / 13.8
enemy_death_light_3 frequent 37.5 / 13.9
enemy_death_light_4 frequent 36.4 / 13.8
drone_pulse frequent 25.9 / 26.5
melee_swish frequent 3.0 / 16.8
nova_whoosh frequent 0 / 9.7
scatter_explosion frequent 7.5 / 8.8
warp_dash player 25.0 / 36.7 (amendment)
key tier plays/min (A / B) note
pulse_zap frequent 0 / 0 0 plays observed (Pulse never picked)
turret_impact frequent 0 / 0 0 plays observed (Turret never picked)
special_frost frequent 0 / 0 0 plays observed (drone chain zap + DevFlags-only Stasis Web; neither occurred)
eye_laser boss 0 / <0.1 also Railgun’s lance; 10.3/min with a forced Eye. Railgun use must survive the swap
special_barrage boss 2.6 / 17.2 amendment; generic reaction + Warden barrage/spiral bursts (24.0/min with a forced Warden)

special_frost already went in batch 2.

key tier plays/min (A / B) note
level_up player 4.3 / 2.8 also ui_buy
defeat boss 0.7 / 0 once per death
wave_start boss 0 / 0.1
victory boss 0 / 0.1
ui_tap ui 0 / 0 0 plays observed (menus only)
wave_clear boss 0 / 0 0 plays observed (Story room clear only)
special_freeze player 0 / 0 0 plays observed (FREEZE powerup, powerups off)
special_rally player 0 / 0 0 plays observed (Logistics drone OVERCHARGE)
special_aegis player 0 / 0 0 plays observed (Decoy Shield Burst)
special_stomp player 0 / 0 0 plays observed (NUKE powerup, powerups off)
key plays/min (A / B) note
enemy_ranged_fire 15.1 / 43.6
enemy_dash_telegraph 8.6 / 29.5 also the DASH! reaction
enemy_dash_release 7.8 / 24.8
ghost_telegraph 0 / 0.5
accumulator_warn 0 / 0.5
enemy_lob 0 / 0 0 plays observed

Batch 5a: stragglers, most-heard first (9)

Section titled “Batch 5a: stragglers, most-heard first (9)”
key tier plays/min (A / B)
king_hit player 8.4 / 0 (invuln)
boss_missile_launch boss 0 / 2.4
drone_summon player 2.4 / 1.9
enemy_death_heavy_1 frequent 0.9 / 1.9
enemy_death_heavy_2 frequent 0.7 / 1.7
enemy_death_heavy_3 frequent 0.6 / 1.4
enemy_death_heavy_4 frequent 0.6 / 1.5
orbit_ambience frequent loop: 0.5% / 1.0% of ticks
funzo_zone boss 0 / 0.7

warp_dash and special_barrage were moved out of this batch by the amendment above (batches 1 and 2). king_hit (8.4/min mortal) sits just under the 10/min line, so it stays here.

key plays/min (A / B) note
funzo_confetti 0 / 0.4
boss2_summon 0 / 0.3 also the FunZo / Eye arrival rise
funzo_jester 0 / 0.2
graviton_singularity 0 / 0.1 also the shared ENRAGE sting
graviton_pull 0 / <0.1
eye_blink 0 / <0.1
boss_swing_impact 0 / 0 0 plays observed
boss_missile_impact 0 / 0 0 plays observed
boss2_artillery_land 0 / 0 0 plays observed
boss2_cutter_beam 0 / 0 0 plays observed

Totals: 12 + 5 + 10 + 6 + 9 + 10 = 52, each key once.

Generated with tools/sound_map/gen_candidates.py using the new --suffix (replaces the default “soft rounded attack” suffix, which Chris’s “too weak” verdict pointed at), --sound-key (the enemy.any.death event has a null sound_key, so the death-light family needs the tier key passed in, which is also written into each candidate’s label) and --max-low 0.6 (rejects takes with more than 60% of their energy below 250 Hz). ElevenLabs charged 6 credits per 0.5 s take, 7 at 0.68 s and 8 at 0.75 s. Total: 450 credits, hitting the 450 cap exactly. Candidate WAVs are in audio/candidates/<event>/ and are not committed. Passing takes are in audio/sound_map.json as v2_<n> candidates.

Finding: ElevenLabs goes bimodal for short weapon SFX. Prompts asking for “punchy body” or “presence in the low-mids”, or naming “pressurised”/“barrel”/“hum”/“drone”, came back as near-pure sub-bass. The first aim_fire run had centroids of 12-158 Hz with 99% of the energy under 250 Hz, which is exactly Chris’s “too boomy”. These takes PASS the sfxlab pre-filter, because it has no low-end check, which is why --max-low was added. Prompts asking for “crisp” went the other way, to 2-6 kHz. The wording that worked was “muted”, “hollow”, “wooden”, “dull muffled”, “light”, together with “warm midrange, muted highs”. Negated bass words in the suffix (“no sub-bass, no boom”) did not stop the boom. Glass/crystal death prompts landed at a centroid above 8 kHz even with “muted highs”. Only “dull muffled crack” and “mineral pebble” passed.

Suffixes used:

  • S1 (aim1-3, blaster1): “clean defined transient, crisp midrange presence, small and tight, controlled low end, no sub-bass, no boom, no rumble, no long tail, no harsh fizz, dry, no reverb, single hit, no music”. aim1 used a “tight punchy body” variant, and aim3/blaster1 swapped “crisp midrange presence” for “warm midrange presence, muted highs”.
  • S2 (one-shots from blaster2 on): “clean defined transient, warm midrange, muted highs, small and light, short, dry, no reverb, single hit, no music”
  • W (swipes and whooshes): “clean defined onset, warm midrange, muted highs, light and tight, short, dry, no reverb, single swipe, no music”
  • L1 / L2 (beam_hum, a 0.5 s retriggered sustain): L1 = “warm midrange presence, muted highs, seamless sustained loop, steady even level from start to end, no attack, no fade, controlled low end, no sub-bass rumble, no fizz, dry, no reverb, no music”; L2 = “warm midrange, mellow highs, seamless sustained loop, steady even level, no attack, no fade, dry, no reverb, no music”
key event id final prompt (+ suffix) dur takes gen / passed credits
aim_fire weapon.aim.fire “muted hollow puff of warm plasma breath, short thock like a soft wooden tube, light taut string twang, tiny warm analog synth blip tail” + S1 (warm variant). Run 1 gave 3 sub-bass passes, which were manually moved to _rejected. Run 2 (“crisp”) gave 0/4. 0.5 12 / 3 (v2_9, v2_10, v2_12) 72
blaster_fire weapon.blaster.fire “light muted pluck of a taut metal string with a quick soft puff of air, short warm analog synth chirp tail” + S2. Run 1 (“compressed air from a hollow barrel”) gave 0/6, nearly all boom. Failed twice. 0.5 12 / 1 (v2_10) 72
beam_hum weapon.beam.beam “soft warm buzzing tone of a bowed taut metal string with breathy plasma air, mellow analog synth tone” + L2. Run 1 (“steady warm hum … drone” + L1) gave 0/4, all 100% below 250 Hz. Failed twice. 0.5 8 / 1 (v2_5) 48
enemy_death_light_1 enemy.any.death (--sound-key) “small frosted glass bead cracking with a dull muffled crunch into grit, soft breathy puff, warm analog synth sigh” + S2. Run 1 (“small glass bead cracking softly…”) gave 0/3, centroid above 9 kHz. Failed twice. 0.5 6 / 0 36
enemy_death_light_2 enemy.any.death (--sound-key) “brittle crystal shard snapping in two with a muted tinkle, short warm analog synth drop” + S2. Centroid above 9.6 kHz. Failed once; not retried (budget). 0.5 3 / 0 18
enemy_death_light_3 enemy.any.death (--sound-key) “small mineral pebble crunching to dust, soft breathy puff, warm hollow analog synth blip” + S2 0.5 3 / 1 (v2_9) 18
enemy_death_light_4 enemy.any.death (--sound-key) “thin glass shell popping with a dull muffled crack, brief sandy hiss, short warm analog synth pluck tail” + S2 0.5 3 / 1 (v2_10) 18
drone_pulse player.drone_attack_pulse “soft rounded pulse of warm plasma breath with a muted metal resonance ping, short warm analog synth blip” + S2. Not retried (budget). 0.55 4 / 1 (v2_3) 24
melee_swish weapon.melee.slash “short thin swish of air from a light reed flicked fast, soft cut, tiny warm analog synth tail” + W. Run 1 (“quick muted swipe of air past a taut blade…”) gave 0/6, nearly all boom. Failed twice. 0.5 10 / 1 (v2_8) 60
nova_whoosh weapon.nova.nova “soft expanding ring of warm plasma breath rushing outward, muted airy whoosh, warm analog synth bloom tail” + W 0.75 4 / 4 32
scatter_explosion weapon.scatter.scatter_fire “cluster of small muted plasma breath pops scattering outward, soft quick air puffs, short warm analog synth tail” + S2. All 4 peak 75-250 ms after onset, so they swell rather than hit; check by ear. 0.5 4 / 4 24
warp_dash player.dash “quick muted rush of air and warm plasma breath zipping past, taut string glide, warm analog synth sweep tail” + W 0.68 4 / 2 (v2_1, v2_3) 28

Round-robin coverage is short. blaster_fire and melee_swish have 1 passing take each, not the 3 variants needed. Death-light 1 and 2 have none. The next run should start from the wording that worked (“muted / hollow / dull muffled / mineral”) and needs roughly another 300 credits to fill blaster, melee and the death family to 3 takes or more each.

The manifest write in gen_candidates.py is now append-only. It re-reads sound_map.json at write time and replaces it atomically, because the Sound Map server was running from this worktree while Chris assigned picks. His picks (weapon.aim.fire = c3, weapon.blaster.fire = c1) survived the run. --max-low 0.6 stayed on. Every take cost 6 credits. Total: 300 credits (batch 1 total 750).

key event id prompt (+ suffix) takes gen / passed credits
enemy_death_light_1 enemy.any.death a: “muffled hollow knock of a small clay shell breaking apart, soft dusty puff, short warm analog synth sigh” + S2 → 1/4 (v2_18); b: “tiny light clay bead cracking with a dull muffled tick, soft dusty puff, short warm hollow analog synth sigh” → 0/4 (all > 5 kHz); c: “small clay shell breaking apart with a dull muffled crack, soft dusty puff, short warm analog synth sigh” → 0/4 (> 4 kHz) 12 / 1 72
enemy_death_light_2 enemy.any.death a: “dull muffled snap of a thin slate chip splitting, soft gritty scatter, short warm analog synth drop” + S2 → 1/4 (v2_22); b: “small thin slate flake snapping with a dull muffled crack, light gritty scatter, …” → 0/4 (> 6 kHz); c: “dull muffled mid-pitched snap of a slate chip splitting, …” → 0/4 (2-6 kHz 37-45%) 12 / 1 72
blaster_fire weapon.blaster.fire a: “muted hollow pluck of a taut metal string with a quick puff of warm plasma breath, short warm analog synth chirp tail” + S2 → 0/6 (5 boom); b: “muted pluck of a taut nylon string with a soft brushed puff of air, short warm analog synth chirp” + S3 (“clean quick onset, mid-pitched, warm midrange, muted highs, small and light, short, dry, no reverb, single hit, no music”) → 0/6 12 / 0 72
melee_swish weapon.melee.slash “short muted swish of air from a wooden rod flicked fast, soft hollow cut, tiny warm analog synth tail” + W 6 / 2 (v2_12, v2_14) 36
drone_pulse player.drone_attack_pulse same prompt as round 1 + S2 → 3 boom, 1 instant-onset 4 / 0 24
beam_hum weapon.beam.beam same prompt as round 1 + L2 4 / 0 24

Findings:

  • Size words flip the spectrum. “tiny / light / thin / small” sends a death prompt above 5 kHz. “Knock / hollow / clay” sends it to sub-bass. Clay and slate deaths sit between the two only about 1 time in 4.
  • The blaster spectrum is fixed, but the onset is now the blocker. Blaster round b had 4 of 6 takes at centroid 270-980 Hz with no boom. They were rejected only because their attack measured 0.0-0.4 ms (the sound starts at sample 0). drone v2_8 and beam v2_11 were rejected the same way. A 2-3 ms fade-in in to_wav would rescue these takes cheaply. It would change what the click filter measures, so it is a call for Chris and was not done here. The rejected files are still in audio/candidates/<event>/ to audit.

Coverage after round 2:

key passing takes
aim_fire 3
blaster_fire 1
beam_hum 1
death_light_1 1
death_light_2 1
death_light_3 1
death_light_4 1
drone_pulse 1
melee_swish 3
nova_whoosh 4
scatter_explosion 4
warp_dash 2

Coordinator ruling: a take that starts at full amplitude on sample 0 clicks in-game, so a 3 ms fade-in fixes a real defect; it does not game the filter, and Chris still judges every take by ear. to_wav now applies a 3 ms linear fade-in to every future take. The new gen_candidates.py <event> --rescue [--sound-key K] --max-low 0.6 makes no API call. It applies the fade-in to each existing *_rejected.wav and re-runs the pre-filter and tier normalisation. Each pass is written as <stem>_rescued.wav and appended as a candidate whose comment reads “rescued: 3 ms fade-in (from …)”. The rejected originals and all existing entries are left alone, and the manifest is re-read before the write. Takes rejected for boom, brightness or crest stay rejected, because the fade only changes the onset.

key event id rescued (source run) passing takes now
blaster_fire weapon.blaster.fire 7: v2_9, v2_11 (blaster2), v2_17 (round 2a), v2_20-23 (round 2b) 8
drone_pulse player.drone_attack_pulse 2: v2_4 (round 1), v2_8 (round 2) 3
enemy_death_light_4 enemy.any.death 1: v2_11 (death4 run) 2
beam_hum weapon.beam.beam 0 (v2_11 still fails crest 8.0) 1
aim_fire, melee_swish, nova_whoosh, scatter_explosion, warp_dash, death_light_1-3 0 (no attack-only rejections) unchanged

Explosions batch (2026-10-12): player_explosion, boss_explosion

Section titled “Explosions batch (2026-10-12): player_explosion, boss_explosion”

Chris: new big-ship explosions that REPLACE the defeat/victory stingers on player and boss death. Both new keys are boss tier (audio/sfx_tiers.json). Cap 400 credits; spent 376.

  • Round 1 (244 credits, 0/8): “massive starship hull explosion … deep sub-bass boom … rumble” (3.0 s, 33 cr/take) and “colossal alien warship detonating, deep sub-bass … 80-250 Hz …” (2.5 s, 28 cr/take). Every take was pure sub (centroid ~40 Hz, -40 to -65 dB above 250 Hz): asking for sub-bass AND “muted highs” in one prompt yields sub only. Long takes cost ~11 credits/s, not 6/take.
  • Round 2 (132 credits, 3/6 passed): mid/high layer only, suffix “no bass, no rumble, … warm midrange, natural decay”, 2.0 s: “close-up fireball igniting with a sharp crack, roaring flames, crystal and metal shards splintering …” and “heavy armoured hull tearing apart, chain of crackling secondary blasts, metal plates groaning …”.
  • Hand-layered (spec §7, 0 credits): round-1 sub (lowpass 220 Hz, gain picked for ~80% <250 Hz share) under a round-2 mid take (highpass 120 Hz), onsets aligned by silence trim; 4 candidates per key (v2_8..11_layer), all pass. Lesson: for explosions, buy the sub and the mid/high as SEPARATE takes and layer them; one prompt can’t get both.