#!/usr/bin/env python3 """ export_cond_enc.py — Export AceStep Condition Encoder to ONNX. The condition encoder takes outputs from the text encoder (text_hidden + lyric_embed) and reference audio features (timbre_feats), and produces enc_hidden for DiT cross-attention. Internal architecture: - text_projector: Linear(1024→2048, no bias) — projects text encoder output - lyric_encoder: Linear(1024→2048)+bias → 8-layer bidirectional Qwen3 → RMSNorm - timbre_encoder: Linear(64→2048)+bias → [CLS prepend] → 4-layer bidir Qwen3 → RMSNorm → position[0] - cat(lyric_out, timbre_out, text_proj_out) → enc_hidden [B, S_total, 2048] Usage: python export_cond_enc.py --model-dir --output """ import argparse import os import sys import time import struct from pathlib import Path import numpy as np import torch import torch.nn as nn class CondEncoderWrapper(nn.Module): """Wrapper for ONNX export that simplifies the condition encoder interface. For inference (batch_size=1), we simplify: - No pack_sequences sorting (all tokens are valid, no padding) - Timbre: single reference, so unpack is trivial (just unsqueeze) - Output is simple cat(lyric, timbre, text_proj) ONNX inputs: text_hidden: [B, S_text, 1024] fp16 — from text encoder lyric_embed: [B, S_lyric, 1024] fp16 — from embedding table lookup timbre_feats: [B, S_ref, 64] fp16 — from VAE encoder (or zeros) has_timbre: [1] int64 — 1 if timbre is present, 0 if not ONNX output: enc_hidden: [B, S_total, 2048] fp16 — packed conditioning """ def __init__(self, cond_encoder): super().__init__() self.text_projector = cond_encoder.text_projector self.lyric_encoder = cond_encoder.lyric_encoder self.timbre_encoder = cond_encoder.timbre_encoder def forward(self, text_hidden, lyric_embed, timbre_feats, has_timbre): """ Forward pass with simplified interface for ONNX export. Note: For ONNX tracing, has_timbre must be a tensor, not a Python bool. We use torch.where / masking to handle the conditional timbre path. """ B = text_hidden.shape[0] # 1) Text projection: [B, S_text, 1024] → [B, S_text, 2048] text_proj = self.text_projector(text_hidden) # 2) Lyric encoding: [B, S_lyric, 1024] → 8L bidir Qwen3 → [B, S_lyric, 2048] S_lyric = lyric_embed.shape[1] lyric_mask = torch.ones(B, S_lyric, device=lyric_embed.device, dtype=torch.long) lyric_out = self.lyric_encoder( inputs_embeds=lyric_embed, attention_mask=lyric_mask, ) if hasattr(lyric_out, 'last_hidden_state'): lyric_out = lyric_out.last_hidden_state else: lyric_out = lyric_out[0] # 3) Timbre encoding: [B, S_ref, 64] → 4L bidir Qwen3 → position[0] → [B, 1, 2048] # For ONNX: we always run the timbre path but zero out if has_timbre=0 S_ref = timbre_feats.shape[1] timbre_mask = torch.ones(1, S_ref, device=timbre_feats.device, dtype=torch.long) # refer_audio_order_mask: all 0s means everything belongs to batch 0 order_mask = torch.zeros(1, device=timbre_feats.device, dtype=torch.long) # Reshape for timbre encoder: expects [N_packed, S_ref, 64] timbre_input = timbre_feats # [B, S_ref, 64] timbre_embs, timbre_embs_mask = self.timbre_encoder( refer_audio_acoustic_hidden_states_packed=timbre_input, refer_audio_order_mask=order_mask, attention_mask=timbre_mask, ) # timbre_embs: [B, 1, 2048] — CLS token output per batch # 4) Concatenate: [lyric, timbre, text_proj] # When has_timbre=0, skip timbre in the concatenation # For ONNX compatibility, always cat but mask the timbre contribution ht = has_timbre[0] if ht > 0: enc_hidden = torch.cat([lyric_out, timbre_embs, text_proj], dim=1) else: enc_hidden = torch.cat([lyric_out, text_proj], dim=1) return enc_hidden class CondEncoderWrapperFixed(nn.Module): """Fixed version that always includes timbre (simplifies ONNX graph). For inference, timbre is always present (silence latent as zero timbre). This avoids dynamic control flow in the ONNX graph. IMPORTANT: The timbre encoder's forward() uses unpack_timbre_embeddings() which has data-dependent control flow (refer_audio_order_mask.max().item()). torch.export cannot handle this. So we manually invoke the timbre encoder's sub-components: embed_tokens → CLS prepend → transformer layers → norm → take position 0. This is equivalent for B=1 inference. ONNX inputs: text_hidden: [B, S_text, 1024] fp16 lyric_embed: [B, S_lyric, 1024] fp16 timbre_feats: [B, S_ref, 64] fp16 (zeros if no reference) ONNX output: enc_hidden: [B, S_total, 2048] fp16 where S_total = S_lyric + 1 + S_text """ def __init__(self, cond_encoder): super().__init__() self.text_projector = cond_encoder.text_projector self.lyric_encoder = cond_encoder.lyric_encoder # Extract timbre encoder sub-components for manual invocation self.timbre_embed_tokens = cond_encoder.timbre_encoder.embed_tokens self.timbre_special_token = cond_encoder.timbre_encoder.special_token self.timbre_norm = cond_encoder.timbre_encoder.norm self.timbre_rotary_emb = cond_encoder.timbre_encoder.rotary_emb self.timbre_layers = cond_encoder.timbre_encoder.layers self.timbre_config = cond_encoder.timbre_encoder.config def _timbre_forward_simple(self, timbre_feats): """Run the timbre encoder without unpack_timbre_embeddings. timbre_feats: [B, S_ref, 64] Returns: [B, 1, hidden_size] — CLS token output """ B = timbre_feats.shape[0] # Project: [B, S_ref, 64] → [B, S_ref, hidden_size] inputs_embeds = self.timbre_embed_tokens(timbre_feats) # Prepend CLS token: [B, S_ref+1, hidden_size] cls_token = self.timbre_special_token.expand(B, 1, -1) inputs_embeds = torch.cat([cls_token, inputs_embeds], dim=1) S = inputs_embeds.shape[1] # Position IDs and RoPE cache_position = torch.arange(0, S, device=inputs_embeds.device) position_ids = cache_position.unsqueeze(0) position_embeddings = self.timbre_rotary_emb(inputs_embeds, position_ids) # Build attention mask (full bidirectional, no padding) # Using None for SDPA = no mask = full attention hidden_states = inputs_embeds for layer in self.timbre_layers: layer_outputs = layer( hidden_states, position_embeddings, None, # attention_mask=None → full bidirectional position_ids, ) hidden_states = layer_outputs[0] hidden_states = self.timbre_norm(hidden_states) # Extract CLS token (position 0): [B, hidden_size] timbre_emb = hidden_states[:, 0:1, :] # [B, 1, hidden_size] return timbre_emb def forward(self, text_hidden, lyric_embed, timbre_feats): B = text_hidden.shape[0] # 1) Text projection text_proj = self.text_projector(text_hidden) # 2) Lyric encoding S_lyric = lyric_embed.shape[1] lyric_mask = torch.ones(B, S_lyric, device=lyric_embed.device, dtype=torch.long) lyric_out = self.lyric_encoder( inputs_embeds=lyric_embed, attention_mask=lyric_mask, ) if hasattr(lyric_out, 'last_hidden_state'): lyric_out = lyric_out.last_hidden_state else: lyric_out = lyric_out[0] # 3) Timbre encoding — manual path (bypasses unpack_timbre_embeddings) timbre_embs = self._timbre_forward_simple(timbre_feats) # timbre_embs: [B, 1, 2048] # 4) Concatenate: lyric + timbre + text_proj enc_hidden = torch.cat([lyric_out, timbre_embs, text_proj], dim=1) return enc_hidden def load_model(model_dir: str, device: str = "cuda", dtype=torch.float32): """Load the AceStep model and extract the condition encoder.""" model_dir = Path(model_dir) if sys.platform == "win32": import io sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace') # Monkey-patch transformers auto_docstring try: import transformers.utils.auto_docstring as _ad _ad.auto_docstring = lambda *a, **kw: (lambda cls: cls) except Exception: pass sys.path.insert(0, str(model_dir)) # Create a stub AceStepConfig module to bypass the 'acestep' package import. # The real AceStepConfig is a PretrainedConfig subclass. We construct it # using AutoConfig which reads config.json and finds the auto_map. # But first we need the config module to exist so the model code can import it. import json import types from transformers import PretrainedConfig with open(model_dir / "config.json") as f: config_dict = json.load(f) # Create AceStepConfig class dynamically from config.json class AceStepConfig(PretrainedConfig): model_type = "acestep" def __init__(self, **kwargs): super().__init__(**kwargs) # Set all config keys as attributes for k, v in kwargs.items(): if not hasattr(self, k): setattr(self, k, v) # Ensure critical attrs have defaults if not hasattr(self, 'text_hidden_dim'): self.text_hidden_dim = 1024 if not hasattr(self, 'timbre_hidden_dim'): self.timbre_hidden_dim = 64 if not hasattr(self, 'encoder_hidden_size'): self.encoder_hidden_size = 2048 if not hasattr(self, 'encoder_intermediate_size'): self.encoder_intermediate_size = 6144 if not hasattr(self, 'encoder_num_attention_heads'): self.encoder_num_attention_heads = 16 if not hasattr(self, 'encoder_num_key_value_heads'): self.encoder_num_key_value_heads = 8 if not hasattr(self, 'num_lyric_encoder_hidden_layers'): self.num_lyric_encoder_hidden_layers = 8 if not hasattr(self, 'num_timbre_encoder_hidden_layers'): self.num_timbre_encoder_hidden_layers = 4 if not hasattr(self, 'num_attention_pooler_hidden_layers'): self.num_attention_pooler_hidden_layers = 2 if not hasattr(self, 'out_channels'): self.out_channels = 64 if not hasattr(self, 'in_channels'): self.in_channels = 192 # Register the stub config module stub_mod = types.ModuleType("configuration_acestep_v15") stub_mod.AceStepConfig = AceStepConfig sys.modules["configuration_acestep_v15"] = stub_mod # Create stub acestep package hierarchy. Each intermediate module needs # __path__ set so it acts as a package (allows submodule imports). for mod_name in ["acestep", "acestep.models", "acestep.models.common"]: m = types.ModuleType(mod_name) m.__path__ = [] # makes it act as a package sys.modules[mod_name] = m # Register configuration_acestep_v15 under the acestep.models.common path cfg_mod = types.ModuleType("acestep.models.common.configuration_acestep_v15") cfg_mod.AceStepConfig = AceStepConfig sys.modules["acestep.models.common.configuration_acestep_v15"] = cfg_mod # Create apg_guidance stub — these functions are used by the DiT diffusion # loop but NOT by the condition encoder. Provide dummies to satisfy import. class MomentumBuffer: def __init__(self, *a, **kw): pass def _apg_stub(*a, **kw): return None apg_stub = types.ModuleType("acestep.models.common.apg_guidance") apg_stub.MomentumBuffer = MomentumBuffer apg_stub.adg_forward = _apg_stub apg_stub.adg_w_norm_forward = _apg_stub apg_stub.adg_wo_clip_forward = _apg_stub apg_stub.apg_forward = _apg_stub apg_stub.cfg_forward = _apg_stub apg_stub.call_cos_tensor = _apg_stub apg_stub.compute_perpendicular_component = _apg_stub apg_stub.project = _apg_stub sys.modules["acestep.models.common.apg_guidance"] = apg_stub # Also register the local apg_guidance module apg_local = types.ModuleType("apg_guidance") apg_local.MomentumBuffer = MomentumBuffer apg_local.adg_forward = _apg_stub apg_local.apg_forward = _apg_stub apg_local.cfg_forward = _apg_stub sys.modules["apg_guidance"] = apg_local config = AceStepConfig(**config_dict) config._attn_implementation = "sdpa" # The encoder's Qwen3 sub-models (lyric/timbre) use encoder_hidden_size # as their hidden_size. Set it on the config so Qwen3RotaryEmbedding works. # transformers 5.x requires rope_parameters dict. if not hasattr(config, 'rope_parameters') or config.rope_parameters is None: config.rope_parameters = { "rope_type": "default", "rope_theta": config.rope_theta if hasattr(config, 'rope_theta') else 1000000.0, } print(f"[export_cond_enc] Loading model from {model_dir}...") t0 = time.time() # The full model creates a separate encoder config with encoder-specific # dimensions (see AceStepConditionGenerationModel.__init__ lines 1621-1628). # The encoder uses encoder_hidden_size (2048), not the DiT hidden_size (2560). import copy encoder_config = copy.deepcopy(config) encoder_config.hidden_size = config.encoder_hidden_size encoder_config.intermediate_size = config.encoder_intermediate_size encoder_config.num_attention_heads = config.encoder_num_attention_heads encoder_config.num_key_value_heads = config.encoder_num_key_value_heads from modeling_acestep_v15_xl_base import AceStepConditionEncoder cond_encoder = AceStepConditionEncoder(encoder_config) # Load weights — filter to encoder.* prefix from safetensors.torch import load_file st_path = model_dir / "model.safetensors" state_dict = load_file(str(st_path)) cond_state_dict = {} for k, v in state_dict.items(): if k.startswith("encoder."): cond_state_dict[k[len("encoder."):]] = v missing, unexpected = cond_encoder.load_state_dict(cond_state_dict, strict=False) if missing: print(f"[export_cond_enc] Warning: {len(missing)} missing keys (first 5: {missing[:5]})") if unexpected: print(f"[export_cond_enc] Warning: {len(unexpected)} unexpected keys (first 5: {unexpected[:5]})") cond_encoder = cond_encoder.to(device=device, dtype=dtype) cond_encoder.eval() t1 = time.time() n_params = sum(p.numel() for p in cond_encoder.parameters()) / 1e6 print(f"[export_cond_enc] Model loaded in {t1-t0:.1f}s ({n_params:.0f}M params)") print(f"[export_cond_enc] text_hidden_dim={encoder_config.text_hidden_dim}, hidden_size={encoder_config.hidden_size}") return cond_encoder, encoder_config def export_onnx(cond_encoder, config, output_path: str, opset: int = 18): """Export the condition encoder to ONNX.""" device = next(cond_encoder.parameters()).device dtype = next(cond_encoder.parameters()).dtype wrapper = CondEncoderWrapperFixed(cond_encoder) wrapper.eval() # Dummy inputs B = 1 S_text = 64 S_lyric = 128 S_ref = 8 # 8 frames of reference audio (short clip) dummy_text_hidden = torch.randn(B, S_text, config.text_hidden_dim, device=device, dtype=dtype) dummy_lyric_embed = torch.randn(B, S_lyric, config.text_hidden_dim, device=device, dtype=dtype) dummy_timbre_feats = torch.randn(B, S_ref, config.timbre_hidden_dim, device=device, dtype=dtype) print(f"[export_cond_enc] Tracing with shapes: text={list(dummy_text_hidden.shape)}, " f"lyric={list(dummy_lyric_embed.shape)}, timbre={list(dummy_timbre_feats.shape)}") # Test forward print("[export_cond_enc] Testing forward pass...") with torch.no_grad(): test_out = wrapper(dummy_text_hidden, dummy_lyric_embed, dummy_timbre_feats) expected_S = S_lyric + 1 + S_text # lyric + timbre(1) + text print(f"[export_cond_enc] Output shape: {list(test_out.shape)} " f"(expected [{B}, {expected_S}, {config.hidden_size}])") # Export print(f"[export_cond_enc] Exporting to ONNX (opset {opset})...") t0 = time.time() torch.onnx.export( wrapper, (dummy_text_hidden, dummy_lyric_embed, dummy_timbre_feats), output_path, opset_version=opset, input_names=["text_hidden", "lyric_embed", "timbre_feats"], output_names=["enc_hidden"], dynamic_axes={ "text_hidden": {0: "batch", 1: "text_seq"}, "lyric_embed": {0: "batch", 1: "lyric_seq"}, "timbre_feats": {0: "batch", 1: "timbre_seq"}, "enc_hidden": {0: "batch", 1: "enc_seq"}, }, do_constant_folding=True, export_params=True, ) t1 = time.time() file_size = os.path.getsize(output_path) print(f"[export_cond_enc] Exported to {output_path}") print(f"[export_cond_enc] File size: {file_size/1e6:.1f} MB") print(f"[export_cond_enc] Export time: {t1-t0:.1f}s") def export_null_cond_emb(model_dir: str, output_path: str): """Export null_condition_emb as raw float32 binary.""" from safetensors.torch import load_file model_dir = Path(model_dir) st_path = model_dir / "model.safetensors" state_dict = load_file(str(st_path)) key = "null_condition_emb" if key not in state_dict: print(f"[export_cond_enc] WARNING: {key} not found, skipping") return vec = state_dict[key].detach().cpu().float().numpy().flatten() with open(output_path, "wb") as f: f.write(struct.pack(" {output_path} ({len(vec)*4} bytes)") def verify_onnx(onnx_path: str, cond_encoder, config): """Verify ONNX output matches PyTorch.""" try: import onnxruntime as ort except ImportError: print("[export_cond_enc] onnxruntime not installed, skipping verification") return device = next(cond_encoder.parameters()).device dtype = next(cond_encoder.parameters()).dtype wrapper = CondEncoderWrapperFixed(cond_encoder) wrapper.eval() B, S_text, S_lyric, S_ref = 1, 32, 64, 8 text_hidden = torch.randn(B, S_text, config.text_hidden_dim, device=device, dtype=dtype) lyric_embed = torch.randn(B, S_lyric, config.text_hidden_dim, device=device, dtype=dtype) timbre_feats = torch.randn(B, S_ref, config.timbre_hidden_dim, device=device, dtype=dtype) with torch.no_grad(): ref_out = wrapper(text_hidden, lyric_embed, timbre_feats).cpu().float().numpy() providers = ["CUDAExecutionProvider", "CPUExecutionProvider"] sess = ort.InferenceSession(onnx_path, providers=providers) ort_out = sess.run(None, { "text_hidden": text_hidden.cpu().float().numpy(), "lyric_embed": lyric_embed.cpu().float().numpy(), "timbre_feats": timbre_feats.cpu().float().numpy(), })[0] max_diff = np.max(np.abs(ref_out - ort_out)) mean_diff = np.mean(np.abs(ref_out - ort_out)) print(f"[export_cond_enc] Verification: max_diff={max_diff:.6f}, mean_diff={mean_diff:.6f}") if max_diff < 0.05: print("[export_cond_enc] PASS: ONNX output matches PyTorch") else: print("[export_cond_enc] WARNING: Large difference — may need investigation") def main(): parser = argparse.ArgumentParser(description="Export AceStep condition encoder to ONNX") parser.add_argument("--model-dir", required=True, help="Path to the DiT model directory (contains encoder weights)") parser.add_argument("--output", default=None, help="Output ONNX file (default: models/onnx/cond_encoder.onnx)") parser.add_argument("--opset", type=int, default=18) parser.add_argument("--verify", action="store_true") parser.add_argument("--device", default="cuda") args = parser.parse_args() if args.output is None: onnx_dir = Path(args.model_dir).parent / "onnx" onnx_dir.mkdir(parents=True, exist_ok=True) args.output = str(onnx_dir / "cond_encoder.onnx") os.makedirs(os.path.dirname(args.output), exist_ok=True) output_dir = os.path.dirname(args.output) # Load model cond_encoder, config = load_model(args.model_dir, device=args.device) # Export ONNX export_onnx(cond_encoder, config, args.output, opset=args.opset) # Export null_condition_emb null_cond_path = os.path.join(output_dir, "null_condition_emb.bin") export_null_cond_emb(args.model_dir, null_cond_path) # Verify if args.verify: verify_onnx(args.output, cond_encoder, config) print("[export_cond_enc] Done!") if __name__ == "__main__": main()