tts-voice-creator-clone-and.../architecture/audiobook-attribution.md
mARTin-B78 37e2622d4e feat(audiobook): advisory verify and span-native recast for span casts
Verify / recast now run span-natively (no text re-emission): a second differently-windowed
pass marks lines agreed/disputed and records the other answer, without changing any speaker
(~9 min vs ~49 min). Measured on the real pipeline, overruling disputed lines with a reasoning
adjudication made them worse (66.5% -> 61.4%), so that step was removed; agreed lines were 83%
right vs 48.5% for disputed ones, which is the signal used for confidence and review.
Auto-repair no longer runs after a span cast; segments carry a spanId.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 20:17:38 +02:00

7.8 KiB
Raw Blame History

SOP — Audiobook speaker attribution (span mode)

Status: implemented, default on. Switch off with localStorage['ab-span-mode'] = '0' (the classic whole-passage engine then runs). A book with no recognisable quotation style automatically uses the classic engine.

Why it changed

The classic engine sends ~3,000-character passages to an LLM and asks it to re-emit the whole passage as JSON segments (/api/attribute-dialogue). That has three costs:

  • Text corruption. The model fuses, duplicates or drops text; a stack of post-processing passes (dedupe, recover-dropped-text, prefix-dup cut, orphan-quote repair) exists only to undo it. Measured on a hand-corrected book: narration/dialogue typing agreed on 93.6 % of words.
  • Hours per book. Output tokens ≈ input tokens (a 4 h run for one novel).
  • Unmeasurable. Exact-text matching counts any boundary difference as an error.

Design

book text ──► segmentByQuotes()      deterministic (static/js/audiobook-spans.js)
              narration / dialogue spans with exact source offsets
          ──► buildWindows()         ~2.4–3.6k chars per LLM call, cut at paragraphs
          ──► POST /api/attribute-spans   LLM answers ONLY {id, speaker, emotion} per dialogue span
          ──► assemble segments      text is taken from the spans, never from the LLM
          ──► existing finalisation  phantom drop, role→name aliases, name consolidation,
                                     confidence, autosave, Rehearser save, auto-repair

The LLM can no longer change, drop, duplicate or fuse a single word; it can only choose a speaker, so its output is a few tokens per dialogue line.

Segmenter rules (audiobook-spans.js)

  • One quote style per book, chosen by counting well-formed pairs (»…« German inward, „…“, “…”, «…» French). No style with ≥ 5 pairs → classic engine.
  • A closing mark with no opener (opener lost in PDF extraction): the speech starts at the earliest run of speech-like sentences directly before it (1st/2nd-person pronouns, ?, short !).
  • An opener whose closer is lost: the speech ends after the leading run of speech-like sentences.
  • A speech continues across paragraph breaks while a closing mark still follows before the next opener (some typesetting re-opens every paragraph, some only closes the last).
  • A quote that is a term/title inside a sentence (no sentence-final punctuation, lower-case continuation) is narration.
  • Adjacent narration merges; each quote stays its own dialogue span.

POST /api/attribute-spans

Request: {spans:[{id, kind, text}], known_characters, recent, following, language, llm_url, model, timeout_seconds, want_reasoning}. Ids are N<n> / D<n>; kind must match the id prefix; ≤ 400 spans, ≤ 20 000 chars per span, ≤ 60 000 total, at least one dialogue span (HTTP 400 otherwise). Response: {speakers:{id:{speaker, emotion}}, missing:[ids], characters:[names]}, or {failed:true, error, missing:[…]} (HTTP 200) when the LLM is unavailable — the client then falls back to speech-tag guessing. The text is never returned.

Behaviour: thinking is disabled by default (chat_template_kwargs.enable_thinking=false, retried without the key on HTTP 400/422). Measured: reasoning mode was ~25× slower with no accuracy gain on the same windows. An answer that parses to nothing is retried once with a larger token budget; lines the model skipped are retried once on their own. Calls are serialised with every other attribution call.

Naming rule: the model answers with the label the text uses ("Der Fremde", "Baronin", "Oberst") when a person is only referred to by role in the scene, instead of guessing another known character's name. Role → name resolution then happens book-wide in the existing _audiobookResolveRoleAliases / _audiobookConsolidateSpeakerAliases passes.

Measuring

scripts/score_attribution.py <manual.json> <auto.json> [--characters <manual chars>] aligns the two casts word by word (insensitive to chunking) and reports coverage, narration/dialogue type agreement, dialogue speaker accuracy (strict and alias-tolerant) and the largest confusions. Read it with care: the human references contain label noise (inline narration folded into the neighbouring dialogue, inconsistent handling of very long monologues, occasional wrong speakers), so differences of ~1–2 points are noise.

Book 1 (Der Sturm), same reference, same scorer:

classic (Run 3) span mode
type agreement 93.6 % 97.4 %
dialogue words, right speaker (tolerant) 60.6 % 75.4 %
overall every word right (tolerant) 87.0 % 92.2 %
wall time ≈ 4 h ≈ 11 min
Unknown-speaker words 16 114

Verify and recast (span casts)

Workflow step 3 (Recast unknown), step 4 (2nd Quality Run) and auto-repair all enter through audiobookRecastUnknown; for a span cast they are routed to audiobookVerifySpans (static/js/audiobook-cast-spans.js) instead of the classic passes, which would have the LLM re-emit text. Segments carry their spanId, so a cast maps back to the deterministic spans; a cast that does not (classic cast, or a different text) is declined and the classic path runs.

  • 2nd Quality Run (Verify) is advisory. One extra no-thinking pass with different window boundaries (5000/7000 chars vs 2400/3600), about as long as the first cast (~10 min per novel). It never changes a speaker. Lines both passes agree on get verified: 'agreed'; where they disagree the first answer is kept, the line gets verified: 'disputed' and the other pass's answer is stored as altSpeaker (shown in the confidence explanation); a line the first pass left Unknown stays Unknown (unresolved, with the other pass's suggestion). Confidence: agreed +10, disputed −14. Nothing is applied until the pass finished, so a stopped or failed run leaves the cast unchanged.
  • Recast unknown (unknownOnly): the grammar pre-pass first, then one reasoning question per cluster of remaining Unknown lines (fuller context, attribute_only so the rest of the window is context). A line the model still cannot place stays Unknown — never invented. Auto-repair after a span cast is off: Unknown lines stay visible for review.

What was measured, and what was rejected

Book 1, alias-tolerant, real in-app runs on the KONTROLLE document:

words first pass after
lines both passes agreed on 18 442 82.0 % 82.0 % (unchanged)
lines the passes disputed, overruled by a reasoning question restricted to the two candidates 3 203 66.5 % 61.4 %
Unknown lines "resolved" by the second pass or reasoning 128 0 % 3 %

An earlier offline experiment suggested adjudication helped (56 % → 72 % on a sample of disagreeing lines) but compared against a biased baseline; on the real pipeline it made the lines it changed worse, so the adjudication step (and the candidates request field) was removed. The only reliable signal is agreement itself: agreed lines are right 82 % of the time, contested first answers 67 %. Sampling matters: windows of ~1 200 characters were clearly worse (−4 points) and ~5 000 about equal to the default, so the default stays.

Request additions: attribute_only [ids] — the other spans are context; unrequested lines are never reported missing.

Security / privacy

Book text is sent to the configured LLM exactly as before (same endpoint, same settings); the server never returns text. Inputs are validated at the boundary (see limits above). No new credentials, storage or network destinations.

Rollback

localStorage['ab-span-mode'] = '0' restores the classic engine per browser; removing the dispatch at the top of audiobookCast() (static/js/audiobook-pipeline.js) removes it for everyone. Casts made by either engine are the same shape and remain readable by the other.