StemSplit commited on
Commit
a03a3ce
·
verified ·
1 Parent(s): 491e8d4

Add htdemucs ONNX model card, inference script, and requirements

Browse files
Files changed (3) hide show
  1. README.md +265 -0
  2. infer.py +141 -0
  3. requirements.txt +3 -0
README.md ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ language: en
3
+ license: mit
4
+ library_name: onnxruntime
5
+ pipeline_tag: audio-to-audio
6
+ tags:
7
+ - onnx
8
+ - onnxruntime
9
+ - stem-separation
10
+ - source-separation
11
+ - vocal-remover
12
+ - karaoke
13
+ - acapella
14
+ - demucs
15
+ - htdemucs
16
+ - music
17
+ - audio-to-audio
18
+ - mobile
19
+ - ios
20
+ - android
21
+ - coreml
22
+ - directml
23
+ - production-ready
24
+ datasets:
25
+ - StemSplitio/stem-separation-benchmark-2026
26
+ inference: false
27
+ ---
28
+
29
+ # HT-Demucs (single-file 4-stem) — ONNX
30
+
31
+ The **first ONNX export of the standard `htdemucs` (non-FT) model** on
32
+ the Hugging Face Hub. Runs in `onnxruntime` on CPU out of the box, and
33
+ on CoreML / CUDA / DirectML with a one-line provider change.
34
+ **No PyTorch required at inference.**
35
+
36
+ This repo is the single-file companion to
37
+ [`StemSplitio/htdemucs-ft-onnx`](https://huggingface.co/StemSplitio/htdemucs-ft-onnx).
38
+ You get all 4 stems out of one 316 MB `.onnx` file (`htdemucs.onnx`),
39
+ or 166 MB if you grab the fp16weights variant. The FT bag is higher
40
+ quality; this single model is ~30% faster and uses 1 session instead of 4.
41
+
42
+ ---
43
+
44
+ ## TL;DR
45
+
46
+ ```bash
47
+ # 316 MB fp32 model:
48
+ pip install onnxruntime numpy soundfile
49
+ python infer.py your-song.mp3 ./out/ --write-all-stems
50
+ # writes ./out/{drums,bass,other,vocals}.wav at 44.1 kHz stereo
51
+
52
+ # 166 MB fp16weights variant (same runtime cost):
53
+ python infer.py your-song.mp3 ./out/ --small --write-all-stems
54
+ ```
55
+
56
+ The repo contains:
57
+
58
+ - `htdemucs.onnx` — 316 MB, opset 17, parity-verified vs PyTorch fp32.
59
+ - `htdemucs_fp16weights.onnx` — 166 MB, fp16-stored weights, same runtime memory / latency.
60
+ - `infer.py` — pure-numpy reference inference (~200 lines, no torch).
61
+ - `requirements.txt` — three small packages, no PyTorch.
62
+
63
+ ---
64
+
65
+ ## Quality
66
+
67
+ The official `htdemucs` model is the precursor to `htdemucs_ft` — same
68
+ architecture, single set of weights instead of 4 specialist sub-models.
69
+ On MUSDB18-HQ:
70
+
71
+ | Metric | `htdemucs` (this) | `htdemucs_ft` (4-bag) |
72
+ |---|---:|---:|
73
+ | Median vocals SDR | ~8.8 dB | **9.19 dB** |
74
+ | Median drums SDR | ~9.5 dB | **10.11 dB** |
75
+ | Total model size | **316 MB** | 1.26 GB |
76
+ | Sessions to load | **1** | 4 |
77
+ | Speed vs the bag | **~1.4× faster** | baseline |
78
+
79
+ Parity vs PyTorch fp32 (random input, 7.8 s segment):
80
+
81
+ - `htdemucs.onnx` max abs diff: **6.62 × 10⁻⁴**
82
+ - `htdemucs_fp16weights.onnx` max abs diff (vs fp32 weights): **4.6 × 10⁻⁵**
83
+
84
+ Both well within the 1e-3 publish threshold.
85
+
86
+ ---
87
+
88
+ ## Performance
89
+
90
+ Single 7.8 s segment, Apple M4 Pro CPU:
91
+
92
+ | Variant | RAM | Latency | RTF |
93
+ |---|---:|---:|---:|
94
+ | `htdemucs.onnx` (fp32) | ~1.1 GB | ~1.6 s | 0.20 |
95
+ | `htdemucs_fp16weights.onnx` | ~1.1 GB | ~1.6 s | 0.20 |
96
+ | For comparison: `htdemucs_ft` (4-session bag) | ~4.0 GB | ~6.4 s | 0.49 |
97
+
98
+ CUDA / DirectML / CoreML EPs are typically ≥ 5× faster on real GPUs.
99
+
100
+ ---
101
+
102
+ ## Quick start
103
+
104
+ ### Python
105
+
106
+ ```python
107
+ import soundfile as sf
108
+ import infer
109
+
110
+ audio, sr = sf.read("your-song.mp3", dtype="float32", always_2d=True)
111
+ stems = infer.separate(audio.T, sr,
112
+ model_path=infer.DEFAULT_MODEL,
113
+ providers=["CPUExecutionProvider"])
114
+ for stem, arr in stems.items():
115
+ sf.write(f"{stem}.wav", arr.T, sr)
116
+ ```
117
+
118
+ ### CLI
119
+
120
+ ```bash
121
+ python infer.py your-song.mp3 ./out/ --write-all-stems
122
+ python infer.py your-song.mp3 ./out/ --providers coreml # macOS arm64
123
+ python infer.py your-song.mp3 ./out/ --providers cuda # Linux + NVIDIA
124
+ python infer.py your-song.mp3 ./out/ --providers dml # Windows + DX12
125
+ python infer.py your-song.mp3 ./out/ --small # 166 MB variant
126
+ ```
127
+
128
+ ### Mobile / Web (after pip install `onnxruntime-mobile` or `onnxruntime-web`)
129
+
130
+ ```swift
131
+ // iOS / Swift
132
+ import onnxruntime_objc
133
+ let opts = try ORTSessionOptions()
134
+ try opts.appendCoreMLExecutionProvider(with: ORTCoreMLExecutionProviderOptions())
135
+ let session = try ORTSession(env: env,
136
+ modelPath: Bundle.main.path(forResource: "htdemucs", ofType: "onnx")!,
137
+ sessionOptions: opts)
138
+ ```
139
+
140
+ ```js
141
+ // Browser / web
142
+ import * as ort from "onnxruntime-web";
143
+ const sess = await ort.InferenceSession.create("htdemucs_fp16weights.onnx", {
144
+ executionProviders: ["wasm"],
145
+ });
146
+ const t = new ort.Tensor("float32", audioBuffer, [1, 2, 343980]);
147
+ const out = await sess.run({ mix: t }); // out.stems is (1, 4, 2, 343980)
148
+ ```
149
+
150
+ For a turnkey browser demo with file-picker + chunked overlap-add, see
151
+ [`demucs-onnx browser-demo`](https://github.com/StemSplit/demucs-onnx#browser-demos).
152
+
153
+ ---
154
+
155
+ ## Input / output spec
156
+
157
+ | Tensor | Name | Shape | Dtype | Notes |
158
+ |---|---|---|---|---|
159
+ | Input | `mix` | `(1, 2, 343980)` | float32 | Stereo, 44.1 kHz, 7.8 s segment. Values in [-1, 1]. |
160
+ | Output | `stems` | `(1, 4, 2, 343980)` | float32 | Stems in order `[drums, bass, other, vocals]`. All 4 are real predictions (unlike the FT specialists). |
161
+
162
+ For longer audio, chunk with overlap-add — see `infer.py::separate` for
163
+ a working 60-line implementation.
164
+
165
+ ---
166
+
167
+ ## Tooling — `demucs-onnx` Python package
168
+
169
+ This model can be run (and re-exported from PyTorch) via the open-source
170
+ [`demucs-onnx`](https://github.com/StemSplit/demucs-onnx) Python package
171
+ on PyPI. It auto-downloads from this repo on first use, so you don't
172
+ have to clone or wrangle file paths.
173
+
174
+ ```bash
175
+ pip install demucs-onnx
176
+
177
+ # Single-file 4-stem flavor (this repo):
178
+ demucs-onnx separate song.mp3 stems/ --model htdemucs
179
+
180
+ # Python API:
181
+ python -c "from demucs_onnx import separate; \
182
+ print(separate('song.mp3', model='htdemucs').keys())"
183
+ ```
184
+
185
+ To re-export your own fine-tune:
186
+
187
+ ```bash
188
+ pip install 'demucs-onnx[export]'
189
+ demucs-onnx export htdemucs out/htdemucs.onnx
190
+ ```
191
+
192
+ ---
193
+
194
+ ## How it was built
195
+
196
+ The export pipeline lives in the open-source
197
+ [`demucs-onnx`](https://github.com/StemSplit/demucs-onnx) package at
198
+ [`demucs_onnx/export/`](https://github.com/StemSplit/demucs-onnx/tree/main/src/demucs_onnx/export).
199
+ It applies four patches to make `torch.onnx.export` work on htdemucs:
200
+
201
+ 1. Complex-typed `torch.stft` outputs → `Conv1d` with sin/cos kernels.
202
+ 2. `model.segment` `fractions.Fraction` → plain `float`.
203
+ 3. `random.randrange` in transformer pos-embedding → hardcoded `shift=0`.
204
+ 4. `aten::_native_multi_head_attention` (no ONNX symbolic) → drop-in
205
+ `nn.MultiheadAttention.forward` built from `Linear`/`bmm`/`softmax`.
206
+
207
+ These are the four blockers every previous community attempt at "demucs
208
+ onnx" stalled on. See the [README of the demucs-onnx package](https://github.com/StemSplit/demucs-onnx#the-4-blockers-explained)
209
+ for the full write-up with code references.
210
+
211
+ ---
212
+
213
+ ## Related work
214
+
215
+ Sibling ONNX repos from the same export pipeline:
216
+
217
+ | Repo | Format | Stems | Use when |
218
+ |---|---|---|---|
219
+ | `htdemucs-onnx` *(this)* | Single file | 4 | Faster startup, fewer sessions, ~30% lower latency than the FT bag. |
220
+ | [`htdemucs-ft-onnx`](https://huggingface.co/StemSplitio/htdemucs-ft-onnx) | Bag of 4 files | 4 | Best SDR, especially on vocals. The default in StemSplit production. |
221
+ | [`htdemucs-6s-onnx`](https://huggingface.co/StemSplitio/htdemucs-6s-onnx) | Single file | 6 | Need guitar + piano stems on top of the standard 4. |
222
+ | [`htdemucs-ft-{drums,bass,other,vocals}-onnx`](https://huggingface.co/StemSplitio) | Single specialist | 1 | Fastest single-stem inference; 4× faster than the bag. |
223
+
224
+ Full benchmark across every popular open-source separator:
225
+ [StemSplitio/stem-separation-benchmark-2026](https://huggingface.co/datasets/StemSplitio/stem-separation-benchmark-2026).
226
+
227
+ ---
228
+
229
+ ## Skip the infrastructure — use the StemSplit API
230
+
231
+ Don't want to bundle a 316 MB model in your app, manage a GPU pool, or
232
+ write overlap-add chunking? Use the **[StemSplit API](https://stemsplit.io/developers)**
233
+ instead — same model under the hood, hosted for you, with credits and a
234
+ dashboard.
235
+
236
+ - 🌐 [stemsplit.io](https://stemsplit.io)
237
+ - 📘 [Developer docs](https://stemsplit.io/developers/docs)
238
+ - 🔌 [API reference](https://stemsplit.io/developers/reference)
239
+
240
+ Or use the no-code tools that ship the same model family:
241
+
242
+ - 🎤 [Vocal Remover](https://stemsplit.io/vocal-remover)
243
+ - 🎶 [Karaoke Maker](https://stemsplit.io/karaoke-maker)
244
+ - 🎙️ [Acapella Maker](https://stemsplit.io/acapella-maker)
245
+ - 📺 [YouTube Stem Splitter](https://stemsplit.io/youtube-stem-splitter)
246
+
247
+ ---
248
+
249
+ ## License & attribution
250
+
251
+ This repo is **MIT-licensed**, matching the original HT-Demucs.
252
+
253
+ ```bibtex
254
+ @inproceedings{rouard2023hybrid,
255
+ title = {Hybrid Transformers for Music Source Separation},
256
+ author = {Rouard, Simon and Massa, Francisco and D{\'e}fossez, Alexandre},
257
+ booktitle = {ICASSP},
258
+ year = {2023}
259
+ }
260
+ ```
261
+
262
+ - Original PyTorch model: [`facebookresearch/demucs`](https://github.com/facebookresearch/demucs)
263
+ - ONNX export, parity verification, and packaging by [StemSplit](https://stemsplit.io)
264
+ - Search keywords: **htdemucs onnx**, **demucs onnx single file**, **demucs ios**,
265
+ **demucs android**, **music source separation onnx**, **stem separation mobile**.
infer.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pure numpy + onnxruntime inference for htdemucs (single 4-stem ONNX model).
2
+
3
+ Reference implementation, no torch. ~200 lines, MIT-licensed.
4
+
5
+ Usage:
6
+ python infer.py your-song.mp3 ./out/
7
+ python infer.py your-song.mp3 ./out/ --providers coreml
8
+
9
+ The pre-trained ONNX file (``htdemucs.onnx`` fp32 or
10
+ ``htdemucs_fp16weights.onnx`` half-storage) lives next to this script.
11
+ On first run we load the local file directly; no Hugging Face Hub
12
+ download required.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ import numpy as np
21
+ import onnxruntime as ort
22
+ import soundfile as sf
23
+
24
+ HERE = Path(__file__).resolve().parent
25
+ DEFAULT_MODEL = HERE / "htdemucs.onnx"
26
+ FP16_MODEL = HERE / "htdemucs_fp16weights.onnx"
27
+
28
+ SOURCES = ("drums", "bass", "other", "vocals")
29
+ SAMPLE_RATE = 44100
30
+ SEGMENT_S = 7.8
31
+ N_SAMPLES = int(SEGMENT_S * SAMPLE_RATE) # 343,980
32
+ N_CHANNELS = 2
33
+
34
+
35
+ def _make_window(n: int, overlap: int) -> np.ndarray:
36
+ w = np.ones(n, dtype=np.float32)
37
+ fade = np.linspace(0, 1, overlap, dtype=np.float32)
38
+ w[:overlap] = fade
39
+ w[-overlap:] = fade[::-1]
40
+ return w
41
+
42
+
43
+ def separate(mix: np.ndarray, sr: int, *,
44
+ model_path: Path = DEFAULT_MODEL,
45
+ providers: list[str] | None = None,
46
+ verbose: bool = False) -> dict[str, np.ndarray]:
47
+ """Run htdemucs on ``mix`` (shape ``(channels, samples)``).
48
+
49
+ Returns ``{stem: (channels, samples) float32}`` for all 4 stems.
50
+ Caller is responsible for resampling if ``sr != 44100``.
51
+ """
52
+ if mix.ndim != 2 or mix.shape[0] not in (1, 2):
53
+ raise ValueError(f"expected (1|2, samples), got {mix.shape}")
54
+ if mix.shape[0] == 1:
55
+ mix = np.repeat(mix, 2, axis=0)
56
+ if sr != SAMPLE_RATE:
57
+ raise ValueError(
58
+ f"input sample rate {sr} != model rate {SAMPLE_RATE}; "
59
+ "resample first (e.g. soxr.resample).",
60
+ )
61
+
62
+ providers = providers or ["CPUExecutionProvider"]
63
+ sess_opts = ort.SessionOptions()
64
+ sess_opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
65
+ sess = ort.InferenceSession(str(model_path), sess_options=sess_opts,
66
+ providers=providers)
67
+ if verbose:
68
+ print(f" loaded {model_path.name} on {sess.get_providers()[0]}")
69
+
70
+ total = mix.shape[1]
71
+ overlap = N_SAMPLES // 4
72
+ stride = N_SAMPLES - overlap
73
+ n_chunks = max(1, (total + stride - 1) // stride)
74
+ window = _make_window(N_SAMPLES, overlap)
75
+ out = np.zeros((len(SOURCES), N_CHANNELS, total), dtype=np.float32)
76
+ weight = np.zeros(total, dtype=np.float32)
77
+
78
+ for i in range(n_chunks):
79
+ start = i * stride
80
+ end = min(start + N_SAMPLES, total)
81
+ chunk = mix[:, start:end]
82
+ if chunk.shape[1] < N_SAMPLES:
83
+ chunk = np.pad(chunk, ((0, 0), (0, N_SAMPLES - chunk.shape[1])),
84
+ mode="constant")
85
+ x = chunk[np.newaxis, ...].astype(np.float32, copy=False)
86
+ stems = sess.run(["stems"], {"mix": x})[0][0] # (4, 2, N)
87
+ clen = end - start
88
+ w = window[:clen]
89
+ out[:, :, start:end] += stems[:, :, :clen] * w
90
+ weight[start:end] += w
91
+ if verbose:
92
+ print(f" chunk {i + 1}/{n_chunks}")
93
+
94
+ out /= np.maximum(weight, 1e-8)
95
+ return {src: out[i] for i, src in enumerate(SOURCES)}
96
+
97
+
98
+ def main() -> int:
99
+ p = argparse.ArgumentParser()
100
+ p.add_argument("input", type=Path, help="Input audio file (44.1 kHz stereo recommended)")
101
+ p.add_argument("output_dir", type=Path, help="Directory to write {stem}.wav files")
102
+ p.add_argument("--providers", default="cpu",
103
+ choices=["cpu", "coreml", "cuda", "dml"],
104
+ help="ONNX Runtime execution provider")
105
+ p.add_argument("--small", action="store_true",
106
+ help=f"Use {FP16_MODEL.name} (half the disk size, same runtime).")
107
+ p.add_argument("--write-all-stems", action="store_true",
108
+ help="Write all 4 stems instead of just vocals.")
109
+ args = p.parse_args()
110
+
111
+ provider_map = {
112
+ "cpu": ["CPUExecutionProvider"],
113
+ "coreml": ["CoreMLExecutionProvider", "CPUExecutionProvider"],
114
+ "cuda": ["CUDAExecutionProvider", "CPUExecutionProvider"],
115
+ "dml": ["DmlExecutionProvider", "CPUExecutionProvider"],
116
+ }
117
+ providers = provider_map[args.providers]
118
+
119
+ audio, sr = sf.read(str(args.input), dtype="float32", always_2d=True)
120
+ audio = audio.T # (channels, samples)
121
+ if sr != SAMPLE_RATE:
122
+ print(f"ERROR: input sample rate is {sr} Hz; resample to {SAMPLE_RATE} first.",
123
+ file=sys.stderr)
124
+ return 2
125
+
126
+ model_path = FP16_MODEL if args.small else DEFAULT_MODEL
127
+ if not model_path.exists():
128
+ print(f"ERROR: model file missing: {model_path}", file=sys.stderr)
129
+ return 2
130
+ stems = separate(audio, sr, model_path=model_path, providers=providers, verbose=True)
131
+ args.output_dir.mkdir(parents=True, exist_ok=True)
132
+ out_targets = SOURCES if args.write_all_stems else ("vocals",)
133
+ for s in out_targets:
134
+ path = args.output_dir / f"{s}.wav"
135
+ sf.write(str(path), stems[s].T, sr, subtype="PCM_16")
136
+ print(f"wrote {path}")
137
+ return 0
138
+
139
+
140
+ if __name__ == "__main__":
141
+ raise SystemExit(main())
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ onnxruntime>=1.17
2
+ numpy>=1.24
3
+ soundfile>=0.12