feat(@projects/@magic-civilization): add audio option system and alternative ogg support

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-28 17:29:11 -04:00
parent 0d26c271b3
commit f3db6674ad
9 changed files with 289 additions and 12 deletions

View file

@ -2,6 +2,7 @@ import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import styled from "styled-components";
import audioManifest from "@game-data/audio.json";
import audioOptionsData from "@game-data/audio-options.json";
import { play as playSynth, hasExplicitRecipe } from "../audioSynth";
import { t } from "../theme";
@ -25,10 +26,54 @@ const REAL_BY_REL: Record<string, string> = (() => {
return out;
})();
// URL map of every alternative-candidate .ogg pre-fetched by
// tools/audio-fetch-options.sh. Path layout:
// .local/audio-alternatives/<safe_key>/<idx>.ogg
// where safe_key = output_path with '/' → '_' and trailing '.ogg' stripped.
const ALT_OGG_URLS = import.meta.glob(
"@audio-alts/**/*.ogg",
{ eager: true, query: "?url", import: "default" },
) as Record<string, string>;
/** Map "<safe_key>/<idx>" URL. Used by AudioOption rows to fetch the
* alternative pre-encoded by audio-fetch-options.sh. */
const ALT_BY_KEY: Record<string, string> = (() => {
const out: Record<string, string> = {};
for (const [absPath, url] of Object.entries(ALT_OGG_URLS)) {
const idx = absPath.indexOf("/audio-alternatives/");
if (idx < 0) continue;
out[absPath.slice(idx + "/audio-alternatives/".length).replace(/\.ogg$/, "")] = url;
}
return out;
})();
interface AudioOption {
label: string;
source_url: string;
license: string;
attribution: string;
}
interface AudioOptionsFile {
options: Record<string, AudioOption[]>;
}
const audioOptions = audioOptionsData as AudioOptionsFile;
/** Convert an output_path like "audio/music/defeat.ogg" to the alt-tree
* safe key ("audio_music_defeat"). Mirrors audio-fetch-options.sh. */
function altSafeKey(outputPath: string): string {
return outputPath.replace(/\.ogg$/, "").replace(/\//g, "_");
}
/** Return the alt URL for option index `idx` of `outputPath`, or null. */
function altUrlFor(outputPath: string, idx: number): string | null {
return ALT_BY_KEY[`${altSafeKey(outputPath)}/${idx}`] ?? null;
}
let liveAudio: HTMLAudioElement | null = null;
async function playReal(relPath: string): Promise<void> {
const url = REAL_BY_REL[relPath];
async function playUrl(url: string | null): Promise<void> {
if (url == null) return;
// Single shared element so successive clicks don't pile up overlapping
// playbacks of the same cue.
@ -42,6 +87,10 @@ async function playReal(relPath: string): Promise<void> {
await liveAudio.play();
}
async function playReal(relPath: string): Promise<void> {
await playUrl(REAL_BY_REL[relPath] ?? null);
}
/** Return the resolved manifest path whose .ogg actually exists on disk
* (checks every entry in streams[] plus legacy single `stream`). */
function realOggAvailable(entry: { stream?: string; streams?: string[] }): string | null {
@ -367,6 +416,20 @@ const PlayBtnInline = styled(PlayBtn)`
// Real-OGG play button — distinct accent so the eye can tell synth-preview
// (gold) from real-asset (green) at a glance.
const OptSelect = styled.select`
background: ${t.bg.surface};
color: ${t.sem.positive};
border: 1px solid ${t.sem.positive}66;
border-radius: ${t.radius.btn};
font-family: ${t.font.mono};
font-size: 11px;
padding: 2px 4px;
max-width: 220px;
cursor: pointer;
&:hover { border-color: ${t.sem.positive}; }
&:focus { outline: 1px solid ${t.sem.positive}; }
`;
const PlayRealBtn = styled.button`
background: ${t.bg.btnNormal};
color: ${t.sem.positive};

View file

@ -16,7 +16,8 @@
"baseUrl": ".",
"paths": {
"@game-data/*": ["../../../public/games/age-of-dwarves/data/*"],
"@game-assets/*": ["../../../public/games/age-of-dwarves/assets/*"]
"@game-assets/*": ["../../../public/games/age-of-dwarves/assets/*"],
"@audio-alts/*": ["../../../.local/audio-alternatives/*"]
}
},
"include": ["src", "../../../public/games/age-of-dwarves/data/audio.json"]

View file

@ -8,6 +8,7 @@ export default defineConfig({
alias: {
"@game-data": path.resolve(__dirname, "../../../public/games/age-of-dwarves/data"),
"@game-assets": path.resolve(__dirname, "../../../public/games/age-of-dwarves/assets"),
"@audio-alts": path.resolve(__dirname, "../../../.local/audio-alternatives"),
},
},
server: {
@ -18,6 +19,7 @@ export default defineConfig({
allow: [
path.resolve(__dirname),
path.resolve(__dirname, "../../../public"),
path.resolve(__dirname, "../../../.local/audio-alternatives"),
],
},
watch: {

View file

@ -10,7 +10,7 @@ Each row records one `.ogg` shipped under `public/games/age-of-dwarves/assets/au
| 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/defeat.ogg` | CC0-1.0 | [link](https://opengameart.org/sites/default/files/JRPG%20Music%20Pack%20%234%20%5BCalm%5D%20by%20Juhani%20Junkala_0.zip#Calm6 - Innocence.ogg) | Juhani Junkala (SubspaceAudio | OpenGameArt) | loudnorm I=-16/TP=-3+ogg 128kbps |
| `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 |

View file

@ -85,4 +85,4 @@ audio/sfx/fauna/apex_death.ogg,https://opengameart.org/sites/default/files/80-CC
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
audio/music/defeat.ogg,https://opengameart.org/sites/default/files/JRPG%20Music%20Pack%20%234%20%5BCalm%5D%20by%20Juhani%20Junkala_0.zip#Calm6 - Innocence.ogg,CC0-1.0,Juhani Junkala (SubspaceAudio, OpenGameArt),loudnorm I=-16/TP=-3+ogg 128kbps,2026-04-28

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

View file

@ -0,0 +1,84 @@
{
"schema_version": 1,
"_doc": [
"Sidecar to audio.json: per-output-path candidate source list.",
"Lets a designer audition alternatives via the design app at /audio",
"before promoting one to the canonical sources.csv row.",
"",
"Schema: { options: { <output_path>: [ { label, source_url,",
"license, attribution } ] } }",
"",
"Files declared here are pre-fetched by tools/audio-fetch-options.sh",
"to .local/audio-alternatives/<safe_path>/<idx>.ogg — outside the",
"shipped assets tree so the orphan scan in audio-validate.py stays",
"clean. The design app's Vite server exposes them via @audio-alts."
],
"options": {
"audio/music/defeat.ogg": [
{
"label": "Junkala — Innocence (current)",
"source_url": "https://opengameart.org/sites/default/files/JRPG%20Music%20Pack%20%234%20%5BCalm%5D%20by%20Juhani%20Junkala_0.zip#Calm6 - Innocence.ogg",
"license": "CC0-1.0",
"attribution": "Juhani Junkala (SubspaceAudio, OpenGameArt)"
},
{
"label": "Junkala — Sand Castles",
"source_url": "https://opengameart.org/sites/default/files/JRPG%20Music%20Pack%20%234%20%5BCalm%5D%20by%20Juhani%20Junkala_0.zip#Calm4 - Sand Castles.ogg",
"license": "CC0-1.0",
"attribution": "Juhani Junkala (SubspaceAudio, OpenGameArt)"
},
{
"label": "Junkala — Childhood Friends",
"source_url": "https://opengameart.org/sites/default/files/JRPG%20Music%20Pack%20%234%20%5BCalm%5D%20by%20Juhani%20Junkala_0.zip#Calm2 - Childhood Friends.ogg",
"license": "CC0-1.0",
"attribution": "Juhani Junkala (SubspaceAudio, OpenGameArt)"
},
{
"label": "Bogart VGM — Game over man (chiptune, off-theme)",
"source_url": "https://opengameart.org/sites/default/files/Game%20over%20man_0.mp3",
"license": "CC-BY-4.0",
"attribution": "Bogart VGM (OpenGameArt)"
}
],
"audio/music/victory.ogg": [
{
"label": "Junkala — Preparing For Battle (current)",
"source_url": "https://opengameart.org/sites/default/files/JRPG%20Music%20Pack%20%235%20%5BAction%5D%20by%20Juhani%20Junkala.zip#Action3 - Preparing For Battle.ogg",
"license": "CC0-1.0",
"attribution": "Juhani Junkala (SubspaceAudio, OpenGameArt)"
},
{
"label": "Junkala — Encounter With The Witches",
"source_url": "https://opengameart.org/sites/default/files/JRPG%20Music%20Pack%20%235%20%5BAction%5D%20by%20Juhani%20Junkala.zip#Action1 - Encounter With The Witches.ogg",
"license": "CC0-1.0",
"attribution": "Juhani Junkala (SubspaceAudio, OpenGameArt)"
},
{
"label": "Junkala — Army Approaching",
"source_url": "https://opengameart.org/sites/default/files/JRPG%20Music%20Pack%20%235%20%5BAction%5D%20by%20Juhani%20Junkala.zip#Action2 - Army Approaching.ogg",
"license": "CC0-1.0",
"attribution": "Juhani Junkala (SubspaceAudio, OpenGameArt)"
}
],
"audio/sfx/era_advanced.ogg": [
{
"label": "Kenney — impactBell_heavy_003 (current)",
"source_url": "https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactBell_heavy_003.ogg",
"license": "CC0-1.0",
"attribution": "Kenney (Impact Sounds)"
},
{
"label": "Kenney — impactBell_heavy_000",
"source_url": "https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactBell_heavy_000.ogg",
"license": "CC0-1.0",
"attribution": "Kenney (Impact Sounds)"
},
{
"label": "Kenney — impactBell_heavy_004",
"source_url": "https://kenney.nl/media/pages/assets/impact-sounds/8aa7b545c9-1677589768/kenney_impact-sounds.zip#Audio/impactBell_heavy_004.ogg",
"license": "CC0-1.0",
"attribution": "Kenney (Impact Sounds)"
}
]
}
}

View file

@ -1,10 +1,12 @@
# 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.
# defeat_stinger: short SFX cue — Kenney Impact Sounds, deep heavy plate
# hit. CC0. Cohesive with the rest of the impact-sourced foley.
# defeat music: Juhani Junkala "JRPG Pack 4 (Calm)" — track "Innocence".
# Same composer as the other 7 music tracks (Pack 2 Towns + Pack 5
# Action), so the defeat cue lives in the same musical universe rather
# than reading as a tonal swerve. Wistful / lament-mood orchestral.
# Pack page: https://opengameart.org/content/jrpg-pack-4-calm
# Licence quoted on page: "Creative Commons Zero — public domain"
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
audio/music/defeat.ogg https://opengameart.org/sites/default/files/JRPG%20Music%20Pack%20%234%20%5BCalm%5D%20by%20Juhani%20Junkala_0.zip#Calm6 - Innocence.ogg CC0-1.0 Juhani Junkala (SubspaceAudio, OpenGameArt) loudnorm I=-16/TP=-3+ogg 128kbps

Can't render this file because it contains an unexpected character in line 5 and column 19.

125
tools/audio-fetch-options.sh Executable file
View file

@ -0,0 +1,125 @@
#!/usr/bin/env bash
# Pre-fetch every alternative declared in audio-options.json so the
# design app at /audio can preview them via its dropdown selector.
#
# Files land at .local/audio-alternatives/<safe_path>/<idx>.ogg —
# outside the shipped assets tree, so audio-validate.py's orphan scan
# is not affected. The design app exposes them via Vite's `@audio-alts`
# alias.
#
# Idempotent: skips alternatives whose .ogg already exists.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(dirname "$SCRIPT_DIR")"
THEME="${AUDIO_THEME:-age-of-dwarves}"
OPTIONS_JSON="$REPO_ROOT/public/games/$THEME/data/audio-options.json"
ALT_ROOT="$REPO_ROOT/.local/audio-alternatives"
STAGING="$REPO_ROOT/.local/audio-staging"
if [ ! -f "$OPTIONS_JSON" ]; then
echo "audio-options.json not found at $OPTIONS_JSON" >&2
exit 1
fi
mkdir -p "$ALT_ROOT" "$STAGING"
ok=0
skip=0
fail=0
# Walk every (output_path, idx, source_url) triple via python (jq isn't
# guaranteed everywhere; python3 is). One line per triple, tab-separated.
mapfile -t ROWS < <(python3 -c "
import json
data = json.load(open('$OPTIONS_JSON'))
for output_path, opts in data.get('options', {}).items():
for i, opt in enumerate(opts):
url = opt.get('source_url', '')
if not url:
continue
print(f'{output_path}\t{i}\t{url}')
")
for row in "${ROWS[@]}"; do
IFS=$'\t' read -r output_path idx source_url <<< "$row"
[ -z "$output_path" ] && continue
safe_key="$(printf '%s' "$output_path" | tr '/' '_' | sed 's/\.ogg$//')"
out_dir="$ALT_ROOT/$safe_key"
out_file="$out_dir/$idx.ogg"
if [ -f "$out_file" ]; then
skip=$((skip + 1))
continue
fi
echo "$output_path[$idx]"
mkdir -p "$out_dir"
fetch_url="$source_url"
inner_path=""
if [[ "$source_url" == *"#"* ]]; then
fetch_url="${source_url%%#*}"
inner_path="${source_url#*#}"
fi
case "$fetch_url" in
https://github.com/*/blob/*)
fetch_url="$(echo "$fetch_url" | sed -e 's|github.com|raw.githubusercontent.com|' -e 's|/blob/|/|')"
;;
esac
if [ -n "$inner_path" ]; then
zip_hash="$(printf '%s' "$fetch_url" | shasum | cut -c1-8)"
zip_cache="$STAGING/_zip_${zip_hash}.zip"
zip_extract_dir="$STAGING/_zip_${zip_hash}"
if [ ! -f "$zip_cache" ]; then
if ! curl -sfL -o "$zip_cache" "$fetch_url"; then
echo " ✗ ZIP download failed: $fetch_url" >&2
fail=$((fail + 1)); continue
fi
fi
if [ ! -d "$zip_extract_dir" ]; then
mkdir -p "$zip_extract_dir"
if ! unzip -q -o "$zip_cache" -d "$zip_extract_dir"; then
echo " ✗ ZIP extract failed" >&2
fail=$((fail + 1)); continue
fi
fi
staged="$zip_extract_dir/$inner_path"
if [ ! -f "$staged" ]; then
echo " ✗ ZIP missing inner file: $inner_path" >&2
fail=$((fail + 1)); continue
fi
else
src_ext="${fetch_url##*.}"
case "$src_ext" in
wav|ogg|mp3|flac) ;;
*) src_ext="bin" ;;
esac
staged="$STAGING/_alt_${safe_key}_${idx}.${src_ext}"
if ! curl -sfL -o "$staged" "$fetch_url"; then
echo " ✗ download failed: $fetch_url" >&2
fail=$((fail + 1)); continue
fi
fi
if ! ffmpeg -y -hide_banner -loglevel error \
-i "$staged" \
-af "loudnorm=I=-16:TP=-3:LRA=11,aresample=44100" \
-c:a libvorbis -b:a 128k \
"$out_file"; then
echo " ✗ encode failed" >&2
fail=$((fail + 1)); continue
fi
ok=$((ok + 1))
done
echo ""
echo "── alternatives summary ────"
echo " ok: $ok"
echo " skip: $skip"
echo " fail: $fail"
[ $fail -gt 0 ] && exit 1
exit 0