--- license: apache-2.0 tags: - audio - audio-to-audio - source-separation - music-source-separation - spleeter - onnx library_name: onnx pipeline_tag: audio-to-audio --- # Spleeter 4stems — ONNX Deezer's [Spleeter](https://github.com/deezer/spleeter) **4stems** — vocals, drums, bass, other — converted to ONNX. Deezer ship 4stems as a TensorFlow 1 checkpoint from October 2019. [sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx) ship an excellent ONNX export of **2stems** and state plainly: *"We only support the `2-stem` model at present."* This is the missing half. | file | size | notes | |---|---|---| | `{vocals,drums,bass,other}.fp16.onnx` | 19.7 MB each | **use these** — self-contained | | `{vocals,drums,bass,other}.onnx` | 39.4 MB each | fp32, self-contained | Each stem is an independent U-Net (9,826,759 params). Every file is self-contained — no external-data sidecars. ## Verification | | | |---|---| | TF vs PyTorch port, per stem | vocals 2.6e-04 · drums 1.3e-02 · bass 1.1e-03 · other 2.6e-03 | | `sum(4 stems) − mix` | **−153.1 dB** | | Inference, all four stems | 1.69 s for 30 s of 44.1 kHz stereo (~18× realtime, CPU) | The −153.1 dB is the one to trust. Spleeter's ratio masks sum to exactly 1 by construction, so the stems *must* reconstruct the mix — and they do, to machine precision. That single assertion tests the STFT, the mask, the band extension and the iSTFT at once, without anyone listening to anything. ## ⚠️ These models use ELU. This is the whole trick. If you plan to run these through an existing Spleeter port, read this first. Spleeter reads its activations from config: ```jsonc // configs/2stems/base_config.json "model": { "type": "unet.unet", "params": {} } // ^^ defaults: LeakyReLU(0.2) + ReLU // configs/4stems/base_config.json "model": { "type": "unet.unet", "params": { "conv_activation": "ELU", "deconv_activation": "ELU" } } ``` 2stems and 4stems have **identical architecture and identical weight shapes**, kernel for kernel. So a 2stems-shaped implementation loads 4stems weights **without a murmur** and returns garbage. That is why sherpa-onnx only publish 2stems: their `unet.py` hardcodes LeakyReLU/ReLU, and nothing about the shapes tells you. It presented as a max error of **945 against a TF output whose entire range was 178** — the error larger than the signal. With ELU: **945 → 2.6e-04**. The activations are recorded in each file's ONNX metadata (`conv_activation`, `deconv_activation`) so the next person doesn't have to find this the hard way. ## Interface ``` input x : float32 [2, num_splits, 512, 1024] # [channels, splits, frames, bins] output y : float32 [2, num_splits, 512, 1024] # that stem's magnitude estimate ``` `num_splits` is dynamic. The graph takes **magnitudes and returns magnitudes** — no complex numbers cross the boundary, which is why this ports cleanly where time-domain models don't. The STFT, the mask and the iSTFT are yours to do. ### The STFT contract The forward transform must produce the magnitudes the net was trained on: **periodic Hann, frame 4096, hop 1024**, 44.1 kHz stereo. `np.hanning` is *symmetric* and is **not** this — it differs by one sample, and that sample is the difference between matching training-time spectrograms and merely resembling them. Feed `abs(stft(x))[..., :1024]` (1024 of 2049 bins), padded and partitioned to 512-frame splits. ### The mask Spleeter's soft ratio mask, across all four stems: ```python total = sum(e ** 2 for e in estimates.values()) + 1e-10 mask = (estimate ** 2 + 1e-10 / 4) / total ``` Apply it to the **original complex STFT** — you keep the original phase, so there is nothing to reconstruct. You cannot cherry-pick one stem: the denominator needs all four. ### Band extension — use `average`, not `zeros` The net models 1024 of 2049 bins (to ~11 kHz). Everything above needs a value. Spleeter's default `zeros` **discards** it, which is a −23 dB hole in the reconstruction. `average` carries the per-frame mean up and reconstructs exactly (the −153.1 dB above is with `average`; with `zeros` it is −23.0 dB). ## Usage Complete and runnable — the graphs are only half a separator, so here is the other half. ```python import numpy as np, onnxruntime as ort, soundfile as sf N_FFT, HOP, T, F, BINS = 4096, 1024, 512, 1024, 2049 PAD = N_FFT - HOP W = np.hanning(N_FFT + 1)[:-1] # PERIODIC. np.hanning(N_FFT) is symmetric STEMS = ("vocals", "drums", "bass", "other") def stft(x): n = int(np.ceil((PAD + len(x)) / HOP)) p = np.zeros((n - 1) * HOP + N_FFT) p[PAD:PAD + len(x)] = x # front pad: see note below idx = np.arange(N_FFT)[None, :] + HOP * np.arange(n)[:, None] return np.fft.rfft(p[idx] * W, axis=-1) def istft(spec, length): frames = np.fft.irfft(spec, n=N_FFT, axis=-1) total = (len(spec) - 1) * HOP + N_FFT out, wsum = np.zeros(total), np.zeros(total) for i in range(len(spec)): at = i * HOP out[at:at + N_FFT] += frames[i] * W wsum[at:at + N_FFT] += W ** 2 out = np.divide(out, wsum, out=np.zeros_like(out), where=wsum > 1e-8) return out[PAD:PAD + length] wave, sr = sf.read("song.wav", dtype="float64") # 44.1 kHz stereo assert sr == 44100 and wave.shape[1] == 2 n = len(wave) spec = np.stack([stft(wave[:, c]) for c in range(2)]) frames = spec.shape[1] splits = int(np.ceil(frames / T)) mag = np.zeros((2, splits * T, F), dtype=np.float32) mag[:, :frames] = np.abs(spec[:, :, :F]) net_in = mag.reshape(2, splits, T, F) est = {} for s in STEMS: # all four: the mask needs every one sess = ort.InferenceSession(f"{s}.fp16.onnx", providers=["CPUExecutionProvider"]) out = sess.run(["y"], {"x": net_in})[0] est[s] = out.reshape(2, -1, F)[:, :frames] stems = {} denom = sum(e ** 2 for e in est.values()) + 1e-10 for s, e in est.items(): mask = (e ** 2 + 1e-10 / len(est)) / denom # extend 1024 -> 2049 bins with the per-frame mean ("average", not "zeros") tail = np.repeat(mask.mean(axis=-1, keepdims=True), BINS - F, axis=-1) full = np.concatenate([mask, tail], axis=-1) # applied to the ORIGINAL complex spectrum: the phase is already correct stems[s] = np.stack([istft(spec[c] * full[c], n) for c in range(2)], axis=-1) # The invariant. Check this, not your ears -- see below. res = 10 * np.log10(np.mean((sum(stems.values()) - wave) ** 2) / np.mean(wave ** 2)) print(f"sum(stems) - mix = {res:.1f} dB") # -153 dB for s, y in stems.items(): sf.write(f"out_{s}.wav", y, sr, subtype="FLOAT") ``` ### Check the invariant, not your ears The four masks sum to 1 by construction, so `sum(stems)` **must** equal the mix. It does, to **−153.1 dB**. If yours doesn't, your window or your hop is wrong, and no amount of listening will tell you which — every one of those mistakes produces audio that sounds approximately right. Two ways to measure it wrongly, both learned the hard way: - **Measure it in memory, before writing.** `sf.write` defaults to `PCM_16`, and 16-bit quantisation of four stems costs ~75 dB on its own — enough to turn −153 into −77 and send you hunting a bug that isn't there. Hence `subtype="FLOAT"` above. - **`zeros` is not a bug.** With Spleeter's default `mask_extension`, −23 dB is the *correct* answer: that is the >11 kHz band being discarded, exactly as asked. ### Why the front pad `stft` above prepends `N_FFT-HOP` zeros, which Spleeter does not. Alignment is not part of the contract — the U-Net is convolutional and translation-equivariant in time — and without the pad, reconstruction is exact in theory and broken in practice: on the ramp-in a periodic Hann is ~1e-7, so `W²` is ~1e-13, and dividing by it turns float noise into the one stretch of signal with no redundancy to spare. Unpadded, the round-trip error is **2.3**. Padded, it is **1e-15**. This is also why there is no `WINDOW_COMPENSATION_FACTOR` here. Spleeter's 2/3 constant and its `inverse_stft_window_fn` exist to undo TensorFlow-specific normalisation; plain weighted overlap-add inverts this forward with no constants at all. ## Source Conversion scripts, the full pipeline, the tests, and the debugging trail that found the ELU: **[github.com/madewith-bestpractice/spleeter-4stems-onnx](https://github.com/madewith-bestpractice/spleeter-4stems-onnx)** ## License and attribution **Apache-2.0**, and deliberately the stricter of the two licences in play, since these artefacts carry both: - **The weights are Deezer's Spleeter, MIT.** Spleeter's README licenses "the code"; the authors' own peer-reviewed JOSS paper (Hennequin, Khlif, Voituret & Moussallam, JOSS 5(50):2154, 2020, [doi:10.21105/joss.02154](https://doi.org/10.21105/joss.02154)) states: *"Spleeter source code and pre-trained models are available on github and distributed under a MIT license."* That ambiguity is real and unresolved — [deezer/spleeter#898](https://github.com/deezer/spleeter/issues/898) has been open and unanswered since 2024-04-26 — so it is stated here rather than glossed. Deezer trained on their own licensed catalogue (the "Bean" dataset) and released the weights *because* the data could not be released, so unlike models trained on MUSDB18 there is no non-commercial dataset term upstream. - **The graph derives from [sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx) (Apache-2.0)**, whose PyTorch reimplementation of Spleeter's U-Net these were exported through. Per Apache-2.0 §4(b): that U-Net was **modified** — its two activation functions were parameterised instead of hardcoded. ## Credits - **[Deezer](https://github.com/deezer/spleeter)** — Spleeter, and for training on a licensed catalogue and releasing the weights *because* the data couldn't be. That decision is the only reason this model is usable rather than merely good. - **[sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx)** (Xiaomi, Fangjun Kuang) — the TF→PyTorch→ONNX route, the U-Net port, and the TF-vs-torch assertion that made the ELU bug findable instead of silent. This is a four-stem extension of their work, not a replacement for it. ```bibtex @article{spleeter2020, doi = {10.21105/joss.02154}, author = {Romain Hennequin and Anis Khlif and Felix Voituret and Manuel Moussallam}, title = {Spleeter: a fast and efficient music source separation tool with pre-trained models}, journal = {Journal of Open Source Software}, volume = {5}, number = {50}, pages = {2154}, year = {2020} } ```