joeylib2/scripts/lib/pspAudioGrade.py
2026-10-13 14:01:46 -05:00

124 lines
4.7 KiB
Python

#!/usr/bin/env python3
# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
# See LICENSE.md for license information.
# SPDX-License-Identifier: MIT
"""pspAudioGrade.py - grade what AUDIOPRB played on the PSP.
AUDIOPRB is examples/audio built to press its own keys (make/examples.mk):
the tracker song from the start, then S every PROBE_STREAM_FRAMES frames
from PROBE_STREAM_FIRST (and N once, whose noise sweep the streams cover).
Each S streams a falling square wave, each take STREAM_TAKE_HZ higher than
the last. scripts/verify-psp-audio.sh records what reached the PSP's audio
output (PPSSPP's own recording, 44.1 kHz stereo on the emulated clock), and
this grades it from the moment the music starts:
music the tracker song alone, before the first take: sound, and
quieter than a take
take N each of the first three takes, a little way in: loud, and at
its pitch, from the zero crossings -- so the PSP plays the mix
at the rate it was made for
end the recording stops within END_SLACK seconds of the program
being told to quit: it ran until then, and ended on it
pspAudioGrade.py LABEL WAV QUIT_SECONDS
QUIT_SECONDS is when (since the recording began) the gate pressed Select,
which the PSP port gives as Escape. Prints one line per check and PASS or
FAIL; exit 1 on a failure.
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "tools"))
from gradeAudioPhases import readMono, rms # noqa: E402
# examples/audio/audio.c: the probe's timetable and the streamed tone.
FRAME_HZ = 60
PROBE_STREAM_FIRST = 30
PROBE_STREAM_FRAMES = 90
STREAM_RATE_HZ = 8000
STREAM_BLOCK = 256
STREAM_BLOCKS = 48
STREAM_START_HZ = 330
STREAM_DROP_HZ = 220
STREAM_TAKE_HZ = 55
TAKES = 3
# The music starts when its first sample rises above ONSET_LEVEL.
ONSET_LEVEL = 200
MUSIC_WINDOW = (0.05, 0.40)
MUSIC_RMS = (1000, 8000)
# A take is measured from TAKE_WINDOW seconds after it is due to start: far
# enough in to miss the audio queue's latency, early in its fall.
TAKE_WINDOW = (0.10, 0.20)
TAKE_MIN_RMS = 15000
PITCH_TOLERANCE = 0.10
END_SLACK = 0.5
def crossingsPerSecond(samples, rate, start, end):
a = int(start * rate)
b = min(int(end * rate), len(samples))
count = 0
for i in range(a + 1, b):
if (samples[i - 1] < 0) != (samples[i] < 0):
count += 1
return count / ((b - a) / rate)
def takeHz(take, seconds):
"""The pitch take is at, seconds after it starts."""
block = int(seconds * STREAM_RATE_HZ) // STREAM_BLOCK
return STREAM_START_HZ + STREAM_TAKE_HZ * take - STREAM_DROP_HZ * block / STREAM_BLOCKS
def main(argv):
if len(argv) != 4:
print(__doc__, file=sys.stderr)
return 2
label, path, quitSeconds = argv[1], argv[2], float(argv[3])
samples, rate = readMono(path)
duration = len(samples) / rate
onset = next((i / rate for i, v in enumerate(samples) if abs(v) > ONSET_LEVEL), None)
if onset is None:
print(f"{label}: FAIL - {duration:.1f}s recorded and all of it silent", file=sys.stderr)
return 1
fails = []
level = rms(samples, rate, onset + MUSIC_WINDOW[0], onset + MUSIC_WINDOW[1])
print(f"{label}: music from {onset:.2f}s, RMS {level:.0f}")
if level is None or not MUSIC_RMS[0] <= level <= MUSIC_RMS[1]:
fails.append(f"music RMS {level}, want {MUSIC_RMS[0]}-{MUSIC_RMS[1]}")
for take in range(TAKES):
due = onset + (PROBE_STREAM_FIRST + PROBE_STREAM_FRAMES * take) / FRAME_HZ
start = due + TAKE_WINDOW[0]
end = due + TAKE_WINDOW[1]
level = rms(samples, rate, start, end)
if level is None:
fails.append(f"take {take + 1} is past the end of the recording")
continue
hz = crossingsPerSecond(samples, rate, start, end) / 2
want = takeHz(take, (TAKE_WINDOW[0] + TAKE_WINDOW[1]) / 2)
print(f"{label}: take {take + 1} at {start:.2f}s: RMS {level:.0f}, {hz:.0f} Hz (want {want:.0f})")
if level < TAKE_MIN_RMS:
fails.append(f"take {take + 1} RMS {level:.0f}, want at least {TAKE_MIN_RMS}")
if abs(hz - want) > want * PITCH_TOLERANCE:
fails.append(f"take {take + 1} at {hz:.0f} Hz, want {want:.0f}")
print(f"{label}: the recording ends at {duration:.2f}s, Select at {quitSeconds:.2f}s")
if abs(duration - quitSeconds) > END_SLACK:
fails.append(f"the recording ends at {duration:.2f}s, not within {END_SLACK}s of Select at {quitSeconds:.2f}s")
for f in fails:
print(f"{label}: FAIL - {f}", file=sys.stderr)
if fails:
return 1
print(f"{label}: PASS")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))