359 lines
13 KiB
Python
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())
|