#!/usr/bin/env python3 from __future__ import annotations import argparse import subprocess import sys import tempfile from pathlib import Path import numpy as np import soundfile as sf import torch def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Compare audio.cpp STFT against torch.stft.") parser.add_argument("wav", type=Path) parser.add_argument("--dump-bin", type=Path, default=Path("build/debug/bin/stft_parity_dump")) parser.add_argument("--kind", choices=("stft",), default="stft") parser.add_argument("--n-fft", type=int, default=512) parser.add_argument("--hop-length", type=int, default=160) parser.add_argument("--win-length", type=int, default=400) parser.add_argument("--pad-mode", choices=("constant", "reflect"), default="constant") parser.add_argument("--preemphasis", type=float, default=0.0) parser.add_argument("--periodic-window", action="store_true") return parser.parse_args() def run_dump( dump_bin: Path, wav: Path, kind: str, preemphasis: float, n_fft: int, hop_length: int, win_length: int, pad_mode: str, ) -> tuple[np.ndarray, tuple[int, ...]]: with tempfile.TemporaryDirectory(prefix=f"stft-{kind}-") as tmp: tmp_path = Path(tmp) f32_path = tmp_path / "spec.f32" shape_path = tmp_path / "shape.i64" cmd = [str(dump_bin), str(wav), kind, str(f32_path), str(shape_path)] if preemphasis != 0.0: cmd.extend(["--preemphasis", str(preemphasis)]) cmd.extend( [ "--n-fft", str(n_fft), "--hop-length", str(hop_length), "--win-length", str(win_length), "--pad-mode", pad_mode, ] ) subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) shape = tuple(np.fromfile(shape_path, dtype=np.int64).tolist()) spec = np.fromfile(f32_path, dtype=np.float32).reshape(shape) return spec, shape def apply_preemphasis(audio: np.ndarray, coeff: float) -> np.ndarray: if coeff == 0.0 or audio.size == 0: return audio out = audio.copy() out[1:] = out[1:] - coeff * out[:-1] return out def compare(name: str, cpp_spec: np.ndarray, torch_spec: np.ndarray) -> None: diff = cpp_spec - torch_spec abs_diff = np.abs(diff) idx = np.unravel_index(np.argmax(abs_diff), abs_diff.shape) print(f"{name}:") print(f" shape={cpp_spec.shape}") print(f" max_abs={float(abs_diff.max())}") print(f" mean_abs={float(abs_diff.mean())}") print(f" rmse={float(np.sqrt(np.mean(diff * diff)))}") print(f" worst_idx={idx}") print(f" cpp_val={float(cpp_spec[idx])}") print(f" torch_val={float(torch_spec[idx])}") def main() -> int: args = parse_args() audio, sr = sf.read(args.wav, dtype="float32") if sr != 16000: raise RuntimeError(f"expected 16000 Hz WAV, got {sr}") if audio.ndim != 1: raise RuntimeError("expected mono WAV") audio = apply_preemphasis(audio, args.preemphasis) x = torch.from_numpy(audio) window = torch.hann_window( args.win_length, periodic=args.periodic_window, dtype=torch.float32, ) spec = torch.stft( x, n_fft=args.n_fft, hop_length=args.hop_length, win_length=args.win_length, window=window, center=True, pad_mode=args.pad_mode, normalized=False, onesided=True, return_complex=True, ) torch_spec = torch.view_as_real(spec).unsqueeze(0).numpy().astype(np.float32) cpp_spec, _ = run_dump( args.dump_bin, args.wav, args.kind, args.preemphasis, args.n_fft, args.hop_length, args.win_length, args.pad_mode, ) compare(args.kind, cpp_spec, torch_spec) return 0 if __name__ == "__main__": sys.exit(main())