#!/usr/bin/env python3 # convert.py: Qwen3-TTS HF checkpoint -> GGUF. # # Reads a HF safetensors checkpoint and writes a GGUF F32 file with a # flat tensor naming scheme and arch-specific KV metadata. Two converter # paths live here : # tokenizer Mimi-style 12Hz audio codec (encoder + decoder + RVQ) with # precomputed codebook embeddings (embedding_sum / clamp( # cluster_usage, min=eps)) for single-shot F.embedding lookup. # talker Qwen3-style autoregressive LM (talker + 5-layer code # predictor MTP head) plus the optional ECAPA-TDNN speaker # encoder shipped with Base checkpoints. BPE vocab and # merges of the Qwen2 tokenizer are embedded in the GGUF. import json import os import re import sys from pathlib import Path import numpy as np from safetensors import safe_open import gguf # RVQ codebook epsilon clamp, matches Qwen3TTS EuclideanCodebook.epsilon RVQ_EPS = 1e-5 # Tokenizer 12Hz fixed shape: 4 DAC blocks (one per upsample stride) and # 3 residual units per block (dilations 1, 3, 9). DAC_NUM_BLOCKS = 4 DAC_RU_PER_BLOCK = 3 def rename_pre_transformer(name: str) -> str: # decoder.pre_transformer.layers.{i}.input_layernorm.weight -> tok_dec.pre_tfm.blk.{i}.attn_norm.weight # decoder.pre_transformer.layers.{i}.post_attention_layernorm.weight -> tok_dec.pre_tfm.blk.{i}.ffn_norm.weight # decoder.pre_transformer.layers.{i}.self_attn.q_proj.weight -> tok_dec.pre_tfm.blk.{i}.attn_q.weight # decoder.pre_transformer.layers.{i}.self_attn.{k,v,o}_proj.weight -> tok_dec.pre_tfm.blk.{i}.attn_{k,v,output}.weight # decoder.pre_transformer.layers.{i}.self_attn_layer_scale.scale -> tok_dec.pre_tfm.blk.{i}.attn_scale # decoder.pre_transformer.layers.{i}.mlp.{gate,up,down}_proj.weight -> tok_dec.pre_tfm.blk.{i}.ffn_{gate,up,down}.weight # decoder.pre_transformer.layers.{i}.mlp_layer_scale.scale -> tok_dec.pre_tfm.blk.{i}.ffn_scale assert name.startswith("decoder.pre_transformer.layers.") parts = name.split(".") idx = parts[3] rest = parts[4:] if rest == ["input_layernorm", "weight"]: return f"tok_dec.pre_tfm.blk.{idx}.attn_norm.weight" if rest == ["post_attention_layernorm", "weight"]: return f"tok_dec.pre_tfm.blk.{idx}.ffn_norm.weight" if rest[:2] == ["self_attn", "q_proj"]: return f"tok_dec.pre_tfm.blk.{idx}.attn_q.{rest[-1]}" if rest[:2] == ["self_attn", "k_proj"]: return f"tok_dec.pre_tfm.blk.{idx}.attn_k.{rest[-1]}" if rest[:2] == ["self_attn", "v_proj"]: return f"tok_dec.pre_tfm.blk.{idx}.attn_v.{rest[-1]}" if rest[:2] == ["self_attn", "o_proj"]: return f"tok_dec.pre_tfm.blk.{idx}.attn_output.{rest[-1]}" if rest == ["self_attn_layer_scale", "scale"]: return f"tok_dec.pre_tfm.blk.{idx}.attn_scale" if rest[:2] == ["mlp", "gate_proj"]: return f"tok_dec.pre_tfm.blk.{idx}.ffn_gate.{rest[-1]}" if rest[:2] == ["mlp", "up_proj"]: return f"tok_dec.pre_tfm.blk.{idx}.ffn_up.{rest[-1]}" if rest[:2] == ["mlp", "down_proj"]: return f"tok_dec.pre_tfm.blk.{idx}.ffn_down.{rest[-1]}" if rest == ["mlp_layer_scale", "scale"]: return f"tok_dec.pre_tfm.blk.{idx}.ffn_scale" raise ValueError(f"Unhandled pre_transformer tensor : {name}") def rename_pre_conv(name: str) -> str: # decoder.pre_conv.conv.{weight,bias} -> tok_dec.pre_conv.{weight,bias} suffix = name.rsplit(".", 1)[-1] return f"tok_dec.pre_conv.{suffix}" def rename_upsample(name: str) -> str: # Two ModuleList per stage: index 0 is the CausalTransConv, index 1 # is the ConvNeXt block. # decoder.upsample.{i}.0.conv.{weight,bias} -> tok_dec.upsample.{i}.conv.{weight,bias} # decoder.upsample.{i}.1.dwconv.conv.{weight,bias} -> tok_dec.upsample.{i}.dwconv.{weight,bias} # decoder.upsample.{i}.1.norm.{weight,bias} -> tok_dec.upsample.{i}.norm.{weight,bias} # decoder.upsample.{i}.1.pwconv{1,2}.{weight,bias} -> tok_dec.upsample.{i}.pwconv{1,2}.{weight,bias} # decoder.upsample.{i}.1.gamma -> tok_dec.upsample.{i}.gamma parts = name.split(".") assert parts[0] == "decoder" and parts[1] == "upsample" block_idx = parts[2] sub = parts[3] if sub == "0": suffix = parts[-1] return f"tok_dec.upsample.{block_idx}.conv.{suffix}" if sub == "1": if parts[4] == "dwconv": suffix = parts[-1] return f"tok_dec.upsample.{block_idx}.dwconv.{suffix}" if parts[4] in ("norm", "pwconv1", "pwconv2"): suffix = parts[-1] return f"tok_dec.upsample.{block_idx}.{parts[4]}.{suffix}" if parts[4] == "gamma": return f"tok_dec.upsample.{block_idx}.gamma" raise ValueError(f"Unhandled upsample tensor : {name}") def precompute_codebook(embedding_sum: np.ndarray, cluster_usage: np.ndarray) -> np.ndarray: # Qwen3TTS EuclideanCodebook.decode : # embedding = embedding_sum / cluster_usage.clamp(min=epsilon)[:, None] # Stored in F32 to match runtime precision of the codebook lookup. We # pre-divide at convert time so the runtime can read a ready to use # F.embedding table straight from the GGUF. usage = np.clip(cluster_usage, RVQ_EPS, None).astype(np.float32) sums = embedding_sum.astype(np.float32) return sums / usage[:, None] def rename_decoder_chain(name: str) -> str: # Direct {i} preservation per the koboldcpp tok_dec convention : # decoder.decoder.{i}.block.0.alpha -> tok_dec.dec.{i}.snake.alpha # decoder.decoder.{i}.block.0.beta -> tok_dec.dec.{i}.snake.beta # decoder.decoder.{i}.block.1.conv.{weight,bias} -> tok_dec.dec.{i}.conv_t.{weight,bias} # decoder.decoder.{i}.block.{j}.act{1,2}.{alpha,beta} -> tok_dec.dec.{i}.res.{j-2}.act{1,2}.{alpha,beta} # decoder.decoder.{i}.block.{j}.conv{1,2}.conv.{w,b} -> tok_dec.dec.{i}.res.{j-2}.conv{1,2}.{w,b} # decoder.decoder.0.conv.{weight,bias} -> tok_dec.dec.0.conv.{weight,bias} # decoder.decoder.5.{alpha,beta} -> tok_dec.dec.5.snake.{alpha,beta} # decoder.decoder.6.conv.{weight,bias} -> tok_dec.dec.6.conv.{weight,bias} parts = name.split(".") assert parts[0] == "decoder" and parts[1] == "decoder" idx = int(parts[2]) if idx == 0: return f"tok_dec.dec.0.conv.{parts[-1]}" if idx == 5: return f"tok_dec.dec.5.snake.{parts[-1]}" if idx == 6: return f"tok_dec.dec.6.conv.{parts[-1]}" sub = int(parts[4]) if sub == 0: return f"tok_dec.dec.{idx}.snake.{parts[-1]}" if sub == 1: return f"tok_dec.dec.{idx}.conv_t.{parts[-1]}" if sub in (2, 3, 4): ru = sub - 2 rest = parts[5] if rest in ("act1", "act2"): return f"tok_dec.dec.{idx}.res.{ru}.{rest}.{parts[-1]}" if rest in ("conv1", "conv2"): return f"tok_dec.dec.{idx}.res.{ru}.{rest}.{parts[-1]}" raise ValueError(f"Unhandled decoder chain tensor : {name}") def rename_seanet(name: str) -> str: # encoder.encoder.layers.{idx}.conv.{weight,bias} -> tok_enc.conv.{idx}.{weight,bias} # encoder.encoder.layers.{idx}.block.{j}.conv.{weight,bias} -> tok_enc.res.{idx}.blk.{j}.{weight,bias} # We pass the raw Python ModuleList index through so the loader # reconstructs the SEANet topology from the upsampling_ratios array. parts = name.split(".") assert parts[0] == "encoder" and parts[1] == "encoder" and parts[2] == "layers" idx = parts[3] suffix = parts[-1] if len(parts) == 6 and parts[4] == "conv": return f"tok_enc.conv.{idx}.{suffix}" if len(parts) == 8 and parts[4] == "block" and parts[6] == "conv": sub = parts[5] return f"tok_enc.res.{idx}.blk.{sub}.{suffix}" raise ValueError(f"Unhandled SEANet tensor : {name}") def rename_encoder_transformer(name: str) -> str: # encoder.encoder_transformer.layers.{i}.input_layernorm.{weight,bias} -> tok_enc.blk.{i}.attn_norm.{weight,bias} # encoder.encoder_transformer.layers.{i}.post_attention_layernorm.{weight,bias} -> tok_enc.blk.{i}.ffn_norm.{weight,bias} # encoder.encoder_transformer.layers.{i}.self_attn.{q,k,v,o}_proj.weight -> tok_enc.blk.{i}.attn_{q,k,v,output}.weight # encoder.encoder_transformer.layers.{i}.self_attn_layer_scale.scale -> tok_enc.blk.{i}.attn_scale # encoder.encoder_transformer.layers.{i}.mlp.{fc1,fc2}.weight -> tok_enc.blk.{i}.{ffn_up,ffn_down}.weight # encoder.encoder_transformer.layers.{i}.mlp_layer_scale.scale -> tok_enc.blk.{i}.ffn_scale assert name.startswith("encoder.encoder_transformer.layers.") parts = name.split(".") idx = parts[3] rest = parts[4:] if rest[:1] == ["input_layernorm"]: return f"tok_enc.blk.{idx}.attn_norm.{rest[-1]}" if rest[:1] == ["post_attention_layernorm"]: return f"tok_enc.blk.{idx}.ffn_norm.{rest[-1]}" if rest[:2] == ["self_attn", "q_proj"]: return f"tok_enc.blk.{idx}.attn_q.{rest[-1]}" if rest[:2] == ["self_attn", "k_proj"]: return f"tok_enc.blk.{idx}.attn_k.{rest[-1]}" if rest[:2] == ["self_attn", "v_proj"]: return f"tok_enc.blk.{idx}.attn_v.{rest[-1]}" if rest[:2] == ["self_attn", "o_proj"]: return f"tok_enc.blk.{idx}.attn_output.{rest[-1]}" if rest == ["self_attn_layer_scale", "scale"]: return f"tok_enc.blk.{idx}.attn_scale" if rest[:2] == ["mlp", "fc1"]: return f"tok_enc.blk.{idx}.ffn_up.{rest[-1]}" if rest[:2] == ["mlp", "fc2"]: return f"tok_enc.blk.{idx}.ffn_down.{rest[-1]}" if rest == ["mlp_layer_scale", "scale"]: return f"tok_enc.blk.{idx}.ffn_scale" raise ValueError(f"Unhandled encoder transformer tensor : {name}") def rename_encoder_downsample(name: str) -> str: # encoder.downsample.conv.{weight,bias} -> tok_enc.downsample.{weight,bias} suffix = name.rsplit(".", 1)[-1] return f"tok_enc.downsample.{suffix}" def rename_encoder_quantizer_proj(name: str) -> str: # encoder.quantizer.{semantic|acoustic}_residual_vector_quantizer.{input_proj,output_proj}.weight -> # tok_enc.vq_{semantic|acoustic}.{input_proj,output_proj}.weight if "input_proj" in name: proj = "input_proj" elif "output_proj" in name: proj = "output_proj" else: raise ValueError(f"Unknown encoder quantizer proj : {name}") if "semantic_residual_vector_quantizer" in name: return f"tok_enc.vq_semantic.{proj}.weight" if "acoustic_residual_vector_quantizer" in name: return f"tok_enc.vq_acoustic.{proj}.weight" raise ValueError(f"Unhandled encoder quantizer proj : {name}") def convert_tokenizer_12hz(checkpoint_dir: Path, out_path: Path) -> int: cfg_path = checkpoint_dir / "config.json" st_path = checkpoint_dir / "model.safetensors" if not cfg_path.is_file() or not st_path.is_file(): print(f"[Convert] FATAL: missing checkpoint files in {checkpoint_dir}") return 1 cfg = json.loads(cfg_path.read_text()) dec = cfg["decoder_config"] enc = cfg["encoder_config"] arch = "qwen3-tts-tokenizer" writer = gguf.GGUFWriter(str(out_path), arch) writer.add_string("general.name", "Qwen3-TTS-Tokenizer-12Hz") # Tokenizer-level metadata writer.add_uint32("qwen3-tts-tokenizer.input_sample_rate", cfg["input_sample_rate"]) writer.add_uint32("qwen3-tts-tokenizer.output_sample_rate", cfg["output_sample_rate"]) writer.add_uint32("qwen3-tts-tokenizer.decode_upsample_rate", cfg["decode_upsample_rate"]) writer.add_uint32("qwen3-tts-tokenizer.encode_downsample_rate", cfg["encode_downsample_rate"]) writer.add_uint32("qwen3-tts-tokenizer.encoder_valid_num_quantizers", cfg["encoder_valid_num_quantizers"]) # Decoder-level metadata writer.add_uint32("qwen3-tts-tokenizer.decoder.latent_dim", dec["latent_dim"]) writer.add_uint32("qwen3-tts-tokenizer.decoder.codebook_dim", dec["codebook_dim"]) writer.add_uint32("qwen3-tts-tokenizer.decoder.codebook_size", dec["codebook_size"]) writer.add_uint32("qwen3-tts-tokenizer.decoder.decoder_dim", dec["decoder_dim"]) writer.add_uint32("qwen3-tts-tokenizer.decoder.hidden_size", dec["hidden_size"]) writer.add_uint32("qwen3-tts-tokenizer.decoder.intermediate_size", dec["intermediate_size"]) writer.add_uint32("qwen3-tts-tokenizer.decoder.head_dim", dec["head_dim"]) writer.add_uint32("qwen3-tts-tokenizer.decoder.num_attention_heads", dec["num_attention_heads"]) writer.add_uint32("qwen3-tts-tokenizer.decoder.num_key_value_heads", dec["num_key_value_heads"]) writer.add_uint32("qwen3-tts-tokenizer.decoder.num_hidden_layers", dec["num_hidden_layers"]) writer.add_uint32("qwen3-tts-tokenizer.decoder.num_quantizers", dec["num_quantizers"]) writer.add_uint32("qwen3-tts-tokenizer.decoder.num_semantic_quantizers", dec["num_semantic_quantizers"]) writer.add_float32("qwen3-tts-tokenizer.decoder.rms_norm_eps", dec["rms_norm_eps"]) writer.add_float32("qwen3-tts-tokenizer.decoder.rope_theta", float(dec["rope_theta"])) writer.add_uint32("qwen3-tts-tokenizer.decoder.sliding_window", dec["sliding_window"]) writer.add_float32("qwen3-tts-tokenizer.decoder.layer_scale_initial_scale", dec["layer_scale_initial_scale"]) writer.add_array("qwen3-tts-tokenizer.decoder.upsample_rates", dec["upsample_rates"]) writer.add_array("qwen3-tts-tokenizer.decoder.upsampling_ratios", dec["upsampling_ratios"]) writer.add_uint32("qwen3-tts-tokenizer.decoder.vector_quantization_hidden_dim", dec["vector_quantization_hidden_dimension"]) writer.add_uint32("qwen3-tts-tokenizer.decoder.codebook_dim_internal", 256) # the actual codebook vector dim before output_proj # Encoder-level metadata. The encoder is a Mimi-style stack: SEANet conv # downsampler -> 8-layer Mimi transformer -> 1 conv downsample -> RVQ. writer.add_uint32("qwen3-tts-tokenizer.encoder.num_filters", enc["num_filters"]) writer.add_uint32("qwen3-tts-tokenizer.encoder.kernel_size", enc["kernel_size"]) writer.add_uint32("qwen3-tts-tokenizer.encoder.last_kernel_size", enc["last_kernel_size"]) writer.add_uint32("qwen3-tts-tokenizer.encoder.residual_kernel_size", enc["residual_kernel_size"]) writer.add_uint32("qwen3-tts-tokenizer.encoder.num_residual_layers", enc["num_residual_layers"]) writer.add_uint32("qwen3-tts-tokenizer.encoder.dilation_growth_rate", enc["dilation_growth_rate"]) writer.add_uint32("qwen3-tts-tokenizer.encoder.compress", enc["compress"]) writer.add_array("qwen3-tts-tokenizer.encoder.upsampling_ratios", enc["upsampling_ratios"]) writer.add_uint32("qwen3-tts-tokenizer.encoder.hidden_size", enc["hidden_size"]) writer.add_uint32("qwen3-tts-tokenizer.encoder.intermediate_size", enc["intermediate_size"]) writer.add_uint32("qwen3-tts-tokenizer.encoder.head_dim", enc["head_dim"]) writer.add_uint32("qwen3-tts-tokenizer.encoder.num_attention_heads", enc["num_attention_heads"]) writer.add_uint32("qwen3-tts-tokenizer.encoder.num_key_value_heads", enc["num_key_value_heads"]) writer.add_uint32("qwen3-tts-tokenizer.encoder.num_hidden_layers", enc["num_hidden_layers"]) writer.add_float32("qwen3-tts-tokenizer.encoder.norm_eps", enc["norm_eps"]) writer.add_float32("qwen3-tts-tokenizer.encoder.rope_theta", float(enc["rope_theta"])) writer.add_float32("qwen3-tts-tokenizer.encoder.layer_scale_initial_scale", enc["layer_scale_initial_scale"]) writer.add_uint32("qwen3-tts-tokenizer.encoder.codebook_dim", enc["codebook_dim"]) writer.add_uint32("qwen3-tts-tokenizer.encoder.codebook_size", enc["codebook_size"]) writer.add_uint32("qwen3-tts-tokenizer.encoder.num_quantizers", enc["num_quantizers"]) writer.add_uint32("qwen3-tts-tokenizer.encoder.num_semantic_quantizers", enc["num_semantic_quantizers"]) writer.add_uint32("qwen3-tts-tokenizer.encoder.vector_quantization_hidden_dim", enc["vector_quantization_hidden_dimension"]) # Walk safetensors n_added = 0 n_skipped_encoder_extra_acoustic = 0 # Truncation policy: the encoder ships 32 acoustic codebooks (semantic 1 # + acoustic 31), but encoder_valid_num_quantizers=16 means only the first # 16 (1 semantic + 15 acoustic) are consumed at encode time and matched # by the decoder. We drop the unused acoustic 15..30 to stay aligned with # the decoder side and shrink the GGUF. encoder_valid = cfg["encoder_valid_num_quantizers"] encoder_acoustic_kept = encoder_valid - cfg["encoder_config"]["num_semantic_quantizers"] # Pair embed_sum and cluster_usage entries by (origin, side, layer) # for fusion at the end of the walk. rvq_buffers = {} with safe_open(str(st_path), framework="pt") as f: all_keys = list(f.keys()) for k in all_keys: t = f.get_tensor(k) arr = t.numpy().astype(np.float32) # Encoder SEANet conv stack if k.startswith("encoder.encoder.layers."): writer.add_tensor(rename_seanet(k), arr) n_added += 1 continue # Encoder Mimi-style transformer if k.startswith("encoder.encoder_transformer."): writer.add_tensor(rename_encoder_transformer(k), arr) n_added += 1 continue # Encoder downsample (final conv k=4 stride=2 between transformer # output and the RVQ) if k.startswith("encoder.downsample."): writer.add_tensor(rename_encoder_downsample(k), arr) n_added += 1 continue # Encoder quantizer if k.startswith("encoder.quantizer."): # input_proj projects 512 -> 256 before the codebook lookup, # output_proj projects 256 -> 512 to reconstruct the residual # during the RVQ encode loop. Both are needed at encode time. if k.endswith(".input_proj.weight") or k.endswith(".output_proj.weight"): writer.add_tensor(rename_encoder_quantizer_proj(k), arr) n_added += 1 continue # Codebook tensors : pair embed_sum and cluster_usage and # emit a single pre-fused codebook tensor (koboldcpp name, # our pre-fusion semantics so the runtime can read F.embedding # straight from disk with no extra division). if "._codebook." in k or ".codebook." in k: parts = k.split(".") side_full = parts[2] if side_full.startswith("semantic"): side = "semantic" elif side_full.startswith("acoustic"): side = "acoustic" else: raise ValueError(f"Unknown encoder quantizer side : {k}") layer_idx = int(parts[4]) field_raw = parts[-1] # Apply truncation : only keep the first encoder_acoustic_kept # acoustic layers ; semantic always has 1 layer. if side == "acoustic" and layer_idx >= encoder_acoustic_kept: n_skipped_encoder_extra_acoustic += 1 continue if field_raw == "initialized": # Boolean flag, not used at runtime continue field = "embedding_sum" if field_raw == "embed_sum" else field_raw rvq_buffers.setdefault(("encoder", side, layer_idx), {})[field] = arr continue raise ValueError(f"Unhandled encoder quantizer tensor : {k}") # Decoder pre-conv if k.startswith("decoder.pre_conv."): writer.add_tensor(rename_pre_conv(k), arr) n_added += 1 continue # Decoder pre-transformer if k.startswith("decoder.pre_transformer."): # Top level pre transformer projections and norm. if k == "decoder.pre_transformer.input_proj.weight": writer.add_tensor("tok_dec.pre_tfm.input_proj.weight", arr) n_added += 1 continue if k == "decoder.pre_transformer.input_proj.bias": writer.add_tensor("tok_dec.pre_tfm.input_proj.bias", arr) n_added += 1 continue if k == "decoder.pre_transformer.output_proj.weight": writer.add_tensor("tok_dec.pre_tfm.output_proj.weight", arr) n_added += 1 continue if k == "decoder.pre_transformer.output_proj.bias": writer.add_tensor("tok_dec.pre_tfm.output_proj.bias", arr) n_added += 1 continue if k == "decoder.pre_transformer.norm.weight": writer.add_tensor("tok_dec.pre_tfm.norm.weight", arr) n_added += 1 continue # Per layer transformer block. if k.startswith("decoder.pre_transformer.layers."): writer.add_tensor(rename_pre_transformer(k), arr) n_added += 1 continue raise ValueError(f"Unhandled decoder.pre_transformer tensor : {k}") # Decoder upsample stage if k.startswith("decoder.upsample."): writer.add_tensor(rename_upsample(k), arr) n_added += 1 continue # Decoder DAC chain (decoder.decoder.{0..6}.*) if k.startswith("decoder.decoder."): writer.add_tensor(rename_decoder_chain(k), arr) n_added += 1 continue # Decoder quantizer side if k.startswith("decoder.quantizer."): # output_proj is needed at decode time if k.endswith(".output_proj.weight"): if "rvq_first" in k: writer.add_tensor("tok_dec.vq_first.output_proj.weight", arr) elif "rvq_rest" in k: writer.add_tensor("tok_dec.vq_rest.output_proj.weight", arr) else: raise ValueError(f"Unknown quantizer output_proj : {k}") n_added += 1 continue # input_proj on the decoder side has the same value as the # encoder side and is unused at decode time : skip rather # than carry a duplicate. if k.endswith(".input_proj.weight"): continue # Codebook tensors : pair embed_sum and cluster_usage and # emit a single pre-fused codebook tensor under the # koboldcpp tok_dec.vq_{first,rest} namespace. if "._codebook." in k: parts = k.split(".") side = parts[2] layer_idx = int(parts[5]) field = parts[-1] rvq_buffers.setdefault(("decoder", side, layer_idx), {})[field] = arr continue raise ValueError(f"Unhandled quantizer tensor : {k}") raise ValueError(f"Unhandled top-level tensor : {k}") # Fuse paired embed_sum and cluster_usage into a single pre-divided # codebook tensor per layer per side. Output names follow koboldcpp. n_codebooks_emitted = 0 for (origin, side, layer_idx), buf in sorted(rvq_buffers.items()): if "cluster_usage" not in buf or "embedding_sum" not in buf: print(f"[Convert] WARNING: incomplete codebook ({origin}, {side}, {layer_idx}): {list(buf.keys())}") continue emb = precompute_codebook(buf["embedding_sum"], buf["cluster_usage"]) if origin == "decoder": # HF source uses rvq_first / rvq_rest, koboldcpp emits vq_first # / vq_rest. Strip the leading "r" so the output matches the # tok_dec.vq_{first,rest}.{layer}.codebook convention used by # the runtime loaders and the koboldcpp HF release. assert side in ("rvq_first", "rvq_rest"), f"Unexpected decoder side : {side}" out_side = side[1:] writer.add_tensor(f"tok_dec.{out_side}.{layer_idx}.codebook", emb) else: writer.add_tensor(f"tok_enc.vq_{side}.{layer_idx}.codebook", emb) n_codebooks_emitted += 1 print(f"[Convert] Tensors: {n_added} written, {n_codebooks_emitted} codebooks fused") print(f"[Convert] Truncate: {n_skipped_encoder_extra_acoustic} encoder acoustic codebook tensors dropped (kept {encoder_acoustic_kept}/{cfg['encoder_config']['num_quantizers'] - cfg['encoder_config']['num_semantic_quantizers']})") writer.write_header_to_file() writer.write_kv_data_to_file() writer.write_tensors_to_file() writer.close() print(f"[Convert] Wrote {out_path}") return 0 def rename_talker_layer(name: str) -> str: # talker.model.layers.{i}.input_layernorm.weight -> talker.blk.{i}.attn_norm.weight # talker.model.layers.{i}.post_attention_layernorm.weight -> talker.blk.{i}.ffn_norm.weight # talker.model.layers.{i}.self_attn.{q,k,v,o}_proj.weight -> talker.blk.{i}.attn_{q,k,v,output}.weight # talker.model.layers.{i}.self_attn.{q,k}_norm.weight -> talker.blk.{i}.attn_{q,k}_norm.weight # talker.model.layers.{i}.mlp.{gate,up,down}_proj.weight -> talker.blk.{i}.ffn_{gate,up,down}.weight assert name.startswith("talker.model.layers.") return _xlate_lm_layer(name, prefix="talker.model.layers.", out_prefix="talker.blk") def rename_code_predictor_layer(name: str) -> str: # talker.code_predictor.model.layers.{i}. -> code_pred.blk.{i}. assert name.startswith("talker.code_predictor.model.layers.") return _xlate_lm_layer(name, prefix="talker.code_predictor.model.layers.", out_prefix="code_pred.blk") def rename_text_projection(name: str) -> str: # talker.text_projection.linear_fc{1,2}.{weight,bias} -> talker.text_proj.fc{1,2}.{weight,bias} assert name.startswith("talker.text_projection.") return name.replace("talker.text_projection.linear_fc", "talker.text_proj.fc") # Common HF -> llama.cpp suffix table for the Qwen3 backbone, shared between # the Talker and the Code Predictor since both follow the same architecture. _LM_LAYER_SUFFIX = { "input_layernorm.weight": "attn_norm.weight", "post_attention_layernorm.weight": "ffn_norm.weight", "self_attn.q_proj.weight": "attn_q.weight", "self_attn.k_proj.weight": "attn_k.weight", "self_attn.v_proj.weight": "attn_v.weight", "self_attn.o_proj.weight": "attn_output.weight", "self_attn.q_norm.weight": "attn_q_norm.weight", "self_attn.k_norm.weight": "attn_k_norm.weight", "mlp.gate_proj.weight": "ffn_gate.weight", "mlp.up_proj.weight": "ffn_up.weight", "mlp.down_proj.weight": "ffn_down.weight", } def _xlate_lm_layer(name: str, prefix: str, out_prefix: str) -> str: rest = name[len(prefix):] dot = rest.find(".") layer_idx = rest[:dot] suffix = rest[dot + 1:] new_suffix = _LM_LAYER_SUFFIX.get(suffix) if new_suffix is None: raise ValueError(f"Unhandled LM layer suffix : {suffix} (full name : {name})") return f"{out_prefix}.{layer_idx}.{new_suffix}" def load_bpe_vocab(checkpoint_dir: Path): # Load Qwen2 BPE vocab + merges from a HF checkpoint directory and # produce the (tokens, token_types, merges) triple expected by the # GGUF tokenizer convention. Special tokens listed in # tokenizer_config.json:added_tokens_decoder are tagged as user-defined # (token_type 4) so the runtime can recognise them as verbatim chunks. vocab = json.loads((checkpoint_dir / "vocab.json").read_text()) tok_cfg = json.loads((checkpoint_dir / "tokenizer_config.json").read_text()) added = tok_cfg.get("added_tokens_decoder", {}) max_id = max(vocab.values()) for sid in added.keys(): max_id = max(max_id, int(sid)) tokens = [None] * (max_id + 1) token_types = [1] * (max_id + 1) # 1 = normal for tok, tid in vocab.items(): tokens[tid] = tok for sid_str, info in added.items(): sid = int(sid_str) tokens[sid] = info["content"] token_types[sid] = 4 # 4 = user-defined / special # Empty slots (gaps in id space) get a placeholder so the GGUF array # has no None entries. for i, tok in enumerate(tokens): if tok is None: tokens[i] = f"<|unused-{i}|>" token_types[i] = 5 # 5 = unused # merges.txt : first line may be a "#version" comment, skip it. merges_lines = (checkpoint_dir / "merges.txt").read_text().splitlines() merges = [ln for ln in merges_lines if ln and not ln.startswith("#")] return tokens, token_types, merges def convert_talker_base(checkpoint_dir: Path, out_path: Path, model_size: str) -> int: cfg_path = checkpoint_dir / "config.json" st_path = checkpoint_dir / "model.safetensors" gen_path = checkpoint_dir / "generation_config.json" if not cfg_path.is_file() or not st_path.is_file(): print(f"[Convert] FATAL: missing checkpoint files in {checkpoint_dir}") return 1 cfg = json.loads(cfg_path.read_text()) talker_cfg = cfg["talker_config"] cp_cfg = talker_cfg["code_predictor_config"] spk_cfg = cfg.get("speaker_encoder_config") gen_cfg = json.loads(gen_path.read_text()) if gen_path.is_file() else {} arch = "qwen3-tts" writer = gguf.GGUFWriter(str(out_path), arch) writer.add_string("general.name", f"Qwen3-TTS-12Hz-{model_size}-{cfg['tts_model_type']}") # Top-level TTS metadata writer.add_string("qwen3-tts.tokenizer_type", cfg["tokenizer_type"]) writer.add_string("qwen3-tts.model_size", cfg["tts_model_size"]) writer.add_string("qwen3-tts.model_type", cfg["tts_model_type"]) writer.add_uint32("qwen3-tts.num_code_groups", talker_cfg["num_code_groups"]) # Talker LM hyperparameters writer.add_uint32("qwen3-tts.talker.embedding_length", talker_cfg["hidden_size"]) writer.add_uint32("qwen3-tts.talker.feed_forward_length", talker_cfg["intermediate_size"]) writer.add_uint32("qwen3-tts.talker.block_count", talker_cfg["num_hidden_layers"]) writer.add_uint32("qwen3-tts.talker.attention.head_count", talker_cfg["num_attention_heads"]) writer.add_uint32("qwen3-tts.talker.attention.head_count_kv", talker_cfg["num_key_value_heads"]) writer.add_uint32("qwen3-tts.talker.attention.key_length", talker_cfg["head_dim"]) writer.add_uint32("qwen3-tts.talker.vocab_size", talker_cfg["vocab_size"]) writer.add_uint32("qwen3-tts.talker.text_vocab_size", talker_cfg["text_vocab_size"]) writer.add_uint32("qwen3-tts.talker.text_hidden_size", talker_cfg["text_hidden_size"]) writer.add_uint32("qwen3-tts.talker.context_length", talker_cfg["max_position_embeddings"]) writer.add_float32("qwen3-tts.talker.rope.freq_base", float(talker_cfg["rope_theta"])) writer.add_float32("qwen3-tts.talker.attention.layer_norm_rms_epsilon", float(talker_cfg["rms_norm_eps"])) writer.add_uint32("qwen3-tts.talker.position_id_per_seconds", talker_cfg["position_id_per_seconds"]) rope_scaling = talker_cfg.get("rope_scaling") or {} if "mrope_section" in rope_scaling: writer.add_array("qwen3-tts.talker.rope.mrope_section", rope_scaling["mrope_section"]) writer.add_bool("qwen3-tts.talker.mrope_interleaved", bool(rope_scaling.get("interleaved", False))) # Code predictor (subtalker) hyperparameters writer.add_uint32("qwen3-tts.code_pred.embedding_length", cp_cfg["hidden_size"]) writer.add_uint32("qwen3-tts.code_pred.feed_forward_length", cp_cfg["intermediate_size"]) writer.add_uint32("qwen3-tts.code_pred.block_count", cp_cfg["num_hidden_layers"]) writer.add_uint32("qwen3-tts.code_pred.attention.head_count", cp_cfg["num_attention_heads"]) writer.add_uint32("qwen3-tts.code_pred.attention.head_count_kv", cp_cfg["num_key_value_heads"]) writer.add_uint32("qwen3-tts.code_pred.attention.key_length", cp_cfg["head_dim"]) writer.add_uint32("qwen3-tts.code_pred.vocab_size", cp_cfg["vocab_size"]) writer.add_uint32("qwen3-tts.code_pred.context_length", cp_cfg["max_position_embeddings"]) writer.add_float32("qwen3-tts.code_pred.rope.freq_base", float(cp_cfg["rope_theta"])) writer.add_float32("qwen3-tts.code_pred.attention.layer_norm_rms_epsilon", float(cp_cfg["rms_norm_eps"])) # Speaker encoder hyperparameters (Base checkpoints only). CustomVoice # and VoiceDesign carry no speaker encoder so the keys are skipped # entirely, the runtime detects the absence via tensor lookup. if spk_cfg is not None: writer.add_uint32("qwen3-tts.spk_enc.embedding_length", spk_cfg["enc_dim"]) writer.add_uint32("qwen3-tts.spk_enc.sample_rate", spk_cfg["sample_rate"]) # Codec stream special tokens writer.add_uint32("qwen3-tts.codec.pad_id", talker_cfg["codec_pad_id"]) writer.add_uint32("qwen3-tts.codec.bos_id", talker_cfg["codec_bos_id"]) writer.add_uint32("qwen3-tts.codec.eos_id", talker_cfg["codec_eos_token_id"]) writer.add_uint32("qwen3-tts.codec.think_id", talker_cfg["codec_think_id"]) writer.add_uint32("qwen3-tts.codec.nothink_id", talker_cfg["codec_nothink_id"]) writer.add_uint32("qwen3-tts.codec.think_bos_id", talker_cfg["codec_think_bos_id"]) writer.add_uint32("qwen3-tts.codec.think_eos_id", talker_cfg["codec_think_eos_id"]) # Language id table flattened to two parallel arrays. Names stay as in # the upstream config so the runtime can pass --lang chinese verbatim. lang_map = talker_cfg.get("codec_language_id") or {} lang_names = list(lang_map.keys()) lang_ids = [int(lang_map[k]) for k in lang_names] writer.add_array("qwen3-tts.codec.language_names", lang_names) writer.add_array("qwen3-tts.codec.language_ids", lang_ids) # Speaker table for CustomVoice variants. Three parallel arrays indexed # by speaker position : name, codec embedding id, and optional dialect # name pulled from codec_language_id. Empty dialect string means the # speaker keeps the user supplied language. Skipped entirely for Base # and VoiceDesign which have no spk_id map. spk_map = talker_cfg.get("spk_id") or {} if spk_map: dialect_map = talker_cfg.get("spk_is_dialect") or {} spk_names = list(spk_map.keys()) spk_ids = [int(spk_map[k]) for k in spk_names] spk_dialects = [dialect_map.get(k) or "" for k in spk_names] spk_dialects = [d if isinstance(d, str) else "" for d in spk_dialects] writer.add_array("qwen3-tts.codec.speaker_names", spk_names) writer.add_array("qwen3-tts.codec.speaker_ids", spk_ids) writer.add_array("qwen3-tts.codec.speaker_dialects", spk_dialects) # Text-side special tokens (Qwen2 BPE) writer.add_uint32("qwen3-tts.text.im_start_id", cfg["im_start_token_id"]) writer.add_uint32("qwen3-tts.text.im_end_id", cfg["im_end_token_id"]) writer.add_uint32("qwen3-tts.text.tts_pad_id", cfg["tts_pad_token_id"]) writer.add_uint32("qwen3-tts.text.tts_bos_id", cfg["tts_bos_token_id"]) writer.add_uint32("qwen3-tts.text.tts_eos_id", cfg["tts_eos_token_id"]) # Default sampling parameters from generation_config.json if gen_cfg: if "do_sample" in gen_cfg: writer.add_bool("generation.do_sample", bool(gen_cfg["do_sample"])) if "top_k" in gen_cfg: writer.add_uint32("generation.top_k", int(gen_cfg["top_k"])) if "top_p" in gen_cfg: writer.add_float32("generation.top_p", float(gen_cfg["top_p"])) if "temperature" in gen_cfg: writer.add_float32("generation.temperature", float(gen_cfg["temperature"])) if "repetition_penalty" in gen_cfg: writer.add_float32("generation.repetition_penalty", float(gen_cfg["repetition_penalty"])) if "subtalker_dosample" in gen_cfg: writer.add_bool("generation.subtalker_do_sample", bool(gen_cfg["subtalker_dosample"])) if "subtalker_top_k" in gen_cfg: writer.add_uint32("generation.subtalker_top_k", int(gen_cfg["subtalker_top_k"])) if "subtalker_top_p" in gen_cfg: writer.add_float32("generation.subtalker_top_p", float(gen_cfg["subtalker_top_p"])) if "subtalker_temperature" in gen_cfg: writer.add_float32("generation.subtalker_temperature", float(gen_cfg["subtalker_temperature"])) if "max_new_tokens" in gen_cfg: writer.add_uint32("generation.max_new_tokens", int(gen_cfg["max_new_tokens"])) # BPE tokenizer payload bpe_tokens, bpe_token_types, bpe_merges = load_bpe_vocab(checkpoint_dir) writer.add_string("tokenizer.ggml.model", "gpt2") writer.add_array("tokenizer.ggml.tokens", bpe_tokens) writer.add_array("tokenizer.ggml.token_type", bpe_token_types) writer.add_array("tokenizer.ggml.merges", bpe_merges) writer.add_uint32("tokenizer.ggml.eos_token_id", 151643) # <|endoftext|> # Top level talker tensors. Renames mirror the koboldcpp TENSOR_MAP. TALKER_TOP = { "talker.model.codec_embedding.weight": "talker.codec_embd.weight", "talker.model.text_embedding.weight": "talker.text_embd.weight", "talker.model.norm.weight": "talker.output_norm.weight", "talker.codec_head.weight": "talker.codec_head.weight", "talker.text_projection.linear_fc1.weight": "talker.text_proj.fc1.weight", "talker.text_projection.linear_fc1.bias": "talker.text_proj.fc1.bias", "talker.text_projection.linear_fc2.weight": "talker.text_proj.fc2.weight", "talker.text_projection.linear_fc2.bias": "talker.text_proj.fc2.bias", } # Top level code predictor tensors. CP_TOP = { "talker.code_predictor.model.norm.weight": "code_pred.output_norm.weight", "talker.code_predictor.small_to_mtp_projection.weight": "code_pred.mtp_proj.weight", "talker.code_predictor.small_to_mtp_projection.bias": "code_pred.mtp_proj.bias", } # Speaker encoder rename table, koboldcpp SPEAKER_ENCODER_PATTERNS plus # standalone tensors. block 0 is the entry conv, blocks 1 to 3 hold the # Res2Net + SE + TDNN stack. SPK_TOP = { "speaker_encoder.blocks.0.conv.weight": "spk_enc.conv0.weight", "speaker_encoder.blocks.0.conv.bias": "spk_enc.conv0.bias", "speaker_encoder.asp.conv.weight": "spk_enc.asp.conv.weight", "speaker_encoder.asp.conv.bias": "spk_enc.asp.conv.bias", "speaker_encoder.asp.tdnn.conv.weight": "spk_enc.asp.tdnn.weight", "speaker_encoder.asp.tdnn.conv.bias": "spk_enc.asp.tdnn.bias", "speaker_encoder.mfa.conv.weight": "spk_enc.mfa.weight", "speaker_encoder.mfa.conv.bias": "spk_enc.mfa.bias", "speaker_encoder.fc.weight": "spk_enc.fc.weight", "speaker_encoder.fc.bias": "spk_enc.fc.bias", } def rename_speaker_encoder_block(k: str) -> str: # speaker_encoder.blocks.{i}.res2net_block.blocks.{j}.conv.{weight,bias} -> spk_enc.blk.{i}.res2net.{j}.{weight,bias} # speaker_encoder.blocks.{i}.se_block.conv{1,2}.{weight,bias} -> spk_enc.blk.{i}.se.conv{1,2}.{weight,bias} # speaker_encoder.blocks.{i}.tdnn{1,2}.conv.{weight,bias} -> spk_enc.blk.{i}.tdnn{1,2}.{weight,bias} parts = k.split(".") idx = parts[2] if parts[3] == "res2net_block": sub = parts[5] suffix = parts[-1] return f"spk_enc.blk.{idx}.res2net.{sub}.{suffix}" if parts[3] == "se_block": which = parts[4] # conv1 or conv2 suffix = parts[-1] return f"spk_enc.blk.{idx}.se.{which}.{suffix}" if parts[3] in ("tdnn1", "tdnn2"): return f"spk_enc.blk.{idx}.{parts[3]}.{parts[-1]}" raise ValueError(f"Unhandled speaker encoder block tensor : {k}") # Walk safetensors n_added = 0 n_unhandled = 0 with safe_open(str(st_path), framework="pt") as f: all_keys = sorted(list(f.keys())) for k in all_keys: t = f.get_tensor(k) arr = t.float().numpy() # Talker transformer layers if k.startswith("talker.model.layers."): writer.add_tensor(rename_talker_layer(k), arr) n_added += 1 continue # Talker top level (codec_embedding, text_embedding, norm, # codec_head, text_projection) if k in TALKER_TOP: writer.add_tensor(TALKER_TOP[k], arr) n_added += 1 continue # Code predictor transformer layers if k.startswith("talker.code_predictor.model.layers."): writer.add_tensor(rename_code_predictor_layer(k), arr) n_added += 1 continue # Code predictor codec embeddings (one per acoustic codebook) if k.startswith("talker.code_predictor.model.codec_embedding."): idx = k.split(".")[4] writer.add_tensor(f"code_pred.codec_embd.{idx}.weight", arr) n_added += 1 continue # Code predictor lm heads (one per acoustic codebook) if k.startswith("talker.code_predictor.lm_head."): idx = k.split(".")[3] writer.add_tensor(f"code_pred.lm_head.{idx}.weight", arr) n_added += 1 continue # Code predictor top level (final norm, MTP projection) if k in CP_TOP: writer.add_tensor(CP_TOP[k], arr) n_added += 1 continue # Speaker encoder, only present in Base checkpoints if k in SPK_TOP: writer.add_tensor(SPK_TOP[k], arr) n_added += 1 continue if k.startswith("speaker_encoder.blocks.") and not k.startswith("speaker_encoder.blocks.0."): writer.add_tensor(rename_speaker_encoder_block(k), arr) n_added += 1 continue print(f"[Convert] WARNING: unhandled tensor : {k} shape={tuple(t.shape)}") n_unhandled += 1 print(f"[Convert] Tensors: {n_added} written, {n_unhandled} unhandled") print(f"[Convert] BPE: {len(bpe_tokens)} tokens, {len(bpe_merges)} merges") writer.write_header_to_file() writer.write_kv_data_to_file() writer.write_tensors_to_file() writer.close() print(f"[Convert] Wrote {out_path}") return 0 CHECKPOINT_DIR = "checkpoints" OUTPUT_DIR = "models" # Talker checkpoints follow the upstream pattern Qwen3-TTS-12Hz-{size}-{kind} # where size is 0.6B or 1.7B and kind is Base, CustomVoice or VoiceDesign. # The compiled regex captures both groups for the GGUF filename suffix and # the model_size argument fed to convert_talker_base. TALKER_RE = re.compile(r"^Qwen3-TTS-12Hz-([0-9.]+B)-(\w+)$") def classify(dir_name: str): """Return (kind, model_size) for a known checkpoint directory or None. kind is 'tokenizer' or 'talker'. model_size is '0.6B' / '1.7B' for talker, None for tokenizer.""" if "Tokenizer" in dir_name: return ("tokenizer", None) m = TALKER_RE.match(dir_name) if m: return ("talker", m.group(1)) return None def output_path_for(out_dir: Path, kind: str, dir_name: str) -> Path: """Map a checkpoint directory name to its F32 GGUF output path.""" if kind == "tokenizer": return out_dir / "qwen-tokenizer-12hz-F32.gguf" m = TALKER_RE.match(dir_name) short = f"{m.group(1).lower()}-{m.group(2).lower()}" return out_dir / f"qwen-talker-{short}-F32.gguf" def main() -> int: ckpt_root = Path(CHECKPOINT_DIR) out_dir = Path(OUTPUT_DIR) if not ckpt_root.is_dir(): print(f"[Convert] FATAL: {ckpt_root}/ not found") return 1 out_dir.mkdir(parents=True, exist_ok=True) converted = 0 skipped_unknown: list[str] = [] rc = 0 for name in sorted(os.listdir(ckpt_root)): ckpt = ckpt_root / name if not ckpt.is_dir(): continue info = classify(name) if info is None: skipped_unknown.append(name) continue kind, model_size = info out = output_path_for(out_dir, kind, name) if out.exists(): print(f"[Convert] skip {out.name}: exists") converted += 1 continue if kind == "tokenizer": rc |= convert_tokenizer_12hz(ckpt, out) else: rc |= convert_talker_base(ckpt, out, model_size) converted += 1 if skipped_unknown: print(f"[Convert] skipped (unknown): {', '.join(skipped_unknown)}") print(f"[Convert] done : {converted} model(s) in {out_dir}") return rc if __name__ == "__main__": sys.exit(main())