@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/separate.js CHANGED
@@ -12,6 +12,15 @@
12
12
  // of sevagh/demucs.onnx. 7.8 s segments overlap-added as demucs.apply.apply_model does; each
13
13
  // runs as waveform + complex-as-channels spectrogram in, frequency- and time-branch estimates
14
14
  // out, summed after the iSTFT.
15
+ // 'complex': complex spectrogram models (SCNet: Tong, Zhang, Liu, Li, Yu, "SCNet: Sparse Compression
16
+ // Network for Music Source Separation", ICASSP 2024; the Band-Split RoFormer family has the same
17
+ // contract) with their STFT and iSTFT out of the graph: each channel's STFT in, re and im as
18
+ // channels, each source's out. 11 s segments overlap-added under linear fades, as
19
+ // ZFTurbo/Music-Source-Separation-Training's demix() runs them.
20
+ // 'multires': masking models over several STFT resolutions (MRX: Petermann, Wichern, Wang, Le Roux, "The Cocktail
21
+ // Fork Problem: Three-Stem Audio Separation for Real-World Soundtracks", ICASSP 2022), their STFTs and iSTFTs out of
22
+ // the graph: each channel's magnitudes in, a real mask per source and resolution out; a source is the sum of the
23
+ // iSTFTs of its masked spectrograms. The input at the loudness the model trained at, as upstream's separate.py sets it.
15
24
  // 'waveform': any [1, C, N] → [1, S, C, N] graph.
16
25
  //
17
26
  // Long inputs run in overlapping chunks, so memory stays bounded by the chunk, not the file.
@@ -342,12 +351,40 @@ function complexInverse(Are, Aim, outRe, outIm, n, re, im) {
342
351
  // Demucs source order (demucs/pretrained.py SOURCES), the S axis of its outputs.
343
352
  const DEMUCS = ['drums', 'bass', 'other', 'vocals']
344
353
 
354
+ // SCNet's STFT (torch.stft's window=None: ones; normalized) and segment, 476 frames of 11 s
355
+ const SCNET = { modelType: 'complex', sampleRate: 44100, targets: DEMUCS, n: 4096, hop: 1024, window: 'ones', normalized: true, segment: 485100, frames: 476 }
356
+
357
+ // MRX's STFTs (mrx.py _stft: Hann, hop 256, centered, normalized) at three resolutions; its sources in its order
358
+ // (music, speech, sfx), named as a soundtrack's stems are; channels encoded apart, so mono stays mono; the input at
359
+ // -27 LUFS (separate.py's input_lufs, the stems scaled back); 20 s chunks (Node's peak 1.9 GB on 60 s, whole 4.4 GB)
360
+ const MRX = {
361
+ modelType: 'multires', sampleRate: 44100, targets: ['dialogue', 'music', 'effects'], sources: ['music', 'dialogue', 'effects'],
362
+ windows: [1024, 2048, 8192], hop: 256, loudness: -27, mono: true, chunk: 20, script: 'export-mrx.py',
363
+ }
364
+
365
+ // TIGER's DnR model as scripts/export-tiger.py exports it (three band-split models, each its kept source, one graph): its
366
+ // STFT (Hann, unnormalized, centered), 12 s segments of 1034 frames every 4 s, as TIGERDNR.wav_chunk_inference cuts
367
+ // them; mono, channels apart
368
+ const TIGER = {
369
+ modelType: 'complex', sampleRate: 44100, targets: ['dialogue', 'music', 'effects'], sources: ['dialogue', 'music', 'effects'],
370
+ n: 2048, hop: 512, window: 'hann', normalized: false, segment: 529200, frames: 1034, step: 1 / 3, fade: 0, pad: 'zero',
371
+ standardize: false, mono: true, script: 'export-tiger.py',
372
+ }
373
+
345
374
  // Presets: files are <weights>/<name>/<target>.onnx (one graph per target) or
346
375
  // <weights>/<name>/<name>.onnx, as scripts/export-*.py write them.
347
376
  export const models = {
348
377
  umxhq: { modelType: 'openunmix', sampleRate: 44100, targets: ['vocals', 'drums', 'bass', 'other'], perTarget: true },
349
378
  htdemucs: { modelType: 'hybrid', sampleRate: 44100, targets: DEMUCS },
350
379
  htdemucs_ft: { modelType: 'hybrid', sampleRate: 44100, targets: DEMUCS, perTarget: true },
380
+ // SCNet-large and SCNet (starrytong/SCNet, MIT weights) as scripts/export-scnet.py exports them
381
+ 'scnet-large': SCNET,
382
+ scnet: SCNET,
383
+ // MRX (merlresearch/cocktail-fork-separation, MIT weights) as scripts/export-mrx.py exports it: dialogue, music
384
+ // and effects of a soundtrack (Divide and Remaster)
385
+ mrx: MRX,
386
+ // TIGER (JusperLee/TIGER, Apache-2.0 weights): the same three stems, three band-split models, about 50 times MRX's time
387
+ tiger: TIGER,
351
388
  }
352
389
 
353
390
  async function cacheDir() {
@@ -376,7 +413,7 @@ async function checkLocal(graphs, name) {
376
413
  let [{ existsSync }, { fileURLToPath }] = await Promise.all([import('node:fs'), import('node:url')])
377
414
  let missing = files.map(f => fileURLToPath(f)).filter(f => !existsSync(f))
378
415
  if (!missing.length) return
379
- let script = models[name].modelType === 'hybrid' ? 'export-htdemucs.py' : 'export-openunmix.py'
416
+ let script = models[name].script ?? { hybrid: 'export-htdemucs.py', complex: 'export-scnet.py' }[models[name].modelType] ?? 'export-openunmix.py'
380
417
  throw new Error(`neural-separate: ${name} weights not found: ${missing.join(', ')}. ` +
381
418
  `Produce them with python3 node_modules/@audio/neural-separate/scripts/${script} --model ${name}, ` +
382
419
  `or pass opts.weights: a URL or directory holding ${name}/<file>.onnx`)
@@ -395,12 +432,12 @@ async function resolveModel(opts) {
395
432
  let p = models[model], base = await weightsBase(opts, model)
396
433
  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
434
  let targets = pick(p.targets)
398
- let sources = p.modelType === 'hybrid' ? DEMUCS : null
435
+ let sources = p.sources ?? (p.modelType === 'hybrid' || p.modelType === 'complex' ? DEMUCS : null)
399
436
  let graphs = p.perTarget
400
437
  ? targets.map(t => ({ spec: `${base}${model}/${t}.onnx`, sources: sources ?? [t], keep: [t] }))
401
438
  : [{ spec: `${base}${model}/${model}.onnx`, sources: sources ?? p.targets, keep: targets }]
402
439
  if (isNode) await checkLocal(graphs, model)
403
- return { graphs, modelType: p.modelType, sampleRate: p.sampleRate, targets }
440
+ return { graphs, modelType: p.modelType, sampleRate: p.sampleRate, targets, preset: p }
404
441
  }
405
442
 
406
443
  let graphs
@@ -542,6 +579,23 @@ async function separateSpectral(channels, rate, opts, graphs, modelType, load) {
542
579
 
543
580
  // ------------------------------------------------------------------ hybrid
544
581
 
582
+ // The input over its whole length: (x − mean) / deviation of the channels' mean (demucs.api, and demix's callers
583
+ // with inference.normalize); back(ch) scales a source back and adds the mean, as both do to every source; scale(ch)
584
+ // only scales it back, the mean (the mixture's DC) left to no source.
585
+ function normalize(channels) {
586
+ let C = channels.length, N = channels[0].length, mono = new Float64Array(N), mean = 0, sq = 0
587
+ for (let x of channels) for (let i = 0; i < N; i++) mono[i] += x[i] / C
588
+ for (let i = 0; i < N; i++) mean += mono[i]
589
+ mean /= N
590
+ for (let i = 0; i < N; i++) sq += (mono[i] - mean) ** 2
591
+ let std = Math.sqrt(sq / Math.max(1, N - 1)) + 1e-8
592
+ return {
593
+ norm: channels.map(x => Float32Array.from(x, v => (v - mean) / std)),
594
+ back: ch => { for (let i = 0; i < ch.length; i++) ch[i] = ch[i] * std + mean; return ch },
595
+ scale: ch => { for (let i = 0; i < ch.length; i++) ch[i] *= std; return ch },
596
+ }
597
+ }
598
+
545
599
  // One segment through a demucs.onnx-contract graph: STFT and iSTFT as HTDemucs._spec,
546
600
  // _magnitude, _mask and _ispec do them (demucs/htdemucs.py, spec.py: torch.stft with
547
601
  // normalized=True, n_fft 4096, hop 1024; the Nyquist bin dropped; reflect re-padding by 3/4
@@ -585,15 +639,7 @@ async function hybridSegment(session, mix, want, n, hop) {
585
639
  // triangular window normalized by its sum.
586
640
  async function separateHybrid(channels, rate, opts, graphs, load) {
587
641
  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))
642
+ let C = channels.length, N = channels[0].length, { norm, back } = normalize(channels)
597
643
 
598
644
  let result = {}
599
645
  for (let g of graphs) {
@@ -615,7 +661,7 @@ async function separateHybrid(channels, rate, opts, graphs, load) {
615
661
  g.keep.forEach((name, i) => { res[name] = stems[i].map(ch => ch.subarray(off, off + len)) })
616
662
  return res
617
663
  }, 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 })
664
+ for (let name in out) result[name] = out[name].map(back)
619
665
  } finally {
620
666
  session.free?.()
621
667
  }
@@ -623,6 +669,176 @@ async function separateHybrid(channels, rate, opts, graphs, load) {
623
669
  return result
624
670
  }
625
671
 
672
+ // ------------------------------------------------------------------ complex
673
+
674
+ // One segment through a complex-spectrogram graph, [1, 2C, F, T] in (channel c's STFT at 2c, re, and 2c + 1, im),
675
+ // [1, 2·S·C, F, T] out (source s, channel c at 2(sC + c), re, and the next, im): the segment zero-padded to
676
+ // (T − 1)·hop samples when shorter (SCNet.forward pads so), centered reflect-padded frames, × `scale` (normalized:
677
+ // 1/√n) and back. seg: C channels of L samples; returns the sources at indices `want`, C channels of L samples each.
678
+ async function complexSegment(session, seg, want, n, hop, win, scale, T) {
679
+ let C = seg.length, L = seg[0].length, F = (n >> 1) + 1, spec = new Float32Array(2 * C * F * T)
680
+ seg.forEach((x, c) => {
681
+ let padded = new Float64Array(Math.max(L, (T - 1) * hop))
682
+ padded.set(x)
683
+ let { re, im } = stftFlat(padded, n, hop, win, true)
684
+ let zr = spec.subarray(2 * c * F * T), zi = spec.subarray((2 * c + 1) * F * T)
685
+ for (let f = 0, k = 0; f < F; f++) for (let t = 0, i = f; t < T; t++, i += F, k++) { zr[k] = re[i] * scale; zi[k] = im[i] * scale }
686
+ })
687
+ let name = session.inputs?.[0]?.name ?? 'mix_spec'
688
+ let z = outputOf(session, await session.run({ [name]: tensor(spec, [1, 2 * C, F, T], 'float32') })).data
689
+ let env = envelope(T, n, hop, win)
690
+ return want.map(s => Array.from({ length: C }, (_, c) => {
691
+ let re = new Float64Array(T * F), im = new Float64Array(T * F), b = 2 * (s * C + c) * F * T
692
+ // the [F, T] planes read in order, frames written strided
693
+ for (let f = 0, k = b; f < F; f++) for (let t = 0, i = f; t < T; t++, i += F, k++) { re[i] = z[k] / scale; im[i] = z[k + F * T] / scale }
694
+ let out = new Float64Array((T - 1) * hop + n)
695
+ ola(rows(re, F), rows(im, F), out, 0, n, hop, win)
696
+ return olaFinish(out, env, n, true, L)
697
+ }))
698
+ }
699
+
700
+ // demix() of Music-Source-Separation-Training (utils/model_utils.py, generic mode): segments of L samples every
701
+ // `step` of L, each weighted by a window fading in and out linearly over L/10 (the first not in, the last not out),
702
+ // the sum divided by the summed weights. An input longer than two segments less a step is first reflected L − step
703
+ // samples out on each side; a segment running past the end is reflected out when more than half of it is input,
704
+ // zero-padded when not. The input is normalized by its mean and deviation, and the sources scaled back; demix's
705
+ // callers add the mean back to every source, here to none: the stems sum to the input less its DC, which stays in
706
+ // the residual. And two departures: the segments stop at the first to reach the end (demix runs on while one
707
+ // starts before it: a 7 s song took three 11 s segments, now one, its SDR within 0.02 dB), and the first and last
708
+ // fades are set per segment (demix sets them per batch of 8: its first segment fades in, every one of a last
709
+ // batch holds).
710
+ // TIGER's DnR model (its preset's `step` 1/3, `fade` 0, `pad` 'zero', `standardize` false, `mono`) runs the segments
711
+ // TIGERDNR.wav_chunk_inference cuts: 12 s every 4 s, unweighted, the input zero-padded by 8 s at each end and the last
712
+ // segments with zeros, each channel apart, nothing normalized; its sum divided by the three segments over every
713
+ // sample, as the summed weights divide it here.
714
+ async function separateComplex(channels, rate, opts, graphs, load, p) {
715
+ let n = opts.n ?? p?.n ?? 4096, hop = opts.hop ?? p?.hop ?? 1024
716
+ let win = (opts.window ?? p?.window) === 'ones' ? new Float64Array(n).fill(1) : hann(n), scale = (opts.normalized ?? p?.normalized ?? true) ? 1 / Math.sqrt(n) : 1
717
+ let C = channels.length, N = channels[0].length, zero = (opts.pad ?? p?.pad) === 'zero'
718
+ let { norm, scale: back } = opts.standardize ?? p?.standardize ?? true ? normalize(channels) : { norm: channels, scale: c => c }
719
+ let result = {}
720
+ for (let g of graphs) {
721
+ let session = await load(g.spec)
722
+ try {
723
+ let T = session.inputs?.[0]?.dims?.[3]
724
+ T = typeof T === 'number' && T > 0 ? T : opts.frames ?? p?.frames
725
+ let L = opts.segment ?? p?.segment ?? (T - 1) * hop, step = Math.max(1, Math.floor(L * (opts.step ?? p?.step ?? 0.25)))
726
+ let border = L - step, wide = border > 0 && (zero || N > 2 * border)
727
+ let x = wide ? norm.map(c => Float32Array.from({ length: N + 2 * border }, (_, i) => zero ? (i < border || i >= border + N ? 0 : c[i - border]) : c[reflectIndex(i - border, N)])) : norm
728
+ let M = x[0].length, fade = Math.floor(L * (opts.fade ?? p?.fade ?? 0.1)), starts = []
729
+ for (let s = 0; ; s += step) { starts.push(s); if (s + L >= M) break }
730
+ let keep = g.keep.map(name => g.sources.indexOf(name))
731
+ let acc = g.keep.map(() => Array.from({ length: C }, () => new Float64Array(M))), wsum = new Float64Array(M)
732
+ for (let k = 0; k < starts.length; k++) {
733
+ let start = starts[k], len = Math.min(L, M - start), reflect = !zero && len > L / 2
734
+ opts.progress?.({ chunk: k + 1, totalChunks: starts.length })
735
+ let seg = x.map(c => { let s = new Float32Array(L); for (let i = 0; i < L; i++) s[i] = i < len ? c[start + i] : reflect ? c[start + reflectIndex(i, len)] : 0; return s })
736
+ // a mono model hears each channel apart
737
+ let stems = p?.mono ? [] : await complexSegment(session, seg, keep, n, hop, win, scale, T)
738
+ if (p?.mono) for (let c = 0; c < C; c++) (await complexSegment(session, [seg[c]], keep, n, hop, win, scale, T)).forEach((st, j) => (stems[j] ??= [])[c] = st[0])
739
+ for (let i = 0; i < len; i++) {
740
+ let w = 1
741
+ if (k > 0 && i < fade) w = i / (fade - 1)
742
+ if (k < starts.length - 1 && i >= L - fade) w = Math.min(w, (L - 1 - i) / (fade - 1))
743
+ wsum[start + i] += w
744
+ for (let j = 0; j < keep.length; j++) for (let c = 0; c < C; c++) acc[j][c][start + i] += stems[j][c][i] * w
745
+ }
746
+ }
747
+ g.keep.forEach((name, j) => {
748
+ result[name] = acc[j].map(a => back(Float32Array.from({ length: N }, (_, i) => wsum[i + (wide ? border : 0)] > 0 ? a[i + (wide ? border : 0)] / wsum[i + (wide ? border : 0)] : 0)))
749
+ })
750
+ } finally {
751
+ session.free?.()
752
+ }
753
+ }
754
+ return result
755
+ }
756
+
757
+ // ------------------------------------------------------------------ multires
758
+
759
+ // Each channel apart (B = 1), chunk by chunk: its STFT at every resolution (Hann windows of `windows` samples, one
760
+ // hop, centered), the magnitudes scaled 1/√n (torch.stft's normalized=True) in as mag_<n> [1, F, T], masks out as
761
+ // mask_<n> [1, S, F, T] in the graph's source order; a source is the sum over resolutions of the iSTFT of its mask
762
+ // times the spectrogram (the masks are real: the 1/√n and the iSTFT's √n cancel).
763
+ async function separateMultires(channels, rate, opts, graphs, load, p) {
764
+ let windows = opts.windows ?? p?.windows, hop = opts.hop ?? p?.hop ?? 256
765
+ let C = channels.length, N = channels[0].length, result = {}
766
+ for (let g of graphs) {
767
+ let session = await load(g.spec)
768
+ try {
769
+ let { starts, size, weight } = crossfadeChunks(N, rate, { ...opts, chunk: opts.chunk ?? p?.chunk })
770
+ Object.assign(result, await overlapAdd(N, C, starts, size, weight, async (start, len) => {
771
+ let res = Object.fromEntries(g.keep.map(name => [name, []]))
772
+ for (let c = 0; c < C; c++) {
773
+ let x = channels[c].subarray(start, start + len), feeds = {}
774
+ // the spectra kept in float32 (the 8192 window's alone 113 MB a 20 s chunk), one masked copy reused
775
+ let specs = windows.map((n, r) => {
776
+ let { re, im, T, bins: F } = stftFlat(x, n, hop, hann(n), true), mag = new Float32Array(F * T), k = 1 / Math.sqrt(n)
777
+ for (let t = 0, i = 0; t < T; t++) for (let f = 0; f < F; f++, i++) mag[f * T + t] = Math.hypot(re[i], im[i]) * k
778
+ feeds[session.inputs?.[r]?.name ?? `mag_${n}`] = tensor(mag, [1, F, T], 'float32')
779
+ return { re: Float32Array.from(re), im: Float32Array.from(im), T, F }
780
+ })
781
+ let out = await session.run(feeds), acc = g.keep.map(() => new Float64Array(len))
782
+ specs.forEach(({ re, im, T, F }, r) => {
783
+ let n = windows[r], m = outputOf(session, out, r).data, mr = new Float64Array(T * F), mi = new Float64Array(T * F)
784
+ g.keep.forEach((name, j) => {
785
+ let b = g.sources.indexOf(name) * F * T
786
+ for (let f = 0; f < F; f++) for (let t = 0, i = f, q = b + f * T; t < T; t++, i += F, q++) { mr[i] = re[i] * m[q]; mi[i] = im[i] * m[q] }
787
+ let o = new Float64Array((T - 1) * hop + n)
788
+ ola(rows(mr, F), rows(mi, F), o, 0, n, hop, hann(n))
789
+ let y = olaFinish(o, envelope(T, n, hop, hann(n)), n, true, len)
790
+ for (let i = 0; i < len; i++) acc[j][i] += y[i]
791
+ })
792
+ })
793
+ g.keep.forEach((name, j) => res[name].push(acc[j]))
794
+ }
795
+ return res
796
+ }, opts.progress))
797
+ } finally {
798
+ session.free?.()
799
+ }
800
+ }
801
+ return result
802
+ }
803
+
804
+ // ------------------------------------------------------------------ loudness
805
+
806
+ // Integrated loudness, ITU-R BS.1770-4, as pyloudnorm 0.2 measures it (upstream MRX's separate.py sets its input with
807
+ // it): K-weighting by pyloudnorm's two biquads at any rate (a 4 dB shelf over 1.5 kHz, a 38 Hz high-pass), 400 ms
808
+ // blocks every 100 ms, gated at -70 LUFS, then 10 LU under the mean of the rest; -Infinity for silence.
809
+ function lufs(channels, rate) {
810
+ let shelf = (G, Q, fc) => {
811
+ let A = 10 ** (G / 40), w = 2 * Math.PI * fc / rate, al = Math.sin(w) / (2 * Q), c = Math.cos(w), r = 2 * Math.sqrt(A) * al
812
+ return [A * ((A + 1) + (A - 1) * c + r), -2 * A * ((A - 1) + (A + 1) * c), A * ((A + 1) + (A - 1) * c - r), (A + 1) - (A - 1) * c + r, 2 * ((A - 1) - (A + 1) * c), (A + 1) - (A - 1) * c - r]
813
+ }
814
+ let pass = (Q, fc) => { let w = 2 * Math.PI * fc / rate, al = Math.sin(w) / (2 * Q), c = Math.cos(w); return [(1 + c) / 2, -(1 + c), (1 + c) / 2, 1 + al, -2 * c, 1 - al] }
815
+ let filters = [shelf(4, 1 / Math.sqrt(2), 1500), pass(0.5, 38)], N = channels[0].length, T = 0.4, step = 0.25
816
+ // numpy's round, half to even, as pyloudnorm counts its blocks
817
+ let even = v => { let f = Math.floor(v), d = v - f; return d > 0.5 || (d === 0.5 && f % 2) ? f + 1 : f }
818
+ let blocks = even((N / rate - T) / (T * step)) + 1
819
+ if (!(blocks > 0)) return -Infinity
820
+ let z = new Float64Array(blocks), G = [1, 1, 1, 1.41, 1.41]
821
+ channels.forEach((x, c) => {
822
+ let y = Float64Array.from(x)
823
+ for (let [b0, b1, b2, a0, a1, a2] of filters) {
824
+ // scipy.signal.lfilter from rest: direct form II transposed, coefficients over a0
825
+ b0 /= a0; b1 /= a0; b2 /= a0; a1 /= a0; a2 /= a0
826
+ let z1 = 0, z2 = 0
827
+ for (let i = 0; i < N; i++) { let v = y[i], o = b0 * v + z1; z1 = b1 * v - a1 * o + z2; z2 = b2 * v - a2 * o; y[i] = o }
828
+ }
829
+ for (let j = 0; j < blocks; j++) {
830
+ let lo = Math.trunc(T * (j * step) * rate), hi = Math.min(N, Math.trunc(T * (j * step + 1) * rate)), s = 0
831
+ for (let i = lo; i < hi; i++) s += y[i] * y[i]
832
+ z[j] += (G[c] ?? 1) * s / (T * rate)
833
+ }
834
+ })
835
+ let L = v => -0.691 + 10 * Math.log10(v), mean = v => v.reduce((a, b) => a + b, 0) / v.length
836
+ let abs = [...z].filter(v => L(v) >= -70)
837
+ if (!abs.length) return -Infinity
838
+ let rel = L(mean(abs)) - 10, gated = [...z].filter(v => L(v) > rel && L(v) > -70)
839
+ return gated.length ? L(mean(gated)) : -Infinity
840
+ }
841
+
626
842
  // ------------------------------------------------------------------ waveform
627
843
 
628
844
  // [1, C, N] in, [1, S, C, N] out, contiguous per (source, channel).
@@ -658,8 +874,6 @@ function normalizeAudio(audio, opts) {
658
874
  else throw new Error('neural-separate: audio must be Float32Array[] or { channelData, sampleRate }')
659
875
  if (!sampleRate) throw new Error('neural-separate: sampleRate is required (opts.sampleRate, or audio.sampleRate)')
660
876
  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
877
  return { channelData, sampleRate }
664
878
  }
665
879
 
@@ -677,20 +891,30 @@ export default async function separate(audio, opts = {}) {
677
891
  if (opts.dtype && opts.dtype !== 'float32') throw new Error(`neural-separate: dtype '${opts.dtype}' not supported: only 'float32' tensor marshalling is implemented`)
678
892
 
679
893
  let { channelData, sampleRate: rate } = normalizeAudio(audio, opts)
680
- let { graphs, modelType, sampleRate: modelRate, targets } = await resolveModel(opts)
894
+ let { graphs, modelType, sampleRate: modelRate, targets, preset } = await resolveModel(opts)
895
+ // mono → duplicated stereo, as openunmix.utils.preprocess does; a model hearing channels apart takes it as it is
896
+ if (channelData.length === 1 && !preset?.mono) channelData = [channelData[0], channelData[0]]
681
897
  let empty = () => channelData.map(() => new Float32Array(0))
682
898
  if (!channelData[0].length) return { stems: Object.fromEntries(targets.map(t => [t, empty()])), sampleRate: rate, residual: empty() }
683
899
  let targetRate = opts.targetRate ?? modelRate ?? rate
684
900
  let proc = targetRate !== rate ? channelData.map(c => resampleSinc(c, { from: rate, to: targetRate })) : channelData
685
901
  let load = loader(opts)
902
+ // the input at the loudness the model trained at, its stems scaled back (a silent input as it is)
903
+ let level = preset?.loudness == null ? -Infinity : lufs(proc, targetRate), gain = Number.isFinite(level) ? 10 ** ((preset.loudness - level) / 20) : 1
904
+ if (gain !== 1) proc = proc.map(c => c.map(v => v * gain))
686
905
 
687
906
  let stemsAtRate = modelType === 'hybrid' ? await separateHybrid(proc, targetRate, opts, graphs, load)
907
+ : modelType === 'complex' ? await separateComplex(proc, targetRate, opts, graphs, load, preset)
908
+ : modelType === 'multires' ? await separateMultires(proc, targetRate, opts, graphs, load, preset)
688
909
  : modelType === 'waveform' ? await separateWaveform(proc, targetRate, opts, graphs, load)
689
910
  : await separateSpectral(proc, targetRate, opts, graphs, modelType, load)
690
911
 
691
912
  let N = channelData[0].length
692
913
  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))
914
+ for (let name of targets) stems[name] = stemsAtRate[name].map(c => {
915
+ if (gain !== 1) c = Float32Array.from(c, v => v / gain)
916
+ return fitLength(targetRate !== rate ? resampleSinc(c, { from: targetRate, to: rate }) : c, N)
917
+ })
694
918
 
695
919
  let residual = channelData.map((c, ci) => {
696
920
  let r = Float32Array.from(c)