features

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)

#FeatureMigrationServiceUI
1Manuscript ingestion (EPUB)epub-parser.ts/dashboard/admin/manuscripts/new
1Manuscript ingestion (DOCX)docx-parser.ts(same page)
1Manuscript ingestion (PDF)pdf-parser.ts(same page)
1Cover-image auto-importuploadCoverImage in season-media.ts(same page)
2Pronunciation lexicon283pronunciation-lexicon.tsPronunciationLexiconModal.tsx
3Character voice mapping285character-voices.tsCharacterVoicesModal.tsx
4ACX/M4B export284acx-export.tsAcxExportModal.tsx
5Pre-flight audio previewaudio-preview.tsPreflightAudioModal.tsx
6Bulk chapter renderaudio-bulk-render.tsRenderAllChaptersModal.tsx
7Status overview cardaudiobook-overview.tsAudiobookOverviewCard.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:

  1. <w:pStyle w:val="Heading1"/> paragraphs — Word's "Heading 1" outline style. Highest-signal split for authors who use Word's outliner.
  2. <w:br w:type="page"/> page breaks — common in manuscripts that skip heading styles.
  3. 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 time
  • respell text — plain-text alternative (e.g. ih-LAR-ah); literal substitution
  • notes 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 render
  • season-audio.ts renderSeasonAudio — segment render
  • renderIntentPulse — multi-voice Breakout-framework chorale, each voice fragment passes through applyLexiconToText before 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 to claude-haiku-4-5 at 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 like Dr/Mrs), min 3 chars
  • LLM-side: terms common English readers handle correctly (John, London) come back in skipped_common and 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 survives
  • voice_id text — ElevenLabs voice the character speaks with
  • voice_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) — returns VoiceSpan[] 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 default direction 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 (renderIntentPulse already 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 M4B
  • acx_export_rendered_at timestamptz
  • acx_export_status textidle / rendering / failed
  • acx_export_error text
  • acx_export_duration_seconds numeric
  • acx_export_size_bytes bigint

Render pipeline (acx-export.ts)

  1. Pull all triggered chapters in order with their existing chapter_audio_urls
  2. Download each MP3 to a temp dir
  3. 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)
  4. Build a ;FFMETADATA1 chapter-marker file with TIMEBASE=1/1000 and cumulative START/END offsets per chapter
  5. 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_pic if present)
  6. Upload to season-assets/acx/<season_id>.m4b with audio/mp4 content-type
  7. 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. Sets status='rendering'
    • clears prior error, kicks off void background render, returns 202. Render duration is 30s-5min depending on book length — well past any proxy timeout, hence the polling pattern.
  • 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: applyLexiconToTextsplitProseByVoicePromise.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):

HeaderMeaning
X-Preview-EpisodeEpisode number actually sampled
X-Preview-ChapterChapter title (URL-encoded)
X-Preview-Words-UsedWords rendered
X-Preview-Words-TotalTotal words in the source chapter
X-Preview-Truncated"1" if shorter than source
X-Preview-SpansURL-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):

StatusCondition
renderedhas audio_url AND no newer last_error
renderinglast_attempt_at < 5 min ago AND no audio yet AND no error
failedlast_error is set AND no successful render after it
pendingeverything else

The 5-min ceiling on rendering means stuck renders auto-flip to pending so admins aren't blocked by a forever spinner.

Routes

MethodPathPurpose
POST/:id/render-all-chaptersFire-and-forget batch kicker (returns 202; renders 30s-15min)
GET/:id/render-all-chapters/statusPolled 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 rendering state

Why a discrete bulk render route exists

Alongside renderSeasonAudio (the legacy stage-4 button) for two reasons:

  1. 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.
  2. 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 + resolved narrator_voice_name from VOICE_BANK
  • chapters: total / triggered / with_prose / with_audio / total_words / total_audio_duration_seconds
  • bible: bootstrapped + character / setting / motif counts
  • lexicon: entry_count
  • voices: assigned_count + unassigned_with_dialogue_count (active bible characters without a voice assignment)
  • acx_export: status / url / rendered_at / duration / size
  • pipeline_steps: ordered 6-step progress strip (manuscript → bible → lexicon → voices → render → export) where each step is tagged ready (✓) / 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 are ready, 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

StagePer-chapter costNotes
Manuscript parse$0Pure-Node, no LLM
Bible bootstrap~$0.05 LLMExisting — story-engine.md
Pronunciation lexicon (admin edit)$0Pure substitution at render time
Auto-suggest lexicon (one-time per season)~$0.005-0.02 LLMclaude-haiku-4-5, scans bible + prose top-N proper nouns
Character voices (admin edit)$0Pure attribution at render time
Auto-cast voices (one-time per season)~$0.005-0.02 LLMclaude-haiku-4-5, ~0.5-2¢ per cast
Audio render — narrator-only~$0.10-0.30 TTSExisting — 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 TTSSequential 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/canvas as 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)
  • 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
Audiobook Suite — Docs | HiveJournal