Skip to content

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 consult bh-sdd-concurrent before Task 1 and sdd-worktree-leak-guard after every dispatch. Implementers are bh-implementer agents (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

  • Render-side only: nothing under /sim changes and the determinism baselines must not move. The only tool-side change touching a sim harness is Task 5’s optional on_tick observer in tools/soak/soak_run.gd, which never draws RNG.
  • Every new tuning value is a registered Remote Console tunable (static var + net/tunables_registry.gd entry + a label in tools/tunable_docs/docs.d/*.json + a LiveScope entry). control/src/tunable_docs.js is GENERATED (node tools/tunable_docs/generate.mjs); never hand-edit it.
  • Every SFX WAV stays PCM (compress/mode=0 in its .import), including everything under audio/sfx_v2/. tests/test_audio_import_settings.gd already 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/**/*.md need Starlight title: frontmatter.
  • After any godot --import in a checkout: git checkout -- project.godot audio/bus_layout.tres before a clean-tree check.
  • Never pull a whole GUT run into context: scripts/check-test-count.sh, then grep $TMPDIR/bh-gut-last.log (set GUT_LOG=<own path> in a worktree).
  • Merge rule: scripts/gate.sh green in the worktree, bh-reviewer MERGE, then merge + git push origin main immediately (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 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 (the for 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) -> dict with keys dur, centroid, harsh, low, rms_db, peak_db, crest, attack_ms, flat, lufs
    • TIERS = {"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 python3
import os, sys, tempfile, unittest
import numpy as np
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
import sfxlab
SR = 44100
def 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 of
400 ms: most SFX are 0.3-0.8 s, too short for standard integrated LUFS to be meaningful. A file
shorter than one block is measured whole. Numbers are comparable within this tool, not to a
broadcast meter on long programme.
"""
import json, os, sys
import numpy as np
from scipy.io import wavfile
from scipy.signal import lfilter
TIERS = {"frequent": -24.0, "ui": -22.0, "telegraph": -20.0, "player": -18.0, "boss": -16.0}
TIER_TOL_LU = 1.5
PEAK_CEIL_DB = -1.0
HARSH_MAX = 0.20
CENTROID_MAX = {"frequent": 3500.0}
CENTROID_MAX_DEFAULT = 5000.0
CREST_MIN_DB = 8.0
ATTACK_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. If test_minus20dbfs_997hz_sine_reads_minus23_lufs is 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 include tools/sound_map/tests/sfxlab.test.py:

Terminal window
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; do

The 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.sh then commit tools(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 the sfxlab check step)

Interfaces:

  • Consumes: sfxlab.py normalize|check (Task 1).

  • Produces: audio/sfx_tiers.json (keys = the exact _ready() key list in audio/audio_manager.gd + DEATH_LIGHT + DEATH_HEAVY); res://audio/sfx_v2/<key>.wav for each.

  • Step 1: Write audio/sfx_tiers.json. These are the 52 live keys, verified against AudioManager._ready()’s list on main at 3ea6797c. attack and enemy_hit are 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, then grep -A3 test_sfx_bank_files $TMPDIR/sfx-t2.log. Expected: v2 files missing.

  • Step 4: Seed the bank.

Terminal window
mkdir -p audio/sfx_v2
python3 - <<'EOF'
import json, subprocess
t = 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)
EOF
godot --headless --path . --import

Then 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 → expect 0 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:

Terminal window
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_voice call sites, the three trim call sites, set_orbit_active)
  • Modify: net/tunables_registry.gd (one entry after AudioManager.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 = 2
    • func _stream_for(key: String) -> AudioStream
    • func _rr_pick(key: String) -> String
    • static func mastering_trim(db: float) -> float
    • const 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_for not 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 = 2
const 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.0

Add fields beside _streams:

var _streams_v2: Dictionary = {} # key (and "<key>_N" variants) -> v2 AudioStream
var _rr: Dictionary = {} # key -> [key, key_2, ...] when v2 variants exist
var _rr_last: Dictionary = {} # key -> variant played last

In _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] = variants

Add 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 it

and drop the stream assignment from _ready (leave the player created there).

  • Step 4: Register the tunable in net/tunables_registry.gd after the WAVE_CLEAR_DB line:
# 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, all tests/test_audio_*.gd, tests/test_tunables_registry.gd, tests/test_live_scope.gd, and node --test control/test/*.test.mjs. Then run the full suite via scripts/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: call SfxMix.install() first; reserve voices on Stinger)
  • 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 SfxMix
    • static 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 = AudioEffectCompressor with sidechain = &"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 (SfxMix not declared).

  • Step 3: Implement audio/sfx_mix.gd:

class_name SfxMix
extends 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 := 0
const FX_SHELF := 1
const FX_SOFTCLIP := 2
const FX_SPACE := 3
static var ENABLED: bool = true # master switch for the SFX chain
static var SPACE_ON: bool = true # reverb A/B
static var DUCK_ON: bool = true # music ducking A/B
static var LOW_CUT_HZ: float = 60.0
static var SHELF_HZ: float = 4000.0
static var SHELF_DB: float = -3.0
static var SOFTCLIP_DRIVE: float = 0.08
static var SPACE_ROOM: float = 0.35
static var SPACE_DAMP: float = 0.8
static var SPACE_WET_DB: float = -14.0
static var SPACE_PREDELAY_MS: float = 12.0
static var DUCK_THRESHOLD_DB: float = -24.0
static var DUCK_RATIO: float = 4.0
static 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() then SfxMix.apply(). In the pool loop, set p.bus = &"Stinger" if _i < BOSS_RESERVED_VOICES else &"SFX", and update the BOSS_RESERVED_VOICES doc 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: add const SFX_MIX := "res://audio/sfx_mix.gd" beside AUDIO, then entries:

      • Bools ("type": "bool"): ENABLED, SPACE_ON, DUCK_ON.

      • Numeric with explicit bounds:

        Name min max
        LOW_CUT_HZ 20 200
        SHELF_HZ 1500 10000
        SHELF_DB −12 3
        SOFTCLIP_DRIVE 0 0.5
        SPACE_ROOM 0 1
        SPACE_DAMP 0 1
        SPACE_WET_DB −40 −3
        SPACE_PREDELAY_MS 0 80
        DUCK_THRESHOLD_DB −48 0
        DUCK_RATIO 1 20
        DUCK_RELEASE_MS 20 1500

      Keys are "SfxMix.<NAME>".

    • net/live_scope.gd: const HOOK_SFX_MIX := "sfx_mix" # SfxMix.apply() pushes values into the bus effects, plus one TUNABLES row 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 the HOOK_ENEMY_PROJ branch: if hooks.has(LiveScope.HOOK_SFX_MIX): SfxMix.apply().

    • main.gd: immediately after the if net_start: remote_config.apply_for_run({}) else: ... block (~line 1276), and after the remote_config.apply_for_run({}) at ~4561, add SfxMix.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 no AudioServer errors in the console. Then git 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 (optional on_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), SoakRun cfg keys (seed, ship, coop, max_ticks, content, invuln, player, upgrades, maximize, meta, profile, hard_tuning).

  • Produces: cfg["on_tick"]: Callable(sim: Sim), called after every sim.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, and if on_tick.is_valid(): on_tick.call(sim) right after sim.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 . --import first if the class cache is stale; wrap with perl -e 'alarm 900; exec @ARGV'), then write docs/research/2026-10-12-sfx-play-frequency.md (Starlight title: frontmatter). Include the table and the five batch lists, built by this rule, in order:

    1. Frequent-tier keys with ≥ 5 plays/min, plus aim_fire.
    2. Remaining weapon-fire keys (every value of SOUND_FOR_FX for a WEAPON_FIRE_KINDS kind).
    3. 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.
    4. Telegraph tier.
    5. 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.

  • 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) — show v2 status and a “variant” selector on Assign
  • Modify: audio/sound_map.json (add "v2": "pending" to every event with a sound_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 writes audio/sfx_v2/<sound_key>.wav (variant 1) or audio/sfx_v2/<sound_key>_<n>.wav (n ≥ 2) and sets event["v2"] = "picked". Omitting bank keeps 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 stubs REPO_ROOT):

    • Assign with bank: "v2", variant: 1 copies to audio/sfx_v2/<key>.wav, leaves audio/<key>.wav byte-identical, and sets v2 == "picked".
    • variant: 3 writes <key>_3.wav.
    • variant: 10 → 400 (RR_MAX is 9).
    • variant: 0 → 400.
    • A flagged_source_shared event still → 409.
  • 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())
return

Add 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 json
p = '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, show v2: <status> beside the status badge. The Assign button posts {candidate_id, bank: "v2", variant}, with a <select> of 1-3 defaulting to 1. Add a detail.test.js case asserting that the request body includes bank: "v2". Live playback of the current sound should prefer /audio/sfx_v2/<key>.wav and fall back to /audio/<key>.wav on 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 whose
comment records the prompt and the metrics. Rejected takes are kept as v2_<n>_rejected.wav (not in the
manifest) so a rejection can be audited. Never commits.
"""
import argparse, json, os, subprocess, sys, tempfile, urllib.request
sys.path.insert(0, os.path.dirname(__file__))
import sfxlab
REPO = 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.fire with the spec’s weapon vocabulary. Record the ElevenLabs credits used: check the account usage page, or the response’s character-cost header 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.


  • 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, or bh-worktree-device-install from a worktree).
  • Step 3: With Chris in a live run, drive the A/B through the dark-cosmos-console MCP send_command (set_tunables). There must be a run in progress (sim == null rejects). Verify by the on-device “Console: …” change note, not /status, which lags 10–20 s. In order:
    1. AudioManager.SFX_BANK 1 ↔ 2 (loudness alone)
    2. SfxMix.ENABLED off ↔ on
    3. SfxMix.SPACE_ON
    4. SfxMix.DUCK_ON during a boss
    5. Tune SPACE_WET_DB, SHELF_DB and DUCK_RATIO by ear
  • Step 4: Check audio cost: bh-telemetry → the aud_us per-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 var defaults, gate, merge. Publishing a remote patch instead needs his explicit go.
  • Step 6: Update memory bullet-heaven-sound-effects with Phase 0’s result and the batch lists’ location.

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.

  1. 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 --duration to match the v1 file (the profile table in spec §1), with a minimum of 0.5.
  2. 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.
  3. 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.
  4. Wire: a branch + worktree per batch. Commit the picked audio/sfx_v2/*.wav (+ .import after godot --headless --path . --import; re-apply loop_mode=2 (Forward; 1 = Disabled in Godot’s WAV importer) for orbit_ambience if it was replaced). Set each picked event’s "v2": "shipped" in audio/sound_map.json. Run scripts/gate.sh; its sfxlab step now also applies the pre-filter to the shipped keys. Then bh-reviewer.
  5. Device A/B with SFX_BANK 1 ↔ 2. The verdict is merge, retune via tunables, or reject → step 2. Merge, push.
  6. 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.

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.tres edit: import rewrites that file, so the buses are built at runtime instead.
    • A new sfx_bank console command: the existing set_tunables covers 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.wav and enemy_hit.wav are never loaded (orphans).
    • AudioManager._streams also caches music, so the v2 bank uses a separate dict.
    • orbit_ambience loops via its .import loop_mode=2 (Forward; 1 = Disabled in Godot’s WAV importer, which tests/test_sfx_bank_files.gd rejects), so a fresh v2 .import loses 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 sfxlab uses 100 ms blocks.
  • Concurrency: check git log origin/main and bh-sdd-concurrent before 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-overhaul off origin/main, execution mode subagent-driven.