The cheaper voice is the one I actually use
I’ve wanted my computer to read things to me for years, and every time I tried it I quit within minutes. The macOS system voice is the reason. It’s say on the command line, and it’s fine for a two second notification, but an article read in that voice is worse than not reading the article.
So the obvious move is to pay for a good one. And that works, right up until you look at the meter.
What it actually is
Kokoro-82M, a text to speech model with 82 million parameters, running through mlx-audio, which is Metal-backed so it uses the GPU on Apple Silicon. 339 MB of weights, downloaded once, offline after that.
82 million parameters is SMALL, and that’s the entire point, because a model that small stays resident in memory and renders faster than you can listen to it.
- Audio produced
- 14.6 min
- Time to render it
- 36.7sabout 24x realtime
- Time to first sound
- 0.19swarm model, streaming playback
- Cold start
- 3.1sfirst run in a fresh process
- Weights on disk
- 339 MBdownloaded once
- Cost per word
- $0
That 0.19 seconds is what made it usable, and it’s plumbing rather than model. Kokoro emits audio in groups of sentences, so you play the first while the second renders. Wait for the whole file and you stare at a terminal for 37 seconds. Stream it and speech starts before you’ve let go of the return key.
Three things I point it at
A long article, in the background. Strip a page to text, pipe it in, go do something else. Rendering beats playback by about 24x, so it never has to catch up.
Claude Code, out loud. A Stop hook pulls the last assistant message out of the session transcript and speaks it, so I hear what Claude did without looking at the window. The gist has the hook, including the trailing & that keeps it from blocking your session.
The morning briefing. My briefing script already runs on a cron, so now it renders to a wav before I’m up. 500 words is 2.6 minutes of audio and about 10 seconds to render, most of that the model waking up rather than the words.
The second one grew into raven, which keeps the model warm in a daemon and streams Claude’s replies to my phone over Tailscale so I can follow a session while driving. That’s a whole other post.
Two traps
MLX’s Metal stream is thread-local. Hand the audio to a player thread, evaluate it there, and it dies with There is no Stream(gpu, 0) in current thread. Streaming playback is exactly the case that needs a second thread, so I hit it immediately. Call np.asarray() on the thread that produced the audio.
It reads markdown out loud. Feed it a raw .md and it says “pound pound the cause” and then recites a URL one character at a time. Twenty lines of regex fixes it, and that’s the difference between something you’d use and something you’d close.
Why the free one won
Stripped of its markdown that article is 12,460 characters, about $1.25 through ElevenLabs’ API at their listed $0.10 per 1,000 characters, or $0.62 on Flash. One a day is $19 to $37 a month, which honestly isn’t unreasonable for a better voice.
But I know what I do with a meter. It’s a long distance call in 1995. You keep it short and you skip the thing you were only mildly curious about. Metered speech turns “read me this” into a small decision, and a small decision made forty times a day is how a habit dies.
The local voice isn’t as good as the paid one. Maybe 80%. But it’s on all day, because something you use constantly at 80% beats something you ration at 100%.
The code
One Python file, 116 lines including comments, in the gist with setup, the three recipes, and both traps. Apple Silicon only, because mlx-audio is Metal-backed.
uv venv .venv --python 3.12uv pip install --python .venv/bin/python mlx-audio "misaki[en]" soundfile numpypbpaste | ./speak.pyThat last line reads your clipboard. Start there.