191 lines
8.3 KiB
Python
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())
|