@audio/neural-separate 0.1.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,219 @@
1
+ #!/usr/bin/env python3
2
+ """Export open-unmix-pytorch checkpoints to the ONNX contract @audio/neural-separate
3
+ expects (modelType 'openunmix'): one graph per target, magnitude spectrogram
4
+ in, estimated magnitude spectrogram out, shape [1, C, F, T] (batch, channels,
5
+ frequency bins, time frames), row-major, T dynamic.
6
+
7
+ pip install torch openunmix onnx onnxruntime onnxscript
8
+ python3 export-openunmix.py --model umxhq --verify
9
+
10
+ Writes <out-dir>/<target>.onnx, weights embedded (one self-contained file per
11
+ target, loadable from bytes). Default out-dir: $AUDIO_NEURAL_CACHE/<model>, else
12
+ ~/.cache/audiojs/neural/<model>, where `separate(audio, { model: 'umxhq' })`
13
+ looks. Last run: torch 2.14, onnx 1.23, onnxruntime 1.30, openunmix 1.3.0.
14
+
15
+ Two things keep the frame axis dynamic, both found by running this:
16
+ - A wrapper, not OpenUnmix itself. OpenUnmix.forward reads the frame count off
17
+ `x.data.shape`; `.data` leaves the traced graph, so the exporter records the
18
+ dummy input's frame count as a constant. ExportWrapper restates the same
19
+ forward with shapes taken from the traced tensor; --verify compares the ONNX
20
+ graph against the original module, so the restatement is checked, not trusted.
21
+ - The TorchScript exporter (dynamo=False). The torch.export-based default bakes
22
+ the LSTM's sequence length into a Reshape even with dynamic_shapes declared.
23
+
24
+ max_bin: umxhq crops the network input to bins below 16 kHz
25
+ (`bandwidth_to_max_bin(44100, 4096, 16000)`, 1487 of 2049 bins,
26
+ openunmix/utils.py), and fc3 regresses all 2049 output bins from that: a learned
27
+ extrapolation above 16 kHz, not a zero fill.
28
+ """
29
+ import argparse
30
+ import os
31
+ import sys
32
+ from pathlib import Path
33
+
34
+
35
+ def build_wrapper(model):
36
+ import torch
37
+ import torch.nn as nn
38
+ import torch.nn.functional as F
39
+
40
+ class ExportWrapper(nn.Module):
41
+ """OpenUnmix.forward (openunmix/model.py) with symbolic shapes. Eval mode:
42
+ dropout and batch-norm statistics are the module's own."""
43
+
44
+ def __init__(self, m):
45
+ super().__init__()
46
+ self.m = m
47
+
48
+ def forward(self, magnitude):
49
+ m = self.m
50
+ x = magnitude.permute(3, 0, 1, 2) # (frames, samples, channels, bins)
51
+ mix = x
52
+ frames, samples, channels = x.shape[0], x.shape[1], x.shape[2]
53
+ x = x[..., : m.nb_bins]
54
+ x = (x + m.input_mean) * m.input_scale
55
+ x = m.bn1(m.fc1(x.reshape(-1, channels * m.nb_bins)))
56
+ x = torch.tanh(x.reshape(frames, samples, m.hidden_size))
57
+ x = torch.cat([x, m.lstm(x)[0]], -1)
58
+ x = F.relu(m.bn2(m.fc2(x.reshape(-1, x.shape[-1]))))
59
+ x = m.bn3(m.fc3(x)).reshape(frames, samples, channels, m.nb_output_bins)
60
+ x = x * m.output_scale + m.output_mean
61
+ return (F.relu(x) * mix).permute(1, 2, 3, 0)
62
+
63
+ return ExportWrapper(model).eval()
64
+
65
+
66
+ def build_combined(models):
67
+ import torch
68
+ import torch.nn as nn
69
+
70
+ class CombinedWrapper(nn.Module):
71
+ """All targets in one graph, output [1, S, C, F, T]: the JS side's
72
+ `{ url, targets }` model option."""
73
+
74
+ def __init__(self, ms):
75
+ super().__init__()
76
+ self.ms = nn.ModuleList(ms)
77
+
78
+ def forward(self, magnitude):
79
+ return torch.stack([m(magnitude) for m in self.ms], dim=1)
80
+
81
+ return CombinedWrapper(models).eval()
82
+
83
+
84
+ def load_targets(model_name, targets, checkpoint_dir):
85
+ """{ target: OpenUnmix } in eval mode."""
86
+ import openunmix
87
+
88
+ if checkpoint_dir:
89
+ # <dir>/<target>.pth + <dir>/<target>.json, openunmix.utils.load_target_models's layout
90
+ models = openunmix.utils.load_target_models(targets=targets, model_str_or_path=checkpoint_dir, pretrained=True)
91
+ else:
92
+ spec_fn = getattr(openunmix, f"{model_name}_spec", None)
93
+ if spec_fn is None:
94
+ raise SystemExit(f"unknown --model '{model_name}': expected umx, umxhq, umxl, umxse, or --checkpoint-dir")
95
+ models = spec_fn(targets=targets, pretrained=True)
96
+ for m in models.values():
97
+ m.eval()
98
+ return models
99
+
100
+
101
+ def export_one(wrapped, reference, out_path, n_channels, n_bins, opset, fp16, verify):
102
+ import torch
103
+
104
+ x = torch.randn(1, n_channels, n_bins, 8).abs()
105
+ torch.onnx.export(
106
+ wrapped,
107
+ (x,),
108
+ str(out_path),
109
+ input_names=["magnitude"],
110
+ output_names=["estimate"],
111
+ dynamic_axes={"magnitude": {3: "frames"}, "estimate": {3: "frames"}},
112
+ opset_version=opset,
113
+ dynamo=False,
114
+ )
115
+ print(f"wrote {out_path} ({out_path.stat().st_size / 1e6:.1f} MB)")
116
+ if verify:
117
+ verify_export(reference, out_path, n_channels, n_bins, 1e-4)
118
+
119
+ if fp16:
120
+ # post-converted from the verified fp32 graph, float32 I/O kept
121
+ import onnx
122
+ from onnxruntime.transformers.float16 import convert_float_to_float16
123
+
124
+ fp16_path = out_path.with_suffix(".fp16.onnx")
125
+ onnx.save(convert_float_to_float16(onnx.load(str(out_path)), keep_io_types=True), str(fp16_path))
126
+ print(f"wrote {fp16_path} ({fp16_path.stat().st_size / 1e6:.1f} MB)")
127
+ if verify:
128
+ verify_export(reference, fp16_path, n_channels, n_bins, None)
129
+
130
+
131
+ def verify_export(reference, onnx_path, n_channels, n_bins, tol):
132
+ """onnxruntime vs the original torch module on random magnitudes. fp32: max
133
+ |diff| relative to max |output| < tol. fp16 (tol None): report only."""
134
+ import numpy as np
135
+ import onnxruntime as ort
136
+ import torch
137
+
138
+ session = ort.InferenceSession(str(onnx_path), providers=["CPUExecutionProvider"])
139
+ rng = np.random.default_rng(0)
140
+ for n_frames in (1, 8, 37, 300): # the dynamic frames axis, incl. 1 and Open-Unmix's Wiener window
141
+ x = np.abs(rng.standard_normal((1, n_channels, n_bins, n_frames))).astype(np.float32)
142
+ with torch.no_grad():
143
+ expected = reference(torch.from_numpy(x)).numpy()
144
+ (actual,) = session.run(None, {"magnitude": x})
145
+ rel = np.abs(actual - expected).max() / max(np.abs(expected).max(), 1e-12)
146
+ ok = tol is None or rel < tol
147
+ print(f" verify {onnx_path.name} frames={n_frames}: max|diff|/max|y| = {rel:.2e}" + ("" if tol is None else " OK" if ok else " FAIL"))
148
+ if not ok:
149
+ raise SystemExit(f"{onnx_path}: verification failed ({rel:.2e} >= {tol})")
150
+
151
+
152
+ def write_reference(model_name, targets, out_dir):
153
+ """openunmix's own Separator as `umx` runs it (niter=1, 300-frame Wiener windows), in float64:
154
+ upstream's float32 EM alone moves the stems by -71 to -93 dB on this mix, which would hide
155
+ any difference in the port."""
156
+ import numpy as np
157
+ import openunmix
158
+ import torch
159
+
160
+ sys.path.insert(0, str(Path(__file__).parent))
161
+ sys.dont_write_bytecode = True # no __pycache__ beside the package's scripts
162
+ import reference
163
+
164
+ sep = openunmix.utils.load_separator(model_name, targets=targets, niter=1, residual=False, wiener_win_len=300, device="cpu").eval().double()
165
+
166
+ def separate(mix):
167
+ with torch.no_grad():
168
+ est = sep(torch.from_numpy(mix.astype(np.float64))[None])[0]
169
+ return {t: est[k].numpy() for k, t in enumerate(sep.target_models)}
170
+
171
+ reference.write(out_dir, separate)
172
+
173
+
174
+ def main():
175
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
176
+ ap.add_argument("--model", default="umxhq", help="umx | umxhq | umxl | umxse (umxl weights are CC BY-NC-SA 4.0)")
177
+ ap.add_argument("--checkpoint-dir", default=None, help="local dir with <target>.pth/.json instead of the pretrained download")
178
+ ap.add_argument("--targets", default=None, help="comma-separated targets (default: all the model has)")
179
+ ap.add_argument("--out-dir", default=None, help="default: $AUDIO_NEURAL_CACHE/<model> or ~/.cache/audiojs/neural/<model>")
180
+ ap.add_argument("--opset", type=int, default=18)
181
+ ap.add_argument("--fp16", action="store_true", help="also write <target>.fp16.onnx (float16 weights, float32 I/O)")
182
+ ap.add_argument("--combined", action="store_true", help="also write targets.onnx, one multi-target graph, output [1,S,C,F,T]")
183
+ ap.add_argument("--verify", action="store_true", help="compare onnxruntime against the torch module (relative max|diff| < 1e-4), "
184
+ "and write the reference separation test.js compares against (scripts/reference.py)")
185
+ args = ap.parse_args()
186
+
187
+ try:
188
+ import torch # noqa: F401
189
+ except ImportError:
190
+ raise SystemExit("torch is required: pip install torch openunmix onnx onnxruntime onnxscript")
191
+
192
+ cache = os.environ.get("AUDIO_NEURAL_CACHE") or Path.home() / ".cache" / "audiojs" / "neural"
193
+ out_dir = Path(args.out_dir) if args.out_dir else Path(cache) / args.model
194
+ out_dir.mkdir(parents=True, exist_ok=True)
195
+ targets = args.targets.split(",") if args.targets else None
196
+
197
+ models = load_targets(args.model, targets, args.checkpoint_dir)
198
+ targets = list(models)
199
+ any_model = next(iter(models.values()))
200
+ n_channels = any_model.fc1.in_features // any_model.nb_bins # fc1: Linear(nb_bins * nb_channels, hidden)
201
+ n_bins = any_model.nb_output_bins
202
+
203
+ for name in targets:
204
+ export_one(build_wrapper(models[name]), models[name], out_dir / f"{name}.onnx", n_channels, n_bins, args.opset, args.fp16, args.verify)
205
+
206
+ if args.combined:
207
+ combined = build_combined([build_wrapper(models[t]) for t in targets])
208
+ reference = build_combined([models[t] for t in targets])
209
+ export_one(combined, reference, out_dir / "targets.onnx", n_channels, n_bins, args.opset, args.fp16, args.verify)
210
+
211
+ if args.verify and not args.checkpoint_dir:
212
+ write_reference(args.model, targets, out_dir)
213
+
214
+ print(f"\ndone: {len(targets)} target graph(s) in {out_dir}" + (" + targets.onnx" if args.combined else ""))
215
+ print("JS: separate(audio, { model: { " + ", ".join(f"{t}: '{out_dir}/{t}.onnx'" for t in targets) + " } })")
216
+
217
+
218
+ if __name__ == "__main__":
219
+ sys.exit(main())
@@ -0,0 +1,34 @@
1
+ """Reference separation for the package tests: a deterministic 9 s stereo mix (two 7.8 s
2
+ Demucs segments overlap on it) and its separation by the original Python implementation,
3
+ written next to the exported weights:
4
+
5
+ <dir>/test.f32 mix, planar float32 (L then R), 44.1 kHz
6
+ <dir>/test.<target>.f32 reference stem, same layout
7
+
8
+ test.js runs @audio/neural-separate on the same mix and compares, whenever the files exist.
9
+ """
10
+ import numpy as np
11
+
12
+ RATE = 44100
13
+
14
+
15
+ def test_mix(seconds=9.0, rate=RATE):
16
+ t = np.arange(int(seconds * rate)) / rate
17
+ rng = np.random.RandomState(0)
18
+ # voice-like: 220 Hz harmonics, 5 Hz vibrato, 3 Hz syllables
19
+ phase = 2 * np.pi * np.cumsum(220 * (1 + 0.01 * np.sin(2 * np.pi * 5 * t))) / rate
20
+ voice = sum(np.sin(k * phase) / k for k in range(1, 9)) * (0.5 + 0.5 * np.sin(2 * np.pi * 3 * t) ** 2) * 0.15
21
+ bass = (np.sin(2 * np.pi * 55 * t) + 0.5 * np.sin(2 * np.pi * 110 * t)) * 0.2
22
+ beat = t % 0.5
23
+ drums = np.sin(2 * np.pi * 60 * beat) * np.exp(-beat * 30) * 0.4 + rng.randn(t.size) * np.exp(-((t + 0.25) % 0.5) * 60) * 0.1
24
+ pad = sum(np.sin(2 * np.pi * f * t) for f in (261.63, 329.63, 392.0)) * 0.05
25
+ return np.stack([voice + 1.1 * bass + drums + 0.7 * pad, voice + 0.9 * bass + drums + 1.3 * pad]).astype(np.float32)
26
+
27
+
28
+ def write(out_dir, separate):
29
+ """separate(mix (2, N) float32) -> { target: (2, N) array }"""
30
+ mix = test_mix()
31
+ mix.tofile(out_dir / "test.f32")
32
+ for name, x in separate(mix).items():
33
+ np.asarray(x, dtype=np.float32).tofile(out_dir / f"test.{name}.f32")
34
+ print(f"wrote {out_dir}/test.f32 and its reference stems")