mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-26 18:25:35 -04:00
8dbe04e6c5
Two hardware-faithfulness fixes found by the always-on tap rings + Beetle oracle A/B on fingerprint-matched attract music (tools/audio_pair_segments): the recomp played the identical track at -3.8 dBFS vs the oracle's -14.1 dBFS — +10.3 dB hot, clipping continuously. 1. spu.c: the voice repeat/loop address register (voice reg 7) is LIVE state on real hardware — writing it after KEYON retargets where the next END+REPEAT block jumps (Beetle spu.cpp:1150/333), and the block auto-latch (flag bit 2) updates the guest-visible register (386/1302). The recomp cached repeat_addr at KEYON only, so X5's driver idiom — KEYON, then point the loop register at a silent tail block; no KEYOFF ever — left voices looping the LOUD sample body forever. Measured: 3 stuck voices re-looping ~1400x/s, 160k SPU events/min vs the oracle's 633, zero game KEYOFF writes (the game never needs one), +11 dB. 2. cdrom.c: implement the CD controller's decode volume matrix ([src][port], 0x80 unity, pending banks + apply latch at 1F801803.3 bit 5 — mapping copied from Beetle cdc.cpp:1483-1501, applied after the XA resample per PS1 hardware tests). X5 drives it constantly: 0x73-0x7E steady, ramped to 0x00 at scene changes (measured via the new beetle `cdc_volume` command + PS_CDC::PSXRecomp_GetDecodeVolume peek). Without it music fades never happened and steady level sat ~0.6 dB hot. Also: AUDIO_TAP_VOICES (T0 voice-sum tap) for source-by-source mix decomposition, per-tap WAV rates, bridge stats in audio_stats. Measured after (MMX5 attract, 20-25 s windows): spu_out RMS: -3.8 dBFS (clipping) -> -15.5 dBFS [oracle -14.1] music-only chain already matched the oracle at -14.8 dBFS, confirming decoder + mixer gain equivalence; the entire excess was stuck voices. underruns: 2 events / 98 s (delivery fix holding). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
131 lines
4.6 KiB
Python
131 lines
4.6 KiB
Python
#!/usr/bin/env python3
|
|
"""audio_pair_segments.py - find the SAME musical content in two archived
|
|
ring dumps (runtime vs beetle attract cycles are phase-shifted) and emit the
|
|
best-matching aligned window pair for A/B analysis.
|
|
|
|
Method: concatenate each side's numbered arch WAVs into one continuous
|
|
timeline (they were dumped gaplessly with absolute start indices in the
|
|
filename). Compute a coarse spectral fingerprint sequence (log-mel-ish band
|
|
energies per 0.5 s hop), normalized-cross-correlate runtime-vs-beetle
|
|
fingerprints at every offset, take the best offset, and cut the overlapping
|
|
region (minus silent edges) from both sides to <out>/pair_runtime.wav and
|
|
<out>/pair_beetle.wav.
|
|
|
|
Usage: python audio_pair_segments.py <archive_dir> [--min-secs 20]
|
|
Requires numpy.
|
|
"""
|
|
import argparse
|
|
import glob
|
|
import os
|
|
import re
|
|
import sys
|
|
import wave
|
|
|
|
import numpy as np
|
|
|
|
HOP_S = 0.5
|
|
BANDS = 24
|
|
|
|
def load_side(archive_dir, side):
|
|
files = []
|
|
for p in glob.glob(os.path.join(archive_dir, f"{side}_arch*_*.wav")):
|
|
m = re.search(rf"{side}_arch(\d+)_(\d+)\.wav$", p)
|
|
if m:
|
|
files.append((int(m.group(1)), int(m.group(2)), p))
|
|
files.sort()
|
|
chunks = []
|
|
rate = 44100
|
|
for _idx, _start, p in files:
|
|
w = wave.open(p)
|
|
rate = w.getframerate()
|
|
n = w.getnframes()
|
|
data = np.frombuffer(w.readframes(n), dtype=np.int16).reshape(-1, 2)
|
|
w.close()
|
|
chunks.append(data)
|
|
if not chunks:
|
|
raise SystemExit(f"no {side}_arch*.wav in {archive_dir}")
|
|
return np.concatenate(chunks), rate
|
|
|
|
def fingerprint(x, rate):
|
|
"""Band-energy sequence, one row per HOP_S, log-compressed, per-row norm."""
|
|
mono = x.astype(np.float32).mean(axis=1) / 32768.0
|
|
hop = int(rate * HOP_S)
|
|
nfft = 4096
|
|
rows = []
|
|
for i in range(0, len(mono) - nfft, hop):
|
|
seg = mono[i:i + nfft] * np.hanning(nfft)
|
|
mag = np.abs(np.fft.rfft(seg))
|
|
# log-spaced band edges 60 Hz .. 16 kHz
|
|
edges = np.geomspace(60, 16000, BANDS + 1) / (rate / 2) * (nfft // 2)
|
|
band = np.array([mag[int(a):max(int(a) + 1, int(b))].mean()
|
|
for a, b in zip(edges[:-1], edges[1:])])
|
|
rows.append(np.log1p(band * 1000.0))
|
|
fp = np.array(rows)
|
|
# per-row zero-mean so correlation keys on spectral SHAPE sequence
|
|
fp -= fp.mean(axis=1, keepdims=True)
|
|
n = np.linalg.norm(fp, axis=1, keepdims=True)
|
|
fp /= np.maximum(n, 1e-9)
|
|
return fp
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("archive_dir")
|
|
ap.add_argument("--min-secs", type=float, default=20.0)
|
|
args = ap.parse_args()
|
|
|
|
rt, rate = load_side(args.archive_dir, "runtime")
|
|
bt, _ = load_side(args.archive_dir, "beetle")
|
|
print(f"runtime: {len(rt)/rate:.0f}s beetle: {len(bt)/rate:.0f}s")
|
|
|
|
frt = fingerprint(rt, rate)
|
|
fbt = fingerprint(bt, rate)
|
|
|
|
# slide beetle over runtime (both directions), score mean row-dot
|
|
best = (-1.0, 0)
|
|
min_rows = int(args.min_secs / HOP_S)
|
|
for off in range(-(len(fbt) - min_rows), len(frt) - min_rows):
|
|
a0, b0 = max(0, off), max(0, -off)
|
|
n = min(len(frt) - a0, len(fbt) - b0)
|
|
if n < min_rows:
|
|
continue
|
|
score = float(np.einsum("ij,ij->i", frt[a0:a0 + n], fbt[b0:b0 + n]).mean())
|
|
if score > best[0]:
|
|
best = (score, off)
|
|
score, off = best
|
|
a0, b0 = max(0, off), max(0, -off)
|
|
n = min(len(frt) - a0, len(fbt) - b0)
|
|
print(f"best offset {off * HOP_S:+.1f}s score {score:.3f} overlap {n * HOP_S:.0f}s")
|
|
|
|
# cut the overlapping window, trimming rows where either side is silent
|
|
energy_rt = np.abs(rt[:, 0].astype(np.int32)) > 256
|
|
energy_bt = np.abs(bt[:, 0].astype(np.int32)) > 256
|
|
s_rt = int(a0 * HOP_S * rate)
|
|
s_bt = int(b0 * HOP_S * rate)
|
|
length = int(n * HOP_S * rate)
|
|
cut_rt = rt[s_rt:s_rt + length]
|
|
cut_bt = bt[s_bt:s_bt + length]
|
|
|
|
def write(path, data):
|
|
w = wave.open(path, "wb")
|
|
w.setnchannels(2)
|
|
w.setsampwidth(2)
|
|
w.setframerate(rate)
|
|
w.writeframes(data.astype(np.int16).tobytes())
|
|
w.close()
|
|
print("wrote", path, f"{len(data)/rate:.0f}s")
|
|
|
|
write(os.path.join(args.archive_dir, "pair_runtime.wav"), cut_rt)
|
|
write(os.path.join(args.archive_dir, "pair_beetle.wav"), cut_bt)
|
|
# rough level comparison on the matched content
|
|
def rms_db(x):
|
|
f = x.astype(np.float64) / 32768.0
|
|
act = f[np.abs(f[:, 0]) > 0.001]
|
|
if len(act) == 0:
|
|
return -120.0
|
|
return 20 * np.log10(np.sqrt((act ** 2).mean()))
|
|
print(f"matched-content RMS: runtime {rms_db(cut_rt):.1f} dBFS, "
|
|
f"beetle {rms_db(cut_bt):.1f} dBFS")
|
|
|
|
if __name__ == "__main__":
|
|
main()
|