515 lines
26 KiB
Python
515 lines
26 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
|
|
"""Grade a recording of AUDPAR (examples/audpar) against the audio contract.
|
|
|
|
AUDPAR plays one timeline and logs when each step started
|
|
("AUDPAR <ms> <event> <values>"). This finds the timeline in the recording
|
|
by its two sync tones (1 kHz, at the start and the end, so an emulator whose
|
|
clock runs a little fast or slow is mapped straight), cuts each step out of
|
|
it, and measures it:
|
|
|
|
tone, voice the fundamental within PITCH_TOLERANCE of the asked Hz
|
|
voice levels voice 0's attenuation steps 2 dB apart, LEVEL_TOLERANCE
|
|
voices 1, 2 their pitch, and as loud as voice 0 (VOICE_MATCH_DB)
|
|
noise the generator's rate, 125000 / pitch Hz: read off the
|
|
first spectral null where every machine's output passes
|
|
it (NOISE_GRADED_HZ, within NOISE_TOLERANCE), darker as
|
|
the pitch rises everywhere, and its steps 2 dB apart
|
|
sfx a sine of 8 samples a cycle sounds rate / 8 Hz, starting
|
|
within SFX_LATENCY_MAX of the call; panned left or right
|
|
it is in one channel (--stereo ports); and the mix: a
|
|
full-scale sample peaks where a voice at attenuation 0
|
|
does, so the sine sounds SFX_VOICE_DB against voice 0
|
|
(VOICE_MATCH_DB), each by its louder channel on a
|
|
--stereo port (the Amiga's voices play on one side), the
|
|
sine's level the median of the BALANCE_RATE effects
|
|
(centre, left, right): one sound an emulator spoils does
|
|
not decide it (MAME 0.264 clocks the X68000's MSM6258 at
|
|
4 MHz, not the 8 its CT1 pin says, until the first write
|
|
to the OPM's CT1, so the first ADPCM sound after boot
|
|
decodes every other nibble)
|
|
stream the same sine streamed, plain and raw (in the port's raw
|
|
format), likewise; the raw one as clean as the plain one
|
|
(RAW_PURE_SHARE of its power at the pitch)
|
|
mod stop a module that loops sounds until jlAudioStopMod, and is
|
|
silent from SFX_LATENCY_MAX after it
|
|
mod a module that does not loop sounds, then ends, and
|
|
jlAudioIsPlayingMod goes false within MOD_END_SLACK of
|
|
its sound stopping; and the mix: AUDPAR's module plays
|
|
one channel of a full-scale square at full volume, as
|
|
loud as voice 0 at attenuation 0 (MOD_VOICE_DB,
|
|
VOICE_MATCH_DB), by the louder channel on a --stereo port
|
|
tick the callbacks that came, against hz * time
|
|
silence every gap is quiet: nothing left sounding
|
|
|
|
Usage:
|
|
gradeAudioConformance.py PORT LOG WAV [--stereo] [--skip NAME ...]
|
|
|
|
--stereo the port has stereo output: grade the pans
|
|
--skip a check this port cannot pass, by name prefix (a documented
|
|
hardware limit), reported as SKIP
|
|
--level-tolerance DB
|
|
how far an attenuation step may land from 2 dB a step: a
|
|
chip whose own levels are coarser (the YM2149's) gets its
|
|
own figure
|
|
|
|
Prints one line per check, then "AUDPAR PORT passed/graded". Exit 0 when
|
|
every graded check passes, 1 when one fails, 2 on a usage or input error.
|
|
"""
|
|
|
|
import argparse
|
|
import math
|
|
import re
|
|
import sys
|
|
import wave
|
|
|
|
import numpy as np
|
|
from scipy import signal
|
|
|
|
SYNC_HZ = 1000.0
|
|
SYNC_BAND_HZ = 60.0
|
|
FRAME_S = 0.010
|
|
WINDOW_S = 0.040
|
|
SYNC_SHARE = 0.5 # band power share that marks a sync frame
|
|
SYNC_MIN_DBFS = -45.0
|
|
SYNC_RUN_FRAMES = 10 # consecutive frames: 100 ms of sync
|
|
MARGIN_S = 0.060 # trimmed from each end of a step
|
|
PITCH_LOW_HZ = 40.0
|
|
PITCH_HIGH_HZ = 8000.0
|
|
PITCH_TOLERANCE = 0.01
|
|
LEVEL_STEP_DB = 2.0
|
|
LEVEL_TOLERANCE = 1.0
|
|
NOISE_LEVEL_TOLERANCE = 1.5
|
|
VOICE_MATCH_DB = 3.0
|
|
TONE_MATCH_DB = 4.0
|
|
NOISE_RATE_HZ = 125000.0
|
|
NOISE_TOLERANCE = 0.15
|
|
NOISE_FIT_LIMIT = 0.45 # of the sample rate: the band a null is sought in
|
|
NULL_SEARCH_LOW = 0.6 # of the rate asked: where its first null is sought
|
|
NULL_SEARCH_HIGH = 1.6
|
|
NULL_LEVEL_LOW = 0.2 # the level a null is measured down from
|
|
NULL_DEPTH_DB = 6.0
|
|
NULL_SMOOTH = 5 # bins of the spectrum averaged
|
|
NOISE_GRADED_HZ = 4100.0 # rates up to this are read off the spectrum:
|
|
# above it the machines' own output filters
|
|
# (the Amiga's, the ST's) bury the null
|
|
SILENCE_DBFS = -55.0
|
|
SOUND_DBFS = -45.0
|
|
PAN_APART_DB = 12.0
|
|
PAN_CENTRE_DB = 3.0
|
|
CENTRE_CHECK_HZ = 440.0 # the tone whose stereo position is graded
|
|
NOISE_LOUDNESS_PITCH = 31 # the noise compared with voice 0 for loudness
|
|
MOD_END_SLACK_S = 0.6
|
|
MOD_STOP_LOOK_S = 0.2 # how long before the stop, and after it, a stopped module is heard
|
|
SFX_LATENCY_MAX = 0.050 # a sound effect starts this soon after the call
|
|
ONSET_SEARCH_S = 1.0
|
|
PURE_BAND = 0.10 # of the pitch: the band a pure tone's power is in
|
|
RAW_PURE_SHARE = 0.75 # a raw stream keeps at least this share of the plain one's purity
|
|
ONSET_BAND = 0.08 # of the pitch: the band an onset is heard in
|
|
ONSET_SHARE = 0.5
|
|
MOD_LEAD_S = 0.1
|
|
TICK_SHARE = 0.03
|
|
TICK_SLACK = 0.25 # of a call: the window ends a moment after its last tick is due
|
|
FULL_SCALE = 32768.0
|
|
LEVEL_FRAME_S = 0.020
|
|
SFX_SAMPLES = 4000 # AUDPAR's sine, when its log line does not say
|
|
SINE_CYCLE = (0, 71, 100, 71, 0, -71, -100, -71) # AUDPAR's kSineCycle
|
|
SAMPLE_FULL_SCALE = 127.0 # a signed 8-bit sample's full swing
|
|
BALANCE_RATE = '8000' # the sound effects weighed against a voice
|
|
MOD_VOICE_DB = 0.0 # assets/test.mod's square against a voice's
|
|
MOD_LEVEL_S = 2.0 # how much of the module's start is weighed
|
|
# The canonical mix: AUDPAR's sine, peaking under full scale, sounds this
|
|
# far under a full-swing square of the same peak (a voice at attenuation 0).
|
|
SFX_VOICE_DB = 20.0 * math.log10(math.sqrt(sum(s * s for s in SINE_CYCLE) / len(SINE_CYCLE)) / SAMPLE_FULL_SCALE)
|
|
|
|
|
|
class Grader:
|
|
def __init__(self, port, skips):
|
|
self.port = port
|
|
self.skips = skips
|
|
self.passed = 0
|
|
self.graded = 0
|
|
self.failed = 0
|
|
|
|
def check(self, name, ok, detail):
|
|
if any(name.startswith(s) for s in self.skips):
|
|
print(f"SKIP {name}: {detail}")
|
|
return
|
|
self.graded += 1
|
|
if ok:
|
|
self.passed += 1
|
|
else:
|
|
self.failed += 1
|
|
print(f"{'PASS' if ok else 'FAIL'} {name}: {detail}")
|
|
|
|
|
|
def dbfs(x):
|
|
"""Level without DC: a DAC resting off centre (the ST's YM) is silence."""
|
|
rms = float(np.sqrt(np.mean(np.square(x - x.mean())))) if len(x) else 0.0
|
|
return 20.0 * math.log10(max(rms, 1e-9) / FULL_SCALE)
|
|
|
|
|
|
def steadyDbfs(x, rate):
|
|
"""The median level of LEVEL_FRAME_S frames: a step's own level, which
|
|
a glitch of the recording path (a real-time emulator's sound server
|
|
dropping or repeating a block) cannot move the way it moves a mean."""
|
|
frame = int(LEVEL_FRAME_S * rate)
|
|
if len(x) < frame:
|
|
return dbfs(x)
|
|
levels = [dbfs(x[i:i + frame]) for i in range(0, len(x) - frame + 1, frame)]
|
|
return float(np.median(levels))
|
|
|
|
|
|
def loadWav(path):
|
|
with wave.open(path, 'rb') as w:
|
|
channels = w.getnchannels()
|
|
width = w.getsampwidth()
|
|
rate = w.getframerate()
|
|
raw = w.readframes(w.getnframes())
|
|
if width != 2:
|
|
raise ValueError(f"{path}: expected 16-bit samples, got {width * 8}-bit")
|
|
data = np.frombuffer(raw, dtype='<i2').astype(np.float64)
|
|
data = data[:(len(data) // channels) * channels].reshape(-1, channels)
|
|
return data, rate
|
|
|
|
|
|
def parseLog(path):
|
|
events = []
|
|
with open(path, errors='replace') as f:
|
|
for line in f:
|
|
m = re.search(r'AUDPAR (\d+) (\w+)(.*)', line)
|
|
if m:
|
|
events.append((int(m.group(1)) / 1000.0, m.group(2), m.group(3).split()))
|
|
return events
|
|
|
|
|
|
def syncFrames(mono, rate):
|
|
"""Per FRAME_S hop: True where the 1 kHz sync tone dominates."""
|
|
window = int(WINDOW_S * rate)
|
|
hop = int(FRAME_S * rate)
|
|
freqs = np.fft.rfftfreq(window, 1.0 / rate)
|
|
band = (freqs > SYNC_HZ - SYNC_BAND_HZ) & (freqs < SYNC_HZ + SYNC_BAND_HZ)
|
|
taper = np.hanning(window)
|
|
flags = []
|
|
for start in range(0, len(mono) - window, hop):
|
|
frame = mono[start:start + window]
|
|
power = np.abs(np.fft.rfft(frame * taper)) ** 2
|
|
total = power.sum()
|
|
loud = dbfs(frame) > SYNC_MIN_DBFS
|
|
flags.append(bool(loud and total > 0 and power[band].sum() / total > SYNC_SHARE))
|
|
return flags
|
|
|
|
|
|
def syncOnset(flags, fromFrame):
|
|
"""First frame at or after fromFrame that starts SYNC_RUN_FRAMES of sync."""
|
|
run = 0
|
|
for i in range(fromFrame, len(flags)):
|
|
run = run + 1 if flags[i] else 0
|
|
if run == SYNC_RUN_FRAMES:
|
|
return i - SYNC_RUN_FRAMES + 1
|
|
return None
|
|
|
|
|
|
def pitchOf(x, rate):
|
|
"""Strongest frequency in PITCH_LOW_HZ..PITCH_HIGH_HZ, refined between bins."""
|
|
if len(x) < 256:
|
|
return 0.0
|
|
size = 1 << (int(math.log2(len(x))) + 2)
|
|
spectrum = np.abs(np.fft.rfft((x - x.mean()) * np.hanning(len(x)), size))
|
|
freqs = np.fft.rfftfreq(size, 1.0 / rate)
|
|
allowed = np.where((freqs >= PITCH_LOW_HZ) & (freqs <= PITCH_HIGH_HZ))[0]
|
|
peak = allowed[np.argmax(spectrum[allowed])]
|
|
if 0 < peak < len(spectrum) - 1:
|
|
a, b, c = np.log(spectrum[peak - 1:peak + 2] + 1e-12)
|
|
offset = 0.5 * (a - c) / (a - 2.0 * b + c) if (a - 2.0 * b + c) != 0 else 0.0
|
|
else:
|
|
offset = 0.0
|
|
return (peak + offset) * rate / size
|
|
|
|
|
|
def purityOf(x, rate, hz):
|
|
"""The share of a tone's power within PURE_BAND of its pitch: a sine
|
|
played as it was made keeps nearly all of it; one whose bytes were
|
|
taken in the wrong format (signed for unsigned) spreads it into
|
|
harmonics and a DC step."""
|
|
if len(x) < 256:
|
|
return 0.0
|
|
spectrum = np.abs(np.fft.rfft((x - x.mean()) * np.hanning(len(x)))) ** 2
|
|
freqs = np.fft.rfftfreq(len(x), 1.0 / rate)
|
|
band = (freqs >= hz * (1.0 - PURE_BAND)) & (freqs <= hz * (1.0 + PURE_BAND))
|
|
return float(spectrum[band].sum() / max(spectrum.sum(), 1e-12))
|
|
|
|
|
|
def noiseRate(x, rate, expected):
|
|
"""The noise's sample-and-hold rate, read off its first spectral null:
|
|
noise held at R Hz has a sinc^2 spectrum that first falls to nothing at
|
|
R. Looked for between NULL_SEARCH_LOW and NULL_SEARCH_HIGH of the rate
|
|
asked, and only a real dip counts -- NULL_DEPTH_DB below the spectrum's
|
|
level under it -- so a wrong rate shows as a null in the wrong place or
|
|
as none. (Fitting the whole spectrum was thrown by the images a chip's
|
|
own output rate leaves above it: the IIgs's DOC runs at 26.3 kHz.)
|
|
Returns None when there is no such null."""
|
|
nper = min(2048, len(x))
|
|
freqs, psd = signal.welch(x - x.mean(), rate, nperseg=nper)
|
|
level = 10.0 * np.log10(np.convolve(psd, np.ones(NULL_SMOOTH) / NULL_SMOOTH, mode='same') + 1e-12)
|
|
search = np.where((freqs >= NULL_SEARCH_LOW * expected) & (freqs <= min(NULL_SEARCH_HIGH * expected, NOISE_FIT_LIMIT * rate)))[0]
|
|
below = np.where((freqs >= NULL_LEVEL_LOW * expected) & (freqs <= NULL_SEARCH_LOW * expected))[0]
|
|
if len(search) < 3 or len(below) == 0:
|
|
return None
|
|
dip = search[np.argmin(level[search])]
|
|
if dip in (search[0], search[-1]) or level[below].max() - level[dip] < NULL_DEPTH_DB:
|
|
return None
|
|
return float(freqs[dip])
|
|
|
|
|
|
def centroid(x, rate):
|
|
freqs, psd = signal.welch(x - x.mean(), rate, nperseg=min(2048, len(x)))
|
|
return float((freqs * psd).sum() / max(psd.sum(), 1e-12))
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(add_help=True)
|
|
parser.add_argument('port')
|
|
parser.add_argument('log')
|
|
parser.add_argument('wav')
|
|
parser.add_argument('--stereo', action='store_true')
|
|
parser.add_argument('--skip', nargs='*', default=[])
|
|
parser.add_argument('--level-tolerance', type=float, default=LEVEL_TOLERANCE)
|
|
args = parser.parse_args()
|
|
|
|
try:
|
|
data, rate = loadWav(args.wav)
|
|
events = parseLog(args.log)
|
|
except (OSError, ValueError, wave.Error) as error:
|
|
print(f"AUDPAR {args.port}: cannot read the recording or the log: {error}")
|
|
return 2
|
|
syncs = [e for e in events if e[1] == 'sync']
|
|
done = [e for e in events if e[1] == 'done']
|
|
if len(syncs) != 2 or not done:
|
|
print(f"AUDPAR {args.port}: the log does not hold a whole run (syncs {len(syncs)}, done {len(done)})")
|
|
return 2
|
|
mono = data.mean(axis=1)
|
|
grader = Grader(args.port, args.skip)
|
|
|
|
# Align: the two sync onsets map log seconds onto the recording.
|
|
flags = syncFrames(mono, rate)
|
|
first = syncOnset(flags, 0)
|
|
span = syncs[1][0] - syncs[0][0]
|
|
last = None if first is None else syncOnset(flags, first + int(0.8 * span / FRAME_S))
|
|
if first is None or last is None:
|
|
grader.check('sync', False, f"sync tones found: {'start' if first is not None else 'none'}{', end' if last is not None else ''}")
|
|
print(f"AUDPAR {args.port} {grader.passed}/{grader.graded}")
|
|
return 1
|
|
t0 = first * FRAME_S
|
|
scale = (last - first) * FRAME_S / span
|
|
grader.check('sync', abs(scale - 1.0) < 0.05, f"start {t0:.3f} s, the timeline runs at {scale:.4f} of the program's clock")
|
|
|
|
def cut(start, end, channel=None, shift=0.0):
|
|
a = int((t0 + start * scale + shift + MARGIN_S) * rate)
|
|
b = int((t0 + end * scale + shift - MARGIN_S) * rate)
|
|
if b <= a:
|
|
return np.zeros(0)
|
|
return mono[a:b] if channel is None else data[a:b, channel]
|
|
|
|
def onset(start, hz):
|
|
"""Seconds from a step's log time to a sound at hz, or None: the tail
|
|
of whatever came before is a different pitch and does not count."""
|
|
window = int(WINDOW_S * rate)
|
|
hop = int(FRAME_S * rate)
|
|
freqs = np.fft.rfftfreq(window, 1.0 / rate)
|
|
band = (freqs > hz * (1.0 - ONSET_BAND)) & (freqs < hz * (1.0 + ONSET_BAND))
|
|
taper = np.hanning(window)
|
|
a = int((t0 + start * scale) * rate)
|
|
for i in range(a, min(len(mono), a + int(ONSET_SEARCH_S * rate)) - window, hop):
|
|
frame = mono[i:i + window]
|
|
power = np.abs(np.fft.rfft((frame - frame.mean()) * taper)) ** 2
|
|
if dbfs(frame) > SOUND_DBFS and power[band].sum() > ONSET_SHARE * power.sum():
|
|
return (i - a) / rate
|
|
return None
|
|
|
|
steps = []
|
|
for i, event in enumerate(events):
|
|
nextStart = events[i + 1][0] if i + 1 < len(events) else event[0]
|
|
steps.append((event[0], nextStart, event[1], event[2]))
|
|
|
|
def loudest(start, end, measure, shift=0.0):
|
|
"""A step's level in its louder channel on a stereo port, else the
|
|
mix's."""
|
|
if not args.stereo:
|
|
return measure(cut(start, end, shift=shift))
|
|
return max(measure(cut(start, end, c, shift)) for c in (0, 1))
|
|
|
|
def centred(start, end, name):
|
|
left = dbfs(cut(start, end, 0))
|
|
right = dbfs(cut(start, end, 1))
|
|
grader.check(f"{name} centred", abs(left - right) <= PAN_CENTRE_DB, f"left {left:.1f} dBFS, right {right:.1f} dBFS")
|
|
|
|
lag = 0.0 # the sample path's latency, from the last sfx
|
|
plainPure = None # the plain stream's purity, for the raw one's
|
|
toneLevels = []
|
|
voiceLevels = {}
|
|
noiseLevels = {}
|
|
noiseCentroids = []
|
|
balanceVoice = None # voice 0 at attenuation 0, and the sound effects
|
|
balanceSfx = [] # weighed against it, by loudest()
|
|
balanceMod = None
|
|
for start, end, kind, values in steps:
|
|
if kind in ('tone', 'sync'):
|
|
hz = float(values[0])
|
|
x = cut(start, end)
|
|
got = pitchOf(x, rate)
|
|
level = dbfs(x)
|
|
grader.check(f"{kind} {int(hz)} Hz", abs(got / hz - 1.0) <= PITCH_TOLERANCE and level > SOUND_DBFS, f"{got:.1f} Hz at {level:.1f} dBFS")
|
|
if kind == 'tone':
|
|
toneLevels.append((int(hz), level))
|
|
if args.stereo and hz == CENTRE_CHECK_HZ:
|
|
centred(start, end, 'tone')
|
|
elif kind == 'voice':
|
|
voice, hz, atten = int(values[0]), float(values[1]), int(values[2])
|
|
x = cut(start, end)
|
|
level = steadyDbfs(x, rate)
|
|
if args.stereo and atten == 0:
|
|
centred(start, end, f"voice {voice}")
|
|
voiceLevels[(voice, atten)] = level
|
|
if voice == 0 and atten == 0:
|
|
balanceVoice = loudest(start, end, lambda y: steadyDbfs(y, rate))
|
|
if atten <= 8:
|
|
got = pitchOf(x, rate)
|
|
grader.check(f"voice {voice} {int(hz)} Hz atten {atten}", abs(got / hz - 1.0) <= PITCH_TOLERANCE, f"{got:.1f} Hz at {level:.1f} dBFS")
|
|
elif kind == 'noise':
|
|
pitch, atten = int(values[0]), int(values[1])
|
|
x = cut(start, end)
|
|
level = steadyDbfs(x, rate)
|
|
if atten == 0 and (pitch, 0) not in noiseLevels:
|
|
expected = NOISE_RATE_HZ / max(pitch, 1)
|
|
noiseCentroids.append((pitch, centroid(x, rate)))
|
|
if level <= SOUND_DBFS:
|
|
grader.check(f"noise {pitch} sounds", False, f"{level:.1f} dBFS: it does not hold for the step")
|
|
elif expected <= NOISE_GRADED_HZ:
|
|
got = noiseRate(x, rate, expected)
|
|
grader.check(f"noise {pitch} rate", got is not None and abs(got / expected - 1.0) <= NOISE_TOLERANCE, f"{got:.0f} Hz, {expected:.0f} asked, at {level:.1f} dBFS" if got is not None else f"no null near {expected:.0f} Hz, at {level:.1f} dBFS")
|
|
else:
|
|
grader.check(f"noise {pitch} sounds", True, f"{level:.1f} dBFS (its rate, {expected:.0f} Hz, is graded by the darkening below)")
|
|
noiseLevels[(pitch, atten)] = level
|
|
elif kind in ('sfx', 'stream'):
|
|
hz, pan = float(values[2]), int(values[3])
|
|
# The sine's samples follow the pan (a stream's after its raw
|
|
# flag), when the log gives them.
|
|
samplesAt = 4 if kind == 'sfx' else 5
|
|
samples = int(values[samplesAt]) if len(values) > samplesAt else SFX_SAMPLES
|
|
length = samples / float(values[1])
|
|
panName = {0: 'left', 1: 'centre', 2: 'right'}.get(pan, str(pan))
|
|
# A stream's name says so, and whether it went raw.
|
|
what = 'sfx' if kind == 'sfx' else ('raw stream' if len(values) > 4 and values[4] == '1' else 'stream')
|
|
found = onset(start, hz)
|
|
grader.check(f"{what} {values[1]} Hz {panName} starts", found is not None and found <= SFX_LATENCY_MAX, f"{found * 1000:.0f} ms after the call" if found is not None else "never")
|
|
lag = found if found is not None else 0.0
|
|
x = cut(start, start + length, shift=lag)
|
|
got = pitchOf(x, rate)
|
|
level = dbfs(x)
|
|
pure = purityOf(x, rate, hz)
|
|
grader.check(f"{what} {values[1]} Hz {panName}", abs(got / hz - 1.0) <= PITCH_TOLERANCE and level > SOUND_DBFS, f"{got:.1f} Hz ({hz:.0f} asked) at {level:.1f} dBFS, {pure * 100:.1f}% at its pitch")
|
|
# The raw stream's bytes in the wrong format sound at the same
|
|
# pitch, but spread into harmonics: it must be about as clean
|
|
# as the plain stream through the same path.
|
|
if what == 'stream':
|
|
plainPure = pure
|
|
elif what == 'raw stream' and plainPure is not None:
|
|
grader.check("raw stream as clean as the plain one", pure >= plainPure * RAW_PURE_SHARE, f"{pure * 100:.1f}% at its pitch, the plain stream {plainPure * 100:.1f}%")
|
|
if what == 'sfx' and values[1] == BALANCE_RATE:
|
|
balanceSfx.append(loudest(start, start + length, dbfs, lag))
|
|
if data.shape[1] >= 2:
|
|
left = dbfs(cut(start, start + length, 0, lag))
|
|
right = dbfs(cut(start, start + length, 1, lag))
|
|
apart = left - right
|
|
if pan == 0:
|
|
ok = apart >= PAN_APART_DB
|
|
elif pan == 2:
|
|
ok = -apart >= PAN_APART_DB
|
|
else:
|
|
ok = abs(apart) <= PAN_CENTRE_DB
|
|
if args.stereo:
|
|
grader.check(f"pan {panName}", ok, f"left {left:.1f} dBFS, right {right:.1f} dBFS")
|
|
elif kind == 'silence':
|
|
# After a sample-path sound, its tail is late by the latency
|
|
# the sfx check graded: the silence is judged after it.
|
|
x = cut(start, end, shift=lag)
|
|
level = steadyDbfs(x, rate)
|
|
grader.check(f"silence at {start:.2f} s", level < SILENCE_DBFS, f"{level:.1f} dBFS")
|
|
elif kind == 'mod' and values and values[0] == 'start':
|
|
endEvents = [e for e in events if e[1] == 'mod' and e[2] and e[2][0] == 'end']
|
|
if not endEvents:
|
|
grader.check('mod', False, "no mod end in the log")
|
|
continue
|
|
endAt, endValues = endEvents[0][0], endEvents[0][2]
|
|
balanceMod = loudest(start, min(start + MOD_LEVEL_S, endAt), lambda y: steadyDbfs(y, rate))
|
|
stillPlaying = len(endValues) > 1 and endValues[1] != '0'
|
|
# Listened to a second past the end, or up to whatever sounds next.
|
|
later = [e[0] for e in events if e[0] > endAt and e[1] != 'silence']
|
|
x = cut(start, min([endAt + 1.0] + later))
|
|
frames = int(FRAME_S * rate)
|
|
loud = [dbfs(x[i:i + frames]) > SOUND_DBFS for i in range(0, len(x) - frames, frames)]
|
|
sounded = any(loud)
|
|
lastLoud = (max(i for i, v in enumerate(loud) if v) + 1) * FRAME_S if sounded else 0.0
|
|
soundEnd = start + (MARGIN_S + lastLoud) / scale
|
|
grader.check('mod sounds', sounded, f"{sum(loud) * FRAME_S:.1f} s of it above {SOUND_DBFS:.0f} dBFS")
|
|
grader.check('mod ends', not stillPlaying and endAt - start < 11.5, f"jlAudioIsPlayingMod false {endAt - start:.2f} s after the start")
|
|
grader.check('mod end reported', -MOD_LEAD_S <= endAt - soundEnd <= MOD_END_SLACK_S, f"its sound stopped at {soundEnd - start:.2f} s, reported at {endAt - start:.2f} s")
|
|
elif kind == 'mod' and values and values[0] == 'stop':
|
|
before = dbfs(cut(start - MOD_STOP_LOOK_S, start))
|
|
after = steadyDbfs(cut(start + SFX_LATENCY_MAX, start + SFX_LATENCY_MAX + MOD_STOP_LOOK_S), rate)
|
|
grader.check('mod sounds until its stop', before > SOUND_DBFS, f"{before:.1f} dBFS before jlAudioStopMod")
|
|
grader.check('mod stops', after < SILENCE_DBFS, f"{after:.1f} dBFS from {SFX_LATENCY_MAX * 1000:.0f} ms after jlAudioStopMod")
|
|
elif kind == 'tick':
|
|
hz, ok, calls, ms = int(values[0]), values[1] == '1', int(values[2]), int(values[3])
|
|
expected = hz * ms / 1000.0
|
|
good = ok and abs(calls - expected) <= max(1.0, TICK_SHARE * expected) + TICK_SLACK
|
|
grader.check(f"tick {hz} Hz", good, f"registered {'yes' if ok else 'no'}, {calls} calls in {ms} ms, {expected:.0f} expected")
|
|
|
|
if toneLevels:
|
|
base = toneLevels[0][1]
|
|
spread = max(abs(level - base) for _, level in toneLevels)
|
|
grader.check('tone levels even', spread <= TONE_MATCH_DB, ", ".join(f"{hz} Hz {level:.1f}" for hz, level in toneLevels))
|
|
if (0, 0) in voiceLevels:
|
|
base = voiceLevels[(0, 0)]
|
|
for atten in range(1, 15):
|
|
if (0, atten) in voiceLevels:
|
|
drop = base - voiceLevels[(0, atten)]
|
|
grader.check(f"voice level {atten}", abs(drop - LEVEL_STEP_DB * atten) <= args.level_tolerance, f"{drop:.1f} dB below attenuation 0, {LEVEL_STEP_DB * atten:.0f} asked")
|
|
for voice in (1, 2):
|
|
if (voice, 0) in voiceLevels:
|
|
grader.check(f"voice {voice} as loud as voice 0", abs(voiceLevels[(voice, 0)] - base) <= VOICE_MATCH_DB, f"{voiceLevels[(voice, 0)]:.1f} against {base:.1f} dBFS")
|
|
# Full-swing noise and a full-swing square carry the same power, as
|
|
# on the PSG every port's voices are modelled on.
|
|
if (NOISE_LOUDNESS_PITCH, 0) in noiseLevels:
|
|
grader.check("noise as loud as a voice", abs(noiseLevels[(NOISE_LOUDNESS_PITCH, 0)] - base) <= VOICE_MATCH_DB, f"{noiseLevels[(NOISE_LOUDNESS_PITCH, 0)]:.1f} against {base:.1f} dBFS")
|
|
if balanceVoice is not None and balanceSfx:
|
|
apart = float(np.median(balanceSfx)) - balanceVoice
|
|
grader.check("sfx as loud as a voice", abs(apart - SFX_VOICE_DB) <= VOICE_MATCH_DB, f"{apart:+.1f} dB against voice 0, {SFX_VOICE_DB:+.1f} asked (a full-scale sample peaks where the voice does)")
|
|
if balanceVoice is not None and balanceMod is not None:
|
|
apart = balanceMod - balanceVoice
|
|
grader.check("mod as loud as a voice", abs(apart - MOD_VOICE_DB) <= VOICE_MATCH_DB, f"{apart:+.1f} dB against voice 0, {MOD_VOICE_DB:+.1f} asked (a module channel at full volume peaks where the voice does)")
|
|
stepPitch = None
|
|
for (pitch, atten) in noiseLevels:
|
|
if atten > 0:
|
|
stepPitch = pitch
|
|
if stepPitch is not None and (stepPitch, 0) in noiseLevels:
|
|
base = noiseLevels[(stepPitch, 0)]
|
|
for atten in sorted(a for p, a in noiseLevels if p == stepPitch and a > 0):
|
|
drop = base - noiseLevels[(stepPitch, atten)]
|
|
grader.check(f"noise level {atten}", abs(drop - LEVEL_STEP_DB * atten) <= max(NOISE_LEVEL_TOLERANCE, args.level_tolerance), f"{drop:.1f} dB below attenuation 0, {LEVEL_STEP_DB * atten:.0f} asked")
|
|
# Each pitch darker than the one before, where the later one's rate is
|
|
# inside the recording's band: rates past half the sample rate all
|
|
# record as near-white noise and cannot be told apart.
|
|
if len(noiseCentroids) > 1:
|
|
darker = all(b[1] < a[1] for a, b in zip(noiseCentroids, noiseCentroids[1:]) if NOISE_RATE_HZ / max(b[0], 1) < rate / 2.0)
|
|
grader.check('noise darkens', darker, ", ".join(f"{p}: {c:.0f} Hz" for p, c in noiseCentroids))
|
|
|
|
print(f"AUDPAR {args.port} {grader.passed}/{grader.graded}")
|
|
return 0 if grader.failed == 0 else 1
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|