// ── 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 = `
Casting audiobook
Analysing…
`; 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 = `
Casting audiobook passage 0 / ${total}
Characters found
listening…
`; 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; if (typeof _audiobook.abort === 'function') _audiobook.abort(); ov.remove(); } 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]) => `${escHtml(n)}${info.count}`).join('') : 'listening…'; }; 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 = `${escHtml(s.speaker)}${s.emotion ? ' · ' + escHtml(s.emotion) : ''}${escHtml((s.text || '').slice(0, 160))}`; } else { row.innerHTML = `Narrator${escHtml((s.text || '').slice(0, 160))}`; } 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 = ` ${escHtml(summary)}`; 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 ac = new AbortController(); _audiobook.abort = () => ac.abort(); 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' }, signal: ac.signal, 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 (err) { if (err.name === 'AbortError') break; 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 => `