refactor: extract audiobook quote segmentation

This commit is contained in:
mARTin-B78 2026-09-04 11:22:15 +02:00
parent 2fde0a176d
commit 0876527554
8 changed files with 81 additions and 79 deletions

View File

@ -8,6 +8,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased] ## [Unreleased]
### Changed ### 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 - **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. 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 - **Voice Design stylesheet split.** Voice-design form, sample-grid, and preview-match rules now live in

View File

@ -97,15 +97,15 @@ architecture change: the larger Audiobook/Rehearser/style split.
### P2 — after behavior and safety stabilize ### 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. duration and failure reason without logging prompts, keys or book contents.
- [ ] **M — Separate provider health from configuration status.** Show actionable - [ ] **M — Separate provider health from configuration status.** Show actionable
connectivity and authentication errors. 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. 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. 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. existing compile, bundle and SonarQube checks.
## P0 — Testing, data safety, and resource limits ## 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 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 training transcript comparison helpers into `rehearser-train.js`.
- [x] Extract Rehearser character development and backend-selection controls into `rehearser-voice-design.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`. - [x] Extract Rehearser library persistence and IndexedDB migration into `rehearser-library.js`.
- [ ] Extract Audiobook parsing and casting. - [ ] Extract Audiobook parsing and casting.
- [x] Extract Audiobook parsing primitives and speaker-heuristic constants into `audiobook-parse.js`. - [x] Extract Audiobook parsing primitives and speaker-heuristic constants into `audiobook-parse.js`.

View File

@ -1 +1 @@
1.24.37 1.24.38

View File

@ -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-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-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. - ✅ `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-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`. - ✅ `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. - ✅ `static/css/resource-diagnostics.css` — isolated Performance/resource diagnostic panel styles.

View File

@ -10,7 +10,7 @@
<meta name="format-detection" content="telephone=no"> <meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2563EB"> <meta name="theme-color" content="#2563EB">
<meta name="app-version" content="1.24.37"> <meta name="app-version" content="1.24.38">
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml"> <link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon.svg"> <link rel="apple-touch-icon" href="/static/icon.svg">
@ -27,10 +27,10 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── --> <!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css"> <link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.24.37"> <link rel="stylesheet" href="/static/style.css?v=1.24.38">
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.24.37"> <link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.24.38">
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.24.37"> <link rel="stylesheet" href="/static/css/background-jobs.css?v=1.24.38">
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.24.37"> <link rel="stylesheet" href="/static/css/voice-design.css?v=1.24.38">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -395,7 +395,7 @@ window.toggleNavTree = function(treeId, chevronId) {
</script> </script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton --> <!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js?v=1.24.37"></script> <script src="/static/loader.js?v=1.24.38"></script>
</body> </body>
</html> </html>

View File

@ -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 };

View File

@ -1355,71 +1355,6 @@ function _audiobookMergeAdjacentDialogue(segments) {
return { segments: out, merged }; 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) { function audiobookIsRouterModel(model) {
return /^auto[-_ ]?router/i.test(String(model || '').trim()); return /^auto[-_ ]?router/i.test(String(model || '').trim());
} }

View File

@ -5,8 +5,8 @@
], ],
"lazy": { "lazy": {
"s-rehearser": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker"], "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-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-cast", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern"], "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-cast", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern", "studio"] "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"]
} }
} }