@audio/neural-separate 0.1.0 → 0.3.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 +29 -1
- package/README.md +88 -15
- package/index.d.ts +47 -6
- package/package.json +10 -2
- 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 +241 -17
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Export SCNet (Tong et al., "SCNet: Sparse Compression Network for Music Source
|
|
3
|
+
Separation", ICASSP 2024) to the ONNX contract @audio/neural-separate expects for
|
|
4
|
+
modelType 'complex': the network without its STFT and iSTFT, which JS computes.
|
|
5
|
+
|
|
6
|
+
pip install torch onnx onnxruntime pyyaml
|
|
7
|
+
python3 export-scnet.py --model scnet-large --verify
|
|
8
|
+
python3 export-scnet.py --model scnet --verify
|
|
9
|
+
|
|
10
|
+
The models are SCNet-large and SCNet as their author trained them on MUSDB18-HQ
|
|
11
|
+
(starrytong/SCNet, MIT; the author confirms the weights are MIT too and may be
|
|
12
|
+
redistributed converted, ONNX included: github.com/starrytong/SCNet/issues/35), in
|
|
13
|
+
the layout of ZFTurbo/Music-Source-Separation-Training (MIT), whose releases host
|
|
14
|
+
them with their configs (v1.0.9 SCNet-large_starrytong_fixed.ckpt, 41.2 M
|
|
15
|
+
parameters; v.1.0.6 scnet_checkpoint_musdb18.ckpt, 10.1 M); the model code is that
|
|
16
|
+
repository's models/scnet, fetched at a pinned commit. MUSDB18 test, its own
|
|
17
|
+
measure: SCNet-large vocals 10.94, drums 11.15, bass 9.38, other 7.31 dB SDR; SCNet
|
|
18
|
+
9.90, 10.44, 8.89, 6.89.
|
|
19
|
+
|
|
20
|
+
One 11 s segment (485100 samples at 44.1 kHz, padded as SCNet.forward pads it to
|
|
21
|
+
486400, 476 frames) per run:
|
|
22
|
+
input mix_spec [1, 4, 2049, 476] STFT, complex as channels (L re, L im, R re, R im)
|
|
23
|
+
output stems_spec [1, 16, 2049, 476] per source (drums, bass, other, vocals) and channel, re and im
|
|
24
|
+
The STFT is SCNet's own: n_fft 4096, hop 1024, no window (torch.stft's default,
|
|
25
|
+
ones), normalized (1/sqrt(4096)), centered with reflect padding.
|
|
26
|
+
|
|
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:
|
|
29
|
+
$AUDIO_NEURAL_CACHE/<model>, else ~/.cache/audiojs/neural/<model>. --verify compares the stems rebuilt from the ONNX
|
|
30
|
+
output with SCNet.forward on the same segment, and writes the reference
|
|
31
|
+
separation test.js compares the JS pipeline against (scripts/reference.py),
|
|
32
|
+
chunked as scripts/reference.py's `chunked` does. Last run: torch 2.14,
|
|
33
|
+
onnxruntime 1.30.
|
|
34
|
+
"""
|
|
35
|
+
import argparse
|
|
36
|
+
import gc
|
|
37
|
+
import math
|
|
38
|
+
import os
|
|
39
|
+
import sys
|
|
40
|
+
import urllib.request
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
|
|
43
|
+
REPO = "https://raw.githubusercontent.com/ZFTurbo/Music-Source-Separation-Training/84b1eac0887756b4f1a9d7a1ff49105939749ed2/"
|
|
44
|
+
RELEASE = "https://github.com/ZFTurbo/Music-Source-Separation-Training/releases/download/"
|
|
45
|
+
MODELS = { # release, checkpoint, config
|
|
46
|
+
"scnet-large": ("v1.0.9", "SCNet-large_starrytong_fixed.ckpt", "config_musdb18_scnet_large_starrytong.yaml"),
|
|
47
|
+
"scnet": ("v.1.0.6", "scnet_checkpoint_musdb18.ckpt", "config_musdb18_scnet.yaml"),
|
|
48
|
+
}
|
|
49
|
+
SEGMENT = 485100
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def fetch(url, path):
|
|
53
|
+
if not path.exists():
|
|
54
|
+
print(f"fetching {url}")
|
|
55
|
+
urllib.request.urlretrieve(url, str(path) + ".part")
|
|
56
|
+
os.replace(str(path) + ".part", path)
|
|
57
|
+
return path
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load(src, name="scnet-large"):
|
|
61
|
+
"""SCNet or SCNet-large with its weights: the model code and checkpoint fetched into `src` once."""
|
|
62
|
+
import torch
|
|
63
|
+
import yaml
|
|
64
|
+
|
|
65
|
+
pkg = src / "scnet_code" / "scnet"
|
|
66
|
+
pkg.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
(pkg / "__init__.py").touch()
|
|
68
|
+
for f in ("scnet.py", "separation.py"):
|
|
69
|
+
fetch(REPO + "models/scnet/" + f, pkg / f)
|
|
70
|
+
sys.path.insert(0, str(src / "scnet_code"))
|
|
71
|
+
sys.dont_write_bytecode = True
|
|
72
|
+
from scnet.scnet import SCNet
|
|
73
|
+
|
|
74
|
+
class Loader(yaml.SafeLoader): pass # the config's augmentation settings hold a !!python/tuple
|
|
75
|
+
Loader.add_constructor("tag:yaml.org,2002:python/tuple", lambda l, n: tuple(l.construct_sequence(n)))
|
|
76
|
+
release, ckpt, config = MODELS[name]
|
|
77
|
+
cfg = yaml.load(fetch(f"{RELEASE}{release}/{config}", src / config).read_text(), Loader=Loader)
|
|
78
|
+
model = SCNet(**cfg["model"])
|
|
79
|
+
model.load_state_dict(torch.load(fetch(f"{RELEASE}{release}/{ckpt}", src / ckpt), map_location="cpu", weights_only=True))
|
|
80
|
+
return model.eval()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def stft(model, x):
|
|
84
|
+
"""SCNet.forward's padding and STFT: (B, C, L) -> (B, 2C, F, T), and the padding."""
|
|
85
|
+
import torch
|
|
86
|
+
import torch.nn.functional as F
|
|
87
|
+
|
|
88
|
+
hop = model.hop_length
|
|
89
|
+
pad = hop - x.shape[-1] % hop
|
|
90
|
+
if (x.shape[-1] + pad) // hop % 2 == 0:
|
|
91
|
+
pad += hop
|
|
92
|
+
x = F.pad(x, (0, pad))
|
|
93
|
+
B, C, L = x.shape
|
|
94
|
+
z = torch.view_as_real(torch.stft(x.reshape(-1, L), **model.stft_config, return_complex=True))
|
|
95
|
+
return z.permute(0, 3, 1, 2).reshape(B, 2 * C, z.shape[1], z.shape[2]), pad
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def istft(model, y, pad):
|
|
99
|
+
"""SCNet.forward's tail: (B, 2·S·C, F, T) -> (B, S, C, L)."""
|
|
100
|
+
import torch
|
|
101
|
+
|
|
102
|
+
B, _, Fr, T = y.shape
|
|
103
|
+
y = torch.view_as_complex(y.reshape(-1, 2, Fr, T).permute(0, 2, 3, 1).contiguous())
|
|
104
|
+
x = torch.istft(y, **model.stft_config)
|
|
105
|
+
return x.reshape(B, len(model.sources), model.audio_channels, -1)[..., :-pad]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def dft(x, inverse):
|
|
109
|
+
"""FeatureConversion's rfft and irfft over time (norm "ortho") as products with cos and sin matrices: no ONNX
|
|
110
|
+
exporter of torch 2.14 takes aten::fft_rfft, and a MatMul runs on every backend. x: (B, C, F, T), T even."""
|
|
111
|
+
import torch
|
|
112
|
+
|
|
113
|
+
T = x.shape[3] if not inverse else 2 * (x.shape[3] - 1)
|
|
114
|
+
t, k = torch.arange(T, dtype=torch.float64), torch.arange(T // 2 + 1, dtype=torch.float64)
|
|
115
|
+
a = 2 * math.pi * k[:, None] * t[None] / T # (K, T)
|
|
116
|
+
if not inverse:
|
|
117
|
+
c, s = (torch.cos(a).T / math.sqrt(T)).to(x.dtype), (-torch.sin(a).T / math.sqrt(T)).to(x.dtype)
|
|
118
|
+
return torch.cat([x @ c, x @ s], dim=1) # (re, im) on channels, as torch.cat([x.real, x.imag], 1)
|
|
119
|
+
w = torch.full((T // 2 + 1, 1), 2.0, dtype=torch.float64)
|
|
120
|
+
w[0] = w[-1] = 1 # irfft: DC and Nyquist once, their imaginary parts unread
|
|
121
|
+
c, s = (w * torch.cos(a) / math.sqrt(T)).to(x.dtype), (-w * torch.sin(a) / math.sqrt(T)).to(x.dtype)
|
|
122
|
+
h = x.shape[1] // 2
|
|
123
|
+
return x[:, :h] @ c + x[:, h:] @ s
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def group_norm(mod):
|
|
127
|
+
"""GroupNorm(1, C) with its mean and variance reduced one axis at a time, the last first. Exported as it is,
|
|
128
|
+
onnxruntime 1.30 sums a whole (C, F, T) group in float32 (10 million values in the dual-path layers): the stems
|
|
129
|
+
came out 52 to 55 dB from torch's; reduced by axis, 110 dB, as torch's own float32 against float64."""
|
|
130
|
+
import torch
|
|
131
|
+
|
|
132
|
+
def mean(x):
|
|
133
|
+
for d in range(x.dim() - 1, 0, -1):
|
|
134
|
+
x = x.mean(d, keepdim=True)
|
|
135
|
+
return x
|
|
136
|
+
|
|
137
|
+
def forward(x):
|
|
138
|
+
mu = mean(x)
|
|
139
|
+
var = mean((x - mu) ** 2)
|
|
140
|
+
shape = (1, -1) + (1,) * (x.dim() - 2)
|
|
141
|
+
return (x - mu) / torch.sqrt(var + mod.eps) * mod.weight.view(shape) + mod.bias.view(shape)
|
|
142
|
+
|
|
143
|
+
return forward
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class Core:
|
|
147
|
+
"""SCNet.forward between its STFT and its iSTFT."""
|
|
148
|
+
|
|
149
|
+
@staticmethod
|
|
150
|
+
def build(model):
|
|
151
|
+
import torch.nn as nn
|
|
152
|
+
from collections import deque
|
|
153
|
+
|
|
154
|
+
for f in model.separation_net.feature_conversion:
|
|
155
|
+
f.forward = (lambda inv: lambda x: dft(x, inv))(f.inverse)
|
|
156
|
+
for m in model.modules():
|
|
157
|
+
if isinstance(m, nn.GroupNorm) and m.num_groups == 1:
|
|
158
|
+
m.forward = group_norm(m)
|
|
159
|
+
|
|
160
|
+
class C(nn.Module):
|
|
161
|
+
def __init__(self, m):
|
|
162
|
+
super().__init__()
|
|
163
|
+
self.m = m
|
|
164
|
+
|
|
165
|
+
def forward(self, x):
|
|
166
|
+
m, skips = self.m, deque()
|
|
167
|
+
for sd in m.encoder:
|
|
168
|
+
x, skip, lengths, orig = sd(x)
|
|
169
|
+
skips.append((skip, lengths, orig))
|
|
170
|
+
x = m.separation_net(x)
|
|
171
|
+
for fusion, su in m.decoder:
|
|
172
|
+
skip, lengths, orig = skips.pop()
|
|
173
|
+
x = su(fusion(x, skip), lengths, orig)
|
|
174
|
+
return x
|
|
175
|
+
|
|
176
|
+
return C(model).eval()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def export(model, path, opset):
|
|
180
|
+
import torch
|
|
181
|
+
|
|
182
|
+
spec, _ = stft(model, torch.randn(1, model.audio_channels, SEGMENT))
|
|
183
|
+
with torch.no_grad():
|
|
184
|
+
torch.onnx.export(Core.build(model), (spec,), str(path), input_names=["mix_spec"], output_names=["stems_spec"],
|
|
185
|
+
opset_version=opset, dynamo=False)
|
|
186
|
+
print(f"wrote {path} ({path.stat().st_size / 1e6:.1f} MB), segment {SEGMENT} samples, spec {list(spec.shape)}")
|
|
187
|
+
|
|
188
|
+
|
|
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
|
|
193
|
+
|
|
194
|
+
onnx.save(convert_float_to_float16(onnx.load(str(path)), keep_io_types=True), str(out))
|
|
195
|
+
print(f"wrote {out} ({out.stat().st_size / 1e6:.1f} MB)")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def session(path):
|
|
199
|
+
import onnxruntime as ort
|
|
200
|
+
|
|
201
|
+
o = ort.SessionOptions()
|
|
202
|
+
o.enable_cpu_mem_arena = False
|
|
203
|
+
o.enable_mem_pattern = False
|
|
204
|
+
return ort.InferenceSession(str(path), o, providers=["CPUExecutionProvider"])
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def verify(model, path, tol):
|
|
208
|
+
"""Stems rebuilt from onnxruntime's output against SCNet.forward: noise and tones."""
|
|
209
|
+
import numpy as np
|
|
210
|
+
import torch
|
|
211
|
+
|
|
212
|
+
s = session(path)
|
|
213
|
+
t = torch.arange(SEGMENT) / 44100
|
|
214
|
+
tone = sum(torch.sin(2 * math.pi * f * t) / k for k, f in enumerate((110, 220, 440, 880, 1760), 1))
|
|
215
|
+
for name, mix in (("noise", 0.1 * torch.randn(1, 2, SEGMENT)), ("tones", 0.2 * torch.stack([tone, 0.8 * tone])[None])):
|
|
216
|
+
with torch.no_grad():
|
|
217
|
+
want = model(mix).numpy()
|
|
218
|
+
spec, pad = stft(model, mix)
|
|
219
|
+
got = istft(model, torch.from_numpy(s.run(None, {"mix_spec": spec.numpy()})[0]), pad).numpy()
|
|
220
|
+
rel = np.abs(got - want).max() / max(np.abs(want).max(), 1e-12)
|
|
221
|
+
print(f" verify {path.name} {name}: max|diff|/max|y| = {rel:.2e} " + ("OK" if rel < tol else "FAIL"))
|
|
222
|
+
if rel >= tol:
|
|
223
|
+
raise SystemExit(f"{path}: verification failed ({rel:.2e} >= {tol})")
|
|
224
|
+
del s
|
|
225
|
+
gc.collect()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def reference(model, out_dir):
|
|
229
|
+
"""The reference separation: SCNet.forward on segments as scripts/reference.py's normalized() and chunked() cut
|
|
230
|
+
them, over a 20 s mix (longer than two segments less a step: its ends reflected out)."""
|
|
231
|
+
import torch
|
|
232
|
+
|
|
233
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
234
|
+
import reference as ref
|
|
235
|
+
|
|
236
|
+
def run(seg):
|
|
237
|
+
with torch.no_grad():
|
|
238
|
+
return model(torch.from_numpy(seg)[None])[0].numpy()
|
|
239
|
+
|
|
240
|
+
ref.write(out_dir, lambda mix: dict(zip(model.sources, ref.normalized(mix, lambda x: ref.chunked(x, run, SEGMENT)))), 20.0)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def main():
|
|
244
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
245
|
+
ap.add_argument("--model", default="scnet-large", choices=list(MODELS))
|
|
246
|
+
ap.add_argument("--out-dir", default=None, help="default: $AUDIO_NEURAL_CACHE/<model> or ~/.cache/audiojs/neural/<model>")
|
|
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)")
|
|
249
|
+
ap.add_argument("--verify", action="store_true", help="compare with SCNet.forward; write the reference separation")
|
|
250
|
+
args = ap.parse_args()
|
|
251
|
+
|
|
252
|
+
cache = Path(os.environ.get("AUDIO_NEURAL_CACHE") or Path.home() / ".cache" / "audiojs" / "neural")
|
|
253
|
+
out_dir = Path(args.out_dir) if args.out_dir else cache / args.model
|
|
254
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
255
|
+
src = out_dir / "src"
|
|
256
|
+
src.mkdir(exist_ok=True)
|
|
257
|
+
try:
|
|
258
|
+
model = load(src, args.model)
|
|
259
|
+
except ImportError as e:
|
|
260
|
+
raise SystemExit(f"torch and pyyaml are required: pip install torch onnx onnxruntime pyyaml ({e})")
|
|
261
|
+
path = out_dir / f"{args.model}.onnx"
|
|
262
|
+
export(model, path, args.opset)
|
|
263
|
+
if args.verify:
|
|
264
|
+
verify(model, path, 1e-4)
|
|
265
|
+
reference(model, out_dir)
|
|
266
|
+
if args.fp16:
|
|
267
|
+
fp16(path, out_dir / f"{args.model}.fp16.onnx")
|
|
268
|
+
if args.verify:
|
|
269
|
+
verify(model, out_dir / f"{args.model}.fp16.onnx", 2e-2)
|
|
270
|
+
print(f"done: {args.model} in {out_dir}")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
if __name__ == "__main__":
|
|
274
|
+
sys.exit(main())
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Export TIGER's Divide and Remaster model (Xu, Li, Chen, Hu, "TIGER: Time-frequency Interleaved Gain Extraction and
|
|
3
|
+
Reconstruction for Efficient Speech Separation", ICLR 2025) to the ONNX contract @audio/neural-separate expects for
|
|
4
|
+
modelType 'complex': the networks between their STFT and iSTFT, which JS computes.
|
|
5
|
+
|
|
6
|
+
pip install torch onnx onnxruntime safetensors
|
|
7
|
+
python3 export-tiger.py --verify
|
|
8
|
+
|
|
9
|
+
TIGERDNR is three band-split TIGER models of 1.4 M parameters, each separating three sources, of which it keeps one:
|
|
10
|
+
the dialogue of the first, the effects of the second, the music of the third. The weights are the authors'
|
|
11
|
+
(huggingface.co/JusperLee/TIGER-DnR, whose model card states license: apache-2.0; the code, JusperLee/TIGER, is MIT),
|
|
12
|
+
fetched at a pinned revision with the model code at a pinned commit. Its paper, DnR test (v1), SI-SDR: music 7.4,
|
|
13
|
+
speech 15.5, effects 6.5 dB.
|
|
14
|
+
|
|
15
|
+
One mono 12 s segment (529200 samples at 44.1 kHz, 1034 frames) per run, the three models in one graph:
|
|
16
|
+
input mix_spec [1, 2, 1025, 1034] STFT, re and im as channels
|
|
17
|
+
output stems_spec [1, 6, 1025, 1034] dialogue, music, effects, each re and im
|
|
18
|
+
The STFT is TIGER's own: n_fft 2048, hop 512, Hann, unnormalized, centered with reflect padding. Three changes to the
|
|
19
|
+
graph, none to its function: F.adaptive_avg_pool1d (the exporter takes only output sizes that divide the input's)
|
|
20
|
+
becomes a cumulative sum read at torch's windows, UConvBlock's global sum starts at its first term instead of a
|
|
21
|
+
zeros tensor (exported, a constant as large as the features), and each GroupNorm(1, C) reduces its statistics one
|
|
22
|
+
axis at a time (export-scnet.py's; exported whole, each was an InstanceNormalization with constants as large as its
|
|
23
|
+
batch).
|
|
24
|
+
|
|
25
|
+
Writes <out-dir>/tiger.onnx (29 MB), weights embedded. Default out-dir: $AUDIO_NEURAL_CACHE/tiger, else
|
|
26
|
+
~/.cache/audiojs/neural/tiger. --verify compares the graph with the three TIGER.forward on one segment (in PyTorch on a
|
|
27
|
+
CPU, minutes), and writes the reference separation test.js compares the JS pipeline against: upstream's
|
|
28
|
+
TIGERDNR.wav_chunk_inference (12 s segments every 4 s, unweighted, zero-padded) over reference.py's 20 s mix, each
|
|
29
|
+
channel apart, its segments run through the verified graph. Last run: torch 2.14, onnxruntime 1.30.
|
|
30
|
+
"""
|
|
31
|
+
import argparse
|
|
32
|
+
import os
|
|
33
|
+
import sys
|
|
34
|
+
import types
|
|
35
|
+
import importlib.util
|
|
36
|
+
import json
|
|
37
|
+
import urllib.request
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
COMMIT = "9f18d4a10a7137e1ce8052cfb62215179f1287b6"
|
|
41
|
+
REPO = f"https://raw.githubusercontent.com/JusperLee/TIGER/{COMMIT}/look2hear/"
|
|
42
|
+
REVISION = "b7a59560bbca10febbcd46fb01600f868e587f57"
|
|
43
|
+
HF = f"https://huggingface.co/JusperLee/TIGER-DnR/resolve/{REVISION}/"
|
|
44
|
+
RATE, N_FFT, HOP, SEGMENT = 44100, 2048, 512, 529200
|
|
45
|
+
# TIGERDNR.forward: each model's kept source, in the order the graph gives them
|
|
46
|
+
KEEP = (("dialog", 2), ("music", 0), ("effect", 1))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def fetch(url, path):
|
|
50
|
+
if not path.exists():
|
|
51
|
+
print(f"fetching {url}")
|
|
52
|
+
urllib.request.urlretrieve(url, str(path) + ".part")
|
|
53
|
+
os.replace(str(path) + ".part", path)
|
|
54
|
+
return path
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def module(name, path):
|
|
58
|
+
s = importlib.util.spec_from_file_location(name, path)
|
|
59
|
+
m = importlib.util.module_from_spec(s)
|
|
60
|
+
sys.modules[name] = m
|
|
61
|
+
s.loader.exec_module(m)
|
|
62
|
+
return m
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load(src):
|
|
66
|
+
"""TIGERDNR with its weights: tiger_dnr.py and the two layer modules it reads, without the package's training stack
|
|
67
|
+
(its layers/__init__ and base_model import lightning, rich and huggingface_hub)."""
|
|
68
|
+
import torch
|
|
69
|
+
from safetensors.torch import load_file
|
|
70
|
+
|
|
71
|
+
for f in ("models/tiger_dnr.py", "layers/activations.py", "layers/normalizations.py"):
|
|
72
|
+
fetch(REPO + f, src / Path(f).name)
|
|
73
|
+
for f in ("config.json", "model.safetensors"):
|
|
74
|
+
fetch(HF + f, src / f)
|
|
75
|
+
for p in ("look2hear", "look2hear.layers", "look2hear.models"):
|
|
76
|
+
sys.modules[p] = types.ModuleType(p)
|
|
77
|
+
sys.modules[p].__path__ = []
|
|
78
|
+
sys.dont_write_bytecode = True
|
|
79
|
+
layers = sys.modules["look2hear.layers"]
|
|
80
|
+
layers.activations = module("look2hear.layers.activations", src / "activations.py")
|
|
81
|
+
layers.normalizations = module("look2hear.layers.normalizations", src / "normalizations.py")
|
|
82
|
+
base = types.ModuleType("look2hear.models.base_model")
|
|
83
|
+
|
|
84
|
+
class BaseModel(torch.nn.Module):
|
|
85
|
+
def __init__(self, sample_rate, in_chan=1):
|
|
86
|
+
super().__init__()
|
|
87
|
+
|
|
88
|
+
base.BaseModel = BaseModel
|
|
89
|
+
sys.modules["look2hear.models.base_model"] = base
|
|
90
|
+
tiger = module("look2hear.models.tiger_dnr", src / "tiger_dnr.py")
|
|
91
|
+
model = tiger.TIGERDNR(**json.loads((src / "config.json").read_text()))
|
|
92
|
+
model.load_state_dict(load_file(str(src / "model.safetensors")))
|
|
93
|
+
return model.eval(), tiger
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def aap(x, output_size):
|
|
97
|
+
"""F.adaptive_avg_pool1d, windows floor(i L / n) to ceil((i + 1) L / n) as torch's, by a cumulative sum read at the
|
|
98
|
+
windows' ends"""
|
|
99
|
+
import torch
|
|
100
|
+
import torch.nn.functional as F
|
|
101
|
+
|
|
102
|
+
L, n = int(x.shape[-1]), int(output_size[0] if isinstance(output_size, (list, tuple)) else output_size)
|
|
103
|
+
a = torch.tensor([(i * L) // n for i in range(n)])
|
|
104
|
+
b = torch.tensor([-((-(i + 1) * L) // n) for i in range(n)])
|
|
105
|
+
c = F.pad(torch.cumsum(x, -1), (1, 0))
|
|
106
|
+
return (c.index_select(-1, b) - c.index_select(-1, a)) / (b - a).to(x.dtype)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def uconv(self, x):
|
|
110
|
+
"""UConvBlock.forward, its global sum started at the first pooled level (0 + a is a)"""
|
|
111
|
+
residual = x
|
|
112
|
+
output = [self.spp_dw[0](self.proj_1x1(x))]
|
|
113
|
+
for k in range(1, self.depth):
|
|
114
|
+
output.append(self.spp_dw[k](output[-1]))
|
|
115
|
+
g = None
|
|
116
|
+
for fea in output:
|
|
117
|
+
p = aap(fea, output[-1].shape[-1])
|
|
118
|
+
g = p if g is None else g + p
|
|
119
|
+
g = self.globalatt(g)
|
|
120
|
+
fused = [self.loc_glo_fus[i](output[i], g) for i in range(self.depth)]
|
|
121
|
+
expanded = None
|
|
122
|
+
for i in range(self.depth - 2, -1, -1):
|
|
123
|
+
expanded = self.last_layer[i](fused[i], fused[i - 1] if i == self.depth - 2 else expanded)
|
|
124
|
+
return self.res_conv(expanded) + residual
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def group_norm(mod):
|
|
128
|
+
"""GroupNorm(1, C), its mean and variance reduced one axis at a time"""
|
|
129
|
+
import torch
|
|
130
|
+
|
|
131
|
+
def mean(x):
|
|
132
|
+
for d in range(x.dim() - 1, 0, -1):
|
|
133
|
+
x = x.mean(d, keepdim=True)
|
|
134
|
+
return x
|
|
135
|
+
|
|
136
|
+
def forward(x):
|
|
137
|
+
mu = mean(x)
|
|
138
|
+
var = mean((x - mu) ** 2)
|
|
139
|
+
shape = (1, -1) + (1,) * (x.dim() - 2)
|
|
140
|
+
return (x - mu) / torch.sqrt(var + mod.eps) * mod.weight.view(shape) + mod.bias.view(shape)
|
|
141
|
+
|
|
142
|
+
return forward
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def stft(x):
|
|
146
|
+
"""TIGER.forward's STFT: (B, L) -> (B, 2, F, T)"""
|
|
147
|
+
import torch
|
|
148
|
+
|
|
149
|
+
z = torch.stft(x, N_FFT, HOP, window=torch.hann_window(N_FFT), return_complex=True)
|
|
150
|
+
return torch.stack([z.real, z.imag], 1)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def istft(y, length):
|
|
154
|
+
"""(B, 2·S, F, T) -> (B, S, length), as TIGER.forward's iSTFT"""
|
|
155
|
+
import torch
|
|
156
|
+
|
|
157
|
+
B, C, F, T = y.shape
|
|
158
|
+
z = torch.complex(y[:, 0::2], y[:, 1::2]).reshape(-1, F, T)
|
|
159
|
+
return torch.istft(z, N_FFT, HOP, window=torch.hann_window(N_FFT), length=length).reshape(B, C // 2, length)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class Core:
|
|
163
|
+
"""The three TIGER.forward between their STFT and iSTFT, each its kept source."""
|
|
164
|
+
|
|
165
|
+
@staticmethod
|
|
166
|
+
def build(model, tiger):
|
|
167
|
+
import torch
|
|
168
|
+
import torch.nn as nn
|
|
169
|
+
import torch.nn.functional as F
|
|
170
|
+
|
|
171
|
+
# the patches, on the module the model's code reads them from
|
|
172
|
+
tiger.F = types.SimpleNamespace(**{k: getattr(F, k) for k in dir(F) if not k.startswith("_")}, )
|
|
173
|
+
tiger.F.adaptive_avg_pool1d = aap
|
|
174
|
+
tiger.UConvBlock.forward = uconv
|
|
175
|
+
for m in model.modules():
|
|
176
|
+
if isinstance(m, nn.GroupNorm) and m.num_groups == 1:
|
|
177
|
+
m.forward = group_norm(m)
|
|
178
|
+
|
|
179
|
+
def one(t, src, spec):
|
|
180
|
+
B, _, _, T = spec.shape
|
|
181
|
+
re, im = spec[:, 0], spec[:, 1]
|
|
182
|
+
feats, bands, i0 = [], [], 0
|
|
183
|
+
for i, bw in enumerate(t.band_width):
|
|
184
|
+
bw = int(bw)
|
|
185
|
+
bands.append((i0, bw))
|
|
186
|
+
feats.append(t.BN[i](spec[:, :, i0:i0 + bw].reshape(B, bw * 2, T)))
|
|
187
|
+
i0 += bw
|
|
188
|
+
x = t.separator(torch.stack(feats, 1)).view(B, t.nband, t.feature_dim, T)
|
|
189
|
+
outr, outi = [], []
|
|
190
|
+
for i, (i0, bw) in enumerate(bands):
|
|
191
|
+
o = t.mask[i](x[:, i]).view(B, 2, 2, t.num_output, bw, T)
|
|
192
|
+
mk = o[:, 0] * torch.sigmoid(o[:, 1])
|
|
193
|
+
mr, mi = mk[:, 0], mk[:, 1]
|
|
194
|
+
mr = (mr - (mr.sum(1, keepdim=True) - 1) / t.num_output)[:, src]
|
|
195
|
+
mi = (mi - mi.sum(1, keepdim=True) / t.num_output)[:, src]
|
|
196
|
+
r, j = re[:, i0:i0 + bw], im[:, i0:i0 + bw]
|
|
197
|
+
outr.append(r * mr - j * mi)
|
|
198
|
+
outi.append(r * mi + j * mr)
|
|
199
|
+
return torch.stack([torch.cat(outr, 1), torch.cat(outi, 1)], 1)
|
|
200
|
+
|
|
201
|
+
class C(nn.Module):
|
|
202
|
+
def __init__(self, m):
|
|
203
|
+
super().__init__()
|
|
204
|
+
self.m = m
|
|
205
|
+
|
|
206
|
+
def forward(self, spec):
|
|
207
|
+
return torch.cat([one(getattr(self.m, name), src, spec) for name, src in KEEP], 1)
|
|
208
|
+
|
|
209
|
+
return C(model).eval()
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def export(core, path, opset):
|
|
213
|
+
import torch
|
|
214
|
+
|
|
215
|
+
spec = stft(0.1 * torch.randn(1, SEGMENT))
|
|
216
|
+
with torch.no_grad():
|
|
217
|
+
torch.onnx.export(core, (spec,), str(path), input_names=["mix_spec"], output_names=["stems_spec"],
|
|
218
|
+
opset_version=opset, dynamo=False)
|
|
219
|
+
print(f"wrote {path} ({path.stat().st_size / 1e6:.1f} MB), segment {SEGMENT} samples, spec {list(spec.shape)}")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def session(path):
|
|
223
|
+
import onnxruntime as ort
|
|
224
|
+
|
|
225
|
+
o = ort.SessionOptions()
|
|
226
|
+
o.enable_cpu_mem_arena = False
|
|
227
|
+
o.enable_mem_pattern = False
|
|
228
|
+
return ort.InferenceSession(str(path), o, providers=["CPUExecutionProvider"])
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def verify(model, path, tol):
|
|
232
|
+
"""One segment: the graph's sources through the iSTFT against each TIGER.forward's kept source (unpatched)."""
|
|
233
|
+
import math
|
|
234
|
+
import numpy as np
|
|
235
|
+
import torch
|
|
236
|
+
|
|
237
|
+
s = session(path)
|
|
238
|
+
t = torch.arange(SEGMENT) / RATE
|
|
239
|
+
x = (0.2 * sum(torch.sin(2 * math.pi * f * t) / k for k, f in enumerate((110, 220, 440, 880), 1)) + 0.05 * torch.randn(SEGMENT))[None]
|
|
240
|
+
got = istft(torch.from_numpy(s.run(None, {"mix_spec": stft(x).numpy()})[0]), SEGMENT).numpy()
|
|
241
|
+
with torch.no_grad():
|
|
242
|
+
want = np.stack([getattr(model, name)(x)[:, src].numpy() for name, src in KEEP], 1)
|
|
243
|
+
rel = np.abs(got - want).max() / max(np.abs(want).max(), 1e-12)
|
|
244
|
+
print(f" verify {path.name}: max|diff|/max|y| = {rel:.2e} " + ("OK" if rel < tol else "FAIL"))
|
|
245
|
+
if rel >= tol:
|
|
246
|
+
raise SystemExit(f"{path}: verification failed ({rel:.2e} >= {tol})")
|
|
247
|
+
return s
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def reference(model, s, out_dir):
|
|
251
|
+
"""upstream's wav_chunk_inference over reference.py's 20 s mix, each channel apart, its segments through the graph"""
|
|
252
|
+
import torch
|
|
253
|
+
|
|
254
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
255
|
+
import reference as ref
|
|
256
|
+
|
|
257
|
+
def graph(k):
|
|
258
|
+
# TIGER.forward's contract for wav_chunk_inference: (B, L) -> (B, 3, L), the kept source at its index
|
|
259
|
+
def run(seg):
|
|
260
|
+
y = istft(torch.from_numpy(s.run(None, {"mix_spec": stft(seg.reshape(-1, seg.shape[-1])).numpy()})[0]), seg.shape[-1])
|
|
261
|
+
out = torch.zeros(y.shape[0], 3, y.shape[-1])
|
|
262
|
+
out[:, KEEP[k][1]] = y[:, k]
|
|
263
|
+
return out
|
|
264
|
+
return run
|
|
265
|
+
|
|
266
|
+
def run(mix):
|
|
267
|
+
with torch.no_grad():
|
|
268
|
+
x = torch.from_numpy(mix)[:, None] # each channel a batch of one
|
|
269
|
+
out = {}
|
|
270
|
+
for k, name in enumerate(("dialogue", "music", "effects")):
|
|
271
|
+
y = torch.stack([model.wav_chunk_inference(graph(k), x[c:c + 1])[KEEP[k][1], 0] for c in range(len(mix))])
|
|
272
|
+
out[name] = y.numpy()
|
|
273
|
+
return out
|
|
274
|
+
|
|
275
|
+
ref.write(out_dir, run, 20.0)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def main():
|
|
279
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
280
|
+
ap.add_argument("--out-dir", default=None, help="default: $AUDIO_NEURAL_CACHE/tiger or ~/.cache/audiojs/neural/tiger")
|
|
281
|
+
ap.add_argument("--opset", type=int, default=17)
|
|
282
|
+
ap.add_argument("--verify", action="store_true", help="compare with TIGER.forward; write the reference separation")
|
|
283
|
+
args = ap.parse_args()
|
|
284
|
+
|
|
285
|
+
cache = Path(os.environ.get("AUDIO_NEURAL_CACHE") or Path.home() / ".cache" / "audiojs" / "neural")
|
|
286
|
+
out_dir = Path(args.out_dir) if args.out_dir else cache / "tiger"
|
|
287
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
288
|
+
src = out_dir / "src"
|
|
289
|
+
src.mkdir(exist_ok=True)
|
|
290
|
+
try:
|
|
291
|
+
model, tiger = load(src)
|
|
292
|
+
ref_model, _ = load(src) if args.verify else (None, None)
|
|
293
|
+
except ImportError as e:
|
|
294
|
+
raise SystemExit(f"torch and safetensors are required: pip install torch onnx onnxruntime safetensors ({e})")
|
|
295
|
+
path = out_dir / "tiger.onnx"
|
|
296
|
+
export(Core.build(model, tiger), path, args.opset)
|
|
297
|
+
if args.verify:
|
|
298
|
+
s = verify(ref_model, path, 1e-4)
|
|
299
|
+
reference(ref_model, s, out_dir)
|
|
300
|
+
print(f"done: tiger in {out_dir}")
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
if __name__ == "__main__":
|
|
304
|
+
sys.exit(main())
|
package/scripts/reference.py
CHANGED
|
@@ -25,9 +25,48 @@ def test_mix(seconds=9.0, rate=RATE):
|
|
|
25
25
|
return np.stack([voice + 1.1 * bass + drums + 0.7 * pad, voice + 0.9 * bass + drums + 1.3 * pad]).astype(np.float32)
|
|
26
26
|
|
|
27
27
|
|
|
28
|
-
def
|
|
28
|
+
def normalized(mix, run):
|
|
29
|
+
"""run() on the mix less its mean over its deviation (of the channels' mean, ddof 1, + 1e-8), its output scaled
|
|
30
|
+
back, the mean added to no source (separate.js's separateComplex)."""
|
|
31
|
+
mono = mix.astype(np.float64).mean(0)
|
|
32
|
+
mean, std = mono.mean(), mono.std(ddof=1) + 1e-8
|
|
33
|
+
return run(((mix - mean) / std).astype(np.float32)) * std
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def chunked(mix, run, L, step=0.25):
|
|
37
|
+
"""ZFTurbo/Music-Source-Separation-Training's demix() (utils/model_utils.py, generic mode) over run(segment
|
|
38
|
+
(C, L) float32) -> (S, C, L): segments every step·L until one reaches the end, linear fades of L/10 (the first
|
|
39
|
+
segment not in, the last not out), the input reflected L - step out on each side when longer than
|
|
40
|
+
2 (L - step), a segment past the end reflected out when more than half of it is input, else zero-padded;
|
|
41
|
+
float64 sums (separate.js's separateComplex). -> (S, C, N)"""
|
|
42
|
+
C, N = mix.shape
|
|
43
|
+
hop = max(1, int(L * step))
|
|
44
|
+
border = L - hop
|
|
45
|
+
wide = N > 2 * border and border > 0
|
|
46
|
+
x = np.pad(mix, ((0, 0), (border, border)), mode="reflect") if wide else mix
|
|
47
|
+
M, fade = x.shape[1], L // 10
|
|
48
|
+
starts = [k * hop for k in range(max(1, -(-(M - L) // hop) + 1))] # until one reaches the end
|
|
49
|
+
acc, wsum = None, np.zeros(M)
|
|
50
|
+
for k, s in enumerate(starts):
|
|
51
|
+
part = x[:, s:s + L]
|
|
52
|
+
n = part.shape[1]
|
|
53
|
+
part = np.pad(part, ((0, 0), (0, L - n)), mode="reflect" if n > L // 2 else "constant")
|
|
54
|
+
y = np.asarray(run(part.astype(np.float32)), dtype=np.float64)
|
|
55
|
+
w = np.ones(L)
|
|
56
|
+
if k > 0:
|
|
57
|
+
w[:fade] = np.linspace(0, 1, fade)
|
|
58
|
+
if k < len(starts) - 1:
|
|
59
|
+
w[L - fade:] = np.linspace(1, 0, fade)
|
|
60
|
+
acc = np.zeros((y.shape[0], C, M)) if acc is None else acc
|
|
61
|
+
acc[..., s:s + n] += y[..., :n] * w[:n]
|
|
62
|
+
wsum[s:s + n] += w[:n]
|
|
63
|
+
out = acc / np.where(wsum > 0, wsum, 1)
|
|
64
|
+
return out[..., border:border + N] if wide else out
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def write(out_dir, separate, seconds=9.0):
|
|
29
68
|
"""separate(mix (2, N) float32) -> { target: (2, N) array }"""
|
|
30
|
-
mix = test_mix()
|
|
69
|
+
mix = test_mix(seconds)
|
|
31
70
|
mix.tofile(out_dir / "test.f32")
|
|
32
71
|
for name, x in separate(mix).items():
|
|
33
72
|
np.asarray(x, dtype=np.float32).tofile(out_dir / f"test.{name}.f32")
|