"Express" is also TD Snap's page-set name in the same field. The standard's reference app is now called the AAC Reference App: READMEs, crate docs, tool docs and the page-set design notes (which now say it was renamed). Its default title, without AAC_APP_TITLE, and its manifest name are "AAC — reference app". Code identifiers are unchanged. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1292 lines
67 KiB
Python
1292 lines
67 KiB
Python
#!/usr/bin/env python3.14
|
|
"""Build the neural voice packs for the AAC Reference App (aac-web) and their
|
|
catalogue.
|
|
|
|
Every input is pinned: GitHub and Hugging Face files by commit, the npm
|
|
runtime by version and registry integrity, and every file (including the
|
|
Moonshine CDN files, whose URLs carry no revision) by exact size and SHA-256
|
|
in tools/voices/sources.lock.json. Sources are downloaded once into
|
|
tools/voices/.cache/ (git-ignored) and checked on every run; a mismatch stops
|
|
the build.
|
|
|
|
Outputs (the pack format of the aac voices plan, schema "aac-voice-index/1"):
|
|
|
|
../aac-voice-packs/ beside the open-standards checkout ($AAC_VOICES_DIR
|
|
overrides; --out too): what the site builds copy
|
|
to /app/voices/ after tools/voices/check_packs.py
|
|
index.json pointer {schema, current}; serve no-store
|
|
index.<sha12>.json the immutable catalogue
|
|
LICENSES.md attribution for every runtime, component and voice
|
|
runtime/moonshine-wasm-0.1.5-<sha12>/ moonshine.mjs, moonshine.wasm, voice-worker.js
|
|
c/kokoro/<file>.<sha12>.<ext> shared Kokoro-82M core
|
|
c/g2p/<lang>/<file>.<sha12>.<ext> Moonshine G2P data per language
|
|
v/<lang>/<voice>.<sha12>.<ext> .kokorovoice, or Piper .ort + .onnx.json
|
|
licences/<id>.<sha12>.txt full licence and notice texts
|
|
data/voices/index.json committed copy of the catalogue
|
|
data/voices/LICENSES.md committed copy of LICENSES.md
|
|
|
|
Runtime files keep their npm names inside a content-addressed directory,
|
|
because Emscripten's pthread bootstrap does `new Worker(new URL("moonshine.mjs",
|
|
import.meta.url))` and the ES modules import one another by name: renaming the
|
|
files themselves would break both.
|
|
|
|
Piper voices are converted from upstream .onnx to one ORT-format model with
|
|
onnxruntime 1.23.2 (Moonshine 0.1.5 cannot load its split-stage Piper files
|
|
from memory). The conversion runs in a pinned venv under .cache/ with address
|
|
space layout randomisation switched off, because ONNX Runtime orders one
|
|
table of the ORT file by an address-seeded hash; with ASLR off the output is
|
|
byte-stable and its SHA-256 is pinned in the lock like any download.
|
|
|
|
Usage (from aac/aac-rs):
|
|
python3.14 tools/voices/build_voices.py # build, verify every pin
|
|
python3.14 tools/voices/build_voices.py --update-lock # record pins for new sources
|
|
python3.14 tools/voices/build_voices.py --offline # cache only, no network
|
|
node tools/voices/smoke.mjs # synthesize with every pack
|
|
|
|
Standard library only (Python >= 3.11). The Piper conversion needs a
|
|
python3.12 or python3.13 on PATH (onnxruntime 1.23.2 has no 3.14 wheels) and
|
|
network access the first time, to install the pinned wheels into
|
|
.cache/ort-venv/.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import concurrent.futures
|
|
import ctypes
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import os
|
|
import platform
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
ROOT = HERE.parents[1] # aac/aac-rs
|
|
CACHE = HERE / ".cache"
|
|
LOCK = HERE / "sources.lock.json"
|
|
# Where the site builds (aac-aurasense-web's build-app.sh, prerender-static.sh) read the packs.
|
|
OUT_DEFAULT = Path(os.environ.get("AAC_VOICES_DIR") or ROOT.parents[2] / "aac-voice-packs")
|
|
COMMITTED = ROOT / "data/voices"
|
|
# Each voice's sample, heard before the voice is downloaded: rendered once by
|
|
# render_samples.mjs, listened to, and committed (synthesis is not repeatable:
|
|
# Piper samples its durations), with samples/index.json saying what each says.
|
|
SAMPLES = HERE / "samples"
|
|
I18N_RS = ROOT / "crates/aac-core/src/i18n.rs"
|
|
WORKER_CANDIDATES = (
|
|
ROOT / "third_party/moonshine-wasm/voice-worker.js",
|
|
ROOT / "crates/aac-web/static/voices/voice-worker.js",
|
|
HERE / "voice-worker.js",
|
|
)
|
|
|
|
SCHEMA = "aac-voice-index/1"
|
|
LOCK_SCHEMA = "aac-voice-sources-lock/1"
|
|
# Date the pins and licences were read at their primary sources. It is also the
|
|
# catalogue's `generated` date and the mtime of every output file, so a rebuild
|
|
# is byte-identical; bump it whenever a pin changes.
|
|
RETRIEVED = "2026-09-29"
|
|
EPOCH = int(datetime.fromisoformat(RETRIEVED).replace(tzinfo=timezone.utc).timestamp())
|
|
|
|
# ─── Pinned revisions (read 2026-09-29) ─────────────────────────────────────
|
|
|
|
MOONSHINE_REPO = "moonshine-ai/moonshine"
|
|
MOONSHINE_COMMIT = "234f60faa0eb388b01cdf7e60aca232af37aefda" # main, 2026-08-24T21:24Z
|
|
NPM_NAME = "@moonshine-ai/moonshine-wasm"
|
|
NPM_VERSION = "0.1.5"
|
|
NPM_TARBALL = "https://registry.npmjs.org/@moonshine-ai/moonshine-wasm/-/moonshine-wasm-0.1.5.tgz"
|
|
NPM_INTEGRITY = "sha512-b90GVFkbuQQthW8wZFU/dvJpnnsISHSSxs/p6Xv/NMfaHotLhRp0e9Risy5/np6QSF3SFust3jGE3Uj2LdecmQ=="
|
|
RUNTIME_ID = f"moonshine-wasm@{NPM_VERSION}"
|
|
RUNTIME_DIR_STEM = f"moonshine-wasm-{NPM_VERSION}"
|
|
CDN = "https://download.moonshine.ai/tts/" # unversioned: pinned by size + SHA-256 in the lock
|
|
|
|
PIPER_REPO = "rhasspy/piper-voices"
|
|
PIPER_REV = "c10ece1aade47bb51c153c893d14e5bf8e5b7117"
|
|
KOKORO_REPO = "hexgrad/Kokoro-82M"
|
|
KOKORO_REV = "f3ff3571791e39611d31c381e3a41a3af07b4987"
|
|
STYLETTS2_COMMIT = "5cedc71c333f8d8b8551ca59378bdcc7af4c9529"
|
|
SPDX_COMMIT = "31ba1a50e5397e00a304dbadc76531740e89ee48" # spdx/license-list-data tag v3.29.0
|
|
IPADICT_COMMIT = "43c3570eb3553bdd19fccd2bd0091534889af023"
|
|
CMUDICT_COMMIT = "74790861f652b15e4ac49015a90074ad62a27690"
|
|
GERMAN_IPA_COMMIT = "a08113e0ab80f049b625fc360196491f56243ca1"
|
|
FRENCH_DICT_COMMIT = "f3931745ed208c702342e4aae14f4a9dfa711e9a"
|
|
FREQWORDS_COMMIT = "525f9b560de45753a5ea01069454e72e9aa541c6"
|
|
WIKIPRON_COMMIT = "d282e848a211ea31cfd730f0ced8bc8cdab9e83d"
|
|
OHF_DATASETS_COMMIT = "8ed76fa9001fafc77db32bd682f434938c679e3e"
|
|
RDH_COMMIT = "ddde8826adac6a0ce212a413c3153b5595c72c27"
|
|
JA_ROBERTA_REV = "bc79c53ec770967828a21e1929c712fd8c29e1e3"
|
|
ZH_ROBERTA_REV = "2e15617eb11e0dec06d76780d1a873987a9fef56"
|
|
|
|
# Piper conversion: onnxruntime 1.23.2 matches the ORT 1.23 core linked into
|
|
# moonshine.wasm. The dependency set is the one the conversion was verified with.
|
|
ORT_PINS = (
|
|
"onnxruntime==1.23.2", "coloredlogs==15.0.1", "humanfriendly==10.0",
|
|
"flatbuffers==25.12.19", "numpy==2.5.3", "packaging==26.3",
|
|
"protobuf==7.36.2", "sympy==1.14.0", "mpmath==1.3.0",
|
|
"onnx==1.23.0", "ml_dtypes==0.6.0", "typing_extensions==4.16.0",
|
|
)
|
|
CONVERT_ARGS = ("--optimization_style", "Fixed", "--target_platform", "arm")
|
|
CONVERT_TOOL = "onnxruntime==1.23.2 convert_onnx_models_to_ort " + " ".join(CONVERT_ARGS) + " (ASLR off)"
|
|
|
|
|
|
def gh(repo: str, commit: str, path: str) -> str:
|
|
return f"https://raw.githubusercontent.com/{repo}/{commit}/{path}"
|
|
|
|
|
|
def hf(repo: str, rev: str, path: str) -> str:
|
|
return f"https://huggingface.co/{repo}/resolve/{rev}/{path}"
|
|
|
|
|
|
def spdx(ident: str) -> str:
|
|
return gh("spdx/license-list-data", SPDX_COMMIT, f"text/{ident}.txt")
|
|
|
|
|
|
def ms(path: str) -> str:
|
|
return gh(MOONSHINE_REPO, MOONSHINE_COMMIT, path)
|
|
|
|
|
|
def ms_data(lang: str) -> str:
|
|
return ms(f"core/moonshine-tts/data/{lang}/README.md")
|
|
|
|
|
|
IPADICT_README = gh("open-dict-data/ipa-dict", IPADICT_COMMIT, "README.md")
|
|
IPADICT_LICENSE = gh("open-dict-data/ipa-dict", IPADICT_COMMIT, "LICENSE")
|
|
|
|
# ─── Shared components ──────────────────────────────────────────────────────
|
|
|
|
|
|
@dataclass
|
|
class Notice:
|
|
title: str
|
|
url: str
|
|
|
|
|
|
@dataclass
|
|
class Component:
|
|
id: str
|
|
kind: str # kokoro-core | g2p
|
|
names: list[str] # Moonshine asset keys, served from the CDN
|
|
licence: str
|
|
licence_url: str
|
|
attribution: str
|
|
notices: list[Notice]
|
|
licence_texts: list[str] # SPDX ids
|
|
note: str = ""
|
|
|
|
|
|
KOKORO_CORE = Component(
|
|
id="kokoro-core@1",
|
|
kind="kokoro-core",
|
|
names=[
|
|
"kokoro/prosody.model.ort", "kokoro/prosody.weights.ort",
|
|
"kokoro/decoder.model.ort", "kokoro/decoder.weights.ort", "kokoro/config.json",
|
|
],
|
|
licence="Apache-2.0",
|
|
licence_url=f"https://huggingface.co/{KOKORO_REPO}/blob/{KOKORO_REV}/README.md",
|
|
attribution=(
|
|
"Kokoro-82M v1.0 by hexgrad (Apache-2.0), built on StyleTTS 2 by Yinghao Aaron Li et al. "
|
|
"Packed to int8 ORT stages by Moonshine AI. Its training data includes the SIWIS French "
|
|
"Speech Synthesis Database (University of Edinburgh, CC BY 4.0) and Koniwa tnc "
|
|
"(CC BY 3.0). All speech from this model is synthesized."
|
|
),
|
|
notices=[
|
|
Notice("Kokoro-82M model card (licence, training data, CC BY attribution)", hf(KOKORO_REPO, KOKORO_REV, "README.md")),
|
|
Notice("Moonshine: how the Kokoro ORT bundle was made", ms_data("kokoro")),
|
|
Notice("StyleTTS 2 README (pre-trained model condition: tell listeners the speech is synthesized)",
|
|
gh("yl4579/StyleTTS2", STYLETTS2_COMMIT, "README.md")),
|
|
Notice("StyleTTS 2 licence", gh("yl4579/StyleTTS2", STYLETTS2_COMMIT, "LICENSE")),
|
|
],
|
|
licence_texts=["Apache-2.0", "CC-BY-4.0", "CC-BY-3.0"],
|
|
)
|
|
|
|
G2P: dict[str, Component] = {}
|
|
|
|
|
|
def g2p(lang: str, names: list[str], licence: str, licence_url: str, attribution: str,
|
|
notices: list[Notice], texts: list[str], note: str = "") -> None:
|
|
G2P[lang] = Component(f"g2p-{lang}@1", "g2p", names, licence, licence_url, attribution,
|
|
[Notice(f"Moonshine {lang} data provenance", ms_data(lang)), *notices], texts, note)
|
|
|
|
|
|
g2p("en_us",
|
|
["en_us/dict_filtered_heteronyms.tsv", "en_us/g2p-config.json", "en_us/oov/model.ort", "en_us/oov/onnx-config.json"],
|
|
"BSD-2-Clause AND MIT",
|
|
f"https://github.com/cmusphinx/cmudict/blob/{CMUDICT_COMMIT}/LICENSE",
|
|
"English pronunciations from the CMU Pronouncing Dictionary, Copyright (C) 1993-2015 Carnegie "
|
|
"Mellon University. Out-of-vocabulary model and G2P by Moonshine AI (MIT).",
|
|
[Notice("CMU Pronouncing Dictionary licence", gh("cmusphinx/cmudict", CMUDICT_COMMIT, "LICENSE"))],
|
|
["BSD-2-Clause", "MIT"])
|
|
g2p("fr",
|
|
["fr/dict.tsv", "fr/adj.csv", "fr/adv.csv", "fr/conj.csv", "fr/det.csv",
|
|
"fr/noun.csv", "fr/prep.csv", "fr/pron.csv", "fr/verb.csv"],
|
|
"MIT",
|
|
f"https://github.com/open-dict-data/ipa-dict/blob/{IPADICT_COMMIT}/README.md#license",
|
|
"French pronunciations from ipa-dict by open-dict-data (MIT). Liaison word lists after "
|
|
"hbenbel/French-Dictionary (MIT).",
|
|
[Notice("ipa-dict README (credits and licence)", IPADICT_README),
|
|
Notice("ipa-dict licence", IPADICT_LICENSE),
|
|
Notice("French-Dictionary licence", gh("hbenbel/French-Dictionary", FRENCH_DICT_COMMIT, "LICENSE"))],
|
|
["MIT"])
|
|
g2p("de", ["de/dict.tsv"],
|
|
"CC-BY-SA-3.0",
|
|
f"https://github.com/devio-at/german-ipa-dict/blob/{GERMAN_IPA_COMMIT}/LICENSE.md",
|
|
"German pronunciations from german-ipa-dict by devio-at, extracted from Wiktionary (CC BY-SA), "
|
|
"via ipa-dict by open-dict-data.",
|
|
[Notice("german-ipa-dict licence", gh("devio-at/german-ipa-dict", GERMAN_IPA_COMMIT, "LICENSE.md")),
|
|
Notice("german-ipa-dict README", gh("devio-at/german-ipa-dict", GERMAN_IPA_COMMIT, "README.md")),
|
|
Notice("ipa-dict README (credits and licence)", IPADICT_README)],
|
|
["CC-BY-SA-3.0"],
|
|
"german-ipa-dict names Wiktionary's CC BY-SA without a version; it was extracted in February 2023, "
|
|
"when Wiktionary text was CC BY-SA 3.0.")
|
|
g2p("nl", ["nl/dict.tsv"],
|
|
"CC-BY",
|
|
f"https://github.com/open-dict-data/ipa-dict/blob/{IPADICT_COMMIT}/README.md#credits",
|
|
"Dutch pronunciations provided by the Instituut voor de Nederlandse Taal (INT) under a CC BY "
|
|
"licence, via ipa-dict by open-dict-data.",
|
|
[Notice("ipa-dict README (credits and licence)", IPADICT_README)],
|
|
["CC-BY-4.0"],
|
|
"ipa-dict states 'a CC BY license' for the INT data without a version; the CC BY 4.0 text is "
|
|
"included as the current version for reference.")
|
|
g2p("hi", ["hi/dict.tsv"],
|
|
"CC-BY-SA-4.0",
|
|
f"https://github.com/{MOONSHINE_REPO}/blob/{MOONSHINE_COMMIT}/core/moonshine-tts/data/hi/README.md",
|
|
"Hindi pronunciations from English Wiktionary (CC BY-SA); word ranking from "
|
|
"hermitdave/FrequencyWords (content CC BY-SA 4.0, OpenSubtitles-derived).",
|
|
[Notice("FrequencyWords README (content licence)", gh("hermitdave/FrequencyWords", FREQWORDS_COMMIT, "README.md"))],
|
|
["CC-BY-SA-4.0"])
|
|
g2p("it", ["it/dict.tsv"],
|
|
"CC-BY-SA-4.0",
|
|
f"https://github.com/CUNY-CL/wikipron/blob/{WIKIPRON_COMMIT}/README.md#license",
|
|
"Italian pronunciations scraped from Wiktionary (CC BY-SA) with WikiPron by CUNY-CL (Apache-2.0).",
|
|
[Notice("WikiPron README (licence of tool and data)", gh("CUNY-CL/wikipron", WIKIPRON_COMMIT, "README.md")),
|
|
Notice("WikiPron licence", gh("CUNY-CL/wikipron", WIKIPRON_COMMIT, "LICENSE.txt"))],
|
|
["CC-BY-SA-4.0", "Apache-2.0"])
|
|
g2p("pt_br", ["pt_br/dict.tsv"],
|
|
"MIT",
|
|
f"https://github.com/open-dict-data/ipa-dict/blob/{IPADICT_COMMIT}/README.md#license",
|
|
"Brazilian Portuguese pronunciations from ipa-dict by open-dict-data (MIT).",
|
|
[Notice("ipa-dict README (credits and licence)", IPADICT_README), Notice("ipa-dict licence", IPADICT_LICENSE)],
|
|
["MIT"])
|
|
g2p("ja",
|
|
["ja/dict.tsv", "ja/roberta_japanese_char_luw_upos_onnx/meta.json",
|
|
"ja/roberta_japanese_char_luw_upos_onnx/vocab.txt",
|
|
"ja/roberta_japanese_char_luw_upos_onnx/tokenizer_config.json",
|
|
"ja/roberta_japanese_char_luw_upos_onnx/model.ort"],
|
|
"CC-BY-SA-4.0",
|
|
"https://www.edrdg.org/edrdg/licence.html",
|
|
"Japanese pronunciations from EDICT, the property of the Electronic Dictionary Research and "
|
|
"Development Group, used in conformance with the Group's licence (CC BY-SA 4.0; "
|
|
"https://www.edrdg.org/wiki/index.php/JMdict-EDICT_Dictionary_Project), via ipa-dict. "
|
|
"Word segmentation: roberta-small-japanese-char-luw-upos by Koichi Yasuoka (CC BY-SA 4.0), "
|
|
"converted to ORT by Moonshine AI.",
|
|
[Notice("ipa-dict README (credits and licence)", IPADICT_README),
|
|
Notice("roberta-small-japanese-char-luw-upos model card",
|
|
hf("KoichiYasuoka/roberta-small-japanese-char-luw-upos", JA_ROBERTA_REV, "README.md"))],
|
|
["CC-BY-SA-4.0", "CC-BY-SA-3.0"],
|
|
"ipa-dict credits EDICT as CC BY-SA 3.0; the EDRDG licence statement read on 2026-09-29 says "
|
|
"CC BY-SA 4.0 and requires the acknowledgement on a separate screen (About / Sources) of an app.")
|
|
g2p("zh_hans",
|
|
["zh_hans/dict.tsv", "zh_hans/roberta_chinese_base_upos_onnx/meta.json",
|
|
"zh_hans/roberta_chinese_base_upos_onnx/vocab.txt",
|
|
"zh_hans/roberta_chinese_base_upos_onnx/tokenizer_config.json",
|
|
"zh_hans/roberta_chinese_base_upos_onnx/model.model.ort",
|
|
"zh_hans/roberta_chinese_base_upos_onnx/model.weights.ort"],
|
|
"CC-BY-3.0 AND Unicode-3.0 AND Apache-2.0 AND MIT",
|
|
f"https://github.com/open-dict-data/ipa-dict/blob/{IPADICT_COMMIT}/README.md#credits",
|
|
"Mandarin pronunciations from ipa-dict by open-dict-data (MIT), built from Unihan (Unicode "
|
|
"licence), KFCD Pinyin (CC BY 3.0) and 開放漢語詞典 kaifangcidian.com (CC BY 3.0). Word "
|
|
"segmentation: chinese-roberta-base-upos by Koichi Yasuoka (Apache-2.0), converted to ORT by "
|
|
"Moonshine AI.",
|
|
[Notice("ipa-dict README (credits and licence)", IPADICT_README),
|
|
Notice("chinese-roberta-base-upos model card",
|
|
hf("KoichiYasuoka/chinese-roberta-base-upos", ZH_ROBERTA_REV, "README.md"))],
|
|
["CC-BY-3.0", "Unicode-3.0", "Apache-2.0", "MIT"])
|
|
|
|
# ─── Voices ─────────────────────────────────────────────────────────────────
|
|
|
|
KOKORO_VOICES_MD = hf(KOKORO_REPO, KOKORO_REV, "VOICES.md")
|
|
KOKORO_LICENCE_URL = f"https://huggingface.co/{KOKORO_REPO}/blob/{KOKORO_REV}/README.md"
|
|
PIPER_README = hf(PIPER_REPO, PIPER_REV, "README.md")
|
|
SIWIS = ("Voice trained on the SIWIS French Speech Synthesis Database (University of Edinburgh "
|
|
"DataShare, https://datashare.ed.ac.uk/handle/10283/2353), CC BY 4.0.")
|
|
KONIWA = ("Voice trained on Koniwa tnc (https://github.com/koniwa/koniwa, "
|
|
"source/tnc/tnc__kumonoito.txt), CC BY 3.0.")
|
|
|
|
|
|
@dataclass
|
|
class Voice:
|
|
id: str
|
|
lang: str # language code in aac-core i18n
|
|
name: str
|
|
gender: str
|
|
engine: str # kokoro | piper
|
|
lang_key: str # Moonshine language key
|
|
g2p: str | None
|
|
quality: str # maintainers' notes, in English; the app shows `rating` instead
|
|
default: bool
|
|
grade: str | None = None # upstream naturalness grade (Kokoro's VOICES.md), if any
|
|
withheld: str = "" # why the voice is not offered, set by hand; see also RATING_RULE
|
|
rating: str = "" # derived from the evidence by rate()
|
|
# kokoro
|
|
kokoro_voice: str = ""
|
|
extra_attribution: str = ""
|
|
extra_texts: tuple[str, ...] = ()
|
|
# piper
|
|
piper_path: str = "" # directory in piper-voices, e.g. en/en_US/norman/medium
|
|
piper_stem: str = ""
|
|
onnx_sha256: str = "" # Hugging Face LFS oid at PIPER_REV
|
|
onnx_bytes: int = 0
|
|
piper_licence: str = ""
|
|
piper_attribution: str = ""
|
|
piper_notices: tuple[Notice, ...] = ()
|
|
piper_texts: tuple[str, ...] = ()
|
|
|
|
@property
|
|
def runtime_tier(self) -> str:
|
|
# Kokoro dequantizes to float32 at load (about 506 MB peak RSS per
|
|
# Moonshine's Pi 4 measurement) and runs at RTF ~1-1.4 even on an
|
|
# M5 Max, so it is kept off Minimal devices (<2 GB or <4 cores).
|
|
return "wasm-only" if self.engine == "kokoro" else "minimal"
|
|
|
|
|
|
def kokoro(pid, lang, name, gender, lang_key, g2p_lang, voice, quality, default,
|
|
extra_attribution="", extra_texts=(), *, grade, withheld="") -> Voice:
|
|
return Voice(pid, lang, name, gender, "kokoro", lang_key, g2p_lang, quality, default, grade, withheld,
|
|
kokoro_voice=voice, extra_attribution=extra_attribution, extra_texts=tuple(extra_texts))
|
|
|
|
|
|
def piper(pid, lang, name, gender, lang_key, g2p_lang, path, stem, sha, size, quality, default,
|
|
licence, attribution, notices=(), texts=(), *, grade=None, withheld="") -> Voice:
|
|
return Voice(pid, lang, name, gender, "piper", lang_key, g2p_lang, quality, default, grade, withheld,
|
|
piper_path=path, piper_stem=stem, onnx_sha256=sha, onnx_bytes=size,
|
|
piper_licence=licence, piper_attribution=attribution,
|
|
piper_notices=tuple(notices), piper_texts=tuple(texts))
|
|
|
|
|
|
# What the app tells the person about a voice (aac-web's voice_pack_settings.rs
|
|
# puts each into plain words) rests on evidence: intelligibility.json, where a
|
|
# speech recognizer wrote down what each voice said of its language's everyday
|
|
# phrases (tools/voices/intelligibility.py), and the upstream naturalness grade.
|
|
RATINGS = ("natural", "clear", "experimental")
|
|
INTELLIGIBILITY = HERE / "intelligibility.json"
|
|
RATING_RULE = (
|
|
"withheld when the recognizer understood less than 0.80 of the phrases on average (the voice says other "
|
|
"words); otherwise natural at 0.95 and over with an upstream grade of A or B, clear at 0.90 and over, and "
|
|
"experimental below that, or when any one phrase scored under 0.60, or when the voice was never measured"
|
|
)
|
|
|
|
|
|
def rate(grade: str | None, measured: dict | None) -> tuple[str, str]:
|
|
"""A voice's rating, and why it is withheld ("" when it is offered), by RATING_RULE."""
|
|
if not measured:
|
|
return "experimental", ""
|
|
mean, low = measured["mean"], measured["min"]
|
|
if mean < 0.80:
|
|
# Plain words only: what a recognizer writes down for babble is
|
|
# itself babble, and not worth quoting to anyone.
|
|
return "experimental", (
|
|
f"It says other words: in testing, a speech recognizer understood {mean:.0%} of "
|
|
f"{len(measured['phrases'])} everyday phrases it said.")
|
|
if low < 0.60 or mean < 0.90:
|
|
return "experimental", ""
|
|
if mean >= 0.95 and grade and grade[0] in "AB":
|
|
return "natural", ""
|
|
return "clear", ""
|
|
|
|
|
|
# A voice that says something other than what the person chose is not
|
|
# offered at all, whatever its rating: its reason goes in the catalogue's
|
|
# `withheld` list and in LICENSES.md, and it is not built.
|
|
WITHHELD_NORMAN = ("The single word 'more' comes out as 3-5 s of babble, also under reference inference of the "
|
|
"upstream model. Cori is offered instead.")
|
|
|
|
# What each voice's sample says: a greeting and a request, in the language.
|
|
SAMPLE_TEXT = {
|
|
"en": "Hi! Could I have some more juice, please?",
|
|
"es": "¡Hola! Quiero más jugo, por favor.",
|
|
"fr": "Bonjour ! Je voudrais encore un peu de jus, s'il vous plaît.",
|
|
"de": "Hallo! Ich hätte gern noch etwas Saft, bitte.",
|
|
"pt": "Olá! Eu queria mais suco, por favor.",
|
|
"it": "Ciao! Vorrei ancora un po' di succo, per favore.",
|
|
"nl": "Hallo! Mag ik nog wat sap, alsjeblieft?",
|
|
"zh": "你好!请再给我一些果汁,谢谢。",
|
|
"ja": "こんにちは!ジュースをもう少しください。",
|
|
"hi": "नमस्ते! कृपया मुझे और जूस दीजिए।",
|
|
"uk": "Привіт! Можна мені ще соку, будь ласка?",
|
|
}
|
|
MAX_SAMPLE_BYTES = 256 * 1024
|
|
|
|
MLS = "Multilingual LibriSpeech (MLS; Pratap et al., openslr.org/94), CC BY 4.0"
|
|
# `name` is what the person sees. A voice built from an anonymous dataset
|
|
# speaker is called "Reader", never by its dataset id; `quality` keeps the id.
|
|
VOICES: list[Voice] = [
|
|
kokoro("en-kokoro-af_heart", "en", "Heart", "female", "en_us", "en_us", "af_heart", "Kokoro grade A", True, grade="A"),
|
|
kokoro("en-kokoro-am_michael", "en", "Michael", "male", "en_us", "en_us", "am_michael", "Kokoro grade C+", False, grade="C+"),
|
|
kokoro("en-kokoro-bf_emma", "en", "Emma (British)", "female", "en_gb", "en_us", "bf_emma", "Kokoro grade B-", False, grade="B-"),
|
|
piper("en-piper-norman", "en", "Norman", "male", "en_us", "en_us", "en/en_US/norman/medium",
|
|
"en_US-norman-medium", "b9739443232a80a59c7d18810dd856899bf16a7964725f5ab81ea49b1351cb71", 63531379,
|
|
"audiobook-grade (Piper medium), light and fast. Unreliable on some single words: 'more' "
|
|
"came out as 3-5 s of babble, with upstream reference inference too", False,
|
|
"MIT", "Piper voice en_US-norman-medium by Bryce Beattie (rhasspy/piper-voices, MIT), trained "
|
|
"from scratch on LibriVox recordings (public domain).", texts=["MIT"],
|
|
withheld=WITHHELD_NORMAN),
|
|
# Added beside Norman (the plan's 'equally clean alternative'): same author and licence,
|
|
# and clean on the single words where Norman fails.
|
|
piper("en-piper-cori", "en", "Cori (British)", "female", "en_gb", "en_us", "en/en_GB/cori/high",
|
|
"en_GB-cori-high", "470b4dd634c98f8a4850d7626ffc3dfc90774628eeef6605a6dd8f88f30a5903", 114219352,
|
|
"Piper high, British English; light and fast, clean on single words in testing", False,
|
|
"MIT", "Piper voice en_GB-cori-high by Bryce Beattie (rhasspy/piper-voices, MIT), trained "
|
|
"from scratch on LibriVox recordings (public domain).", texts=["MIT"]),
|
|
kokoro("es-kokoro-ef_dora", "es", "Dora", "female", "es_es", None, "ef_dora",
|
|
"Kokoro, ungraded upstream", True, grade=None),
|
|
kokoro("es-kokoro-em_alex", "es", "Alex", "male", "es_es", None, "em_alex",
|
|
"Kokoro, ungraded upstream", False, grade=None),
|
|
kokoro("fr-kokoro-ff_siwis", "fr", "Siwis", "female", "fr", "fr", "ff_siwis", "Kokoro grade B-", True,
|
|
SIWIS, ["CC-BY-4.0"], grade="B-"),
|
|
piper("de-piper-mls", "de", "Reader", "unknown", "de", "de", "de/de_DE/mls/medium",
|
|
"de_DE-mls-medium", "69cd1d2aa5a35839a518966fcc4924b5f93e5f8c948ed0752b1a616ad53f65bf", 76961079,
|
|
"audiobook-grade (Piper MLS medium), speaker 0 (MLS 2422) of 236, the only one Moonshine 0.1.5 can select. "
|
|
"Slow and halting in testing (5-7 s for 'Ich möchte mehr Saft, bitte.', with upstream reference "
|
|
"inference too)", True,
|
|
"MIT AND CC-BY-4.0", f"Piper voice de_DE-mls-medium (rhasspy/piper-voices, MIT), trained from "
|
|
f"scratch on {MLS}.", texts=["MIT", "CC-BY-4.0"]),
|
|
kokoro("pt-kokoro-pf_dora", "pt", "Dora", "female", "pt_br", "pt_br", "pf_dora",
|
|
"Kokoro, ungraded upstream", True, grade=None),
|
|
kokoro("pt-kokoro-pm_alex", "pt", "Alex", "male", "pt_br", "pt_br", "pm_alex",
|
|
"Kokoro, ungraded upstream", False, grade=None),
|
|
kokoro("it-kokoro-if_sara", "it", "Sara", "female", "it", "it", "if_sara", "Kokoro grade C", True, grade="C"),
|
|
kokoro("it-kokoro-im_nicola", "it", "Nicola", "male", "it", "it", "im_nicola", "Kokoro grade C", False, grade="C"),
|
|
piper("nl-piper-mls", "nl", "Reader (Netherlands)", "unknown", "nl", "nl", "nl/nl_NL/mls/medium",
|
|
"nl_NL-mls-medium", "88312e0fbf505b87caf2373d94c1384892e86b1bf2ee482cf65dc8ba179cc7d3", 76584246,
|
|
"audiobook-grade (Piper MLS medium); speaker 0 (MLS 2450) of 52, the only one Moonshine 0.1.5 can select", True,
|
|
"MIT AND CC-BY-4.0", f"Piper voice nl_NL-mls-medium (rhasspy/piper-voices, MIT), trained from "
|
|
f"scratch on {MLS}.", texts=["MIT", "CC-BY-4.0"]),
|
|
piper("nl-piper-rdh", "nl", "Reader (Flemish)", "male", "nl", "nl", "nl/nl_BE/rdh/medium",
|
|
"nl_BE-rdh-medium", "fb1a9d89ff53dbd510c5d10bd1365c36a5f76249c3ca603076646fd778003db1", 63104526,
|
|
"Piper medium, Belgian Dutch", False,
|
|
"MIT AND CC0-1.0", "Piper voice nl_BE-rdh-medium (rhasspy/piper-voices, MIT), trained from "
|
|
"scratch on dutch-vl-tts by r-dh (CC0 1.0).",
|
|
[Notice("dutch-vl-tts licence", gh("r-dh/dutch-vl-tts", RDH_COMMIT, "LICENSE"))], ["MIT", "CC0-1.0"]),
|
|
kokoro("zh-kokoro-zf_xiaobei", "zh", "Xiaobei", "female", "zh", "zh_hans", "zf_xiaobei",
|
|
"Kokoro grade D", True, grade="D"),
|
|
kokoro("zh-kokoro-zm_yunjian", "zh", "Yunjian", "male", "zh", "zh_hans", "zm_yunjian",
|
|
"Kokoro grade D", False, grade="D"),
|
|
kokoro("ja-kokoro-jf_alpha", "ja", "Alpha", "female", "ja", "ja", "jf_alpha", "Kokoro grade C+", True, grade="C+"),
|
|
kokoro("ja-kokoro-jm_kumo", "ja", "Kumo", "male", "ja", "ja", "jm_kumo", "Kokoro grade C-", False,
|
|
KONIWA, ["CC-BY-3.0"], grade="C-"),
|
|
kokoro("hi-kokoro-hf_alpha", "hi", "Alpha", "female", "hi", "hi", "hf_alpha", "Kokoro grade C", True, grade="C"),
|
|
kokoro("hi-kokoro-hm_omega", "hi", "Omega", "male", "hi", "hi", "hm_omega", "Kokoro grade C", False, grade="C"),
|
|
piper("uk-piper-ukrainian_tts", "uk", "Lada", "female", "uk", None, "uk/uk_UA/ukrainian_tts/medium",
|
|
"uk_UA-ukrainian_tts-medium", "7920419ac5f6fd8b6450520f24b52ed5a319cb53dd018fbcd71c9e079cbac84f", 76735663,
|
|
"Piper medium; speaker 0 (lada) of 3, the only one Moonshine 0.1.5 can select", True,
|
|
"MIT AND CC0-1.0", "Piper voice uk_UA-ukrainian_tts-medium (rhasspy/piper-voices, MIT), trained "
|
|
"from scratch on OHF-Voice voice-datasets (CC0).",
|
|
[Notice("OHF-Voice voice-datasets README (CC0)", gh("OHF-Voice/voice-datasets", OHF_DATASETS_COMMIT, "README.md"))],
|
|
["MIT", "CC0-1.0"]),
|
|
]
|
|
|
|
# ─── Language status (all 45 languages of aac_core::i18n) ────────────────────
|
|
|
|
LESSAC = "built on the lessac voice, whose Blizzard-2013 licence bars commercial synthesis"
|
|
RYAN = "built on the Ryan voice (RyanSpeech, CC BY-NC-SA)"
|
|
LIBRITTS_R = "built on LibriTTS-R, which Piper's own card says is fine-tuned from lessac"
|
|
NO_G2P = "there is no non-GPL G2P for it in the runtime yet (eSpeak NG is GPL)"
|
|
PLANNED_OPENBIBLE = ("OpenBible VITS {name} (CC BY-SA 4.0, trained from scratch on Open.Bible audio) is "
|
|
"verified; it needs an ONNX export and the onnxruntime-web runtime, planned for v1.1.")
|
|
LANGUAGE_STATUS: dict[str, tuple[str, str | None]] = {
|
|
"ko": ("web-speech-only", "No voice with a verifiable licence: Piper ko_KR-kss is CC BY-NC-SA, and Moonshine's "
|
|
"ko_KR-melotts-medium states no base checkpoint and its training code is not public."),
|
|
"ar": ("web-speech-only", f"Piper ar_JO-kareem is {LESSAC}, and its dataset licence is unstated. OpenBible "
|
|
"Arabic is a later candidate pending a rights check."),
|
|
"he": ("web-speech-only", f"Piper he_IL-saspeech has an unverified dataset licence and is {LIBRITTS_R}; "
|
|
f"also {NO_G2P}."),
|
|
"ur": ("web-speech-only", f"Piper ur_PK-fasih is built on hi_IN-rohan, which is {LESSAC}; aegis_female "
|
|
f"states no base; and {NO_G2P}."),
|
|
"fa": ("web-speech-only", f"Every Persian Piper voice is {LESSAC} or chained to one (amir, reza_ibrahim, "
|
|
f"ganji, ganji_adabi); gyro's licence and base are unclear; and {NO_G2P}."),
|
|
"bn": ("web-speech-only", f"Piper bn_BD-google has CC BY-SA data but is {LIBRITTS_R}; {NO_G2P}. Rasa-13 "
|
|
"and OpenBible Bengali await a rights review."),
|
|
"ta": ("web-speech-only", "No verifiable voice: the AI4Bharat Rasa-13 card is gated, and the Open.Bible "
|
|
"Tamil source rights could not be checked."),
|
|
"te": ("web-speech-only", f"Piper padmavathi and venkatesh (CC BY 4.0 data) state no base checkpoint, "
|
|
f"maya is {LESSAC}, and {NO_G2P}."),
|
|
"ml": ("web-speech-only", f"Piper arjun and meera are {LESSAC} with an unstated dataset licence; Rasa-13 "
|
|
"is gated and unverifiable."),
|
|
"mr": ("web-speech-only", f"Piper mr_IN-google is {LIBRITTS_R}, and {NO_G2P}. OpenBible Marathi awaits a "
|
|
"rights review."),
|
|
"kn": ("web-speech-only", "No verifiable voice: Rasa-13 is gated and the Open.Bible Kannada source rights "
|
|
"are unverified."),
|
|
"gu": ("web-speech-only", "No verifiable voice: the Open.Bible Gujarati source rights are unverified."),
|
|
"pa": ("web-speech-only", "No verifiable voice: Rasa-13 is gated and the Open.Bible Punjabi source rights "
|
|
"are unverified."),
|
|
"th": ("web-speech-only", "Piper th_TH-tsync2 is CC BY-NC-SA 3.0 (non-commercial)."),
|
|
"vi": ("web-speech-only", f"Piper vais1000 is {LESSAC}, vivos is CC BY-NC-SA, and 25hours_single is "
|
|
f"{RYAN} with an unknown licence."),
|
|
"id": ("web-speech-only", f"Piper id_ID-news_tts is {LESSAC}, with an unstated dataset licence."),
|
|
"ms": ("web-speech-only", "No Malay voice exists in Piper, Kokoro or Pocket TTS."),
|
|
"tl": ("web-speech-only", "No Tagalog voice exists in Piper, Kokoro or Pocket TTS (OpenBible has "
|
|
"Hiligaynon, not Tagalog)."),
|
|
"ru": ("web-speech-only", f"Piper denis and dmitri are {LESSAC}, irina's licence is unknown, and ruslan "
|
|
"is CC BY-NC-SA."),
|
|
"pl": ("web-speech-only", f"Every Polish Piper voice is {LESSAC} or {RYAN}."),
|
|
"ro": ("web-speech-only", f"Piper ro_RO-mihai is {LESSAC}."),
|
|
"cs": ("web-speech-only", f"Piper jirka is {LESSAC}; kasandra (CC BY 4.0) states no base checkpoint; and "
|
|
f"{NO_G2P}."),
|
|
"sv": ("web-speech-only", f"Piper sv_SE-nst-medium is clean (CC0, trained from scratch), but {NO_G2P}."),
|
|
"da": ("web-speech-only", f"Piper da_DK-talesyntese is {LESSAC}."),
|
|
"nb": ("web-speech-only", f"Piper no_NO-nvcc-medium is clean (CC0, trained from scratch), but {NO_G2P}."),
|
|
"fi": ("web-speech-only", f"Piper harri-medium is {LESSAC} and harri-low is {RYAN}."),
|
|
"sw": ("web-speech-only", f"Piper sw_CD-lanfrica is {LESSAC}, with an unstated dataset licence. OpenBible "
|
|
"Swahili awaits a rights review."),
|
|
"am": ("web-speech-only", "No voice with a licence that allows commercial use (none in Piper; MMS-TTS is "
|
|
"CC BY-NC)."),
|
|
"ha": ("planned", PLANNED_OPENBIBLE.format(name="Hausa")),
|
|
"yo": ("planned", PLANNED_OPENBIBLE.format(name="Yoruba") + " Yoruba text must carry full tone marks."),
|
|
"zu": ("web-speech-only", "No voice with a licence that allows commercial use (OpenBible has Ndebele, not "
|
|
"Zulu; MMS-TTS is CC BY-NC)."),
|
|
"tr": ("web-speech-only", f"Piper tr_TR-dfki is CC BY-NC-SA and {LESSAC}; fahrettin and fettah are not in "
|
|
"the current piper-voices catalogue, so their licences cannot be checked."),
|
|
"el": ("web-speech-only", f"Piper el_GR-rapunzelina-medium is clean (CC0, trained from scratch), but "
|
|
f"{NO_G2P}."),
|
|
"hu": ("web-speech-only", f"Piper anna, berta and imre are all {LESSAC}."),
|
|
}
|
|
|
|
RUNTIME_LICENCE = "MIT AND MPL-2.0 AND BSD-3-Clause AND Apache-2.0 AND BSL-1.0"
|
|
RUNTIME_ATTRIBUTION = (
|
|
"Moonshine Voice for the Web 0.1.5 by Moonshine AI (Useful Sensors, Inc.), MIT. The WebAssembly "
|
|
"build bundles ONNX Runtime (MIT, Microsoft), Eigen (MPL-2.0), KISS FFT (BSD-3-Clause), "
|
|
"kaldi-native-fbank (Apache-2.0), nlohmann/json (MIT), utf8proc (MIT) and utfcpp (BSL-1.0)."
|
|
)
|
|
RUNTIME_NOTICES = [
|
|
Notice("Moonshine licence (MIT; the TTS data files carry their own licences)", ms("LICENSE")),
|
|
Notice("cpp-annote licence", ms("core/cpp-annote/LICENSE")),
|
|
Notice("ONNX Runtime licence", ms("core/third-party/onnxruntime/LICENSE.txt")),
|
|
Notice("Eigen licence (MPL-2.0; source: https://gitlab.com/libeigen/eigen)", ms("core/third-party/Eigen/COPYING.MPL2")),
|
|
Notice("KISS FFT licence", ms("core/third-party/kissfft/COPYING")),
|
|
Notice("kaldi-native-fbank licence", ms("core/third-party/kaldi-native-fbank/LICENSE")),
|
|
Notice("nlohmann/json licence", ms("core/third-party/nlohmann/LICENSE.MIT")),
|
|
Notice("utf8proc licence", ms("core/third-party/utf8proc/LICENSE.md")),
|
|
Notice("utfcpp licence", ms("core/third-party/utf-8/LICENSE.txt")),
|
|
]
|
|
# Only the Emscripten module and its wasm are served. The package's TypeScript
|
|
# layer (index.js, text-to-speech.js, ...) is left out: the worker uses the
|
|
# low-level module API, and that layer defaults to fetching assets from
|
|
# download.moonshine.ai, which the app must never do.
|
|
RUNTIME_KEEP = ("moonshine.mjs", "moonshine.wasm")
|
|
|
|
# ─── Utilities ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
class BuildError(Exception):
|
|
pass
|
|
|
|
|
|
def log(msg: str) -> None:
|
|
print(msg, flush=True)
|
|
|
|
|
|
def sha256_file(path: Path) -> str:
|
|
h = hashlib.sha256()
|
|
with path.open("rb") as f:
|
|
while chunk := f.read(1 << 22):
|
|
h.update(chunk)
|
|
return h.hexdigest()
|
|
|
|
|
|
def sha256_bytes(data: bytes) -> str:
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def hashed(name: str, sha: str) -> str:
|
|
"""`dict.tsv` -> `dict.<sha12>.tsv`; matches sw.js IMMUTABLE."""
|
|
stem, dot, ext = name.rpartition(".")
|
|
if not dot or not re.fullmatch(r"[a-z0-9]+", ext):
|
|
raise BuildError(f"cannot content-address {name!r}: needs a lower-case alphanumeric extension")
|
|
return f"{stem}.{sha[:12]}.{ext}"
|
|
|
|
|
|
def mb(n: int) -> str:
|
|
return f"{n / 1e6:.1f} MB"
|
|
|
|
|
|
def dumps(obj) -> bytes:
|
|
return (json.dumps(obj, indent=1, ensure_ascii=False) + "\n").encode("utf-8")
|
|
|
|
|
|
class Lock:
|
|
def __init__(self, path: Path, update: bool):
|
|
self.path = path
|
|
self.update = update
|
|
self.dirty = False
|
|
self._mu = threading.Lock()
|
|
data = json.loads(path.read_text()) if path.exists() else {"schema": LOCK_SCHEMA, "sources": {}, "converted": {}}
|
|
if data.get("schema") != LOCK_SCHEMA:
|
|
raise BuildError(f"{path}: unknown schema {data.get('schema')!r}")
|
|
self.sources: dict = data["sources"]
|
|
self.converted: dict = data["converted"]
|
|
|
|
def save(self) -> None:
|
|
if not self.dirty:
|
|
return
|
|
data = {"schema": LOCK_SCHEMA, "retrieved": RETRIEVED,
|
|
"sources": dict(sorted(self.sources.items())),
|
|
"converted": dict(sorted(self.converted.items()))}
|
|
self.path.write_bytes(dumps(data))
|
|
log(f"lock: wrote {self.path.relative_to(ROOT)}")
|
|
|
|
def record(self, table: dict, key: str, entry: dict) -> None:
|
|
with self._mu:
|
|
if not self.update:
|
|
raise BuildError(f"not pinned in {self.path.name}: {key}\n"
|
|
" review the source, then rerun with --update-lock to record it")
|
|
table[key] = entry
|
|
self.dirty = True
|
|
|
|
|
|
class Fetcher:
|
|
def __init__(self, lock: Lock, offline: bool):
|
|
self.lock = lock
|
|
self.offline = offline
|
|
(CACHE / "src").mkdir(parents=True, exist_ok=True)
|
|
(CACHE / "tmp").mkdir(parents=True, exist_ok=True)
|
|
|
|
def path_for(self, sha: str) -> Path:
|
|
return CACHE / "src" / sha
|
|
|
|
def get(self, url: str, expect_sha: str | None = None, expect_bytes: int | None = None) -> Path:
|
|
"""Return the cached, verified file for `url`, downloading it if needed."""
|
|
pin = self.lock.sources.get(url)
|
|
if pin and expect_sha and (pin["sha256"] != expect_sha or expect_bytes and pin["bytes"] != expect_bytes):
|
|
raise BuildError(f"lock and config disagree for {url}")
|
|
want_sha = pin["sha256"] if pin else expect_sha
|
|
want_bytes = pin["bytes"] if pin else expect_bytes
|
|
if want_sha:
|
|
p = self.path_for(want_sha)
|
|
if p.exists():
|
|
size = p.stat().st_size
|
|
if (want_bytes is None or size == want_bytes) and sha256_file(p) == want_sha:
|
|
if not pin:
|
|
self.lock.record(self.lock.sources, url, {"bytes": size, "sha256": want_sha})
|
|
return p
|
|
p.unlink()
|
|
if self.offline:
|
|
raise BuildError(f"--offline and not cached: {url}")
|
|
tmp = self._download(url)
|
|
got_sha, got_bytes = sha256_file(tmp), tmp.stat().st_size
|
|
if (want_sha and got_sha != want_sha) or (want_bytes and got_bytes != want_bytes):
|
|
tmp.unlink()
|
|
raise BuildError(f"checksum mismatch for {url}\n want {want_bytes} B {want_sha}\n got {got_bytes} B {got_sha}")
|
|
if not pin:
|
|
self.lock.record(self.lock.sources, url, {"bytes": got_bytes, "sha256": got_sha})
|
|
dest = self.path_for(got_sha)
|
|
os.replace(tmp, dest)
|
|
return dest
|
|
|
|
def _download(self, url: str) -> Path:
|
|
last = None
|
|
for attempt in range(4):
|
|
fd, name = tempfile.mkstemp(dir=CACHE / "tmp")
|
|
tmp = Path(name)
|
|
try:
|
|
req = urllib.request.Request(url, headers={"User-Agent": "aac-voices-builder/1 (+open AAC standard)"})
|
|
with urllib.request.urlopen(req, timeout=120) as r, os.fdopen(fd, "wb") as f:
|
|
shutil.copyfileobj(r, f, 1 << 22)
|
|
return tmp
|
|
except (urllib.error.URLError, TimeoutError, ConnectionError, OSError) as e:
|
|
last = e
|
|
tmp.unlink(missing_ok=True)
|
|
time.sleep(2 * (attempt + 1))
|
|
raise BuildError(f"download failed: {url}: {last}")
|
|
|
|
|
|
def verify_npm_integrity(path: Path) -> None:
|
|
algo, _, b64 = NPM_INTEGRITY.partition("-")
|
|
digest = hashlib.new(algo, path.read_bytes()).digest()
|
|
if base64.b64encode(digest).decode() != b64:
|
|
raise BuildError(f"npm integrity mismatch for {NPM_TARBALL}")
|
|
|
|
|
|
def language_codes() -> list[str]:
|
|
"""Codes of aac_core::i18n::supported_languages(), in order."""
|
|
src = I18N_RS.read_text(encoding="utf-8")
|
|
body = src[src.index("pub fn supported_languages"):]
|
|
body = body[: body.index("\n}\n")]
|
|
codes = re.findall(r'\blang\(\s*"([a-z]{2,3})"', body)
|
|
if len(codes) != len(set(codes)) or not codes:
|
|
raise BuildError(f"could not read the language list from {I18N_RS}")
|
|
return codes
|
|
|
|
|
|
# ─── Piper conversion ───────────────────────────────────────────────────────
|
|
|
|
SPAWN_NO_ASLR = r"""
|
|
import ctypes, os, sys
|
|
libc = ctypes.CDLL(None, use_errno=True)
|
|
attr = ctypes.c_void_p()
|
|
assert libc.posix_spawnattr_init(ctypes.byref(attr)) == 0
|
|
assert libc.posix_spawnattr_setflags(ctypes.byref(attr), ctypes.c_short(0x100)) == 0 # _POSIX_SPAWN_DISABLE_ASLR
|
|
argv = sys.argv[1:]
|
|
cargv = (ctypes.c_char_p * (len(argv) + 1))(*[a.encode() for a in argv], None)
|
|
envl = [f"{k}={v}".encode() for k, v in os.environ.items()]
|
|
cenv = (ctypes.c_char_p * (len(envl) + 1))(*envl, None)
|
|
pid = ctypes.c_int()
|
|
r = libc.posix_spawn(ctypes.byref(pid), argv[0].encode(), None, ctypes.byref(attr), cargv, cenv)
|
|
if r:
|
|
sys.exit(f"posix_spawn failed: {os.strerror(r)}")
|
|
_, st = os.waitpid(pid.value, 0)
|
|
sys.exit(os.waitstatus_to_exitcode(st))
|
|
"""
|
|
|
|
|
|
def without_aslr(argv: list[str]) -> list[str]:
|
|
system = platform.system()
|
|
if system == "Darwin":
|
|
return [sys.executable, "-c", SPAWN_NO_ASLR, *argv]
|
|
if system == "Linux" and shutil.which("setarch"):
|
|
return ["setarch", platform.machine(), "-R", *argv]
|
|
log(" warning: cannot disable ASLR here; the ORT output may differ from the lock")
|
|
return argv
|
|
|
|
|
|
def converter_python(explicit: str | None) -> Path:
|
|
venv = CACHE / "ort-venv"
|
|
py = venv / "bin/python"
|
|
stamp = venv / "pins.txt"
|
|
want = "\n".join(ORT_PINS) + "\n"
|
|
if py.exists() and stamp.exists() and stamp.read_text() == want:
|
|
return py
|
|
base = explicit or next((shutil.which(c) for c in ("python3.12", "python3.13", "python3.11", "python3.10") if shutil.which(c)), None)
|
|
if not base:
|
|
raise BuildError("the Piper conversion needs python3.10-3.13 (onnxruntime 1.23.2 has no 3.14 wheels); "
|
|
"pass --converter-python")
|
|
log(f"converter: creating {venv.relative_to(ROOT)} with {base}")
|
|
shutil.rmtree(venv, ignore_errors=True)
|
|
subprocess.run([base, "-m", "venv", str(venv)], check=True)
|
|
subprocess.run([str(py), "-m", "pip", "install", "--quiet", "--disable-pip-version-check",
|
|
"--only-binary=:all:", *ORT_PINS], check=True)
|
|
stamp.write_text(want)
|
|
return py
|
|
|
|
|
|
def convert_piper(v: Voice, onnx: Path, lock: Lock, offline: bool, conv_py: str | None) -> Path:
|
|
key = f"{v.piper_stem}.ort"
|
|
pin = lock.converted.get(key)
|
|
out_dir = CACHE / "conv"
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
if pin:
|
|
if pin["input_sha256"] != v.onnx_sha256 or pin["tool"] != CONVERT_TOOL:
|
|
raise BuildError(f"lock entry for {key} was made from a different input or tool; rerun with --update-lock")
|
|
cached = out_dir / pin["sha256"]
|
|
if cached.exists() and cached.stat().st_size == pin["bytes"] and sha256_file(cached) == pin["sha256"]:
|
|
return cached
|
|
if offline and not (CACHE / "ort-venv/bin/python").exists():
|
|
raise BuildError(f"--offline and no converter venv for {key}")
|
|
py = converter_python(conv_py)
|
|
log(f"convert: {v.piper_stem}.onnx -> ORT")
|
|
with tempfile.TemporaryDirectory(dir=CACHE / "tmp") as work:
|
|
work = Path(work)
|
|
src = work / "model.onnx"
|
|
shutil.copyfile(onnx, src)
|
|
proc = subprocess.run(without_aslr([str(py), "-m", "onnxruntime.tools.convert_onnx_models_to_ort",
|
|
str(src), *CONVERT_ARGS, "--output_dir", str(work / "out")]),
|
|
capture_output=True, text=True)
|
|
result = work / "out/model.ort"
|
|
if proc.returncode != 0 or not result.exists():
|
|
raise BuildError(f"ORT conversion failed for {v.piper_stem}:\n{proc.stdout[-2000:]}\n{proc.stderr[-2000:]}")
|
|
sha, size = sha256_file(result), result.stat().st_size
|
|
if pin and sha != pin["sha256"]:
|
|
if not lock.update:
|
|
raise BuildError(f"converted {key} is {sha}, lock pins {pin['sha256']}: the converter output "
|
|
"changed (other onnxruntime wheel, or ASLR could not be disabled). "
|
|
"Check the voice with smoke.mjs, then rerun with --update-lock.")
|
|
pin = None
|
|
if not pin:
|
|
lock.record(lock.converted, key, {"input_sha256": v.onnx_sha256, "tool": CONVERT_TOOL,
|
|
"bytes": size, "sha256": sha})
|
|
dest = out_dir / sha
|
|
os.replace(result, dest)
|
|
return dest
|
|
|
|
|
|
# ─── Build plan ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
@dataclass
|
|
class OutFile:
|
|
rel: str # path under the output dir
|
|
src: Path | None # cached source, or None when `data` is set
|
|
sha256: str
|
|
bytes: int
|
|
data: bytes | None = None
|
|
|
|
|
|
@dataclass
|
|
class Plan:
|
|
files: dict[str, OutFile] = field(default_factory=dict)
|
|
|
|
def add(self, rel: str, src: Path | None = None, data: bytes | None = None) -> OutFile:
|
|
if data is not None:
|
|
f = OutFile(rel, None, sha256_bytes(data), len(data), data)
|
|
else:
|
|
f = OutFile(rel, src, sha256_file(src), src.stat().st_size)
|
|
old = self.files.get(rel)
|
|
if old and old.sha256 != f.sha256:
|
|
raise BuildError(f"two different files for {rel}")
|
|
self.files[rel] = f
|
|
return f
|
|
|
|
|
|
def fileref(name: str, f: OutFile) -> dict:
|
|
return {"name": name, "url": f.rel, "bytes": f.bytes, "sha256": f.sha256}
|
|
|
|
|
|
def licence_text(title: str, licence: str, attribution: str, note: str, sources: list[dict],
|
|
notices: list[Notice], texts: list[str], fetch: Fetcher) -> bytes:
|
|
out = io.StringIO()
|
|
out.write(f"{title}\n{'=' * len(title)}\n\n")
|
|
out.write(f"Licence: {licence}\n\nAttribution:\n {attribution}\n\n")
|
|
if note:
|
|
out.write(f"Note:\n {note}\n\n")
|
|
out.write(f"Sources (retrieved {RETRIEVED}):\n")
|
|
for s in sources:
|
|
out.write(f" - {s['url']}\n sha256 {s['sha256']}\n")
|
|
for n in notices:
|
|
body = fetch.get(n.url).read_bytes().decode("utf-8", errors="replace").replace("\r\n", "\n")
|
|
head = f"{n.title}\n{n.url}"
|
|
out.write(f"\n\n{'-' * 78}\n{head}\n{'-' * 78}\n\n{body.rstrip()}\n")
|
|
for ident in texts:
|
|
body = fetch.get(spdx(ident)).read_bytes().decode("utf-8", errors="replace").replace("\r\n", "\n")
|
|
out.write(f"\n\n{'-' * 78}\nFull text: {ident}\n{spdx(ident)}\n{'-' * 78}\n\n{body.rstrip()}\n")
|
|
return out.getvalue().encode("utf-8")
|
|
|
|
|
|
def safe_id(ident: str) -> str:
|
|
return re.sub(r"[^A-Za-z0-9._-]", "_", ident)
|
|
|
|
|
|
def build(args) -> None:
|
|
lock = Lock(LOCK, args.update_lock)
|
|
fetch = Fetcher(lock, args.offline)
|
|
out_root: Path = args.out.resolve()
|
|
codes = language_codes()
|
|
plan = Plan()
|
|
try:
|
|
catalogue = assemble(args, lock, fetch, plan, codes)
|
|
finally:
|
|
lock.save()
|
|
|
|
cat_bytes = dumps(catalogue)
|
|
cat_name = f"index.{sha256_bytes(cat_bytes)[:12]}.json"
|
|
plan.add(cat_name, data=cat_bytes)
|
|
plan.add("index.json", data=dumps({"schema": SCHEMA, "current": cat_name}))
|
|
licences_md = render_licences_md(catalogue, cat_name)
|
|
plan.add("LICENSES.md", data=licences_md)
|
|
|
|
write_tree(out_root, plan)
|
|
COMMITTED.mkdir(parents=True, exist_ok=True)
|
|
for name, data in (("index.json", cat_bytes), ("LICENSES.md", licences_md)):
|
|
dest = COMMITTED / name
|
|
if not dest.exists() or dest.read_bytes() != data:
|
|
dest.write_bytes(data)
|
|
report(catalogue, plan, out_root, cat_name)
|
|
|
|
|
|
def assemble(args, lock: Lock, fetch: Fetcher, plan: Plan, codes: list[str]) -> dict:
|
|
unknown = set(LANGUAGE_STATUS) - set(codes)
|
|
if unknown:
|
|
raise BuildError(f"status for languages not in i18n.rs: {sorted(unknown)}")
|
|
measured = json.loads(INTELLIGIBILITY.read_text()) if INTELLIGIBILITY.exists() else {}
|
|
unmeasured = []
|
|
for v in VOICES:
|
|
v.rating, failed = rate(v.grade, measured.get(v.id))
|
|
if failed and not v.withheld:
|
|
v.withheld = failed
|
|
if v.id not in measured and not v.withheld:
|
|
unmeasured.append(v.id)
|
|
if v.rating not in RATINGS:
|
|
raise BuildError(f"{v.id}: rating {v.rating!r} is not one of {RATINGS}")
|
|
if unmeasured:
|
|
log(f"intelligibility: not measured, so rated experimental: {', '.join(unmeasured)}: "
|
|
"run tools/voices/intelligibility.py and build again")
|
|
# A language whose default voice is withheld takes its first offered voice.
|
|
for lang in {v.lang for v in VOICES}:
|
|
mine = [v for v in VOICES if v.lang == lang and not v.withheld]
|
|
if mine and not any(v.default for v in mine):
|
|
mine[0].default = True
|
|
for v in VOICES:
|
|
if v.withheld:
|
|
v.default = False
|
|
offered = [v for v in VOICES if not v.withheld]
|
|
|
|
# Prefetch every payload in parallel; licence texts are small and fetched on demand.
|
|
cdn_names = {n for c in [KOKORO_CORE, *G2P.values()] for n in c.names}
|
|
cdn_names |= {f"kokoro/voices/{v.kokoro_voice}.kokorovoice" for v in offered if v.engine == "kokoro"}
|
|
jobs = [(CDN + n, None, None) for n in sorted(cdn_names)]
|
|
jobs.append((NPM_TARBALL, None, None))
|
|
for v in offered:
|
|
if v.engine == "piper":
|
|
jobs.append((hf(PIPER_REPO, PIPER_REV, f"{v.piper_path}/{v.piper_stem}.onnx"), v.onnx_sha256, v.onnx_bytes))
|
|
jobs.append((hf(PIPER_REPO, PIPER_REV, f"{v.piper_path}/{v.piper_stem}.onnx.json"), None, None))
|
|
with concurrent.futures.ThreadPoolExecutor(args.jobs) as pool:
|
|
futs = {pool.submit(fetch.get, u, s, b): u for u, s, b in jobs}
|
|
errors = []
|
|
for fut in concurrent.futures.as_completed(futs):
|
|
try:
|
|
fut.result()
|
|
except BuildError as e:
|
|
errors.append(str(e))
|
|
if errors:
|
|
raise BuildError("\n".join(sorted(errors)))
|
|
log(f"sources: {len(jobs)} payload files verified")
|
|
|
|
def source(url: str) -> dict:
|
|
return {"url": url, "sha256": lock.sources[url]["sha256"]}
|
|
|
|
# Runtime
|
|
tgz = fetch.get(NPM_TARBALL)
|
|
verify_npm_integrity(tgz)
|
|
rt_files: list[tuple[str, bytes]] = []
|
|
with tarfile.open(tgz, "r:gz") as tar:
|
|
for m in sorted(tar.getmembers(), key=lambda m: m.name):
|
|
if m.isfile() and m.name.removeprefix("package/dist/") in RUNTIME_KEEP:
|
|
rt_files.append((m.name.removeprefix("package/dist/"), tar.extractfile(m).read()))
|
|
worker = args.worker or next((p for p in WORKER_CANDIDATES if p.exists()), None)
|
|
if worker:
|
|
rt_files.append(("voice-worker.js", Path(worker).read_bytes()))
|
|
rt_files.sort()
|
|
rt_digest = sha256_bytes("".join(f"{n}\0{sha256_bytes(d)}\n" for n, d in rt_files).encode())
|
|
rt_dir = f"runtime/{RUNTIME_DIR_STEM}-{rt_digest[:12]}/"
|
|
rt_refs = [fileref(n, plan.add(rt_dir + n, data=d)) for n, d in rt_files]
|
|
rt_sources = [source(NPM_TARBALL)]
|
|
rt_text = licence_text(f"Runtime {RUNTIME_ID}", RUNTIME_LICENCE, RUNTIME_ATTRIBUTION,
|
|
f"npm {NPM_NAME}@{NPM_VERSION}, integrity {NPM_INTEGRITY}. Notices are read at "
|
|
f"Moonshine commit {MOONSHINE_COMMIT} (main on the npm publish date).",
|
|
rt_sources, RUNTIME_NOTICES, ["MIT", "MPL-2.0", "BSD-3-Clause", "Apache-2.0", "BSL-1.0"], fetch)
|
|
rt_lic = plan.add(f"licences/{hashed(safe_id(RUNTIME_ID) + '.txt', sha256_bytes(rt_text))}", data=rt_text)
|
|
runtimes = {RUNTIME_ID: {
|
|
"version": NPM_VERSION,
|
|
"licence": RUNTIME_LICENCE,
|
|
"licence_url": f"https://github.com/{MOONSHINE_REPO}/blob/{MOONSHINE_COMMIT}/LICENSE",
|
|
"attribution": RUNTIME_ATTRIBUTION,
|
|
"dir": rt_dir,
|
|
"files": rt_refs,
|
|
"module": rt_dir + "moonshine.mjs",
|
|
"wasm": rt_dir + "moonshine.wasm",
|
|
**({"entry": rt_dir + "voice-worker.js"} if worker else {}),
|
|
"requires": {"cross_origin_isolated": True, "wasm_simd": True, "wasm_threads": True},
|
|
"total_bytes": sum(r["bytes"] for r in rt_refs),
|
|
"licence_text": fileref("licence", rt_lic),
|
|
"sources": [{"url": NPM_TARBALL, "integrity": NPM_INTEGRITY, "sha256": lock.sources[NPM_TARBALL]["sha256"],
|
|
"retrieved": RETRIEVED}],
|
|
}}
|
|
|
|
# Shared components
|
|
components: dict[str, dict] = {}
|
|
used_g2p = {v.g2p for v in offered if v.g2p}
|
|
for comp in [KOKORO_CORE, *(G2P[k] for k in G2P if k in used_g2p)]:
|
|
refs = []
|
|
for name in comp.names:
|
|
src = fetch.get(CDN + name)
|
|
base = "c/kokoro/" + name.removeprefix("kokoro/") if comp.kind == "kokoro-core" else "c/g2p/" + name
|
|
d, _, b = base.rpartition("/")
|
|
refs.append(fileref(name, plan.add(f"{d}/{hashed(b, sha256_file(src))}", src)))
|
|
sources = [source(CDN + n) for n in comp.names]
|
|
text = licence_text(f"Component {comp.id}", comp.licence, comp.attribution, comp.note, sources,
|
|
comp.notices, comp.licence_texts, fetch)
|
|
lic = plan.add(f"licences/{hashed(safe_id(comp.id) + '.txt', sha256_bytes(text))}", data=text)
|
|
components[comp.id] = {
|
|
"kind": comp.kind,
|
|
"files": refs,
|
|
"licence": comp.licence,
|
|
"licence_url": comp.licence_url,
|
|
"attribution": comp.attribution,
|
|
"total_bytes": sum(r["bytes"] for r in refs),
|
|
"licence_text": fileref("licence", lic),
|
|
"sources": [{"url": CDN + n, "retrieved": RETRIEVED} for n in comp.names],
|
|
**({"note": comp.note} if comp.note else {}),
|
|
}
|
|
|
|
# Samples (committed; see SAMPLES), used only while they say today's text.
|
|
sample_index = json.loads((SAMPLES / "index.json").read_text()) if (SAMPLES / "index.json").exists() else {}
|
|
stale_samples: list[str] = []
|
|
|
|
def sample_for(v: Voice) -> dict | None:
|
|
entry = sample_index.get(v.id)
|
|
path = SAMPLES / f"{v.id}.mp3"
|
|
if not entry or entry.get("text") != SAMPLE_TEXT[v.lang] or not path.is_file():
|
|
stale_samples.append(v.id)
|
|
return None
|
|
sha = sha256_file(path)
|
|
if sha != entry.get("sha256") or path.stat().st_size > MAX_SAMPLE_BYTES:
|
|
raise BuildError(f"{path}: does not match samples/index.json (or is over {MAX_SAMPLE_BYTES} bytes)")
|
|
return fileref("sample", plan.add(f"samples/{v.lang}/{hashed(v.id + '.mp3', sha)}", path))
|
|
|
|
# Voices
|
|
packs_by_lang: dict[str, list[dict]] = {}
|
|
withheld_by_lang: dict[str, list[dict]] = {}
|
|
for v in VOICES:
|
|
if v.lang not in codes:
|
|
raise BuildError(f"{v.id}: language {v.lang} is not in i18n.rs")
|
|
if v.withheld:
|
|
withheld_by_lang.setdefault(v.lang, []).append(
|
|
{"id": v.id, "voice": {"name": v.name, "gender": v.gender}, "reason": v.withheld})
|
|
continue
|
|
requires = (["kokoro-core@1"] if v.engine == "kokoro" else []) + ([G2P[v.g2p].id] if v.g2p else [])
|
|
if v.engine == "kokoro":
|
|
name = f"kokoro/voices/{v.kokoro_voice}.kokorovoice"
|
|
src = fetch.get(CDN + name)
|
|
refs = [fileref(name, plan.add(f"v/{v.lang}/{hashed(v.kokoro_voice + '.kokorovoice', sha256_file(src))}", src))]
|
|
sources = [source(CDN + name)]
|
|
licence = "Apache-2.0" + (" AND CC-BY-4.0" if "CC-BY-4.0" in v.extra_texts else "") \
|
|
+ (" AND CC-BY-3.0" if "CC-BY-3.0" in v.extra_texts else "")
|
|
attribution = (f"Kokoro-82M voice {v.kokoro_voice} by hexgrad (Apache-2.0)."
|
|
+ (f" {v.extra_attribution}" if v.extra_attribution else ""))
|
|
licence_url = KOKORO_LICENCE_URL
|
|
options = {"voice": f"kokoro_{v.kokoro_voice}"}
|
|
notices = [Notice("Kokoro-82M voice list (grades, training data)", KOKORO_VOICES_MD)]
|
|
texts = ["Apache-2.0", *v.extra_texts]
|
|
upstream = [{"url": CDN + name, "retrieved": RETRIEVED},
|
|
{"url": f"https://huggingface.co/{KOKORO_REPO}/blob/{KOKORO_REV}/voices/{v.kokoro_voice}.pt",
|
|
"retrieved": RETRIEVED}]
|
|
else:
|
|
onnx_url = hf(PIPER_REPO, PIPER_REV, f"{v.piper_path}/{v.piper_stem}.onnx")
|
|
json_url = onnx_url + ".json"
|
|
ort = convert_piper(v, fetch.get(onnx_url), lock, args.offline, args.converter_python)
|
|
cfg = fetch.get(json_url)
|
|
refs = [
|
|
fileref("piper/onnx", plan.add(f"v/{v.lang}/{hashed(v.piper_stem + '.ort', sha256_file(ort))}", ort)),
|
|
fileref("piper/onnx.json", plan.add(f"v/{v.lang}/{hashed(v.piper_stem + '.onnx.json', sha256_file(cfg))}", cfg)),
|
|
]
|
|
sources = [source(onnx_url), source(json_url)]
|
|
licence = v.piper_licence
|
|
attribution = v.piper_attribution + " Converted to ORT format with onnxruntime 1.23.2."
|
|
licence_url = f"https://huggingface.co/{PIPER_REPO}/blob/{PIPER_REV}/{v.piper_path}/MODEL_CARD"
|
|
# Both keys: with only vocoder_engine, a language whose default voice is Kokoro
|
|
# (en_us) still looks for kokoro/config.json.
|
|
options = {"vocoder_engine": "piper", "voice": f"piper_{v.piper_stem}"}
|
|
notices = [Notice("Piper voice model card", hf(PIPER_REPO, PIPER_REV, f"{v.piper_path}/MODEL_CARD")),
|
|
Notice("piper-voices README (repository licence)", PIPER_README), *v.piper_notices]
|
|
texts = list(v.piper_texts)
|
|
upstream = [{"url": onnx_url, "revision": PIPER_REV, "retrieved": RETRIEVED,
|
|
"converted": {"tool": CONVERT_TOOL, "sha256": lock.converted[f"{v.piper_stem}.ort"]["sha256"]}},
|
|
{"url": json_url, "revision": PIPER_REV, "retrieved": RETRIEVED}]
|
|
text = licence_text(f"Voice {v.id}", licence, attribution, "", sources, notices, texts, fetch)
|
|
lic = plan.add(f"licences/{hashed(safe_id(v.id) + '.txt', sha256_bytes(text))}", data=text)
|
|
total = sum(r["bytes"] for r in refs) + sum(components[c]["total_bytes"] for c in requires)
|
|
sample = sample_for(v)
|
|
packs_by_lang.setdefault(v.lang, []).append({
|
|
"id": v.id,
|
|
"voice": {"name": v.name, "gender": v.gender, "age": "adult"},
|
|
"runtime": RUNTIME_ID,
|
|
"engine": v.engine,
|
|
"lang_key": v.lang_key,
|
|
"options": options,
|
|
"requires": requires,
|
|
"files": refs,
|
|
"total_bytes": total,
|
|
"total_bytes_with_runtime": total + runtimes[RUNTIME_ID]["total_bytes"],
|
|
"sample_rate": 24000,
|
|
"licence": licence,
|
|
"licence_url": licence_url,
|
|
"attribution": attribution,
|
|
"licence_text": fileref("licence", lic),
|
|
"rating": v.rating,
|
|
**({"intelligibility": {k: measured[v.id][k] for k in ("mean", "min", "model", "measured")}}
|
|
if v.id in measured else {}),
|
|
**({"sample": sample} if sample else {}),
|
|
"sample_text": SAMPLE_TEXT[v.lang],
|
|
"quality": v.quality,
|
|
"min_ai_tier": v.runtime_tier,
|
|
"default": v.default,
|
|
"sources": upstream,
|
|
})
|
|
|
|
if stale_samples:
|
|
log(f"samples: {len(stale_samples)} voice(s) have no current sample ({', '.join(stale_samples)}): "
|
|
"run `node tools/voices/render_samples.mjs`, listen to them, commit them, and build again")
|
|
|
|
languages: dict[str, dict] = {}
|
|
for code in codes:
|
|
packs = packs_by_lang.get(code, [])
|
|
if packs:
|
|
if code in LANGUAGE_STATUS:
|
|
raise BuildError(f"{code} has packs and a non-neural status")
|
|
if sum(p["default"] for p in packs) != 1:
|
|
raise BuildError(f"{code} needs exactly one default pack")
|
|
languages[code] = {"status": "neural", "packs": packs}
|
|
else:
|
|
if code in LANGUAGE_STATUS:
|
|
status, reason = LANGUAGE_STATUS[code]
|
|
elif code in withheld_by_lang:
|
|
gone = withheld_by_lang[code]
|
|
names = ", ".join(w["voice"]["name"] for w in gone)
|
|
means = sorted(measured[w["id"]]["mean"] for w in gone if w["id"] in measured)
|
|
if len(means) == len(gone):
|
|
share = f"{means[0]:.0%}" if means[0] == means[-1] else f"{means[0]:.0%} to {means[-1]:.0%}"
|
|
why = f"in testing, a speech recognizer understood {share} of everyday phrases they said"
|
|
else:
|
|
why = " ".join(w["reason"] for w in gone)
|
|
status, reason = "web-speech-only", (
|
|
f"The voices with a licence this app can use ({names}) do not yet say the words reliably with "
|
|
f"this voice engine: {why}.")
|
|
else:
|
|
raise BuildError(f"{code} has neither packs nor a recorded status")
|
|
languages[code] = {"status": status, "reason": reason, "packs": []}
|
|
if code in withheld_by_lang:
|
|
languages[code]["withheld"] = withheld_by_lang[code]
|
|
|
|
return {
|
|
"schema": SCHEMA,
|
|
"generated": RETRIEVED,
|
|
"base": "/app/voices/",
|
|
"runtimes": runtimes,
|
|
"components": components,
|
|
"languages": languages,
|
|
}
|
|
|
|
|
|
def write_tree(out_root: Path, plan: Plan) -> None:
|
|
# Files not in the plan are pruned, so never adopt a directory that is not ours.
|
|
if out_root.exists() and any(out_root.iterdir()) and not (out_root / "index.json").exists():
|
|
raise BuildError(f"refusing to prune non-empty {out_root}: it has no index.json from an earlier build")
|
|
out_root.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Content-addressed files first, the catalogue after them, and the
|
|
# pointer (index.json) last: a run that stops part way never leaves a
|
|
# pointer to files that are not there yet.
|
|
def order(rel: str) -> tuple[int, str]:
|
|
if rel == "index.json":
|
|
return (3, rel)
|
|
if rel == "LICENSES.md":
|
|
return (2, rel)
|
|
return (1 if re.fullmatch(r"index\.[0-9a-f]{12}\.json", rel) else 0, rel)
|
|
|
|
for rel, f in sorted(plan.files.items(), key=lambda kv: order(kv[0])):
|
|
dest = out_root / rel
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
fresh = dest.exists() and dest.stat().st_size == f.bytes and sha256_file(dest) == f.sha256
|
|
if not fresh:
|
|
tmp = dest.with_name(dest.name + ".tmp")
|
|
if f.data is not None:
|
|
tmp.write_bytes(f.data)
|
|
else:
|
|
shutil.copyfile(f.src, tmp)
|
|
os.replace(tmp, dest)
|
|
os.chmod(dest, 0o644)
|
|
os.utime(dest, (EPOCH, EPOCH))
|
|
keep = set(plan.files)
|
|
for p in sorted(out_root.rglob("*"), reverse=True):
|
|
rel = p.relative_to(out_root).as_posix()
|
|
if p.is_file() and rel not in keep:
|
|
p.unlink()
|
|
log(f"prune: {rel}")
|
|
elif p.is_dir() and not any(p.iterdir()):
|
|
p.rmdir()
|
|
|
|
|
|
# ─── LICENSES.md ────────────────────────────────────────────────────────────
|
|
|
|
|
|
def render_licences_md(cat: dict, cat_name: str) -> bytes:
|
|
o = io.StringIO()
|
|
w = o.write
|
|
w("# Neural voices: licences and attribution\n\n")
|
|
w(f"Generated by `tools/voices/build_voices.py` for catalogue `{cat_name}` "
|
|
f"(sources read {cat['generated']}). Do not edit by hand.\n\n")
|
|
w("Every neural voice runs entirely on the device, inside the browser; no text leaves it. Every one of "
|
|
"them is a **synthetic voice** and should be labelled so wherever it is offered (this also meets the "
|
|
"StyleTTS 2 pre-trained model condition that Kokoro inherits). Nothing below is non-commercial or "
|
|
"GPL: all of it may be used in a free app and in a later paid tier. ShareAlike terms bind only the "
|
|
"data or model files that carry them, not the app. The full licence and notice texts are in "
|
|
"`licences/`, one file per runtime, component and voice.\n\n")
|
|
w("## Runtime\n\n")
|
|
for rid, r in cat["runtimes"].items():
|
|
w(f"### {rid}\n\n- Licence: {r['licence']} ({r['licence_url']})\n- Attribution: {r['attribution']}\n"
|
|
f"- Full texts: `{r['licence_text']['url']}`\n- Source: {r['sources'][0]['url']} "
|
|
f"(integrity `{r['sources'][0]['integrity']}`)\n\n")
|
|
w("## Shared components\n\n")
|
|
for cid, c in cat["components"].items():
|
|
w(f"### {cid}\n\n- Licence: {c['licence']} ({c['licence_url']})\n- Attribution: {c['attribution']}\n")
|
|
if c.get("note"):
|
|
w(f"- Note: {c['note']}\n")
|
|
w(f"- Size: {mb(c['total_bytes'])} in {len(c['files'])} files\n- Full texts: `{c['licence_text']['url']}`\n\n")
|
|
w("## Voices\n\n")
|
|
for code, lang in cat["languages"].items():
|
|
for p in lang["packs"]:
|
|
dflt = ", default" if p["default"] else ""
|
|
w(f"### {p['id']} ({code}{dflt})\n\n- Voice: {p['voice']['name']} ({p['voice']['gender']}, "
|
|
f"{p['voice']['age']}), {p['engine']}; rated {p['rating']}; quality: {p['quality']}\n"
|
|
f"- Licence: {p['licence']} ({p['licence_url']})\n- Attribution: {p['attribution']}\n"
|
|
f"- Requires: {', '.join(p['requires']) or 'nothing shared'}; download {mb(p['total_bytes'])} "
|
|
f"({mb(p['total_bytes_with_runtime'])} with the runtime)\n- Full texts: `{p['licence_text']['url']}`\n"
|
|
+ (f"- Sample: `{p['sample']['url']}`, the voice's own output (under its licence), saying "
|
|
f"\"{p['sample_text']}\"\n" if p.get("sample") else "")
|
|
+ "\n")
|
|
withheld = [(code, x) for code, lang in cat["languages"].items() for x in lang.get("withheld", [])]
|
|
if withheld:
|
|
w("## Voices not offered\n\nThese pass the licence review but are not built or offered, because a voice "
|
|
"must say what the person chose.\n\n| voice | language | reason |\n|---|---|---|\n")
|
|
for code, x in withheld:
|
|
w(f"| {x['id']} ({x['voice']['name']}) | {code} | {x['reason']} |\n")
|
|
w("\n")
|
|
w("## Languages without a neural voice\n\n| code | status | reason |\n|---|---|---|\n")
|
|
for code, lang in cat["languages"].items():
|
|
if lang["status"] != "neural":
|
|
w(f"| {code} | {lang['status']} | {lang['reason']} |\n")
|
|
return o.getvalue().encode("utf-8")
|
|
|
|
|
|
def report(cat: dict, plan: Plan, out_root: Path, cat_name: str) -> None:
|
|
rt = cat["runtimes"][RUNTIME_ID]
|
|
log(f"\nruntime {RUNTIME_ID}: {mb(rt['total_bytes'])} in {rt['dir']}"
|
|
+ (f" entry {rt['entry']}" if "entry" in rt else " (no voice-worker.js found: no entry; pass --worker)"))
|
|
for cid, c in cat["components"].items():
|
|
log(f"component {cid:18} {mb(c['total_bytes']):>9}")
|
|
log(f"\n{'pack':28} {'own':>9} {'total':>9} {'+runtime':>9}")
|
|
for code, lang in cat["languages"].items():
|
|
for p in lang["packs"]:
|
|
own = sum(f["bytes"] for f in p["files"])
|
|
log(f"{p['id']:28} {mb(own):>9} {mb(p['total_bytes']):>9} {mb(p['total_bytes_with_runtime']):>9}"
|
|
+ (" default" if p["default"] else ""))
|
|
counts: dict[str, int] = {}
|
|
for lang in cat["languages"].values():
|
|
counts[lang["status"]] = counts.get(lang["status"], 0) + 1
|
|
total = sum(f.bytes for f in plan.files.values())
|
|
samples = sum(1 for lang in cat["languages"].values() for p in lang["packs"] if p.get("sample"))
|
|
packs = sum(len(lang["packs"]) for lang in cat["languages"].values())
|
|
log(f"\nlanguages: {counts}; samples for {samples} of {packs} voices")
|
|
log(f"output: {out_root} — {len(plan.files)} files, {mb(total)}; catalogue {cat_name}")
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
|
|
ap.add_argument("--out", type=Path, default=OUT_DEFAULT,
|
|
help="output dir (default: $AAC_VOICES_DIR, else aac-voice-packs beside the open-standards checkout)")
|
|
ap.add_argument("--update-lock", action="store_true", help="record pins for sources not yet in the lock")
|
|
ap.add_argument("--offline", action="store_true", help="use the cache only")
|
|
ap.add_argument("--worker", type=Path, help="voice-worker.js to ship in the runtime dir")
|
|
ap.add_argument("--converter-python", help="python3.10-3.13 used to create the ORT converter venv")
|
|
ap.add_argument("--jobs", type=int, default=4, help="parallel downloads")
|
|
args = ap.parse_args()
|
|
try:
|
|
build(args)
|
|
except BuildError as e:
|
|
print(f"error: {e}", file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|