open-standards/aac/aac-rs/tools/voices/check_packs.py
Transaction Science d9e0f89b89
Some checks failed
standards workspaces build + test / jouleclaw-rs (push) Successful in 3m1s
standards workspaces build + test / aac-rs (push) Successful in 1m24s
standards workspaces build + test / joule-code-rs (push) Successful in 54s
standards workspaces build + test / joulecontract-rs (push) Successful in 59s
no-vanity-metrics / Scan committed Markdown for vanity metrics (push) Successful in 4s
standards workspaces build + test / map-rs (push) Successful in 1m8s
standards workspaces build + test / mesh-rs (push) Successful in 40s
standards workspaces build + test / eoc-rs (push) Successful in 1m49s
standards workspaces build + test / jcp-rs (push) Successful in 1m25s
standards workspaces build + test / openpay (push) Successful in 3m24s
standards workspaces build + test / proof-rs (push) Successful in 1m16s
standards workspaces build + test / sandbox-rs (push) Successful in 2m9s
standards workspaces build + test / smart-byte-rs (push) Successful in 2m36s
standards workspaces build + test / arl-rs (push) Has been cancelled
standards workspaces build + test / joule-ui-rs (push) Has been cancelled
standards workspaces build + test / jouledesk-rs (push) Has been cancelled
standards workspaces build + test / joulehook-rs (push) Has been cancelled
standards workspaces build + test / sense-rs (push) Has been cancelled
standards workspaces build + test / macOS energy backend (jcp-energy) (push) Has been cancelled
feat(aac voices): hear a voice before downloading it; ratings measured
A better voice is a 100 MB download, and the person could not hear
one before deciding. Making the samples showed something worse: some
voices did not say the words at all.

- Samples: each voice says a greeting and a request in its language
  (two or three seconds, MP3, about 10 KB). Settings > Better voices
  plays it with the sentence written beside it, before any download;
  checked by size and SHA-256 and played through the voices' own
  output. render_samples.mjs renders them with LAME; they are
  committed (Piper's synthesis is not repeatable), and build_voices.py
  publishes each beside the voices while it says the current text.
- Intelligibility: intelligibility.py has every voice say seven
  everyday phrases, a speech recognizer (Whisper large-v3-turbo on MLX)
  writes down what it hears, and the scores are committed. Ratings now
  come from that evidence (RATING_RULE): under 0.80 the voice is
  withheld; under 0.90, or any phrase under 0.60, experimental; 0.90
  and over clear; 0.95 and over with an upstream grade of A or B,
  natural.
- Withheld, because they say other words: German (the only German
  voice, 0.14), Dutch MLS (0.27), Ukrainian (0.18) and both Japanese
  voices (0.63-0.69: they misread 外, 好き and 疲れた). Their languages
  say so plainly under "Why". Portuguese and Hindi are experimental;
  Spanish, Chinese, Italian, Flemish and Cori are clear; Heart, Emma
  and Siwis natural. 16 voices in 8 languages, 452 MB of packs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 20:20:25 -04:00

95 lines
3.3 KiB
Python

#!/usr/bin/env python3
"""Check a voice-pack directory before a site build ships it.
python3 tools/voices/check_packs.py DIR
DIR must hold what tools/voices/build_voices.py built for the committed
catalogue (data/voices/index.json): its index.json points at
index.<sha12>.json, whose bytes are the committed catalogue's, and every file
that catalogue names is there with its exact size and SHA-256. Otherwise this
exits non-zero and says what is wrong, so a build never ships packs that
disagree with the catalogue the site describes, or that are incomplete.
Standard library only; Python 3.9 or later (macOS's own python3).
"""
import hashlib
import json
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
COMMITTED = HERE.parents[1] / "data" / "voices" / "index.json"
SHOW = 10
def sha256(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def file_refs(cat):
"""Every FileRef the catalogue names: runtimes, components and packs,
each with its licence text, and each pack's sample."""
refs = []
owners = list(cat.get("runtimes", {}).values()) + list(cat.get("components", {}).values())
for lang in cat.get("languages", {}).values():
owners += lang.get("packs", [])
for owner in owners:
refs += owner.get("files", [])
for one in ("licence_text", "sample"):
if one in owner:
refs.append(owner[one])
return refs
def check(out):
committed = COMMITTED.read_bytes()
want = "index.%s.json" % hashlib.sha256(committed).hexdigest()[:12]
try:
pointer = json.loads((out / "index.json").read_text(encoding="utf-8"))
except (OSError, ValueError) as e:
return ["%s: no readable index.json (%s)" % (out, e)]
problems = []
if pointer.get("current") != want:
problems.append(
"index.json points at %s, but the committed catalogue is %s: rebuild the packs "
"with tools/voices/build_voices.py" % (pointer.get("current"), want)
)
catalogue = out / want
if not catalogue.is_file() or catalogue.read_bytes() != committed:
problems.append("%s is missing, or differs from data/voices/index.json" % want)
return problems
for ref in file_refs(json.loads(committed)):
path = out / ref["url"]
if not path.is_file():
problems.append("missing: %s" % ref["url"])
elif path.stat().st_size != ref["bytes"]:
problems.append("%s is %d bytes, not %d" % (ref["url"], path.stat().st_size, ref["bytes"]))
elif sha256(path) != ref["sha256"]:
problems.append("%s does not match its SHA-256" % ref["url"])
return problems
def main(argv):
if len(argv) != 2:
print(__doc__.strip(), file=sys.stderr)
return 2
out = Path(argv[1])
problems = check(out)
if problems:
print("✗ voice packs in %s do not match the committed catalogue:" % out, file=sys.stderr)
for p in problems[:SHOW]:
print(" - " + p, file=sys.stderr)
if len(problems) > SHOW:
print(" ... and %d more" % (len(problems) - SHOW), file=sys.stderr)
return 1
print("✓ voice packs in %s match the committed catalogue" % out)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))