#!/usr/bin/env python """v2 Stage 4a — re-derive the Transkun ONNX export + frozen buffers + TDD fixtures. Boundary: featuresBatch -> S (backbone + inner-product CRF scorer). The mel front end (audio -> featuresBatch) and the Viterbi decode (S -> intervals) are ported to C# (4b/4c); this script also emits their reference fixtures. All outputs are written raw little-endian + a manifest.json for trivial C# reads. """ import json, math, os, sys import numpy as np import torch TK_DIR = "/private/tmp/claude-501/-Users-lawls-Development-TuesdayCrowd-Projects-audio-claudio/37748a9b-31e0-48a1-896e-7a25c1faf008/scratchpad/transkun-env/lib/python3.14/site-packages/transkun" OUT = sys.argv[1] if len(sys.argv) > 1 else os.path.dirname(os.path.abspath(__file__)) + "/artifacts" os.makedirs(OUT, exist_ok=True) torch.manual_seed(0) np.random.seed(0) manifest = {} def save(name, arr, dtype): a = np.ascontiguousarray(arr).astype(dtype) fn = name + (".f32" if dtype == " S - class ExportWrapper(torch.nn.Module): def __init__(self, m): super().__init__() self.backbone = m.backbone self.scorer = m.scorer self.register_buffer("outputIndices", torch.tensor(m.targetMIDIPitch)) def forward(self, featuresBatch): # [nBatch, T, 229, 6] ctx = self.backbone(featuresBatch, outputIndices=self.outputIndices) S_batch, _ = self.scorer(ctx) # S_batch: [T, T, nBatch, 90]; skip is provably 0 return S_batch.flatten(-2, -1) # [T, T, nBatch*90] wrapper = ExportWrapper(model).eval() onnx_path = os.path.join(OUT, "model.onnx") T0 = 64 feat_example = torch.randn(1, T0, 229, 6) # The backbone's axial attention calls SDPA on 5-D q/k/v ([B, T', H, L, D]); the ONNX exporter's SDPA only # supports 4-D. SDPA treats every dim before the last two as batch, so collapsing the two leading dims to # one 4-D batch is a mathematical identity (validated by corr below). Patch for the duration of the export. import torch.nn.functional as F _orig_sdpa = F.scaled_dot_product_attention def _sdpa_4d(q, k, v, *a, **kw): if q.ndim == 5: B, X, H, L, D = q.shape rs = lambda t: t.reshape(B * X, H, L, D) return _orig_sdpa(rs(q), rs(k), rs(v), *a, **kw).reshape(B, X, H, L, D) return _orig_sdpa(q, k, v, *a, **kw) F.scaled_dot_product_attention = _sdpa_4d torch.nn.functional.scaled_dot_product_attention = _sdpa_4d print(f"[2] exporting featuresBatch{list(feat_example.shape)} -> S, opset 17 (SDPA reshaped 5D->4D)") try: torch.onnx.export( wrapper, (feat_example,), onnx_path, opset_version=17, input_names=["featuresBatch"], output_names=["S"], dynamic_axes={"featuresBatch": {1: "T"}, "S": {0: "T", 1: "T"}}) print(f" export OK: {os.path.getsize(onnx_path)/1e6:.1f} MB (+ external .data)") except Exception as e: print(f" STOCK EXPORT FAILED: {type(e).__name__}: {e}\n (would apply eye-multiply patch)") raise # Consolidate the external-data weights into ONE self-contained .onnx for committing. import onnx m_full = onnx.load(onnx_path) # pulls in model.onnx.data single_path = os.path.join(OUT, "transkun.onnx") onnx.save(m_full, single_path, save_as_external_data=False) os.remove(onnx_path) if os.path.exists(onnx_path + ".data"): os.remove(onnx_path + ".data") print(f" consolidated -> transkun.onnx {os.path.getsize(single_path)/1e6:.1f} MB (single file)") # ---------------------------------------------------------------- 3. validate ONNX == PyTorch ----------- print("[3] validating single-file ONNX vs PyTorch") import onnxruntime as ort sess = ort.InferenceSession(single_path, providers=["CPUExecutionProvider"]) def check(feat, tag): s_torch = wrapper(feat).numpy() s_onnx = sess.run(None, {"featuresBatch": feat.numpy()})[0] corr = np.corrcoef(s_torch.ravel(), s_onnx.ravel())[0, 1] denom = np.abs(s_torch).max() + 1e-9 relerr = np.abs(s_torch - s_onnx).max() / denom print(f" {tag:14} shape={list(s_onnx.shape)} corr={corr:.6f} maxRelErr={relerr:.2e}") return corr, relerr, s_onnx check(feat_example, "random T=64") check(torch.randn(1, 100, 229, 6), "random T=100") # dynamic-T sanity # ---------------------------------------------------------------- 4. frozen buffers --------------------- print("[4] extracting frozen buffers") fe = model.framewiseFeatureExtractor freq2mels = fe.freq2mels.numpy() # [2049, 229] win = fe.spectrogramExtractor.win # [4096] Hann wins = torch.cat([win.unsqueeze(0), fe.spectrogramExtractor.winGen.get().t()], dim=0).numpy() # [6, 4096] save("freq2mels", freq2mels, " featuresBatch ------ print("[5] ref3b (mel front end reference)") fs, hop, wsz, eps, nmel = model.fs, model.hopSize, model.windowSize, fe.eps, fe.outputDim # The first 1.5 s of the committed two-bar MeltySynth piano render (44100 Hz mono int16) — a real piano # signal (attack + timbre) that fires the MAESTRO-trained model, fully reproducible from a committed WAV. import wave wf = wave.open("/Users/lawls/Development/TuesdayCrowd/Projects/audio-claudio/fixtures/golden/two-bar.wav") assert wf.getframerate() == fs and wf.getnchannels() == 1 and wf.getsampwidth() == 2 nread = int(1.5 * fs) audio = (np.frombuffer(wf.readframes(nread), dtype=" features {list(features.shape)}") # ---------------------------------------------------------------- 6. ref3c: S -> intervals -------------- print("[6] ref3c (Viterbi decode reference)") from transkun.CRF.NeuralSemiCRFInterval import viterbiBackward S_real = wrapper(features).squeeze() # [nFrame,nFrame,90] (nBatch=1 -> flatten gives 90) T = S_real.shape[0] noise = torch.zeros(T - 1, S_real.shape[2]) intervals_real = viterbiBackward(S_real, noise, None) # per-track list of (begin,end) save("ref3c_S", S_real.numpy(), " {nnotes} intervals across 90 tracks") # Hand-checkable synthetic S, multi-track (score[end, begin, track]): track 5 = interval (1,3) + singleton # (5,5); track 10 = interval (0,4); track 20 = singleton (2,2). Decoded with default forcedStartPos. Tsyn, nSym = 6, 90 Ssyn = torch.full((Tsyn, Tsyn, nSym), -5.0) Ssyn[3, 1, 5] = 4.0 Ssyn[5, 5, 5] = 2.0 Ssyn[4, 0, 10] = 6.0 Ssyn[2, 2, 20] = 3.0 noise_syn = torch.zeros(Tsyn - 1, nSym) intervals_syn = viterbiBackward(Ssyn, noise_syn, None) save("ref3c_syn_S", Ssyn.numpy(), " t5={intervals_syn[5]} t10={intervals_syn[10]} t20={intervals_syn[20]}") # A forcedStartPos case (used by 4d segment stitching): same S, but track 5 forced to start at frame 4, # so its interval (1,3) is skipped and only the singleton (5,5) survives. forced = [0] * nSym forced[5] = 4 intervals_forced = viterbiBackward(Ssyn, noise_syn, forced) json.dump({"forcedStartPos": forced, "intervals": {str(k): v for k, v in enumerate(intervals_forced)}}, open(os.path.join(OUT, "ref3c_forced_intervals.json"), "w")) print(f" forced(t5->4) -> t5={intervals_forced[5]} (interval (1,3) skipped)") # ---------------------------------------------------------------- 7. write manifest + params ------------ json.dump(manifest, open(os.path.join(OUT, "manifest.json"), "w"), indent=2) json.dump(params, open(os.path.join(OUT, "params.json"), "w"), indent=2) print(f"[7] wrote manifest.json + params.json to {OUT}") print("DONE")