joeylib2/tools/gradeAudioPhases.py
2026-09-26 19:54:15 -05:00

128 lines
4.3 KiB
Python
Executable file

#!/usr/bin/env python3
"""Grade a recorded WAV against expected loudness phases.
Shared by scripts/verify-amiga-audio.sh and scripts/verify-dos-audio.sh.
Both ports emit sound through hardware no emulator here will let us tap
register by register (Paula via fs-uae, a Sound Blaster via DOSBox), so
those gates record what actually came out and grade it. The IIgs and ST
gates read their chips directly and do not need this.
The scripts drive the AUDIO example through four phases whose expected
loudness differs by a wide margin, which is what makes recorded audio
gradeable at all rather than a judgement call:
music the baked tracker song, loud
paused P pressed: near silence
sfx SPACE during the pause, so ANY sound is the digital SFX
resumed P again: loud once more
Firing the SFX into a paused song is the whole trick. Mixed under the
music an 8-bit one-shot barely moves the total, but against silence it is
unambiguous -- and it still proves the two layers coexist, because the
music comes back afterwards on the same hardware.
Usage:
gradeAudioPhases.py <label> <file.wav> <phase> [<phase> ...]
phase = name:startSec:endSec:minRms:maxRms
minRms or maxRms may be empty to skip that bound.
Exit code:
0 = every phase inside its bounds
1 = at least one phase out of bounds, or the file is unusable
2 = usage error
"""
import sys
import wave
def readMono(path):
"""-> (samples as a list of floats, sample rate). Averages channels."""
with wave.open(path, 'rb') as w:
channels = w.getnchannels()
width = w.getsampwidth()
rate = w.getframerate()
count = w.getnframes()
raw = w.readframes(count)
if width != 2:
raise ValueError(f"expected 16-bit samples, got {width * 8}-bit")
import array
data = array.array('h')
data.frombytes(raw[:(len(raw) // (2 * channels)) * 2 * channels])
if channels == 1:
return data, rate
mono = array.array('d', [0.0]) * (len(data) // channels)
for i in range(len(mono)):
total = 0
base = i * channels
for c in range(channels):
total += data[base + c]
mono[i] = total / channels
return mono, rate
def rms(samples, rate, startSec, endSec):
a = int(startSec * rate)
b = min(int(endSec * rate), len(samples))
if b <= a:
return None
total = 0.0
for i in range(a, b):
total += samples[i] * samples[i]
return (total / (b - a)) ** 0.5
def main(argv):
if len(argv) < 4:
print(__doc__, file=sys.stderr)
return 2
label = argv[1]
path = argv[2]
specs = argv[3:]
try:
samples, rate = readMono(path)
except Exception as exc: # noqa: BLE001 - reported, not handled
print(f"{label}: FAIL - cannot read {path}: {exc}", file=sys.stderr)
return 1
duration = len(samples) / float(rate)
if duration < 1.0:
print(f"{label}: FAIL - recorded only {duration:.2f}s of audio; the capture "
f"never attached to the emulator's output", file=sys.stderr)
return 1
fails = []
parts = []
for spec in specs:
bits = spec.split(':')
if len(bits) != 5:
print(f"{label}: bad phase spec {spec!r}", file=sys.stderr)
return 2
name, t0, t1, lo, hi = bits
level = rms(samples, rate, float(t0), float(t1))
if level is None:
fails.append(f"phase '{name}' ({t0}-{t1}s) is past the end of a "
f"{duration:.1f}s recording")
continue
parts.append(f"{name}={level:.0f}")
if lo and level < float(lo):
fails.append(f"phase '{name}' RMS {level:.0f} is below {lo}: expected sound "
f"here and got near-silence")
if hi and level > float(hi):
fails.append(f"phase '{name}' RMS {level:.0f} is above {hi}: expected near-silence "
f"here and got sound")
print(f"{label}: {duration:.1f}s recorded, RMS " + " ".join(parts))
for f in fails:
print(f"{label}: {f}", file=sys.stderr)
if fails:
print(f"{label}: FAIL", file=sys.stderr)
return 1
print(f"{label}: PASS")
return 0
if __name__ == '__main__':
sys.exit(main(sys.argv))