@audio/neural-separate 0.1.0 → 0.4.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,351 @@
1
+ #!/usr/bin/env python3
2
+ """Compact an exported separation graph: its weights stored in fewer bits, its compute left in float32.
3
+
4
+ pip install onnx onnxruntime numpy
5
+ python3 compact.py --model scnet-large --verify # scnet-large.int8.onnx
6
+ python3 compact.py --model tiger --as fp16 --verify # tiger.fp16.onnx
7
+
8
+ Reads <dir>/<model>.onnx (an export-*.py output; <dir> the neural cache's <model>/) and writes <model>.<as>.onnx beside
9
+ it. The weights are the initializers a Conv, ConvTranspose, MatMul, Gemm, LSTM or GRU takes as its weight, of over 1024
10
+ values (biases, norms and constants stay float32):
11
+ int8 stored as int8 with a float32 scale per output channel (symmetric, the channel's largest magnitude to 127; an
12
+ LSTM's or GRU's per gate row of each direction), Cast and multiplied by its scale in the graph; all of them when
13
+ their rounding together costs --budget dB under the output or less (40; 0: no bound) on the calibration mix,
14
+ else int8 for the weights cheapest per byte saved (each rounded alone, the rest float32) while their summed
15
+ cost stays within the budget, float16 for the rest (separate weights' costs add: the sum predicts the whole
16
+ to a fraction of a dB)
17
+ fp16 stored as float16, Cast to float32 in the graph
18
+ fp32 as they are
19
+ onnxruntime folds the Cast and the product at load (both of constants): the session holds float32 weights and computes
20
+ as the float32 graph does, on every execution provider. DequantizeLinear would not: onnxruntime 1.30 fuses it with the
21
+ MatMul it feeds into MatMulNBits, whose default accuracy level quantizes the activations to int8 on a CPU (MRX's int8
22
+ graph 38.4 dB from float32 so, 40.4 dB as stored here).
23
+
24
+ Every variant is also folded and named short, neither of which changes a value. With each input's shape fixed (SCNet's
25
+ and TIGER's graphs; MRX's takes any length and is left as it is), every value computable from the initializers and the
26
+ shapes alone is computed once and stored as the graph computes it (SCNet's DFT matrices, made in float64 from a Range,
27
+ which onnxruntime-web's WebGPU session neither places nor folds), but for a fill (ConstantOfShape: SCNet's LSTM zero
28
+ states), kept with its shape stored. Identical initializers are stored once; node and value names become base-36
29
+ counters (TIGER's 56,652 nodes carried 11.7 MB of names), the graph's inputs and outputs keep theirs.
30
+
31
+ --verify runs the variant and the float32 graph on the calibration mix (default <dir>/test.f32, scripts/reference.py's;
32
+ --calibrate another: planar float32 stereo at 44.1 kHz), cut and transformed as the package does it, and prints each
33
+ output's largest difference over its peak and its SNR against the float32 graph's (MRX's masks as the spectra they
34
+ mask).
35
+ """
36
+ import argparse
37
+ import hashlib
38
+ import os
39
+ import sys
40
+ from pathlib import Path
41
+
42
+ import numpy as np
43
+
44
+ WEIGHT = {"Conv": (1,), "ConvTranspose": (1,), "MatMul": (1,), "Gemm": (1,), "LSTM": (1, 2), "GRU": (1, 2)}
45
+ MIN, SMALL = 1024, 1024
46
+
47
+
48
+ def users_of(g):
49
+ users = {}
50
+ for n in g.node:
51
+ for k, name in enumerate(n.input): users.setdefault(name, []).append((n, k))
52
+ return users
53
+
54
+
55
+ def weights(g):
56
+ """{name: node} of the float32 initializers of over MIN values that every user takes as its weight."""
57
+ from onnx import TensorProto
58
+
59
+ users, out = users_of(g), {}
60
+ for i in g.initializer:
61
+ uses = users.get(i.name, [])
62
+ if i.data_type != TensorProto.FLOAT or int(np.prod(i.dims)) <= MIN or not uses: continue
63
+ if all(n.op_type in WEIGHT and k in WEIGHT[n.op_type] for n, k in uses) and len({n.op_type for n, _ in uses}) == 1:
64
+ out[i.name] = uses[0][0]
65
+ return out
66
+
67
+
68
+ def q8(node, w):
69
+ """Symmetric int8 per output channel (an LSTM's or GRU's per gate row of each direction), the channel's largest
70
+ magnitude to 127: q in w's layout and its scale, shaped to broadcast against it."""
71
+ keep = (0, 1) if node.op_type in ("LSTM", "GRU") else ({"ConvTranspose": 1, "MatMul": w.ndim - 1}.get(node.op_type, 0),)
72
+ if node.op_type == "Gemm": keep = (0 if any(x.name == "transB" and x.i for x in node.attribute) else 1,)
73
+ scale = np.maximum(np.abs(w).max(axis=tuple(k for k in range(w.ndim) if k not in keep), keepdims=True), 1e-30) / 127
74
+ return np.clip(np.round(w / scale), -127, 127).astype(np.int8), scale.astype(np.float32)
75
+
76
+
77
+ def rounded(node, w):
78
+ """w through q8 and back, in float32: what the int8 graph computes with."""
79
+ q, scale = q8(node, w)
80
+ return q.astype(np.float32) * scale
81
+
82
+
83
+ def prepend(g, nodes):
84
+ body = list(g.node)
85
+ del g.node[:]
86
+ g.node.extend(nodes + body)
87
+
88
+
89
+ def store(m, kinds):
90
+ """Each weight as kinds[name] says: 'fp16' (Cast), 'int8' (Cast, Mul by its scale), else float32."""
91
+ from onnx import helper, numpy_helper, TensorProto
92
+
93
+ g = m.graph
94
+ inits = {i.name: i for i in g.initializer}
95
+ nodes = []
96
+ for name, node in weights(g).items():
97
+ kind = kinds.get(name)
98
+ if kind not in ("fp16", "int8"): continue
99
+ w = numpy_helper.to_array(inits[name])
100
+ g.initializer.remove(inits[name])
101
+ if kind == "fp16":
102
+ g.initializer.append(numpy_helper.from_array(w.astype(np.float16), name + "_h"))
103
+ nodes.append(helper.make_node("Cast", [name + "_h"], [name], to=TensorProto.FLOAT))
104
+ continue
105
+ q, scale = q8(node, w)
106
+ g.initializer.extend([numpy_helper.from_array(q, name + "_q"), numpy_helper.from_array(scale, name + "_s")])
107
+ nodes += [helper.make_node("Cast", [name + "_q"], [name + "_c"], to=TensorProto.FLOAT), helper.make_node("Mul", [name + "_c", name + "_s"], [name])]
108
+ prepend(g, nodes)
109
+ return m
110
+
111
+
112
+ def static(g):
113
+ return all(d.dim_value > 0 for i in g.input for d in i.type.tensor_type.shape.dim)
114
+
115
+
116
+ def fold(m):
117
+ """Values computable from the initializers and the input shapes alone: stored when small, else made at load."""
118
+ import onnx
119
+ import onnxruntime as ort
120
+ from onnx import helper, numpy_helper, shape_inference
121
+
122
+ g = m.graph
123
+ if not static(g): return dedupe(m)
124
+ const, cnodes = {i.name for i in g.initializer}, []
125
+ for n in g.node:
126
+ ins = [x for x in n.input if x]
127
+ if n.op_type in ("Constant", "Shape") or (ins and all(x in const for x in ins) and not n.op_type.startswith("Random")):
128
+ cnodes.append(n)
129
+ const.update(n.output)
130
+ computed = [o for n in cnodes for o in n.output]
131
+ types = {v.name: v.type.tensor_type.elem_type for v in shape_inference.infer_shapes(m).graph.value_info}
132
+ for n in cnodes:
133
+ if n.op_type == "Constant": types[n.output[0]] = next(a.t.data_type for a in n.attribute if a.name == "value")
134
+ probe = onnx.ModelProto()
135
+ probe.CopyFrom(m)
136
+ probe.graph.output.extend(helper.make_tensor_value_info(x, types[x], None) for x in computed)
137
+ s = session(probe.SerializeToString())
138
+ shapes = {i.name: [d.dim_value for d in i.type.tensor_type.shape.dim] for i in g.input}
139
+ vals = dict(zip(computed, s.run(computed, {k: np.zeros(v, np.float32) for k, v in shapes.items()})))
140
+ # the same from another input, or the graph holds a shape that depends on the data: left as it is
141
+ other = s.run(computed, {k: np.random.default_rng(0).standard_normal(v).astype(np.float32) for k, v in shapes.items()})
142
+ if any(not np.array_equal(vals[x], y, equal_nan=vals[x].dtype.kind == "f") for x, y in zip(computed, other)): return dedupe(m)
143
+ # what the rest of the graph reads, stored; a fill (ConstantOfShape) kept, its shape stored
144
+ producer = {o: n for n in cnodes for o in n.output}
145
+ inner = {id(n) for n in cnodes}
146
+ keep, stored = set(), {}
147
+ want = [x for n in g.node if id(n) not in inner for x in n.input] + [o.name for o in g.output]
148
+ while want:
149
+ x = want.pop()
150
+ if x not in producer or x in stored or id(producer[x]) in keep: continue
151
+ if producer[x].op_type == "ConstantOfShape" and vals[x].size > SMALL:
152
+ keep.add(id(producer[x]))
153
+ want += list(producer[x].input)
154
+ else: stored[x] = vals[x]
155
+ body = [n for n in g.node if id(n) not in inner or id(n) in keep]
156
+ del g.node[:]
157
+ g.node.extend(body)
158
+ g.initializer.extend(numpy_helper.from_array(np.asarray(v), x) for x, v in stored.items())
159
+ return dedupe(m)
160
+
161
+
162
+ def dedupe(m):
163
+ """Identical initializers stored once; those no node reads dropped."""
164
+ from onnx import numpy_helper
165
+
166
+ g = m.graph
167
+ seen, alias = {}, {}
168
+ for i in list(g.initializer):
169
+ a = numpy_helper.to_array(i)
170
+ key = hashlib.sha256(str((a.dtype, a.shape)).encode() + a.tobytes()).hexdigest()
171
+ if key in seen:
172
+ alias[i.name] = seen[key]
173
+ g.initializer.remove(i)
174
+ else: seen[key] = i.name
175
+ for n in g.node:
176
+ for k, x in enumerate(n.input):
177
+ if x in alias: n.input[k] = alias[x]
178
+ read = {x for n in g.node for x in n.input} | {o.name for o in g.output}
179
+ for i in [i for i in g.initializer if i.name not in read]: g.initializer.remove(i)
180
+ return m
181
+
182
+
183
+ def shorten(m):
184
+ """Node and value names as base-36 counters; the inputs and outputs keep theirs."""
185
+ g = m.graph
186
+ keep = {v.name for v in list(g.input) + list(g.output)}
187
+ names = {}
188
+
189
+ def base36(k):
190
+ s = ""
191
+ while True:
192
+ s, k = "0123456789abcdefghijklmnopqrstuvwxyz"[k % 36] + s, k // 36
193
+ if not k: return s
194
+
195
+ def short(n):
196
+ if n == "" or n in keep: return n
197
+ if n not in names: names[n] = "_" + base36(len(names))
198
+ return names[n]
199
+
200
+ for i in g.initializer: i.name = short(i.name)
201
+ for k, n in enumerate(g.node):
202
+ for j, x in enumerate(n.input): n.input[j] = short(x)
203
+ for j, x in enumerate(n.output): n.output[j] = short(x)
204
+ n.name, n.doc_string = base36(k), "" # unique: onnxruntime-web's partitioner reports nodes by name
205
+ del g.value_info[:]
206
+ m.doc_string = ""
207
+ return m
208
+
209
+
210
+ # ------------------------------------------------------------------ calibration
211
+
212
+ def session(model, fold=False):
213
+ import onnxruntime as ort
214
+
215
+ o = ort.SessionOptions()
216
+ o.enable_cpu_mem_arena = False
217
+ o.enable_mem_pattern = False
218
+ o.log_severity_level = 3 # the overridable weights' warnings
219
+ if not fold: o.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL
220
+ return ort.InferenceSession(model if isinstance(model, bytes) else str(model), o, providers=["CPUExecutionProvider"])
221
+
222
+
223
+ def feeds(g, mix):
224
+ """The graph's inputs from a mixture [2, samples] at 44.1 kHz, as separate.js cuts and transforms it: SCNet one
225
+ 11 s segment (no window, 1/sqrt(n)), TIGER one 12 s segment of the first channel (Hann), MRX 10 s of each
226
+ channel at three resolutions (Hann, 1/sqrt(n), magnitudes)."""
227
+ ins = {i.name: [d.dim_value or None for d in i.type.tensor_type.shape.dim] for i in g.input}
228
+
229
+ def spec(x, n, hop, win, scale):
230
+ pad = np.pad(x, ((0, 0), (n // 2, n // 2)), mode="reflect")
231
+ T = 1 + (pad.shape[1] - n) // hop
232
+ fr = np.stack([pad[:, t * hop:t * hop + n] for t in range(T)], 1) * win
233
+ return np.fft.rfft(fr, axis=-1).transpose(0, 2, 1) * scale # [C, F, T]
234
+
235
+ hann = lambda n: 0.5 - 0.5 * np.cos(2 * np.pi * np.arange(n) / n)
236
+ if list(ins) == ["mix_spec"]:
237
+ _, C2, F, T = ins["mix_spec"]
238
+ n = 2 * (F - 1)
239
+ tiger = n == 2048
240
+ x = mix[:C2 // 2, :(T - 1) * (n // 4)]
241
+ X = spec(x, n, n // 4, hann(n) if tiger else 1.0, 1.0 if tiger else n ** -0.5)[..., :T]
242
+ X = np.pad(X, ((0, 0), (0, 0), (0, T - X.shape[2])))
243
+ return {"mix_spec": np.stack([X.real, X.imag], 1).reshape(1, C2, F, T).astype(np.float32)}
244
+ x = mix[:, :441000]
245
+ return {k: np.abs(spec(x, int(k.split("_")[1]), 256, hann(int(k.split("_")[1])), int(k.split("_")[1]) ** -0.5)).astype(np.float32) for k in ins}
246
+
247
+
248
+ def outputs(s, feed):
249
+ """The outputs as the stems see them: a mask times the magnitudes it masks."""
250
+ ys = dict(zip([o.name for o in s.get_outputs()], s.run(None, feed)))
251
+ return {k: (y * feed["mag_" + k[5:]][:, None] if k.startswith("mask_") else y).astype(np.float64) for k, y in ys.items()}
252
+
253
+
254
+ def noise(y, y0):
255
+ """Error power over signal power, the worst output's."""
256
+ return max(((y[k] - y0[k]) ** 2).sum() / max((y0[k] ** 2).sum(), 1e-300) for k in y0)
257
+
258
+
259
+ def costs(m, feed, W):
260
+ """The cost at int8 of all the weights rounded together, and a function giving each one's alone (the others
261
+ float32): noise power over output power. The weights are fed as overridable initializers, one session for all."""
262
+ import onnx
263
+ from onnx import helper, numpy_helper
264
+
265
+ inits = {i.name: i for i in m.graph.initializer}
266
+ probe = onnx.ModelProto()
267
+ probe.CopyFrom(m)
268
+ probe.graph.input.extend(helper.make_tensor_value_info(n, inits[n].data_type, list(inits[n].dims)) for n in W)
269
+ s = session(probe.SerializeToString())
270
+ y0 = outputs(s, feed)
271
+ q = {n: rounded(node, numpy_helper.to_array(inits[n])) for n, node in W.items()}
272
+ return noise(outputs(s, {**feed, **q}), y0), lambda n: noise(outputs(s, {**feed, n: q[n]}), y0)
273
+
274
+
275
+ def int8(m, feed, budget):
276
+ """{weight: 'int8' | 'fp16'}: every weight int8 when their rounding together costs budget dB under the output or
277
+ less (no budget: always); else int8 for the weights cheapest per byte saved, each rounded alone, while their summed
278
+ cost stays within the budget (costs of separate weights add), float16 for the rest."""
279
+ W = weights(m.graph)
280
+ if budget is None: return {n: "int8" for n in W}
281
+ whole, alone = costs(m, feed, W)
282
+ print(f" every weight int8: SNR {-10 * np.log10(whole):.1f} dB against float32")
283
+ if whole <= 10 ** (-budget / 10): return {n: "int8" for n in W}
284
+ size = {i.name: int(np.prod(i.dims)) for i in m.graph.initializer}
285
+ cost = {}
286
+ for k, n in enumerate(W):
287
+ cost[n] = alone(n)
288
+ print(f"\r cost {k + 1}/{len(W)}", end="", file=sys.stderr)
289
+ print(file=sys.stderr)
290
+ kinds, total = {}, 0.0
291
+ for n in sorted(W, key=lambda n: cost[n] / size[n]):
292
+ if total + cost[n] <= 10 ** (-budget / 10): kinds[n], total = "int8", total + cost[n]
293
+ else: kinds[n] = "fp16"
294
+ for n in sorted((n for n in kinds if kinds[n] == "fp16"), key=lambda n: -cost[n]):
295
+ print(f" float16: {W[n].name or n}, {size[n]} values, alone at int8 {-10 * np.log10(max(cost[n], 1e-30)):.1f} dB")
296
+ print(f" int8: {sum(k == 'int8' for k in kinds.values())} of {len(kinds)} weights, "
297
+ f"{sum(size[n] for n in kinds if kinds[n] == 'int8') / sum(size[n] for n in W):.1%} of their values; predicted SNR {-10 * np.log10(max(total, 1e-30)):.1f} dB")
298
+ return kinds
299
+
300
+
301
+ def compact(path, out, kind, calibration=None, budget=40.0):
302
+ import onnx
303
+
304
+ m = onnx.load(str(path))
305
+ # the trained weights, chosen before folding (never a folded constant)
306
+ kinds = int8(m, feeds(m.graph, calibration), budget) if kind == "int8" else {n: kind for n in weights(m.graph)}
307
+ m = shorten(store(fold(m), kinds))
308
+ m.metadata_props.append(onnx.StringStringEntryProto(key="compact", value=kind + (f", budget {budget:g} dB" if kind == "int8" and budget else "")))
309
+ onnx.checker.check_model(m)
310
+ onnx.save(m, str(out))
311
+ return out
312
+
313
+
314
+ def verify(path, base, mix):
315
+ import onnx
316
+
317
+ a, b = session(base, True), session(path, True)
318
+ feed = feeds(onnx.load(str(base), load_external_data=False).graph, mix)
319
+ y0, y = outputs(a, feed), outputs(b, feed)
320
+ for k in y0:
321
+ d = y[k] - y0[k]
322
+ rel = np.abs(d).max() / np.abs(y0[k]).max()
323
+ print(f" {path.name} {k}: max|diff|/max|y| = {rel:.2e}, SNR {10 * np.log10((y0[k] ** 2).sum() / max((d ** 2).sum(), 1e-300)):.1f} dB against float32")
324
+
325
+
326
+ def main():
327
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
328
+ ap.add_argument("--model", required=True, help="scnet-large | scnet | mrx | tiger (any <dir>/<model>.onnx)")
329
+ ap.add_argument("--as", dest="kind", default="int8", choices=["fp32", "fp16", "int8"])
330
+ ap.add_argument("--budget", type=float, default=40, help="int8: the rounding's cost, dB under the output (40); 0: every weight int8")
331
+ ap.add_argument("--dir", default=None, help="default: $AUDIO_NEURAL_CACHE/<model> or ~/.cache/audiojs/neural/<model>")
332
+ ap.add_argument("--out", default=None, help="default: <dir>/<model>.<as>.onnx")
333
+ ap.add_argument("--calibrate", default=None, help="planar float32 stereo at 44.1 kHz (default <dir>/test.f32)")
334
+ ap.add_argument("--verify", action="store_true", help="the variant against the float32 graph on the calibration mix")
335
+ args = ap.parse_args()
336
+
337
+ cache = Path(os.environ.get("AUDIO_NEURAL_CACHE") or Path.home() / ".cache" / "audiojs" / "neural")
338
+ d = Path(args.dir) if args.dir else cache / args.model
339
+ path = d / f"{args.model}.onnx"
340
+ out = Path(args.out) if args.out else d / f"{args.model}.{args.kind}.onnx"
341
+ cal = Path(args.calibrate) if args.calibrate else d / "test.f32"
342
+ mix = np.fromfile(cal, np.float32).reshape(2, -1) if cal.exists() else None
343
+ budget = args.budget or None
344
+ if mix is None and ((args.kind == "int8" and budget) or args.verify): raise SystemExit(f"{cal}: no calibration mix (export with --verify writes test.f32)")
345
+ compact(path, out, args.kind, mix, budget)
346
+ print(f"wrote {out} ({out.stat().st_size / 1e6:.2f} MB, from {path.stat().st_size / 1e6:.2f})")
347
+ if args.verify: verify(out, path, mix)
348
+
349
+
350
+ if __name__ == "__main__":
351
+ sys.exit(main())
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env python3
2
+ """Export MRX (Petermann, Wichern, Wang, Le Roux, "The Cocktail Fork Problem: Three-Stem Audio Separation for
3
+ Real-World Soundtracks", ICASSP 2022) to the ONNX contract @audio/neural-separate expects for modelType
4
+ 'multires': the network between its three STFTs and their inverses, which JS computes.
5
+
6
+ pip install torch onnx onnxruntime pyloudnorm
7
+ python3 export-mrx.py --verify
8
+
9
+ The model is MERL's own, trained on Divide and Remaster with the SNR loss so its stems keep the mixture's level
10
+ (merlresearch/cocktail-fork-separation, checkpoints/default_mrx_pre_trained_weights.pth, 30.5 M parameters; the
11
+ repository's .reuse/dep5 licenses the four checkpoints MIT, Copyright 2023 Mitsubishi Electric Research Laboratories);
12
+ its code and checkpoint are fetched at a pinned commit. Its README, DnR test, average SI-SDR: speech 12.5, music 4.2,
13
+ sound effects 5.7 dB (the mixture 1.0, -6.8, -5.0).
14
+
15
+ The graph sees each channel's magnitude spectrogram at three resolutions, scaled as torch.stft(normalized=True)
16
+ scales it (1/sqrt(n)): Hann windows of 1024, 2048 and 8192 samples, hop 256, centered with reflect padding, at
17
+ 44.1 kHz. B (channels, encoded apart) and T (frames) are free:
18
+ inputs mag_1024 [B, 513, T] mag_2048 [B, 1025, T] mag_8192 [B, 4097, T]
19
+ outputs mask_1024 [B, 3, 513, T] mask_2048 [B, 3, 1025, T] mask_8192 [B, 3, 4097, T]
20
+ the sources in MRX's order: music, speech, sfx. A source is the sum over resolutions of the iSTFT of its mask times
21
+ that resolution's spectrogram (the scale 1/sqrt(n) and its inverse cancel: the masks are real).
22
+
23
+ Writes <out-dir>/mrx.onnx (122 MB), weights embedded. Default out-dir: $AUDIO_NEURAL_CACHE/mrx, else
24
+ ~/.cache/audiojs/neural/mrx. --verify compares the sources rebuilt from the ONNX output with MRX.forward, and writes
25
+ the reference separation test.js compares the JS pipeline against: separate.separate_soundtrack, as upstream's
26
+ separate.py runs it (the input at -27 LUFS by pyloudnorm, the stems scaled back), on scripts/reference.py's mix.
27
+ Last run: torch 2.14, onnxruntime 1.30.
28
+ """
29
+ import argparse
30
+ import os
31
+ import sys
32
+ import urllib.request
33
+ from pathlib import Path
34
+
35
+ COMMIT = "19b3de827ebc4bfb014570cf92dd32b4ee3b6921"
36
+ REPO = f"https://raw.githubusercontent.com/merlresearch/cocktail-fork-separation/{COMMIT}/"
37
+ LFS = f"https://media.githubusercontent.com/media/merlresearch/cocktail-fork-separation/{COMMIT}/"
38
+ CKPT = "checkpoints/default_mrx_pre_trained_weights.pth"
39
+ WINDOWS, HOP, RATE = (1024, 2048, 8192), 256, 44100
40
+
41
+
42
+ def fetch(url, path):
43
+ if not path.exists():
44
+ print(f"fetching {url}")
45
+ urllib.request.urlretrieve(url, str(path) + ".part")
46
+ os.replace(str(path) + ".part", path)
47
+ return path
48
+
49
+
50
+ def load(src):
51
+ """MRX with its weights, and upstream's separate module: code and checkpoint fetched into `src` once."""
52
+ import torch
53
+
54
+ for f in ("mrx.py", "separate.py", "consistency.py", "dnr_dataset.py"):
55
+ fetch(REPO + f, src / f)
56
+ sys.path.insert(0, str(src))
57
+ sys.dont_write_bytecode = True
58
+ from mrx import MRX
59
+
60
+ model = MRX().eval()
61
+ model.load_state_dict(torch.load(fetch(LFS + CKPT, src / Path(CKPT).name), map_location="cpu", weights_only=True))
62
+ return model
63
+
64
+
65
+ def mags(x):
66
+ """x (B, N) -> the three normalized magnitude spectrograms (B, F, T) and complex spectrograms MRX._stft makes"""
67
+ import mrx
68
+
69
+ specs = [mrx._stft(x, n, HOP) for n in WINDOWS]
70
+ return [s.abs() for s in specs], specs
71
+
72
+
73
+ class Core:
74
+ """MRX.forward between its STFTs and its iSTFTs: magnitudes in, masks out."""
75
+
76
+ @staticmethod
77
+ def build(model):
78
+ import torch
79
+ import torch.nn as nn
80
+
81
+ class C(nn.Module):
82
+ def __init__(self, m):
83
+ super().__init__()
84
+ self.m = m
85
+
86
+ def forward(self, m1, m2, m3):
87
+ m = self.m
88
+ enc = []
89
+ for mag, layer in zip((m1, m2, m3), m._encoders):
90
+ B, F, T = mag.shape
91
+ e = layer.layer(mag.transpose(1, 2).reshape(-1, F)) # _EncoderBlock, its abs() done
92
+ enc.append(torch.tanh(e.reshape(B, T, -1)))
93
+ cross = m._cross_net(enc)
94
+ out = []
95
+ for r, mag in enumerate((m1, m2, m3)):
96
+ B, F, T = mag.shape
97
+ masks = [dec[r](cross).transpose(-1, -2) for dec in m._decoders] # (B, F, T) per source
98
+ out.append(torch.stack(masks, 1))
99
+ return tuple(out)
100
+
101
+ return C(model).eval()
102
+
103
+
104
+ def export(model, path, opset):
105
+ import torch
106
+
107
+ m, _ = mags(0.1 * torch.randn(2, RATE * 4))
108
+ names = [f"mag_{n}" for n in WINDOWS]
109
+ outs = [f"mask_{n}" for n in WINDOWS]
110
+ axes = {**{k: {0: "B", 2: "T"} for k in names}, **{k: {0: "B", 3: "T"} for k in outs}}
111
+ with torch.no_grad():
112
+ torch.onnx.export(Core.build(model), tuple(m), str(path), input_names=names, output_names=outs,
113
+ dynamic_axes=axes, opset_version=opset, dynamo=False)
114
+ print(f"wrote {path} ({path.stat().st_size / 1e6:.1f} MB)")
115
+
116
+
117
+ def session(path):
118
+ import onnxruntime as ort
119
+
120
+ o = ort.SessionOptions()
121
+ o.enable_cpu_mem_arena = False
122
+ o.enable_mem_pattern = False
123
+ return ort.InferenceSession(str(path), o, providers=["CPUExecutionProvider"])
124
+
125
+
126
+ def verify(model, path, tol):
127
+ """Sources rebuilt from onnxruntime's masks against MRX.forward: noise and tones, mono and stereo."""
128
+ import math
129
+ import numpy as np
130
+ import torch
131
+ import mrx
132
+
133
+ s = session(path)
134
+ t = torch.arange(RATE * 3) / RATE
135
+ tone = sum(torch.sin(2 * math.pi * f * t) / k for k, f in enumerate((110, 220, 440, 880, 1760), 1))
136
+ for name, x in (("noise", 0.1 * torch.randn(1, RATE * 3)), ("tones", 0.2 * torch.stack([tone, 0.8 * tone]))):
137
+ with torch.no_grad():
138
+ want = model(x).numpy()
139
+ m, specs = mags(x)
140
+ masks = s.run(None, {f"mag_{n}": v.numpy() for n, v in zip(WINDOWS, m)})
141
+ got = sum(mrx._istft(torch.from_numpy(k) * z[:, None], HOP, x.shape[-1]) for k, z in zip(masks, specs))
142
+ got = got.transpose(0, 1).numpy()
143
+ rel = np.abs(got - want).max() / max(np.abs(want).max(), 1e-12)
144
+ print(f" verify {path.name} {name}: max|diff|/max|y| = {rel:.2e} " + ("OK" if rel < tol else "FAIL"))
145
+ if rel >= tol:
146
+ raise SystemExit(f"{path}: verification failed ({rel:.2e} >= {tol})")
147
+
148
+
149
+ def reference(model, out_dir):
150
+ """The reference separation: upstream's separate_soundtrack (-27 LUFS in, back out) on reference.py's mix, 20 s
151
+ (longer than the package's chunks are not: MRX hears the whole input at once, as upstream)."""
152
+ import torch
153
+
154
+ sys.path.insert(0, str(Path(__file__).parent))
155
+ import reference as ref
156
+ import separate
157
+
158
+ def run(mix):
159
+ with torch.no_grad():
160
+ y = separate.separate_soundtrack(torch.from_numpy(mix.copy()), separation_model=model)
161
+ return {"dialogue": y["speech"].numpy(), "music": y["music"].numpy(), "effects": y["sfx"].numpy()}
162
+
163
+ ref.write(out_dir, run, 20.0)
164
+
165
+
166
+ def main():
167
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
168
+ ap.add_argument("--out-dir", default=None, help="default: $AUDIO_NEURAL_CACHE/mrx or ~/.cache/audiojs/neural/mrx")
169
+ ap.add_argument("--opset", type=int, default=17)
170
+ ap.add_argument("--verify", action="store_true", help="compare with MRX.forward; write the reference separation")
171
+ args = ap.parse_args()
172
+
173
+ cache = Path(os.environ.get("AUDIO_NEURAL_CACHE") or Path.home() / ".cache" / "audiojs" / "neural")
174
+ out_dir = Path(args.out_dir) if args.out_dir else cache / "mrx"
175
+ out_dir.mkdir(parents=True, exist_ok=True)
176
+ src = out_dir / "src"
177
+ src.mkdir(exist_ok=True)
178
+ try:
179
+ model = load(src)
180
+ except ImportError as e:
181
+ raise SystemExit(f"torch is required: pip install torch onnx onnxruntime pyloudnorm ({e})")
182
+ path = out_dir / "mrx.onnx"
183
+ export(model, path, args.opset)
184
+ if args.verify:
185
+ verify(model, path, 1e-4)
186
+ reference(model, out_dir)
187
+ print(f"done: mrx in {out_dir}")
188
+
189
+
190
+ if __name__ == "__main__":
191
+ sys.exit(main())