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
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]>
75 lines
3.2 KiB
Python
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))
|