From c0ac5dd77535e183ef7904234cb5cbabdff64aba Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Fri, 4 Sep 2026 11:08:49 +0200 Subject: [PATCH] refactor: extract audiobook cast serialization --- CHANGELOG.md | 2 ++ TODO.md | 1 + VERSION | 2 +- docs/ARCHITECTURE.md | 1 + static/index.html | 6 ++-- static/js/audiobook-cast.js | 56 +++++++++++++++++++++++++++++++++ static/js/audiobook.js | 63 ------------------------------------- static/module-manifest.json | 6 ++-- 8 files changed, 67 insertions(+), 70 deletions(-) create mode 100644 static/js/audiobook-cast.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 76576cb..bbe21e5 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 cast export extraction.** Markdown serialization and legacy page-stamping migration now live in + `audiobook-cast.js`, leaving the live casting workflow in `audiobook.js` and preserving the same global helpers. - **Rehearser training extraction.** Transcript normalization, LCS matching, and word-level comparison now live in `rehearser-train.js`; recording and state-machine wiring remain behavior-compatible in `rehearser.js`. - **Rehearser cast-controls extraction.** Cast view switching, sorting, filtering, and their guarded event wiring now diff --git a/TODO.md b/TODO.md index dfaf6b7..b7bf8aa 100644 --- a/TODO.md +++ b/TODO.md @@ -135,6 +135,7 @@ architecture change: the larger Audiobook/Rehearser/style split. - [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`. +- [x] Extract Audiobook cast serialization and page-stamping helpers into `audiobook-cast.js`. - [ ] Split `style.css` by feature. - [ ] Do not migrate to TypeScript or rewrite the frontend until behavior is covered by tests. diff --git a/VERSION b/VERSION index db49fca..7388294 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.24.32 +1.24.33 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ce4a00d..d666ffc 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`. - ✅ `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`. - ✅ `rehearser-train.js` — transcript normalization, LCS comparison, and word-level training feedback; microphone/state wiring remains in `rehearser.js`. - ✅ `rehearser-library.js` — SQLite persistence helpers and IndexedDB migration; import/export and auto-save remain in `rehearser.js` until their UI/event-handler dependencies are isolated. diff --git a/static/index.html b/static/index.html index afefdfc..45225db 100644 --- a/static/index.html +++ b/static/index.html @@ -10,7 +10,7 @@ - + @@ -27,7 +27,7 @@ - + @@ -392,7 +392,7 @@ window.toggleNavTree = function(treeId, chevronId) { - + diff --git a/static/js/audiobook-cast.js b/static/js/audiobook-cast.js new file mode 100644 index 0000000..63a7963 --- /dev/null +++ b/static/js/audiobook-cast.js @@ -0,0 +1,56 @@ +// ── Audiobook cast serialization ──────────────────────────────────────────── +// Live casting remains in audiobook.js; these deterministic export/migration +// helpers have no casting-state side effects and can be loaded independently. + +function _abCastMarkdown(data) { + const payload = data || {}; + const segments = Array.isArray(payload.segments) ? payload.segments : []; + const speakers = [...new Set(segments.filter(s => s?.type === 'dialogue' && s.speaker).map(s => String(s.speaker)))].sort(); + const lines = [`# ${payload.title || 'Cast Script'}`, '']; + const metaBits = []; + if (payload.savedAt) metaBits.push('exported ' + new Date(payload.savedAt).toISOString().slice(0, 16).replace('T', ' ')); + metaBits.push(`${speakers.length} character${speakers.length !== 1 ? 's' : ''}`); + metaBits.push(`${segments.length} segment${segments.length !== 1 ? 's' : ''}`); + lines.push(`*${metaBits.join(' · ')}*`, ''); + if (speakers.length) lines.push(`**Characters:** ${speakers.join(', ')}`, ''); + let lastPage = null; + for (const seg of segments) { + const text = String(seg?.text || '').trim(); + if (!text) continue; + if (seg.page != null && seg.page !== lastPage) { + lines.push('---', '', `## Page ${seg.page}`, ''); + lastPage = seg.page; + } + if (seg.type === 'dialogue') { + const speaker = String(seg.speaker || 'Narrator'); + const tag = seg.emotion ? ` *(${seg.emotion})*` : ''; + lines.push(`**${speaker.toUpperCase()}**${tag}: "${text}"`); + } else lines.push(text); + lines.push(''); + } + return lines.join('\n'); +} + +function _abStampSegmentPages(segs, pageMarks, text) { + if (!Array.isArray(segs) || !segs.length || segs.some(s => s && s.page != null)) return; + const marks = (pageMarks || []).slice().sort((a, b) => (a.offset || 0) - (b.offset || 0)); + const src = text || ''; + if (!marks.length || !src) return; + let markIdx = 0, searchPos = 0, cur = null; + if (marks[0].offset <= 2) { cur = marks[0].page + 1; markIdx = 1; } + for (const s of segs) { + const probe = String(s.text || '').trim().slice(0, 24); + const at = probe ? src.indexOf(probe, searchPos) : -1; + const pos = at >= 0 ? at : searchPos; + if (at >= 0) searchPos = at + probe.length; + while (markIdx < marks.length && pos >= marks[markIdx].offset) { + cur = marks[markIdx].page + 1; + markIdx++; + } + if (cur != null) s.page = cur; + } +} + +window.TTVC = window.TTVC || {}; +window.TTVC.audiobook = window.TTVC.audiobook || {}; +window.TTVC.audiobook.cast = { toMarkdown: _abCastMarkdown, stampPages: _abStampSegmentPages }; diff --git a/static/js/audiobook.js b/static/js/audiobook.js index 616ca48..09bc5b8 100644 --- a/static/js/audiobook.js +++ b/static/js/audiobook.js @@ -300,69 +300,6 @@ function _abSafeFilename(name, fallback = 'cast') { return (s || fallback || 'cast').slice(0, 120); } -// Mirrors routes/reader.py's _cast_to_md — a readable Markdown script (plain -// paragraphs for narration, **SPEAKER** (emotion): "line" for dialogue, -// grouped under page headings), used when there's no server-side bookId to -// export through. One-way export for reading/sharing, not meant to round-trip. -function _abCastMarkdown(data) { - const payload = data || {}; - const segments = Array.isArray(payload.segments) ? payload.segments : []; - const speakers = [...new Set( - segments.filter(s => s?.type === 'dialogue' && s.speaker).map(s => String(s.speaker)) - )].sort(); - const lines = [`# ${payload.title || 'Cast Script'}`, '']; - const metaBits = []; - if (payload.savedAt) metaBits.push('exported ' + new Date(payload.savedAt).toISOString().slice(0, 16).replace('T', ' ')); - metaBits.push(`${speakers.length} character${speakers.length !== 1 ? 's' : ''}`); - metaBits.push(`${segments.length} segment${segments.length !== 1 ? 's' : ''}`); - lines.push(`*${metaBits.join(' · ')}*`, ''); - if (speakers.length) lines.push(`**Characters:** ${speakers.join(', ')}`, ''); - let lastPage = null; - for (const seg of segments) { - const text = String(seg?.text || '').trim(); - if (!text) continue; - if (seg.page != null && seg.page !== lastPage) { - lines.push('---', '', `## Page ${seg.page}`, ''); - lastPage = seg.page; - } - if (seg.type === 'dialogue') { - const speaker = String(seg.speaker || 'Narrator'); - const tag = seg.emotion ? ` *(${seg.emotion})*` : ''; - lines.push(`**${speaker.toUpperCase()}**${tag}: "${text}"`); - } else { - lines.push(text); - } - lines.push(''); - } - return lines.join('\n'); -} - -// One-time migration for drafts saved before segments carried a .page number: -// re-derive each segment's page from the draft's pageMarks via the old -// forward-only offset search and stamp it on. Mirrors the legacy render-time -// fallback exactly (segments before the first mark stay unstamped → unlabeled -// leading card), but runs once at draft load instead of on every redraw, so -// the feed renderer stays single-path. -function _abStampSegmentPages(segs, pageMarks, text) { - if (!Array.isArray(segs) || !segs.length || segs.some(s => s && s.page != null)) return; - const marks = (pageMarks || []).slice().sort((a, b) => (a.offset || 0) - (b.offset || 0)); - const src = text || ''; - if (!marks.length || !src) return; - let markIdx = 0, searchPos = 0, cur = null; - if (marks[0].offset <= 2) { cur = marks[0].page + 1; markIdx = 1; } - for (const s of segs) { - const probe = String(s.text || '').trim().slice(0, 24); - const at = probe ? src.indexOf(probe, searchPos) : -1; - const pos = at >= 0 ? at : searchPos; - if (at >= 0) searchPos = at + probe.length; - while (markIdx < marks.length && pos >= marks[markIdx].offset) { - cur = marks[markIdx].page + 1; - markIdx++; - } - if (cur != null) s.page = cur; - } -} - // Called after essentially every ~80-segment chunk during a full-book cast // run — with the ENTIRE accumulated segments array re-stringified, written // to localStorage, AND sent over the network EVERY time. Both the string diff --git a/static/module-manifest.json b/static/module-manifest.json index e7fc6ad..97095ed 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", "rehearser-library", "rehearser-stage", "rehearser-voice-picker"], - "s-reader": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook", "character-sheets"], - "s-library": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern"], - "s-caststudio": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern", "studio"] + "s-reader": ["rehearser-parse", "rehearser-cast", "rehearser-train", "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", "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", "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"] } }