Files
aetherbound-guild/tools/audio/build_runtime_manifest.py
T

143 lines
4.8 KiB
Python

#!/usr/bin/env python3
"""Generate `runtime/assets/audio/audio_manifest.json`.
Maps every contract SFX event (docs/04_ART_ANIMATION_AUDIO.md section 8) to its
generated candidate file plus bus, priority class and cooldown, and maps the
non-battle music families to their base beds.
Bus and priority assignment follows the contract's section 3 mix tree and 3.1
priority classes; per-event overrides cover the P0/P1/P2 semantics that cannot
be inferred from the family alone.
"""
from __future__ import annotations
import json
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
CONTRACT = ROOT / "docs/04_ART_ANIMATION_AUDIO.md"
SFX_DIR = ROOT / "runtime/assets/audio/sfx"
MUSIC_DIR = ROOT / "runtime/assets/audio/music"
TARGET = ROOT / "runtime/assets/audio/audio_manifest.json"
FAMILY_BUS = {
"UI": "SFXUI",
"SHP": "SFXPlayerCommit",
"EQP": "SFXPlayerCommit",
"ORD": "SFXPlayerCommit",
"RES": "SFXCritical",
"RUN": "SFXWorld",
"BAT": "SFXCombat",
}
FAMILY_PRIORITY = {
"UI": "P4",
"SHP": "P3",
"EQP": "P3",
"ORD": "P3",
"RES": "P2",
"RUN": "P3",
"BAT": "P4",
}
FAMILY_COOLDOWN_MS = {
"UI": 60,
"SHP": 250,
"EQP": 150,
"ORD": 80,
"RES": 400,
"RUN": 150,
"BAT": 60,
}
# P0 never suppressed; P1 tactical; P2 player commits.
OVERRIDES: dict[str, dict] = {
"SFX-UI-018": {"priority": "P2", "cooldown_ms": 120},
"SFX-UI-021": {"priority": "P0", "cooldown_ms": 400},
"SFX-SHP-008": {"priority": "P2"},
"SFX-SHP-015": {"priority": "P2"},
"SFX-SHP-020": {"priority": "P0"},
"SFX-EQP-007": {"priority": "P2"},
"SFX-EQP-012": {"priority": "P2"},
"SFX-ORD-006": {"priority": "P2"},
"SFX-ORD-020": {"priority": "P2"},
"SFX-RUN-003": {"priority": "P2"},
"SFX-RUN-016": {"priority": "P2"},
"SFX-RUN-018": {"priority": "P1"},
"SFX-RUN-023": {"priority": "P1"},
"SFX-RUN-025": {"priority": "P0"},
"SFX-RES-002": {"priority": "P1"},
"SFX-RES-004": {"priority": "P1"},
"SFX-RES-011": {"priority": "P2"},
"SFX-RES-012": {"priority": "P1"},
"SFX-RES-013": {"priority": "P1"},
"SFX-RES-019": {"priority": "P2"},
}
MUSIC_FAMILIES = {
"title": ("MUS-G00-TITLE-base.mp3", "return title layer", "G00"),
"guild": ("MUS-G01-GUILD-base.mp3", "guild base", "G01"),
"market": ("MUS-G02-SHOP-base.mp3", "browse base", "G02"),
"prep": ("MUS-G03-PREP-base.mp3", "recruit/equipment/order base", "G03"),
"run": ("MUS-G04-RUN-base.mp3", "run overview base", "G04"),
"result_win": ("MUS-G05-RESULT-WIN-base.mp3", "survival statement", "G05"),
"result_loss": ("MUS-G06-RESULT-LOSS-base.mp3", "consequence statement", "G06"),
}
def contract_events() -> dict[str, str]:
names: dict[str, str] = {}
for line in CONTRACT.read_text().splitlines():
match = re.match(r"^(SFX-([A-Z]+)-\d{3})\s+(\S+)\s*$", line.strip())
if match and match.group(2) in FAMILY_BUS:
names[match.group(1)] = match.group(3)
return names
def find_file(event_id: str, event_name: str) -> str | None:
preferred = SFX_DIR / f"{event_id}-{event_name}.v01.mp3"
if preferred.exists():
return preferred.name
matches = sorted(SFX_DIR.glob(f"{event_id}-*.v*.mp3"))
return matches[0].name if matches else None
def main() -> int:
events = contract_events()
items: dict[str, dict] = {}
missing: list[str] = []
resolved_ids: set[str] = set()
for event_id, event_name in sorted(events.items()):
family = event_id.split("-")[1]
filename = find_file(event_id, event_name)
if filename is None:
missing.append(event_id)
continue
entry = {
"event": event_id,
"name": event_name,
"file": f"sfx/{filename}",
"bus": FAMILY_BUS[family],
"priority": FAMILY_PRIORITY[family],
"cooldown_ms": FAMILY_COOLDOWN_MS[family],
}
entry.update(OVERRIDES.get(event_id, {}))
items[event_name] = entry
resolved_ids.add(event_id)
music = {}
for family, (filename, role, suite) in MUSIC_FAMILIES.items():
path = MUSIC_DIR / filename
if path.exists():
music[family] = {"file": f"music/{filename}", "bus": "MusicBed", "role": role, "suite": suite}
battle_deferred = sorted(set(events) - resolved_ids)
manifest = {
"version": 1,
"music": {"crossfade_seconds": 0.9, "families": music},
"sfx": {"max_simultaneous": 12, "deferred_family": "BAT", "deferred_events": battle_deferred},
"events": items,
}
TARGET.write_text(json.dumps(manifest, ensure_ascii=False, indent=1) + "\n")
print(f"events={len(items)} music_families={len(music)} battle_deferred={len(battle_deferred)} unresolved={missing}")
return 0
if __name__ == "__main__":
raise SystemExit(main())