@audio/neural-separate 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,481 @@
1
+ #!/usr/bin/env python3
2
+ """Export Mel-Band RoFormer (Wang, Lu, Won, Chen, Song, "Mel-Band RoFormer for Music Source Separation", ISMIR LBD
3
+ 2023) to the ONNX contract @audio/neural-separate expects for modelType 'complex': the network between its STFT and
4
+ iSTFT, which JS computes, written for the GPU.
5
+
6
+ pip install torch onnx onnxruntime pyyaml einops beartype rotary-embedding-torch librosa
7
+ python3 export-roformer.py --model mel-roformer --verify
8
+
9
+ The model is Kimberley Jensen's vocals Mel-Band RoFormer (huggingface.co/KimberleyJSN/melbandroformer, MIT), with the
10
+ config ZFTurbo/Music-Source-Separation-Training (MIT) runs it by (configs/KimberleyJensen/
11
+ config_vocals_mel_band_roformer_kj.yaml) and that repository's models/bs_roformer code, both at a pinned commit.
12
+
13
+ One 8 s segment (352800 samples at 44.1 kHz, 801 frames) per run:
14
+ input mix_spec [1, 4, 1025, 801] STFT, complex as channels (L re, L im, R re, R im)
15
+ output stems_spec [1, 4, 1025, 801] the vocals, per channel, re and im
16
+ The STFT is the model's own: n_fft 2048, hop 441, Hann window of 2048, unnormalized, centered with reflect padding.
17
+
18
+ The graph computes what MelBandRoformer.forward computes between its STFT and iSTFT, rearranged so a GPU runs it in few
19
+ and large operations; none of it changes the function (--verify: against the PyTorch forward):
20
+ - the 60 bands' input projections and mask MLPs run as batched matrix products, bands of near widths grouped and
21
+ zero-padded to the widest of their group (10 groups, none wider than 1.3 times its narrowest), not 60
22
+ small products each;
23
+ - each RMSNorm's scale (√d·γ) is folded into the weights of the linear layer it feeds; the attention's query scale
24
+ into the query weights; its gates' projection joins the q, k, v projection;
25
+ - the rotary embedding's interleaved pairs become halves (the same permutation of q's and k's head features leaves
26
+ q·k as it was), so the rotation is two products with tables of cos and sin and one with a fixed 64×64 matrix;
27
+ - the masks of overlapping mel bands are averaged by two gathers and a weight, not a scatter, the DC bin's weight 0
28
+ (zero_dc);
29
+ - no tensor passes 100 MB: each transformer runs on slices of its sequences, the mask MLP by group of bands
30
+ (onnxruntime's WebGPU Softmax went wrong past WebGPU's default 128 MiB storage binding).
31
+
32
+ Writes <out-dir>/mel-roformer.onnx (928 MB), and with --fp16 mel-roformer.fp16.onnx (float16 weights, float32 compute,
33
+ half the download). Products computed in float16 measured slower on WebGPU (3.2 against 2.6 s a segment): not offered.
34
+ Default out-dir: $AUDIO_NEURAL_CACHE/<model>, else ~/.cache/audiojs/neural/<model>. --verify compares the stems rebuilt
35
+ from onnxruntime's output with MelBandRoformer.forward on the same segment and writes the reference separation test.js
36
+ compares the JS pipeline against (scripts/reference.py, chunked as separate.js chunks: step 0.5, Kim's num_overlap 2).
37
+ Last run: torch 2.14, onnxruntime 1.31.
38
+ """
39
+ import argparse
40
+ import gc
41
+ import math
42
+ import os
43
+ import sys
44
+ import urllib.request
45
+ from pathlib import Path
46
+
47
+ REPO = "https://raw.githubusercontent.com/ZFTurbo/Music-Source-Separation-Training/84b1eac0887756b4f1a9d7a1ff49105939749ed2/"
48
+ MODELS = { # checkpoint URL, its SHA-256, config path in REPO
49
+ "mel-roformer": ("https://huggingface.co/KimberleyJSN/melbandroformer/resolve/ac9b0614ab3cd7f77219e18ba494dfd93956c348/MelBandRoformer.ckpt",
50
+ "87201f4d31afb5bc79993230fc49446918425574db48c01c405e44f365c7559e",
51
+ "configs/KimberleyJensen/config_vocals_mel_band_roformer_kj.yaml"),
52
+ }
53
+ SEGMENT = 352800
54
+ STEP = 0.5
55
+
56
+
57
+ def fetch(url, path):
58
+ if not path.exists():
59
+ print(f"fetching {url}")
60
+ urllib.request.urlretrieve(url, str(path) + ".part")
61
+ os.replace(str(path) + ".part", path)
62
+ return path
63
+
64
+
65
+ def load(src, name="mel-roformer"):
66
+ """MelBandRoformer with its weights: the model code, config and checkpoint fetched into `src` once."""
67
+ import hashlib
68
+ import torch
69
+ import yaml
70
+
71
+ pkg = src / "roformer_code" / "models" / "bs_roformer"
72
+ pkg.mkdir(parents=True, exist_ok=True)
73
+ (pkg.parent / "__init__.py").touch()
74
+ (pkg / "__init__.py").touch()
75
+ for f in ("mel_band_roformer.py", "attend.py"):
76
+ fetch(REPO + "models/bs_roformer/" + f, pkg / f)
77
+ sys.path.insert(0, str(src / "roformer_code"))
78
+ sys.dont_write_bytecode = True
79
+ from models.bs_roformer.mel_band_roformer import MelBandRoformer
80
+
81
+ class Loader(yaml.SafeLoader): pass # the config's resolutions are a !!python/tuple
82
+ Loader.add_constructor("tag:yaml.org,2002:python/tuple", lambda l, n: tuple(l.construct_sequence(n)))
83
+ url, sha, config = MODELS[name]
84
+ cfg = yaml.load(fetch(REPO + config, src / Path(config).name).read_text(), Loader=Loader)
85
+ ckpt = fetch(url, src / Path(url).name)
86
+ h = hashlib.sha256()
87
+ with open(ckpt, "rb") as f:
88
+ for b in iter(lambda: f.read(1 << 24), b""):
89
+ h.update(b)
90
+ if h.hexdigest() != sha:
91
+ raise SystemExit(f"{ckpt}: SHA-256 {h.hexdigest()}, expected {sha}")
92
+ model = MelBandRoformer(**cfg["model"])
93
+ state = torch.load(ckpt, map_location="cpu", weights_only=True)
94
+ model.load_state_dict(state.get("state_dict", state) if isinstance(state, dict) else state)
95
+ return model.eval(), cfg
96
+
97
+
98
+ def stft(model, x):
99
+ """The model's STFT: (B, C, L) -> (B, 2C, F, T), complex as channels."""
100
+ import torch
101
+
102
+ B, C, L = x.shape
103
+ z = torch.view_as_real(torch.stft(x.reshape(-1, L), **model.stft_kwargs, window=model.stft_window_fn(), return_complex=True))
104
+ return z.permute(0, 3, 1, 2).reshape(B, 2 * C, z.shape[1], z.shape[2])
105
+
106
+
107
+ def istft(model, y, length):
108
+ """(B, 2·S·C, F, T) -> (B, S, C, L)"""
109
+ import torch
110
+
111
+ B, _, Fr, T = y.shape
112
+ y = torch.view_as_complex(y.reshape(-1, 2, Fr, T).permute(0, 2, 3, 1).contiguous())
113
+ x = torch.istft(y, **model.stft_kwargs, window=model.stft_window_fn(), length=length)
114
+ return x.reshape(B, -1, model.audio_channels, length)
115
+
116
+
117
+ def groups(widths, slack=1.3):
118
+ """Contiguous runs of bands whose widest is at most `slack` times their narrowest: [(first, last + 1, widest)]"""
119
+ out, b0 = [], 0
120
+ for b in range(1, len(widths) + 1):
121
+ if b == len(widths) or max(widths[b0:b + 1]) > slack * min(widths[b0:b + 1]):
122
+ out.append((b0, b, max(widths[b0:b])))
123
+ b0 = b
124
+ return out
125
+
126
+
127
+ class Core:
128
+ """MelBandRoformer.forward between its STFT and its iSTFT, for the GPU (the module docstring says how)."""
129
+
130
+ @staticmethod
131
+ def build(model, T):
132
+ import torch
133
+ import torch.nn as nn
134
+ import torch.nn.functional as F
135
+
136
+ f64 = torch.float64
137
+ Fq = model.num_bands_per_freq.numel() # 1025
138
+ S = model.audio_channels
139
+ fi = model.freq_indices.tolist() # (f·S + s) per band's pair, bands in order
140
+ nf = model.num_freqs_per_band.tolist()
141
+ widths = [2 * n * S for n in nf] # a band's input: its (f, s) pairs, re and im
142
+ G = groups(widths)
143
+ lin = torch.matmul
144
+ # every tensor under WebGPU's default storage binding (128 MiB, maxStorageBufferBindingSize): onnxruntime
145
+ # 1.30's WebGPU Softmax over a larger one came out wrong one run in two (36 dB from the CPU's at 307 MB, right
146
+ # at 123 MB), so a transformer runs on as many sequences at a time as keep its largest, the attention's
147
+ # scores (heads × n × n) or its feed-forward's hidden layer (n × 4·dim), under 100 MB
148
+ BUDGET = 100e6
149
+
150
+ def sliced(f, x):
151
+ per = max(f.biggest(x.shape[1]), 1)
152
+ size = max(1, int(BUDGET // per))
153
+ return torch.cat([f(x[i:i + size]) for i in range(0, x.shape[0], size)]) if size < x.shape[0] else f(x)
154
+
155
+ rot = lambda n, emb: emb(emb.get_seq_pos(n, device="cpu", dtype=torch.float32)) # (n, dh) interleaved
156
+
157
+ def rms(m):
158
+ return (m.gamma.detach().double() * m.scale)
159
+
160
+ class Mod(nn.Module):
161
+ def __init__(s):
162
+ super().__init__()
163
+ # the STFT as columns: E[t, (f·S + s)·2 + c], and a zero column past them (index Fq·S·2)
164
+ pos = [] # each band's input columns, in its order
165
+ o = 0
166
+ for n in nf:
167
+ pos.append([fi[k] * 2 + c for k in range(o, o + n * S) for c in range(2)])
168
+ o += n * S
169
+ zero = Fq * S * 2
170
+ bs = model.band_split.to_features
171
+ s.split_idx, s.split_w, s.split_b = [], nn.ParameterList(), nn.ParameterList()
172
+ for b0, b1, W in G:
173
+ idx = torch.full((b1 - b0, W), zero, dtype=torch.long)
174
+ w = torch.zeros(b1 - b0, W, bs[0][1].out_features, dtype=f64)
175
+ bias = torch.zeros(b1 - b0, 1, w.shape[2], dtype=f64)
176
+ for j, b in enumerate(range(b0, b1)):
177
+ norm, lin = bs[b][0], bs[b][1]
178
+ idx[j, :widths[b]] = torch.tensor(pos[b])
179
+ w[j, :widths[b]] = (lin.weight.detach().double() * rms(norm)[None]).T
180
+ bias[j, 0] = lin.bias.detach().double()
181
+ s.register_buffer(f"si{len(s.split_idx)}", idx)
182
+ s.split_idx.append(f"si{len(s.split_idx)}")
183
+ s.split_w.append(nn.Parameter(w.float(), requires_grad=False))
184
+ s.split_b.append(nn.Parameter(bias.float(), requires_grad=False))
185
+ # transformers: time then frequency, per depth
186
+ s.blocks = nn.ModuleList()
187
+ for time, freq in model.layers:
188
+ s.blocks.append(nn.ModuleList([Block(time, T), Block(freq, len(nf))]))
189
+ # mask estimator (one stem): its MLP's linear layers before the last batched over bands (their shapes
190
+ # alike), the last by group, then GLU
191
+ me = model.mask_estimators[0].to_freqs
192
+ lin = [[m for m in me[b][0] if isinstance(m, nn.Linear)] for b in range(len(nf))]
193
+ s.mw = nn.ParameterList([nn.Parameter(torch.stack([l[i].weight.detach().T for l in lin]), requires_grad=False) for i in range(len(lin[0]) - 1)])
194
+ s.mb = nn.ParameterList([nn.Parameter(torch.stack([l[i].bias.detach() for l in lin])[:, None], requires_grad=False) for i in range(len(lin[0]) - 1)])
195
+ s.m2_w, s.m2_b = nn.ParameterList(), nn.ParameterList()
196
+ col, cols = 0, {}
197
+ for b0, b1, W in G:
198
+ H = lin[0][-1].in_features
199
+ w = torch.zeros(b1 - b0, H, 2 * W)
200
+ bias = torch.zeros(b1 - b0, 1, 2 * W)
201
+ for j, b in enumerate(range(b0, b1)):
202
+ l2 = lin[b][-1]
203
+ n = widths[b]
204
+ w[j, :, :n], w[j, :, W:W + n] = l2.weight.detach()[:n].T, l2.weight.detach()[n:].T
205
+ bias[j, 0, :n], bias[j, 0, W:W + n] = l2.bias.detach()[:n], l2.bias.detach()[n:]
206
+ for e in range(n):
207
+ cols.setdefault(pos[b][e], []).append(col + j * W + e)
208
+ s.m2_w.append(nn.Parameter(w, requires_grad=False))
209
+ s.m2_b.append(nn.Parameter(bias, requires_grad=False))
210
+ col += (b1 - b0) * W
211
+ # each output column's sources among the masks (at most two bands overlap), the zero column for none,
212
+ # and its weight: 1 over the bands that cover it, 0 at DC
213
+ i1 = torch.full((zero,), col, dtype=torch.long)
214
+ i2 = torch.full((zero,), col, dtype=torch.long)
215
+ wt = torch.zeros(zero)
216
+ for o, src in cols.items():
217
+ assert len(src) <= 2, "three bands over one frequency"
218
+ i1[o] = src[0]
219
+ i2[o] = src[-1] if len(src) == 2 else col
220
+ wt[o] = 0 if o // (S * 2) == 0 and model.zero_dc else 1 / len(src)
221
+ s.register_buffer("i1", i1)
222
+ s.register_buffer("i2", i2)
223
+ s.register_buffer("wt", wt)
224
+ s.Fq, s.S = Fq, S
225
+
226
+ def forward(s, mix_spec):
227
+ Fq, S = s.Fq, s.S
228
+ T_ = mix_spec.shape[3]
229
+ # (1, 2S, F, T) -> E (T, F·S·2): [t, (f·S + s)·2 + c]
230
+ E = mix_spec.reshape(S, 2, Fq, T_).permute(3, 2, 0, 1).reshape(T_, Fq * S * 2)
231
+ Ez = torch.cat([E, torch.zeros(T_, 1, dtype=E.dtype)], 1)
232
+ xs = []
233
+ for k, name in enumerate(s.split_idx):
234
+ idx = getattr(s, name) # (nb, W)
235
+ x = Ez[:, idx].permute(1, 0, 2) # (nb, T, W)
236
+ x = x / x.norm(dim=-1, keepdim=True).clamp(min=1e-12)
237
+ xs.append(lin(x, s.split_w[k]) + s.split_b[k])
238
+ x = torch.cat(xs, 0) # (B, T, D)
239
+ for time, freq in s.blocks:
240
+ x = sliced(time, x) # sequences over time, one per band
241
+ x = sliced(freq, x.transpose(0, 1)).transpose(0, 1) # over bands, one per frame
242
+ ms = []
243
+ for k, (lo, hi, W) in enumerate(G):
244
+ h = x[lo:hi]
245
+ for w, b in zip(s.mw, s.mb):
246
+ h = torch.tanh(lin(h, w[lo:hi]) + b[lo:hi]) # (nb, T, H)
247
+ y = lin(h, s.m2_w[k]) + s.m2_b[k] # (nb, T, 2W)
248
+ m = y[..., :W] * torch.sigmoid(y[..., W:]) # GLU
249
+ ms.append(m.permute(1, 0, 2).reshape(T_, (hi - lo) * W))
250
+ M = torch.cat(ms + [torch.zeros(T_, 1)], 1) # (T, columns + 1)
251
+ mask = (M[:, s.i1] + M[:, s.i2]) * s.wt # (T, F·S·2)
252
+ e = E.reshape(T_, Fq, S, 2)
253
+ mk = mask.reshape(T_, Fq, S, 2)
254
+ er, ei, mr, mi = e[..., 0], e[..., 1], mk[..., 0], mk[..., 1]
255
+ out = torch.stack([er * mr - ei * mi, er * mi + ei * mr], -1) # (T, F, S, 2)
256
+ return out.permute(2, 3, 1, 0).reshape(1, 2 * S, Fq, T_)
257
+
258
+ class Block(nn.Module):
259
+ """One Transformer of the model (its layers and output norm) over sequences of n: x (batch, n, D)."""
260
+
261
+ def __init__(s, tr, n):
262
+ super().__init__()
263
+ s.layers = nn.ModuleList()
264
+ for attn, ff in tr.layers:
265
+ s.layers.append(nn.ModuleList([Attn(attn, n), FF(ff)]))
266
+ s.out = nn.Parameter(rms(tr.norm).float(), requires_grad=False)
267
+
268
+ def biggest(s, n):
269
+ """bytes of the largest tensor one sequence of n makes"""
270
+ a, ff = s.layers[0]
271
+ return 4 * max(a.h * n * n, n * ff.w1.shape[1], n * a.qkvg.shape[1])
272
+
273
+ def forward(s, x):
274
+ for attn, ff in s.layers:
275
+ x = attn(x) + x
276
+ x = ff(x) + x
277
+ return x / x.norm(dim=-1, keepdim=True).clamp(min=1e-12) * s.out
278
+
279
+ class Attn(nn.Module):
280
+ def __init__(s, a, n):
281
+ super().__init__()
282
+ h, dh = a.heads, a.to_qkv.out_features // (3 * a.heads)
283
+ g = rms(a.norm)[None]
284
+ wq, wk, wv = (a.to_qkv.weight.detach().double() * g).reshape(3, h, dh, -1)
285
+ # interleaved pairs -> halves, the same permutation of q's and k's features
286
+ perm = torch.cat([torch.arange(0, dh, 2), torch.arange(1, dh, 2)])
287
+ wq, wk = wq[:, perm] * a.scale, wk[:, perm]
288
+ wg = a.to_gates.weight.detach().double() * g
289
+ s.qkvg = nn.Parameter(torch.cat([wq.reshape(h * dh, -1), wk.reshape(h * dh, -1), wv.reshape(h * dh, -1), wg]).T.float(), requires_grad=False)
290
+ s.gb = nn.Parameter(a.to_gates.bias.detach().float(), requires_grad=False)
291
+ s.wo = nn.Parameter(a.to_out[0].weight.detach().T.float(), requires_grad=False)
292
+ freqs = rot(n, a.rotary_embed).double()[:, perm] # (n, dh)
293
+ s.register_buffer("cos", freqs.cos().float())
294
+ s.register_buffer("sin", freqs.sin().float())
295
+ R = torch.zeros(dh, dh) # x @ R: (-x[half:], x[:half])
296
+ R[torch.arange(dh // 2) + dh // 2, torch.arange(dh // 2)] = -1
297
+ R[torch.arange(dh // 2), torch.arange(dh // 2) + dh // 2] = 1
298
+ s.register_buffer("R", R)
299
+ s.h, s.dh = h, dh
300
+
301
+ def forward(s, x):
302
+ Bt, n, _ = x.shape
303
+ h, dh = s.h, s.dh
304
+ x = x / x.norm(dim=-1, keepdim=True).clamp(min=1e-12)
305
+ y = lin(x, s.qkvg)
306
+ q, k, v = (y[..., i * h * dh:(i + 1) * h * dh].reshape(Bt, n, h, dh).transpose(1, 2) for i in range(3))
307
+ gates = torch.sigmoid(y[..., 3 * h * dh:] + s.gb) # (Bt, n, h)
308
+ q = q * s.cos + torch.matmul(q, s.R) * s.sin
309
+ k = k * s.cos + torch.matmul(k, s.R) * s.sin
310
+ o = torch.matmul(torch.softmax(torch.matmul(q, k.transpose(-1, -2)), -1), v) # (Bt, h, n, dh)
311
+ o = o * gates.transpose(1, 2)[..., None]
312
+ return lin(o.transpose(1, 2).reshape(Bt, n, h * dh), s.wo)
313
+
314
+ class FF(nn.Module):
315
+ def __init__(s, ff):
316
+ super().__init__()
317
+ norm, l1, _, _, l2, _ = ff.net
318
+ s.w1 = nn.Parameter((l1.weight.detach().double() * rms(norm)[None]).T.float(), requires_grad=False)
319
+ s.b1 = nn.Parameter(l1.bias.detach().float(), requires_grad=False)
320
+ s.w2 = nn.Parameter(l2.weight.detach().T.float(), requires_grad=False)
321
+ s.b2 = nn.Parameter(l2.bias.detach().float(), requires_grad=False)
322
+
323
+ def forward(s, x):
324
+ x = x / x.norm(dim=-1, keepdim=True).clamp(min=1e-12)
325
+ return lin(F.gelu(lin(x, s.w1) + s.b1), s.w2) + s.b2
326
+
327
+ return Mod().eval()
328
+
329
+
330
+ def frames(model):
331
+ import torch
332
+
333
+ return stft(model, torch.zeros(1, model.audio_channels, SEGMENT)).shape[3]
334
+
335
+
336
+ def export(model, path, opset):
337
+ import torch
338
+
339
+ T = frames(model)
340
+ core = Core.build(model, T)
341
+ spec = stft(model, torch.randn(1, model.audio_channels, SEGMENT))
342
+ with torch.no_grad():
343
+ torch.onnx.export(core, (spec,), str(path), input_names=["mix_spec"], output_names=["stems_spec"],
344
+ opset_version=opset, dynamo=False)
345
+ print(f"wrote {path} ({path.stat().st_size / 1e6:.1f} MB), segment {SEGMENT} samples, spec {list(spec.shape)}, groups {groups([2 * n * model.audio_channels for n in model.num_freqs_per_band.tolist()])}")
346
+ return core
347
+
348
+
349
+ def engine(model, out_dir, name):
350
+ """The GPU form's tensors for the package's own WebGPU engine (roformer.js): <name>.engine.bin, each tensor in turn
351
+ (weights float16, tables float32, indices int32), and <name>.engine.json naming them, their type, shape and offset,
352
+ with the band groups and the sizes the engine reads."""
353
+ import json
354
+ import numpy as np
355
+ import torch
356
+
357
+ T = frames(model)
358
+ core = Core.build(model, T)
359
+ tensors, blob, off = {}, bytearray(), 0
360
+ for n, t in list(core.named_parameters()) + list(core.named_buffers()):
361
+ a = t.detach().numpy()
362
+ kind = "int32" if a.dtype.kind in "iu" else "float16" if n.rsplit(".", 1)[-1] in ("qkvg", "wo", "w1", "w2") or n.startswith(("split_w.", "mw.", "m2_w.")) else "float32"
363
+ b = a.astype(kind).tobytes()
364
+ pad = -len(blob) % 16
365
+ blob += bytes(pad)
366
+ tensors[n] = {"type": kind, "shape": list(a.shape), "offset": len(blob)}
367
+ blob += b
368
+ nf = model.num_freqs_per_band.tolist()
369
+ meta = {
370
+ "model": name, "frames": T, "freqs": core.Fq, "channels": core.S, "dim": model.layers[0][0].layers[0][0].to_qkv.in_features,
371
+ "heads": model.layers[0][0].layers[0][0].heads, "depth": len(model.layers), "bands": len(nf),
372
+ "groups": [list(g) for g in groups([2 * n * core.S for n in nf])], "widths": [2 * n * core.S for n in nf],
373
+ "tensors": tensors,
374
+ }
375
+ (out_dir / f"{name}.engine.bin").write_bytes(bytes(blob))
376
+ (out_dir / f"{name}.engine.json").write_text(json.dumps(meta))
377
+ print(f"wrote {out_dir / (name + '.engine.bin')} ({len(blob) / 1e6:.1f} MB) and its .json, {len(tensors)} tensors")
378
+
379
+
380
+ def fp16(path, out):
381
+ """float16 weights, float32 compute (scripts/compact.py --as fp16)."""
382
+ sys.path.insert(0, str(Path(__file__).parent))
383
+ import compact
384
+
385
+ compact.compact(path, out, "fp16")
386
+ print(f"wrote {out} ({out.stat().st_size / 1e6:.1f} MB)")
387
+
388
+
389
+ def session(path):
390
+ import onnxruntime as ort
391
+
392
+ o = ort.SessionOptions()
393
+ o.enable_cpu_mem_arena = False
394
+ o.enable_mem_pattern = False
395
+ return ort.InferenceSession(str(path), o, providers=["CPUExecutionProvider"])
396
+
397
+
398
+ def mixes():
399
+ import torch
400
+
401
+ t = torch.arange(SEGMENT) / 44100
402
+ tone = sum(torch.sin(2 * math.pi * f * t) / k for k, f in enumerate((110, 220, 440, 880, 1760), 1))
403
+ return (("noise", 0.1 * torch.randn(1, 2, SEGMENT)), ("tones", 0.2 * torch.stack([tone, 0.8 * tone])[None]))
404
+
405
+
406
+ def verify(model, run, label, tol):
407
+ """The vocals rebuilt from run(spec) against MelBandRoformer.forward: noise and tones."""
408
+ import numpy as np
409
+ import torch
410
+
411
+ for name, mix in mixes():
412
+ with torch.no_grad():
413
+ want = model(mix).numpy()
414
+ got = istft(model, torch.as_tensor(run(stft(model, mix))), SEGMENT)[:, 0].numpy()
415
+ want = want[..., :got.shape[-1]]
416
+ rel = np.abs(got - want).max() / max(np.abs(want).max(), 1e-12)
417
+ print(f" verify {label} {name}: max|diff|/max|y| = {rel:.2e} " + ("OK" if rel < tol else "FAIL"))
418
+ if rel >= tol:
419
+ raise SystemExit(f"{label}: verification failed ({rel:.2e} >= {tol})")
420
+
421
+
422
+ def reference(model, out_dir):
423
+ """The reference separation: MelBandRoformer.forward on segments as scripts/reference.py's chunked() cuts them
424
+ (step 0.5, no standardization: Kim's config normalizes nothing), over a 20 s mix with a noise floor."""
425
+ import torch
426
+
427
+ sys.path.insert(0, str(Path(__file__).parent))
428
+ import reference as ref
429
+
430
+ def run(seg):
431
+ with torch.no_grad():
432
+ return model(torch.from_numpy(seg)[None])[0][None].numpy() # (1 stem, C, L)
433
+
434
+ # under the mix a noise floor 60 dB down: each band normalizes its input, and a silent band's is its rounding
435
+ ref.write(out_dir, lambda mix: {"vocals": ref.chunked(mix, run, SEGMENT, STEP)[0]}, 20.0, 1e-3)
436
+
437
+
438
+ def main():
439
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
440
+ ap.add_argument("--model", default="mel-roformer", choices=list(MODELS))
441
+ ap.add_argument("--out-dir", default=None, help="default: $AUDIO_NEURAL_CACHE/<model> or ~/.cache/audiojs/neural/<model>")
442
+ ap.add_argument("--opset", type=int, default=17)
443
+ ap.add_argument("--fp16", action="store_true", help="also write <model>.fp16.onnx (float16 weights, float32 compute)")
444
+ ap.add_argument("--verify", action="store_true", help="compare with MelBandRoformer.forward; write the reference separation")
445
+ ap.add_argument("--engine", action="store_true", help="also write <model>.engine.bin/.json: the tensors for roformer.js, the package's WebGPU engine")
446
+ args = ap.parse_args()
447
+
448
+ cache = Path(os.environ.get("AUDIO_NEURAL_CACHE") or Path.home() / ".cache" / "audiojs" / "neural")
449
+ out_dir = Path(args.out_dir) if args.out_dir else cache / args.model
450
+ out_dir.mkdir(parents=True, exist_ok=True)
451
+ src = out_dir / "src"
452
+ src.mkdir(exist_ok=True)
453
+ try:
454
+ model, _ = load(src, args.model)
455
+ except ImportError as e:
456
+ raise SystemExit(f"required: pip install torch onnx onnxruntime pyyaml einops beartype rotary-embedding-torch librosa ({e})")
457
+ path = out_dir / f"{args.model}.onnx"
458
+ core = export(model, path, args.opset)
459
+ if args.verify:
460
+ import torch
461
+
462
+ with torch.no_grad():
463
+ verify(model, lambda spec: core(spec), "the GPU form in torch", 1e-4)
464
+ # onnxruntime against torch: on the tones 1.7e-4, where torch's own float32 stands 4.6e-3 from its float64
465
+ s = session(path)
466
+ verify(model, lambda spec: s.run(None, {"mix_spec": spec.numpy()})[0], path.name, 1e-3)
467
+ del s
468
+ gc.collect()
469
+ reference(model, out_dir)
470
+ if args.fp16:
471
+ fp16(path, out_dir / f"{args.model}.fp16.onnx")
472
+ if args.verify:
473
+ s = session(out_dir / f"{args.model}.fp16.onnx")
474
+ verify(model, lambda spec: s.run(None, {"mix_spec": spec.numpy()})[0], f"{args.model}.fp16.onnx", 2e-2)
475
+ if args.engine:
476
+ engine(model, out_dir, args.model)
477
+ print(f"done: {args.model} in {out_dir}")
478
+
479
+
480
+ if __name__ == "__main__":
481
+ sys.exit(main())
@@ -25,7 +25,7 @@ The STFT is SCNet's own: n_fft 4096, hop 1024, no window (torch.stft's default,
25
25
  ones), normalized (1/sqrt(4096)), centered with reflect padding.
26
26
 
27
27
  Writes <out-dir>/<model>.onnx (scnet-large 169 MB, scnet 43 MB) and, with --fp16,
28
- <model>.fp16.onnx (half that), weights embedded. Default out-dir:
28
+ <model>.fp16.onnx (half that: float16 weights, float32 compute, scripts/compact.py's), weights embedded. Default out-dir:
29
29
  $AUDIO_NEURAL_CACHE/<model>, else ~/.cache/audiojs/neural/<model>. --verify compares the stems rebuilt from the ONNX
30
30
  output with SCNet.forward on the same segment, and writes the reference
31
31
  separation test.js compares the JS pipeline against (scripts/reference.py),
@@ -187,11 +187,11 @@ def export(model, path, opset):
187
187
 
188
188
 
189
189
  def fp16(path, out):
190
- """float16 weights, float32 inputs and outputs (onnxruntime's converter, as export-openunmix.py's)."""
191
- import onnx
192
- from onnxruntime.transformers.float16 import convert_float_to_float16
190
+ """float16 weights, float32 compute (scripts/compact.py --as fp16)."""
191
+ sys.path.insert(0, str(Path(__file__).parent))
192
+ import compact
193
193
 
194
- onnx.save(convert_float_to_float16(onnx.load(str(path)), keep_io_types=True), str(out))
194
+ compact.compact(path, out, "fp16")
195
195
  print(f"wrote {out} ({out.stat().st_size / 1e6:.1f} MB)")
196
196
 
197
197
 
@@ -245,7 +245,7 @@ def main():
245
245
  ap.add_argument("--model", default="scnet-large", choices=list(MODELS))
246
246
  ap.add_argument("--out-dir", default=None, help="default: $AUDIO_NEURAL_CACHE/<model> or ~/.cache/audiojs/neural/<model>")
247
247
  ap.add_argument("--opset", type=int, default=17)
248
- ap.add_argument("--fp16", action="store_true", help="also write <model>.fp16.onnx (float16 weights, float32 I/O)")
248
+ ap.add_argument("--fp16", action="store_true", help="also write <model>.fp16.onnx (float16 weights, float32 compute)")
249
249
  ap.add_argument("--verify", action="store_true", help="compare with SCNet.forward; write the reference separation")
250
250
  args = ap.parse_args()
251
251
 
@@ -64,9 +64,12 @@ def chunked(mix, run, L, step=0.25):
64
64
  return out[..., border:border + N] if wide else out
65
65
 
66
66
 
67
- def write(out_dir, separate, seconds=9.0):
68
- """separate(mix (2, N) float32) -> { target: (2, N) array }"""
67
+ def write(out_dir, separate, seconds=9.0, floor=0.0):
68
+ """separate(mix (2, N) float32) -> { target: (2, N) array }. `floor`: white noise of that RMS under the mix, so no
69
+ band of it is silent (a model that normalizes each band's input turns a silent band's rounding into its direction)"""
69
70
  mix = test_mix(seconds)
71
+ if floor:
72
+ mix = (mix + floor * np.random.RandomState(1).randn(*mix.shape)).astype(np.float32)
70
73
  mix.tofile(out_dir / "test.f32")
71
74
  for name, x in separate(mix).items():
72
75
  np.asarray(x, dtype=np.float32).tofile(out_dir / f"test.{name}.f32")