StemSplit commited on
Commit
cedf43c
·
verified ·
1 Parent(s): 832b303

Initial release: parity-verified ONNX export of htdemucs_ft vocals specialist

Browse files
Files changed (3) hide show
  1. README.md +243 -0
  2. infer.py +150 -0
  3. requirements.txt +3 -0
README.md ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ - demucs
12
+ - htdemucs
13
+ - music
14
+ - audio-to-audio
15
+ - mobile
16
+ - ios
17
+ - android
18
+ - coreml
19
+ - directml
20
+ - production-ready
21
+ - vocal-extraction
22
+ - vocal-isolation
23
+ - vocal-remover
24
+ - karaoke
25
+ - acapella
26
+ datasets:
27
+ - StemSplitio/stem-separation-benchmark-2026
28
+ inference: false
29
+ ---
30
+
31
+ # HT-Demucs FT — Vocals Specialist, ONNX
32
+
33
+ **The #1 open-source vocal separator on MUSDB18-HQ**, exported to ONNX. No PyTorch required at inference. Runs on CPU / CoreML / CUDA / DirectML.
34
+
35
+ This repo packages sub-model 3 of the
36
+ [`htdemucs_ft`](https://github.com/facebookresearch/demucs) 4-bag ensemble
37
+ as a single 316 MB `.onnx` file plus a ~150-line numpy reference inference
38
+ script. Verified to be **numerically equivalent** to the original PyTorch
39
+ model.
40
+
41
+ > Want all 4 stems in one drop-in package? Use the full bag repo:
42
+ > [`StemSplitio/htdemucs-ft-onnx`](https://huggingface.co/StemSplitio/htdemucs-ft-onnx).
43
+
44
+ ---
45
+
46
+ ## TL;DR
47
+
48
+ ```bash
49
+ pip install onnxruntime numpy soundfile
50
+ python infer.py your-song.mp3 ./out/
51
+ # writes ./out/vocals.wav at 44.1 kHz stereo
52
+ ```
53
+
54
+ That's it. No PyTorch, no CUDA setup, no GPU server.
55
+
56
+ ---
57
+
58
+ ## Quality
59
+
60
+ | Metric (MUSDB18-HQ test, 50 songs) | Value | Source |
61
+ |---|---|---|
62
+ | Median vocals SDR | **9.19 dB** | [StemSplitio/stem-separation-benchmark-2026](https://huggingface.co/datasets/StemSplitio/stem-separation-benchmark-2026) |
63
+ | Rank among open-source separators on vocals | **#1** (the highest open-source vocal SDR on MUSDB18-HQ) | same |
64
+ | ONNX vs PyTorch max abs diff | **< 1e-3** | verified during export (see [Day 1 spike report](https://huggingface.co/StemSplitio/htdemucs-ft-drums-onnx#how-it-was-built)) |
65
+
66
+ ---
67
+
68
+ ## Performance
69
+
70
+ | Runtime | Hardware | Per 7.8-s segment | Per 3-min song |
71
+ |---|---|---:|---:|
72
+ | **onnxruntime CPU EP** | Apple M4 Pro | **~1.6 s** | **~22 s** |
73
+ | PyTorch CPU | Apple M4 Pro | ~2.1 s | ~29 s |
74
+ | onnxruntime CUDA EP | NVIDIA L4 | ~0.4 s | ~5 s *(extrapolated)* |
75
+ | onnxruntime DirectML EP | RTX 4090 | ~0.2 s | ~2 s *(extrapolated)* |
76
+
77
+ **Real-time factor on M4 Pro CPU: 0.20.** Roughly 1.31× faster than
78
+ PyTorch CPU on the same hardware.
79
+
80
+ ---
81
+
82
+ ## Common use cases
83
+
84
+ - **Karaoke maker** — extract clean instrumental + acapella in one pass (pair with the `other` ONNX)
85
+ - **Acapella extraction** — harvest isolated vocals for sampling, remixing, vocal-coach feedback
86
+ - **Vocal removal** — build a vocal-remover app on iOS / Android / web without a GPU server
87
+ - **Speech-from-music** — isolate spoken-word from background music for transcription
88
+
89
+ ---
90
+
91
+ ## Quick start
92
+
93
+ ### Python — minimal
94
+
95
+ ```python
96
+ import infer
97
+ vocals = infer.separate_vocals("your-song.mp3")
98
+ # vocals: numpy array (2, samples) at 44.1 kHz
99
+ ```
100
+
101
+ ### Python — full control
102
+
103
+ ```python
104
+ import soundfile as sf
105
+ import infer
106
+
107
+ # Optional execution providers — CPU is the default and most portable.
108
+ # Swap to "coreml" on macOS, "cuda" on NVIDIA, "dml" on Windows DX12.
109
+ audio, sr = sf.read("your-song.mp3", dtype="float32", always_2d=True)
110
+ stems = infer.separate(audio.T, sr, providers=["CPUExecutionProvider"])
111
+ sf.write("vocals.wav", stems[infer.SOURCES.index("vocals")].T, sr)
112
+ ```
113
+
114
+ ### CLI
115
+
116
+ ```bash
117
+ python infer.py your-song.mp3 ./out/
118
+ python infer.py your-song.mp3 ./out/ --providers cuda # NVIDIA
119
+ python infer.py your-song.mp3 ./out/ --providers coreml # macOS
120
+ python infer.py your-song.mp3 ./out/ --providers dml # Windows
121
+ ```
122
+
123
+ ### Mobile (iOS / Swift)
124
+
125
+ ```swift
126
+ import onnxruntime_objc
127
+
128
+ let env = try ORTEnv(loggingLevel: .warning)
129
+ let opts = try ORTSessionOptions()
130
+ try opts.appendCoreMLExecutionProvider(with: ORTCoreMLExecutionProviderOptions())
131
+ let session = try ORTSession(env: env,
132
+ modelPath: Bundle.main.path(forResource: "htdemucs_ft_vocals", ofType: "onnx")!,
133
+ sessionOptions: opts)
134
+ // audio: 1 × 2 × 343980 Float32 buffer, then session.run(...).
135
+ ```
136
+
137
+ ### Mobile (Android / Kotlin)
138
+
139
+ ```kotlin
140
+ import ai.onnxruntime.OrtEnvironment
141
+ import ai.onnxruntime.OrtSession
142
+
143
+ val env = OrtEnvironment.getEnvironment()
144
+ val opts = OrtSession.SessionOptions().apply { addNnapi() }
145
+ val session = env.createSession(modelPath, opts)
146
+ ```
147
+
148
+ ### Web (onnxruntime-web)
149
+
150
+ ```js
151
+ import * as ort from "onnxruntime-web";
152
+ const session = await ort.InferenceSession.create("htdemucs_ft_vocals.onnx", {
153
+ executionProviders: ["wasm"],
154
+ graphOptimizationLevel: "all",
155
+ });
156
+ const tensor = new ort.Tensor("float32", audioBuffer, [1, 2, 343980]);
157
+ const out = await session.run({ mix: tensor });
158
+ // out.stems.data is a Float32Array (1, 4, 2, 343980); use row 3 for vocals.
159
+ ```
160
+
161
+ ---
162
+
163
+ ## Input / output spec
164
+
165
+ | Tensor | Name | Shape | Dtype | Notes |
166
+ |---|---|---|---|---|
167
+ | Input | `mix` | `(1, 2, 343980)` | float32 | Stereo audio, 44.1 kHz, 7.8 s segment. Values in [-1, 1]. |
168
+ | Output | `stems` | `(1, 4, 2, 343980)` | float32 | `[drums, bass, other, vocals]` order. **Use only row 3 (`vocals`)** — the other 3 rows are weakly-predicted by-products of the vocals specialist. |
169
+
170
+ For longer audio, chunk with overlap-add — see `infer.py::separate` for a
171
+ working ~60-line implementation.
172
+
173
+ ---
174
+
175
+ ## Related repos
176
+
177
+ Sibling stem-specialist ONNX repos from the same export:
178
+
179
+ | Repo | Stem | Use when |
180
+ |---|---|---|
181
+ | [`htdemucs-ft-drums-onnx`](https://huggingface.co/StemSplitio/htdemucs-ft-drums-onnx) | drums | Drum extraction, beat transcription |
182
+ | [`htdemucs-ft-bass-onnx`](https://huggingface.co/StemSplitio/htdemucs-ft-bass-onnx) | bass | Bassline transcription, mix rebalancing |
183
+ | [`htdemucs-ft-other-onnx`](https://huggingface.co/StemSplitio/htdemucs-ft-other-onnx) | other | Karaoke instrumentals, sample-flipping |
184
+ | [`htdemucs-ft-vocals-onnx`](https://huggingface.co/StemSplitio/htdemucs-ft-vocals-onnx) | vocals | **#1 open-source vocal SDR** — karaoke, acapella, vocal removal |
185
+ | [`htdemucs-ft-onnx`](https://huggingface.co/StemSplitio/htdemucs-ft-onnx) | all 4 | Full 4-stem separation in one repo |
186
+
187
+ PyTorch versions for HF Inference Endpoints:
188
+ [`htdemucs-ft-pytorch`](https://huggingface.co/StemSplitio/htdemucs-ft-pytorch),
189
+ [`htdemucs-ft-vocals-pytorch`](https://huggingface.co/StemSplitio/htdemucs-ft-vocals-pytorch).
190
+
191
+ Full benchmark across every popular open-source separator:
192
+ [StemSplitio/stem-separation-benchmark-2026](https://huggingface.co/datasets/StemSplitio/stem-separation-benchmark-2026).
193
+
194
+ ---
195
+
196
+ ## Skip the infrastructure — use the StemSplit API
197
+
198
+ Don't want to ship a 316 MB model in your app, manage a GPU pool, or write
199
+ overlap-add chunking? Use the **[StemSplit API](https://stemsplit.io/developers)**
200
+ instead — same model under the hood, hosted for you, with credits and a
201
+ dashboard.
202
+
203
+ - 🌐 [stemsplit.io](https://stemsplit.io)
204
+ - 📘 [Developer docs](https://stemsplit.io/developers/docs)
205
+ - 🔌 [API reference](https://stemsplit.io/developers/reference)
206
+ - 📚 [Guides & recipes](https://stemsplit.io/developers/guides)
207
+
208
+ Or use the no-code tools that ship the same model family:
209
+
210
+ - 🎧 [Vocal Remover](https://stemsplit.io/vocal-remover)
211
+ - 🎧 [Karaoke Maker](https://stemsplit.io/karaoke-maker)
212
+ - 🎧 [Acapella Maker](https://stemsplit.io/acapella-maker)
213
+ - 🎧 [YouTube Stem Splitter](https://stemsplit.io/youtube-stem-splitter)
214
+
215
+ ---
216
+
217
+ ## Files in this repo
218
+
219
+ | File | Size | Purpose |
220
+ |---|---:|---|
221
+ | `htdemucs_ft_vocals.onnx` | 316 MB | The exported model. Opset 17. Passes `onnx.checker`. |
222
+ | `infer.py` | ~6 KB | Pure numpy + onnxruntime reference. No torch. |
223
+ | `requirements.txt` | <1 KB | `onnxruntime`, `numpy`, `soundfile`. |
224
+ | `README.md` | this file | |
225
+
226
+ ---
227
+
228
+ ## License & attribution
229
+
230
+ This repo is **MIT-licensed**, matching the original HT-Demucs.
231
+
232
+ ```bibtex
233
+ @inproceedings{rouard2023hybrid,
234
+ title = {Hybrid Transformers for Music Source Separation},
235
+ author = {Rouard, Simon and Massa, Francisco and D{\'e}fossez, Alexandre},
236
+ booktitle = {ICASSP},
237
+ year = {2023}
238
+ }
239
+ ```
240
+
241
+ - Original PyTorch model: [`facebookresearch/demucs`](https://github.com/facebookresearch/demucs)
242
+ - ONNX export, parity verification, and packaging by [StemSplit](https://stemsplit.io)
243
+ - Search keywords: vocal remover onnx, karaoke maker, acapella extractor, htdemucs vocals onnx, vocal separation ios
infer.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pure numpy + onnxruntime reference implementation for the HT-Demucs FT
3
+ vocals specialist. NO TORCH at inference.
4
+
5
+ Usage:
6
+ python infer.py input.mp3 out_dir/
7
+ # writes out_dir/vocals.wav
8
+
9
+ Or as a library:
10
+ import infer
11
+ vocals = infer.separate_vocals("song.mp3")
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import sys
17
+ import time
18
+ from pathlib import Path
19
+
20
+ import numpy as np
21
+ import onnxruntime as ort
22
+ import soundfile as sf
23
+
24
+ SAMPLE_RATE = 44100
25
+ SEGMENT_S = 7.8
26
+ N_SAMPLES = int(SEGMENT_S * SAMPLE_RATE)
27
+ N_CHANNELS = 2
28
+ SOURCES = ["drums", "bass", "other", "vocals"]
29
+ SPECIALIST_STEM = "vocals"
30
+ DEFAULT_ONNX = Path(__file__).resolve().parent / "htdemucs_ft_vocals.onnx"
31
+
32
+
33
+ def _make_transition_window(segment: int, overlap_frac: float = 0.25) -> np.ndarray:
34
+ transition = int(segment * overlap_frac)
35
+ window = np.ones(segment, dtype=np.float32)
36
+ fade = np.linspace(0, 1, transition, dtype=np.float32)
37
+ window[:transition] = fade
38
+ window[-transition:] = fade[::-1]
39
+ return window
40
+
41
+
42
+ def separate(mix: np.ndarray, sample_rate: int,
43
+ onnx_path: Path = DEFAULT_ONNX,
44
+ providers: list[str] | None = None,
45
+ verbose: bool = True) -> np.ndarray:
46
+ """Run chunked overlap-add separation on a full-length mix.
47
+ Returns: (n_sources, channels, samples). Only the row at
48
+ SOURCES.index(SPECIALIST_STEM) is meaningfully predicted.
49
+ """
50
+ if sample_rate != SAMPLE_RATE:
51
+ raise ValueError(f"Bound to {SAMPLE_RATE} Hz; got {sample_rate}.")
52
+ if mix.ndim != 2 or mix.shape[0] != N_CHANNELS:
53
+ raise ValueError(f"Expected (2, samples) input, got {mix.shape}")
54
+
55
+ if providers is None:
56
+ providers = ["CPUExecutionProvider"]
57
+ sess = ort.InferenceSession(str(onnx_path), providers=providers)
58
+
59
+ total_len = mix.shape[1]
60
+ overlap = N_SAMPLES // 4
61
+ stride = N_SAMPLES - overlap
62
+ n_chunks = max(1, (total_len + stride - 1) // stride)
63
+
64
+ if verbose:
65
+ print(f" input: {total_len:,} samples ({total_len / sample_rate:.1f}s)")
66
+ print(f" segment: {N_SAMPLES:,} samples ({SEGMENT_S}s)")
67
+ print(f" chunks: {n_chunks}, provider {sess.get_providers()[0]}")
68
+
69
+ window = _make_transition_window(N_SAMPLES)
70
+ out = np.zeros((len(SOURCES), N_CHANNELS, total_len), dtype=np.float32)
71
+ weight = np.zeros(total_len, dtype=np.float32)
72
+
73
+ t0 = time.perf_counter()
74
+ for i in range(n_chunks):
75
+ start = i * stride
76
+ end = min(start + N_SAMPLES, total_len)
77
+ chunk = mix[:, start:end]
78
+ if chunk.shape[1] < N_SAMPLES:
79
+ chunk = np.pad(chunk, ((0, 0), (0, N_SAMPLES - chunk.shape[1])),
80
+ mode="constant")
81
+ x = chunk[np.newaxis, ...].astype(np.float32)
82
+ stems = sess.run(["stems"], {"mix": x})[0][0]
83
+ chunk_len = end - start
84
+ w = window[:chunk_len]
85
+ out[:, :, start:end] += stems[:, :, :chunk_len] * w
86
+ weight[start:end] += w
87
+ if verbose:
88
+ print(f" chunk {i+1}/{n_chunks}: "
89
+ f"{time.perf_counter() - t0:.1f}s elapsed")
90
+
91
+ weight = np.maximum(weight, 1e-8)
92
+ out /= weight
93
+ if verbose:
94
+ rtf = (time.perf_counter() - t0) / (total_len / sample_rate)
95
+ print(f" total: {time.perf_counter() - t0:.2f}s (RTF {rtf:.2f})")
96
+ return out
97
+
98
+
99
+ def separate_vocals(input_path: str, onnx_path: Path = DEFAULT_ONNX,
100
+ providers: list[str] | None = None) -> np.ndarray:
101
+ """Convenience: load audio, separate, return only the vocals stem."""
102
+ audio, sr = sf.read(input_path, dtype="float32", always_2d=True)
103
+ audio = audio.T
104
+ if audio.shape[0] == 1:
105
+ audio = np.tile(audio, (2, 1))
106
+ elif audio.shape[0] > 2:
107
+ audio = audio[:2]
108
+ stems = separate(audio, sr, onnx_path=onnx_path, providers=providers)
109
+ return stems[SOURCES.index(SPECIALIST_STEM)]
110
+
111
+
112
+ def main() -> None:
113
+ ap = argparse.ArgumentParser(description=__doc__)
114
+ ap.add_argument("input", type=Path)
115
+ ap.add_argument("out_dir", type=Path)
116
+ ap.add_argument("--onnx", type=Path, default=DEFAULT_ONNX)
117
+ ap.add_argument("--providers", type=str, default="cpu",
118
+ choices=["cpu", "coreml", "cuda", "dml"])
119
+ ap.add_argument("--write-all-stems", action="store_true",
120
+ help="Also write the (low-quality) by-product stems.")
121
+ args = ap.parse_args()
122
+
123
+ providers_map = {
124
+ "cpu": ["CPUExecutionProvider"],
125
+ "coreml": ["CoreMLExecutionProvider", "CPUExecutionProvider"],
126
+ "cuda": ["CUDAExecutionProvider", "CPUExecutionProvider"],
127
+ "dml": ["DmlExecutionProvider", "CPUExecutionProvider"],
128
+ }
129
+ args.out_dir.mkdir(parents=True, exist_ok=True)
130
+
131
+ audio, sr = sf.read(str(args.input), dtype="float32", always_2d=True)
132
+ audio = audio.T
133
+ if audio.shape[0] == 1:
134
+ audio = np.tile(audio, (2, 1))
135
+ elif audio.shape[0] > 2:
136
+ audio = audio[:2]
137
+
138
+ stems = separate(audio, sr, onnx_path=args.onnx,
139
+ providers=providers_map[args.providers])
140
+ if args.write_all_stems:
141
+ for i, src in enumerate(SOURCES):
142
+ sf.write(str(args.out_dir / f"{src}.wav"), stems[i].T, sr)
143
+ else:
144
+ target = stems[SOURCES.index(SPECIALIST_STEM)]
145
+ sf.write(str(args.out_dir / f"{SPECIALIST_STEM}.wav"), target.T, sr)
146
+ print(f" wrote {args.out_dir / f'{SPECIALIST_STEM}.wav'}")
147
+
148
+
149
+ if __name__ == "__main__":
150
+ main()
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ onnxruntime>=1.20
2
+ numpy>=1.24
3
+ soundfile>=0.12