@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.
package/separate.js CHANGED
@@ -12,13 +12,22 @@
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, Zhu, Chen, Kang, Jiang, Li, Wu, Meng, "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.
18
27
 
19
28
  import { fft, ifft } from 'fourier-transform'
20
29
  import resampleSinc from '@audio/resample-sinc'
21
- import { load as neuralLoad, tensor } from '@audio/neural-runtime'
30
+ import { load as neuralLoad, tensor, fetchModel } from '@audio/neural-runtime'
22
31
 
23
32
  const PI2 = Math.PI * 2
24
33
  const isNode = typeof process !== 'undefined' && !!process.versions?.node
@@ -342,12 +351,61 @@ 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
 
345
- // Presets: files are <weights>/<name>/<target>.onnx (one graph per target) or
346
- // <weights>/<name>/<name>.onnx, as scripts/export-*.py write them.
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
+
374
+ // The compact files (scripts/compact.py: int8 weights, a few float16, float32 compute), each within 0.05 dB of its
375
+ // export's median SDR or SNR per stem (README, Size); their Hugging Face repositories, and SHA-256 checked when fetched
376
+ const FILES = {
377
+ 'scnet-large': { file: 'scnet-large.int8.onnx', repo: 'audiojs/scnet-large', sha256: 'b2dc586a1e0e6c0afe4915e9057ea29111397b7bbf589edc3d85de91de79cd72' },
378
+ scnet: { file: 'scnet.int8.onnx', repo: 'audiojs/scnet', sha256: '98228931494151762a1c4ab1ec7899a894b1f81fd4a509921a9d2f9bacc50845' },
379
+ mrx: { file: 'mrx.int8.onnx', repo: 'audiojs/mrx', sha256: 'd876c92d224f5d8cd2207d528b23369da895a6de71fb24132416fcb16f9f8424' },
380
+ tiger: { file: 'tiger.int8.onnx', repo: 'audiojs/tiger-dnr', sha256: '2189859ac7e89cf4d9d284c6f393e737aaa2b3233ed7ee424ad622012bce7bca' },
381
+ }
382
+
383
+ // The hosted revision of each compact file's repository (a commit), its URL https://huggingface.co/<repo>/resolve/<revision>/
384
+ // <file>: what a compact preset reads without opts.weights, in the browser, and in Node fetched once into the neural cache
385
+ // (unless the cache holds it or the export). Empty: not hosted.
386
+ export const REVISIONS = {
387
+ 'scnet-large': '95f22d9e68f6cab78aef358f1a0de248d4edf805',
388
+ scnet: 'e71bcdb42bce1d62b7fae25ba90fcbcf90f73f46',
389
+ mrx: 'dedc823a0bea2ed085d19e8b7f32589d35169ee8',
390
+ tiger: '1c959ab7ff8794dc5663b104ba301687ab95d2ab',
391
+ }
392
+
393
+ const hosted = name => models[name].repo && REVISIONS[name] ? `https://huggingface.co/${models[name].repo}/resolve/${REVISIONS[name]}/${models[name].file}` : null
394
+
395
+ // Presets: files are <weights>/<name>/<target>.onnx (one graph per target) or <weights>/<name>/<name>.onnx, as
396
+ // scripts/export-*.py write them; a compact preset's <weights>/<name>/<file> first, as scripts/compact.py writes it.
347
397
  export const models = {
348
398
  umxhq: { modelType: 'openunmix', sampleRate: 44100, targets: ['vocals', 'drums', 'bass', 'other'], perTarget: true },
349
399
  htdemucs: { modelType: 'hybrid', sampleRate: 44100, targets: DEMUCS },
350
400
  htdemucs_ft: { modelType: 'hybrid', sampleRate: 44100, targets: DEMUCS, perTarget: true },
401
+ // SCNet-large and SCNet (starrytong/SCNet, MIT weights) as scripts/export-scnet.py exports them
402
+ 'scnet-large': { ...SCNET, ...FILES['scnet-large'] },
403
+ scnet: { ...SCNET, ...FILES.scnet },
404
+ // MRX (merlresearch/cocktail-fork-separation, MIT weights) as scripts/export-mrx.py exports it: dialogue, music
405
+ // and effects of a soundtrack (Divide and Remaster)
406
+ mrx: { ...MRX, ...FILES.mrx },
407
+ // TIGER (JusperLee/TIGER, Apache-2.0 weights): the same three stems, three band-split models, about 50 times MRX's time
408
+ tiger: { ...TIGER, ...FILES.tiger },
351
409
  }
352
410
 
353
411
  async function cacheDir() {
@@ -368,18 +426,40 @@ async function weightsBase(opts, name) {
368
426
  return pathToFileURL(w.endsWith('/') ? w : w + '/').href
369
427
  }
370
428
 
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`)
429
+ // A preset's files in a local directory; a file missing named with how to make it, rather than ENOENT from the runtime.
430
+ // A compact preset reads its compact file, else its export (float32). From the neural cache (no opts.weights) its compact
431
+ // file only as hosted (its SHA-256), else the export, else the hosted file fetched into the cache, checked, written whole;
432
+ // offline, a compact file of another SHA-256 (scripts/compact.py's own) as it is.
433
+ async function local(graphs, name, opts) {
434
+ let [fs, { fileURLToPath }] = await Promise.all([import('node:fs'), import('node:url')])
435
+ let file = g => typeof g.spec === 'string' && g.spec.startsWith('file:') ? fileURLToPath(g.spec) : null
436
+ let here = g => !file(g) || fs.existsSync(file(g)), p = models[name], why = ''
437
+ let whole = g => ({ ...g, spec: g.spec.replace(/[^/]*$/, `${name}.onnx`), sha256: null })
438
+ if (p.file) graphs = await Promise.all(graphs.map(async g => {
439
+ if (!file(g)) return g
440
+ let cached = opts.weights == null
441
+ if (here(g) && (!cached || await sha256(fs.readFileSync(file(g))) === p.sha256)) return { ...g, sha256: null }
442
+ if (here(whole(g))) return whole(g)
443
+ if (cached && hosted(name)) try { return { ...g, spec: await fetched(name, file(g)), sha256: null } } catch (e) { why = ` (${e.message})` }
444
+ return { ...g, sha256: null }
445
+ }))
446
+ let missing = graphs.filter(g => !here(g)).map(file)
447
+ if (!missing.length) return graphs
448
+ let script = p.script ?? { hybrid: 'export-htdemucs.py', complex: 'export-scnet.py' }[p.modelType] ?? 'export-openunmix.py'
449
+ throw new Error(`neural-separate: ${name} weights not found: ${missing.join(', ')}${why}. ` +
450
+ `Produce them with python3 node_modules/@audio/neural-separate/scripts/${script} --model ${name}` +
451
+ (p.file ? ` (and scripts/compact.py --model ${name} for ${p.file})` : '') +
452
+ `, or pass opts.weights: a URL or directory holding ${name}/<file>.onnx`)
453
+ }
454
+
455
+ // The hosted compact file into the cache, checked against its SHA-256, written whole or not at all → its file URL
456
+ async function fetched(name, dest) {
457
+ let [fs, path, { pathToFileURL }] = await Promise.all([import('node:fs'), import('node:path'), import('node:url')])
458
+ let bytes = await checked(hosted(name), models[name].sha256, { cache: false })
459
+ fs.mkdirSync(path.dirname(dest), { recursive: true })
460
+ fs.writeFileSync(dest + '.part', bytes)
461
+ fs.renameSync(dest + '.part', dest)
462
+ return pathToFileURL(dest).href
383
463
  }
384
464
 
385
465
  // opts.model → { graphs: [{ spec, sources, keep }], modelType, sampleRate, targets }. A graph's
@@ -392,15 +472,16 @@ async function resolveModel(opts) {
392
472
  let pick = names => want ? names.filter(n => want.includes(n)) : names
393
473
 
394
474
  if (typeof model === 'string' && Object.hasOwn(models, model)) {
395
- let p = models[model], base = await weightsBase(opts, model)
475
+ // the browser without opts.weights: the hosted file
476
+ let p = models[model], url = !isNode && opts.weights == null && hosted(model), base = url ? '' : await weightsBase(opts, model)
396
477
  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
478
  let targets = pick(p.targets)
398
- let sources = p.modelType === 'hybrid' ? DEMUCS : null
479
+ let sources = p.sources ?? (p.modelType === 'hybrid' || p.modelType === 'complex' ? DEMUCS : null)
399
480
  let graphs = p.perTarget
400
481
  ? 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 }
482
+ : [{ spec: url || `${base}${model}/${p.file ?? `${model}.onnx`}`, sources: sources ?? p.targets, keep: targets, sha256: p.sha256 }]
483
+ if (isNode) graphs = await local(graphs, model, opts)
484
+ return { graphs, modelType: p.modelType, sampleRate: p.sampleRate, targets, preset: p }
404
485
  }
405
486
 
406
487
  let graphs
@@ -422,8 +503,25 @@ async function resolveModel(opts) {
422
503
  // No pooled arena: one htdemucs segment peaks at 2.7 GB RSS in onnxruntime-node instead of 3.2 GB.
423
504
  const sessionOptions = { enableCpuMemArena: false, enableMemPattern: false }
424
505
 
506
+ // A graph's session; a fetched file with a SHA-256 checked against it first
425
507
  function loader(opts) {
426
- return spec => opts.session ? opts.session(spec, opts) : neuralLoad(spec, { backend: opts.device, sessionOptions })
508
+ return async g => {
509
+ if (opts.session) return opts.session(g.spec, opts)
510
+ let spec = g.sha256 && !g.spec.startsWith('file:') ? await checked(g.spec, g.sha256) : g.spec
511
+ return neuralLoad(spec, { backend: opts.device, sessionOptions })
512
+ }
513
+ }
514
+
515
+ // A fetched file's bytes, refused unless of its SHA-256
516
+ async function checked(url, hash, opts) {
517
+ let bytes = await fetchModel(url, opts), hex = await sha256(bytes)
518
+ if (hex !== hash) throw new Error(`neural-separate: ${url} has SHA-256 ${hex}, expected ${hash}: a partial or altered download (clear the 'audio-neural' cache), or another build of the file`)
519
+ return bytes
520
+ }
521
+
522
+ async function sha256(bytes) {
523
+ let subtle = globalThis.crypto?.subtle ?? (await import('node:crypto')).webcrypto.subtle
524
+ return Array.from(new Uint8Array(await subtle.digest('SHA-256', bytes)), b => b.toString(16).padStart(2, '0')).join('')
427
525
  }
428
526
 
429
527
  function outputOf(session, out, i = 0) {
@@ -512,7 +610,7 @@ async function separateSpectral(channels, rate, opts, graphs, modelType, load) {
512
610
  let wopts = { iterations, softmask: opts.softmask ?? false, eps: opts.eps, residual }
513
611
  let sessions = []
514
612
  try {
515
- for (let g of graphs) sessions.push(await load(g.spec))
613
+ for (let g of graphs) sessions.push(await load(g))
516
614
  let C = channels.length, N = channels[0].length
517
615
  let { starts, size, weight } = crossfadeChunks(N, rate, opts)
518
616
  return await overlapAdd(N, C, starts, size, weight, async (start, len) => {
@@ -542,6 +640,23 @@ async function separateSpectral(channels, rate, opts, graphs, modelType, load) {
542
640
 
543
641
  // ------------------------------------------------------------------ hybrid
544
642
 
643
+ // The input over its whole length: (x − mean) / deviation of the channels' mean (demucs.api, and demix's callers
644
+ // with inference.normalize); back(ch) scales a source back and adds the mean, as both do to every source; scale(ch)
645
+ // only scales it back, the mean (the mixture's DC) left to no source.
646
+ function normalize(channels) {
647
+ let C = channels.length, N = channels[0].length, mono = new Float64Array(N), mean = 0, sq = 0
648
+ for (let x of channels) for (let i = 0; i < N; i++) mono[i] += x[i] / C
649
+ for (let i = 0; i < N; i++) mean += mono[i]
650
+ mean /= N
651
+ for (let i = 0; i < N; i++) sq += (mono[i] - mean) ** 2
652
+ let std = Math.sqrt(sq / Math.max(1, N - 1)) + 1e-8
653
+ return {
654
+ norm: channels.map(x => Float32Array.from(x, v => (v - mean) / std)),
655
+ back: ch => { for (let i = 0; i < ch.length; i++) ch[i] = ch[i] * std + mean; return ch },
656
+ scale: ch => { for (let i = 0; i < ch.length; i++) ch[i] *= std; return ch },
657
+ }
658
+ }
659
+
545
660
  // One segment through a demucs.onnx-contract graph: STFT and iSTFT as HTDemucs._spec,
546
661
  // _magnitude, _mask and _ispec do them (demucs/htdemucs.py, spec.py: torch.stft with
547
662
  // normalized=True, n_fft 4096, hop 1024; the Nyquist bin dropped; reflect re-padding by 3/4
@@ -585,19 +700,11 @@ async function hybridSegment(session, mix, want, n, hop) {
585
700
  // triangular window normalized by its sum.
586
701
  async function separateHybrid(channels, rate, opts, graphs, load) {
587
702
  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))
703
+ let C = channels.length, N = channels[0].length, { norm, back } = normalize(channels)
597
704
 
598
705
  let result = {}
599
706
  for (let g of graphs) {
600
- let session = await load(g.spec)
707
+ let session = await load(g)
601
708
  try {
602
709
  let L = session.inputs?.[0]?.dims?.[2]
603
710
  L = typeof L === 'number' && L > 0 ? L : opts.segment ?? 343980 // htdemucs: int(7.8 · 44100)
@@ -615,7 +722,92 @@ async function separateHybrid(channels, rate, opts, graphs, load) {
615
722
  g.keep.forEach((name, i) => { res[name] = stems[i].map(ch => ch.subarray(off, off + len)) })
616
723
  return res
617
724
  }, 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 })
725
+ for (let name in out) result[name] = out[name].map(back)
726
+ } finally {
727
+ session.free?.()
728
+ }
729
+ }
730
+ return result
731
+ }
732
+
733
+ // ------------------------------------------------------------------ complex
734
+
735
+ // One segment through a complex-spectrogram graph, [1, 2C, F, T] in (channel c's STFT at 2c, re, and 2c + 1, im),
736
+ // [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
737
+ // (T − 1)·hop samples when shorter (SCNet.forward pads so), centered reflect-padded frames, × `scale` (normalized:
738
+ // 1/√n) and back. seg: C channels of L samples; returns the sources at indices `want`, C channels of L samples each.
739
+ async function complexSegment(session, seg, want, n, hop, win, scale, T) {
740
+ let C = seg.length, L = seg[0].length, F = (n >> 1) + 1, spec = new Float32Array(2 * C * F * T)
741
+ seg.forEach((x, c) => {
742
+ let padded = new Float64Array(Math.max(L, (T - 1) * hop))
743
+ padded.set(x)
744
+ let { re, im } = stftFlat(padded, n, hop, win, true)
745
+ let zr = spec.subarray(2 * c * F * T), zi = spec.subarray((2 * c + 1) * F * T)
746
+ 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 }
747
+ })
748
+ let name = session.inputs?.[0]?.name ?? 'mix_spec'
749
+ let z = outputOf(session, await session.run({ [name]: tensor(spec, [1, 2 * C, F, T], 'float32') })).data
750
+ let env = envelope(T, n, hop, win)
751
+ return want.map(s => Array.from({ length: C }, (_, c) => {
752
+ let re = new Float64Array(T * F), im = new Float64Array(T * F), b = 2 * (s * C + c) * F * T
753
+ // the [F, T] planes read in order, frames written strided
754
+ 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 }
755
+ let out = new Float64Array((T - 1) * hop + n)
756
+ ola(rows(re, F), rows(im, F), out, 0, n, hop, win)
757
+ return olaFinish(out, env, n, true, L)
758
+ }))
759
+ }
760
+
761
+ // demix() of Music-Source-Separation-Training (utils/model_utils.py, generic mode): segments of L samples every
762
+ // `step` of L, each weighted by a window fading in and out linearly over L/10 (the first not in, the last not out),
763
+ // the sum divided by the summed weights. An input longer than two segments less a step is first reflected L − step
764
+ // samples out on each side; a segment running past the end is reflected out when more than half of it is input,
765
+ // zero-padded when not. The input is normalized by its mean and deviation, and the sources scaled back; demix's
766
+ // callers add the mean back to every source, here to none: the stems sum to the input less its DC, which stays in
767
+ // the residual. And two departures: the segments stop at the first to reach the end (demix runs on while one
768
+ // 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
769
+ // fades are set per segment (demix sets them per batch of 8: its first segment fades in, every one of a last
770
+ // batch holds).
771
+ // TIGER's DnR model (its preset's `step` 1/3, `fade` 0, `pad` 'zero', `standardize` false, `mono`) runs the segments
772
+ // TIGERDNR.wav_chunk_inference cuts: 12 s every 4 s, unweighted, the input zero-padded by 8 s at each end and the last
773
+ // segments with zeros, each channel apart, nothing normalized; its sum divided by the three segments over every
774
+ // sample, as the summed weights divide it here.
775
+ async function separateComplex(channels, rate, opts, graphs, load, p) {
776
+ let n = opts.n ?? p?.n ?? 4096, hop = opts.hop ?? p?.hop ?? 1024
777
+ 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
778
+ let C = channels.length, N = channels[0].length, zero = (opts.pad ?? p?.pad) === 'zero'
779
+ let { norm, scale: back } = opts.standardize ?? p?.standardize ?? true ? normalize(channels) : { norm: channels, scale: c => c }
780
+ let result = {}
781
+ for (let g of graphs) {
782
+ let session = await load(g)
783
+ try {
784
+ let T = session.inputs?.[0]?.dims?.[3]
785
+ T = typeof T === 'number' && T > 0 ? T : opts.frames ?? p?.frames
786
+ let L = opts.segment ?? p?.segment ?? (T - 1) * hop, step = Math.max(1, Math.floor(L * (opts.step ?? p?.step ?? 0.25)))
787
+ let border = L - step, wide = border > 0 && (zero || N > 2 * border)
788
+ 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
789
+ let M = x[0].length, fade = Math.floor(L * (opts.fade ?? p?.fade ?? 0.1)), starts = []
790
+ for (let s = 0; ; s += step) { starts.push(s); if (s + L >= M) break }
791
+ let keep = g.keep.map(name => g.sources.indexOf(name))
792
+ let acc = g.keep.map(() => Array.from({ length: C }, () => new Float64Array(M))), wsum = new Float64Array(M)
793
+ for (let k = 0; k < starts.length; k++) {
794
+ let start = starts[k], len = Math.min(L, M - start), reflect = !zero && len > L / 2
795
+ opts.progress?.({ chunk: k + 1, totalChunks: starts.length })
796
+ 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 })
797
+ // a mono model hears each channel apart
798
+ let stems = p?.mono ? [] : await complexSegment(session, seg, keep, n, hop, win, scale, T)
799
+ 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])
800
+ for (let i = 0; i < len; i++) {
801
+ let w = 1
802
+ if (k > 0 && i < fade) w = i / (fade - 1)
803
+ if (k < starts.length - 1 && i >= L - fade) w = Math.min(w, (L - 1 - i) / (fade - 1))
804
+ wsum[start + i] += w
805
+ 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
806
+ }
807
+ }
808
+ g.keep.forEach((name, j) => {
809
+ 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)))
810
+ })
619
811
  } finally {
620
812
  session.free?.()
621
813
  }
@@ -623,6 +815,91 @@ async function separateHybrid(channels, rate, opts, graphs, load) {
623
815
  return result
624
816
  }
625
817
 
818
+ // ------------------------------------------------------------------ multires
819
+
820
+ // Each channel apart (B = 1), chunk by chunk: its STFT at every resolution (Hann windows of `windows` samples, one
821
+ // hop, centered), the magnitudes scaled 1/√n (torch.stft's normalized=True) in as mag_<n> [1, F, T], masks out as
822
+ // 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
823
+ // times the spectrogram (the masks are real: the 1/√n and the iSTFT's √n cancel).
824
+ async function separateMultires(channels, rate, opts, graphs, load, p) {
825
+ let windows = opts.windows ?? p?.windows, hop = opts.hop ?? p?.hop ?? 256
826
+ let C = channels.length, N = channels[0].length, result = {}
827
+ for (let g of graphs) {
828
+ let session = await load(g)
829
+ try {
830
+ let { starts, size, weight } = crossfadeChunks(N, rate, { ...opts, chunk: opts.chunk ?? p?.chunk })
831
+ Object.assign(result, await overlapAdd(N, C, starts, size, weight, async (start, len) => {
832
+ let res = Object.fromEntries(g.keep.map(name => [name, []]))
833
+ for (let c = 0; c < C; c++) {
834
+ let x = channels[c].subarray(start, start + len), feeds = {}
835
+ // the spectra kept in float32 (the 8192 window's alone 113 MB a 20 s chunk), one masked copy reused
836
+ let specs = windows.map((n, r) => {
837
+ let { re, im, T, bins: F } = stftFlat(x, n, hop, hann(n), true), mag = new Float32Array(F * T), k = 1 / Math.sqrt(n)
838
+ 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
839
+ feeds[session.inputs?.[r]?.name ?? `mag_${n}`] = tensor(mag, [1, F, T], 'float32')
840
+ return { re: Float32Array.from(re), im: Float32Array.from(im), T, F }
841
+ })
842
+ let out = await session.run(feeds), acc = g.keep.map(() => new Float64Array(len))
843
+ specs.forEach(({ re, im, T, F }, r) => {
844
+ let n = windows[r], m = outputOf(session, out, r).data, mr = new Float64Array(T * F), mi = new Float64Array(T * F)
845
+ g.keep.forEach((name, j) => {
846
+ let b = g.sources.indexOf(name) * F * T
847
+ 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] }
848
+ let o = new Float64Array((T - 1) * hop + n)
849
+ ola(rows(mr, F), rows(mi, F), o, 0, n, hop, hann(n))
850
+ let y = olaFinish(o, envelope(T, n, hop, hann(n)), n, true, len)
851
+ for (let i = 0; i < len; i++) acc[j][i] += y[i]
852
+ })
853
+ })
854
+ g.keep.forEach((name, j) => res[name].push(acc[j]))
855
+ }
856
+ return res
857
+ }, opts.progress))
858
+ } finally {
859
+ session.free?.()
860
+ }
861
+ }
862
+ return result
863
+ }
864
+
865
+ // ------------------------------------------------------------------ loudness
866
+
867
+ // Integrated loudness, ITU-R BS.1770-4, as pyloudnorm 0.2 measures it (upstream MRX's separate.py sets its input with
868
+ // 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
869
+ // blocks every 100 ms, gated at -70 LUFS, then 10 LU under the mean of the rest; -Infinity for silence.
870
+ function lufs(channels, rate) {
871
+ let shelf = (G, Q, fc) => {
872
+ 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
873
+ 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]
874
+ }
875
+ 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] }
876
+ let filters = [shelf(4, 1 / Math.sqrt(2), 1500), pass(0.5, 38)], N = channels[0].length, T = 0.4, step = 0.25
877
+ // numpy's round, half to even, as pyloudnorm counts its blocks
878
+ let even = v => { let f = Math.floor(v), d = v - f; return d > 0.5 || (d === 0.5 && f % 2) ? f + 1 : f }
879
+ let blocks = even((N / rate - T) / (T * step)) + 1
880
+ if (!(blocks > 0)) return -Infinity
881
+ let z = new Float64Array(blocks), G = [1, 1, 1, 1.41, 1.41]
882
+ channels.forEach((x, c) => {
883
+ let y = Float64Array.from(x)
884
+ for (let [b0, b1, b2, a0, a1, a2] of filters) {
885
+ // scipy.signal.lfilter from rest: direct form II transposed, coefficients over a0
886
+ b0 /= a0; b1 /= a0; b2 /= a0; a1 /= a0; a2 /= a0
887
+ let z1 = 0, z2 = 0
888
+ 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 }
889
+ }
890
+ for (let j = 0; j < blocks; j++) {
891
+ let lo = Math.trunc(T * (j * step) * rate), hi = Math.min(N, Math.trunc(T * (j * step + 1) * rate)), s = 0
892
+ for (let i = lo; i < hi; i++) s += y[i] * y[i]
893
+ z[j] += (G[c] ?? 1) * s / (T * rate)
894
+ }
895
+ })
896
+ let L = v => -0.691 + 10 * Math.log10(v), mean = v => v.reduce((a, b) => a + b, 0) / v.length
897
+ let abs = [...z].filter(v => L(v) >= -70)
898
+ if (!abs.length) return -Infinity
899
+ let rel = L(mean(abs)) - 10, gated = [...z].filter(v => L(v) > rel && L(v) > -70)
900
+ return gated.length ? L(mean(gated)) : -Infinity
901
+ }
902
+
626
903
  // ------------------------------------------------------------------ waveform
627
904
 
628
905
  // [1, C, N] in, [1, S, C, N] out, contiguous per (source, channel).
@@ -631,7 +908,7 @@ async function separateWaveform(channels, rate, opts, graphs, load) {
631
908
  let { starts, size, weight } = crossfadeChunks(N, rate, opts)
632
909
  let result = {}
633
910
  for (let g of graphs) {
634
- let session = await load(g.spec)
911
+ let session = await load(g)
635
912
  try {
636
913
  let out = await overlapAdd(N, C, starts, size, weight, async (start, len) => {
637
914
  let data = new Float32Array(C * len)
@@ -658,8 +935,6 @@ function normalizeAudio(audio, opts) {
658
935
  else throw new Error('neural-separate: audio must be Float32Array[] or { channelData, sampleRate }')
659
936
  if (!sampleRate) throw new Error('neural-separate: sampleRate is required (opts.sampleRate, or audio.sampleRate)')
660
937
  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
938
  return { channelData, sampleRate }
664
939
  }
665
940
 
@@ -677,20 +952,30 @@ export default async function separate(audio, opts = {}) {
677
952
  if (opts.dtype && opts.dtype !== 'float32') throw new Error(`neural-separate: dtype '${opts.dtype}' not supported: only 'float32' tensor marshalling is implemented`)
678
953
 
679
954
  let { channelData, sampleRate: rate } = normalizeAudio(audio, opts)
680
- let { graphs, modelType, sampleRate: modelRate, targets } = await resolveModel(opts)
955
+ let { graphs, modelType, sampleRate: modelRate, targets, preset } = await resolveModel(opts)
956
+ // mono → duplicated stereo, as openunmix.utils.preprocess does; a model hearing channels apart takes it as it is
957
+ if (channelData.length === 1 && !preset?.mono) channelData = [channelData[0], channelData[0]]
681
958
  let empty = () => channelData.map(() => new Float32Array(0))
682
959
  if (!channelData[0].length) return { stems: Object.fromEntries(targets.map(t => [t, empty()])), sampleRate: rate, residual: empty() }
683
960
  let targetRate = opts.targetRate ?? modelRate ?? rate
684
961
  let proc = targetRate !== rate ? channelData.map(c => resampleSinc(c, { from: rate, to: targetRate })) : channelData
685
962
  let load = loader(opts)
963
+ // the input at the loudness the model trained at, its stems scaled back (a silent input as it is)
964
+ let level = preset?.loudness == null ? -Infinity : lufs(proc, targetRate), gain = Number.isFinite(level) ? 10 ** ((preset.loudness - level) / 20) : 1
965
+ if (gain !== 1) proc = proc.map(c => c.map(v => v * gain))
686
966
 
687
967
  let stemsAtRate = modelType === 'hybrid' ? await separateHybrid(proc, targetRate, opts, graphs, load)
968
+ : modelType === 'complex' ? await separateComplex(proc, targetRate, opts, graphs, load, preset)
969
+ : modelType === 'multires' ? await separateMultires(proc, targetRate, opts, graphs, load, preset)
688
970
  : modelType === 'waveform' ? await separateWaveform(proc, targetRate, opts, graphs, load)
689
971
  : await separateSpectral(proc, targetRate, opts, graphs, modelType, load)
690
972
 
691
973
  let N = channelData[0].length
692
974
  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))
975
+ for (let name of targets) stems[name] = stemsAtRate[name].map(c => {
976
+ if (gain !== 1) c = Float32Array.from(c, v => v / gain)
977
+ return fitLength(targetRate !== rate ? resampleSinc(c, { from: targetRate, to: rate }) : c, N)
978
+ })
694
979
 
695
980
  let residual = channelData.map((c, ci) => {
696
981
  let r = Float32Array.from(c)