128 lines
4.3 KiB
Python
Executable file
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))
|