Seven years ago I used Google’s text-to-speech API to create audio versions of my posts. There was a Python script that parsed the Markdown file, cleaned it up, chunked it to stay under Google’s 5000 byte limit, convert each chunk to an MP3, stitch them together, and finally embed the resulting file with a Hugo shortcode that is essentiall a fancy <audio> tag. It worked, it was free1, and I was pretty pleased with it. The little audio player has been sitting at the top of my posts ever since.
However, it does sound a little like 2010. Every time I hit play I hear the flat, slightly seasick cadence of a Wavenet voice, and it needs a Google Cloud project, an API key, and a billing account to regenerate2. Now that the blog does fancy semantic search in the browser from a 4 MB lookup table, the narration should not be the most dated thing on the page, and text-to-speech models have come really far recently.
The new audio is generated by Kokoro, an 82-million-parameter open weight (Apache 2.0) model that runs comfortably on my M1 MacBook Air. No cloud, no lost keys, no per-character bill should I ever go over a million characters. It also sounds dramatically better.
The 2010 sound of 2019
Here is the main argument for doing this at all. This is a paragraph of that 2019 post, read by Google’s Wavenet, exactly as it has sounded on the live site since I published it:
And here is the same post, re-narrated today by Kokoro running locally on my laptop:
I also added a script that samples the audio and creates an actual image of the shape of the soundwave; look at the difference between the nasal Wavenet voice and the Kokoro one. It puts stress in roughly the right places, pauses at clause boundaries instead of on a fixed timer, and doesn’t have that metallic edge.
It is not going to fool anyone into thinking that it’s voiced by a real human, but I do think it crossed the line from “accessibility feature I feel slightly guilty about” to “thing I would actually listen to.”
What changed: Kokoro and MLX
Kokoro-82M is a small, Apache-2.0-licensed text-to-speech model that punches absurdly above its weight; it spent a good while at the top of the TTS Arena leaderboard for open models, beating things many times its size. The 82 million parameters matter: the weights are a few hundred megabytes, not the tens of gigabytes a large speech model requires. And it was trained mostly on long-form reading, which is exactly the job here. It is a narrator, not a chatbot voice.
The thing that makes it practical on a my laptop is MLX-Audio, which runs Kokoro on Apple’s MLX framework; natively on the Apple Silicon GPU, no PyTorch, no CUDA, no ONNX runtime. The whole “install” is:
pip install mlx-audio
and then, in Python, the entire synthesis step is:
from mlx_audio.tts.utils import load_model
model = load_model("mlx-community/Kokoro-82M-bf16")
for result in model.generate(text="Hello there.", voice="af_heart", speed=1.0, lang_code="a"):
audio = result.audio # 24 kHz float32 samples
Kokoro ships with 54 voices; I settled on af_heart for narration3.
Faster than real time
The internet will tell you Kokoro runs at “15x real time and up” on Apple Silicon4. When I reprocessed all my posts (14 total), which is about two full hours of audio, it took 922 seconds, or a little over fifteen minutes. That is 7.8x real time on my M1, measured end to end including all the Markdown parsing and MP3 encoding.
7.8x is not 15x, but this is more than enough for my use case. I like my little fanless laptop that could, and it’s still pretty wild to me that it did it no problem.
Dealing with jargon
There _is) one place Kokoro reliably falls on its face, and it is a place a blog like mine steps on constantly. Kokoro converts text to phonemes with espeak-ng, and espeak-ng has never heard of some of the words in my vocabulary. int8 comes out as “int eight” if you are lucky and something worse if you are not. RRF gets read as a word. Goldmark, the Markdown renderer Hugo uses, becomes something with a hard g that isn’t a name anyone would recognize. Kubernetes is also hilarious.
The fix is boring and effective: there’s a yaml file that maps certain words to more phonetically pleasing spellings:
# scripts/tts_lexicon.yaml — surface form -> spoken form
int8: "int eight"
RRF: "R R F"
Goldmark: "Gold mark"
espeak-ng: "e speak N G"
WordPiece: "word piece"
Matching is case-sensitive, whole-token, and longest-match-first, so int8 wins over int and int never fires inside printf. When a new post mispronounces a term, I add a line. It is a spelling list for a machine that reads out loud.
The pipeline
The rest is plumbing, a single pip install. A small Python package turns a post into an MP3 in five stages:
- Extract the narratable prose from Markdown. I reused the exact
to_prosefunction from the semantic-search build: it already strips front matter, code blocks, and shortcodes, and I added a pass to drop footnote markers and table pipes, which a narrator would otherwise read aloud as noise. - Apply the lexicon so the jargon is spelled the way it should sound.
- Chunk the prose on sentence boundaries into bite-sized pieces.
- Synthesize each chunk with Kokoro.
- Stitch the pieces into one MP3.
A click CLI wraps it, with an --all flag that backs up the existing audio and re-renders every post behind a progress bar:
uv run python scripts/text_to_speech.py content/post/some-post.md # one post
uv run python scripts/text_to_speech.py --all # the whole blog
“Runs on your laptop” always has an asterisk or two; Python 3.13 removed the audioop module that pydub needs, so there is a backport pinned in. Kokoro’s English G2P needs a couple of extra packages that aren’t installed by default. Getting from pip install to first sound took some extra steps Claude happily helped me with. But once it runs, it runs offline, forever, for free.
Free, local, and yours
In 2019 I needed a cloud API and a billing account to make my blog talk. In 2026 an 82-million-parameter model does it better, on a laptop with no fan, and the only account involved is the one I use to git push. Ssame story as the search box on the front page: a capability that used to mean a datacenter now fits on the machine already on your desk5.
First million characters per month are free with a Google Cloud account. ↩︎
I’ve had to recreate the keys more than once, forgetting about them when migrating laptops. ↩︎
I compared the available voices on Tero Karvinen’s blog ↩︎
Tbf I didn’t use CoreML. Also my M1 processor is from 2020. ↩︎
God I feel old sometimes. ↩︎