magicciv/tools/audio-generate-manifest.py
autocommit 61ba6298af feat(audio): generated audio.json manifest + ledger cleanup (p2-16)
- audio-generate-manifest.py: derive data/audio.json from library + subscription (SSoT, not hand-authored), drift-gated in audio-validate.py
- sources.csv: pruned 13 corrupt rows (now 106 == on-disk files); audio-licenses-render guard rejects non-audio/ paths
- all 106 streams resolve, schema-valid; unblocks guide @data/audio.json import
- p2-16: held in_progress pending human listen-test

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 04:40:23 -07:00

156 lines
5.7 KiB
Python
Executable file

#!/usr/bin/env python3
"""Generate the resolved per-theme audio manifest `data/audio.json`.
`public/games/<theme>/data/audio.json` is a DERIVED artifact — the single
flat manifest a consumer gets after the subscription model resolves. It is
NOT a source of truth; never hand-edit it. The sources of truth are:
* `public/resources/audio/library.json` — cross-theme SFX + music library
* `public/games/<theme>/data/audio/manifest.json` — per-theme subscription
({ source, includes: true|[...], overrides: {...} })
* `public/games/<theme>/data/audio/pools.json` — per-theme music pools
({ default_track_id, crossfade_seconds, victory_pool, defeat_pool })
This script mirrors `AudioManager._apply_subscription` (audio_manager.gd):
filter the library `sfx` + `music.tracks` by the manifest `includes`, apply
per-key `overrides`, then merge the four `pools.json` fields into the `music`
object. The result is a single object conforming to
`data/schemas/audio.schema.json` (schema_version, sfx, music — the same shape
the guide's AssetPipeline.tsx and the formal validator already understand).
Two modes:
* No flag → (re)write `data/audio.json` from the three sources.
* `--check` → render to a buffer and compare against the committed
`data/audio.json`. Non-zero diff or a missing file fails. Suitable for
`./run validate` / CI. (audio-validate.py also performs this drift check
inline when the file is present.)
Usage:
python3 tools/audio-generate-manifest.py [--check] [--theme age-of-dwarves]
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
DEFAULT_THEME = "age-of-dwarves"
# Pools fields that live inside the schema's `music` object.
POOL_KEYS = ("default_track_id", "crossfade_seconds", "victory_pool", "defeat_pool")
def _read_json(path: Path) -> dict:
if not path.exists():
raise FileNotFoundError(f"required source not found: {path}")
return json.loads(path.read_text(encoding="utf-8"))
def resolve_manifest(library: dict, manifest: dict, pools: dict) -> dict:
"""Resolve library + subscription + pools into one flat manifest.
Mirrors AudioManager._apply_subscription + pools merge. The output is the
legacy single-file `audio.json` shape the schema validates against.
"""
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)
# Merge the pools.json fields into the music object (schema permits
# exactly tracks / crossfade_seconds / default_track_id / victory_pool /
# defeat_pool, additionalProperties:false).
for k in POOL_KEYS:
if k in pools:
out["music"][k] = pools[k]
return out
def render(theme: str) -> str:
theme_data = REPO / "public" / "games" / theme / "data"
library = _read_json(REPO / "public" / "resources" / "audio" / "library.json")
manifest = _read_json(theme_data / "audio" / "manifest.json")
pools = _read_json(theme_data / "audio" / "pools.json")
resolved = resolve_manifest(library, manifest, pools)
return json.dumps(resolved, indent=2, ensure_ascii=True) + "\n"
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--check", action="store_true",
help="Diff the rendered manifest against the committed "
"data/audio.json; exit non-zero on drift or absence.",
)
parser.add_argument(
"--theme", default=DEFAULT_THEME,
help=f"Theme id under public/games/. Default: {DEFAULT_THEME}",
)
args = parser.parse_args()
out_path = REPO / "public" / "games" / args.theme / "data" / "audio.json"
try:
rendered = render(args.theme)
except FileNotFoundError as e:
print(f"[{args.theme}] {e}", file=sys.stderr)
return 1
except json.JSONDecodeError as e:
print(f"[{args.theme}] source JSON parse error: {e}", file=sys.stderr)
return 1
if args.check:
committed = out_path.read_text(encoding="utf-8") if out_path.exists() else ""
if committed != rendered:
print(
f"[{args.theme}] data/audio.json is out of date relative to "
f"library.json + audio/manifest.json + audio/pools.json. Run "
f"`python3 tools/audio-generate-manifest.py` and commit the "
f"result.",
file=sys.stderr,
)
return 1
print(f"[{args.theme}] data/audio.json is in sync. OK")
return 0
out_path.write_text(rendered, encoding="utf-8")
print(f"[{args.theme}] wrote {out_path}")
return 0
if __name__ == "__main__":
sys.exit(main())