feat(audio-tools): Introduce audio validation and subscription-friendly audio splitting utilities

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-04-30 01:27:46 -07:00
parent 8f7e0606ce
commit 5e92fb313d
2 changed files with 136 additions and 10 deletions

View file

@ -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/<theme>/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/<theme>/data/audio/manifest.json # subscription
{source: "resources/audio", includes: true, overrides: {}}
public/games/<theme>/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()

View file

@ -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