Add parity tests for the browser port against metrics.py
fixtures.py synthesises audio aimed at the metric code's branches: shorter than each window, digital silence, a passage below the relative gate, 96 kHz, every PCM layout the direct WAV/AIFF reader handles, and (with ffmpeg) MP3/Vorbis/Opus/AAC. reference.py loads each file as the desktop app does and saves the exact mono signal plus every compute() result; parity.mjs runs web/dsp.js on that same signal and compares every field. Current result: 323/323 fields in tolerance; float64 paths agree to ~1e-12, float32 paths (true peak, spectrogram dB, RMS) to ~1e-6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015UXA39Sh9coAx4FhRkd8R2
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Dump the Python metrics as the reference for the browser port (web/dsp.js).
|
||||
|
||||
Each audio file is loaded exactly as the desktop app loads it
|
||||
(`master_core.AudioFile`); the mono signal the metrics see is written out next
|
||||
to every metric's `compute()` result. `parity.mjs` feeds that same signal to
|
||||
dsp.js, so the comparison isolates the DSP port from decoder differences.
|
||||
|
||||
uv run --no-project --with librosa --with pyloudnorm --with scipy --with mutagen \
|
||||
python tests/parity/reference.py OUT_DIR AUDIO...
|
||||
|
||||
Writes OUT_DIR/<file name>/{mono.f32, reference.json, spectrogram.f32}.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
|
||||
from master_core import AudioFile # noqa: E402
|
||||
from metrics import METRICS # noqa: E402
|
||||
|
||||
|
||||
def _jsonable(value):
|
||||
if isinstance(value, np.ndarray):
|
||||
return [float(v) if np.isfinite(v) else None for v in value.astype(np.float64).ravel()]
|
||||
if isinstance(value, (float, np.floating)):
|
||||
return float(value) if np.isfinite(value) else None
|
||||
return value
|
||||
|
||||
|
||||
def main() -> None:
|
||||
out = Path(sys.argv[1])
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
for path in sys.argv[2:]:
|
||||
name = Path(path).name
|
||||
try:
|
||||
audio = AudioFile(path)
|
||||
except Exception as exc: # librosa can't read every container the browser can
|
||||
print(f"{name}: skipped ({exc})")
|
||||
continue
|
||||
|
||||
target = out / name
|
||||
target.mkdir(exist_ok=True)
|
||||
audio.y_mono.astype(np.float32).tofile(target / "mono.f32")
|
||||
|
||||
results = {}
|
||||
for metric_id, metric in METRICS.items():
|
||||
data = metric.compute(audio)
|
||||
if metric_id == "spectrogram":
|
||||
s_db = np.ascontiguousarray(data["s_db"], dtype=np.float32)
|
||||
s_db.tofile(target / "spectrogram.f32")
|
||||
results[metric_id] = {
|
||||
"rows": int(s_db.shape[0]), "cols": int(s_db.shape[1]),
|
||||
"times": _jsonable(data["times"]), "freqs": _jsonable(data["freqs"]),
|
||||
"nyquist": float(data["nyquist"]),
|
||||
}
|
||||
else:
|
||||
results[metric_id] = {key: _jsonable(val) for key, val in data.items()}
|
||||
|
||||
meta = {
|
||||
"file": name, "sr": int(audio.sr), "n": int(len(audio.y_mono)),
|
||||
"song_name": audio.song_name,
|
||||
"max_amplitude": float(audio.max_amplitude), "avg_amplitude": float(audio.avg_amplitude),
|
||||
}
|
||||
(target / "reference.json").write_text(json.dumps({"meta": meta, "metrics": results}))
|
||||
print(f"{name}: {audio.sr} Hz, {len(audio.y_mono) / audio.sr:.2f} s, song_name={audio.song_name!r}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user