<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: elenaashford</title>
    <description>The latest articles on DEV Community by elenaashford (@elenaashford).</description>
    <link>https://dev.to/elenaashford</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4046017%2F9f50209a-093c-4a7e-bd3b-9a4fec724392.png</url>
      <title>DEV Community: elenaashford</title>
      <link>https://dev.to/elenaashford</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/elenaashford"/>
    <language>en</language>
    <item>
      <title>Stitching Hundreds of TTS Segments Into an Audiobook Without Seams</title>
      <dc:creator>elenaashford</dc:creator>
      <pubDate>Sat, 25 Jul 2026 08:08:36 +0000</pubDate>
      <link>https://dev.to/elenaashford/stitching-hundreds-of-tts-segments-into-an-audiobook-without-seams-38g5</link>
      <guid>https://dev.to/elenaashford/stitching-hundreds-of-tts-segments-into-an-audiobook-without-seams-38g5</guid>
      <description>&lt;p&gt;Character attribution gets all the attention in multi-voice audiobook work, and fair enough — it's the interesting ML problem. But what makes a generated audiobook feel amateur is almost never the voice casting. It's the seams. A click between paragraphs. A chapter that starts mid-copyright-notice. Volume that jumps when a different speaker takes over. Losing your place because the app re-generated and every segment boundary shifted.&lt;/p&gt;

&lt;p&gt;That's assembly engineering. It's unglamorous, and it's where perceived quality actually lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  EPUB structure lies to you
&lt;/h2&gt;

&lt;p&gt;An EPUB is a zip with an OPF manifest, a &lt;code&gt;spine&lt;/code&gt; giving reading order, and usually a nav document (&lt;code&gt;nav.xhtml&lt;/code&gt; in EPUB 3, &lt;code&gt;toc.ncx&lt;/code&gt; in EPUB 2). Naively, spine items are chapters. They are not.&lt;/p&gt;

&lt;p&gt;Real-world breakage, all of which I've hit: one spine item containing &lt;strong&gt;five chapters&lt;/strong&gt; (common in older conversions where the whole book is &lt;code&gt;book.xhtml&lt;/code&gt;); one chapter &lt;strong&gt;split across four spine items&lt;/strong&gt; because the converter chunked on file size; spine entries that are cover images, blank pages, or ads for the publisher's other titles; and nav docs pointing at &lt;strong&gt;fragment IDs&lt;/strong&gt; (&lt;code&gt;chapter3.xhtml#ch3&lt;/code&gt;), so chapter boundaries live &lt;em&gt;inside&lt;/em&gt; files rather than at file boundaries.&lt;/p&gt;

&lt;p&gt;So chapter detection is a merge of three signals, in priority order: nav document entries (including fragments), heading structure in the content, and spine order as fallback.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;detect_chapters&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;epub&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;nav&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_nav&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;epub&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                    &lt;span class="c1"&gt;# strongest signal
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;nav&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nav&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;split_at_anchors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;epub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nav&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# fall back to headings across the flattened spine
&lt;/span&gt;    &lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;flatten_spine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;epub&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;heads&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headings&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;level&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;plausible_chapter_rhythm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;heads&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;split_at&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;heads&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;Chapter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;from_spine&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;epub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;spine&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;plausible_chapter_rhythm&lt;/code&gt; is a sanity check worth writing: if "chapters" average 200 words, you split on scene-break headings. If one is 60% of the book, you missed the real boundaries. Reject and fall through.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Front and back matter&lt;/strong&gt; need explicit handling because nobody wants to listen to a copyright page. Classify by position plus title matching plus length: leading items titled cover/title/copyright/dedication, trailing items titled acknowledgments/about the author/index. Don't silently delete them — mark them &lt;code&gt;skippable&lt;/code&gt; and default them off. Some listeners want the author's note. Nobody wants ISBN digits read aloud.&lt;/p&gt;

&lt;p&gt;The other thing that must not reach TTS: stranded page numbers, running headers repeated at every page break, and footnote markers. &lt;code&gt;The result was clear.7&lt;/code&gt; becomes "the result was clear seven," and it sounds broken every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stitching without seams
&lt;/h2&gt;

&lt;p&gt;You will generate hundreds of segments — sentence or paragraph level, sometimes across different voices. Concatenating them is where three specific artifacts come from.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Sample rate mismatch.&lt;/strong&gt; Different voices, or the same engine on a different day, can hand back different sample rates. Concatenating 24kHz and 22.05kHz PCM without resampling produces pitch-shifted, chipmunked segments. Normalize &lt;em&gt;everything&lt;/em&gt; on ingest — decode to float32, resample to one working rate, consistent channel count — and only encode at the very end.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Clicks at boundaries.&lt;/strong&gt; A click is a discontinuity: segment A ends at amplitude 0.3, segment B starts at -0.2, and that instantaneous jump is broadband noise your ear hears as a tick. Fix by trimming to zero-crossings and applying a short fade:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;FADE_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;   &lt;span class="c1"&gt;# inaudible as a fade, sufficient to kill the click
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sr&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;trim_trailing_silence&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;threshold_db&lt;/span&gt;&lt;span class="o"&gt;=-&lt;/span&gt;&lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;trim_leading_silence&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;threshold_db&lt;/span&gt;&lt;span class="o"&gt;=-&lt;/span&gt;&lt;span class="mi"&gt;45&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;apply_fade_out&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;FADE_MS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;apply_fade_in&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="n"&gt;ms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;FADE_MS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Trim the engine's own leading and trailing silence first — it's inconsistent between segments, and leaving it in produces erratic gaps that read as hesitation. Then re-insert silence &lt;em&gt;deliberately&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Wrong pacing.&lt;/strong&gt; Silence is punctuation. Fixed gaps everywhere sound robotic; no gaps sound breathless. What has worked for me:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;within a paragraph, sentence to sentence   ~250-400 ms
paragraph to paragraph                     ~600-900 ms
scene break (blank line / *** )            ~1200-1500 ms
speaker change in dialogue                 ~150-300 ms  (tighter!)
chapter boundary                           ~1500-2000 ms + optional title read
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Speaker changes being &lt;em&gt;tighter&lt;/em&gt; than paragraph breaks is the counterintuitive one. Real dialogue overlaps and interrupts; a long pause between two characters trading lines destroys the sense of exchange. Multi-voice with generous gaps sounds like two people reading in separate rooms — because that's literally what it is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Loudness normalization is not peak normalization.&lt;/strong&gt; Different voices have genuinely different perceived loudness at the same peak amplitude, and peak-normalizing each segment makes it worse. Use an integrated loudness measure (EBU R128 / LUFS), and measure per &lt;em&gt;voice&lt;/em&gt; across a whole chapter rather than per segment — per-segment normalization flattens intentional dynamics and makes quiet lines shout. Around −18 LUFS integrated with true peak under about −1.5 dBTP is a reasonable starting point for speech, but audiobook distributors publish their own required windows, so check before mastering.&lt;/p&gt;

&lt;p&gt;Getting this stack right end-to-end — EPUB parse, front-matter classification, per-voice loudness, silence grammar — is most of the work behind &lt;a href="https://voxforge.app" rel="noopener noreferrer"&gt;VoxForge&lt;/a&gt;; the character casting was genuinely the easier half.&lt;/p&gt;

&lt;h2&gt;
  
  
  Playback position that survives regeneration
&lt;/h2&gt;

&lt;p&gt;Last one, and it's the one that gets discovered too late to fix cheaply.&lt;/p&gt;

&lt;p&gt;If your playback position is a byte offset or an absolute timestamp into a concatenated file, it is invalid the moment anything upstream changes. Re-cast one character, fix one mispronunciation, swap a TTS model — every downstream offset shifts and your listener is thrown to the wrong place in a nine-hour file.&lt;/p&gt;

&lt;p&gt;Anchor position to the &lt;strong&gt;text&lt;/strong&gt;, not the audio:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"chapter_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ch07"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"segment_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ch07-p012-s03"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"offset_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1420&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"text_hash"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"9f2ab1c4"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Segment IDs derive from document structure, so they're stable across regeneration. On resume: look up the segment, seek to its current start, add the offset. If &lt;code&gt;text_hash&lt;/code&gt; doesn't match, the text itself changed — seek to segment start and accept losing a couple of seconds rather than landing somewhere arbitrary.&lt;/p&gt;

&lt;p&gt;This same index is what makes chapter navigation, sentence-level scrubbing, and read-along highlighting possible later. Build it during assembly. Reconstructing text↔audio alignment afterward means forced alignment over hours of audio, and you'll wish you had written the offsets down while you had them.&lt;/p&gt;

</description>
      <category>audio</category>
      <category>python</category>
      <category>programming</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Multi-Voice Audiobooks: The Character Attribution Problem</title>
      <dc:creator>elenaashford</dc:creator>
      <pubDate>Fri, 24 Jul 2026 19:02:22 +0000</pubDate>
      <link>https://dev.to/elenaashford/multi-voice-audiobooks-the-character-attribution-problem-2dif</link>
      <guid>https://dev.to/elenaashford/multi-voice-audiobooks-the-character-attribution-problem-2dif</guid>
      <description>&lt;p&gt;Turning a novel into a multi-voice audiobook sounds like a TTS problem. It is mostly a parsing problem, and the parsing is harder than the synthesis.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attribution is not solved by quotation marks
&lt;/h2&gt;

&lt;p&gt;The naive approach: find text in quotes, that is dialogue, assign a voice. It breaks immediately on real prose.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"I told you," she said, "and you did not listen."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One utterance, split across two quoted spans, with the attribution wedged in the middle. Naive splitting produces two separate lines and often assigns them different speakers.&lt;/p&gt;

&lt;p&gt;Then there is the harder case — no attribution at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"Are you coming?"
"In a minute."
"You said that an hour ago."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three lines, zero speaker tags. A human tracks the alternation from context. A parser has to model turn-taking, and it has to know when the alternation breaks because a third character walked in.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually helps
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Attribution verb lexicons beat regex.&lt;/strong&gt; &lt;code&gt;said / asked / whispered / muttered&lt;/code&gt; plus their inflections cover the majority of tagged dialogue. The pattern is usually &lt;code&gt;"..." &amp;lt;verb&amp;gt; &amp;lt;entity&amp;gt;&lt;/code&gt; or &lt;code&gt;&amp;lt;entity&amp;gt; &amp;lt;verb&amp;gt;, "..."&lt;/code&gt; — worth handling both orderings explicitly rather than hoping one regex catches everything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Coreference is unavoidable.&lt;/strong&gt; "she said" only resolves if you know who "she" is in this scene. Without a coref pass you get correct attribution to the wrong voice, which sounds worse than a monotone read because listeners notice the inconsistency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Alternation as a prior, not a rule.&lt;/strong&gt; In untagged runs, alternating between the last two active speakers is right most of the time. Treat it as a default that named attribution overrides — not as ground truth.&lt;/p&gt;

&lt;h2&gt;
  
  
  The consistency constraint nobody warns you about
&lt;/h2&gt;

&lt;p&gt;Once a voice is assigned to a character, it must stay assigned. Chapter 14 cannot re-cast the protagonist because your attribution confidence dipped.&lt;/p&gt;

&lt;p&gt;That means voice assignment is a &lt;strong&gt;document-level&lt;/strong&gt; decision made after full-text analysis, not a streaming one made per chapter. Which in turn means you cannot start rendering audio until you have parsed the entire book — an architectural constraint that surprises people building this incrementally.&lt;/p&gt;

&lt;p&gt;Practical shape that works:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Full-text parse -&amp;gt; character inventory with mention counts&lt;/li&gt;
&lt;li&gt;Assign voices to the top N by frequency, narrator to everything else&lt;/li&gt;
&lt;li&gt;Persist the mapping&lt;/li&gt;
&lt;li&gt;Render chapters against the frozen mapping&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I built this pipeline into &lt;a href="https://voxforge.app" rel="noopener noreferrer"&gt;VoxForge&lt;/a&gt; — epub or txt in, auto-cast characters, chaptered audiobook out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it still fails
&lt;/h2&gt;

&lt;p&gt;First-person narration where the narrator is also a character. Epistolary novels. Books that switch POV mid-chapter without a section break. All three break the character-inventory assumption, and I have not found a clean general solution — currently they need manual overrides.&lt;/p&gt;

</description>
      <category>python</category>
      <category>ai</category>
      <category>audio</category>
      <category>nlp</category>
    </item>
  </channel>
</rss>
