#!/usr/bin/env python3 """ check_wav.py — sanity-check a rendered WAV: duration, peak (clipping), RMS loudness, and dead-air (long silent stretches that usually mean a MIDI track failed to render). Usage: python3 tools/check_wav.py file.wav [expected_sec] Exit 0 = pass, 1 = fail. Designed for CI / cron use. """ import sys, wave, struct def analyse(path): w = wave.open(path) n, ch, sw = w.getnframes(), w.getnchannels(), w.getsampwidth() rate = w.getframerate() assert sw == 2, "expected 16-bit PCM, got %d-bit" % (sw * 8) samples = struct.unpack("<%dh" % (n * ch), w.readframes(n)) peak = max(abs(s) for s in samples) rms = (sum(s * s for s in samples) / len(samples)) ** 0.5 # dead air: longest run of frames where every channel < 64 silent_run = worst = 0 for i in range(0, len(samples), ch): if max(abs(s) for s in samples[i:i + ch]) < 64: silent_run += 1 worst = max(worst, silent_run) else: silent_run = 0 return {"sec": n / rate, "rate": rate, "peak": peak, "rms": rms, "max_silence_sec": worst / rate} def main(): path = sys.argv[1] expected = float(sys.argv[2]) if len(sys.argv) > 2 else None a = analyse(path) fails = [] if a["peak"] >= 32767: fails.append("CLIPPING (peak %d)" % a["peak"]) if a["rms"] < 300: fails.append("too quiet (rms %.0f)" % a["rms"]) if a["max_silence_sec"] > 8: fails.append("dead air: %.1fs silent stretch" % a["max_silence_sec"]) if expected and abs(a["sec"] - expected) > expected * 0.15 + 2: fails.append("duration %.1fs, expected ~%.1fs" % (a["sec"], expected)) print("%s: %.1fs peak=%d rms=%.0f max_silence=%.1fs" % (path, a["sec"], a["peak"], a["rms"], a["max_silence_sec"])) for f in fails: print(" FAIL:", f) return 1 if fails else 0 if __name__ == "__main__": sys.exit(main())