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

369 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Neural voice packs
`build_voices.py` builds the optional on-device neural voices for the AAC
Reference App: one runtime (Moonshine WASM 0.1.5), the shared Kokoro-82M core,
the G2P data for each language, and the voices themselves. It writes them in
the `aac-voice-index/1` pack format with a catalogue and full licence texts.
Neither the pack files nor the app bundle go into git. The catalogue and
`LICENSES.md` are committed in `data/voices/`; each voice's sample and the
measurements its rating rests on are committed here.
v1 offers 16 voices in 8 of the 45 languages: en, es, fr, pt, it, zh and hi
on Kokoro, and nl (Flemish) on Piper. ha and yo are `planned` for v1.1
(OpenBible VITS on onnxruntime-web). de, ja and uk had voices, which are
withheld because they say other words (below); with the other 32 languages,
they are `web-speech-only`, each with its reason in the catalogue and in
`LICENSES.md`.
## Ratings, and voices not offered
A voice must say the words the person chose. That is measured, not assumed:
`intelligibility.py` has every voice say its language's seven everyday phrases
(`phrases.json`: a request, help, stop, yes, no, a feeling, a question), a
speech recognizer (Whisper large-v3-turbo) writes down what it hears, and the
score is how much of each phrase it got back (0 to 1, ignoring case, spaces
and punctuation). The results are committed in `intelligibility.json`, and
`build_voices.py` derives each voice's `rating` from them and from the
upstream naturalness grade (Kokoro's VOICES.md), by `RATING_RULE`:
| rating | earned by | the app says |
|---|---|---|
| withheld | under 0.80 on average: it says other words | (not offered) |
| `experimental` | under 0.90 on average, any phrase under 0.60, or never measured | "Experimental: it may not sound right. Listen to it before relying on it." |
| `clear` | 0.90 and over | "Clear and easy to understand, though less natural." |
| `natural` | 0.95 and over, with an upstream grade of A or B | "Sounds natural." |
A missing or unknown rating reads as `experimental` in the app. Each pack
carries its `intelligibility` (mean, min, model, date) for maintainers, beside
`quality`, the notes behind the grade; neither is shown.
Measured on 2026-09-30, twice (Piper voices vary a little from run to run):
| voice | mean | lowest | rating |
|---|---:|---:|---|
| en Heart (A) | 1.00 | 1.00 | natural |
| en Michael (C+) | 0.99 | 0.92 | clear |
| en Emma (B-) | 0.99 | 0.92 | natural |
| en Cori (Piper) | 0.92 | 0.67 | clear |
| es Dora, Alex | 1.00, 0.99 | 1.00, 0.95 | clear |
| fr Siwis (B-) | 0.98 | 0.92 | natural |
| it Sara, Nicola (C) | 0.98, 0.96 | 0.89, 0.87 | clear |
| nl Reader, Flemish (Piper) | 0.95 | 0.90 | clear |
| zh Xiaobei, Yunjian (D) | 0.97, 0.96 | 0.77, 0.71 | clear |
| pt Dora, Alex | 0.86, 0.84 | 0.40, 0.55 | experimental ("mãe", "não" garbled) |
| hi Alpha, Omega (C) | 0.87, 0.88 | 0.62, 0.69 | experimental ("नहीं" garbled) |
| ja Alpha, Kumo (C+, C-) | 0.69, 0.63 | 0.29 | withheld: misreads 外, 好き, 疲れた |
| nl Reader, Netherlands (Piper MLS) | 0.27 | 0.00 | withheld: babble |
| de Reader (Piper MLS) | 0.14 | 0.00 | withheld: babble |
| uk Lada (Piper) | 0.18 | 0.00 | withheld: other words |
The failures are the voices with this runtime, not the packaging: the same
conversion makes Cori and the Flemish voice clear, Moonshine has its own
Ukrainian pronunciation rules (`uk` is a rule-based locale in 0.1.5), and
both Japanese voices misread the same words, so its Japanese reading is at
fault. Test a withheld voice again after a runtime update by deleting its
entry in `intelligibility.json`: it is then built, rated experimental, and
measured.
A voice withheld this way, or by hand (`withheld` on its definition, as for
Norman), is not built or offered. Its reason goes into the catalogue (the
language's `withheld` list, which the app ignores; a language left with no
voice says why, in plain words, under "Why") and into `LICENSES.md`. It keeps
its definition and its pins.
## Samples
Every offered voice has a sample: two or three seconds of it saying its
language's `SAMPLE_TEXT` (a greeting and a request), MP3 at 32 kbit/s, about
10 KB. The app plays it, with the sentence written beside it, before the
person downloads the voice, so nobody spends 100 MB on a voice they would not
choose. `render_samples.mjs` renders them from the built packs with LAME
(peaks kept at -1 dBFS) into `samples/` with `samples/index.json`; they are
committed, because synthesis is not repeatable (Piper samples its durations),
and `build_voices.py` publishes each under `samples/<lang>/` beside the voices,
only while it says the current text. Listen to every sample before committing
it. A sample is the voice as it is: Portuguese Dora's says "mais açúcar"
(sugar) for "mais suco", and that is what the person should hear.
A voice from an anonymous dataset speaker is called "Reader" (with the
region, where a language has two), never by its dataset id.
## Running it
```bash
cd aac/aac-rs
python3.14 tools/voices/build_voices.py # build and verify every pin (~15 s once cached)
node tools/voices/smoke.mjs # synthesize with every pack (~35 s, Node >= 22)
node tools/voices/smoke.mjs --wav /tmp/v en # also write WAVs to listen to
python3 tools/voices/check_packs.py ../../../aac-voice-packs # what every site build runs first
node tools/voices/render_samples.mjs # samples missing or stale, then listen and commit
python tools/voices/intelligibility.py # measure (MLX Python with mlx-whisper; Apple Silicon)
```
The packs go to `aac-voice-packs` beside the open-standards checkout
(`$AAC_VOICES_DIR` overrides it, and `--out` overrides both): the directory
the site builds copy from. The pointer `index.json` is written last, after
every file it names.
What you need:
- Python >= 3.11, standard library only.
- For the Piper conversion, python3.12 or python3.13 on PATH, because
onnxruntime 1.23.2 has no 3.14 wheels. On first use the builder creates
`.cache/ort-venv/` and installs `ORT_PINS` into it as wheels.
- Network access the first time, about 1 GB. After that, `--offline` builds
from the cache alone.
### Flags
- `--update-lock` records a pin for any source that is not yet in
`sources.lock.json`. Without it, an unpinned source stops the build.
- `--worker PATH` ships a specific `voice-worker.js`. By default the builder
takes `third_party/moonshine-wasm/voice-worker.js` if it exists.
- `--out DIR` writes somewhere other than the directory the site builds read.
- `--converter-python` sets which Python builds the converter venv.
## Pins and reproducibility
- **What is pinned, and how:**
- GitHub and Hugging Face files by commit. Every revision is a constant at
the top of `build_voices.py`, read on 2026-09-29.
- The npm tarball by version and by the registry's sha512 integrity.
- Every downloaded file by exact size and SHA-256 in `sources.lock.json`.
- Piper `.onnx` files are also pinned in the script to their Hugging Face
LFS oid.
- **The Moonshine CDN has no revisions.** Files from `download.moonshine.ai/tts/`
are pinned by content only. If Moonshine replaces a file, the build stops
with a checksum mismatch rather than shipping something different.
- **Downloads are cached.** They go once into `.cache/src/<sha256>` (git-ignored)
and are re-hashed on every run.
- **Rebuilds are byte-identical.** A second run gives the same bytes and the
same mtimes: every output file is stamped with `RETRIEVED`. This was
checked with a fresh conversion, after deleting `.cache/conv`, and with an
`--offline` run. The tree hash matched every time.
- **The ORT conversion needs one workaround.** `convert_onnx_models_to_ort`
writes the kernel-type table in the order of an absl hash map, and absl
seeds that hash from an address. Two ordinary runs therefore differ in
about 3 KB near the start of the file. The builder runs the converter with
ASLR off (on macOS a `posix_spawn` flag, on Linux `setarch -R`), which makes
the output stable. The same bytes came out of two different venvs. The
converted SHA-256 is pinned in the lock's `converted` table. A different
onnxruntime wheel on another platform may still produce other bytes; the
build then stops, and after checking the voice with `smoke.mjs` you
re-pin with `--update-lock`.
- **To change a pin:** edit the constant, bump `RETRIEVED`, run with
`--update-lock`, review the diff of `sources.lock.json`, and commit it
together with `data/voices/`.
- **Everything is the latest stable release, with one deliberate match.**
Moonshine WASM 0.1.5 is the newest on npm (checked 2026-09-30). The Piper
converter is onnxruntime 1.23.2, not the newest (1.30.0), because its
ORT-format output has to load in the ORT 1.23 core linked into
`moonshine.wasm`. It moves when a Moonshine release links a newer ORT.
## Output: the pack format
Everything in the output directory is served at `/app/voices/` (and at
`/voices/` on aac.transaction.science):
| path | what |
|---|---|
| `index.json` | pointer `{schema, current}`; served `no-cache`, fetched `no-store` |
| `index.<sha12>.json` | the catalogue (same bytes as the committed `data/voices/index.json`) |
| `LICENSES.md` | attribution for everything; served `no-cache` (it is not content-addressed) |
| `runtime/moonshine-wasm-0.1.5-<sha12>/` | `moonshine.mjs`, `moonshine.wasm`, `voice-worker.js` |
| `c/kokoro/<file>.<sha12>.<ext>` | Kokoro-82M core (prosody and decoder stages, config), 83.4 MB |
| `c/g2p/<lang>/<path>.<sha12>.<ext>` | Moonshine G2P data (en_us, fr, de, nl, hi, it, pt_br, ja, zh_hans) |
| `v/<lang>/<voice>.<sha12>.<ext>` | `.kokorovoice`, or a Piper `.ort` plus its `.onnx.json` |
| `licences/<id>.<sha12>.txt` | full licence text, upstream notices and sources for each runtime, component and pack |
Every name except `index.json` and `LICENSES.md` carries the first 12 hex
digits of its SHA-256, or sits in a content-addressed directory. Those names
match `sw.js` `IMMUTABLE`, and a changed file always gets a new URL.
`FileRef.name` is the key the file is handed to `TextToSpeech` under
(`kokoro/prosody.model.ort`, `en_us/oov/model.ort`, `piper/onnx`,
`piper/onnx.json`, ...). `smoke.mjs` checks that these keys cover the
runtime's own `ttsDependencies()` list for every pack.
### Where this differs from the plan, and why
1. **Runtime files keep their npm names, inside a content-addressed directory.**
Emscripten starts its pthreads with
`new Worker(new URL("moonshine.mjs", import.meta.url))`, so the file must
be called `moonshine.mjs`, and renaming it would break threading.
2. **Only `moonshine.mjs`, `moonshine.wasm` and `voice-worker.js` are shipped.**
The worker uses the low-level module API. The package's TypeScript layer
is unused, and it defaults to fetching from `download.moonshine.ai`, which
the app must never do.
3. **No `voice`-kind components.** Each pack carries its own voice files in
`files`, and its licence in `licence`, `attribution` and `licence_text`.
The only components are `kokoro-core@1` and `g2p-<lang>@1`.
4. **Piper options are `{"vocoder_engine":"piper","voice":"piper_<stem>"}`.**
With `vocoder_engine` alone, en_us still looks for `kokoro/config.json`,
because its default voice is Kokoro. The `voice` key is harmless for the
other languages.
5. **Extra fields**, all additive, which serde ignores:
- top level: `base`;
- runtime: `dir`, `module`, `wasm`, `requires`, `total_bytes`,
`licence_text` and `sources`;
- components: `total_bytes`, `licence_text`, `sources` and `note`;
- packs: `total_bytes_with_runtime`, `licence_text` and `sources`.
`reason` is left out for neural languages rather than set to null,
because `neural_voice.rs` reads it as a `String`.
6. **How `min_ai_tier` maps to `AiTier::as_attr`:**
- Kokoro is `wasm-only`, so it runs on any device above Minimal. Its
peak RSS is about 0.5 GB, and it never uses WebGPU, so gating it on a
WebGPU tier would exclude devices for no reason.
- Piper is `minimal`.
7. **Added `en-piper-cori`** (en_GB-cori-high, LibriVox public domain, trained
from scratch, MIT repository). The plan named it as Norman's clean
alternative, and it is clean on the single words where Norman fails
(see below).
8. **`data/voices/index.json` is the catalogue, not the pointer.** The
pointer alone would be useless in git. aac-aurasense-web's
`sync-languages.mjs` reads it, so /languages/ cannot disagree with the
app, and `check_packs.py` holds the built packs to it.
## Sizes
These are download sizes in MB. The runtime (13.3 MB) is fetched once.
Shared components are counted once per device, so after the first Kokoro
language, the next one costs only its G2P data and 0.5 MB of voice.
| pack | own | total | + runtime |
|---|---:|---:|---:|
| en-kokoro-af_heart (default) | 0.5 | 109.0 | 122.3 |
| en-kokoro-am_michael / bf_emma | 0.5 | 109.0 | 122.3 |
| en-piper-cori | 114.9 | 140.0 | 153.3 |
| es-kokoro-ef_dora (default) / em_alex | 0.5 | 83.9 | 97.3 |
| fr-kokoro-ff_siwis (default) | 0.5 | 115.4 | 128.7 |
| pt-kokoro-pf_dora (default) / pm_alex | 0.5 | 86.6 | 99.9 |
| it-kokoro-if_sara (default) / im_nicola | 0.5 | 85.7 | 99.0 |
| nl-piper-rdh (default) | 63.7 | 67.4 | 80.8 |
| zh-kokoro-zf_xiaobei (default) / zm_yunjian | 0.5 | 187.8 | 201.1 |
| hi-kokoro-hf_alpha (default) / hm_omega | 0.5 | 84.0 | 97.3 |
Each pack's sample adds about 10 KB, downloaded only when played. The whole
output is 93 files and 452 MB. The largest single file is Cori's
Piper model, about 115 MB (the Chinese RoBERTa weights are next, about
102 MB), under model_store's 256 MB limit.
## Smoke test results (Node 26, MacBook Pro M5 Max, 2026-09-29)
All 21 packs built then (the 16 offered now, with de, ja, uk and nl's MLS
voice; 22 with Norman, before it was withheld) loaded from the output
directory alone and synthesized a request and a single word at 24 kHz. That
showed they run, not that they say the words: `intelligibility.py` came later,
and withheld five of them.
| engine | load | real-time factor |
|---|---|---|
| Kokoro | 0.2–0.9 s | about 0.35–0.47 |
| Piper | 0.4–1.9 s | about 0.03–0.22 |
Node's RSS peaked at about 2.4 GB with all the runtimes and packs loaded one
after another in one process. These are desktop numbers: measure on an iPad
and a mid-range Android before choosing defaults.
Quality problems found, and recorded in each voice's `quality`:
- **en-piper-norman (withheld):** the single word "more" comes out as 3–5 s
of babble. It does the same with a plain onnxruntime reference inference of
the upstream `.onnx`, so the fault is in the voice, not in Moonshine or the
conversion. "juice" is fine. `en-piper-cori` is the light English voice
instead.
- **de-piper-mls (withheld):** speaker 0 takes 5–7 s over "Ich möchte mehr
Saft, bitte." (4.9 s and 7.0 s in two runs on 2026-09-30), also under
reference inference, where about 2 s is a normal pace; the app's speech
rate (the runtime's `speed`) shortens it only to 3.7 s at 2.0, the fastest.
It was first offered as experimental on the belief that it said the right
words slowly. Measured, it does not: a recognizer understood 14% of its
everyday phrases. Moonshine 0.1.5 hard-codes `sid = 0`, so no other MLS
speaker can be tried; the Dutch MLS voice fails the same way.
- **Sentences are the runtime's own.** The app splits a message where
Moonshine's `ttsSplitUtterances` does (the utterances its `say()` makes), so
"1.5 mg", "1.000 Euro" and "Dr. Smith" stay whole. It does take a German
ordinal for the end of a sentence: "Am 3. Oktober" is said as "Am 3." and
"Oktober", by `say()` as much as by the app.
`smoke.mjs` flags a single word that takes longer than 2.5 s as `?`. That is
a warning, not a failure.
## Deploying
The packs ride along with each site's own build and deploy; nothing is copied
by hand.
1. Build the packs on dev (into `aac-voice-packs` beside the open-standards
checkout, by default), commit `data/voices/`, then run `smoke.mjs`:
```bash
python3.14 tools/voices/build_voices.py
node tools/voices/smoke.mjs
```
2. The site builds check them with `check_packs.py` against the committed
catalogue, then copy them in (cloned, not copied, on APFS), each from
`$AAC_VOICES_DIR` when set: aac-aurasense-web's `scripts/build-app.sh` into
`dist/app/voices/` (served at `aac.aurasense.ai/app/voices/`), and
`prerender-static.sh` into `voices/` (served at
`aac.transaction.science/voices/`). Packs that are missing, stale or
incomplete stop the build; `AAC_VOICES=none` builds without voices on
purpose (CI does), and the site then offers none.
3. aac-aurasense-web's `scripts/precompress.mjs` writes brotli and gzip
sidecars for the pronunciation tables and the Kokoro and Chinese models
(a French voice downloads as about 78 MB instead of 129); the sidecars are
cached by content, so only new files cost time.
4. Caddy (site-ops `Caddyfile`, the `aac.aurasense.ai` and
`aac.transaction.science` blocks) sends:
- `Cache-Control: public, max-age=31536000, immutable, no-transform` for
every content-addressed file (a hash in its name, or in the runtime's
directory name), and `no-cache, no-transform` for `index.json` and
`LICENSES.md`, which the app also fetches with `cache: "no-store"`;
- COOP `same-origin` and COEP `require-corp` (everything is same-origin,
so no CORP header is needed);
- `.wasm` as `application/wasm` and `.mjs` as `text/javascript`. The other
files are fetched as bytes and checked against their SHA-256, so their
type does not matter;
- a real 404 for a missing file, never the app's index page.
5. After a deploy, check that the pointer names a catalogue that is served,
on each host:
```bash
B=https://aac.aurasense.ai/app/voices
curl -fsS "$B/$(curl -fsS "$B/index.json" | python3 -c 'import json,sys; print(json.load(sys.stdin)["current"])')" | head -c 80
```
The site builds give `index.json` and `LICENSES.md` the build's own
modification time, because the builder stamps every file with one fixed
time and rsync compares size and time: otherwise a new pointer of the same
length would not be copied.
6. The app vendors the same runtime separately, through
`third_party/moonshine-wasm/vendor.sh` into `/app/vendor/`. This build's
`runtime/` directory is the copy the pack format describes, and its
licence entry is the one the Licences screen shows. Serve both, or drop one
once `neural_voice.rs` settles on a single location.
## Licences
`LICENSES.md` lists every runtime, component and pack, with its licence,
attribution, sources and size. Each `licences/*.txt` file holds the upstream
notices verbatim, fetched at pinned commits, with the full SPDX texts from
`spdx/license-list-data` v3.29.0. Nothing is NC or GPL.
Obligations the app has to meet:
- **Label every neural voice "synthetic voice".** This covers the StyleTTS 2
pre-trained model condition that Kokoro inherits.
- **Show every attribution on a Licences / Sources screen.** EDRDG, whose
EDICT data the ja G2P uses, requires the acknowledgement on a separate
About or Sources screen, not only a launch page.
- **Offer the MPL-2.0 source.** Eigen is linked into `moonshine.wasm` under
MPL-2.0, whose source is https://gitlab.com/libeigen/eigen.
- **Keep ShareAlike files under their own licence.** These files are
CC BY-SA:
- the de and hi dictionaries;
- the it WikiPron data;
- the ja EDICT data and the ja RoBERTa model.
If any of them is modified, the modified version stays CC BY-SA.
Licence evidence was read on 2026-09-29. The URL for each item is recorded
in the catalogue (`licence_url`, `sources`) and in its licence text.