@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.
- package/NOTICE +36 -0
- package/README.md +149 -0
- package/index.d.ts +163 -0
- package/package.json +61 -0
- package/scripts/export-htdemucs.py +230 -0
- package/scripts/export-openunmix.py +219 -0
- package/scripts/reference.py +34 -0
- package/separate.js +702 -0
|
@@ -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")
|