reiscook commited on
Commit
8f632b3
·
verified ·
1 Parent(s): 27befd0

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()