bgmlv
EN ES

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.

VoiceAudioCPUCPU speedApple GPU (MPS)MPS speed
af_heart13.5 s1.41 s9.6x0.64 s21.2x
af_bella13.9 s1.47 s9.4x0.65 s21.4x
af_nicole20.6 s2.25 s9.1x0.91 s22.7x
am_michael14.9 s1.62 s9.2x0.68 s22.0x
am_fenrir13.8 s1.50 s9.2x0.66 s21.0x
Kokoro, af_heart. 13.5 seconds of audio, 0.64 seconds to generate.
Kokoro, am_michael. Same sentence, different voice.

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.

Kokoro, af_heart, with espeak-ng phonemes: Russian with an American accent.

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.

Kokoro with espeak-ng phonemes on the test sentence.

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.

VoiceAudioGenerationSpeed
aidar13.1 s0.096 s136x
baya13.1 s0.097 s136x
kseniya12.7 s0.092 s137x
xenia11.2 s0.081 s137x
eugene9.8 s0.074 s133x

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.

Silero, eugene, automatic stress. 9.8 seconds of audio, 0.074 seconds to generate.

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

Bar chart of seconds needed to generate one minute of speech: saying it aloud takes 60, Kokoro on CPU 6.5, Kokoro on the Apple GPU 2.8, Silero on CPU 0.4

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.

Diagram of three pipelines. Text goes to a step that decides stress, misaki for English Kokoro, espeak-ng for Russian Kokoro, built-in stress for Silero, then to the voice model and audio

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:

Automatic: Белая мука — просто мука.
Marked: Белая мук+а — просто м+ука.

Then the whole test sentence, with this markup:

Тимл+ид позвон+ит в ср+еду: депл+ой ф+ичи ст+оит отлож+ить, пок+а б+аг сто+ит в бэкл+оге.
В ст+аром з+амке слом+ался зам+ок, догов+ор на апгр+ейд застр+ял, а комм+ит в м+астер —
пр+осто м+ука. Зат+о б+елая мук+а и твор+ог по катал+огу деш+евле.
Automatic stress, full sentence.
Every stress marked by hand, full sentence.

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: