makepad/libs/tts/tools/convert_kokoro.py
Admin 449f51be7a gamemaker prerequisites: makepad-tts library, TTS/whisper model downloader, aigame design docs
libs/tts was never tracked despite being a build dependency of the gamemaker
example. tools/download_tts.sh fetches the public upstream weights (HuggingFace
Kokoro-82M + whisper.cpp) and converts them locally with the in-repo stdlib-only
converter; model artifacts are gitignored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 13:29:22 +02:00

120 lines
4 KiB
Python

#!/usr/bin/env python3
"""Convert Kokoro's `.pth` weights into the flat `.mktts` file the Rust loader reads.
Standard library only — a `.pth` is a zip around a pickle, so torch and numpy are
not needed. Tensors are copied verbatim; PyTorch's weight-norm pairs
(`weight_g` / `weight_v`) are left intact and reconstructed on the Rust side,
where `W = g * v / ||v||` is cheap.
python3 convert_kokoro.py kokoro-v1_0.pth kokoro-v1_0.mktts
python3 convert_kokoro.py --voice kokoro_voices/af_heart.pt af_heart.mkvoice
Format (little endian):
magic b"MKTTS\\0\\0\\0" 8 bytes
version u32 = 1
count u32 number of tensors
index count * {
name_len u32, name utf8,
dtype u8 0 = f32
ndim u8
shape ndim * u32
offset u64 from start of file
nbytes u64
}
blob tensor data, each 32-byte aligned
"""
import struct
import sys
import zipfile
sys.path.insert(0, __file__.rsplit("/", 1)[0])
from inspect_pth import DTYPES, Storage, Tensor, load, walk # noqa: E402
MAGIC = b"MKTTS\0\0\0"
VERSION = 1
ALIGN = 32
def storage_bytes(archive, prefix, storage, cache):
"""Raw bytes of one storage record, cached — tensors can share storage."""
if storage.key not in cache:
with archive.open(f"{prefix}data/{storage.key}") as handle:
cache[storage.key] = handle.read()
return cache[storage.key]
def tensor_bytes(archive, prefix, tensor, cache):
dtype, itemsize = DTYPES[tensor.storage.dtype]
if dtype != "f32":
raise SystemExit(f"unsupported dtype {dtype}; expected all-f32 weights")
raw = storage_bytes(archive, prefix, tensor.storage, cache)
start = tensor.offset * itemsize
end = start + tensor.numel * itemsize
if end > len(raw):
raise SystemExit(f"tensor overruns its storage ({end} > {len(raw)})")
return raw[start:end]
def collect(path):
root = load(path)
if isinstance(root, Tensor):
return [("style", root)] # a voice pack is a bare tensor
return list(walk(root))
def convert(src, dst):
tensors = collect(src)
if not tensors:
raise SystemExit(f"no tensors found in {src}")
with zipfile.ZipFile(src) as archive:
pkl = next(n for n in archive.namelist() if n.endswith("data.pkl"))
prefix = pkl[: -len("data.pkl")]
cache = {}
payloads = [tensor_bytes(archive, prefix, t, cache) for _, t in tensors]
# Lay out the index first so offsets are known before anything is written.
index_size = 4 + 4 # version + count (magic written separately)
for (name, tensor), _ in zip(tensors, payloads):
index_size += 4 + len(name.encode()) + 1 + 1 + 4 * len(tensor.shape) + 8 + 8
offset = len(MAGIC) + index_size
offsets = []
for payload in payloads:
offset = (offset + ALIGN - 1) // ALIGN * ALIGN
offsets.append(offset)
offset += len(payload)
with open(dst, "wb") as out:
out.write(MAGIC)
out.write(struct.pack("<II", VERSION, len(tensors)))
for ((name, tensor), payload, at) in zip(tensors, payloads, offsets):
encoded = name.encode()
out.write(struct.pack("<I", len(encoded)))
out.write(encoded)
out.write(struct.pack("<BB", 0, len(tensor.shape)))
for dim in tensor.shape:
out.write(struct.pack("<I", dim))
out.write(struct.pack("<QQ", at, len(payload)))
for payload, at in zip(payloads, offsets):
out.write(b"\0" * (at - out.tell()))
out.write(payload)
total = sum(t.numel for _, t in tensors)
print(f"{src} -> {dst}")
print(f" tensors : {len(tensors)}")
print(f" params : {total/1e6:.2f}M")
print(f" bytes : {offset/1048576:.1f} MB")
def main():
args = [a for a in sys.argv[1:] if a != "--voice"]
if len(args) != 2:
raise SystemExit(__doc__)
convert(args[0], args[1])
if __name__ == "__main__":
main()