@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/separate.js ADDED
@@ -0,0 +1,702 @@
1
+ // Source separation (stems) through @audio/neural-runtime's ONNX adapter. Three model families:
2
+ //
3
+ // 'openunmix' | 'mask': spectrogram models of the Open-Unmix class (Stöter, Uhlich, Liutkus,
4
+ // Mitsufuji, "Open-Unmix - A Reference Implementation for Music Source Separation", JOSS 2019).
5
+ // STFT (4096/1024, Hann, center=True, torch.stft-compatible) → magnitude → one ONNX run per
6
+ // target → multichannel Wiener EM in 300-frame windows (Duong, Vincent, Gribonval,
7
+ // "Under-determined reverberant audio source separation using a full-rank spatial covariance
8
+ // model", IEEE TASLP 2010; algorithm and defaults from sigsep/norbert and open-unmix-pytorch's
9
+ // filtering.py and model.Separator) → iSTFT.
10
+ // 'hybrid': Hybrid Transformer Demucs (Rouard, Massa, Défossez, "Hybrid Transformers for Music
11
+ // Source Separation", ICASSP 2023) with its STFT and iSTFT moved out of the graph, the split
12
+ // of sevagh/demucs.onnx. 7.8 s segments overlap-added as demucs.apply.apply_model does; each
13
+ // runs as waveform + complex-as-channels spectrogram in, frequency- and time-branch estimates
14
+ // out, summed after the iSTFT.
15
+ // 'waveform': any [1, C, N] → [1, S, C, N] graph.
16
+ //
17
+ // Long inputs run in overlapping chunks, so memory stays bounded by the chunk, not the file.
18
+
19
+ import { fft, ifft } from 'fourier-transform'
20
+ import resampleSinc from '@audio/resample-sinc'
21
+ import { load as neuralLoad, tensor } from '@audio/neural-runtime'
22
+
23
+ const PI2 = Math.PI * 2
24
+ const isNode = typeof process !== 'undefined' && !!process.versions?.node
25
+
26
+ // ---------------------------------------------------------------- STFT/iSTFT
27
+
28
+ const hannCache = new Map()
29
+ function hann(n) {
30
+ let w = hannCache.get(n)
31
+ if (!w) {
32
+ w = new Float64Array(n)
33
+ for (let i = 0; i < n; i++) w[i] = 0.5 - 0.5 * Math.cos(PI2 * i / n)
34
+ hannCache.set(n, w)
35
+ }
36
+ return w
37
+ }
38
+
39
+ // numpy/torch 'reflect' pad index: mirrors without repeating the edge sample, period
40
+ // 2(n-1); any pad width, even wider than the signal.
41
+ function reflectIndex(i, n) {
42
+ if (n === 1) return 0
43
+ let period = 2 * (n - 1)
44
+ i = ((i % period) + period) % period
45
+ return i < n ? i : period - i
46
+ }
47
+
48
+ // Frames as rows of `bins` values: views into one flat frame-major buffer.
49
+ function rows(flat, bins) {
50
+ let T = flat.length / bins, r = new Array(T)
51
+ for (let t = 0; t < T; t++) r[t] = flat.subarray(t * bins, (t + 1) * bins)
52
+ return r
53
+ }
54
+
55
+ // Flat complex STFT: re, im = Float64Array(T·bins), frame t at [t·bins, (t+1)·bins).
56
+ function stftFlat(x, n, hop, win, center) {
57
+ let N = x.length, bins = (n >> 1) + 1, pad = center ? n >> 1 : 0
58
+ let T = center ? 1 + Math.floor(N / hop) : (N < n ? 0 : 1 + Math.floor((N - n) / hop))
59
+ let re = new Float64Array(T * bins), im = new Float64Array(T * bins), frame = new Float64Array(n)
60
+ for (let m = 0; m < T; m++) {
61
+ let pos = m * hop - pad
62
+ if (pos < 0 || pos + n > N) for (let i = 0; i < n; i++) frame[i] = (center ? x[reflectIndex(pos + i, N)] : x[pos + i] ?? 0) * win[i]
63
+ else for (let i = 0; i < n; i++) frame[i] = x[pos + i] * win[i]
64
+ let [r, j] = fft(frame) // internal buffers, copied out before the next call
65
+ re.set(r, m * bins); im.set(j, m * bins)
66
+ }
67
+ return { re, im, T, bins }
68
+ }
69
+
70
+ // stft(x, opts): mono complex STFT, torch.stft(center=True, pad_mode='reflect') compatible:
71
+ // 1 + floor(N/hop) frames, whatever n (the n/2 reflect pad on each side cancels out).
72
+ export function stft(x, opts = {}) {
73
+ let n = opts.n ?? 4096, hop = opts.hop ?? 1024, center = opts.center ?? true
74
+ let s = stftFlat(x, n, hop, opts.window ?? hann(n), center)
75
+ return { re: rows(s.re, s.bins), im: rows(s.im, s.bins), n, hop, bins: s.bins, center }
76
+ }
77
+
78
+ // Weighted overlap-add of frame rows into out from frame t0: the synthesis half of istft,
79
+ // callable in consecutive batches; olaFinish divides by the window envelope once.
80
+ function ola(re, im, out, t0, n, hop, win) {
81
+ for (let m = 0; m < re.length; m++) {
82
+ let f = ifft(re[m], im[m]) // internal buffer, consumed before the next call
83
+ for (let i = 0, pos = (t0 + m) * hop; i < n; i++) out[pos + i] += f[i] * win[i]
84
+ }
85
+ }
86
+
87
+ // Squared-window envelope of T frames: the WOLA denominator (torch.istft's window envelope).
88
+ function envelope(T, n, hop, win) {
89
+ let env = new Float64Array(T > 0 ? (T - 1) * hop + n : 0)
90
+ for (let m = 0; m < T; m++) for (let i = 0; i < n; i++) env[m * hop + i] += win[i] * win[i]
91
+ return env
92
+ }
93
+
94
+ // Divide by the envelope, drop the center padding, fit to `length`.
95
+ function olaFinish(out, env, n, center, length) {
96
+ let pad = center ? n >> 1 : 0
97
+ length ??= Math.max(0, out.length - 2 * pad)
98
+ let result = new Float32Array(length)
99
+ for (let i = 0, j = pad; i < length && j < out.length; i++, j++) if (env[j] > 1e-8) result[i] = out[j] / env[j]
100
+ return result
101
+ }
102
+
103
+ // istft(frames, opts): exact inverse of stft by weighted overlap-add with squared-window
104
+ // normalization (WOLA), as torch.istft does: perfect reconstruction when the squared window
105
+ // satisfies NOLA (Hann at 75% overlap does). `length` crops or pads, as torch.istft's.
106
+ export function istft(fr, opts = {}) {
107
+ let { re, im, n, hop, center = true } = fr
108
+ let win = opts.window ?? hann(n), T = re.length
109
+ let out = new Float64Array(T > 0 ? (T - 1) * hop + n : 0)
110
+ ola(re, im, out, 0, n, hop, win)
111
+ return olaFinish(out, envelope(T, n, hop, win), n, center, opts.length)
112
+ }
113
+
114
+ // ------------------------------------------------------------- Wiener filter
115
+
116
+ // wienerFilter(mixStft, estimates, opts): norbert's algorithm (Liutkus & Stöter,
117
+ // github.com/sigsep/norbert) with open-unmix-pytorch's defaults (filtering.py: eps=1e-10,
118
+ // softmask=False, scale_factor=10). One EM window: the spatial covariances average over all
119
+ // given frames (separate() windows by 300 frames, as open-unmix's Separator does).
120
+ //
121
+ // mixStft: per-channel stft() output.
122
+ // estimates: { [target]: magnitude[channel][frame] } (Float64Array(bins) per frame); a
123
+ // single-channel estimate broadcasts to all mix channels (norbert's convention).
124
+ // opts.iterations (1): EM steps; 0 returns the initial estimate ("raw masks").
125
+ // opts.softmask (false): true = ratio mask, summing to the mixture by construction; false =
126
+ // target magnitude with the mixture's phase (open-unmix's recommendation).
127
+ // opts.residual (false): appends 'residual' = mixture minus the other targets, before EM.
128
+ // opts.eps (1e-10): regularization floor and division guard.
129
+ //
130
+ // Returns { [target]: per-channel { re, im, n, hop, bins, center } }, ready for istft().
131
+ export function wienerFilter(mixStft, estimates, opts = {}) {
132
+ let C = mixStft.length, T = mixStft[0].re.length, bins = mixStft[0].bins
133
+ let names = Object.keys(estimates)
134
+ if (!names.length) throw new Error('wienerFilter: estimates has no targets')
135
+ let flat = rs => { let a = new Float64Array(T * bins); rs.forEach((r, t) => a.set(r, t * bins)); return a }
136
+ let V = names.map(name => {
137
+ let e = estimates[name]
138
+ if (e.length !== C && e.length !== 1) throw new Error(`wienerFilter: estimate '${name}' has ${e.length} channel(s), mixture has ${C}`)
139
+ return Array.from({ length: C }, (_, c) => flat(e[e.length === 1 ? 0 : c]))
140
+ })
141
+ let { Yr, Yi } = wiener(mixStft.map(s => flat(s.re)), mixStft.map(s => flat(s.im)), V, T, bins, opts)
142
+ let { n, hop, center } = mixStft[0], result = {}
143
+ Yr.forEach((y, j) => { result[names[j] ?? 'residual'] = y.map((re, c) => ({ re: rows(re, bins), im: rows(Yi[j][c], bins), n, hop, bins, center })) })
144
+ return result
145
+ }
146
+
147
+ // The filter on flat frame-major spectra (T·bins per channel): X mixture, V[j][c] target
148
+ // magnitudes. Returns Y[j][c], the residual (when asked) last. EM runs on the mixture and
149
+ // estimates divided by max(1, max|x| / scaleFactor), filtering.py's guard for numerical
150
+ // stability: the initial estimate is built scaled, the mixture scales on the fly, and the
151
+ // last EM pass writes the stems back at full scale.
152
+ function wiener(Xr, Xi, V, T, bins, { iterations = 1, softmask = false, residual = false, eps = 1e-10, scaleFactor = 10 } = {}) {
153
+ let C = Xr.length, S0 = V.length, S = S0 + (residual ? 1 : 0), TB = T * bins
154
+ let k = 1
155
+ if (iterations > 0) {
156
+ let maxPow = 0
157
+ for (let c = 0; c < C; c++) { let xr = Xr[c], xi = Xi[c]; for (let i = 0; i < TB; i++) { let p = xr[i] * xr[i] + xi[i] * xi[i]; if (p > maxPow) maxPow = p } }
158
+ k = 1 / Math.max(1, Math.sqrt(maxPow) / scaleFactor)
159
+ }
160
+ let Yr = Array.from({ length: S }, () => Array.from({ length: C }, () => new Float64Array(TB)))
161
+ let Yi = Array.from({ length: S }, () => Array.from({ length: C }, () => new Float64Array(TB)))
162
+ let a = new Float64Array(TB), b = new Float64Array(TB)
163
+ for (let c = 0; c < C; c++) {
164
+ let xr = Xr[c], xi = Xi[c]
165
+ if (softmask) {
166
+ // ratio mask: a = eps + Σ_j v_j
167
+ a.fill(eps)
168
+ for (let j = 0; j < S0; j++) { let v = V[j][c]; for (let i = 0; i < TB; i++) a[i] += v[i] }
169
+ for (let j = 0; j < S0; j++) {
170
+ let v = V[j][c], yr = Yr[j][c], yi = Yi[j][c]
171
+ for (let i = 0; i < TB; i++) { let r = v[i] / a[i] * k; yr[i] = r * xr[i]; yi[i] = r * xi[i] }
172
+ }
173
+ } else {
174
+ // the mixture's phase as (cos, sin) in a, b; openunmix's atan2 maps 0/0 to angle 0
175
+ for (let i = 0; i < TB; i++) {
176
+ let m = Math.sqrt(xr[i] * xr[i] + xi[i] * xi[i])
177
+ a[i] = (m > 0 ? xr[i] / m : 1) * k; b[i] = m > 0 ? xi[i] / m * k : 0
178
+ }
179
+ for (let j = 0; j < S0; j++) {
180
+ let v = V[j][c], yr = Yr[j][c], yi = Yi[j][c]
181
+ for (let i = 0; i < TB; i++) { yr[i] = v[i] * a[i]; yi[i] = v[i] * b[i] }
182
+ }
183
+ }
184
+ if (residual) {
185
+ let rr = Yr[S0][c], ri = Yi[S0][c]
186
+ for (let i = 0; i < TB; i++) { rr[i] = xr[i] * k; ri[i] = xi[i] * k }
187
+ for (let j = 0; j < S0; j++) { let yr = Yr[j][c], yi = Yi[j][c]; for (let i = 0; i < TB; i++) { rr[i] -= yr[i]; ri[i] -= yi[i] } }
188
+ }
189
+ }
190
+ if (iterations > 0) em(Yr, Yi, Xr, Xi, T, bins, iterations, eps, k)
191
+ return { Yr, Yi }
192
+ }
193
+
194
+ // expectation_maximization, ported from norbert / openunmix's filtering.py: re-estimate each
195
+ // source's power spectral density v_j and spatial covariance R_j, rebuild the mixture
196
+ // covariance Cxx = Σ v_j R_j + √eps·I, apply the multichannel Wiener gain v_j R_j Cxx⁻¹.
197
+ // Applied as y_j = v_j R_j (Cxx⁻¹ x): the inverse times the mixture once per bin, then one
198
+ // C×C product per source, the same algebra as forming each gain first. R_j and Cxx are
199
+ // Hermitian (sums of y yᴴ), so stereo takes the closed-form 2×2 inverse; other channel counts
200
+ // a Gauss-Jordan inverse. Y arrives scaled by k; X is scaled by k where read.
201
+ function em(Yr, Yi, Xr, Xi, T, bins, iterations, eps, k) {
202
+ let S = Yr.length, C = Xr.length, TB = T * bins
203
+ let pairs = [] // upper triangle (c1 ≤ c2) of each C×C covariance
204
+ for (let c1 = 0; c1 < C; c1++) for (let c2 = c1; c2 < C; c2++) pairs.push([c1, c2])
205
+ let V = Array.from({ length: S }, () => new Float64Array(TB))
206
+ let Rr = Array.from({ length: S }, () => pairs.map(() => new Float64Array(bins)))
207
+ let Ri = Array.from({ length: S }, () => pairs.map(() => new Float64Array(bins)))
208
+ let w = new Float64Array(bins), sqrtEps = Math.sqrt(eps)
209
+
210
+ for (let iter = 0; iter < iterations; iter++) {
211
+ for (let j = 0; j < S; j++) {
212
+ // 1. v_j = mean over channels of |y_j|²
213
+ let v = V[j]
214
+ v.fill(0)
215
+ for (let c = 0; c < C; c++) { let yr = Yr[j][c], yi = Yi[j][c]; for (let i = 0; i < TB; i++) v[i] += yr[i] * yr[i] + yi[i] * yi[i] }
216
+ for (let i = 0; i < TB; i++) v[i] /= C
217
+ // 2. R_j = Σ_t y yᴴ / (eps + Σ_t v_j), per bin
218
+ w.fill(eps)
219
+ for (let o = 0; o < TB; o += bins) for (let f = 0; f < bins; f++) w[f] += v[o + f]
220
+ pairs.forEach(([c1, c2], p) => {
221
+ let ar = Yr[j][c1], ai = Yi[j][c1], br = Yr[j][c2], bi = Yi[j][c2], rr = Rr[j][p], ri = Ri[j][p]
222
+ rr.fill(0); ri.fill(0)
223
+ for (let o = 0; o < TB; o += bins) for (let f = 0, i = o; f < bins; f++, i++) {
224
+ rr[f] += ar[i] * br[i] + ai[i] * bi[i]
225
+ ri[f] += ai[i] * br[i] - ar[i] * bi[i]
226
+ }
227
+ for (let f = 0; f < bins; f++) { rr[f] /= w[f]; ri[f] /= w[f] }
228
+ })
229
+ }
230
+ // 3. per (frame, bin): w = Cxx⁻¹ x, y_j = v_j R_j w; the last pass writes at full scale
231
+ let out = iter === iterations - 1 ? 1 / k : 1
232
+ if (C === 2) apply2(Yr, Yi, Xr, Xi, V, Rr, Ri, TB, bins, sqrtEps, k, out)
233
+ else applyN(Yr, Yi, Xr, Xi, V, Rr, Ri, pairs, TB, bins, sqrtEps, k, out)
234
+ }
235
+ }
236
+
237
+ // Stereo: Cxx = [[a, b], [b̄, d]] with a, d real, inverse [[d, −b], [−b̄, a]] / (ad − |b|²).
238
+ // Sources interleave per bin (R as P[(f·S + j)·4 + k], v as VP[i·S + j], y as YP[(i·S + j)·4 + k]):
239
+ // one typed array per quantity instead of one per source, twice as fast.
240
+ function apply2(Yr, Yi, Xr, Xi, V, Rr, Ri, TB, bins, sqrtEps, xs, ys) {
241
+ let S = V.length, P = new Float64Array(bins * S * 4), VP = new Float64Array(TB * S), YP = new Float64Array(TB * S * 4)
242
+ for (let j = 0; j < S; j++) {
243
+ let r00 = Rr[j][0], r01r = Rr[j][1], r01i = Ri[j][1], r11 = Rr[j][2], v = V[j]
244
+ for (let f = 0, q = j * 4; f < bins; f++, q += S * 4) { P[q] = r00[f]; P[q + 1] = r01r[f]; P[q + 2] = r01i[f]; P[q + 3] = r11[f] }
245
+ for (let i = 0, q = j; i < TB; i++, q += S) VP[q] = v[i]
246
+ }
247
+ let [x0r, x1r] = Xr, [x0i, x1i] = Xi
248
+ for (let o = 0; o < TB; o += bins) for (let f = 0, i = o; f < bins; f++, i++) {
249
+ let a = sqrtEps, d = sqrtEps, br = 0, bi = 0, pb = f * S * 4, vb = i * S
250
+ for (let j = 0, q = pb; j < S; j++, q += 4) { let v = VP[vb + j]; a += v * P[q]; br += v * P[q + 1]; bi += v * P[q + 2]; d += v * P[q + 3] }
251
+ let det = a * d - br * br - bi * bi, k = det !== 0 ? 1 / det : 0
252
+ let xr0 = x0r[i] * xs, xi0 = x0i[i] * xs, xr1 = x1r[i] * xs, xi1 = x1i[i] * xs
253
+ let w0r = (d * xr0 - br * xr1 + bi * xi1) * k, w0i = (d * xi0 - br * xi1 - bi * xr1) * k
254
+ let w1r = (a * xr1 - br * xr0 - bi * xi0) * k, w1i = (a * xi1 - br * xi0 + bi * xr0) * k
255
+ for (let j = 0, q = pb, y = vb * 4; j < S; j++, q += 4, y += 4) {
256
+ let v = VP[vb + j] * ys, p = P[q], qr = P[q + 1], qi = P[q + 2], s = P[q + 3]
257
+ YP[y] = v * (p * w0r + qr * w1r - qi * w1i)
258
+ YP[y + 1] = v * (p * w0i + qr * w1i + qi * w1r)
259
+ YP[y + 2] = v * (qr * w0r + qi * w0i + s * w1r)
260
+ YP[y + 3] = v * (qr * w0i - qi * w0r + s * w1i)
261
+ }
262
+ }
263
+ for (let j = 0; j < S; j++) {
264
+ let y0r = Yr[j][0], y0i = Yi[j][0], y1r = Yr[j][1], y1i = Yi[j][1]
265
+ for (let i = 0, y = j * 4; i < TB; i++, y += S * 4) { y0r[i] = YP[y]; y0i[i] = YP[y + 1]; y1r[i] = YP[y + 2]; y1i[i] = YP[y + 3] }
266
+ }
267
+ }
268
+
269
+ // Any channel count: Cxx assembled from the Hermitian pairs, Gauss-Jordan inverse.
270
+ function applyN(Yr, Yi, Xr, Xi, V, Rr, Ri, pairs, TB, bins, sqrtEps, xs, ys) {
271
+ let S = V.length, C = Xr.length, CC = C * C
272
+ let cr = new Float64Array(CC), ci = new Float64Array(CC), ir = new Float64Array(CC), ii = new Float64Array(CC)
273
+ let sr = new Float64Array(2 * CC), si = new Float64Array(2 * CC), wr = new Float64Array(C), wi = new Float64Array(C)
274
+ let rr = new Float64Array(CC), ri = new Float64Array(CC)
275
+ let full = (j, f) => pairs.forEach(([c1, c2], p) => {
276
+ rr[c1 * C + c2] = Rr[j][p][f]; ri[c1 * C + c2] = Ri[j][p][f]
277
+ rr[c2 * C + c1] = Rr[j][p][f]; ri[c2 * C + c1] = -Ri[j][p][f]
278
+ })
279
+ for (let o = 0; o < TB; o += bins) for (let f = 0, i = o; f < bins; f++, i++) {
280
+ cr.fill(0); ci.fill(0)
281
+ for (let j = 0; j < S; j++) { let v = V[j][i]; full(j, f); for (let k = 0; k < CC; k++) { cr[k] += v * rr[k]; ci[k] += v * ri[k] } }
282
+ for (let c = 0; c < C; c++) cr[c * C + c] += sqrtEps
283
+ complexInverse(cr, ci, ir, ii, C, sr, si)
284
+ for (let c1 = 0; c1 < C; c1++) {
285
+ let ar = 0, ai = 0
286
+ for (let c2 = 0; c2 < C; c2++) { let gr = ir[c1 * C + c2], gi = ii[c1 * C + c2], xr = Xr[c2][i] * xs, xi = Xi[c2][i] * xs; ar += gr * xr - gi * xi; ai += gr * xi + gi * xr }
287
+ wr[c1] = ar; wi[c1] = ai
288
+ }
289
+ for (let j = 0; j < S; j++) {
290
+ let v = V[j][i] * ys
291
+ full(j, f)
292
+ for (let c1 = 0; c1 < C; c1++) {
293
+ let ar = 0, ai = 0
294
+ for (let c2 = 0; c2 < C; c2++) { let gr = rr[c1 * C + c2], gi = ri[c1 * C + c2]; ar += gr * wr[c2] - gi * wi[c2]; ai += gr * wi[c2] + gi * wr[c2] }
295
+ Yr[j][c1][i] = v * ar; Yi[j][c1][i] = v * ai
296
+ }
297
+ }
298
+ }
299
+ }
300
+
301
+ // Complex Gauss-Jordan inverse with partial pivoting, n×n row-major; scratch re/im are n×2n.
302
+ function complexInverse(Are, Aim, outRe, outIm, n, re, im) {
303
+ let m = n * 2
304
+ for (let i = 0; i < n; i++) {
305
+ for (let j = 0; j < n; j++) { re[i * m + j] = Are[i * n + j]; im[i * m + j] = Aim[i * n + j] }
306
+ for (let j = n; j < m; j++) { re[i * m + j] = 0; im[i * m + j] = 0 }
307
+ re[i * m + n + i] = 1
308
+ }
309
+ for (let col = 0; col < n; col++) {
310
+ let piv = col, best = re[col * m + col] ** 2 + im[col * m + col] ** 2
311
+ for (let r = col + 1; r < n; r++) {
312
+ let mag = re[r * m + col] ** 2 + im[r * m + col] ** 2
313
+ if (mag > best) { best = mag; piv = r }
314
+ }
315
+ if (piv !== col) for (let k = 0; k < m; k++) {
316
+ let tr = re[col * m + k]; re[col * m + k] = re[piv * m + k]; re[piv * m + k] = tr
317
+ let ti = im[col * m + k]; im[col * m + k] = im[piv * m + k]; im[piv * m + k] = ti
318
+ }
319
+ let pr = re[col * m + col], pi = im[col * m + col]
320
+ let d = pr * pr + pi * pi || 1e-300
321
+ for (let k = 0; k < m; k++) {
322
+ let vr = re[col * m + k], vi = im[col * m + k]
323
+ re[col * m + k] = (vr * pr + vi * pi) / d
324
+ im[col * m + k] = (vi * pr - vr * pi) / d
325
+ }
326
+ for (let r = 0; r < n; r++) {
327
+ if (r === col) continue
328
+ let fr = re[r * m + col], fi = im[r * m + col]
329
+ if (fr === 0 && fi === 0) continue
330
+ for (let k = 0; k < m; k++) {
331
+ let vr = re[col * m + k], vi = im[col * m + k]
332
+ re[r * m + k] -= fr * vr - fi * vi
333
+ im[r * m + k] -= fr * vi + fi * vr
334
+ }
335
+ }
336
+ }
337
+ for (let i = 0; i < n; i++) for (let j = 0; j < n; j++) { outRe[i * n + j] = re[i * m + n + j]; outIm[i * n + j] = im[i * m + n + j] }
338
+ }
339
+
340
+ // ------------------------------------------------------------------ models
341
+
342
+ // Demucs source order (demucs/pretrained.py SOURCES), the S axis of its outputs.
343
+ const DEMUCS = ['drums', 'bass', 'other', 'vocals']
344
+
345
+ // Presets: files are <weights>/<name>/<target>.onnx (one graph per target) or
346
+ // <weights>/<name>/<name>.onnx, as scripts/export-*.py write them.
347
+ export const models = {
348
+ umxhq: { modelType: 'openunmix', sampleRate: 44100, targets: ['vocals', 'drums', 'bass', 'other'], perTarget: true },
349
+ htdemucs: { modelType: 'hybrid', sampleRate: 44100, targets: DEMUCS },
350
+ htdemucs_ft: { modelType: 'hybrid', sampleRate: 44100, targets: DEMUCS, perTarget: true },
351
+ }
352
+
353
+ async function cacheDir() {
354
+ if (process.env.AUDIO_NEURAL_CACHE) return process.env.AUDIO_NEURAL_CACHE
355
+ let [path, os] = await Promise.all([import('node:path'), import('node:os')])
356
+ return path.join(os.homedir(), '.cache', 'audiojs', 'neural')
357
+ }
358
+
359
+ // Base URL of a preset's files: opts.weights (URL or, in Node, a directory), else the neural cache.
360
+ async function weightsBase(opts, name) {
361
+ let w = opts.weights
362
+ if (w == null && !isNode) throw new Error(`neural-separate: model '${name}' needs opts.weights in the browser: the URL its files are served from (${name}/<file>.onnx)`)
363
+ if (w == null) w = await cacheDir()
364
+ // a URL scheme has two characters at least, so a Windows drive (C:\) stays a path
365
+ if (/^[a-z][a-z0-9+.-]+:/i.test(w)) return w.endsWith('/') ? w : w + '/'
366
+ if (!isNode) throw new Error(`neural-separate: opts.weights must be a URL in the browser, got '${w}'`)
367
+ let { pathToFileURL } = await import('node:url')
368
+ return pathToFileURL(w.endsWith('/') ? w : w + '/').href
369
+ }
370
+
371
+ // Weights produced locally must exist before a session loads them: name the missing file and
372
+ // how to make it, rather than surfacing ENOENT from deep inside the runtime.
373
+ async function checkLocal(graphs, name) {
374
+ let files = graphs.map(g => g.spec).filter(s => typeof s === 'string' && s.startsWith('file:'))
375
+ if (!files.length) return
376
+ let [{ existsSync }, { fileURLToPath }] = await Promise.all([import('node:fs'), import('node:url')])
377
+ let missing = files.map(f => fileURLToPath(f)).filter(f => !existsSync(f))
378
+ if (!missing.length) return
379
+ let script = models[name].modelType === 'hybrid' ? 'export-htdemucs.py' : 'export-openunmix.py'
380
+ throw new Error(`neural-separate: ${name} weights not found: ${missing.join(', ')}. ` +
381
+ `Produce them with python3 node_modules/@audio/neural-separate/scripts/${script} --model ${name}, ` +
382
+ `or pass opts.weights: a URL or directory holding ${name}/<file>.onnx`)
383
+ }
384
+
385
+ // opts.model → { graphs: [{ spec, sources, keep }], modelType, sampleRate, targets }. A graph's
386
+ // output stacks `sources` on its S axis (size 1 for a single target, the same flat layout as no
387
+ // axis); `keep` are the sources it contributes.
388
+ async function resolveModel(opts) {
389
+ let model = opts.model
390
+ if (model == null) throw new Error('neural-separate: opts.model is required (preset name | url | bytes | {target: url, ...} | {url, targets})')
391
+ let want = opts.targets
392
+ let pick = names => want ? names.filter(n => want.includes(n)) : names
393
+
394
+ if (typeof model === 'string' && Object.hasOwn(models, model)) {
395
+ let p = models[model], base = await weightsBase(opts, model)
396
+ if (want) for (let t of want) if (!p.targets.includes(t)) throw new Error(`neural-separate: ${model} has no target '${t}' (has ${p.targets.join(', ')})`)
397
+ let targets = pick(p.targets)
398
+ let sources = p.modelType === 'hybrid' ? DEMUCS : null
399
+ let graphs = p.perTarget
400
+ ? targets.map(t => ({ spec: `${base}${model}/${t}.onnx`, sources: sources ?? [t], keep: [t] }))
401
+ : [{ spec: `${base}${model}/${model}.onnx`, sources: sources ?? p.targets, keep: targets }]
402
+ if (isNode) await checkLocal(graphs, model)
403
+ return { graphs, modelType: p.modelType, sampleRate: p.sampleRate, targets }
404
+ }
405
+
406
+ let graphs
407
+ if (typeof model === 'string' || model instanceof Uint8Array) graphs = [{ spec: model, sources: ['stem'], keep: ['stem'] }]
408
+ else if (model.url != null && Array.isArray(model.targets)) graphs = [{ spec: model.url, sources: model.targets, keep: pick(model.targets) }]
409
+ else {
410
+ let names = Object.keys(model)
411
+ if (!names.length) throw new Error('neural-separate: opts.model target map is empty')
412
+ graphs = pick(names).map(t => {
413
+ let g = model[t]
414
+ return g?.url != null && Array.isArray(g.targets) ? { spec: g.url, sources: g.targets, keep: [t] } : { spec: g, sources: [t], keep: [t] }
415
+ })
416
+ }
417
+ graphs = graphs.filter(g => g.keep.length)
418
+ if (!graphs.length) throw new Error(`neural-separate: none of targets ${want} is in the model`)
419
+ return { graphs, modelType: opts.modelType ?? 'openunmix', sampleRate: null, targets: graphs.flatMap(g => g.keep) }
420
+ }
421
+
422
+ // No pooled arena: one htdemucs segment peaks at 2.7 GB RSS in onnxruntime-node instead of 3.2 GB.
423
+ const sessionOptions = { enableCpuMemArena: false, enableMemPattern: false }
424
+
425
+ function loader(opts) {
426
+ return spec => opts.session ? opts.session(spec, opts) : neuralLoad(spec, { backend: opts.device, sessionOptions })
427
+ }
428
+
429
+ function outputOf(session, out, i = 0) {
430
+ let name = session.outputs?.[i]?.name
431
+ if (name && out[name]) return out[name]
432
+ let vals = Object.values(out)
433
+ if (!vals[i]) throw new Error(`neural-separate: model returned ${vals.length} output(s), expected at least ${i + 1}`)
434
+ return vals[i]
435
+ }
436
+
437
+ // ------------------------------------------------------------------ chunking
438
+
439
+ // Overlap-add over spans [start, start + len): run(start, len) → { [target]: channel arrays of
440
+ // len samples }; each span weighted by weight(k, len), the sum normalized by the summed weights.
441
+ async function overlapAdd(N, C, starts, size, weight, run, progress) {
442
+ let outputs = null, wsum = new Float64Array(N)
443
+ for (let i = 0; i < starts.length; i++) {
444
+ let start = starts[i], len = Math.min(size, N - start)
445
+ progress?.({ chunk: i + 1, totalChunks: starts.length })
446
+ let res = await run(start, len)
447
+ if (!outputs) {
448
+ outputs = {}
449
+ for (let name in res) outputs[name] = Array.from({ length: C }, () => new Float64Array(N))
450
+ }
451
+ let w = new Float64Array(len)
452
+ for (let k = 0; k < len; k++) { w[k] = weight(k, len); wsum[start + k] += w[k] }
453
+ for (let name in res) for (let c = 0; c < C; c++) {
454
+ let src = res[name][c], dst = outputs[name][c]
455
+ for (let k = 0; k < len; k++) dst[start + k] += src[k] * w[k]
456
+ }
457
+ }
458
+ let result = {}
459
+ for (let name in outputs) result[name] = outputs[name].map(ch => {
460
+ let out = new Float32Array(N)
461
+ for (let i = 0; i < N; i++) if (wsum[i] > 0) out[i] = ch[i] / wsum[i]
462
+ return out
463
+ })
464
+ return result
465
+ }
466
+
467
+ // Chunks of `chunk` seconds overlapping by `overlap`, linear crossfades (the spectral and
468
+ // waveform paths). One chunk when the input fits; the last runs on what remains.
469
+ function crossfadeChunks(N, rate, opts) {
470
+ let chunkSec = opts.chunk ?? 30, overlapSec = opts.overlap ?? 2
471
+ if (overlapSec >= chunkSec) throw new Error('neural-separate: opts.overlap must be smaller than opts.chunk')
472
+ let size = Math.max(1, Math.round(chunkSec * rate))
473
+ let ov = Math.max(0, Math.round(overlapSec * rate))
474
+ let starts = [0]
475
+ if (N > size) { starts = []; for (let s = 0; s < N; s += size - ov) { starts.push(s); if (s + size >= N) break } }
476
+ let weight = (k, len) => Math.min(1, (k + 0.5) / ov, (len - k - 0.5) / ov)
477
+ return { starts, size, weight: ov ? weight : () => 1 }
478
+ }
479
+
480
+ const slice = (channels, start, len) => channels.map(ch => ch.subarray(start, start + len))
481
+
482
+ // ------------------------------------------------------------------ spectral
483
+
484
+ // Magnitude tensor [1, C, F, T], T innermost (Open-Unmix's own layout), from flat spectra.
485
+ function packSpectral(mix, T, bins) {
486
+ let C = mix.length, data = new Float32Array(C * bins * T)
487
+ mix.forEach(({ re, im }, c) => {
488
+ for (let t = 0, base = c * bins * T; t < T; t++) for (let f = 0, i = t * bins; f < bins; f++, i++) data[base + f * T + t] = Math.sqrt(re[i] * re[i] + im[i] * im[i])
489
+ })
490
+ return tensor(data, [1, C, bins, T], 'float32')
491
+ }
492
+
493
+ // Frames [t0, t1) of one target's model output ([C, F, T] from `base`) per channel, flat
494
+ // frame-major; modelType 'mask' multiplies the [0, 1] mask by the mixture magnitude (the
495
+ // packed input, same layout).
496
+ function estimate(data, base, mag, C, T, bins, t0, t1, mask) {
497
+ return Array.from({ length: C }, (_, c) => {
498
+ let v = new Float64Array((t1 - t0) * bins), b = base + c * bins * T, m = c * bins * T
499
+ if (mask) for (let f = 0; f < bins; f++) for (let t = t0, i = f; t < t1; t++, i += bins) v[i] = data[b + f * T + t] * mag[m + f * T + t]
500
+ else for (let f = 0; f < bins; f++) for (let t = t0, i = f; t < t1; t++, i += bins) v[i] = data[b + f * T + t]
501
+ return v
502
+ })
503
+ }
504
+
505
+ async function separateSpectral(channels, rate, opts, graphs, modelType, load) {
506
+ let n = opts.n ?? 4096, hop = opts.hop ?? 1024, win = hann(n)
507
+ let iterations = opts.wiener ?? 1
508
+ let W = opts.wienerWindow ?? 300 // frames per EM window: openunmix Separator's wiener_win_len
509
+ let names = graphs.flatMap(g => g.keep)
510
+ // EM needs two sources: one target runs against the residual, open-unmix's residual=True
511
+ let residual = names.length === 1 && iterations > 0
512
+ let wopts = { iterations, softmask: opts.softmask ?? false, eps: opts.eps, residual }
513
+ let sessions = []
514
+ try {
515
+ for (let g of graphs) sessions.push(await load(g.spec))
516
+ let C = channels.length, N = channels[0].length
517
+ let { starts, size, weight } = crossfadeChunks(N, rate, opts)
518
+ return await overlapAdd(N, C, starts, size, weight, async (start, len) => {
519
+ let mix = slice(channels, start, len).map(x => stftFlat(x, n, hop, win, true))
520
+ let { T, bins } = mix[0]
521
+ let input = packSpectral(mix, T, bins)
522
+ let raw = {}
523
+ for (let i = 0; i < graphs.length; i++) {
524
+ let s = sessions[i], out = outputOf(s, await s.run({ [s.inputs?.[0]?.name ?? 'input']: input }))
525
+ for (let name of graphs[i].keep) raw[name] = { data: out.data, base: graphs[i].sources.indexOf(name) * C * bins * T }
526
+ }
527
+ let acc = names.map(() => Array.from({ length: C }, () => new Float64Array((T - 1) * hop + n)))
528
+ for (let t0 = 0; t0 < T; t0 += W) {
529
+ let t1 = Math.min(T, t0 + W), view = x => x.subarray(t0 * bins, t1 * bins)
530
+ let V = names.map(name => estimate(raw[name].data, raw[name].base, input.data, C, T, bins, t0, t1, modelType === 'mask'))
531
+ let { Yr, Yi } = wiener(mix.map(m => view(m.re)), mix.map(m => view(m.im)), V, t1 - t0, bins, wopts)
532
+ names.forEach((_, j) => { for (let c = 0; c < C; c++) ola(rows(Yr[j][c], bins), rows(Yi[j][c], bins), acc[j][c], t0, n, hop, win) })
533
+ }
534
+ let env = envelope(T, n, hop, win), res = {}
535
+ names.forEach((name, j) => { res[name] = acc[j].map(o => olaFinish(o, env, n, true, len)) })
536
+ return res
537
+ }, opts.progress)
538
+ } finally {
539
+ for (let s of sessions) s.free?.()
540
+ }
541
+ }
542
+
543
+ // ------------------------------------------------------------------ hybrid
544
+
545
+ // One segment through a demucs.onnx-contract graph: STFT and iSTFT as HTDemucs._spec,
546
+ // _magnitude, _mask and _ispec do them (demucs/htdemucs.py, spec.py: torch.stft with
547
+ // normalized=True, n_fft 4096, hop 1024; the Nyquist bin dropped; reflect re-padding by 3/4
548
+ // hop so frames align with the time branch). mix: C channels of L samples; returns the
549
+ // sources at indices `want` (of the graph's S), C channels each.
550
+ async function hybridSegment(session, mix, want, n, hop) {
551
+ let C = mix.length, L = mix[0].length, win = hann(n)
552
+ let le = Math.ceil(L / hop), pad = (hop >> 1) * 3, F = n >> 1, bins = F + 1, scale = 1 / Math.sqrt(n)
553
+ let spec = new Float32Array(C * 2 * F * le), wave = new Float32Array(C * L)
554
+ mix.forEach((x, c) => {
555
+ let padded = new Float64Array(le * hop + 2 * pad)
556
+ for (let i = 0; i < padded.length; i++) padded[i] = x[reflectIndex(i - pad, L)]
557
+ let { re, im } = stftFlat(padded, n, hop, win, true)
558
+ let zr = spec.subarray(2 * c * F * le), zi = spec.subarray((2 * c + 1) * F * le)
559
+ for (let f = 0, k = 0; f < F; f++) for (let t = 0, i = 2 * bins + f; t < le; t++, i += bins, k++) { zr[k] = re[i] * scale; zi[k] = im[i] * scale }
560
+ wave.set(x, c * L)
561
+ })
562
+ let inputs = session.inputs ?? [{ name: 'mix' }, { name: 'mix_spec' }]
563
+ let out = await session.run({ [inputs[0].name]: tensor(wave, [1, C, L], 'float32'), [inputs[1].name]: tensor(spec, [1, 2 * C, F, le], 'float32') })
564
+ let zs = outputOf(session, out, 0).data, xt = outputOf(session, out, 1).data
565
+
566
+ // per source and channel: zero Nyquist bin and two zero frames each side, iSTFT (× √n
567
+ // undoes normalized=True), crop the re-padding, add the time branch
568
+ let T = le + 4, len = hop * le + 2 * pad, unscale = Math.sqrt(n), env = envelope(T, n, hop, win)
569
+ return want.map(s => Array.from({ length: C }, (_, c) => {
570
+ let re = new Float64Array(T * bins), im = new Float64Array(T * bins), b = (s * 2 * C + 2 * c) * F * le
571
+ // read the [F, T] planes in order, write frames strided
572
+ for (let f = 0, k = b; f < F; f++) for (let t = 0, i = 2 * bins + f; t < le; t++, i += bins, k++) { re[i] = zs[k] * unscale; im[i] = zs[k + F * le] * unscale }
573
+ let out = new Float64Array((T - 1) * hop + n)
574
+ ola(rows(re, bins), rows(im, bins), out, 0, n, hop, win)
575
+ let x = olaFinish(out, env, n, true, len)
576
+ let y = new Float32Array(L), tb = (s * C + c) * L
577
+ for (let i = 0; i < L; i++) y[i] = x[pad + i] + xt[tb + i]
578
+ return y
579
+ }))
580
+ }
581
+
582
+ // demucs.apply.apply_model(split=True, overlap=0.25, shifts=0) with demucs.api's whole-input
583
+ // normalization: segments of L samples every floor(0.75 L), each centered in L with the
584
+ // neighbouring input as context (TensorChunk.padded), center-trimmed, overlap-added under a
585
+ // triangular window normalized by its sum.
586
+ async function separateHybrid(channels, rate, opts, graphs, load) {
587
+ let n = opts.n ?? 4096, hop = opts.hop ?? 1024
588
+ let C = channels.length, N = channels[0].length
589
+ let mono = new Float64Array(N)
590
+ for (let x of channels) for (let i = 0; i < N; i++) mono[i] += x[i] / C
591
+ let mean = 0, sq = 0
592
+ for (let i = 0; i < N; i++) mean += mono[i]
593
+ mean /= N
594
+ for (let i = 0; i < N; i++) sq += (mono[i] - mean) ** 2
595
+ let std = Math.sqrt(sq / Math.max(1, N - 1)) + 1e-8
596
+ let norm = channels.map(x => Float32Array.from(x, v => (v - mean) / std))
597
+
598
+ let result = {}
599
+ for (let g of graphs) {
600
+ let session = await load(g.spec)
601
+ try {
602
+ let L = session.inputs?.[0]?.dims?.[2]
603
+ L = typeof L === 'number' && L > 0 ? L : opts.segment ?? 343980 // htdemucs: int(7.8 · 44100)
604
+ let stride = Math.floor(0.75 * L) // apply_model's overlap=0.25
605
+ let starts = []
606
+ for (let s = 0; s < N; s += stride) starts.push(s)
607
+ let half = L >> 1, tri = k => (k < half ? k + 1 : L - k) / half
608
+ let keep = g.keep.map(name => g.sources.indexOf(name))
609
+ let out = await overlapAdd(N, C, starts, L, tri, async (start, len) => {
610
+ // the chunk centered in L samples, context from the input around it, zeros past its ends
611
+ let from = start - ((L - len) >> 1)
612
+ let seg = norm.map(x => { let s = new Float32Array(L), a = Math.max(0, from); s.set(x.subarray(a, Math.min(N, from + L)), a - from); return s })
613
+ let stems = await hybridSegment(session, seg, keep, n, hop)
614
+ let off = (L - len) >> 1, res = {}
615
+ g.keep.forEach((name, i) => { res[name] = stems[i].map(ch => ch.subarray(off, off + len)) })
616
+ return res
617
+ }, opts.progress)
618
+ for (let name in out) result[name] = out[name].map(ch => { for (let i = 0; i < N; i++) ch[i] = ch[i] * std + mean; return ch })
619
+ } finally {
620
+ session.free?.()
621
+ }
622
+ }
623
+ return result
624
+ }
625
+
626
+ // ------------------------------------------------------------------ waveform
627
+
628
+ // [1, C, N] in, [1, S, C, N] out, contiguous per (source, channel).
629
+ async function separateWaveform(channels, rate, opts, graphs, load) {
630
+ let C = channels.length, N = channels[0].length
631
+ let { starts, size, weight } = crossfadeChunks(N, rate, opts)
632
+ let result = {}
633
+ for (let g of graphs) {
634
+ let session = await load(g.spec)
635
+ try {
636
+ let out = await overlapAdd(N, C, starts, size, weight, async (start, len) => {
637
+ let data = new Float32Array(C * len)
638
+ slice(channels, start, len).forEach((ch, c) => data.set(ch, c * len))
639
+ let y = outputOf(session, await session.run({ [session.inputs?.[0]?.name ?? 'input']: tensor(data, [1, C, len], 'float32') })).data
640
+ let res = {}
641
+ for (let name of g.keep) { let s = g.sources.indexOf(name); res[name] = Array.from({ length: C }, (_, c) => y.subarray((s * C + c) * len, (s * C + c + 1) * len)) }
642
+ return res
643
+ }, opts.progress)
644
+ Object.assign(result, out)
645
+ } finally {
646
+ session.free?.()
647
+ }
648
+ }
649
+ return result
650
+ }
651
+
652
+ // -------------------------------------------------------------------- input
653
+
654
+ function normalizeAudio(audio, opts) {
655
+ let channelData, sampleRate
656
+ if (Array.isArray(audio)) { channelData = audio; sampleRate = opts.sampleRate }
657
+ else if (audio && audio.channelData) { channelData = audio.channelData; sampleRate = audio.sampleRate ?? opts.sampleRate }
658
+ else throw new Error('neural-separate: audio must be Float32Array[] or { channelData, sampleRate }')
659
+ if (!sampleRate) throw new Error('neural-separate: sampleRate is required (opts.sampleRate, or audio.sampleRate)')
660
+ if (!channelData.length) throw new Error('neural-separate: audio has no channels')
661
+ // mono → duplicated stereo, as openunmix.utils.preprocess does
662
+ if (channelData.length === 1) channelData = [channelData[0], channelData[0]]
663
+ return { channelData, sampleRate }
664
+ }
665
+
666
+ function fitLength(arr, len) {
667
+ if (arr.length === len) return arr
668
+ let out = new Float32Array(len)
669
+ out.set(arr.subarray(0, Math.min(len, arr.length)))
670
+ return out
671
+ }
672
+
673
+ // ------------------------------------------------------------------- default
674
+
675
+ // separate(audio, opts) → { stems: { [target]: Float32Array[] }, sampleRate, residual }
676
+ export default async function separate(audio, opts = {}) {
677
+ if (opts.dtype && opts.dtype !== 'float32') throw new Error(`neural-separate: dtype '${opts.dtype}' not supported: only 'float32' tensor marshalling is implemented`)
678
+
679
+ let { channelData, sampleRate: rate } = normalizeAudio(audio, opts)
680
+ let { graphs, modelType, sampleRate: modelRate, targets } = await resolveModel(opts)
681
+ let empty = () => channelData.map(() => new Float32Array(0))
682
+ if (!channelData[0].length) return { stems: Object.fromEntries(targets.map(t => [t, empty()])), sampleRate: rate, residual: empty() }
683
+ let targetRate = opts.targetRate ?? modelRate ?? rate
684
+ let proc = targetRate !== rate ? channelData.map(c => resampleSinc(c, { from: rate, to: targetRate })) : channelData
685
+ let load = loader(opts)
686
+
687
+ let stemsAtRate = modelType === 'hybrid' ? await separateHybrid(proc, targetRate, opts, graphs, load)
688
+ : modelType === 'waveform' ? await separateWaveform(proc, targetRate, opts, graphs, load)
689
+ : await separateSpectral(proc, targetRate, opts, graphs, modelType, load)
690
+
691
+ let N = channelData[0].length
692
+ let stems = {}
693
+ for (let name of targets) stems[name] = stemsAtRate[name].map(c => fitLength(targetRate !== rate ? resampleSinc(c, { from: targetRate, to: rate }) : c, N))
694
+
695
+ let residual = channelData.map((c, ci) => {
696
+ let r = Float32Array.from(c)
697
+ for (let name of targets) { let s = stems[name][ci]; for (let i = 0; i < N; i++) r[i] -= s[i] }
698
+ return r
699
+ })
700
+
701
+ return { stems, sampleRate: rate, residual }
702
+ }