open-standards/aac/aac-rs/tools/voices/intelligibility.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

75 lines
3.2 KiB
Python

#!/usr/bin/env python3
"""Measure whether each voice says the words: the evidence its rating rests on.
Every voice says its language's everyday phrases (phrases.json); a speech
recognizer (Whisper large-v3-turbo, on MLX) writes down what it hears; the
similarity of that to the phrase, 0 to 1 after dropping case, spaces and
punctuation, is the score. The results go into intelligibility.json
(committed), from which build_voices.py rates each voice and withholds any
that says other words (see RATING_RULE there).
python tools/voices/intelligibility.py # every voice in the built packs
python tools/voices/intelligibility.py de-piper-mls ja # some voices or languages
Needs Node >= 22 and an MLX Python (Apple Silicon) with mlx-whisper; the
model (about 1.6 GB) downloads once. Voices the build withholds are not in the
packs, so their last results are kept: delete one's entry to test it again.
"""
import difflib
import json
import re
import subprocess
import sys
import tempfile
import types
import unicodedata
from datetime import date
from pathlib import Path
HERE = Path(__file__).resolve().parent
RESULTS = HERE / "intelligibility.json"
MODEL = "mlx-community/whisper-large-v3-turbo"
try:
import mlx_whisper
except ImportError as e:
if "numba" not in str(e).lower():
raise
# Numba only serves word timestamps, unused here, and some environments'
# Numba refuses their NumPy: stand in for it.
stub = types.ModuleType("numba")
stub.jit = stub.njit = lambda *a, **k: (a[0] if len(a) == 1 and callable(a[0]) and not k else (lambda f: f))
sys.modules["numba"] = stub
import mlx_whisper
def norm(text):
return re.sub(r"[\W_]+", "", unicodedata.normalize("NFKC", text).lower())
def main(argv):
with tempfile.TemporaryDirectory() as tmp:
subprocess.run(["node", str(HERE / "intelligibility.mjs"), tmp, *argv[1:]], check=True)
items = json.loads((Path(tmp) / "manifest.json").read_text())
results = json.loads(RESULTS.read_text()) if RESULTS.exists() else {}
per = {}
for it in items:
heard = mlx_whisper.transcribe(it["file"], path_or_hf_repo=MODEL, language=it["lang"], verbose=None)["text"].strip()
score = difflib.SequenceMatcher(None, norm(it["text"]), norm(heard)).ratio()
# Babble makes a recognizer repeat itself at length: keep the start.
kept = heard if len(heard) <= 200 else heard[:200] + "\u2026"
per.setdefault(it["pack"], []).append({"text": it["text"], "heard": kept, "score": round(score, 3),
"seconds": it["seconds"]})
for pack, phrases in per.items():
scores = [p["score"] for p in phrases]
results[pack] = {"mean": round(sum(scores) / len(scores), 3), "min": round(min(scores), 3),
"model": MODEL, "measured": date.today().isoformat(), "phrases": phrases}
print(f"{pack:26} mean {results[pack]['mean']:.2f} min {results[pack]['min']:.2f}")
RESULTS.write_text(json.dumps(dict(sorted(results.items())), ensure_ascii=False, indent=1) + "\n")
print(f"wrote {RESULTS}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))