kokoro-kmp-models / scripts /sync_upstream_assets.py
Shusek00's picture
Publish complete Kokoro multilingual catalog v2.0.0
0dbae7f verified
Raw History Blame Contribute Delete
16.7 kB
#!/usr/bin/env python3
"""Download and convert every voice from the pinned official Kokoro releases.
All network requests are anonymous and every downloaded LFS object is checked
against the SHA-256 exposed by the source repository at the pinned revision.
"""
from __future__ import annotations
import argparse
from concurrent.futures import ThreadPoolExecutor, as_completed
import hashlib
import json
from pathlib import Path
import shutil
import tempfile
from typing import Any
from huggingface_hub import HfApi, hf_hub_download
import numpy as np
import requests
import torch
ROOT = Path(__file__).resolve().parents[1]
BASE_REPOSITORY = "hexgrad/Kokoro-82M"
BASE_REVISION = "f3ff3571791e39611d31c381e3a41a3af07b4987"
ZH_REPOSITORY = "hexgrad/Kokoro-82M-v1.1-zh"
ZH_REVISION = "01e7505bd6a7a2ac4975463114c3a7650a9f7218"
LANGUAGE_MODELS_REPOSITORY = "software-mansion/react-native-executorch-kokoro"
LANGUAGE_MODELS_REVISION = "3744b57964eab7df6e8c48f0b84badb29e14df07"
PHONEMIS_REPOSITORY = "IgorSwat/Phonemis"
PHONEMIS_REVISION = "71eb1ce33bd586d38cbac037843b8539d7829c3b"
EXPECTED_VOICE_COUNTS = {
BASE_REPOSITORY: 54,
ZH_REPOSITORY: 103,
}
MODEL_FILE_SHA256 = {
(BASE_REPOSITORY, "config.json"): "5abb01e2403b072bf03d04fde160443e209d7a0dad49a423be15196b9b43c17f",
(BASE_REPOSITORY, "kokoro-v1_0.pth"): "496dba118d1a58f5f3db2efc88dbdc216e0483fc89fe6e47ee1f2c53f18ad1e4",
(ZH_REPOSITORY, "config.json"): "bc333efa5ce4ceff433c8c8e5d027a1eca0166001e4e4a62bea2d26ff7a46890",
(ZH_REPOSITORY, "kokoro-v1_1-zh.pth"): "b1d8410fa44dfb5c15471fd6c4225ea6b4e9ac7fa03c98e8bea47a9928476e2b",
}
LANGUAGE_MODEL_FILES = {
"finetunes/kokoro_polish_converted.pth": (
"kokoro-pl-fp32",
"e3202dc4d1f6e65dddff8a8e8d2e091ce9a3a66cf989c6c581a81b2f1969af49",
),
"finetunes/kokoro_german_converted.pth": (
"kokoro-de-fp32",
"b8b2ab322963e7662c6036035c76c34a6a5582f814917407c94d632a1c930f71",
),
}
LANGUAGE_VOICE_FILES = {
"voices/pm_mateusz.bin": (
"voices/pl/pm_mateusz.bin",
"dc8f2919ede945e6962310b5204b912e64689d5698c571822bf75ad704870cb4",
),
"voices/df_anna.bin": (
"voices/de/df_anna.bin",
"d583ccff3cdca2f7fae535cb998ac07e9fcb90f09737b9a41fa2734ec44a8f0b",
),
}
LANGUAGE_BY_PREFIX = {
"a": "en-us",
"b": "en-gb",
"e": "es",
"f": "fr",
"h": "hi",
"i": "it",
"j": "ja",
"p": "pt-br",
"z": "zh",
}
PHONEMIS_FILES = {
"data/de/phonemizer_de.bin": (
"phonemizers/de/phonemizer_de.bin",
"4888dc7e54dc66098551555096562063364091fc246da0d057b629587bedaa0b",
7_094_120,
),
"data/en-gb/lexicon_full.json": (
"phonemizers/en-gb/lexicon_full.json",
"52167ca536a93d56a02e8b1db29f572438cb7103737ef2c3f8dd9dbfc0b35e0a",
6_664_570,
),
"data/en-gb/phonemizer_en_gb.bin": (
"phonemizers/en-gb/phonemizer_en_gb.bin",
"3d4fe5a541c02de30879a5b84b88f5229f5b31f8fb2dcf7e89a1ce3a6c330334",
7_094_120,
),
"data/en-gb/tagger.json": (
"phonemizers/en-gb/tagger.json",
"af2fe9831e8560fa78ebf7d96da715ce5ecb43a3363bd701c952a6db206f169c",
1_634_732,
),
"data/en-us/lexicon_full.json": (
"phonemizers/en-us/lexicon_full.json",
"ef0b19a0126455e4216fb08083c8b50f7e85f98f6055738129a89d2095e635d7",
6_254_347,
),
"data/en-us/phonemizer_en_us.bin": (
"phonemizers/en-us/phonemizer_en_us.bin",
"e059561fb8d51eadfd2000965be30e31f0152e7e8c8b4fcf7859dbcce8557576",
7_094_120,
),
"data/en-us/tagger.json": (
"phonemizers/en-us/tagger.json",
"af2fe9831e8560fa78ebf7d96da715ce5ecb43a3363bd701c952a6db206f169c",
1_634_732,
),
"data/es/phonemizer_es.bin": (
"phonemizers/es/phonemizer_es.bin",
"8dc68946e12c1a233ac9153fd369f6418b930a3d731587ff64dc789f44d75a52",
7_094_120,
),
"data/fr/phonemizer_fr.bin": (
"phonemizers/fr/phonemizer_fr.bin",
"fa0018b750a3670328026107b44e02eafe3223b6878585ca0927786b7812d96a",
7_094_120,
),
"data/hi/phonemizer_hi.bin": (
"phonemizers/hi/phonemizer_hi.bin",
"dcee3272f96d7f1b7cc40c5df23060b502a5b9066f7cb20092413dff82a487f5",
9_194_344,
),
"data/it/phonemizer_it.bin": (
"phonemizers/it/phonemizer_it.bin",
"dca8d068d76134a40856cd874ea6e5e05c988914369ab643f52ab77a512067e3",
7_094_120,
),
"data/pl/phonemizer_pl.bin": (
"phonemizers/pl/phonemizer_pl.bin",
"ec85f4dc2c4ac7a72ff88b98b0664a4ed887bd15c0d5add2eb1d6a6ee05b73f2",
7_094_120,
),
"data/pt/phonemizer_pt.bin": (
"phonemizers/pt-br/phonemizer_pt.bin",
"89049ea03c52ffa7233a343d44a35059aae3b1231d772c8c498fbc4427756ecf",
7_094_120,
),
}
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for block in iter(lambda: source.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def download_hf_file(
repository: str,
revision: str,
filename: str,
cache_dir: Path,
) -> Path:
return Path(
hf_hub_download(
repository,
filename=filename,
revision=revision,
cache_dir=cache_dir,
token=False,
)
)
def convert_voice(source: Path, destination: Path) -> None:
voice = torch.load(source, map_location="cpu", weights_only=True)
if not isinstance(voice, torch.Tensor):
raise TypeError(f"Expected a tensor in {source}, got {type(voice).__name__}")
values = voice.detach().cpu().numpy().astype("<f4", copy=False)
if values.size != 510 * 256:
raise RuntimeError(f"Unexpected voice shape {values.shape} in {source}")
values = values.reshape(510, 256)
if not np.isfinite(values).all():
raise RuntimeError(f"Non-finite values in {source}")
destination.parent.mkdir(parents=True, exist_ok=True)
values.tofile(destination)
def voice_display_name(voice_id: str) -> str:
name = voice_id.split("_", 1)[1]
if name.isdigit():
gender = "Female" if voice_id[1] == "f" else "Male"
return f"Chinese {gender} {name}"
return name.replace("_", " ").title()
def list_voice_files(api: HfApi, repository: str, revision: str) -> list[Any]:
entries = [
entry
for entry in api.list_repo_tree(
repository,
revision=revision,
recursive=True,
expand=True,
token=False,
)
if entry.path.startswith("voices/") and entry.path.endswith(".pt")
]
expected = EXPECTED_VOICE_COUNTS[repository]
if len(entries) != expected:
raise RuntimeError(
f"Expected {expected} voices in {repository}@{revision}, found {len(entries)}"
)
if any(entry.lfs is None for entry in entries):
raise RuntimeError(f"A voice in {repository}@{revision} has no LFS metadata")
return sorted(entries, key=lambda entry: entry.path)
def sync_voices(
cache_dir: Path, workers: int
) -> tuple[list[dict[str, Any]], dict[str, str], list[dict[str, Any]]]:
api = HfApi(token=False)
sources = [
(BASE_REPOSITORY, BASE_REVISION, "kokoro-v1.0-fp32"),
(ZH_REPOSITORY, ZH_REVISION, "kokoro-v1.1-zh-fp32"),
]
jobs: list[tuple[str, str, str, Any]] = []
for repository, revision, model_id in sources:
for entry in list_voice_files(api, repository, revision):
jobs.append((repository, revision, model_id, entry))
downloaded: dict[tuple[str, str], Path] = {}
with ThreadPoolExecutor(max_workers=workers) as executor:
futures = {
executor.submit(
download_hf_file,
repository,
revision,
entry.path,
cache_dir,
): (repository, entry.path)
for repository, revision, _model_id, entry in jobs
}
for index, future in enumerate(as_completed(futures), start=1):
repository, path = futures[future]
downloaded[(repository, path)] = future.result()
if index % 20 == 0 or index == len(futures):
print(f"Downloaded {index}/{len(futures)} voice tensors")
inventory = []
for repository, revision, model_id, entry in jobs:
source = downloaded[(repository, entry.path)]
if source.stat().st_size != entry.lfs.size or sha256(source) != entry.lfs.sha256:
raise RuntimeError(f"Source LFS verification failed: {repository}/{entry.path}")
voice_id = Path(entry.path).stem
language_id = LANGUAGE_BY_PREFIX[voice_id[0]]
destination = ROOT / "voices" / language_id / f"{voice_id}.bin"
convert_voice(source, destination)
inventory.append(
{
"id": voice_id,
"languageId": language_id,
"displayName": voice_display_name(voice_id),
"gender": "female" if voice_id[1] == "f" else "male",
"modelId": model_id,
"sourceRepository": repository,
"sourceRevision": revision,
"sourcePath": entry.path,
"sourceSizeBytes": entry.lfs.size,
"sourceSha256": entry.lfs.sha256,
"artifactPath": destination.relative_to(ROOT).as_posix(),
"artifactSizeBytes": destination.stat().st_size,
"artifactSha256": sha256(destination),
}
)
checkpoints: dict[str, str] = {}
model_files = [
(
BASE_REPOSITORY,
BASE_REVISION,
"kokoro-v1_0.pth",
"runtime/kokoro-v1.0-config.json",
"kokoro-v1.0-fp32",
),
(
ZH_REPOSITORY,
ZH_REVISION,
"kokoro-v1_1-zh.pth",
"runtime/kokoro-v1.1-zh-config.json",
"kokoro-v1.1-zh-fp32",
),
]
for repository, revision, checkpoint_name, config_target, model_id in model_files:
checkpoint = download_hf_file(
repository, revision, checkpoint_name, cache_dir
)
config = download_hf_file(repository, revision, "config.json", cache_dir)
if sha256(checkpoint) != MODEL_FILE_SHA256[(repository, checkpoint_name)]:
raise RuntimeError(f"Checkpoint verification failed: {repository}/{checkpoint_name}")
if sha256(config) != MODEL_FILE_SHA256[(repository, "config.json")]:
raise RuntimeError(f"Config verification failed: {repository}/config.json")
target = ROOT / config_target
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(config, target)
checkpoints[model_id] = str(checkpoint.resolve())
language_model_inventory = []
for source_path, (model_id, expected_sha) in LANGUAGE_MODEL_FILES.items():
checkpoint = download_hf_file(
LANGUAGE_MODELS_REPOSITORY,
LANGUAGE_MODELS_REVISION,
source_path,
cache_dir,
)
if sha256(checkpoint) != expected_sha:
raise RuntimeError(f"Checkpoint verification failed: {source_path}")
checkpoints[model_id] = str(checkpoint.resolve())
language_model_inventory.append(
{
"role": "checkpoint",
"modelId": model_id,
"sourcePath": source_path,
"sourceSizeBytes": checkpoint.stat().st_size,
"sourceSha256": expected_sha,
}
)
for source_path, (target_path, expected_sha) in LANGUAGE_VOICE_FILES.items():
source = download_hf_file(
LANGUAGE_MODELS_REPOSITORY,
LANGUAGE_MODELS_REVISION,
source_path,
cache_dir,
)
if sha256(source) != expected_sha:
raise RuntimeError(f"Voice verification failed: {source_path}")
destination = ROOT / target_path
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source, destination)
language_model_inventory.append(
{
"role": "voice",
"sourcePath": source_path,
"sourceSizeBytes": source.stat().st_size,
"sourceSha256": expected_sha,
"artifactPath": target_path,
"artifactSizeBytes": destination.stat().st_size,
"artifactSha256": sha256(destination),
}
)
expected_voice_paths = {item["artifactPath"] for item in inventory}
expected_voice_paths.update(target for target, _sha in LANGUAGE_VOICE_FILES.values())
actual_voice_paths = {
path.relative_to(ROOT).as_posix()
for path in (ROOT / "voices").glob("*/*.bin")
}
if actual_voice_paths != expected_voice_paths:
raise RuntimeError(
"Voice directory does not exactly match the pinned inventory: "
f"missing={sorted(expected_voice_paths - actual_voice_paths)}, "
f"unexpected={sorted(actual_voice_paths - expected_voice_paths)}"
)
return inventory, checkpoints, language_model_inventory
def download_verified(url: str, destination: Path, expected_sha256: str, size: int) -> None:
if destination.is_file() and destination.stat().st_size == size:
if sha256(destination) == expected_sha256:
return
destination.parent.mkdir(parents=True, exist_ok=True)
temporary = destination.with_suffix(destination.suffix + ".download")
with requests.get(url, stream=True, timeout=120) as response:
response.raise_for_status()
with temporary.open("wb") as output:
for block in response.iter_content(1024 * 1024):
if block:
output.write(block)
if temporary.stat().st_size != size or sha256(temporary) != expected_sha256:
raise RuntimeError(f"Downloaded file verification failed: {url}")
temporary.replace(destination)
def sync_phonemis() -> list[dict[str, Any]]:
inventory = []
for source_path, (target_path, expected_sha, expected_size) in PHONEMIS_FILES.items():
url = (
"https://media.githubusercontent.com/media/"
f"{PHONEMIS_REPOSITORY}/{PHONEMIS_REVISION}/{source_path}"
)
destination = ROOT / target_path
download_verified(url, destination, expected_sha, expected_size)
inventory.append(
{
"sourcePath": source_path,
"sourceSizeBytes": expected_size,
"sourceSha256": expected_sha,
"artifactPath": target_path,
}
)
return inventory
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument(
"--cache-dir",
type=Path,
default=Path(tempfile.gettempdir()) / "kokoro-kmp-hf-cache",
)
parser.add_argument("--workers", type=int, default=8)
args = parser.parse_args()
if not 1 <= args.workers <= 16:
raise SystemExit("--workers must be in 1..16")
voices, checkpoints, language_models = sync_voices(args.cache_dir, args.workers)
phonemis = sync_phonemis()
manifest = {
"sourceRepositories": {
"kokoroBase": {
"repository": BASE_REPOSITORY,
"revision": BASE_REVISION,
},
"kokoroChinese": {
"repository": ZH_REPOSITORY,
"revision": ZH_REVISION,
},
"languageModels": {
"repository": LANGUAGE_MODELS_REPOSITORY,
"revision": LANGUAGE_MODELS_REVISION,
},
"phonemis": {
"repository": PHONEMIS_REPOSITORY,
"revision": PHONEMIS_REVISION,
},
},
"voiceCount": len(voices),
"catalogVoiceCount": len(voices) + len(LANGUAGE_VOICE_FILES),
"voices": voices,
"languageModels": language_models,
"phonemis": phonemis,
}
source_dir = ROOT / "sources"
source_dir.mkdir(parents=True, exist_ok=True)
(source_dir / "upstream-assets.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
checkpoint_manifest = Path(tempfile.gettempdir()) / "kokoro-kmp-checkpoints.json"
checkpoint_manifest.write_text(
json.dumps(checkpoints, indent=2) + "\n", encoding="utf-8"
)
print(f"Converted {len(voices)} upstream voices")
print(f"Checkpoint paths: {checkpoint_manifest}")
if __name__ == "__main__":
main()