Files
Papay-Shooter/tools/audio_audit.py
T
2026-08-02 02:20:02 -04:00

359 lines
13 KiB
Python

#!/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())