AI-assistedI directed the piece and edited it; AI helped draft. How hard I edited varies by post, which is why the badge is here.

The cheaper voice is the one I actually use

aimacoslocal-ai

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.

one 2,056 word article, on an M4 Pro
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.

Terminal window
uv venv .venv --python 3.12
uv pip install --python .venv/bin/python mlx-audio "misaki[en]" soundfile numpy
pbpaste | ./speak.py

That last line reads your clipboard. Start there.