dexxdean commited on
Commit
8b4d481
·
verified ·
1 Parent(s): 459ec43

Initial release: HTDemucs to Core ML conversion (FP32 + FP16)

Browse files
ATTRIBUTION.md ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Attribution
2
+
3
+ The Core ML model produced by this repository is a converted form of
4
+ **Hybrid Transformer Demucs (HTDemucs)** by Meta AI / Meta Platforms, Inc.
5
+
6
+ When you ship the resulting `.mlpackage` in an application, you must keep the
7
+ following attribution available to users (e.g., in an "About" / "Legal"
8
+ screen, in your privacy policy, or in a `THIRD_PARTY_LICENSES` file):
9
+
10
+ ---
11
+
12
+ > This product uses Hybrid Transformer Demucs by Meta Platforms, Inc.,
13
+ > licensed under the MIT License.
14
+ >
15
+ > Source: https://github.com/facebookresearch/demucs
16
+ >
17
+ > Reference papers:
18
+ > - Défossez, A. (2021). *Hybrid Spectrogram and Waveform Source Separation.*
19
+ > Proceedings of the ISMIR 2021 Workshop on Music Source Separation.
20
+ > - Rouard, S., Massa, F., & Défossez, A. (2023).
21
+ > *Hybrid Transformers for Music Source Separation.* ICASSP 2023.
22
+
23
+ ---
24
+
25
+ ## Demucs upstream license
26
+
27
+ ```
28
+ MIT License
29
+
30
+ Copyright (c) Meta Platforms, Inc. and affiliates.
31
+
32
+ Permission is hereby granted, free of charge, to any person obtaining a copy
33
+ of this software and associated documentation files (the "Software"), to deal
34
+ in the Software without restriction, including without limitation the rights
35
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
36
+ copies of the Software, and to permit persons to whom the Software is
37
+ furnished to do so, subject to the following conditions:
38
+
39
+ The above copyright notice and this permission notice shall be included in
40
+ all copies or substantial portions of the Software.
41
+
42
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
43
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
44
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
45
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
46
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
47
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
48
+ DEALINGS IN THE SOFTWARE.
49
+ ```
50
+
51
+ The pre-trained HTDemucs weights are released under the same MIT license by
52
+ Meta and are downloaded automatically by the `demucs` Python package the
53
+ first time `convert.py` runs.
54
+
55
+ ## Not affiliated
56
+
57
+ This repository is an independent open-source project. It is **not
58
+ affiliated with, endorsed by, or sponsored by Apple, Meta, or the Demucs
59
+ authors**. Apple, Core ML, the Neural Engine, and any other Apple
60
+ trademarks are property of Apple Inc. and used here for descriptive
61
+ purposes only.
CONVERSION_NOTES.md ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Conversion notes — the three workarounds
2
+
3
+ This document explains the three non-trivial pieces in `convert.py`. Each one
4
+ is a Core ML / `coremltools` quirk that won't show up until you're already
5
+ several layers deep into a conversion attempt.
6
+
7
+ The same patterns apply to **any audio model** that uses STFT + Transformer
8
+ (MDX-Net, Spleeter, OpenUnmix, BS-RoFormer, …), so they're reusable beyond
9
+ HTDemucs.
10
+
11
+ ---
12
+
13
+ ## 1. `complex64` is not supported in Core ML
14
+
15
+ ### Problem
16
+
17
+ `torch.stft(..., return_complex=True)` produces `complex64`. `coremltools`
18
+ cannot represent complex tensors, so any graph that carries them through
19
+ will fail to convert (or, worse, "convert" but produce nonsense).
20
+
21
+ The naive workaround — call `torch.view_as_real` once at the boundary —
22
+ breaks again the moment you need `torch.istft`, because `istft` requires a
23
+ complex input.
24
+
25
+ ### Solution
26
+
27
+ Replace the entire STFT/ISTFT pair with **purely real-valued operations**
28
+ (`RealSTFT` and `RealISTFT` in `convert.py`).
29
+
30
+ **`RealSTFT`** is straightforward — keep `torch.stft` (still emits complex
31
+ internally) but immediately call `view_as_real` so the *traced graph* never
32
+ carries a complex tensor:
33
+
34
+ ```python
35
+ z = torch.stft(x_flat, ..., return_complex=True)
36
+ z_ri = torch.view_as_real(z) # (..., freqs, frames, 2)
37
+ ```
38
+
39
+ **`RealISTFT`** is the harder half. We rebuild ISTFT from scratch using a
40
+ matrix-form IDFT plus an explicit overlap-add:
41
+
42
+ 1. Pre-compute `cos`/`sin` basis matrices for a one-sided spectrum, with
43
+ correct DC/Nyquist scaling and the `normalized=True` factor folded in.
44
+ 2. Per-frame IDFT becomes a single `matmul`:
45
+ `signal = real @ cos_basis - imag @ sin_basis`.
46
+ 3. Apply the synthesis (Hann) window.
47
+ 4. Overlap-add into an output buffer.
48
+ 5. Divide by the pre-computed sum-of-squared-windows for correct
49
+ reconstruction normalization.
50
+ 6. Strip the `n_fft // 2` center padding.
51
+
52
+ This is more code than `torch.istft`, but it converts cleanly to Core ML
53
+ and has no `complex` operations anywhere.
54
+
55
+ ### Why not use `coremltools`'s built-in STFT op?
56
+
57
+ `coremltools` has gained more audio ops over time, but as of writing:
58
+ - The op coverage for STFT/ISTFT around HTDemucs's specific window size,
59
+ hop length, `normalized=True`, and `center=True` combination is brittle.
60
+ - HTDemucs's own STFT pre/post-processing (the `_pad1d` reflect padding,
61
+ the `_spec` trim of the last freq bin, the `+2 / -2` frame trim) needs
62
+ to be reproduced bit-for-bit. It's easier to keep the whole STFT
63
+ pipeline as plain PyTorch tensor ops.
64
+
65
+ ---
66
+
67
+ ## 2. `nn.MultiheadAttention` cannot be traced
68
+
69
+ ### Problem
70
+
71
+ `coremltools` can't convert `_native_multi_head_attention`, the fused C++
72
+ op that PyTorch dispatches to inside `nn.MultiheadAttention`. You'll get
73
+ something like:
74
+
75
+ ```
76
+ PyTorch convert function for op '_native_multi_head_attention' not implemented.
77
+ ```
78
+
79
+ ### Solution
80
+
81
+ Replace every `nn.MultiheadAttention` instance with a hand-written
82
+ `ManualMHA` module that decomposes attention into the primitive ops
83
+ `coremltools` *does* support: `linear`, `matmul`, `softmax`.
84
+
85
+ The substitution is in-place via `_replace_mha_recursive`, which walks the
86
+ HTDemucs `crosstransformer` and swaps modules. This preserves all
87
+ pre-trained weights — `ManualMHA` uses the same `in_proj_weight`,
88
+ `in_proj_bias`, and `out_proj` tensors as the original module.
89
+
90
+ `ManualMHA` handles two cases:
91
+ - **Self-attention** (`query == key == value` by pointer equality):
92
+ single `in_proj` then `chunk(3)`.
93
+ - **Cross-attention**: split the `in_proj` weight into Q/K/V slices and
94
+ apply each linear separately.
95
+
96
+ Trade-off: a few percent slower than the fused op on CPU/GPU. Negligible
97
+ for the once-per-10-seconds inference cadence in stem separation.
98
+
99
+ ---
100
+
101
+ ## 3. Core ML's 1D `scatter_add` is fragile
102
+
103
+ ### Problem
104
+
105
+ A natural way to write overlap-add inside an ISTFT is something like:
106
+
107
+ ```python
108
+ output = torch.zeros(batch, out_length)
109
+ output.scatter_add_(1, ola_indices, frames_signal_flat)
110
+ ```
111
+
112
+ `coremltools` *will* convert this, but for some shape/index combinations
113
+ the resulting Core ML graph mis-compiles silently — outputs come out
114
+ slightly wrong or full-on garbage on the GPU backend.
115
+
116
+ ### Solution
117
+
118
+ Pre-compute the OLA index tensor **once at module init time** and store it
119
+ as a registered buffer:
120
+
121
+ ```python
122
+ frame_offsets = torch.arange(num_frames) * hop_length
123
+ local_offsets = torch.arange(n_fft)
124
+ ola_indices = (frame_offsets.unsqueeze(1) + local_offsets.unsqueeze(0)).reshape(-1)
125
+ self.register_buffer("ola_indices", ola_indices.long())
126
+ ```
127
+
128
+ At forward-time, `expand` it to the batch dimension and call the canonical
129
+ `scatter_add_` with constant indices. This sidesteps the buggy code path
130
+ because the indices are now part of the constant graph rather than a
131
+ runtime computation.
132
+
133
+ A pre-computed `win_sum` buffer (sum of squared windows over all frames)
134
+ takes care of normalization the same way — done once, not recomputed
135
+ inside the model.
136
+
137
+ This is why the converter's `RealISTFT.__init__` takes a fixed
138
+ `num_frames` parameter: the converter bakes a specific segment length
139
+ into the model. If you want a different segment length, re-run
140
+ `convert.py --segment N`.
141
+
142
+ ---
143
+
144
+ ## Compute units
145
+
146
+ `compute_units=ct.ComputeUnit.CPU_AND_GPU` is the default in `convert.py`
147
+ and the right choice for HTDemucs.
148
+
149
+ `ct.ComputeUnit.ALL` (which lets the runtime route ops to the **Apple
150
+ Neural Engine**) produces incorrect output on some HTDemucs shapes —
151
+ likely a mismatch between ANE's quantization assumptions and the
152
+ pre-/post-processing math around the network. The validation step at the
153
+ end of `convert.py` will report a large max-diff if this happens. Do not
154
+ ignore it.
155
+
156
+ If you really need ANE inference, you'd have to: split the model so that
157
+ only the encoder/transformer/decoder runs on ANE, and keep the STFT/ISTFT
158
+ on CPU/GPU. That's a larger restructure — out of scope for this repo.
159
+
160
+ ---
161
+
162
+ ## Validation step
163
+
164
+ After conversion, `convert.py` runs the same dummy input through both the
165
+ PyTorch wrapper and the saved Core ML model and reports max/mean
166
+ differences:
167
+
168
+ | Precision | Expected max diff | Threshold |
169
+ |---|---|---|
170
+ | FP32 | ~1e-4 to 1e-2 | < 0.1 |
171
+ | FP16 | ~1e-2 to 1e-1 | < 0.2 |
172
+
173
+ If you see drift larger than the threshold, something has gone wrong —
174
+ typically a `coremltools` version mismatch or an ANE routing bug.
175
+
176
+ ---
177
+
178
+ ## Reproducing on a fresh machine
179
+
180
+ ```bash
181
+ python3 -m venv venv && source venv/bin/activate
182
+ pip install -r requirements.txt
183
+ python convert.py --fp16 # or default FP32
184
+ ```
185
+
186
+ First run downloads ~300 MB of HTDemucs weights to `~/.cache/torch/hub/`.
187
+ Conversion takes 2–5 minutes on an Apple Silicon Mac with 16 GB RAM.
188
+
189
+ If `coremltools` complains about Python or `torch` versions, see the
190
+ pinned bounds in `requirements.txt`.
HTDemucs_CoreML.mlpackage/Data/com.apple.CoreML/model.mlmodel ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2df5b2241de48f4d9752403073488ab54d50ecb3a731acfa2e3e4ab3aac2f3c4
3
+ size 42821996
HTDemucs_CoreML.mlpackage/Data/com.apple.CoreML/weights/weight.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3b7c1b77a48919f4fa8fe744bdf487f46d6c91e00cae09134c8287d4dfa92d6f
3
+ size 378366080
HTDemucs_CoreML.mlpackage/Manifest.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "fileFormatVersion": "1.0.0",
3
+ "itemInfoEntries": {
4
+ "70E43B23-A4BB-4678-9897-9F7F13EFF6F4": {
5
+ "author": "com.apple.CoreML",
6
+ "description": "CoreML Model Specification",
7
+ "name": "model.mlmodel",
8
+ "path": "com.apple.CoreML/model.mlmodel"
9
+ },
10
+ "FDA2CD24-2727-44BF-B7A5-1AEBBA959083": {
11
+ "author": "com.apple.CoreML",
12
+ "description": "CoreML Model Weights",
13
+ "name": "weights",
14
+ "path": "com.apple.CoreML/weights"
15
+ }
16
+ },
17
+ "rootModelIdentifier": "70E43B23-A4BB-4678-9897-9F7F13EFF6F4"
18
+ }
HTDemucs_CoreML_FP16.mlpackage/Data/com.apple.CoreML/model.mlmodel ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:307ddf24af60111ba821d4ad6bf2d1d987d6531ca2f487b6a95ec16a16a9c644
3
+ size 42887847
HTDemucs_CoreML_FP16.mlpackage/Data/com.apple.CoreML/weights/weight.bin ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:efab790ad07d93faeb5a19b6e1eedad8c37ad351563a891a153fce307811c099
3
+ size 190099328
HTDemucs_CoreML_FP16.mlpackage/Manifest.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "fileFormatVersion": "1.0.0",
3
+ "itemInfoEntries": {
4
+ "02CB79C0-A986-4E3F-ABF8-796A39543460": {
5
+ "author": "com.apple.CoreML",
6
+ "description": "CoreML Model Weights",
7
+ "name": "weights",
8
+ "path": "com.apple.CoreML/weights"
9
+ },
10
+ "5472320A-6037-4CD3-AA89-B348A89A8D53": {
11
+ "author": "com.apple.CoreML",
12
+ "description": "CoreML Model Specification",
13
+ "name": "model.mlmodel",
14
+ "path": "com.apple.CoreML/model.mlmodel"
15
+ }
16
+ },
17
+ "rootModelIdentifier": "5472320A-6037-4CD3-AA89-B348A89A8D53"
18
+ }
LICENSE ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dejan Nikolic
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ This project converts the Demucs (Hybrid Transformer Demucs) model from
26
+ PyTorch to Apple Core ML format. The original Demucs project is licensed
27
+ under MIT as well; see ATTRIBUTION.md for the full upstream notice.
README.md ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ library_name: coreml
4
+ tags:
5
+ - audio
6
+ - music-source-separation
7
+ - stem-separation
8
+ - demucs
9
+ - htdemucs
10
+ - coreml
11
+ - apple-silicon
12
+ - swift
13
+ - macos
14
+ - ios
15
+ pipeline_tag: audio-to-audio
16
+ ---
17
+
18
+ # HTDemucs → Core ML
19
+
20
+ Convert Meta's [Hybrid Transformer Demucs](https://github.com/facebookresearch/demucs) (HTDemucs) into a Core ML `.mlpackage` you can drop into a macOS or iOS app and run with `MLModel`.
21
+
22
+ The hard part of converting HTDemucs to Core ML is not the network itself — it is the STFT/ISTFT and the multi-head attention around it. This repo contains a **single-file converter** (`convert.py`, ~600 LoC) that solves the three blockers you hit otherwise:
23
+
24
+ 1. Core ML doesn't support `complex64` → real-valued STFT/ISTFT.
25
+ 2. `coremltools` can't trace `nn.MultiheadAttention` → manual decomposition.
26
+ 3. Core ML's 1D `scatter_add` is fragile → pre-computed OLA index buffer.
27
+
28
+ The result is a **stand-alone `.mlpackage`** that takes raw stereo audio and outputs four stems (vocals, drums, bass, other) at 44.1 kHz.
29
+
30
+ ## Why another conversion?
31
+
32
+ There is one prior public Core ML conversion of HTDemucs by [john-rocky/CoreML-Models](https://github.com/john-rocky/CoreML-Models) at 7.8 s segments / 80 MB. This repo offers:
33
+
34
+ - **Longer segments (10 s by default)** → fewer overlap-add boundaries on long files.
35
+ - **CLI flags** for segment length, FP16 quantization, compute-unit selection.
36
+ - **Source order** reordered to `[vocals, drums, bass, other]` (DJ/UI convention).
37
+ - **Documented workarounds** so you can reproduce or adapt the pipeline for other audio models (Spleeter, OpenUnmix, MDX-Net).
38
+
39
+ ## Quick start
40
+
41
+ ```bash
42
+ git clone https://github.com/dexxdean/htdemucs-coreml
43
+ cd htdemucs-coreml
44
+ python3 -m venv venv && source venv/bin/activate
45
+ pip install -r requirements.txt
46
+
47
+ # default: 10 s segments, FP32, ~400 MB
48
+ python convert.py
49
+
50
+ # half size, ~200 MB, slight numerical drift but inaudible in practice
51
+ python convert.py --fp16
52
+
53
+ # shorter segments if you want lower latency / smaller buffers
54
+ python convert.py --segment 7
55
+ ```
56
+
57
+ The output is `HTDemucs_CoreML.mlpackage` (or `HTDemucs_CoreML_FP16.mlpackage`).
58
+
59
+ ## Usage in Swift
60
+
61
+ ```swift
62
+ import CoreML
63
+ import AVFoundation
64
+
65
+ // 1. Load the model.
66
+ let url = Bundle.main.url(forResource: "HTDemucs_CoreML", withExtension: "mlpackage")!
67
+ let config = MLModelConfiguration()
68
+ config.computeUnits = .cpuAndGPU // see "Compute units" below
69
+ let model = try MLModel(contentsOf: url, configuration: config)
70
+
71
+ // 2. Feed a (1, 2, 441000) Float32 MLMultiArray named "audio".
72
+ // Output is a (1, 4, 2, 441000) Float32 array named "sources",
73
+ // in the order [vocals, drums, bass, other].
74
+ ```
75
+
76
+ A more complete example with chunking, overlap-add, and `AVAudioEngine` playback is in [`examples/swift/StemSeparator.swift`](examples/swift/StemSeparator.swift).
77
+
78
+ ## Model I/O
79
+
80
+ | | |
81
+ |---|---|
82
+ | Input name | `audio` |
83
+ | Input shape | `(1, 2, segment_samples)` Float32 |
84
+ | Output name | `sources` |
85
+ | Output shape | `(1, 4, 2, segment_samples)` Float32 |
86
+ | Output order | `vocals, drums, bass, other` |
87
+ | Sample rate | 44 100 Hz, stereo |
88
+ | Default segment | 441 000 samples (10 s) |
89
+ | Min. deployment | macOS 14 / iOS 17 |
90
+
91
+ ## Compute units
92
+
93
+ HTDemucs is **not stable on the Apple Neural Engine**. Use `.cpuAndGPU` (the default baked into the model). Forcing `.all` or `.cpuAndNeuralEngine` may produce silent garbage on some shapes — the validation step in `convert.py` will warn if numerical drift is large.
94
+
95
+ ## File sizes
96
+
97
+ | Variant | Size | Notes |
98
+ |---|---|---|
99
+ | FP32, 10 s | ~400 MB | Default, full reference quality. |
100
+ | FP16, 10 s | ~200 MB | Inaudible quality difference for music separation. |
101
+ | FP32, 7.8 s | ~310 MB | Closer to john-rocky's segment length. |
102
+
103
+ ## How it works
104
+
105
+ See [`CONVERSION_NOTES.md`](CONVERSION_NOTES.md) for the technical deep-dive on the three workarounds (real STFT, manual MHA, OLA scatter).
106
+
107
+ ## License & attribution
108
+
109
+ This repo is **MIT-licensed** — see [`LICENSE`](LICENSE).
110
+
111
+ The converted model derives from [facebookresearch/demucs](https://github.com/facebookresearch/demucs) © Meta Platforms, Inc., MIT-licensed. The pre-trained HTDemucs weights are downloaded by the `demucs` Python package at conversion time from Meta's official release. **You must comply with Demucs' MIT license when redistributing the resulting `.mlpackage`** — keep the attribution in `ATTRIBUTION.md` alongside the model and in your app's about/legal screen.
112
+
113
+ This project is **not affiliated with Apple, Meta, or Demucs**. The package name `HTDemucs_CoreML.mlpackage` was chosen to avoid any confusion with Apple-internal model names (e.g., `MusicSourceSeparation`).
114
+
115
+ ## Citation
116
+
117
+ If you use this in academic work, please cite the original Demucs papers:
118
+
119
+ ```bibtex
120
+ @inproceedings{rouard2023hybrid,
121
+ title={Hybrid Transformers for Music Source Separation},
122
+ author={Rouard, Simon and Massa, Francisco and D{\'e}fossez, Alexandre},
123
+ booktitle={ICASSP 2023},
124
+ year={2023}
125
+ }
126
+ ```
convert.py ADDED
@@ -0,0 +1,658 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ convert.py — Convert Demucs (Hybrid Transformer) to Core ML.
4
+
5
+ Core ML does not support complex64 tensors. This script wraps HTDemucs with
6
+ a real-valued STFT/ISTFT implementation (rfft -> view_as_real for STFT,
7
+ matrix IDFT + overlap-add for ISTFT) while keeping the neural network
8
+ (encoder/transformer/decoder) unchanged.
9
+
10
+ Default output: HTDemucs_CoreML.mlpackage
11
+
12
+ Prerequisites:
13
+ python3 -m venv venv && source venv/bin/activate
14
+ pip install -r requirements.txt
15
+
16
+ Usage:
17
+ python convert.py # FP32, ~400 MB
18
+ python convert.py --fp16 # FP16, ~200 MB
19
+ python convert.py --segment 7 # 7-second segments instead of 10
20
+ python convert.py --output Foo.mlpackage
21
+ """
22
+
23
+ import argparse
24
+ import math
25
+ import warnings
26
+ from pathlib import Path
27
+
28
+ import torch
29
+ import torch.nn as nn
30
+ import torch.nn.functional as F
31
+ import numpy as np
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Defaults (override via CLI args)
35
+ # ---------------------------------------------------------------------------
36
+ MODEL_NAME = "htdemucs"
37
+ SAMPLE_RATE = 44100
38
+ SEGMENT_SAMPLES = 441000 # 10s @ 44.1 kHz
39
+ NUM_CHANNELS = 2
40
+ NUM_SOURCES = 4
41
+ DEFAULT_OUTPUT = "HTDemucs_CoreML.mlpackage"
42
+
43
+ # Demucs internal source order: drums(0), bass(1), other(2), vocals(3)
44
+ # We reorder to vocals, drums, bass, other (typical UI / DJ convention).
45
+ SOURCE_REORDER = [3, 0, 1, 2]
46
+ SOURCE_NAMES = ["vocals", "drums", "bass", "other"]
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # ManualMHA: replaces nn.MultiheadAttention.
51
+ # coremltools cannot convert the fused _native_multi_head_attention op,
52
+ # so we decompose attention into matmul + softmax explicitly.
53
+ # ---------------------------------------------------------------------------
54
+ class ManualMHA(nn.Module):
55
+ """Drop-in für nn.MultiheadAttention, dekomponiert in matmul+softmax."""
56
+
57
+ def __init__(self, mha: nn.MultiheadAttention):
58
+ super().__init__()
59
+ self.embed_dim = mha.embed_dim
60
+ self.num_heads = mha.num_heads
61
+ self.head_dim = mha.embed_dim // mha.num_heads
62
+ self.in_proj_weight = mha.in_proj_weight
63
+ self.in_proj_bias = mha.in_proj_bias
64
+ self.out_proj = mha.out_proj
65
+ # Cross-attention: separate k/v projections
66
+ self.kdim = mha.kdim
67
+ self.vdim = mha.vdim
68
+ self._qkv_same_embed_dim = mha._qkv_same_embed_dim
69
+
70
+ def forward(self, query, key, value, need_weights=False, **kwargs):
71
+ B, T, E = query.shape
72
+ S = key.shape[1]
73
+
74
+ if self._qkv_same_embed_dim and query.data_ptr() == key.data_ptr():
75
+ # Self-attention: single in_proj for Q, K, V.
76
+ qkv = F.linear(query, self.in_proj_weight, self.in_proj_bias)
77
+ q, k, v = qkv.chunk(3, dim=-1)
78
+ else:
79
+ # Cross-attention or different inputs.
80
+ w_q, w_k, w_v = self.in_proj_weight.chunk(3, dim=0)
81
+ b_q, b_k, b_v = (self.in_proj_bias.chunk(3, dim=0)
82
+ if self.in_proj_bias is not None
83
+ else (None, None, None))
84
+ q = F.linear(query, w_q, b_q)
85
+ k = F.linear(key, w_k, b_k)
86
+ v = F.linear(value, w_v, b_v)
87
+
88
+ q = q.view(B, T, self.num_heads, self.head_dim).transpose(1, 2)
89
+ k = k.view(B, S, self.num_heads, self.head_dim).transpose(1, 2)
90
+ v = v.view(B, S, self.num_heads, self.head_dim).transpose(1, 2)
91
+
92
+ scale = self.head_dim ** -0.5
93
+ attn = torch.matmul(q, k.transpose(-2, -1)) * scale
94
+ attn = F.softmax(attn, dim=-1)
95
+ out = torch.matmul(attn, v)
96
+
97
+ out = out.transpose(1, 2).contiguous().view(B, T, E)
98
+ out = self.out_proj(out)
99
+ return out, None
100
+
101
+
102
+ def _replace_mha_recursive(module: nn.Module) -> None:
103
+ """Replace all nn.MultiheadAttention submodules with ManualMHA, in place."""
104
+ for name, child in module.named_children():
105
+ if isinstance(child, nn.MultiheadAttention):
106
+ setattr(module, name, ManualMHA(child))
107
+ else:
108
+ _replace_mha_recursive(child)
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # 1D reflect-pad helper (mirrors demucs.hdemucs.pad1d).
113
+ # ---------------------------------------------------------------------------
114
+ def _pad1d(x: torch.Tensor, paddings: tuple, mode: str = "reflect"):
115
+ """Reflect-pad along the last dim, with a fallback for very short signals."""
116
+ pl, pr = paddings
117
+ length = x.shape[-1]
118
+ max_pad = max(pl, pr)
119
+ if length <= max_pad:
120
+ extra_pad = max_pad - length + 1
121
+ x = F.pad(x, (0, extra_pad))
122
+ padded = F.pad(x, (pl, pr), mode=mode)
123
+ end = padded.shape[-1] - extra_pad
124
+ return padded[..., :end]
125
+ return F.pad(x, (pl, pr), mode=mode)
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # RealSTFT: real-valued STFT via rfft -> view_as_real.
130
+ # Produces (..., freqs, frames, 2) so no complex64 leaks into the traced graph.
131
+ # ---------------------------------------------------------------------------
132
+ class RealSTFT(nn.Module):
133
+ """STFT that returns only real tensors."""
134
+
135
+ def __init__(self, n_fft: int, hop_length: int):
136
+ super().__init__()
137
+ self.n_fft = n_fft
138
+ self.hop_length = hop_length
139
+ self.register_buffer("window", torch.hann_window(n_fft))
140
+
141
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
142
+ """
143
+ Input: (B, C, T)
144
+ Output: (B, C, freqs, frames, 2) -- [real, imag]
145
+ """
146
+ B, C, T = x.shape
147
+ x_flat = x.reshape(B * C, T)
148
+
149
+ # torch.stft -> complex -> immediately view_as_real.
150
+ z = torch.stft(
151
+ x_flat, self.n_fft, self.hop_length,
152
+ window=self.window, win_length=self.n_fft,
153
+ normalized=True, center=True, return_complex=True,
154
+ )
155
+ # z: (B*C, freqs, frames) complex64.
156
+ z_ri = torch.view_as_real(z) # (B*C, freqs, frames, 2) float32.
157
+ _, Fr, Fm, _ = z_ri.shape
158
+ return z_ri.view(B, C, Fr, Fm, 2)
159
+
160
+
161
+ # ---------------------------------------------------------------------------
162
+ # RealISTFT: real-valued ISTFT via matrix IDFT + overlap-add.
163
+ # Avoids view_as_complex (not supported by coremltools).
164
+ # ---------------------------------------------------------------------------
165
+ class RealISTFT(nn.Module):
166
+ """Pure real-valued ISTFT (matrix IDFT + OLA)."""
167
+
168
+ def __init__(self, n_fft: int, hop_length: int, num_frames: int):
169
+ super().__init__()
170
+ self.n_fft = n_fft
171
+ self.hop_length = hop_length
172
+ freqs = n_fft // 2 + 1
173
+
174
+ # Synthesis window.
175
+ window = torch.hann_window(n_fft)
176
+ self.register_buffer("window", window)
177
+
178
+ # IDFT basis matrices: cos / sin for a one-sided spectrum.
179
+ n = torch.arange(n_fft, dtype=torch.float32).unsqueeze(0) # (1, N)
180
+ k = torch.arange(freqs, dtype=torch.float32).unsqueeze(1) # (freqs, 1)
181
+ angles = 2.0 * math.pi * k * n / n_fft # (freqs, N)
182
+
183
+ cos_basis = torch.cos(angles)
184
+ sin_basis = torch.sin(angles)
185
+
186
+ # Scaling: DC and Nyquist single, rest double (one-sided spectrum).
187
+ # Normalization: /N * sqrt(N) because the forward STFT used normalized=True.
188
+ norm = math.sqrt(n_fft)
189
+ scale = torch.ones(freqs, 1) * (2.0 / n_fft * norm)
190
+ scale[0] = 1.0 / n_fft * norm
191
+ scale[-1] = 1.0 / n_fft * norm
192
+
193
+ self.register_buffer("cos_basis", cos_basis * scale) # (freqs, N)
194
+ self.register_buffer("sin_basis", sin_basis * scale) # (freqs, N)
195
+
196
+ # Pre-compute OLA indices and window-sum buffer.
197
+ # Core ML's 1D scatter_add can mis-compile for some shapes; using a
198
+ # pre-built index tensor + the canonical scatter_add_ call sidesteps it.
199
+ out_length = (num_frames - 1) * hop_length + n_fft
200
+ frame_offsets = torch.arange(num_frames) * hop_length
201
+ local_offsets = torch.arange(n_fft)
202
+ ola_indices = (frame_offsets.unsqueeze(1) + local_offsets.unsqueeze(0)).reshape(-1)
203
+ self.register_buffer("ola_indices", ola_indices.long())
204
+
205
+ window_sq = window * window
206
+ win_sum = torch.zeros(out_length)
207
+ for i in range(num_frames):
208
+ start = i * hop_length
209
+ win_sum[start:start + n_fft] += window_sq
210
+ win_sum = win_sum.clamp(min=1e-8)
211
+ self.register_buffer("win_sum", win_sum)
212
+ self.out_length = out_length
213
+
214
+ def forward(self, z_ri: torch.Tensor, length: int) -> torch.Tensor:
215
+ """
216
+ Input: z_ri (batch, freqs, frames, 2)
217
+ Output: (batch, length)
218
+ """
219
+ real = z_ri[..., 0] # (batch, freqs, frames)
220
+ imag = z_ri[..., 1]
221
+
222
+ # Per-frame IDFT: (batch, frames, freqs) @ (freqs, N) -> (batch, frames, N)
223
+ real_t = real.transpose(-2, -1)
224
+ imag_t = imag.transpose(-2, -1)
225
+
226
+ frames_signal = (
227
+ torch.matmul(real_t, self.cos_basis)
228
+ - torch.matmul(imag_t, self.sin_basis)
229
+ )
230
+
231
+ # Apply synthesis window.
232
+ frames_signal = frames_signal * self.window.unsqueeze(0).unsqueeze(0)
233
+
234
+ # --- Overlap-add via scatter_add ---
235
+ batch = frames_signal.shape[0]
236
+ idx = self.ola_indices.unsqueeze(0).expand(batch, -1)
237
+
238
+ flat = frames_signal.reshape(batch, -1)
239
+ output = torch.zeros(batch, self.out_length, device=z_ri.device)
240
+ output.scatter_add_(1, idx, flat)
241
+
242
+ # Window normalization (pre-computed buffer).
243
+ output = output / self.win_sum.unsqueeze(0)
244
+
245
+ # Strip center padding.
246
+ pad = self.n_fft // 2
247
+ output = output[:, pad:pad + length]
248
+
249
+ return output
250
+
251
+
252
+ # ---------------------------------------------------------------------------
253
+ # RealValuedHTDemucs: wrapper that swaps STFT/ISTFT for real-valued versions
254
+ # while keeping the actual network (encoder / transformer / decoder) intact.
255
+ # ---------------------------------------------------------------------------
256
+ class RealValuedHTDemucs(nn.Module):
257
+ """
258
+ Wraps HTDemucs with real-valued STFT/ISTFT.
259
+
260
+ Data flow:
261
+ 1. RealSTFT -> (B, C, Fr, T, 2) [real]
262
+ 2. Spec trimming (real instead of complex)
263
+ 3. _magnitude (cac=True): permute+reshape -> (B, C*2, Fr, T) [real]
264
+ 4. Encoder / CrossTransformer / Decoder [all real]
265
+ 5. _mask (cac=True): reshape+permute -> (B, S, C, Fr, T, 2) [real]
266
+ 6. RealISTFT -> waveform [real]
267
+ 7. + time branch (denormalized) [real]
268
+ """
269
+
270
+ def __init__(self, model: nn.Module, segment_samples: int):
271
+ super().__init__()
272
+ self.segment_samples = segment_samples
273
+
274
+ # Adopt network submodules from the loaded HTDemucs.
275
+ self.encoder = model.encoder
276
+ self.tencoder = model.tencoder
277
+ self.decoder = model.decoder
278
+ self.tdecoder = model.tdecoder
279
+ self.crosstransformer = model.crosstransformer
280
+ self.freq_emb = model.freq_emb
281
+ self.freq_emb_scale = model.freq_emb_scale
282
+ self.sources = model.sources
283
+ self.depth = model.depth
284
+
285
+ # Bottom-channel projection (present in some HTDemucs variants).
286
+ self.bottom_channels = model.bottom_channels
287
+ if self.bottom_channels:
288
+ self.channel_upsampler = model.channel_upsampler
289
+ self.channel_downsampler = model.channel_downsampler
290
+ self.channel_upsampler_t = model.channel_upsampler_t
291
+ self.channel_downsampler_t = model.channel_downsampler_t
292
+
293
+ # STFT / ISTFT parameters.
294
+ self.nfft = model.nfft
295
+ self.hop_length = model.hop_length
296
+
297
+ # Real-valued STFT / ISTFT modules.
298
+ self.real_stft = RealSTFT(model.nfft, model.hop_length)
299
+
300
+ # Frame count for ISTFT (fixed for the chosen segment size).
301
+ le = int(math.ceil(segment_samples / model.hop_length))
302
+ num_frames_istft = le + 4 # after padding inside _real_ispec
303
+ self.real_istft = RealISTFT(model.nfft, model.hop_length, num_frames_istft)
304
+
305
+ # nn.MultiheadAttention -> ManualMHA (fused op not supported by coremltools).
306
+ _replace_mha_recursive(self)
307
+
308
+ def _real_spec(self, mix: torch.Tensor) -> torch.Tensor:
309
+ """
310
+ Real-valued STFT + trim.
311
+ Input: (B, C, T)
312
+ Output: (B, C, Fr, le, 2) -- trimmed, real
313
+ """
314
+ hl = self.hop_length
315
+ length = mix.shape[-1]
316
+
317
+ le = int(math.ceil(length / hl))
318
+ pad = hl // 2 * 3
319
+ x = _pad1d(mix, (pad, pad + le * hl - length), mode="reflect")
320
+
321
+ z_ri = self.real_stft(x) # (B, C, Fr, frames, 2)
322
+
323
+ # Trim: drop the last freq bin, keep frames [2 : 2+le].
324
+ z_ri = z_ri[:, :, :-1, :, :]
325
+ z_ri = z_ri[:, :, :, 2:2 + le, :]
326
+ return z_ri
327
+
328
+ def _real_magnitude(self, z_ri: torch.Tensor) -> torch.Tensor:
329
+ """
330
+ cac=True: real/imag channels.
331
+ Input: (B, C, Fr, T, 2)
332
+ Output: (B, C*2, Fr, T)
333
+ """
334
+ # Move the (..., 2) dim into the channel axis:
335
+ # (B, C, Fr, T, 2) -> (B, C, 2, Fr, T) -> (B, C*2, Fr, T).
336
+ B, C, Fr, T, _ = z_ri.shape
337
+ m = z_ri.permute(0, 1, 4, 2, 3)
338
+ m = m.reshape(B, C * 2, Fr, T)
339
+ return m
340
+
341
+ def _real_mask(self, m: torch.Tensor) -> torch.Tensor:
342
+ """
343
+ cac=True: network output -> real/imag tensor.
344
+ Input: (B, S, C*2, Fr, T) -- denormalized network output
345
+ Output: (B*S*C, Fr, T, 2) -- ready for RealISTFT
346
+ """
347
+ B, S, _, Fr, T = m.shape
348
+ out = m.view(B, S, -1, 2, Fr, T).permute(0, 1, 2, 4, 5, 3)
349
+ out = out.reshape(B * S * (out.shape[2]), Fr, T, 2)
350
+ return out
351
+
352
+ def _real_ispec(self, z_ri: torch.Tensor, length: int) -> torch.Tensor:
353
+ """
354
+ Real-valued ISTFT.
355
+ Input: (batch, Fr, T, 2)
356
+ Output: (batch, length)
357
+ """
358
+ hl = self.hop_length
359
+ # Pad freq: add 1 bin at the end.
360
+ z_ri = F.pad(z_ri, (0, 0, 0, 0, 0, 1))
361
+ # Pad frames: add 2 on each side.
362
+ z_ri = F.pad(z_ri, (0, 0, 2, 2))
363
+
364
+ pad = hl // 2 * 3
365
+ le = hl * int(math.ceil(length / hl)) + 2 * pad
366
+ x = self.real_istft(z_ri, le)
367
+ x = x[:, pad:pad + length]
368
+ return x
369
+
370
+ def forward(self, mix: torch.Tensor) -> torch.Tensor:
371
+ """
372
+ Input: (1, 2, segment_samples)
373
+ Output: (1, 4, 2, segment_samples) -- [vocals, drums, bass, other]
374
+ """
375
+ length = mix.shape[-1]
376
+
377
+ # --- Frequency branch: real-valued STFT ---
378
+ z_ri = self._real_spec(mix) # (B, C, Fr, T, 2)
379
+ mag = self._real_magnitude(z_ri) # (B, C*2, Fr, T) float
380
+ x = mag
381
+
382
+ B, C_mag, Fq, T = x.shape
383
+
384
+ # Normalize.
385
+ mean = x.mean(dim=(1, 2, 3), keepdim=True)
386
+ std = x.std(dim=(1, 2, 3), keepdim=True)
387
+ x = (x - mean) / (1e-5 + std)
388
+
389
+ # --- Time branch ---
390
+ xt = mix
391
+ meant = xt.mean(dim=(1, 2), keepdim=True)
392
+ stdt = xt.std(dim=(1, 2), keepdim=True)
393
+ xt = (xt - meant) / (1e-5 + stdt)
394
+
395
+ # --- Encoder ---
396
+ saved = []
397
+ saved_t = []
398
+ lengths = []
399
+ lengths_t = []
400
+
401
+ for idx, encode in enumerate(self.encoder):
402
+ lengths.append(x.shape[-1])
403
+ inject = None
404
+ if idx < len(self.tencoder):
405
+ lengths_t.append(xt.shape[-1])
406
+ tenc = self.tencoder[idx]
407
+ xt = tenc(xt)
408
+ if not tenc.empty:
409
+ saved_t.append(xt)
410
+ else:
411
+ inject = xt
412
+ x = encode(x, inject)
413
+ if idx == 0 and self.freq_emb is not None:
414
+ frs = torch.arange(x.shape[-2], device=x.device)
415
+ emb = self.freq_emb(frs).t()[None, :, :, None].expand_as(x)
416
+ x = x + self.freq_emb_scale * emb
417
+ saved.append(x)
418
+
419
+ # --- Cross-Transformer ---
420
+ if self.crosstransformer:
421
+ if self.bottom_channels:
422
+ b, c, f, t = x.shape
423
+ from einops import rearrange
424
+ x = rearrange(x, "b c f t-> b c (f t)")
425
+ x = self.channel_upsampler(x)
426
+ x = rearrange(x, "b c (f t)-> b c f t", f=f)
427
+ xt = self.channel_upsampler_t(xt)
428
+
429
+ x, xt = self.crosstransformer(x, xt)
430
+
431
+ if self.bottom_channels:
432
+ x = rearrange(x, "b c f t-> b c (f t)")
433
+ x = self.channel_downsampler(x)
434
+ x = rearrange(x, "b c (f t)-> b c f t", f=f)
435
+ xt = self.channel_downsampler_t(xt)
436
+
437
+ # --- Decoder ---
438
+ for idx, decode in enumerate(self.decoder):
439
+ skip = saved.pop(-1)
440
+ x, pre = decode(x, skip, lengths.pop(-1))
441
+
442
+ offset = self.depth - len(self.tdecoder)
443
+ if idx >= offset:
444
+ tdec = self.tdecoder[idx - offset]
445
+ length_t = lengths_t.pop(-1)
446
+ if tdec.empty:
447
+ pre = pre[:, :, 0]
448
+ xt, _ = tdec(pre, None, length_t)
449
+ else:
450
+ skip = saved_t.pop(-1)
451
+ xt, _ = tdec(xt, skip, length_t)
452
+
453
+ # --- Frequency branch: denormalize + mask ---
454
+ S = len(self.sources)
455
+ x = x.view(B, S, -1, Fq, T)
456
+ x = x * std[:, None] + mean[:, None]
457
+
458
+ # _real_mask -> (B*S*C, Fr, T, 2)
459
+ zout_ri = self._real_mask(x)
460
+
461
+ # Real-valued ISTFT.
462
+ x_freq = self._real_ispec(zout_ri, length)
463
+ # x_freq: (B*S*C, length) -> (B, S, C, length)
464
+ C_orig = NUM_CHANNELS
465
+ x_freq = x_freq.view(B, S, C_orig, length)
466
+
467
+ # --- Time branch: denormalize ---
468
+ xt = xt.view(B, S, -1, length)
469
+ xt = xt * stdt[:, None] + meant[:, None]
470
+
471
+ # --- Combine ---
472
+ x_out = x_freq + xt
473
+
474
+ # Reorder sources: drums,bass,other,vocals -> vocals,drums,bass,other.
475
+ x_out = x_out[:, SOURCE_REORDER, :, :]
476
+
477
+ return x_out
478
+
479
+
480
+ # ---------------------------------------------------------------------------
481
+ # Metadata
482
+ # ---------------------------------------------------------------------------
483
+ def _add_metadata(mlmodel, segment_samples: int) -> None:
484
+ mlmodel.author = "HTDemucs CoreML conversion"
485
+ mlmodel.license = (
486
+ "MIT. Original Demucs: Copyright (c) Meta Platforms, Inc. and "
487
+ "affiliates, MIT License. See LICENSE and ATTRIBUTION."
488
+ )
489
+ mlmodel.short_description = (
490
+ f"Hybrid Transformer Demucs (HTDemucs) -- music source separation "
491
+ f"into {', '.join(SOURCE_NAMES)} at {SAMPLE_RATE} Hz."
492
+ )
493
+ mlmodel.input_description["audio"] = (
494
+ f"Stereo audio. Shape (1, 2, {segment_samples}), Float32, {SAMPLE_RATE} Hz."
495
+ )
496
+ mlmodel.output_description["sources"] = (
497
+ f"Separated stems. Shape (1, 4, 2, {segment_samples}). "
498
+ f"Order: [{', '.join(SOURCE_NAMES)}]."
499
+ )
500
+
501
+
502
+ # ---------------------------------------------------------------------------
503
+ # Main
504
+ # ---------------------------------------------------------------------------
505
+ def parse_args() -> argparse.Namespace:
506
+ p = argparse.ArgumentParser(
507
+ description="Convert Demucs (HTDemucs) to Core ML mlpackage."
508
+ )
509
+ p.add_argument(
510
+ "--segment", type=float, default=10.0,
511
+ help="Segment length in seconds (default: 10.0).",
512
+ )
513
+ p.add_argument(
514
+ "--fp16", action="store_true",
515
+ help="Quantize to FP16 (~half the file size, minor accuracy loss).",
516
+ )
517
+ p.add_argument(
518
+ "--output", type=str, default=None,
519
+ help="Output mlpackage path (default: HTDemucs_CoreML[_FP16].mlpackage).",
520
+ )
521
+ p.add_argument(
522
+ "--compute-units", choices=["cpu_and_gpu", "all", "cpu_only"],
523
+ default="cpu_and_gpu",
524
+ help="Default ComputeUnit baked into the model (default: cpu_and_gpu). "
525
+ "HTDemucs is unstable on the Neural Engine -- keep 'cpu_and_gpu' "
526
+ "unless you have specifically validated 'all'.",
527
+ )
528
+ return p.parse_args()
529
+
530
+
531
+ def main() -> None:
532
+ import coremltools as ct
533
+
534
+ warnings.filterwarnings("ignore", category=UserWarning)
535
+ warnings.filterwarnings("ignore", category=FutureWarning)
536
+
537
+ args = parse_args()
538
+
539
+ segment_samples = int(round(args.segment * SAMPLE_RATE))
540
+ output_path = args.output or (
541
+ "HTDemucs_CoreML_FP16.mlpackage" if args.fp16 else DEFAULT_OUTPUT
542
+ )
543
+ precision = ct.precision.FLOAT16 if args.fp16 else ct.precision.FLOAT32
544
+ compute_units = {
545
+ "cpu_and_gpu": ct.ComputeUnit.CPU_AND_GPU,
546
+ "all": ct.ComputeUnit.ALL,
547
+ "cpu_only": ct.ComputeUnit.CPU_ONLY,
548
+ }[args.compute_units]
549
+
550
+ print("=" * 60)
551
+ print(" HTDemucs -> Core ML Converter")
552
+ print(" (real-valued STFT / ISTFT wrapper)")
553
+ print("=" * 60)
554
+ print(f" Model: {MODEL_NAME}")
555
+ print(f" Sample rate: {SAMPLE_RATE} Hz")
556
+ print(f" Segment: {segment_samples} samples ({args.segment:.1f}s)")
557
+ print(f" Stems: {', '.join(SOURCE_NAMES)}")
558
+ print(f" Precision: {'FP16' if args.fp16 else 'FP32'}")
559
+ print(f" Compute: {args.compute_units}")
560
+ print(f" Output: {output_path}")
561
+ print("=" * 60)
562
+
563
+ # --- Load model ---
564
+ print(f"\n[1/5] Loading Demucs '{MODEL_NAME}' ...")
565
+ from demucs.pretrained import get_model
566
+ bag = get_model(MODEL_NAME)
567
+ model = bag.models[0]
568
+ model.eval()
569
+ model.use_train_segment = False
570
+ num_params = sum(p.numel() for p in model.parameters()) / 1e6
571
+ print(f" {num_params:.1f}M parameters loaded.")
572
+
573
+ # --- Build wrapper ---
574
+ print("\n[2/5] Building real-valued wrapper ...")
575
+ wrapper = RealValuedHTDemucs(model, segment_samples=segment_samples)
576
+ wrapper.eval()
577
+
578
+ dummy = torch.randn(1, NUM_CHANNELS, segment_samples)
579
+
580
+ # --- PyTorch sanity check ---
581
+ print("\n[3/5] PyTorch forward pass ...")
582
+ with torch.no_grad():
583
+ out_wrapper = wrapper(dummy)
584
+
585
+ print(f" Output shape: {out_wrapper.shape}")
586
+ expected = (1, NUM_SOURCES, NUM_CHANNELS, segment_samples)
587
+ assert out_wrapper.shape == expected, f"Shape {out_wrapper.shape} != {expected}"
588
+ print(" OK.")
589
+
590
+ # --- Trace ---
591
+ print("\n[4/5] torch.jit.trace ...")
592
+ with torch.no_grad():
593
+ traced = torch.jit.trace(wrapper, dummy, strict=False)
594
+ print(" Trace OK.")
595
+
596
+ # --- Core ML conversion ---
597
+ print("\n[5/5] Core ML conversion ...")
598
+ mlmodel = ct.convert(
599
+ traced,
600
+ inputs=[
601
+ ct.TensorType(
602
+ name="audio",
603
+ shape=(1, NUM_CHANNELS, segment_samples),
604
+ dtype=np.float32,
605
+ )
606
+ ],
607
+ outputs=[ct.TensorType(name="sources")],
608
+ convert_to="mlprogram",
609
+ compute_units=compute_units,
610
+ compute_precision=precision,
611
+ minimum_deployment_target=ct.target.macOS14,
612
+ )
613
+
614
+ _add_metadata(mlmodel, segment_samples)
615
+ mlmodel.save(output_path)
616
+
617
+ # --- Validation ---
618
+ # Important: reload with the SAME compute_units we converted for.
619
+ # MLModel(path) without a config defaults to ComputeUnit.ALL, which on
620
+ # HTDemucs may dispatch to ANE and crash with E5RT errors -- exactly
621
+ # the bug we baked the CPU_AND_GPU default into the model to avoid.
622
+ print("\n[Val] Validating Core ML vs. PyTorch reference ...")
623
+ try:
624
+ val_config = ct.ComputeUnit.CPU_AND_GPU
625
+ mlmodel_loaded = ct.models.MLModel(output_path, compute_units=val_config)
626
+ with torch.no_grad():
627
+ ref = wrapper(dummy).numpy()
628
+ pred = mlmodel_loaded.predict({"audio": dummy.numpy()})
629
+ cml_out = pred["sources"]
630
+
631
+ assert ref.shape == cml_out.shape, f"Shape mismatch: {ref.shape} vs {cml_out.shape}"
632
+ max_diff = float(np.max(np.abs(ref - cml_out)))
633
+ mean_diff = float(np.mean(np.abs(ref - cml_out)))
634
+ print(f" Max diff: {max_diff:.6f}")
635
+ print(f" Mean diff: {mean_diff:.6f}")
636
+ threshold = 0.2 if args.fp16 else 0.1
637
+ if max_diff < threshold:
638
+ print(" Validation OK.")
639
+ else:
640
+ print(" Large numerical drift (expected for FP16 on ANE).")
641
+ except Exception as e:
642
+ print(f" Validation skipped: {e}")
643
+
644
+ # --- Summary ---
645
+ size_mb = sum(
646
+ f.stat().st_size for f in Path(output_path).rglob("*") if f.is_file()
647
+ ) / (1024 * 1024)
648
+
649
+ print("\n" + "=" * 60)
650
+ print(f" Done: {output_path} ({size_mb:.0f} MB)")
651
+ print()
652
+ print(" Next step: drag the .mlpackage into your Xcode project")
653
+ print(" and load it via MLModel(contentsOf: ...). See examples/swift/.")
654
+ print("=" * 60)
655
+
656
+
657
+ if __name__ == "__main__":
658
+ main()
examples/swift/StemSeparator.swift ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // StemSeparator.swift
2
+ //
3
+ // Minimal example showing how to load HTDemucs_CoreML.mlpackage in Swift,
4
+ // chunk a stereo audio file, run inference per chunk, and reassemble the
5
+ // four stems via overlap-add.
6
+ //
7
+ // Drop the .mlpackage into your Xcode target's Resources, then call:
8
+ //
9
+ // let separator = try StemSeparator()
10
+ // let stems = try await separator.separate(fileURL: someURL) { progress in
11
+ // print("\(Int(progress * 100))%")
12
+ // }
13
+ // // stems[.vocals], stems[.drums], stems[.bass], stems[.other]
14
+ //
15
+ // This is a pared-down reference. Real apps usually want:
16
+ // - Resampling to 44.1 kHz before chunking (AVAudioConverter).
17
+ // - Triangular overlap-add windowing (this file uses linear taper for brevity).
18
+ // - Cancellation / progress on a Task.
19
+ //
20
+ // License: MIT (same as the rest of this repo).
21
+
22
+ import AVFoundation
23
+ import CoreML
24
+
25
+ public enum StemKind: Int, CaseIterable {
26
+ case vocals = 0
27
+ case drums = 1
28
+ case bass = 2
29
+ case other = 3
30
+ }
31
+
32
+ public enum StemSeparatorError: LocalizedError {
33
+ case modelNotFound
34
+ case unsupportedFormat
35
+ case inferenceFailed(String)
36
+
37
+ public var errorDescription: String? {
38
+ switch self {
39
+ case .modelNotFound: return "HTDemucs_CoreML.mlpackage not found in bundle."
40
+ case .unsupportedFormat: return "Audio must be 44.1 kHz stereo Float32."
41
+ case .inferenceFailed(let m): return "Inference failed: \(m)"
42
+ }
43
+ }
44
+ }
45
+
46
+ public final class StemSeparator {
47
+
48
+ // The converter bakes a fixed segment length into the model. Defaults
49
+ // match `python convert.py` (10 s @ 44.1 kHz). If you converted with
50
+ // --segment 7, change segmentSamples to 308700, etc.
51
+ private let segmentSamples = 441_000
52
+ private let overlapSamples = 44_100 // 1 s overlap-add
53
+ private let sampleRate = 44_100.0
54
+
55
+ private let model: MLModel
56
+ private let inferenceLock = NSLock() // MLModel.prediction is not thread-safe.
57
+
58
+ public init() throws {
59
+ guard let url = Bundle.main.url(
60
+ forResource: "HTDemucs_CoreML", withExtension: "mlpackage"
61
+ ) ?? Bundle.main.url(
62
+ forResource: "HTDemucs_CoreML", withExtension: "mlmodelc"
63
+ ) else {
64
+ throw StemSeparatorError.modelNotFound
65
+ }
66
+
67
+ let config = MLModelConfiguration()
68
+ config.computeUnits = .cpuAndGPU // do NOT use .all -- ANE is unstable here
69
+ self.model = try MLModel(contentsOf: url, configuration: config)
70
+ }
71
+
72
+ /// Separate a file into four stems, returning each as an
73
+ /// AVAudioPCMBuffer at 44.1 kHz / stereo / Float32.
74
+ public func separate(
75
+ fileURL: URL,
76
+ progress: @Sendable @escaping (Double) -> Void = { _ in }
77
+ ) async throws -> [StemKind: AVAudioPCMBuffer] {
78
+ let mix = try loadAndResample(fileURL: fileURL)
79
+ return try await Task.detached(priority: .userInitiated) {
80
+ try self.runChunked(mix: mix, progress: progress)
81
+ }.value
82
+ }
83
+
84
+ // MARK: - Loading & resampling
85
+
86
+ private func loadAndResample(fileURL: URL) throws -> AVAudioPCMBuffer {
87
+ let file = try AVAudioFile(forReading: fileURL)
88
+ let target = AVAudioFormat(
89
+ commonFormat: .pcmFormatFloat32,
90
+ sampleRate: sampleRate,
91
+ channels: 2,
92
+ interleaved: false
93
+ )!
94
+ guard let converter = AVAudioConverter(from: file.processingFormat, to: target) else {
95
+ throw StemSeparatorError.unsupportedFormat
96
+ }
97
+
98
+ let outFrames = AVAudioFrameCount(
99
+ Double(file.length) * sampleRate / file.processingFormat.sampleRate
100
+ )
101
+ guard let out = AVAudioPCMBuffer(pcmFormat: target, frameCapacity: outFrames) else {
102
+ throw StemSeparatorError.unsupportedFormat
103
+ }
104
+
105
+ let input = AVAudioPCMBuffer(pcmFormat: file.processingFormat,
106
+ frameCapacity: AVAudioFrameCount(file.length))!
107
+ try file.read(into: input)
108
+
109
+ var consumed = false
110
+ var error: NSError?
111
+ converter.convert(to: out, error: &error) { _, status in
112
+ if consumed { status.pointee = .endOfStream; return nil }
113
+ consumed = true
114
+ status.pointee = .haveData
115
+ return input
116
+ }
117
+ if let error { throw StemSeparatorError.inferenceFailed(error.localizedDescription) }
118
+ return out
119
+ }
120
+
121
+ // MARK: - Chunked inference + overlap-add
122
+
123
+ private func runChunked(
124
+ mix: AVAudioPCMBuffer,
125
+ progress: @Sendable @escaping (Double) -> Void
126
+ ) throws -> [StemKind: AVAudioPCMBuffer] {
127
+ let total = Int(mix.frameLength)
128
+ let stride = segmentSamples - overlapSamples
129
+ let chunks = max(1, Int(ceil(Double(total) / Double(stride))))
130
+
131
+ // Output buffers (one per stem).
132
+ var outputs: [StemKind: UnsafeMutablePointer<Float>] = [:]
133
+ var weights = [Float](repeating: 0, count: total)
134
+ defer { outputs.values.forEach { $0.deallocate() } }
135
+
136
+ for kind in StemKind.allCases {
137
+ outputs[kind] = UnsafeMutablePointer<Float>.allocate(capacity: total * 2)
138
+ outputs[kind]!.initialize(repeating: 0, count: total * 2)
139
+ }
140
+
141
+ // Triangular fade window for overlap-add.
142
+ var window = [Float](repeating: 1, count: segmentSamples)
143
+ for i in 0..<overlapSamples {
144
+ let w = Float(i) / Float(overlapSamples)
145
+ window[i] = w
146
+ window[segmentSamples - 1 - i] = w
147
+ }
148
+
149
+ for c in 0..<chunks {
150
+ let start = c * stride
151
+ let chunk = sliceChunk(mix: mix, start: start)
152
+ let stemChunks = try predict(chunk: chunk)
153
+
154
+ for kind in StemKind.allCases {
155
+ guard let dst = outputs[kind] else { continue }
156
+ let src = stemChunks[kind]!
157
+ for f in 0..<segmentSamples {
158
+ let global = start + f
159
+ if global >= total { break }
160
+ let w = window[f]
161
+ dst[global * 2 + 0] += src[f * 2 + 0] * w
162
+ dst[global * 2 + 1] += src[f * 2 + 1] * w
163
+ if kind == .vocals { // accumulate window only once
164
+ weights[global] += w
165
+ }
166
+ }
167
+ }
168
+ progress(Double(c + 1) / Double(chunks))
169
+ }
170
+
171
+ // Normalize by accumulated window weights.
172
+ for kind in StemKind.allCases {
173
+ guard let dst = outputs[kind] else { continue }
174
+ for f in 0..<total {
175
+ let w = max(weights[f], 1e-6)
176
+ dst[f * 2 + 0] /= w
177
+ dst[f * 2 + 1] /= w
178
+ }
179
+ }
180
+
181
+ // Wrap into AVAudioPCMBuffers.
182
+ let outFormat = AVAudioFormat(
183
+ commonFormat: .pcmFormatFloat32,
184
+ sampleRate: sampleRate,
185
+ channels: 2,
186
+ interleaved: false
187
+ )!
188
+ var result: [StemKind: AVAudioPCMBuffer] = [:]
189
+ for kind in StemKind.allCases {
190
+ let buf = AVAudioPCMBuffer(pcmFormat: outFormat,
191
+ frameCapacity: AVAudioFrameCount(total))!
192
+ buf.frameLength = AVAudioFrameCount(total)
193
+ let src = outputs[kind]!
194
+ for f in 0..<total {
195
+ buf.floatChannelData![0][f] = src[f * 2 + 0]
196
+ buf.floatChannelData![1][f] = src[f * 2 + 1]
197
+ }
198
+ result[kind] = buf
199
+ }
200
+ return result
201
+ }
202
+
203
+ // MARK: - Single-chunk prediction
204
+
205
+ private func predict(chunk: [Float]) throws -> [StemKind: [Float]] {
206
+ precondition(chunk.count == segmentSamples * 2,
207
+ "chunk must be \(segmentSamples * 2) interleaved floats")
208
+
209
+ let array = try MLMultiArray(
210
+ shape: [1, 2, NSNumber(value: segmentSamples)],
211
+ dataType: .float32
212
+ )
213
+ // Deinterleave into (1, 2, N).
214
+ let ptr = array.dataPointer.bindMemory(to: Float.self, capacity: array.count)
215
+ for f in 0..<segmentSamples {
216
+ ptr[0 * segmentSamples + f] = chunk[f * 2 + 0]
217
+ ptr[1 * segmentSamples + f] = chunk[f * 2 + 1]
218
+ }
219
+
220
+ inferenceLock.lock()
221
+ defer { inferenceLock.unlock() }
222
+
223
+ let provider = try MLDictionaryFeatureProvider(dictionary: ["audio": array])
224
+ let result = try model.prediction(from: provider)
225
+ guard let out = result.featureValue(for: "sources")?.multiArrayValue else {
226
+ throw StemSeparatorError.inferenceFailed("missing 'sources' output")
227
+ }
228
+
229
+ // Output shape: (1, 4, 2, segmentSamples). Order: [vocals, drums, bass, other].
230
+ var stems: [StemKind: [Float]] = [:]
231
+ let outPtr = out.dataPointer.bindMemory(to: Float.self, capacity: out.count)
232
+ for kind in StemKind.allCases {
233
+ var samples = [Float](repeating: 0, count: segmentSamples * 2)
234
+ let stemBase = kind.rawValue * 2 * segmentSamples
235
+ for f in 0..<segmentSamples {
236
+ samples[f * 2 + 0] = outPtr[stemBase + 0 * segmentSamples + f]
237
+ samples[f * 2 + 1] = outPtr[stemBase + 1 * segmentSamples + f]
238
+ }
239
+ stems[kind] = samples
240
+ }
241
+ return stems
242
+ }
243
+
244
+ private func sliceChunk(mix: AVAudioPCMBuffer, start: Int) -> [Float] {
245
+ let total = Int(mix.frameLength)
246
+ var out = [Float](repeating: 0, count: segmentSamples * 2)
247
+ let l = mix.floatChannelData![0]
248
+ let r = mix.floatChannelData![1]
249
+ for f in 0..<segmentSamples {
250
+ let g = start + f
251
+ if g < total {
252
+ out[f * 2 + 0] = l[g]
253
+ out[f * 2 + 1] = r[g]
254
+ }
255
+ }
256
+ return out
257
+ }
258
+ }
requirements.txt ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Tested combinations (any of these should work):
2
+ #
3
+ # Verified set A (used to produce the released mlpackage):
4
+ # torch 2.8.0, torchaudio 2.8.0, coremltools 9.0,
5
+ # demucs 4.0.1, numpy 2.0.2, einops 0.8.2, Python 3.9
6
+ #
7
+ # Lower bounds reflect the API surface the converter relies on; upper
8
+ # bounds are kept open since coremltools 9.x and torch 2.8.x are known
9
+ # to convert this model cleanly.
10
+
11
+ torch>=2.1
12
+ torchaudio>=2.1
13
+ demucs>=4.0,<5.0
14
+ coremltools>=7.2
15
+ numpy>=1.24
16
+ einops>=0.7