Building an automated audiobook pipeline with the Speechify TTS API
Turn long-form ePub or Markdown into a single narrated chapter MP3 with a runnable Python demo: chunk on sentence boundaries, synthesize each chunk, stitch with ffmpeg.
Long-form text-to-speech is not one big API call. Speechify’s streaming endpoint, /v1/audio/stream, takes up to 20,000 characters of spoken text per request, and a novel runs to half a million. So the shape is split-loop-stitch: chunk the text on sentence boundaries, synthesize each chunk, concatenate the MP3s with ffmpeg. The middle step is the Python SDK in a loop. The other two are where the actual work hides.
The whole thing is a runnable demo. Clone Speechify-AI/demos/audiobook-pipeline, drop in an API key, point it at a text file, and it writes you a chapter. This post walks that demo and explains the parts worth explaining. You’ll need a Speechify key to run it. Grab one here if you haven’t got one.
Run the demo first
The demo is a single main.py plus a concat.sh helper. Setup is a virtualenv and one dependency install:
cp .env.example .env # paste your SPEECHIFY_API_KEY
uv venv && source .venv/bin/activate
uv pip install -r requirements.txt
python main.py samples/chapter-01.txt
samples/chapter-01.txt is a 421-character demo chapter (a lighthouse keeper, a storm, the usual) that fits in a single chunk. Running it prints exactly what the pipeline decided to do before it spent a single character of synthesis:
Source: samples/chapter-01.txt (421 chars)
Chunks: 1 (under 20,000 char cap)
chunk 0: 420 chars
wrote output/chapter-01/part-000.mp3 (411,693 bytes)
Wrote 1 chunk MP3s to output/chapter-01/
Stitch into one file with: ./concat.sh output/chapter-01
That chunk report is the bit I lean on most. Before anything hits the API you can see how the text was cut, how many requests you’re about to make, and whether any chunk is suspiciously close to the cap. Shrink MAX_CHARS to something tiny and rerun to watch the multi-chunk path without needing a whole book.
Audiobook pipeline
The source text becomes bounded chunks, each chunk becomes an MP3, then ffmpeg re-muxes the parts into one chapter file.
- InputSource textePub, Markdown, plain text, or database row. Anything long enough to exceed one request.
- Stage 1ChunkerParagraph first, sentence second, hard cap of 20,000 characters per chunk.
- Stage 2SpeechifyAI TTS loopOne
POST /v1/audio/speechper chunk, sequential or bounded parallel.part-000.mp3part-001.mp3part-002.mp3part-NNN.mp3 - Stage 3ffmpeg concatLossless re-mux with
-f concatand-c copy. - Outputchapter-01.mp3Single chapter file. Same voice, no mid-sentence cuts, no re-encoding step.
Only the TTS loop touches the API. Chunking and concatenation are local, which makes retries and caching clean at the chunk boundary.
Where the naive approach breaks
If you’ve only shipped TTS for short content (a UI prompt, a notification, a single paragraph) you’ve never had to chunk anything. The pattern just works. Long-form breaks it in four specific places:
- The per-request character ceiling.
/v1/audio/streamaccepts up to 20,000 characters of spoken text per request, and/v1/audio/speech, which returns the whole file in one response, accepts 2,000. A novel is 80,000 to 100,000 words, roughly half a million characters, so even on the streaming endpoint a book is around 25 requests, and a long chapter is more than one. - Mid-sentence cuts. Chunk by raw character count and the splits land mid-word, so the narrated voice “exhales” oddly at every boundary. Listeners catch it instantly.
- Voice drift. Each chunk is a fresh synthesis with no carried-over prosody, so a long pause or a shouted line in one chunk doesn’t inform the next. You hear it most on dialogue.
- Rate limits. Twelve requests fired at once can exceed your plan’s per-account rate or concurrency limit, so you need retry-with-backoff.
None of those are model problems. They’re orchestration problems, and you solve them in the pipeline, not the API call.
Splitting text on sensible boundaries
The chunker has two jobs: never emit a chunk over the ceiling, and split on natural language boundaries when it has to. Paragraphs first, sentence ends second, mid-sentence never.
My first version split on . and broke on every abbreviation. “Mr. Smith” became “Mr” and “Smith”, and the audio came out about as bad as that sounds. The fix is (?<=[.!?])\s+: split after sentence-ending punctuation, only on whitespace, with a lookbehind so the punctuation stays attached to the sentence it belongs to. Took me embarrassingly long to land on that.
import re
MAX_CHARS = 20_000
def chunk_text(text: str, max_chars: int = MAX_CHARS) -> list[str]:
"""Split text into chunks under max_chars, preferring paragraph then sentence boundaries."""
paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
chunks: list[str] = []
buf = ""
for para in paragraphs:
if len(buf) + len(para) + 2 <= max_chars:
buf = f"{buf}\n\n{para}" if buf else para
continue
if buf:
chunks.append(buf)
buf = ""
if len(para) > max_chars:
for sent in re.split(r"(?<=[.!?])\s+", para):
if len(buf) + len(sent) + 1 <= max_chars:
buf = f"{buf} {sent}".strip()
else:
if buf:
chunks.append(buf)
buf = sent
else:
buf = para
if buf:
chunks.append(buf)
return chunks
Three branches, in order of preference. The paragraph fits alongside what’s already buffered, so append it. The paragraph doesn’t fit, so flush the buffer and start fresh with the paragraph on its own. Or the paragraph is itself bigger than the cap, so fall back to sentence splits inside it. The demo’s chunk report is just this function’s output printed before synthesis.
Text chunking
The chunker fills a buffer with whole paragraphs first. When it has to split, it falls back to sentence boundaries.
Source text
Chapter one. The lighthouse keeper had not slept in three days.
She watched the storm from the lamp room, hand resting on the brass railing, and listened to the rhythm of the rotating beam. The keeper's log lay open on the desk, half-filled, ink still wet on the last entry.
Below, the surf battered the rocks the way it had every winter for two hundred years. She knew the sound the way other people know a heartbeat.
Output chunks
Chapter one. The lighthouse keeper had not slept in three days. She watched the storm from the lamp room...
More chunks appear as chapters cross the 20,000 character ceiling.
Synthesizing each chunk
The synthesis loop is one streaming SDK call per chunk with retry-on-failure. It’s the same /v1/audio/stream request I covered in the Node.js TTS post and the Python streaming post, through the SDK’s client.audio.stream(...), run N times:
import os
import time
from pathlib import Path
from dotenv import load_dotenv
from speechify import Speechify
def synthesize_chunks(
chunks: list[str],
out_dir: Path,
voice_id: str = "geffen_32",
model: str = "simba-3.2",
max_retries: int = 3,
) -> list[Path]:
load_dotenv()
token = os.environ.get("SPEECHIFY_API_KEY")
if not token:
raise SystemExit("Set SPEECHIFY_API_KEY (copy .env.example to .env).")
client = Speechify(token=token)
out_dir.mkdir(parents=True, exist_ok=True)
files: list[Path] = []
for i, chunk in enumerate(chunks):
out = out_dir / f"part-{i:03d}.mp3"
attempt = 0
while True:
try:
with out.open("wb") as f:
for audio in client.audio.stream(
input=chunk,
voice_id=voice_id,
model=model,
accept="audio/mpeg",
):
f.write(audio)
break
except Exception as exc:
attempt += 1
if attempt > max_retries:
raise
wait = 2**attempt
print(f"chunk {i}: retry {attempt} after {wait}s ({exc})")
time.sleep(wait)
files.append(out)
return files
client.audio.stream yields the MP3 bytes as they are generated, and accept="audio/mpeg" picks the format, so each part goes to disk as it streams and never sits whole in memory. A retry reopens the part file, so a stream cut off halfway starts that part over instead of leaving half an MP3. The demo defaults to the geffen_32 catalogue voice and the simba-3.2 model. The retry loop is cheap exponential backoff: 2s, 4s, 8s, then raise. A production pipeline would classify the error first (retry on 429 and 5xx, don’t retry on other 4xx), but for an overnight audiobook job this is enough.
A few things I learned running this on a real chapter:
- Keep
voice_idconstant across chunks. Swapping voices mid-chapter for “character voices” is tempting, but the seam is audible at the switch, and SSML paces dialogue without changing the voice. - Pre-process ambiguous abbreviations. Speechify reads
Dr.as “doctor” cleanly, butSt.is genuinely ambiguous (street or saint). If your source has consistent ambiguity, expand it before synthesis. - Dry-run one chunk first. A synthesis error mid-chapter costs pennies but wastes the whole run’s time. Verify chunk zero, then loop.
On the demo’s 420-character chunk that call streams a part-000.mp3 of 411,693 bytes: MPEG layer III, 128 kbps, 24 kHz mono, about 26 seconds of audio. A chunk at the 20,000-character cap is around 19 minutes of narration, and in a test run it streamed in under a minute.
Stitching with ffmpeg
After the loop you’ve got output/chapter-01/part-000.mp3 through part-NNN.mp3. The demo’s concat.sh builds the manifest that ffmpeg’s concat demuxer wants and runs the concat for you:
./concat.sh output/chapter-01
Under the hood that’s the concat demuxer pointed at a manifest of the parts in order:
ffmpeg -f concat -safe 0 -i manifest.txt -c copy chapter-01.mp3
-c copy is the important flag. It re-muxes without re-encoding, so audio quality stays bit-for-bit identical and the whole stitch finishes in well under a second. -safe 0 is needed because the concat demuxer rejects absolute paths by default, and the manifest concat.sh generates uses them. Here’s the real run on the single-chunk sample:
size= 402kB time=00:00:25.70 bitrate= 128.3kbits/s speed=1.73e+03x
Wrote output/chapter-01/chapter-01.mp3
One chunk in, one chapter out, 26 seconds of narration. Feed it a real multi-paragraph chapter and the only thing that changes is the number of parts the manifest points at: a 25,801-character chapter comes out as two parts that stitch into one 26-minute MP3.
If you want .m4b instead (the audiobook container with chapter markers that Audible and Apple Books read), it’s a similar concat plus a metadata file mapping chapter titles to timestamps, output through the AAC codec rather than -c copy. ffmpeg’s ffmetadata documentation describes the exact [CHAPTER] block format, which is more than a single post wants to reproduce.
What’s left for production
Split-loop-stitch gets you a working audiobook end-to-end. Production adds three things on top:
- Parallel synthesis. The demo loop is sequential to keep the rate-limit story simple. A real pipeline fans chunks out with bounded concurrency (
asyncio.Semaphore(5)or similar) and reassembles in order, which is roughly two to three times faster on a typical chapter. - Caching. When you’re iterating on chunking logic you don’t want to re-synthesize chunks whose text hasn’t changed. Hash each chunk’s input, key the cached MP3 by that hash, reuse on a hit. On a long book that saves real money.
- SSML for pacing. The default voice is a neutral narrator, which suits non-fiction. For fiction with dialogue, give lines room with SSML
<break>and pace them with<prosody rate>before synthesis. The full SSML surface lives in the Speechify docs.
That’s the gap between a weekend project and a pipeline you’d ship.
FAQ
What is the maximum text length per Speechify TTS request?
20,000 characters of spoken text per request to the streaming endpoint, /v1/audio/stream, and 2,000 to /v1/audio/speech. SSML tags do not count toward either. Sending more returns a 400. For long-form content like audiobooks or articles, split the input into chunks under that ceiling and synthesize each one separately, then concatenate the resulting MP3s with ffmpeg -f concat to stitch them back together losslessly. Pre-process abbreviations first so the chunker doesn’t break mid-word.
Why split on sentence boundaries instead of word count?
Mid-sentence splits produce audible “exhale” artifacts at each chunk boundary, because the synth has no context for what came before or after the cut. Splitting on sentence ends with a regex like (?<=[.!?])\s+ keeps the punctuation attached to the preceding sentence, so the next chunk starts cleanly. Listeners notice bad chunking immediately, which is why word-count splits work for accessibility readouts but not for narration.
Can I use SSML inside the chunks for character voices in dialogue?
Only for pacing. Both endpoints accept SSML in the input field, so each chunk can pace dialogue lines with <prosody rate> and give them room with <break>. Current models do not apply pitch, volume or emphasis, so SSML will not turn one voice into several characters. A separate voice ID per character works, but mixing voice IDs across chunks produces audible seams at the boundaries.
How do I add chapter markers for an .m4b audiobook file?
ffmpeg accepts a metadata file alongside the concat manifest. Each chapter gets a [CHAPTER] block with START and END timestamps in milliseconds plus a title. Pass it with -i metadata.txt -map_metadata 1 and output to .m4b with the AAC codec. Audible, Apple Books, and most ebook readers read those markers directly. ffmpeg’s ffmetadata documentation describes the exact format.
This post is narrated by Harper on Simba 3.2 through our text to speech API. How the player works: add read-aloud to your docs and turn speech marks into captions.