original qwen3tts files

This commit is contained in:
Concedo
2026-03-13 15:24:18 +08:00
parent 4427bab37e
commit 0a38237ff5
2 changed files with 1049 additions and 0 deletions
@@ -0,0 +1,480 @@
#!/usr/bin/env python3
"""
Convert Qwen3-TTS-Tokenizer-12Hz model to GGUF format.
Usage:
python scripts/convert_tokenizer_to_gguf.py \
--input models/Qwen3-TTS-Tokenizer-12Hz \
--output models/qwen3-tts-tokenizer-f16.gguf \
--type f16
"""
from __future__ import annotations
import argparse
import json
import logging
import re
import sys
from pathlib import Path
from typing import Any, Iterator
import numpy as np
import torch
from safetensors import safe_open
from tqdm import tqdm
# Add gguf-py to path (if available)
GGUF_PY_PATH = Path(__file__).resolve().parents[1] / "gguf-py"
try:
if GGUF_PY_PATH.exists():
sys.path.insert(0, str(GGUF_PY_PATH))
except (PermissionError, OSError):
pass
import gguf
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger(__name__)
class Qwen3TTSTokenizerConverter:
"""Converter for Qwen3-TTS-Tokenizer-12Hz model to GGUF format."""
# Direct tensor name mappings
TENSOR_MAP = {
# Encoder - downsample conv
"encoder.downsample.conv.weight": "tok_enc.downsample.weight",
# Encoder quantizer projections
"encoder.quantizer.acoustic_residual_vector_quantizer.input_proj.weight": "tok_enc.vq_acoustic.input_proj.weight",
"encoder.quantizer.acoustic_residual_vector_quantizer.output_proj.weight": "tok_enc.vq_acoustic.output_proj.weight",
"encoder.quantizer.semantic_residual_vector_quantizer.input_proj.weight": "tok_enc.vq_semantic.input_proj.weight",
"encoder.quantizer.semantic_residual_vector_quantizer.output_proj.weight": "tok_enc.vq_semantic.output_proj.weight",
# Decoder pre-conv and output
"decoder.pre_conv.conv.bias": "tok_dec.pre_conv.bias",
"decoder.pre_conv.conv.weight": "tok_dec.pre_conv.weight",
# Decoder pre-transformer projections
"decoder.pre_transformer.input_proj.bias": "tok_dec.pre_tfm.input_proj.bias",
"decoder.pre_transformer.input_proj.weight": "tok_dec.pre_tfm.input_proj.weight",
"decoder.pre_transformer.output_proj.bias": "tok_dec.pre_tfm.output_proj.bias",
"decoder.pre_transformer.output_proj.weight": "tok_dec.pre_tfm.output_proj.weight",
"decoder.pre_transformer.norm.weight": "tok_dec.pre_tfm.norm.weight",
# Decoder quantizer projections
"decoder.quantizer.rvq_first.input_proj.weight": "tok_dec.vq_first.input_proj.weight",
"decoder.quantizer.rvq_first.output_proj.weight": "tok_dec.vq_first.output_proj.weight",
"decoder.quantizer.rvq_rest.input_proj.weight": "tok_dec.vq_rest.input_proj.weight",
"decoder.quantizer.rvq_rest.output_proj.weight": "tok_dec.vq_rest.output_proj.weight",
# Decoder initial conv (index 0)
"decoder.decoder.0.conv.weight": "tok_dec.dec.0.conv.weight",
"decoder.decoder.0.conv.bias": "tok_dec.dec.0.conv.bias",
# Decoder final snake activation (index 5) and output conv (index 6)
"decoder.decoder.5.alpha": "tok_dec.dec.5.snake.alpha",
"decoder.decoder.5.beta": "tok_dec.dec.5.snake.beta",
"decoder.decoder.6.conv.weight": "tok_dec.dec.6.conv.weight",
"decoder.decoder.6.conv.bias": "tok_dec.dec.6.conv.bias",
}
# Regex patterns for layer-specific tensors
ENCODER_PATTERNS = [
# Encoder conv layers (various indices)
(r"encoder\.encoder\.layers\.(\d+)\.conv\.weight", "tok_enc.conv.{}.weight"),
(r"encoder\.encoder\.layers\.(\d+)\.conv\.bias", "tok_enc.conv.{}.bias"),
# Encoder residual blocks
(r"encoder\.encoder\.layers\.(\d+)\.block\.(\d+)\.conv\.weight", "tok_enc.res.{}.blk.{}.weight"),
(r"encoder\.encoder\.layers\.(\d+)\.block\.(\d+)\.conv\.bias", "tok_enc.res.{}.blk.{}.bias"),
# Encoder transformer layers
(r"encoder\.encoder_transformer\.layers\.(\d+)\.input_layernorm\.weight", "tok_enc.blk.{}.attn_norm.weight"),
(r"encoder\.encoder_transformer\.layers\.(\d+)\.input_layernorm\.bias", "tok_enc.blk.{}.attn_norm.bias"),
(r"encoder\.encoder_transformer\.layers\.(\d+)\.post_attention_layernorm\.weight", "tok_enc.blk.{}.ffn_norm.weight"),
(r"encoder\.encoder_transformer\.layers\.(\d+)\.post_attention_layernorm\.bias", "tok_enc.blk.{}.ffn_norm.bias"),
(r"encoder\.encoder_transformer\.layers\.(\d+)\.self_attn\.q_proj\.weight", "tok_enc.blk.{}.attn_q.weight"),
(r"encoder\.encoder_transformer\.layers\.(\d+)\.self_attn\.k_proj\.weight", "tok_enc.blk.{}.attn_k.weight"),
(r"encoder\.encoder_transformer\.layers\.(\d+)\.self_attn\.v_proj\.weight", "tok_enc.blk.{}.attn_v.weight"),
(r"encoder\.encoder_transformer\.layers\.(\d+)\.self_attn\.o_proj\.weight", "tok_enc.blk.{}.attn_output.weight"),
(r"encoder\.encoder_transformer\.layers\.(\d+)\.self_attn_layer_scale\.scale", "tok_enc.blk.{}.attn_scale"),
(r"encoder\.encoder_transformer\.layers\.(\d+)\.mlp\.fc1\.weight", "tok_enc.blk.{}.ffn_up.weight"),
(r"encoder\.encoder_transformer\.layers\.(\d+)\.mlp\.fc2\.weight", "tok_enc.blk.{}.ffn_down.weight"),
(r"encoder\.encoder_transformer\.layers\.(\d+)\.mlp_layer_scale\.scale", "tok_enc.blk.{}.ffn_scale"),
# Encoder acoustic quantizer codebooks (embed_sum is the actual codebook)
(r"encoder\.quantizer\.acoustic_residual_vector_quantizer\.layers\.(\d+)\.codebook\.embed_sum", "tok_enc.vq_acoustic.{}.codebook"),
(r"encoder\.quantizer\.acoustic_residual_vector_quantizer\.layers\.(\d+)\.codebook\.cluster_usage", "tok_enc.vq_acoustic.{}.usage"),
(r"encoder\.quantizer\.acoustic_residual_vector_quantizer\.layers\.(\d+)\.codebook\.initialized", "tok_enc.vq_acoustic.{}.initialized"),
# Encoder semantic quantizer codebooks
(r"encoder\.quantizer\.semantic_residual_vector_quantizer\.layers\.(\d+)\.codebook\.embed_sum", "tok_enc.vq_semantic.{}.codebook"),
(r"encoder\.quantizer\.semantic_residual_vector_quantizer\.layers\.(\d+)\.codebook\.cluster_usage", "tok_enc.vq_semantic.{}.usage"),
(r"encoder\.quantizer\.semantic_residual_vector_quantizer\.layers\.(\d+)\.codebook\.initialized", "tok_enc.vq_semantic.{}.initialized"),
]
DECODER_PATTERNS = [
# Decoder blocks (1-4 have residual structure)
(r"decoder\.decoder\.(\d+)\.block\.0\.alpha", "tok_dec.dec.{}.snake.alpha"),
(r"decoder\.decoder\.(\d+)\.block\.0\.beta", "tok_dec.dec.{}.snake.beta"),
(r"decoder\.decoder\.(\d+)\.block\.1\.conv\.weight", "tok_dec.dec.{}.conv_t.weight"),
(r"decoder\.decoder\.(\d+)\.block\.1\.conv\.bias", "tok_dec.dec.{}.conv_t.bias"),
# Decoder residual blocks within each decoder block
(r"decoder\.decoder\.(\d+)\.block\.(\d+)\.act1\.alpha", "tok_dec.dec.{}.res.{}.act1.alpha"),
(r"decoder\.decoder\.(\d+)\.block\.(\d+)\.act1\.beta", "tok_dec.dec.{}.res.{}.act1.beta"),
(r"decoder\.decoder\.(\d+)\.block\.(\d+)\.act2\.alpha", "tok_dec.dec.{}.res.{}.act2.alpha"),
(r"decoder\.decoder\.(\d+)\.block\.(\d+)\.act2\.beta", "tok_dec.dec.{}.res.{}.act2.beta"),
(r"decoder\.decoder\.(\d+)\.block\.(\d+)\.conv1\.conv\.weight", "tok_dec.dec.{}.res.{}.conv1.weight"),
(r"decoder\.decoder\.(\d+)\.block\.(\d+)\.conv1\.conv\.bias", "tok_dec.dec.{}.res.{}.conv1.bias"),
(r"decoder\.decoder\.(\d+)\.block\.(\d+)\.conv2\.conv\.weight", "tok_dec.dec.{}.res.{}.conv2.weight"),
(r"decoder\.decoder\.(\d+)\.block\.(\d+)\.conv2\.conv\.bias", "tok_dec.dec.{}.res.{}.conv2.bias"),
# Decoder pre-transformer layers
(r"decoder\.pre_transformer\.layers\.(\d+)\.input_layernorm\.weight", "tok_dec.pre_tfm.blk.{}.attn_norm.weight"),
(r"decoder\.pre_transformer\.layers\.(\d+)\.post_attention_layernorm\.weight", "tok_dec.pre_tfm.blk.{}.ffn_norm.weight"),
(r"decoder\.pre_transformer\.layers\.(\d+)\.self_attn\.q_proj\.weight", "tok_dec.pre_tfm.blk.{}.attn_q.weight"),
(r"decoder\.pre_transformer\.layers\.(\d+)\.self_attn\.k_proj\.weight", "tok_dec.pre_tfm.blk.{}.attn_k.weight"),
(r"decoder\.pre_transformer\.layers\.(\d+)\.self_attn\.v_proj\.weight", "tok_dec.pre_tfm.blk.{}.attn_v.weight"),
(r"decoder\.pre_transformer\.layers\.(\d+)\.self_attn\.o_proj\.weight", "tok_dec.pre_tfm.blk.{}.attn_output.weight"),
(r"decoder\.pre_transformer\.layers\.(\d+)\.self_attn_layer_scale\.scale", "tok_dec.pre_tfm.blk.{}.attn_scale"),
(r"decoder\.pre_transformer\.layers\.(\d+)\.mlp\.gate_proj\.weight", "tok_dec.pre_tfm.blk.{}.ffn_gate.weight"),
(r"decoder\.pre_transformer\.layers\.(\d+)\.mlp\.up_proj\.weight", "tok_dec.pre_tfm.blk.{}.ffn_up.weight"),
(r"decoder\.pre_transformer\.layers\.(\d+)\.mlp\.down_proj\.weight", "tok_dec.pre_tfm.blk.{}.ffn_down.weight"),
(r"decoder\.pre_transformer\.layers\.(\d+)\.mlp_layer_scale\.scale", "tok_dec.pre_tfm.blk.{}.ffn_scale"),
# Decoder quantizer codebooks (rvq_first and rvq_rest)
(r"decoder\.quantizer\.rvq_first\.vq\.layers\.(\d+)\._codebook\.embedding_sum", "tok_dec.vq_first.{}.codebook"),
(r"decoder\.quantizer\.rvq_first\.vq\.layers\.(\d+)\._codebook\.cluster_usage", "tok_dec.vq_first.{}.usage"),
(r"decoder\.quantizer\.rvq_rest\.vq\.layers\.(\d+)\._codebook\.embedding_sum", "tok_dec.vq_rest.{}.codebook"),
(r"decoder\.quantizer\.rvq_rest\.vq\.layers\.(\d+)\._codebook\.cluster_usage", "tok_dec.vq_rest.{}.usage"),
# Decoder upsample layers
(r"decoder\.upsample\.(\d+)\.0\.conv\.weight", "tok_dec.upsample.{}.conv.weight"),
(r"decoder\.upsample\.(\d+)\.0\.conv\.bias", "tok_dec.upsample.{}.conv.bias"),
(r"decoder\.upsample\.(\d+)\.1\.dwconv\.conv\.weight", "tok_dec.upsample.{}.dwconv.weight"),
(r"decoder\.upsample\.(\d+)\.1\.dwconv\.conv\.bias", "tok_dec.upsample.{}.dwconv.bias"),
(r"decoder\.upsample\.(\d+)\.1\.gamma", "tok_dec.upsample.{}.gamma"),
(r"decoder\.upsample\.(\d+)\.1\.norm\.weight", "tok_dec.upsample.{}.norm.weight"),
(r"decoder\.upsample\.(\d+)\.1\.norm\.bias", "tok_dec.upsample.{}.norm.bias"),
(r"decoder\.upsample\.(\d+)\.1\.pwconv1\.weight", "tok_dec.upsample.{}.pwconv1.weight"),
(r"decoder\.upsample\.(\d+)\.1\.pwconv1\.bias", "tok_dec.upsample.{}.pwconv1.bias"),
(r"decoder\.upsample\.(\d+)\.1\.pwconv2\.weight", "tok_dec.upsample.{}.pwconv2.weight"),
(r"decoder\.upsample\.(\d+)\.1\.pwconv2\.bias", "tok_dec.upsample.{}.pwconv2.bias"),
]
def __init__(
self,
input_dir: Path,
output_path: Path,
output_type: str = "f16",
):
self.input_dir = input_dir
self.output_path = output_path
self.output_type = output_type
# Load config
self.config = self._load_config()
self._extract_params()
def _load_config(self) -> dict[str, Any]:
"""Load model configuration from config.json."""
config_path = self.input_dir / "config.json"
if not config_path.exists():
raise FileNotFoundError(f"Config file not found: {config_path}")
with open(config_path, "r", encoding="utf-8") as f:
return json.load(f)
def _extract_params(self) -> None:
"""Extract model parameters from config."""
encoder_config = self.config.get("encoder_config", {})
decoder_config = self.config.get("decoder_config", {})
# Encoder parameters
self.encoder_hidden_size = encoder_config.get("hidden_size", 512)
self.encoder_num_layers = encoder_config.get("num_hidden_layers", 8)
self.encoder_num_heads = encoder_config.get("num_attention_heads", 8)
self.encoder_num_quantizers = encoder_config.get("num_quantizers", 32)
self.encoder_valid_quantizers = self.config.get("encoder_valid_num_quantizers", 16)
self.encoder_codebook_size = encoder_config.get("codebook_size", 2048)
self.encoder_codebook_dim = encoder_config.get("codebook_dim", 256)
# Decoder parameters
self.decoder_hidden_size = decoder_config.get("hidden_size", 512)
self.decoder_num_layers = decoder_config.get("num_hidden_layers", 8)
self.decoder_num_heads = decoder_config.get("num_attention_heads", 16)
self.decoder_latent_dim = decoder_config.get("latent_dim", 1024)
self.decoder_codebook_size = decoder_config.get("codebook_size", 2048)
self.decoder_codebook_dim = decoder_config.get("codebook_dim", 512)
self.decoder_num_quantizers = decoder_config.get("num_quantizers", 16)
self.decoder_semantic_codebook_size = decoder_config.get("semantic_codebook_size", 4096)
# Audio parameters
self.sample_rate = self.config.get("input_sample_rate", 24000)
self.frame_rate = encoder_config.get("_frame_rate", 12.5)
self.upsample_rates = decoder_config.get("upsample_rates", [8, 5, 4, 3])
self.model_name = "Qwen3-TTS-Tokenizer-12Hz"
def _map_tensor_name(self, hf_name: str) -> str | None:
"""Map HuggingFace tensor name to GGML convention."""
# Check direct mapping first
if hf_name in self.TENSOR_MAP:
return self.TENSOR_MAP[hf_name]
# Check encoder patterns
for pattern, template in self.ENCODER_PATTERNS:
match = re.match(pattern, hf_name)
if match:
groups = match.groups()
if len(groups) == 1:
return template.format(groups[0])
elif len(groups) == 2:
return template.format(groups[0], groups[1])
return None
# Check decoder patterns
for pattern, template in self.DECODER_PATTERNS:
match = re.match(pattern, hf_name)
if match:
groups = match.groups()
if len(groups) == 1:
return template.format(groups[0])
elif len(groups) == 2:
return template.format(groups[0], groups[1])
return None
return None
def _get_tensors(self) -> Iterator[tuple[str, torch.Tensor]]:
"""Iterate over all tensors from safetensors files."""
tokenizer_dir = self.input_dir / "speech_tokenizer"
if tokenizer_dir.exists():
safetensor_files = list(tokenizer_dir.glob("*.safetensors"))
else:
safetensor_files = list(self.input_dir.glob("*.safetensors"))
if not safetensor_files:
raise FileNotFoundError(f"No safetensors files found in {self.input_dir} or {tokenizer_dir}")
for sf_path in sorted(safetensor_files):
logger.info(f"Loading tensors from {sf_path.name}")
with safe_open(sf_path, framework="pt", device="cpu") as f:
for name in f.keys():
yield name, f.get_tensor(name)
def _convert_dtype(self, tensor: torch.Tensor, tensor_name: str = "") -> tuple[np.ndarray, gguf.GGMLQuantizationType]:
"""Convert tensor to appropriate dtype for GGUF."""
if tensor.dtype == torch.bfloat16:
data = tensor.float().numpy()
else:
data = tensor.numpy()
n_dims = len(data.shape)
# 1D tensors (norms, biases, scales) should be F32
if n_dims <= 1:
return data.astype(np.float32), gguf.GGMLQuantizationType.F32
# For 2D+ tensors, use the specified output type
if self.output_type == "f32":
return data.astype(np.float32), gguf.GGMLQuantizationType.F32
elif self.output_type == "f16":
return data.astype(np.float16), gguf.GGMLQuantizationType.F16
elif self.output_type == "q8_0":
# Keep some tensors in F16 for quality (codebooks, norms)
if any(x in tensor_name for x in ["codebook", "_norm", "norm.", "scale", "alpha", "beta"]):
return data.astype(np.float16), gguf.GGMLQuantizationType.F16
data = data.astype(np.float32)
try:
quantized = gguf.quants.quantize(data, gguf.GGMLQuantizationType.Q8_0)
return quantized, gguf.GGMLQuantizationType.Q8_0
except Exception as e:
logger.warning(f"Q8_0 quantization failed for {tensor_name}: {e}, falling back to F16")
return data.astype(np.float16), gguf.GGMLQuantizationType.F16
else:
return data.astype(np.float16), gguf.GGMLQuantizationType.F16
def convert(self) -> None:
"""Convert the model to GGUF format."""
logger.info(f"Converting {self.model_name} to GGUF format")
logger.info(f"Input: {self.input_dir}")
logger.info(f"Output: {self.output_path}")
logger.info(f"Output type: {self.output_type}")
# Create output directory if needed
self.output_path.parent.mkdir(parents=True, exist_ok=True)
# Initialize GGUF writer
arch = "qwen3-tts-tokenizer"
writer = gguf.GGUFWriter(path=None, arch=arch)
# Add metadata
self._add_metadata(writer)
# Process tensors
tensor_count = 0
skipped_count = 0
skipped_tensors = []
# Collect embedding_sum and cluster_usage pairs for codebook computation
codebook_pairs: dict[str, dict[str, torch.Tensor]] = {}
logger.info("Processing tensors...")
all_tensors = list(self._get_tensors())
# First pass: collect codebook pairs
for hf_name, tensor in all_tensors:
if "embedding_sum" in hf_name:
base_name = hf_name.replace("embedding_sum", "")
if base_name not in codebook_pairs:
codebook_pairs[base_name] = {}
codebook_pairs[base_name]["embedding_sum"] = tensor
elif "cluster_usage" in hf_name:
base_name = hf_name.replace("cluster_usage", "")
if base_name not in codebook_pairs:
codebook_pairs[base_name] = {}
codebook_pairs[base_name]["cluster_usage"] = tensor
for hf_name, tensor in tqdm(all_tensors, desc="Converting"):
ggml_name = self._map_tensor_name(hf_name)
if ggml_name is None:
skipped_tensors.append(hf_name)
skipped_count += 1
continue
# Skip cluster_usage tensors (we'll use them to compute codebooks)
if "cluster_usage" in hf_name:
skipped_count += 1
continue
# For embedding_sum, compute actual codebook = embedding_sum / cluster_usage
if "embedding_sum" in hf_name:
base_name = hf_name.replace("embedding_sum", "")
if base_name in codebook_pairs and "cluster_usage" in codebook_pairs[base_name]:
embedding_sum = codebook_pairs[base_name]["embedding_sum"]
cluster_usage = codebook_pairs[base_name]["cluster_usage"]
tensor = embedding_sum / cluster_usage.clamp(min=1e-5).unsqueeze(1)
logger.debug(f" Computing codebook from embedding_sum/cluster_usage for {hf_name}")
# Convert tensor
data, dtype = self._convert_dtype(tensor, ggml_name)
# Add tensor to writer
writer.add_tensor(ggml_name, data, raw_dtype=dtype)
tensor_count += 1
logger.debug(f" {hf_name} -> {ggml_name} [{dtype.name}] {data.shape}")
logger.info(f"Converted {tensor_count} tensors, skipped {skipped_count}")
if skipped_tensors and logger.level <= logging.DEBUG:
logger.debug("Skipped tensors:")
for t in skipped_tensors:
logger.debug(f" {t}")
# Write to file
logger.info(f"Writing GGUF file to {self.output_path}")
writer.write_header_to_file(path=self.output_path)
writer.write_kv_data_to_file()
writer.write_tensors_to_file(progress=True)
writer.close()
logger.info("Conversion complete!")
def _add_metadata(self, writer: gguf.GGUFWriter) -> None:
"""Add model metadata to GGUF writer."""
arch = "qwen3-tts-tokenizer"
# General metadata
writer.add_name(self.model_name)
writer.add_type(gguf.GGUFType.MODEL)
# File type
if self.output_type == "f32":
ftype = gguf.LlamaFileType.ALL_F32
elif self.output_type == "f16":
ftype = gguf.LlamaFileType.MOSTLY_F16
elif self.output_type == "q8_0":
ftype = gguf.LlamaFileType.MOSTLY_Q8_0
else:
ftype = gguf.LlamaFileType.MOSTLY_F16
writer.add_file_type(ftype)
# Quantization version
writer.add_quantization_version(gguf.GGML_QUANT_VERSION)
# Tokenizer-specific hyperparameters
writer.add_uint32(f"{arch}.num_codebooks", self.decoder_num_quantizers)
writer.add_uint32(f"{arch}.codebook_size", self.encoder_codebook_size)
writer.add_uint32(f"{arch}.sample_rate", self.sample_rate)
writer.add_float32(f"{arch}.frame_rate", self.frame_rate)
# Encoder parameters
writer.add_uint32(f"{arch}.encoder.hidden_size", self.encoder_hidden_size)
writer.add_uint32(f"{arch}.encoder.num_layers", self.encoder_num_layers)
writer.add_uint32(f"{arch}.encoder.num_heads", self.encoder_num_heads)
writer.add_uint32(f"{arch}.encoder.num_quantizers", self.encoder_num_quantizers)
writer.add_uint32(f"{arch}.encoder.valid_quantizers", self.encoder_valid_quantizers)
writer.add_uint32(f"{arch}.encoder.codebook_dim", self.encoder_codebook_dim)
# Decoder parameters
writer.add_uint32(f"{arch}.decoder.hidden_size", self.decoder_hidden_size)
writer.add_uint32(f"{arch}.decoder.num_layers", self.decoder_num_layers)
writer.add_uint32(f"{arch}.decoder.num_heads", self.decoder_num_heads)
writer.add_uint32(f"{arch}.decoder.latent_dim", self.decoder_latent_dim)
writer.add_uint32(f"{arch}.decoder.codebook_dim", self.decoder_codebook_dim)
writer.add_uint32(f"{arch}.decoder.semantic_codebook_size", self.decoder_semantic_codebook_size)
# Upsample rates as array
writer.add_array(f"{arch}.upsample_rates", self.upsample_rates)
logger.info("Added model metadata")
def main():
parser = argparse.ArgumentParser(
description="Convert Qwen3-TTS-Tokenizer-12Hz model to GGUF format"
)
parser.add_argument(
"--input", "-i",
type=Path,
required=True,
help="Path to HuggingFace model directory"
)
parser.add_argument(
"--output", "-o",
type=Path,
required=True,
help="Output GGUF file path"
)
parser.add_argument(
"--type", "-t",
choices=["f16", "f32", "q8_0"],
default="f16",
help="Output data type (default: f16)"
)
parser.add_argument(
"--verbose", "-v",
action="store_true",
help="Enable verbose logging"
)
args = parser.parse_args()
if args.verbose:
logging.getLogger().setLevel(logging.DEBUG)
converter = Qwen3TTSTokenizerConverter(
input_dir=args.input,
output_path=args.output,
output_type=args.type,
)
converter.convert()
if __name__ == "__main__":
main()
@@ -0,0 +1,569 @@
#!/usr/bin/env python3
"""
Convert HuggingFace Qwen3-TTS-12Hz-0.6B-Base model to GGUF format.
Usage:
python scripts/convert_tts_to_gguf.py \
--input models/Qwen3-TTS-12Hz-0.6B-Base \
--output models/qwen3-tts-0.6b-f16.gguf \
--type f16
"""
from __future__ import annotations
import argparse
import json
import logging
import re
import sys
from pathlib import Path
from typing import Any, Iterator
import numpy as np
import torch
from safetensors import safe_open
from tqdm import tqdm
# Add gguf-py to path (if available)
GGUF_PY_PATH = Path(__file__).resolve().parents[1] / "gguf-py"
try:
if GGUF_PY_PATH.exists():
sys.path.insert(0, str(GGUF_PY_PATH))
except (PermissionError, OSError):
pass
import gguf
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger(__name__)
class Qwen3TTSConverter:
"""Converter for Qwen3-TTS-12Hz-0.6B-Base model to GGUF format."""
# Direct tensor name mapping from HuggingFace to GGML conventions
TENSOR_MAP = {
# Talker - Main embeddings and heads
"talker.model.codec_embedding.weight": "talker.codec_embd.weight",
"talker.model.text_embedding.weight": "talker.text_embd.weight",
"talker.codec_head.weight": "talker.codec_head.weight",
"talker.model.norm.weight": "talker.output_norm.weight",
# Talker - Text projection
"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",
# Code Predictor - Output norm
"talker.code_predictor.model.norm.weight": "code_pred.output_norm.weight",
# Speaker Encoder - Initial conv
"speaker_encoder.blocks.0.conv.weight": "spk_enc.conv0.weight",
"speaker_encoder.blocks.0.conv.bias": "spk_enc.conv0.bias",
# Speaker Encoder - ASP (Attentive Statistics Pooling)
"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 (Multi-layer Feature Aggregation)
"speaker_encoder.mfa.conv.weight": "spk_enc.mfa.weight",
"speaker_encoder.mfa.conv.bias": "spk_enc.mfa.bias",
# Speaker Encoder - Final FC
"speaker_encoder.fc.weight": "spk_enc.fc.weight",
"speaker_encoder.fc.bias": "spk_enc.fc.bias",
}
# Regex patterns for layer-specific tensors
TALKER_LAYER_PATTERNS = [
# Talker transformer layers (28 layers)
(r"talker\.model\.layers\.(\d+)\.input_layernorm\.weight", "talker.blk.{}.attn_norm.weight"),
(r"talker\.model\.layers\.(\d+)\.self_attn\.q_proj\.weight", "talker.blk.{}.attn_q.weight"),
(r"talker\.model\.layers\.(\d+)\.self_attn\.k_proj\.weight", "talker.blk.{}.attn_k.weight"),
(r"talker\.model\.layers\.(\d+)\.self_attn\.v_proj\.weight", "talker.blk.{}.attn_v.weight"),
(r"talker\.model\.layers\.(\d+)\.self_attn\.o_proj\.weight", "talker.blk.{}.attn_output.weight"),
(r"talker\.model\.layers\.(\d+)\.self_attn\.q_norm\.weight", "talker.blk.{}.attn_q_norm.weight"),
(r"talker\.model\.layers\.(\d+)\.self_attn\.k_norm\.weight", "talker.blk.{}.attn_k_norm.weight"),
(r"talker\.model\.layers\.(\d+)\.post_attention_layernorm\.weight", "talker.blk.{}.ffn_norm.weight"),
(r"talker\.model\.layers\.(\d+)\.mlp\.gate_proj\.weight", "talker.blk.{}.ffn_gate.weight"),
(r"talker\.model\.layers\.(\d+)\.mlp\.up_proj\.weight", "talker.blk.{}.ffn_up.weight"),
(r"talker\.model\.layers\.(\d+)\.mlp\.down_proj\.weight", "talker.blk.{}.ffn_down.weight"),
]
CODE_PREDICTOR_LAYER_PATTERNS = [
# Code Predictor transformer layers (5 layers)
(r"talker\.code_predictor\.model\.layers\.(\d+)\.input_layernorm\.weight", "code_pred.blk.{}.attn_norm.weight"),
(r"talker\.code_predictor\.model\.layers\.(\d+)\.self_attn\.q_proj\.weight", "code_pred.blk.{}.attn_q.weight"),
(r"talker\.code_predictor\.model\.layers\.(\d+)\.self_attn\.k_proj\.weight", "code_pred.blk.{}.attn_k.weight"),
(r"talker\.code_predictor\.model\.layers\.(\d+)\.self_attn\.v_proj\.weight", "code_pred.blk.{}.attn_v.weight"),
(r"talker\.code_predictor\.model\.layers\.(\d+)\.self_attn\.o_proj\.weight", "code_pred.blk.{}.attn_output.weight"),
(r"talker\.code_predictor\.model\.layers\.(\d+)\.self_attn\.q_norm\.weight", "code_pred.blk.{}.attn_q_norm.weight"),
(r"talker\.code_predictor\.model\.layers\.(\d+)\.self_attn\.k_norm\.weight", "code_pred.blk.{}.attn_k_norm.weight"),
(r"talker\.code_predictor\.model\.layers\.(\d+)\.post_attention_layernorm\.weight", "code_pred.blk.{}.ffn_norm.weight"),
(r"talker\.code_predictor\.model\.layers\.(\d+)\.mlp\.gate_proj\.weight", "code_pred.blk.{}.ffn_gate.weight"),
(r"talker\.code_predictor\.model\.layers\.(\d+)\.mlp\.up_proj\.weight", "code_pred.blk.{}.ffn_up.weight"),
(r"talker\.code_predictor\.model\.layers\.(\d+)\.mlp\.down_proj\.weight", "code_pred.blk.{}.ffn_down.weight"),
]
CODE_PREDICTOR_CODEBOOK_PATTERNS = [
# Code Predictor codebook embeddings (15 codebooks, indices 0-14)
(r"talker\.code_predictor\.model\.codec_embedding\.(\d+)\.weight", "code_pred.codec_embd.{}.weight"),
# Code Predictor LM heads (15 heads)
(r"talker\.code_predictor\.lm_head\.(\d+)\.weight", "code_pred.lm_head.{}.weight"),
]
SPEAKER_ENCODER_PATTERNS = [
# Speaker Encoder Res2Net blocks (blocks 1-3)
(r"speaker_encoder\.blocks\.(\d+)\.res2net_block\.blocks\.(\d+)\.conv\.weight", "spk_enc.blk.{}.res2net.{}.weight"),
(r"speaker_encoder\.blocks\.(\d+)\.res2net_block\.blocks\.(\d+)\.conv\.bias", "spk_enc.blk.{}.res2net.{}.bias"),
# Speaker Encoder SE blocks
(r"speaker_encoder\.blocks\.(\d+)\.se_block\.conv1\.weight", "spk_enc.blk.{}.se.conv1.weight"),
(r"speaker_encoder\.blocks\.(\d+)\.se_block\.conv1\.bias", "spk_enc.blk.{}.se.conv1.bias"),
(r"speaker_encoder\.blocks\.(\d+)\.se_block\.conv2\.weight", "spk_enc.blk.{}.se.conv2.weight"),
(r"speaker_encoder\.blocks\.(\d+)\.se_block\.conv2\.bias", "spk_enc.blk.{}.se.conv2.bias"),
# Speaker Encoder TDNN layers
(r"speaker_encoder\.blocks\.(\d+)\.tdnn1\.conv\.weight", "spk_enc.blk.{}.tdnn1.weight"),
(r"speaker_encoder\.blocks\.(\d+)\.tdnn1\.conv\.bias", "spk_enc.blk.{}.tdnn1.bias"),
(r"speaker_encoder\.blocks\.(\d+)\.tdnn2\.conv\.weight", "spk_enc.blk.{}.tdnn2.weight"),
(r"speaker_encoder\.blocks\.(\d+)\.tdnn2\.conv\.bias", "spk_enc.blk.{}.tdnn2.bias"),
]
def __init__(
self,
input_dir: Path,
output_path: Path,
output_type: str = "f16",
):
self.input_dir = input_dir
self.output_path = output_path
self.output_type = output_type
# Load config
self.config = self._load_config()
# Extract model parameters
self._extract_params()
def _load_config(self) -> dict[str, Any]:
"""Load model configuration from config.json."""
config_path = self.input_dir / "config.json"
if not config_path.exists():
raise FileNotFoundError(f"Config file not found: {config_path}")
with open(config_path, "r", encoding="utf-8") as f:
return json.load(f)
def _extract_params(self) -> None:
"""Extract model parameters from config."""
talker_config = self.config.get("talker_config", {})
code_predictor_config = talker_config.get("code_predictor_config", {})
speaker_encoder_config = self.config.get("speaker_encoder_config", {})
# Talker parameters
self.hidden_size = talker_config.get("hidden_size", 1024)
self.intermediate_size = talker_config.get("intermediate_size", 3072)
self.num_hidden_layers = talker_config.get("num_hidden_layers", 28)
self.num_attention_heads = talker_config.get("num_attention_heads", 16)
self.num_kv_heads = talker_config.get("num_key_value_heads", 8)
self.head_dim = talker_config.get("head_dim", 128)
self.vocab_size = talker_config.get("vocab_size", 3072) # codec vocab
self.text_vocab_size = talker_config.get("text_vocab_size", 151936)
self.text_hidden_size = talker_config.get("text_hidden_size", 2048)
self.num_code_groups = talker_config.get("num_code_groups", 16)
self.rms_norm_eps = talker_config.get("rms_norm_eps", 1e-6)
self.rope_theta = talker_config.get("rope_theta", 1000000)
# M-RoPE configuration
rope_scaling = talker_config.get("rope_scaling", {})
self.mrope_section = rope_scaling.get("mrope_section", [24, 20, 20])
# Code Predictor parameters
self.code_predictor_num_layers = code_predictor_config.get("num_hidden_layers", 5)
self.code_predictor_vocab_size = code_predictor_config.get("vocab_size", 2048)
# Speaker Encoder parameters
self.speaker_enc_dim = speaker_encoder_config.get("enc_dim", 1024)
self.speaker_sample_rate = speaker_encoder_config.get("sample_rate", 24000)
# Special codec token IDs
self.codec_pad_id = talker_config.get("codec_pad_id", 2148)
self.codec_bos_id = talker_config.get("codec_bos_id", 2149)
self.codec_eos_id = talker_config.get("codec_eos_token_id", 2150)
# Model name
self.model_name = "Qwen3-TTS-12Hz-0.6B"
def _map_tensor_name(self, hf_name: str) -> str | None:
"""Map HuggingFace tensor name to GGML convention."""
# Check direct mapping first
if hf_name in self.TENSOR_MAP:
return self.TENSOR_MAP[hf_name]
# Check Talker layer patterns
for pattern, template in self.TALKER_LAYER_PATTERNS:
match = re.match(pattern, hf_name)
if match:
layer_idx = match.group(1)
return template.format(layer_idx)
# Check Code Predictor layer patterns
for pattern, template in self.CODE_PREDICTOR_LAYER_PATTERNS:
match = re.match(pattern, hf_name)
if match:
layer_idx = match.group(1)
return template.format(layer_idx)
# Check Code Predictor codebook patterns
for pattern, template in self.CODE_PREDICTOR_CODEBOOK_PATTERNS:
match = re.match(pattern, hf_name)
if match:
codebook_idx = match.group(1)
return template.format(codebook_idx)
# Check Speaker Encoder patterns
for pattern, template in self.SPEAKER_ENCODER_PATTERNS:
match = re.match(pattern, hf_name)
if match:
groups = match.groups()
if len(groups) == 2:
return template.format(groups[0], groups[1])
else:
return template.format(groups[0])
return None
def _get_tensors(self) -> Iterator[tuple[str, torch.Tensor]]:
"""Iterate over all tensors from safetensors files."""
safetensor_files = list(self.input_dir.glob("*.safetensors"))
if not safetensor_files:
raise FileNotFoundError(f"No safetensors files found in {self.input_dir}")
for sf_path in sorted(safetensor_files):
logger.info(f"Loading tensors from {sf_path.name}")
with safe_open(sf_path, framework="pt", device="cpu") as f:
for name in f.keys():
yield name, f.get_tensor(name)
def _should_quantize(self, tensor_name: str) -> bool:
"""Determine if a tensor should be quantized (Q8_0) or kept in F16.
Tensors to keep in F16 for quality:
- Embeddings (codec_embd, text_embd, codebook)
- Layer norms (attn_norm, ffn_norm, output_norm)
- Biases
- LM heads
"""
# Keep embeddings in F16
if any(x in tensor_name for x in ["_embd", "codebook"]):
return False
# Keep layer norms in F16
if "_norm" in tensor_name:
return False
# Keep biases in F16
if ".bias" in tensor_name:
return False
# Keep LM heads in F16
if "lm_head" in tensor_name or "codec_head" in tensor_name:
return False
# Quantize weight matrices
return True
def _convert_dtype(self, tensor: torch.Tensor, tensor_name: str = "") -> tuple[np.ndarray, gguf.GGMLQuantizationType]:
"""Convert tensor to appropriate dtype for GGUF."""
# Convert to numpy
if tensor.dtype == torch.bfloat16:
data = tensor.float().numpy()
else:
data = tensor.numpy()
n_dims = len(data.shape)
if n_dims == 3 and "weight" in tensor_name:
logger.info(f"Conv1d weight {tensor_name}: shape {data.shape} [OC,IC,K] - GGUF will reverse to [K,IC,OC]")
# 1D tensors (norms, biases) should be F32
if n_dims <= 1:
return data.astype(np.float32), gguf.GGMLQuantizationType.F32
# For 2D+ tensors, use the specified output type
if self.output_type == "f32":
return data.astype(np.float32), gguf.GGMLQuantizationType.F32
elif self.output_type == "f16":
return data.astype(np.float16), gguf.GGMLQuantizationType.F16
elif self.output_type == "q8_0":
if not self._should_quantize(tensor_name):
logger.debug(f"Keeping {tensor_name} in F16 (not quantizing)")
return data.astype(np.float16), gguf.GGMLQuantizationType.F16
data = data.astype(np.float32)
try:
quantized = gguf.quants.quantize(data, gguf.GGMLQuantizationType.Q8_0)
return quantized, gguf.GGMLQuantizationType.Q8_0
except Exception as e:
logger.warning(f"Q8_0 quantization failed for {tensor_name}: {e}, falling back to F16")
return data.astype(np.float16), gguf.GGMLQuantizationType.F16
elif self.output_type == "q4_k":
if not self._should_quantize(tensor_name):
logger.debug(f"Keeping {tensor_name} in F16 (not quantizing)")
return data.astype(np.float16), gguf.GGMLQuantizationType.F16
data = data.astype(np.float32)
try:
quantized = gguf.quants.quantize(data, gguf.GGMLQuantizationType.Q4_K)
return quantized, gguf.GGMLQuantizationType.Q4_K
except Exception as e:
logger.warning(f"Q4_K quantization failed for {tensor_name}: {e}, falling back to F16")
return data.astype(np.float16), gguf.GGMLQuantizationType.F16
else:
return data.astype(np.float16), gguf.GGMLQuantizationType.F16
def _load_tokenizer(self) -> tuple[list[str], list[int], list[str]]:
"""Load tokenizer vocabulary and merges."""
vocab_path = self.input_dir / "vocab.json"
merges_path = self.input_dir / "merges.txt"
if not vocab_path.exists():
raise FileNotFoundError(f"Vocab file not found: {vocab_path}")
# Load vocabulary
with open(vocab_path, "r", encoding="utf-8") as f:
vocab_dict = json.load(f)
# Sort by token ID
sorted_vocab = sorted(vocab_dict.items(), key=lambda x: x[1])
tokens = []
toktypes = []
for token, token_id in sorted_vocab:
tokens.append(token)
# Determine token type
if token.startswith("<|") and token.endswith("|>"):
toktypes.append(gguf.TokenType.CONTROL)
else:
toktypes.append(gguf.TokenType.NORMAL)
# Pad to text_vocab_size if needed
while len(tokens) < self.text_vocab_size:
tokens.append(f"[PAD{len(tokens)}]")
toktypes.append(gguf.TokenType.UNUSED)
# Load merges
merges = []
if merges_path.exists():
with open(merges_path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line and not line.startswith("#"):
merges.append(line)
return tokens, toktypes, merges
def convert(self) -> None:
"""Convert the model to GGUF format."""
logger.info(f"Converting {self.model_name} to GGUF format")
logger.info(f"Input: {self.input_dir}")
logger.info(f"Output: {self.output_path}")
logger.info(f"Output type: {self.output_type}")
# Create output directory if needed
self.output_path.parent.mkdir(parents=True, exist_ok=True)
# Initialize GGUF writer
arch = "qwen3-tts"
writer = gguf.GGUFWriter(path=None, arch=arch)
# Add metadata
self._add_metadata(writer)
# Add tokenizer
self._add_tokenizer(writer)
# Process tensors
tensor_count = 0
skipped_count = 0
logger.info("Processing tensors...")
for hf_name, tensor in tqdm(list(self._get_tensors()), desc="Converting"):
ggml_name = self._map_tensor_name(hf_name)
if ggml_name is None:
logger.warning(f"Skipping unmapped tensor: {hf_name}")
skipped_count += 1
continue
# Convert tensor
data, dtype = self._convert_dtype(tensor, ggml_name)
# Add tensor to writer
writer.add_tensor(ggml_name, data, raw_dtype=dtype)
tensor_count += 1
logger.debug(f" {hf_name} -> {ggml_name} [{dtype.name}] {data.shape}")
logger.info(f"Converted {tensor_count} tensors, skipped {skipped_count}")
# Write to file
logger.info(f"Writing GGUF file to {self.output_path}")
writer.write_header_to_file(path=self.output_path)
writer.write_kv_data_to_file()
writer.write_tensors_to_file(progress=True)
writer.close()
logger.info("Conversion complete!")
def _add_metadata(self, writer: gguf.GGUFWriter) -> None:
"""Add model metadata to GGUF writer."""
arch = "qwen3-tts"
# General metadata
writer.add_name(self.model_name)
writer.add_type(gguf.GGUFType.MODEL)
# File type
if self.output_type == "f32":
ftype = gguf.LlamaFileType.ALL_F32
elif self.output_type == "f16":
ftype = gguf.LlamaFileType.MOSTLY_F16
elif self.output_type == "q8_0":
ftype = gguf.LlamaFileType.MOSTLY_Q8_0
elif self.output_type == "q4_k":
ftype = gguf.LlamaFileType.MOSTLY_Q4_K_M
else:
ftype = gguf.LlamaFileType.MOSTLY_F16
writer.add_file_type(ftype)
# Quantization version
writer.add_quantization_version(gguf.GGML_QUANT_VERSION)
# Talker (main architecture) parameters
writer.add_block_count(self.num_hidden_layers)
writer.add_embedding_length(self.hidden_size)
writer.add_feed_forward_length(self.intermediate_size)
writer.add_head_count(self.num_attention_heads)
writer.add_head_count_kv(self.num_kv_heads)
writer.add_key_length(self.head_dim)
writer.add_value_length(self.head_dim)
writer.add_rope_freq_base(self.rope_theta)
writer.add_layer_norm_rms_eps(self.rms_norm_eps)
writer.add_vocab_size(self.vocab_size)
# TTS-specific parameters
writer.add_uint32(f"{arch}.text_vocab_size", self.text_vocab_size)
writer.add_uint32(f"{arch}.text_hidden_size", self.text_hidden_size)
writer.add_uint32(f"{arch}.num_code_groups", self.num_code_groups)
# M-RoPE configuration
writer.add_array(f"{arch}.rope.mrope_section", self.mrope_section)
# Code Predictor parameters
writer.add_uint32(f"{arch}.code_predictor.layer_count", self.code_predictor_num_layers)
writer.add_uint32(f"{arch}.code_predictor.vocab_size", self.code_predictor_vocab_size)
# Speaker Encoder parameters
writer.add_uint32(f"{arch}.speaker_encoder.embedding_length", self.speaker_enc_dim)
writer.add_uint32(f"{arch}.speaker_encoder.sample_rate", self.speaker_sample_rate)
# Special codec token IDs
writer.add_uint32(f"{arch}.codec.pad_id", self.codec_pad_id)
writer.add_uint32(f"{arch}.codec.bos_id", self.codec_bos_id)
writer.add_uint32(f"{arch}.codec.eos_id", self.codec_eos_id)
logger.info("Added model metadata")
def _add_tokenizer(self, writer: gguf.GGUFWriter) -> None:
"""Add tokenizer to GGUF writer."""
tokens, toktypes, merges = self._load_tokenizer()
# Tokenizer model type
writer.add_tokenizer_model("gpt2")
writer.add_tokenizer_pre("qwen2")
# Token list
writer.add_token_list(tokens)
writer.add_token_types(toktypes)
# Merges
if merges:
writer.add_token_merges(merges)
# Special tokens from tokenizer_config.json
tokenizer_config_path = self.input_dir / "tokenizer_config.json"
if tokenizer_config_path.exists():
with open(tokenizer_config_path, "r", encoding="utf-8") as f:
tokenizer_config = json.load(f)
# EOS token
eos_token = tokenizer_config.get("eos_token")
if isinstance(eos_token, dict):
eos_token = eos_token.get("content")
if eos_token:
vocab_path = self.input_dir / "vocab.json"
with open(vocab_path, "r", encoding="utf-8") as f:
vocab = json.load(f)
if eos_token in vocab:
writer.add_eos_token_id(vocab[eos_token])
# PAD token
pad_token = tokenizer_config.get("pad_token")
if isinstance(pad_token, dict):
pad_token = pad_token.get("content")
if pad_token:
vocab_path = self.input_dir / "vocab.json"
with open(vocab_path, "r", encoding="utf-8") as f:
vocab = json.load(f)
if pad_token in vocab:
writer.add_pad_token_id(vocab[pad_token])
# Chat template
chat_template = tokenizer_config.get("chat_template")
if chat_template:
writer.add_chat_template(chat_template)
logger.info(f"Added tokenizer with {len(tokens)} tokens and {len(merges)} merges")
def main():
parser = argparse.ArgumentParser(
description="Convert Qwen3-TTS-12Hz-0.6B-Base model to GGUF format"
)
parser.add_argument(
"--input", "-i",
type=Path,
required=True,
help="Path to HuggingFace model directory"
)
parser.add_argument(
"--output", "-o",
type=Path,
required=True,
help="Output GGUF file path"
)
parser.add_argument(
"--type", "-t",
choices=["f16", "f32", "q8_0", "q4_k"],
default="f16",
help="Output data type (default: f16). q8_0 provides ~50%% size reduction, q4_k provides ~70%% size reduction."
)
parser.add_argument(
"--verbose", "-v",
action="store_true",
help="Enable verbose logging"
)
args = parser.parse_args()
if args.verbose:
logging.getLogger().setLevel(logging.DEBUG)
converter = Qwen3TTSConverter(
input_dir=args.input,
output_path=args.output,
output_type=args.type,
)
converter.convert()
if __name__ == "__main__":
main()