@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.
- package/NOTICE +32 -2
- package/README.md +167 -18
- package/index.d.ts +57 -7
- package/package.json +11 -2
- package/scripts/compact.py +351 -0
- package/scripts/export-mrx.py +191 -0
- package/scripts/export-scnet.py +274 -0
- package/scripts/export-tiger.py +304 -0
- package/scripts/reference.py +41 -2
- package/separate.js +323 -38
|
@@ -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())
|