Files
aetherbound-guild/tools/verify_fg4_audio.py
T
2026-08-17 05:33:27 +08:00

191 lines
8.3 KiB
Python

#!/usr/bin/env python3
"""Machine-check FG-4 PCM assets and their semantic event contract."""
from __future__ import annotations
import argparse
import array
import json
import math
import sys
import wave
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_MANIFEST = ROOT / "runtime/assets/fg4/audio-manifest.json"
EXPECTED_EVENTS = {
"route_committed": ("FG4-SFX-COMMIT", 2, 0.18, True),
"plan_committed": ("FG4-SFX-COMMIT", 2, 0.18, True),
"allied_basic": ("FG4-SFX-IMPACT", 4, 0.09, False),
"wave_arrived": ("FG4-SFX-FORECAST", 1, 0.22, True),
"plan_response": ("FG4-SFX-COMMIT", 2, 0.18, True),
"damage": ("FG4-SFX-IMPACT", 4, 0.09, False),
"fallen": ("FG4-SFX-CASUALTY", 0, 0.40, True),
"outcome": ("FG4-SFX-VICTORY", 2, 0.60, True),
"recovery": ("FG4-SFX-RECOVERY", 2, 0.30, True),
"pressure_valve_disclosed": ("FG4-SFX-VALVE", 1, 0.24, True),
"construct_state_changed": ("FG4-SFX-VALVE", 2, 0.22, True),
"delayed_heat_forecast": ("FG4-SFX-HEAT", 1, 0.34, True),
"item_cooldown_forecast": ("FG4-SFX-FORECAST", 1, 0.24, True),
"side_swap_forecast": ("FG4-SFX-FORECAST", 1, 0.24, True),
"weak_side_vented": ("FG4-SFX-VALVE", 2, 0.24, True),
"strong_side_burst": ("FG4-SFX-IMPACT", 1, 0.26, True),
"repeat_counter_disclosed": ("FG4-SFX-SPARK", 1, 0.24, True),
"next_spark_disclosed": ("FG4-SFX-SPARK", 1, 0.24, True),
"spark_promoted": ("FG4-SFX-PROMOTION", 1, 0.34, True),
"spark_removed": ("FG4-SFX-SPARK", 2, 0.22, True),
"repeat_counter_reset": ("FG4-SFX-GUARD", 2, 0.22, True),
"archive_clause_disclosed": ("FG4-SFX-CLAUSE", 1, 0.24, True),
"copy_source_disclosed": ("FG4-SFX-COPY", 1, 0.24, True),
"archive_clause_obeyed": ("FG4-SFX-CLAUSE", 2, 0.20, True),
"archive_clause_broken": ("FG4-SFX-REDACTION", 1, 0.24, True),
"action_copy_resolved": ("FG4-SFX-COPY", 2, 0.22, True),
"target_redirect_resolved": ("FG4-SFX-REPLAY", 2, 0.22, True),
"cleanse_trap_resolved": ("FG4-SFX-CLAUSE", 2, 0.22, True),
"clause_evaluation_resolved": ("FG4-SFX-CLAUSE", 2, 0.22, True),
"redaction_source_disclosed": ("FG4-SFX-REDACTION", 1, 0.26, True),
"target_clause_omitted": ("FG4-SFX-REDACTION", 1, 0.26, True),
"replay_order_disclosed": ("FG4-SFX-REPLAY", 1, 0.24, True),
"index_pointer_disclosed": ("FG4-SFX-REPLAY", 1, 0.24, True),
"action_count_steered": ("FG4-SFX-CLAUSE", 2, 0.22, True),
"index_pointer_interrupted": ("FG4-SFX-REDACTION", 1, 0.24, True),
"safe_rule_seeded": ("FG4-SFX-CLAUSE", 1, 0.24, True),
"replay_order_changed": ("FG4-SFX-REPLAY", 1, 0.24, True),
"dangerous_replay_interrupted": ("FG4-SFX-REDACTION", 1, 0.24, True),
}
def resource_path(value: str) -> Path:
if not value.startswith("res://"):
raise ValueError(f"invalid resource path: {value}")
return ROOT / "runtime" / value[6:]
def inspect(asset: dict, manifest: dict) -> tuple[dict, list[str]]:
path = resource_path(str(asset["path"]))
errors: list[str] = []
if not path.is_file():
return {"id": asset["id"], "path": asset["path"]}, [f"missing {asset['id']}"]
with wave.open(str(path), "rb") as wav_file:
channels = wav_file.getnchannels()
width = wav_file.getsampwidth()
rate = wav_file.getframerate()
frames = wav_file.getnframes()
raw = wav_file.readframes(frames)
samples = array.array("h")
samples.frombytes(raw)
if sys.byteorder != "little":
samples.byteswap()
normalized = [sample / 32768.0 for sample in samples]
peak = max((abs(value) for value in normalized), default=0.0)
peak_dbfs = 20.0 * math.log10(max(peak, 1.0e-12))
rms = math.sqrt(sum(value * value for value in normalized) / max(1, len(normalized)))
rms_dbfs = 20.0 * math.log10(max(rms, 1.0e-12))
silence_ratio = sum(abs(value) < 0.0015 for value in normalized) / max(1, len(normalized))
dc_offset = abs(sum(normalized) / max(1, len(normalized)))
clipped = sum(abs(sample) >= 32760 for sample in samples)
duration = frames / rate if rate else 0.0
edge_delta = abs(normalized[0] - normalized[-1]) if normalized else 0.0
if channels != int(manifest["channels"]):
errors.append(f"{asset['id']} channels {channels}")
if width != int(manifest["sample_width_bytes"]):
errors.append(f"{asset['id']} width {width}")
if rate != int(manifest["sample_rate"]):
errors.append(f"{asset['id']} rate {rate}")
if abs(duration - float(asset["duration_seconds"])) > float(asset["duration_tolerance"]):
errors.append(f"{asset['id']} duration {duration:.6f}")
if not float(asset["peak_dbfs_min"]) <= peak_dbfs <= float(asset["peak_dbfs_max"]):
errors.append(f"{asset['id']} peak {peak_dbfs:.3f}")
if silence_ratio > float(asset["silence_ratio_max"]):
errors.append(f"{asset['id']} silence {silence_ratio:.4f}")
if dc_offset > 0.02:
errors.append(f"{asset['id']} dc {dc_offset:.5f}")
if clipped:
errors.append(f"{asset['id']} clipped {clipped}")
if bool(asset["loop"]) and edge_delta > float(asset["loop_edge_delta_max"]):
errors.append(f"{asset['id']} loop edge {edge_delta:.6f}")
return {
"id": asset["id"],
"path": asset["path"],
"bus": asset["bus"],
"loop": asset["loop"],
"channels": channels,
"sample_width_bytes": width,
"sample_rate": rate,
"frames": frames,
"duration_seconds": round(duration, 6),
"peak_dbfs": round(peak_dbfs, 4),
"rms_dbfs": round(rms_dbfs, 4),
"silence_ratio": round(silence_ratio, 6),
"dc_offset": round(dc_offset, 7),
"clipped_samples": clipped,
"loop_edge_delta": round(edge_delta, 7),
}, errors
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
parser.add_argument("--output", type=Path)
args = parser.parse_args()
manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
errors: list[str] = []
if manifest.get("kind") != "abg-fg4-audio-manifest":
errors.append("unexpected manifest kind")
sfx_rule = manifest.get("sfx", {})
if float(sfx_rule.get("voice_volume_db", 0.0)) != -3.0:
errors.append("SFX voice gain must reserve exactly -3 dB headroom")
event_rows = manifest.get("events", {})
if set(event_rows) != set(EXPECTED_EVENTS):
errors.append(f"event set mismatch: {sorted(event_rows)}")
asset_ids = {asset["id"] for asset in manifest.get("assets", [])}
if len(asset_ids) != 19:
errors.append(f"asset count {len(asset_ids)}")
for event_id, row in event_rows.items():
expected = EXPECTED_EVENTS.get(event_id)
if expected is None:
continue
asset_id, priority, cooldown, duck = expected
for key, value in {
"asset_id": asset_id,
"priority": priority,
"cooldown_seconds": cooldown,
"duck": duck,
}.items():
if row.get(key) != value:
errors.append(f"{event_id} {key} contract mismatch")
for key in ("asset_id", "guard_asset_id"):
if key in row and row[key] not in asset_ids:
errors.append(f"{event_id} missing reference {row[key]}")
damage = event_rows.get("damage", {})
if damage.get("guard_asset_id") != "FG4-SFX-GUARD" or damage.get("guard_priority") != 3:
errors.append("damage guard contract mismatch")
records = []
for asset in manifest.get("assets", []):
record, asset_errors = inspect(asset, manifest)
records.append(record)
errors.extend(asset_errors)
report = {
"version": 1,
"kind": "abg-fg4-audio-validation-report",
"ok": not errors,
"errors": errors,
"assets": records,
"events": sorted(event_rows),
}
encoded = json.dumps(report, ensure_ascii=True, indent=2, sort_keys=True) + "\n"
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(encoded, encoding="utf-8")
if errors:
for error in errors:
print(f"FG4_AUDIO_ERROR {error}")
return 1
print("ABG_FG4_AUDIO_ASSET_OK assets=19 events=38 wav=pcm16 mono=true rate=22050 loops=4 clipping=0 voice_gain=-3db dc=true silence=true seam=true paid_calls=0")
return 0
if __name__ == "__main__":
raise SystemExit(main())