| |
| """ |
| Compare PyTorch vocoder vs quantized axmodel output. |
| Saves intermediate tensors for board-side comparison. |
| |
| Usage: |
| # Dev machine: generate test data |
| python3 compare_vocoder.py --save-test-data |
| |
| # Board: run axmodel on same input, save output |
| python3 compare_vocoder.py --run-axmodel |
| |
| # Dev machine: compare results |
| python3 compare_vocoder.py --compare |
| """ |
| import sys, os, math, argparse |
| import numpy as np |
|
|
| REPO_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) |
| ONNX_DIR = os.path.join(REPO_DIR, 'cpp', 'vocoder_onnx') |
| TEST_DIR = os.path.join(ONNX_DIR, 'test_data') |
| os.makedirs(TEST_DIR, exist_ok=True) |
|
|
| FEAT_SCALE = 0.1 |
| N_FFT = 1024 |
| HOP = 256 |
|
|
|
|
| def irfft_overlap_add(real_spec, imag_spec): |
| """C++ IRFFT + overlap-add, verified to match PyTorch istft (cos_sim=0.999999).""" |
| n_freqs = N_FFT // 2 + 1 |
| T = real_spec.shape[1] |
| window = 0.5 * (1.0 - np.cos(2.0 * math.pi * np.arange(N_FFT) / (N_FFT - 1))) |
| window_sq = window ** 2 |
|
|
| |
| irfft_cos = np.zeros((N_FFT, n_freqs), dtype=np.float32) |
| irfft_sin = np.zeros((N_FFT, n_freqs), dtype=np.float32) |
| for n in range(N_FFT): |
| for k in range(n_freqs): |
| ang = 2.0 * math.pi * k * n / N_FFT |
| if k == 0: |
| irfft_cos[n, k] = 1.0 / N_FFT |
| irfft_sin[n, k] = 0.0 |
| elif k == n_freqs - 1: |
| irfft_cos[n, k] = math.cos(ang) / N_FFT |
| irfft_sin[n, k] = 0.0 |
| else: |
| irfft_cos[n, k] = math.cos(ang) * (2.0 / N_FFT) |
| irfft_sin[n, k] = math.sin(ang) * (2.0 / N_FFT) |
|
|
| out_len = (T - 1) * HOP + N_FFT |
| audio = np.zeros(out_len, dtype=np.float32) |
| envelope = np.zeros(out_len, dtype=np.float32) |
|
|
| for t in range(T): |
| r = real_spec[0, t, :] |
| im = imag_spec[0, t, :] |
| frame = irfft_cos @ r - irfft_sin @ im |
| pos = t * HOP |
| for n in range(N_FFT): |
| p = pos + n |
| if p < out_len: |
| audio[p] += frame[n] * window[n] |
| envelope[p] += window_sq[n] |
|
|
| audio /= np.maximum(envelope, 1e-10) |
| pad = N_FFT // 2 |
| return audio[pad:pad + (T - 1) * HOP] |
|
|
|
|
| def compare(name, ref, test): |
| ref = np.asarray(ref, dtype=np.float32).flatten() |
| test = np.asarray(test, dtype=np.float32).flatten() |
| if len(ref) != len(test): |
| print(f" [{name}] SIZE MISMATCH: ref={ref.shape} test={test.shape}") |
| return |
| diff = np.abs(ref - test) |
| sig = np.mean(np.abs(ref)) + 1e-10 |
| cos = np.dot(ref, test) / (np.linalg.norm(ref) * np.linalg.norm(test) + 1e-10) |
| print(f" [{name}] max_err={diff.max():.2e} rel_err={diff.mean()/sig:.2e} cos_sim={cos:.6f}") |
|
|
|
|
| def cmd_save_test_data(): |
| """Generate test mel and save PT/ONNX reference outputs.""" |
| import torch |
| sys.path.insert(0, REPO_DIR) |
| from scripts.local_vocos import LocalVocos |
| import onnxruntime as ort |
|
|
| |
| vocoder = LocalVocos() |
| sd = torch.load(f'{REPO_DIR}/resources/vocos-mel-24khz/pytorch_model.bin', |
| weights_only=True, map_location='cpu') |
| sd = {k: v for k, v in sd.items() if k.startswith(('backbone.', 'head.'))} |
| vocoder.load_state_dict(sd) |
| vocoder.eval() |
|
|
| sess_f = ort.InferenceSession(f'{ONNX_DIR}/vocos_full_B1_T620.onnx') |
|
|
| |
| mel_bin = os.path.join(REPO_DIR, 'cpp', 'output_mel.bin') |
| if os.path.exists(mel_bin): |
| real_mel = np.fromfile(mel_bin, dtype=np.float32).reshape(-1, 100) |
| T = real_mel.shape[0] |
| |
| mel_input = (real_mel / FEAT_SCALE).T[np.newaxis, :, :].astype(np.float32) |
| print(f"Using real mel: shape={mel_input.shape}, range=[{mel_input.min():.3f}, {mel_input.max():.3f}]") |
| else: |
| T = 200 |
| mel_input = np.random.RandomState(42).randn(1, 100, T).astype(np.float32) * 3.0 |
| print(f"Using random mel: shape={mel_input.shape}") |
|
|
| |
| T_pad = 620 |
| mel_onnx = np.zeros((1, 100, T_pad), dtype=np.float32) |
| mel_onnx[:, :, :T] = mel_input[:, :, :T] |
|
|
| |
| mel_pt = torch.from_numpy(mel_input) |
| with torch.no_grad(): |
| features_pt = vocoder.backbone(mel_pt) |
| audio_pt = vocoder.head(features_pt).squeeze().numpy() |
| h = vocoder.head.out(features_pt) |
| mag, phase = h.chunk(2, dim=-1) |
| mag = torch.exp(mag).clamp(max=1e2) |
| real_pt = (mag * torch.cos(phase)).numpy() |
| imag_pt = (mag * torch.sin(phase)).numpy() |
|
|
| |
| onnx_out = sess_f.run(None, {'mel': mel_onnx}) |
| real_onnx = onnx_out[0][:, :T, :] |
| imag_onnx = onnx_out[1][:, :T, :] |
| audio_onnx = irfft_overlap_add(real_onnx, imag_onnx) |
|
|
| |
| np.save(f'{TEST_DIR}/mel_input.npy', mel_input) |
| np.save(f'{TEST_DIR}/mel_onnx_padded.npy', mel_onnx) |
| np.save(f'{TEST_DIR}/pt_real.npy', real_pt) |
| np.save(f'{TEST_DIR}/pt_imag.npy', imag_pt) |
| np.save(f'{TEST_DIR}/pt_audio.npy', audio_pt) |
| np.save(f'{TEST_DIR}/onnx_real.npy', real_onnx) |
| np.save(f'{TEST_DIR}/onnx_imag.npy', imag_onnx) |
| np.save(f'{TEST_DIR}/onnx_audio.npy', audio_onnx) |
| np.save(f'{TEST_DIR}/T_frames.npy', np.array([T], dtype=np.int32)) |
|
|
| |
| with open(f'{TEST_DIR}/info.txt', 'w') as f: |
| f.write(f"T={T}\n") |
| f.write(f"feat_scale={FEAT_SCALE}\n") |
| f.write(f"mel_range=[{mel_input.min():.4f}, {mel_input.max():.4f}]\n") |
| f.write(f"pt_real_range=[{real_pt.min():.4f}, {real_pt.max():.4f}]\n") |
| f.write(f"pt_audio_len={len(audio_pt)}\n") |
|
|
| |
| print("\n=== PT vs ONNX (dev machine) ===") |
| compare('real_spectrum', real_pt, real_onnx) |
| compare('imag_spectrum', imag_pt, imag_onnx) |
| compare('audio', audio_pt, audio_onnx) |
|
|
| |
| import soundfile as sf |
| sf.write(f'{TEST_DIR}/pt_audio.wav', audio_pt, 24000) |
| sf.write(f'{TEST_DIR}/onnx_audio.wav', audio_onnx, 24000) |
|
|
| print(f"\nTest data saved to {TEST_DIR}/") |
| print("Copy to board and run: python3 compare_vocoder.py --run-axmodel") |
|
|
|
|
| def cmd_run_axmodel(): |
| """Run axmodel on board with the same test mel, save output.""" |
| import onnxruntime as ort |
| from axengine import InferenceSession |
|
|
| T = int(np.load(f'{TEST_DIR}/T_frames.npy')[0]) |
| mel_onnx = np.load(f'{TEST_DIR}/mel_onnx_padded.npy') |
|
|
| |
| model_path = f'{ONNX_DIR}/axmodel/vocos_full.axmodel' |
| if not os.path.exists(model_path): |
| print(f"ERROR: {model_path} not found") |
| return |
|
|
| print(f"Loading axmodel: {model_path}") |
| sess = InferenceSession(model_path) |
|
|
| print(f"Running inference (mel shape={mel_onnx.shape})...") |
| outputs = sess.run(None, {'mel': mel_onnx}) |
| print(f"Output keys: {list(outputs.keys()) if isinstance(outputs, dict) else type(outputs)}") |
|
|
| |
| if isinstance(outputs, dict): |
| real_ax = outputs['real'][:, :T, :] |
| imag_ax = outputs['imag'][:, :T, :] |
| elif isinstance(outputs, (list, tuple)): |
| real_ax = outputs[0][:, :T, :] |
| imag_ax = outputs[1][:, :T, :] |
| else: |
| real_ax = outputs[:, :T, :] |
| imag_ax = None |
|
|
| audio_ax = irfft_overlap_add(real_ax, imag_ax) |
|
|
| |
| np.save(f'{TEST_DIR}/ax_real.npy', real_ax) |
| np.save(f'{TEST_DIR}/ax_imag.npy', imag_ax) |
| np.save(f'{TEST_DIR}/ax_audio.npy', audio_ax) |
| import soundfile as sf |
| sf.write(f'{TEST_DIR}/ax_audio.wav', audio_ax, 24000) |
|
|
| |
| onnx_real = np.load(f'{TEST_DIR}/onnx_real.npy') |
| onnx_imag = np.load(f'{TEST_DIR}/onnx_imag.npy') |
| onnx_audio = np.load(f'{TEST_DIR}/onnx_audio.npy') |
|
|
| print("\n=== axmodel vs ONNX (board) ===") |
| compare('real_spectrum', onnx_real, real_ax) |
| compare('imag_spectrum', onnx_imag, imag_ax) |
| compare('audio', onnx_audio, audio_ax) |
|
|
| print(f"\nResults saved to {TEST_DIR}/") |
| print("Copy back to dev machine and run: python3 compare_vocoder.py --compare") |
|
|
|
|
| def cmd_compare(): |
| """Compare all outputs (dev machine, after copying ax_*.npy from board).""" |
| pt_audio = np.load(f'{TEST_DIR}/pt_audio.npy') |
| onnx_audio = np.load(f'{TEST_DIR}/onnx_audio.npy') |
| ax_audio = np.load(f'{TEST_DIR}/ax_audio.npy') |
|
|
| pt_real = np.load(f'{TEST_DIR}/pt_real.npy') |
| onnx_real = np.load(f'{TEST_DIR}/onnx_real.npy') |
| ax_real = np.load(f'{TEST_DIR}/ax_real.npy') |
|
|
| pt_imag = np.load(f'{TEST_DIR}/pt_imag.npy') |
| onnx_imag = np.load(f'{TEST_DIR}/onnx_imag.npy') |
| ax_imag = np.load(f'{TEST_DIR}/ax_imag.npy') |
|
|
| print("=== Full Comparison ===") |
| print("\n--- Spectrum ---") |
| compare('real: PT vs ONNX', pt_real, onnx_real) |
| compare('real: PT vs axmodel', pt_real, ax_real) |
| compare('real: ONNX vs axmodel', onnx_real, ax_real) |
| print() |
| compare('imag: PT vs ONNX', pt_imag, onnx_imag) |
| compare('imag: PT vs axmodel', pt_imag, ax_imag) |
| compare('imag: ONNX vs axmodel', onnx_imag, ax_imag) |
|
|
| print("\n--- Audio ---") |
| compare('audio: PT vs ONNX', pt_audio, onnx_audio) |
| compare('audio: PT vs axmodel', pt_audio, ax_audio) |
| compare('audio: ONNX vs axmodel', onnx_audio, ax_audio) |
|
|
|
|
| def main(): |
| parser = argparse.ArgumentParser() |
| parser.add_argument('--save-test-data', action='store_true') |
| parser.add_argument('--run-axmodel', action='store_true') |
| parser.add_argument('--compare', action='store_true') |
| args = parser.parse_args() |
|
|
| if args.save_test_data: |
| cmd_save_test_data() |
| elif args.run_axmodel: |
| cmd_run_axmodel() |
| elif args.compare: |
| cmd_compare() |
| else: |
| print("Usage: --save-test-data | --run-axmodel | --compare") |
| print("\nWorkflow:") |
| print(" 1. Dev machine: python3 compare_vocoder.py --save-test-data") |
| print(" 2. Copy TEST_DIR to board, run: python3 compare_vocoder.py --run-axmodel") |
| print(" 3. Copy ax_*.npy back to dev, run: python3 compare_vocoder.py --compare") |
|
|
|
|
| if __name__ == '__main__': |
| main() |
|
|