Building an automated audiobook pipeline with the Speechify TTS API

8 min read

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.

Developer Relations · SpeechifyAI Labs

Long-form text-to-speech is not one big API call. Speechify caps a single /v1/audio/speech request at 20,000 characters, and a book chapter runs 80,000 to 240,000. 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 (192,236 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. Input Source text ePub, Markdown, plain text, or database row. Anything long enough to exceed one request.
  2. Stage 1 Chunker Paragraph first, sentence second, hard cap of 20,000 characters per chunk.
  3. Stage 2 SpeechifyAI TTS loop One POST /v1/audio/speech per chunk, sequential or bounded parallel.
    part-000.mp3 part-001.mp3 part-002.mp3 part-NNN.mp3
  4. Stage 3 ffmpeg concat Lossless re-mux with -f concat and -c copy.
  5. Output chapter-01.mp3 Single 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. Speechify accepts up to 20,000 characters per /v1/audio/speech request. A typical chapter is 15,000 to 40,000 words, which is roughly 80,000 to 240,000 characters, so you’re sending six to twelve requests per chapter at minimum.
  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 will sometimes bump the per-key budget, 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 SDK call per chunk with retry-on-failure. It’s the same client.audio.speech(...) shape I covered in the Node.js TTS post and the Python streaming post, run N times:

import base64
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):
        attempt = 0
        while True:
            try:
                resp = client.audio.speech(
                    input=chunk,
                    voice_id=voice_id,
                    audio_format="mp3",
                    model=model,
                )
                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)

        out = out_dir / f"part-{i:03d}.mp3"
        out.write_bytes(base64.b64decode(resp.audio_data))
        files.append(out)
    return files

resp.audio_data is a base64 string, so base64.b64decode gives you the raw MP3 bytes to write. 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. If you need different voices for dialogue, use SSML inside one synthesis call, not separate calls.
  • 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 returns a part-000.mp3 of 192,236 bytes: MPEG layer III, 64 kbps, 24 kHz mono, about 24 seconds of audio. Same response shape as every other Speechify TTS call.

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=     188KiB time=00:00:24.02 bitrate=  64.1kbits/s speed=9.16e+03x

Wrote output/chapter-01/chapter-01.mp3

One chunk in, one chapter out, 24 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.

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. The Matroska wiki documents 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 prosody. The default voice is a neutral narrator, which suits non-fiction. For fiction with dialogue, wrap quoted lines in SSML <prosody> and <emphasis> 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 per request to /v1/audio/speech. 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?

Yes. The /v1/audio/speech endpoint accepts SSML in the input field, so each chunk can wrap dialogue lines in <prosody> or <emphasis> tags. Mixing actual voice IDs across chunks produces audible seams at the boundaries, but mixing prosody inside a single chunk does not. For multi-voice dialogue, prefer SSML within a chunk over separate calls.

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. The Matroska wiki documents the exact format.