Read Aloud (new "Vorlesen" tab):
- PDF (real page render + overlay highlight) / TXT reader with live word
highlighting, voice + speed, per-sentence synthesis-state colours, zoom
(fit-width/height, two-page, ±), resume, and a server-side book library
(syncs across devices; per-unit MP3 audio fetched on demand).
Book -> multi-speaker audiobook:
- "Cast as audiobook" attributes dialogue to characters via the LLM
(guillemet/quote-style aware, turn-taking, recent-context), with a
deterministic speech-tag fallback. Editable preview, non-blocking live
casting panel, then auto-saved as a reopenable Script Rehearser play.
- Audiobook export: synthesise every cast line -> one MP3 per chapter.
Character sheets:
- LLM-extracted, self-filling RPG-style sheets (with page+quote sources)
in both Read Aloud and the Rehearser.
Also: MP3 storage + per-page/sentence export, voice-library "Precompute
embeddings" pre-warm, German "Vorlesen" i18n + flag language toggle,
large-PDF performance (lazy raster, buffer/canvas eviction, yielded parse),
and the Seed Finder changelog entry.
New: routes/reader.py, POST /api/attribute-dialogue, POST /api/character-sheets,
static/js/{reader,audiobook,character-sheets}.js, static/sections/s-reader.html.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
469 lines
27 KiB
JavaScript
469 lines
27 KiB
JavaScript
// ── Book → multi-speaker audiobook ──────────────────────────────────────────
|
||
//
|
||
// Bridges the Read Aloud reader and the Script Rehearser: an LLM scans the
|
||
// document (in the current scope — selection / page range / whole book),
|
||
// attributes every segment to a speaker ("Narrator" or a character) with an
|
||
// emotion, then hands the result to the Script Rehearser as a cast-able script
|
||
// so each character gets its own voice. The rehearser is the editable preview:
|
||
// you fix any mis-attribution, cast voices, and synthesise there.
|
||
//
|
||
// Reuses: /api/attribute-dialogue (LLM), readerState + readerScopeIndices()
|
||
// (reader.js), parseScript / detectCharacters / rehState / rehDefaultLlmUrl
|
||
// (rehearser.js), splitTextIntoChunks (generation.js), $ / toast (utils.js).
|
||
|
||
const AUDIOBOOK_CHUNK_CHARS = 3000; // passage size per LLM attribution call
|
||
const _audiobook = { running: false, cancel: false };
|
||
|
||
// Opening/closing quote glyphs across book conventions: English "..."/“...”,
|
||
// German »...«/„...“, French «...», single ‘...’/›...‹, CJK 「...」『...』, em-dash speech.
|
||
const AB_DIALOGUE_RE = /[«»„“”"‟‚‘’›‹『「]|(?:^|\n)\s*[—–]\s/;
|
||
function audiobookHasDialogue(t) { return AB_DIALOGUE_RE.test(t || ''); }
|
||
|
||
// Join words hyphenated across a PDF line break ("Schwer- tes" → "Schwertes")
|
||
// so the audiobook reads cleanly and speaker tags aren't split.
|
||
function audiobookDehyphenate(t) { return (t || '').replace(/([a-zäöüß])-\s+(?=[a-zäöüßA-ZÄÖÜ])/g, '$1'); }
|
||
|
||
// Speech-tag heuristic so the book still casts with REAL names when the LLM is down.
|
||
const AB_SPEECH_VERBS = '(?:sagte|fragte|rief|antwortete|erwiderte|entgegnete|meinte|flüsterte|wisperte|raunte|murmelte|brummte|knurrte|brüllte|schrie|stammelte|fauchte|zischte|seufzte|lachte|kicherte|befahl|wiederholte|fuhr\\s+fort|said|asked|replied|answered|whispered|murmured|muttered|shouted|cried|called|exclaimed|added|continued)';
|
||
const AB_NOTNAME = new Set([
|
||
'Der', 'Die', 'Das', 'Den', 'Dem', 'Ein', 'Eine', 'Einen', 'Er', 'Sie', 'Es', 'Ich', 'Du', 'Wir', 'Ihr', 'Man',
|
||
'Und', 'Aber', 'Da', 'Dann', 'Doch', 'So', 'Nun', 'Jetzt', 'The', 'He', 'She', 'It', 'They', 'A', 'An', 'And', 'But', 'Then', 'Now',
|
||
// common sentence-initial adverbs / interjections / abstractions that are NOT characters
|
||
'Sofort', 'Plötzlich', 'Endlich', 'Schließlich', 'Stille', 'Schweigen', 'Stimme', 'Stimmen', 'Frage', 'Antwort',
|
||
'Gelächter', 'Wieder', 'Gleich', 'Sogleich', 'Langsam', 'Leise', 'Laut', 'Kaum', 'Vielleicht', 'Natürlich', 'Wirklich',
|
||
'Ja', 'Nein', 'Komm', 'Warte', 'Halt', 'Geh', 'Hier', 'Dort', 'Oben', 'Unten', 'Schon', 'Noch', 'Auch', 'Nur', 'Immer', 'Nie']);
|
||
const _AB_NAME = "([A-ZÄÖÜ][A-Za-zäöüß'\\-]+)";
|
||
function audiobookGuessSpeaker(after, before) {
|
||
let m;
|
||
const ok = n => (n && !AB_NOTNAME.has(n)) ? n : null;
|
||
// NOTE: no 'i' flag — names must be genuinely capitalized; German speech verbs
|
||
// after a quote are lowercase, so this rejects pronouns like "sagte er".
|
||
// after the quote: ", sagte Riskan" / "sagte Riskan" (verb → name)
|
||
if ((m = new RegExp('^[\\s,;–-]*' + AB_SPEECH_VERBS + '\\s+(?:der|die|das|ein|eine)?\\s*' + _AB_NAME).exec(after || ''))) { const r = ok(m[1]); if (r) return r; }
|
||
// after the quote: ", Riskan sagte" (name → verb)
|
||
if ((m = new RegExp('^[\\s,;–-]*' + _AB_NAME + '\\s+' + AB_SPEECH_VERBS).exec(after || ''))) { const r = ok(m[1]); if (r) return r; }
|
||
// before the quote: "Riskan sagte:" / "Riskan fragte"
|
||
if ((m = new RegExp(_AB_NAME + '\\s+' + AB_SPEECH_VERBS + '[\\s:,–-]*$').exec(before || ''))) { const r = ok(m[1]); if (r) return r; }
|
||
return null;
|
||
}
|
||
|
||
// Deterministic fallback: split a passage into narration + dialogue by quotation
|
||
// spans and attribute speakers from the surrounding speech tags. Used when the LLM
|
||
// is unavailable so dialogue — and as many speakers as possible — are never lost.
|
||
const AB_QUOTE_SPAN = /»([^«]+)«|«([^»]+)»|„([^“”]+)[“”]|“([^”]+)”|"([^"]+)"|「([^」]+)」|『([^』]+)』/g;
|
||
function audiobookSplitByQuotes(text) {
|
||
const spans = []; let m;
|
||
AB_QUOTE_SPAN.lastIndex = 0;
|
||
while ((m = AB_QUOTE_SPAN.exec(text))) {
|
||
spans.push({ start: m.index, end: AB_QUOTE_SPAN.lastIndex, quote: (m[1] || m[2] || m[3] || m[4] || m[5] || m[6] || m[7] || '').trim() });
|
||
}
|
||
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);
|
||
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 && s.speaker !== 'Unknown') {
|
||
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 audiobookLlmUrl() { return $('reh-llm-url')?.value.trim() || (typeof rehDefaultLlmUrl === 'function' ? rehDefaultLlmUrl() : ''); }
|
||
function audiobookLlmModel() { return $('reh-llm-model')?.value || ''; }
|
||
function audiobookLang() { return $('reh-design-lang')?.value || ''; }
|
||
|
||
// Gather the plain text of the current reader scope (selection > page range > all).
|
||
function audiobookScopeText() {
|
||
if (typeof readerScopeIndices !== 'function' || !readerState?.sentences?.length) return '';
|
||
const raw = readerScopeIndices().map(i => readerState.sentences[i].text).join(' ').replace(/\s+/g, ' ').trim();
|
||
return audiobookDehyphenate(raw); // mend PDF line-break hyphenation for clean speech + tag matching
|
||
}
|
||
|
||
// ── Progress overlay ─────────────────────────────────────────────────────────
|
||
|
||
function audiobookProgress(total) {
|
||
let ov = document.getElementById('audiobook-overlay');
|
||
if (!ov) {
|
||
ov = document.createElement('div');
|
||
ov.id = 'audiobook-overlay';
|
||
ov.className = 'audiobook-overlay';
|
||
ov.innerHTML = `<div class="audiobook-box">
|
||
<div class="audiobook-title"><span class="mdi mdi-drama-masks"></span> Casting audiobook</div>
|
||
<div class="audiobook-msg" id="audiobook-msg">Analysing…</div>
|
||
<div class="reader-synth-track"><div class="reader-synth-fill" id="audiobook-fill"></div></div>
|
||
<div class="audiobook-actions"><button class="btn-secondary btn-sm" id="audiobook-cancel">Cancel</button></div>
|
||
</div>`;
|
||
document.body.appendChild(ov);
|
||
ov.querySelector('#audiobook-cancel').addEventListener('click', () => { _audiobook.cancel = true; });
|
||
}
|
||
ov.hidden = false;
|
||
const fill = ov.querySelector('#audiobook-fill');
|
||
const msg = ov.querySelector('#audiobook-msg');
|
||
return {
|
||
update(done, label) { if (fill) fill.style.width = (done / total * 100) + '%'; if (msg && label) msg.textContent = label; },
|
||
done() { ov.hidden = true; },
|
||
};
|
||
}
|
||
|
||
const _AB_PALETTE = ['#3b82f6', '#10b981', '#8b5cf6', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4', '#84cc16', '#f97316', '#14b8a6', '#6366f1', '#d946ef'];
|
||
|
||
// Live casting view: a scrolling feed of attributed lines + a character roster
|
||
// that fills up as speakers are discovered. Far clearer than a bare bar.
|
||
function audiobookCastView(total) {
|
||
document.getElementById('audiobook-overlay')?.remove();
|
||
// A floating, NON-blocking, minimisable panel — keep working in the app and
|
||
// come back to watch progress (no backdrop, no modal lock).
|
||
const ov = document.createElement('div');
|
||
ov.id = 'audiobook-overlay'; ov.className = 'ab-castpanel';
|
||
ov.innerHTML = `
|
||
<div class="ab-castpanel-head" id="ab-cv-head">
|
||
<span class="mdi mdi-drama-masks"></span>
|
||
<span class="ab-castpanel-title">Casting audiobook</span>
|
||
<span class="ab-castpanel-count" id="ab-cv-count">passage 0 / ${total}</span>
|
||
<span style="flex:1"></span>
|
||
<button class="ab-castpanel-btn" id="ab-cv-min" title="Minimise — keep working, come back later"><span class="mdi mdi-window-minimize"></span></button>
|
||
<button class="ab-castpanel-btn" id="ab-cv-x" title="Cancel"><span class="mdi mdi-close"></span></button>
|
||
</div>
|
||
<div class="reader-synth-track ab-castpanel-bar"><div class="reader-synth-fill" id="ab-cv-fill"></div></div>
|
||
<div class="ab-cv-body">
|
||
<div class="ab-cv-feed" id="ab-cv-feed"></div>
|
||
<div class="ab-cv-side">
|
||
<div class="ab-cv-side-head">Characters found</div>
|
||
<div class="ab-cv-chars" id="ab-cv-chars"><span class="ab-cv-empty">listening…</span></div>
|
||
</div>
|
||
</div>
|
||
<div class="ab-castpanel-foot" id="ab-cv-foot" hidden></div>`;
|
||
document.body.appendChild(ov);
|
||
const setMin = min => {
|
||
ov.classList.toggle('minimized', min);
|
||
const ic = ov.querySelector('#ab-cv-min .mdi');
|
||
if (ic) ic.className = 'mdi ' + (min ? 'mdi-window-maximize' : 'mdi-window-minimize');
|
||
};
|
||
ov.querySelector('#ab-cv-min').addEventListener('click', () => setMin(!ov.classList.contains('minimized')));
|
||
ov.querySelector('#ab-cv-x').addEventListener('click', () => { if (_audiobook.running) _audiobook.cancel = true; else ov.remove(); });
|
||
ov.querySelector('#ab-cv-head').addEventListener('click', e => { if (ov.classList.contains('minimized') && !e.target.closest('button')) setMin(false); });
|
||
const fill = ov.querySelector('#ab-cv-fill'), count = ov.querySelector('#ab-cv-count');
|
||
const feed = ov.querySelector('#ab-cv-feed'), chars = ov.querySelector('#ab-cv-chars');
|
||
const roster = new Map(); // name -> { count, color }
|
||
const colorFor = name => {
|
||
if (!roster.has(name)) roster.set(name, { count: 0, color: _AB_PALETTE[roster.size % _AB_PALETTE.length] });
|
||
return roster.get(name).color;
|
||
};
|
||
const renderRoster = () => {
|
||
const items = [...roster.entries()].sort((a, b) => b[1].count - a[1].count);
|
||
chars.innerHTML = items.length
|
||
? items.map(([n, info]) => `<span class="ab-chip" style="--c:${info.color}"><span class="ab-chip-dot"></span>${escHtml(n)}<b>${info.count}</b></span>`).join('')
|
||
: '<span class="ab-cv-empty">listening…</span>';
|
||
};
|
||
const MAXROWS = 80;
|
||
const trim = () => { while (feed.childElementCount > MAXROWS) feed.removeChild(feed.firstChild); feed.scrollTop = feed.scrollHeight; };
|
||
return {
|
||
update(done) { if (fill) fill.style.width = (done / total * 100) + '%'; if (count) count.textContent = `passage ${done} / ${total}`; },
|
||
addSegments(segs) {
|
||
const frag = document.createDocumentFragment();
|
||
for (const s of segs) {
|
||
const dialog = s.type === 'dialogue' && s.speaker && s.speaker.toLowerCase() !== 'narrator';
|
||
const row = document.createElement('div');
|
||
row.className = 'ab-cv-row' + (dialog ? '' : ' is-narr');
|
||
if (dialog) { const c = colorFor(s.speaker); roster.get(s.speaker).count++;
|
||
row.innerHTML = `<span class="ab-cv-spk" style="color:${c}">${escHtml(s.speaker)}${s.emotion ? ' · ' + escHtml(s.emotion) : ''}</span><span class="ab-cv-txt">${escHtml((s.text || '').slice(0, 160))}</span>`;
|
||
} else {
|
||
row.innerHTML = `<span class="ab-cv-spk">Narrator</span><span class="ab-cv-txt">${escHtml((s.text || '').slice(0, 160))}</span>`;
|
||
}
|
||
frag.appendChild(row);
|
||
}
|
||
feed.appendChild(frag); trim(); renderRoster();
|
||
},
|
||
note(text) { const r = document.createElement('div'); r.className = 'ab-cv-note'; r.textContent = text; feed.appendChild(r); trim(); },
|
||
// Park the panel in a "done" state with a Review button instead of auto-popping
|
||
// the preview — so it waits for you if you wandered off to do something else.
|
||
complete(summary, onOpen) {
|
||
if (count) count.textContent = 'done';
|
||
if (fill) fill.style.width = '100%';
|
||
ov.querySelector('#ab-cv-min').hidden = true;
|
||
ov.querySelector('#ab-cv-x').title = 'Dismiss';
|
||
const foot = ov.querySelector('#ab-cv-foot');
|
||
foot.hidden = false;
|
||
foot.innerHTML = `<span class="ab-cv-done"><span class="mdi mdi-check-circle-outline"></span> ${escHtml(summary)}</span><span style="flex:1"></span><button class="btn-primary btn-sm" id="ab-cv-review"><span class="mdi mdi-account-music-outline"></span> Review & cast</button>`;
|
||
foot.querySelector('#ab-cv-review').addEventListener('click', () => { ov.remove(); onOpen(); });
|
||
// gently nudge the panel open if it was minimised while you were away
|
||
ov.classList.add('ab-castpanel-done');
|
||
},
|
||
done() { ov.remove(); },
|
||
};
|
||
}
|
||
|
||
// ── Attribution → script handoff ─────────────────────────────────────────────
|
||
|
||
async function audiobookCast() {
|
||
if (_audiobook.running) return;
|
||
const text = audiobookScopeText();
|
||
if (!text) { toast('Import a document first', 'error'); return; }
|
||
if (typeof parseScript !== 'function') { toast('Rehearser not loaded yet — try again in a moment', 'error'); return; }
|
||
|
||
const chunks = (typeof splitTextIntoChunks === 'function')
|
||
? splitTextIntoChunks(text, AUDIOBOOK_CHUNK_CHARS)
|
||
: [text];
|
||
|
||
_audiobook.running = true; _audiobook.cancel = false;
|
||
const view = audiobookCastView(chunks.length);
|
||
const llm_url = audiobookLlmUrl(), model = audiobookLlmModel(), language = audiobookLang();
|
||
|
||
const allSegments = [];
|
||
const roster = [];
|
||
let narrationOnly = 0; // passages with no quotes at all — legitimately all narration
|
||
let degraded = 0; // passages with dialogue the LLM couldn't analyse → quotes auto-extracted
|
||
try {
|
||
for (let i = 0; i < chunks.length; i++) {
|
||
if (_audiobook.cancel) break;
|
||
view.update(i);
|
||
// No quotation marks anywhere → pure narration; skip the LLM entirely (faster, not an error)
|
||
if (!audiobookHasDialogue(chunks[i])) {
|
||
const seg = { speaker: 'Narrator', type: 'narration', text: chunks[i], emotion: '' };
|
||
allSegments.push(seg); narrationOnly++; view.addSegments([seg]);
|
||
continue;
|
||
}
|
||
// recent attributed dialogue → lets the LLM continue turn-taking across the boundary
|
||
const recent = allSegments.filter(s => s.type === 'dialogue' && s.speaker && s.speaker !== 'Unknown')
|
||
.slice(-6).map(s => `${s.speaker}: ${(s.text || '').slice(0, 80)}`).join('\n');
|
||
let data = null;
|
||
try {
|
||
const r = await fetch('/api/attribute-dialogue', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ text: chunks[i], known_characters: roster.slice(-40), recent, language, llm_url, model }),
|
||
});
|
||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
|
||
data = await r.json();
|
||
} catch (_) { data = null; }
|
||
|
||
let segs = data && Array.isArray(data.segments) ? data.segments : [];
|
||
if (!segs.length) {
|
||
// LLM unavailable or returned nothing, but this passage HAS quotes —
|
||
// extract dialogue + attribute speakers from speech tags so it isn't lost.
|
||
segs = audiobookSplitByQuotes(chunks[i]);
|
||
degraded++;
|
||
const named = segs.filter(s => s.type === 'dialogue' && s.speaker !== 'Unknown').length;
|
||
view.note(`Passage ${i + 1} — auto-detected dialogue${named ? ` (${named} speaker${named !== 1 ? 's' : ''} from tags)` : ' (set speakers in review)'}`);
|
||
}
|
||
// harvest speaker names (from LLM or tag heuristic) into the running roster
|
||
(data && data.characters || []).forEach(n => { if (n && n !== 'Unknown' && !roster.includes(n)) roster.push(n); });
|
||
segs.forEach(s => { if (s.type === 'dialogue' && s.speaker && s.speaker !== 'Unknown' && !roster.includes(s.speaker)) roster.push(s.speaker); });
|
||
segs.forEach(s => allSegments.push(s));
|
||
view.addSegments(segs);
|
||
}
|
||
view.update(chunks.length);
|
||
} finally {
|
||
_audiobook.running = false;
|
||
}
|
||
|
||
if (_audiobook.cancel) { view.done(); toast('Casting cancelled', 'error'); return; }
|
||
if (!allSegments.length) { view.done(); toast('No segments produced', 'error'); return; }
|
||
|
||
_audiobook.segments = allSegments;
|
||
_audiobook.roster = roster;
|
||
_audiobook.narratedPassages = narrationOnly;
|
||
_audiobook.degraded = degraded;
|
||
// Park the panel with a "Review & cast" button (don't auto-pop, in case you wandered off)
|
||
const speakers = new Set(allSegments.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker));
|
||
const summary = `${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${allSegments.length} segments`;
|
||
view.complete(summary, audiobookShowPreview);
|
||
}
|
||
|
||
// ── Editable attribution preview ─────────────────────────────────────────────
|
||
|
||
function audiobookShowPreview() {
|
||
const segs = _audiobook.segments || [];
|
||
const roster = _audiobook.roster || [];
|
||
document.getElementById('audiobook-preview')?.remove();
|
||
const ov = document.createElement('div');
|
||
ov.id = 'audiobook-preview';
|
||
ov.className = 'audiobook-overlay';
|
||
// datalist = Narrator + LLM roster + any speakers present in the segments (incl. Unknown)
|
||
const speakerSet = [...new Set(['Narrator', ...roster, ...segs.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker)])];
|
||
const charCount = speakerSet.filter(n => n !== 'Narrator' && n !== 'Unknown').length;
|
||
const opts = speakerSet.map(n => `<option value="${escHtml(n)}">`).join('');
|
||
ov.innerHTML = `<div class="audiobook-box audiobook-preview-box">
|
||
<div class="audiobook-title"><span class="mdi mdi-drama-masks"></span> Review cast & lines
|
||
<span class="audiobook-count">${segs.length} segments · ${charCount} character${charCount !== 1 ? 's' : ''}</span></div>
|
||
<div class="audiobook-msg">Fix any wrong speaker or emotion, then open in the Script Rehearser to assign voices.${_audiobook.narratedPassages ? ` <span class="audiobook-narr-note">${_audiobook.narratedPassages} passage${_audiobook.narratedPassages !== 1 ? 's' : ''} had no dialogue (narrator).</span>` : ''}${_audiobook.degraded ? ` <span class="audiobook-narr-note">${_audiobook.degraded} passage${_audiobook.degraded !== 1 ? 's' : ''} used quick detection — set the “Unknown” speakers.</span>` : ''}</div>
|
||
<datalist id="audiobook-roster">${opts}</datalist>
|
||
<div class="audiobook-seglist" id="audiobook-seglist"></div>
|
||
<div class="audiobook-actions">
|
||
<button class="btn-secondary btn-sm" id="audiobook-preview-cancel">Cancel</button>
|
||
<button class="btn-primary btn-sm" id="audiobook-preview-open"><span class="mdi mdi-account-music-outline"></span> Open in Rehearser</button>
|
||
</div>
|
||
</div>`;
|
||
document.body.appendChild(ov);
|
||
ov.querySelector('#audiobook-seglist').innerHTML = segs.map((s, i) => `<div class="audiobook-seg${s.type === 'dialogue' ? ' is-dialog' : ''}">
|
||
<input class="audiobook-seg-sp" data-i="${i}" list="audiobook-roster" value="${escHtml(s.speaker || 'Narrator')}" aria-label="Speaker">
|
||
<input class="audiobook-seg-emo" data-i="${i}" value="${escHtml(s.emotion || '')}" placeholder="emotion" aria-label="Emotion"${s.type === 'dialogue' ? '' : ' disabled'}>
|
||
<div class="audiobook-seg-text">${escHtml(s.text)}</div>
|
||
</div>`).join('');
|
||
ov.querySelector('#audiobook-preview-cancel').addEventListener('click', () => ov.remove());
|
||
ov.querySelector('#audiobook-preview-open').addEventListener('click', () => { audiobookApplyPreviewAndOpen(); ov.remove(); });
|
||
}
|
||
|
||
function audiobookApplyPreviewAndOpen() {
|
||
const segs = _audiobook.segments;
|
||
document.querySelectorAll('#audiobook-seglist .audiobook-seg-sp').forEach(inp => {
|
||
const i = +inp.dataset.i; const v = inp.value.trim() || 'Narrator';
|
||
segs[i].speaker = v;
|
||
segs[i].type = (v.toLowerCase() === 'narrator') ? 'narration' : 'dialogue';
|
||
});
|
||
document.querySelectorAll('#audiobook-seglist .audiobook-seg-emo').forEach(inp => {
|
||
const i = +inp.dataset.i; segs[i].emotion = inp.value.trim();
|
||
});
|
||
const { script, emotions } = audiobookBuildScript(segs);
|
||
audiobookOpenInRehearser(script, (readerState.title || 'Audiobook'), emotions);
|
||
}
|
||
|
||
// Build a rehearser script (CAPS speaker + line; narration as plain paragraphs)
|
||
// and a parallel list of per-dialogue-line emotions (same order as dialog lines).
|
||
function audiobookBuildScript(segments) {
|
||
let script = '';
|
||
const emotions = [];
|
||
for (const s of segments) {
|
||
const t = (s.text || '').trim(); if (!t) continue;
|
||
const isDialogue = s.type === 'dialogue' && s.speaker && s.speaker.toLowerCase() !== 'narrator';
|
||
if (isDialogue) {
|
||
script += '\n' + s.speaker.toUpperCase() + '\n' + t + '\n';
|
||
emotions.push(s.emotion || '');
|
||
} else {
|
||
script += '\n' + t + '\n'; // narration → narrator reads these
|
||
}
|
||
}
|
||
return { script: script.trim(), emotions };
|
||
}
|
||
|
||
async function audiobookOpenInRehearser(script, title, dialogueEmotions) {
|
||
if ($('reh-script-text')) $('reh-script-text').value = script;
|
||
if ($('reh-script-title')) $('reh-script-title').value = title;
|
||
if (typeof navTo === 'function') navTo('s-rehearser');
|
||
// Reuse the rehearser's own parse flow (builds lines, cast, jumps to Cast phase)
|
||
const btn = $('reh-parse-btn');
|
||
if (btn) btn.click();
|
||
else if (typeof parseScript === 'function') { rehState.lines = parseScript(script); }
|
||
// Apply per-line emotions to dialog lines in order (Phase 3: emotion-aware narration)
|
||
let speakers = 0, lines = 0;
|
||
if (window.rehState && Array.isArray(rehState.lines)) {
|
||
let k = 0;
|
||
rehState.lines.forEach(l => { if (l.type === 'dialog') { const e = dialogueEmotions[k++]; if (e) l.emotion = e; lines++; } });
|
||
speakers = Object.keys(rehState.cast || {}).filter(s => !String(s).includes('NARRATOR')).length;
|
||
}
|
||
// Persist as a reopenable rehearsal so the cast/lines aren't lost — find it under
|
||
// Script Rehearser → Bibliothek (Library) and reopen anytime to edit & synthesise.
|
||
let saved = false;
|
||
if (typeof saveToLibrary === 'function') {
|
||
try { rehState.savedId = null; await saveToLibrary(); saved = true; } catch (_) {}
|
||
}
|
||
toast(`Cast ${speakers} character${speakers !== 1 ? 's' : ''} · ${lines} lines` + (saved ? ' — saved to Rehearser → Bibliothek' : ''), 'success');
|
||
}
|
||
|
||
// ── Audiobook export (rehearser): synthesise every line → MP3 per chapter ────
|
||
|
||
function audiobookIsChapter(line) {
|
||
if (line.type === 'act' || line.type === 'scene') return true;
|
||
const t = (typeof stripMarkdown === 'function' ? stripMarkdown(line.text || '') : (line.text || '')).trim();
|
||
if (!t || t.length > 60) return false;
|
||
return /^(chapter|kapitel|chap\.?|part|book|prologue|epilogue|prolog|epilog|teil)\b/i.test(t);
|
||
}
|
||
|
||
function audiobookLineVoice(l) {
|
||
if (l.type === 'dialog') {
|
||
const c = rehState.cast[l.speaker] || {};
|
||
return { voice: c.voice, instruct: (typeof _buildInstruct === 'function' ? _buildInstruct(c.instruct, l.emotion) : '') };
|
||
}
|
||
return { voice: rehState.narratorVoice, instruct: '' };
|
||
}
|
||
|
||
async function audiobookExport() {
|
||
if (_audiobook.running) return;
|
||
if (!window.rehState || !(rehState.lines || []).length) { toast('Open a script in the rehearser first', 'error'); return; }
|
||
if (!rehState.backend) { toast('Select a TTS backend in the rehearser first', 'error'); return; }
|
||
if (typeof _ensureNarrator === 'function') _ensureNarrator();
|
||
|
||
const speakable = i => {
|
||
const l = rehState.lines[i];
|
||
if (!l || l.ignored || l.hidden) return false;
|
||
if (l.type === 'dialog') { const c = rehState.cast[l.speaker]; return !!(c && c.voice && c.voice !== 'me'); }
|
||
return !!(rehState.narratorVoice && (l.text || '').trim());
|
||
};
|
||
|
||
// Bucket speakable lines into chapters (by chapter headings / act / scene)
|
||
const buckets = [];
|
||
let cur = null;
|
||
rehState.lines.forEach((l, i) => {
|
||
if (audiobookIsChapter(l)) { cur = { title: (typeof stripMarkdown === 'function' ? stripMarkdown(l.text) : l.text).trim().slice(0, 50), idx: [] }; buckets.push(cur); }
|
||
if (speakable(i)) { if (!cur) { cur = { title: '', idx: [] }; buckets.push(cur); } cur.idx.push(i); }
|
||
});
|
||
const allIdx = buckets.flatMap(b => b.idx);
|
||
if (!allIdx.length) { toast('Nothing to synthesise — cast voices first', 'error'); return; }
|
||
|
||
_audiobook.running = true; _audiobook.cancel = false;
|
||
const prog = audiobookProgress(allIdx.length);
|
||
const mp3 = new Map();
|
||
let done = 0;
|
||
const queue = allIdx.slice();
|
||
const worker = async () => {
|
||
while (queue.length && !_audiobook.cancel) {
|
||
const i = queue.shift();
|
||
const l = rehState.lines[i];
|
||
const { voice, instruct } = audiobookLineVoice(l);
|
||
const text = (typeof _rehInlineTone === 'function')
|
||
? _rehInlineTone(stripMarkdown(l.text), l.emotion)
|
||
: (typeof stripMarkdown === 'function' ? stripMarkdown(l.text) : l.text);
|
||
try { mp3.set(i, await fetchTtsPreviewBlob(voice, text, 'mp3', instruct, rehState.backend)); } catch (_) {}
|
||
prog.update(++done, `Synthesising line ${done} / ${allIdx.length}…`);
|
||
}
|
||
};
|
||
try { await Promise.all(Array.from({ length: Math.min(2, allIdx.length) }, worker)); }
|
||
finally { prog.done(); _audiobook.running = false; }
|
||
|
||
if (_audiobook.cancel) { toast('Export cancelled', 'error'); return; }
|
||
const title = (typeof readerSafeName === 'function' ? readerSafeName($('reh-script-title')?.value || 'Audiobook') : ($('reh-script-title')?.value || 'Audiobook'));
|
||
const realChapters = buckets.filter(b => b.title).length > 0;
|
||
let files = 0;
|
||
for (let c = 0; c < buckets.length; c++) {
|
||
const blobs = buckets[c].idx.map(i => mp3.get(i)).filter(Boolean);
|
||
if (!blobs.length) continue;
|
||
const blob = new Blob(blobs, { type: 'audio/mpeg' });
|
||
const ch = buckets[c].title ? ' ' + readerSafeName(buckets[c].title) : '';
|
||
const name = (realChapters || buckets.length > 1)
|
||
? `${title} - ${String(c + 1).padStart(2, '0')}${ch}.mp3`
|
||
: `${title}.mp3`;
|
||
if (typeof readerDownload === 'function') readerDownload(blob, name);
|
||
files++;
|
||
await new Promise(r => setTimeout(r, 400));
|
||
}
|
||
toast('Exported audiobook · ' + files + (realChapters ? ' chapter MP3 file(s)' : ' MP3 file(s)'), 'success');
|
||
}
|
||
|
||
// ── Wiring ───────────────────────────────────────────────────────────────────
|
||
|
||
$('reader-audiobook-btn')?.addEventListener('click', audiobookCast);
|
||
$('reh-tb-audiobook')?.addEventListener('click', audiobookExport);
|