convert epub files to audiobooks using qwen3-tts or with any openai compatible text-to-speech endpoint.
- python 3.12+
- ffmpeg
- sox
- uv (python package manager)
- gpu recommended (cuda or rocm) if running tts locally
- or an openai-compatible tts endpoint (no local gpu required)
# cuda gpu (default, includes local tts)
make build-cuda
# amd rocm gpu (gfx1151, includes local tts)
make build-rocm
# cpu only (includes local tts)
make build-cpulocal tts extras are optional. to drive an openai-compatible tts endpoint
instead, install without the [local] extra and set --api-base / OPENAI_BASE_URL.
source .venv/bin/activate
autiobook --helpautiobook chapters book.epubautiobook convert book.epub -o workdir/runs all phases, skipping already-completed steps.
extract chapter text from epub to workdir.
autiobook extract book.epub -o workdir/
creates:
workdir/extract/metadata.json- book metadataworkdir/extract/NN_Title.txt- chapter text filesworkdir/extract/state.json- resumability state
convert text files to wav audio.
autiobook synthesize workdir/ -s ryan
creates:
workdir/synthesize/NN_Title.wav- audio filesworkdir/synthesize/state.json- resumability state
convert wav files to mp3 with metadata.
autiobook export workdir/
creates:
workdir/export/NN_Title.mp3- mp3 files with id3 tagsworkdir/export/state.json- resumability state
rebuild the source epub with media overlays, so a reader highlights each phrase as it is narrated.
autiobook export workdir/ --epub3
creates workdir/export/<book>.epub.
the source epub is located automatically from the extract state; pass
--epub book.epub if it has since moved. --epub3-bitrate controls the
embedded audio (default 64k - a novel runs a few hundred MiB, so raising
this gets large quickly).
epub2 sources are upgraded to epub3, which media overlays require.
read-along playback is supported by apple books, thorium, and other readium-based readers. kindle ignores media overlays entirely, and support in google play books and kobo is patchy.
generate a full cast performance using an openai-compatible llm (including llama.cpp) and voice cloning.
# 1. extract text
autiobook extract book.epub -o workdir/
# 2. generate cast list (using llm)
# each character gets a description (who they are) and a separate
# voice prompt; only the voice prompt is sent to the tts model.
# --llm-audition-lines also asks for a per-character audition line,
# filling only characters that don't already have one
autiobook cast workdir/ --api-key sk-...
# 3. generate base voice per character (review/edit characters.json first if needed)
# every character auditions on the same sentence, so takes differ only in
# voice. set "audition_line" on a character in characters.json to give that
# one its own line; --audition-line changes it for the whole run
autiobook audition workdir/
# 4. generate per-emotion voice variants
autiobook emote workdir/
# 5. create dramatized script (using llm)
autiobook script workdir/ --api-key sk-...
# 6. review and repair script (optional; --dry-run to only report)
autiobook revise workdir/ --api-key sk-...
# 7. perform the script (voice cloning)
autiobook perform workdir/
# 8. export to mp3
autiobook export workdir/or run the full dramatization pipeline in one go:
autiobook dramatize book.epub --api-key sk-...
# pause after each phase for examination
autiobook dramatize book.epub --step
# re-run the last completed phase
autiobook dramatize book.epub --redo
# enable all inline quality checks (script revise + voice/segment retake)
autiobook dramatize book.epub --strict
# finish each chapter end to end before starting the next
autiobook dramatize book.epub --chapter-wiseeach phase runs across all chapters before the next phase starts. --chapter-wise
instead completes script → revise → review → perform → retake → export for
chapter 1 before chapter 2 begins, so you hear finished audio sooner. either way
cast, audition and emote run across every chapter first: the script phase keys
its resume state on the whole cast, so casting chapter by chapter would send
every earlier chapter back through the LLM each time a new character turned up.
after generating scripts, revise reviews them against the source text,
detecting missing or hallucinated segments and repairing them via llm:
# review and repair: fill missing text, remove hallucinated segments
autiobook revise workdir/ --api-key sk-...
# only review; don't modify scripts
autiobook revise workdir/ --dry-run
# local cleanup only: strip hallucinations, skip the llm fix-missing pass
autiobook revise workdir/ --pruneaudit walks the flags review and revise raised for human attention. an
open flag defers its chapter from perform, so this is where a stalled
chapter gets unblocked.
autiobook audit workdir/
autiobook audit workdir/ --list # non-interactive
autiobook audit workdir/ --all # include applied-edit recordsper entry: [a]pply writes the suggested correction, [s]uggest asks the llm
for one when the flag arrived without it, [e]dit opens the chapter script,
[d]ismiss clears the flag, [n]ext leaves it open for later.
callbackscansaudition/andemote/wavs for silent/clipped/noisy takes and re-generates them with a bumped seed.retakedoes the same forperform/andsynthesize/segments.locatelooks up which segment wav backs a given audio time position (useful for debugging a glitch you heard in the output).
autiobook callback workdir/
autiobook retake workdir/
autiobook locate workdir/perform/NN_Title.wav 00:12:34-o, --output DIR- workdir for intermediate files (default:<epub>_output/)-s, --voice NAME- tts voice forsynthesize(default: ryan)-c, --chapters RANGE- chapter selection (e.g., 1-5, 3,7,10)--tts-model,--tts-design-model,--tts-clone-model- override tts models--api-base,--api-key- openai-compatible endpoint (defaults to$OPENAI_BASE_URL/$OPENAI_API_KEY)--tts-api-base,--tts-api-key- point tts at a different provider than the llm--tts-dialect- request subset:qwen,openai, orauto(by host)--tts-voices- preset voices for a backend with no/audio/voicesendpoint--tts-direction-field(top-levelinstructions) orprefix(folded into the text)--llm-model- llm model name--m4b- export as a single m4b with chapter markers-v, --verbose- verbose output-f, --force- ignore resume state--seed- seed for tts and llm (0disables seeding)
the seed for a book is chosen once and recorded in workdir/seed.json, so
resuming a conversion reuses it instead of drifting to a new one each run.
precedence is --seed, then $AUTIOBOOK_SEED, then the recorded seed, then a
fresh random one. passing --seed (or setting the env var) replaces what was
recorded, and says so, since the whole point is that a book keeps one seed.
environment variables (also loadable from .env; see .env.example):
OPENAI_API_KEY, OPENAI_BASE_URL, AUTIOBOOK_LLM_MODEL,
AUTIOBOOK_TTS_CLONE_MODEL, AUTIOBOOK_TTS_INSTRUCT_MODEL,
AUTIOBOOK_TTS_DESIGN_MODEL, AUTIOBOOK_SEED,
AUTIOBOOK_LLM_THINKING_BUDGET, AUTIOBOOK_CAST_BATCH_SIZE,
AUTIOBOOK_TTS_MODEL, AUTIOBOOK_TTS_API_BASE, AUTIOBOOK_TTS_API_KEY,
AUTIOBOOK_TTS_DIALECT, AUTIOBOOK_TTS_VOICES, AUTIOBOOK_TTS_DIRECTION,
AUTIOBOOK_TTS_RESPONSE_FORMAT, AUTIOBOOK_TTS_MAX_RETRIES,
AUTIOBOOK_TTS_RETRY_DELAY.
the default tts backend is a local qwen3-tts server, whose api is a superset
of openai's: wav responses, sse streaming, and an /audio/voices endpoint for
listing presets and creating cloned voices. hosted providers offer none of
that, so --tts-dialect openai (selected automatically for openrouter.ai
and api.openai.com) narrows each request to model, input, voice,
response_format and instructions, asks for pcm instead of wav, and skips
the sse probe -- on a metered api that probe is a second billed synthesis of
the same text.
export OPENAI_BASE_URL=https://openrouter.ai/api/v1
export OPENAI_API_KEY=sk-or-...
autiobook dramatize book.epub \
--tts-model google/gemini-3.1-flash-tts-preview \
--preset-voices --tts-direction prefix--tts-model (or $AUTIOBOOK_TTS_MODEL) sets the model for every mode. the
three per-mode defaults are qwen model ids, so a hosted run that leaves them
in place asks openrouter for a model it has never heard of.
against any http backend, a dropped connection, a timeout or a 429/5xx reply
is retried with exponential backoff, so a blip an hour into a synthesis run no
longer ends it: AUTIOBOOK_TTS_MAX_RETRIES (4) retries at
AUTIOBOOK_TTS_RETRY_DELAY (2 seconds), doubling each time. every other 4xx
-- a malformed body, an unknown voice, a rejected key -- fails at once, since
the reply will not change and hosted providers bill for each send.
takes play while they render on a hosted backend without any extra setting --
AUTIOBOOK_TTS_STREAM_BATCH_SIZE is a qwen-server knob, and the provider's
response body is already a stream. in --directed casting the prompt stays
live during playback, so n moves on mid-take instead of after it, and
[p]rev steps back to an earlier voice (replayed from cache, not re-rendered).
--directed opens with the cast roster (names, aliases, descriptions) and waits
for approval before the first take, so a cast with junk entries or duplicates
can be fixed before an hour of takes rather than after. only characters the
session will stop on are listed; approved voices are left off, a finished cast
is not prompted at all, and resuming asks again only if the cast changed. each
character starts from the configured seed, so [n]ext on one character does not
change what the next character's first take sounds like, and a character opens
on the same take whether or not the backend streams.
--preset-voices is required: hosted providers have fixed voices and no
server-side voice creation, so voice design and cloning are unavailable there.
openrouter publishes no voice discovery api either; the gemini tts voice names
are built in, and anything else needs --tts-voices Zephyr,Puck,Kore.
--tts-direction prefix folds emotion and voice direction into the input text
rather than the instructions field, which openrouter does not document as a
top-level parameter. without it, delivery direction may be silently dropped
and every line comes out flat.
to keep tts local while the llm runs hosted (or the reverse), set
--tts-api-base/--tts-api-key alongside --api-base/--api-key.
qwen3-tts: Vivian, Ryan, Sunny, Aria, Bella, Nova, Echo, Finn, Atlas
gemini tts (built in, used with --preset-voices): Zephyr, Puck, Charon,
Kore, Fenrir, Leda, Orus, Aoede, and 22 more.
creates one mp3 file per chapter in workdir/export/ (or a single .m4b
with chapter markers when --m4b is passed):
workdir/export/
├── 01_Introduction.mp3
├── 02_Chapter_One.mp3
└── ...
The export/ folder also contains .srt and .vtt subtitles alongside
each chapter mp3 (with speaker labels for dramatized output).
--epub3 instead produces a single read-along epub with the narration
embedded and synchronized to the text.
compatible with the Voice audiobook player for android.
Intermediate files are organized into subdirectories by command:
workdir/
├── seed.json # seed recorded for this book, reused on resume
├── extract/ # extracted text and metadata
│ ├── metadata.json
│ ├── cover.jpg
│ ├── NN_Title.txt
│ └── state.json
├── cast/ # character list and analysis state
│ ├── characters.json
│ └── state.json
├── audition/ # per-character base voices (description only)
│ ├── Character.wav
│ └── state.json
├── emote/ # per-emotion voice variants
│ ├── Character__neutral.wav
│ ├── Character__happy.wav
│ ├── ...
│ └── state.json
├── script/ # dramatized scripts (speaker segments)
│ ├── NN_Title.json
│ └── state.json
├── perform/ # dramatized audio performance
│ ├── NN_Title.wav
│ ├── NN_Title.wav.timing.json # per-chunk start/end offsets + metadata
│ ├── segments/ # segment cache
│ └── state.json
├── synthesize/ # standard mono-voice audio
│ ├── NN_Title.wav
│ ├── NN_Title.wav.timing.json
│ ├── segments/ # segment cache
│ └── state.json
└── export/ # final mp3 output
├── NN_Title.mp3
├── NN_Title.srt # subtitles (with speaker labels)
├── NN_Title.vtt # webvtt subtitles
└── state.json
Each command is fully resumable based on content hashes stored in state.json.