diff --git a/tools/audio-fetch-batch.sh b/tools/audio-fetch-batch.sh index bd35083a..954d1b76 100755 --- a/tools/audio-fetch-batch.sh +++ b/tools/audio-fetch-batch.sh @@ -18,8 +18,8 @@ set -uo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(dirname "$SCRIPT_DIR")" -THEME="age-of-dwarves" -ASSETS_ROOT="$REPO_ROOT/public/games/$THEME/assets" +# Audio assets live in the shared cross-theme pool, not under any one theme. +ASSETS_ROOT="$REPO_ROOT/public/resources" SOURCES_CSV="$ASSETS_ROOT/audio/sources.csv" STAGING="$REPO_ROOT/.local/audio-staging" TODAY="$(date -u +%Y-%m-%d)" @@ -56,31 +56,71 @@ while IFS=$'\t' read -r output_path source_url licence attribution edits; do mkdir -p "$(dirname "$full_path")" stem="$(basename "$output_path" .ogg)" - src_ext="${source_url##*.}" - case "$src_ext" in - wav|ogg|mp3|flac) ;; - *) src_ext="bin" ;; - esac - staged="$STAGING/${stem}.${src_ext}" - # Convert github.com blob URLs to raw URLs automatically (still allow - # raw URLs and other hosts unchanged). + # Source URL may be one of: + # 1. direct file URL ending in .wav/.ogg/.mp3/.flac + # 2. github.com blob URL (auto-converted to raw) + # 3. ZIP archive with an inner path: "#" fetch_url="$source_url" - case "$source_url" in + 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 "$source_url" | sed -e 's|github.com|raw.githubusercontent.com|' -e 's|/blob/|/|')" + fetch_url="$(echo "$fetch_url" | sed -e 's|github.com|raw.githubusercontent.com|' -e 's|/blob/|/|')" ;; esac - if ! curl -sfL -o "$staged" "$fetch_url"; then - echo " ✗ download failed: $fetch_url" >&2 - fail=$((fail + 1)) - continue + if [ -n "$inner_path" ]; then + # ZIP path: cache the archive once per URL, extract a single + # member into staging. + 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: $zip_cache" >&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/${stem}.${src_ext}" + if ! curl -sfL -o "$staged" "$fetch_url"; then + echo " ✗ download failed: $fetch_url" >&2 + fail=$((fail + 1)) + continue + fi fi # loudnorm two-pass would be more accurate, but for SFX one-pass is fine # at this scale. Music tracks should be normalised manually with two-pass. - if ! ffmpeg -y -hide_banner -loglevel error \ + # `-nostdin` is critical: without it ffmpeg consumes characters from the + # mapping file (the script's stdin), corrupting subsequent iterations' + # output_path values. This caused dirs like `c/`, `sic/`, `music/`, + # `udio/` to appear instead of the intended `audio/...` paths. + if ! ffmpeg -y -nostdin -hide_banner -loglevel error \ -i "$staged" \ -af "loudnorm=I=-16:TP=-3:LRA=11,aresample=44100" \ -c:a libvorbis -b:a 128k \ 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 diff --git a/tools/audio-licenses-render.py b/tools/audio-licenses-render.py index db3ccca6..be8524aa 100755 --- a/tools/audio-licenses-render.py +++ b/tools/audio-licenses-render.py @@ -168,7 +168,8 @@ def load_rows(csv_path: Path) -> list[dict]: def validate_rows(rows: list[dict], theme: str) -> list[str]: errors: list[str] = [] - asset_root = REPO / "public" / "games" / theme / "assets" + # Audio is shared — lives at public/resources/audio/, not under any theme. + asset_root = REPO / "public" / "resources" seen_paths: set[str] = set() required = {"output_path", "source_url", "license", "attribution", "edits", "added"} @@ -217,7 +218,10 @@ def main() -> int: ) args = parser.parse_args() - asset_dir = REPO / "public" / "games" / args.theme / "assets" / "audio" + # Audio assets are shared cross-theme; LICENSES.md and sources.csv live + # at public/resources/audio/. The `--theme` flag is retained only for + # the human-readable header in the rendered file. + asset_dir = REPO / "public" / "resources" / "audio" sources_csv = asset_dir / "sources.csv" licenses_md = asset_dir / "LICENSES.md" diff --git a/tools/audio-reorganize.py b/tools/audio-reorganize.py new file mode 100644 index 00000000..b2b3d64c --- /dev/null +++ b/tools/audio-reorganize.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""One-shot reorganization: move audio assets from theme-specific dir to +shared `public/resources/audio/`, and normalize the SFX layout into a +consistent flat-categorical structure. + +Idempotent enough to re-run if interrupted, but designed for a single pass. + +Old layout: + public/games/age-of-dwarves/assets/audio/sfx/turn_started.ogg (flat) + public/games/age-of-dwarves/assets/audio/sfx/buildings/... (nested) + public/games/age-of-dwarves/assets/audio/sfx/units/melee_spawn.ogg (flat-but-named) + public/games/age-of-dwarves/assets/audio/sfx/units/melee/... (nested) + public/games/age-of-dwarves/assets/audio/music/... + +New layout: + public/resources/audio/sfx//.ogg + public/resources/audio/music/.ogg + public/resources/audio/sources.csv + public/resources/audio/LICENSES.md + +Categories: ui, city, combat, era, buildings, units/, fauna, +weather, generic. +""" + +import json, csv, io, shutil, sys, re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +OLD_ROOT = REPO_ROOT / "public/games/age-of-dwarves/assets/audio" +NEW_ROOT = REPO_ROOT / "public/resources/audio" +MANIFEST = REPO_ROOT / "public/games/age-of-dwarves/data/audio.json" + +# Map of old asset-relative path -> new asset-relative path. The asset-relative +# part is what shows up as `audio/...` in the manifest streams + sources.csv. +RENAMES: dict[str, str] = { + # ── flat sfx top-level → categorical subfolders ───────────────────── + "audio/sfx/turn_started.ogg": "audio/sfx/ui/turn_started.ogg", + "audio/sfx/turn_ended.ogg": "audio/sfx/ui/turn_ended.ogg", + "audio/sfx/unit_moved.ogg": "audio/sfx/ui/unit_moved.ogg", + "audio/sfx/unit_promoted.ogg": "audio/sfx/ui/unit_promoted.ogg", + "audio/sfx/border_expanded.ogg": "audio/sfx/ui/border_expanded.ogg", + "audio/sfx/research_start.ogg": "audio/sfx/ui/research_start.ogg", + "audio/sfx/tech_researched.ogg": "audio/sfx/ui/tech_researched.ogg", + "audio/sfx/culture_researched.ogg": "audio/sfx/ui/culture_researched.ogg", + + "audio/sfx/city_founded.ogg": "audio/sfx/city/city_founded.ogg", + + "audio/sfx/combat_hit.ogg": "audio/sfx/combat/combat_hit.ogg", + "audio/sfx/unit_killed.ogg": "audio/sfx/combat/unit_killed.ogg", + "audio/sfx/unit_defeated.ogg": "audio/sfx/combat/unit_defeated.ogg", + "audio/sfx/unit_victorious.ogg": "audio/sfx/combat/unit_victorious.ogg", + + "audio/sfx/era_advanced.ogg": "audio/sfx/era/era_advanced.ogg", + "audio/sfx/golden_age_swell.ogg": "audio/sfx/era/golden_age_swell.ogg", + "audio/sfx/victory_fanfare.ogg": "audio/sfx/era/victory_fanfare.ogg", + "audio/sfx/defeat_stinger.ogg": "audio/sfx/era/defeat_stinger.ogg", + + "audio/sfx/wonder_built.ogg": "audio/sfx/buildings/wonder_built.ogg", + "audio/sfx/wonder_built_own.ogg": "audio/sfx/buildings/wonder_built_own.ogg", + "audio/sfx/wonder_built_rival.ogg": "audio/sfx/buildings/wonder_built_rival.ogg", + + # ── normalize unit subcategory paths (flat-but-named → nested) ────── + "audio/sfx/units/melee_spawn.ogg": "audio/sfx/units/melee/spawn.ogg", + "audio/sfx/units/ranged_spawn.ogg": "audio/sfx/units/ranged/spawn.ogg", + "audio/sfx/units/siege_hit.ogg": "audio/sfx/units/siege/hit.ogg", + "audio/sfx/units/siege_death.ogg": "audio/sfx/units/siege/death.ogg", + "audio/sfx/units/support_attack.ogg":"audio/sfx/units/support/attack.ogg", + "audio/sfx/units/support_hit.ogg": "audio/sfx/units/support/hit.ogg", + "audio/sfx/units/support_death.ogg": "audio/sfx/units/support/death.ogg", +} + +def step_move_files() -> tuple[int, int]: + """Move every audio file from OLD_ROOT to NEW_ROOT, applying RENAMES.""" + moved = 0 + skipped = 0 + for src in OLD_ROOT.rglob("*"): + if not src.is_file(): + continue + rel = src.relative_to(OLD_ROOT) + # The manifest path is "audio/..." — equivalent to the relative path + # under OLD_ROOT prefixed with "audio/". So a file at + # OLD_ROOT/sfx/turn_started.ogg matches "audio/sfx/turn_started.ogg". + manifest_key = f"audio/{rel.as_posix()}" + new_manifest_key = RENAMES.get(manifest_key, manifest_key) + # Special-case: LICENSES.md and sources.csv at OLD_ROOT root keep + # their basename, no "audio/" prefix. + if rel.parent == Path("."): + new_path = NEW_ROOT / rel.name + else: + assert new_manifest_key.startswith("audio/") + new_path = NEW_ROOT / new_manifest_key[len("audio/"):] + new_path.parent.mkdir(parents=True, exist_ok=True) + if new_path.exists(): + skipped += 1 + continue + shutil.move(str(src), str(new_path)) + moved += 1 + return moved, skipped + + +def step_update_manifest() -> int: + """Rewrite audio.json stream paths.""" + with open(MANIFEST) as f: + data = json.load(f) + rewrites = 0 + for key, entry in data.get("sfx", {}).items(): + if "stream" in entry and entry["stream"] in RENAMES: + entry["stream"] = RENAMES[entry["stream"]] + rewrites += 1 + if "streams" in entry: + new_list = [] + for s in entry["streams"]: + if s in RENAMES: + new_list.append(RENAMES[s]) + rewrites += 1 + else: + new_list.append(s) + entry["streams"] = new_list + # Music tracks didn't move (they were never in the flat-confusion zone), + # but defensive. + for track in data.get("music", {}).get("tracks", []): + if "stream" in track and track["stream"] in RENAMES: + track["stream"] = RENAMES[track["stream"]] + rewrites += 1 + with open(MANIFEST, "w") as f: + json.dump(data, f, indent=2) + f.write("\n") + return rewrites + + +def step_update_sources_csv() -> int: + """Rewrite output_path column in sources.csv.""" + csv_path = NEW_ROOT / "sources.csv" + if not csv_path.exists(): + return 0 + head_lines: list[str] = [] + body_lines: list[str] = [] + with open(csv_path) as f: + for ln in f: + if ln.startswith("#"): + head_lines.append(ln) + else: + body_lines.append(ln) + reader = csv.reader(io.StringIO("".join(body_lines))) + rows = list(reader) + hdr, body = rows[0], rows[1:] + op_idx = hdr.index("output_path") + rewrites = 0 + for r in body: + if r[op_idx] in RENAMES: + r[op_idx] = RENAMES[r[op_idx]] + rewrites += 1 + with open(csv_path, "w") as f: + f.writelines(head_lines) + w = csv.writer(f) + w.writerow(hdr) + w.writerows(body) + return rewrites + + +def step_remove_old_root() -> None: + """Delete OLD_ROOT if empty after the move.""" + if not OLD_ROOT.exists(): + return + leftover = list(OLD_ROOT.rglob("*")) + leftover_files = [p for p in leftover if p.is_file()] + if leftover_files: + print(f" WARN: {len(leftover_files)} files remain at {OLD_ROOT}") + for p in leftover_files[:5]: + print(f" {p}") + return + shutil.rmtree(OLD_ROOT) + # Walk up and prune empty parent dirs (assets/) but never above public/games/age-of-dwarves + parent = OLD_ROOT.parent # .../assets + if parent.is_dir() and not any(parent.iterdir()): + parent.rmdir() + + +def main() -> None: + NEW_ROOT.mkdir(parents=True, exist_ok=True) + moved, skipped = step_move_files() + print(f"moved {moved} files, skipped {skipped} (already at destination)") + rewrites = step_update_manifest() + print(f"manifest rewrites: {rewrites}") + csv_rewrites = step_update_sources_csv() + print(f"sources.csv rewrites: {csv_rewrites}") + step_remove_old_root() + print("done.") + + +if __name__ == "__main__": + main() diff --git a/tools/audio-validate.py b/tools/audio-validate.py index 67eb5121..fc6670d6 100755 --- a/tools/audio-validate.py +++ b/tools/audio-validate.py @@ -5,19 +5,14 @@ Checks (per pack under `public/games//data/audio.json`): 1. JSON validates against the schema at `public/games//data/schemas/audio.schema.json`. - 2. Every `stream` / `streams[]` path that the manifest references either - resolves to a real file under `public/games//assets/audio/...` - OR is reachable via a `fallback` chain that terminates at the - `_silent` sentinel. Files that don't exist trigger a warning, not an - error — `p2-16` is the asset-acquisition objective and lands files - incrementally. + 2. Every `stream` / `streams[]` path resolves to a real file under + `public/games//assets/audio/...`. Missing files warn (the + runtime fails loud at playback time via `audio_asset_missing`). 3. No orphan `.ogg` files: every `.ogg` under `assets/audio/**` is referenced by at least one manifest entry. Orphans warn; not fatal. - 4. Every `fallback` reference is a valid sound key in the same manifest - (or `_silent`). - 5. The `_silent` sentinel exists with an empty `streams: []`. + 4. `streams[]` arrays are non-empty (no silent-sentinel pattern). -Exit status: non-zero iff a structural problem (1, 4, 5) is found. +Exit status: non-zero iff a structural problem (1, 4) is found. Missing files (2) and orphans (3) only warn — they're a normal intermediate state while assets land. @@ -33,7 +28,6 @@ from pathlib import Path REPO = Path(__file__).resolve().parent.parent DEFAULT_THEMES = ["age-of-dwarves"] -SILENT_KEY = "_silent" @dataclass @@ -83,7 +77,7 @@ def validate_schema(manifest: dict, report: Report) -> None: # Each SFX entry shape allowed_entry_keys = { "stream", "streams", "volume_db", "bus", "pitch_jitter", - "fallback", "description", + "description", } for key, entry in sfx.items(): if not isinstance(entry, dict): @@ -97,6 +91,19 @@ def validate_schema(manifest: dict, report: Report) -> None: f"sfx[{key!r}] declares both `stream` and `streams[]` — " f"streams[] takes precedence at runtime; drop `stream`" ) + if "streams" in entry: + raw = entry["streams"] + if isinstance(raw, list) and len(raw) == 0: + report.fail( + f"sfx[{key!r}] declares empty `streams: []` — fail-loud " + f"policy forbids silent sentinels; remove the entry or " + f"ship at least one stream" + ) + if "stream" not in entry and "streams" not in entry: + report.fail( + f"sfx[{key!r}] declares neither `stream` nor `streams[]` — " + f"every entry must point at at least one path" + ) if "pitch_jitter" in entry: j = entry["pitch_jitter"] if not isinstance(j, (int, float)) or j < 0.0 or j > 0.5: @@ -104,51 +111,6 @@ def validate_schema(manifest: dict, report: Report) -> None: f"sfx[{key!r}].pitch_jitter must be a number in [0.0, 0.5]" ) - # Sentinel - if SILENT_KEY not in sfx: - report.fail( - f"sfx[{SILENT_KEY!r}] sentinel missing — fallback chains " - f"need a terminator" - ) - else: - sentinel = sfx[SILENT_KEY] - if not (isinstance(sentinel.get("streams"), list) - and len(sentinel["streams"]) == 0): - report.fail( - f"sfx[{SILENT_KEY!r}] must declare `streams: []` (empty array)" - ) - - # Fallback chain references resolve - for key, entry in sfx.items(): - if not isinstance(entry, dict): - continue - fb = entry.get("fallback") - if fb is None: - continue - if not isinstance(fb, str) or not fb: - report.fail(f"sfx[{key!r}].fallback must be a non-empty string") - continue - if fb not in sfx: - report.fail( - f"sfx[{key!r}].fallback={fb!r} is not a valid sound key" - ) - - # Cycle check on fallback graph - for key in sfx: - seen: set[str] = set() - cur = key - while cur in sfx: - if cur in seen: - report.fail( - f"sfx[{key!r}] fallback chain cycles through {cur!r}" - ) - break - seen.add(cur) - nxt = sfx[cur].get("fallback") if isinstance(sfx[cur], dict) else None - if not nxt: - break - cur = str(nxt) - # Music tracks tracks = music.get("tracks", []) if not isinstance(tracks, list): @@ -189,7 +151,9 @@ def collect_referenced_streams(manifest: dict) -> set[str]: def check_assets(theme: str, refs: set[str], report: Report) -> None: - assets_root = REPO / "public" / "games" / theme / "assets" + # Audio is shared across themes — lives in public/resources/audio/. + # `theme` is retained for the warning prefix only. + assets_root = REPO / "public" / "resources" missing: list[str] = [] for rel in sorted(refs): if not rel: