mirror of
https://github.com/huggingface/diffusers.git
synced 2026-10-24 20:59:27 +03:00
* Add MiniMax Music 3 transformer, pipeline sub-models, and conversion script
* Add MiniMax Music 3 pipeline, tests, and docs
* Wire example docstring, point diffusers-format references at MiniMaxAI/MiniMax-Music-3
* Use MiniMaxAI/MiniMax-Music3 as the official diffusers weights repo id
* Apply doc-builder style
* Use dtype in examples, demonstrate the structured prompt format
* Use soundfile in examples like the other audio pipelines
* Convert to a modular pipeline and move models under src/diffusers/models
Per review: the conditioner moves to models/condition_embedders, the RVQ depth
decoder and vocoder to models/transformers and models/autoencoders, and the
standard pipeline is replaced by MiniMaxMusic3ModularPipeline (anima-style
blocks, helios-style chunk loop, guider-abstracted CFG with a zeros
unconditional branch). Index configs now reference all components under the
diffusers library. Also applies the transformer review suggestions
(Transformer2DModelOutput, inlined rotary forward, explicit processor default).
* Point modular index loading specs at the Hub repo in the converter
* Apply doc-builder style to the modular blocks docstrings
* Address review-bot comments: overlap comment accuracy, single pipeline-level progress bar, zero-frame duration guard, import ordering, docstring cross-reference
* Support CPU offloading in the AR step: trigger offload hooks before submodule calls (minimax_h3 workaround), clear error when the AR models cannot co-reside
* Docs: PR-install tip and memory-usage section with measured offloading numbers
* Address review comments on the block assembly
Take the individual models rather than `components` in `_generate_depth_codes` and
`_embed_audio_frame`; `num_codebooks` / `audio_vocab_size` come off the depth decoder's
config, so the two pipeline properties that existed only for them are gone.
Assemble the blockset as three top-level children, each owning models and runnable on its
own — generate the semantics once and denoise it with different settings:
semantic_generator MiniMaxMusic3SemanticGenerationStep [tokenize, generate]
denoise MiniMaxMusic3CoreDenoiseStep [prepare_chunks, denoise]
decode MiniMaxMusic3VocoderDecodeStep
`MiniMaxMusic3TextEncoderStep` is renamed `MiniMaxMusic3TokenizeStep` (it only tokenizes)
and the autoregressive leaf `MiniMaxMusic3AutoregressiveStep`, freeing its old name for
the sequential.
Point the tests at `hf-internal-testing/tiny-minimax-music3-modular-pipe`; the previous
fixture repo's component specs pointed at a local path, so nothing loaded off that machine.
Drop the PR-branch install tip from the docs.
20s and 60s generations are bit-identical to before these changes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: yiyixuxu <yixu310@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
270 lines
13 KiB
Python
270 lines
13 KiB
Python
# Conversion script for MiniMax Music 3 (https://huggingface.co/MiniMaxAI/MiniMax-Music3).
|
|
#
|
|
# Original checkpoint layout:
|
|
# flowmatching_vae.pth flow-matching DiT + condition projection
|
|
# dav.pth Flow-VAE (DAC-style) decoder
|
|
# qwen_7B/qwen_7B/ Qwen3 backbone + audio embedding + RVQ depth decoder (sharded safetensors)
|
|
# qwen_7B/qwen3-8B-tokenizer-music/ music tokenizer
|
|
#
|
|
# Usage:
|
|
# python scripts/convert_minimax_music3_to_diffusers.py \
|
|
# --checkpoint_dir MiniMaxAI/MiniMax-Music3 --output_path ./minimax-music3-diffusers
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
|
|
import torch
|
|
from safetensors.torch import load_file
|
|
|
|
from diffusers import (
|
|
FlowMatchEulerDiscreteScheduler,
|
|
MiniMaxMusic3ConditionEncoder,
|
|
MiniMaxMusic3RVQDepthDecoder,
|
|
MiniMaxMusic3Transformer1DModel,
|
|
MiniMaxMusic3Vocoder,
|
|
)
|
|
|
|
|
|
def load_dit_state_dict(checkpoint_dir: str) -> dict:
|
|
return torch.load(os.path.join(checkpoint_dir, "flowmatching_vae.pth"), map_location="cpu", weights_only=True)
|
|
|
|
|
|
def load_dav_state_dict(checkpoint_dir: str) -> dict:
|
|
return torch.load(os.path.join(checkpoint_dir, "dav.pth"), map_location="cpu", weights_only=True)
|
|
|
|
|
|
def load_qwen_state_dict(checkpoint_dir: str) -> dict:
|
|
qwen_dir = os.path.join(checkpoint_dir, "qwen_7B", "qwen_7B")
|
|
with open(os.path.join(qwen_dir, "model.safetensors.index.json")) as f:
|
|
index = json.load(f)
|
|
state_dict = {}
|
|
for filename in sorted(set(index["weight_map"].values())):
|
|
state_dict.update(load_file(os.path.join(qwen_dir, filename), device="cpu"))
|
|
return state_dict
|
|
|
|
|
|
def convert_transformer(dit_state_dict: dict) -> MiniMaxMusic3Transformer1DModel:
|
|
prefix = "diffusion_transformer."
|
|
converted = {
|
|
"time_proj.weight": dit_state_dict[prefix + "timestep_features.weight"],
|
|
"time_embed.linear_1.weight": dit_state_dict[prefix + "to_timestep_embed.0.weight"],
|
|
"time_embed.linear_1.bias": dit_state_dict[prefix + "to_timestep_embed.0.bias"],
|
|
"time_embed.linear_2.weight": dit_state_dict[prefix + "to_timestep_embed.2.weight"],
|
|
"time_embed.linear_2.bias": dit_state_dict[prefix + "to_timestep_embed.2.bias"],
|
|
"preprocess_conv.weight": dit_state_dict[prefix + "preprocess_conv.weight"],
|
|
"postprocess_conv.weight": dit_state_dict[prefix + "postprocess_conv.weight"],
|
|
"proj_in.weight": dit_state_dict[prefix + "transformer.project_in.weight"],
|
|
"proj_out.weight": dit_state_dict[prefix + "transformer.project_out.weight"],
|
|
}
|
|
num_layers = 0
|
|
while prefix + f"transformer.layers.{num_layers}.pre_norm.gamma" in dit_state_dict:
|
|
num_layers += 1
|
|
for i in range(num_layers):
|
|
original = prefix + f"transformer.layers.{i}."
|
|
target = f"transformer_blocks.{i}."
|
|
converted[target + "norm1.weight"] = dit_state_dict[original + "pre_norm.gamma"]
|
|
converted[target + "norm1.bias"] = dit_state_dict[original + "pre_norm.beta"]
|
|
query, key, value = dit_state_dict[original + "self_attn.to_qkv.weight"].chunk(3, dim=0)
|
|
converted[target + "attn.to_q.weight"] = query
|
|
converted[target + "attn.to_k.weight"] = key
|
|
converted[target + "attn.to_v.weight"] = value
|
|
converted[target + "attn.to_out.0.weight"] = dit_state_dict[original + "self_attn.to_out.weight"]
|
|
converted[target + "norm2.weight"] = dit_state_dict[original + "ff_norm.gamma"]
|
|
converted[target + "norm2.bias"] = dit_state_dict[original + "ff_norm.beta"]
|
|
converted[target + "ff_in.weight"] = dit_state_dict[original + "ff.ff.0.proj.weight"]
|
|
converted[target + "ff_in.bias"] = dit_state_dict[original + "ff.ff.0.proj.bias"]
|
|
converted[target + "ff_out.weight"] = dit_state_dict[original + "ff.ff.2.weight"]
|
|
converted[target + "ff_out.bias"] = dit_state_dict[original + "ff.ff.2.bias"]
|
|
|
|
with torch.device("meta"):
|
|
transformer = MiniMaxMusic3Transformer1DModel(num_layers=num_layers)
|
|
transformer.load_state_dict(converted, strict=True, assign=True)
|
|
return transformer
|
|
|
|
|
|
def convert_condition_encoder(dit_state_dict: dict) -> MiniMaxMusic3ConditionEncoder:
|
|
converted = {
|
|
"layer_weight_logits": dit_state_dict["cond_layer_logits"],
|
|
"layer_scale": dit_state_dict["cond_layer_scale"],
|
|
"proj.weight": dit_state_dict["latent_conditioners.0.weight"],
|
|
"proj.bias": dit_state_dict["latent_conditioners.0.bias"],
|
|
}
|
|
with torch.device("meta"):
|
|
condition_encoder = MiniMaxMusic3ConditionEncoder()
|
|
condition_encoder.load_state_dict(converted, strict=True, assign=True)
|
|
return condition_encoder
|
|
|
|
|
|
def convert_vocoder(dav_state_dict: dict) -> MiniMaxMusic3Vocoder:
|
|
converted = {
|
|
"dec_in_proj.weight": dav_state_dict["dec_in_proj.weight"],
|
|
"dec_in_proj.bias": dav_state_dict["dec_in_proj.bias"],
|
|
}
|
|
# The reference decoder is one nn.Sequential: [conv_in, block*4, snake, conv_out, tanh].
|
|
for suffix in ("weight_g", "weight_v", "bias"):
|
|
converted[f"conv_in.{suffix}"] = dav_state_dict[f"decoder.model.0.{suffix}"]
|
|
converted[f"conv_out.{suffix}"] = dav_state_dict[f"decoder.model.6.{suffix}"]
|
|
converted["snake_out.alpha"] = dav_state_dict["decoder.model.5.alpha"]
|
|
for block_index in range(4):
|
|
original = f"decoder.model.{block_index + 1}.block."
|
|
target = f"blocks.{block_index}."
|
|
converted[target + "snake1.alpha"] = dav_state_dict[original + "0.alpha"]
|
|
for suffix in ("weight_g", "weight_v", "bias"):
|
|
converted[target + f"conv_t1.{suffix}"] = dav_state_dict[original + f"1.{suffix}"]
|
|
for unit_index, unit_name in ((2, "res_unit1"), (3, "res_unit2"), (4, "res_unit3")):
|
|
converted[target + f"{unit_name}.snake1.alpha"] = dav_state_dict[original + f"{unit_index}.block.0.alpha"]
|
|
converted[target + f"{unit_name}.snake2.alpha"] = dav_state_dict[original + f"{unit_index}.block.2.alpha"]
|
|
for suffix in ("weight_g", "weight_v", "bias"):
|
|
converted[target + f"{unit_name}.conv1.{suffix}"] = dav_state_dict[
|
|
original + f"{unit_index}.block.1.{suffix}"
|
|
]
|
|
converted[target + f"{unit_name}.conv2.{suffix}"] = dav_state_dict[
|
|
original + f"{unit_index}.block.3.{suffix}"
|
|
]
|
|
|
|
vocoder = MiniMaxMusic3Vocoder()
|
|
vocoder.load_state_dict(converted, strict=True)
|
|
return vocoder
|
|
|
|
|
|
def convert_rvq_depth_decoder(qwen_state_dict: dict, model_config: dict) -> MiniMaxMusic3RVQDepthDecoder:
|
|
prefix = "model.audio_decoder."
|
|
converted = {
|
|
"audio_embeddings.weight": qwen_state_dict["model.audio_extra_embedding.weight"],
|
|
"projection.weight": qwen_state_dict[prefix + "projection.weight"],
|
|
"pos_embedding.weight": qwen_state_dict[prefix + "pos_embedding.weight"],
|
|
"norm.weight": qwen_state_dict[prefix + "norm.weight"],
|
|
}
|
|
num_codebooks = int(model_config["audio_num_codebooks"])
|
|
for i in range(num_codebooks - 1):
|
|
converted[f"audio_heads.{i}.weight"] = qwen_state_dict[prefix + f"audio_heads.{i}.weight"]
|
|
num_layers = int(model_config["decoder_num_layers"])
|
|
for i in range(num_layers):
|
|
original = prefix + f"layers.{i}."
|
|
target = f"layers.{i}."
|
|
converted[target + "input_layernorm.weight"] = qwen_state_dict[original + "input_layernorm.weight"]
|
|
converted[target + "post_attention_layernorm.weight"] = qwen_state_dict[
|
|
original + "post_attention_layernorm.weight"
|
|
]
|
|
converted[target + "attn.to_q.weight"] = qwen_state_dict[original + "self_attn.q_proj.weight"]
|
|
converted[target + "attn.to_k.weight"] = qwen_state_dict[original + "self_attn.k_proj.weight"]
|
|
converted[target + "attn.to_v.weight"] = qwen_state_dict[original + "self_attn.v_proj.weight"]
|
|
converted[target + "attn.to_out.weight"] = qwen_state_dict[original + "self_attn.o_proj.weight"]
|
|
for proj in ("gate_proj", "up_proj", "down_proj"):
|
|
converted[target + proj + ".weight"] = qwen_state_dict[original + f"mlp.{proj}.weight"]
|
|
|
|
with torch.device("meta"):
|
|
rvq_depth_decoder = MiniMaxMusic3RVQDepthDecoder(
|
|
hidden_size=int(model_config["hidden_size"]),
|
|
num_layers=num_layers,
|
|
num_attention_heads=int(model_config["decoder_num_heads"]),
|
|
intermediate_size=int(model_config["decoder_intermediate_size"]),
|
|
audio_vocab_size=int(model_config["audio_vocab_size"]),
|
|
num_codebooks=num_codebooks,
|
|
)
|
|
rvq_depth_decoder.load_state_dict(converted, strict=True, assign=True)
|
|
return rvq_depth_decoder
|
|
|
|
|
|
def convert_language_model(qwen_state_dict: dict, model_config: dict):
|
|
from transformers import Qwen3Config, Qwen3ForCausalLM
|
|
|
|
config = Qwen3Config(
|
|
vocab_size=model_config["vocab_size"],
|
|
hidden_size=model_config["hidden_size"],
|
|
intermediate_size=model_config["intermediate_size"],
|
|
num_hidden_layers=model_config["num_hidden_layers"],
|
|
num_attention_heads=model_config["num_attention_heads"],
|
|
num_key_value_heads=model_config["num_key_value_heads"],
|
|
head_dim=model_config["head_dim"],
|
|
max_position_embeddings=model_config.get("max_position_embeddings", 10240),
|
|
rope_theta=model_config.get("rope_theta", 1000000),
|
|
rms_norm_eps=model_config.get("rms_norm_eps", 1e-6),
|
|
tie_word_embeddings=model_config.get("tie_word_embeddings", False),
|
|
)
|
|
backbone_state_dict = {
|
|
key: value
|
|
for key, value in qwen_state_dict.items()
|
|
if not key.startswith(("model.audio_extra_embedding", "model.audio_decoder."))
|
|
}
|
|
with torch.device("meta"):
|
|
language_model = Qwen3ForCausalLM(config)
|
|
language_model.load_state_dict(backbone_state_dict, strict=True, assign=True)
|
|
return language_model
|
|
|
|
|
|
def main(args):
|
|
checkpoint_dir = args.checkpoint_dir
|
|
if not os.path.isdir(checkpoint_dir):
|
|
from huggingface_hub import snapshot_download
|
|
|
|
checkpoint_dir = snapshot_download(checkpoint_dir)
|
|
|
|
with open(os.path.join(checkpoint_dir, "qwen_7B", "qwen_7B", "config.json")) as f:
|
|
model_config = json.load(f)
|
|
|
|
dit_state_dict = load_dit_state_dict(checkpoint_dir)
|
|
transformer = convert_transformer(dit_state_dict).to(args.dtype)
|
|
condition_encoder = convert_condition_encoder(dit_state_dict).to(args.dtype)
|
|
del dit_state_dict
|
|
vocoder = convert_vocoder(load_dav_state_dict(checkpoint_dir)).to(args.dtype)
|
|
|
|
qwen_state_dict = load_qwen_state_dict(checkpoint_dir)
|
|
rvq_depth_decoder = convert_rvq_depth_decoder(qwen_state_dict, model_config).to(torch.bfloat16)
|
|
language_model = convert_language_model(qwen_state_dict, model_config)
|
|
del qwen_state_dict
|
|
|
|
from transformers import AutoTokenizer
|
|
|
|
from diffusers import MiniMaxMusic3Blocks
|
|
|
|
tokenizer = AutoTokenizer.from_pretrained(os.path.join(checkpoint_dir, "qwen_7B", "qwen3-8B-tokenizer-music"))
|
|
# num_train_timesteps=1 keeps `scheduler.timesteps` equal to the flow-matching time in [0, 1] that the
|
|
# transformer's Fourier embedding expects.
|
|
scheduler = FlowMatchEulerDiscreteScheduler(num_train_timesteps=1, shift=1.0, invert_sigmas=True)
|
|
|
|
pipeline = MiniMaxMusic3Blocks().init_pipeline()
|
|
pipeline.update_components(
|
|
language_model=language_model,
|
|
rvq_depth_decoder=rvq_depth_decoder,
|
|
condition_encoder=condition_encoder,
|
|
transformer=transformer,
|
|
vocoder=vocoder,
|
|
tokenizer=tokenizer,
|
|
scheduler=scheduler,
|
|
)
|
|
pipeline.save_pretrained(args.output_path, safe_serialization=True, max_shard_size="5GB")
|
|
|
|
# save_pretrained bakes the local output path into the modular index's loading specs; point them at the
|
|
# Hub repo the components will be uploaded to instead.
|
|
index_path = os.path.join(args.output_path, "modular_model_index.json")
|
|
with open(index_path) as f:
|
|
index = json.load(f)
|
|
for entry in index.values():
|
|
if isinstance(entry, list) and len(entry) == 3 and isinstance(entry[2], dict):
|
|
if entry[2].get("pretrained_model_name_or_path") == args.output_path:
|
|
entry[2]["pretrained_model_name_or_path"] = args.repo_id
|
|
with open(index_path, "w") as f:
|
|
json.dump(index, f, indent=2, sort_keys=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument(
|
|
"--checkpoint_dir",
|
|
type=str,
|
|
default="MiniMaxAI/MiniMax-Music3",
|
|
help="Local directory or Hugging Face Hub repo id of the original checkpoint.",
|
|
)
|
|
parser.add_argument("--output_path", type=str, required=True)
|
|
parser.add_argument(
|
|
"--repo_id",
|
|
type=str,
|
|
default="MiniMaxAI/MiniMax-Music3",
|
|
help="Hub repo id the converted components will live in (written into the modular index loading specs).",
|
|
)
|
|
parser.add_argument("--dtype", type=lambda name: getattr(torch, name), default="float32")
|
|
args = parser.parse_args()
|
|
main(args)
|