Skip to content

Commit 7059368

Browse files
feat: build all split model members
1 parent 056f141 commit 7059368

3 files changed

Lines changed: 151 additions & 18 deletions

File tree

docs/split-dsp.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,14 @@ for frequency, and `5.472e-5` max absolute error and `6.695e-12` MSE for time. O
113113
input, the official Hugging Face model loader and the legacy fork/checkpoint loader produced
114114
byte-identical final output (`sha256:21d88c3dcb713451ffcf20c4c44a17306bb10f11ae0277df4ab90c3043146359`).
115115

116+
The v2 builder accepts explicit member names or `--all`. It atomically writes the requested model
117+
set and a `manifest.json` containing the graph flavor, upstream version, tensor contract, source
118+
order, precision, specialty, artifact size and digest, and measured branch parity. A full build is:
119+
120+
```sh
121+
pnpm build-model-v2 --all
122+
```
123+
116124
Split artifacts must use a distinct directory, filename convention, and manifest flavor while the
117125
current self-contained artifacts remain supported. A split model must never be loadable as a
118126
waveform model by accident.
@@ -156,10 +164,10 @@ code.
156164

157165
### 2. Verify the split ONNX artifact
158166

159-
- Compare frequency and time outputs independently against PyTorch.
160-
- Extend model-building scripts with an explicit split flavor.
161-
- Generate all standard and fine-tuned members only after the standard model passes.
162-
- Record exact model sizes and tensor metadata.
167+
- [x] Compare frequency and time outputs independently against PyTorch.
168+
- [x] Add an isolated split-model builder with explicit-member and `--all` modes.
169+
- [x] Generate and verify the standard model and all four fine-tuned specialists.
170+
- [x] Record exact model sizes, digests, parity metrics, and tensor metadata in a manifest.
163171

164172
### 3. Implement native runtime DSP
165173

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"link-wt": "bash tools/link-wt.sh",
88
"bass-cover": "uv run --no-sync tools/bass-cover.py",
99
"build-model": "uv run python tools/model-export/build_models.py",
10-
"build-model-v2": "uv run --project tools/model-export-v2 python tools/model-export-v2/export_split_onnx.py --out data/onnx-split/htdemucs.onnx",
10+
"build-model-v2": "uv run --project tools/model-export-v2 python tools/model-export-v2/export_split_onnx.py --out data/onnx-split",
1111
"verify-parity": "uv run python tools/model-export/verify_parity.py",
1212
"model-release": "uv run --no-sync tools/model_release.py",
1313
"build-wasm": "wasm-pack build crates/wasm --target web --release",

tools/model-export-v2/export_split_onnx.py

Lines changed: 138 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,18 @@
55
66
Usage:
77
uv run --project tools/model-export-v2 python tools/model-export-v2/export_split_onnx.py \
8-
--out data/onnx-split/htdemucs.onnx
8+
htdemucs --out data/onnx-split
9+
uv run --project tools/model-export-v2 python tools/model-export-v2/export_split_onnx.py \
10+
--all --out data/onnx-split
911
"""
1012

1113
import argparse
14+
import hashlib
15+
import json
16+
import os
1217
from pathlib import Path
18+
import shutil
19+
import tempfile
1320

1421
import numpy as np
1522
import onnx
@@ -20,6 +27,15 @@
2027
from demucs.pretrained import get_model
2128
from einops import rearrange
2229

30+
MEMBERS = (
31+
"htdemucs",
32+
"htdemucs_ft_drums",
33+
"htdemucs_ft_bass",
34+
"htdemucs_ft_other",
35+
"htdemucs_ft_vocals",
36+
)
37+
FT_PREFIX = "htdemucs_ft_"
38+
2339

2440
class SplitHTDemucs(torch.nn.Module):
2541
"""HTDemucs from packed spectrogram/waveform inputs to decoded branch outputs."""
@@ -106,18 +122,45 @@ def forward(
106122
return frequency, time
107123

108124

109-
def get_core() -> HTDemucs:
110-
model = get_model("htdemucs")
125+
def unwrap_model(model: HTDemucs | BagOfModels, name: str) -> HTDemucs:
111126
if isinstance(model, BagOfModels):
112127
if len(model.models) != 1:
113-
raise ValueError(f"expected one htdemucs member, got {len(model.models)}")
128+
raise ValueError(f"expected one {name} member, got {len(model.models)}")
114129
model = model.models[0]
115130
if not isinstance(model, HTDemucs):
116131
raise TypeError(f"expected HTDemucs, got {type(model)}")
117132
model.eval()
118133
return model
119134

120135

136+
def load_members(requested: list[str]) -> dict[str, HTDemucs]:
137+
members = {}
138+
if "htdemucs" in requested:
139+
members["htdemucs"] = unwrap_model(get_model("htdemucs"), "htdemucs")
140+
141+
requested_ft = {name.removeprefix(FT_PREFIX) for name in requested if name.startswith(FT_PREFIX)}
142+
if requested_ft:
143+
bag = get_model("htdemucs_ft")
144+
if not isinstance(bag, BagOfModels):
145+
raise TypeError(f"expected htdemucs_ft bag, got {type(bag)}")
146+
for core, weights in zip(bag.models, bag.weights):
147+
selected = [source for source, weight in zip(bag.sources, weights) if weight > 0]
148+
if len(selected) != 1:
149+
raise ValueError(f"expected one-hot htdemucs_ft weights, got {weights}")
150+
source = selected[0]
151+
if source not in requested_ft:
152+
continue
153+
if not isinstance(core, HTDemucs):
154+
raise TypeError(f"expected HTDemucs, got {type(core)}")
155+
core.eval()
156+
members[f"{FT_PREFIX}{source}"] = core
157+
158+
missing = [name for name in requested if name not in members]
159+
if missing:
160+
raise ValueError(f"failed to load member(s): {', '.join(missing)}")
161+
return members
162+
163+
121164
def pack_spectrogram(core: HTDemucs, mix: torch.Tensor) -> torch.Tensor:
122165
z = core._spec(mix)
123166
batch, channels, frequencies, frames = z.shape
@@ -161,7 +204,7 @@ def validate_onnx(
161204
spectrogram: torch.Tensor,
162205
expected_frequency: torch.Tensor,
163206
expected_time: torch.Tensor,
164-
) -> None:
207+
) -> dict[str, dict[str, float]]:
165208
model = onnx.load(str(path), load_external_data=False)
166209
onnx.checker.check_model(model)
167210
inputs = [(value.name, [dim.dim_value for dim in value.type.tensor_type.shape.dim])
@@ -200,6 +243,7 @@ def validate_onnx(
200243
["frequency", "time"],
201244
{"waveform": mix.numpy(), "spectrogram": spectrogram.numpy()},
202245
)
246+
parity = {}
203247
for name, actual, expected in (
204248
("frequency", frequency, expected_frequency.numpy()),
205249
("time", time, expected_time.numpy()),
@@ -210,34 +254,115 @@ def validate_onnx(
210254
print(f"ONNX {name}: max abs {max_abs:.3e}, mse {mse:.3e}")
211255
if max_abs > 2e-3 or mse > 1e-7:
212256
raise SystemExit(f"ONNX {name} parity failed: max abs {max_abs:.3e}, mse {mse:.3e}")
257+
parity[name] = {"max_abs": max_abs, "mse": mse}
213258
print(f"split graph: {path.stat().st_size / 1e6:.1f} MB, no DFT payload")
259+
return parity
214260

215261

216-
def main() -> None:
217-
parser = argparse.ArgumentParser(description=__doc__)
218-
parser.add_argument("--out", type=Path, required=True)
219-
args = parser.parse_args()
262+
def sha256(path: Path) -> str:
263+
digest = hashlib.sha256()
264+
with path.open("rb") as file:
265+
while chunk := file.read(1024 * 1024):
266+
digest.update(chunk)
267+
return digest.hexdigest()
268+
220269

221-
core = get_core()
270+
def export_member(name: str, core: HTDemucs, out: Path) -> dict:
271+
print(f"exporting {name} ...")
222272
split = SplitHTDemucs(core).eval()
223273
generator = torch.Generator().manual_seed(42)
224274
mix = torch.randn(1, 2, mix_length(core), generator=generator)
225275
with torch.no_grad():
226276
spectrogram = pack_spectrogram(core, mix)
227277
frequency, time = verify_python_seam(core, split, mix, spectrogram)
228278

229-
args.out.parent.mkdir(parents=True, exist_ok=True)
279+
path = out / f"{name}.onnx"
230280
torch.onnx.export(
231281
split,
232282
(mix, spectrogram),
233-
args.out,
283+
path,
234284
export_params=True,
235285
opset_version=17,
236286
do_constant_folding=True,
237287
input_names=["waveform", "spectrogram"],
238288
output_names=["frequency", "time"],
239289
)
240-
validate_onnx(args.out, mix, spectrogram, frequency, time)
290+
parity = validate_onnx(path, mix, spectrogram, frequency, time)
291+
return {
292+
"file": path.name,
293+
"member": name,
294+
"precision": "fp32",
295+
"sha256": sha256(path),
296+
"size": path.stat().st_size,
297+
"specialty": name.removeprefix(FT_PREFIX) if name.startswith(FT_PREFIX) else None,
298+
"parity": parity,
299+
}
300+
301+
302+
def replace_output(staging: Path, output: Path) -> None:
303+
backup = None
304+
if output.exists():
305+
backup = Path(tempfile.mkdtemp(prefix=f".{output.name}.old-", dir=output.parent))
306+
backup.rmdir()
307+
os.replace(output, backup)
308+
try:
309+
os.replace(staging, output)
310+
except BaseException:
311+
if backup is not None:
312+
os.replace(backup, output)
313+
raise
314+
if backup is not None:
315+
shutil.rmtree(backup)
316+
317+
318+
def main() -> None:
319+
parser = argparse.ArgumentParser(description=__doc__)
320+
parser.add_argument("members", nargs="*", metavar="MEMBER")
321+
parser.add_argument("--all", action="store_true", help="build all model members")
322+
parser.add_argument("--out", type=Path, required=True)
323+
args = parser.parse_args()
324+
325+
if args.all and args.members:
326+
parser.error("pass either --all or explicit members, not both")
327+
if not args.all and not args.members:
328+
parser.error("pass --all or at least one member")
329+
requested = list(MEMBERS) if args.all else list(dict.fromkeys(args.members))
330+
unknown = [name for name in requested if name not in MEMBERS]
331+
if unknown:
332+
parser.error(f"unknown member(s): {', '.join(unknown)}; expected: {', '.join(MEMBERS)}")
333+
334+
output = args.out.resolve()
335+
output.parent.mkdir(parents=True, exist_ok=True)
336+
staging = Path(tempfile.mkdtemp(prefix=f".{output.name}.new-", dir=output.parent))
337+
try:
338+
cores = load_members(requested)
339+
models = [export_member(name, cores[name], staging) for name in requested]
340+
manifest = {
341+
"format": 1,
342+
"graph_flavor": "split-dsp",
343+
"models": models,
344+
"onnx_contract": {
345+
"inputs": {
346+
"waveform": [1, 2, 343980],
347+
"spectrogram": [1, 4, 2048, 336],
348+
},
349+
"outputs": {
350+
"frequency": [1, 4, 4, 2048, 336],
351+
"time": [1, 4, 2, 343980],
352+
},
353+
"sample_rate": 44100,
354+
"sources": ["drums", "bass", "other", "vocals"],
355+
},
356+
"upstream": {"demucs": "4.1.0"},
357+
}
358+
(staging / "manifest.json").write_text(
359+
json.dumps(manifest, indent=2, sort_keys=True) + "\n"
360+
)
361+
replace_output(staging, output)
362+
except BaseException:
363+
shutil.rmtree(staging, ignore_errors=True)
364+
raise
365+
print(f"wrote {len(requested)} model(s) and manifest to {output}")
241366

242367

243368
if __name__ == "__main__":

0 commit comments

Comments
 (0)