From 08765275543d28af34029a0b1cebc4b53535ac11 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Fri, 4 Sep 2026 11:22:15 +0200 Subject: [PATCH] refactor: extract audiobook quote segmentation --- CHANGELOG.md | 2 + TODO.md | 9 ++-- VERSION | 2 +- docs/ARCHITECTURE.md | 1 + static/index.html | 12 +++--- static/js/audiobook-segmentation.js | 63 ++++++++++++++++++++++++++++ static/js/audiobook.js | 65 ----------------------------- static/module-manifest.json | 6 +-- 8 files changed, 81 insertions(+), 79 deletions(-) create mode 100644 static/js/audiobook-segmentation.js diff --git a/CHANGELOG.md b/CHANGELOG.md index a78d9d8..791f873 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ## [Unreleased] ### Changed +- **Audiobook segmentation extraction.** Quote segmentation, conservative turn-taking, and dialogue-piece validation + now live in `audiobook-segmentation.js`; the live casting pipeline remains compatible with the same global helpers. - **Rehearser voice-design extraction.** Character development, backend selection, and backend-refresh synchronization now live in `rehearser-voice-design.js` while preserving the existing cast controls and global API. - **Voice Design stylesheet split.** Voice-design form, sample-grid, and preview-match rules now live in diff --git a/TODO.md b/TODO.md index 73d2ce2..253d207 100644 --- a/TODO.md +++ b/TODO.md @@ -97,15 +97,15 @@ architecture change: the larger Audiobook/Rehearser/style split. ### P2 — after behavior and safety stabilize -- [ ] **M — Add structured logs with request/job IDs.** Report provider, endpoint, +- [x] **M — Add structured logs with request/job IDs.** Report provider, endpoint, duration and failure reason without logging prompts, keys or book contents. - [ ] **M — Separate provider health from configuration status.** Show actionable connectivity and authentication errors. -- [ ] **M — Add visible job history and retry controls.** Expose recent failures and +- [x] **M — Add visible job history and retry controls.** Expose recent failures and safe retry state to users. -- [ ] **L — Add memory diagnostics.** Show browser heap where supported, rendered rows, +- [x] **L — Add memory diagnostics.** Show browser heap where supported, rendered rows, cache size, active jobs, server/container memory and recent operation sizes. -- [ ] **L — Add linting and duplicate-function checks.** Run them in CI alongside the +- [x] **L — Add linting and duplicate-function checks.** Run them in CI alongside the existing compile, bundle and SonarQube checks. ## P0 — Testing, data safety, and resource limits @@ -133,6 +133,7 @@ architecture change: the larger Audiobook/Rehearser/style split. - [x] Extract Rehearser cast view, sorting, filtering, and toolbar wiring into `rehearser-cast.js`. - [x] Extract Rehearser training transcript comparison helpers into `rehearser-train.js`. - [x] Extract Rehearser character development and backend-selection controls into `rehearser-voice-design.js`. +- [x] Extract Audiobook quote segmentation and conservative turn-taking into `audiobook-segmentation.js`. - [x] Extract Rehearser library persistence and IndexedDB migration into `rehearser-library.js`. - [ ] Extract Audiobook parsing and casting. - [x] Extract Audiobook parsing primitives and speaker-heuristic constants into `audiobook-parse.js`. diff --git a/VERSION b/VERSION index fb56e1f..c2ea603 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.24.37 +1.24.38 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 97273d7..994df25 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -35,6 +35,7 @@ Done incrementally as global-scope ordered files (no ES-module change needed) - ✅ `rehearser-voice-picker.js` — voice preview state and matched-choice presentation; import and assignment actions remain in `rehearser.js`. - ✅ `rehearser-cast.js` — cast view, sort/filter state handlers, and cast toolbar wiring; card rendering remains in `rehearser.js`. - ✅ `rehearser-voice-design.js` — character development, backend selection, and backend-refresh synchronization. +- ✅ `audiobook-segmentation.js` — quote segmentation, conservative turn-taking, and dialogue-piece validation. - ✅ `audiobook-parse.js` — dialogue detection, dehyphenation, and speaker-heuristic constants; the casting pipeline remains in `audiobook.js`. - ✅ `audiobook-cast.js` — cast Markdown export and legacy page-stamping migration helpers; live casting remains in `audiobook.js`. - ✅ `static/css/resource-diagnostics.css` — isolated Performance/resource diagnostic panel styles. diff --git a/static/index.html b/static/index.html index ee6ceec..7aaac00 100644 --- a/static/index.html +++ b/static/index.html @@ -10,7 +10,7 @@ - + @@ -27,10 +27,10 @@ - - - - + + + + @@ -395,7 +395,7 @@ window.toggleNavTree = function(treeId, chevronId) { - + diff --git a/static/js/audiobook-segmentation.js b/static/js/audiobook-segmentation.js new file mode 100644 index 0000000..a95fda0 --- /dev/null +++ b/static/js/audiobook-segmentation.js @@ -0,0 +1,63 @@ +// ── Audiobook quote segmentation ──────────────────────────────────────────── +// Quote-boundary segmentation and conservative turn-taking are kept separate +// from the live LLM casting workflow. Their shared quote/heuristic helpers are +// resolved when a passage is processed, preserving the legacy global API. + +function _abImplausibleDialoguePiece(text) { + const t = String(text || '').trim(); + if (!t) return true; + const words = t.split(/\s+/).filter(Boolean); + return words.length === 1 && !/[.!?…]["'’”»]?$/.test(t); +} + +function audiobookSplitByQuotes(text) { + const spans = []; + for (let i = 0; i < text.length; i++) { + const open = text[i], close = AB_QUOTE_PAIRS[open]; + if (!close) continue; + const minStart = spans.length ? spans[spans.length - 1].end : 0; + const orphan = _abOrphanClosingQuoteSpan(text, i, minStart); + if (orphan) { spans.push(orphan); i = orphan.end - 1; continue; } + if ((open === '«' || open === '»') && i > 0 && !/[\s([{—–-]/.test(text[i - 1])) continue; + const raw = text.slice(i + 1); + const closeIdx = raw.indexOf(close); + const endInRaw = _abUnclosedQuoteEnd(raw, closeIdx); + const quote = raw.slice(0, endInRaw).trim(); + if (!quote) continue; + const consumedClose = closeIdx >= 0 && endInRaw === closeIdx; + spans.push({ start: i, end: i + 1 + endInRaw + (consumedClose ? 1 : 0), quote }); + i = spans[spans.length - 1].end - 1; + } + if (!spans.length) return [{ speaker: 'Narrator', type: 'narration', text, emotion: '' }]; + const out = []; let last = 0; + for (let k = 0; k < spans.length; k++) { + const sp = spans[k], pre = text.slice(last, sp.start); + if (pre.trim()) out.push({ speaker: 'Narrator', type: 'narration', text: pre.trim(), emotion: '' }); + if (sp.quote) { + const after = text.slice(sp.end, k + 1 < spans.length ? spans[k + 1].start : text.length); + out.push({ speaker: audiobookGuessSpeaker(after, pre) || 'Unknown', type: 'dialogue', text: sp.quote, emotion: '' }); + } + last = sp.end; + } + const tail = text.slice(last).trimEnd(); + if (tail.trim()) out.push({ speaker: 'Narrator', type: 'narration', text: tail.trim(), emotion: '' }); + return audiobookTurnTaking(out); +} + +function audiobookTurnTaking(segs) { + let a = null, b = null; + for (const s of segs) { + if (s.type !== 'dialogue') continue; + if (s.speaker && !/^Unknown|Unbekannt/i.test(s.speaker)) { + if (s.speaker !== b) { a = b; b = s.speaker; } + } else if (a && b && a !== b) { + s.speaker = a; + const t = a; a = b; b = t; + } + } + return segs; +} + +window.TTVC = window.TTVC || {}; +window.TTVC.audiobook = window.TTVC.audiobook || {}; +window.TTVC.audiobook.segmentation = { splitByQuotes: audiobookSplitByQuotes, turnTaking: audiobookTurnTaking }; diff --git a/static/js/audiobook.js b/static/js/audiobook.js index 09bc5b8..0cb6093 100644 --- a/static/js/audiobook.js +++ b/static/js/audiobook.js @@ -1355,71 +1355,6 @@ function _audiobookMergeAdjacentDialogue(segments) { return { segments: out, merged }; } -function _abImplausibleDialoguePiece(text) { - const t = String(text || '').trim(); - if (!t) return true; - const words = t.split(/\s+/).filter(Boolean); - return words.length === 1 && !/[.!?…]["'’”»]?$/.test(t); -} - -function audiobookSplitByQuotes(text) { - const spans = []; - for (let i = 0; i < text.length; i++) { - const open = text[i]; - const close = AB_QUOTE_PAIRS[open]; - if (!close) continue; - const minStart = spans.length ? spans[spans.length - 1].end : 0; - const orphan = _abOrphanClosingQuoteSpan(text, i, minStart); - if (orphan) { - spans.push(orphan); - i = orphan.end - 1; - continue; - } - if ((open === '«' || open === '»') && i > 0 && !/[\s([{—–-]/.test(text[i - 1])) continue; - const raw = text.slice(i + 1); - const closeIdx = raw.indexOf(close); - const endInRaw = _abUnclosedQuoteEnd(raw, closeIdx); - const quote = raw.slice(0, endInRaw).trim(); - if (!quote) continue; - const consumedClose = closeIdx >= 0 && endInRaw === closeIdx; - spans.push({ start: i, end: i + 1 + endInRaw + (consumedClose ? 1 : 0), quote }); - i = spans[spans.length - 1].end - 1; - } - if (!spans.length) return [{ speaker: 'Narrator', type: 'narration', text, emotion: '' }]; - const out = []; let last = 0; - for (let k = 0; k < spans.length; k++) { - const sp = spans[k]; - const pre = text.slice(last, sp.start); - if (pre.trim()) out.push({ speaker: 'Narrator', type: 'narration', text: pre.trim(), emotion: '' }); - if (sp.quote) { - const after = text.slice(sp.end, k + 1 < spans.length ? spans[k + 1].start : text.length); - const speaker = audiobookGuessSpeaker(after, pre) || 'Unknown'; - out.push({ speaker, type: 'dialogue', text: sp.quote, emotion: '' }); - } - last = sp.end; - } - const tail = text.slice(last).trimEnd(); - if (tail.trim()) out.push({ speaker: 'Narrator', type: 'narration', text: tail.trim(), emotion: '' }); - return audiobookTurnTaking(out); -} - -// Fill 'Unknown' dialogue speakers by two-person alternation — but only once TWO -// distinct named speakers are established nearby (conservative: won't guess in a -// monologue, so it rarely invents a wrong name). -function audiobookTurnTaking(segs) { - let a = null, b = null; // two most recent distinct named speakers (b = latest) - for (const s of segs) { - if (s.type !== 'dialogue') continue; - if (s.speaker && !/^Unknown|Unbekannt/i.test(s.speaker)) { - if (s.speaker !== b) { a = b; b = s.speaker; } - } else if (a && b && a !== b) { - s.speaker = a; // the other of the two → alternate - const t = a; a = b; b = t; // rotate so the next Unknown alternates back - } - } - return segs; -} - function audiobookIsRouterModel(model) { return /^auto[-_ ]?router/i.test(String(model || '').trim()); } diff --git a/static/module-manifest.json b/static/module-manifest.json index 0ad2965..a786651 100644 --- a/static/module-manifest.json +++ b/static/module-manifest.json @@ -5,8 +5,8 @@ ], "lazy": { "s-rehearser": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker"], - "s-reader": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-cast", "audiobook", "character-sheets"], - "s-library": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-cast", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern"], - "s-caststudio": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-cast", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern", "studio"] + "s-reader": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook", "character-sheets"], + "s-library": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern"], + "s-caststudio": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern", "studio"] } }