Some checks failed
Linux Build / Linux cpu (push) Has been cancelled
Linux Build / Linux cpu-clang (push) Has been cancelled
Linux Build / Linux vulkan (push) Has been cancelled
mac-build / macOS CPU (push) Has been cancelled
Nix Build / Nix (cpu) (push) Has been cancelled
Nix Build / Nix (vulkan) (push) Has been cancelled
windows-build / Windows CPU (push) Has been cancelled
330 lines
11 KiB
Python
330 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Prepare the published GLM-TTS checkpoint for audio.cpp.
|
|
|
|
The upstream snapshot already stores the Llama and Whisper-VQ weights as
|
|
Safetensors. The remaining PyTorch checkpoints are copied into deterministic
|
|
Safetensors files, and the ChatGLM4/tiktoken rank table is converted to the
|
|
vocab/merge representation consumed by audio.cpp's shared BPE tokenizer.
|
|
|
|
This tool deliberately does not download files. Point --model-dir at a complete
|
|
zai-org/GLM-TTS snapshot. The native CAMPPlus component uses the canonical
|
|
Safetensors layout also published by mlx-community/index-tts2-mlx. Place it at
|
|
frontend/campplus.safetensors or pass its path with --campplus-safetensors.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import json
|
|
import math
|
|
import shutil
|
|
from pathlib import Path
|
|
from typing import Dict, List, Mapping, MutableMapping, Tuple
|
|
|
|
|
|
def _byte_encoder() -> Dict[int, str]:
|
|
"""GPT-2 reversible byte-to-Unicode table used by llama.cpp BPE assets."""
|
|
|
|
visible = (
|
|
list(range(ord("!"), ord("~") + 1))
|
|
+ list(range(0xA1, 0xAC + 1))
|
|
+ list(range(0xAE, 0xFF + 1))
|
|
)
|
|
encoded = list(visible)
|
|
extra = 0
|
|
for value in range(256):
|
|
if value not in visible:
|
|
visible.append(value)
|
|
encoded.append(256 + extra)
|
|
extra += 1
|
|
return dict(zip(visible, (chr(value) for value in encoded)))
|
|
|
|
|
|
def _encoded_token(token: bytes, byte_encoder: Mapping[int, str]) -> str:
|
|
return "".join(byte_encoder[value] for value in token)
|
|
|
|
|
|
def _load_tiktoken_ranks(path: Path) -> Dict[bytes, int]:
|
|
ranks: Dict[bytes, int] = {}
|
|
with path.open("r", encoding="utf-8") as handle:
|
|
for line_number, line in enumerate(handle, start=1):
|
|
fields = line.strip().split()
|
|
if not fields:
|
|
continue
|
|
if len(fields) != 2:
|
|
raise ValueError(
|
|
f"{path}:{line_number}: expected base64-token and rank"
|
|
)
|
|
token = base64.b64decode(fields[0], validate=True)
|
|
rank = int(fields[1])
|
|
if token in ranks or rank in ranks.values():
|
|
raise ValueError(f"{path}:{line_number}: duplicate token or rank")
|
|
ranks[token] = rank
|
|
if not ranks:
|
|
raise ValueError(f"empty tiktoken rank table: {path}")
|
|
return ranks
|
|
|
|
|
|
def _merge_parts_for_token(
|
|
token: bytes, rank: int, ranks: Mapping[bytes, int]
|
|
) -> Tuple[bytes, bytes]:
|
|
"""Recover the final binary merge that creates one tiktoken token."""
|
|
|
|
parts = [bytes((value,)) for value in token]
|
|
while len(parts) > 2:
|
|
candidates = [
|
|
(ranks.get(parts[index] + parts[index + 1], math.inf), index)
|
|
for index in range(len(parts) - 1)
|
|
]
|
|
pair_rank, index = min(candidates)
|
|
if pair_rank >= rank:
|
|
break
|
|
parts[index : index + 2] = [parts[index] + parts[index + 1]]
|
|
if len(parts) != 2 or parts[0] + parts[1] != token:
|
|
raise ValueError(
|
|
f"cannot reconstruct merge for rank {rank} token {token!r}"
|
|
)
|
|
if ranks.get(parts[0], math.inf) >= rank:
|
|
raise ValueError(f"left merge input is not an earlier token at rank {rank}")
|
|
if ranks.get(parts[1], math.inf) >= rank:
|
|
raise ValueError(f"right merge input is not an earlier token at rank {rank}")
|
|
return parts[0], parts[1]
|
|
|
|
|
|
def convert_tokenizer(model_dir: Path, overwrite: bool) -> Tuple[Path, Path]:
|
|
tokenizer_dir = model_dir / "vq32k-phoneme-tokenizer"
|
|
rank_path = tokenizer_dir / "tokenizer.model"
|
|
vocab_path = tokenizer_dir / "tokenizer_vocab.json"
|
|
merges_path = tokenizer_dir / "tokenizer_merges.txt"
|
|
if not rank_path.is_file():
|
|
raise FileNotFoundError(rank_path)
|
|
if not overwrite and (vocab_path.exists() or merges_path.exists()):
|
|
raise FileExistsError(
|
|
"converted tokenizer assets already exist; pass --overwrite"
|
|
)
|
|
|
|
ranks = _load_tiktoken_ranks(rank_path)
|
|
byte_encoder = _byte_encoder()
|
|
ordered = sorted(ranks.items(), key=lambda item: item[1])
|
|
expected_ranks = list(range(len(ordered)))
|
|
actual_ranks = [rank for _, rank in ordered]
|
|
if actual_ranks != expected_ranks:
|
|
raise ValueError("GLM-TTS tokenizer ranks must be contiguous from zero")
|
|
|
|
vocab = {
|
|
_encoded_token(token, byte_encoder): rank for token, rank in ordered
|
|
}
|
|
merges: List[str] = []
|
|
for token, rank in ordered:
|
|
if len(token) == 1:
|
|
continue
|
|
try:
|
|
left, right = _merge_parts_for_token(token, rank, ranks)
|
|
except ValueError:
|
|
# GLM4 deliberately occupies unused base-vocabulary ranks with
|
|
# direct sentinels such as <|UNUSED_pad_256|>. They are valid
|
|
# vocabulary entries but are not constructible BPE merges.
|
|
if token.startswith(b"<|UNUSED_pad_") and token.endswith(b"|>"):
|
|
continue
|
|
raise
|
|
merges.append(
|
|
f"{_encoded_token(left, byte_encoder)} "
|
|
f"{_encoded_token(right, byte_encoder)}"
|
|
)
|
|
|
|
vocab_path.write_text(
|
|
json.dumps(vocab, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
merges_path.write_text(
|
|
"#version: 0.2\n" + "\n".join(merges) + "\n", encoding="utf-8"
|
|
)
|
|
return vocab_path, merges_path
|
|
|
|
|
|
def _unwrap_state_dict(value: object, source: Path) -> MutableMapping[str, object]:
|
|
if not isinstance(value, MutableMapping):
|
|
raise TypeError(f"{source} does not contain a tensor dictionary")
|
|
for key in ("state_dict", "model", "generator"):
|
|
nested = value.get(key)
|
|
if isinstance(nested, MutableMapping):
|
|
value = nested
|
|
break
|
|
return value
|
|
|
|
|
|
def convert_torch_checkpoint(
|
|
source: Path, destination: Path, overwrite: bool
|
|
) -> None:
|
|
import torch
|
|
from safetensors.torch import save_file
|
|
|
|
if not source.is_file():
|
|
raise FileNotFoundError(source)
|
|
if destination.exists() and not overwrite:
|
|
raise FileExistsError(f"{destination} exists; pass --overwrite")
|
|
|
|
checkpoint = torch.load(source, map_location="cpu", weights_only=True)
|
|
state = _unwrap_state_dict(checkpoint, source)
|
|
tensors = {}
|
|
for name, value in state.items():
|
|
if not isinstance(value, torch.Tensor):
|
|
continue
|
|
tensors[str(name)] = value.detach().cpu().contiguous()
|
|
if not tensors:
|
|
raise ValueError(f"{source} contains no tensors")
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
save_file(
|
|
tensors,
|
|
str(destination),
|
|
metadata={
|
|
"format": "pt",
|
|
"source": source.name,
|
|
"audio.cpp.component": destination.parent.name,
|
|
},
|
|
)
|
|
|
|
|
|
def install_campplus(
|
|
source: Path, model_dir: Path, overwrite: bool
|
|
) -> Path:
|
|
if not source.is_file():
|
|
raise FileNotFoundError(source)
|
|
destination = model_dir / "frontend" / "campplus.safetensors"
|
|
if destination.exists() and not overwrite:
|
|
raise FileExistsError(f"{destination} exists; pass --overwrite")
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(source, destination)
|
|
return destination
|
|
|
|
|
|
def write_audio_cpp_config(model_dir: Path, overwrite: bool) -> Path:
|
|
destination = model_dir / "audio_cpp_config.json"
|
|
if destination.exists() and not overwrite:
|
|
raise FileExistsError(f"{destination} exists; pass --overwrite")
|
|
config = {
|
|
"schema_version": 1,
|
|
"family": "glm_tts",
|
|
"sample_rate": 24000,
|
|
"flow": {
|
|
"speech_token_dim": 512,
|
|
"vocab_size": 100000,
|
|
"mel_dim": 80,
|
|
"trans_dim": 768,
|
|
"depth": 18,
|
|
"heads": 12,
|
|
"dim_head": 64,
|
|
"conv_layers": 4,
|
|
"mel_framerate": 50,
|
|
"input_frame_rate": 25.0,
|
|
"inference_cfg_rate": 0.7,
|
|
"inference_steps": 10,
|
|
"scheduler": "cosine",
|
|
"speaker_embedding_dim": 192,
|
|
"speaker_adaln": True,
|
|
"speech_token_cfg": False,
|
|
"remove_speaker_concat_condition": True,
|
|
},
|
|
"hift": {
|
|
"in_channels": 80,
|
|
"base_channels": 512,
|
|
"nb_harmonics": 8,
|
|
"sampling_rate": 24000,
|
|
"nsf_alpha": 0.1,
|
|
"nsf_sigma": 0.003,
|
|
"nsf_voiced_threshold": 10.0,
|
|
"upsample_rates": [8, 5, 3],
|
|
"upsample_kernel_sizes": [16, 11, 7],
|
|
"istft_params": {"n_fft": 16, "hop_len": 4},
|
|
"resblock_kernel_sizes": [3, 7, 11],
|
|
"resblock_dilation_sizes": [[1, 3, 5], [1, 3, 5], [1, 3, 5]],
|
|
"source_resblock_kernel_sizes": [7, 7, 11],
|
|
"source_resblock_dilation_sizes": [
|
|
[1, 3, 5],
|
|
[1, 3, 5],
|
|
[1, 3, 5],
|
|
],
|
|
"lrelu_slope": 0.1,
|
|
"audio_limit": 0.99,
|
|
"f0_num_class": 1,
|
|
"f0_in_channels": 80,
|
|
"f0_cond_channels": 512,
|
|
},
|
|
}
|
|
destination.write_text(
|
|
json.dumps(config, indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
|
)
|
|
return destination
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument(
|
|
"--model-dir",
|
|
type=Path,
|
|
required=True,
|
|
help="Complete zai-org/GLM-TTS Hugging Face snapshot.",
|
|
)
|
|
parser.add_argument(
|
|
"--reference-dir",
|
|
type=Path,
|
|
help="Optional zai-org/GLM-TTS source checkout used to validate provenance.",
|
|
)
|
|
parser.add_argument(
|
|
"--campplus-safetensors",
|
|
type=Path,
|
|
help=(
|
|
"Canonical CAMPPlus Safetensors. If omitted, "
|
|
"<model-dir>/frontend/campplus.safetensors must already exist."
|
|
),
|
|
)
|
|
parser.add_argument("--overwrite", action="store_true")
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
model_dir = args.model_dir.resolve()
|
|
if args.reference_dir is not None:
|
|
reference_dir = args.reference_dir.resolve()
|
|
if not (reference_dir / "glmtts_inference.py").is_file():
|
|
raise FileNotFoundError(
|
|
f"{reference_dir} is not a GLM-TTS source checkout"
|
|
)
|
|
|
|
vocab, merges = convert_tokenizer(model_dir, args.overwrite)
|
|
convert_torch_checkpoint(
|
|
model_dir / "flow" / "flow.pt",
|
|
model_dir / "flow" / "model.safetensors",
|
|
args.overwrite,
|
|
)
|
|
convert_torch_checkpoint(
|
|
model_dir / "hift" / "hift.pt",
|
|
model_dir / "hift" / "model.safetensors",
|
|
args.overwrite,
|
|
)
|
|
if args.campplus_safetensors is not None:
|
|
campplus = install_campplus(
|
|
args.campplus_safetensors.resolve(), model_dir, args.overwrite
|
|
)
|
|
else:
|
|
campplus = model_dir / "frontend" / "campplus.safetensors"
|
|
if not campplus.is_file():
|
|
raise FileNotFoundError(
|
|
"CAMPPlus Safetensors is required at "
|
|
f"{campplus} or through --campplus-safetensors"
|
|
)
|
|
runtime_config = write_audio_cpp_config(model_dir, args.overwrite)
|
|
|
|
print(f"tokenizer vocab: {vocab}")
|
|
print(f"tokenizer merges: {merges}")
|
|
print(f"flow weights: {model_dir / 'flow' / 'model.safetensors'}")
|
|
print(f"HiFT weights: {model_dir / 'hift' / 'model.safetensors'}")
|
|
print(f"CAMPPlus weights: {campplus}")
|
|
print(f"runtime config: {runtime_config}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|