55
66Usage:
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
1113import argparse
14+ import hashlib
15+ import json
16+ import os
1217from pathlib import Path
18+ import shutil
19+ import tempfile
1320
1421import numpy as np
1522import onnx
2027from demucs .pretrained import get_model
2128from 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
2440class 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+
121164def 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
243368if __name__ == "__main__" :
0 commit comments