Initial release: HTDemucs to Core ML conversion (FP32 + FP16)
Browse files- ATTRIBUTION.md +61 -0
- CONVERSION_NOTES.md +190 -0
- HTDemucs_CoreML.mlpackage/Data/com.apple.CoreML/model.mlmodel +3 -0
- HTDemucs_CoreML.mlpackage/Data/com.apple.CoreML/weights/weight.bin +3 -0
- HTDemucs_CoreML.mlpackage/Manifest.json +18 -0
- HTDemucs_CoreML_FP16.mlpackage/Data/com.apple.CoreML/model.mlmodel +3 -0
- HTDemucs_CoreML_FP16.mlpackage/Data/com.apple.CoreML/weights/weight.bin +3 -0
- HTDemucs_CoreML_FP16.mlpackage/Manifest.json +18 -0
- LICENSE +27 -0
- README.md +126 -0
- convert.py +658 -0
- examples/swift/StemSeparator.swift +258 -0
- requirements.txt +16 -0
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
|