Sound Map Tool — design
Sound Map Tool — design
Section titled “Sound Map Tool — design”Status: approved by Chris 2026-07-19, ready for implementation planning.
Problem
Section titled “Problem”audio/SOUND_MAP.md is a hand-maintained living audit of every gameplay event that should make
sound (88 tracked events across Weapons/Enemies/Bosses/Player/UI/Music) — what plays today, what
type of sound it needs, what’s still SILENT/GENERIC. It answers “is this wired?” but not “does
this sound actually fit the thing that makes it?” There’s no way to hear a sound paired with the
graphic that triggers it, no structured way to try multiple candidate takes before committing to
one, and no record of why a given sound was chosen over the alternatives once it’s live.
Chris wants a tool that shows each entity’s graphic next to a play button for its sound, lets him judge which sounds work and which don’t, and lets him build up a pool of candidate sounds per event (uploaded or sourced from ElevenLabs) so a bad choice is a quick swap, not a re-litigate.
- See every SFX-tied event’s actual in-game graphic next to a play button for its current sound.
- Full
SOUND_MAP.mdscope: Weapons, Enemies, Bosses, Player, UI, and Music (UI/Music rows get a placeholder glyph, not a baked render — see Non-goals). - Maintain a candidate pool per event: multiple takes, each with an optional uploaded file and/or a free-text comment (a generation brief for a human/Claude to act on via ElevenLabs).
- Assign a candidate as the live sound for an event, with the change written directly into the project (not just recorded for later application).
- Retire
audio/SOUND_MAP.mdin favour of a single committed JSON source of truth.
Non-goals
Section titled “Non-goals”- No live ElevenLabs API call triggered from the tool. The comment field is a generation brief for
a human/Claude to act on manually — the same shape as the existing
marketing/raw/elevenlabs_candidates/sourcing pattern (prompt → multiple downloaded takes → pick the best), just formalized per-event instead of ad hoc. - No automatic fix for GENERIC rows that share a sound because the underlying weapon/enemy code
emits a shared
fx_eventkind (e.g. Scythe and Melee both emit"slash"). The tool flags these as “shared at the source — needs a code split before it can get its own sound” rather than silently allowing an assignment that can’t actually take effect. Splitting the kind is agameplay-programmingtask (seedark-cosmos/gameplay-programming/ROLE.md). - No audio waveform editing/trimming inside the tool — play and swap whole files only.
- No changes to
sim/, determinism, or gameplay logic. This is render-adjacent tooling; the existing determinism baseline is untouched by construction.
Architecture
Section titled “Architecture”Four pieces, modeled on the existing tools/design-bible/ precedent (a dependency-free static web
app for browsing/editing game content, already proven in this repo):
tools/sound_map_bake/ Godot (windowed) — one-time/re-runnable thumbnail bake scripttools/sound_map/ Browser UI (static HTML/JS, mirrors design-bible's structure)tools/sound_map/server.py Small local backend: upload handling + write-backaudio/sound_map.json Committed manifest — the source of truthaudio/sound_map.json — data model
Section titled “audio/sound_map.json — data model”One entry per event, superseding SOUND_MAP.md’s table rows:
{ "schemaVersion": 1, "events": [ { "id": "weapon.pulse.bolt", // stable id, category.entity.event "category": "weapons", // weapons | enemies | bosses | player | ui | music "label": "Pulse (→ Tesla Coil)", "thumbnail": "thumbnails/weapon.pulse.bolt.png", "trigger": "bolt", // the fx_event kind/name string; determines which // dict a wire-new insert targets (see below) "sound_key": "pulse_zap", // null if SILENT (no key wired yet) "status": "done", // done | generic | silent | flagged_source_shared "sound_type": "zap-cast", // SOUND_MAP.md's existing taxonomy "shared_with": null, // event id this shares a source-level kind with, if flagged "candidates": [ { "id": "c1", "file": "candidates/weapon.pulse.bolt/c1.wav", // null if comment-only "label": "brighter crackle", "comment": "more high-end sizzle, shorter tail", "added": "2026-07-19" } ], "assigned_candidate": null // candidate id currently live, or null if sound_key's // existing file has never been replaced via the tool } ]}Generated once (initial import) from SOUND_MAP.md’s existing rows + audio_manager.gd’s
dictionaries, then hand-maintained via the tool from that point on — SOUND_MAP.md is retired
(replaced with a short pointer to this file + the tool) once the import is verified complete.
Thumbnail baking (tools/sound_map_bake/)
Section titled “Thumbnail baking (tools/sound_map_bake/)”Single windowed Godot process, one capture loop per category, modeled on
tools/ship_preview/preview.gd’s existing _do_bake() technique (reconfigure scene → wait ~16
frames for shaders/SubViewport to settle → grab the SubViewport texture → save_png()) rather
than launching Godot separately per entity:
| Category | What’s rendered |
|---|---|
| Weapons | The fire-visual effect itself (e.g. Pulse’s bolt), via the real weapon draw code |
| Enemies / Bosses | The entity’s ArchetypeRenderer shape at rest |
| Player | Reuse tools/ship_preview’s existing baked hull renders directly — no re-bake |
| UI | The actual ShopIcons-drawn glyph for that interaction |
| Music | A generated placeholder glyph (waveform/music-note) — not a fake baked render, since a loop isn’t tied to one entity |
Some renderers need a minimal live Sim to read from, the same way tools/funzo_lab stubs one for
boss VFX previews — not every category can be captured from static config alone.
Windowed-only, same constraint as ship_preview/ui_sweep/bg_preview (headless Godot can’t read
back _draw()/MultiMesh/shader output). Re-run whenever new content is added; not part of the
bh-dev-chunk per-change ritual.
Browser UI (tools/sound_map/)
Section titled “Browser UI (tools/sound_map/)”Grid of cards grouped by category (Weapons/Enemies/Bosses/Player/UI/Music, matching
SOUND_MAP.md’s existing section order), each card showing thumbnail, name, and a status badge
(done/generic/silent/flagged). A filter/sort bar (by status, category, or “has unreviewed
candidates”) — 88+ rows is too many to scroll blind.
Expanding a card shows:
- Play button for the current live sound (HTML5
<audio>). - Every candidate in the pool, each with its own play button, label, and comment.
- “+ Add candidate” — upload a local file (drag/drop →
server.pysaves it underaudio/candidates/<event-id>/) and/or a text-only comment (a generation brief with no file yet). - “Assign” per candidate — triggers write-back (see below). Disabled with an explanation for
flagged_source_sharedrows.
Write-back (server.py)
Section titled “Write-back (server.py)”A small local backend alongside the static file server (same “dependency-free” spirit as
design-bible’s python3 -m http.server, just with upload + assign routes added) — needed because
a pure static page can’t write files outside a browser download, and Chris wants assignment to
write directly into the project, not just stage a decision.
Two write-back modes, chosen automatically based on the event’s current status:
- Replace (status
done/generic,sound_keyalready exists) — overwriteaudio/<sound_key>.wavwith the chosen candidate’s file. No code edits. Shows up as a normal binary file change ingit status. - Wire new (status
silent, nosound_keyyet) — insert one new line intoaudio_manager.gd’sSOUND_FOR_FXorSOUND_FOR_NAMED_REACTIONdict literal (anchored insert immediately before the dict’s closing}, not a freeform rewrite), plus place the file at the new key’s path. Which dict follows the existing codebase convention automatically, not a manual choice: lowercasefx_eventkind strings (e.g."chalice_cast") go inSOUND_FOR_FX; ALL-CAPS named-reaction strings (e.g."BLINK") go inSOUND_FOR_NAMED_REACTION. The event’s trigger string (already recorded when the manifest was imported fromSOUND_MAP.md’s “Fire event”/ “Trigger” columns) determines this — no schema field needed. The edit is re-parsed after insertion to confirm the dict is still well-formed — fail loudly rather than silently corrupt.
Scope limit for both modes: server.py only ever touches audio/*.wav, audio/candidates/,
audio/sound_map.json, and (wire-new only) the two named dict literals in audio_manager.gd.
Never auto-commits — every write lands as an uncommitted diff for normal review.
flagged_source_shared rows (GENERIC because the emission site shares an fx_event kind, e.g.
Scythe/Melee both emitting "slash") get neither write-back mode — assignment is disabled with an
inline explanation that the kind needs splitting in code first.
Testing
Section titled “Testing”- A GUT test asserting every non-null
sound_keyinaudio/sound_map.jsonresolves to a real file underaudio/(catches drift if a key gets renamed/deleted outside the tool). - A manifest-schema check (every event has a category; every
silent-status event has a nullsound_key; everyflagged_source_sharedevent has a non-nullshared_with) — plain data validation, not GUT. - No sim/determinism impact by construction (render-adjacent tooling only) — no re-pin needed.
Process note — new dark-cosmos role
Section titled “Process note — new dark-cosmos role”While scoping this, it became clear the ongoing use of this tool (judging sound quality, curating
candidates) doesn’t fit any of the six existing dark-cosmos/ roles — it’s structurally identical
to art-direction’s “quality gate, not retrospective sweep” framing, but for audio. Added a
seventh role, dark-cosmos/audio-direction/ROLE.md, alongside this spec (see that file + the
updated dark-cosmos/README.md role table). Building the tool itself remains
gameplay-programming/content-pipeline work; using it going forward belongs to the new role.