@audio/neural-separate 0.1.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 ADDED
@@ -0,0 +1,36 @@
1
+ @audio/neural-separate
2
+ Copyright 2026 Dmitry Iv. and audiojs contributors
3
+
4
+ This product includes a JavaScript translation of parts of open-unmix-pytorch
5
+ (https://github.com/sigsep/open-unmix-pytorch) and norbert (https://github.com/sigsep/norbert):
6
+ filtering.py's multichannel Wiener filter and its expectation-maximization, with their defaults,
7
+ and model.Separator's 300-frame windowing, into separate.js. Both under the MIT License:
8
+
9
+ Copyright (c) 2019 Inria (Fabian-Robert Stöter, Antoine Liutkus)
10
+
11
+ This product includes a JavaScript translation of parts of Demucs
12
+ (https://github.com/facebookresearch/demucs): apply.apply_model's segment overlap-add and
13
+ htdemucs.py's spectrogram framing, into separate.js. Under the MIT License:
14
+
15
+ Copyright (c) Meta Platforms, Inc. and affiliates.
16
+
17
+ The MIT License, for each notice above:
18
+
19
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this
20
+ software and associated documentation files (the "Software"), to deal in the Software
21
+ without restriction, including without limitation the rights to use, copy, modify, merge,
22
+ publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
23
+ to whom the Software is furnished to do so, subject to the following conditions:
24
+
25
+ The above copyright notice and this permission notice shall be included in all copies or
26
+ substantial portions of the Software.
27
+
28
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
29
+ INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
30
+ PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
31
+ FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
32
+ OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
33
+ DEALINGS IN THE SOFTWARE.
34
+
35
+ No model weights are included. The export scripts write them to the local cache; their terms are
36
+ their authors' (Open-Unmix umxhq: MIT; Demucs htdemucs weights: see the README).
package/README.md ADDED
@@ -0,0 +1,149 @@
1
+ # @audio/neural-separate
2
+
3
+ > Source separation (stems): Open-Unmix and Hybrid Transformer Demucs through ONNX, matching the original PyTorch implementations.
4
+
5
+ The ML upgrade to [`@audio/vocals`](https://github.com/audiojs/vocals)'s classical center-cancel: instead of one M/S trick, a trained model estimates each target (vocals, drums, bass, other), and the pipeline around it (STFT, Wiener refinement, chunking, overlap-add) is ported from the reference implementations and checked against them.
6
+
7
+ ```js
8
+ import separate from '@audio/neural-separate'
9
+
10
+ let { stems, residual } = await separate([left, right], { sampleRate: 44100, model: 'umxhq' })
11
+ stems.vocals // Float32Array[2]
12
+ ```
13
+
14
+ ## Install
15
+
16
+ ```
17
+ npm install @audio/neural-separate onnxruntime-node
18
+ ```
19
+
20
+ In the browser `@audio/neural-runtime` runs `onnxruntime-web` instead (`device: 'wasm'` or `'webgpu'`).
21
+
22
+ ## Models
23
+
24
+ | `model` | Architecture | Files | Weights | SDR, MUSDB18 previews: vocals · drums · bass · other | Real-time factor: Node CPU · browser wasm · WebGPU |
25
+ |---|---|---|---|---|---|
26
+ | `'umxhq'` | Open-Unmix: bi-LSTM on magnitude spectrograms, multichannel Wiener EM | 4 × 35.6 MB (fp16: 4 × 17.8 MB) | MIT | 6.75 · 6.11 · 5.00 · 3.36 | 0.19 (vocals only: 0.11) · 0.12 · 0.29 |
27
+ | `'htdemucs'` | Hybrid Transformer Demucs: waveform and spectrogram U-Nets joined by a cross-domain transformer | 174 MB | research only | 8.86 · 9.55 · 9.30 · 5.69 | 1.4 (vocals only: 1.2) · 0.70 · 0.21 |
28
+ | `'htdemucs_ft'` | four fine-tuned HTDemucs, one per source | 4 × 174 MB | research only | not measured | vocals only: 1.1 · not measured · not measured |
29
+
30
+ SDR as measured below (Verification), identical to the Python originals'. The previews are 7 s excerpts, so these numbers sit apart from full-track results in scale, not in order: the Demucs README reports overall SDR 9.0 for fine-tuned HT Demucs against 5.3 for Open-Unmix on the MUSDB18-HQ test set.
31
+
32
+ Real-time factor: processing time over duration, lower is faster. 27 s of music; onnxruntime-node and onnxruntime-web 1.30, the web one in headless Chromium (wasm, Metal WebGPU); the best of two to four runs spread over a day on a 14-core M4 Max shared with other jobs (load averages 45 to 180), so upper bounds. The Python originals' best there: 0.43 (open-unmix) and 2.8 (demucs). umxhq's time is mostly JS (ONNX Runtime takes 6 to 12 %: STFT, Wiener EM, iSTFT around it), and it runs faster on wasm than on WebGPU; htdemucs's is about 80 % inference, where WebGPU pays. Memory: umxhq 1.3 GB, hybrid models 3 GB at peak in Node.
33
+
34
+ `targets` picks a subset: `targets: ['vocals']` runs only the vocals graph of `umxhq` (against the residual, see Algorithm: 2.4× faster, vocals SDR 6.50 instead of 6.75) and of `htdemucs_ft`, and skips the other sources' iSTFT for `htdemucs`. Presets resample to their 44.1 kHz and back.
35
+
36
+ ## Weights
37
+
38
+ Nothing ships in this package and nothing is fetched by default. Export the weights once from the original checkpoints:
39
+
40
+ ```sh
41
+ pip install torch openunmix onnx onnxruntime onnxscript
42
+ python3 node_modules/@audio/neural-separate/scripts/export-openunmix.py --model umxhq --verify
43
+
44
+ pip install demucs
45
+ python3 node_modules/@audio/neural-separate/scripts/export-htdemucs.py --model htdemucs --verify
46
+ ```
47
+
48
+ They land in `$AUDIO_NEURAL_CACHE/<model>/`, default `~/.cache/audiojs/neural/<model>/`, where a preset looks in Node; a missing file throws, naming itself and the script. Elsewhere, serve that directory and pass its URL: `separate(audio, { model: 'umxhq', weights: 'https://…/' })`; `@audio/neural-runtime` fetches each file once and caches it (Cache API in the browser). `--verify` compares the ONNX graphs with the PyTorch modules and writes the reference separation `test.js` checks the JS pipeline against. `export-openunmix.py --fp16` also writes float16 weights with float32 I/O, half the size.
49
+
50
+ - **`export-openunmix.py`** exports each target's `OpenUnmix` through a wrapper that takes the frame count from the traced tensor (upstream reads it off `x.data.shape`, which export freezes), with the TorchScript exporter (the `torch.export` one bakes the LSTM's sequence length into a reshape). Magnitude `[1, C, F, T]` in and out, T dynamic. `umxhq`'s bandwidth restriction (`max_bin`, 1487 of 2049 bins for `n_fft=4096`@44.1kHz) crops the network's *input* below 16 kHz for efficiency; the final dense layer regresses the **full** bin range from that reduced representation (a learned extrapolation, not a literal zero-fill).
51
+ - **`export-htdemucs.py`** follows [sevagh/demucs.onnx](https://github.com/sevagh/demucs.onnx): the STFT and iSTFT, which ONNX export cannot carry, move out of the graph. Rather than a vendored copy of `htdemucs.py`, it runs upstream's own `forward` with its four transform methods swapped on the instance. One graph per 7.8 s segment: `mix [1, 2, 343980]` and `mix_spec [1, 4, 2048, 336]` (complex as channels) in, `stems_spec [1, 4, 4, 2048, 336]` and `stems_wave [1, 4, 2, 343980]` out; a source is the iSTFT of the first plus the second.
52
+
53
+ ## Algorithm
54
+
55
+ **`'openunmix'`, `'mask'`** (spectrogram models):
56
+
57
+ 1. **STFT** each channel — `n_fft` 4096, hop 1024, periodic Hann, `center=True` (torch.stft-compatible reflect padding) — own implementation on top of [`fourier-transform`](https://github.com/scijs/fourier-transform)'s raw FFT (not its bundled `stft` submodule, which zero-pads instead of centering — Open-Unmix's own filterbank is `torch.stft(..., center=True, pad_mode="reflect")`, so this package matches that framing exactly rather than reusing a differently-conventioned STFT).
58
+ 2. **Magnitude** per channel → **one ONNX run per target** (or one multi-target graph) → estimated magnitude (`modelType: 'openunmix'`) or a `[0,1]` mask multiplied by the mixture magnitude (`modelType: 'mask'`).
59
+ 3. **Multichannel Wiener EM** (`wiener` option, default 1 iteration) refines the per-target magnitude estimates into full complex spectra, using the mixture's spatial (inter-channel) structure — see below. It runs in windows of 300 frames, as open-unmix's `Separator` does (`wiener_win_len=300`), each window estimating its own spatial covariances; a single target runs against a residual (open-unmix's `residual=True`), since EM needs two sources.
60
+ 4. **iSTFT** each target's channel spectra back to waveforms.
61
+ 5. Long files are **chunked** (`chunk` seconds, default 30, with `overlap` seconds crossfade, default 2) so a bi-LSTM-class model's memory stays bounded; chunk stems are stitched back with a linear crossfade. Trade-off: the model only sees `chunk` seconds of context at once, same reasoning as Open-Unmix's own reference `Separator` batching its Wiener step into `wiener_win_len=300`-frame windows (`openunmix/model.py`) — coarser here (whole pipeline per chunk, not just the Wiener step), chosen for a simple, uniform memory bound across both spectral and waveform model families.
62
+
63
+ **`'hybrid'`** (Hybrid Transformer Demucs): `demucs.apply.apply_model` with `split=True, overlap=0.25, shifts=0`, and `demucs.api`'s normalization by the whole input's mean and standard deviation. Segments of 7.8 s start every 5.85 s; each is centered in its window with the neighbouring input as context, zeros past the ends. Per segment: HTDemucs's framing (reflect re-padding by ¾ hop, `normalized=True`, the Nyquist bin and two edge frames dropped) → ONNX → frequency branch through the iSTFT plus time branch. Segments overlap-add under a triangular window. `shifts` (averaging randomly time-shifted runs, "up to 0.2 points" of SDR per demucs's docstring) is not implemented; the CLI's default single shift averages nothing.
64
+
65
+ `modelType: 'waveform'` (Demucs v2-class) skips steps 1–4 entirely: chunked raw audio in `[1, C, N]`, stacked stems out `[1, S, C, N]`, no STFT or Wiener step — Demucs v2 operates in the time domain by design.
66
+
67
+ ## Verification
68
+
69
+ | Check | Result |
70
+ |---|---|
71
+ | ONNX vs PyTorch module, random input (`--verify`) | umxhq max \|diff\| ≤ 5.7e-6 of max \|y\| (fp16: ≤ 5.3e-3); stems of htdemucs ≤ 1.6e-4, of htdemucs_ft ≤ 2.5e-4 |
72
+ | Pipeline vs `openunmix.Separator` in float64, 9 s reference mix (`test.js`) | 112–134 dB SNR per stem |
73
+ | Pipeline vs `demucs.apply.apply_model`, same mix (`test.js`) | htdemucs 80–84 dB, htdemucs_ft 78–89 dB SNR per stem |
74
+ | `wienerFilter` vs open-unmix's `wiener()`, float64 fixture (`test.js`) | ratio mask: 3e-12; mixture phase: 1.7e-7, upstream's `atan2` adds a float32 π |
75
+
76
+ The 50 test tracks of the MUSDB18 7 s previews (`musdb.DB(download=True)`; its terms are educational and non-commercial, so it serves measurement only), SDR by [museval](https://github.com/sigsep/sigsep-mus-eval) (BSSEval v4, 1 s windows, median over windows, then over tracks: the SiSEC 2018 aggregation):
77
+
78
+ | | vocals | drums | bass | other | this package vs Python, waveform SNR per track (median / lowest) |
79
+ |---|---|---|---|---|---|
80
+ | `umxhq`, open-unmix `Separator` | 6.75 | 6.11 | 5.00 | 3.36 | |
81
+ | `umxhq`, this package | 6.75 | 6.11 | 5.00 | 3.36 | 116–125 / 72–83 dB |
82
+ | `htdemucs`, demucs `apply_model` | 8.86 | 9.55 | 9.30 | 5.69 | |
83
+ | `htdemucs`, this package | 8.86 | 9.55 | 9.30 | 5.69 | 78–88 / 56–75 dB |
84
+
85
+ Per track, the SDRs differ by at most 0.003 dB.
86
+
87
+ Upstream runs its Wiener EM in float32. On the reference mix that alone moves its stems by 71 to 93 dB SNR against a float64 run of the same code, which this port matches to 112 to 134 dB; the float64 run is the reference.
88
+
89
+ ## Wiener filter
90
+
91
+ `wienerFilter(mixStft, estimates, opts)` is a from-scratch JS port of the algorithm in [norbert](https://github.com/sigsep/norbert) (Liutkus & Stöter), with [open-unmix-pytorch](https://github.com/sigsep/open-unmix-pytorch)'s `openunmix/filtering.py` defaults (`eps=1e-10`, `softmask=False`, `scale_factor=10`) — softmask off by default, matching upstream's own recommendation ("`softmask=False` is recommended... once the model estimates are themselves good"). The core update — re-estimate each source's power spectral density and spatial covariance matrix, rebuild the modelled mixture covariance, apply the resulting multichannel Wiener gain, iterate — is the local Gaussian model from **Duong, Vincent, Gribonval, "Under-determined reverberant audio source separation using a full-rank spatial covariance model," IEEE TASLP 18(7), 2010**. The gain applies as `v_j R_j (Cxx⁻¹ x)`: one inverse times the mixture per bin, then one C×C product per source. The covariances are Hermitian, so stereo takes the closed-form 2×2 inverse, other channel counts a Gauss-Jordan solver.
92
+
93
+ ```js
94
+ import { stft, wienerFilter, istft } from '@audio/neural-separate'
95
+
96
+ let mixStft = [stft(left, {}), stft(right, {})] // per channel
97
+ let estimates = { vocals: vocalsMag, drums: drumsMag } // per target: magnitude[channel][frame] (Float64Array(bins))
98
+ let complex = wienerFilter(mixStft, estimates, { iterations: 1 }) // per target: complex STFT per channel
99
+ let vocals = complex.vocals.map(ch => istft(ch, { length: left.length }))
100
+ ```
101
+
102
+ `iterations: 0` returns the initial estimate untouched ("raw masks"). `softmask: true` uses a ratio mask that sums to the mixture exactly, by construction; `residual: true` appends a `'residual'` target (mixture minus the other targets) computed before EM. **Known limitation**: multichannel Wiener EM assumes genuine inter-channel diversity — mono content duplicated to stereo (identical L≡R) gives a rank-1 spatial covariance matrix per bin, a degenerate case where EM iterations can occasionally underperform `iterations: 0` on an already-good separation (real stereo mixes, with any actual left/right difference, don't hit this — verified in `test.js`).
103
+
104
+ ## `opts`
105
+
106
+ | Option | Default | |
107
+ |---|---|---|
108
+ | `sampleRate` | — | required unless `audio` is `{ channelData, sampleRate }` |
109
+ | `model` | — | required. Preset `'umxhq' \| 'htdemucs' \| 'htdemucs_ft'` · `url \| bytes` (single target, named `'stem'`) · `{ target: url \| { url, targets }, ... }` (one graph per target, Open-Unmix's own layout; a multi-source graph contributes its own target) · `{ url, targets: [...] }` (one multi-target graph, stacks a target axis) |
110
+ | `targets` | all | the targets to return |
111
+ | `weights` | `$AUDIO_NEURAL_CACHE` or `~/.cache/audiojs/neural` (Node) | where a preset's files are: URL, or a directory in Node |
112
+ | `modelType` | `'openunmix'` | `'openunmix'` (magnitude out) · `'mask'` (`[0,1]` mask out, multiplied by mixture magnitude) · `'hybrid'` (demucs.onnx contract) · `'waveform'` (Demucs v2-class); presets know theirs |
113
+ | `wiener` | `1` | EM iterations; `0` = raw masks. Ignored for `'hybrid'` and `'waveform'` |
114
+ | `wienerWindow` | `300` | frames per EM window |
115
+ | `chunk` / `overlap` | `30` / `2` (seconds) | `overlap` must be `< chunk`; not used by `'hybrid'`, which segments as demucs does |
116
+ | `segment` | `343980` | `'hybrid'`: segment length in samples when the graph does not declare its input length |
117
+ | `targetRate` | the preset's rate, else the input rate | resample to the model's rate for inference; stems are resampled back to the input rate |
118
+ | `device` | — | passed through to `@audio/neural-runtime`'s `load()` as `backend` |
119
+ | `dtype` | `'float32'` | only `'float32'` tensor marshalling is implemented; anything else throws |
120
+ | `progress` | — | `({ chunk, totalChunks }) => {}` |
121
+ | `session` | — | overrides `@audio/neural-runtime`'s `load()` — for tests, or a custom ORT setup |
122
+
123
+ Mono input is duplicated to stereo internally (matching `openunmix.utils.preprocess`'s own "if we have mono, we duplicate it to get stereo"), so stems always come back stereo. `separate()` returns `{ stems, sampleRate, residual }` — `residual` is the mixture minus the sum of all stems, per channel, at the input rate.
124
+
125
+ ## Precedence and licenses
126
+
127
+ Open-source stem separation has three lineages; this package runs the first two.
128
+
129
+ | Project | Code | Weights | |
130
+ |---|---|---|---|
131
+ | **Open-Unmix** (`umx`, `umxhq`) | MIT | MIT ([Zenodo](https://zenodo.org/records/3370489)-declared) | trained on MUSDB18(-HQ); this package's primary target |
132
+ | **Open-Unmix** (`umxl`) | MIT | **CC BY-NC-SA 4.0 — non-commercial only** | despite being the `openunmix` package's own default variant name; trained on a private stems dataset (see the project [README](https://github.com/sigsep/open-unmix-pytorch#pre-trained-models)) |
133
+ | **Demucs** (Meta; `htdemucs`, `htdemucs_ft`) | MIT | **research only**: "The model weights are not covered by the MIT license, and are provided only for scientific purposes" (the author in [#327](https://github.com/facebookresearch/demucs/issues/327), 2022; again in [#508](https://github.com/facebookresearch/demucs/issues/508), 2023) | trained on MUSDB18-HQ plus 800 songs; the repository is archived, maintained at [adefossez/demucs](https://github.com/adefossez/demucs) |
134
+ | **Spleeter** (Deezer) | MIT | **undocumented** | the README licenses only "the code of Spleeter"; the pretrained weights' license is an open, unresolved question ([deezer/spleeter#898](https://github.com/deezer/spleeter/issues/898)) — do not assume MIT |
135
+
136
+ Audit any weight source yourself before shipping it — this table reflects what each project states as of this writing, not a guarantee. `scripts/export-openunmix.py` defaults to `umxhq` (not `umxl`) for exactly this reason.
137
+
138
+ ## Reference
139
+
140
+ Stöter, Uhlich, Liutkus, Mitsufuji, "Open-Unmix - A Reference Implementation for Music Source Separation," *JOSS* 4(41), 2019. · Rouard, Massa, Défossez, "Hybrid Transformers for Music Source Separation," *ICASSP* 2023. · Duong, Vincent, Gribonval, "Under-determined reverberant audio source separation using a full-rank spatial covariance model," *IEEE TASLP* 18(7), 2010. · Rafii, Liutkus, Stöter, Mimilakis, Bittner, "The MUSDB18 corpus for music separation," 2017. · [norbert](https://github.com/sigsep/norbert) (Liutkus & Stöter) · [open-unmix-pytorch](https://github.com/sigsep/open-unmix-pytorch) · [Demucs](https://github.com/facebookresearch/demucs) · [demucs.onnx](https://github.com/sevagh/demucs.onnx) · [museval](https://github.com/sigsep/sigsep-mus-eval) · [Spleeter](https://github.com/deezer/spleeter).
141
+
142
+ **Use when:** you have (or can license) an ONNX-exported spectrogram-mask, Hybrid-Demucs or waveform separation model and want to run it — with proper multichannel Wiener refinement, not just the raw mask — dependency-free, in Node or the browser; `umxhq` where the weights must be MIT, `htdemucs` for the higher SDR under research-only terms.<br>
143
+ **Not for:** the classical, model-free case — reach for [`@audio/vocals`](https://github.com/audiojs/vocals) when a center-panned M/S trick is all you need; training a model (this is inference-only); real-time streaming (neither the bi-LSTM Open-Unmix nor Hybrid Transformer Demucs is causal — offline/chunked only, same as upstream).
144
+
145
+ ---
146
+
147
+ Part of the [@audio/neural](https://github.com/audiojs/neural) lane.
148
+
149
+ MIT © [audiojs](https://github.com/audiojs)
package/index.d.ts ADDED
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Source separation (stems) through @audio/neural-runtime's ONNX adapter: Open-Unmix-class
3
+ * spectrogram models (Stöter, Uhlich, Liutkus, Mitsufuji, JOSS 2019), Hybrid Transformer Demucs
4
+ * (Rouard, Massa, Défossez, ICASSP 2023) with STFT and iSTFT outside the graph, and waveform graphs.
5
+ */
6
+
7
+ /** Mono duplicates internally; multichannel arrays must share the same length. */
8
+ export type AudioInput = Float32Array[] | { channelData: Float32Array[]; sampleRate: number }
9
+
10
+ /** A model spec resolvable by @audio/neural-runtime's load(): URL string or raw ONNX bytes. */
11
+ export type ModelSpec = string | Uint8Array
12
+
13
+ /** Presets whose files the export scripts write: <weights>/<name>/<target>.onnx or <weights>/<name>/<name>.onnx */
14
+ export type ModelName = 'umxhq' | 'htdemucs' | 'htdemucs_ft'
15
+
16
+ /** One graph whose output stacks several sources on its S axis */
17
+ export type MultiGraph = { url: ModelSpec; targets: string[] }
18
+
19
+ export type ModelOption =
20
+ | ModelName
21
+ | ModelSpec // single target, named 'stem'
22
+ | Record<string, ModelSpec | MultiGraph> // one graph per target (Open-Unmix's layout); a multi-source graph contributes its own target
23
+ | MultiGraph
24
+
25
+ export type ModelType =
26
+ | 'openunmix' // target model outputs the estimated magnitude directly
27
+ | 'mask' // target model outputs a [0,1] mask; multiplied by the mixture magnitude
28
+ | 'hybrid' // demucs.onnx contract: mix [1,C,L] + CaC spectrogram [1,2C,F,T] in; [1,S,2C,F,T] + [1,S,C,L] out
29
+ | 'waveform' // Demucs v2-class: [1,C,N] waveform in, [1,S,C,N] stacked waveforms out
30
+
31
+ export interface ModelPreset {
32
+ modelType: ModelType
33
+ sampleRate: number
34
+ /** in the order stems come back */
35
+ targets: string[]
36
+ /** one graph per target (<target>.onnx), else one graph for all (<name>.onnx) */
37
+ perTarget?: boolean
38
+ }
39
+
40
+ /** Model presets by name */
41
+ export const models: Record<ModelName, ModelPreset>
42
+
43
+ /** A neural-runtime-shaped session — enough of it to drive separate() with a test double. */
44
+ export interface Session {
45
+ run(feeds: Record<string, { data: Float32Array; dims: number[]; type: string }>): Promise<Record<string, { data: Float32Array; dims: number[] }>>
46
+ /** 'hybrid' reads its segment length off inputs[0].dims[2] when declared */
47
+ inputs?: { name: string; dims?: number[] }[]
48
+ outputs?: { name: string }[]
49
+ free?(): void
50
+ }
51
+
52
+ export interface SeparateOptions {
53
+ /** required unless audio is the { channelData, sampleRate } form */
54
+ sampleRate?: number
55
+ model: ModelOption
56
+ /** ignored for presets, which know theirs (default 'openunmix') */
57
+ modelType?: ModelType
58
+ /** subset of the model's targets to return; spectral models skip the other graphs, and one target runs Wiener EM against the residual */
59
+ targets?: string[]
60
+ /** where a preset's files are: URL, or a directory in Node (default $AUDIO_NEURAL_CACHE or ~/.cache/audiojs/neural) */
61
+ weights?: string
62
+ /** iterations of multichannel Wiener EM refinement (default 1); 0 = raw masks. Ignored for modelType 'hybrid' and 'waveform'. */
63
+ wiener?: number
64
+ /** wienerFilter's softmask option (default false, matching open-unmix-pytorch) */
65
+ softmask?: boolean
66
+ /** wienerFilter's eps (default 1e-10) */
67
+ eps?: number
68
+ /** frames per Wiener EM window (default 300, open-unmix Separator's wiener_win_len) */
69
+ wienerWindow?: number
70
+ /** 'hybrid': segment length in samples when the graph does not declare it (default 343980, htdemucs's 7.8 s) */
71
+ segment?: number
72
+ /** 'openunmix' | 'mask' | 'waveform': chunk length in seconds (default 30) */
73
+ chunk?: number
74
+ /** crossfade overlap in seconds between chunks (default 2); must be < chunk */
75
+ overlap?: number
76
+ /** resample to this rate for model inference, stems back to the input rate (default: the preset's rate, else the input's) */
77
+ targetRate?: number
78
+ /** STFT size for the spectral pipeline (default 4096, Open-Unmix's n_fft) */
79
+ n?: number
80
+ /** STFT hop for the spectral pipeline (default 1024, Open-Unmix's n_hop) */
81
+ hop?: number
82
+ /** passed through to @audio/neural-runtime's load() as `backend` */
83
+ device?: string
84
+ /** only 'float32' is implemented; anything else throws */
85
+ dtype?: 'float32'
86
+ progress?: (p: { chunk: number; totalChunks: number }) => void
87
+ /** overrides @audio/neural-runtime's load() — for tests, or a custom ORT setup */
88
+ session?: (model: ModelSpec, opts: SeparateOptions) => Session | Promise<Session>
89
+ }
90
+
91
+ export interface SeparateResult {
92
+ /** keys are the model's target names ('stem' for a single bare model) */
93
+ stems: Record<string, Float32Array[]>
94
+ sampleRate: number
95
+ /** mixture minus the sum of all stems, per channel, at the input rate */
96
+ residual: Float32Array[]
97
+ }
98
+
99
+ /** Separate a mixture into stems. See README for the ONNX I/O contract per modelType. */
100
+ export default function separate(audio: AudioInput, opts: SeparateOptions): Promise<SeparateResult>
101
+
102
+ // ------------------------------------------------------------- STFT / iSTFT
103
+
104
+ export interface StftOptions {
105
+ /** FFT size, power of 2 (default 4096) */
106
+ n?: number
107
+ /** hop size (default 1024) */
108
+ hop?: number
109
+ /** analysis window, length n (default: periodic Hann) */
110
+ window?: Float64Array
111
+ /** torch.stft-compatible reflect-padding by n/2 on both ends (default true) */
112
+ center?: boolean
113
+ }
114
+
115
+ export interface ComplexStft {
116
+ /** re[frame] is a Float64Array(bins), bins = n/2+1 */
117
+ re: Float64Array[]
118
+ im: Float64Array[]
119
+ n: number
120
+ hop: number
121
+ bins: number
122
+ center: boolean
123
+ }
124
+
125
+ /** Mono complex STFT. Frame count = 1 + floor(x.length / hop) when center=true (torch.stft-compatible). */
126
+ export function stft(x: Float32Array | Float64Array, opts?: StftOptions): ComplexStft
127
+
128
+ export interface IstftOptions {
129
+ /** exact output length (torch.istft-compatible crop/pad); default: paddedLength − 2·(n/2) when center */
130
+ length?: number
131
+ /** synthesis window (default: the same periodic Hann as stft()) */
132
+ window?: Float64Array
133
+ }
134
+
135
+ /** Exact inverse of stft() via squared-window-normalized overlap-add (WOLA). */
136
+ export function istft(frames: ComplexStft, opts?: IstftOptions): Float32Array
137
+
138
+ // ------------------------------------------------------------- Wiener filter
139
+
140
+ export interface WienerOptions {
141
+ /** EM refinement steps (default 1); 0 returns the initial softmask/phase-substituted estimate untouched */
142
+ iterations?: number
143
+ /** true = ratio mask (sums to the mixture exactly); false = magnitude with the mixture's phase (default, open-unmix's recommendation) */
144
+ softmask?: boolean
145
+ /** appends a 'residual' target = mixture − Σ(other targets), computed before EM */
146
+ residual?: boolean
147
+ /** regularization floor (default 1e-10) */
148
+ eps?: number
149
+ /** EM numerical-stability rescaling divisor (default 10, open-unmix-pytorch's scale_factor) */
150
+ scaleFactor?: number
151
+ }
152
+
153
+ /**
154
+ * Multichannel Wiener EM (Liutkus & Stöter, github.com/sigsep/norbert; Duong,
155
+ * Vincent, Gribonval, IEEE TASLP 2010) with open-unmix-pytorch's defaults.
156
+ * estimates: per-target magnitude, one Float64Array(bins) per frame per
157
+ * channel — a single-channel estimate broadcasts to all mixture channels.
158
+ */
159
+ export function wienerFilter(
160
+ mixStft: ComplexStft[],
161
+ estimates: Record<string, Float64Array[][]>,
162
+ opts?: WienerOptions
163
+ ): Record<string, ComplexStft[]>
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@audio/neural-separate",
3
+ "version": "0.1.0",
4
+ "description": "Source separation (stems): Open-Unmix-class spectrogram masking + multichannel Wiener refinement, Hybrid Transformer Demucs, model-agnostic ONNX",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "main": "separate.js",
8
+ "types": "./index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./index.d.ts",
12
+ "default": "./separate.js"
13
+ },
14
+ "./package.json": "./package.json"
15
+ },
16
+ "files": [
17
+ "separate.js",
18
+ "index.d.ts",
19
+ "scripts/export-openunmix.py",
20
+ "scripts/export-htdemucs.py",
21
+ "scripts/reference.py",
22
+ "NOTICE"
23
+ ],
24
+ "dependencies": {
25
+ "@audio/neural-runtime": "^0.1.0",
26
+ "@audio/resample-sinc": "^1.1.2",
27
+ "fourier-transform": "^2.4.1"
28
+ },
29
+ "devDependencies": {
30
+ "onnxruntime-node": "^1.29.0",
31
+ "tst": "^9.4.0"
32
+ },
33
+ "scripts": {
34
+ "test": "node test.js"
35
+ },
36
+ "keywords": [
37
+ "audio",
38
+ "ml",
39
+ "neural",
40
+ "separate",
41
+ "stems",
42
+ "open-unmix",
43
+ "demucs",
44
+ "wiener",
45
+ "onnx"
46
+ ],
47
+ "license": "MIT",
48
+ "author": "Dmitry Iv. <dfcreative@gmail.com>",
49
+ "engines": {
50
+ "node": ">=18"
51
+ },
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "git+https://github.com/audiojs/neural.git",
55
+ "directory": "packages/neural-separate"
56
+ },
57
+ "funding": "https://github.com/sponsors/audiojs",
58
+ "publishConfig": {
59
+ "access": "public"
60
+ }
61
+ }
@@ -0,0 +1,230 @@
1
+ #!/usr/bin/env python3
2
+ """Export Demucs v4 (Hybrid Transformer Demucs) to the ONNX contract
3
+ @audio/neural-separate expects for modelType 'hybrid': the network without its
4
+ STFT and iSTFT, which ONNX export cannot carry. The split is sevagh's
5
+ demucs.onnx (github.com/sevagh/demucs.onnx, MIT); JS computes both transforms.
6
+
7
+ pip install torch demucs onnx onnxruntime
8
+ python3 export-htdemucs.py --model htdemucs --verify
9
+ python3 export-htdemucs.py --model htdemucs_ft --verify # four source-specialist graphs
10
+
11
+ One 7.8 s training segment (343980 samples at 44.1 kHz) per run:
12
+ inputs mix [1, 2, 343980] waveform
13
+ mix_spec [1, 4, 2048, 336] STFT as complex-as-channels (L re, L im, R re, R im)
14
+ outputs stems_spec [1, S, 4, 2048, 336] frequency branch, same layout
15
+ stems_wave [1, S, 2, 343980] time branch
16
+ stem = iSTFT(stems_spec) + stems_wave
17
+
18
+ Writes <out-dir>/<model>.onnx (htdemucs_ft: <out-dir>/<source>.onnx per
19
+ specialist), weights embedded. Default out-dir: $AUDIO_NEURAL_CACHE/<model>, else
20
+ ~/.cache/audiojs/neural/<model>. Last run: torch 2.14, demucs 4.1.0,
21
+ onnxruntime 1.30.
22
+
23
+ How: HTDemucs.forward runs unmodified with its four transform methods swapped
24
+ on the instance: _spec and _magnitude return the given spectrogram, _mask keeps
25
+ the frequency-branch estimate, _ispec returns 0 so forward returns the time
26
+ branch alone. sevagh edits a vendored copy of htdemucs.py to the same effect;
27
+ patching keeps upstream's forward as the single source. --verify rebuilds the
28
+ stems from the ONNX outputs with demucs's own _mask and _ispec and compares them
29
+ with model(mix) on the same segment.
30
+
31
+ Weights: Demucs's checkpoints "are not covered by the MIT license, and are
32
+ provided only for scientific purposes" (the author,
33
+ github.com/facebookresearch/demucs/issues/327). Memory: an export peaks near
34
+ 3.2 GB; htdemucs_ft exports and checks each specialist in its own process.
35
+ """
36
+ import argparse
37
+ import gc
38
+ import os
39
+ import subprocess
40
+ import sys
41
+ from pathlib import Path
42
+
43
+
44
+ def build_core(model):
45
+ import torch
46
+ import torch.nn as nn
47
+
48
+ class HybridCore(nn.Module):
49
+ def __init__(self, m):
50
+ super().__init__()
51
+ self.m = m
52
+
53
+ def forward(self, mix, mix_spec):
54
+ m, box = self.m, {}
55
+ m._spec = lambda x: mix_spec
56
+ m._magnitude = lambda z: z
57
+ m._mask = lambda z, x: box.setdefault("spec", x)
58
+ m._ispec = lambda z, length=None, scale=0: torch.zeros((), dtype=mix.dtype)
59
+ try:
60
+ wave = m(mix)
61
+ finally:
62
+ for k in ("_spec", "_magnitude", "_mask", "_ispec"):
63
+ del m.__dict__[k]
64
+ return box["spec"], wave
65
+
66
+ return HybridCore(model).eval()
67
+
68
+
69
+ def cac_spec(model, mix):
70
+ """The mix's STFT as HTDemucs sees it (its own _spec and _magnitude)."""
71
+ return model._magnitude(model._spec(mix))
72
+
73
+
74
+ def export_one(model, out_path, opset, verify):
75
+ import torch
76
+
77
+ L = int(model.segment * model.samplerate)
78
+ mix = torch.randn(1, model.audio_channels, L)
79
+ with torch.no_grad():
80
+ spec = cac_spec(model, mix)
81
+ # traced with grad enabled: under no_grad, nn.MultiheadAttention takes its fused
82
+ # fast path, aten::_native_multi_head_attention, which has no ONNX export
83
+ torch.onnx.export(
84
+ build_core(model),
85
+ (mix, spec),
86
+ str(out_path),
87
+ input_names=["mix", "mix_spec"],
88
+ output_names=["stems_spec", "stems_wave"],
89
+ opset_version=opset,
90
+ dynamo=False,
91
+ )
92
+ print(f"wrote {out_path} ({out_path.stat().st_size / 1e6:.1f} MB), segment {L} samples")
93
+ gc.collect() # the traced graph: export peaks near 3 GB
94
+ if verify:
95
+ verify_export(model, out_path, L)
96
+
97
+
98
+ def verify_export(model, onnx_path, L):
99
+ """Stems rebuilt from onnxruntime's outputs against model(mix): a random
100
+ segment and a harmonic one, relative max |diff| < 1e-3 (float32 through a
101
+ transformer; a transform mismatch would show at order 1)."""
102
+ import math
103
+ import numpy as np
104
+ import onnxruntime as ort
105
+ import torch
106
+
107
+ opts = ort.SessionOptions()
108
+ opts.enable_cpu_mem_arena = False # give memory back between runs
109
+ opts.enable_mem_pattern = False
110
+ session = ort.InferenceSession(str(onnx_path), opts, providers=["CPUExecutionProvider"])
111
+ t = torch.arange(L) / model.samplerate
112
+ tone = sum(torch.sin(2 * math.pi * f * t) / k for k, f in enumerate((110, 220, 440, 880, 1760), 1))
113
+ for name, mix in (("noise", 0.1 * torch.randn(1, 2, L)), ("tones", 0.2 * torch.stack([tone, 0.8 * tone])[None])):
114
+ with torch.no_grad():
115
+ expected = model(mix).numpy()
116
+ spec = cac_spec(model, mix)
117
+ s, w = session.run(None, {"mix": mix.numpy(), "mix_spec": spec.numpy()})
118
+ actual = (model._ispec(model._mask(None, torch.from_numpy(s)), L) + torch.from_numpy(w)).numpy()
119
+ rel = np.abs(actual - expected).max() / max(np.abs(expected).max(), 1e-12)
120
+ ok = rel < 1e-3
121
+ print(f" verify {onnx_path.name} {name}: max|diff|/max|y| = {rel:.2e} " + ("OK" if ok else "FAIL"))
122
+ if not ok:
123
+ raise SystemExit(f"{onnx_path}: verification failed ({rel:.2e} >= 1e-3)")
124
+ del session
125
+ gc.collect()
126
+
127
+
128
+ def write_reference(bag, out_dir):
129
+ """demucs.api.Separator's path: whole-input normalization, apply_model with split=True,
130
+ overlap=0.25 and shifts=0 (the CLI's one random shift is not reproducible)."""
131
+ import torch
132
+ from demucs.apply import apply_model
133
+
134
+ sys.path.insert(0, str(Path(__file__).parent))
135
+ sys.dont_write_bytecode = True # no __pycache__ beside the package's scripts
136
+ import reference
137
+
138
+ def separate(mix):
139
+ wav = torch.from_numpy(mix)
140
+ ref = wav.mean(0)
141
+ mean, std = ref.mean(), ref.std() + 1e-8
142
+ with torch.no_grad():
143
+ est = apply_model(bag, ((wav - mean) / std)[None], shifts=0, split=True, overlap=0.25)[0] * std + mean
144
+ return {s: est[k].numpy() for k, s in enumerate(bag.sources)}
145
+
146
+ reference.write(out_dir, separate)
147
+
148
+
149
+ def bag_of(name):
150
+ """(signature, source) per model of a bag of source specialists (htdemucs_ft), read off the
151
+ bag's YAML in the demucs package without importing torch; None for a single model."""
152
+ import importlib.util
153
+ import yaml
154
+
155
+ spec = importlib.util.find_spec("demucs")
156
+ f = Path(spec.origin).parent / "remote" / f"{name}.yaml" if spec else None
157
+ bag = yaml.safe_load(f.read_text()) if f and f.exists() else {}
158
+ if len(bag.get("models", [])) < 2 or not bag.get("weights"):
159
+ return None
160
+ sources = ["drums", "bass", "other", "vocals"] # demucs.pretrained.SOURCES, the weights' column order
161
+ return [(sig, sources[max(range(len(w)), key=lambda k: w[k])]) for sig, w in zip(bag["models"], bag["weights"])]
162
+
163
+
164
+ def main():
165
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
166
+ ap.add_argument("--model", default="htdemucs", help="htdemucs | htdemucs_ft | htdemucs_6s")
167
+ ap.add_argument("--out-dir", default=None, help="default: $AUDIO_NEURAL_CACHE/<model> or ~/.cache/audiojs/neural/<model>")
168
+ ap.add_argument("--opset", type=int, default=17)
169
+ ap.add_argument("--verify", action="store_true", help="compare the rebuilt stems with model(mix), and write the "
170
+ "reference separation test.js compares against (scripts/reference.py)")
171
+ ap.add_argument("--source", default=None, help=argparse.SUPPRESS) # a bag's specialist, in its own process
172
+ ap.add_argument("--reference", action="store_true", help=argparse.SUPPRESS) # a bag's reference, in its own process
173
+ ap.add_argument("--check", action="store_true", help=argparse.SUPPRESS) # verify a specialist's export, in its own process
174
+ args = ap.parse_args()
175
+
176
+ cache = os.environ.get("AUDIO_NEURAL_CACHE") or Path.home() / ".cache" / "audiojs" / "neural"
177
+ out_dir = Path(args.out_dir) if args.out_dir else Path(cache) / args.model
178
+ out_dir.mkdir(parents=True, exist_ok=True)
179
+
180
+ try:
181
+ bag = bag_of(args.model)
182
+ except ImportError:
183
+ raise SystemExit("torch and demucs are required: pip install torch demucs onnx onnxruntime")
184
+ if bag and not (args.reference or args.source):
185
+ # a bag of source specialists: one process each, since an export peaks near 3 GB and is
186
+ # not handed back; this one stays free of torch
187
+ run = lambda *extra: subprocess.run([sys.executable, __file__, "--model", args.model, "--out-dir", str(out_dir), "--opset", str(args.opset), *extra], check=True)
188
+ if args.verify:
189
+ run("--reference")
190
+ for _, src in bag:
191
+ run("--source", src)
192
+ if args.verify:
193
+ run("--source", src, "--check")
194
+ print(f"done: {args.model} in {out_dir}")
195
+ return
196
+
197
+ try:
198
+ from demucs.pretrained import get_model
199
+ from demucs.htdemucs import HTDemucs
200
+ except ImportError:
201
+ raise SystemExit("torch and demucs are required: pip install torch demucs onnx onnxruntime")
202
+
203
+ def load(name):
204
+ model = get_model(name)
205
+ for m in getattr(model, "models", [model]):
206
+ if not isinstance(m, HTDemucs):
207
+ raise SystemExit(f"{name}: not a Hybrid Transformer Demucs model")
208
+ m.eval()
209
+ return model
210
+
211
+ if args.reference:
212
+ write_reference(load(args.model), out_dir)
213
+ elif args.source:
214
+ # the one specialist, by its signature: a single HTDemucs; exported, or checked in a fresh process
215
+ model, path = load(dict((s, g) for g, s in bag)[args.source]), out_dir / f"{args.source}.onnx"
216
+ if args.check:
217
+ verify_export(model, path, int(model.segment * model.samplerate))
218
+ else:
219
+ export_one(model, path, args.opset, False)
220
+ else:
221
+ model = load(args.model)
222
+ if args.verify:
223
+ write_reference(model, out_dir) # first, while memory is fresh
224
+ gc.collect()
225
+ export_one(getattr(model, "models", [model])[0], out_dir / f"{args.model}.onnx", args.opset, args.verify)
226
+ print(f"done: {args.model} in {out_dir}")
227
+
228
+
229
+ if __name__ == "__main__":
230
+ sys.exit(main())