@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,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: float16 weights, float32 compute, scripts/compact.py's), 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 compute (scripts/compact.py --as fp16)."""
191
+ sys.path.insert(0, str(Path(__file__).parent))
192
+ import compact
193
+
194
+ compact.compact(path, out, "fp16")
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 compute)")
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())
@@ -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 write(out_dir, separate):
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")