Split the book into narration/dialogue spans from its own quotation marks and ask the LLM only who speaks each dialogue span (POST /api/attribute-spans), so text can no longer be fused, dropped or duplicated and a novel takes ~10 min instead of ~4 h. Book 1 vs the hand-corrected reference: every-word-right 87.0% -> 92.3%, type agreement 93.6% -> 97.4%; Books 2/3 (not tuned on) 90.8% / 89.9%. - static/js/audiobook-spans.js (pure, unit-tested) + audiobook-cast-spans.js - core/attribution_spans.py, route, tests; scripts/score_attribution.py - default on; localStorage ab-span-mode=0 restores the classic engine - architecture/audiobook-attribution.md, Manual, CHANGELOG 1.32.0, TODO Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
5.2 KiB
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 |
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.