Files
psxrecomp/tools/audio_ab_diff.py
Matthew Stanley 5a5922d98f audio: always-on PCM tap rings + oracle WAV capture (SNES-campaign port)
Measurement infrastructure for the audio-quality campaign (MMX5 complaint),
ported from snesrecomp's audio_trace/oracle methodology and the
_audio_round2 tap model. No behavior change to the audio path itself.

- audio_trace.{c,h}: three always-on 95 s PCM rings — T1 spu_out (canonical
  spu_render output), T2 cd_in (XA/CD-DA PCM entering the SPU CD bus),
  T3 host (exact post-fade bytes handed to SDL_QueueAudio) — plus a 512k
  event ring (every SPU reg write stamped with the SPU sample clock;
  RENDER/PUMP_SKIP/UNDERRUN/MUTE/UNMUTE/CD_PUSH/SPU-DMA), audible-vs-
  silent counters (-42 dBFS gate), queue watermarks, and a server-side
  44100 Hz WAV dumper (rate exactness matters: snesrecomp da33c06).
- psx-beetle: the libretro audio callbacks stop discarding Beetle's fully-
  mixed reference SPU PCM and record it into the same tap ring. Port 4380
  gains audio_stats/audio_wav/audio_events with the identical wire protocol
  as the runtime (rule 16 symmetric harness) — the oracle WAV capture.
- debug_server: audio_stats / audio_wav / audio_events on the runtime port.
- tools: audio_ab_diff.py + audio_spectral_ab.py (drift-tolerant offline
  A/B: drift ppm, onsets, cents, timbre, click rate) and audio_analyze.py
  (first-dirty-tap cross-tap decision tree) ported from snesrecomp/
  _audio_round2; audio_capture_ab.py dumps both processes symmetrically.

Probes query history; nothing is armed at probe time.
2026-07-10 00:00:09 -07:00

351 lines
15 KiB
Python

#!/usr/bin/env python3
"""audio_ab_diff.py - drift-tolerant A/B comparison of recomp audio vs an
accuracy-grade emulator reference.
WHY drift-tolerant (not bit-exact): the SNES recomp drives its SPC700/S-DSP on
a *synthetic* clock (cpu_state.c pace_cycles = +256 master cycles per HW-register
touch, converted to APU cycles), not a real cycle count. So even a perfect DSP
will phase-drift against hardware. A sample-exact diff is therefore meaningless
here; what matters is whether the *music* matches: same notes, same onsets, same
pitch, same timbre, with bounded time drift. This tool measures exactly those.
Inputs are two WAVs at (possibly) different native rates:
- REF : the oracle. Best = bsnes Accuracy via tools/snesref (embeds blargg's
cycle-exact S-DSP). bsnes libretro emits 48000 Hz.
- TEST : the recomp. Dump its always-on native PCM ring (32040 Hz) via the
debug server: audio_wav <path> (runner/src/audio_trace.c).
Both are resampled to a common rate (default 32040; SNES content is band-limited
well below 16 kHz so nothing is lost). Then:
1. global alignment - FFT cross-correlation -> best lag + peak correlation
2. drift - per-window local lag; a growing lag = clock/tempo drift
(reported as ms/s and ppm). This is the headline SNES
metric: it quantifies "off-cue".
3. onset timing - spectral-flux onsets matched across streams -> timing
error distribution (median / IQR / p90), in ms.
4. pitch / tuning - dominant-pitch track ratio in cents -> "off-tune".
5. timbre - log-spectral distance + spectral-centroid delta.
6. per-stream quality- click rate + noise floor (reused round-2 detectors).
Only dependency is numpy. Usage:
python audio_ab_diff.py --ref REF.wav --test TEST.wav [--rate 32040]
[--start-s 0] [--dur-s 10] [--json out.json]
"""
import sys, os, wave, argparse, json
import numpy as np
# ----------------------------------------------------------------------------- io
def load_wav(path):
"""Robust WAV loader: tolerates an UNFINALIZED header (data-size==0 or wrong)
from a force-killed capture by taking all bytes after the data chunk start.
Falls back to raw parse if the stdlib wave module rejects the header."""
try:
with wave.open(path, "rb") as w:
ch, rate, n = w.getnchannels(), w.getframerate(), w.getnframes()
raw = w.readframes(n)
if len(raw) >= (n * ch * 2) and n > 0:
x = np.frombuffer(raw, dtype="<i2").astype(np.float64) / 32768.0
return (x.reshape(-1, ch).mean(axis=1) if ch > 1 else x), rate
except Exception:
pass
# manual parse
b = open(path, "rb").read()
assert b[:4] == b"RIFF" and b[8:12] == b"WAVE", "not a WAV"
ch, rate, bits, off = 2, 32040, 16, 12
data_off, data_len = None, 0
while off + 8 <= len(b):
cid = b[off:off + 4]; sz = int.from_bytes(b[off + 4:off + 8], "little"); body = off + 8
if cid == b"fmt ":
ch = int.from_bytes(b[body + 2:body + 4], "little")
rate = int.from_bytes(b[body + 4:body + 8], "little")
bits = int.from_bytes(b[body + 14:body + 16], "little")
elif cid == b"data":
data_off = body
data_len = sz if 0 < sz <= len(b) - body else (len(b) - body)
break
off = body + sz + (sz & 1)
raw = b[data_off:data_off + (data_len - data_len % (ch * (bits // 8)))]
x = np.frombuffer(raw, dtype="<i2").astype(np.float64) / 32768.0
return (x.reshape(-1, ch).mean(axis=1) if ch > 1 else x), rate
def resample_linear(x, src, dst):
if src == dst or x.size == 0:
return x
n_out = int(round(x.size * dst / src))
t = np.arange(n_out) * (src / dst)
i0 = np.floor(t).astype(int)
i0 = np.clip(i0, 0, x.size - 2)
frac = t - i0
return x[i0] * (1.0 - frac) + x[i0 + 1] * frac
def active_region(x, thr=1e-4):
nz = np.where(np.abs(x) > thr)[0]
if nz.size == 0:
return 0, x.size
return int(nz[0]), int(nz[-1]) + 1
# --------------------------------------------------------------------- alignment
def best_lag(a, b, max_lag):
"""Lag (in samples) that best aligns b onto a, via FFT cross-correlation,
restricted to |lag| <= max_lag. Returns (lag, peak_norm_corr)."""
n = 1 << int(np.ceil(np.log2(a.size + b.size)))
fa = np.fft.rfft(a - a.mean(), n)
fb = np.fft.rfft(b - b.mean(), n)
cc = np.fft.irfft(fa * np.conj(fb), n)
cc = np.concatenate([cc[-max_lag:], cc[: max_lag + 1]])
lags = np.arange(-max_lag, max_lag + 1)
k = int(np.argmax(cc))
denom = (np.linalg.norm(a) * np.linalg.norm(b)) or 1.0
return int(lags[k]), float(cc[k] / denom)
def drift_track(a, b, rate, win_s=0.75, hop_s=0.375, search_ms=120):
"""Local lag per window -> drift slope. a,b already globally aligned."""
win = int(win_s * rate); hop = int(hop_s * rate); ml = int(search_ms * rate / 1000)
n = min(a.size, b.size)
centers, lags, corrs = [], [], []
pos = 0
while pos + win <= n:
aw = a[pos:pos + win]; bw = b[pos:pos + win]
if np.linalg.norm(aw) > 1e-3 and np.linalg.norm(bw) > 1e-3:
lag, c = best_lag(aw, bw, ml)
centers.append((pos + win / 2) / rate); lags.append(lag / rate * 1000.0); corrs.append(c)
pos += hop
if len(centers) < 3:
return None
centers = np.array(centers); lags = np.array(lags); corrs = np.array(corrs)
# weighted least-squares slope (weight by local correlation quality)
wts = np.clip(corrs, 0, None)
if wts.sum() < 1e-6:
wts = np.ones_like(corrs)
A = np.vstack([centers, np.ones_like(centers)]).T
W = np.diag(wts)
coef = np.linalg.lstsq(W @ A, W @ lags, rcond=None)[0]
slope_ms_per_s = float(coef[0])
return {
"n_windows": len(centers),
"lag_ms_mean": float(lags.mean()),
"lag_ms_std": float(lags.std()),
"drift_ms_per_s": slope_ms_per_s,
"drift_ppm": slope_ms_per_s * 1000.0, # ms/s -> ppm
"local_corr_median": float(np.median(corrs)),
}
# ------------------------------------------------------------------------ onsets
def onsets(x, rate, fft=1024, hop=256, k=1.5):
if x.size < fft:
return np.array([])
win = np.hanning(fft)
frames = 1 + (x.size - fft) // hop
mag = np.empty((frames, fft // 2 + 1))
for i in range(frames):
seg = x[i * hop:i * hop + fft] * win
mag[i] = np.abs(np.fft.rfft(seg))
flux = np.maximum(0.0, np.diff(mag, axis=0)).sum(axis=1)
if flux.size == 0:
return np.array([])
flux /= (flux.max() or 1.0)
# adaptive threshold: local mean + k*local std
w = 16
thr = np.array([flux[max(0, i - w):i + w].mean() + k * flux[max(0, i - w):i + w].std()
for i in range(flux.size)])
pk = (flux[1:-1] > flux[:-2]) & (flux[1:-1] >= flux[2:]) & (flux[1:-1] > thr[1:-1])
idx = np.where(pk)[0] + 1
return idx * hop / rate # seconds
def onset_match(ref_t, test_t, lag_s, tol_ms=50):
"""Match test onsets (shifted by -lag) to nearest ref onset; report errors."""
if ref_t.size == 0 or test_t.size == 0:
return None
tt = test_t - lag_s
errs = []
matched = 0
for t in tt:
d = np.abs(ref_t - t)
j = int(np.argmin(d))
if d[j] * 1000.0 <= tol_ms:
errs.append((t - ref_t[j]) * 1000.0); matched += 1
if not errs:
return {"matched": 0, "ref_onsets": int(ref_t.size), "test_onsets": int(test_t.size)}
errs = np.array(errs)
return {
"ref_onsets": int(ref_t.size), "test_onsets": int(test_t.size),
"matched": matched,
"match_rate": matched / max(1, test_t.size),
"err_ms_median": float(np.median(errs)),
"err_ms_abs_median": float(np.median(np.abs(errs))),
"err_ms_iqr": float(np.percentile(errs, 75) - np.percentile(errs, 25)),
"err_ms_p90": float(np.percentile(np.abs(errs), 90)),
}
# ---------------------------------------------------------------- pitch & timbre
def avg_spectrum(x, rate, fft=4096, hop=2048):
if x.size < fft:
return None, None
win = np.hanning(fft); frames = 1 + (x.size - fft) // hop
acc = np.zeros(fft // 2 + 1)
for i in range(frames):
acc += np.abs(np.fft.rfft(x[i * hop:i * hop + fft] * win))
acc /= frames
freqs = np.fft.rfftfreq(fft, 1.0 / rate)
return freqs, acc
def dominant_pitch_track(x, rate, fft=4096, hop=2048, fmin=80, fmax=2000):
if x.size < fft:
return np.array([])
win = np.hanning(fft); frames = 1 + (x.size - fft) // hop
freqs = np.fft.rfftfreq(fft, 1.0 / rate)
band = (freqs >= fmin) & (freqs <= fmax)
out = []
for i in range(frames):
sp = np.abs(np.fft.rfft(x[i * hop:i * hop + fft] * win))
if sp[band].max() < 1e-3:
continue
out.append(freqs[band][int(np.argmax(sp[band]))])
return np.array(out)
def cents(ratio):
return 1200.0 * np.log2(ratio) if ratio > 0 else float("nan")
# ----------------------------------------------------------- per-stream quality
def click_rate(x, rate, k=6.0, rms_ms=10.0):
if x.size < 4:
return 0.0
d1 = np.abs(np.diff(x, prepend=x[0])); d2 = np.abs(np.diff(x, n=2, prepend=[x[0], x[0]]))
w = max(8, int(rate * rms_ms / 1000.0))
cs = np.cumsum(np.concatenate([[0.0], x * x]))
lo = np.maximum(0, np.arange(x.size) - w); hi = np.minimum(x.size, np.arange(x.size) + w)
rms = np.sqrt((cs[hi] - cs[lo]) / np.maximum(1, hi - lo))
score = np.maximum(d1, d2) / np.maximum(rms, 1e-4)
idx = np.where(score > k)[0]
if idx.size:
keep = [idx[0]]
for i in idx[1:]:
if i - keep[-1] >= 3:
keep.append(i)
idx = np.array(keep)
return idx.size / (x.size / rate)
def noise_floor_db(x):
if x.size == 0:
return float("nan")
q = np.sqrt(np.mean(np.sort(x * x)[: max(1, x.size // 10)])) # quietest 10% RMS
return 20.0 * np.log10(q + 1e-12)
# ------------------------------------------------------------------------- main
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--ref", required=True, help="oracle WAV (bsnes)")
ap.add_argument("--test", required=True, help="recomp WAV (audio_wav dump)")
ap.add_argument("--rate", type=int, default=32040, help="common analysis rate")
ap.add_argument("--start-s", type=float, default=0.0)
ap.add_argument("--dur-s", type=float, default=0.0, help="0 = all")
ap.add_argument("--max-lag-s", type=float, default=2.0)
ap.add_argument("--json", default=None)
a = ap.parse_args()
rate = a.rate
xr, rr = load_wav(a.ref); xt, rt = load_wav(a.test)
xr = resample_linear(xr, rr, rate); xt = resample_linear(xt, rt, rate)
# trim leading silence on each independently, then optional window
r0, r1 = active_region(xr); t0, t1 = active_region(xt)
xr = xr[r0:r1]; xt = xt[t0:t1]
if a.start_s or a.dur_s:
s = int(a.start_s * rate); e = xr.size if a.dur_s == 0 else s + int(a.dur_s * rate)
xr = xr[s:e]; xt = xt[s:min(e, xt.size)]
n = min(xr.size, xt.size)
xr = xr[:n]; xt = xt[:n]
if n < rate:
print("ERROR: <1s of overlapping active audio; check captures.", file=sys.stderr)
sys.exit(2)
ml = int(a.max_lag_s * rate)
glag, gcorr = best_lag(xr, xt, ml)
# apply global lag so downstream operate aligned
if glag >= 0:
xa, xb = xr[glag:], xt[: n - glag]
else:
xa, xb = xr[: n + glag], xt[-glag:]
m = min(xa.size, xb.size); xa = xa[:m]; xb = xb[:m]
drift = drift_track(xa, xb, rate)
o_ref = onsets(xa, rate); o_test = onsets(xb, rate)
onset = onset_match(o_ref, o_test, 0.0)
fr, sr_ = avg_spectrum(xa, rate); _, st_ = avg_spectrum(xb, rate)
timbre = {}
if sr_ is not None and st_ is not None:
lo = (fr >= 50) & (fr <= 16000)
ls = np.log(sr_[lo] + 1e-9); lt = np.log(st_[lo] + 1e-9)
timbre["log_spectral_dist_db"] = float(np.sqrt(np.mean((ls - lt) ** 2)) * (20 / np.log(10)))
cen = lambda s: float((fr[lo] * s[lo]).sum() / (s[lo].sum() + 1e-9))
timbre["centroid_ref_hz"] = cen(sr_); timbre["centroid_test_hz"] = cen(st_)
timbre["centroid_delta_hz"] = timbre["centroid_test_hz"] - timbre["centroid_ref_hz"]
pr = dominant_pitch_track(xa, rate); pt = dominant_pitch_track(xb, rate)
pitch = {}
if pr.size and pt.size:
ratio = np.median(pt) / np.median(pr)
pitch = {"median_ref_hz": float(np.median(pr)), "median_test_hz": float(np.median(pt)),
"tuning_error_cents": float(cents(ratio))}
quality = {
"ref": {"click_per_s": click_rate(xa, rate), "noise_floor_db": noise_floor_db(xa)},
"test": {"click_per_s": click_rate(xb, rate), "noise_floor_db": noise_floor_db(xb)},
}
result = {
"ref": os.path.basename(a.ref), "test": os.path.basename(a.test),
"rate": rate, "overlap_s": round(m / rate, 2),
"global_align": {"lag_ms": glag / rate * 1000.0, "peak_corr": gcorr},
"drift": drift, "onset": onset, "pitch": pitch, "timbre": timbre, "quality": quality,
}
# ----- human verdict -----
print(f"\n=== A/B audio diff: {result['test']} vs REF {result['ref']} ===")
print(f" overlap {result['overlap_s']}s @ {rate} Hz")
ga = result["global_align"]
print(f" global align : lag {ga['lag_ms']:+.1f} ms, peak corr {ga['peak_corr']:.3f} "
f"({'STRONG' if ga['peak_corr']>0.5 else 'WEAK - streams may differ structurally'})")
if drift:
print(f" drift : {drift['drift_ms_per_s']:+.2f} ms/s ({drift['drift_ppm']:+.0f} ppm), "
f"lag std {drift['lag_ms_std']:.1f} ms, local corr {drift['local_corr_median']:.2f}")
if onset:
if onset.get("matched"):
print(f" onsets : {onset['matched']}/{onset['test_onsets']} matched "
f"({onset['match_rate']*100:.0f}%), |err| median {onset['err_ms_abs_median']:.1f} ms, "
f"p90 {onset['err_ms_p90']:.1f} ms")
else:
print(f" onsets : 0 matched (ref {onset['ref_onsets']}, test {onset['test_onsets']})")
if pitch:
print(f" tuning : test {pitch['median_test_hz']:.1f} Hz vs ref "
f"{pitch['median_ref_hz']:.1f} Hz -> {pitch['tuning_error_cents']:+.0f} cents")
if timbre:
print(f" timbre : log-spectral dist {timbre['log_spectral_dist_db']:.1f} dB, "
f"centroid delta {timbre['centroid_delta_hz']:+.0f} Hz")
q = quality
print(f" quality : clicks ref {q['ref']['click_per_s']:.2f}/s test {q['test']['click_per_s']:.2f}/s; "
f"noise floor ref {q['ref']['noise_floor_db']:.0f} dB test {q['test']['noise_floor_db']:.0f} dB")
print()
if a.json:
with open(a.json, "w") as f:
json.dump(result, f, indent=2)
print(f" wrote {a.json}")
if __name__ == "__main__":
main()