big
This commit is contained in:
@@ -0,0 +1,358 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user