Initial experimental support for yue2

This commit is contained in:
Jaret Burkett committed 2026-09-14 05:07:11 -06:00
1 parent 82137c8ca0
commit e43f138ae4
17 files changed
+1586 -8

No files matched your search

@@ -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,
]
@@ -0,0 +1 @@
from .yue2_model import YuE2AudioModel
Whitespace-only changes.
@@ -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
@@ -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)
@@ -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
@@ -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)
@@ -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: <CAPTION> (style), <LYRICS>, <DURATION> (max seconds
for samples).
"""
import os
import random
import re
from typing import List, Optional
import torch
from safetensors.torch import load_file
from tqdm import tqdm
from extensions_built_in.audio_models.base_audio_model import BaseAudioModel
from toolkit.basic import flush
from toolkit.config_modules import GenerateImageConfig
from toolkit.dto import DTO
from toolkit.models.v2.resolver import resolve_named_file
from toolkit.print import print_acc
from toolkit.prompt_utils import PromptEmbeds
from toolkit.samplers.custom_flowmatch_sampler import CustomFlowMatchEulerDiscreteScheduler
from .src.model import CODEC_OFFSET, FRAMES_PER_SECOND, MUSIC_END, YuE2Model, merge_nar_lora
from .src.pipeline import YuE2Pipeline
from .src.tokenizer import HEAD_FILE, HEAD_REPO, NAR_LORA_FILE, SemanticTokenizer, YuE2TextTokenizer
from .src.vae import SAMPLE_RATE, YuE2VAE
DEFAULT_CHECKPOINT = "Comfy-Org/YuE2/checkpoints/yue2_3b_bf16.safetensors"
scheduler_config = {
"num_train_timesteps": 1000,
"shift": 1.0,
"use_dynamic_shifting": False,
}
def _tag(text: str, name: str) -> str:
m = re.search(rf"<{name}>(.*?)</{name}>", text, re.DOTALL)
return m.group(1).strip() if m else ""
_SECTION = re.compile(r"^\s*\[(Tags|Lyrics|Duration)\]\s*$", re.IGNORECASE | re.MULTILINE)
_SONG_SECTION = re.compile(r"^\s*\[[^\]]+\]\s*$")
def _number(value: str):
try:
return float(value) if value.strip() else None
except ValueError:
return None
def parse_caption(text: str) -> dict:
"""Caption / prompt -> style, lyrics, duration.
Native form (YuE2's own prompt layout): style text, then a ``[Lyrics]`` line and the
lyrics; an optional leading ``[Tags]`` line and an optional trailing ``[Duration]``
line (max seconds, samples only). Plain text with no sections is the style.
Legacy ``<CAPTION>``/``<LYRICS>``/``<DURATION>`` tags are still accepted."""
if not isinstance(text, str):
text = ""
if "<CAPTION>" in text or "<LYRICS>" in text:
return {"style": _tag(text, "CAPTION"), "lyrics": _tag(text, "LYRICS"), "duration": _number(_tag(text, "DURATION"))}
sections = {"tags": []}
current = "tags"
for line in text.splitlines():
m = _SECTION.match(line)
if m:
current = m.group(1).lower()
sections.setdefault(current, [])
continue
# no [Lyrics] header: the first song-section line ([Intro], [Verse 1], ...) starts the lyrics
if current == "tags" and "lyrics" not in sections and _SONG_SECTION.match(line):
current = "lyrics"
sections["lyrics"] = []
sections.setdefault(current, []).append(line)
style = "\n".join(sections["tags"]).strip()
lyrics = "\n".join(sections.get("lyrics", [])).strip()
duration = _number("\n".join(sections.get("duration", [])))
return {"style": style, "lyrics": lyrics, "duration": duration}
class YuE2TextEncoder(torch.nn.Module):
"""Prompt prefix -> AR token embeddings. Holds no weights of its own; the
embedding table lives in the AR expert (looked up on whatever device it is on)."""
def __init__(self, model: YuE2Model, tokenizer: YuE2TextTokenizer, cot: str = "off"):
super().__init__()
self._model = [model]
self.tokenizer = tokenizer
self.cot = cot
self.register_buffer("_anchor", torch.zeros(1), persistent=False)
@property
def device(self):
return self._anchor.device
@property
def dtype(self):
return self._model[0].ar.model.embed_tokens.weight.dtype
def prefix_ids(self, style: str, lyrics: str) -> List[int]:
return self.tokenizer.prefix_ids(style, lyrics, cot=self.cot)
@torch.no_grad()
def forward(self, style: str, lyrics: str) -> torch.Tensor:
ids = torch.tensor([self.prefix_ids(style, lyrics)], dtype=torch.long)
return self._model[0].ar.embed(ids)
class YuE2AudioModel(BaseAudioModel):
arch = "yue2"
sample_rate = SAMPLE_RATE
def __init__(self, device, model_config, dtype="bf16", custom_pipeline=None, noise_scheduler=None, **kwargs):
super().__init__(device, model_config, dtype, custom_pipeline, noise_scheduler, **kwargs)
self.is_flow_matching = True
self.is_transformer = True
self.target_lora_modules = ["YuE2AR", "YuE2NAR"]
kw = self.model_config.model_kwargs
self.train_window_frames = int(kw.get("train_window_frames", 1500))
self.ar_loss_weight = float(kw.get("ar_loss_weight", 1.0))
# AR next-token loss always runs on the song from its start (lyric order); 0 = whole song
self.ar_max_tokens = int(kw.get("ar_max_tokens", 0))
# optional separate learning rate for the AR expert's LoRA (Adam ignores loss scale)
self.ar_lr_multiplier = float(kw.get("ar_lr_multiplier", 1.0))
self._loss_log_every = int(kw.get("loss_log_every", 25))
self._loss_log_step = 0
self.merge_nar_lora = bool(kw.get("merge_nar_lora", True))
self.nar_lora_path = kw.get("nar_lora_path", f"{HEAD_REPO}/{NAR_LORA_FILE}")
self.semantic_head_path = kw.get("semantic_head_path", f"{HEAD_REPO}/{HEAD_FILE}")
self.sample_max_seconds = float(kw.get("sample_max_seconds", 120.0))
self.sample_ar_temperature = float(kw.get("sample_ar_temperature", 1.0))
self.semantic_tokenizer: Optional[SemanticTokenizer] = None
self._pending_aux_loss = None
@staticmethod
def get_train_scheduler():
return CustomFlowMatchEulerDiscreteScheduler(**scheduler_config)
# ------------------------------------------------------------------
# loading
# ------------------------------------------------------------------
def load_model(self):
dtype = self.torch_dtype
device = self.device_torch
name_or_path = self.model_config.name_or_path or DEFAULT_CHECKPOINT
ckpt_path = resolve_named_file(name_or_path, component="yue2 checkpoint")
self.print_and_status_update(f"Loading YuE2 from {ckpt_path}")
sd = load_file(ckpt_path)
tok_json = sd.pop("text_encoders.yue2_tokenizer_json", None)
if tok_json is None:
raise ValueError("YuE2 checkpoint has no embedded tokenizer (expected the Comfy-Org repack)")
self.tokenizer = YuE2TextTokenizer(tok_json.numpy().tobytes())
if self.model_config.layer_offloading and self.model_config.layer_offloading_transformer_percent > 0:
raise NotImplementedError("Layer offloading not yet implemented for YuE2")
self.model = YuE2Model.load_from_state_dict(sd, dtype=dtype)
if self.merge_nar_lora:
lora_path = resolve_named_file(self.nar_lora_path, component="yue2 nar lora")
self.print_and_status_update(f"Merging NAR adapter {os.path.basename(lora_path)}")
merge_nar_lora(self.model, lora_path)
vae_sd = {k[len("vae.") :]: v for k, v in sd.items() if k.startswith("vae.")}
# reference runs the VAE in fp32; keep the trainer's dtype check from downcasting it
self.vae_torch_dtype = torch.float32
self.vae = YuE2VAE.load_from_state_dict(vae_sd, dtype=torch.float32)
del sd
flush()
self.model.aitk_post_load(**self.component_load_kwargs("transformer"))
flush()
self.model.to(device)
self.vae.to(self.vae_device_torch)
self.text_encoder = YuE2TextEncoder(self.model, self.tokenizer)
self.pipeline = YuE2Pipeline(self.model, self.vae)
self.pipeline.do_tiled_decoding = True
self.pipeline.use_cuda_graphs = bool(self.model_config.model_kwargs.get("ar_cuda_graphs", True))
def _get_semantic_tokenizer(self) -> SemanticTokenizer:
if self.semantic_tokenizer is None:
head_path = resolve_named_file(self.semantic_head_path, component="yue2 semantic head")
# log only: this can run mid-training and a status update would replace "Training" in the UI
print_acc("Loading YuE2 semantic tokenizer (MERT-v2-FullSong + head)")
self.semantic_tokenizer = SemanticTokenizer(head_path)
return self.semantic_tokenizer
def get_transformer_block_names(self) -> Optional[List[str]]:
return ["model.layers"]
def get_model_has_grad(self):
return False
def get_te_has_grad(self):
return False
# ------------------------------------------------------------------
# conditioning
# ------------------------------------------------------------------
def get_prompt_embeds(self, prompt) -> PromptEmbeds:
prompts = [prompt] if isinstance(prompt, str) else list(prompt)
embeds, masks = [], []
for p in prompts:
parsed = parse_caption(p)
e = self.text_encoder(parsed["style"], parsed["lyrics"]) # [1, L, H]
embeds.append(e)
masks.append(torch.ones(1, e.shape[1], dtype=torch.long, device=e.device))
max_len = max(e.shape[1] for e in embeds)
embeds = [torch.nn.functional.pad(e, (0, 0, 0, max_len - e.shape[1])) for e in embeds]
masks = [torch.nn.functional.pad(m, (0, max_len - m.shape[1])) for m in masks]
return PromptEmbeds(torch.cat(embeds, 0), attention_mask=torch.cat(masks, 0))
def encode_audio(self, audio_tensor: torch.Tensor, device=None, dtype=None):
"""[B, 2, samples] at 48 kHz -> DTO [B, T, 64] with codec ``tokens`` [B, T]."""
if device is None:
device = self.vae_device_torch
if self.vae.device == torch.device("cpu"):
self.vae.to(device)
latents = self.vae.encode(audio_tensor.to(device=device, dtype=self.vae.dtype)) # [B, 64, T]
latents = latents.transpose(1, 2).contiguous()
tokenizer = self._get_semantic_tokenizer()
if tokenizer.device != self.device_torch:
tokenizer.to(self.device_torch)
tokens = torch.stack([tokenizer.tokenize(wav, SAMPLE_RATE) for wav in audio_tensor]) # [B, T']
n = min(latents.shape[1], tokens.shape[1])
latents = latents[:, :n].to(dtype=self.torch_dtype if dtype is None else dtype)
return DTO(latents, tokens=tokens[:, :n].to(latents.device, torch.int32))
# ------------------------------------------------------------------
# training
# ------------------------------------------------------------------
def condition_noisy_latents(self, latents: torch.Tensor, batch):
frames = latents.shape[1]
window = self.train_window_frames
if window <= 0 or frames <= window:
batch.yue2_window = (0, frames)
return latents
start = random.randint(0, frames - window)
batch.yue2_window = (start, start + window)
return latents[:, start : start + window]
def get_loss_target(self, *args, **kwargs):
noise = kwargs.get("noise")
batch = kwargs.get("batch")
start, end = getattr(batch, "yue2_window", (0, noise.shape[1]))
return (noise - batch.latents)[:, start:end].detach()
def scale_loss(self, loss):
aux = self._pending_aux_loss
self._pending_aux_loss = None
if aux is None:
return loss
self._loss_log_step += 1
if self._loss_log_every > 0 and self._loss_log_step % self._loss_log_every == 0:
# the trainer's bar shows the sum; the two terms move on different scales
info = getattr(self, "_pending_aux_info", "")
tqdm.write(f"yue2 step {self._loss_log_step}: flow {loss.detach().float().mean().item():.4f} ar_ce {aux.detach().float().item() / max(self.ar_loss_weight, 1e-8):.4f} {info}")
return loss + aux
@staticmethod
def _train_prefix(prefix_embeds: torch.Tensor, prefix_mask: torch.Tensor) -> torch.Tensor:
"""Unpadded prefix embeds for one item."""
# the mask may arrive cast to bf16; count entries, never sum them
length = int((prefix_mask > 0.5).sum().item())
return prefix_embeds[:length]
def _ar_info(self, logits: torch.Tensor, ids: torch.Tensor, prefix: torch.Tensor, total: int, whole_song: bool) -> str:
"""Readout diagnostics: what the AR loss was computed over."""
if self._loss_log_every <= 0 or (self._loss_log_step + 1) % self._loss_log_every != 0:
return ""
with torch.no_grad():
p = logits.softmax(-1)
p_target = p.gather(1, ids[:, None]).squeeze(1)
return (f"[ar targets {ids.shape[0]} unique {ids.unique().numel()} (song {total}, whole {whole_song}) "
f"prefix {prefix.shape[0]} p(END|prefix) {p[0, MUSIC_END].item():.3f} p(target) median {p_target.median().item():.3f}]")
def _ar_inputs(self, prefix: torch.Tensor, tokens: torch.Tensor, end_token: bool = True):
"""One item: prefix (unpadded [L, H]) + codec tokens [+ MUSIC_END] -> embeds [1, L', H], ids [n]."""
ids = tokens.long() + CODEC_OFFSET
if end_token:
ids = torch.cat([ids, torch.tensor([MUSIC_END], device=tokens.device)])
embeds = torch.cat([prefix.to(self.model.ar.dtype), self.model.ar.embed(ids)], dim=0)[None]
return embeds, ids
# ------------------------------------------------------------------
# per-expert learning rate: the trainer hands us the LoRA network before it
# builds optimizer groups, so split the AR expert's modules into their own group
# ------------------------------------------------------------------
@property
def network(self):
return self._network
@network.setter
def network(self, value):
self._network = value
mult = getattr(self, "ar_lr_multiplier", 1.0)
if value is None or mult == 1.0 or getattr(value, "_yue2_lr_split", False):
return
orig = value.prepare_optimizer_params
def prepare_optimizer_params(text_encoder_lr=None, unet_lr=None, default_lr=None):
groups = orig(text_encoder_lr, unet_lr, default_lr)
ar_ids = set()
for lora in getattr(value, "unet_loras", []):
if ".ar." in lora.lora_name.replace("$$", "."):
ar_ids.update(id(p) for p in lora.parameters())
out = []
n_ar = 0
for group in groups:
base_lr = group.get("lr", default_lr)
ar = [p for p in group["params"] if id(p) in ar_ids]
rest = [p for p in group["params"] if id(p) not in ar_ids]
if rest:
out.append({**group, "params": rest})
if ar:
n_ar += len(ar)
if base_lr is not None:
out.append({**group, "params": ar, "lr": base_lr * mult})
else:
out.append({**group, "params": ar})
if n_ar:
self.print_and_status_update(f"YuE2: AR expert LoRA learning rate x{mult} ({n_ar} tensors)")
return out
value.prepare_optimizer_params = prepare_optimizer_params
value._yue2_lr_split = True
def get_noise_prediction(self, latent_model_input: torch.Tensor, timestep: torch.Tensor, text_embeddings: PromptEmbeds, batch=None, **kwargs):
# `batch` must be a named parameter: predict_noise only forwards it when it sees it in the signature
if batch is None or not isinstance(batch.latents, DTO) or batch.latents.get("tokens") is None:
raise ValueError("YuE2 training needs codec tokens in the latent cache; enable latent caching")
start, end = getattr(batch, "yue2_window", (0, latent_model_input.shape[1]))
tokens_all = batch.latents.tokens
model = self.model
if model.device == torch.device("cpu"):
model.to(self.device_torch)
device = self.device_torch
t = (timestep.to(device, dtype=torch.float32) / 1000.0).to(self.torch_dtype)
prefix_embeds = text_embeddings.text_embeds.to(device, self.torch_dtype)
prefix_mask = text_embeddings.attention_mask.to(device)
train_ar = self.ar_loss_weight > 0 and torch.is_grad_enabled()
preds, lm_losses = [], []
for i in range(latent_model_input.shape[0]):
song = tokens_all[i].to(device)
total = song.shape[0]
prefix = self._train_prefix(prefix_embeds[i], prefix_mask[i])
# NAR conditioning follows the released chunk protocol: prefix + window + MUSIC_END
embeds, ids = self._ar_inputs(prefix, song[start:end])
whole_song = start == 0 and end == total
if train_ar and not whole_song:
# the language-model loss must see the song from its beginning, or the AR
# learns that lyrics and tokens can start anywhere relative to each other
limit = total if self.ar_max_tokens <= 0 else min(total, self.ar_max_tokens)
ar_embeds, ar_ids = self._ar_inputs(prefix, song[:limit], end_token=limit == total)
_, hidden = model.ar.prefill(ar_embeds, return_hidden=True)
logits = model.ar.model.lm_head(hidden[0, -ar_ids.shape[0] - 1 : -1]).float()
lm_losses.append(torch.nn.functional.cross_entropy(logits, ar_ids))
self._pending_aux_info = self._ar_info(logits, ar_ids, prefix, total, whole_song)
with torch.no_grad():
cache, _ = model.ar.prefill(embeds)
else:
cache, hidden = model.ar.prefill(embeds, return_hidden=train_ar)
if train_ar:
logits = model.ar.model.lm_head(hidden[0, -ids.shape[0] - 1 : -1]).float()
lm_losses.append(torch.nn.functional.cross_entropy(logits, ids))
self._pending_aux_info = self._ar_info(logits, ids, prefix, total, whole_song)
# the flow loss must not train the AR through its KV cache: only next-token CE shapes the AR
cache = [(k.detach(), v.detach()) for k, v in cache]
pred = model.nar(latent_model_input[i : i + 1].to(device, self.torch_dtype), t[i : i + 1], cache, embeds.shape[1])
preds.append(pred)
if lm_losses:
self._pending_aux_loss = torch.stack(lm_losses).mean() * self.ar_loss_weight
return torch.cat(preds, 0)
# ------------------------------------------------------------------
# sampling
# ------------------------------------------------------------------
def get_generation_pipeline(self):
return self.pipeline
def generate_single_audio(self, pipeline: YuE2Pipeline, gen_config: GenerateImageConfig, conditional_embeds: PromptEmbeds, unconditional_embeds, generator, extra):
if self.model.device == torch.device("cpu"):
self.model.to(self.device_torch)
if gen_config.output_ext not in ["mp3", "wav", "flac"]:
gen_config.output_ext = "mp3"
parsed = parse_caption(gen_config.prompt)
# sample-config duration field first, then a [Duration] prompt line, then the model default
max_seconds = getattr(gen_config, "duration", None) or parsed["duration"] or self.sample_max_seconds
max_tokens = max(1, int(round(max_seconds * FRAMES_PER_SECOND)))
length = int((conditional_embeds.attention_mask[0] > 0.5).sum().item())
prefix = conditional_embeds.text_embeds[:1, :length].to(self.device_torch, self.torch_dtype)
# nested bars under generate_images' "Generating Samples" bar (position 0)
self._status_update(f"YuE2: generating codec tokens (up to {max_seconds:.0f}s)")
token_bar = tqdm(total=max_tokens, desc=" codec tokens", unit="tok", position=1, leave=False, dynamic_ncols=True)
def token_progress(done, total):
token_bar.update(done - token_bar.n)
token_bar.set_postfix_str(f"{done / FRAMES_PER_SECOND:.1f}s", refresh=False)
try:
codec = pipeline.generate_codec_tokens(
prefix, max_tokens=max_tokens, seed=gen_config.seed, temperature=self.sample_ar_temperature, progress=token_progress,
)
finally:
token_bar.close()
if len(codec) == 0:
codec = [0]
self._status_update(f"YuE2: rendering {len(codec) / FRAMES_PER_SECOND:.1f}s")
render_bar = tqdm(desc=f" render {len(codec) / FRAMES_PER_SECOND:.0f}s", unit="step", position=1, leave=False, dynamic_ncols=True)
def render_step(i, n, x):
if render_bar.total != n:
render_bar.total = n
render_bar.update(i + 1 - render_bar.n)
self._emit_sample_step(x, i, n)
try:
latents = pipeline.synthesize(
prefix,
codec,
seed=gen_config.seed,
steps=gen_config.num_inference_steps,
step_callback=render_step,
)
finally:
render_bar.close()
audio = pipeline.decode(latents) # [1, 2, samples]
return audio.cpu()
# ------------------------------------------------------------------
# LoRA key layout: transformer.nar.* -> diffusion_model.*, transformer.ar.* -> text_encoders.*
# ------------------------------------------------------------------
def convert_lora_weights_before_save(self, state_dict):
out = {}
for k, v in state_dict.items():
if k.startswith("transformer.nar."):
k = "diffusion_model." + k[len("transformer.nar.") :]
elif k.startswith("transformer.ar."):
k = "text_encoders." + k[len("transformer.ar.") :]
out[k] = v
return out
def convert_lora_weights_before_load(self, state_dict):
out = {}
for k, v in state_dict.items():
if k.startswith("diffusion_model."):
k = "transformer.nar." + k[len("diffusion_model.") :]
elif k.startswith("text_encoders."):
k = "transformer.ar." + k[len("text_encoders.") :]
out[k] = v
return out