Replaces the April wreckage (fifteen 70-byte 'working' MIDIs, archived) with a proper pipeline: - lib/midikit.py: stdlib-only SMF format-1 writer + reader/verifier - lib/songkit.py: composition helpers (bar-grid score DSL with a 4/4 bar linter, OPL arps, driving bass, vibrato, drum patterns) - tunes/: three original tunes - DOS adventure theme (Dm/132), N64 ambient (G/72), chiptune racing (Em/152) - all with LOOP_START/ LOOP_END markers for game engines - tools/build_all.sh + tools/check_wav.py: generate, verify, render via timidity+FluidR3, fail on clipping/dead air - tests/smoke_test.py: round-trip verified against mido All three tunes render clean: no clipping, no dead air, durations match, chromaticism audited against the chord chart.
55 lines
1.9 KiB
Python
55 lines
1.9 KiB
Python
#!/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())
|