sano (सानो) — Nepali for "small." A family of tiny neural text-to-speech voices — 294k to 2.3M parameters — that run with no cloud and no NPU: real-time on a ~$3 ESP32-S3 (out a GPIO into an LM386 and a speaker), or live in the browser via WASM.
- smallest neural TTS family known — 294k to 2.3M parameters
- runs real-time on a $3 microcontroller — measured RTF 0.383 on an ESP32-S3, 2.6x faster than real time, with the Xtensa LX7 SIMD kernels the Arduino library assembles by default (BOARDS.md, 2026-09-04). Scalar C on the same board is 1.58, i.e. slower than real time
- runs right in your browser — WebAssembly, no server
- 0.3 to 8.7 MB per voice depending on precision — 337 KB int8 (heart-nano), ~3 MB fp16, 5.5-8.7 MB fp32 — zero dependencies (espeak-ng phonemizer included)
- 30 voices across 16 languages in the browser demo — English, German, French, Spanish, Italian, Portuguese, Russian, Czech, Romanian, Turkish, Arabic, Nepali (नेपाली), Hindi (हिन्दी), Vietnamese (Tiếng Việt), Indonesian (Bahasa), Chinese (中文). The pip package ships 27 of them across 13 languages; Nepali, Hindi and Chinese are browser-only for now
- new (2026-09-08): ten more languages — German, Turkish, Russian, French,
Spanish, Italian, Portuguese, Romanian, Czech and Arabic — each ~1.56M
parameters, and eight of them also in a ~511k variant. Whisper word error rate
on out-of-domain text runs 0.038 (Portuguese) to 0.392 (Romanian); that is
intelligibility over 16 sentences per language, not naturalness, and no one has
run a listening test. Numbers and method in
experiments/evidence/ood-tatoeba-20260908.json - heart, our best-sounding voice at 2.27M parameters (24 kHz), and heart-nano, the same voice in 294k parameters — a complete text-to-speech stack, int8, in 337 KB. Both synthesize live in the browser demo
- open source, GPL-3.0
ampixa.github.io/sanoTTS — every voice synthesizes your text live in the browser. No server, no upload: text goes through an espeak-ng-in-WASM phonemizer and that voice's own neural stack, all client-side.
| Platform | Install | Then |
|---|---|---|
| Python | pip install sanotts |
sanotts say "Hello" --voice amy -o hello.wav |
| Web (npm) | npm install sanotts-web |
const tts = await SanoTTS.load(); await tts.synthesize('Hello', {voice:'amy'}) |
| Web (no build) | copy dist/ + voices/ |
see Deploy on your own site |
| Arduino / PlatformIO | zip-install or lib_deps = https://github.com/Ampixa/sanoTTS.git |
arduino/README.md |
| Hugging Face | huggingface.co/ampixa/sanoTTS |
voice packages + samples |
| Browser | nothing | ampixa.github.io/sanoTTS |
CLI + library; voices download on first use:
pip install sanotts
sanotts say "Hello from a two megabyte voice." --voice amy -o hello.wav
sanotts say "Xin chào!" --voice vi -o xinchao.wavimport sanotts
result = sanotts.synthesize("Hello world", voice="amy") # numpy audio @ 22.05 kHzVoices, largest to smallest: heart (2.27M), hfc and amy-1p8m (1.8M),
amy, kristin, vi and id (1.46M), amy-1p1m (1.1M), and heart-nano
(294k, 337 KB of weights).
They are fetched from
huggingface.co/ampixa/sanoTTS into
~/.cache/sanotts/, and fall back to the GitHub
voices-v1 and
voices-v2 releases
if Hugging Face cannot be reached. Set SANOTTS_VOICE_SOURCE=hf or =github
to pin one host. Pure numpy inference, no torch, no onnxruntime.
The SanoTTS library lives in arduino/: add it to the Arduino
IDE as a .zip library, or in platformio.ini:
lib_deps = https://github.com/Ampixa/sanoTTS.gitSee arduino/README.md for board support (ESP32-S3 ✓),
memory guidance, and flashing the model blobs
(mcu-kristin-745k-q8.tar.gz).
Every voice package, plus audio samples, lives at
huggingface.co/ampixa/sanoTTS. That is
where pip install sanotts downloads from by default; the GitHub releases are
the fallback.
Nothing to install: tts.ampixa.com/sanoTTS.
sanoTTS's browser demo runs entirely client-side — WebAssembly, no server — so "deploying" it just means hosting a handful of static files. Two ways to do it:
Option A — npm install sanotts-web (on npm)
import { SanoTTS, playAudio } from 'sanotts-web';
const tts = await SanoTTS.load({
assetBase: 'https://your-cdn.example.com/sanotts/', // where you copied dist/
});
const result = await tts.synthesize('Hello from my own server.', {
voice: 'amy',
voiceBase: 'https://your-cdn.example.com/sanotts/', // where you copied voices/
});
playAudio(result);Copy the package's dist/ (the wasm runtime) and this repo's web/voices/
directory to your own static host, then point assetBase/voiceBase at it.
Everything else — phonemization, synthesis, playback — happens in the
visitor's browser.
Option B — no build, no npm
Copy web/snt_g2p.js, web/snt_g2p.wasm, web/snt_g2p.data,
web/snt_voice.js, web/snt_voice.wasm, and web/voices/ from this repo
(or scrape them straight from ampixa.github.io/sanoTTS) onto your static
host, and load them the same way web/index.html does:
<script src="/sanotts/snt_g2p.js"></script>
<script src="/sanotts/snt_voice.js"></script>
<script type="module">
const [G2P, Voice] = await Promise.all([SaanoG2P(), SaanoVoice()]);
G2P._snt_g2p_init();
// ...set voice, phonemize, synthesize — see web/index.html for the full sequence.
</script>Sizes to plan around:
- wasm runtime: ~700 KB total gzipped over the wire (espeak-ng G2P
ships ~2.5 MB uncompressed including its phoneme-table
.data, ~700 KB gzipped; the acoustic/decoder wasm adds another ~40 KB) - per-voice weights, fetched lazily on first use of that voice and never
bundled with the runtime:
- int8 — heart-nano at 337 KB, the on-device stack unchanged
- fp16 — the ten voices added 2026-09-08 at ~3.0 MB each; fp16 is the reference for these, not an approximation, since every number they have was measured on the fp16 package. The browser widens them to fp32 after download and checks a sha256 of the widened bytes
- fp32 — the twelve older voices, 5.5 to 8.7 MB (amy 5.6, Vietnamese 6.0, heart 8.7). Converting these to fp16 would halve them, and is not done only because their published output is the fp32 output
CSP note: the wasm runtime needs 'wasm-unsafe-eval' (or
'unsafe-eval' on older browsers) in your script-src Content-Security-Policy,
for WebAssembly.instantiate/instantiateStreaming. Nothing else needs
relaxing — the runtime never eval()s JavaScript. Most default/modern CSPs
(including having no explicit script-src) already allow this.
Open small-scale TTS on an honest gate — a diverse 24-sentence set scored with the same no-reference suite (SCOREQ / UTMOS are naturalness predictors, DNSMOS-SIG is signal quality; higher is better). Parameter counts are inference-time and exclude the shared external G2P.
Kokoro is 36x larger than our largest voice (heart, 2.27M), and 279x larger than our smallest (heart-nano, 294k). Shipped-file sizes: sanoTTS amy 2.8 MB fp16 and TinyTTS 3.5 MB fp16, both verified from the released files; Kokoro's ~330 MB fp32 is its widely cited public figure.
| System | Params | SCOREQ | UTMOS | DNS-SIG |
|---|---|---|---|---|
| sanoTTS (amy) | 1.46 M | 4.13 | 4.10 | 3.61 |
| sanoTTS (heart) | 2.27 M | 3.48 | 3.38 | 3.51 |
| sanoTTS (heart-nano) | 0.29 M | 2.29 | 2.45 | 3.35 |
| TinyTTS | 1.62 M | 3.94 | 3.65 | 3.62 |
| Inflect Nano | 4.63 M | 3.81 | 3.65 | 3.58 |
| Kitten TTS nano | 15 M | 3.02 | 3.58 | 3.43 |
| reference (~15 M) | ~15 M | 4.71 | 4.47 | 3.65 |
| Kokoro | 82 M | 4.89 | 4.52 | 3.69 |
sanoTTS is the smallest model here and the best on naturalness (SCOREQ and
UTMOS) among everything up to 15M params — beating TinyTTS while being smaller.
On DNSMOS-SIG, TinyTTS edges us by 0.01 — no single metric tells the whole story.
It's the only one that runs a full neural stack on a $3 MCU. Parameter count
isn't destiny at this scale: Kitten TTS at 10x the size scores a full SCOREQ
point lower. The frontier only pulls ahead at ~15M-class models and Kokoro (82M,
60x larger) — a gap we don't claim to close. Reproduce it with
tools/eval_mos_all.py + tools/eval_scorecard.py.
Same voice (amy), same duration/acoustic recipe — only decoder size changes. Quality lives in the decoder: doubling it from 1.09M to 1.84M params moves SCOREQ from 3.70 to 4.16.
| Language | Voice | Params | SCOREQ | WER |
|---|---|---|---|---|
| English 🇺🇸 | amy | 1.45 M | 4.13 | 0.058 ‡ |
| kristin | 1.40 M | 4.09 | 0.171 ‡ | |
| hfc | 1.83 M | 3.94 | 0.069 ‡ | |
| amy-small | 1.08 M | 3.70 | — | |
| heart (24 kHz) | 2.27 M | 3.48 | 0.080 ‡ | |
| heart-nano (int8, 24 kHz) | 294 k | 2.29 | 0.083 ‡ | |
| robot (on-device, int8) | 567 k | — | — | |
| German 🇩🇪 | German | 1.57 M | — | 0.099 |
| German small | 512 k | — | 0.145 | |
| Turkish 🇹🇷 | Turkish | 1.56 M | — | 0.303 |
| Turkish small | 510 k | — | — | |
| Russian 🇷🇺 | Russian | 1.57 M | — | 0.120 |
| Russian small | 512 k | — | — | |
| French 🇫🇷 | French | 1.57 M | — | 0.221 |
| Spanish 🇪🇸 | Spanish | 1.56 M | — | 0.147 |
| Spanish small | 510 k | — | — | |
| Italian 🇮🇹 | Italian | 1.57 M | — | 0.080 |
| Italian small | 512 k | — | — | |
| Portuguese 🇧🇷 | Portuguese | 1.57 M | — | 0.038 |
| Portuguese small | 512 k | — | — | |
| Romanian 🇷🇴 | Romanian | 1.57 M | — | 0.392 |
| Romanian small | 512 k | — | — | |
| Czech 🇨🇿 | Czech | 1.57 M | — | 0.351 |
| Czech small | 513 k | — | — | |
| Arabic 🇯🇴 | Arabic | 1.57 M | — | 0.274 |
| Nepali नेपाली | Nepali | 1.47 M | — | — |
| Hindi हिन्दी | Hindi | 1.50 M | — | — |
| Vietnamese Tiếng Việt | Vietnamese | 1.57 M | 1.53 | 0.468 |
| Indonesian Bahasa | Indonesian | 1.56 M | 1.71 | 0.256 |
| Chinese 中文 | Chinese | 1.55 M | — | 0.262 † |
Two different questions, and neither column answers the other. SCOREQ is a no-reference quality predictor — how it sounds. WER is Whisper word error rate — whether the words arrive. A voice can score well on one and badly on the other, and on the two voices where we have both, the second number is much less flattering: Indonesian reads 0.256 word error, so the words do arrive, and still scores 1.71 against amy's 4.13.
‡ English WER is measured on a different set from the other languages: 24 diverse held-out English sentences, the same ones the SCOREQ column uses, rather than the 16 Tatoeba sentences. Corpus WER — total errors over total words — which is why it is not the mean of the per-clip figures.
Worth reading across that row rather than down it. heart-nano at 294 k scores 0.083 against heart's 0.080 at 2.27 M — the same words arrive from a model 7.7 times smaller — while SCOREQ separates them 2.29 against 3.48. That is the whole point of carrying both columns: shrinking the model cost almost nothing in intelligibility and a great deal in how it sounds.
† Chinese WER is not the same measurement as the others. Mandarin is not written with spaces, so there are no word tokens to compare until a segmenter invents them — this figure segments both sides with jieba, which means part of it is the segmenter disagreeing with itself rather than the voice being wrong. The comparable Chinese number is character error rate: 0.201 on the same 24 held-out sentences, against the teacher's 0.188. The voice it replaced scored 0.468.
An empty cell means unmeasured, not zero. The English SCOREQ figures share one eval set; the Indonesian and Vietnamese ones come from a different set (24 held-out Tatoeba sentences each), so the column ranks within a language rather than across them — SCOREQ is a predictor trained largely on English and comparing it across languages is not an established use of it. WER for the ten languages added on 2026-09-08 comes from 16 held-out Tatoeba sentences each, out of domain from the corpus they were distilled on — directional, not precise. Nepali, Hindi and Chinese predate that harness and have neither number. No listening test has been run on any non-English voice.
The "robot" row is the same 567,008-parameter model that runs on the ESP32-S3 —
bit-exact with the chip's own output. (Some older packaging, including the
mcu-kristin-745k-q8.tar.gz filename, carries a "745k" label; that was a
directory name, never a parameter count for these binaries.)
heart and heart-nano are a second recipe: a 100-band mel interface between
the acoustic model and a noise-shaping ConvNeXt + iSTFT decoder, at 24 kHz.
heart-nano is the smallest complete neural TTS stack we have built —
duration 22,858 + acoustic 65,299 + decoder 206,122 = 294,279 parameters, shipped
as 337 KB of int8 blobs (web/voices/heartnano/) and run in the browser with the
int8 arithmetic of the microcontroller build unchanged (mcu/src/snt_nano.c,
golden fixture mcu/test/fixtures/en_us_e13b). heart ships float32 weights
(web/voices/heart/, 9.1 MB): its int8 form fails the 0.98 golden gate at 0.951
minimum correlation, the float build reproduces the training-side output at
1.000000 (mcu/test/fixtures/en_us_r227f32). Rebuild both with
mcu/ports/wasm/build_nano.sh, gate with mcu/ports/wasm/verify_nano_node.mjs.
Other people's work, linked because it is theirs and because some of it goes where we have not.
sanoTTS-jp — Japanese, which this
repository does not have. A clean-room reimplementation of the distillation
recipe by @ayutaz, 559 K parameters, MIT, teacher
is piper-plus (MB-iSTFT-VITS2). It does
the harder half of Japanese on the chip: morphological analysis of mixed
kanji/kana and pitch-accent estimation, not just the acoustic model. Browser
demo at ayutaz.github.io/sanoTTS-jp —
type kanji, it speaks. Inference is dependency-free C99 that never calls
malloc.
Hardware ports of it, all M5Stack: CoreS3 (ESP-IDF), Tab5 / ESP32-P4 (PIE SIMD), Stack-chan (streaming synthesis with avatar lip-sync), and RLCD4.2.
An independent hardware measurement of sanoTTS-jp on an M5Stack AtomS3, and worth reading precisely because it does not agree with the headline: correctness PASS (PCM checksum exactly matched the upstream baseline), repeatability PASS, real-time FAIL at xRT 1.718 against a threshold of 1.0. Different board, different language, different model from the one we measure at 0.383 xRT — which is the point. "Runs real-time on a $3 chip" is a claim about a specific model on specific silicon with specific kernels, and someone changing any of those may well find otherwise.
kokopop — a standalone C++ library for running neural TTS from GGUF with no Python, MIT.
If you have built something, open an issue and it goes here.
espeak-ng provides phoneme IDs; a duration model predicts timing; an acoustic
model predicts generator latents; a decoder renders 22 kHz audio.
The web voices (amy, kristin, hfc, and the other languages) use a compact
time-domain decoder running in fp32 WASM; the on-device model instead uses
a quantized int8 iSTFT decoder, sized to fit and run in real time on the
ESP32-S3. heart / heart-nano predict a 100-band mel spectrogram and render it
with a noise-fed ConvNeXt + iSTFT decoder at 24 kHz (mcu/src/snt_nano.c).
The end-to-end recipe is in the docs:
build a probe pack → train the duration, acoustic-latent, and decoder models →
joint finetune → export int8. New-language porting is
docs/roota-language-porting-recipe.md.
pip install -e .
# then follow the training recipe in docs/ to make a new voice- ESP32-S3 talking device — a standalone WiFi dashboard: type text, the board
phonemizes (on-chip espeak-ng) and speaks. See
mcu/ports/esp32s3/. - Browser — the full stack in WASM, no server. ▶ Hear and synthesize all 9
voices live (GitHub Pages); source in
web/. - Other MCUs — which chips can run it and how well:
docs/mcu-classes-and-porting.md.
The eval loop measures what actually matters — intelligibility (Whisper WER),
phoneme-class fidelity, and G2P parity — not just a gameable MOS score:
tools/eval_scorecard.py, tools/eval_phoneme_class_fidelity.py,
tools/eval_g2p_parity.py.
docs/repository-layout.md. In short: src/saanotts/
(package), tools/ (pipeline + eval commands), mcu/ (portable C runtime + device
ports), web/ (browser demo), configs/ + data/textsets/ (contracts).
The inference runtime is MIT. The project as a whole is GPLv3.
| Licence | |
|---|---|
Runtime + language bindings (mcu/src/snt_*.c, mcu/include/, mobile/) |
MIT — see LICENSE.MIT |
| Everything else, including the espeak-ng G2P ports and the training tooling | GPL-3.0-or-later — see LICENSE |
The copyleft comes from espeak-ng
alone, which is used for grapheme-to-phoneme. An earlier version of this note
said piper was GPLv3 too. That needs splitting: the original
rhasspy/piper and piper-phonemize are MIT,
and the teacher voices we distil from are MIT data — but piper-tts on PyPI,
which our training tools import, is
OHF-Voice/piper1-gpl and is
GPL-3.0-or-later. It is a training-time tool that the shipped package never
imports, so it does not reach the runtime, but "piper is MIT" is too loose to
leave standing.
The runtime files were audited against that boundary: none of them reference
espeak, and the espeak-ng code lives entirely in the G2P and port layers, which
stay GPLv3. LICENSE.MIT lists every covered file and shows the reasoning,
including the upstream licences it was checked against. So you can embed the
runtime in a permissively-licensed project; you cannot embed the espeak-ng G2P
without taking GPLv3 with it.
Copyright (C) 2026 Ampixa.