// ── 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_WARMUP_TIMEOUT_MS = 180000; const AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS = 90000; const AUDIOBOOK_ATTRIBUTION_RETRY_TIMEOUT_MS = 60000; const AUDIOBOOK_RECAST_TIMEOUT_MS = 75000; const _audiobook = { running: false, cancel: false }; async function audiobookFetchWithTimeout(url, options = {}, timeoutMs = AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS) { const parentSignal = options.signal; const ac = new AbortController(); let timedOut = false; const timer = setTimeout(() => { timedOut = true; ac.abort(); }, timeoutMs); const onParentAbort = () => ac.abort(parentSignal?.reason); if (parentSignal) { if (parentSignal.aborted) onParentAbort(); else parentSignal.addEventListener('abort', onParentAbort, { once: true }); } try { return await fetch(url, { ...options, signal: ac.signal }); } catch (err) { if (timedOut) { const timeoutErr = new Error(`Timed out after ${Math.ceil(timeoutMs / 1000)}s`); timeoutErr.name = 'TimeoutError'; throw timeoutErr; } throw err; } finally { clearTimeout(timer); if (parentSignal) parentSignal.removeEventListener('abort', onParentAbort); } } function audiobookTimeoutSeconds(timeoutMs) { return Math.max(5, Math.round(timeoutMs / 1000)); } // Same hue algorithm as library.js _charHue so avatar colours match across views function _abCharHue(name) { return Math.abs((name || '?').split('').reduce(function (h, c) { return (h * 31 + c.charCodeAt(0)) % 360; }, 0)); } // String coercer (mirrors library.js _libStr) function _abStr(v) { if (v == null) return ''; if (typeof v === 'string') return v; if (Array.isArray(v)) return v.filter(Boolean).join(', '); return String(v); } // ── Autosave / draft recovery ───────────────────────────────────────────────── // Saves the accumulated segments to localStorage after every chunk so a page // refresh or browser crash doesn't lose hours of casting work. const _AB_DRAFT_KEY = 'ttsvc_ab_draft'; // The book this casting session belongs to. When a saved library book is open, // the draft is keyed by its id so reopening the SAME book always restores its // cast — independent of any drift in the extracted text fingerprint (the old // text-only key silently lost the cast whenever PDF re-extraction differed even // slightly). Falls back to the global key for unsaved/ad-hoc documents. function _abBookId() { return (window.readerState && readerState.savedId) || _audiobook.bookId || null; } function _abDraftKey(bookId) { return bookId ? (_AB_DRAFT_KEY + '_' + bookId) : _AB_DRAFT_KEY; } function _abTextId(text) { // Cheap fingerprint — no need for a full hash; length + first/last chars is enough // to detect doc changes without storing the source text itself. const s = (text.slice(0, 300) + text.slice(-300)).replace(/\s+/g, ''); let h = 5381; for (let i = 0; i < s.length; i++) h = (((h << 5) + h) ^ s.charCodeAt(i)) >>> 0; return h.toString(36) + '_' + text.length; } function _abSaveDraft(segs, roster, text, done, total) { const bookId = _abBookId(); const payload = { bookId: bookId, title: window.readerState?.title || '', textId: _abTextId(text), segments: segs, roster: roster, pageMarks: _audiobook.pageMarks || [], rehId: _audiobook.rehId || null, done: done, total: total, savedAt: Date.now() }; try { localStorage.setItem(_abDraftKey(bookId), JSON.stringify(payload)); } catch (_) {} // Mirror to server when a library book is open (fire-and-forget) if (bookId) { fetch(`/api/reader/docs/${encodeURIComponent(bookId)}/scripts/cast`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }).catch(() => {}); } } async function _abLoadDraftServer(bookId) { if (!bookId) return null; try { const r = await fetch(`/api/reader/docs/${encodeURIComponent(bookId)}/scripts/cast`); if (!r.ok) return null; const d = await r.json(); if (d && Array.isArray(d.segments) && d.segments.length) return d; } catch (_) {} return null; } function _abLoadDraft(text) { const bookId = _abBookId(); try { // 1. Per-book localStorage — fastest, no network if (bookId) { const raw = localStorage.getItem(_abDraftKey(bookId)); if (raw) { const d = JSON.parse(raw); if (d && Array.isArray(d.segments) && d.segments.length) return d; } } // 2. Global draft (ad-hoc docs) — must match the text fingerprint const raw = localStorage.getItem(_AB_DRAFT_KEY); if (!raw) return null; const d = JSON.parse(raw); if (!d || !Array.isArray(d.segments) || !d.segments.length) return null; if (d.textId !== _abTextId(text)) return null; return d; } catch (_) { return null; } } function _abClearDraft() { const bookId = _abBookId(); try { localStorage.removeItem(_abDraftKey(bookId)); } catch (_) {} if (bookId) { fetch(`/api/reader/docs/${encodeURIComponent(bookId)}/scripts/cast`, { method: 'DELETE' }).catch(() => {}); } } // 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).trimEnd(); if (tail) { // If the tail starts with an unclosed opening quote (PDF line-break cut it off), // treat the quoted portion as dialogue rather than narrator. const unclosed = tail.match(/^([\s\S]*?)(»)([\s\S]+)$/); if (unclosed && !tail.includes('«')) { if (unclosed[1].trim()) out.push({ speaker: 'Narrator', type: 'narration', text: unclosed[1].trim(), emotion: '' }); const speaker = audiobookGuessSpeaker('', unclosed[1]) || 'Unknown'; out.push({ speaker, type: 'dialogue', text: unclosed[3].trim(), emotion: '' }); } else 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 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). // Also records PDF page boundaries as character offsets in the returned text // (_audiobook.pageMarks) so saving to the Rehearser can reconstruct page breaks. function audiobookScopeText() { _audiobook.pageMarks = []; if (typeof readerScopeIndices !== 'function' || !readerState?.sentences?.length) return ''; const idxs = readerScopeIndices(); const anchors = []; // {page, anchor} — first sentence of each new page let lastPage = null; for (const i of idxs) { const u = readerState.sentences[i]; const pg = u?.words?.[0]?.page; if (pg != null && pg !== lastPage) { anchors.push({ page: pg, anchor: (u.text || '').trim().slice(0, 40) }); lastPage = pg; } } const raw = idxs.map(i => readerState.sentences[i].text).join(' ').replace(/\s+/g, ' ').trim(); const text = audiobookDehyphenate(raw); // mend PDF line-break hyphenation for clean speech + tag matching // Locate each page anchor in the final text → page-break offsets. let from = 0; for (const a of anchors) { const probe = audiobookDehyphenate(a.anchor).slice(0, 24); if (!probe) continue; const pos = text.indexOf(probe, from); if (pos >= 0) { _audiobook.pageMarks.push({ offset: pos, page: a.page }); from = pos; } } return text; } // ── 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']; // Escape a segment's text and underline any known character names. Module-level // so the review/preview overlay (audiobookShowPreview) can use it too — the cast // view (audiobookCastView) defines its own roster-coloured version that shadows // this inside its closure. Names default to the current run's roster. function highlightText(text, names) { if (!text) return ''; let html = escHtml(text); const list = (names || (_audiobook && _audiobook.roster) || []) .filter(n => n && n.toLowerCase() !== 'narrator' && !/^Unknown|Unbekannt/i.test(n)) .sort((a, b) => b.length - a.length); list.forEach((name, idx) => { if (name.length < 2) return; const safe = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const color = _AB_PALETTE[idx % _AB_PALETTE.length]; html = html.replace(new RegExp(`\\b(${safe})\\b`, 'gi'), m => `${m}`); }); return html; } // 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, llmUrl, defaultModel, isIdle = false) { const panel = document.getElementById('reader-audiobook-panel'); if (!panel) return; if (typeof window.navReaderView === 'function') window.navReaderView('cast'); else if (typeof window.showReaderView === 'function') window.showReaderView('cast'); else { const mainView = document.getElementById('reader-main-view'); if (mainView) mainView.hidden = true; panel.hidden = false; } panel.className = 'ab-castpanel-inline card'; panel.style.display = ''; panel.style.flexDirection = ''; panel.style.minHeight = ''; panel.innerHTML = `
Casting audiobook passage 0 / ${total}
Characters found
${[38,62,45,28,54,35].map(w => `
`).join('')}
`; const AB_DEFAULT_PROMPT = `Du bist ein erfahrener Drehbuchautor und Hörbuch-Regisseur. Deine Aufgabe ist es, einen Auszug aus einem deutschen Roman zu analysieren und ihn perfekt in einzelne Segmente für Erzähler und Dialoge (wörtliche Rede) zu unterteilen. WICHTIGE DEFINITION VON DIALOG: Text ist NUR dann 'dialogue', wenn er explizit in Anführungszeichen steht (z.B. »...«, „...“, "...", «...», <<...>>) oder mit einem Gedankenstrich (—) beginnt. ALLES ANDERE, einschließlich Handlungsbeschreibungen (Inquit-Formeln), inneren Gedanken und Beschreibungen, MUSS als 'narration' (Erzähler) deklariert werden. Markiere Text NIEMALS als Dialog, nur weil der Name eines Charakters erwähnt wird! (z.B. "Karyla rannte zur Tür." ist Narration, KEIN Dialog). ANALYSE-REGELN FÜR DIE ZUORDNUNG DES SPRECHERS (Sei deduktiv): 1. Direkte Zuordnung: Achte auf Wörter wie "sagte [Name]", "fragte er", "rief sie". Löse Pronomen (er/sie) zum tatsächlichen Namen auf. 2. Handlungs-Hinweise (Action Beats): Wenn ein Charakter eine Handlung ausführt und direkt davor/danach wörtliche Rede steht, spricht meist dieser Charakter (z.B. "Thomas trat ans Fenster. »Es regnet.«"). 3. Das Ping-Pong-Prinzip: Wenn zwei Personen sprechen, wechseln sie sich ab. Verfolge diese Kette lückenlos zurück zur letzten eindeutigen Nennung. 4. Gruppen-Dialoge (3+ Personen): An wen richtet sich die Aussage? Passt die Aussage zum Wissen oder Tonfall eines bestimmten Charakters? 5. Wiederverwendung: Nutze EXAKT die Namen aus der Liste der bekannten Charaktere, FALLS der Name dort aufgeführt ist. Wenn ein neuer Charakter spricht, extrahiere seinen Namen direkt aus dem Text (z.B. 'Karyla', 'Uriens'). 6. Unbekannte Sprecher: Nur wenn eine Zuordnung durch Kontext absolut nicht möglich ist, verwende 'Unknown'. Rate nicht blind, aber bevorzuge immer einen namentlich genannten Charakter gegenüber 'Unknown'. FÜR JEDES SEGMENT GIBST DU FOLGENDES AUS: - speaker: 'Narrator' für Narration/Erzählertext, oder den EXAKTEN Namen des Charakters für gesprochene Dialoge. - type: 'narration' oder 'dialogue' - text: Der EXAKTE, wortwörtliche Text aus dem Auszug. Bei 'dialogue' ENTFERNST du die umschließenden Anführungszeichen. - emotion: Bei Dialogen 1-2 deutsche Wörter, die den Tonfall beschreiben (z.B. wütend, flüsternd, ängstlich). Bei Narration leer lassen (''). STRIKTE FORMAT- UND TEXTREGELN: - Mische NIEMALS Narration und Dialog im selben Segment! Trenne sie strikt. Wenn ein Zitat durch eine Handlungsanweisung unterbrochen wird (»Nein«, sagte sie, »halt.«), erstelle 3 Segmente: dialogue ("Nein"), narration (", sagte sie, "), dialogue ("halt."). - »Text?« und »Text!« sind vollständige Dialoge — das ?« bzw. !« schließt das Zitat ab, auch wenn es ungewohnt aussieht. - Wenn ein Auszug mit einem offenen »-Zitat endet (kein schließendes «), behandle den Text ab » bis Textende als 'dialogue'. - Lasse NIEMALS Wörter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und lückenlos Wort für Wort rekonstruieren!`; const globalPrompt = (typeof _appSettings !== 'undefined' && _appSettings.audiobook_prompt) ? _appSettings.audiobook_prompt : AB_DEFAULT_PROMPT; panel.querySelector('#ab-cv-prompt-text').value = globalPrompt; const closePanel = () => { panel.hidden = true; panel.innerHTML = ''; if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(false); if (typeof window.navReaderView === 'function') window.navReaderView('main'); else if (typeof window.showReaderView === 'function') window.showReaderView('main'); else { const mv = document.getElementById('reader-main-view'); if (mv) mv.hidden = false; } }; const setStoppingState = () => { const cancelBtn = panel.querySelector('#ab-cv-cancel'); if (cancelBtn) { cancelBtn.disabled = true; cancelBtn.innerHTML = ' Stopping...'; cancelBtn.title = 'Stopping the current casting request'; } const status = panel.querySelector('#ab-cv-status-msg'); if (status) { status.style.display = 'inline-block'; status.textContent = 'Stopping... completed passages will stay saved.'; } }; panel.querySelector('#ab-cv-cancel').addEventListener('click', () => { if (_audiobook.running) { _audiobook.cancel = true; setStoppingState(); if (typeof _audiobook.abort === 'function') _audiobook.abort(); return; } closePanel(); }); panel.querySelector('#ab-cv-prompt-btn').addEventListener('click', () => { const p = panel.querySelector('#ab-cv-prompt-panel'); p.hidden = !p.hidden; const chevron = panel.querySelector('#ab-cv-prompt-chevron'); if (chevron) { chevron.className = p.hidden ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-up'; } }); // Collapse the whole settings block (LLM engine + prompt) to free vertical space. (function () { const tBtn = panel.querySelector('#ab-cv-settings-toggle'); const sBox = panel.querySelector('#ab-cv-settings'); const chev = panel.querySelector('#ab-cv-settings-chevron'); function apply(collapsed) { if (sBox) sBox.style.display = collapsed ? 'none' : 'flex'; if (collapsed) { const p = panel.querySelector('#ab-cv-prompt-panel'); if (p) p.hidden = true; } if (chev) chev.className = 'mdi ' + (collapsed ? 'mdi-chevron-down' : 'mdi-chevron-up'); } let collapsed = false; try { collapsed = localStorage.getItem('ttsvc_ab_settings_collapsed') === '1'; } catch (_) {} apply(collapsed); if (tBtn) tBtn.addEventListener('click', () => { collapsed = !collapsed; try { localStorage.setItem('ttsvc_ab_settings_collapsed', collapsed ? '1' : '0'); } catch (_) {} apply(collapsed); }); })(); // Prompt Library logic let savedPrompts = []; try { savedPrompts = JSON.parse(localStorage.getItem('ttsvc_ab_prompts') || '[]'); } catch (_) { savedPrompts = []; } const libSelect = panel.querySelector('#ab-cv-prompt-lib'); const delBtn = panel.querySelector('#ab-cv-prompt-del'); const promptText = panel.querySelector('#ab-cv-prompt-text'); const renderPromptLib = (selectedIdx = -1) => { libSelect.innerHTML = '' + savedPrompts.map((p, i) => ``).join(''); if (selectedIdx >= 0) { libSelect.value = selectedIdx; delBtn.style.display = 'block'; } else { libSelect.value = ''; delBtn.style.display = 'none'; } }; renderPromptLib(); libSelect.addEventListener('change', () => { const idx = parseInt(libSelect.value); const nameInput = panel.querySelector('#ab-cv-prompt-name'); if (!isNaN(idx) && savedPrompts[idx]) { promptText.value = savedPrompts[idx].prompt; if (nameInput) nameInput.value = savedPrompts[idx].name; delBtn.style.display = 'block'; } else { if (nameInput) nameInput.value = ''; delBtn.style.display = 'none'; } }); delBtn.addEventListener('click', () => { const idx = parseInt(libSelect.value); if (isNaN(idx)) return; if (confirm('Delete this saved prompt preset?')) { savedPrompts.splice(idx, 1); localStorage.setItem('ttsvc_ab_prompts', JSON.stringify(savedPrompts)); renderPromptLib(); toast('Prompt deleted', 'success'); } }); panel.querySelector('#ab-cv-prompt-save').addEventListener('click', async () => { const val = promptText.value.trim(); if (!val) { toast('Prompt is empty', 'error'); return; } const nameInput = panel.querySelector('#ab-cv-prompt-name'); const name = nameInput.value.trim() || 'Custom Prompt ' + (savedPrompts.length + 1); let targetIdx = parseInt(libSelect.value); if (!isNaN(targetIdx) && savedPrompts[targetIdx] && savedPrompts[targetIdx].name === name) { savedPrompts[targetIdx].prompt = val; } else { savedPrompts.push({ name, prompt: val }); targetIdx = savedPrompts.length - 1; } localStorage.setItem('ttsvc_ab_prompts', JSON.stringify(savedPrompts)); renderPromptLib(targetIdx); toast('Prompt preset saved', 'success'); // Also save as global default for next time if (typeof _appSettings !== 'undefined') _appSettings.audiobook_prompt = val; try { await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ audiobook_prompt: val }) }); } catch (e) {} }); // Sync the LLM engine dropdown with global options const syncEngineSelect = () => { const localSel = panel.querySelector('#ab-cv-llm-url'); const globalSel = document.getElementById('llm-active-url'); if (globalSel && localSel) { const currentVal = localSel.value; localSel.innerHTML = globalSel.innerHTML; localSel.value = currentVal || globalSel.value || ''; } }; syncEngineSelect(); // Fetch available models and populate the select drop-down const loadModels = () => { const urlInput = panel.querySelector('#ab-cv-llm-url'); const sel = panel.querySelector('#ab-cv-llm-select'); if (!sel || !urlInput) return; const currentUrl = urlInput.value.trim(); const oldVal = sel.value; sel.innerHTML = ''; fetch('/api/conversation/llm-models' + (currentUrl ? '?url=' + encodeURIComponent(currentUrl) : '')) .then(r => r.json()) .then(d => { if (d.models && d.models.length) { sel.innerHTML = d.models.map(m => ``).join(''); if (oldVal && d.models.includes(oldVal)) sel.value = oldVal; else if (d.models.includes(defaultModel)) sel.value = defaultModel; } else { sel.innerHTML = ``; } }).catch(() => { sel.innerHTML = ``; }); }; loadModels(); panel.querySelector('#ab-cv-llm-refresh').addEventListener('click', loadModels); const urlInput = panel.querySelector('#ab-cv-llm-url'); if (urlInput) urlInput.addEventListener('change', loadModels); const applyPromptAndRun = (callback) => { const newPrompt = panel.querySelector('#ab-cv-prompt-text').value; const currentUrl = panel.querySelector('#ab-cv-llm-url')?.value.trim(); const currentModel = panel.querySelector('#ab-cv-llm-select')?.value; if (typeof _appSettings !== 'undefined') _appSettings.audiobook_prompt = newPrompt; fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ audiobook_prompt: newPrompt }) }) .finally(() => { if (callback) callback(currentUrl, currentModel); }); }; if (isIdle) { const hasSegs = _audiobook.segments && _audiobook.segments.length > 0; panel.querySelector('#ab-cv-status-msg').style.display = 'inline-block'; if (hasSegs) { const recastUnkBtn = panel.querySelector('#ab-cv-start-recast-unk'); const recastBtn = panel.querySelector('#ab-cv-start-recast'); recastUnkBtn.style.display = 'inline-block'; recastBtn.style.display = 'inline-block'; recastUnkBtn.addEventListener('click', () => applyPromptAndRun(audiobookRecastUnknown)); recastBtn.addEventListener('click', () => applyPromptAndRun(audiobookCast)); panel.querySelector('#ab-cv-status-msg').textContent = 'Ready to recast.'; } else { const castBtn = panel.querySelector('#ab-cv-start-cast'); castBtn.style.display = 'inline-block'; castBtn.addEventListener('click', () => applyPromptAndRun(audiobookCast)); } } else { panel.querySelector('#ab-cv-cancel').innerHTML = ' Stop Casting'; } const fill = panel.querySelector('#ab-cv-fill'), count = panel.querySelector('#ab-cv-count'); const feed = panel.querySelector('#ab-cv-feed'), chars = panel.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 highlightText = (text) => { if (!text) return ''; let html = escHtml(text); const names = [...roster.keys()] .filter(n => n.toLowerCase() !== 'narrator' && !/^Unknown|Unbekannt/i.test(n)) .sort((a, b) => b.length - a.length); for (const name of names) { if (name.length < 2) continue; const safeName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const regex = new RegExp(`\\b(${safeName})\\b`, 'gi'); html = html.replace(regex, (match) => { return `${match}`; }); } return html; }; // ── Character selection bar ──────────────────────────────────────────────── // Clicking a character shows a compact bar above the feed with avatar, name, // prev/next line navigation, a search box, and a "Profil" button that opens // the full character sheet (hiding the feed temporarily). const feedWrap = panel.querySelector('.ab-cv-feed-wrap'); const jumpBtn = panel.querySelector('#ab-cv-jump-btn'); // Insert bar above the feed (once) const _abBar = document.createElement('div'); _abBar.className = 'ab-char-bar'; _abBar.hidden = true; feedWrap.insertBefore(_abBar, feedWrap.firstChild); let _abCharRows = []; // rows in feed for selected character let _abNavIdx = 0; let _abDetailEl = null; // full profile panel (when Profil is open) const _abClearHL = () => { feed.querySelectorAll('.ab-cv-row.ab-char-hl, .ab-cv-row.ab-char-focus') .forEach(r => r.classList.remove('ab-char-hl', 'ab-char-focus')); }; const _abCloseProfile = () => { if (_abDetailEl) { _abDetailEl.remove(); _abDetailEl = null; } feed.style.display = ''; if (jumpBtn && typeof _userScrolled !== 'undefined') jumpBtn.hidden = !_userScrolled; _abCharRows.forEach(r => r.classList.add('ab-char-hl')); const btn = _abBar.querySelector('.ab-char-bar-profile'); if (btn) btn.innerHTML = ' Profil'; }; const _abCloseBar = () => { _abBar.hidden = true; _abBar.innerHTML = ''; _abCloseProfile(); _abClearHL(); _abCharRows = []; chars.querySelectorAll('.ab-char-item').forEach(el => el.classList.remove('is-active')); }; const _abNav = (dir, pool) => { const rows = pool || _abCharRows; if (!rows.length) return; _abNavIdx = ((_abNavIdx + dir) + rows.length) % rows.length; feed.querySelectorAll('.ab-cv-row.ab-char-focus').forEach(r => r.classList.remove('ab-char-focus')); rows[_abNavIdx].classList.add('ab-char-focus'); rows[_abNavIdx].scrollIntoView({ behavior: 'smooth', block: 'center' }); const pos = _abBar.querySelector('.ab-char-bar-pos'); if (pos) pos.textContent = (_abNavIdx + 1) + ' / ' + rows.length; }; // Open the full character profile (hides feed) const _abShowProfile = async (name) => { _abCloseProfile(); feed.style.display = 'none'; if (jumpBtn) jumpBtn.hidden = true; const profileBtn = _abBar.querySelector('.ab-char-bar-profile'); if (profileBtn) profileBtn.innerHTML = ' Skript'; const title = window.readerState?.title || ''; let rec = null; try { const all = typeof clGetAllByTagOrBook === 'function' ? await clGetAllByTagOrBook(title) : (typeof clGetAll === 'function' ? await clGetAll() : []); rec = all.find(r => (r.name || '').toLowerCase() === name.toLowerCase()); } catch (_) {} const detail = document.createElement('div'); detail.className = 'ab-char-detail-panel'; _abDetailEl = detail; if (!rec) { detail.innerHTML = '' + '
Kein Charakterblatt – zuerst „Cast Characters" ausführen.
'; feedWrap.appendChild(detail); detail.querySelector('.ab-char-detail-back').addEventListener('click', _abCloseProfile); return; } const sh = rec.sheet || {}; const hue = _abCharHue(rec.name), hue2 = (hue + 40) % 360; const tier = String(sh.tier || '').toLowerCase(); const tierLabel = tier === 'main' ? 'Hauptcharakter' : tier === 'supporting' ? 'Nebencharakter' : 'Nebenfigur'; const voiceId = rec.voice ? (typeof rec.voice === 'object' ? (rec.voice.id || '') : String(rec.voice)) : ''; const pct = rec.sheet?.moral_alignment_score != null ? Math.max(0, Math.min(100, rec.sheet.moral_alignment_score)) : null; const arcMap = { 'good-to-bad':'↘ Entwicklung zum Bösen','bad-to-good':'↗ Wandel zum Guten','complex':'↕ Komplex','stable-good':'→ Stabil gut','stable-bad':'→ Stabil böse','neutral':'→ Neutral' }; const avatarHtml = rec.image ? `
${escHtml(rec.name)}
` : `
${escHtml((rec.name||'?')[0].toUpperCase())}
`; // field(label, value, sheet-key) — key enables editing when pencil is active const field = (lbl, val, sk) => { const v = _abStr(val); if (!v && !sk) return ''; return `
${lbl}
${escHtml(v)}
`; }; // Sources section const srcItems = Array.isArray(rec.sources) ? rec.sources : []; const sourcesHtml = srcItems.length ? `
Quellen im Text
${srcItems.map(s => `Seite ${s.page||'?'}`).join('')}
` : ''; detail.innerHTML = `
${avatarHtml}
${escHtml(rec.name)}
${_abStr(sh.aliases) ? `
auch bekannt als ${escHtml(_abStr(sh.aliases))}
` : ''} ${_abStr(sh.archetype) ? `
${escHtml(_abStr(sh.archetype))}
` : ''}
${tierLabel}
${voiceId ? escHtml(voiceId) : 'Noch keine Stimme'}
${pct != null ? `
Böse
Gut
${arcMap[sh.arc_direction||'neutral']||'→'} · ${pct>=70?'Rechtschaffen':pct<=30?'Böse':'Ambivalent'} (${pct}/100)
` : ''}
${field('Körperlich',sh.physical,'physical')}${field('Kleidung',sh.clothing,'clothing')}
${field('Eigenheiten',sh.mannerisms,'mannerisms')}${field('Stimme & Sprache',sh.voice_pattern,'voice_pattern')}
${field('Hintergrund',sh.backstory,'backstory')}${field('Motivation',sh.motivation,'motivation')}
${field('Fertigkeiten',sh.skills,'skills')}${field('Besondere Fähigkeiten',sh.capabilities,'capabilities')}
${field('',sh.relationships,'relationships')}
${sourcesHtml}
`; feedWrap.appendChild(detail); detail.querySelector('.ab-char-detail-back').addEventListener('click', _abCloseProfile); detail.querySelector('.ab-cd-pick')?.addEventListener('click', () => { if (typeof _openVoicePicker === 'function') _openVoicePicker(detail.querySelector('.lcd-voice-top'), rec, async () => { const up = (typeof clGetAll === 'function' ? await clGetAll() : []).find(r => r.id === rec.id); if (up) { _abCloseProfile(); _abShowProfile(up.name); } }); }); detail.querySelector('.ab-cd-auto')?.addEventListener('click', async () => { if (typeof _autoAssignVoice === 'function') { await _autoAssignVoice(rec); const up = (typeof clGetAll === 'function' ? await clGetAll() : []).find(r => r.id === rec.id); if (up) { _abCloseProfile(); _abShowProfile(up.name); } } }); detail.querySelector('.ab-cd-online')?.addEventListener('click', () => { if (typeof _charSearchOnline === 'function') _charSearchOnline(rec); }); // Pencil edit toggle let _saveTimer = null; const editBtn = detail.querySelector('.ab-cd-edit-btn'); editBtn?.addEventListener('click', function () { const editing = detail.classList.toggle('ab-cd-editing'); this.innerHTML = editing ? '' : ''; this.title = editing ? 'Fertig' : 'Charakterblatt bearbeiten'; detail.querySelectorAll('.ab-editable').forEach(el => { el.contentEditable = editing ? 'true' : 'false'; if (editing) { el.addEventListener('input', function onInput() { clearTimeout(_saveTimer); _saveTimer = setTimeout(async () => { if (!rec.sheet) rec.sheet = {}; rec.sheet[el.dataset.sk] = el.textContent.trim(); if (typeof clPut === 'function') await clPut(rec); }, 900); }); } }); if (editing) detail.querySelector('.ab-editable')?.focus(); }); // Source page clicks → jump to reader detail.querySelectorAll('.ab-cd-src').forEach(el => { el.addEventListener('click', () => { const pg = parseInt(el.dataset.page, 10); if (!pg) return; if (typeof navTo === 'function') navTo('s-reader'); setTimeout(() => { const pages = window.readerState?.pages; if (pages && pages.length >= pg && pages[pg-1]?.pageDiv) { pages[pg-1].pageDiv.scrollIntoView({ behavior: 'smooth', block: 'start' }); } else if (typeof toast === 'function') { toast('Buch im „Vorlesen"-Bereich öffnen, dann nochmal klicken', 'info'); } }, 300); }); }); }; // Select a character: show bar + highlight rows, keep feed visible const _abSelectChar = (name) => { // Toggle off if same character clicked again if (!_abBar.hidden && _abBar.dataset.charName === name) { _abCloseBar(); return; } _abCloseBar(); chars.querySelectorAll('.ab-char-item').forEach(el => el.classList.toggle('is-active', el.dataset.name === name)); _abCharRows = Array.from(feed.querySelectorAll('.ab-cv-row')).filter(r => r.__seg?.speaker && r.__seg.speaker.toLowerCase() === name.toLowerCase() ); _abNavIdx = 0; const hue = _abCharHue(name); _abBar.dataset.charName = name; _abBar.hidden = false; _abBar.innerHTML = `${(name||'?')[0].toUpperCase()}
${escHtml(name)} ${_abCharRows.length} Zeilen
1 / ${_abCharRows.length}
`; // highlight all + jump to first _abCharRows.forEach(r => r.classList.add('ab-char-hl')); if (_abCharRows.length) { _abCharRows[0].classList.add('ab-char-focus'); _abCharRows[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); } _abBar.querySelector('.ab-char-bar-prev').addEventListener('click', () => _abNav(-1)); _abBar.querySelector('.ab-char-bar-next').addEventListener('click', () => _abNav(1)); _abBar.querySelector('.ab-char-bar-profile').addEventListener('click', () => { if (_abDetailEl) _abCloseProfile(); else _abShowProfile(name); }); _abBar.querySelector('.ab-char-bar-close').addEventListener('click', _abCloseBar); // Search within this character's rows const inp = _abBar.querySelector('.ab-char-bar-search'); let _st = null; const _doSearch = (jump) => { const q = inp.value.trim().toLowerCase(); const pool = q ? _abCharRows.filter(r => (r.__seg?.text || r.querySelector('.ab-cv-txt')?.textContent || '').toLowerCase().includes(q) ) : _abCharRows; feed.querySelectorAll('.ab-cv-row.ab-char-focus').forEach(r => r.classList.remove('ab-char-focus')); if (pool.length) { if (jump) _abNavIdx = (_abNavIdx + 1) % pool.length; else _abNavIdx = 0; pool[_abNavIdx].classList.add('ab-char-focus'); pool[_abNavIdx].scrollIntoView({ behavior: 'smooth', block: 'center' }); _abBar.querySelector('.ab-char-bar-pos').textContent = (_abNavIdx + 1) + ' / ' + pool.length; } else { _abBar.querySelector('.ab-char-bar-pos').textContent = '0 Treffer'; } }; inp.addEventListener('input', () => { clearTimeout(_st); _st = setTimeout(() => _doSearch(false), 200); }); inp.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); _doSearch(true); } }); }; const renderRoster = () => { const items = [...roster.entries()].filter(([, info]) => info.count > 0).sort((a, b) => b[1].count - a[1].count); if (!items.length) { chars.innerHTML = 'reading…'; return; } const prev = _abBar.hidden ? null : _abBar.dataset.charName; chars.innerHTML = items.map(([n]) => { const hue = _abCharHue(n); return `
${escHtml((n||'?')[0].toUpperCase())} ${escHtml(n)} ${roster.get(n)?.count||0}
`; }).join(''); chars.querySelectorAll('.ab-char-item').forEach(item => { item.addEventListener('click', () => _abSelectChar(item.dataset.name)); }); }; const MAXROWS = 600; // keep most of the book visible; only trim when user is at bottom // Whether the user has scrolled up to review/edit earlier content. // While true: auto-scroll and trim are suppressed so their position is preserved. let _userScrolled = false; // jumpBtn already declared above (in the character-detail block) feed.addEventListener('scroll', () => { const atBottom = feed.scrollTop + feed.clientHeight >= feed.scrollHeight - 80; _userScrolled = !atBottom; if (jumpBtn) jumpBtn.hidden = atBottom; }, { passive: true }); const _scrollToBottom = () => { feed.scrollTop = feed.scrollHeight; }; if (jumpBtn) { jumpBtn.addEventListener('click', () => { _userScrolled = false; jumpBtn.hidden = true; _scrollToBottom(); }); } const trim = () => { if (_userScrolled) return; // don't touch the feed while user is reading/editing while (feed.childElementCount > MAXROWS) feed.removeChild(feed.firstChild); _scrollToBottom(); }; // Manual character assignment logic let assignModeSeg = null; let assignModeRow = null; let assignPopup = document.getElementById('ab-cv-assign-popup'); if (!assignPopup) { assignPopup = document.createElement('div'); assignPopup.id = 'ab-cv-assign-popup'; assignPopup.style.cssText = 'position:fixed; z-index:2001; display:none; background:var(--surface); border:1px solid var(--border); border-radius:8px; box-shadow:0 10px 25px rgba(0,0,0,0.4); padding:10px; width:220px; max-height:320px; flex-direction:column; gap:8px;'; const header = document.createElement('div'); header.style.cssText = 'display:flex; justify-content:space-between; align-items:center; margin-bottom:-4px; margin-top:-4px;'; header.innerHTML = 'Assign to'; assignPopup.appendChild(header); const inp = document.createElement('input'); inp.type = 'text'; inp.placeholder = 'Search or add character...'; inp.style.cssText = 'width:100%; padding:6px; font-size:13px; border:1px solid var(--border); border-radius:4px; background:var(--bg); color:var(--text);'; assignPopup.appendChild(inp); const list = document.createElement('div'); list.className = 'ab-cv-popup-list'; list.style.cssText = 'display:flex; flex-direction:column; gap:2px; overflow-y:auto; max-height:220px; margin-right:-4px; padding-right:4px;'; assignPopup.appendChild(list); document.body.appendChild(assignPopup); header.querySelector('.mdi-close').addEventListener('click', closeAssignPopup); document.addEventListener('mousedown', e => { if (assignPopup.style.display !== 'none' && !assignPopup.contains(e.target) && !e.target.closest('.ab-cv-spk')) { closeAssignPopup(); } }); document.addEventListener('keydown', e => { if (e.key === 'Escape' && assignPopup.style.display !== 'none') { closeAssignPopup(); } }); inp.addEventListener('keydown', e => { if (e.key === 'Enter') { const addBtn = assignPopup.querySelector('.ab-cv-popup-add-btn'); const visibleBtns = Array.from(assignPopup.querySelectorAll('.ab-cv-popup-list > div[data-name]')).filter(b => b.style.display !== 'none'); if (addBtn && addBtn.style.display !== 'none') { addBtn.click(); } else if (visibleBtns.length === 1) { visibleBtns[0].click(); } else { const val = inp.value.trim(); if (val) assignName(val); } } else if (e.key === 'Escape') { closeAssignPopup(); } }); inp.addEventListener('input', () => { const val = inp.value.trim().toLowerCase(); const listItems = assignPopup.querySelectorAll('.ab-cv-popup-list > div[data-name]'); let exactMatch = false; listItems.forEach(item => { const name = item.dataset.name.toLowerCase(); if (name === val) exactMatch = true; if (name.includes(val)) item.style.display = 'flex'; else item.style.display = 'none'; }); let addBtn = assignPopup.querySelector('.ab-cv-popup-add-btn'); if (val && !exactMatch && val !== 'narrator') { if (!addBtn) { addBtn = document.createElement('div'); addBtn.className = 'ab-cv-popup-add-btn'; addBtn.style.cssText = 'padding:6px 8px; font-size:12px; cursor:pointer; border-radius:4px; display:flex; align-items:center; gap:6px; transition:background 0.1s; font-weight:600; color:var(--primary); border-top:1px solid var(--border); margin-top:4px; padding-top:8px;'; addBtn.onmouseover = () => addBtn.style.background = 'var(--panel)'; addBtn.onmouseout = () => addBtn.style.background = 'transparent'; } addBtn.innerHTML = `+ Add "${escHtml(inp.value.trim())}"`; addBtn.onclick = () => assignName(inp.value.trim()); addBtn.style.display = 'flex'; assignPopup.querySelector('.ab-cv-popup-list').appendChild(addBtn); // keep at bottom } else if (addBtn) { addBtn.style.display = 'none'; } }); } function closeAssignPopup() { assignPopup.style.display = 'none'; if (assignModeRow) assignModeRow.classList.remove('is-assigning'); assignModeSeg = null; assignModeRow = null; } function assignName(name) { if (!assignModeSeg) return; const isNarrator = name.toLowerCase() === 'narrator'; // Decrement old speaker's count before reassigning const _oldSpk = assignModeSeg.speaker; if (_oldSpk && roster.has(_oldSpk)) { const _old = roster.get(_oldSpk); if (_old.count > 0) _old.count--; } assignModeSeg.speaker = isNarrator ? 'Narrator' : name; assignModeSeg.type = isNarrator ? 'narration' : 'dialogue'; if (isNarrator) assignModeSeg.emotion = ''; assignModeRow.classList.remove('is-assigning'); const speakerName = isNarrator ? 'Narrator' : name; const c = colorFor(speakerName); if (!roster.has(speakerName)) roster.set(speakerName, { count: 0, color: c }); roster.get(speakerName).count++; assignModeRow.className = 'ab-cv-row' + (isNarrator ? ' is-narr' : ''); assignModeRow.querySelector('.ab-cv-spk').innerHTML = `${escHtml(speakerName)}${assignModeSeg.emotion ? ' (' + escHtml(assignModeSeg.emotion) + ')' : ''}`; assignModeRow.querySelector('.ab-cv-spk').style.color = c; assignModeRow.querySelector('.ab-cv-spk').title = 'Click to assign character'; renderRoster(); toast(`Assigned to ${isNarrator ? 'Narrator' : name}`, 'success'); closeAssignPopup(); // Persist manual corrections to both localStorage (fast) and IndexedDB (durable) if (_audiobook.lastText) { setTimeout(() => _abSaveDraft(_audiobook.segments || [], _audiobook.roster || [], _audiobook.lastText, -1, -1), 50); } _audiobookDebouncedSave(); } feed.addEventListener('click', e => { const spk = e.target.closest('.ab-cv-spk'); if (spk) { const row = spk.closest('.ab-cv-row'); if (row && row.__seg && !row.classList.contains('is-processing')) { if (assignModeRow) assignModeRow.classList.remove('is-assigning'); assignModeSeg = row.__seg; assignModeRow = row; row.classList.add('is-assigning'); window.getSelection().removeAllRanges(); assignPopup.style.display = 'flex'; const rect = spk.getBoundingClientRect(); // ensure popup doesn't go off bottom of screen const top = Math.min(rect.bottom + 4, window.innerHeight - 300); assignPopup.style.top = top + 'px'; assignPopup.style.left = rect.left + 'px'; const list = assignPopup.querySelector('.ab-cv-popup-list'); list.innerHTML = ''; // Always show Narrator as the first option const narrBtn = document.createElement('div'); narrBtn.dataset.name = 'Narrator'; narrBtn.style.cssText = 'padding:6px 8px; font-size:12px; cursor:pointer; border-radius:4px; display:flex; align-items:center; gap:6px; transition:background 0.1s; font-weight:600; color:var(--subtext); border-bottom:1px solid var(--border); margin-bottom:4px; padding-bottom:8px;'; narrBtn.innerHTML = `📖 Narrator`; narrBtn.onmouseover = () => narrBtn.style.background = 'var(--panel)'; narrBtn.onmouseout = () => narrBtn.style.background = 'transparent'; narrBtn.onclick = () => assignName('Narrator'); list.appendChild(narrBtn); const items = [...roster.entries()].sort((a, b) => b[1].count - a[1].count); for (const [n, info] of items) { const btn = document.createElement('div'); btn.dataset.name = n; btn.style.cssText = 'padding:6px 8px; font-size:12px; cursor:pointer; border-radius:4px; display:flex; align-items:center; gap:6px; transition:background 0.1s; font-weight:600; color:var(--text);'; btn.innerHTML = ` ${escHtml(n)}`; btn.onmouseover = () => btn.style.background = 'var(--panel)'; btn.onmouseout = () => btn.style.background = 'transparent'; btn.onclick = () => assignName(n); list.appendChild(btn); } const inp = assignPopup.querySelector('input'); inp.value = ''; setTimeout(() => inp.focus(), 50); } } }); let splitBtn = document.getElementById('ab-cv-split-btn'); if (!splitBtn) { splitBtn = document.createElement('button'); splitBtn.id = 'ab-cv-split-btn'; splitBtn.className = 'btn-secondary btn-sm'; splitBtn.innerHTML = ' Split text to Unknown Speaker'; splitBtn.style.cssText = 'position:fixed; z-index:2000; display:none; background:var(--accent); color:#fff; border:none; box-shadow:0 4px 12px rgba(0,0,0,0.3);'; document.body.appendChild(splitBtn); } let currentSplitState = null; document.addEventListener('selectionchange', () => { if (!feed) return; // Panel closed const sel = window.getSelection(); if (sel.isCollapsed || !feed.contains(sel.anchorNode)) { if (splitBtn) splitBtn.style.display = 'none'; currentSplitState = null; return; } const txtSpan = sel.anchorNode.nodeType === 3 ? sel.anchorNode.parentNode.closest('.ab-cv-txt') : sel.anchorNode.closest('.ab-cv-txt'); if (!txtSpan) { splitBtn.style.display = 'none'; return; } const row = txtSpan.closest('.ab-cv-row'); if (!row || !row.__seg) return; const text = sel.toString().trim(); if (text.length > 0) { // If we are in assign mode and they selected a short text, it's for assigning a name. Don't show split. if (assignModeSeg && text.length < 40 && !text.includes('\n')) { splitBtn.style.display = 'none'; return; } const rect = sel.getRangeAt(0).getBoundingClientRect(); splitBtn.style.display = 'block'; splitBtn.style.top = (rect.bottom + 8) + 'px'; splitBtn.style.left = Math.max(10, rect.left + (rect.width / 2) - 100) + 'px'; currentSplitState = { row, text, seg: row.__seg }; } else { splitBtn.style.display = 'none'; currentSplitState = null; } }); splitBtn.addEventListener('click', () => { if (!currentSplitState) return; const { row, text, seg } = currentSplitState; const fullText = seg.text || ''; const idx = fullText.indexOf(text); if (idx === -1) return; const before = fullText.substring(0, idx).trim(); const after = fullText.substring(idx + text.length).trim(); // Try committed segments first, then live array (during active casting) let arr = _audiobook.segments || []; let globalIdx = arr.indexOf(seg); if (globalIdx === -1 && Array.isArray(_audiobook.liveSegments)) { arr = _audiobook.liveSegments; globalIdx = arr.indexOf(seg); } const newSegs = []; if (before) newSegs.push({ speaker: seg.speaker, type: seg.type, emotion: seg.emotion, text: before }); newSegs.push({ speaker: 'Unknown', type: 'dialogue', emotion: '', text: text }); if (after) newSegs.push({ speaker: seg.speaker, type: seg.type, emotion: seg.emotion, text: after }); if (globalIdx !== -1) { arr.splice(globalIdx, 1, ...newSegs); } else { // DOM-only split — will be reconciled once casting finishes console.warn('[audiobook] split during casting: DOM-only, segment not yet in array'); } const frag = document.createDocumentFragment(); for (const s of newSegs) { const isNarrator = s.type !== 'dialogue' || !s.speaker || s.speaker.toLowerCase() === 'narrator'; const speakerName = isNarrator ? 'Narrator' : s.speaker; const c = colorFor(speakerName); const newRow = document.createElement('div'); newRow.className = 'ab-cv-row' + (isNarrator ? ' is-narr' : ''); newRow.__seg = s; newRow.innerHTML = `${escHtml(speakerName)}${s.emotion ? ' (' + escHtml(s.emotion) + ')' : ''}${highlightText(s.text || '')}`; frag.appendChild(newRow); } row.parentNode.insertBefore(frag, row); row.remove(); splitBtn.style.display = 'none'; window.getSelection().removeAllRanges(); toast('Segment split! You can now assign a character to the Unknown block.', 'success'); }); feed.addEventListener('mouseup', () => { if (!assignModeSeg) return; const sel = window.getSelection(); if (!sel.isCollapsed && feed.contains(sel.anchorNode)) { const text = sel.toString().trim(); if (text && text.length < 40 && !text.includes('\n')) { assignName(text); sel.removeAllRanges(); } } }); chars.addEventListener('click', e => { const chip = e.target.closest('.ab-chip'); if (chip) { let name = ''; for (const n of chip.childNodes) if (n.nodeType === 3) name += n.nodeValue; name = name.trim(); if (assignModeSeg) { if (name) assignName(name); } else if (name) { // Scroll to the next occurrence of this character in the feed const rows = Array.from(feed.querySelectorAll('.ab-cv-row')).filter(r => r.__seg && r.__seg.speaker === name); if (!rows.length) return; const currentY = feed.scrollTop; let target = rows.find(r => (r.offsetTop - feed.offsetTop) > currentY + 10); if (!target) target = rows[0]; // loop around to the top target.scrollIntoView({ behavior: 'smooth', block: 'center' }); // Highlight briefly target.style.transition = 'background 0.3s'; target.style.background = 'var(--accent-hover, rgba(100, 150, 255, 0.2))'; setTimeout(() => { if (target.parentNode) target.style.background = ''; }, 1000); } } }); return { update(done) { const castBar = panel.querySelector('.ab-castpanel-bar'); if (castBar) castBar.hidden = false; const pct = Math.round((done / total) * 100); if (fill) fill.style.width = pct + '%'; if (count) { count.hidden = false; count.textContent = `passage ${done} / ${total}`; } const fillText = panel.querySelector('#ab-cv-fill-text'); if (fillText) fillText.textContent = `${pct}% (Passage ${done} of ${total})`; }, processing(text) { if (this._procRow) this._procRow.remove(); this._procRow = document.createElement('div'); this._procRow.className = 'ab-cv-row is-processing ab-cv-llm-row'; const isLong = text.length > 160; const preview = escHtml(text.slice(0, 160)) + (isLong ? '…' : ''); this._procRow.innerHTML = `
LLM Reading… ${isLong ? '' : ''}
${preview}
${isLong ? `` : ''} `; if (isLong) { this._procRow.querySelector('.ab-cv-expand-btn').addEventListener('click', function () { const full = this.closest('.ab-cv-llm-row').querySelector('.ab-cv-llm-full'); full.hidden = !full.hidden; const icon = this.querySelector('span'); if (icon) icon.className = full.hidden ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-up'; }); } feed.appendChild(this._procRow); trim(); }, clearProcessing() { if (this._procRow) { this._procRow.remove(); this._procRow = null; } }, addSegments(segs) { this.clearProcessing(); feed.querySelector('.ab-skel-feed')?.remove(); chars.querySelector('.ab-skel-chars')?.remove(); const makeRow = (s) => { const isNarrator = s.type !== 'dialogue' || !s.speaker || s.speaker.toLowerCase() === 'narrator'; const speakerName = isNarrator ? 'Narrator' : s.speaker; const c = colorFor(speakerName); roster.get(speakerName).count++; const row = document.createElement('div'); row.className = 'ab-cv-row' + (isNarrator ? ' is-narr' : ''); row.__seg = s; row.innerHTML = `${escHtml(speakerName)}${s.emotion ? ' (' + escHtml(s.emotion) + ')' : ''}${highlightText(s.text || '')}`; return row; }; const CHUNK = 80; // Render first chunk immediately so the screen fills fast const first = segs.slice(0, CHUNK); const frag0 = document.createDocumentFragment(); first.forEach(s => frag0.appendChild(makeRow(s))); feed.appendChild(frag0); renderRoster(); // Stream remaining chunks without blocking the main thread if (segs.length > CHUNK) { let i = CHUNK; const next = () => { if (i >= segs.length) { trim(); renderRoster(); return; } const batch = document.createDocumentFragment(); const end = Math.min(i + CHUNK, segs.length); for (; i < end; i++) batch.appendChild(makeRow(segs[i])); feed.appendChild(batch); renderRoster(); setTimeout(next, 0); }; setTimeout(next, 0); } else { trim(); } }, note(text) { const r = document.createElement('div'); r.className = 'ab-cv-note'; r.textContent = text; feed.appendChild(r); trim(); }, // Visual gap marking that the passages before/after are not contiguous. // prevIdx / nextIdx are indices into _audiobook.segments for the gap boundaries. // Click expands the divider to show all segments in the gap inline. divider(prevIdx, nextIdx) { this.clearProcessing(); const r = document.createElement('div'); r.className = 'ab-cv-divider ab-cv-divider-expand'; const gapFrom = Math.max(0, (prevIdx >= 0 ? prevIdx + 1 : 0)); const segsAll = _audiobook.segments || []; const gapTo = Math.min(segsAll.length - 1, (nextIdx >= 0 ? nextIdx - 1 : segsAll.length - 1)); const gapCount = Math.max(0, gapTo - gapFrom + 1); r.innerHTML = `⋯ ${gapCount > 0 ? gapCount + ' line' + (gapCount !== 1 ? 's' : '') + ' hidden — ' : ''}click to expand`; r.title = 'Click to reveal the text between these passages'; r.addEventListener('click', () => { const gap = segsAll.slice(gapFrom, gapTo + 1); if (!gap.length) { r.remove(); return; } const PEEK = 20; const show = gap.slice(0, PEEK); const rest = gap.slice(PEEK); const frag = document.createDocumentFragment(); for (const s of show) { const isNarr = s.type !== 'dialogue' || !s.speaker || s.speaker.toLowerCase() === 'narrator'; const spk = isNarr ? 'Narrator' : s.speaker; const c = colorFor(spk); const row = document.createElement('div'); row.className = 'ab-cv-row ab-cv-ctx-row' + (isNarr ? ' is-narr' : ''); row.__seg = s; row.innerHTML = `${escHtml(spk)}${s.emotion ? ' (' + escHtml(s.emotion) + ')' : ''}${highlightText(s.text || '')}`; frag.appendChild(row); } if (rest.length) { // Insert a new collapsed divider for the remaining lines const next = document.createElement('div'); next.className = 'ab-cv-divider ab-cv-divider-expand'; next.innerHTML = `⋯ ${rest.length} line${rest.length !== 1 ? 's' : ''} hidden — click to expand`; next.title = 'Click to reveal more'; const restSegs = rest; // closure next.addEventListener('click', function onClick() { const f2 = document.createDocumentFragment(); for (const s of restSegs) { const isNarr = s.type !== 'dialogue' || !s.speaker || s.speaker.toLowerCase() === 'narrator'; const spk = isNarr ? 'Narrator' : s.speaker; const c = colorFor(spk); const row = document.createElement('div'); row.className = 'ab-cv-row ab-cv-ctx-row' + (isNarr ? ' is-narr' : ''); row.__seg = s; row.innerHTML = `${escHtml(spk)}${s.emotion ? ' (' + escHtml(s.emotion) + ')' : ''}${highlightText(s.text || '')}`; f2.appendChild(row); } next.replaceWith(f2); }); frag.appendChild(next); } r.replaceWith(frag); }); feed.appendChild(r); trim(); }, // Page-break marker in the casting feed (from source PDF page boundaries). pagemark(pageNum) { const r = document.createElement('div'); r.className = 'ab-cv-pagemark'; r.innerHTML = ` Page ${pageNum}`; 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, onRecast, onRecastUnknown) { if (count) count.hidden = true; const castBar = panel.querySelector('.ab-castpanel-bar'); if (castBar) castBar.hidden = true; if (typeof allSegments !== 'undefined' && allSegments.length) { feed.innerHTML = ''; const frag = document.createDocumentFragment(); for (const s of allSegments) { const isNarrator = s.type !== 'dialogue' || !s.speaker || s.speaker.toLowerCase() === 'narrator'; const speakerName = isNarrator ? 'Narrator' : s.speaker; const c = colorFor(speakerName); const row = document.createElement('div'); row.className = 'ab-cv-row' + (isNarrator ? ' is-narr' : ''); row.__seg = s; row.innerHTML = `${escHtml(speakerName)}${s.emotion ? ' (' + escHtml(s.emotion) + ')' : ''}${highlightText(s.text || '')}`; frag.appendChild(row); } feed.appendChild(frag); } const cancelBtn = panel.querySelector('#ab-cv-cancel'); if (cancelBtn) cancelBtn.hidden = true; const foot = panel.querySelector('#ab-cv-foot'); foot.hidden = false; foot.innerHTML = ` ${escHtml(summary)}`; const runVerificationPass = () => { const verificationPrompt = `Du bist ein Qualitätsprüfer für die Analyse eines deutschen Hörbuchs. Eine erste KI hat den Textauszug bereits in Segmente unterteilt und jedem Segment einen Sprecher zugewiesen. Deine Aufgabe ist es JEDEN Sprecher-Zuweisung kritisch zu überprüfen und zu korrigieren. AUFGABE (2. Qualitätslauf — Verifizierung): 1. ÜBERPRÜFE jede Sprecher-Zuweisung: Ist der angegebene Sprecher wirklich derjenige, der spricht? Nutze den Kontext (Inquit-Formeln wie "sagte X", "fragte sie", "rief er", Handlungsbeschreibungen, das Ping-Pong-Prinzip zwischen Sprechern, und den Gesamtkontext der Szene). 2. KORRIGIERE falsch zugewiesene Sprecher mit dem korrekten Namen direkt aus dem Text. 3. LÖSE alle 'Unknown'-Segmente auf: Nutze umgebenden Kontext, die Reihenfolge der Sprecher und textliche Hinweise. 'Unknown' ist NUR dann erlaubt, wenn der Sprecher absolut nicht bestimmbar ist. 4. BEHALTE alle korrekten Zuweisungen exakt unverändert bei. DEFINITION VON DIALOG (KRITISCH): Text ist NUR 'dialogue', wenn er in Anführungszeichen steht (»...«, „...", "...", «...», <<...>>) oder mit einem Gedankenstrich (—) beginnt. ALLES ANDERE ist 'narration' (speaker: 'Narrator'). FÜR JEDES SEGMENT AUSGABE: - speaker: 'Narrator' für Narration, oder EXAKT der Name des Charakters. - type: 'narration' oder 'dialogue' - text: EXAKT der WORTWÖRTLICHE Originaltext — KEINE Änderungen, KEINE Auslassungen, KEINE Ergänzungen. - emotion: Bei Dialogen 1-2 deutsche Wörter für den Tonfall. Bei Narration leer (''). ABSOLUTE REGELN: - Alle Segmente zusammen MÜSSEN den Originaltext exakt, lückenlos und wortgetreu rekonstruieren. - Erfinde NIEMALS Text. Lasse NIEMALS Wörter weg. Füge NIEMALS etwas hinzu. - Mische NIEMALS Narration und Dialog in einem Segment.`; panel.querySelector('#ab-cv-prompt-text').value = verificationPrompt; foot.querySelector('#ab-cv-recast-unk').click(); }; foot.querySelector('#ab-cv-verify').addEventListener('click', runVerificationPass); foot.querySelector('#ab-cv-open-reh').addEventListener('click', async () => { // Ensure the record is saved, then open it in Script Rehearser if (!_audiobook.rehId) await audiobookSaveAsRehearsal({ silent: true }); if (_audiobook.rehId && typeof window.rehDbGetById === 'function' && typeof window.rehLoadRecord === 'function') { try { const rec = await window.rehDbGetById(_audiobook.rehId); if (rec) { closePanel(); window.rehLoadRecord(rec); if (typeof navTo === 'function') navTo('s-rehearser'); return; } } catch (_) {} } // Fallback: open rehearser via the old preview path closePanel(); onOpen(); }); const applyPromptAndRun = (callback) => { const newPrompt = panel.querySelector('#ab-cv-prompt-text').value; const currentUrl = panel.querySelector('#ab-cv-llm-url')?.value.trim(); const currentModel = panel.querySelector('#ab-cv-llm-select')?.value; if (typeof _appSettings !== 'undefined') _appSettings.audiobook_prompt = newPrompt; fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ audiobook_prompt: newPrompt }) }) .finally(() => { closePanel(); if (callback) callback(currentUrl, currentModel); }); }; foot.querySelector('#ab-cv-recast').addEventListener('click', () => applyPromptAndRun(audiobookCast)); foot.querySelector('#ab-cv-recast-unk').addEventListener('click', () => applyPromptAndRun(audiobookRecastUnknown)); foot.querySelector('#ab-cv-cast-chars').addEventListener('click', () => { if (typeof window.csForReader === 'function') window.csForReader(); else if (typeof csForReader === 'function') csForReader(); else toast('Character sheets not loaded yet', 'error'); }); panel.classList.add('ab-castpanel-done'); if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(false); }, done(options = {}) { const stopped = options.stopped || _audiobook.cancel; if (!stopped) { closePanel(); return; } this.clearProcessing(); if (count) count.hidden = true; const castBar = panel.querySelector('.ab-castpanel-bar'); if (castBar) castBar.hidden = true; feed.querySelector('.ab-skel-feed')?.remove(); chars.querySelector('.ab-skel-chars')?.remove(); const message = options.message || 'Casting stopped before any passage completed.'; if (!feed.querySelector('.ab-cv-row, .ab-cv-note, .ab-cv-divider, .ab-cv-pagemark')) { feed.innerHTML = ''; const r = document.createElement('div'); r.className = 'ab-cv-note'; r.textContent = message; feed.appendChild(r); } else if (options.message) { const r = document.createElement('div'); r.className = 'ab-cv-note'; r.textContent = message; feed.appendChild(r); } if (!chars.querySelector('.ab-char-item')) { chars.innerHTML = 'No completed characters yet.'; } const foot = panel.querySelector('#ab-cv-foot'); const hasSegments = Array.isArray(_audiobook.segments) && _audiobook.segments.length > 0; foot.hidden = false; foot.innerHTML = ` ${escHtml(message)}${hasSegments ? '' : ''}`; foot.querySelector('#ab-cv-stopped-back')?.addEventListener('click', closePanel); foot.querySelector('#ab-cv-stopped-review')?.addEventListener('click', audiobookShowPreview); foot.querySelector('#ab-cv-stopped-recast')?.addEventListener('click', () => { const newPrompt = panel.querySelector('#ab-cv-prompt-text')?.value; const currentUrl = panel.querySelector('#ab-cv-llm-url')?.value.trim(); const currentModel = panel.querySelector('#ab-cv-llm-select')?.value; if (typeof _appSettings !== 'undefined' && newPrompt) _appSettings.audiobook_prompt = newPrompt; fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ audiobook_prompt: newPrompt || '' }) }).finally(() => { closePanel(); audiobookCast(currentUrl, currentModel); }); }); panel.classList.add('ab-castpanel-done'); if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(false); }, // Show fresh "Ready to cast" state (used when server draft fetch returns empty) setFreshState() { feed.querySelector('.ab-skel-feed')?.remove(); chars.querySelector('.ab-skel-chars')?.remove(); const statusMsg = panel.querySelector('#ab-cv-status-msg'); if (statusMsg) statusMsg.style.display = 'inline-block'; const castBtn = panel.querySelector('#ab-cv-start-cast'); if (castBtn) { castBtn.style.display = 'inline-block'; castBtn.addEventListener('click', () => { const newPrompt = panel.querySelector('#ab-cv-prompt-text')?.value; const currentUrl = panel.querySelector('#ab-cv-llm-url')?.value.trim(); const currentModel = panel.querySelector('#ab-cv-llm-select')?.value; if (typeof _appSettings !== 'undefined' && newPrompt) _appSettings.audiobook_prompt = newPrompt; audiobookCast(currentUrl, currentModel); }); } }, }; } async function audiobookRecastUnknown(overrideUrl, overrideModel) { if (_audiobook.running) return; const segs = _audiobook.segments; if (!segs || !segs.length) return; const unknownIdxs = []; for (let i = 0; i < segs.length; i++) { if (segs[i].type === 'dialogue' && (!segs[i].speaker || /^Unknown|Unbekannt/i.test(segs[i].speaker))) { unknownIdxs.push(i); } } if (!unknownIdxs.length) { toast('No Unknown speakers found', 'success'); audiobookShowPreview(); return; } _audiobook.running = true; _audiobook.cancel = false; if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(true); const ac = new AbortController(); _audiobook.abort = () => ac.abort(); if (overrideUrl && typeof overrideUrl !== 'string') overrideUrl = null; const llm_url = overrideUrl || audiobookLlmUrl(), language = audiobookLang(); let model = overrideModel || audiobookLlmModel(); const view = audiobookCastView(unknownIdxs.length, llm_url, model); view.processing('Waking up LLM model (this may take a few minutes if cold-booting)…'); try { await audiobookFetchWithTimeout('/api/attribute-dialogue', { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: ac.signal, body: JSON.stringify({ text: 'Wake up.', known_characters: [], recent: '', language, llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url, model: document.getElementById('ab-cv-llm-select')?.value || model, timeout_seconds: audiobookTimeoutSeconds(AUDIOBOOK_WARMUP_TIMEOUT_MS) }) }, AUDIOBOOK_WARMUP_TIMEOUT_MS + 5000); } catch (err) { if (err.name === 'AbortError') { _audiobook.cancel = true; _audiobook.running = false; _audiobook.abort = null; view.done({ stopped: true, message: 'Recast stopped before any lines were updated.' }); toast('Recast stopped. Existing cast preserved.', 'info'); return; } } let done = 0; let prevIdx = -2; try { for (let i of unknownIdxs) { if (_audiobook.cancel) break; const targetSeg = segs[i]; // These Unknown lines come from scattered places in the document. Mark a // gap with "⋯" whenever this passage isn't directly after the previous one. if (i !== prevIdx + 1) view.divider(prevIdx, i); prevIdx = i; const start = Math.max(0, i - 12); const end = Math.min(segs.length, i + 6); const contextSegs = segs.slice(start, end); const passageText = contextSegs.map(s => { if (s.type === 'narration') return s.text; return `"${s.text}"`; }).join(' '); view.update(done++, `Attributing line ${done} / ${unknownIdxs.length}…`); view.processing(passageText.trim()); let data = null; try { const r = await audiobookFetchWithTimeout('/api/attribute-dialogue', { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: ac.signal, body: JSON.stringify({ text: passageText.trim(), known_characters: _audiobook.roster.slice(-40), recent: '', language, llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url, model: document.getElementById('ab-cv-llm-select')?.value || model, timeout_seconds: audiobookTimeoutSeconds(AUDIOBOOK_RECAST_TIMEOUT_MS) }) }, AUDIOBOOK_RECAST_TIMEOUT_MS + 5000); if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || e.error || r.statusText); } data = await r.json(); } catch (err) { if (err.name === 'AbortError') { _audiobook.cancel = true; break; } view.note(`API Error: ${err.message}`); continue; } if (data && data.segments) { const match = data.segments.find(s => s.type === 'dialogue' && targetSeg.text.includes(s.text.slice(0, 15))); if (match && match.speaker && !/^Unknown|Unbekannt/i.test(match.speaker)) { targetSeg.speaker = match.speaker; if (match.emotion) targetSeg.emotion = match.emotion; if (!_audiobook.roster.includes(match.speaker)) _audiobook.roster.push(match.speaker); } } view.addSegments([targetSeg]); } view.update(_audiobook.cancel ? done : unknownIdxs.length); } catch (e) { if (e.name !== 'AbortError') view.note('Error recasting unknown: ' + e.message); } finally { _audiobook.running = false; _audiobook.abort = null; } if (_audiobook.cancel) { if (_audiobook.lastText) _abSaveDraft(_audiobook.segments || [], _audiobook.roster || [], _audiobook.lastText, -1, -1); view.done({ stopped: true, message: 'Recast stopped. Existing cast preserved.' }); toast('Recast stopped. Existing cast preserved.', 'info'); return; } const speakers = new Set(segs.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker)); const summary = `${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${segs.length} segments`; view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown); } function audiobookOpenCastView() { if (_audiobook.running) return; const text = audiobookScopeText(); if (!text) { toast('Import a document first', 'error'); return; } // Bind this casting session to the open library book so its draft is restored // by identity on reopen. Clearing the in-memory cast when the book changes // prevents a stale in-memory cast from masking the correct per-book draft. const _curBook = (window.readerState && readerState.savedId) || null; if (_curBook && _audiobook.bookId && _audiobook.bookId !== _curBook) { _audiobook.segments = null; _audiobook.lastText = null; _audiobook.roster = null; } _audiobook.bookId = _curBook; const llm_url = audiobookLlmUrl(); const model = audiobookLlmModel(); const chunks = (typeof splitTextIntoChunks === 'function') ? splitTextIntoChunks(text, AUDIOBOOK_CHUNK_CHARS) : [text]; // 1. In-memory restore — user navigated away and came back in the same session if (_audiobook.segments && _audiobook.segments.length > 0 && _audiobook.lastText === text) { const view = audiobookCastView(chunks.length, llm_url, model, false); view.addSegments(_audiobook.segments); const speakers = new Set(_audiobook.segments.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker)); const summary = `${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${_audiobook.segments.length} segments`; view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown); view.update(chunks.length); return; } // Helper: apply a draft object into the view const _applyDraft = (draft, view, source) => { _audiobook.segments = draft.segments; _audiobook.roster = draft.roster || []; _audiobook.lastText = text; _audiobook.pageMarks = draft.pageMarks || []; _audiobook.rehId = draft.rehId || null; view.addSegments(draft.segments); const ageMs = Date.now() - (draft.savedAt || 0); const ageMins = Math.round(ageMs / 60000); const ageStr = ageMins < 1 ? 'gerade eben' : ageMins < 60 ? `vor ${ageMins} Min.` : `vor ${Math.round(ageMins/60)} Std.`; const pct = draft.total > 0 ? Math.round((draft.done / draft.total) * 100) : 100; const wasDone = draft.done === draft.total || draft.done < 0; const src = source === 'server' ? '☁️ Vom Server geladen' : '🔄 Autosave wiederhergestellt'; view.note(`${src} — ${pct}% vollständig, gespeichert ${ageStr}.${!wasDone ? ' Casting wurde unterbrochen — „Recast all" für komplette Neuauswertung.' : ''}`); const speakers = new Set(draft.segments.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker)); const summary = `${speakers.size} Charakter${speakers.size !== 1 ? 'e' : ''} · ${draft.segments.length} Segmente`; view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown); view.update(draft.done > 0 ? draft.done : chunks.length); }; // 2. localStorage draft restore — fastest, no network const _localDraft = _abLoadDraft(text); if (_localDraft && _localDraft.segments && _localDraft.segments.length > 0) { const view = audiobookCastView(chunks.length, llm_url, model, false); _applyDraft(_localDraft, view, 'local'); // Backfill server in case it's missing this draft if (_abBookId()) { fetch(`/api/reader/docs/${encodeURIComponent(_abBookId())}/scripts/cast`, { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify(_localDraft) }).catch(() => {}); } return; } // 3. Server draft restore — for other browsers/devices or cleared localStorage const bookId = _abBookId(); if (bookId) { const view = audiobookCastView(chunks.length, llm_url, model, false); _abLoadDraftServer(bookId).then(serverDraft => { if (serverDraft && serverDraft.segments && serverDraft.segments.length > 0) { // Cache locally so next open is instant try { localStorage.setItem(_abDraftKey(bookId), JSON.stringify(serverDraft)); } catch(_) {} _applyDraft(serverDraft, view, 'server'); } else { // No draft anywhere — show fresh state view.setFreshState(); } }).catch(() => view.setFreshState()); return; } // 4. Fresh start (no book ID, no draft) audiobookCastView(chunks.length, llm_url, model, true); } async function audiobookCast(overrideUrl, overrideModel) { 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; _audiobook.rehId = null; // fresh cast creates a new rehearsal record _abClearDraft(); if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(true); const ac = new AbortController(); _audiobook.abort = () => ac.abort(); if (overrideUrl && typeof overrideUrl !== 'string') overrideUrl = null; const llm_url = overrideUrl || audiobookLlmUrl(), language = audiobookLang(); let model = overrideModel || audiobookLlmModel(); const view = audiobookCastView(chunks.length, llm_url, model); view.processing('Waking up LLM model (this may take a few minutes if cold-booting)…'); try { await audiobookFetchWithTimeout('/api/attribute-dialogue', { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: ac.signal, body: JSON.stringify({ text: 'Wake up.', known_characters: [], recent: '', language, llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url, model: document.getElementById('ab-cv-llm-select')?.value || model, timeout_seconds: audiobookTimeoutSeconds(AUDIOBOOK_WARMUP_TIMEOUT_MS) }) }, AUDIOBOOK_WARMUP_TIMEOUT_MS + 5000); } catch (err) { if (err.name === 'AbortError') { _audiobook.cancel = true; _audiobook.running = false; _audiobook.abort = null; view.done({ stopped: true, message: 'Casting stopped before any passage completed.' }); toast('Casting stopped before any passages were saved.', 'info'); return; } } const allSegments = []; _audiobook.liveSegments = allSegments; // expose so split works during casting 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 let completedChunks = 0; // Page-mark tracking for visual page-break rows in the casting feed. const _pgMarks = (_audiobook.pageMarks || []).slice(); if (_pgMarks.length && _pgMarks[0].offset <= 2) _pgMarks.shift(); // skip page-1 mark at pos 0 let _pgMarkIdx = 0, _pgCharPos = 0; try { for (let i = 0; i < chunks.length; i++) { if (_audiobook.cancel) break; view.update(i); // Insert page-break markers in the feed when the source PDF page changes. while (_pgMarkIdx < _pgMarks.length && _pgCharPos >= _pgMarks[_pgMarkIdx].offset) { view.pagemark(_pgMarks[_pgMarkIdx].page + 1); _pgMarkIdx++; } _pgCharPos += chunks[i].length + 1; // +1 for joining space between chunks // 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]); completedChunks = i + 1; _abSaveDraft(allSegments, roster, text, completedChunks, chunks.length); continue; } // recent attributed dialogue → lets the LLM continue turn-taking across the boundary const recent = allSegments.filter(s => s.type === 'dialogue' && s.speaker && !/^Unknown|Unbekannt/i.test(s.speaker)) .slice(-6).map(s => `${s.speaker}: ${(s.text || '').slice(0, 80)}`).join('\n'); view.processing(chunks[i]); // ── Helper: run one attribution call and return parsed segments (or null on error) ── const attributeChunk = async (chunkText, recentCtx, timeoutMs = AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS) => { try { const r = await audiobookFetchWithTimeout('/api/attribute-dialogue', { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: ac.signal, body: JSON.stringify({ text: chunkText, known_characters: roster.slice(-40), recent: recentCtx, language, llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url, model: document.getElementById('ab-cv-llm-select')?.value || model, timeout_seconds: audiobookTimeoutSeconds(timeoutMs) }), }, timeoutMs + 5000); if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || e.error || r.statusText); } const d = await r.json(); return Array.isArray(d.segments) ? d.segments : null; } catch (err) { if (err.name === 'AbortError') throw err; // propagate cancel return { error: err.message }; } }; // ── First attempt ── let result = await attributeChunk(chunks[i], recent); let data = null; if (result && !result.error) { data = result; } else if (result && result.error) { // ── Retry: split the failing chunk in half and run both halves ── view.note(`⚠️ Passage ${i + 1} timed out — retrying in two halves…`); const half = Math.floor(chunks[i].length / 2); const splitAt = chunks[i].lastIndexOf(' ', half) || half; const chunkA = chunks[i].slice(0, splitAt).trim(); const chunkB = chunks[i].slice(splitAt).trim(); const resA = await attributeChunk(chunkA, recent, AUDIOBOOK_ATTRIBUTION_RETRY_TIMEOUT_MS); const resB = await attributeChunk(chunkB, recent, AUDIOBOOK_ATTRIBUTION_RETRY_TIMEOUT_MS); const segsA = (resA && !resA.error) ? resA : null; const segsB = (resB && !resB.error) ? resB : null; if (segsA || segsB) { data = [...(segsA || audiobookSplitByQuotes(chunkA)), ...(segsB || audiobookSplitByQuotes(chunkB))]; if (!segsA) { degraded++; view.note(`⚠️ Passage ${i + 1} (first half) — auto-detected (retry also failed)`); } if (!segsB) { degraded++; view.note(`⚠️ Passage ${i + 1} (second half) — auto-detected (retry also failed)`); } } else { // Both halves also failed view.note(`❌ Passage ${i + 1} — LLM error: ${result.error}`); data = null; } } let segs = Array.isArray(data) ? data : (data && Array.isArray(data.segments) ? data.segments : []); const hasDialogue = segs.some(s => s.type === 'dialogue'); if (!segs.length || (!hasDialogue && audiobookHasDialogue(chunks[i]))) { // LLM completely unavailable — extract dialogue from quotes without speaker attribution segs = audiobookSplitByQuotes(chunks[i]); degraded++; const named = segs.filter(s => s.type === 'dialogue' && !/^Unknown|Unbekannt/i.test(s.speaker)).length; view.note(`Passage ${i + 1} — auto-detected dialogue${named ? ` (${named} speaker${named !== 1 ? 's' : ''} from tags)` : ' (set speakers in review)'}`); } else { // Clean up LLM hallucinations where it outputs the same text twice or leaves quotes in narration let cleanedSegs = []; for (let s of segs) { s.text = s.text || ''; // Strict validation: if LLM claims it's dialogue, but the original text has NO quotes around it, it's a hallucination. if (s.type === 'dialogue' && s.text.trim().length > 10) { const tText = s.text.trim(); // Try to find the text in the original chunk to check its surroundings const idx = chunks[i].indexOf(tText); if (idx !== -1) { const surround = chunks[i].slice(Math.max(0, idx - 8), idx) + chunks[i].slice(idx + tText.length, idx + tText.length + 8); if (!/[«»„“”"‟‚‘’›‹『「—–]/.test(surround)) { s.type = 'narration'; s.speaker = 'Narrator'; s.emotion = ''; } } } if (cleanedSegs.length > 0) { let last = cleanedSegs[cleanedSegs.length - 1]; if (s.text.trim() === last.text.trim()) { if (s.type === 'dialogue' && last.type !== 'dialogue') { cleanedSegs[cleanedSegs.length - 1] = s; continue; } else if (s.type !== 'dialogue' && last.type === 'dialogue') { continue; } else { continue; } } if (s.type === 'dialogue' && last.type === 'narration') { const tS = s.text.trim(), tL = last.text.trim(); if (tL.endsWith(tS)) { last.text = tL.slice(0, -tS.length).trim(); if (!last.text) cleanedSegs.pop(); } } } if (s.text.trim()) cleanedSegs.push(s); } // Merge consecutive narration segments to preserve paragraph flow let mergedSegs = []; for (let s of cleanedSegs) { if (mergedSegs.length > 0) { let last = mergedSegs[mergedSegs.length - 1]; if (s.type === 'narration' && last.type === 'narration' && (s.speaker || 'Narrator').toLowerCase() === 'narrator' && (last.speaker || 'Narrator').toLowerCase() === 'narrator') { // Only add a newline if they don't already flow perfectly (e.g. LLM split mid-sentence) // But usually we just join with double newline to preserve paragraphs, or single space if it's mid-sentence. // A safe heuristic: if it ends with punctuation, use double newline (paragraph break). if (/[.!?]$/.test(last.text.trim())) { last.text = last.text.trimEnd() + '\n\n' + s.text.trimStart(); } else { last.text = last.text.trimEnd() + ' ' + s.text.trimStart(); } continue; } } mergedSegs.push(s); } segs = mergedSegs; } // harvest speaker names (from LLM or tag heuristic) into the running roster segs.forEach(s => { const speakerName = (s.type !== 'dialogue' || !s.speaker || s.speaker.toLowerCase() === 'narrator') ? 'Narrator' : s.speaker; if (!/^Unknown|Unbekannt/i.test(speakerName) && !roster.includes(speakerName)) roster.push(speakerName); }); segs.forEach(s => allSegments.push(s)); view.addSegments(segs); completedChunks = i + 1; _abSaveDraft(allSegments, roster, text, completedChunks, chunks.length); } view.update(_audiobook.cancel ? completedChunks : chunks.length); } catch (err) { if (err.name === 'AbortError') { _audiobook.cancel = true; view.note('Casting stopped. Completed passages were preserved.'); } else { throw err; } } finally { _audiobook.running = false; _audiobook.abort = null; } if (!allSegments.length) { if (_audiobook.cancel) { view.done({ stopped: true, message: 'Casting stopped before any passage completed.' }); toast('Casting stopped before any passages were saved.', 'info'); return; } view.done(); toast('No segments produced', 'error'); return; } _audiobook.segments = allSegments; _audiobook.lastText = text; _audiobook.roster = roster; _audiobook.narratedPassages = narrationOnly; _audiobook.degraded = degraded; _abSaveDraft(allSegments, roster, text, _audiobook.cancel ? completedChunks : chunks.length, chunks.length); audiobookSaveAsRehearsal({ silent: true }); // persist to Rehearser IndexedDB if (_audiobook.cancel) toast('Casting stopped early. Progress preserved.', 'info'); // 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 stoppedEarly = _audiobook.cancel && completedChunks < chunks.length; const summary = `${stoppedEarly ? 'Stopped early · ' : ''}${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${allSegments.length} segments`; view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown); } // ── 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 => !/^Unknown|Unbekannt/i.test(n)).length; const opts = speakerSet.map(n => `