diff --git a/extensions_built_in/audio_models/__init__.py b/extensions_built_in/audio_models/__init__.py index b01e265..552d72a 100644 --- a/extensions_built_in/audio_models/__init__.py +++ b/extensions_built_in/audio_models/__init__.py @@ -1,7 +1,9 @@ from .ace_step import AceStep15Model, AceStep15XLModel +from .yue2 import YuE2AudioModel AI_TOOLKIT_MODELS = [ # put a list of models here AceStep15Model, AceStep15XLModel, + YuE2AudioModel, ] diff --git a/extensions_built_in/audio_models/yue2/__init__.py b/extensions_built_in/audio_models/yue2/__init__.py new file mode 100644 index 0000000..906018b --- /dev/null +++ b/extensions_built_in/audio_models/yue2/__init__.py @@ -0,0 +1 @@ +from .yue2_model import YuE2AudioModel diff --git a/extensions_built_in/audio_models/yue2/src/__init__.py b/extensions_built_in/audio_models/yue2/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/extensions_built_in/audio_models/yue2/src/model.py b/extensions_built_in/audio_models/yue2/src/model.py new file mode 100644 index 0000000..4bbcec0 --- /dev/null +++ b/extensions_built_in/audio_models/yue2/src/model.py @@ -0,0 +1,414 @@ +"""YuE2 AR + NAR mixture-of-transformers backbone in the ComfyUI repack layout. + +Two 28-layer Qwen3-shaped experts share one architecture: + - ``YuE2AR`` (``text_encoders.*`` in the repack): token embedding, causal + layers, final norm and lm head. Writes ABC + semantic codec tokens. + - ``YuE2NAR`` (``model.diffusion_model.*``): latent in/out projections, + timestep embedder, sinusoidal frame position table and the same layer + stack. Predicts flow velocity on VAE latents while attending into the + AR expert's key/value cache over ``prefix + codec tokens + MUSIC_END``. + +Weights use the merged ``qkv_proj`` / ``gate_up_proj`` linears of the +Comfy-Org checkpoint so LoRA keys line up with ComfyUI's loader. +""" + +import math +from typing import List, Optional, Tuple + +import torch +import torch.nn.functional as F +from torch import nn +import torch.utils.checkpoint as ckpt + +from toolkit.models.v2._mixin import OstrisModelMixin + +# vocabulary layout of the AR expert +EOD = 151643 +ABC_START, ABC_END = 151847, 151848 +MUSIC_START, MUSIC_END = 151851, 151852 +CODEC_OFFSET, CODEC_SIZE = 151853, 32768 +CONTEXT = 24576 +FRAMES_PER_SECOND = 25 +LATENT_DIM = 64 + +INSTRUCTIONS = { + "off": "Generate music with codec tokens from the given conditions.", + "melody": "Generate a melody-only ABC transcription without chord symbols, then generate music with codec tokens from the given conditions.", + "full": "Generate a chord-annotated ABC transcription, then generate music with codec tokens from the given conditions.", +} + + +class YuE2Config: + hidden_size = 2048 + intermediate_size = 6144 + num_hidden_layers = 28 + num_attention_heads = 16 + num_key_value_heads = 8 + head_dim = 128 + vocab_size = 184704 + rms_norm_eps = 1e-6 + rope_theta = 1000000.0 + max_position_embeddings = CONTEXT + + +class RMSNorm(nn.Module): + def __init__(self, dim, eps=1e-6): + super().__init__() + self.weight = nn.Parameter(torch.ones(dim)) + self.eps = eps + + def forward(self, x): + out = x.float() + out = out * torch.rsqrt(out.pow(2).mean(-1, keepdim=True) + self.eps) + return out.to(x.dtype) * self.weight + + +def rope_cos_sin(positions: torch.Tensor, head_dim: int, theta: float): + """positions [B, L] -> cos, sin [B, L, head_dim // 2] in fp32.""" + inv_freq = 1.0 / ( + theta ** (torch.arange(0, head_dim, 2, dtype=torch.float32, device=positions.device) / head_dim) + ) + angles = positions.float().unsqueeze(-1) * inv_freq + return angles.cos(), angles.sin() + + +def apply_rope(x, cos, sin): + # x [B, H, L, D]; cos/sin [B, L, D/2] + half = x.shape[-1] // 2 + x1, x2 = x[..., :half], x[..., half:] + cos = cos.unsqueeze(1).to(x.dtype) + sin = sin.unsqueeze(1).to(x.dtype) + return torch.cat([x1 * cos - x2 * sin, x2 * cos + x1 * sin], dim=-1) + + +class YuE2Attention(nn.Module): + def __init__(self, cfg: YuE2Config): + super().__init__() + self.num_heads = cfg.num_attention_heads + self.num_kv_heads = cfg.num_key_value_heads + self.head_dim = cfg.head_dim + inner = self.num_heads * self.head_dim + kv = self.num_kv_heads * self.head_dim + self.qkv_proj = nn.Linear(cfg.hidden_size, inner + 2 * kv, bias=False) + self.o_proj = nn.Linear(inner, cfg.hidden_size, bias=False) + self.q_norm = RMSNorm(self.head_dim, cfg.rms_norm_eps) + self.k_norm = RMSNorm(self.head_dim, cfg.rms_norm_eps) + + def project(self, x, cos, sin): + b, l, _ = x.shape + inner = self.num_heads * self.head_dim + kv = self.num_kv_heads * self.head_dim + q, k, v = self.qkv_proj(x).split((inner, kv, kv), dim=-1) + q = q.view(b, l, self.num_heads, self.head_dim).transpose(1, 2) + k = k.view(b, l, self.num_kv_heads, self.head_dim).transpose(1, 2) + v = v.view(b, l, self.num_kv_heads, self.head_dim).transpose(1, 2) + q = apply_rope(self.q_norm(q), cos, sin) + k = apply_rope(self.k_norm(k), cos, sin) + return q, k, v + + def attend(self, q, k, v, causal: bool): + out = F.scaled_dot_product_attention( + q, k, v, is_causal=causal, enable_gqa=self.num_heads != self.num_kv_heads + ) + b, _, l, _ = out.shape + return self.o_proj(out.transpose(1, 2).reshape(b, l, -1)) + + +class YuE2MLP(nn.Module): + def __init__(self, cfg: YuE2Config): + super().__init__() + self.gate_up_proj = nn.Linear(cfg.hidden_size, cfg.intermediate_size * 2, bias=False) + self.down_proj = nn.Linear(cfg.intermediate_size, cfg.hidden_size, bias=False) + + def forward(self, x): + gate, up = self.gate_up_proj(x).chunk(2, dim=-1) + return self.down_proj(F.silu(gate) * up) + + +class YuE2Layer(nn.Module): + def __init__(self, cfg: YuE2Config): + super().__init__() + self.input_layernorm = RMSNorm(cfg.hidden_size, cfg.rms_norm_eps) + self.self_attn = YuE2Attention(cfg) + self.post_attention_layernorm = RMSNorm(cfg.hidden_size, cfg.rms_norm_eps) + self.mlp = YuE2MLP(cfg) + + def forward( + self, + x, + cos, + sin, + prefix_kv: Optional[Tuple[torch.Tensor, torch.Tensor]] = None, + causal: bool = True, + return_kv: bool = False, + ): + q, k, v = self.self_attn.project(self.input_layernorm(x), cos, sin) + present = (k, v) if return_kv else None + if prefix_kv is not None: + k = torch.cat([prefix_kv[0], k], dim=2) + v = torch.cat([prefix_kv[1], v], dim=2) + x = x + self.self_attn.attend(q, k, v, causal) + x = x + self.mlp(self.post_attention_layernorm(x)) + return x, present + + +class YuE2Stack(nn.Module): + """``model.layers`` + ``model.norm`` of one expert.""" + + def __init__(self, cfg: YuE2Config): + super().__init__() + self.layers = nn.ModuleList([YuE2Layer(cfg) for _ in range(cfg.num_hidden_layers)]) + self.norm = RMSNorm(cfg.hidden_size, cfg.rms_norm_eps) + + +class YuE2Expert(nn.Module): + """Shared expert base: LoRA targets this class name.""" + + def __init__(self, cfg: YuE2Config): + super().__init__() + self.cfg = cfg + self.gradient_checkpointing = False + + def _run_layer(self, layer, *args, **kwargs): + if self.gradient_checkpointing and torch.is_grad_enabled(): + return ckpt.checkpoint(layer, *args, use_reentrant=False, **kwargs) + return layer(*args, **kwargs) + + @property + def device(self): + return self.model.norm.weight.device + + @property + def dtype(self): + # norms stay in the activation dtype when the linears are quantized + return self.model.norm.weight.dtype + + +class YuE2AR(YuE2Expert): + def __init__(self, cfg: YuE2Config): + super().__init__(cfg) + self.model = YuE2Stack(cfg) + self.model.embed_tokens = nn.Embedding(cfg.vocab_size, cfg.hidden_size) + self.model.lm_head = nn.Linear(cfg.hidden_size, cfg.vocab_size, bias=False) + + def embed(self, ids: torch.Tensor): + return self.model.embed_tokens(ids.to(self.model.embed_tokens.weight.device)) + + def prefill(self, inputs_embeds: torch.Tensor, return_hidden: bool = False): + """Causal pass over [B, L, H]. Returns per-layer post-rope (k, v) as + [B, kv_heads, L, head_dim] plus (optionally) the final normed hidden.""" + b, l, _ = inputs_embeds.shape + positions = torch.arange(l, device=inputs_embeds.device)[None].expand(b, -1) + cos, sin = rope_cos_sin(positions, self.cfg.head_dim, self.cfg.rope_theta) + x = inputs_embeds + cache: List[Tuple[torch.Tensor, torch.Tensor]] = [] + for layer in self.model.layers: + x, kv = self._run_layer(layer, x, cos, sin, None, True, True) + cache.append(kv) + hidden = self.model.norm(x) if return_hidden else None + return cache, hidden + + @torch.no_grad() + def decode_step(self, token_embeds: torch.Tensor, cache: "KVCache"): + """One new token per batch row against a preallocated cache.""" + b = token_embeds.shape[0] + pos = torch.full((b, 1), cache.length, device=token_embeds.device, dtype=torch.long) + cos, sin = rope_cos_sin(pos, self.cfg.head_dim, self.cfg.rope_theta) + x = token_embeds + for i, layer in enumerate(self.model.layers): + q, k, v = layer.self_attn.project(layer.input_layernorm(x), cos, sin) + k, v = cache.append(i, k, v) + x = x + layer.self_attn.attend(q, k, v, causal=False) + x = x + layer.mlp(layer.post_attention_layernorm(x)) + cache.length += 1 + return self.model.lm_head(self.model.norm(x[:, -1])) + + @torch.no_grad() + def prefill_into_cache(self, inputs_embeds: torch.Tensor, cache: "KVCache"): + b, l, _ = inputs_embeds.shape + positions = torch.arange(l, device=inputs_embeds.device)[None].expand(b, -1) + cos, sin = rope_cos_sin(positions, self.cfg.head_dim, self.cfg.rope_theta) + x = inputs_embeds + for i, layer in enumerate(self.model.layers): + q, k, v = layer.self_attn.project(layer.input_layernorm(x), cos, sin) + cache.write(i, 0, k, v) + x = x + layer.self_attn.attend(q, k, v, causal=True) + x = x + layer.mlp(layer.post_attention_layernorm(x)) + cache.length = l + return self.model.lm_head(self.model.norm(x[:, -1])) + + +class KVCache: + """Preallocated [B, kv_heads, capacity, head_dim] per layer for AR decode.""" + + def __init__(self, cfg: YuE2Config, batch: int, capacity: int, device, dtype): + shape = (batch, cfg.num_key_value_heads, capacity, cfg.head_dim) + self.k = [torch.empty(shape, device=device, dtype=dtype) for _ in range(cfg.num_hidden_layers)] + self.v = [torch.empty(shape, device=device, dtype=dtype) for _ in range(cfg.num_hidden_layers)] + self.capacity = capacity + self.length = 0 + + def write(self, layer, start, k, v): + n = k.shape[2] + self.k[layer][:, :, start : start + n] = k + self.v[layer][:, :, start : start + n] = v + + def append(self, layer, k, v): + end = self.length + k.shape[2] + if end > self.capacity: + raise ValueError("YuE2 KV cache capacity exceeded") + self.write(layer, self.length, k, v) + return self.k[layer][:, :, :end], self.v[layer][:, :, :end] + + +class TimestepEmbedder(nn.Module): + def __init__(self, hidden_size, freq_size=256): + super().__init__() + self.mlp = nn.Sequential( + nn.Linear(freq_size, hidden_size), nn.SiLU(), nn.Linear(hidden_size, hidden_size) + ) + self.freq_size = freq_size + + def forward(self, t: torch.Tensor): + half = self.freq_size // 2 + freqs = torch.exp( + -math.log(10000) * torch.arange(half, device=t.device, dtype=torch.float32) / half + ) + args = t.float().unsqueeze(-1) * freqs.unsqueeze(0) + emb = torch.cat([torch.cos(args), torch.sin(args)], dim=-1) + return self.mlp(emb.to(self.mlp[0].weight.dtype)) + + +class AudioPositionEmbedding(nn.Module): + def __init__(self, max_frames, hidden_size): + super().__init__() + self.register_buffer("pe", torch.zeros(max_frames, hidden_size)) + + def forward(self, length: int): + return self.pe[:length] + + +class YuE2NAR(YuE2Expert): + def __init__(self, cfg: YuE2Config): + super().__init__(cfg) + self.model = YuE2Stack(cfg) + self.vae2llm = nn.Linear(LATENT_DIM, cfg.hidden_size) + self.llm2vae = nn.Linear(cfg.hidden_size, LATENT_DIM) + self.time_embedder = TimestepEmbedder(cfg.hidden_size) + self.latent_pos_embed = AudioPositionEmbedding(cfg.max_position_embeddings, cfg.hidden_size) + + def forward( + self, + noisy_latents: torch.Tensor, # [B, T, 64] + t: torch.Tensor, # [B] in 0..1, 1 = pure noise + prefix_cache: List[Tuple[torch.Tensor, torch.Tensor]], + prefix_len: int, + ): + b, frames, _ = noisy_latents.shape + n = frames + 2 + x = F.pad(noisy_latents, (0, 0, 1, 1)) # START / END slots + x = self.vae2llm(x.to(self.vae2llm.weight.dtype)) + x = x + self.time_embedder(t)[:, None] + x = x + self.latent_pos_embed(n)[None].to(x.dtype) + positions = torch.arange(prefix_len, prefix_len + n, device=x.device)[None].expand(b, -1) + cos, sin = rope_cos_sin(positions, self.cfg.head_dim, self.cfg.rope_theta) + for layer, kv in zip(self.model.layers, prefix_cache): + x, _ = self._run_layer(layer, x, cos, sin, kv, False, False) + return self.llm2vae(self.model.norm(x))[:, 1:-1] + + +class YuE2Model(nn.Module, OstrisModelMixin): + """Both experts under one module so a single LoRA network covers them.""" + + aitk_comfy_repo = "Comfy-Org/YuE2" + + def __init__(self, cfg: Optional[YuE2Config] = None): + super().__init__() + cfg = cfg or YuE2Config() + self.cfg = cfg + self.ar = YuE2AR(cfg) + self.nar = YuE2NAR(cfg) + + @classmethod + def get_transformer_block_names(cls): + return ["ar.model.layers", "nar.model.layers"] + + @classmethod + def get_quantization_exclude_modules(cls): + return ["ar.model.embed_tokens", "ar.model.lm_head", "nar.vae2llm", "nar.llm2vae", "nar.time_embedder*"] + + @property + def device(self): + return self.nar.device + + @property + def dtype(self): + return self.nar.dtype + + def enable_gradient_checkpointing(self): + self.ar.gradient_checkpointing = True + self.nar.gradient_checkpointing = True + + def disable_gradient_checkpointing(self): + self.ar.gradient_checkpointing = False + self.nar.gradient_checkpointing = False + + @classmethod + def load_from_state_dict(cls, sd: dict, dtype=torch.bfloat16): + """``sd`` is the Comfy-Org all-in-one checkpoint (raw keys).""" + model = cls() + nar_sd = {k[len("model.diffusion_model.") :]: v for k, v in sd.items() if k.startswith("model.diffusion_model.")} + ar_sd = {k[len("text_encoders.") :]: v for k, v in sd.items() if k.startswith("text_encoders.") and k != "text_encoders.yue2_tokenizer_json"} + for name, module, part in (("NAR", model.nar, nar_sd), ("AR", model.ar, ar_sd)): + missing, unexpected = module.load_state_dict({k: v.to(dtype) for k, v in part.items()}, strict=False) + if missing: + raise ValueError(f"YuE2 {name} missing keys: {missing[:5]} (+{max(0, len(missing) - 5)})") + if unexpected: + print(f" YuE2 {name} unexpected: {len(unexpected)} (first 3: {unexpected[:3]})") + return model.to(dtype) + + +@torch.no_grad() +def merge_nar_lora(model: YuE2Model, ckpt_path: str, scale: float = 1.0): + """Fold the community NAR adapter (unmerged q/k/v + gate/up LoRA pairs, + plus full vae2llm/llm2vae weights) into the merged-projection base.""" + try: + ck = torch.load(ckpt_path, map_location="cpu", weights_only=True) + except Exception: + ck = torch.load(ckpt_path, map_location="cpu", weights_only=False) + tensors = iter(ck["lora"]) + cfg = model.cfg + inner = cfg.num_attention_heads * cfg.head_dim + kv = cfg.num_key_value_heads * cfg.head_dim + qkv_rows = {"q_proj": (0, inner), "k_proj": (inner, inner + kv), "v_proj": (inner + kv, inner + 2 * kv)} + gu_rows = {"gate_proj": (0, cfg.intermediate_size), "up_proj": (cfg.intermediate_size, 2 * cfg.intermediate_size)} + for layer in model.nar.model.layers: + for name in ("q_proj", "k_proj", "v_proj", "o_proj"): + A = next(tensors).float() + B = next(tensors).float() + delta = (B @ A) * scale + if name == "o_proj": + w = layer.self_attn.o_proj.weight + w.add_(delta.to(w.device, w.dtype)) + else: + r0, r1 = qkv_rows[name] + w = layer.self_attn.qkv_proj.weight + w[r0:r1].add_(delta.to(w.device, w.dtype)) + for name in ("gate_proj", "up_proj", "down_proj"): + A = next(tensors).float() + B = next(tensors).float() + delta = (B @ A) * scale + if name == "down_proj": + w = layer.mlp.down_proj.weight + w.add_(delta.to(w.device, w.dtype)) + else: + r0, r1 = gu_rows[name] + w = layer.mlp.gate_up_proj.weight + w[r0:r1].add_(delta.to(w.device, w.dtype)) + io = ck.get("io", {}) + for name in ("vae2llm", "llm2vae"): + if name in io: + mod = getattr(model.nar, name) + for pname, value in io[name].items(): + getattr(mod, pname).copy_(value.to(mod.weight.device, mod.weight.dtype)) + return model diff --git a/extensions_built_in/audio_models/yue2/src/pipeline.py b/extensions_built_in/audio_models/yue2/src/pipeline.py new file mode 100644 index 0000000..af516d0 --- /dev/null +++ b/extensions_built_in/audio_models/yue2/src/pipeline.py @@ -0,0 +1,253 @@ +"""Sampling for YuE2: AR codec-token generation, then NAR flow matching. + +Follows the released protocol: semantic sampling with temperature 1.0, +top-p 0.95, top-k 100, repetition penalty 1.2 over the last 50 tokens, at +least 200 tokens before MUSIC_END; acoustic synthesis with a midpoint ODE +solver from t=1 (noise) to t=0, one AR prefill per context chunk. +""" + +from typing import Callable, List, Optional, Tuple + +import torch + +from .model import ( + CODEC_OFFSET, + CODEC_SIZE, + CONTEXT, + MUSIC_END, + KVCache, + YuE2Model, + rope_cos_sin, +) +from .vae import HOP, YuE2VAE + + +def chunk_ranges(frames: int, prefix_tokens: int, context: int = CONTEXT) -> List[Tuple[int, int]]: + size = (context - prefix_tokens - 3) // 2 + if frames < 1 or size < 1: + raise ValueError("YuE2 needs codec tokens and enough context for at least one acoustic frame.") + return [(start, min(start + size, frames)) for start in range(0, frames, size)] + + +def sample_logits( + logits: torch.Tensor, + history: List[int], + step: int, + temperature: float, + top_p: float, + top_k: int, + repetition_penalty: float, + penalty_window: int, + min_tokens: int, + generator: torch.Generator, +) -> int: + scores = logits.float().clone() + allowed = torch.full_like(scores, -torch.inf) + allowed[CODEC_OFFSET : CODEC_OFFSET + CODEC_SIZE] = 0 + allowed[MUSIC_END] = 0 + scores += allowed + if step < min_tokens: + scores[MUSIC_END] = -torch.inf + if repetition_penalty != 1.0 and history: + recent = torch.tensor(history[-penalty_window:], dtype=torch.long, device=scores.device) + counts = torch.zeros_like(scores).scatter_add_(0, recent, torch.ones_like(recent, dtype=scores.dtype)) + penalty = repetition_penalty ** counts + scores = torch.where(scores < 0, scores * penalty, scores / penalty) + if temperature == 0: + return int(scores.argmax().item()) + scores /= temperature + threshold = scores.topk(min(top_k, scores.shape[-1])).values[-1] + scores.masked_fill_(scores < threshold, -torch.inf) + if top_p < 1: + values, indices = scores.sort(descending=True) + probs = values.softmax(-1) + removed = probs.cumsum(-1) - probs > top_p + # released "off" mode keeps the top 3 candidates regardless of mass + removed[:3] = False + values.masked_fill_(removed, -torch.inf) + scores = values.scatter(-1, indices, values) + probs = scores.softmax(-1) + return int(torch.multinomial(probs.to(generator.device), 1, generator=generator).item()) + + +class ARDecodeGraph: + """One-token AR decode step captured as a CUDA graph over static buffers: + token id in, logits out, preallocated [1, kv_heads, capacity, head_dim] + cache per layer. Attention is hand-grouped (GQA) with a length mask so the + whole step is capturable; the eager KVCache path stays as the fallback.""" + + def __init__(self, ar, capacity: int, device, dtype): + cfg = ar.cfg + self.ar = ar + self.capacity = capacity + self.kv_heads = cfg.num_key_value_heads + self.groups = cfg.num_attention_heads // cfg.num_key_value_heads + self.head_dim = cfg.head_dim + shape = (1, self.kv_heads, capacity, self.head_dim) + self.k = [torch.zeros(shape, device=device, dtype=dtype) for _ in range(cfg.num_hidden_layers)] + self.v = [torch.zeros(shape, device=device, dtype=dtype) for _ in range(cfg.num_hidden_layers)] + self.token = torch.zeros((1, 1), device=device, dtype=torch.long) + self.pos = torch.zeros((1, 1), device=device, dtype=torch.long) + self.index = torch.arange(capacity, device=device) + self.logits = torch.empty((1, cfg.vocab_size), device=device, dtype=dtype) + self.graph = None + + def load_prefill(self, cache, length: int): + for i, (k, v) in enumerate(cache): + self.k[i][:, :, :length] = k + self.v[i][:, :, :length] = v + self.pos.fill_(length) + + def _step(self): + ar = self.ar + x = ar.model.embed_tokens(self.token) + cos, sin = rope_cos_sin(self.pos, self.head_dim, ar.cfg.rope_theta) + mask = (self.index <= self.pos)[None, None] # [1, 1, 1, capacity] + scale = self.head_dim ** -0.5 + slot = self.pos.view(-1) + for i, layer in enumerate(ar.model.layers): + q, k, v = layer.self_attn.project(layer.input_layernorm(x), cos, sin) + self.k[i].index_copy_(2, slot, k) + self.v[i].index_copy_(2, slot, v) + qg = q.view(1, self.kv_heads, self.groups, self.head_dim) + scores = torch.matmul(qg, self.k[i].transpose(-1, -2)) * scale # [1, kv, groups, capacity] + scores = scores.float().masked_fill(~mask, float("-inf")).softmax(-1).to(q.dtype) + out = torch.matmul(scores, self.v[i]).reshape(1, 1, -1) + x = x + layer.self_attn.o_proj(out) + x = x + layer.mlp(layer.post_attention_layernorm(x)) + self.logits.copy_(ar.model.lm_head(ar.model.norm(x[:, -1]))) + + def capture(self): + stream = torch.cuda.Stream() + stream.wait_stream(torch.cuda.current_stream()) + with torch.cuda.stream(stream): + for _ in range(2): + self._step() + torch.cuda.current_stream().wait_stream(stream) + self.graph = torch.cuda.CUDAGraph() + with torch.cuda.graph(self.graph): + self._step() + + def step(self, token: int): + self.token.fill_(token) + if self.graph is not None: + self.graph.replay() + else: + self._step() + self.pos.add_(1) + return self.logits[0] + + +class YuE2Pipeline: + def __init__(self, model: YuE2Model, vae: YuE2VAE): + self.model = model + self.vae = vae + self.do_tiled_decoding = True + self.use_cuda_graphs = True + + @torch.no_grad() + def generate_codec_tokens( + self, + prefix_embeds: torch.Tensor, # [1, L, H] + max_tokens: int, + seed: int, + temperature: float = 1.0, + top_p: float = 0.95, + top_k: int = 100, + repetition_penalty: float = 1.2, + penalty_window: int = 50, + min_tokens: int = 200, + progress: Optional[Callable[[int, int], None]] = None, + ) -> List[int]: + ar = self.model.ar + device = prefix_embeds.device + prefix_len = prefix_embeds.shape[1] + if prefix_len + max_tokens > CONTEXT: + raise ValueError("YuE2 prompt plus token budget exceeds the model context") + # reference sampler: seeded generator on the compute device, multinomial over the filtered softmax + generator = torch.Generator(device=device).manual_seed(seed) + capacity = prefix_len + max_tokens + graph = None + if self.use_cuda_graphs and device.type == "cuda": + try: + graph = ARDecodeGraph(ar, capacity, device, ar.dtype) + prefill_cache, hidden = ar.prefill(prefix_embeds.to(ar.dtype), return_hidden=True) + logits = ar.model.lm_head(hidden[:, -1])[0] + graph.load_prefill(prefill_cache, prefix_len) + del prefill_cache, hidden + graph.capture() + except Exception as e: # capture unsupported for these ops/kernels: eager path + print(f"YuE2: CUDA graph decode unavailable ({type(e).__name__}: {e}); using eager decode") + graph = None + torch.cuda.empty_cache() + if graph is None: + cache = KVCache(self.model.cfg, 1, capacity, device, ar.dtype) + logits = ar.prefill_into_cache(prefix_embeds.to(ar.dtype), cache)[0] + history: List[int] = [] + min_tokens = min(min_tokens, max_tokens) + for step in range(max_tokens): + token = sample_logits( + logits, history, step, temperature, top_p, top_k, repetition_penalty, penalty_window, min_tokens, generator + ) + if token == MUSIC_END: + break + history.append(token) + if progress is not None: + progress(step + 1, max_tokens) + if step + 1 < max_tokens: + if graph is not None: + logits = graph.step(token) + else: + emb = ar.embed(torch.tensor([[token]], device=device)) + logits = ar.decode_step(emb, cache)[0] + if graph is None: + del cache + del graph + return [t - CODEC_OFFSET for t in history] + + @torch.no_grad() + def synthesize( + self, + prefix_embeds: torch.Tensor, # [1, L, H] + codec: List[int], + seed: int, + steps: int = 32, + step_callback: Optional[Callable[[int, int, torch.Tensor], None]] = None, + ) -> torch.Tensor: + """-> latents [1, T, 64] in the NAR's dtype.""" + ar, nar = self.model.ar, self.model.nar + device = prefix_embeds.device + dtype = nar.dtype + generator = torch.Generator(device="cpu").manual_seed(seed) + noise = torch.randn((len(codec), 64), generator=generator).to(device, dtype) + ranges = chunk_ranges(len(codec), prefix_embeds.shape[1]) + outputs = [] + total_steps = steps * len(ranges) + for ci, (start, end) in enumerate(ranges): + ids = torch.tensor([[c + CODEC_OFFSET for c in codec[start:end]] + [MUSIC_END]], device=device) + embeds = torch.cat([prefix_embeds.to(ar.dtype), ar.embed(ids)], dim=1) + cache, _ = ar.prefill(embeds) + prefix_len = embeds.shape[1] + state = noise[start:end][None] + dt = 1.0 / steps + for step in range(steps): + t = 1.0 - step * dt + v1 = nar(state, torch.full((1,), t, device=device, dtype=dtype), cache, prefix_len) + mid = state - v1 * (dt / 2) + v2 = nar(mid, torch.full((1,), t - dt / 2, device=device, dtype=dtype), cache, prefix_len) + state = state - v2 * dt + if step_callback is not None: + step_callback(ci * steps + step, total_steps, state) + outputs.append(state) + del cache + return torch.cat(outputs, dim=1) + + @torch.no_grad() + def decode(self, latents: torch.Tensor) -> torch.Tensor: + """[1, T, 64] -> waveform [1, 2, samples] float32.""" + z = latents.transpose(1, 2).to(self.vae.device, self.vae.dtype) + if self.do_tiled_decoding: + audio = self.vae.tiled_decode(z) + else: + audio = self.vae.decode(z) + return audio.float().clamp(-1, 1) diff --git a/extensions_built_in/audio_models/yue2/src/tokenizer.py b/extensions_built_in/audio_models/yue2/src/tokenizer.py new file mode 100644 index 0000000..8d7209a --- /dev/null +++ b/extensions_built_in/audio_models/yue2/src/tokenizer.py @@ -0,0 +1,154 @@ +"""Text side and audio side of YuE2 conditioning. + +- ``YuE2TextTokenizer``: the Qwen BPE that ComfyUI embeds in the checkpoint + (``text_encoders.yue2_tokenizer_json``), plus the prompt/prefix protocol. +- ``SemanticTokenizer``: the community audio -> codec-token head + (Mothersuperior v4): MERT-v2-FullSong layer-20 features at 25 Hz, + per-track instance normalised, through an 8-layer transformer classifier + over 512-frame windows. +""" + +import unicodedata +from typing import List, Optional + +import torch +import torch.nn.functional as F +from torch import nn + +from .model import ABC_END, ABC_START, CODEC_SIZE, EOD, INSTRUCTIONS, MUSIC_START + +MERT_REPO = "m-a-p/MERT-v2-FullSong" +MERT_SAMPLE_RATE = 24000 +MERT_LAYER = 20 +HEAD_REPO = "Mothersuperior/yue2-mothersuperior-realaudio-tokenizer-v4" +HEAD_FILE = "tokenizer_head_joint_v4.pt" +NAR_LORA_FILE = "nar_lora_joint_v4.pt" +HEAD_WINDOW = 512 + + +class YuE2TextTokenizer: + def __init__(self, tokenizer_json: bytes): + from tokenizers import Tokenizer + + self.tokenizer = Tokenizer.from_str(tokenizer_json.decode("utf-8")) + + def encode(self, text: str) -> List[int]: + return self.tokenizer.encode(unicodedata.normalize("NFC", text)).ids + + def decode(self, ids: List[int]) -> str: + return self.tokenizer.decode(ids, skip_special_tokens=True) + + def prefix_ids(self, style: str, lyrics: str, cot: str = "off", abc: str = "") -> List[int]: + """Everything the AR expert sees before the first codec token.""" + prompt = f"{INSTRUCTIONS[cot]}\n[Tags]\n{style}\n[Lyrics]\n{lyrics}\n" + abc_ids = [] if cot == "off" else self.encode(abc) + return [EOD] + self.encode(prompt) + [ABC_START] + abc_ids + [ABC_END, MUSIC_START] + + +class TokenizerHead(nn.Module): + def __init__(self, din=1024, d=512, layers=8, heads=8, vocab=CODEC_SIZE, window=HEAD_WINDOW): + super().__init__() + self.inp = nn.Linear(din, d) + self.pos = nn.Parameter(torch.zeros(1, window, d)) + layer = nn.TransformerEncoderLayer(d, heads, 4 * d, dropout=0.1, batch_first=True, norm_first=True, activation="gelu") + self.enc = nn.TransformerEncoder(layer, layers) + self.norm = nn.LayerNorm(d) + self.head = nn.Linear(d, vocab) + + def forward(self, x): + return self.head(self.norm(self.enc(self.inp(x) + self.pos[:, : x.shape[1]]))) + + +def _rebuild_rotary(model: nn.Module) -> int: + """MERT2's RotaryEmbedding keeps ``inv_freq`` as a non-persistent buffer; transformers' + meta-device loading materializes it uninitialized (zeros on a fresh GPU, garbage or NaN + otherwise). Recompute it from the module's own formula and drop the cos/sin cache.""" + n = 0 + for m in model.modules(): + if hasattr(m, "inv_freq") and hasattr(m, "head_dim") and hasattr(m, "base"): + inv = 1.0 / (m.base ** (torch.arange(0, m.head_dim, 2, dtype=torch.float32) / m.head_dim)) + m.inv_freq = inv.to(device=m.inv_freq.device) + for attr, val in (("_cos", None), ("_sin", None), ("_sequence_length", 0), ("_cache_device", None)): + if hasattr(m, attr): + setattr(m, attr, val) + n += 1 + return n + + +class SemanticTokenizer(nn.Module): + """Waveform -> per-frame codec ids (0..32767) at 25 Hz.""" + + def __init__(self, head_path: str, mert_repo: str = MERT_REPO): + super().__init__() + from transformers import AutoFeatureExtractor, AutoModel + + self.processor = AutoFeatureExtractor.from_pretrained(mert_repo, trust_remote_code=True) + self.mert = AutoModel.from_pretrained(mert_repo, trust_remote_code=True).eval() + self.mert.requires_grad_(False) + _rebuild_rotary(self.mert) + self.head = TokenizerHead() + ck = torch.load(head_path, map_location="cpu", weights_only=False) + self.head.load_state_dict(ck["model"]) + self.head.eval().requires_grad_(False) + + @property + def device(self): + return self.head.head.weight.device + + @torch.no_grad() + def mert_features(self, mono24: torch.Tensor) -> torch.Tensor: + """mono24 [samples] at 24 kHz -> [T25, 1024] fp32 layer-20 features.""" + device = self.device + chunk = MERT_SAMPLE_RATE * 30 + chunks = [mono24[s : s + chunk] for s in range(0, mono24.shape[0], chunk)] + chunks = [c for c in chunks if c.shape[0] >= MERT_SAMPLE_RATE] + full = [c for c in chunks if c.shape[0] == chunk] + tail = [c for c in chunks if c.shape[0] < chunk] + feats = [] + with torch.autocast(device_type=device.type, dtype=torch.bfloat16, enabled=device.type == "cuda"): + for group in ([full] if full else []) + [[c] for c in tail]: + inp = self.processor([c.cpu().numpy() for c in group], sampling_rate=MERT_SAMPLE_RATE, return_tensors="pt") + inp = {k: v.to(device) for k, v in inp.items()} + out = self.mert(**inp, output_hidden_states=True) + feats.append(out.hidden_states[MERT_LAYER].reshape(-1, 1024)) + h = torch.cat(feats, 0).float() + t25 = int(round(mono24.shape[0] / MERT_SAMPLE_RATE * 25)) + return F.interpolate(h.T[None], size=t25, mode="linear", align_corners=False)[0].T + + @torch.no_grad() + def tokens_from_features(self, feats: torch.Tensor) -> torch.Tensor: + """[T, 1024] -> [T] long. Windowed inference with edge trimming.""" + x = feats.float() + x = (x - x.mean(0)) / (x.std(0) + 1e-5) + total = x.shape[0] + win = HEAD_WINDOW + out = torch.zeros(total, dtype=torch.long, device=x.device) + starts = list(range(0, max(1, total - win + 1), win // 2)) + if starts[-1] + win < total: + starts.append(max(0, total - win)) + device = self.device + for s0 in starts: + xw = x[s0 : s0 + win] + n = xw.shape[0] + if n < win: + xw = F.pad(xw, (0, 0, 0, win - n)) + with torch.autocast(device_type=device.type, dtype=torch.bfloat16, enabled=device.type == "cuda"): + pred = self.head(xw[None].to(device))[0, :n].float().argmax(-1) + lo = s0 + (0 if s0 == 0 else win // 4) + hi = s0 + n - (0 if s0 + n >= total else win // 4) + out[lo:hi] = pred[lo - s0 : hi - s0].to(out.device) + return out + + @torch.no_grad() + def tokenize(self, waveform: torch.Tensor, sample_rate: int) -> torch.Tensor: + """waveform [C, samples] -> codec ids [T] (long, no vocab offset).""" + import torchaudio + + mono = waveform.float().mean(0) if waveform.dim() == 2 else waveform.float() + if sample_rate != MERT_SAMPLE_RATE: + mono = torchaudio.functional.resample(mono, sample_rate, MERT_SAMPLE_RATE) + feats = self.mert_features(mono.to(self.device)) + out = self.tokens_from_features(feats) + if out.unique().numel() < 8: + print(f"YuE2 tokenizer: degenerate token stream (unique {out.unique().numel()}); MERT features nan {torch.isnan(feats).sum().item()}") + return out diff --git a/extensions_built_in/audio_models/yue2/src/vae.py b/extensions_built_in/audio_models/yue2/src/vae.py new file mode 100644 index 0000000..9e34ea6 --- /dev/null +++ b/extensions_built_in/audio_models/yue2/src/vae.py @@ -0,0 +1,178 @@ +"""YuE2 Oobleck-style waveform VAE (48 kHz stereo, 64-ch latents at 25 Hz). + +Matches the ``vae.*`` tensors of the Comfy-Org repack: channels 64, channel +multipliers (1, 2, 4, 8, 16, 32), strides (2, 2, 4, 4, 5, 6) -> hop 1920. +Encoding is deterministic (mean half of the bottleneck). Checkpoints carry +old-style ``weight_g`` / ``weight_v`` weight-norm keys; they are remapped to +torch parametrizations on load. +""" + +import math + +import torch +from torch import nn + +from toolkit.models.v2._mixin import OstrisModelMixin + +SAMPLE_RATE = 48000 +HOP = 1920 +LATENT_RATE = SAMPLE_RATE // HOP # 25 + + +def WNConv1d(*args, **kwargs): + return torch.nn.utils.parametrizations.weight_norm(nn.Conv1d(*args, **kwargs)) + + +def WNConvT1d(*args, **kwargs): + return torch.nn.utils.parametrizations.weight_norm(nn.ConvTranspose1d(*args, **kwargs)) + + +class SnakeBeta(nn.Module): + def __init__(self, channels): + super().__init__() + self.alpha = nn.Parameter(torch.zeros(channels)) + self.beta = nn.Parameter(torch.zeros(channels)) + + def forward(self, x): + a = self.alpha.unsqueeze(0).unsqueeze(-1).exp() + b = self.beta.unsqueeze(0).unsqueeze(-1).exp() + return x + (1.0 / (b + 1e-9)) * torch.sin(x * a).pow(2) + + +class ResUnit(nn.Module): + def __init__(self, ch, dilation): + super().__init__() + self.layers = nn.Sequential( + SnakeBeta(ch), + WNConv1d(ch, ch, 7, dilation=dilation, padding=(dilation * 6) // 2), + SnakeBeta(ch), + WNConv1d(ch, ch, 1), + ) + + def forward(self, x): + return x + self.layers(x) + + +class EncBlock(nn.Module): + def __init__(self, in_ch, out_ch, stride): + super().__init__() + self.layers = nn.Sequential( + ResUnit(in_ch, 1), + ResUnit(in_ch, 3), + ResUnit(in_ch, 9), + SnakeBeta(in_ch), + WNConv1d(in_ch, out_ch, 2 * stride, stride=stride, padding=math.ceil(stride / 2)), + ) + + def forward(self, x): + return self.layers(x) + + +class DecBlock(nn.Module): + def __init__(self, in_ch, out_ch, stride): + super().__init__() + self.layers = nn.Sequential( + SnakeBeta(in_ch), + # odd strides need output_padding to land on exactly stride * T + WNConvT1d(in_ch, out_ch, 2 * stride, stride=stride, padding=math.ceil(stride / 2), output_padding=stride % 2), + ResUnit(out_ch, 1), + ResUnit(out_ch, 3), + ResUnit(out_ch, 9), + ) + + def forward(self, x): + return self.layers(x) + + +class _SeqWrap(nn.Module): + def __init__(self, *modules): + super().__init__() + self.layers = nn.Sequential(*modules) + + def forward(self, x): + return self.layers(x) + + +def convert_weight_norm_keys(sd: dict) -> dict: + out = {} + for k, v in sd.items(): + if k.endswith(".weight_g"): + k = k[: -len(".weight_g")] + ".parametrizations.weight.original0" + elif k.endswith(".weight_v"): + k = k[: -len(".weight_v")] + ".parametrizations.weight.original1" + out[k] = v + return out + + +class YuE2VAE(nn.Module, OstrisModelMixin): + @classmethod + def load_from_state_dict(cls, state_dict, dtype=torch.float32): + vae = cls() + sd = convert_weight_norm_keys(state_dict) + missing, unexpected = vae.load_state_dict({k: v.to(dtype) for k, v in sd.items()}, strict=False) + if missing: + raise ValueError(f"YuE2 VAE missing keys: {missing[:5]} (+{max(0, len(missing) - 5)})") + if unexpected: + print(f" YuE2 VAE unexpected: {len(unexpected)} (first 3: {unexpected[:3]})") + return vae.to(dtype) + + def __init__( + self, + in_ch=2, + channels=64, + latent_dim=64, + c_mults=(1, 2, 4, 8, 16, 32), + strides=(2, 2, 4, 4, 5, 6), + ): + super().__init__() + cm = [1] + list(c_mults) + enc = [WNConv1d(in_ch, cm[0] * channels, 7, padding=3)] + for i in range(len(cm) - 1): + enc.append(EncBlock(cm[i] * channels, cm[i + 1] * channels, strides[i])) + enc += [SnakeBeta(cm[-1] * channels), WNConv1d(cm[-1] * channels, latent_dim * 2, 3, padding=1)] + self.encoder = _SeqWrap(*enc) + dec = [WNConv1d(latent_dim, cm[-1] * channels, 7, padding=3)] + for i in range(len(cm) - 1, 0, -1): + dec.append(DecBlock(cm[i] * channels, cm[i - 1] * channels, strides[i - 1])) + dec += [SnakeBeta(cm[0] * channels), WNConv1d(cm[0] * channels, in_ch, 7, padding=3, bias=False)] + self.decoder = _SeqWrap(*dec) + self.latent_dim = latent_dim + self.upscale_factor = math.prod(strides) + + @property + def device(self): + return next(self.parameters()).device + + @property + def dtype(self): + return next(self.parameters()).dtype + + def encode(self, x: torch.Tensor, chunk_seconds: float = 60.0) -> torch.Tensor: + """[B, 2, samples] -> [B, 64, T]. Encodes in whole-second chunks that + are multiples of the hop, matching the community tokenizer's prep.""" + chunk = int(chunk_seconds * SAMPLE_RATE) + chunk -= chunk % HOP + outs = [] + for s in range(0, x.shape[-1], chunk): + seg = x[..., s : s + chunk] + if seg.shape[-1] < HOP: + break + outs.append(self.encoder(seg).chunk(2, dim=1)[0]) + return torch.cat(outs, dim=-1) + + def decode(self, z: torch.Tensor) -> torch.Tensor: + return self.decoder(z) + + def tiled_decode(self, z: torch.Tensor, core_frames: int = 750, halo_frames: int = 16) -> torch.Tensor: + """Halo-crop tiled decode: decode core + halo, keep the core.""" + total = z.shape[-1] + if total <= core_frames + 2 * halo_frames: + return self.decoder(z) + pieces = [] + for start in range(0, total, core_frames): + end = min(start + core_frames, total) + lo = max(0, start - halo_frames) + hi = min(total, end + halo_frames) + audio = self.decoder(z[..., lo:hi]) + pieces.append(audio[..., (start - lo) * HOP : (start - lo + end - start) * HOP]) + return torch.cat(pieces, dim=-1) diff --git a/extensions_built_in/audio_models/yue2/yue2_model.py b/extensions_built_in/audio_models/yue2/yue2_model.py new file mode 100644 index 0000000..470e448 --- /dev/null +++ b/extensions_built_in/audio_models/yue2/yue2_model.py @@ -0,0 +1,470 @@ +"""YuE2 (m-a-p/YuE2-3B) for ai-toolkit. + +Weights: the Comfy-Org all-in-one checkpoint (``checkpoints/yue2_3b_bf16.safetensors``) +resolved under MODELS_PATH in ComfyUI's folder layout and downloaded there when missing. +The official audio -> semantic-token tokenizer is unreleased; training conditioning comes +from the community head (MERT-v2-FullSong + classifier) in +``Mothersuperior/yue2-mothersuperior-realaudio-tokenizer-v4``. Its companion NAR adapter is +merged into the base NAR on load (``model_kwargs.merge_nar_lora``, default true) since the +head's tokens are a dialect the stock renderer was not trained on. + +Training: per song the latent cache carries VAE latents plus the head's codec tokens; the +"text embedding" is the AR prompt prefix (instruction + tags + lyrics) as token embeddings. +Each step crops one window (``model_kwargs.train_window_frames``, 25 fps, 0 = whole song), +prefills the AR expert over ``prefix + tokens[window] + MUSIC_END`` and trains the NAR +flow loss on the real latents. With ``model_kwargs.ar_loss_weight`` > 0 the same prefill +also trains the AR expert with next-token cross entropy over the codec tokens, so one LoRA +network on both experts learns composition and rendering together. + +Captions use the ace-step tag format: