124 lines
4.7 KiB
Python
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))
|