I wanted readers to be able to listen to a post instead of only reading it. The site now generates an MP3 for posts that opt in, and adds a player to the post page when that audio is available.

1. How the pieces fit together

The pipeline has three parts:

  • A post opts in using audio: true in its front matter. It can also set audio_voice and audio_rate.
  • scripts/generate_audio.py reads opted-in Markdown posts, converts their prose into speech, and writes MP3 files under assets/audio/. It records the results in assets/audio/manifest.json and _data/audio.json.
  • The Jekyll post layout looks up the current post in site.data.audio.posts. If the post opted in and has a data entry, it renders the “Listen to this post” audio player. Enabling audio in front matter alone does not display a player. An audio file and matching data entry must also exist.

You might already be asking, “why store the audio files?”. Because:

  • I do not want to depend on some external service.
  • They’re small files.
  • I do not want to offload the TTS computing to the client. I tried that via the Web Speech API. And the experience is not uniform because it depends on the client. I do not like that. Always thinking in terms of Product from a Customer (CEX) and User Experience (UX) perspective.

I make the audios, and now they’re mine. Forever.

2. Turning Markdown into speech

The generator uses the community-maintained Python package edge-tts, installed via uv (of course). It calls edge_tts.Communicate with the selected voice and rate, then saves the result from Microsoft’s online speech service. The default voice is en-US-AndrewNeural, and the default rate is +0%.

I am not using a full Markdown or YAML parser. The code recognizes front matter between opening and closing --- lines, then reads simple key: value entries whose keys contain letters, numbers, and underscores. It removes surrounding quotes from values and treats everything after the closing delimiter as the post body. Layout fields are not spoken because the script processes the body, not the rendered page.

Before synthesis, a sequence of regular expressions prepares the body for reading aloud. It drops fenced code blocks but keeps the text inside single-backtick spans, removing only the backticks. It also removes horizontal rules, HTML comments and tags, and Markdown formatting marks. For images and links it keeps the alt text or link label, not the URL. It removes Jekyll attribute blocks and strips list and blockquote markers. Headings become spoken lines with terminal punctuation added when needed, which gives the speech a pause between sections. Finally, the script normalizes spaces, keeps paragraph breaks, and decodes HTML entities.

The generated filename combines a short hash of the post path with the post’s filename. Separately, a SHA-256 input hash covers the cleaned text, voice, rate, and generator version. When that input hash and the expected file are already present, the generator reuses the MP3 instead of synthesizing it again. Changing the post text or voice/rate invalidates that cached result. Always thinking about compute cost. No re-computing when it’s not needed.

3. The player and graceful failure

In _layouts/post.html, Jekyll looks up site.data.audio.posts[page.path]. The “Listen to this post” block is rendered only when both page.audio and the matching audio record exist. It uses the browser’s native audio controls, preloads metadata, and includes a download link as a fallback.

If synthesis fails, the generator removes the failed or previous audio and drops that post from its current audio records. The post still builds and deploys as text, but without a player or a stale MP3. This keeps audio optional rather than making a speech-service outage a site-build blocker.

Always thinking about availability and reliability. One piece of the puzzle failing should not bring down with it everything else.

4. Local commands and GitHub Actions

The Makefile wraps the generator with uv:

make audio-install
make audio
make audio POST=_posts/2026-11-05-tts.md
make audio-all
  • audio-install runs uv sync.
  • audio checks all posts and regenerates missing or changed audio.
  • POST limits the run to one existing post.
  • audio-all passes --force to regenerate every opted-in post.

The Publish site and audio GHA workflow runs on pushes to main and on manual dispatch. It checks out the repository, sets up uv, and runs the same generator command as the local workflow. Always thinking about reproducibility.

Audio generation is marked continue-on-error, so the workflow proceeds if the online service fails. Again, always thinking about availaibility.
An always() step stages assets/audio and _data/audio.json, commits changes as the GitHub Actions bot with [skip ci], and pushes them when there is a diff. The workflow then configures Pages, sets up Ruby, builds the Jekyll site, and uploads the Pages artifact; those steps also run with always(). Deployment runs only for main.

The result is a static MP3 and a small data lookup in the built site. No speech synthesis runs in the reader’s browser. This is old phone friendly.