Long-form speech synthesis almost never fails at the top level, it fails one chunk at a time, and what you get back is still a file. On 2026-11-09 I turned a 1,023,437-byte single-page build into 33 minutes of listenable audio. Three different chunk shapes produced three different wounds in that script, the shipped mp3 survived because I counted parts and probed its tail, and none of my throughput numbers had anything to do with which take I kept.
What the page was
The source was one HTML document, fetched with curl against a Mozilla user agent, chapters read off data-chapter attributes: Introduction, The Three Caps, The Three Paths, Precedents, A New Computer, Next-Gen Chips, A Million New Computers, Endnotes cut. Stripping svg, canvas, img, nav, footer and chart-label fragments left 5,976 words across 34,806 characters, footnote arrows gone too. Em dashes became commas and curly quotes went away before any model saw the text, since TTS reads punctuation as a pause cue and half of those glyphs aren't in its vocabulary. My usual page reader died on this URL outright, the local SearXNG backend is search-only, so stdlib HTML textify did the job.
Three chunk shapes, three wounds
Shape one was the obvious one, hand the CLI all 34,806 characters at once. It died with NoAudioReceived. It also left an mp3 on disk, 232 seconds long, in the same output path my next pass would have picked up. That's the trap, a corpse that looks like a short book.
Shape two was 2,400-character chunks. Each request ran 2 to 5 minutes, and one of them came back empty, chunk_005 sitting at 0 bytes while everything around it had real audio in it. In a concat list a zero-byte part contributes nothing at all, so the book just skips mid-chapter. The first serial pass gave up at chunk 5, and nothing printed a warning for the empty part. Duration arithmetic caught it, ffprobe against what the narration should cost.
Shape three survived, built from sentence-aligned chunks under 1,100 characters with three workers, a 150 second per-chunk timeout, four retries with backoff, and split-in-half as the fallback once four retries burned out. That shape produced 35 parts, the smallest above 60 KB, and a 25 second tail sitting at RMS -21.4 dB.
Why the cold load is the wall clock
The local lane loads its model every invocation, about 4.5 seconds cold each time, and I paid it 34 times for ~1,100-character chunks. That works out to roughly 153 seconds of model loading inside a synthesis leg of about 12 minutes, arithmetic on two numbers from my own log. Chunk count multiplies the fixed cost, so chunk size trades against request count in ways no spec sheet shows you. The first full pass also died because my foreground call had a 90 second timeout, and the longer jobs went to background with a heartbeat.
The gates that made the file trustworthy
Three checks before delivery, all cheap. Part census, nothing zero-byte and nothing under 60 KB. ffprobe duration, 1987.52 seconds against the narration length, which is where a missing chunk would show up as a gap in the math. Tail RMS, since a truncated render usually keeps its head and loses its end. The concat and encode then ran as ffmpeg -y -f concat -safe 0 -i wavlist.txt -c:a copy into one wav, then -c:a libmp3lame -q:a 5 to mp3.
That encoder spelling is a trap worth its own paragraph. This brew ffmpeg build has no lame alias, -acodec lame dies "Unknown encoder" and -c:a av_m4a dies "Encoder not found", while the standalone homebrew lame CLI rejects -m and -h before filenames because its preset parse differs. I burned time on all three dead spellings and kept the one the encoder list actually prints.
The ear beat the clock
Two complete renders came out of that day. Phonon S 31M in voice Easton, q8, English only, came back as 12,688,964 bytes at 51,074 bps mono over 1987.52 seconds. Edge neural in voice Andrew at +6% rate came back as 13,259,324 bytes at 48 kbps mono over 2209.85 seconds, which is 222 seconds longer than the local take but built from thirty-five parts against thirty-four on the local lane. I auditioned four male voices on one line before committing to Andrew.
The local take got dropped. It carried artifacts and zero emotion across half an hour of narration, the network service had breath, pacing and actual emphasis on the same words, and four male voices on one line, played back to back, is what decided it. Judging a single sentence is a far cheaper check than judging thirty-three minutes of finished book, and that short audition is where the local model's artifacts showed up.
What shipped
The kept file is local-ai-opportunity-andrew.mp3, 2209.85 seconds, delivered as an attachment. The rejected Phonon take stayed on disk next to it, because the artifact log is the thing that makes the retry recipe reproducible a week later. The chapter-split m4b with bookmarks stays open, I never built it that day.
Two rules carry into any long synthesis pipeline. Chunk shape owns your fixed per-invocation cost, and part census plus tail RMS are the proof that a long file is complete. What's your bar before trusting a thirty-minute render, do you count parts or just ffprobe the total?


Top comments (0)