Files
UmbraAudio/specs/14-two-phase-segmentation.md
B.D. Murphy df36dad8aa Weekly commit: attribution QA, dramatic variations, GPU TTS, AR export
Squashes ~3 weeks of uncommitted work (2026-09-03 → 09-21). The repo is the
only durable copy; a single accidental `git checkout` on routes.py nearly lost
a month of work (recovered from an editor snapshot, not git). See AGENTS.md →
"Commit Cadence" — commit every Sunday.

Attribution & QA
- New umbraaudio/analysis/attribution_qa.py: mechanical check_attribution /
  fix_high_confidence / verify_conversation_flow (speech-tag mismatch, narrator
  dialogue, role-tag guessing) plus scripts/qa_attribution.py CLI.
- llm_attributor prompt rules 3e-3h (unnamed roles, pronoun coreference,
  repeated lines) with wider scene/position context.
- Per-utterance QA box in chapter audio detail UI: hover issues, Recheck
  (auto-fix loop) and Accept (persisted qa_accepted* + text/speaker signature,
  auto re-surfaces when either changes). New POST /api/utterance/{id}/recheck
  and /api/utterance/{id}/qa-accept. _ensure_utterance_columns migration fixes
  legacy DBs that lacked the columns.

Generation
- Dramatic utterance variations: 26 emotions + pace + custom instruct,
  composed into every TTS voice mode (clone/icl/ref/embedding) and all
  generation sites; markers record the instruct so edits re-trigger.
- Staleness markers are JSON {speaker,text}; legacy plain-text markers always
  stale.
- Scene-break segmentation (generation/scene_break.py) + resegment merge.
- AR package exporter (assembly/ar_export.py) with tests.
- Voice ref resolution: rglob refs, canonical alias / first-word fallback.
- Edge-TTS fallback visibility: durable voice_engines.json ledger + UI badge.

GPU / TTS server
- qwen_tts_server: instruct + clone/icl support (voice_description →
  instruct_ids prepended to the talker input).
- qwen_tts.py remote path sends voice_description alongside voice_ref/slug.

Config / ops
- .env: UMBRA_TTS__QWEN_TTS_URL=http://localhost:8881.
- .gitignore: book projects, tmp_out, logs, one-off samples A/B demo.
- AGENTS.md + README.md: Commit Cadence (commit at least weekly, on Sunday).

New tests: test_qa_acceptance, test_variations, test_ar_export,
test_scene_break, test_resegment_merge, test_vocals_project. Suite: 327 pass
(6 pre-existing voice-profile accent-wording failures, unrelated).
2026-09-21 06:44:22 -07:00

18 KiB
Raw Permalink Blame History

Spec: Two-Phase Segmentation (Pass 2)

Overview

Pass 2 segments chapter text into utterances (narration and dialogue) and attributes speakers using a two-phase approach: a mechanical segmenter handles text splitting, and an LLM attributes speakers to all dialogue. This produces a 0.05% dialogue-as-narrator error rate (1/1,882 dialogue utterances) across all 35 chapters.

Architecture

Raw chapter text
    │
    ▼
┌─────────────────────────────────────────────────┐
│  PHASE 1: MECHANICAL SEGMENTER                  │
│  mapping/segmenter.py                           │
│                                                 │
│  - Sentence splitting at .!? (not commas)       │
│  - Dialogue detection via quotes ("'""')        │
│  - Tag-based attribution (forward/backward)     │
│  - Scene break (<<<>>>) handling                 │
│  - Chapter title detection                      │
└─────────────────────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────────────────────┐
│  PHASE 2: LLM SPEAKER ATTRIBUTION               │
│  analysis/llm_attributor.py                     │
│                                                 │
│  - Attributes ALL dialogue (overrides guesses)  │
│  - Resolves pronouns using context              │
│  - Uses character profiles from speakers.json   │
│  - Batches of 40 utterances for efficiency      │
│  - Normalizes names (aliases/first-word,        │
│    narrator lowercase, pronoun guard)           │
└─────────────────────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────────────────────┐
│  OUTPUT: Utterances table                       │
│  4,339 utterances across 35 chapters            │
│  0.05% dialogue-as-narrator (1/1,882)           │
└─────────────────────────────────────────────────┘

Components

1. Mechanical Segmenter (mapping/segmenter.py)

Class: Segmenter

class Segmenter:
    def segment(
        self,
        text: str,
        chapter_id: int,
        character_names: list[str] | None = None,
    ) -> list[dict[str, Any]]

Returns:

[{
    "index": int,
    "text": str,
    "type": "narration" | "dialogue",
    "character_name": str | None,
}, ...]

Segmentation Pipeline:

Raw text
    │
    ├── 1. _split_segments(text) → list[str]
    │       Character-by-character parser
    │       Detects quote boundaries and sentence endings
    │
    ├── 2. _build_utterances(segments) → list[dict]
    │       Tags each segment as "dialogue" or "narration"
    │       based on leading quote character
    │
    └── 3. _attribute_speakers(utterances, character_names) → list[dict]
            Forward-tracking speaker attribution
            Regex matching against character names

Quote handling: a quoted dialogue span is extracted whole and kept as one utterance even with multiple sentences. _restore_quote_wrap re-wraps it with typographic double quotes and only treats double-quote closes (", \u201d) as outer closes — trailing inner single-quotes (’) are preserved (e.g. ...named ‘Nanny.’). Previously the inner text was split back into sentences and each was re-quoted, which fragmented multi-sentence speech and mangled inner single-quotes.

Quote Detection:

  • Open: ", ', \u201c (left double), \u2018 (left single)
  • Close: ", ', \u201d (right double), \u2019 (right single)
  • Matching pairs: " → ", \u201c → \u201d, etc.

Sentence Splitting:

  • Split at ., !, ? followed by whitespace/quote/dash
  • Minimum 2 words per segment (avoids fragments)
  • Quote boundaries always create segment breaks

Speaker Attribution Algorithm:

def _attribute_speakers(self, utterances, character_names) -> list[dict]
  1. Tag Detection: Find NAME said/asked/replied patterns
  2. Forward Tracking: When tag found, set next_speaker = NAME
  3. Backward Reference: When no tag, look back for said NAME patterns
  4. Name Normalization: Case-insensitive matching, alias resolution

Speech Verbs (42 total):

said, asked, replied, whispered, shouted, muttered, called,
exclaimed, answered, began, continued, added, finished, noted,
stated, remarked, yelled, screamed, laughed, sighed, says,
tells, told, orders, ordered, demands, demanded, insists,
insisted, begs, begged, pleads, pleaded, commands, commanded,
snapped, snarled, hissed, growled, barked, roared, croaked,
grunted, murmured, purred, intoned, declared, announced,
proclaimed, responds, repeats, mentions, offers

2. LLM Speaker Attribution (analysis/llm_attributor.py)

Class: LLMAttributor

class LLMAttributor:
    def __init__(self, config: LLMConfig, model: str | None = None) -> None
    
    def attribute_speakers(
        self,
        utterances: list[dict[str, Any]],
        chapter_title: str,
        chapter_id: int,
    ) -> list[dict[str, Any]]

Key Insight: The LLM attributes all dialogue utterances. The mechanical attribution from the segmenter is preserved only as a current_speaker hint (Rule 8) — the LLM's verdict takes precedence so that ambiguous or incorrectly guessed lines can be corrected. This differs from the original design where only speaker_name == "narrator" dialogue was attributed.

Attribution Flow:

  1. Filter: Collect all dialogue utterances (type == "dialogue")
  2. Batch: Group into batches of 40 utterances
  3. Context: Split into scenes (<<<>>>); include per-scene narration names + last-12 narration lines as context
  4. LLM Call: Send batch with character profiles and scene context to LLM
  5. Parse Response: Extract JSON array with index → speaker mapping
  6. Post-Process: Strip speech verb suffixes ("says", "said", etc.); run _normalize_speaker (aliases + first-word-of-primary → primary; narrator/the-narrator → lowercase narrator; pronouns → narrator guard)
  7. Update: Apply LLM verdicts to all dialogue utterances
  8. Fallback: Re-attribute any dialogue still un-attributed via speech-tag regex on the 3 preceding utterances, then speaker carry-forward (scene breaks reset the carried speaker)

LLM Prompt (inline):

You are a dialogue attribution expert for audiobook production.

Given utterances that need speaker attribution, identify who is speaking each line.

## Character Profiles
{character_profiles}

## Chapter Title
{chapter_title}

## Scene Context
This chapter has {scene_count} scene(s) separated by scene breaks (<<<>>>).
You are attributing dialogue for: {scene_label}

{scene_speakers_context}

## Utterances to Attribute
{utterances_json}

## Rules
1. Each utterance is already correctly split — do NOT merge or split them
2. EVERY utterance MUST be attributed to a speaker. Return EXACTLY one entry per utterance index. Do not skip any.
3. ALL these utterances are dialogue (in quotes) — they are spoken by a CHARACTER, almost never "Narrator". Only use "Narrator" if the line is truly un-attributable.
3b. STRONG HINT: when a preceding narration line says a character IS telling/explaining/speaking to someone (e.g. "Nanny is telling the princess about what will happen."), the following quoted lines are that character's dialogue, until another character is indicated.
3c. A multi-line speech block from one character continues until dialogue tags, action, or the responder's lines indicate a change.
3d. A single quoted dialogue utterance may contain MULTIPLE sentences (e.g. one paragraph of speech) — every sentence within it belongs to the SAME speaker. Do not split the speaker across sentences within one utterance.
4. Use dialogue tags (X says/replies/asks) to identify speakers
5. Use context: who is present, who was speaking before, who is addressed
6. Resolve pronouns: "she says" refers to the most recently mentioned female character
7. If a character has aliases, use their EXACT full primary_name shown in the profiles (e.g. "Kainda Walters Foster Murphy", NOT "Kainda" or "Princess Kainda")
8. The "current_speaker" field shows who the mechanical segmenter guessed — use it as a hint but correct if wrong
9. In a conversation, speakers usually alternate. If one line is Character A, the next line is likely a different character (often the one addressed).
10. CRITICAL — only return the EXACT utterance indices provided in the input. Do NOT invent or hallucinate extra indices.
11. CRITICAL — every returned speaker must be the ACTUAL character speaking. Assigning the wrong speaker ruins the audiobook.
12. AI-1 is a specific AI character voice. Only attribute dialogue to AI-1 if the text explicitly indicates AI-1 is speaking. Do NOT default undetermined utterances to AI-1.

## Scene Attribution Rules (CRITICAL)
- Chapters can contain multiple scenes separated by scene breaks (<<<>>>)
- Each scene is an independent unit. Speakers identified in one scene may NOT appear in other scenes
- Within a scene, once a speaker is identified, subsequent dialogue by the same person continues until another speaker is identified
- "captain" in one scene may refer to a different person than "captain" in another scene — there can be multiple characters with the same title
- Only attribute a speaker to dialogue if there is evidence within THAT SCENE that they are speaking
- If a scene introduces new characters via narration, those characters are candidates for dialogue in that scene
- A speaker who appears in scene 1 may be entirely absent from scene 2 — do NOT carry speakers across scene boundaries
- When ambiguous ("he says", "she says"), resolve using the most recent speaker mention within the SAME SCENE

## Output Format
Return ONLY a JSON array of objects, one per utterance, each with:
- "index": the utterance index (number)
- "speaker": the correct speaker name (use primary_name from profiles, MUST be a character name, NOT "Narrator")

Return ONLY the JSON array, no other text.

Performance:

  • ~5 seconds per batch of 40 utterances with google/gemma-4-12b-qat (~34 tok/s on the remote LM Studio server)
  • The model must be loaded with context_length: 8192 (default 262k-token context requests ~44.9 GB of memory and blocks loading)
  • ~20-30 minutes for all 35 chapters; every dialogue utterance is processed (not just unattributed ones)

3. Scene Break Handling

Scene Breaks (<<<>>>):

  • 51 scene breaks across the book
  • Marked as special utterances: {"speaker": "scene_break", "text": "<<<>>>"}
  • Within a scene: track which characters are present
  • At a scene break: re-evaluate present characters
  • Chapter context carries across scene breaks

LLM Context:

  • Full chapter text with <<<>>> preserved
  • LLM maintains context across scene breaks
  • Can alter character presence between scenes

4. Sentence Splitting Rules

Correct Behavior:

  • Split at sentence endings: . ! ? (narration only)
  • Do NOT split at commas
  • Narration: each sentence = separate utterance
  • Quoted dialogue: the whole quoted span = ONE utterance, even when it contains multiple sentences (all sentences share the same speaker — Rule 3d)

Examples:

"I'm on a yacht." → ["I'm on a yacht."]
"That is not something..." → ["That is not something..."]
"Help! Open the latch." → ["Help! Open the latch."]  # one utterance, one speaker

5. Speaker Name Resolution

Every LLM-returned speaker passes through _normalize_speaker before being stored:

Aliases → Primary Names:

  • "Princess", "Kainda", "the princess" → "Kainda Walters Foster Murphy"
  • "Captain", "Captain Wake" → "Willamina Wake"
  • "Hunter", "Ship AI", "The Deal Hunter" → "Deal Hunter" (the ship's AI — one speaker)

First-word-of-primary mapping: the first word of each primary name maps to the full primary name when it is not already an alias (e.g. "kainda" → "Kainda Walters Foster Murphy").

Narrator lowercase: "narrator" / "the narrator" → canonical lowercase narrator (the DB and generation code check this exact case).

Pronoun guard: pronouns ("she", "he", "they", "her", etc.) → narrator; a bare pronoun is an LLM failure, never a character.

LLM Post-Processing:

  • Strip speech verb suffixes: "Kainda says" → "Kainda"
  • Handle group speakers: "Management Group", "Guard Detail Members"
  • Handle AI speakers: "Deal Hunter" (alias "Hunter"/"Ship AI"), "AI-1"
  • Unknown names are kept as-is and surfaced on the orphaned-utterances page for review

Output

Utterances are saved to the utterances table in project.db:

INSERT INTO utterances (
    chapter_id,
    utterance_index,
    text,
    type,  -- "narration" | "dialogue"
    speaker_name  -- "narrator" | "Princess Kainda" | "scene_break" | etc.
) VALUES (?, ?, ?, ?, ?)

Quality Metrics

Metric Value
Total utterances 4,339
Dialogue utterances 1,882
Narration utterances 2,453
Dialogue-as-narrator errors 1 (0.05%)
Scene breaks 51
Chapters processed 35

Error Analysis

Remaining 0.05% Error:

  • 1 dialogue utterance still attributed to narrator (ch35 "About the author" back matter — expected, no speaker is present)
  • Edge cases where the LLM and mechanical segmenter both fail
  • Acceptable for production use

Common Failure Modes:

  1. Missing dialogue tags with no context
  2. Multiple speakers in rapid succession
  3. Internal thoughts (should be narrator, sometimes misattributed)
  4. Indirect speech ("she said that...") vs direct speech
  5. Bare pronoun speakers ("She") — caught by the pronoun guard, which maps them to narrator

Validation

Run after any re-segment or re-attribute to confirm the quality gates hold:

-- Dialogue left as narrator (expect only the ch35 back-matter exception)
SELECT chapter_id, COUNT(*) FROM utterances
WHERE type = 'dialogue' AND lower(speaker_name) = 'narrator'
GROUP BY chapter_id;

-- Pronoun speakers (must be 0 — guard maps these to narrator)
SELECT COUNT(*) FROM utterances
WHERE type = 'dialogue' AND lower(speaker_name) IN
  ('i','me','my','you','your','he','him','his','she','her',
   'they','them','their','we','us','our','it','its','one');

-- Narrator case (must be lowercase 'narrator', never 'Narrator')
SELECT COUNT(*) FROM utterances WHERE speaker_name = 'Narrator';

-- Unknown speakers (cross-check against speakers.json aliases/first-words;
-- leftovers appear on the /orphaned-utterances page)
SELECT DISTINCT speaker_name FROM utterances
WHERE type = 'dialogue' AND lower(speaker_name) != 'narrator';

Additional checks:

  • ch13 golden reference: 44 utterances — 15 Kainda / 15 Nanny / 14 narrator (verified against the manuscript, e.g. "Fifty light years" is Kainda, "Don't help Princess Kainda" is Nanny)
  • Multi-sentence quotes: spot-check that a paragraph of quoted speech is ONE dialogue utterance with a single speaker (Rule 3d)
  • Alternation: dialogue should alternate between the characters present; a long run of one speaker with no tag is suspicious

Scripts

resegment_all.py

# Run on all chapters with LLM attribution
python scripts/resegment_all.py --model google/gemma-4-12b-qat

# Run on specific chapters
python scripts/resegment_all.py --chapters 5,6,7 --model google/gemma-4-12b-qat

# Mechanical-only (no LLM)
python scripts/resegment_all.py --mechanical-only

reattribute_all.py

Re-runs LLM speaker attribution on existing utterances without re-segmenting (preserves utterance IDs and mechanical segmentation; only updates speaker_name):

# All chapters (--sleep paces requests to the remote server)
python scripts/reattribute_all.py --model google/gemma-4-12b-qat --sleep 0.2

# Specific chapters
python scripts/reattribute_all.py --chapters 5,6,7 --model google/gemma-4-12b-qat

Note: before running attribution, ensure the remote LM Studio server has the model loaded with context_length: 8192:

curl -X POST http://192.168.123.14:1234/api/v1/models/load \
  -H 'Content-Type: application/json' \
  -d '{"model": "google/gemma-4-12b-qat", "context_length": 8192, "flash_attention": true, "echo_load_config": true}'

Key Design Decisions

  1. Two-phase over one-phase: Mechanical segmenter handles text splitting reliably; LLM only does attribution
  2. LLM attributes all dialogue: The LLM's verdict overrides the mechanical current_speaker guess so ambiguous lines get corrected (not just the unattributed ones)
  3. Quotes kept whole: Multi-sentence quoted speech is one utterance; splitting it loses the single-speaker evidence
  4. Inner-quote preservation: _restore_quote_wrap only recognizes double-quote closes so trailing inner single-quotes (’) survive re-wrapping
  5. Name normalization: Aliases and first-word-of-primary map to canonical primary names; narrator stays lowercase; pronouns map to narrator (no fabricated speakers)
  6. Deterministic fallback: Speech-tag regex + speaker carry-forward guarantee un-attributed dialogue still gets a speaker
  7. Batch processing: 40 utterances per LLM call for efficiency
  8. Scene break preservation: <<<>>> markers maintained as special utterances; they reset the carried speaker
  9. google/gemma-4-12b-qat model: ~34 tok/s on the remote LM Studio server, must be loaded with context_length: 8192 (~44.9 GB context otherwise blocks loading)