refactor: extract audiobook cast serialization
This commit is contained in:
parent
d3bc806f33
commit
c0ac5dd775
@ -8,6 +8,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Changed
|
### 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 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-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
|
- **Rehearser cast-controls extraction.** Cast view switching, sorting, filtering, and their guarded event wiring now
|
||||||
|
|||||||
1
TODO.md
1
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`.
|
- [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`.
|
||||||
|
- [x] Extract Audiobook cast serialization and page-stamping helpers into `audiobook-cast.js`.
|
||||||
- [ ] Split `style.css` by feature.
|
- [ ] Split `style.css` by feature.
|
||||||
- [ ] Do not migrate to TypeScript or rewrite the frontend until behavior is covered by tests.
|
- [ ] Do not migrate to TypeScript or rewrite the frontend until behavior is covered by tests.
|
||||||
|
|
||||||
|
|||||||
@ -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`.
|
||||||
- ✅ `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`.
|
||||||
- ✅ `rehearser-train.js` — transcript normalization, LCS comparison, and word-level training feedback; microphone/state wiring remains in `rehearser.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
|
- ✅ `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.
|
in `rehearser.js` until their UI/event-handler dependencies are isolated.
|
||||||
|
|||||||
@ -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.32">
|
<meta name="app-version" content="1.24.33">
|
||||||
<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,7 +27,7 @@
|
|||||||
|
|
||||||
<!-- ── 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.32">
|
<link rel="stylesheet" href="/static/style.css?v=1.24.33">
|
||||||
|
|
||||||
|
|
||||||
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
||||||
@ -392,7 +392,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.32"></script>
|
<script src="/static/loader.js?v=1.24.33"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
56
static/js/audiobook-cast.js
Normal file
56
static/js/audiobook-cast.js
Normal file
@ -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 };
|
||||||
@ -300,69 +300,6 @@ function _abSafeFilename(name, fallback = 'cast') {
|
|||||||
return (s || fallback || 'cast').slice(0, 120);
|
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
|
// Called after essentially every ~80-segment chunk during a full-book cast
|
||||||
// run — with the ENTIRE accumulated segments array re-stringified, written
|
// run — with the ENTIRE accumulated segments array re-stringified, written
|
||||||
// to localStorage, AND sent over the network EVERY time. Both the string
|
// to localStorage, AND sent over the network EVERY time. Both the string
|
||||||
|
|||||||
@ -5,8 +5,8 @@
|
|||||||
],
|
],
|
||||||
"lazy": {
|
"lazy": {
|
||||||
"s-rehearser": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker"],
|
"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-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", "character-sheets", "library", "character-columns", "library-characters", "sillytavern"],
|
"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", "character-sheets", "library", "character-columns", "library-characters", "sillytavern", "studio"]
|
"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"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user