<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Hugo on Damian Galarza | Software Engineering &amp; AI Consulting</title><link>https://www.damiangalarza.com/tags/hugo/</link><description>Recent posts from Damian Galarza | Software Engineering &amp; AI Consulting</description><generator>Hugo</generator><language>en-us</language><managingEditor>Damian Galarza</managingEditor><atom:link href="https://www.damiangalarza.com/tags/hugo/feed.xml" rel="self" type="application/rss+xml"/><item><title>Adding Audio Narration Revealed the Bugs in My Writing</title><link>https://www.damiangalarza.com/posts/2026-04-20-adding-audio-narration-revealed-the-bugs-in-my-writing/</link><pubDate>Mon, 20 Apr 2026 00:00:00 -0400</pubDate><author>Damian Galarza</author><guid>https://www.damiangalarza.com/posts/2026-04-20-adding-audio-narration-revealed-the-bugs-in-my-writing/</guid><description>How I added ElevenLabs TTS audio narration to my Hugo blog, cloned my own voice, and discovered my writing had patterns no voice model could read.</description><content:encoded><![CDATA[<p>Last week I was driving on a stretch of highway replaying a draft post in my head. I&rsquo;d written it the night before, wanted to catch any obvious flaws, and couldn&rsquo;t touch the keyboard. What I wanted was to hear it.</p>
<p>I&rsquo;d seen narrated posts show up on X and on OpenAI&rsquo;s blog recently. A small play button at the top of the article. A consistent voice reading the piece aloud. It&rsquo;s a small affordance and a nice one. I realized I wanted that for my site.</p>
<p>So I messaged Emma.</p>
<h2 id="who-emma-is">Who Emma Is</h2>
<p>Emma is my assistant. An AI agent I keep in my pocket for exactly this kind of request. While I drove, I described what I wanted and asked her to draft a PRD.</p>
<p>By the time I reached my destination, I had one waiting. Service choice (ElevenLabs). Storage (Cloudflare R2). Generation trigger (GitHub Actions on push to main). Opt-in via a frontmatter flag so I could control cost post by post.</p>
<p>I reviewed the PRD, approved it, and Emma kicked off a Claude Code session to scaffold the feature. By the time I pulled into my driveway, the branch was open with a generation script, a Hugo player partial, and the R2 upload wiring.</p>
<p>That&rsquo;s the ambient workflow I&rsquo;ve settled into. One human conversation, two agents, and a draft implementation before I&rsquo;ve gotten out of the car. I still drive the engineering. But I no longer start from zero.</p>
<h2 id="voice-auditions">Voice Auditions</h2>
<p>I pulled the branch. Then I went into ElevenLabs, grabbed an API key, picked a voice from their library, and updated my <code>.env</code> with the key and voice ID. Next I ran the script on one of my longer recent posts.</p>
<p>The first version sounded too slow. Every sentence stretched past its welcome.</p>
<p>I went back to the library and picked another. This one paced better but felt wrong for the content. Too smooth, too polished, the kind of voice you hear in onboarding videos for insurance products. Wrong register for technical writing.</p>
<p>After a few more auditions I found one I liked. Then I paused. The whole point was to put my voice on my posts. Why was I renting someone else&rsquo;s?</p>
<p>ElevenLabs offers voice cloning on their Creator plan. Instant clones take a minute of clean audio. Professional clones train on 30 minutes to several hours of source material and produce noticeably better fidelity. Fortunately, I had a few months worth of YouTube recordings that I could leverage. I pointed Claude Code at my archives and asked it to extract 2 hours worth of audio tracks for me to use. While you can generate using 30 minutes, 2 hours is the preferred amount for the highest quality clone.</p>
<p>About an hour later ElevenLabs sent me a notification. The clone was ready. I plugged the new voice ID into the script, regenerated the test post, and hit play.</p>
<p>It was my voice, reading my post, in a car I was now sitting in.</p>
<p>The first pass was quite good but there was room for improvement.</p>
<h2 id="the-writing-was-the-bug">The Writing Was the Bug</h2>
<p>The clone itself sounded fine. The writing was the problem.</p>
<p>My first real-run listen surfaced half a dozen things I&rsquo;d never noticed about my own prose.</p>
<p><code>~170</code> read as &ldquo;tilde one seventy.&rdquo; I&rsquo;d written &ldquo;around 170&rdquo; for readers but let the tilde do the work for listeners.</p>
<p>Colons introducing questions arrived flat. &ldquo;The question was concrete: how much data does the model actually need?&rdquo; The voice couldn&rsquo;t flag the interrogative early enough, so the setup clause sounded deadpan and the question sounded abrupt.</p>
<p>Arrows in tables and inline CTAs like <code>421 → 337</code> or <code>Get the Scorecard →</code> went silent, or worse, were read as the word &ldquo;arrow.&rdquo;</p>
<p>Dense tables came out as comma-joined rows with no column labels. &ldquo;One, twenty-five, four hundred twenty-one, three hundred thirty-seven, easy wins, exact duplicates, severity level consolidation, frequency variants.&rdquo; Meaningless as speech.</p>
<p>Headings landed without enough pause in front of them, so the listener had no section break to hold onto.</p>
<p>Every one of these reads fine on the page. Every one of them is a bug in narration.</p>
<h2 id="fixing-it-through-conversation">Fixing It Through Conversation</h2>
<p>I didn&rsquo;t have a mental model for &ldquo;how to write for a TTS.&rdquo; So I did what I&rsquo;ve been doing for most engineering work lately. I played the audio, wrote down the moments that stumbled, and asked Claude Code to help.</p>
<p>The back-and-forth surfaced a set of patterns I hadn&rsquo;t seen on my own:</p>
<ul>
<li>Split colon-introduced questions into two sentences so the interrogative intonation has somewhere to land.</li>
<li>Replace <code>~</code> with &ldquo;around&rdquo; before narration.</li>
<li>Convert arrows between values to &ldquo;to.&rdquo; Strip decorative trailing arrows from CTAs.</li>
<li>Render tables as labeled rows so the column header gives each cell context.</li>
<li>Inject explicit pause tags at section breaks.</li>
</ul>
<p>Most of these patterns are not original to Claude, of course. They&rsquo;re baked into audiobook-production wisdom going back decades. Punctuation as pacing. Questions as standalone sentences. Narration scripts with more punctuation than print prose, not less. <a href="https://theurbanwriters.com/blogs/publishing/pacing-and-flow-how-to-optimize-your-writing-for-audiobook-performance">Urban Writers on audiobook pacing</a> is a fine single-page version of that playbook. I just didn&rsquo;t know the prior art existed until after the fixes were working.</p>
<p>Which is a second thing this post is about. A lot of what &ldquo;feels like figuring something out&rdquo; is actually relaying, through an agent, knowledge that&rsquo;s been in the world for decades. I got to the audiobook playbook via an LLM conversation, not through research. The cross-pollination is the interesting part. Not that technical prose needs narration discipline, but that I arrived at it by talking to Claude about what my ears didn&rsquo;t like.</p>
<p>What Claude couldn&rsquo;t pull from audiobook canon were the things unique to technical writing. Numbers with shorthand like <code>~</code> and <code>k</code> and <code>M</code>. Arrows, ASCII and unicode. Inline code fragments that look fine in monospace and fall apart in speech. Markdown tables as information-dense structures. Those got purpose-built transforms.</p>
<h2 id="the-text-cleaner">The Text Cleaner</h2>
<p>All of these fixes live in one place: a text cleaner at <code>scripts/generate-audio.mjs</code> that runs on the raw markdown before anything reaches ElevenLabs. It does the following:</p>
<ul>
<li>Strips code blocks, frontmatter, HTML, Hugo shortcodes.</li>
<li>Expands <code>~170</code> to &ldquo;around 170.&rdquo;</li>
<li>Converts arrows between tokens to &ldquo;to.&rdquo; <code>421 → 337</code> becomes <code>421 to 337</code>.</li>
<li>Strips decorative trailing arrows from CTAs, so <code>Get the Scorecard →</code> narrates as just <code>Get the Scorecard</code>.</li>
<li>Renders markdown tables as labeled rows. The first row becomes column labels, and each data row becomes a short sentence. <code>Round: 1; Iterations: 25; Lines: 421 to 337; Focus: Easy wins.</code></li>
<li>Injects pause tags around headings (a one-second pause before, a shorter pause after) and between paragraphs.</li>
<li>Honors a <code>&lt;!-- audio-skip --&gt;</code> HTML comment before a table to replace that table with &ldquo;See the written post for the full table.&rdquo; Readers still see it. Listeners get a clean hand-off.</li>
</ul>
<p>I also updated the voice-profile document my editor agent consults when I write new posts. It now has a &ldquo;Writing for Audio Narration&rdquo; section. Three months from now, when I lean into a colon-introduced question out of habit, the agent will catch it at draft time.</p>
<h2 id="architecture">Architecture</h2>
<p>The generation pipeline runs entirely outside the Hugo build. That was the design constraint from the start. Audio generation is expensive and side-effectful. Hugo rebuilds should stay cheap and deterministic.</p>
<pre tabindex="0"><code> ┌─────────────┐
 │ push to main│
 └──────┬──────┘
        │
        ▼
 ┌──────────────────────┐      ┌──────────────┐
 │ generate-audio.yml   │──▶──▶│ ElevenLabs   │
 │ (GitHub Actions)     │      │ TTS API      │
 └──────┬───────────────┘      └──────┬───────┘
        │                             │
        │                             ▼ MP3
        │                      ┌──────────────┐
        │                      │ Cloudflare R2│
        │                      └──────────────┘
        ▼
 ┌──────────────────────┐
 │ data/audio_hashes.json│  ◀── commit hash manifest back
 └──────┬───────────────┘
        │
        ▼
 ┌──────────────────────┐
 │ Cloudflare Pages     │
 │ rebuilds; player +   │
 │ JSON-LD render when  │
 │ hash entry exists    │
 └──────────────────────┘
</code></pre><p>The moving pieces:</p>
<p><code>scripts/generate-audio.mjs</code> is a Node script. Walks <code>content/posts/</code>, processes each post that has <code>audio: true</code> in its frontmatter, checks a content hash to avoid re-generating unchanged posts, calls ElevenLabs, uploads the MP3 to Cloudflare R2, updates the hash manifest.</p>
<p><code>data/audio_hashes.json</code> is a content-addressable cache. Maps post slug to a SHA of its cleaned text. Lives under Hugo&rsquo;s <code>data/</code> directory so templates can read it as a ready-signal.</p>
<p>The GitHub Actions workflow (<code>generate-audio.yml</code>) triggers on pushes to main that touch <code>content/posts/**.md</code> or the script itself. It runs the generation script and commits the updated hash manifest back to main.</p>
<p>Hugo templates render the audio player only if two conditions hold. <code>audio: true</code> in the post&rsquo;s frontmatter. AND the hash manifest has an entry for the post&rsquo;s slug. That second gate is what prevents a broken player during the window between push and CI completion. If the audio isn&rsquo;t ready yet, the player doesn&rsquo;t appear at all.</p>
<p>Cloudflare R2 stores the MP3s behind <code>assets.damiangalarza.com</code>. Free egress, negligible storage cost, immutable caching. Objects live under <code>audio/posts/&lt;slug&gt;.mp3</code>.</p>
<p>Key property: the Hugo build never touches ElevenLabs. The audio artifact is durable and independent of the generation service. If ElevenLabs changes their API or disappears tomorrow, every existing MP3 keeps playing.</p>
<h2 id="what-the-page-exposes-for-agents">What the Page Exposes for Agents</h2>
<p>LLM crawlers are starting to treat pages as content collections, not just HTML. So once the MP3 is on R2, the post page emits a richer set of semantic signals.</p>
<ul>
<li><code>&lt;link rel=&quot;alternate&quot; type=&quot;audio/mpeg&quot;&gt;</code> sits in the same slot as the RSS alternate link. Feed readers and AI crawlers can auto-discover the audio version.</li>
<li>Open Graph audio tags. <code>og:audio</code>, <code>og:audio:type</code>, <code>og:audio:secure_url</code>. Social platforms that generate link previews can reference the audio directly.</li>
<li>An enriched <code>AudioObject</code> JSON-LD block. <code>contentUrl</code>, <code>encodingFormat</code>, <code>inLanguage</code>, <code>uploadDate</code>, a <code>transcript</code> property pointing back to the post&rsquo;s canonical URL, and a <code>potentialAction</code> of type <code>ListenAction</code>.</li>
</ul>
<p>The <code>transcript</code> property is the one I care about most. It tells an LLM that this audio and this page contain the same content. For retrieval and for training, that&rsquo;s the signal that matters.</p>
<h2 id="why-it-matters">Why It Matters</h2>
<p>Accessibility. A real audio version in a consistent voice, not the synthetic monotone of a browser&rsquo;s built-in reader. Readers with dyslexia, low vision, reading fatigue, or simply a preference for listening get the same content without fighting the format. And because the voice is mine, cloned from hours of my own recordings, the audio carries the same presence as the written post. A screen reader substitutes a generic voice for whatever makes a writer recognizable. A pre-generated narration doesn&rsquo;t. Audio becomes a first-class version of the post, not a degraded fallback.</p>
<p>Consumption mode flexibility. I&rsquo;ll be able to review my own drafts on the road now, which is what started this whole thing. Readers who commute or cook or walk get the same affordance.</p>
<p>And the quieter payoff. Narrator-failure surfaces reader-friction. Every pattern the TTS stumbled over was a pattern a careful human reader would have tripped on too. The voice just made it unignorable.</p>
<h2 id="whats-still-open">What&rsquo;s Still Open</h2>
<p>Cache-busting. When I edit a published post, the MP3 at the same URL changes, but Cloudflare&rsquo;s CDN doesn&rsquo;t invalidate for up to 24 hours. A <code>?v=&lt;hash&gt;</code> query param fixes this. On the list.</p>
<p>SSML <code>&lt;say-as&gt;</code> support. ElevenLabs&rsquo; SSML coverage is narrow. <code>&lt;break&gt;</code> is reliable. <code>&lt;say-as interpret-as=&quot;cardinal&quot;&gt;</code> may or may not be honored on the model I&rsquo;m using. I tested it against <code>~170</code> and ended up preferring the plain-text regex substitution. Worth revisiting as ElevenLabs expands the supported tag set.</p>
<p>The number-pause quirk. The voice still inserts a micro-pause after multi-digit numbers. That&rsquo;s a voice-model trait, not something I can fix in text. Possibly improves with a different model; <code>eleven_flash_v2_5</code> and <code>eleven_multilingual_v2</code> are both on the list to try.</p>
<p>If you&rsquo;re thinking about adding audio to your own site, build it. But read your last three posts aloud first. You&rsquo;ll learn more from that than from any of the transforms above.</p>
<p>If you&rsquo;re working on AI features in a real product and want a second pair of eyes on the architecture, <a href="/services/ai-engineering/">let&rsquo;s talk</a>. Calm, direct conversations about tradeoffs.</p>
<h2 id="additional-reading">Additional Reading</h2>
<ul>
<li><a href="https://theurbanwriters.com/blogs/publishing/pacing-and-flow-how-to-optimize-your-writing-for-audiobook-performance">Audiobook Pacing and Writing for Audiobook Performance</a> — the audiobook playbook this post leans on</li>
<li><a href="https://elevenlabs.io/docs/overview/capabilities/text-to-speech">ElevenLabs Text to Speech</a> — API docs</li>
<li><a href="https://www.enchantingmarketing.com/punctuation-influences-writing-voice/">How Punctuation Influences Your Writing Voice</a> — a useful companion on how punctuation carries tone for readers as well as narrators</li>
</ul>
]]></content:encoded></item></channel></rss>