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.

Updated 9 min read

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

Split, synthesize, stitch

The source text becomes bounded chunks, each chunk becomes an MP3, then ffmpeg re-muxes the parts into one chapter file.

  1. InputSource textePub, Markdown, plain text, or database row. Anything long enough to exceed one request.
  2. Stage 1ChunkerParagraph first, sentence second, hard cap of 20,000 characters per chunk.
  3. Stage 2SpeechifyAI TTS loopOne POST /v1/audio/speech per chunk, sequential or bounded parallel.
    part-000.mp3part-001.mp3part-002.mp3part-NNN.mp3
  4. Stage 3ffmpeg concatLossless re-mux with -f concat and -c copy.
  5. 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:

  1. The per-request character ceiling. /v1/audio/stream accepts 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.
  2. 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.
  3. 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.
  4. 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

Natural breaks, hard character cap

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

Chunk 00420 / 20,000 chars

Chapter one. The lighthouse keeper had not slept in three days. She watched the storm from the lamp room...

Chunk 01only when source exceeds cap

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_id constant 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, but St. 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.

Privacy preferences

Choose what we may store on this device. You can change this at any time from the footer.

Strictly necessary

Sign-in, security, load balancing, and remembering your privacy choices. These cannot be switched off.

Always on

Analytics

How the site is used in aggregate - which pages get read, where people get stuck - so we can improve it.

Marketing

Measures which campaigns bring people here, and lets us show relevant ads on other platforms.