Audiobook Creator Suite — Deep Feature Reference
The audiobook creator suite is the creator-facing side of Graphene: the path from "I have a manuscript" to "I have an .m4b file ready to upload to Audible." It composes the existing TTS + multi-voice + chapter-playlist + karaoke pipeline into an end-to-end workflow for authors who didn't generate their book through the LLM writer engine.
Companion to story-engine.md (the engine that generates prose from prompts). This doc covers the production-and-shipping layer on top.
What it is
Six features, four shipped Phase 1, that turn the platform from "LLM-fiction generator" into a complete audiobook production suite. They compose:
1. Manuscript ingestion (EPUB / DOCX / PDF + cover)
↓
2. Bible bootstrap (existing — story-engine.md)
↓
3. Pronunciation lexicon (fix mispronounced names)
↓
4. Character voice mapping (per-character dialogue voices)
↓
5. Audio render (existing — chapter audio / segments)
↓
6. ACX/M4B export (single-file output for Audible / Findaway / Apple Books)
Every handoff hands a more-complete season to the next step. By step 6 the creator has
a distribution-grade .m4b they can submit to any audiobook platform.
Phase 1 features (all shipped)
| # | Feature | Migration | Service | UI |
|---|---|---|---|---|
| 1 | Manuscript ingestion (EPUB) | — | epub-parser.ts | /dashboard/admin/manuscripts/new |
| 1 | Manuscript ingestion (DOCX) | — | docx-parser.ts | (same page) |
| 1 | Manuscript ingestion (PDF) | — | pdf-parser.ts | (same page) |
| 1 | Cover-image auto-import | — | uploadCoverImage in season-media.ts | (same page) |
| 2 | Pronunciation lexicon | 283 | pronunciation-lexicon.ts | PronunciationLexiconModal.tsx |
| 3 | Character voice mapping | 285 | character-voices.ts | CharacterVoicesModal.tsx |
| 4 | ACX/M4B export | 284 | acx-export.ts | AcxExportModal.tsx |
| 5 | Pre-flight audio preview | — | audio-preview.ts | PreflightAudioModal.tsx |
| 6 | Bulk chapter render | — | audio-bulk-render.ts | RenderAllChaptersModal.tsx |
| 7 | Status overview card | — | audiobook-overview.ts | AudiobookOverviewCard.tsx |
All modals open from the 🎭 Voices / 🗣 Pronunciation / 🎧 Preview audio /
🎬 Render all / 📦 Export M4B buttons in the SuperAdminPanel's tools row on /seasons/[id].
The overview card sits above the tools row and refreshes whenever a stage action lands.
Feature 1 — Manuscript ingestion
Three formats, one shape, one route. EPUB / DOCX / PDF all parse to the same
EpubParseResult shape, dispatch happens by file extension at
/api/manuscript/parse, and the
downstream UI + /api/manuscript/create-season flow stays format-agnostic.
EPUB (epub-parser.ts)
Pure-Node, no new dependency. Uses already-present adm-zip to unzip; reads
META-INF/container.xml to locate the OPF manifest; parses OPF metadata + manifest
items + spine with targeted regex (the XML subset EPUB uses is well-specified enough
to skip a full xml2js dependency). For each spine itemref, pulls the chapter file,
extracts the title (first <title> / <h1-3>), converts XHTML body to plain text via
a hand-rolled stripper (drops <head> / <script> / <style> / <h1-6>, converts
block closings to paragraph breaks, decodes HTML entities). Strips chapter headings
out of the prose body so the chapter-playlist player's "Chapter N" announcement doesn't
get duplicated by the TTS reading "Chapter Twelve" on top.
DOCX (docx-parser.ts)
Same adm-zip approach since DOCX is also a zip (Office Open XML). Reads
word/document.xml for body, docProps/core.xml for metadata. Chapter detection in
priority order:
<w:pStyle w:val="Heading1"/>paragraphs — Word's "Heading 1" outline style. Highest-signal split for authors who use Word's outliner.<w:br w:type="page"/>page breaks — common in manuscripts that skip heading styles.- Single-chapter fallback when neither signal exists.
PDF (pdf-parser.ts)
Uses pdf-parse@2 (which wraps pdfjs-dist). Chapter detection is regex-based on
common header patterns since most self-published PDFs don't set bookmarks:
Chapter N, CHAPTER N, Chapter One, Chapter I (Roman numerals), Part N,
standalone Prologue / Epilogue / Interlude. When the header line is bare
("Chapter 1" alone), peeks ahead 1-3 lines for a short non-terminal-punctuated
candidate title to promote. Falls back to single-chapter when no headers detected.
The reflow() helper handles PDF-extraction artifacts: drops pure-numeric short lines
(page numbers) and Page N / N of N patterns; decodes common ligature glyphs
(fi→fi, fl→fl, ffi→ffi); collapses intra-paragraph wraps from layout line breaks while
preserving paragraph breaks (double newlines + sentence-terminal endings).
Cover-image auto-import
EPUB cover detection priority: (1) EPUB 3 manifest item with properties containing
cover-image; (2) EPUB 2 <meta name="cover" content="<id>"/> in metadata block
→ resolve id in manifest; (3) EPUB 2 last-resort manifest item with id="cover" and
media-type starting image/.
DOCX cover: reads docProps/thumbnail.jpeg (with .jpg / .png fallbacks). Word
auto-generates this on save with default "Save Thumbnail" on.
PDF cover: largest embedded image on page 1, extracted via pdf-parse v2's getImage
API with partial: [1] and imageThreshold: 200 (skips tiny logos/icons). Picks the
image with the largest width × height — that's almost always the cover when fiction
PDFs embed it as an image (the common case for self-published / Reedsy exports).
PDFs that compose their cover from vector primitives still surface as no-cover; those
need pdfjs canvas rasterization to render the page, deferred to a future V2 since
adding @napi-rs/canvas as a dep is heavy for the small affected slice.
Cover bytes flow as cover_image_base64 + cover_image_mime through the
EpubParseResult → /parse response → preview UI → /create-season body → new
uploadCoverImage(prefix, buffer, mime) helper (MIME-aware sibling to uploadImage
so JPEG covers don't get labeled as PNG) → Supabase Storage → poster_url set
inline on the season insert.
Two-step flow
POST /api/manuscript/parse — preview-only, no DB writes. Accepts { filename, content_base64 } (max 25MB decoded — route-mounted express.json({ limit: '25mb' })
so the global 100kb cap stays untouched for every other endpoint). Returns
{ title, author, language, chapters: [...], cover_image_base64, cover_image_mime }.
POST /api/manuscript/create-season — the commit. Accepts the edited chapter list +
season metadata + optional cover bytes. Creates the season (draft, novel mode, bounded
narrative, owner=caller), inserts episodes with chapter_prose pre-populated +
triggered=true, synthesizes one live take per chapter stamped
platform_version='ported_v1' (same sentinel notebooks.ts:2c uses post-fab5f8e5
Odessa-port fix), uploads cover to Storage if present + sets poster_url.
Admin UI
/dashboard/admin/manuscripts/new
— three-stage state machine on one route: upload (drag-drop or file picker, .epub
/ .docx / .pdf) → preview/edit (chapter list with editable titles + drop-row
buttons + collapsible prose preview + cover thumbnail with drop-cover button +
season-metadata form prefilled from manuscript metadata) → submit (spinner + redirect
to /dashboard/admin/seasons/[season_id] on success).
Feature 2 — Pronunciation lexicon
Fixes the #1 LLM-TTS rage: mispronounced character / place / made-up-word names. Without per-book pronunciation control, creators rage-quit the first time the narrator reads "Elara" as "EE-lar-a" when they meant "ih-LAR-ah."
Schema (migration 283)
pronunciation_lexicons keyed on (season_id, term):
ipa text— IPA phonetic spelling (e.g.ɪˈlɑːrə); wrapped in<phoneme alphabet="ipa" ph="...">SSML at TTS timerespell text— plain-text alternative (e.g.ih-LAR-ah); literal substitutionnotes text— admin-facing context ("the protagonist", "fictional city")case_sensitive boolean— default false; useful when ALL-CAPS shorthands have a different pronunciation
CHECK constraint enforces at least one of ipa / respell so empty entries can't
accumulate. Super-admin RLS matches the bible / takes / critic pattern.
Substitution (pronunciation-lexicon.ts)
CRUD helpers + applyLexiconToText(text, seasonId) with a 60s in-memory per-season
cache — so a 30-segment chapter render does 1 DB query, not 30. CRUD writes actively
invalidate so admin-edit-then-render loops feel immediate.
applyLexiconSync extracts the substitution logic for unit-testing independent of the
cache: longer terms applied first (so "Auditor Pell" wins over "Pell"),
word-boundary anchoring for single-word terms, case-insensitive by default.
IPA mode wraps matches in <phoneme alphabet="ipa" ph="...">term</phoneme> SSML tags;
ElevenLabs eleven_multilingual_v2 auto-detects inline SSML in the text payload — no
separate flag needed. Respell mode does a plain literal swap (works on every TTS model
including turbo / flash, no SSML support required).
Coverage
Wired as the LAST step before ttsSegment fires at every TTS callsite in the
season audio pipeline:
season-novel-chapter-audio.ts— chapter audio renderseason-audio.tsrenderSeasonAudio— segment renderrenderIntentPulse— multi-voice Breakout-framework chorale, each voice fragment passes throughapplyLexiconToTextbefore its individual TTS call
Full coverage means a character / place / word mispronunciation can only happen if a lexicon entry is missing, never because a code path forgot to apply it.
Admin UI
🗣 Pronunciation button in SuperAdminPanel tools row opens
PronunciationLexiconModal.tsx
— sorted list with inline add form, inline edit (row expands into the same form
shape), delete with confirm.
Auto-suggest (LLM scan)
✨ Suggest entries button next to + Add entry. Service
lexicon-autosuggest.ts
fronts the discover-by-listening loop: gathers candidate proper nouns from the bible
(characters with voice_brief/physical/backstory context, settings with sensory_palette,
capitalized motifs) and prose-scans every chapter for [A-Z]... runs (with stopword
- sentence-starter filter), then asks the LLM to propose
{ ipa, respell, rationale }per term. Defaults toclaude-haiku-4-5at temperature 0.2 — ~0.5-2¢ per scan.
Filters:
- Pre-filter: terms already in the lexicon (case-insensitive dedupe), stopwords
(
The/And/day+month names/titles likeDr/Mrs), min 3 chars - LLM-side: terms common English readers handle correctly (
John,London) come back inskipped_commonand surface as a count, not as suggestions
UI flow: click → spinner → suggestions panel appears with a checkbox per row (all
selected by default), editable IPA + respell fields (pre-filled from LLM proposal),
source-tagged chip (emerald=character / sky=setting / fuchsia=motif / amber=prose),
occurrence count, rationale italic. "Add selected" sends the (possibly edited) subset
to bulk-add; new entries appear in the main lexicon list below + a success toast
shows Added N entries. Workflow: scan → review → accept → 🎧 preview audio to
validate any uncertain pronunciations.
bulkAddLexiconEntries per-row failures don't abort the batch — failed[] reports
collisions or malformed IPA inline so the admin can fix and retry.
Feature 3 — Character voice mapping for dialogue
The differentiator vs ElevenLabs Studio for audiobook creators. Detects attributed
dialogue ("X," said Pell., Pell said, "X.", verb-of-speaking variants like
replied / asked / shouted / whispered / etc.) in chapter prose at TTS render time,
attributes it to bible characters, routes the dialogue span to the mapped voice
— producing multi-voice acted chapters without per-line manual work.
Schema (migration 285)
season_character_voices keyed on (season_id, character_key):
character_name text— denormalized so the UI can flag "orphaned" mappings when a bible character is renamed/deleted but the mapping survivesvoice_id text— ElevenLabs voice the character speaks withvoice_direction text— optional preset (default / whispered / etc.)notes text
Super-admin RLS.
Attribution engine (character-voices.ts)
detectDialogue(text, characters)— regex on quote-then-verb-then-name and name-then-verb-then-quote patterns. Speech verbs: said, replied, asked, answered, shouted, whispered, murmured, muttered, called, cried, snapped, hissed, growled, breathed, mumbled, demanded, announced, declared, continued, added, began. Smart-quote-aware; dedupes overlaps so multiple patterns hitting the same quote attribute only once.matchCharacter(detected, characters)— case-insensitive bidirectional substring match ("Pell"matches"Auditor Pell"and vice versa); longest character.name wins so"Auditor Pell"beats"Pell"when both are candidates.splitProseByVoice(text, narratorVoiceId, narratorDirection, seasonId)— returnsVoiceSpan[]alternating narrator + character voices. Unmapped / unattributed prose falls through to narrator. 60s in-memory per-season cache mirrors the lexicon pattern.
Render integration
At both season-scoped TTS callsites: chapter audio render and segment render. Same
shape in both: per-segment TTS step calls splitProseByVoice post-lexicon. Single-span
output takes the fast path (one ttsSegment call as before). Multi-span output runs
Promise.all parallel TTS per span and concatenates via concatMp3Buffers ffmpeg
helper (concat demuxer with stream-copy — no re-encode since all spans come from the
same ElevenLabs model).
Cost: each dialogue span = one extra TTS call. A chapter with ~30% dialogue ends up ~1.3-1.5× narrator-only render cost; latency stays similar because the per-span TTS calls run in parallel.
Admin UI
🎭 Voices button in SuperAdminPanel opens
CharacterVoicesModal.tsx
— fetches bible + assignments + voice bank in parallel, renders a row per bible
character with a voice picker (saves on change), inline ▶ Preview button per row
that plays ElevenLabs' library preview for the currently-selected voice in ~3 seconds.
Surfaces "orphan assignments" section when mappings reference characters no longer in
the bible (renamed / deleted) so admin can clean up. Empty state prompts to bootstrap
the bible first when the characters list is empty.
Auto-cast (LLM bulk picker)
Toolbar at the top of the modal with ✨ Fill unassigned and Re-cast all buttons.
Both POST to /api/story-seasons/:id/character-voices/auto-cast. Service
character-voice-autocast.ts
sends the LLM the bible's active characters (name, age, mbti, voice_brief, physical,
inner_life, arc, verbal_tics) + the full VOICE_BANK with metadata + valid direction
preset keys, asks for { character_key, voice_id, voice_direction, rationale } per
character. Defaults to claude-haiku-4-5 at temperature 0.2.
Defensive validation:
- Filters out hallucinated voice IDs (LLM may pick a voice not in the catalog → reported as unassignable, never persisted)
- Falls back to
defaultdirection if the picked preset isn't a real key - Tells the LLM about voices already in use by un-recast characters so it doesn't duplicate
- Surfaces characters the LLM skipped or marked unassignable
Persists via the existing upsertCharacterVoice so cache invalidation +
(season_id, character_key) uniqueness are handled in one place. Returns
assignments WITH rationales — the modal shows a post-cast summary panel with
collapsible per-character rationales, color-coded "new" / "re-cast" badges, and an
unassignable callout for follow-up.
Workflow loop: auto-cast → 🎧 preview audio on chapter 1 → tweak any voice that sounds wrong → preview again. Cost: ~0.5-2¢ per cast.
Not in v1
- LLM-based attribution for ambiguous cases (V2 quality upgrade)
- Pronoun attribution ("he said")
- Mid-paragraph dialogue without tags
- Per-line voice direction overrides (uses the character's default)
- Pulse-layout integration (
renderIntentPulsealready has its own per-voice routing via the persona system — intentional, since pulses are inherently multi-voice chorale) - A/B "cast two ways and compare" (admin can run Re-cast all to swap, but no side-by-side diff)
Feature 4 — ACX/M4B export
Distribution unlock. Turns the platform output from "MP3 stream on
/seasons/[id]" into "single .m4b file the creator can upload to ACX (Audible),
Findaway Voices, Apple Books, or Google Play Books." Closes the loop from
suite-input (manuscript ingest) to suite-output (audiobook file ready for the world).
Schema (migration 284)
Columns on story_seasons:
acx_export_url text— public URL of the rendered M4Bacx_export_rendered_at timestamptzacx_export_status text—idle/rendering/failedacx_export_error textacx_export_duration_seconds numericacx_export_size_bytes bigint
Render pipeline (acx-export.ts)
- Pull all triggered chapters in order with their existing
chapter_audio_urls - Download each MP3 to a temp dir
- Loudness-normalize via ffmpeg's
loudnorm=I=-20:TP=-3:LRA=11(middle of ACX's -23 to -18 LUFS window with the required -3 dB true peak ceiling) - Build a
;FFMETADATA1chapter-marker file with TIMEBASE=1/1000 and cumulative START/END offsets per chapter - Single ffmpeg invocation concatenates via concat demuxer, re-encodes to AAC LC
128kbps mono 44.1kHz, embeds chapter markers + title/artist/album metadata + cover
art (poster_url pulled as
attached_picif present) - Upload to
season-assets/acx/<season_id>.m4bwithaudio/mp4content-type - Stamp the seasons row
Temp dir cleaned up in a finally block even on failure.
Routes
POST /api/story-seasons/:id/acx-export— fire-and-forget. Setsstatus='rendering'- clears prior error, kicks off
voidbackground render, returns 202. Render duration is 30s-5min depending on book length — well past any proxy timeout, hence the polling pattern.
- clears prior error, kicks off
GET /:id/acx-export— returns{ url, rendered_at, status, error, duration_seconds, size_bytes }. UI polls every 4s while status ===rendering.
Failed renders write to service_errors so the daily /errors triage flow +
Marlow's Sunday ops report both surface them.
Admin UI
📦 Export M4B button in SuperAdminPanel opens
AcxExportModal.tsx
— three states (idle / rendering with 4s poll / ready with download link + duration +
size + re-render button). Mobile-aware scroll. About-the-format disclosure lists the
spec choices.
Not in v1
- Per-chapter MP3 zip (ACX accepts both single-M4B and per-chapter-MP3 uploads — v1 ships the consumer-friendlier M4B)
- Two-pass loudnorm (single-pass lands within ACX tolerance for ~99% of TTS material since ElevenLabs is already consistently leveled)
- Auto-upload to platforms (each has its own API workflow — manual download + upload is the v1 contract)
- Pre-flight validation report
Feature 5 — Pre-flight audio preview
Closes the iteration gap between editing voice/lexicon settings and hearing the result. A full chapter render is ~10-30s + real ElevenLabs credits + a Storage upload — way too much friction for the kind of "tweak lexicon entry, re-render, listen, tweak again" loop creators actually do. Pre-flight preview takes the first N words of any chapter (default 150), runs the exact production pipeline (lexicon → split-by-voice → parallel TTS → ffmpeg concat), and streams the MP3 buffer back inline. No upload, no DB writes — close the modal and the clip evaporates.
Service (audio-preview.ts)
Single function generatePreviewAudio(seasonId, { episode_number?, max_words? }).
Pulls the season's narrator_voice_id + narrator_voice_direction; resolves
the requested chapter (or auto-picks the first chapter with prose); word-bounded
truncation to max_words (clamped 20-400, default 150); reuses the same render
helpers from season-audio.ts:
applyLexiconToText → splitProseByVoice → Promise.all(ttsSegment per span)
→ concatMp3Buffers. Returns { buffer, spans, source } — spans is the
VoiceSpan[] actually rendered (so the UI can show which voice spoke which
fragment), source describes the chapter + truncation state.
Route
POST /api/story-seasons/:id/preview-audio (super-admin) — body
{ episode_number?, max_words? }. Returns audio/mpeg binary with metadata in
response headers (so the buffer stays clean for the <audio> element):
| Header | Meaning |
|---|---|
X-Preview-Episode | Episode number actually sampled |
X-Preview-Chapter | Chapter title (URL-encoded) |
X-Preview-Words-Used | Words rendered |
X-Preview-Words-Total | Total words in the source chapter |
X-Preview-Truncated | "1" if shorter than source |
X-Preview-Spans | URL-encoded JSON: [{ voice_id, voice_direction, character_key, char_count }] |
Companion GET /api/story-seasons/:id/preview-audio/chapters returns
[{ episode_number, title, has_prose, word_count }] to populate the modal's
chapter dropdown (empty chapters render disabled so users don't waste a click).
Admin UI
🎧 Preview audio button in SuperAdminPanel opens
PreflightAudioModal.tsx
— chapter dropdown + word-count preset pills (50/100/150/200/300) + render button
with inline spinner + inline <audio controls autoPlay> element fed from a blob
URL + collapsible voice breakdown (emerald chips for character spans, gray for
narrator). Button sits between 🎭 Voices and 📦 Export M4B so the iteration
loop (edit voice mapping → preview → tweak → preview again) lives in one panel
row. Custom fetch (not the standard apiRequest) because the helper JSON-parses
the body and would clobber both the binary buffer and the header metadata. Blob
URL is revoked on next render or modal close so the page doesn't leak ~50-300KB
per preview.
Cost model
A 150-word preview is ~5-15 seconds of audio ≈ 800-1000 characters of ElevenLabs billing per pass — roughly 5% of a full chapter render. Creators can iterate dozens of times without thinking about cost.
Not in v1
- Cached previews (each click renders fresh — the whole point is to hear edits immediately)
- Split preview (just one span / character at a time)
- Voice A/B comparison (creator runs two previews back-to-back instead)
- Preview download (right-click → "save audio as" if you need a local copy)
Feature 6 — Bulk chapter render
The "and now do it for real" button. After the auto-cast voices + auto-suggest
lexicon + pre-flight preview trio sets everything up, the natural next click is
"render the whole audiobook." Bulk render walks every triggered chapter with prose
and renders each one sequentially via the existing renderChapterAudio pipeline;
per-chapter isolation means a single failed chapter doesn't abort the batch.
Service (audio-bulk-render.ts)
bulkRenderChapters(seasonId, { only_unrendered?, include_untriggered? }) iterates
chapters, calls renderChapterAudio(seasonId, epNum, { force: !onlyUnrendered })
per chapter, catches per-chapter errors (logged + stamped via the existing
chapter_audio_last_error path). Sequential rather than parallel — ElevenLabs
free + paid tiers rate-limit by concurrent requests; 12 chapters in parallel trips
429s. Cost: 12-chapter book × $0.10-0.30 per chapter ≈ $1-4 in TTS credits.
listChapterRenderStatus(seasonId) derives per-chapter state from the durable
chapter_audio_* columns (added in migration 228):
| Status | Condition |
|---|---|
rendered | has audio_url AND no newer last_error |
rendering | last_attempt_at < 5 min ago AND no audio yet AND no error |
failed | last_error is set AND no successful render after it |
pending | everything else |
The 5-min ceiling on rendering means stuck renders auto-flip to pending so
admins aren't blocked by a forever spinner.
Routes
| Method | Path | Purpose |
|---|---|---|
| POST | /:id/render-all-chapters | Fire-and-forget batch kicker (returns 202; renders 30s-15min) |
| GET | /:id/render-all-chapters/status | Polled every 4s by the modal — per-chapter status |
Both super-admin gated. Bound to pipeline_events stage 'audio' for the runs-feed
dashboard.
Admin UI
🎬 Render all button in SuperAdminPanel (novel mode only) opens
RenderAllChaptersModal.tsx:
- Summary chips: eligible / rendered / rendering / failed / pending counts + total duration
- Action row: "Force re-render all" checkbox (confirms before overwriting cached audio — expensive) + primary "Render N unrendered chapters" button
- Per-chapter list: ch + title + status pill (emerald rendered / sky animate-pulse rendering / rose failed / gray pending) + word count + duration + relative "rendered Xm ago" timestamp
- Failed chapters show inline error + per-row ↻ Retry button (fires the existing
per-chapter render route with
force: true) - Modal polls every 4s while batch is active; auto-stops polling when no chapter
is in
renderingstate
Why a discrete bulk render route exists
Alongside renderSeasonAudio (the legacy stage-4 button) for two reasons:
- The legacy path has an "abort everything if the first segment fails" guard, which is the wrong semantic for novel-mode bulk renders — we want per-chapter isolation.
- The frontend wants live per-chapter progress, which the all-or-nothing legacy path can't surface.
Not in v1
- Parallel rendering (limited to one in-flight chapter at a time to respect ElevenLabs rate limits)
- Resume from interruption (a server restart mid-batch leaves the rest as
pending— admin re-clicks "Render N unrendered chapters" to continue) - Cost estimate preview ("This will cost ~$N" before clicking) — would need a per-chapter character count + ElevenLabs pricing lookup
- Email notification on batch complete (admin watches the modal or sees the pipeline event land in the runs feed)
Feature 7 — Status overview card
Single-glance "where am I in the pipeline" consolidation. After 6 production features shipped, creator state was scattered across 4 modals + the stage strip; the overview card pulls it into one read-only summary at the top of SuperAdminPanel so creators see the current pipeline state without opening anything.
Service (audiobook-overview.ts)
getAudiobookOverview(seasonId) fans out parallel queries across 5 tables in a
single Promise.all (story_seasons, story_episodes, season_story_bibles,
pronunciation_lexicons, season_character_voices). Returns:
season: id, title, poster_url, premise, mode, narrator_voice_id + resolvednarrator_voice_namefrom VOICE_BANKchapters: total / triggered / with_prose / with_audio / total_words / total_audio_duration_secondsbible: bootstrapped + character / setting / motif countslexicon: entry_countvoices: assigned_count + unassigned_with_dialogue_count (active bible characters without a voice assignment)acx_export: status / url / rendered_at / duration / sizepipeline_steps: ordered 6-step progress strip (manuscript → bible → lexicon → voices → render → export) where each step is taggedready(✓) /in_progress(…) /todo(○) with a one-line detail
Route
GET /api/story-seasons/:id/audiobook-overview (super-admin).
Admin UI
AudiobookOverviewCard.tsx
renders:
- Cover thumbnail + title + narrator voice + readiness ratio chip
(
N/6 ready— emerald when all 6 steps areready, amber otherwise) - 6-step pipeline strip in a responsive grid (2 cols mobile, 3 cols tablet, 6 cols desktop) — each step shows the state glyph + label + one-line detail
- Stat chips row (hidden on mobile to keep card compact): chapters / words / characters / voices cast / lexicon / audio duration
Refreshes on mount + when refreshKey increments (SuperAdminPanel
passes its runsRefreshKey so the card updates whenever a stage action
lands). Read-only — no actions live here; action buttons stay in the
tools row below. The card's job is "tell me where I am" not "let me do
something new."
Not in v1
- Click-to-jump (click a pipeline step → opens the relevant modal)
- Cost breakdown per stage (manuscript / bible / TTS / etc.) — requires per-stage cost tracking that doesn't exist yet
- Inline action buttons on the card itself (intentionally not added — the tools row below is the action surface)
- Per-step ETAs ("Rendering will take ~6 min for 12 chapters")
Cross-cutting patterns
60s in-memory per-season cache
The pronunciation lexicon and character-voices services both use this pattern:
const CACHE: Map<string, CacheEntry> = new Map()
const CACHE_TTL_MS = 60_000
function invalidateCache(seasonId: string) {
CACHE.delete(seasonId)
}
async function loadCached(seasonId: string): Promise<T[]> {
const cached = CACHE.get(seasonId)
if (cached && Date.now() - cached.fetched_at < CACHE_TTL_MS) {
return cached.entries
}
const entries = await loadFromDb(seasonId)
CACHE.set(seasonId, { entries, fetched_at: Date.now() })
return entries
}
Reads on every TTS call but a 30-segment chapter render does 1 DB query, not 30. CRUD writes actively invalidate so admin-edit-then-render loops feel immediate. 60s TTL is short enough that even without active invalidation the next render picks up changes.
EpubParseResult shape (one shape, three parsers)
The EPUB, DOCX, and PDF parsers all return the same shape:
interface EpubParseResult {
title: string | null
author: string | null
language: string | null
chapters: EpubChapter[]
cover_image_base64: string | null
cover_image_mime: string | null
}
Format-agnostic downstream code (route dispatcher, preview UI, create-season endpoint) means adding a new format is just: write a parser that returns this shape, add a case to the extension dispatcher, add the extension to the UI accept list.
Split-by-voice + parallel TTS + ffmpeg concat
The character-voice render integration uses this pattern at both chapter-audio and segment-render callsites:
const spans = await splitProseByVoice(ttsText, narratorVoiceId, narratorDirection, seasonId)
let buf: Buffer
if (spans.length === 1) {
buf = await ttsSegment(spans[0].text, spans[0].voice_id, spans[0].voice_direction)
} else {
const buffers = await Promise.all(
spans.map((s) => ttsSegment(s.text, s.voice_id, s.voice_direction)),
)
buf = await concatMp3Buffers(buffers)
}
Single-span output takes the fast path. Multi-span output runs parallel TTS per span + ffmpeg concat-demuxer stream-copy (no re-encode since all spans come from the same ElevenLabs model + codec params).
SuperAdminPanel tools row
The audiobook-suite features all expose through buttons in the existing
SuperAdminPanel tools row on /seasons/[id]:
📖 Bible(existing — story-engine.md)🗣 Pronunciation— pronunciation lexicon🎭 Voices— character voice mapping🎧 Preview audio— pre-flight audio preview🎬 Render all— bulk per-chapter render (novel mode only)📦 Export M4B— ACX export- (plus existing tools — Takes, Ratings, etc.)
Each opens a modal that fetches season-scoped data on mount + saves CRUD inline.
Cost model
| Stage | Per-chapter cost | Notes |
|---|---|---|
| Manuscript parse | $0 | Pure-Node, no LLM |
| Bible bootstrap | ~$0.05 LLM | Existing — story-engine.md |
| Pronunciation lexicon (admin edit) | $0 | Pure substitution at render time |
| Auto-suggest lexicon (one-time per season) | ~$0.005-0.02 LLM | claude-haiku-4-5, scans bible + prose top-N proper nouns |
| Character voices (admin edit) | $0 | Pure attribution at render time |
| Auto-cast voices (one-time per season) | ~$0.005-0.02 LLM | claude-haiku-4-5, ~0.5-2¢ per cast |
| Audio render — narrator-only | ~$0.10-0.30 TTS | Existing — ElevenLabs |
| Audio render — with character voices | ~$0.13-0.45 TTS | +1.3-1.5× for ~30% dialogue |
| Pre-flight audio preview | ~$0.005-0.02 TTS | ~150 words; ~5% of a full chapter render |
| Bulk chapter render | ~$0.10-0.30 × N chapters TTS | Sequential to respect ElevenLabs rate limits |
| ACX export | ~$0 (ffmpeg compute only) | Re-uses existing chapter MP3s |
A typical 12-chapter audiobook through the full suite: ~$2-6 in LLM + TTS costs depending on dialogue density.
Roadmap
Phase 2 candidates
- LLM attribution for ambiguous dialogue — upgrade dialogue detection from regex-only to regex+LLM hybrid. Cost: ~$0.05-0.10 per chapter. Value: catches pronoun attributions ("he said"), mid-paragraph dialogue without tags, multi-character back-and-forth without per-line tags.
- Pre-flight audio preview — render a 30-second test clip of any chapter in any voice config without committing to a full render. Saves cost during voice iteration.
- PDF cover via canvas rasterization — for PDFs that compose their cover from
vector primitives (rare for self-published fiction). Page-1 embedded-image extraction
already ships and covers the common case; canvas rasterization needs
@napi-rs/canvasas a dep. - Per-chapter MP3 zip export — ACX accepts both single-M4B and per-chapter-MP3; some Findaway workflows prefer the latter.
- Auto-upload to platforms — direct upload via ACX / Findaway / Apple Books APIs. Each has its own auth flow.
- Two-pass loudnorm — more accurate loudness normalization for non-TTS audio sources (when human narrators are added).
- Creator dashboard surface — widen the super-admin gating to "owner of the season" so creators self-serve the full workflow without admin help.
Out of scope
- Paid tier for the suite itself (separate monetization layer)
- Audiobook metadata services (ISBN, ASIN — handled by the distribution platforms)
- DRM (audiobooks are typically DRM-free on creator-direct uploads)
Related docs
- story-engine.md — the LLM writer engine that generates prose when the creator doesn't bring a manuscript
- PORT_TO_GRAPHENE.md — Notebook-to-Season port flow (sibling ingestion path from HiveJournal notebooks)
- docs/ai/INDEX.md — full feature catalog