Local speech synthesis on a MacBook: 136x real time and the stress problem
How far can a laptop get with speech synthesis without any cloud? I ran two small open text-to-speech models, Kokoro and Silero, on a MacBook with an M3 Pro and 18 GB of RAM, first in English and then in Russian.
Short version: real-time speech on a laptop is a solved problem, and both models are fast enough to stop caring about speed. The difficult part is not the voice. It is knowing which syllable to stress.
Kokoro: 82 million parameters
Kokoro-82M is a small Apache-2.0 model with 54 preset voices. I gave five American voices the same 212-token sentence, seven runs each after a warm-up. A token here is a phoneme, not a word: the sentence becomes a phonetic string before the model sees it.
| Voice | Audio | CPU | CPU speed | Apple GPU (MPS) | MPS speed |
|---|---|---|---|---|---|
| af_heart | 13.5 s | 1.41 s | 9.6x | 0.64 s | 21.2x |
| af_bella | 13.9 s | 1.47 s | 9.4x | 0.65 s | 21.4x |
| af_nicole | 20.6 s | 2.25 s | 9.1x | 0.91 s | 22.7x |
| am_michael | 14.9 s | 1.62 s | 9.2x | 0.68 s | 22.0x |
| am_fenrir | 13.8 s | 1.50 s | 9.2x | 0.66 s | 21.0x |
Tokens per second is a misleading number for TTS. af_nicole looks the slowest at 234 tokens per second against 333 for af_heart, because it is a slow, quiet voice and generation time follows the length of the audio. Measured as a multiple of real time, it is the fastest of the five.
Cold starts matter more than the headline number. Loading takes about two seconds, and the very first generation on MPS took 5.6 seconds. An assistant should warm the model up before anyone talks to it.
Russian through a side door
Kokoro has no Russian. Give Cyrillic to its English pipeline and nothing crashes: it transliterates the text and reads out letter names.
But the voice model knows nothing about languages. It only sees phonemes. So you can skip the English front end, ask espeak-ng for Russian phonemes, and hand those straight to the model:
phonemes, _ = EspeakG2P(language="ru")(text)
# Not in Kokoro's vocabulary: dropped without any warning.
phonemes = phonemes.replace("ɭ", "l").replace("ɵ", "o")
audio = KPipeline.infer(model, phonemes, voice_pack).audio
That replace line cost me a run. espeak writes Russian “л” as ɭ, a symbol Kokoro does not have, so at first every “л” silently vanished from the speech. Nothing failed. Only the phoneme counts gave it away.
It works, at about 22x real time, with an American accent. A fun trick rather than a Russian voice.
The test sentence
For Russian I wrote a sentence designed to hurt: homographs, where only stress tells two words apart, and IT slang borrowed from English.
Тимлид позвонит в среду: деплой фичи стоит отложить, пока баг стоит в бэклоге. В старом замке сломался замок, договор на апгрейд застрял, а коммит в мастер — просто мука. Зато белая мука и творог по каталогу дешевле.
Roughly: “The team lead will call on Wednesday: the feature deploy should wait while the bug sits in the backlog. In the old castle a lock broke, the upgrade contract is stuck, and the commit to master is sheer torment. But white flour and cottage cheese are cheaper in the catalogue.”
The traps are the pairs. сто́ит means “should” and стои́т means “stands”. му́ка is torment and мука́ is flour. за́мке is a castle and замо́к is a lock.
Silero: built for the language
Silero v5_6_ru is a 141 MB model made for Russian. It takes plain text, with no external phonemizer, and places stress itself.
| Voice | Audio | Generation | Speed |
|---|---|---|---|
| aidar | 13.1 s | 0.096 s | 136x |
| baya | 13.1 s | 0.097 s | 136x |
| kseniya | 12.7 s | 0.092 s | 137x |
| xenia | 11.2 s | 0.081 s | 137x |
| eugene | 9.8 s | 0.074 s | 133x |
That is on the CPU, with PyTorch reporting a single thread by the end of the run. Thirteen seconds of speech in under a tenth of a second.
The catch is the licence: Silero’s models are non-commercial, so it fits personal projects and not a product.
Why it is so fast
Time to generate one minute of speech, worked out from the measured speed.
Speech is an easier job than it sounds. An LLM has to work out what to say. A speech model already has the words, and only has to turn them into sound, in a voice it knows by heart.
It also does this in one go. Kokoro takes the whole sentence and produces the whole audio in a single pass. Models that clone voices go over the audio again and again, which is why the three I tried on this laptop were slower than real time and did not make it into this post.
Stress is the real problem
A voice model reads what it is given. If the stress is wrong in its input, it pronounces the wrong word confidently and beautifully.
Stress is settled before the voice model starts. That is the step worth fixing.
With the Kokoro setup the stress is visible in the phonemes, so I could check it. espeak-ng was wrong on three of twelve words I tracked: де́плой instead of депло́й, стои́т in “should wait”, and му́ка in “white flour”. It writes both мука the same way, because it does not read context.
RUAccent is a separate stress model, and its output is plain text with marks, so I could read that too. It got the сто́ит/стои́т pair right, which espeak could not. Then it stressed д+еплой, marked two stresses in one word in к+омм+ит, and turned the torment into flour. Better, not solved.
Silero hides its phonemes, so the only way to judge it is to listen. The good news is that it accepts manual stress: a + before a vowel.
model.apply_tts(text="Белая мук+а — просто м+ука", speaker="eugene", sample_rate=48000)
Automatic against marked
The same model and the same voice, first with Silero’s own stress, then with marks. A short pair first:
Then the whole test sentence, with this markup:
Тимл+ид позвон+ит в ср+еду: депл+ой ф+ичи ст+оит отлож+ить, пок+а б+аг сто+ит в бэкл+оге.
В ст+аром з+амке слом+ался зам+ок, догов+ор на апгр+ейд застр+ял, а комм+ит в м+астер —
пр+осто м+ука. Зат+о б+елая мук+а и твор+ог по катал+огу деш+евле.
So the practical answer has two layers. A small dictionary for your own slang, because no model has heard your team say “деплой”: деплой → депл+ой, applied to all its word forms. And for homographs, let whatever writes the text mark them. If an LLM produces the reply anyway, it already knows which мука it means, and asking it for a + costs nothing.
Hand-marking has its own trap. I checked my markup against nothing but my own confidence and got коммит wrong on the first try. IT slang has no dictionary, so someone who actually says these words has to listen.
What I would use
- English, anything live: Kokoro. Fast, small, Apache-2.0.
- Russian, personal projects, anything live: Silero, with a stress dictionary on top.
I did not test how either model reads numbers and abbreviations. The runs and scripts were put together with Claude Code.
Models and tools
Everything in this post came from these places:
- Kokoro-82M weights and voices: hexgrad/Kokoro-82M on Hugging Face. Inference code: hexgrad/kokoro, version 0.9.4.
- Silero
v5_6_ru: snakers4/silero-models, loaded withtorch.hub. The weights are a single file. - Phonemes: misaki for English, espeak-ng for Russian.
- Stress: RUAccent.
- Transcripts for checking: whisper-large-v3-mlx.