diff --git a/.project/designs/app/src/pages/AudioSystem.tsx b/.project/designs/app/src/pages/AudioSystem.tsx index e5eed008..3ba6ac5a 100644 --- a/.project/designs/app/src/pages/AudioSystem.tsx +++ b/.project/designs/app/src/pages/AudioSystem.tsx @@ -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 = (() => { return out; })(); +// URL map of every alternative-candidate .ogg pre-fetched by +// tools/audio-fetch-options.sh. Path layout: +// .local/audio-alternatives//.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; + +/** Map "/" → URL. Used by AudioOption rows to fetch the + * alternative pre-encoded by audio-fetch-options.sh. */ +const ALT_BY_KEY: Record = (() => { + const out: Record = {}; + 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; +} + +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 { - const url = REAL_BY_REL[relPath]; +async function playUrl(url: string | null): Promise { 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 { await liveAudio.play(); } +async function playReal(relPath: string): Promise { + 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}; diff --git a/.project/designs/app/tsconfig.json b/.project/designs/app/tsconfig.json index 6f455de7..e6a12339 100644 --- a/.project/designs/app/tsconfig.json +++ b/.project/designs/app/tsconfig.json @@ -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"] diff --git a/.project/designs/app/vite.config.ts b/.project/designs/app/vite.config.ts index 42af784b..bd235d4b 100644 --- a/.project/designs/app/vite.config.ts +++ b/.project/designs/app/vite.config.ts @@ -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: { diff --git a/public/games/age-of-dwarves/assets/audio/LICENSES.md b/public/games/age-of-dwarves/assets/audio/LICENSES.md index 474f5155..79c2dace 100644 --- a/public/games/age-of-dwarves/assets/audio/LICENSES.md +++ b/public/games/age-of-dwarves/assets/audio/LICENSES.md @@ -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 | diff --git a/public/games/age-of-dwarves/assets/audio/music/defeat.ogg b/public/games/age-of-dwarves/assets/audio/music/defeat.ogg index 098f9634..b3a418b4 100644 Binary files a/public/games/age-of-dwarves/assets/audio/music/defeat.ogg and b/public/games/age-of-dwarves/assets/audio/music/defeat.ogg differ diff --git a/public/games/age-of-dwarves/assets/audio/sources.csv b/public/games/age-of-dwarves/assets/audio/sources.csv index 8ae279db..ad121ce4 100644 --- a/public/games/age-of-dwarves/assets/audio/sources.csv +++ b/public/games/age-of-dwarves/assets/audio/sources.csv @@ -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 diff --git a/public/games/age-of-dwarves/data/audio-options.json b/public/games/age-of-dwarves/data/audio-options.json new file mode 100644 index 00000000..99271713 --- /dev/null +++ b/public/games/age-of-dwarves/data/audio-options.json @@ -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: { : [ { label, source_url,", + "license, attribution } ] } }", + "", + "Files declared here are pre-fetched by tools/audio-fetch-options.sh", + "to .local/audio-alternatives//.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)" + } + ] + } +} diff --git a/tools/audio-batch-05-defeat.tsv b/tools/audio-batch-05-defeat.tsv index 12e5e536..dbc562d1 100644 --- a/tools/audio-batch-05-defeat.tsv +++ b/tools/audio-batch-05-defeat.tsv @@ -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 diff --git a/tools/audio-fetch-options.sh b/tools/audio-fetch-options.sh new file mode 100755 index 00000000..b7ea29d8 --- /dev/null +++ b/tools/audio-fetch-options.sh @@ -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//.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