stable-twi-tts
Speech synthesis for Twi and Ghanaian English, running on ONNX — a ~50 MB dependency instead of a ~2 GB PyTorch install, many times realtime on a laptop CPU, and portable to Linux, macOS, Windows, ARM and mobile.
Two things it does that a generic TTS wrapper doesn't:
- Voices are ranked by measured intelligibility, not by how much audio they were trained on. The checkpoint holds 1,555 pseudo-speakers; the 12 offered by name were selected by synthesising held-out text and scoring it with a phoneme recogniser. Hours turned out to be a poor predictor.
- Batch generation is first-class. Point it at a
.csvof 50,000 lines and it parallelises, resumes, records a manifest, and doesn't abort the run because line 4,012 had a character it couldn't pronounce.
pip install "stable-twi-tts[twi,eng]"
No system packages, no manual downloads. twi brings the Twi front-end (ghana-g2p); eng
brings libespeak-ng 1.51 bundled in a wheel for English words; the model fetches itself from a
GitHub release over the standard library with checksums verified.
The espeak version is pinned deliberately, not incidentally: this model was trained on 1.51,
and espeak's English changes between releases — 1.52 differs on 7.1% of words, 1.53 on 2.4%.
A system espeak-ng still works if you prefer it, and warns when its version is not 1.51. The
eng extra is separate because espeak-ng is GPL-3.0 while this package is MIT, so taking on that
obligation should be your choice.
stable-twi-tts --voice twi-6 --text "Akwaaba, wo ho te sɛn?" --out hello.wav
That fetches the published voice (~80 MB) on first run and caches it. Pass --model <dir> to use
a local voice directory instead.
Listen
Every voice, same two sentences
So you can compare voices directly rather than across different content.
Twi — Akwaaba, wo ho te sɛn? Me da wo ase paa. Code-switched — Mepɛ sɛ mesua [computer science] wɔ [University of Ghana].
| voice | Twi sample | code-switch sample | twi-only err | code-switch err | hours |
|---|---|---|---|---|---|
twi-1 |
▶ play | ▶ play | 34% | 60% | 6.9 |
twi-2 |
▶ play | ▶ play | 29% | 61% | 5.8 |
twi-3 |
▶ play | ▶ play | 29% | 61% | 11.2 |
twi-4 |
▶ play | ▶ play | 32% | 61% | 8.5 |
twi-5 |
▶ play | ▶ play | 28% | 61% | 5.9 |
twi-6 |
▶ play | ▶ play | 27% | 61% | 2.7 |
twi-7 |
▶ play | ▶ play | 27% | 61% | 3.3 |
twi-8 |
▶ play | ▶ play | 30% | 61% | 2.4 |
twi-9 |
▶ play | ▶ play | 28% | 66% | 3.5 |
twi-10 |
▶ play | ▶ play | 30% | 64% | 4.4 |
twi-11 |
▶ play | ▶ play | 30% | 63% | 9.5 |
twi-12 |
▶ play | ▶ play | 30% | 64% | 3.4 |
Error figures are round-trip phoneme error: synthesise, re-recognise, compare against what was asked for. Lower is better; the real-audio floor is 25.9% for Twi.
Range of text
| text | sample | |
|---|---|---|
| greeting | Akwaaba! Yɛma wo akwaaba wɔ Ghana. | ▶ play |
| statement | Ghana yɛ ɔman a ɛwɔ Afrika atɔeɛ fam. | ▶ play |
| question | Wo din de sɛn? Wofiri he na woreba? | ▶ play |
| long | Anɔpa yi, ɔsoro abue na awia bɔ. Nnipa pii firi wɔn afie mu rekɔ adwuma, na mmɔfra nso rekɔ sukuu. | ▶ play |
| numbers | Yɛn nsa kaa nnipa apem ne ahanum wɔ ɔmantam no mu. | ▶ play |
| news | Ɔkyerɛkyerɛni no kaa sɛ [the examination] bɛba [next week]. | ▶ play |
| institution | [Bank of Ghana] abɔ [interest rate] no so bio. | ▶ play |
| english | Good morning, and welcome to the news. | ▶ play |
These are epoch-7 samples, not a finished voice. Twi is solid; English is audibly weaker and band-limited to 8 kHz because its training audio was 16 kHz where Twi's was 24 kHz.
Web interface
A local GUI for people who would rather not use a terminal — and for turning documents into speech without writing a script.
pip install "stable-twi-tts[web,twi,eng]"
stable-twi-tts-web # http://127.0.0.1:7860
Three ways in — type text, upload a PDF, or give a URL — and two ways out: a single clip, or batch mode that splits into sentences and returns one joined wav or a zip of separate files with a manifest.
Extraction and synthesis are deliberately separate steps: a PDF or web page becomes editable text first, so you can fix a heading, drop a footer or bracket the English before anything is spoken. Batch progress streams as it goes, because a 40-page PDF otherwise looks like a hang. The voice picker shows each voice's measured error, since that is the part worth choosing on.
It binds to 127.0.0.1 by default. --host 0.0.0.0 exposes it, and the URL-fetch endpoint then
makes requests from your machine on a caller's behalf — private, loopback and link-local addresses
are refused, but do not put this on an untrusted network.
Speak something
stable-twi-tts --text "Akwaaba, wo ho te sɛn?" --out hello.wav
stable-twi-tts --language eng --text "Good morning, Accra." --out en.wav
from stable_twi_tts import StableTwiTTS
tts = StableTwiTTS.from_pretrained() # downloads and caches the published voice
tts.synthesize("Akwaaba, wo ho te sɛn?", voice="twi-6").save("hello.wav")
# or point at a local voice directory
tts = StableTwiTTS("voices/my_voice")
Choosing a voice
stable-twi-tts --list-voices
voice lang hours code-switch twi-only source
twi-1 twi 6.88 59.8% 33.5% spk_0016
twi-2 twi 5.80 60.7% 29.1% spk_0080
twi-3 twi 11.24 61.0% 29.1% spk_0006
...
twi-6 twi 2.72 61.2% 26.8% spk_0002
twi-9 twi 3.51 65.5% 28.1% spk_0165
Voices are ranked by measured intelligibility, not by training hours — and the two disagree sharply. Each voice synthesised the same held-out text, which was then re-recognised and scored for phoneme error: Twi with the Ghana phoneme ASR, the English spans with KoelLabs. 30 of the 207 Twi pseudo-speakers were measured.
Two rankings, because they do not substitute for each other:
| use | tier | pick |
|---|---|---|
| text mixing English into Twi | tiers.codeswitch |
twi-1 (59.8%) |
| pure Twi | tiers.twi_only |
twi-6 (26.8%, floor is 25.9%) |
twi-1 is the best code-switch voice but 21st of 30 on pure Twi; twi-9 is 3rd on Twi and
among the worst on code-switch. Had we ranked by hours — as the first version did — the best
mixed-text voice would have been buried and two of the three best Twi voices excluded entirely
(they have under 3.3 h each).
voices.json records every measurement, so the ranking is inspectable rather than asserted.
Unlisted speakers are reachable by raw index (--voice 42), unmeasured.
There are no separate English voices. These are Twi voices judged on how well they also handle English, which is what reading real Ghanaian text requires.
Batch generation
Any of .txt (one utterance per line), .csv/.tsv (needs a text column), or .jsonl.
stable-twi-tts --input corpus.csv --out synth/ --workers 8
50000 utterances -> synth/ (8 workers)
[25/50000] 14.2/s eta 58.7 min
...
49987 written, 0 skipped, 13 failed in 57.4 min
41.20 h of audio (2586x realtime)
manifest: synth/manifest.jsonl
Per-row columns override the defaults, so one file can mix voices and languages:
id,text,voice,language
greet_01,"Akwaaba, wo ho te sɛn?",twi-1,twi
news_01,"Good morning, welcome to the news.",eng-1,eng
mix_01,"Mepɛ sɛ mesua [computer science].",twi-2,mixed
Every run writes manifest.jsonl, one record per utterance:
{"id": "greet_01", "status": "ok", "path": "synth/greet_01.wav", "duration": 1.83, "voice": "twi-1", "n_phonemes": 17}
{"id": "bad_09", "status": "error", "error": "PhonemeError: the model has no symbol for: ['ʈ']"}
Re-running skips what already exists, so an interrupted 50k job resumes where it stopped
(--overwrite to force). Failures are per-item — a corpus of 50,000 lines does not lose 49,999
outputs because one line was unpronounceable.
Text really does become phonemes
The model reads phoneme ids, never letters, and it only knows the phonemes its training targets contained. So this package reproduces the training front-end exactly:
| language | phonemiser |
|---|---|
| Twi | ghana-g2p — the same library that produced the Twi targets |
| English | espeak-ng en-us — the same phonemiser that produced the English targets |
Do not substitute a different English G2P. This is the one change most likely to quietly
ruin output. An earlier version of this pipeline folded English into the Ghanaian inventory
(θ→t, æ→a). It produced valid-looking IPA, disagreed with the training targets on 51%
of units, and the model scored 68.6% phoneme error where Twi scored 25.6%. Nothing errored;
it just sounded wrong. Same function on both sides is the difference between working and not.
Note that the accent lives in the audio, not the symbols. English input is canonical
(θ æ ɹ eɪ), and the Ghanaian accent comes from the voice.
Two deployment paths
| path | phoneme error | needs |
|---|---|---|
| Python + onnxruntime | 30.2% | Python, ghana-g2p, espeak-ng for English — unlimited vocabulary |
Native port + libespeak-ng |
matches Python | ~30 lines + 1.56 MB — no Python, unlimited vocabulary |
The native path exists because the Python dependency is only the front-end, and Twi's
grapheme-to-phoneme is a 42-entry longest-match table — verified identical to ghana-g2p across
20,000 words. So a Twi-only app needs no Python, no espeak and no lexicon. English cannot be ported that way (7,132
context rules plus 5,794 exceptions), so it links libespeak-ng — tools/bundle_espeak_english.sh
strips the 31 MB install down to the 1.56 MB English needs, verified working. For targets that
cannot link native code, mobile/english_lexicon.json.gz holds 124,926 precomputed pronunciations
in 0.88 MB, covering 98.2% of tokens but dropping the rest.
mobile/ has Kotlin and Swift references plus 13 test vectors, so a port is verified rather
than hoped-for. Those vectors are worth taking seriously: writing them caught two bugs in the
specification that a 20,000-word equivalence test had missed, and both would have produced
fluent-sounding wrong audio rather than an error.
A third path exists via sherpa-onnx, but neither of the above needs it
sherpa-onnx can load model.onnx directly, but it does its own text-to-phoneme step and
espeak-ng has no Twi, so it needs the generated lexicon_ascii.txt plus two string
replacements (ɔ→q, ɛ→x — Akan has no q or x, so they cannot collide with a real word).
It measured ~36–38% phoneme error and covers 78k words, silently dropping anything outside that.
Superseded by the native port, which is both better and simpler.
Building a voice directory
python tools/export_voice.py \
--checkpoint runs/piper/checkpoints/best.ckpt \
--train-config runs/piper/config.json \
--manifest data/manifest.tsv \
--out voices/my_voice \
--top-n 10 --min-hours 1.0 --lexicon
Produces model.onnx, config.json, voices.json, tokens.txt and optionally lexicon.txt.
Voices are ranked by hours, not clip count — Twi clips average 3.9 s and English 13.6 s, so
ranking by clips would systematically flatter Twi speakers.
Honest status
- Not on PyPI. Install from git; the package name is not registered yet.
- The model is epoch 7, the best checkpoint of a run that had plateaued.
val_mosrose steeply for three epochs (2.56 → 2.99) and then oscillated between 2.75 and 3.02 for six more with no upward trend;val_melimproved 0.005 across the same span and then reversed. Epoch 7's 3.02 is the peak, but epoch 3's 2.99 is within noise of it. More training at this data scale is unlikely to help — the round-trip numbers agree, with Twi drifting slightly worse (31.3% → 33.5%) while English improved (63.7% → 59.5%), i.e. the model trading between languages rather than improving. The gains left are in the data, not the schedule. - The Kotlin and Swift ports have not been compiled — the algorithm is verified against test vectors, but no one has built them on a device yet.
- English is markedly weaker than Twi and this is a data problem, not a tuning one: English had a third of the utterances (43k vs 151k) at 3.5× the length, and VITS learns alignment per utterance.
Known limits
- English voices are band-limited to 8 kHz. The English training audio was 16 kHz; the Twi was 24 kHz. Upsampling cannot invent the missing highs, so English voices sound duller than Twi ones. This is a property of the training data, not a bug.
- Code-switching is extrapolation. No training utterance mixed languages within a sentence. It works, but it is not a trained capability, and the language token is per-utterance so a switched sentence has to pick one frame language.
- Speakers are derived, not real identities. Pseudo-speakers from clustering. One real person may appear as two voices, and a voice is not a consented identity.
- Long input is split on sentence boundaries and concatenated with a short pause. Training clips were 4–14 s and VITS attention degrades well beyond that.
License
Code MIT. The model and voices carry the licences of their training data — the Twi half is
cc-by-nc-4.0, so the voices are non-commercial.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file stable_twi_tts-0.2.1.tar.gz.
File metadata
- Download URL: stable_twi_tts-0.2.1.tar.gz
- Upload date:
- Size: 37.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.10.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7c458b309de4c55e3a700187056bd83f3c87af021d5fe9cecf957bd376bcb02c
|
|
| MD5 |
3a4001b9f366049d51a38291663904af
|
|
| BLAKE2b-256 |
17a0786128b4ad3cbb2788d0f7c0bf8ffc7c64f2d8afd1e1607afb56487b6a67
|
File details
Details for the file stable_twi_tts-0.2.1-py3-none-any.whl.
File metadata
- Download URL: stable_twi_tts-0.2.1-py3-none-any.whl
- Upload date:
- Size: 33.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.10.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f9f739209a781ebc26210ac2d10a32eeaebb0103debf0d9b0e8c0b4ebf02d6c8
|
|
| MD5 |
c2ef94844418db487608b8c029526614
|
|
| BLAKE2b-256 |
2ece8f0c7f2a3002e619dbc850a6191b2f5be17d2443ce533a19d517f722bd47
|