Giving Writing A Voice

Narrating your writing with Kokoro

I gave eighteen written chapters an audio track using an 82-million-parameter model on a laptop. The model turned out to be the easy part. What decides whether a narration is listenable is the text you feed it and the silence you put between the paragraphs.

September 2026 / Part one of three
In Plain English

A narration you can re-run

I had eighteen chapters of written course material and wanted audio for it. The obvious route was a hosted TTS API. I tried the local option first because I wanted to know what I would be giving up.

What I got back was not better audio. It was a different relationship with the audio. Kokoro renders 45 minutes of narration in about 24 minutes of laptop CPU, which makes a re-render a coffee break rather than a decision. I stopped treating the track as something generated once and started treating it as a build artifact. Fix a sentence, run it again, forget about it.

That changes what you are willing to fix. When a render costs money and an hour of waiting, you live with the sentence that reads badly aloud. When it costs a coffee, you fix it.

Small enough to be boring

Kokoro is 82 million parameters, shipped as ONNX, with Apache 2.0 weights. Two files totalling 354 MB that sit next to your project.

For scale, the voice-cloning model in part two is 0.6 billion parameters and needs a GPU to be usable at all. Kokoro is roughly a seventh the size and runs a single forward pass instead of sampling token by token. That is the whole reason it feels different to work with.

Being unremarkable is the point. No warm-up, no batching strategy, no memory ceiling to design around. You call it and it returns audio.

Voices are vectors, so blend them

This is the part I nearly missed, and it mattered more than any other choice I made.

Kokoro's voices are not separate models. They are style vectors, which means they average. A blend of two presets is a real voice that neither gives you on its own.

blending two voices into one
blend = None
for name, weight in zip(names, weights):
    term = kokoro.get_voice_style(name) * (weight / total)
    blend = term if blend is None else np.add(blend, term)
which lets you write this
# an even mix of two British male voices
--voice bm_george+bm_fable

# or weighted, if you want one to lead
--voice bf_emma*0.6+af_heart*0.4

Spend ten minutes here. Single presets tend to carry one distracting quality, some vowel or landing that you stop being able to unhear around minute twenty of a long track. Averaging two voices sands that off. I tried maybe six combinations and shipped on bm_george+bm_fable, which was better than either component alone.

Audition candidates on your own text, not on a demo sentence. A voice that sounds warm reading one line can sound smug reading four paragraphs of explanation.

The words the model actually reads

Kokoro's frontend is espeak-based. It will spell technical acronyms letter by letter, and it will get them wrong. You cannot prompt your way out of this. You fix it with a substitution table applied before the model sees the text.

phonetic spellings, tuned for espeak
SPOKEN_ACRONYMS = [
    (r"\bAPI\b",      "ay pee eye"),
    (r"\bJSON-RPC\b", "jayson arr pee see"),
    (r"\bOAuth\b",    "oh-auth"),
    (r"\bHMAC\b",     "aitch-mack"),
    (r"\bstdio\b",    "standard eye-oh"),
]

Keep that table separate from your punctuation rules. I found out why when I added a second engine with a neural frontend: it says "MCP" correctly on its own, and feeding it "em see pee" actively made things worse. The phonetic table belongs to the engine. The punctuation table belongs to the text.

Write numbers as words in the source

"Version two point zero", not "version 2.0". Do it in the script itself rather than in a substitution rule. You will read the script aloud to check it, and you want to be reading exactly what the model reads. Every transformation between your eyes and the model's input is somewhere for a surprise to hide.

Silence is a parameter

Feed the model one paragraph at a time and insert the gaps yourself. Synthesised pauses vary in length. Concatenated silence is exact, and you can tune it.

two constants that outperform every model setting
GAP_PARAGRAPH = 0.55   # between paragraphs
GAP_OPENING   = 0.85   # a longer beat after "Chapter nine."

for i, block in enumerate(blocks):
    samples, rate = kokoro.create(block, voice=voice, speed=speed, lang=lang)
    pieces.append(samples)
    if i < len(blocks) - 1:
        gap = GAP_OPENING if i == 0 else GAP_PARAGRAPH
        pieces.append(np.zeros(int(rate * gap), dtype=samples.dtype))

I moved those two numbers more than anything else in the pipeline. A slightly long pause reads as thinking. A slightly short one reads as rushing. No model parameter gives you that control, and no amount of voice selection compensates for getting it wrong.

Pace is the other half. Kokoro's default speed of 1.0 lands near 195 words per minute, brisk for anything explanatory. I ship at 0.9, around 170. Listeners who want it faster have a control in their player. Listeners who want it slower usually do not.

Structure your source for the ear

Splitting on blank lines means your paragraph breaks become audible pauses. That makes paragraph length a narration decision, not just a typographic one. Long paragraphs that read fine on a page arrive as an unbroken wall of speech. I broke several in half after hearing them.

Even loudness, small files

Chapters rendered separately drift in loudness. Normalise each to the same peak. A listener reaching for the volume between chapters notices. A listener who never has to notices nothing, which is the goal.

normalise, then encode
audio = np.concatenate(pieces)
peak = float(np.max(np.abs(audio))) or 1.0
audio = (audio / peak) * 0.89

sf.write(mp3, audio, 24_000, format="MP3",
         bitrate_mode="VARIABLE", compression_level=0.4)

That lands around 1 MB per two and a half minutes of speech. Small enough to serve from a static site without thinking about it.

Re-renders should cost nothing

Hash everything that affects the output, and skip any file whose hash has not moved.

content-addressed skipping
key = hashlib.sha256(
    ("\x00".join(blocks) + "|%s|%s|%s" % (voice, speed, lang)
     ).encode("utf-8")).hexdigest()[:16]

if not force and mp3.exists() and stamps.get(cid, {}).get("k") == key:
    print("%s  unchanged, skipping" % cid)
    continue

Fix a typo in chapter twelve and only chapter twelve re-renders. Change the voice and everything does, correctly, because the voice is part of the key.

One detail I got wrong first time: write the stamp file after each chapter, not at the end of the run. I wrote it once at the end, an interrupted run lost every marker it had earned, and the next run redid finished work.

What it will not do

Kokoro reads evenly and well. It does not act. There is no emotional range to direct, no way to say that this sentence is the punchline and that one is an aside. For explanatory writing that is fine, and arguably better than a narrator with opinions about your emphasis.

It also will not sound like you. No setting gets you there. That needs a cloning model, a careful recording, and roughly twenty times the compute, which is part two.

And it is a synthetic voice, which some readers clock immediately. I decided a listenable synthetic narration beats no narration, but that is a judgment about your audience rather than a technical fact.

The short version

An 82M model on a laptop gives you a good narration in about half the wall-clock time of listening to it, with Apache 2.0 weights and nothing leaving your machine.

The model is the easy part. Your attention belongs on the blend, the acronym table, and the two gap constants. That is where a track stops sounding like a robot reading a document and starts sounding like a narration.