A device voice now has a tier, or a reason it is never used, decided from what the browser exposes (its name, identifier and language) and the platform, never from `default` or `localService` alone (WebKit sets both on every voice): - Rejected: a voice that sends the words away (not on the device, "Online", "-network"), eSpeak, Linux speech-dispatcher, Apple's Eloquence voices (Eddy, Flo, Grandma...), novelty voices (Readium's list, and Fred, Junior, Kathy and Ralph) and Apple's super-compact voices. - Premium: Apple Premium (identifier, or its marker in the interface language), a local "(Natural)" voice, or one Readium rates high (Google's natural voices on ChromeOS among them). - Enhanced: Apple Enhanced, or an Android speech engine's voice (Android Chrome lists them under localized names, so the platform comes in). - Basic: Apple compact, Microsoft's desktop voices (David, Zira, Mark...), other "Chrome OS" voices, anything unknown. rank_voices orders a language's usable voices by tier, then the region asked for, the language's usual region, Readium's order. choose_voice gives the person's chosen voice while the device has it, else the best that may speak unasked (premium or enhanced), else none. SystemVoice gains the identifier (voiceURI); Platform reads the user agent (iPadOS Safari says it is a Mac: touch points tell them apart). earlier_choice keeps the old ranking, to keep the voice of someone who was speaking with it; the app still uses it until the next commit. The facts come from Readium Speech v0.13.0 (npm latest; its json/ is byte-identical to v0.11.0's), voice data CC0 and code BSD-3-Clause. third_party/readium-speech/generate.py reads the pinned commit's files, checks their digest, and writes them as Rust tables (access-core/src/voice/readium.rs): nothing is fetched at run time. Tests use voice lists as iPad and Mac Safari, Mac Chrome, Windows Chrome and Edge, ChromeOS, Android Chrome and Linux give them. Co-Authored-By: Claude Opus 5.5 <[email protected]>
219 lines
9.6 KiB
Python
219 lines
9.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Vendor the device-voice facts the app ranks voices with, from Readium Speech.
|
|
|
|
python3 third_party/readium-speech/generate.py [--cache DIR] [--check]
|
|
|
|
Readium Speech (https://github.com/readium/speech, BSD-3-Clause) documents
|
|
the voices browsers expose through Web Speech: which are novelty or
|
|
very-low-quality voices, how Apple marks a voice's quality in its name in
|
|
each interface language, and, per language, every known voice with its
|
|
quality, in the project's recommended order. The voice data started as
|
|
Hadrien Gardeur's web-speech-recommended-voices, released under CC0.
|
|
|
|
This script reads those JSON files at one pinned commit (the v0.13.0 release;
|
|
its json/ is byte-identical to v0.11.0's), checks them against FILES_DIGEST,
|
|
and writes crates/access-core/src/voice/readium.rs: plain Rust tables, so
|
|
the app ships the facts inside its own code, fetches nothing at run time,
|
|
and sends nothing anywhere. Only what ranking needs is kept:
|
|
|
|
- per language the app has (and Mandarin and Cantonese for Chinese): its
|
|
default region, and each voice's names (normalised as
|
|
voice::readium_key does) and quality, in Readium's order. Network
|
|
voices (Edge's "Online", desktop Chrome's Google voices) are left out:
|
|
the app never uses a voice that sends words away, whatever its quality.
|
|
- the novelty voices (names, localised names, Apple identifiers);
|
|
- the very-low-quality ones by kind: Apple's Eloquence voices and the old
|
|
MacinTalk ones (eSpeak is matched by name, so its list is not needed);
|
|
- Apple's localised Enhanced and Premium markers.
|
|
|
|
--check fails, writing nothing, if the generated file is not what the pinned
|
|
data gives (CI and pre-commit use). Python >= 3.11, standard library only.
|
|
Downloads about 300 KB once into the cache (default ~/.cache/aac-rs).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
AAC = HERE.parent.parent
|
|
OUT = AAC / "crates" / "access-core" / "src" / "voice" / "readium.rs"
|
|
|
|
# ─── Pins ────────────────────────────────────────────────────────────────────
|
|
# @readium/speech v0.13.0 (npm latest, published 2026-10-02; read 2026-10-04).
|
|
# Tag v0.13.0 points at this commit.
|
|
VERSION = "0.13.0"
|
|
COMMIT = "4a7b73dd37a736e6762497ea7d7bc773c0fe1a30"
|
|
RAW = f"https://raw.githubusercontent.com/readium/speech/{COMMIT}"
|
|
# sha256 over "<path> <sha256 of its bytes>\n" for FILES, sorted by path.
|
|
FILES_DIGEST = "58f761681f5959ca178b80e2c62973830aab6c8cfde49895ca7c917b023a32a6"
|
|
|
|
# Readium language files the app reads: its languages that Readium documents,
|
|
# with Chinese as Mandarin (cmn) and Cantonese (yue).
|
|
LANGUAGES = [
|
|
"ar", "bn", "cmn", "cs", "da", "de", "el", "en", "es", "fa", "fi", "fr",
|
|
"he", "hi", "hu", "id", "it", "ja", "kn", "ko", "mr", "ms", "nb", "nl",
|
|
"pl", "pt", "ro", "ru", "sv", "ta", "te", "th", "tr", "uk", "vi", "yue",
|
|
]
|
|
FILES = sorted(
|
|
["LICENSE", "json/filters/novelty.json", "json/filters/veryLowQuality.json",
|
|
"json/localizedNames/apple.json"]
|
|
+ [f"json/{code}.json" for code in LANGUAGES]
|
|
)
|
|
QUALITIES = {"veryLow": "VeryLow", "low": "Low", "normal": "Normal", "high": "High", "veryHigh": "VeryHigh"}
|
|
|
|
|
|
def fetch(path: str, cache: Path) -> bytes:
|
|
"""A pinned file, from the cache or the commit's raw URL."""
|
|
local = cache / COMMIT / path
|
|
if local.is_file():
|
|
return local.read_bytes()
|
|
with urllib.request.urlopen(f"{RAW}/{path}", timeout=60) as r: # noqa: S310 (pinned https URL)
|
|
data = r.read()
|
|
local.parent.mkdir(parents=True, exist_ok=True)
|
|
local.write_bytes(data)
|
|
return data
|
|
|
|
|
|
def digest(files: dict[str, bytes]) -> str:
|
|
lines = "".join(f"{p} {hashlib.sha256(files[p]).hexdigest()}\n" for p in sorted(files))
|
|
return hashlib.sha256(lines.encode()).hexdigest()
|
|
|
|
|
|
def key(name: str) -> str:
|
|
"""A voice name as voice::readium_key normalises it: lower case, ASCII
|
|
parentheses and what is in them removed, spaces collapsed."""
|
|
s = re.sub(r"\s*\([^)]*\)", "", name.lower())
|
|
return " ".join(s.split())
|
|
|
|
|
|
def is_network(v: dict) -> bool:
|
|
"""A voice that sends the words to its provider: Edge's "Online" voices
|
|
and desktop Chrome's Google voices."""
|
|
return "Online" in v["name"] or v.get("browser") == ["ChromeDesktop"]
|
|
|
|
|
|
def rs(s: str) -> str:
|
|
return json.dumps(s, ensure_ascii=False)
|
|
|
|
|
|
def strs(items) -> str:
|
|
return "&[" + ", ".join(rs(i) for i in items) + "]"
|
|
|
|
|
|
def generate(files: dict[str, bytes]) -> str:
|
|
load = lambda p: json.loads(files[p].decode("utf-8")) # noqa: E731
|
|
out = [
|
|
"// @generated by third_party/readium-speech/generate.py from Readium Speech",
|
|
f"// v{VERSION} (commit {COMMIT}): voice data CC0-1.0, code BSD-3-Clause.",
|
|
"// See third_party/readium-speech/NOTICE.txt. Do not edit by hand.",
|
|
"",
|
|
"USE_LINE",
|
|
"",
|
|
"/// One documented voice: every name it goes by (as `readium_key` gives",
|
|
"/// them) and its quality, lowest first when it comes in several sizes.",
|
|
"pub(super) struct Entry {",
|
|
" pub keys: &'static [&'static str],",
|
|
" pub quality: &'static [Quality],",
|
|
"}",
|
|
"",
|
|
"/// A language's documented voices, in Readium's recommended order.",
|
|
"pub(super) struct Language {",
|
|
" pub code: &'static str,",
|
|
" pub default_region: &'static str,",
|
|
" pub voices: &'static [Entry],",
|
|
"}",
|
|
"",
|
|
"/// A novelty voice: its names and Apple identifiers.",
|
|
"pub(super) struct Novelty {",
|
|
" pub names: &'static [&'static str],",
|
|
" pub ids: &'static [&'static str],",
|
|
"}",
|
|
"",
|
|
"pub(super) static LANGUAGES: &[Language] = &[",
|
|
]
|
|
for code in LANGUAGES:
|
|
data = load(f"json/{code}.json")
|
|
region = data["defaultRegion"].split("-")[-1]
|
|
out.append(f" Language {{ code: {rs(code)}, default_region: {rs(region)}, voices: &[")
|
|
for v in data["voices"]:
|
|
if is_network(v):
|
|
continue
|
|
keys = []
|
|
for n in [v["name"], *v.get("altNames", [])]:
|
|
k = key(n)
|
|
if k and k not in keys:
|
|
keys.append(k)
|
|
quality = ", ".join(QUALITIES[q] for q in v.get("quality") or [] if q in QUALITIES)
|
|
out.append(f" Entry {{ keys: {strs(keys)}, quality: &[{quality}] }},")
|
|
out.append(" ] },")
|
|
out.append("];")
|
|
out.append("")
|
|
|
|
novelty = load("json/filters/novelty.json")["voices"]
|
|
out.append("pub(super) static NOVELTY: &[Novelty] = &[")
|
|
for v in novelty:
|
|
names = [key(n) for n in [v["name"], *v.get("altNames", [])]]
|
|
out.append(f" Novelty {{ names: {strs(names)}, ids: {strs(v.get('nativeID', []))} }},")
|
|
out.append("];")
|
|
out.append("")
|
|
|
|
low = load("json/filters/veryLowQuality.json")["voices"]
|
|
eloquence = [key(v["name"]) for v in low if v.get("localizedName") == "apple"]
|
|
macintalk = [key(v["name"]) for v in low
|
|
if v.get("localizedName") != "apple" and not v["name"].lower().startswith("espeak")]
|
|
out.append("/// Apple's Eloquence voices (very low quality).")
|
|
out.append(f"pub(super) static ELOQUENCE: &[&str] = {strs(eloquence)};")
|
|
out.append("")
|
|
out.append("/// Apple's old MacinTalk voices (very low quality).")
|
|
out.append(f"pub(super) static MACINTALK: &[&str] = {strs(macintalk)};")
|
|
out.append("")
|
|
|
|
markers = load("json/localizedNames/apple.json")["quality"]
|
|
out.append("/// Apple's quality markers in each interface language: (locale, Enhanced, Premium).")
|
|
out.append("pub(super) static APPLE_MARKERS: &[(&str, &str, &str)] = &[")
|
|
for loc, m in markers.items():
|
|
out.append(f" ({rs(loc)}, {rs(m['normal'])}, {rs(m['high'])}),")
|
|
out.append("];")
|
|
out.append("")
|
|
text = "\n".join(out)
|
|
used = sorted({q for q in QUALITIES.values() if re.search(rf"\b{q}\b", text.split("pub(super) static LANGUAGES", 1)[1])})
|
|
return text.replace("USE_LINE", f"use super::Quality::{{self, {', '.join(used)}}};", 1)
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("--cache", type=Path, default=Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "aac-rs" / "readium-speech")
|
|
ap.add_argument("--check", action="store_true", help="fail if the generated file is out of date")
|
|
args = ap.parse_args()
|
|
|
|
files = {p: fetch(p, args.cache) for p in FILES}
|
|
got = digest(files)
|
|
if got != FILES_DIGEST:
|
|
print(f"✗ Readium Speech files digest {got}, pinned {FILES_DIGEST}", file=sys.stderr)
|
|
print(" (a new pin: read the changes, then set FILES_DIGEST, and VERSION/COMMIT if they moved)", file=sys.stderr)
|
|
return 1
|
|
text = generate(files)
|
|
license_text = files["LICENSE"]
|
|
if args.check:
|
|
stale = [p for p, want in ((OUT, text.encode()), (HERE / "LICENSE.txt", license_text))
|
|
if not p.is_file() or p.read_bytes() != want]
|
|
for p in stale:
|
|
print(f"✗ {p.relative_to(AAC)} is out of date: run third_party/readium-speech/generate.py", file=sys.stderr)
|
|
return 1 if stale else 0
|
|
OUT.parent.mkdir(parents=True, exist_ok=True)
|
|
OUT.write_text(text, encoding="utf-8")
|
|
(HERE / "LICENSE.txt").write_bytes(license_text)
|
|
print(f"✓ {OUT.relative_to(AAC)} ({len(text.encode()):,} bytes) from Readium Speech v{VERSION}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|