#!/usr/bin/env python """Release gate for runtime audio assets. Checks format, headroom, clipping, DC offset, loop continuity and event-family coverage. It intentionally uses only the Python standard library plus ffprobe, matching the rest of the repository's tooling. """ from __future__ import annotations import argparse import json import math import os import shutil import statistics import struct import subprocess import sys import tempfile import wave from pathlib import Path ROOT = Path(__file__).resolve().parents[1] SOUNDS = ROOT / "assets" / "sounds" FFPROBE = shutil.which("ffprobe") FFMPEG = shutil.which("ffmpeg") FAMILIES = { "weapon_ak47": 3, "weapon_m4": 3, "weapon_mp7": 3, "weapon_dmr": 2, "weapon_awp": 2, "weapon_shotgun": 2, "weapon_nailgun": 2, "weapon_plasma": 2, "weapon_rocket": 1, "weapon_swarm": 2, "weapon_mortar": 1, "weapon_knife": 3, "weapon_reload": 1, "explosion_deep": 5, "impact_bullet": 3, "footstep_concrete": 4, "footstep_metal": 4, "footstep_wood": 4, "footstep_glass": 4, "ui_hover_premium": 2, "ui_click_premium": 2, "ui_confirm_premium": 1, "ui_kill_premium": 1, "ui_error_premium": 1, "ui_equip_premium": 1, "movement_grapple_launch": 1, "movement_grapple_latch": 1, } LOOPS = { "movement_wind.wav": 2, "movement_slide_concrete.wav": 2, "movement_slide_metal.wav": 2, "movement_slide_wood.wav": 2, "movement_slide_glass.wav": 2, "movement_wallrun.wav": 2, "movement_grapple_reel.wav": 2, "projectile_rocket_loop.wav": 1, "projectile_swarm_loop.wav": 1, } ONESHOTS = ( "movement_dash_00.wav", "movement_jump_00.wav", "movement_double_jump_00.wav", "movement_vault_00.wav", "movement_land_00.wav", "movement_grapple_launch_00.wav", "movement_grapple_latch_00.wav", ) def db(value: float) -> float: return 20.0 * math.log10(max(value, 1.0e-12)) def wav_metrics(path: Path) -> dict[str, float | int]: with wave.open(str(path), "rb") as source: channels = source.getnchannels() rate = source.getframerate() width = source.getsampwidth() count = source.getnframes() if width != 2: raise ValueError(f"{path.name}: expected 16-bit PCM, got {width * 8}-bit") raw = source.readframes(count) samples = struct.unpack("<" + "h" * (len(raw) // 2), raw) mono: list[float] = [] for frame in range(count): start = frame * channels mono.append(sum(samples[start:start + channels]) / (32768.0 * channels)) peak = max((abs(v) for v in mono), default=0.0) mean = sum(mono) / max(1, count) rms = math.sqrt(sum(v * v for v in mono) / max(1, count)) diffs = [mono[i] - mono[i - 1] for i in range(1, count)] diff_rms = math.sqrt(sum(v * v for v in diffs) / max(1, len(diffs))) sorted_diff = sorted(abs(v) for v in diffs) typical_diff = sorted_diff[len(sorted_diff) // 2] if sorted_diff else 0.0 seam = abs(mono[-1] - mono[0]) if mono else 0.0 clipped = sum(1 for value in samples if abs(value) >= 32767) return { "channels": channels, "rate": rate, "duration": count / rate, "peak_db": db(peak), "rms_db": db(rms), "crest_db": db(peak / max(rms, 1.0e-12)), "dc": abs(mean), "clip_samples": clipped, "roughness": diff_rms / max(rms, 1.0e-12), "seam": seam, "seam_ratio": seam / max(typical_diff, 1.0e-6), } def inspect_music(path: Path) -> dict: if not FFPROBE: raise RuntimeError("ffprobe is required to inspect Ogg music") result = subprocess.run([ FFPROBE, "-v", "error", "-show_entries", "format=duration:stream=sample_rate,channels", "-of", "json", str(path), ], check=True, capture_output=True, text=True) return json.loads(result.stdout) def spectral_metrics(path: Path) -> dict[str, float]: """Active-frame spectral distribution from FFmpeg's spectrum analyzer.""" if not FFMPEG: raise RuntimeError("ffmpeg is required for spectral audio gates") result = subprocess.run([ FFMPEG, "-hide_banner", "-nostats", "-v", "error", "-i", str(path), "-af", "aspectralstats=measure=centroid+flatness,ametadata=print:file=-", "-f", "null", os.devnull, ], check=True, capture_output=True, text=True) centroids: list[float] = [] flatness: list[float] = [] for line in (result.stdout + result.stderr).splitlines(): if "centroid=" in line: value = float(line.rsplit("=", 1)[1]) if value > 20.0: centroids.append(value) elif "flatness=" in line: value = float(line.rsplit("=", 1)[1]) if value > 0.0: flatness.append(value) if not centroids or not flatness: raise RuntimeError(f"{path.name}: no active spectral frames") centroids.sort() flatness.sort() def percentile(values: list[float], amount: float) -> float: index = min(len(values) - 1, round((len(values) - 1) * amount)) return values[index] return { "centroid": statistics.median(centroids), "centroid_p90": percentile(centroids, 0.90), "flatness": statistics.median(flatness), "flatness_p90": percentile(flatness, 0.90), } def music_metrics(path: Path) -> dict[str, float | int]: if not FFMPEG: raise RuntimeError("ffmpeg is required to inspect the music loop seam") with tempfile.TemporaryDirectory(prefix="papaya-music-audit-") as folder: decoded = Path(folder) / "music.wav" subprocess.run([ FFMPEG, "-hide_banner", "-loglevel", "error", "-y", "-i", str(path), "-ar", "48000", "-ac", "2", "-c:a", "pcm_s16le", str(decoded), ], check=True) return wav_metrics(decoded) def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--verbose", action="store_true") args = parser.parse_args() failures: list[str] = [] checked: set[Path] = set() metrics_by_name: dict[str, dict[str, float | int]] = {} for family, minimum in FAMILIES.items(): matches = sorted(SOUNDS.glob(f"{family}_[0-9][0-9].wav")) if len(matches) < minimum: failures.append( f"{family}: needs {minimum} variations, found {len(matches)}") checked.update(matches) for filename, channels in LOOPS.items(): path = SOUNDS / filename if not path.exists(): failures.append(f"{filename}: missing loop") continue checked.add(path) metrics = wav_metrics(path) if metrics["channels"] != channels: failures.append( f"{filename}: expected {channels} channels, got {metrics['channels']}") if metrics["duration"] < 3.5: failures.append(f"{filename}: loop shorter than 3.5 seconds") if filename == "movement_wind.wav" and metrics["duration"] < 40.0: failures.append( f"{filename}: speed-wind cycle is still perceptibly short " f"({metrics['duration']:.1f}s)") if metrics["seam_ratio"] > 6.0: failures.append( f"{filename}: audible loop seam ratio {metrics['seam_ratio']:.2f}") for filename in ONESHOTS: path = SOUNDS / filename if not path.exists(): failures.append(f"{filename}: missing movement one-shot") else: checked.add(path) for path in sorted(checked): try: metrics = wav_metrics(path) except (wave.Error, ValueError) as exc: failures.append(str(exc)) continue metrics_by_name[path.name] = metrics if metrics["rate"] != 48_000: failures.append(f"{path.name}: expected 48 kHz, got {metrics['rate']}") if metrics["peak_db"] > -1.0: failures.append(f"{path.name}: unsafe peak {metrics['peak_db']:.2f} dBFS") if metrics["clip_samples"]: failures.append( f"{path.name}: {metrics['clip_samples']} clipped PCM samples") if metrics["dc"] > 0.012: failures.append(f"{path.name}: DC offset {metrics['dc']:.4f}") # High first-difference energy catches the old raw/screaming noise beds. if path.name in LOOPS and metrics["roughness"] > 1.45: failures.append( f"{path.name}: harsh loop roughness {metrics['roughness']:.2f}") if args.verbose: print( f"{path.name:34} peak {metrics['peak_db']:6.1f} " f"rms {metrics['rms_db']:6.1f} crest {metrics['crest_db']:5.1f} " f"rough {metrics['roughness']:4.2f} seam {metrics['seam_ratio']:4.2f}") # Constant friction/motor loops must remain materially structured. Wind is # exempt from flatness because genuine turbulent air is broadband by nature. for filename in LOOPS: if filename == "movement_wind.wav": continue try: spectrum = spectral_metrics(SOUNDS / filename) if spectrum["flatness"] > 0.48: failures.append( f"{filename}: noise-like spectral flatness " f"{spectrum['flatness']:.2f}") if spectrum["centroid_p90"] > 3_200.0: failures.append( f"{filename}: grating upper-spectrum centroid " f"{spectrum['centroid_p90']:.0f} Hz") if args.verbose: print( f"{filename:34} centroid {spectrum['centroid']:7.0f} Hz " f"p90 {spectrum['centroid_p90']:7.0f} Hz " f"flatness {spectrum['flatness']:.3f}/" f"{spectrum['flatness_p90']:.3f}") except (RuntimeError, ValueError, subprocess.CalledProcessError) as exc: failures.append(str(exc)) # The criticized high/childish families stay physically low, while the # explosion family must sit clearly above firearms before bus processing. low_tone_files = [ *sorted(SOUNDS.glob("ui_*_premium_[0-9][0-9].wav")), SOUNDS / "movement_dash_00.wav", SOUNDS / "movement_jump_00.wav", SOUNDS / "movement_double_jump_00.wav", SOUNDS / "movement_grapple_launch_00.wav", SOUNDS / "movement_grapple_latch_00.wav", ] for path in low_tone_files: try: spectrum = spectral_metrics(path) if spectrum["centroid"] > 4_200.0: failures.append( f"{path.name}: high-pitched centroid " f"{spectrum['centroid']:.0f} Hz") if spectrum["centroid_p90"] > 2_800.0: failures.append( f"{path.name}: harsh transient centroid " f"{spectrum['centroid_p90']:.0f} Hz") roughness = float(metrics_by_name[path.name]["roughness"]) if roughness > 0.18: failures.append( f"{path.name}: grating transient roughness {roughness:.2f}") if args.verbose: print( f"{path.name:34} tone {spectrum['centroid']:7.0f} Hz " f"p90 {spectrum['centroid_p90']:7.0f} Hz") except (RuntimeError, ValueError, subprocess.CalledProcessError) as exc: failures.append(str(exc)) explosion_rms = [ float(values["rms_db"]) for name, values in metrics_by_name.items() if name.startswith("explosion_deep_") ] firearm_rms = [ float(values["rms_db"]) for name, values in metrics_by_name.items() if name.startswith("weapon_") and "reload" not in name ] if explosion_rms and firearm_rms: if statistics.median(explosion_rms) < statistics.median(firearm_rms) + 4.0: failures.append( "explosions lack the required 4 dB median body advantage") if max(explosion_rms) < -15.0: failures.append("explosion family is still too quiet") music = SOUNDS / "music_menu_premium.ogg" if not music.exists(): failures.append("music_menu_premium.ogg: missing") else: try: info = inspect_music(music) stream = info["streams"][0] duration = float(info["format"]["duration"]) if int(stream["sample_rate"]) != 48_000 or int(stream["channels"]) != 2: failures.append("music_menu_premium.ogg: expected 48 kHz stereo") if duration < 25.0: failures.append( f"music_menu_premium.ogg: too short ({duration:.1f}s)") metrics = music_metrics(music) if metrics["seam_ratio"] > 6.0: failures.append( "music_menu_premium.ogg: audible loop seam " f"ratio {metrics['seam_ratio']:.2f}") if metrics["clip_samples"] or metrics["peak_db"] > -1.0: failures.append("music_menu_premium.ogg: unsafe decoded peak") except (RuntimeError, KeyError, ValueError, subprocess.CalledProcessError) as exc: failures.append(f"music_menu_premium.ogg: {exc}") print( f"AUDIO_AUDIT files={len(checked) + int(music.exists())} " f"families={len(FAMILIES)} loops={len(LOOPS)} failures={len(failures)}") for failure in failures: print("FAIL:", failure) if failures: return 1 print("AUDIO_AUDIT PASSED") return 0 if __name__ == "__main__": sys.exit(main())