Skip to content

SFX organic overhaul — design

Date: 2026-10-12 · Owner role: dark-cosmos/audio-direction · Status: approved design, plan next

Chris (2026-10-11): “we need to do serious work on the sfx — pretty much all need to be adjusted, need to sound more organic and less annoying.”

1. Why the current SFX are annoying (measured, not guessed)

Section titled “1. Why the current SFX are annoying (measured, not guessed)”

Profiled every audio/*.wav (spectral centroid, 2–6 kHz energy share, RMS, crest factor, attack time, spectral flatness):

  • 11 live files are raw synthesized test tones from the 2026-06-25 placeholder pass: level_up, ui_tap, wave_start, wave_clear, victory, defeat, special_frost (Chain — fires constantly), special_freeze, special_rally, special_aegis, special_stomp. A 12th, attack.wav, and enemy_hit.wav are orphans (nothing loads them); they are deleted in the cleanup, not replaced. Crest factor 0.7–7.9 dB (near-pure square/sine), RMS around −6 dB — the loudest files in the game. This is the beepy chiptune fatigue.
  • No loudness normalisation: RMS ranges −5.6 dB (victory) to −59.4 dB (funzo_confetti), a 54 dB spread. enemy_death_heavy_1 (−50 dB) and funzo_confetti are close to inaudible.
  • Bright high-frequency content on the most frequent sounds: enemy_death_heavy_* centroid 5.2–8.4 kHz, 33–43% of energy in the 2–6 kHz band (the ear’s fatigue band).
  • No shared acoustic space: the SFX bus is dry, with no EQ, saturation or reverb. Every sound sits separately, which reads as synthetic.

Physical + warm synth. Each sound is built on a real-material layer (glass, air, water, wood, metal resonance) with a soft filtered-synth tail for the neon feel, rounded transients, all in one shared reverb space. The reference points are Nova Drift and Hades. The SFX must carve space around the psytrance music set (branch psy-music, 14 area/boss tracks), which is mid-dense.

3. Phase 0 — mix foundation (no new assets)

Section titled “3. Phase 0 — mix foundation (no new assets)”

Loudness tiers. Each file is normalised with ffmpeg loudnorm, baked into the file, so in-code dB trims trend back to around 0. The targets are starting points, retuned by ear:

Tier Events Target
Frequent hits, deaths, pickups, auto-weapon fire −24 LUFS
UI menu taps, card select −22 LUFS
Telegraph enemy telegraphs + attacks −20 LUFS
Player action Rift Bolt, warp, level-up −18 LUFS
Boss / stinger boss cues, victory/defeat, wave cues −16 LUFS

SFX bus chain (each stage a registered Remote Console tunable, live mid-run):

  1. EQ: high shelf around 4 kHz at −3 dB, plus a low cut around 60 Hz (leaves the sub to the music’s kick).
  2. Soft-clip AudioEffectDistortion, low drive — for rounding, not crunch.
  3. Space: a short (~0.6 s), dark (heavy damping) AudioEffectReverb inserted on the SFX bus, dry 1.0, wet around −14 dB. It’s an insert rather than a send because a Godot bus routes to exactly one parent and has no aux sends. The UI bus stays dry.
  4. The existing Master AudioEffectHardLimiter stays as the safety net.

Boss low end (2026-10-12). The low cut no longer sits on the SFX bus: it lives on its own SfxMain bus (built by SfxMix.install()), which every non-boss SFX plays on and which sends to SFX. Boss-tier keys (audio/sfx_tiers.json “boss”) play on Stinger, which sends straight to SFX, so they bypass the low cut but still get the shelf, soft clip, Space and the SFX bus volume/mute. LOW_CUT_HZ therefore applies to non-boss SFX only. Chris: “boss sounds should stand out by having deep bassy elements”; ordinary SFX were “too boomy”.

Music ducking. Boss cues and stingers route through a Stinger bus that sidechain-keys an AudioEffectCompressor on the Music bus. Ordinary SFX do not duck the music (that would pump constantly).

Budget. Audio cost under 0.3 ms per audio window on ATV, measured with the existing per-window audio-cost telemetry. Bus effects do not run on web (Web Audio sample playback), so web gets the loudness normalisation and the new assets but none of the bus chain. That is accepted, not a bug.

Prompt template: <material layer>, <motion/shape>, <warm synth tail>, <constraints>. The constraints are always: “soft rounded attack, no harsh high frequencies, dry, no reverb, single hit, no music”. The takes are dry because the Space bus supplies the room. Example (enemy death):

small glass bead shattering softly into dust, short airy exhale, faint warm analog synth sigh underneath, soft attack, dry, no harsh highs

Material vocabulary per family, so the roster feels related:

Family Materials
Enemies glass, crystal, brittle mineral
Player weapons air, plasma breath, taut string / metal resonance
Bosses deep stone, large metal, low organic groan
UI soft wood / felt taps, glass chime
Stingers warm pad or bell swell — never a raw waveform

Low end is reserved for bosses (2026-10-12). Boss takes carry a deep sub-bass layer plus an audible low-mid body (80–250 Hz), so they still read as heavy on TV and phone speakers that can’t reproduce the sub. Non-boss SFX keep a tight low end (prompt for it, and SfxMain’s low cut trims the rest), so the bottom of the mix belongs to the bosses.

Generation. The ElevenLabs sound-generation REST API (duration_seconds ≥ 0.5) produces 4 takes per event. Each is converted to mono 44.1 kHz WAV with a short fade-out and lands in audio/candidates/<event>/, which the Sound Map tool already reads.

Metric pre-filter (tools/sound_map/profile.py, promoted from the 2026-10-11 profiling script). A take is rejected before Chris hears it if any of these hold:

  • 2–6 kHz energy share > 20%
  • spectral centroid > 3.5 kHz (frequent tier) / > 5 kHz (others)
  • crest factor < 8 dB (tone-like or over-compressed)
  • attack lands in the first 2 ms (frequent tier only — a click)

Passing takes are loudness-normalised to their tier, so the comparison is like for like. If fewer than 2 takes pass, the prompt is revised and regenerated before Chris hears anything.

Round-robin variants. Sounds that fire several times per second (light and heavy deaths, blaster_fire, turret_impact, melee_swish, enemy_ranged_fire; pickups are dead in store play under AUTO_COLLECT_XP) get 3 variants played in a no-immediate-repeat rotation, on top of the existing pitch/volume jitter. In AudioManager a key can map to [key_1 .. key_n]. The death sounds already do this informally.

Order is by real play frequency. Telemetry only carries aggregate play counts (aud_plays), not per-sound ones, so a headless tool (tools/sfx_freq/) runs a skilled-autopilot survival soak through the real AudioManager routing and counts plays per sound key. The expected shape:

  1. Hits, deaths, pickups, blaster, Rift Bolt (+ round-robin)
  2. Auto-weapons, including the 5 that borrow another cue’s asset (railgun lance, chalice, thunder wake, wildfire, stasis web)
  3. The 12 synth-tone UI/stinger sounds
  4. Enemy telegraphs + attacks
  5. Bosses

The exact event list per batch is fixed in the implementation plan from the telemetry query.

Bank toggle. New sounds are written to audio/sfx_v2/<key>.wav, never over the v1 file. AudioManager resolves v2 when the registered tunable AudioManager.SFX_BANK == 2 (default) and falls back to v1 when no v2 file exists. It’s flipped live mid-run through the console’s existing set_tunables, with no new console command. Phase 0 seeds sfx_v2/ with tier-normalised copies of every v1 file, so the bank toggle A/Bs Phase 0 itself; each batch then overwrites its keys’ v2 files. The three mastering-compensation trims (OVERCHARGE_SFX_DB, AEGIS_SFX_DB, WAVE_CLEAR_DB, which exist only because those files were mastered hot) are ignored under bank 2. The v1 files are deleted only after the last batch is approved.

Per-batch loop:

  1. Generate → filter → normalise → load into the Sound Map tool.
  2. Chris picks a winner per event (or “none, regenerate” with a note).
  3. Winners are wired into sfx_v2/ on a batch branch in its own worktree, then scripts/gate.sh and a bh-reviewer pass.
  4. Dev install on ATV and/or iPhone. Chris plays with the console bank toggle and gives a verdict: merge / retune levels (console tunables, no rebuild) / reject specific events back to step 1.
  5. Merge, push.

Phase 0 ships the same way (branch, Dev install, console toggles, merge).

Tracking. audio/sound_map.json gains a per-event v2 field: pending / candidates / picked / shipped. The Sound Map tool shows it. No separate tracker.

Tests:

  • bank resolution + v1 fallback
  • round-robin never repeats back-to-back
  • every event marked shipped has a v2 file that passes the profile thresholds and sits within ±1.5 LU of its tier (runs in the gate)
  • bus layout pinned (buses present, effect order, tunables registered)
  • The AI takes may not reach “organic”. ElevenLabs is strong at foley textures and weaker at controlled synth tails. If batch 1 fails Chris’s ear, stop and switch the stingers/UI to hand-layered sounds (a foley take plus a filtered synth tail mixed in ffmpeg) rather than pushing through all 5 batches.
  • Web CPU/memory. The v2 bank roughly doubles SFX on disk (about 3 MB of PCM) until v1 is deleted, which is acceptable. The reverb cost on web is measured, and the Space bus can be bypassed.
  • Ducking vs the psytrance set (merged, c0e2aabb): tune the Music compressor against those tracks on device, not against the legacy loop.
  • Determinism: none. Everything here is render-side; /sim is untouched.

Music changes; new sound events (the silent rows); splitting shared fx kinds (the 8 flagged_source_shared rows — gameplay-programming follow-up); voice/dialogue.

  • Every done/generic event has a v2 sound Chris picked.
  • No file sits outside its loudness tier.
  • No synth-tone files remain, and the orphan files are deleted.
  • The v1 bank is deleted.
  • Audio cost is within budget on ATV.
  • Chris can play a 15-minute run without wanting to mute the SFX.