Skip to content

Repository files navigation

sanoTTS — a tiny neural voice that runs anywhere

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.

sanoTTS — thirty tiny voices, sixteen languages, browser + $3 chip

  • 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

Live demo

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.

Install & use

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

Python

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.wav
import sanotts
result = sanotts.synthesize("Hello world", voice="amy")   # numpy audio @ 22.05 kHz

Voices, 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.

Arduino / PlatformIO

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.git

See arduino/README.md for board support (ESP32-S3 ✓), memory guidance, and flashing the model blobs (mcu-kristin-745k-q8.tar.gz).

Hugging Face

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.

Browser

Nothing to install: tts.ampixa.com/sanoTTS.

Deploy on your own site

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.

How it stacks up

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.

Size comparison: sanoTTS 0.75M-1.8M params vs TinyTTS 1.62M vs Inflect Nano 4.63M vs Kokoro 82M, linear axis

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.

The size-quality frontier: SCOREQ rising from 3.70 to 4.16 as decoder size grows from 1.09M to 1.84M params, same voice

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.

The voices

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.

Built on sanoTTS

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.

How it works

text → duration → acoustic → decoder → audio

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).

Train your own voice

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

Deploy

Verify your result

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.

Layout

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).

License

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.

About

sanoTTS (सानो = 'small' in Nepali): a ~1.4M-param neural TTS that runs on a $3 chip or in the browser. Leads SCOREQ/UTMOS in the sub-15M class.

Resources

Stars

745 stars

Watchers

6 watching

Forks

Releases

Packages

Contributors

Languages