open-standards/aac/aac-rs/tools/voices/build_voices.py
Transaction Science 8306784d39 aac: the reference app is the AAC Reference App, no longer "Express"
"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]>
2026-10-10 22:42:12 -04:00

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())