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:
parent
0d26c271b3
commit
f3db6674ad
9 changed files with 289 additions and 12 deletions
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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.
|
84
public/games/age-of-dwarves/data/audio-options.json
Normal file
84
public/games/age-of-dwarves/data/audio-options.json
Normal 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)"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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
125
tools/audio-fetch-options.sh
Executable 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
|
||||
Loading…
Add table
Reference in a new issue