framer-cms-technical-seo · Local TTS

How I Generate Natural Blog Audio Locally for Free

A complete workflow for natural blog narration using local Kokoro generation, speech-ready scripts, pronunciation QA, MP3 publishing, and a comparison with hosted voices.

NEXT NOTEMutationObservers Without Panic: A Performance GuideEditorial illustration representing a written blog article being transformed into an audio waveform locally on a computer.

I wanted a proper Listen button on my blog, but I did not want every click to trigger a paid text-to-speech request. I also did not want the result to depend on whatever robotic voice happened to be installed in the visitor’s browser.

The solution was surprisingly practical: generate the narration locally on my Mac, export one optimized MP3 per article, and serve it as a normal static asset. The generation costs nothing, the voice stays consistent, and the website only has to play an audio file.

This article explains the exact workflow I built using Kokoro, MLX Audio, Python, and FFmpeg.

The idea: generate once, play as many times as needed

Browser speech synthesis is convenient, but it gives the website very little control. The available voice changes between operating systems and browsers. Rhythm, pronunciation, and quality change with it.

A hosted AI voice API gives better consistency, but it adds another service, usage limits, credentials, and a recurring cost. That can be reasonable for dynamic products. It felt unnecessary for articles that only change occasionally.

Static narration changes the model:

  1. Prepare a clean narration text.

  2. Generate the voice locally.

  3. Compress it into an MP3.

  4. Commit the finished audio with the website.

  5. Let every visitor play the same reviewed recording.

There is no text-to-speech process in production and no API call when somebody presses Play.

The local stack

I use MLX Audio, a speech library built for Apple’s MLX framework, with the Kokoro text-to-speech model. Kokoro is compact compared with many modern speech models and its weights are available under the Apache 2.0 license. MLX makes it practical to run on an Apple Silicon Mac.

FFmpeg handles the final compression. Python runs the automation script, but neither Python nor the model needs to exist on Vercel. Only the resulting MP3 files are deployed.

My English default is George, a British male voice identified as bm_george. For Spanish I use the male voice em_alex. Both run at 0.95 speed, which gives technical writing a little more room to breathe without sounding slow.

One-time installation

On an Apple Silicon Mac, I install Python 3.12 and FFmpeg with Homebrew:

brew install python@3.12 ffmpeg

Then I create a project-specific virtual environment and install the local speech tools:

/opt/homebrew/bin/python3.12 -m venv .venv-tts
.venv-tts/bin/python -m pip install --upgrade pip
.venv-tts/bin/pip install mlx-audio "misaki[en]" soundfile

The first run downloads the model and voice files. Later runs reuse the local cache, so they start faster.

The folder-based workflow

I wanted the process to be boring enough that I would actually use it. Narration sources go into language-specific folders:

audio-source/
├── en/
│   └── article-slug.txt
└── es/
    └── article-slug.txt

Then I run one command from the project root:

npm run audio:generate

The generator detects the language from the folder, selects the correct voice, creates a WAV internally, converts it to a 96-kilobit MP3, and writes the public file here:

public/audio/blog/en/article-slug.mp3
public/audio/blog/es/article-slug.mp3

The website can then load the file from /audio/blog/en/article-slug.mp3 like any other public asset.

A queue, a done folder, and a checklist

Generating dozens of long articles takes time, so the script treats the source folders as a queue. After an MP3 is created successfully, the source text moves into a done subfolder.

The script also maintains a JSON checklist. It records a fingerprint made from the narration, language, voice, speed, and model. If the same file appears again with the same settings, the generator recognizes it and does not waste time recreating the audio.

If I change the copy, voice, or speed, the fingerprint changes and the article can be generated again. I can also force a deliberate regeneration:

npm run audio:generate -- --force audio-source/en/article-slug.txt

This matters when the queue contains an entire archive. A simple timestamp is not enough; the checklist makes the generation history explicit.

Preparing text for speech

Good text-to-speech is not only a voice problem. A page and a narration are different interfaces.

My generator accepts plain text, Markdown, or HTML. It removes front matter, HTML tags, images, and code blocks before sending anything to the model. The title is included, but implementation details that make no sense aloud are excluded.

I still review the source text for a few predictable problems:

  • Headings should sound like spoken transitions.

  • Acronyms may need to be expanded or written phonetically.

  • Raw URLs should never be read aloud.

  • Long lists need punctuation and breathing room.

  • Code should be explained, not narrated character by character.

The most natural voice cannot rescue text that was never meant to be heard.

What “free” actually means

The generation is local and does not use a paid API. There is no charge per character, per minute, or per playback. The model runs on hardware I already own.

That does not mean audio has literally zero infrastructure cost. MP3 files occupy repository or storage space, and visitors use normal hosting bandwidth when they listen. A 96-kilobit MP3 is small enough for this use case, but it is still a file being delivered.

The important distinction is that listening does not trigger inference. One hundred listeners and one listener use the same finished asset.

Quality checks before publishing

I do not publish the first render blindly. My audio checklist is short:

  • Listen to the opening, one section transition, and the ending.

  • Check names, acronyms, product names, and numbers.

  • Confirm that no code or URL is being read.

  • Make sure the player never autoplays.

  • Keep Play, Pause, duration, progress, and playback speed accessible by keyboard.

  • Keep the written article available as the canonical version.

If one word sounds wrong, I edit the private narration text phonetically without changing the visible article. That small separation is useful: written accuracy and spoken pronunciation do not always need the same spelling.

Where this approach works best

Local static narration is a strong fit for portfolios, documentation, editorial blogs, and relatively stable content. It provides consistent quality without adding a runtime service to the website.

It is less suitable when text changes constantly, users generate arbitrary content, or audio must be personalized in real time. Those products need a server-side speech system or an API.

For my blog, static audio is the right tradeoff. I control the voice, review the result once, and keep production simple. The fancy part happens locally. The website just plays a file, which is exactly the kind of boring architecture I trust.

Local generation versus ElevenLabs

I previously generated narration with ElevenLabs Studio and uploaded it manually. That workflow is still useful when a project needs a highly polished commercial voice, detailed pronunciation tooling, or multiple collaborators reviewing audio in a hosted interface. It also removes local model setup.

The tradeoff is recurring usage, another account in the publishing chain, and less control over whether future regeneration behaves exactly like the first render. Local Kokoro generation is a better default for this blog because the articles are stable, the hardware already exists, and every published MP3 can be reviewed and versioned with the site.

NeedLocal KokoroHosted voice service
Cost per regenerationNo API feeUsually metered
SetupPython, model, FFmpegAccount and upload
Voice controlsModel-dependentUsually richer
PrivacyText stays localText is processed remotely
RepeatabilityPinned locallyDepends on service/model changes

Turning an article into a narration script

The visible article should remain the canonical source, but it should not be read character for character. Before generation, I create a speech version that keeps the argument while removing visual-only material.

  • Links become the meaning of the link, not a spoken URL.

  • Code blocks become a short explanation of what the code does.

  • Images get a useful spoken transition only when they carry part of the argument.

  • Tables become comparisons in natural sentences.

  • Headings become transitions, so the listener understands that the subject changed.

This preparation has more influence on naturalness than most voice settings. A clean script with ordinary punctuation usually outperforms an overloaded script covered in synthetic pause commands.

Pronunciation, pauses, and SSML

Model support decides which controls are available. Some hosted voices accept SSML break tags, phonemes, aliases, or pronunciation dictionaries. Local models may respond better to punctuation and phonetic rewrites. I keep those rewrites in the private narration source so the published article stays typographically correct.

My order of operations is simple: fix the sentence, adjust punctuation, add a phonetic alias for the specific word, and use explicit break markup only when the chosen model genuinely supports it. Throwing SSML at an incompatible model does not create nuance; sometimes it reads the markup aloud.

For recurring names, products, and acronyms, maintain a small pronunciation lexicon beside the generation script. It is cheaper to solve “JSON-LD,” “Framer,” or a person’s surname once than to rediscover the same problem in every article.

The publishing handoff

The site should prefer the reviewed MP3 when one exists and fall back to browser speech only when it does not. The audio player must never autoplay, and its controls need keyboard access, visible progress, duration, and an understandable text label.

Before shipping, listen to the first paragraph, a transition in the middle, every unusual proper noun, and the ending. That compact QA pass catches most failures without turning publishing into a full recording session.

More from this topic

framer · cms · technical · seoWhy I Left Framer for an AI-Assisted Static Site on Vercelframer · cms · technical · seoHow to Migrate from Framer to an AI-Assisted Static Site on Vercelframer · cms · technical · seoStructural SEO: JSON-LD in Framer Beyond Meta Tags