File size: 10,620 Bytes
a0d6809
cca6173
c716977
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87c5b6d
 
 
 
 
 
c716977
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
---
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}
}
```