@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/NOTICE +5 -3
- package/README.md +94 -10
- package/index.d.ts +11 -2
- package/package.json +6 -2
- package/roformer.js +396 -0
- package/scripts/compact.py +351 -0
- package/scripts/engine.mjs +58 -0
- package/scripts/export-roformer.py +481 -0
- package/scripts/export-scnet.py +6 -6
- package/scripts/reference.py +5 -2
- package/separate.js +144 -31
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,
|
|
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
|
-
//
|
|
375
|
-
//
|
|
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
|
-
//
|
|
409
|
-
//
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
let [
|
|
414
|
-
let
|
|
415
|
-
|
|
416
|
-
let
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
463
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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))
|