| Filename | Latest commit message | Latest commit date |
|---|---|---|
"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]> |
||
| .. | ||
| samples | ||
| .gitignore | ||
| build_voices.py | ||
| check_packs.py | ||
| intelligibility.json | ||
| intelligibility.mjs | ||
| intelligibility.py | ||
| phrases.json | ||
| README.md | ||
| render_samples.mjs | ||
| smoke.mjs | ||
| sources.lock.json | ||
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
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 installsORT_PINSinto it as wheels. - Network access the first time, about 1 GB. After that,
--offlinebuilds from the cache alone.
Flags
--update-lockrecords a pin for any source that is not yet insources.lock.json. Without it, an unpinned source stops the build.--worker PATHships a specificvoice-worker.js. By default the builder takesthird_party/moonshine-wasm/voice-worker.jsif it exists.--out DIRwrites somewhere other than the directory the site builds read.--converter-pythonsets 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
.onnxfiles are also pinned in the script to their Hugging Face LFS oid.
- GitHub and Hugging Face files by commit. Every revision is a constant at
the top of
- 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--offlinerun. The tree hash matched every time. - The ORT conversion needs one workaround.
convert_onnx_models_to_ortwrites 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 aposix_spawnflag, on Linuxsetarch -R), which makes the output stable. The same bytes came out of two different venvs. The converted SHA-256 is pinned in the lock'sconvertedtable. A different onnxruntime wheel on another platform may still produce other bytes; the build then stops, and after checking the voice withsmoke.mjsyou re-pin with--update-lock. - To change a pin: edit the constant, bump
RETRIEVED, run with--update-lock, review the diff ofsources.lock.json, and commit it together withdata/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
-
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 calledmoonshine.mjs, and renaming it would break threading. -
Only
moonshine.mjs,moonshine.wasmandvoice-worker.jsare shipped. The worker uses the low-level module API. The package's TypeScript layer is unused, and it defaults to fetching fromdownload.moonshine.ai, which the app must never do. -
No
voice-kind components. Each pack carries its own voice files infiles, and its licence inlicence,attributionandlicence_text. The only components arekokoro-core@1andg2p-<lang>@1. -
Piper options are
{"vocoder_engine":"piper","voice":"piper_<stem>"}. Withvocoder_enginealone, en_us still looks forkokoro/config.json, because its default voice is Kokoro. Thevoicekey is harmless for the other languages. -
Extra fields, all additive, which serde ignores:
- top level:
base; - runtime:
dir,module,wasm,requires,total_bytes,licence_textandsources; - components:
total_bytes,licence_text,sourcesandnote; - packs:
total_bytes_with_runtime,licence_textandsources.
reasonis left out for neural languages rather than set to null, becauseneural_voice.rsreads it as aString. - top level:
-
How
min_ai_tiermaps toAiTier::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.
- Kokoro is
-
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). -
data/voices/index.jsonis the catalogue, not the pointer. The pointer alone would be useless in git. aac-aurasense-web'ssync-languages.mjsreads it, so /languages/ cannot disagree with the app, andcheck_packs.pyholds 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-coriis 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-codessid = 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
ttsSplitUtterancesdoes (the utterances itssay()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", bysay()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.
- Build the packs on dev (into
aac-voice-packsbeside the open-standards checkout, by default), commitdata/voices/, then runsmoke.mjs:python3.14 tools/voices/build_voices.py node tools/voices/smoke.mjs - The site builds check them with
check_packs.pyagainst the committed catalogue, then copy them in (cloned, not copied, on APFS), each from$AAC_VOICES_DIRwhen set: aac-aurasense-web'sscripts/build-app.shintodist/app/voices/(served ataac.aurasense.ai/app/voices/), andprerender-static.shintovoices/(served ataac.transaction.science/voices/). Packs that are missing, stale or incomplete stop the build;AAC_VOICES=nonebuilds without voices on purpose (CI does), and the site then offers none. - aac-aurasense-web's
scripts/precompress.mjswrites 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. - Caddy (site-ops
Caddyfile, theaac.aurasense.aiandaac.transaction.scienceblocks) sends:Cache-Control: public, max-age=31536000, immutable, no-transformfor every content-addressed file (a hash in its name, or in the runtime's directory name), andno-cache, no-transformforindex.jsonandLICENSES.md, which the app also fetches withcache: "no-store";- COOP
same-originand COEPrequire-corp(everything is same-origin, so no CORP header is needed); .wasmasapplication/wasmand.mjsastext/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.
- After a deploy, check that the pointer names a catalogue that is served,
on each host:
The site builds giveB=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 80index.jsonandLICENSES.mdthe 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. - The app vendors the same runtime separately, through
third_party/moonshine-wasm/vendor.shinto/app/vendor/. This build'sruntime/directory is the copy the pack format describes, and its licence entry is the one the Licences screen shows. Serve both, or drop one onceneural_voice.rssettles 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.wasmunder 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.