feat(@projects): add audio system integration

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-28 16:57:48 -04:00
parent 89784e2331
commit 0d26c271b3
10 changed files with 223 additions and 3 deletions

155
.project/audio-status.md Normal file
View file

@ -0,0 +1,155 @@
# Audio status — what's done, what's left
**Date:** 2026-04-28
**Objectives in flight:** `p2-33` (system extension, **done**), `p2-16` (asset acquisition, **in_progress** — code/tooling complete, asset curation 65 / 65 done, in-game smoke test pending).
## Tally
| Layer | State |
|---|---|
| Manifest schema (`audio.schema.json`) | ✓ ships — typed `streams[]`, `pitch_jitter`, `fallback`, `_silent` sentinel |
| `audio_manager.gd` extensions | ✓ ships — random variant pick, fallback chain, `play_for_entity`, 12 EventBus wires |
| Manifest entries (`audio.json`) | ✓ 49 SFX + 8 music tracks (added `defeat_stinger` + `defeat`) |
| Validator (`audio-validate.py`) | ✓ schema, asset coherence, orphan scan |
| License renderer (`audio-licenses-render.py`) | ✓ renders LICENSES.md, rejects -SA / -NC, allowlist gate |
| Batch driver (`audio-fetch-batch.sh`) | ✓ idempotent, supports direct URLs + `zip#inner` paths |
| `./run audio` family | ✓ status / fetch / fetch:NN / design / validate / render |
| Design app `/audio` page | ✓ resolution playground + manifest browser + mixer + music list, ▶ synth + ● real buttons |
| Vite glob hardening | ✓ `server.watch` + `server.fs.allow` so new files HMR cleanly |
| `.gitignore` for stray `.js` outputs | ✓ design-app source tree |
| **Real .ogg files on disk** | ✓ **65 / 65** — validator OK, no orphans, no missing |
| GUT test `test_audio_manager.gd` | ✓ 13 / 13 pass on apricot headless |
| Design doc + interactive page | ✓ `audio-system.md` + React app at `/audio` |
## Outstanding work (priority order)
### P0 — to close p2-16
1. **In-game audible smoke test on plum/apricot.**
Boot the live game (not the design app), fire each EventBus signal kind manually:
`turn_started` / `turn_ended` / `city_founded` / `city_grew` / `city_starved` /
`tech_researched` / `culture_researched` / `combat_started` / `combat_resolved` /
`unit_destroyed` / `wonder_built` / `era_changed` / `golden_age_started` /
`victory_achieved` / `player_eliminated` (defeat) / `wild_creature_spawned` /
`weather_event_applied`. Confirm each plays through the right bus
(Master / Music / SFX sliders affect them as expected).
*Why this is non-trivial*: requires a live game session with real
players and rendering, not headless. Output: a smoke-checklist
markdown at `.project/screenshots/audio-smoke-2026-XX-XX.md` with
timestamps + perceived-volume notes.
2. **Apricot deployment** — rsync new `.ogg` files to the run host so
the in-game session and any autoplay batches actually have audio.
Already covered by the existing `tools/deploy.sh` rsync (assets are
tracked); just run it.
3. **`p2-16` close-out**: flip status `in_progress → done` once the
smoke test lands + screenshot attached.
### P1 — quality-of-life follow-ups
4. **Bespoke melee variants per dwarf unit.** Categorical
`unit.melee.attack` covers everything; the `play_for_entity` ladder
already supports `<unit_id>.attack` if you want to give Spearmen vs
Halberdiers different swings later. One-line manifest entry per
bespoke voicing, no code change.
5. **Music swap on golden age.**
`audio_manager.gd::_on_golden_age_started` switches to the
`golden_age` track via `play_music`. On `golden_age_ended` it calls
`stop_music()`. Verify the era track resumes correctly on the next
`era_changed` (or proactively re-queue it). Two-line patch to
`_on_golden_age_ended`.
6. **Pre-roll defeat music as the final fade-out.** Right now
`defeat_stinger` and `defeat` music play simultaneously on
`player_eliminated`. Consider a 0.5 s gap so the stinger is heard
cleanly before the music swells. Trivial in
`_on_player_eliminated`.
7. **Loudness audit.** All files normalised at `loudnorm I=-16 TP=-3`
one-pass. For music tracks, two-pass loudnorm gives more accurate
integrated LUFS. Run `ffmpeg -af loudnorm=…:print_format=json` to
get measurement, then re-encode. Optional polish.
8. **Music transition crossfade on era change.** Already implemented
(`_crossfade_seconds = 2.0`). Verify it sounds right with the actual
Junkala tracks — they may need 4-5 s rather than 2.
9. **Credits screen entry.** `LICENSES.md` is the canonical source;
the player-facing credits screen (`scenes/menus/credits.gd`) should
pull author lines for CC-BY entries automatically. Currently we
have one CC-BY entry (Bogart VGM defeat music). Either render a
`credits-audio.json` from `sources.csv` or read it directly.
### P2 — wider integrations
10. **HUD entry points** for tech tree + culture tree (deferred from
the tree work). Currently the proof scenes open them directly; the
live HUD has no button or hotkey. Two scene edits + EventBus
`tech_tree_opened` / `culture_tree_opened`. About 30 LOC each.
11. **Per-instance pitch jitter on `unit_moved`.** The throttle
keeps it tame, but every footstep is identical. Adding
`pitch_jitter: 0.05` to that manifest entry would diversify it.
One-line change.
12. **Mod overrides**`user://overrides/audio.json` merged over the
theme manifest at `AudioManager.load_theme(…)`. Hook reserved in
p2-33 notes; ~10 LOC.
13. **Game 2 / Game 3 packs.** When Kzzykt and Elves ship,
`public/games/<theme>/data/audio.json` + `assets/audio/**` follow
the same shape. The runtime (`audio_manager.gd`) is already
theme-agnostic; the validator + renderer accept `--theme` so
re-running them on each pack works.
14. **Per-unit voice lines (post-EA exploration).** "Aye!" / "For the
Mountain!" — out of scope for launch but the per-entity
resolution ladder already supports
`<unit_id>.select` and `<unit_id>.command` keys when authoring
catches up.
### P3 — polish / not blocking launch
15. **Design app: download `.ogg` from page.** Right-click → save
works already, but a small "download" link next to each ● button
would let designers grab files for tweaking outside the pipeline.
Optional.
16. **Per-pillar weather mood.** Weather-event SFX is currently
one-shot. A persistent ambient layer that fades up when a storm is
over the camera would be richer; needs a new bus or stem layering
in `audio_manager.gd`. Out of scope for launch.
## Source-of-truth files
| File | Purpose |
|---|---|
| `public/games/age-of-dwarves/data/audio.json` | Canonical manifest (49 SFX + 8 music) |
| `public/games/age-of-dwarves/assets/audio/sources.csv` | Provenance ledger — 65 rows, all licences verified |
| `public/games/age-of-dwarves/assets/audio/LICENSES.md` | Auto-rendered from sources.csv; CI fails on hand-edit |
| `public/games/age-of-dwarves/assets/audio/{sfx,music}/**.ogg` | The shipped pack |
| `tools/audio-batch-{01..05}-*.tsv` | Per-pack mappings, idempotent |
| `tools/audio-fetch-batch.sh` / `audio-validate.py` / `audio-licenses-render.py` | Pipeline tools |
| `scripts/run/audio.sh` | `./run audio:*` family |
| `src/game/engine/src/autoloads/audio_manager.gd` | The engine (~430 LOC) |
| `src/game/engine/tests/unit/test_audio_manager.gd` | GUT cases (13 / 13 pass) |
| `.project/designs/audio-system.md` | Static design doc |
| `.project/designs/app/src/pages/AudioSystem.tsx` | Interactive design page |
| `.project/audio-sourcing-checklist.md` | Per-row punch list (now 100% closed for the 65-file pack) |
## Source provenance summary
| Pack | Licence | Files | Source page |
|---|---|---|---|
| Calinou/kenney-interface-sounds (GitHub) | CC0-1.0 | 11 | https://github.com/Calinou/kenney-interface-sounds |
| Kenney Impact Sounds | CC0-1.0 | 26 | https://kenney.nl/assets/impact-sounds |
| Junkala JRPG Pack 2 (Towns) | CC0-1.0 | 4 | https://opengameart.org/content/jrpg-pack-2-towns |
| Junkala JRPG Pack 5 (Action) | CC0-1.0 | 3 | https://opengameart.org/content/jrpg-pack-5-action |
| rubberduck "30 CC0 SFX loops" | CC0-1.0 | 6 | https://opengameart.org/content/30-cc0-sfx-loops |
| rubberduck "80 CC0 creature SFX" | CC0-1.0 | 12 | https://opengameart.org/content/80-cc0-creature-sfx |
| Bogart VGM "Game over man" | CC-BY-4.0 (1 attr line) | 1 | https://opengameart.org/content/gb-victory-stinger-and-game-over-man |
| Calinou kenney-interface-sounds (extras) | CC0-1.0 | 2 | https://github.com/Calinou/kenney-interface-sounds |
**Licence breakdown:** 64 × CC0-1.0 + 1 × CC-BY-4.0 (Bogart VGM, attribution recorded in `sources.csv` and rendered to `LICENSES.md`).

View file

@ -4,11 +4,30 @@ import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: { port: 7777 },
resolve: {
alias: {
"@game-data": path.resolve(__dirname, "../../../public/games/age-of-dwarves/data"),
"@game-assets": path.resolve(__dirname, "../../../public/games/age-of-dwarves/assets"),
},
},
server: {
port: 7777,
fs: {
// Allow Vite to serve files from the parent project tree (assets +
// data live outside the design app's own root).
allow: [
path.resolve(__dirname),
path.resolve(__dirname, "../../../public"),
],
},
watch: {
// import.meta.glob bakes its result at module-eval time. When new
// .ogg files appear under public/games/.../assets/audio after the
// module first loaded, the glob doesn't see them and the design
// page misses their play buttons. Telling Vite's watcher to track
// the assets dir triggers an HMR reload of the AudioSystem module
// when files are added/removed there, which re-evaluates the glob.
ignored: ["!**/public/games/age-of-dwarves/assets/audio/**"],
},
},
});

View file

@ -4,12 +4,13 @@
Each row records one `.ogg` shipped under `public/games/age-of-dwarves/assets/audio/`. Licence policy: CC0 / CC-BY 3.0 / CC-BY 4.0 / Pixabay / Sonniss-GDC-YYYY / Public-Domain accepted. ShareAlike (`-SA`) and NonCommercial (`-NC`) are rejected by the renderer.
**Asset count:** 68 files. (Empty until p2-16 sourcing begins.)
**Asset count:** 70 files. (Empty until p2-16 sourcing begins.)
## Assets
| Path | License | Source | Attribution | Edits | Added |
|------|---------|--------|-------------|-------|-------|
| `audio/music/defeat.ogg` | CC-BY-4.0 | [link](https://opengameart.org/sites/default/files/Game%20over%20man_0.mp3) | Bogart VGM (OpenGameArt) | loudnorm I=-16/TP=-3+mp3→ogg 128kbps | 2026-04-28 |
| `audio/music/golden_age.ogg` | CC0-1.0 | [link](https://opengameart.org/sites/default/files/JRPG%20Music%20Pack%20%235%20%5BAction%5D%20by%20Juhani%20Junkala.zip#Action2 - Army Approaching.ogg) | Juhani Junkala (SubspaceAudio | OpenGameArt) | loudnorm I=-16/TP=-3+ogg 128kbps |
| `audio/music/overworld_ascension.ogg` | CC0-1.0 | [link](https://opengameart.org/sites/default/files/JRPG%20Music%20Pack%20%235%20%5BAction%5D%20by%20Juhani%20Junkala.zip#Action1 - Encounter With The Witches.ogg) | Juhani Junkala (SubspaceAudio | OpenGameArt) | loudnorm I=-16/TP=-3+ogg 128kbps |
| `audio/music/overworld_awakening.ogg` | CC0-1.0 | [link](https://opengameart.org/sites/default/files/JRPG%20Music%20Pack%20%232%20%5BTowns%5D%20by%20Juhani%20Junkala.zip#Town1 - Home Town.ogg) | Juhani Junkala (SubspaceAudio | OpenGameArt) | loudnorm I=-16/TP=-3+ogg 128kbps |
@ -28,6 +29,7 @@ Each row records one `.ogg` shipped under `public/games/age-of-dwarves/assets/au
| `audio/sfx/combat/combat_started.ogg` | CC0-1.0 | [link](https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactBell_heavy_000.ogg) | Kenney (Impact Sounds) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-27 |
| `audio/sfx/combat_hit.ogg` | CC0-1.0 | [link](https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactMetal_medium_000.ogg) | Kenney (Impact Sounds) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-27 |
| `audio/sfx/culture_researched.ogg` | CC0-1.0 | [link](https://github.com/Calinou/kenney-interface-sounds/blob/master/addons/kenney_interface_sounds/confirmation_003.wav) | Kenney (Calinou repackage) | loudnorm I=-16/TP=-3+wav→ogg 128kbps | 2026-04-27 |
| `audio/sfx/defeat_stinger.ogg` | CC0-1.0 | [link](https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactPlate_heavy_004.ogg) | Kenney (Impact Sounds) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-28 |
| `audio/sfx/era_advanced.ogg` | CC0-1.0 | [link](https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactBell_heavy_003.ogg) | Kenney (Impact Sounds) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-27 |
| `audio/sfx/fauna/apex_attack.ogg` | CC0-1.0 | [link](https://opengameart.org/sites/default/files/80-CC0-creature-SFX_0.zip#monster_01.ogg) | rubberduck (OpenGameArt) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-27 |
| `audio/sfx/fauna/apex_death.ogg` | CC0-1.0 | [link](https://opengameart.org/sites/default/files/80-CC0-creature-SFX_0.zip#scream_02.ogg) | rubberduck (OpenGameArt) | loudnorm I=-16/TP=-3+ogg 128kbps | 2026-04-27 |

View file

@ -84,3 +84,5 @@ audio/sfx/fauna/apex_attack.ogg,https://opengameart.org/sites/default/files/80-C
audio/sfx/fauna/apex_death.ogg,https://opengameart.org/sites/default/files/80-CC0-creature-SFX_0.zip#scream_02.ogg,CC0-1.0,rubberduck (OpenGameArt),loudnorm I=-16/TP=-3+ogg 128kbps,2026-04-27
audio/sfx/fauna/herbivore_call.ogg,https://opengameart.org/sites/default/files/80-CC0-creature-SFX_0.zip#cute_05.ogg,CC0-1.0,rubberduck (OpenGameArt),loudnorm I=-16/TP=-3+ogg 128kbps,2026-04-27
audio/sfx/fauna/herbivore_death.ogg,https://opengameart.org/sites/default/files/80-CC0-creature-SFX_0.zip#hurt_04.ogg,CC0-1.0,rubberduck (OpenGameArt),loudnorm I=-16/TP=-3+ogg 128kbps,2026-04-27
audio/sfx/defeat_stinger.ogg,https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactPlate_heavy_004.ogg,CC0-1.0,Kenney (Impact Sounds),loudnorm I=-16/TP=-3+ogg 128kbps,2026-04-28
audio/music/defeat.ogg,https://opengameart.org/sites/default/files/Game%20over%20man_0.mp3,CC-BY-4.0,Bogart VGM (OpenGameArt),loudnorm I=-16/TP=-3+mp3→ogg 128kbps,2026-04-28

Can't render this file because it has a wrong number of fields in line 3.

View file

@ -60,6 +60,13 @@
"bus": "SFX",
"description": "Soft footstep tick on unit movement (throttled)."
},
"defeat_stinger": {
"stream": "audio/sfx/defeat_stinger.ogg",
"volume_db": -3.0,
"bus": "SFX",
"fallback": "_silent",
"description": "Somber descending stinger on defeat / player_eliminated."
},
"victory_fanfare": {
"stream": "audio/sfx/victory_fanfare.ogg",
"volume_db": -2.0,
@ -418,6 +425,16 @@
"era_range": null,
"mood": "triumph",
"description": "Victory screen music, plays once on victory_achieved."
},
{
"id": "defeat",
"stream": "audio/music/defeat.ogg",
"volume_db": -6.0,
"bus": "Music",
"loop": false,
"era_range": null,
"mood": "lament",
"description": "Defeat screen music, plays once when the human player is eliminated."
}
],
"crossfade_seconds": 2.0,

View file

@ -84,7 +84,7 @@
{ "type": "array", "items": { "type": "integer", "minimum": 1, "maximum": 10 }, "minItems": 2, "maxItems": 2 }
]
},
"mood": { "type": "string", "enum": ["ambient", "tension", "climactic", "triumph"] },
"mood": { "type": "string", "enum": ["ambient", "tension", "climactic", "triumph", "lament"] },
"description": { "type": "string" }
}
}

View file

@ -199,6 +199,7 @@ func _connect_event_bus() -> void:
EventBus.culture_researched.connect(_on_culture_researched)
EventBus.wild_creature_spawned.connect(_on_wild_creature_spawned)
EventBus.weather_event_applied.connect(_on_weather_event)
EventBus.player_eliminated.connect(_on_player_eliminated)
func _build_music_players() -> void:
@ -537,6 +538,20 @@ func _on_victory_achieved(_player_index: int, _victory_type: String) -> void:
play_music("victory")
## Defeat is the human-player counterpart of victory_achieved. The signal
## fires for any eliminated player; we only swap to defeat audio when the
## eliminated player is the local human, otherwise the listener gets
## defeat music for an AI's loss which is wrong.
func _on_player_eliminated(player_index: int) -> void:
if player_index < 0 or player_index >= GameState.players.size():
return
var player: RefCounted = GameState.players[player_index] as RefCounted
if player == null or not ("is_human" in player) or not bool(player.get("is_human")):
return
play_sfx("defeat_stinger")
play_music("defeat")
func _music_track_for_era(era: int) -> String:
for id: String in _music_tracks:
var track: Dictionary = _music_tracks[id]

View file

@ -0,0 +1,10 @@
# Batch 05 — Defeat audio.
#
# defeat_stinger: short SFX cue — borrowed from Kenney Impact Sounds
# (deep heavy plate hit). CC0.
# defeat music: "Game over man" by Bogart VGM, CC-BY 4.0
# pack page: https://opengameart.org/content/gb-victory-stinger-and-game-over-man
# licence quoted on page: "CC-BY 4.0"
# Attribution required → recorded in attribution column.
audio/sfx/defeat_stinger.ogg https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactPlate_heavy_004.ogg CC0-1.0 Kenney (Impact Sounds) loudnorm I=-16/TP=-3+ogg 128kbps
audio/music/defeat.ogg https://opengameart.org/sites/default/files/Game%20over%20man_0.mp3 CC-BY-4.0 Bogart VGM (OpenGameArt) loudnorm I=-16/TP=-3+mp3→ogg 128kbps
Can't render this file because it contains an unexpected character in line 5 and column 19.