From 5e92fb313d562a23420caa59d3832b5ca5c70d45 Mon Sep 17 00:00:00 2001 From: autocommit Date: Thu, 30 Apr 2026 01:27:46 -0700 Subject: [PATCH] =?UTF-8?q?feat(audio-tools):=20=E2=9C=A8=20Introduce=20au?= =?UTF-8?q?dio=20validation=20and=20subscription-friendly=20audio=20splitt?= =?UTF-8?q?ing=20utilities?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- tools/audio-split-to-subscription.py | 78 ++++++++++++++++++++++++++++ tools/audio-validate.py | 68 ++++++++++++++++++++---- 2 files changed, 136 insertions(+), 10 deletions(-) create mode 100644 tools/audio-split-to-subscription.py diff --git a/tools/audio-split-to-subscription.py b/tools/audio-split-to-subscription.py new file mode 100644 index 00000000..3690bdb0 --- /dev/null +++ b/tools/audio-split-to-subscription.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""One-shot migration: split a per-theme audio.json into the subscription- +pattern triple used everywhere else in the codebase. + +Before: + public/games//data/audio.json + {schema_version, sfx: {...}, music: {tracks: [...], victory_pool, defeat_pool, + default_track_id, crossfade_seconds}} + +After: + public/resources/audio/library.json # shared catalogue + {schema_version, sfx: {...}, music: {tracks: [...]}} + public/games//data/audio/manifest.json # subscription + {source: "resources/audio", includes: true, overrides: {}} + public/games//data/audio/pools.json # per-game routing + {default_track_id, crossfade_seconds, victory_pool, defeat_pool} + +The migration assumes the current single audio.json IS the only library — +i.e. all entries are subscribed by Game 1. Future games author their own +manifest.json with `includes: [whitelist]` or overrides. +""" + +from __future__ import annotations +import json +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +THEME = "age-of-dwarves" +SRC = REPO / "public" / "games" / THEME / "data" / "audio.json" +LIBRARY = REPO / "public" / "resources" / "audio" / "library.json" +DEST_DIR = REPO / "public" / "games" / THEME / "data" / "audio" +MANIFEST = DEST_DIR / "manifest.json" +POOLS = DEST_DIR / "pools.json" + + +def main() -> None: + with open(SRC) as f: + old = json.load(f) + sfx: dict = old["sfx"] + music: dict = old["music"] + tracks: list = music.get("tracks", []) + + # Library: schema_version + sfx + music tracks (no per-theme routing). + library = { + "schema_version": int(old.get("schema_version", 2)), + "sfx": sfx, + "music": {"tracks": tracks}, + } + + # Manifest: subscription. Game 1 takes everything. + manifest = { + "source": "resources/audio", + "includes": True, + "overrides": {}, + } + + # Pools: per-theme routing. Pull every per-game-only field out of + # the music block. + pools: dict = {} + for k in ("default_track_id", "crossfade_seconds", "victory_pool", + "defeat_pool"): + if k in music: + pools[k] = music[k] + + DEST_DIR.mkdir(parents=True, exist_ok=True) + LIBRARY.parent.mkdir(parents=True, exist_ok=True) + LIBRARY.write_text(json.dumps(library, indent=2) + "\n") + MANIFEST.write_text(json.dumps(manifest, indent=2) + "\n") + POOLS.write_text(json.dumps(pools, indent=2) + "\n") + SRC.unlink() + print(f"library: {LIBRARY} ({len(sfx)} sfx, {len(tracks)} tracks)") + print(f"manifest: {MANIFEST}") + print(f"pools: {POOLS}") + print(f"removed: {SRC}") + + +if __name__ == "__main__": + main() diff --git a/tools/audio-validate.py b/tools/audio-validate.py index fc6670d6..5fdd6488 100755 --- a/tools/audio-validate.py +++ b/tools/audio-validate.py @@ -189,20 +189,68 @@ def check_assets(theme: str, refs: set[str], report: Report) -> None: # ────────────────────────────────────────────────────────────────────────── +def _read_json(path: Path, theme: str, what: str, report: Report) -> dict: + if not path.exists(): + report.fail(f"{theme}: {what} not found at {path}") + return {} + try: + return json.loads(path.read_text()) + except json.JSONDecodeError as e: + report.fail(f"{theme}: {what} JSON parse error: {e}") + return {} + + +def _resolve_manifest(library: dict, manifest: dict) -> dict: + """Mirror AudioManager._apply_subscription: filter library by includes, + merge per-key overrides. Returns a synthesized full manifest dict in + the legacy shape that `validate_schema` and `collect_referenced_streams` + already understand. + """ + includes = manifest.get("includes", True) + overrides = manifest.get("overrides", {}) or {} + + def included(key: str) -> bool: + if isinstance(includes, bool): + return includes + if isinstance(includes, list): + return key in includes + return False + + out: dict = {"schema_version": library.get("schema_version", 2), + "sfx": {}, "music": {"tracks": []}} + for key, entry in (library.get("sfx", {}) or {}).items(): + if not included(key): + continue + merged = dict(entry) + if key in overrides and isinstance(overrides[key], dict): + merged.update(overrides[key]) + out["sfx"][key] = merged + for track in (library.get("music", {}) or {}).get("tracks", []) or []: + if not isinstance(track, dict): + continue + tid = track.get("id", "") + if not tid or not included(tid): + continue + merged = dict(track) + if tid in overrides and isinstance(overrides[tid], dict): + merged.update(overrides[tid]) + out["music"]["tracks"].append(merged) + return out + + def validate_theme(theme: str) -> Report: report = Report(errors=[], warnings=[]) - manifest_path = REPO / "public" / "games" / theme / "data" / "audio.json" - if not manifest_path.exists(): - report.fail(f"{theme}: audio.json not found at {manifest_path}") - return report - try: - manifest = json.loads(manifest_path.read_text()) - except json.JSONDecodeError as e: - report.fail(f"{theme}: JSON parse error: {e}") + library_path = REPO / "public" / "resources" / "audio" / "library.json" + manifest_path = REPO / "public" / "games" / theme / "data" / "audio" / "manifest.json" + + library = _read_json(library_path, theme, "audio library.json", report) + manifest = _read_json(manifest_path, theme, "audio manifest.json", report) + if report.errors: return report - validate_schema(manifest, report) - refs = collect_referenced_streams(manifest) + resolved = _resolve_manifest(library, manifest) + validate_schema(resolved, report) + refs = collect_referenced_streams(resolved) check_assets(theme, refs, report) return report