@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/NOTICE +32 -2
- package/README.md +167 -18
- package/index.d.ts +57 -7
- package/package.json +11 -2
- package/scripts/compact.py +351 -0
- package/scripts/export-mrx.py +191 -0
- package/scripts/export-scnet.py +274 -0
- package/scripts/export-tiger.py +304 -0
- package/scripts/reference.py +41 -2
- package/separate.js +323 -38
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
|
-
//
|
|
346
|
-
|
|
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
|
-
//
|
|
372
|
-
//
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
let [
|
|
377
|
-
let
|
|
378
|
-
|
|
379
|
-
let
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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 =>
|
|
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)
|