Add Core ML export script
Browse files
scripts/export_htdemucs_coreml_core.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Export the real-valued HTDemucs separator core to Core ML.
|
| 3 |
+
|
| 4 |
+
The full HTDemucs waveform graph cannot be exported directly because it uses
|
| 5 |
+
complex STFT/ISTFT tensors. This script cuts the graph at the same boundary the
|
| 6 |
+
model already uses internally when `cac=True`: Swift performs STFT/ISTFT with
|
| 7 |
+
Accelerate, while Core ML runs the learned real-valued separator core.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import argparse
|
| 13 |
+
import hashlib
|
| 14 |
+
import json
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import Any
|
| 17 |
+
|
| 18 |
+
import coremltools as ct
|
| 19 |
+
import torch
|
| 20 |
+
from demucs.pretrained import get_model
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
MODEL_ID = "htdemucs"
|
| 24 |
+
DEMUX_VERSION = "4.0.1"
|
| 25 |
+
CHECKPOINT_URL = "https://dl.fbaipublicfiles.com/demucs/hybrid_transformer/955717e8-8726e21a.th"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class HTDemucsSeparatorCore(torch.nn.Module):
|
| 29 |
+
"""Real-valued HTDemucs separator core.
|
| 30 |
+
|
| 31 |
+
Inputs:
|
| 32 |
+
mix: [B, 2, segment_samples], 44.1 kHz stereo waveform.
|
| 33 |
+
spectrogram: [B, 4, 2048, segment_frames], real/imag-as-channel STFT.
|
| 34 |
+
|
| 35 |
+
Outputs:
|
| 36 |
+
spectrogram_stems: [B, 4, 4, 2048, segment_frames]
|
| 37 |
+
waveform_stems: [B, 4, 2, segment_samples]
|
| 38 |
+
|
| 39 |
+
The source order is the upstream HTDemucs order: drums, bass, other, vocals.
|
| 40 |
+
"""
|
| 41 |
+
|
| 42 |
+
def __init__(self, model: torch.nn.Module) -> None:
|
| 43 |
+
super().__init__()
|
| 44 |
+
self.model = model
|
| 45 |
+
|
| 46 |
+
def forward(self, mix: torch.Tensor, spectrogram: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
|
| 47 |
+
model = self.model
|
| 48 |
+
x = spectrogram
|
| 49 |
+
xt = mix
|
| 50 |
+
|
| 51 |
+
batch, _, freqs, frames = x.shape
|
| 52 |
+
length = mix.shape[-1]
|
| 53 |
+
|
| 54 |
+
mean = x.mean(dim=(1, 2, 3), keepdim=True)
|
| 55 |
+
std = x.std(dim=(1, 2, 3), keepdim=True)
|
| 56 |
+
x = (x - mean) / (1e-5 + std)
|
| 57 |
+
|
| 58 |
+
mean_t = xt.mean(dim=(1, 2), keepdim=True)
|
| 59 |
+
std_t = xt.std(dim=(1, 2), keepdim=True)
|
| 60 |
+
xt = (xt - mean_t) / (1e-5 + std_t)
|
| 61 |
+
|
| 62 |
+
saved = []
|
| 63 |
+
saved_t = []
|
| 64 |
+
lengths = []
|
| 65 |
+
lengths_t = []
|
| 66 |
+
|
| 67 |
+
for idx, encode in enumerate(model.encoder):
|
| 68 |
+
lengths.append(x.shape[-1])
|
| 69 |
+
inject = None
|
| 70 |
+
if idx < len(model.tencoder):
|
| 71 |
+
lengths_t.append(xt.shape[-1])
|
| 72 |
+
tenc = model.tencoder[idx]
|
| 73 |
+
xt = tenc(xt)
|
| 74 |
+
if not tenc.empty:
|
| 75 |
+
saved_t.append(xt)
|
| 76 |
+
else:
|
| 77 |
+
inject = xt
|
| 78 |
+
|
| 79 |
+
x = encode(x, inject)
|
| 80 |
+
if idx == 0 and model.freq_emb is not None:
|
| 81 |
+
freq_positions = torch.arange(x.shape[-2], device=x.device)
|
| 82 |
+
emb = model.freq_emb(freq_positions).t()[None, :, :, None].expand_as(x)
|
| 83 |
+
x = x + model.freq_emb_scale * emb
|
| 84 |
+
|
| 85 |
+
saved.append(x)
|
| 86 |
+
|
| 87 |
+
if model.crosstransformer:
|
| 88 |
+
if model.bottom_channels:
|
| 89 |
+
b, c, f, t = x.shape
|
| 90 |
+
x = x.reshape(b, c, f * t)
|
| 91 |
+
x = model.channel_upsampler(x)
|
| 92 |
+
x = x.reshape(b, -1, f, t)
|
| 93 |
+
xt = model.channel_upsampler_t(xt)
|
| 94 |
+
|
| 95 |
+
x, xt = model.crosstransformer(x, xt)
|
| 96 |
+
|
| 97 |
+
if model.bottom_channels:
|
| 98 |
+
b, c, f, t = x.shape
|
| 99 |
+
x = x.reshape(b, c, f * t)
|
| 100 |
+
x = model.channel_downsampler(x)
|
| 101 |
+
x = x.reshape(b, -1, f, t)
|
| 102 |
+
xt = model.channel_downsampler_t(xt)
|
| 103 |
+
|
| 104 |
+
for idx, decode in enumerate(model.decoder):
|
| 105 |
+
skip = saved.pop(-1)
|
| 106 |
+
x, pre = decode(x, skip, lengths.pop(-1))
|
| 107 |
+
|
| 108 |
+
offset = model.depth - len(model.tdecoder)
|
| 109 |
+
if idx >= offset:
|
| 110 |
+
tdec = model.tdecoder[idx - offset]
|
| 111 |
+
length_t = lengths_t.pop(-1)
|
| 112 |
+
if tdec.empty:
|
| 113 |
+
pre = pre[:, :, 0]
|
| 114 |
+
xt, _ = tdec(pre, None, length_t)
|
| 115 |
+
else:
|
| 116 |
+
skip_t = saved_t.pop(-1)
|
| 117 |
+
xt, _ = tdec(xt, skip_t, length_t)
|
| 118 |
+
|
| 119 |
+
sources = len(model.sources)
|
| 120 |
+
x = x.view(batch, sources, -1, freqs, frames)
|
| 121 |
+
x = x * std[:, None] + mean[:, None]
|
| 122 |
+
|
| 123 |
+
xt = xt.view(batch, sources, -1, length)
|
| 124 |
+
xt = xt * std_t[:, None] + mean_t[:, None]
|
| 125 |
+
return x, xt
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def sha256_file(path: Path) -> str:
|
| 129 |
+
digest = hashlib.sha256()
|
| 130 |
+
with path.open("rb") as handle:
|
| 131 |
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
| 132 |
+
digest.update(chunk)
|
| 133 |
+
return digest.hexdigest()
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def package_files(root: Path) -> list[dict[str, Any]]:
|
| 137 |
+
files: list[dict[str, Any]] = []
|
| 138 |
+
for path in sorted(root.rglob("*")):
|
| 139 |
+
if path.is_file():
|
| 140 |
+
files.append(
|
| 141 |
+
{
|
| 142 |
+
"path": path.relative_to(root.parent).as_posix(),
|
| 143 |
+
"bytes": path.stat().st_size,
|
| 144 |
+
"sha256": sha256_file(path),
|
| 145 |
+
}
|
| 146 |
+
)
|
| 147 |
+
return files
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def write_manifest(path: Path, package_path: Path, segment_samples: int, segment_frames: int) -> None:
|
| 151 |
+
payload = {
|
| 152 |
+
"format_version": 1,
|
| 153 |
+
"model_id": MODEL_ID,
|
| 154 |
+
"demucs_version": DEMUX_VERSION,
|
| 155 |
+
"checkpoint_url": CHECKPOINT_URL,
|
| 156 |
+
"coreml_package": package_path.name,
|
| 157 |
+
"sample_rate": 44100,
|
| 158 |
+
"channels": 2,
|
| 159 |
+
"n_fft": 4096,
|
| 160 |
+
"hop_length": 1024,
|
| 161 |
+
"segment_samples": segment_samples,
|
| 162 |
+
"segment_seconds": segment_samples / 44100.0,
|
| 163 |
+
"segment_frames": segment_frames,
|
| 164 |
+
"frequency_bins": 2048,
|
| 165 |
+
"sources": ["drums", "bass", "other", "vocals"],
|
| 166 |
+
"vocals_source_index": 3,
|
| 167 |
+
"inputs": {
|
| 168 |
+
"mix": [1, 2, segment_samples],
|
| 169 |
+
"spectrogram": [1, 4, 2048, segment_frames],
|
| 170 |
+
},
|
| 171 |
+
"outputs": {
|
| 172 |
+
"spectrogram_stems": [1, 4, 4, 2048, segment_frames],
|
| 173 |
+
"waveform_stems": [1, 4, 2, segment_samples],
|
| 174 |
+
},
|
| 175 |
+
"preprocessing": [
|
| 176 |
+
"Decode to mono or stereo PCM.",
|
| 177 |
+
"Resample to 44100 Hz.",
|
| 178 |
+
"Duplicate mono to stereo.",
|
| 179 |
+
"Pad each chunk to segment_samples.",
|
| 180 |
+
"Reflect-pad by 1536 samples before STFT, then run normalized Hann STFT with n_fft=4096 and hop_length=1024.",
|
| 181 |
+
"Drop the final frequency bin and keep centered segment_frames frames.",
|
| 182 |
+
],
|
| 183 |
+
"postprocessing": [
|
| 184 |
+
"Take vocals_source_index from both Core ML outputs.",
|
| 185 |
+
"Convert spectrogram real/imag channels back to complex STFT.",
|
| 186 |
+
"Append the dropped zero frequency bin, pad two frames on both sides, and run normalized Hann ISTFT.",
|
| 187 |
+
"Remove the 1536-sample STFT pad, add waveform_stems[vocals_source_index], trim original chunk length, and overlap-add chunks.",
|
| 188 |
+
"Downmix the resulting stereo vocals to mono for voice cloning.",
|
| 189 |
+
],
|
| 190 |
+
"files": package_files(package_path),
|
| 191 |
+
}
|
| 192 |
+
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def parse_args() -> argparse.Namespace:
|
| 196 |
+
parser = argparse.ArgumentParser()
|
| 197 |
+
parser.add_argument("--out-dir", type=Path, default=Path("demucs_experiment/exported/htdemucs_coreml_core"))
|
| 198 |
+
parser.add_argument("--package-name", default="htdemucs_separator_core.mlpackage")
|
| 199 |
+
parser.add_argument("--validate", action="store_true")
|
| 200 |
+
return parser.parse_args()
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def main() -> None:
|
| 204 |
+
args = parse_args()
|
| 205 |
+
args.out_dir.mkdir(parents=True, exist_ok=True)
|
| 206 |
+
|
| 207 |
+
bag = get_model(MODEL_ID)
|
| 208 |
+
model = bag.models[0].eval()
|
| 209 |
+
segment_samples = model.valid_length(44100)
|
| 210 |
+
hop_length = model.hop_length
|
| 211 |
+
segment_frames = int((segment_samples + hop_length - 1) // hop_length)
|
| 212 |
+
|
| 213 |
+
core = HTDemucsSeparatorCore(model).eval()
|
| 214 |
+
mix = torch.zeros(1, 2, segment_samples)
|
| 215 |
+
spectrogram = torch.zeros(1, 4, 2048, segment_frames)
|
| 216 |
+
|
| 217 |
+
if args.validate:
|
| 218 |
+
with torch.no_grad():
|
| 219 |
+
probe = torch.randn_like(mix) * 0.01
|
| 220 |
+
z = model._spec(probe)
|
| 221 |
+
mag = model._magnitude(z)
|
| 222 |
+
spec_out, wave_out = core(probe, mag)
|
| 223 |
+
reconstructed = model._ispec(model._mask(z, spec_out), segment_samples) + wave_out
|
| 224 |
+
reference = model(probe)
|
| 225 |
+
max_error = (reconstructed - reference).abs().max().item()
|
| 226 |
+
print(f"wrapper_max_abs_error={max_error:.8f}")
|
| 227 |
+
|
| 228 |
+
traced = torch.jit.trace(core, (mix, spectrogram), strict=False, check_trace=False)
|
| 229 |
+
print(f"traced segment_samples={segment_samples} segment_frames={segment_frames}")
|
| 230 |
+
|
| 231 |
+
mlmodel = ct.convert(
|
| 232 |
+
traced,
|
| 233 |
+
inputs=[
|
| 234 |
+
ct.TensorType(name="mix", shape=mix.shape),
|
| 235 |
+
ct.TensorType(name="spectrogram", shape=spectrogram.shape),
|
| 236 |
+
],
|
| 237 |
+
outputs=[
|
| 238 |
+
ct.TensorType(name="spectrogram_stems"),
|
| 239 |
+
ct.TensorType(name="waveform_stems"),
|
| 240 |
+
],
|
| 241 |
+
convert_to="mlprogram",
|
| 242 |
+
minimum_deployment_target=ct.target.iOS26,
|
| 243 |
+
compute_precision=ct.precision.FLOAT16,
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
package_path = args.out_dir / args.package_name
|
| 247 |
+
if package_path.exists():
|
| 248 |
+
import shutil
|
| 249 |
+
|
| 250 |
+
shutil.rmtree(package_path)
|
| 251 |
+
mlmodel.save(str(package_path))
|
| 252 |
+
write_manifest(args.out_dir / "manifest.json", package_path, segment_samples, segment_frames)
|
| 253 |
+
print(f"saved {package_path}")
|
| 254 |
+
print(f"saved {args.out_dir / 'manifest.json'}")
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
if __name__ == "__main__":
|
| 258 |
+
main()
|