- 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>
156 lines
5.7 KiB
Python
Executable file
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())
|