voice_audio_multi/tools/community_models/prepare_kitten_tts2_gguf.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

191 lines
11 KiB
Python

#!/usr/bin/env python3
"""Convert local Kitten TTS 2 assets into a self-contained audio.cpp GGUF.
No network access. Conversion uses NumPy, PyTorch and safetensors offline.
Inference remains native, with no Python or LibTorch runtime dependency.
"""
from __future__ import annotations
import argparse
import ast
import json
import math
import shutil
import struct
import subprocess
import tempfile
import zipfile
from pathlib import Path
def read_voice_array(path: Path, name: str, dtype: str) -> list:
"""Read upstream's flat numeric NPY arrays without NumPy or pickle."""
with zipfile.ZipFile(path) as archive:
data = archive.read(name + ".npy")
if data[:6] != b"\x93NUMPY" or data[6:8] not in (b"\x01\x00", b"\x02\x00", b"\x03\x00"):
raise ValueError(f"unsupported NPY header: {path}:{name}")
width = 2 if data[6] == 1 else 4
offset = 8 + width
length = int.from_bytes(data[8:offset], "little")
header = ast.literal_eval(data[offset:offset + length].decode("utf-8"))
shape = header["shape"]
if (header["descr"] not in ("<" + dtype, ">" + dtype) or
header["fortran_order"] or not isinstance(shape, tuple) or len(shape) != 1 or
not isinstance(shape[0], int) or shape[0] <= 0):
raise ValueError(f"expected a flat {dtype} array: {path}:{name}")
payload = data[offset + length:]
if len(payload) != shape[0] * 4:
raise ValueError(f"incorrect NPY payload size: {path}:{name}")
fmt = header["descr"][0] + ("f" if dtype == "f4" else "i")
values = [item[0] for item in struct.iter_unpack(fmt, payload)]
if not all(math.isfinite(value) for value in values):
raise ValueError(f"non-finite voice array: {path}:{name}")
return values
def prepare_voices(args, model: Path, s3gen: Path, root: Path, required: set[str]) -> Path:
base = (args.prepared_voices or model / "cpp/default/voices.json").resolve()
prepared = json.loads(base.read_text(encoding="utf-8"))
if not args.prepared_voices:
index_path = model / "voices/voices.json"
index = json.loads(index_path.read_text(encoding="utf-8"))
missing = {name: entry for name, entry in index.items() if name not in prepared}
if missing:
preparer = args.voice_preparer or args.converter.with_name(
"audiocpp_kitten_tts2_prepare_voices" + args.converter.suffix)
if not preparer.is_file():
raise FileNotFoundError(f"build audiocpp_kitten_tts2_prepare_voices or pass --voice-preparer: {preparer}")
manifest = {}
for name, entry in missing.items():
artifacts = index_path.parent / entry["artifacts"]
identity = read_voice_array(artifacts, "embedding", "f4")
tokens = read_voice_array(artifacts, "reference_tokens", "i4")
if len(identity) != 512 or any(token < 0 or token >= 6561 for token in tokens):
raise ValueError(f"invalid upstream voice artifacts: {name}")
manifest[name] = {
"reference": str((index_path.parent / entry["reference"]).resolve()),
"transcript": entry["transcript"], "identity": identity, "reference_tokens": tokens,
}
manifest_path = root / "voice-manifest.json"
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
output = root / "voices.json"
subprocess.run([str(preparer.resolve()), str(model), str(s3gen), str(manifest_path),
str(output), args.voice_backend, str(args.threads)], check=True)
additions = json.loads(output.read_text(encoding="utf-8"))
if additions.keys() != missing.keys():
raise ValueError("native preparer returned an unexpected set of voices")
# Retain existing decimal values verbatim as Python numbers. Only
# newly prepared voices pass through the native JSON serializer.
prepared.update(additions)
output.write_text(json.dumps(prepared, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
base = output
absent = required - prepared.keys()
if absent:
raise ValueError(f"prepared voice index is missing registered presets: {', '.join(sorted(absent))}")
from kitten_tts2_weights import refresh_voice_projections
refresh_voice_projections(model, prepared)
base = root / "voices-refreshed.json"
base.write_text(json.dumps(prepared, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
if args.voices_output:
args.voices_output.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(base, args.voices_output)
print(f"Packaging {len(prepared)} prepared voices.", flush=True)
return base
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--model", type=Path, required=True)
parser.add_argument("--converter", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--overwrite", action="store_true", help="Replace an existing output GGUF.")
parser.add_argument("--type", choices=("bf16", "q8_0", "q4_0", "tq2_1"), default="q8_0",
help="Export type (default q8_0): bf16 preserves full BF16; q8_0 unpacks ternary weights exactly; q4_0 packs the body with a Q8 embedding by default; tq2_1 packs exact 128-group ternary weights for CPU with a BF16 embedding. All use the default decoder.")
parser.add_argument("--s3gen", type=Path, help="Official s3gen_meanflow.safetensors (defaults to MODEL/native/).")
parser.add_argument("--embedding-type", choices=("orig", "q8_0"),
help="Embedding/tied-head storage; defaults to q8_0 for Q4 exports, otherwise orig.")
parser.add_argument("--s3-license", type=Path, help="Chatterbox LICENSE (defaults to MODEL/native/LICENSE).")
parser.add_argument("--voice-preparer", type=Path, help="Native voice preparer (defaults to the converter's sibling executable).")
parser.add_argument("--voice-backend", choices=("cpu", "cuda"), default="cpu")
parser.add_argument("--threads", type=int, default=8)
parser.add_argument("--prepared-voices", type=Path, help="Use an already prepared, complete voice index.")
parser.add_argument("--voices-output", type=Path, help="Also save the complete prepared voice index for reuse or validation.")
args = parser.parse_args()
if args.output.exists() and not args.overwrite:
parser.error("output exists; pass --overwrite to replace it")
if args.voices_output and args.voices_output.exists() and not args.overwrite:
parser.error("--voices-output exists; pass --overwrite to replace it")
if args.threads < 1:
parser.error("--threads must be positive")
model = args.model.resolve()
decoder_dir = model / "cpp" / "default"
s3gen = (args.s3gen or model / "native" / "s3gen_meanflow.safetensors").resolve()
s3_license = (args.s3_license or model / "native" / "LICENSE").resolve()
speaker = model / "speaker" / "model.safetensors"
source = model / "lm" / ("model-ternary.safetensors" if args.type == "q8_0" else "model.safetensors")
spec_path = Path(__file__).resolve().parents[2] / "model_specs" / "kitten_tts2.json"
if json.loads((model / "config.json").read_text(encoding="utf-8"))["type"] != "KITTEN2":
parser.error("--model must name a Kitten TTS 2 repository")
sidecars = {
"config.json": model / "config.json",
"lm/config.json": model / "lm" / "config.json",
"lm/tokenizer_config.json": model / "lm" / "tokenizer_config.json",
"lm/tokenizer.json": model / "lm" / "tokenizer.json",
"cpp/default/voices.json": args.prepared_voices or decoder_dir / "voices.json",
"LICENSE": model / "LICENSE.md",
"speaker/LICENSE": model / "speaker" / "LICENSE",
"native/LICENSE": s3_license,
}
for path in [source, model / "lm/model.safetensors", s3gen, speaker, args.converter, *sidecars.values()]:
if not path.is_file():
parser.error(f"missing input: {path}")
spec = json.loads(spec_path.read_text(encoding="utf-8"))
args.output.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="kitten-gguf-", dir=args.output.parent) as directory:
root = Path(directory)
import torch
torch.set_num_threads(args.threads)
required = set(json.loads((model / "voices/voices.json").read_text(encoding="utf-8")))
sidecars["cpp/default/voices.json"] = prepare_voices(args, model, s3gen, root, required)
if args.type in ("q8_0", "q4_0", "tq2_1"):
from kitten_tts2_weights import stage_language_model
staged_lm = root / "language-model.gguf"
stage_language_model(source, staged_lm, args.type)
source = staged_lm
# Keep preparation manifests (including local WAV paths) outside the
# converter root: only publish the explicit runtime sidecars below.
package_root = root / "package"
package_root.mkdir()
prepared_spec = package_root / "kitten_tts2.json"
prepared_spec.write_text(json.dumps(spec, indent=2), encoding="utf-8")
notice = package_root / "NOTICE"
notice.write_text(
"This Stellon Labs Model is licensed under the Stellon Labs Community License, "
"Copyright © Stellon Labs Inc. All Rights Reserved\n"
"Powered by Stellon Labs.\n"
"Packaging changes: language-model tensors converted to audio.cpp GGUF; "
"native S3 meanflow and speaker encoder tensors included with prepared voices.\n"
"S3 decoder: ResembleAI/chatterbox-turbo; speaker encoder: pyannote/embedding.\n"
"See native/LICENSE and speaker/LICENSE for their respective terms.\n",
encoding="utf-8",
)
sidecars["NOTICE"] = notice
command = [str(args.converter.resolve()), "--input", f"language_model={source}",
"--input", f"s3gen={s3gen}", "--input", f"speaker={speaker}",
"--output", str(args.output.resolve()), "--type", "orig",
"--keep-type", "language_model/model.embed_tokens.weight=" +
(args.embedding_type or ("q8_0" if args.type == "q4_0" else "orig")),
"--keep-type", "language_model/spk_proj.*=orig",
# These checkpoints are F32, with scalar I64 batch counters.
# Preserve both; forcing scalar counters to F32 is unsupported.
"--keep-type", "s3gen/*=orig", "--keep-type", "speaker/*=orig",
"--family", "kitten_tts2", "--model-spec", str(prepared_spec), "--root", str(package_root)]
for destination, path in sidecars.items():
command += ["--sidecar", f"{path}={destination}"]
if args.overwrite:
command.append("--overwrite")
subprocess.run(command, check=True)
if __name__ == "__main__":
main()