SFX organic overhaul — implementation plan
SFX Organic Overhaul Implementation Plan
Section titled “SFX Organic Overhaul Implementation Plan”For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking. In this repo, also consultbh-sdd-concurrentbefore Task 1 andsdd-worktree-leak-guardafter every dispatch. Implementers arebh-implementeragents (model opus) in their own worktree under.claude/worktrees/<name>.
Goal: Make every Dark Cosmos SFX sound organic and non-fatiguing. Phase 0 is the mix foundation (loudness tiers, SFX bus chain, music ducking, a live A/B bank toggle). The tooling then drives five human-picked replacement batches.
Architecture: New sounds live in a parallel bank, audio/sfx_v2/. AudioManager resolves a key’s stream through _stream_for(key), which honours the live tunable AudioManager.SFX_BANK. SfxMix (a new render-side script) builds and configures the SFX/Stinger/Music bus effects at runtime from registered tunables, re-applied through a new LiveScope.HOOK_SFX_MIX. A stdlib+numpy Python tool (tools/sound_map/sfxlab.py) measures, normalises and gates files. A headless Godot tool (tools/sfx_freq/) ranks sound keys by real play frequency. The Sound Map tool gains v2 assignment and an ElevenLabs candidate generator.
Tech Stack: Godot 4.7 typed GDScript + GUT; Python 3.14 (numpy, scipy; no cgi); ffmpeg; ElevenLabs sound-generation REST API; node --test for the Sound Map frontend.
Spec: docs/superpowers/specs/2026-10-12-sfx-organic-overhaul-design.md
Global Constraints
Section titled “Global Constraints”- Render-side only: nothing under
/simchanges and the determinism baselines must not move. The only tool-side change touching a sim harness is Task 5’s optionalon_tickobserver intools/soak/soak_run.gd, which never draws RNG. - Every new tuning value is a registered Remote Console tunable (
static var+net/tunables_registry.gdentry + a label intools/tunable_docs/docs.d/*.json+ aLiveScopeentry).control/src/tunable_docs.jsis GENERATED (node tools/tunable_docs/generate.mjs); never hand-edit it. - Every SFX WAV stays PCM (
compress/mode=0in its.import), including everything underaudio/sfx_v2/.tests/test_audio_import_settings.gdalready walks subdirectories. - Loudness tiers (short-block LUFS, see Task 1): frequent −24, ui −22, telegraph −20, player −18, boss −16. Tolerance ±1.5 LU, unless the file is peak-limited at −1 dBFS (then it is allowed to sit below target).
- Candidate pre-filter: 2–6 kHz share ≤ 20%; centroid ≤ 3500 Hz (frequent tier) / ≤ 5000 Hz (others); crest ≥ 8 dB; frequent tier: attack (first sample ≥ 0.9 × peak) ≥ 2 ms.
- Bus effects do not run on web. That is accepted (store-first distribution, Chris 2026-10-12).
- New
docs/**/*.mdneed Starlighttitle:frontmatter. - After any
godot --importin a checkout:git checkout -- project.godot audio/bus_layout.tresbefore a clean-tree check. - Never pull a whole GUT run into context:
scripts/check-test-count.sh, then grep$TMPDIR/bh-gut-last.log(setGUT_LOG=<own path>in a worktree). - Merge rule:
scripts/gate.shgreen in the worktree,bh-reviewerMERGE, then merge +git push origin mainimmediately (docs/reference/builds-and-releases.md). - Commit messages via
git commit --file=<file>, ending with the session’s Co-Authored-By / Claude-Session lines.
File Structure
Section titled “File Structure”| File | Responsibility |
|---|---|
tools/sound_map/sfxlab.py (new) |
Measure (profile + short-block LUFS), normalise to a tier, check a directory against tiers/filters. CLI + importable. |
tools/sound_map/tests/sfxlab.test.py (new) |
unittest on synthetic signals. Run by scripts/gate.sh. |
audio/sfx_tiers.json (new) |
{sound_key: tier} for all 52 live keys, the one tier assignment. |
audio/sfx_v2/*.wav (+ .import) (new) |
The v2 bank. Seeded in Task 2 with normalised v1 copies; batches overwrite. |
audio/audio_manager.gd (modify) |
Bank resolution, round-robin variants, mastering-trim bypass, Stinger voices, SfxMix.install() call. |
audio/sfx_mix.gd (new) |
SfxMix: runtime bus/effect install + apply() of its tunables. |
net/tunables_registry.gd, net/live_scope.gd, main.gd (modify) |
Register tunables, HOOK_SFX_MIX, re-apply on live edit and at run start. |
tools/tunable_docs/docs.d/sfx_overhaul.json (new) |
Console labels for the new tunables. |
tools/soak/soak_run.gd (modify) |
Optional on_tick Callable observer. |
tools/sfx_freq/sfx_freq.gd (new) |
Headless play-frequency ranking → docs/research/2026-10-12-sfx-play-frequency.md. |
tools/sound_map/server.py, tools/sound_map/src/*.js, audio/sound_map.json (modify) |
v2 assign (with variant slot), v2 status field. |
tools/sound_map/gen_candidates.py (new) |
ElevenLabs takes → ffmpeg → filter → normalise → candidates in the manifest. |
tests/test_audio_bank.gd, tests/test_sfx_mix.gd (new) |
GUT coverage for bank/round-robin and the bus chain. |
Task 1: sfxlab.py — measure, normalise, check
Section titled “Task 1: sfxlab.py — measure, normalise, check”Files:
- Create:
tools/sound_map/sfxlab.py - Create:
tools/sound_map/tests/sfxlab.test.py - Modify:
scripts/gate.sh(thefor t in tools/sound_map/tests/server.test.py ...loop, line ~66)
Interfaces:
-
Produces:
read_mono(path) -> (sr:int, x:np.ndarray float64 in [-1,1])write_mono(path, sr, x)(int16 PCM)loudness(x, sr) -> float(short-block LUFS)profile(x, sr) -> dictwith keysdur, centroid, harsh, low, rms_db, peak_db, crest, attack_ms, flat, lufsTIERS = {"frequent": -24.0, "ui": -22.0, "telegraph": -20.0, "player": -18.0, "boss": -16.0}normalize(x, sr, target) -> (y, limited:bool)filter_problems(p, tier) -> list[str]tier_problems(p, tier) -> list[str]- CLI:
python3 tools/sound_map/sfxlab.py profile <wav...>|normalize <in> <out> <tier>|check <dir> [--filters-for k1,k2]
-
Step 1: Write the failing tests (
tools/sound_map/tests/sfxlab.test.py)
#!/usr/bin/env python3import os, sys, tempfile, unittestimport numpy as npsys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))import sfxlab
SR = 44100def sine(freq=997.0, amp=0.1, dur=1.0): t = np.arange(int(SR * dur)) / SR return amp * np.sin(2 * np.pi * freq * t)
def noise_burst(dur=0.5, seed=1): rng = np.random.default_rng(seed) n = int(SR * dur) env = np.minimum(np.arange(n) / (0.01 * SR), 1.0) * np.exp(-np.arange(n) / (0.12 * SR)) # pink-ish: integrate white noise lightly so energy sits low-mid, not in 2-6 kHz w = rng.standard_normal(n) pink = np.cumsum(w); pink -= np.convolve(pink, np.ones(200) / 200, mode='same') pink /= np.abs(pink).max() return 0.5 * pink * env
class Loudness(unittest.TestCase): def test_minus20dbfs_997hz_sine_reads_minus23_lufs(self): self.assertAlmostEqual(sfxlab.loudness(sine(amp=0.1), SR), -23.0, delta=0.3)
def test_6db_more_amplitude_is_6lu_louder(self): a = sfxlab.loudness(sine(amp=0.1), SR); b = sfxlab.loudness(sine(amp=0.2), SR) self.assertAlmostEqual(b - a, 6.02, delta=0.1)
def test_short_file_under_one_block_still_measures(self): self.assertTrue(np.isfinite(sfxlab.loudness(sine(dur=0.05), SR)))
class Normalize(unittest.TestCase): def test_hits_target(self): y, limited = sfxlab.normalize(noise_burst(), SR, -24.0) self.assertFalse(limited) self.assertAlmostEqual(sfxlab.loudness(y, SR), -24.0, delta=0.2)
def test_peak_limits_instead_of_clipping(self): x = np.zeros(SR // 2); x[100] = 1.0 # one spike: huge crest, can't reach -16 under -1 dBFS y, limited = sfxlab.normalize(x, SR, -16.0) self.assertTrue(limited) self.assertLessEqual(np.abs(y).max(), 10 ** (-1 / 20) + 1e-6)
class Filters(unittest.TestCase): def test_pure_tone_fails_crest(self): p = sfxlab.profile(sine(amp=0.5, freq=1000.0, dur=0.4), SR) self.assertTrue(any('crest' in m for m in sfxlab.filter_problems(p, 'player')))
def test_soft_noise_burst_passes(self): p = sfxlab.profile(noise_burst(), SR) self.assertEqual(sfxlab.filter_problems(p, 'frequent'), [])
def test_bright_tone_fails_harsh_and_centroid(self): rng = np.random.default_rng(2) x = sine(freq=4000.0, amp=0.3, dur=0.4) * (1 + 0.5 * rng.standard_normal(int(SR * 0.4))) msgs = sfxlab.filter_problems(sfxlab.profile(x, SR), 'frequent') self.assertTrue(any('2-6k' in m for m in msgs)); self.assertTrue(any('centroid' in m for m in msgs))
def test_tier_check_tolerance_and_limited_exception(self): y, _ = sfxlab.normalize(noise_burst(), SR, -20.0) self.assertEqual(sfxlab.tier_problems(sfxlab.profile(y, SR), 'telegraph'), []) self.assertTrue(sfxlab.tier_problems(sfxlab.profile(y, SR), 'boss')) # 4 LU low, not limited
class RoundTrip(unittest.TestCase): def test_write_read(self): with tempfile.TemporaryDirectory() as d: p = os.path.join(d, 'a.wav'); sfxlab.write_mono(p, SR, noise_burst()) sr, x = sfxlab.read_mono(p) self.assertEqual(sr, SR); self.assertLessEqual(np.abs(x).max(), 1.0)
if __name__ == '__main__': unittest.main()-
Step 2: Run, expect FAIL —
python3 tools/sound_map/tests/sfxlab.test.py→ModuleNotFoundError: No module named 'sfxlab'. -
Step 3: Implement
tools/sound_map/sfxlab.py
#!/usr/bin/env python3"""SFX lab: measure, normalise and gate Dark Cosmos SFX (spec 2026-10-12 sfx organic overhaul).
Loudness is BS.1770 K-weighted, gated mean square, but over 100 ms blocks (75% overlap) instead of400 ms: most SFX are 0.3-0.8 s, too short for standard integrated LUFS to be meaningful. A fileshorter than one block is measured whole. Numbers are comparable within this tool, not to abroadcast meter on long programme."""import json, os, sysimport numpy as npfrom scipy.io import wavfilefrom scipy.signal import lfilter
TIERS = {"frequent": -24.0, "ui": -22.0, "telegraph": -20.0, "player": -18.0, "boss": -16.0}TIER_TOL_LU = 1.5PEAK_CEIL_DB = -1.0HARSH_MAX = 0.20CENTROID_MAX = {"frequent": 3500.0}CENTROID_MAX_DEFAULT = 5000.0CREST_MIN_DB = 8.0ATTACK_MIN_MS_FREQUENT = 2.0
def read_mono(path): sr, x = wavfile.read(path) if x.dtype.kind == 'i': x = x.astype(np.float64) / float(np.iinfo(x.dtype).max) elif x.dtype.kind == 'u': x = (x.astype(np.float64) - 128.0) / 128.0 else: x = x.astype(np.float64) if x.ndim > 1: x = x.mean(axis=1) return sr, x
def write_mono(path, sr, x): y = np.clip(x, -1.0, 1.0) wavfile.write(path, sr, (y * 32767.0).astype(np.int16))
def _k_weight(x, sr): # pyloudnorm's BS.1770 coefficients, re-derived for any sample rate. G, Q, fc = 3.999843853973347, 0.7071752369554196, 1681.974450955533 A = 10 ** (G / 40); w0 = 2 * np.pi * fc / sr; al = np.sin(w0) / (2 * Q); c = np.cos(w0) b = [A * ((A + 1) + (A - 1) * c + 2 * np.sqrt(A) * al), -2 * A * ((A - 1) + (A + 1) * c), A * ((A + 1) + (A - 1) * c - 2 * np.sqrt(A) * al)] a = [(A + 1) - (A - 1) * c + 2 * np.sqrt(A) * al, 2 * ((A - 1) - (A + 1) * c), (A + 1) - (A - 1) * c - 2 * np.sqrt(A) * al] x = lfilter(b, a, x) Q, fc = 0.5003270373238773, 38.13547087602444 w0 = 2 * np.pi * fc / sr; al = np.sin(w0) / (2 * Q); c = np.cos(w0) return lfilter([(1 + c) / 2, -(1 + c), (1 + c) / 2], [1 + al, -2 * c, 1 - al], x)
def loudness(x, sr): y = _k_weight(x, sr) blk = int(0.1 * sr); hop = max(blk // 4, 1) if len(y) <= blk: ms = [float(np.mean(y ** 2))] else: ms = [float(np.mean(y[i:i + blk] ** 2)) for i in range(0, len(y) - blk + 1, hop)] ms = np.array(ms) lk = -0.691 + 10 * np.log10(ms + 1e-20) ms = ms[lk > -70.0] if ms.size == 0: return -70.0 rel = -0.691 + 10 * np.log10(ms.mean()) - 10.0 gated = ms[(-0.691 + 10 * np.log10(ms + 1e-20)) > rel] return float(-0.691 + 10 * np.log10((gated if gated.size else ms).mean()))
def profile(x, sr): X = np.abs(np.fft.rfft(x)) ** 2; fr = np.fft.rfftfreq(len(x), 1 / sr); tot = X.sum() + 1e-20 pk = float(np.abs(x).max()) + 1e-12; rms = float(np.sqrt(np.mean(x ** 2))) + 1e-12 Xs = X[(fr > 50) & (fr < 12000)] + 1e-12 return { "dur": len(x) / sr, "centroid": float((fr * X).sum() / tot), "harsh": float(X[(fr > 2000) & (fr < 6000)].sum() / tot), "low": float(X[fr < 250].sum() / tot), "rms_db": 20 * np.log10(rms), "peak_db": 20 * np.log10(pk), "crest": 20 * np.log10(pk / rms), "attack_ms": float(np.argmax(np.abs(x) >= 0.9 * pk) / sr * 1000), "flat": float(np.exp(np.log(Xs).mean()) / Xs.mean()), "lufs": loudness(x, sr), }
def normalize(x, sr, target): gain = 10 ** ((target - loudness(x, sr)) / 20) y = x * gain ceil = 10 ** (PEAK_CEIL_DB / 20) pk = np.abs(y).max() if pk > ceil: return y * (ceil / pk), True return y, False
def filter_problems(p, tier): out = [] if p["harsh"] > HARSH_MAX: out.append("2-6k share %.0f%% > %.0f%%" % (p["harsh"] * 100, HARSH_MAX * 100)) cmax = CENTROID_MAX.get(tier, CENTROID_MAX_DEFAULT) if p["centroid"] > cmax: out.append("centroid %.0f Hz > %.0f" % (p["centroid"], cmax)) if p["crest"] < CREST_MIN_DB: out.append("crest %.1f dB < %.1f (tone-like / over-compressed)" % (p["crest"], CREST_MIN_DB)) if tier == "frequent" and p["attack_ms"] < ATTACK_MIN_MS_FREQUENT: out.append("attack %.1f ms < %.1f (click)" % (p["attack_ms"], ATTACK_MIN_MS_FREQUENT)) return out
def tier_problems(p, tier): target = TIERS[tier] if abs(p["lufs"] - target) <= TIER_TOL_LU: return [] if p["lufs"] < target and p["peak_db"] >= PEAK_CEIL_DB - 0.5: return [] # peak-limited: as loud as it can be without clipping return ["%.1f LUFS, tier %s wants %.1f +/- %.1f" % (p["lufs"], tier, target, TIER_TOL_LU)]
def _repo(): return os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
def load_tiers(): with open(os.path.join(_repo(), 'audio', 'sfx_tiers.json')) as f: return json.load(f)
def _base_key(stem, tiers): # round-robin variants "<key>_2".."<key>_9" share their base key's tier if stem in tiers: return stem head, _, tail = stem.rpartition('_') return head if tail.isdigit() and head in tiers else None
def check_dir(d, filters_for=()): tiers = load_tiers(); problems = [] for f in sorted(os.listdir(d)): if not f.endswith('.wav'): continue key = _base_key(f[:-4], tiers) if key is None: problems.append("%s: not in audio/sfx_tiers.json" % f); continue sr, x = read_mono(os.path.join(d, f)); p = profile(x, sr) problems += ["%s: %s" % (f, m) for m in tier_problems(p, tiers[key])] if key in filters_for: problems += ["%s: %s" % (f, m) for m in filter_problems(p, tiers[key])] return problems
def main(argv): cmd = argv[0] if argv else '' if cmd == 'profile': cols = ["dur", "centroid", "harsh", "low", "lufs", "peak_db", "crest", "attack_ms", "flat"] print("%-28s" % "file" + "".join("%10s" % c for c in cols)) for path in argv[1:]: sr, x = read_mono(path); p = profile(x, sr) print("%-28s" % os.path.basename(path)[:28] + "".join("%10.3f" % p[c] for c in cols)) return 0 if cmd == 'normalize': src, dst, tier = argv[1], argv[2], argv[3] sr, x = read_mono(src); y, limited = normalize(x, sr, TIERS[tier]); write_mono(dst, sr, y) print("%s -> %s (%s%s)" % (src, dst, tier, ", peak-limited" if limited else "")) return 0 if cmd == 'check': ff = [] if '--filters-for' in argv: ff = argv[argv.index('--filters-for') + 1].split(',') probs = check_dir(argv[1], ff) for m in probs: print("FAIL " + m) print("sfxlab check: %d problem(s)" % len(probs)) return 1 if probs else 0 print(__doc__); return 2
if __name__ == '__main__': sys.exit(main(sys.argv[1:]))-
Step 4: Run, expect PASS —
python3 tools/sound_map/tests/sfxlab.test.py→OK. Iftest_minus20dbfs_997hz_sine_reads_minus23_lufsis off by more than 0.3, the K-weighting coefficients were mistyped. Fix them; never loosen the delta. -
Step 5: Wire into the gate. In
scripts/gate.sh, extend the python loop list to includetools/sound_map/tests/sfxlab.test.py:
for t in tools/sound_map/tests/server.test.py tools/sound_map/tests/sfxlab.test.py tools/story_board/tests/server.test.py tools/economy_sim/tests/human_targets.test.py; doThe log name derives from the grandparent dir, so both sound_map scripts write sound_map-py.txt. Change the log path expression to "$log/$(basename "$t" .py).txt" so they don’t overwrite each other.
- Step 6: Commit —
git add tools/sound_map/sfxlab.py tools/sound_map/tests/sfxlab.test.py scripts/gate.shthen committools(audio): sfxlab measure/normalise/check for the SFX overhaul.
Task 2: Tier table + seed the v2 bank (Phase 0 loudness)
Section titled “Task 2: Tier table + seed the v2 bank (Phase 0 loudness)”Files:
- Create:
audio/sfx_tiers.json - Create:
audio/sfx_v2/<key>.wav× 52 (+ generated.import) - Modify:
tests/test_audio_import_settings.gd(loop-mode guard) - Create:
tests/test_sfx_bank_files.gd - Modify:
scripts/gate.sh(add thesfxlab checkstep)
Interfaces:
-
Consumes:
sfxlab.py normalize|check(Task 1). -
Produces:
audio/sfx_tiers.json(keys = the exact_ready()key list inaudio/audio_manager.gd+DEATH_LIGHT+DEATH_HEAVY);res://audio/sfx_v2/<key>.wavfor each. -
Step 1: Write
audio/sfx_tiers.json. These are the 52 live keys, verified againstAudioManager._ready()’s list on main at 3ea6797c.attackandenemy_hitare deliberately absent (orphans, never loaded).
{ "pulse_zap": "frequent", "melee_swish": "frequent", "nova_whoosh": "frequent", "beam_hum": "frequent", "turret_impact": "frequent", "scatter_explosion": "frequent", "blaster_fire": "frequent", "special_frost": "frequent", "drone_pulse": "frequent", "orbit_ambience": "frequent", "enemy_death_light_1": "frequent", "enemy_death_light_2": "frequent", "enemy_death_light_3": "frequent", "enemy_death_light_4": "frequent", "enemy_death_heavy_1": "frequent", "enemy_death_heavy_2": "frequent", "enemy_death_heavy_3": "frequent", "enemy_death_heavy_4": "frequent", "ui_tap": "ui", "enemy_ranged_fire": "telegraph", "enemy_lob": "telegraph", "enemy_dash_telegraph": "telegraph", "enemy_dash_release": "telegraph", "ghost_telegraph": "telegraph", "accumulator_warn": "telegraph", "aim_fire": "player", "warp_dash": "player", "level_up": "player", "king_hit": "player", "drone_summon": "player", "special_aegis": "player", "special_rally": "player", "special_stomp": "player", "special_freeze": "player", "special_barrage": "boss", "wave_start": "boss", "wave_clear": "boss", "victory": "boss", "defeat": "boss", "boss_swing_impact": "boss", "boss_missile_launch": "boss", "boss_missile_impact": "boss", "boss2_summon": "boss", "boss2_artillery_land": "boss", "boss2_cutter_beam": "boss", "funzo_zone": "boss", "funzo_jester": "boss", "funzo_confetti": "boss", "graviton_singularity": "boss", "graviton_pull": "boss", "eye_blink": "boss", "eye_laser": "boss"}- Step 2: Write the failing GUT test
tests/test_sfx_bank_files.gd:
extends GutTest# SFX overhaul (2026-10-12): the v2 bank must cover every tiered key, and the tier table must cover# every key AudioManager loads, so a new sound can't skip the loudness gate.
func _tiers() -> Dictionary: return JSON.parse_string(FileAccess.get_file_as_string("res://audio/sfx_tiers.json"))
func test_every_tiered_key_has_a_v1_and_v2_file() -> void: var t := _tiers() assert_eq(t.size(), 52, "52 live SFX keys") for k: String in t: assert_true(ResourceLoader.exists("res://audio/%s.wav" % k), "v1 %s" % k) assert_true(ResourceLoader.exists("res://audio/sfx_v2/%s.wav" % k), "v2 %s" % k)
func test_tier_values_are_known() -> void: for k: String in _tiers(): assert_has(["frequent", "ui", "telegraph", "player", "boss"], String(_tiers()[k]), k)
func test_orbit_loop_survives_in_v2() -> void: var s: AudioStreamWAV = load("res://audio/sfx_v2/orbit_ambience.wav") assert_ne(s.loop_mode, AudioStreamWAV.LOOP_DISABLED, "the orbit hum must still loop from the v2 bank")-
Step 3: Run, expect FAIL —
GUT_LOG=$TMPDIR/sfx-t2.log scripts/check-test-count.sh, thengrep -A3 test_sfx_bank_files $TMPDIR/sfx-t2.log. Expected: v2 files missing. -
Step 4: Seed the bank.
mkdir -p audio/sfx_v2python3 - <<'EOF'import json, subprocesst = json.load(open('audio/sfx_tiers.json'))for k, tier in t.items(): subprocess.run(['python3', 'tools/sound_map/sfxlab.py', 'normalize', f'audio/{k}.wav', f'audio/sfx_v2/{k}.wav', tier], check=True)EOFgodot --headless --path . --importThen edit audio/sfx_v2/orbit_ambience.wav.import: set edit/loop_mode=2 (Forward), edit/loop_begin=0, edit/loop_end=-1. Not 1: in Godot’s WAV importer 1 = Disabled (0 = Detect from WAV, 2 = Forward); the v1 audio/orbit_ambience.wav.import has 1 and only keeps sounding because set_orbit_active() restarts it, so don’t copy its value. Run godot --headless --path . --import again, then git checkout -- project.godot audio/bus_layout.tres.
-
Step 5: Run
python3 tools/sound_map/sfxlab.py check audio/sfx_v2→ expect0 problem(s). A file can fail the tier check here only through a normalise bug, since normalise is the only writer. Investigate rather than widen the tolerance. -
Step 6: Add the gate step in
scripts/gate.sh, after the tool-tests loop:
step "sfx v2 loudness tiers"if python3 tools/sound_map/sfxlab.py check audio/sfx_v2 --filters-for "$(python3 -c "import json;print(','.join(sorted({e['sound_key'] for e in json.load(open('audio/sound_map.json'))['events'] if e.get('v2')=='shipped' and e.get('sound_key')})))")" > "$log/sfxlab.txt" 2>&1; then tail -1 "$log/sfxlab.txt"; else grep FAIL "$log/sfxlab.txt" | head -20; fail=1; fi(With no shipped rows yet, --filters-for "" only applies the tier check. check_dir splits "" into [''], which matches no key.)
- Step 7: Run the tests, expect PASS, then commit
audio/sfx_tiers.json audio/sfx_v2 tests/test_sfx_bank_files.gd scripts/gate.sh—audio: v2 SFX bank seeded with tier-normalised copies (Phase 0 loudness).
Task 3: AudioManager — bank resolution, round-robin, mastering trims
Section titled “Task 3: AudioManager — bank resolution, round-robin, mastering trims”Files:
- Modify:
audio/audio_manager.gd(_ready,play,_play_ui,_start,_pick_sfx_voicecall sites, the three trim call sites,set_orbit_active) - Modify:
net/tunables_registry.gd(one entry afterAudioManager.WAVE_CLEAR_DB) - Create:
tools/tunable_docs/docs.d/sfx_overhaul.json - Create:
tests/test_audio_bank.gd
Interfaces:
-
Produces:
static var SFX_BANK: int = 2func _stream_for(key: String) -> AudioStreamfunc _rr_pick(key: String) -> Stringstatic func mastering_trim(db: float) -> floatconst V2_PATH := "res://audio/sfx_v2/%s.wav"const RR_MAX := 9
-
Step 1: Write the failing tests
tests/test_audio_bank.gd:
extends GutTest# SFX overhaul bank toggle + round-robin (spec 2026-10-12 §4, §6).
var am: AudioManager
func before_each() -> void: AudioManager.SFX_BANK = 2 am = AudioManager.new() add_child_autofree(am)
func after_each() -> void: AudioManager.SFX_BANK = 2
func test_bank2_resolves_the_v2_file() -> void: assert_eq(am._stream_for("level_up").resource_path, "res://audio/sfx_v2/level_up.wav")
func test_bank1_resolves_the_v1_file_live() -> void: AudioManager.SFX_BANK = 1 assert_eq(am._stream_for("level_up").resource_path, "res://audio/level_up.wav")
func test_missing_v2_falls_back_to_v1() -> void: am._streams_v2.erase("level_up") assert_eq(am._stream_for("level_up").resource_path, "res://audio/level_up.wav")
func test_play_uses_the_bank_stream() -> void: am.play("level_up") assert_eq(am._last_player.stream.resource_path, "res://audio/sfx_v2/level_up.wav")
func test_round_robin_never_repeats_back_to_back_and_covers_all() -> void: am._rr["blaster_fire"] = ["blaster_fire", "blaster_fire_2", "blaster_fire_3"] var seen := {} var last := "" for i in 300: var k := am._rr_pick("blaster_fire") assert_ne(k, last, "no immediate repeat at %d" % i) last = k seen[k] = true assert_eq(seen.size(), 3, "every variant plays")
func test_round_robin_is_bank2_only() -> void: am._rr["blaster_fire"] = ["blaster_fire", "blaster_fire_2"] AudioManager.SFX_BANK = 1 for i in 10: assert_eq(am._rr_pick("blaster_fire"), "blaster_fire")
func test_mastering_trims_apply_only_to_bank1() -> void: AudioManager.SFX_BANK = 1 assert_eq(AudioManager.mastering_trim(-6.0), -6.0) AudioManager.SFX_BANK = 2 assert_eq(AudioManager.mastering_trim(-6.0), 0.0)
func test_orbit_player_follows_the_bank() -> void: am.set_orbit_active(true) assert_eq(am._orbit_player.stream.resource_path, "res://audio/sfx_v2/orbit_ambience.wav") AudioManager.SFX_BANK = 1 am.set_orbit_active(true) assert_eq(am._orbit_player.stream.resource_path, "res://audio/orbit_ambience.wav") am.set_orbit_active(false)-
Step 2: Run, expect FAIL (
_stream_fornot found). -
Step 3: Implement in
audio/audio_manager.gd.
Add next to the other sound tunables (after WAVE_CLEAR_DB):
# SFX overhaul (2026-10-12, docs/superpowers/specs/2026-10-12-sfx-organic-overhaul-design.md): 2 = the# v2 bank (audio/sfx_v2/<key>.wav, falling back to v1 per key), 1 = the original files. Read per play# -> live; the console flips it mid-run for the old/new A/B.static var SFX_BANK: int = 2const V2_PATH := "res://audio/sfx_v2/%s.wav"const RR_MAX := 9 # round-robin variants <key>_2 .. <key>_9 (bank 2 only, discovered by file)
## OVERCHARGE/AEGIS/WAVE_CLEAR trims exist only because those v1 files were mastered hot; the v2## bank is tier-normalised, so they don't apply there.static func mastering_trim(db: float) -> float: return db if SFX_BANK == 1 else 0.0Add fields beside _streams:
var _streams_v2: Dictionary = {} # key (and "<key>_N" variants) -> v2 AudioStreamvar _rr: Dictionary = {} # key -> [key, key_2, ...] when v2 variants existvar _rr_last: Dictionary = {} # key -> variant played lastIn _ready(), inside the existing for k in keys: loop, after the v1 load:
var v2 := V2_PATH % k if ResourceLoader.exists(v2): _streams_v2[k] = load(v2) var variants: Array[String] = [k] for n in range(2, RR_MAX + 1): var vk := "%s_%d" % [k, n] if not ResourceLoader.exists(V2_PATH % vk): break _streams_v2[vk] = load(V2_PATH % vk) variants.append(vk) if variants.size() > 1: _rr[k] = variantsAdd the resolvers:
func _stream_for(key: String) -> AudioStream: if SFX_BANK == 2 and _streams_v2.has(key): return _streams_v2[key] return _streams.get(key)
## A variant of `key` for this play: random, never the one played last (bank 2 only).func _rr_pick(key: String) -> String: if SFX_BANK != 2 or not _rr.has(key): return key var v: Array = _rr[key] var i := randi() % v.size() if v[i] == _rr_last.get(key, "") and v.size() > 1: i = (i + 1) % v.size() _rr_last[key] = v[i] return v[i]Change play():
func play(key: String, volume_db: float = 0.0, pitch_mult: float = 1.0, priority: bool = false) -> void: if DevFlags.audio_sfx_off: return var stream := _stream_for(_rr_pick(key)) if stream == null: return var i := _pick_sfx_voice(priority, stream) var player: AudioStreamPlayer = _pool[i] _play_seq += 1 _voice_seq[i] = _play_seq _voice_priority[i] = 1 if priority else 0 _start(player, stream, volume_db, pitch_mult)Change _play_ui(key) the same way: resolve var stream := _stream_for(key), return on null, and pass stream into _start. Change _start(player, stream: AudioStream, volume_db, pitch_mult) to use the passed stream instead of _streams[key]. Grep _start( and _streams.has( across the file and update every caller; the music functions keep using _streams (music is unbanked).
Trim call sites: play("special_aegis", mastering_trim(AEGIS_SFX_DB)); play(SOUND_FOR_NAMED_REACTION[rname], mastering_trim(OVERCHARGE_SFX_DB), 1.0, true); _helper_at("wave_clear", mastering_trim(WAVE_CLEAR_DB)).
set_orbit_active: before the play/stop logic,
if active: var s := _stream_for("orbit_ambience") if s != null and _orbit_player.stream != s: _orbit_player.stream = s # set_stream stops the voice; the branch below restarts itand drop the stream assignment from _ready (leave the player created there).
- Step 4: Register the tunable in
net/tunables_registry.gdafter theWAVE_CLEAR_DBline:
# SFX overhaul (2026-10-12): old/new bank A/B. Read per play -> live. {"key": "AudioManager.SFX_BANK", "script": AUDIO, "name": "SFX_BANK", "min": 1, "max": 2},Create tools/tunable_docs/docs.d/sfx_overhaul.json:
{ "AudioManager.SFX_BANK": {"label": "SFX bank (old / new)", "desc": "1 = the original sound files, 2 = the overhauled bank (tier-normalised, new sounds as batches land). Flip mid-run to A/B.", "unit": "", "src": "code"}}Run node tools/tunable_docs/generate.mjs and regenerate the manifest (godot --headless --path . -s res://tools/remote_config/dump_manifest.gd, then git checkout -- project.godot audio/bus_layout.tres).
-
Step 5: Run the tests, expect PASS:
tests/test_audio_bank.gd, alltests/test_audio_*.gd,tests/test_tunables_registry.gd,tests/test_live_scope.gd, andnode --test control/test/*.test.mjs. Then run the full suite viascripts/check-test-count.sh. -
Step 6: Commit —
feat(audio): SFX_BANK v1/v2 toggle, round-robin variants, bank-aware mastering trims.
Task 4: SfxMix — bus chain, Space, Stinger ducking
Section titled “Task 4: SfxMix — bus chain, Space, Stinger ducking”Files:
- Create:
audio/sfx_mix.gd - Modify:
audio/audio_manager.gd(_ready: callSfxMix.install()first; reserve voices onStinger) - Modify:
net/tunables_registry.gd,net/live_scope.gd,main.gd - Modify:
tools/tunable_docs/docs.d/sfx_overhaul.json - Create:
tests/test_sfx_mix.gd
Interfaces:
- Consumes: none from earlier tasks (independent of the bank).
- Produces:
class_name SfxMixstatic func install() -> void(idempotent)static func apply() -> void- Bus
&"Stinger"(sends to&"SFX") - SFX bus effect indices
FX_LOW_CUT=0, FX_SHELF=1, FX_SOFTCLIP=2, FX_SPACE=3 - Music bus effect 0 =
AudioEffectCompressorwithsidechain = &"Stinger" LiveScope.HOOK_SFX_MIX := "sfx_mix"
The buses are built at runtime rather than in audio/bus_layout.tres because the import/boot steps rewrite that file (scripts/gate.sh header). Runtime install keeps one source of truth, and the test pins the result.
- Step 1: Write the failing tests
tests/test_sfx_mix.gd:
extends GutTest# SFX overhaul Phase 0 bus chain (spec §3). Values are tunables pushed live by SfxMix.apply().
func before_each() -> void: SfxMix.install() SfxMix.apply()
func _sfx() -> int: return AudioServer.get_bus_index(&"SFX")func _music() -> int: return AudioServer.get_bus_index(&"Music")
func test_install_is_idempotent_and_builds_the_chain() -> void: SfxMix.install() SfxMix.install() var b := _sfx() assert_eq(AudioServer.get_bus_effect_count(b), 4, "SFX: low cut, shelf, soft clip, space") assert_true(AudioServer.get_bus_effect(b, SfxMix.FX_LOW_CUT) is AudioEffectHighPassFilter) assert_true(AudioServer.get_bus_effect(b, SfxMix.FX_SHELF) is AudioEffectHighShelfFilter) assert_true(AudioServer.get_bus_effect(b, SfxMix.FX_SOFTCLIP) is AudioEffectDistortion) assert_true(AudioServer.get_bus_effect(b, SfxMix.FX_SPACE) is AudioEffectReverb) var st := AudioServer.get_bus_index(&"Stinger") assert_gt(st, -1, "Stinger bus exists") assert_eq(AudioServer.get_bus_send(st), &"SFX", "stingers get the same chain") var c := AudioServer.get_bus_effect(_music(), 0) assert_true(c is AudioEffectCompressor, "music ducker") assert_eq((c as AudioEffectCompressor).sidechain, &"Stinger") assert_eq(AudioServer.get_bus_effect_count(AudioServer.get_bus_index(&"UI")), 0, "UI stays dry")
func test_apply_pushes_live_values() -> void: var old := SfxMix.SHELF_DB SfxMix.SHELF_DB = -6.0 SfxMix.SPACE_WET_DB = -20.0 SfxMix.apply() var shelf: AudioEffectHighShelfFilter = AudioServer.get_bus_effect(_sfx(), SfxMix.FX_SHELF) assert_almost_eq(shelf.gain, db_to_linear(-6.0), 0.001) var rv: AudioEffectReverb = AudioServer.get_bus_effect(_sfx(), SfxMix.FX_SPACE) assert_almost_eq(rv.wet, db_to_linear(-20.0), 0.001) SfxMix.SHELF_DB = old SfxMix.SPACE_WET_DB = -14.0 SfxMix.apply()
func test_flags_toggle_effects() -> void: SfxMix.SPACE_ON = false SfxMix.DUCK_ON = false SfxMix.apply() assert_false(AudioServer.is_bus_effect_enabled(_sfx(), SfxMix.FX_SPACE)) assert_false(AudioServer.is_bus_effect_enabled(_music(), 0)) SfxMix.ENABLED = false SfxMix.apply() for i in 4: assert_false(AudioServer.is_bus_effect_enabled(_sfx(), i), "master switch %d" % i) SfxMix.ENABLED = true SfxMix.SPACE_ON = true SfxMix.DUCK_ON = true SfxMix.apply() assert_true(AudioServer.is_bus_effect_enabled(_sfx(), SfxMix.FX_SPACE))
func test_reserved_voices_play_on_the_stinger_bus() -> void: var am := AudioManager.new() add_child_autofree(am) for i in AudioManager.BOSS_RESERVED_VOICES: assert_eq(am._pool[i].bus, &"Stinger", "reserve voice %d" % i) assert_eq(am._pool[AudioManager.BOSS_RESERVED_VOICES].bus, &"SFX")
func test_hook_registered_for_every_mix_key() -> void: for k in ["SfxMix.ENABLED", "SfxMix.SHELF_DB", "SfxMix.SPACE_WET_DB", "SfxMix.DUCK_RATIO"]: assert_eq(LiveScope.tunable_hook(k), LiveScope.HOOK_SFX_MIX, k)-
Step 2: Run, expect FAIL (
SfxMixnot declared). -
Step 3: Implement
audio/sfx_mix.gd:
class_name SfxMixextends RefCounted## SFX overhaul Phase 0 (2026-10-12, docs/superpowers/specs/2026-10-12-sfx-organic-overhaul-design.md §3):## the SFX bus chain (low cut -> high shelf -> soft clip -> short dark reverb "Space"), the Stinger## bus boss cues play on, and the Music ducker keyed from it. Built at runtime (install) so the## import-rewritten bus_layout.tres isn't the source of truth; every value is a Remote Console## tunable pushed by apply() (LiveScope.HOOK_SFX_MIX). Bus effects don't run on web (accepted).
const FX_LOW_CUT := 0const FX_SHELF := 1const FX_SOFTCLIP := 2const FX_SPACE := 3
static var ENABLED: bool = true # master switch for the SFX chainstatic var SPACE_ON: bool = true # reverb A/Bstatic var DUCK_ON: bool = true # music ducking A/Bstatic var LOW_CUT_HZ: float = 60.0static var SHELF_HZ: float = 4000.0static var SHELF_DB: float = -3.0static var SOFTCLIP_DRIVE: float = 0.08static var SPACE_ROOM: float = 0.35static var SPACE_DAMP: float = 0.8static var SPACE_WET_DB: float = -14.0static var SPACE_PREDELAY_MS: float = 12.0static var DUCK_THRESHOLD_DB: float = -24.0static var DUCK_RATIO: float = 4.0static var DUCK_RELEASE_MS: float = 250.0
static func install() -> void: var sfx := AudioServer.get_bus_index(&"SFX") if sfx < 0: return if AudioServer.get_bus_effect_count(sfx) == 0: AudioServer.add_bus_effect(sfx, AudioEffectHighPassFilter.new(), FX_LOW_CUT) AudioServer.add_bus_effect(sfx, AudioEffectHighShelfFilter.new(), FX_SHELF) var d := AudioEffectDistortion.new() d.mode = AudioEffectDistortion.MODE_ATAN AudioServer.add_bus_effect(sfx, d, FX_SOFTCLIP) AudioServer.add_bus_effect(sfx, AudioEffectReverb.new(), FX_SPACE) if AudioServer.get_bus_index(&"Stinger") < 0: AudioServer.add_bus() var st := AudioServer.bus_count - 1 AudioServer.set_bus_name(st, &"Stinger") AudioServer.set_bus_send(st, &"SFX") var music := AudioServer.get_bus_index(&"Music") if music >= 0 and AudioServer.get_bus_effect_count(music) == 0: var c := AudioEffectCompressor.new() c.sidechain = &"Stinger" c.attack_us = 2000.0 AudioServer.add_bus_effect(music, c, 0)
static func apply() -> void: var sfx := AudioServer.get_bus_index(&"SFX") if sfx < 0 or AudioServer.get_bus_effect_count(sfx) < 4: return var lc: AudioEffectHighPassFilter = AudioServer.get_bus_effect(sfx, FX_LOW_CUT) lc.cutoff_hz = LOW_CUT_HZ var sh: AudioEffectHighShelfFilter = AudioServer.get_bus_effect(sfx, FX_SHELF) sh.cutoff_hz = SHELF_HZ sh.gain = db_to_linear(SHELF_DB) var d: AudioEffectDistortion = AudioServer.get_bus_effect(sfx, FX_SOFTCLIP) d.drive = SOFTCLIP_DRIVE var rv: AudioEffectReverb = AudioServer.get_bus_effect(sfx, FX_SPACE) rv.room_size = SPACE_ROOM rv.damping = SPACE_DAMP rv.dry = 1.0 rv.wet = db_to_linear(SPACE_WET_DB) rv.predelay_msec = SPACE_PREDELAY_MS for i in 4: AudioServer.set_bus_effect_enabled(sfx, i, ENABLED and (i != FX_SPACE or SPACE_ON)) var music := AudioServer.get_bus_index(&"Music") if music >= 0 and AudioServer.get_bus_effect_count(music) > 0: var c: AudioEffectCompressor = AudioServer.get_bus_effect(music, 0) c.threshold = DUCK_THRESHOLD_DB c.ratio = DUCK_RATIO c.release_ms = DUCK_RELEASE_MS AudioServer.set_bus_effect_enabled(music, 0, DUCK_ON)Verify the Godot 4.7 property names against the class reference (context7 or godot --doctool) before running: AudioEffectFilter.cutoff_hz/gain, AudioEffectDistortion.drive/mode, AudioEffectReverb.room_size/damping/dry/wet/predelay_msec, AudioEffectCompressor.threshold/ratio/release_ms/attack_us/sidechain. If one differs, fix the code, not the test intent.
-
Step 4: AudioManager wiring. At the very top of
_ready():SfxMix.install()thenSfxMix.apply(). In the pool loop, setp.bus = &"Stinger" if _i < BOSS_RESERVED_VOICES else &"SFX", and update theBOSS_RESERVED_VOICESdoc comment: “reserve voices sit on the Stinger bus, which ducks the music. A priority cue that spills onto a general voice (reserve full) plays undocked; accepted.” -
Step 5: Register tunables + hook.
-
net/tunables_registry.gd: addconst SFX_MIX := "res://audio/sfx_mix.gd"besideAUDIO, then entries:-
Bools (
"type": "bool"):ENABLED,SPACE_ON,DUCK_ON. -
Numeric with explicit bounds:
Name min max LOW_CUT_HZ20 200 SHELF_HZ1500 10000 SHELF_DB−12 3 SOFTCLIP_DRIVE0 0.5 SPACE_ROOM0 1 SPACE_DAMP0 1 SPACE_WET_DB−40 −3 SPACE_PREDELAY_MS0 80 DUCK_THRESHOLD_DB−48 0 DUCK_RATIO1 20 DUCK_RELEASE_MS20 1500
Keys are
"SfxMix.<NAME>". -
-
net/live_scope.gd:const HOOK_SFX_MIX := "sfx_mix" # SfxMix.apply() pushes values into the bus effects, plus oneTUNABLESrow per key:"SfxMix.SHELF_DB": [LIVE, HOOK_SFX_MIX, "pushed into the bus effect by SfxMix.apply()"],(all 14). -
main.gd_apply_live_config, after theHOOK_ENEMY_PROJbranch:if hooks.has(LiveScope.HOOK_SFX_MIX): SfxMix.apply(). -
main.gd: immediately after theif net_start: remote_config.apply_for_run({}) else: ...block (~line 1276), and after theremote_config.apply_for_run({})at ~4561, addSfxMix.apply() # tunables were just reset/re-applied for this run. -
tools/tunable_docs/docs.d/sfx_overhaul.json: a label/desc/unit for all 14. Examples:"SfxMix.SPACE_WET_DB": {"label": "SFX room: wet level", "desc": "How much of the short dark reverb that glues every sound into one space. Lower = drier.", "unit": "dB", "src": "code"},"SfxMix.DUCK_RATIO": {"label": "Music duck under boss cues: ratio", "desc": "How hard the music dips while a boss cue or stinger plays. 1 = no dip.", "unit": ":1", "src": "code"}. -
Regenerate:
node tools/tunable_docs/generate.mjs; the manifest dump as in Task 3.
-
-
Step 6: Run the tests, expect PASS:
tests/test_sfx_mix.gd,tests/test_audio_bus_layout.gd(Master still has exactly one effect), the registry and live-scope tests, the node control tests, then the full suite. -
Step 7: Windowed audio sanity check (the headless dummy driver never mixes). Run
godot --path . --rendering-method mobile, start a run, and confirm there are noAudioServererrors in the console. Thengit checkout -- project.godot audio/bus_layout.tres. -
Step 8: Commit —
feat(audio): SfxMix bus chain (low cut, shelf, soft clip, Space reverb) + Stinger music ducking, all live tunables.
Task 5: sfx_freq — rank sound keys by real play frequency
Section titled “Task 5: sfx_freq — rank sound keys by real play frequency”Files:
- Modify:
tools/soak/soak_run.gd(optionalon_tick) - Create:
tools/sfx_freq/sfx_freq.gd - Create:
docs/research/2026-10-12-sfx-play-frequency.md(tool output + batch lists) - Modify:
tests/test_soak_run.gd(observer test)
Interfaces:
-
Consumes:
AudioManager.consume(events, rect),SoakRuncfg keys (seed, ship, coop, max_ticks, content, invuln, player, upgrades, maximize, meta, profile, hard_tuning). -
Produces:
cfg["on_tick"]: Callable(sim: Sim), called after everysim.tick_single(...). It must not touch any RNG stream. -
Step 1: Failing test in
tests/test_soak_run.gd:
func test_on_tick_observer_sees_every_tick_and_keeps_determinism() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var base := {"seed": 7, "ship": "cobalt", "coop": false, "max_ticks": 300, "content": content, "invuln": true, "player": "skilled"} var a := SoakRun.new(base.duplicate()).execute() var n := [0] var cfg := base.duplicate() cfg["on_tick"] = func(_s: Sim) -> void: n[0] += 1 var b := SoakRun.new(cfg).execute() assert_eq(n[0], int(b.ticks_run), "called once per tick") assert_eq(a.digests, b.digests, "observer doesn't change the run")(If the report dict names the digest list differently, read the return { block in SoakRun.execute() and use its key.)
-
Step 2: Run, expect FAIL. Then implement in
SoakRun.execute():var on_tick: Callable = cfg.get("on_tick", Callable())before the loop, andif on_tick.is_valid(): on_tick.call(sim)right aftersim.tick_single(...). Run, expect PASS. -
Step 3: Write
tools/sfx_freq/sfx_freq.gd:
extends SceneTree# SFX overhaul (2026-10-12): which sound keys actually play most, through the REAL AudioManager# routing (caps, cooldowns, camera gating), in skilled-autopilot survival runs. DEV ONLY.# godot --headless --path . -s res://tools/sfx_freq/sfx_freq.gd -- --runs=4 --ticks=36000# Prints plays/min per sound key, most frequent first.
class CountingAudio extends AudioManager: var counts: Dictionary = {} func play(key: String, volume_db: float = 0.0, pitch_mult: float = 1.0, priority: bool = false) -> void: counts[key] = int(counts.get(key, 0)) + 1 super.play(key, volume_db, pitch_mult, priority)
func _init() -> void: var args := {} for a in OS.get_cmdline_user_args(): var kv := a.trim_prefix("--").split("=") args[kv[0]] = kv[1] if kv.size() > 1 else "1" var runs := int(args.get("runs", "4")) var ticks := int(args.get("ticks", "36000")) var content := ContentLoader.load_from_path("res://data/bible.json") var audio := CountingAudio.new() root.add_child(audio) var total_ticks := 0 for r in runs: var cfg := {"seed": 2000 + r, "ship": "cobalt", "coop": false, "max_ticks": ticks, "content": content, "invuln": false, "player": "skilled"} cfg["on_tick"] = func(sim: Sim) -> void: var view := Rect2(sim.player.pos - Vector2(640, 360), Vector2(1280, 720)) audio.consume(sim.fx_events, view) var rep := SoakRun.new(cfg).execute() total_ticks += int(rep.ticks_run) var minutes := float(total_ticks) / 3600.0 var keys := audio.counts.keys() keys.sort_custom(func(a, b): return audio.counts[a] > audio.counts[b]) print("sound_key\tplays/min\ttotal (%d runs, %.1f game-min)" % [runs, minutes]) for k in keys: print("%s\t%.1f\t%d" % [k, audio.counts[k] / minutes, audio.counts[k]]) quit(0)Boss and late cues only show up if runs reach them. If a 10-minute skilled run never meets a boss, add a second pass with "invuln": true and --ticks=72000 and report the two tables separately (frequency in mortal play vs. reachability).
-
Step 4: Run it (
godot --headless --path . --importfirst if the class cache is stale; wrap withperl -e 'alarm 900; exec @ARGV'), then writedocs/research/2026-10-12-sfx-play-frequency.md(Starlighttitle:frontmatter). Include the table and the five batch lists, built by this rule, in order:- Frequent-tier keys with ≥ 5 plays/min, plus
aim_fire. - Remaining weapon-fire keys (every value of
SOUND_FOR_FXfor aWEAPON_FIRE_KINDSkind). - The 11 synth-tone keys (
level_up ui_tap wave_start wave_clear victory defeat special_frost special_freeze special_rally special_aegis special_stomp), minus any already in batch 1 or 2. - Telegraph tier.
- Everything left (boss tier + stragglers).
Each batch must have ≤ 12 keys; split a larger one into a/b. Every one of the 52 keys appears exactly once.
- Frequent-tier keys with ≥ 5 plays/min, plus
-
Step 5: Commit —
tools(audio): sfx_freq play-frequency ranking + overhaul batch lists.
Task 6: Sound Map v2 assign + ElevenLabs candidate generator
Section titled “Task 6: Sound Map v2 assign + ElevenLabs candidate generator”Files:
- Modify:
tools/sound_map/server.py(_handle_assign,_assign_replace, the write-scope docstring) - Modify:
tools/sound_map/tests/server.test.py - Modify:
tools/sound_map/src/detail.js(+ its test) — showv2status and a “variant” selector on Assign - Modify:
audio/sound_map.json(add"v2": "pending"to every event with asound_key) - Create:
tools/sound_map/gen_candidates.py
Interfaces:
-
Consumes:
sfxlab.profile/filter_problems/normalize/read_mono/write_mono/load_tiers(Task 1). -
Produces:
POST /api/assign/<event>body{"candidate_id": "...", "bank": "v2", "variant": 1}. It writesaudio/sfx_v2/<sound_key>.wav(variant 1) oraudio/sfx_v2/<sound_key>_<n>.wav(n ≥ 2) and setsevent["v2"] = "picked". Omittingbankkeeps the legacy v1 replace behaviour.- CLI:
python3 tools/sound_map/gen_candidates.py <event_id> --prompt "<text>" [--takes 4] [--duration 0.8]
-
Step 1: Failing server tests, appended to the existing unittest class in
tools/sound_map/tests/server.test.py. Follow its existing temp-repo fixture pattern (read the file first; it already stubsREPO_ROOT):- Assign with
bank: "v2", variant: 1copies toaudio/sfx_v2/<key>.wav, leavesaudio/<key>.wavbyte-identical, and setsv2 == "picked". variant: 3writes<key>_3.wav.variant: 10→ 400 (RR_MAX is 9).variant: 0→ 400.- A
flagged_source_sharedevent still → 409.
- Assign with
-
Step 2: Implement. In
_handle_assign, after the candidate lookup:
if body.get('bank') == 'v2': variant = int(body.get('variant', 1)) if not 1 <= variant <= 9: self.send_error(400, 'variant must be 1..9') return name = event['sound_key'] if variant == 1 else f"{event['sound_key']}_{variant}" dst_dir = os.path.join(REPO_ROOT, 'audio', 'sfx_v2') os.makedirs(dst_dir, exist_ok=True) shutil.copyfile(os.path.join(REPO_ROOT, 'audio', candidate['file']), os.path.join(dst_dir, f'{name}.wav')) event['v2'] = 'picked' event['assigned_candidate'] = candidate_id save_manifest(manifest) self.send_response(200); self.send_header('Content-Type', 'application/json'); self.end_headers() self.wfile.write(json.dumps({'ok': True, 'file': f'sfx_v2/{name}.wav'}).encode()) returnAdd audio/sfx_v2/ to the docstring’s write scope. Keep ensure_ascii=False in save_manifest (memory: bullet-heaven-sound-map-tool).
- Step 3: Manifest field. Run this one-off with Python and commit the diff (
ensure_ascii=False,indent=2):
import jsonp = 'audio/sound_map.json'd = json.load(open(p))for e in d['events']: if e.get('sound_key'): e.setdefault('v2', 'pending')json.dump(d, open(p, 'w'), indent=2, ensure_ascii=False)open(p, 'a').write('\n')Update tools/sound_map/tests/schema.test.js to allow v2 ∈ {pending, candidates, picked, shipped}.
-
Step 4: Frontend. In
detail.js, showv2: <status>beside the status badge. The Assign button posts{candidate_id, bank: "v2", variant}, with a<select>of 1-3 defaulting to 1. Add adetail.test.jscase asserting that the request body includesbank: "v2". Live playback of the current sound should prefer/audio/sfx_v2/<key>.wavand fall back to/audio/<key>.wavon 404. -
Step 5:
gen_candidates.py:
#!/usr/bin/env python3"""Generate filtered, tier-normalised ElevenLabs candidate takes for one sound-map event. source ~/.secrets; python3 tools/sound_map/gen_candidates.py <event_id> --prompt "..." [--takes 4] [--duration 0.8]Each take: ElevenLabs sound-generation -> ffmpeg mono 44.1 kHz WAV + 15 ms fade-out -> sfxlab filter-> normalise to the key's tier -> audio/candidates/<event>/v2_<n>.wav + a manifest candidate whosecomment records the prompt and the metrics. Rejected takes are kept as v2_<n>_rejected.wav (not in themanifest) so a rejection can be audited. Never commits."""import argparse, json, os, subprocess, sys, tempfile, urllib.requestsys.path.insert(0, os.path.dirname(__file__))import sfxlabREPO = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))SUFFIX = "soft rounded attack, no harsh high frequencies, dry, no reverb, single hit, no music"
def generate(prompt, duration, out_mp3): key = os.environ.get('ELEVENLABS_API_KEY') if not key: sys.exit('ELEVENLABS_API_KEY not set (source ~/.secrets in the same command)') req = urllib.request.Request('https://api.elevenlabs.io/v1/sound-generation', method='POST', headers={'xi-api-key': key, 'Content-Type': 'application/json'}, data=json.dumps({'text': f'{prompt}, {SUFFIX}', 'duration_seconds': max(duration, 0.5), 'prompt_influence': 0.5}).encode()) with urllib.request.urlopen(req, timeout=120) as r, open(out_mp3, 'wb') as f: f.write(r.read())
def to_wav(src, dst, duration): fade_start = max(duration - 0.015, 0.0) subprocess.run(['ffmpeg', '-y', '-loglevel', 'error', '-i', src, '-ac', '1', '-ar', '44100', '-af', f'atrim=0:{duration},afade=t=out:st={fade_start}:d=0.015', dst], check=True)
def main(): ap = argparse.ArgumentParser() ap.add_argument('event'); ap.add_argument('--prompt', required=True) ap.add_argument('--takes', type=int, default=4); ap.add_argument('--duration', type=float, default=0.8) a = ap.parse_args() mp = os.path.join(REPO, 'audio', 'sound_map.json'); m = json.load(open(mp)) ev = next(e for e in m['events'] if e['id'] == a.event) tier = sfxlab.load_tiers()[ev['sound_key']] out_dir = os.path.join(REPO, 'audio', 'candidates', a.event); os.makedirs(out_dir, exist_ok=True) n0 = len([f for f in os.listdir(out_dir) if f.startswith('v2_')]) passed = 0 with tempfile.TemporaryDirectory() as tmp: for i in range(a.takes): n = n0 + i + 1 mp3 = os.path.join(tmp, f'{n}.mp3'); raw = os.path.join(tmp, f'{n}.wav') generate(a.prompt, a.duration, mp3); to_wav(mp3, raw, a.duration) sr, x = sfxlab.read_mono(raw); p = sfxlab.profile(x, sr) probs = sfxlab.filter_problems(p, tier) y, limited = sfxlab.normalize(x, sr, sfxlab.TIERS[tier]) name = f'v2_{n}' + ('_rejected' if probs else '') sfxlab.write_mono(os.path.join(out_dir, name + '.wav'), sr, y) print(f'{name}: centroid {p["centroid"]:.0f} Hz, 2-6k {p["harsh"]*100:.0f}%, crest {p["crest"]:.1f} dB' + (f' REJECTED: {"; ".join(probs)}' if probs else '')) if probs: continue passed += 1 ev['candidates'].append({'id': name, 'file': f'candidates/{a.event}/{name}.wav', 'label': 'v2', 'comment': f'{a.prompt} | centroid {p["centroid"]:.0f} Hz, 2-6k {p["harsh"]*100:.0f}%, crest {p["crest"]:.1f} dB' + (' | peak-limited' if limited else ''), 'added': None}) if passed: ev['v2'] = 'candidates' json.dump(m, open(mp, 'w'), indent=2, ensure_ascii=False); open(mp, 'a').write('\n') print(f'{passed}/{a.takes} passed for {a.event} ({tier})') return 0 if passed >= 2 else 3 # 3 = revise the prompt and regenerate (spec §4)
if __name__ == '__main__': sys.exit(main())Add audio/candidates/**/v2_*_rejected.wav to .gitignore (root-anchored), so rejects stay local for auditing and are never committed.
-
Step 6: One live smoke run against a real event, e.g.
weapon.blaster.firewith the spec’s weapon vocabulary. Record the ElevenLabs credits used: check the account usage page, or the response’scharacter-costheader if present (the subscription endpoint returned nothing for this key on 2026-10-11). Delete the smoke candidates afterwards, unless they’re kept as real batch 1 candidates. -
Step 7: Run all tool tests + gate, commit —
tools(sound_map): v2 bank assign with variants + ElevenLabs candidate generator with metric pre-filter.
Task 7: Ship Phase 0 (human gate)
Section titled “Task 7: Ship Phase 0 (human gate)”- Step 1: Merge Tasks 1–6 through
bh-reviewer→bh-merge-reviewed-branch, then push. - Step 2: Dev install on the ATV and/or iPhone (
bh-deploy, orbh-worktree-device-installfrom a worktree). - Step 3: With Chris in a live run, drive the A/B through the
dark-cosmos-consoleMCPsend_command(set_tunables). There must be a run in progress (sim == nullrejects). Verify by the on-device “Console: …” change note, not/status, which lags 10–20 s. In order:AudioManager.SFX_BANK1 ↔ 2 (loudness alone)SfxMix.ENABLEDoff ↔ onSfxMix.SPACE_ONSfxMix.DUCK_ONduring a boss- Tune
SPACE_WET_DB,SHELF_DBandDUCK_RATIOby ear
- Step 4: Check audio cost:
bh-telemetry→ theaud_usper-window values for the session, against the < 0.3 ms budget on ATV. - Step 5: Persist Chris’s tuned values as new defaults in code: edit the
static vardefaults, gate, merge. Publishing a remote patch instead needs his explicit go. - Step 6: Update memory
bullet-heaven-sound-effectswith Phase 0’s result and the batch lists’ location.
Batch runbook (repeat for batches 1–5)
Section titled “Batch runbook (repeat for batches 1–5)”This isn’t a code task. It’s the per-batch loop from spec §6, using the lists in docs/research/2026-10-12-sfx-play-frequency.md.
- Prompts. For each key in the batch, write a prompt per spec §4:
<material layer>, <motion/shape>, <warm synth tail>, using the family vocabulary (enemies: glass/crystal/brittle mineral; player weapons: air/plasma breath/taut string or metal resonance; bosses: deep stone/large metal/low organic groan; UI: soft wood/felt taps/glass chime; stingers: warm pad or bell swell). Pick--durationto match the v1 file (the profile table in spec §1), with a minimum of 0.5. - Generate:
source ~/.secrets; python3 tools/sound_map/gen_candidates.py <event> --prompt "...". Exit 3 means fewer than 2 takes passed: revise the prompt (usually darker material, “muffled”, “low”) and re-run. The per-key prompt log goes into the batch’s section of the research doc. - Chris picks in
python3 tools/sound_map/server.py→ http://localhost:8080/tools/sound_map/. For round-robin keys (deaths,blaster_fire,turret_impact,melee_swish,enemy_ranged_fire), he assigns variants 1–3. - Wire: a branch + worktree per batch. Commit the picked
audio/sfx_v2/*.wav(+.importaftergodot --headless --path . --import; re-applyloop_mode=2(Forward; 1 = Disabled in Godot’s WAV importer) fororbit_ambienceif it was replaced). Set each picked event’s"v2": "shipped"inaudio/sound_map.json. Runscripts/gate.sh; its sfxlab step now also applies the pre-filter to the shipped keys. Thenbh-reviewer. - Device A/B with
SFX_BANK1 ↔ 2. The verdict is merge, retune via tunables, or reject → step 2. Merge, push. - Stop rule after batch 1 (spec §7): if Chris judges batch 1 not organic enough, stop. Write a short addendum to the spec, switching stingers/UI to hand-layered sounds (a foley take + a filtered synth tail mixed in ffmpeg) before batch 2.
Cleanup after batch 5: delete the v1 files for the 52 keys, plus the orphans attack.wav and enemy_hit.wav (and their .import). Point AudioManager loads at sfx_v2 only: make SFX_BANK a no-op, or remove it with its registry/docs entries. Re-run the coverage tests and update audio/sound_map.json statuses. That is a normal reviewed branch.
Execution handoff
Section titled “Execution handoff”Written 2026-10-12 at the end of the planning session, for an agent with no conversation context.
- Chris’s decisions (brainstorm):
- Direction: “Physical + warm synth” (over musical/tuned, soft-analog-only and foley-forward).
- Review: “Batches + in-game A/B”.
- Approach A: mix foundation first, then frequency-ranked batches.
- He approved all 4 design sections and the spec, then said “go” to the plan.
- On web: “we will probably make it so we mainly guide people to download from a store rather than push the web version”, so web lacking the bus chain is accepted.
- Rejected alternatives:
- B (replace everything, mix last): picks would be judged on a dry, un-normalised mix.
- C (mix only): it can’t fix raw tones.
- A Space send bus: Godot has no aux sends, so it became an insert.
- A
bus_layout.tresedit: import rewrites that file, so the buses are built at runtime instead. - A new
sfx_bankconsole command: the existingset_tunablescovers it. - Telemetry-ranked batches: telemetry is aggregate only, so a headless count tool ranks them.
- Done already: spec committed (9b7b37af) and corrected in this commit (orphans, insert reverb, web, psy-music merged, bank via tunable). No code changes yet.
- Authorized: worktree implementation, gate, reviewer, and merge + push to main (standing repo rule).
- Needs Chris same-day:
- Device installs while he’s playing (Task 7)
- Any remote-config patch publish
- Each batch pick (his ears)
- The batch-1 stop rule
- Spend: ElevenLabs API generation for candidates is in scope (the standing sourcing method since 2026-07). The key’s quota endpoint returned nothing on 2026-10-11, so measure credits on the first Task 6 smoke run and tell Chris the per-batch cost before batch 1.
- Gotchas found while planning:
attack.wavandenemy_hit.wavare never loaded (orphans).AudioManager._streamsalso caches music, so the v2 bank uses a separate dict.orbit_ambienceloops via its.importloop_mode=2(Forward; 1 = Disabled in Godot’s WAV importer, whichtests/test_sfx_bank_files.gdrejects), so a fresh v2.importloses that unless it’s re-applied.- Tier normalisation can’t lift high-crest boss hits to −16 without clipping. Peak-limited files at −1 dBFS are accepted below target.
- Short SFX make standard integrated LUFS meaningless, so
sfxlabuses 100 ms blocks.
- Concurrency: check
git log origin/mainandbh-sdd-concurrentbefore Task 1. Other sessions are active (HDR, learning autopilot, Rift Bolt detonation spec). - Next action: Task 1 (
sfxlab.py) in a new worktree.claude/worktrees/sfx-overhaulofforigin/main, execution mode subagent-driven.