voice_audio_multi/tools/community_models/convert_zipvoice.py
Fabio c36510c21e
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
Initial import: audio.cpp with Orange Pi documentation
2026-11-04 19:05:22 +01:00

189 lines
8.2 KiB
Python

#!/usr/bin/env python3
"""Convert ZipVoice / ZipVoice-Distill torch checkpoints to audio.cpp packages.
ZipVoice (https://github.com/k2-fsa/ZipVoice, k2-fsa/ZipVoice on HuggingFace)
ships `model.pt` checkpoints (a pickled dict with a "model" state dict), a
`model.json` architecture config, and a `tokens.txt` phone vocab. audio.cpp
cannot read the nested pickle directly, so this script first flattens the
state dict to safetensors (the development format) and then packages it into
a self-contained GGUF with two tensor namespaces:
model.* — the ZipVoice flow-matching model (raw torch names)
vocos.* — the Vocos mel-24kHz vocoder used to decode generated features
`tokens.txt` and `model.json` are copied next to the output so the GGUF plus
those two files form a complete model directory for
`audiocpp_cli --family zipvoice`.
Examples:
# ZipVoice-Distill (8 steps, guidance-scale embedding) with bundled vocoder
python3 tools/community_models/convert_zipvoice.py \
--model-dir /models/ZipVoice/zipvoice_distill \
--vocos /models/vocos-mel-24khz/vocos.safetensors \
--converter build/bin/audiocpp_gguf
# safetensors only (development format, no GGUF step)
python3 tools/community_models/convert_zipvoice.py \
--model-dir /models/ZipVoice/zipvoice_distill --safetensors-only
"""
import argparse
import json
import shutil
import subprocess
import sys
from pathlib import Path
import torch
def load_state_dict(model_dir: Path, checkpoint_name: str) -> dict:
ckpt_path = model_dir / checkpoint_name
if not ckpt_path.is_file():
raise SystemExit(f"checkpoint not found: {ckpt_path}")
ckpt = torch.load(str(ckpt_path), map_location="cpu", weights_only=False)
if isinstance(ckpt, dict) and "model" in ckpt:
state = ckpt["model"]
else:
state = ckpt
if not isinstance(state, dict):
raise SystemExit(f"unexpected checkpoint layout in {ckpt_path}")
return {name: tensor.contiguous().to(torch.float32)
for name, tensor in state.items()
if isinstance(tensor, torch.Tensor)}
def write_safetensors(state: dict, output: Path) -> None:
from safetensors.torch import save_file
output.parent.mkdir(parents=True, exist_ok=True)
save_file(state, str(output))
print(f"wrote {output} ({output.stat().st_size / 1e6:.1f} MB, "
f"{len(state)} tensors)")
def convert_gguf(converter: Path, safetensors: Path, vocos: Path,
output: Path, quant_type: str, overwrite: bool,
root: Path) -> None:
command = [
str(converter),
"--input", f"model={safetensors}",
"--input", f"vocos={vocos}",
"--root", str(root),
"--family", "zipvoice",
"--type", quant_type,
"--output", str(output),
]
if overwrite:
command.append("--overwrite")
print("+", " ".join(command))
subprocess.run(command, check=True)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--model-dir", type=Path, required=True,
help="ZipVoice model dir with model.pt / model.json / tokens.txt")
parser.add_argument("--checkpoint-name", default="model.pt")
parser.add_argument("--vocos", type=Path,
default=Path("/models/vocos-mel-24khz/vocos.safetensors"),
help="vocos.safetensors (bundled into the GGUF)")
parser.add_argument("--converter", type=Path,
default=Path("build/bin/audiocpp_gguf"))
parser.add_argument("--output-dir", type=Path, default=None,
help="default: <model-dir>/converted")
parser.add_argument("--type", default="orig",
choices=["orig", "f16", "bf16", "q8_0", "q2_k", "q3_k",
"q4_k", "q5_k", "q6_k"],
help="GGUF storage type (default orig = keep f32)")
parser.add_argument("--name", default=None,
help="package base name (default: model-dir name, lowercased)")
parser.add_argument("--safetensors-only", action="store_true",
help="stop after writing the flattened safetensors")
parser.add_argument("--no-vocos", action="store_true",
help="do not bundle a vocoder namespace into the GGUF")
parser.add_argument("--overwrite", action="store_true")
args = parser.parse_args()
model_dir = args.model_dir.resolve()
for required in ("model.json", "tokens.txt"):
if not (model_dir / required).is_file():
raise SystemExit(f"{required} missing in {model_dir}")
with (model_dir / "model.json").open() as f:
config = json.load(f)
has_guidance_embed = (
config.get("distill", {}).get("guidance_scale_embed", False)
if isinstance(config.get("distill"), dict) else False
)
name = args.name or model_dir.name.lower().replace("_", "-")
out_dir = (args.output_dir or model_dir / "converted").resolve()
out_dir.mkdir(parents=True, exist_ok=True)
state = load_state_dict(model_dir, args.checkpoint_name)
print(f"loaded {len(state)} tensors from {model_dir / args.checkpoint_name}")
# Fixed name matching model_specs/zipvoice.json sources[format=safetensors]
# ("model:zipvoice-orig.safetensors"); the GGUF keeps the -<type> naming.
safetensors_path = out_dir / "zipvoice-orig.safetensors"
write_safetensors(state, safetensors_path)
# dev-format directory: safetensors + config + vocab
shutil.copyfile(model_dir / "tokens.txt", out_dir / "tokens.txt")
shutil.copyfile(model_dir / "model.json", out_dir / "model.json")
# Chinese frontend sidecars (pypinyin tables + jieba dictionaries), when
# the model directory was staged by export_zipvoice_zh_dict.py. Staged
# into out_dir BEFORE packaging: the converter root is scanned for
# sidecars, so these ride inside the GGUF and a converted package is a
# single self-sufficient file (loose copies below remain for the
# directory layout).
for sidecar in sorted(model_dir.glob("zh_*")):
shutil.copyfile(sidecar, out_dir / sidecar.name)
staged_zh = sorted(p.name for p in out_dir.glob("zh_*"))
print(f"copied tokens.txt / model.json -> {out_dir}"
+ (f" + {len(staged_zh)} zh frontend sidecars" if staged_zh else ""))
if args.safetensors_only:
return
converter = args.converter.resolve()
if not converter.is_file():
raise SystemExit(f"converter not found: {converter} (build target audiocpp_gguf)")
gguf_dir = out_dir / name
gguf_dir.mkdir(parents=True, exist_ok=True)
gguf_path = gguf_dir / f"{name}-{args.type}.gguf"
# NOTE: --root must point at a directory with real (non-symlinked)
# sidecar files; HF-cache snapshots use blob symlinks that the sidecar
# scanner does not enumerate, so pass the flattened dev directory that
# already carries tokens.txt / model.json copies.
root = out_dir if (out_dir / "tokens.txt").is_file() else model_dir
if args.no_vocos:
command = [
str(converter),
"--input", f"model={safetensors_path}",
"--root", str(root),
"--family", "zipvoice",
"--type", args.type,
"--output", str(gguf_path),
]
if args.overwrite:
command.append("--overwrite")
print("+", " ".join(command))
subprocess.run(command, check=True)
else:
vocos = args.vocos.resolve()
if not vocos.is_file():
raise SystemExit(f"vocos checkpoint not found: {vocos} "
"(pass --no-vocos or --vocos <path>)")
convert_gguf(converter, safetensors_path, vocos, gguf_path,
args.type, args.overwrite, root)
shutil.copyfile(model_dir / "tokens.txt", gguf_dir / "tokens.txt")
shutil.copyfile(model_dir / "model.json", gguf_dir / "model.json")
# Chinese frontend sidecars (pypinyin tables + jieba dictionaries), when
# the model directory was staged by export_zipvoice_zh_dict.py.
for sidecar in sorted(model_dir.glob("zh_*")):
shutil.copyfile(sidecar, gguf_dir / sidecar.name)
print(f"package ready: {gguf_path}")
if __name__ == "__main__":
main()