Skip to content

Sound Map Tool — design

Status: approved by Chris 2026-07-19, ready for implementation planning.

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.md scope: 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.md in favour of a single committed JSON source of truth.
  • 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_event kind (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 a gameplay-programming task (see dark-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.

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 script
tools/sound_map/ Browser UI (static HTML/JS, mirrors design-bible's structure)
tools/sound_map/server.py Small local backend: upload handling + write-back
audio/sound_map.json Committed manifest — the source of truth

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.

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.

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.py saves it under audio/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_shared rows.

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:

  1. Replace (status done/generic, sound_key already exists) — overwrite audio/<sound_key>.wav with the chosen candidate’s file. No code edits. Shows up as a normal binary file change in git status.
  2. Wire new (status silent, no sound_key yet) — insert one new line into audio_manager.gd’s SOUND_FOR_FX or SOUND_FOR_NAMED_REACTION dict 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: lowercase fx_event kind strings (e.g. "chalice_cast") go in SOUND_FOR_FX; ALL-CAPS named-reaction strings (e.g. "BLINK") go in SOUND_FOR_NAMED_REACTION. The event’s trigger string (already recorded when the manifest was imported from SOUND_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.

  • A GUT test asserting every non-null sound_key in audio/sound_map.json resolves to a real file under audio/ (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 null sound_key; every flagged_source_shared event has a non-null shared_with) — plain data validation, not GUT.
  • No sim/determinism impact by construction (render-adjacent tooling only) — no re-pin needed.

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.