Spleeter 4stems β ONNX
Deezer's Spleeter 4stems β vocals, drums, bass, other β converted to ONNX.
Deezer ship 4stems as a TensorFlow 1 checkpoint from October 2019.
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:
// 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:
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.
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.writedefaults toPCM_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. Hencesubtype="FLOAT"above. zerosis not a bug. With Spleeter's defaultmask_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
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) 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 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 (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 β 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 (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.
@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}
}