@audio/neural-separate 0.3.0 → 0.5.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,7 +12,7 @@
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
15
+ // 'complex': complex spectrogram models (SCNet: Tong, Zhu, Chen, Kang, Jiang, Li, Wu, Meng, "SCNet: Sparse Compression
16
16
  // Network for Music Source Separation", ICASSP 2024; the Band-Split RoFormer family has the same
17
17
  // contract) with their STFT and iSTFT out of the graph: each channel's STFT in, re and im as
18
18
  // channels, each source's out. 11 s segments overlap-added under linear fades, as
@@ -27,7 +27,7 @@
27
27
 
28
28
  import { fft, ifft } from 'fourier-transform'
29
29
  import resampleSinc from '@audio/resample-sinc'
30
- import { load as neuralLoad, tensor } from '@audio/neural-runtime'
30
+ import { load as neuralLoad, tensor, fetchModel, fetchJson, backends } from '@audio/neural-runtime'
31
31
 
32
32
  const PI2 = Math.PI * 2
33
33
  const isNode = typeof process !== 'undefined' && !!process.versions?.node
@@ -371,20 +371,55 @@ const TIGER = {
371
371
  standardize: false, mono: true, script: 'export-tiger.py',
372
372
  }
373
373
 
374
- // Presets: files are <weights>/<name>/<target>.onnx (one graph per target) or
375
- // <weights>/<name>/<name>.onnx, as scripts/export-*.py write them.
374
+ // Mel-Band RoFormer's STFT (Hann of 2048, hop 441, unnormalized), 8 s segments of 801 frames every 4 s, as
375
+ // Music-Source-Separation-Training's demix() runs Kim's vocals model (num_overlap 2); nothing normalized
376
+ const MEL = {
377
+ modelType: 'complex', sampleRate: 44100, targets: ['vocals'], sources: ['vocals'], n: 2048, hop: 441, window: 'hann',
378
+ normalized: false, segment: 352800, frames: 801, step: 0.5, standardize: false, gpu: true, script: 'export-roformer.py',
379
+ // roformer.js's tensors, for a browser whose GPU has subgroup matrices: the engine runs in onnxruntime's place
380
+ engine: { meta: 'mel-roformer.engine.json', bin: 'mel-roformer.engine.bin', sha256: '140fedcd9d03a8f918b235688c69f4dfdd86b08f9d430277659c095688a026ff' },
381
+ }
382
+
383
+ // The compact files (scripts/compact.py: int8 weights, a few float16, float32 compute), each within 0.05 dB of its
384
+ // export's median SDR or SNR per stem (README, Size); their Hugging Face repositories, and SHA-256 checked when fetched
385
+ const FILES = {
386
+ 'scnet-large': { file: 'scnet-large.int8.onnx', repo: 'audiojs/scnet-large', sha256: 'b2dc586a1e0e6c0afe4915e9057ea29111397b7bbf589edc3d85de91de79cd72' },
387
+ scnet: { file: 'scnet.int8.onnx', repo: 'audiojs/scnet', sha256: '98228931494151762a1c4ab1ec7899a894b1f81fd4a509921a9d2f9bacc50845' },
388
+ mrx: { file: 'mrx.int8.onnx', repo: 'audiojs/mrx', sha256: 'd876c92d224f5d8cd2207d528b23369da895a6de71fb24132416fcb16f9f8424' },
389
+ tiger: { file: 'tiger.int8.onnx', repo: 'audiojs/tiger-dnr', sha256: '2189859ac7e89cf4d9d284c6f393e737aaa2b3233ed7ee424ad622012bce7bca' },
390
+ // float16 weights, float32 compute: half the download; int8 (259 MB) ran slower on WebGPU and 10 dB further from float32
391
+ 'mel-roformer': { file: 'mel-roformer.fp16.onnx', repo: 'audiojs/mel-roformer', sha256: 'adcbbb826f12453fbb86652b503242d565a9c81cdef1bca2fe0e54752eba91ef' },
392
+ }
393
+
394
+ // The hosted revision of each compact file's repository (a commit), its URL https://huggingface.co/<repo>/resolve/<revision>/
395
+ // <file>: what a compact preset reads without opts.weights, in the browser, and in Node fetched once into the neural cache
396
+ // (unless the cache holds it or the export). Empty: not hosted.
397
+ export const REVISIONS = {
398
+ 'scnet-large': '95f22d9e68f6cab78aef358f1a0de248d4edf805',
399
+ scnet: 'e71bcdb42bce1d62b7fae25ba90fcbcf90f73f46',
400
+ mrx: 'dedc823a0bea2ed085d19e8b7f32589d35169ee8',
401
+ tiger: '1c959ab7ff8794dc5663b104ba301687ab95d2ab',
402
+ 'mel-roformer': 'c1aba02c00a0bee852d6754fe6ede8164f90a4b3',
403
+ }
404
+
405
+ const hosted = name => models[name].repo && REVISIONS[name] ? `https://huggingface.co/${models[name].repo}/resolve/${REVISIONS[name]}/${models[name].file}` : null
406
+
407
+ // Presets: files are <weights>/<name>/<target>.onnx (one graph per target) or <weights>/<name>/<name>.onnx, as
408
+ // scripts/export-*.py write them; a compact preset's <weights>/<name>/<file> first, as scripts/compact.py writes it.
376
409
  export const models = {
377
410
  umxhq: { modelType: 'openunmix', sampleRate: 44100, targets: ['vocals', 'drums', 'bass', 'other'], perTarget: true },
378
411
  htdemucs: { modelType: 'hybrid', sampleRate: 44100, targets: DEMUCS },
379
412
  htdemucs_ft: { modelType: 'hybrid', sampleRate: 44100, targets: DEMUCS, perTarget: true },
380
413
  // SCNet-large and SCNet (starrytong/SCNet, MIT weights) as scripts/export-scnet.py exports them
381
- 'scnet-large': SCNET,
382
- scnet: SCNET,
414
+ 'scnet-large': { ...SCNET, ...FILES['scnet-large'] },
415
+ scnet: { ...SCNET, ...FILES.scnet },
383
416
  // MRX (merlresearch/cocktail-fork-separation, MIT weights) as scripts/export-mrx.py exports it: dialogue, music
384
417
  // and effects of a soundtrack (Divide and Remaster)
385
- mrx: MRX,
418
+ mrx: { ...MRX, ...FILES.mrx },
386
419
  // TIGER (JusperLee/TIGER, Apache-2.0 weights): the same three stems, three band-split models, about 50 times MRX's time
387
- tiger: TIGER,
420
+ tiger: { ...TIGER, ...FILES.tiger },
421
+ // Mel-Band RoFormer (Kimberley Jensen's vocals model, MIT weights) as scripts/export-roformer.py exports it for the GPU
422
+ 'mel-roformer': { ...MEL, ...FILES['mel-roformer'] },
388
423
  }
389
424
 
390
425
  async function cacheDir() {
@@ -405,18 +440,72 @@ async function weightsBase(opts, name) {
405
440
  return pathToFileURL(w.endsWith('/') ? w : w + '/').href
406
441
  }
407
442
 
408
- // Weights produced locally must exist before a session loads them: name the missing file and
409
- // how to make it, rather than surfacing ENOENT from deep inside the runtime.
410
- async function checkLocal(graphs, name) {
411
- let files = graphs.map(g => g.spec).filter(s => typeof s === 'string' && s.startsWith('file:'))
412
- if (!files.length) return
413
- let [{ existsSync }, { fileURLToPath }] = await Promise.all([import('node:fs'), import('node:url')])
414
- let missing = files.map(f => fileURLToPath(f)).filter(f => !existsSync(f))
415
- if (!missing.length) return
416
- let script = models[name].script ?? { hybrid: 'export-htdemucs.py', complex: 'export-scnet.py' }[models[name].modelType] ?? 'export-openunmix.py'
417
- throw new Error(`neural-separate: ${name} weights not found: ${missing.join(', ')}. ` +
418
- `Produce them with python3 node_modules/@audio/neural-separate/scripts/${script} --model ${name}, ` +
419
- `or pass opts.weights: a URL or directory holding ${name}/<file>.onnx`)
443
+ // A preset's files in a local directory; a file missing named with how to make it, rather than ENOENT from the runtime.
444
+ // A compact preset reads its compact file, else its export (float32). From the neural cache (no opts.weights) its compact
445
+ // file only as hosted (its SHA-256), else the export, else the hosted file fetched into the cache, checked, written whole;
446
+ // offline, a compact file of another SHA-256 (scripts/compact.py's own) as it is.
447
+ async function local(graphs, name, opts) {
448
+ let [fs, { fileURLToPath }] = await Promise.all([import('node:fs'), import('node:url')])
449
+ let file = g => typeof g.spec === 'string' && g.spec.startsWith('file:') ? fileURLToPath(g.spec) : null
450
+ let here = g => !file(g) || fs.existsSync(file(g)), p = models[name], why = ''
451
+ let whole = g => ({ ...g, spec: g.spec.replace(/[^/]*$/, `${name}.onnx`), sha256: null })
452
+ if (p.file) graphs = await Promise.all(graphs.map(async g => {
453
+ if (!file(g)) return g
454
+ let cached = opts.weights == null
455
+ if (here(g) && (!cached || await sha256(fs.readFileSync(file(g))) === p.sha256)) return { ...g, sha256: null }
456
+ if (here(whole(g))) return whole(g)
457
+ if (cached && hosted(name)) try { return { ...g, spec: await fetched(name, file(g)), sha256: null } } catch (e) { why = ` (${e.message})` }
458
+ return { ...g, sha256: null }
459
+ }))
460
+ let missing = graphs.filter(g => !here(g)).map(file)
461
+ if (!missing.length) return graphs
462
+ let script = p.script ?? { hybrid: 'export-htdemucs.py', complex: 'export-scnet.py' }[p.modelType] ?? 'export-openunmix.py'
463
+ throw new Error(`neural-separate: ${name} weights not found: ${missing.join(', ')}${why}. ` +
464
+ `Produce them with python3 node_modules/@audio/neural-separate/scripts/${script} --model ${name}` +
465
+ (p.file ? ` (and scripts/compact.py --model ${name} for ${p.file})` : '') +
466
+ `, or pass opts.weights: a URL or directory holding ${name}/<file>.onnx`)
467
+ }
468
+
469
+ // A preset's own WebGPU engine (roformer.js) as a session, its tensors from `weights` or the hosted revision, or null
470
+ // where the GPU has no subgroup matrices (onnxruntime then): in the browser. Kept after a separation for KEEP ms, so the
471
+ // next one starts at once (its 466 MB of weights and 2 GB of buffers on the GPU, made in about a second), then let go
472
+ const KEEP = 60_000
473
+ let kept = null
474
+ function engineSession(opts, p) {
475
+ let name = opts.model, key = `${name}:${opts.weights ?? ''}:${p.engine.sha256}`
476
+ if (kept?.key === key) { clearTimeout(kept.timer); return kept.session }
477
+ kept?.session.then(s => s?.drop())
478
+ let mine = { key, timer: null }
479
+ // free() after a separation lets go KEEP ms later, unless the next one has taken it
480
+ mine.session = makeEngine(opts, p, name).then(s => s && {
481
+ ...s, drop: s.free,
482
+ free() { clearTimeout(mine.timer); mine.timer = setTimeout(() => { if (kept === mine) kept = null; s.free() }, KEEP) },
483
+ })
484
+ return (kept = mine).session
485
+ }
486
+ async function makeEngine(opts, p, name) {
487
+ let { engine, gpu } = await import('./roformer.js'), device = await gpu()
488
+ if (!device) return null
489
+ try {
490
+ let base = opts.weights != null ? `${await weightsBase(opts, name)}${name}/` : `https://huggingface.co/${p.repo}/resolve/${REVISIONS[name]}/`
491
+ let [meta, bytes] = await Promise.all([fetchJson(base + p.engine.meta), checked(base + p.engine.bin, p.engine.sha256)])
492
+ return await engine(meta, bytes, device)
493
+ } catch (e) {
494
+ // its files not there (weights without them), or the GPU refusing its buffers: onnxruntime, as without it
495
+ device.destroy()
496
+ console.warn(`neural-separate: ${name}'s own engine stands aside (${e.message})`)
497
+ return null
498
+ }
499
+ }
500
+
501
+ // The hosted compact file into the cache, checked against its SHA-256, written whole or not at all → its file URL
502
+ async function fetched(name, dest) {
503
+ let [fs, path, { pathToFileURL }] = await Promise.all([import('node:fs'), import('node:path'), import('node:url')])
504
+ let bytes = await checked(hosted(name), models[name].sha256, { cache: false })
505
+ fs.mkdirSync(path.dirname(dest), { recursive: true })
506
+ fs.writeFileSync(dest + '.part', bytes)
507
+ fs.renameSync(dest + '.part', dest)
508
+ return pathToFileURL(dest).href
420
509
  }
421
510
 
422
511
  // opts.model → { graphs: [{ spec, sources, keep }], modelType, sampleRate, targets }. A graph's
@@ -429,14 +518,15 @@ async function resolveModel(opts) {
429
518
  let pick = names => want ? names.filter(n => want.includes(n)) : names
430
519
 
431
520
  if (typeof model === 'string' && Object.hasOwn(models, model)) {
432
- let p = models[model], base = await weightsBase(opts, model)
521
+ // the browser without opts.weights: the hosted file
522
+ let p = models[model], url = !isNode && opts.weights == null && hosted(model), base = url ? '' : await weightsBase(opts, model)
433
523
  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(', ')})`)
434
524
  let targets = pick(p.targets)
435
525
  let sources = p.sources ?? (p.modelType === 'hybrid' || p.modelType === 'complex' ? DEMUCS : null)
436
526
  let graphs = p.perTarget
437
527
  ? targets.map(t => ({ spec: `${base}${model}/${t}.onnx`, sources: sources ?? [t], keep: [t] }))
438
- : [{ spec: `${base}${model}/${model}.onnx`, sources: sources ?? p.targets, keep: targets }]
439
- if (isNode) await checkLocal(graphs, model)
528
+ : [{ spec: url || `${base}${model}/${p.file ?? `${model}.onnx`}`, sources: sources ?? p.targets, keep: targets, sha256: p.sha256 }]
529
+ if (isNode) graphs = await local(graphs, model, opts)
440
530
  return { graphs, modelType: p.modelType, sampleRate: p.sampleRate, targets, preset: p }
441
531
  }
442
532
 
@@ -459,8 +549,31 @@ async function resolveModel(opts) {
459
549
  // No pooled arena: one htdemucs segment peaks at 2.7 GB RSS in onnxruntime-node instead of 3.2 GB.
460
550
  const sessionOptions = { enableCpuMemArena: false, enableMemPattern: false }
461
551
 
462
- function loader(opts) {
463
- return spec => opts.session ? opts.session(spec, opts) : neuralLoad(spec, { backend: opts.device, sessionOptions })
552
+ // A graph's session; a fetched file with a SHA-256 checked against it first. A preset written for the GPU (`gpu`) runs
553
+ // on WebGPU where there is one (onnxruntime-web's in a browser, onnxruntime-node's in Node) unless `device` says
554
+ // otherwise: SCNet's LSTMs and the others' many small operators run no faster there
555
+ function loader(opts, preset) {
556
+ let device = opts.device ?? (preset?.gpu ? backends().then(b => b.includes('webgpu') ? 'webgpu' : undefined) : undefined)
557
+ let own = !opts.device && !opts.session && preset?.engine && !isNode ? engineSession(opts, preset) : null
558
+ return async g => {
559
+ if (opts.session) return opts.session(g.spec, opts)
560
+ let s = own && await own
561
+ if (s) return s
562
+ let spec = g.sha256 && !g.spec.startsWith('file:') ? await checked(g.spec, g.sha256) : g.spec
563
+ return neuralLoad(spec, { backend: await device, sessionOptions })
564
+ }
565
+ }
566
+
567
+ // A fetched file's bytes, refused unless of its SHA-256
568
+ async function checked(url, hash, opts) {
569
+ let bytes = await fetchModel(url, opts), hex = await sha256(bytes)
570
+ 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`)
571
+ return bytes
572
+ }
573
+
574
+ async function sha256(bytes) {
575
+ let subtle = globalThis.crypto?.subtle ?? (await import('node:crypto')).webcrypto.subtle
576
+ return Array.from(new Uint8Array(await subtle.digest('SHA-256', bytes)), b => b.toString(16).padStart(2, '0')).join('')
464
577
  }
465
578
 
466
579
  function outputOf(session, out, i = 0) {
@@ -549,7 +662,7 @@ async function separateSpectral(channels, rate, opts, graphs, modelType, load) {
549
662
  let wopts = { iterations, softmask: opts.softmask ?? false, eps: opts.eps, residual }
550
663
  let sessions = []
551
664
  try {
552
- for (let g of graphs) sessions.push(await load(g.spec))
665
+ for (let g of graphs) sessions.push(await load(g))
553
666
  let C = channels.length, N = channels[0].length
554
667
  let { starts, size, weight } = crossfadeChunks(N, rate, opts)
555
668
  return await overlapAdd(N, C, starts, size, weight, async (start, len) => {
@@ -643,7 +756,7 @@ async function separateHybrid(channels, rate, opts, graphs, load) {
643
756
 
644
757
  let result = {}
645
758
  for (let g of graphs) {
646
- let session = await load(g.spec)
759
+ let session = await load(g)
647
760
  try {
648
761
  let L = session.inputs?.[0]?.dims?.[2]
649
762
  L = typeof L === 'number' && L > 0 ? L : opts.segment ?? 343980 // htdemucs: int(7.8 · 44100)
@@ -718,7 +831,7 @@ async function separateComplex(channels, rate, opts, graphs, load, p) {
718
831
  let { norm, scale: back } = opts.standardize ?? p?.standardize ?? true ? normalize(channels) : { norm: channels, scale: c => c }
719
832
  let result = {}
720
833
  for (let g of graphs) {
721
- let session = await load(g.spec)
834
+ let session = await load(g)
722
835
  try {
723
836
  let T = session.inputs?.[0]?.dims?.[3]
724
837
  T = typeof T === 'number' && T > 0 ? T : opts.frames ?? p?.frames
@@ -764,7 +877,7 @@ async function separateMultires(channels, rate, opts, graphs, load, p) {
764
877
  let windows = opts.windows ?? p?.windows, hop = opts.hop ?? p?.hop ?? 256
765
878
  let C = channels.length, N = channels[0].length, result = {}
766
879
  for (let g of graphs) {
767
- let session = await load(g.spec)
880
+ let session = await load(g)
768
881
  try {
769
882
  let { starts, size, weight } = crossfadeChunks(N, rate, { ...opts, chunk: opts.chunk ?? p?.chunk })
770
883
  Object.assign(result, await overlapAdd(N, C, starts, size, weight, async (start, len) => {
@@ -847,7 +960,7 @@ async function separateWaveform(channels, rate, opts, graphs, load) {
847
960
  let { starts, size, weight } = crossfadeChunks(N, rate, opts)
848
961
  let result = {}
849
962
  for (let g of graphs) {
850
- let session = await load(g.spec)
963
+ let session = await load(g)
851
964
  try {
852
965
  let out = await overlapAdd(N, C, starts, size, weight, async (start, len) => {
853
966
  let data = new Float32Array(C * len)
@@ -898,7 +1011,7 @@ export default async function separate(audio, opts = {}) {
898
1011
  if (!channelData[0].length) return { stems: Object.fromEntries(targets.map(t => [t, empty()])), sampleRate: rate, residual: empty() }
899
1012
  let targetRate = opts.targetRate ?? modelRate ?? rate
900
1013
  let proc = targetRate !== rate ? channelData.map(c => resampleSinc(c, { from: rate, to: targetRate })) : channelData
901
- let load = loader(opts)
1014
+ let load = loader(opts, preset)
902
1015
  // the input at the loudness the model trained at, its stems scaled back (a silent input as it is)
903
1016
  let level = preset?.loudness == null ? -Infinity : lufs(proc, targetRate), gain = Number.isFinite(level) ? 10 ** ((preset.loudness - level) / 20) : 1
904
1017
  if (gain !== 1) proc = proc.map(c => c.map(v => v * gain))