tts-voice-creator-clone-and.../static/js/audiobook.js
2026-06-25 18:48:42 +02:00

1199 lines
62 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── Book → multi-speaker audiobook ──────────────────────────────────────────
//
// Bridges the Read Aloud reader and the Script Rehearser: an LLM scans the
// document (in the current scope — selection / page range / whole book),
// attributes every segment to a speaker ("Narrator" or a character) with an
// emotion, then hands the result to the Script Rehearser as a cast-able script
// so each character gets its own voice. The rehearser is the editable preview:
// you fix any mis-attribution, cast voices, and synthesise there.
//
// Reuses: /api/attribute-dialogue (LLM), readerState + readerScopeIndices()
// (reader.js), parseScript / detectCharacters / rehState / rehDefaultLlmUrl
// (rehearser.js), splitTextIntoChunks (generation.js), $ / toast (utils.js).
const AUDIOBOOK_CHUNK_CHARS = 3000; // passage size per LLM attribution call
const _audiobook = { running: false, cancel: false };
// Opening/closing quote glyphs across book conventions: English "..."/“...”,
// German »...«/„...“, French «...», single .../..., CJK 「...」『...』, em-dash speech.
const AB_DIALOGUE_RE = /[«»„“”"‟‚‘’›‹『「]|(?:^|\n)\s*[—–]\s/;
function audiobookHasDialogue(t) { return AB_DIALOGUE_RE.test(t || ''); }
// Join words hyphenated across a PDF line break ("Schwer- tes" → "Schwertes")
// so the audiobook reads cleanly and speaker tags aren't split.
function audiobookDehyphenate(t) { return (t || '').replace(/([a-zäöüß])-\s+(?=[a-zäöüßA-ZÄÖÜ])/g, '$1'); }
// Speech-tag heuristic so the book still casts with REAL names when the LLM is down.
const AB_SPEECH_VERBS = '(?:sagte|fragte|rief|antwortete|erwiderte|entgegnete|meinte|flüsterte|wisperte|raunte|murmelte|brummte|knurrte|brüllte|schrie|stammelte|fauchte|zischte|seufzte|lachte|kicherte|befahl|wiederholte|fuhr\\s+fort|said|asked|replied|answered|whispered|murmured|muttered|shouted|cried|called|exclaimed|added|continued)';
const AB_NOTNAME = new Set([
'Der', 'Die', 'Das', 'Den', 'Dem', 'Ein', 'Eine', 'Einen', 'Er', 'Sie', 'Es', 'Ich', 'Du', 'Wir', 'Ihr', 'Man',
'Und', 'Aber', 'Da', 'Dann', 'Doch', 'So', 'Nun', 'Jetzt', 'The', 'He', 'She', 'It', 'They', 'A', 'An', 'And', 'But', 'Then', 'Now',
// common sentence-initial adverbs / interjections / abstractions that are NOT characters
'Sofort', 'Plötzlich', 'Endlich', 'Schließlich', 'Stille', 'Schweigen', 'Stimme', 'Stimmen', 'Frage', 'Antwort',
'Gelächter', 'Wieder', 'Gleich', 'Sogleich', 'Langsam', 'Leise', 'Laut', 'Kaum', 'Vielleicht', 'Natürlich', 'Wirklich',
'Ja', 'Nein', 'Komm', 'Warte', 'Halt', 'Geh', 'Hier', 'Dort', 'Oben', 'Unten', 'Schon', 'Noch', 'Auch', 'Nur', 'Immer', 'Nie']);
const _AB_NAME = "([A-ZÄÖÜ][A-Za-zäöüß'\\-]+)";
function audiobookGuessSpeaker(after, before) {
let m;
const ok = n => (n && !AB_NOTNAME.has(n)) ? n : null;
// NOTE: no 'i' flag — names must be genuinely capitalized; German speech verbs
// after a quote are lowercase, so this rejects pronouns like "sagte er".
// after the quote: ", sagte Riskan" / "sagte Riskan" (verb → name)
if ((m = new RegExp('^[\\s,;-]*' + AB_SPEECH_VERBS + '\\s+(?:der|die|das|ein|eine)?\\s*' + _AB_NAME).exec(after || ''))) { const r = ok(m[1]); if (r) return r; }
// after the quote: ", Riskan sagte" (name → verb)
if ((m = new RegExp('^[\\s,;-]*' + _AB_NAME + '\\s+' + AB_SPEECH_VERBS).exec(after || ''))) { const r = ok(m[1]); if (r) return r; }
// before the quote: "Riskan sagte:" / "Riskan fragte"
if ((m = new RegExp(_AB_NAME + '\\s+' + AB_SPEECH_VERBS + '[\\s:,-]*$').exec(before || ''))) { const r = ok(m[1]); if (r) return r; }
return null;
}
// Deterministic fallback: split a passage into narration + dialogue by quotation
// spans and attribute speakers from the surrounding speech tags. Used when the LLM
// is unavailable so dialogue — and as many speakers as possible — are never lost.
const AB_QUOTE_SPAN = /»([^«]+)«|«([^»]+)»|„([^“”]+)[“”]|“([^”]+)”|"([^"]+)"|「([^」]+)」|『([^』]+)』/g;
function audiobookSplitByQuotes(text) {
const spans = []; let m;
AB_QUOTE_SPAN.lastIndex = 0;
while ((m = AB_QUOTE_SPAN.exec(text))) {
spans.push({ start: m.index, end: AB_QUOTE_SPAN.lastIndex, quote: (m[1] || m[2] || m[3] || m[4] || m[5] || m[6] || m[7] || '').trim() });
}
if (!spans.length) return [{ speaker: 'Narrator', type: 'narration', text, emotion: '' }];
const out = []; let last = 0;
for (let k = 0; k < spans.length; k++) {
const sp = spans[k];
const pre = text.slice(last, sp.start);
if (pre.trim()) out.push({ speaker: 'Narrator', type: 'narration', text: pre.trim(), emotion: '' });
if (sp.quote) {
const after = text.slice(sp.end, k + 1 < spans.length ? spans[k + 1].start : text.length);
const speaker = audiobookGuessSpeaker(after, pre) || 'Unknown';
out.push({ speaker, type: 'dialogue', text: sp.quote, emotion: '' });
}
last = sp.end;
}
const tail = text.slice(last);
if (tail.trim()) out.push({ speaker: 'Narrator', type: 'narration', text: tail.trim(), emotion: '' });
return audiobookTurnTaking(out);
}
// Fill 'Unknown' dialogue speakers by two-person alternation — but only once TWO
// distinct named speakers are established nearby (conservative: won't guess in a
// monologue, so it rarely invents a wrong name).
function audiobookTurnTaking(segs) {
let a = null, b = null; // two most recent distinct named speakers (b = latest)
for (const s of segs) {
if (s.type !== 'dialogue') continue;
if (s.speaker && s.speaker !== 'Unknown') {
if (s.speaker !== b) { a = b; b = s.speaker; }
} else if (a && b && a !== b) {
s.speaker = a; // the other of the two → alternate
const t = a; a = b; b = t; // rotate so the next Unknown alternates back
}
}
return segs;
}
function audiobookLlmUrl() { return $('reh-llm-url')?.value.trim() || (typeof rehDefaultLlmUrl === 'function' ? rehDefaultLlmUrl() : ''); }
function audiobookLlmModel() { return $('reh-llm-model')?.value || ''; }
function audiobookLang() { return $('reh-design-lang')?.value || ''; }
// Gather the plain text of the current reader scope (selection > page range > all).
function audiobookScopeText() {
if (typeof readerScopeIndices !== 'function' || !readerState?.sentences?.length) return '';
const raw = readerScopeIndices().map(i => readerState.sentences[i].text).join(' ').replace(/\s+/g, ' ').trim();
return audiobookDehyphenate(raw); // mend PDF line-break hyphenation for clean speech + tag matching
}
// ── Progress overlay ─────────────────────────────────────────────────────────
function audiobookProgress(total) {
let ov = document.getElementById('audiobook-overlay');
if (!ov) {
ov = document.createElement('div');
ov.id = 'audiobook-overlay';
ov.className = 'audiobook-overlay';
ov.innerHTML = `<div class="audiobook-box">
<div class="audiobook-title"><span class="mdi mdi-drama-masks"></span> Casting audiobook</div>
<div class="audiobook-msg" id="audiobook-msg">Analysing…</div>
<div class="reader-synth-track"><div class="reader-synth-fill" id="audiobook-fill"></div></div>
<div class="audiobook-actions"><button class="btn-secondary btn-sm" id="audiobook-cancel">Cancel</button></div>
</div>`;
document.body.appendChild(ov);
ov.querySelector('#audiobook-cancel').addEventListener('click', () => { _audiobook.cancel = true; });
}
ov.hidden = false;
const fill = ov.querySelector('#audiobook-fill');
const msg = ov.querySelector('#audiobook-msg');
return {
update(done, label) { if (fill) fill.style.width = (done / total * 100) + '%'; if (msg && label) msg.textContent = label; },
done() { ov.hidden = true; },
};
}
const _AB_PALETTE = ['#3b82f6', '#10b981', '#8b5cf6', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4', '#84cc16', '#f97316', '#14b8a6', '#6366f1', '#d946ef'];
// Live casting view: a scrolling feed of attributed lines + a character roster
// that fills up as speakers are discovered. Far clearer than a bare bar.
function audiobookCastView(total, 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 = 'flex';
panel.style.flexDirection = 'column';
panel.style.minHeight = '500px';
panel.innerHTML = `
<div class="ab-castpanel-head" id="ab-cv-head">
<span class="mdi mdi-drama-masks"></span>
<span class="ab-castpanel-title">Casting audiobook</span>
<span class="ab-castpanel-count" id="ab-cv-count">passage 0 / ${total}</span>
<span style="flex:1"></span>
<label for="ab-cv-llm-url" style="font-size:12px; font-weight:600; color:var(--subtext); margin-right:4px;">LLM Engine</label>
<select id="ab-cv-llm-url" style="width:260px; margin-right:4px; padding:2px 6px; font-size:12px; border:1px solid var(--border); border-radius:4px; background:var(--surface); color:var(--text); outline:none; appearance:auto; cursor:pointer;" aria-label="LLM API URL">
<option value="${escHtml(llmUrl)}">${escHtml(llmUrl || '— Select an endpoint —')}</option>
</select>
<button class="ab-castpanel-btn" id="ab-cv-llm-refresh" style="margin-right:4px;" title="Fetch models"><span class="mdi mdi-refresh"></span></button>
<select id="ab-cv-llm-select" style="width:220px; padding:2px 6px; font-size:12px; border:1px solid var(--border); border-radius:4px; background:var(--surface); color:var(--text); outline:none;" aria-label="LLM Model" title="Change LLM model for character extraction">
<option value="${escHtml(defaultModel)}">${escHtml(defaultModel || '— fetch models —')}</option>
</select>
<span style="margin-right:8px;"></span>
<button class="ab-castpanel-btn" id="ab-cv-prompt-btn" title="Edit Casting Prompt"><span class="mdi mdi-text-box-edit-outline"></span></button>
</div>
<div class="ab-castpanel-prompt" id="ab-cv-prompt-panel" style="padding:10px 12px; background:var(--panel); border-bottom:1px solid var(--border);">
<textarea id="ab-cv-prompt-text" rows="8" style="width:100%; font-family:monospace; font-size:13px; font-weight:500; padding:10px; line-height:1.5; border:1px solid var(--border); border-radius:6px; background:var(--surface); color:var(--text); resize:vertical;" placeholder="LLM casting instructions..."></textarea>
<div style="display:flex; justify-content:flex-end; margin-top:8px; gap:8px; align-items:center;">
<select id="ab-cv-prompt-lib" class="btn-secondary btn-sm" style="max-width:200px; outline:none; border:1px solid var(--border); border-radius:4px; padding:4px 8px; font-size:12px; background:var(--surface);" aria-label="Load a saved prompt">
<option value="">— Load saved prompt —</option>
</select>
<button class="btn-secondary btn-sm" id="ab-cv-prompt-del" title="Delete selected prompt" style="display:none; color:var(--error);"><span class="mdi mdi-delete-outline"></span></button>
<input type="text" id="ab-cv-prompt-name" style="width:140px; padding:4px 8px; font-size:12px; border:1px solid var(--border); border-radius:4px; background:var(--surface); color:var(--text); outline:none;" placeholder="Preset name...">
<button class="btn-primary btn-sm" id="ab-cv-prompt-save" title="Save current prompt as a preset"><span class="mdi mdi-content-save"></span> Save preset</button>
</div>
</div>
<div class="reader-synth-track ab-castpanel-bar"><div class="reader-synth-fill" id="ab-cv-fill"></div></div>
<div class="ab-cv-body">
<div class="ab-cv-feed" id="ab-cv-feed"></div>
<div class="ab-cv-side">
<div class="ab-cv-side-head">Characters found</div>
<div class="ab-cv-chars" id="ab-cv-chars"><span class="ab-cv-empty">listening…</span></div>
</div>
</div>
</div>
<div class="ab-castpanel-foot" id="ab-cv-foot" style="border-top:1px solid var(--border); padding:10px 12px; display:flex; justify-content:flex-end; align-items:center;">
<span class="ab-cv-status" id="ab-cv-status-msg" style="color:var(--subtext); font-size:12px; flex:1; display:none;">Ready to cast.</span>
<button class="btn-secondary btn-sm" id="ab-cv-cancel" style="color:var(--red); margin-right:8px;"><span class="mdi mdi-close"></span> Cancel</button>
<button class="btn-secondary btn-sm" id="ab-cv-start-recast-unk" style="display:none; margin-right:8px; color:var(--error);" title="Re-run only Unknown segments"><span class="mdi mdi-account-question-outline"></span> Recast unknown</button>
<button class="btn-secondary btn-sm" id="ab-cv-start-recast" style="display:none; margin-right:8px;" title="Re-run entire document"><span class="mdi mdi-refresh"></span> Recast all</button>
<button class="btn-primary btn-sm" id="ab-cv-start-cast" style="display:none;"><span class="mdi mdi-play"></span> Cast now</button>
</div>`;
const AB_DEFAULT_PROMPT = `You attribute dialogue in prose fiction for a multi-voice audiobook. Split the passage into consecutive segments in reading order. For each segment output:
- speaker: 'Narrator' for narration/description, or the character's name for spoken dialogue. Use 'Unknown' ONLY as an absolute last resort.
- type: 'narration' or 'dialogue'
- text: the verbatim spoken words for dialogue (WITHOUT the surrounding quotation marks), or the verbatim prose for narration
- emotion: for dialogue, one or two words (e.g. neutral, angry, sad, excited, whisper, tender); '' for narration
QUOTATION STYLES — books mark speech in many ways; treat ALL of these as spoken dialogue:
English straight "..." and curly “...”; German »...« (guillemets pointing inward) and „...“; French «...» (pointing outward); single ...; CJK 「...」 『...』; and em-dash speech where a line starts with — or (Spanish/French/Polish style).
German guillemets are the MOST IMPORTANT to detect: »Was schaust du dir an?« is a spoken line.
ATTRIBUTING THE SPEAKER (this is the hard, important part — be decisive):
1. If there is a dialogue tag ('sagte Riskan', 'fragte sie', 'Peter said'), use it. Resolve pronouns (er/sie/he/she) to the actual name from nearby context.
2. UNTAGGED lines: use **conversational turn-taking**. In a two-person exchange the speaker ALTERNATES every line — if Riskan just spoke, the next untagged quote is the other person, then back to Riskan, and so on.
3. Use the scene context, action beats around a quote (the person doing the action usually speaks), the 'Recent dialogue' below (continue the same conversation/alternation across the passage boundary), and the known-characters list. Reuse the EXACT known names.
4. Only output 'Unknown' if the speaker is genuinely indeterminable even after applying turn-taking and context — this should be rare. Prefer the most likely named character over 'Unknown'.
RULES:
- Put dialogue tags and action beats in a NARRATION segment, never inside the dialogue text.
- If a quote is interrupted by a tag (»Die Pause«, sagte Peter, »ist vorbei.«), stitch the spoken parts into ONE dialogue segment ('Die Pause ist vorbei.') with the tag as a separate narration segment.
- Strip the quotation marks/guillemets from dialogue text. Keep every word otherwise, in order.`;
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.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;
}
};
panel.querySelector('#ab-cv-cancel').addEventListener('click', () => {
if (_audiobook.running) {
_audiobook.cancel = true;
if (typeof _audiobook.abort === 'function') _audiobook.abort();
}
closePanel();
});
panel.querySelector('#ab-cv-prompt-btn').addEventListener('click', () => {
const p = panel.querySelector('#ab-cv-prompt-panel');
p.hidden = !p.hidden;
});
// 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 = '<option value="">— Load saved prompt —</option>' +
savedPrompts.map((p, i) => `<option value="${i}">${escHtml(p.name)}</option>`).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 = '<option value="">loading...</option>';
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 => `<option value="${escHtml(m)}">${escHtml(m)}</option>`).join('');
if (oldVal && d.models.includes(oldVal)) sel.value = oldVal;
else if (d.models.includes(defaultModel)) sel.value = defaultModel;
} else {
sel.innerHTML = `<option value="${escHtml(defaultModel)}">${escHtml(defaultModel || '— fetch models —')}</option>`;
}
}).catch(() => {
sel.innerHTML = `<option value="${escHtml(defaultModel)}">${escHtml(defaultModel || '— fetch models —')}</option>`;
});
};
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 = '<span class="mdi mdi-stop"></span> 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 renderRoster = () => {
const items = [...roster.entries()].sort((a, b) => b[1].count - a[1].count);
chars.innerHTML = items.length
? items.map(([n, info]) => `<span class="ab-chip" style="--c:${info.color}"><span class="ab-chip-dot"></span>${escHtml(n)}<b>${info.count}</b></span>`).join('')
: '<span class="ab-cv-empty">listening…</span>';
};
const MAXROWS = 80;
const trim = () => { while (feed.childElementCount > MAXROWS) feed.removeChild(feed.firstChild); feed.scrollTop = feed.scrollHeight; };
// 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 = '<span style="font-size:11px; font-weight:600; color:var(--subtext); text-transform:uppercase;">Assign to</span><span class="mdi mdi-close" style="cursor:pointer; font-size:16px; color:var(--subtext); padding:4px;" title="Close (Esc)"></span>';
assignPopup.appendChild(header);
const inp = document.createElement('input');
inp.type = 'text'; inp.placeholder = 'Type new 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 val = inp.value.trim();
if (val) assignName(val);
} else if (e.key === 'Escape') {
closeAssignPopup();
}
});
}
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';
assignModeSeg.speaker = isNarrator ? 'Narrator' : name;
assignModeSeg.type = isNarrator ? 'narration' : 'dialogue';
if (isNarrator) assignModeSeg.emotion = '';
assignModeRow.classList.remove('is-assigning');
if (isNarrator) {
assignModeRow.className = 'ab-cv-row is-narr';
assignModeRow.querySelector('.ab-cv-spk').innerHTML = 'Narrator';
assignModeRow.querySelector('.ab-cv-spk').style.color = '';
assignModeRow.querySelector('.ab-cv-spk').title = 'Click to assign character';
} else {
const c = colorFor(name);
if (!roster.has(name)) roster.set(name, { count: 0, color: c });
roster.get(name).count++;
assignModeRow.className = 'ab-cv-row';
assignModeRow.querySelector('.ab-cv-spk').innerHTML = `${escHtml(name)}${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();
}
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.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 = `<span style="font-size:13px;">📖</span> 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.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 = `<span style="width:8px;height:8px;border-radius:50%;background:${info.color};"></span> ${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 = '<span class="mdi mdi-call-split"></span> 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();
let arr = _audiobook.segments || [];
// if it's currently casting, they are in allSegments (which we can't easily reference), but we can just update the array when it finishes.
// To be safe, we just modify the object and insert new objects.
const 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 {
// It's casting right now. We can just replace the row visually, and at the end of casting it uses the segments array.
// Wait, allSegments holds the real ones. If we can't find it in _audiobook.segments, we're in trouble.
// Just flag the row. Actually, we should just prevent splitting during active casting.
if (panel.querySelector('#ab-cv-status-msg').textContent !== 'Ready to recast.') {
toast('Wait for casting to finish or stop it before splitting', 'error');
splitBtn.style.display = 'none';
return;
}
}
const frag = document.createDocumentFragment();
for (const s of newSegs) {
const dialog = s.type === 'dialogue' && s.speaker && s.speaker.toLowerCase() !== 'narrator';
const newRow = document.createElement('div');
newRow.className = 'ab-cv-row' + (dialog ? '' : ' is-narr');
newRow.__seg = s;
if (dialog) {
const c = colorFor(s.speaker);
newRow.innerHTML = `<span class="ab-cv-spk" style="color:${c}" title="Click to assign character">${escHtml(s.speaker)}${s.emotion ? ' · ' + escHtml(s.emotion) : ''}</span><span class="ab-cv-txt">${escHtml(s.text || '')}</span>`;
} else {
newRow.innerHTML = `<span class="ab-cv-spk" title="Click to assign character">Narrator</span><span class="ab-cv-txt">${escHtml(s.text || '')}</span>`;
}
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) { if (fill) fill.style.width = (done / total * 100) + '%'; if (count) count.textContent = `passage ${done} / ${total}`; },
processing(text) {
if (this._procRow) this._procRow.remove();
this._procRow = document.createElement('div');
this._procRow.className = 'ab-cv-row is-processing';
this._procRow.innerHTML = `<span class="ab-cv-spk" style="color:var(--blue)"><span class="mdi mdi-loading mdi-spin"></span> LLM Reading…</span><span class="ab-cv-txt" style="opacity:0.6; font-style:italic;">${escHtml(text.slice(0, 200))}${text.length > 200 ? '…' : ''}</span>`;
feed.appendChild(this._procRow);
trim();
},
clearProcessing() {
if (this._procRow) { this._procRow.remove(); this._procRow = null; }
},
addSegments(segs) {
this.clearProcessing();
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');
row.__seg = s;
if (dialog) { const c = colorFor(s.speaker); roster.get(s.speaker).count++;
row.innerHTML = `<span class="ab-cv-spk" style="color:${c}" title="Click to assign character">${escHtml(s.speaker)}${s.emotion ? ' · ' + escHtml(s.emotion) : ''}</span><span class="ab-cv-txt">${escHtml(s.text || '')}</span>`;
} else {
row.innerHTML = `<span class="ab-cv-spk" title="Click to assign character">Narrator</span><span class="ab-cv-txt">${escHtml(s.text || '')}</span>`;
}
frag.appendChild(row);
}
feed.appendChild(frag); trim(); renderRoster();
},
note(text) { const r = document.createElement('div'); r.className = 'ab-cv-note'; r.textContent = text; feed.appendChild(r); trim(); },
// Park the panel in a "done" state with a Review button instead of auto-popping
// the preview — so it waits for you if you wandered off to do something else.
complete(summary, onOpen, onRecast, onRecastUnknown) {
if (count) count.textContent = 'done';
if (fill) fill.style.width = '100%';
const cancelBtn = panel.querySelector('#ab-cv-cancel');
if (cancelBtn) cancelBtn.hidden = true;
const foot = panel.querySelector('#ab-cv-foot');
foot.hidden = false;
foot.innerHTML = `<span class="ab-cv-done"><span class="mdi mdi-check-circle-outline"></span> ${escHtml(summary)}</span><span style="flex:1"></span><button class="btn-secondary btn-sm" id="ab-cv-save-script" style="margin-right:8px;" title="Save to Script Rehearsals without leaving this page"><span class="mdi mdi-content-save-outline"></span> Save script</button><button class="btn-secondary btn-sm" id="ab-cv-recast-unk" style="margin-right:8px; color:var(--error);" title="Re-run only the Unknown segments with the current prompt"><span class="mdi mdi-account-question-outline"></span> Recast unknown</button><button class="btn-secondary btn-sm" id="ab-cv-recast" style="margin-right:8px;" title="Re-run the entire document to apply new settings or prompt tweaks"><span class="mdi mdi-refresh"></span> Recast all</button><button class="btn-primary btn-sm" id="ab-cv-review"><span class="mdi mdi-account-music-outline"></span> Review &amp; cast</button>`;
foot.querySelector('#ab-cv-review').addEventListener('click', () => { closePanel(); onOpen(); });
foot.querySelector('#ab-cv-save-script').addEventListener('click', audiobookSaveAsRehearsal);
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));
panel.classList.add('ab-castpanel-done');
},
done() { closePanel(); },
};
}
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 || segs[i].speaker === 'Unknown')) {
unknownIdxs.push(i);
}
}
if (!unknownIdxs.length) {
toast('No Unknown speakers found', 'success');
audiobookShowPreview();
return;
}
_audiobook.running = true; _audiobook.cancel = false;
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 fetch('/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
})
});
} catch (err) {
if (err.name === 'AbortError') { _audiobook.running = false; view.done(); return; }
}
let done = 0;
try {
for (let i of unknownIdxs) {
if (_audiobook.cancel) break;
const targetSeg = segs[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 fetch('/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
})
});
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') 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 && match.speaker !== 'Unknown') {
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(unknownIdxs.length);
} catch (e) {
if (e.name !== 'AbortError') view.note('Error recasting unknown: ' + e.message);
} finally {
_audiobook.running = false;
}
if (_audiobook.cancel) { view.done(); toast('Recast cancelled', 'error'); 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; }
const llm_url = audiobookLlmUrl();
const model = audiobookLlmModel();
const chunks = (typeof splitTextIntoChunks === 'function')
? splitTextIntoChunks(text, AUDIOBOOK_CHUNK_CHARS)
: [text];
// If the user navigates away and back, restore the view instead of clearing their work
if (_audiobook.segments && _audiobook.segments.length > 0 && _audiobook.lastText === text) {
const view = audiobookCastView(chunks.length, llm_url, model, false);
// we need to set the roster manually so color generation matches
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;
}
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;
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 fetch('/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
})
});
} catch (err) {
if (err.name === 'AbortError') { _audiobook.running = false; view.done(); return; }
}
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');
view.processing(chunks[i]);
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: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url, model: document.getElementById('ab-cv-llm-select')?.value || model }),
});
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') break;
view.note(`API Error: ${err.message}`);
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 (!allSegments.length) { view.done(); toast('No segments produced', 'error'); return; }
_audiobook.segments = allSegments;
_audiobook.lastText = text;
_audiobook.roster = roster;
_audiobook.narratedPassages = narrationOnly;
_audiobook.degraded = degraded;
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 summary = `${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 => n !== 'Narrator' && n !== 'Unknown').length;
const opts = speakerSet.map(n => `<option value="${escHtml(n)}">`).join('');
ov.innerHTML = `<div class="audiobook-box audiobook-preview-box">
<div class="audiobook-title"><span class="mdi mdi-drama-masks"></span> Review cast &amp; lines
<span class="audiobook-count">${segs.length} segments · ${charCount} character${charCount !== 1 ? 's' : ''}</span></div>
<div class="audiobook-msg">Fix any wrong speaker or emotion, then open in the Script Rehearser to assign voices.${_audiobook.narratedPassages ? ` <span class="audiobook-narr-note">${_audiobook.narratedPassages} passage${_audiobook.narratedPassages !== 1 ? 's' : ''} had no dialogue (narrator).</span>` : ''}${_audiobook.degraded ? ` <span class="audiobook-narr-note">${_audiobook.degraded} passage${_audiobook.degraded !== 1 ? 's' : ''} used quick detection — set the “Unknown” speakers.</span>` : ''}</div>
<datalist id="audiobook-roster">${opts}</datalist>
<div class="audiobook-seglist" id="audiobook-seglist"></div>
<div class="audiobook-actions">
<button class="btn-secondary btn-sm" id="audiobook-preview-cancel">Cancel</button>
<button class="btn-primary btn-sm" id="audiobook-preview-open"><span class="mdi mdi-account-music-outline"></span> Open in Rehearser</button>
</div>
</div>`;
document.body.appendChild(ov);
ov.querySelector('#audiobook-seglist').innerHTML = segs.map((s, i) => `<div class="audiobook-seg${s.type === 'dialogue' ? ' is-dialog' : ''}">
<input class="audiobook-seg-sp" data-i="${i}" list="audiobook-roster" value="${escHtml(s.speaker || 'Narrator')}" aria-label="Speaker">
<input class="audiobook-seg-emo" data-i="${i}" value="${escHtml(s.emotion || '')}" placeholder="emotion" aria-label="Emotion"${s.type === 'dialogue' ? '' : ' disabled'}>
<div class="audiobook-seg-text">${escHtml(s.text)}</div>
</div>`).join('');
ov.querySelector('#audiobook-preview-cancel').addEventListener('click', () => ov.remove());
ov.querySelector('#audiobook-preview-open').addEventListener('click', () => { audiobookApplyPreviewAndOpen(); ov.remove(); });
}
function audiobookApplyPreviewAndOpen() {
const segs = _audiobook.segments;
document.querySelectorAll('#audiobook-seglist .audiobook-seg-sp').forEach(inp => {
const i = +inp.dataset.i; const v = inp.value.trim() || 'Narrator';
segs[i].speaker = v;
segs[i].type = (v.toLowerCase() === 'narrator') ? 'narration' : 'dialogue';
});
document.querySelectorAll('#audiobook-seglist .audiobook-seg-emo').forEach(inp => {
const i = +inp.dataset.i; segs[i].emotion = inp.value.trim();
});
const { script, emotions } = audiobookBuildScript(segs);
audiobookOpenInRehearser(script, (readerState.title || 'Audiobook'), emotions);
}
// Build a rehearser script (CAPS speaker + line; narration as plain paragraphs)
// and a parallel list of per-dialogue-line emotions (same order as dialog lines).
function audiobookBuildScript(segments) {
let script = '';
const emotions = [];
for (const s of segments) {
const t = (s.text || '').trim(); if (!t) continue;
const isDialogue = s.type === 'dialogue' && s.speaker && s.speaker.toLowerCase() !== 'narrator';
if (isDialogue) {
script += '\n' + s.speaker.toUpperCase() + '\n' + t + '\n';
emotions.push(s.emotion || '');
} else {
script += '\n' + t + '\n'; // narration → narrator reads these
}
}
return { script: script.trim(), emotions };
}
async function audiobookOpenInRehearser(script, title, dialogueEmotions) {
if ($('reh-script-text')) $('reh-script-text').value = script;
if ($('reh-script-title')) $('reh-script-title').value = title;
if (typeof navTo === 'function') navTo('s-rehearser');
// Reuse the rehearser's own parse flow (builds lines, cast, jumps to Cast phase)
const btn = $('reh-parse-btn');
if (btn) btn.click();
else if (typeof parseScript === 'function') { rehState.lines = parseScript(script); }
// Apply per-line emotions to dialog lines in order (Phase 3: emotion-aware narration)
let speakers = 0, lines = 0;
if (window.rehState && Array.isArray(rehState.lines)) {
let k = 0;
rehState.lines.forEach(l => { if (l.type === 'dialog') { const e = dialogueEmotions[k++]; if (e) l.emotion = e; lines++; } });
speakers = Object.keys(rehState.cast || {}).filter(s => !String(s).includes('NARRATOR')).length;
}
// Persist as a reopenable rehearsal so the cast/lines aren't lost — find it under
// Script Rehearser → Bibliothek (Library) and reopen anytime to edit & synthesise.
let saved = false;
if (typeof saveToLibrary === 'function') {
try { rehState.savedId = null; await saveToLibrary(); saved = true; } catch (_) {}
}
toast(`Cast ${speakers} character${speakers !== 1 ? 's' : ''} · ${lines} lines` + (saved ? ' — saved to Rehearser → Bibliothek' : ''), 'success');
}
// ── Audiobook export (rehearser): synthesise every line → MP3 per chapter ────
function audiobookIsChapter(line) {
if (line.type === 'act' || line.type === 'scene') return true;
const t = (typeof stripMarkdown === 'function' ? stripMarkdown(line.text || '') : (line.text || '')).trim();
if (!t || t.length > 60) return false;
return /^(chapter|kapitel|chap\.?|part|book|prologue|epilogue|prolog|epilog|teil)\b/i.test(t);
}
function audiobookLineVoice(l) {
if (l.type === 'dialog') {
const c = rehState.cast[l.speaker] || {};
return { voice: c.voice, instruct: (typeof _buildInstruct === 'function' ? _buildInstruct(c.instruct, l.emotion) : '') };
}
return { voice: rehState.narratorVoice, instruct: '' };
}
async function audiobookExport() {
if (_audiobook.running) return;
if (!window.rehState || !(rehState.lines || []).length) { toast('Open a script in the rehearser first', 'error'); return; }
if (!rehState.backend) { toast('Select a TTS backend in the rehearser first', 'error'); return; }
if (typeof _ensureNarrator === 'function') _ensureNarrator();
const speakable = i => {
const l = rehState.lines[i];
if (!l || l.ignored || l.hidden) return false;
if (l.type === 'dialog') { const c = rehState.cast[l.speaker]; return !!(c && c.voice && c.voice !== 'me'); }
return !!(rehState.narratorVoice && (l.text || '').trim());
};
// Bucket speakable lines into chapters (by chapter headings / act / scene)
const buckets = [];
let cur = null;
rehState.lines.forEach((l, i) => {
if (audiobookIsChapter(l)) { cur = { title: (typeof stripMarkdown === 'function' ? stripMarkdown(l.text) : l.text).trim().slice(0, 50), idx: [] }; buckets.push(cur); }
if (speakable(i)) { if (!cur) { cur = { title: '', idx: [] }; buckets.push(cur); } cur.idx.push(i); }
});
const allIdx = buckets.flatMap(b => b.idx);
if (!allIdx.length) { toast('Nothing to synthesise — cast voices first', 'error'); return; }
_audiobook.running = true; _audiobook.cancel = false;
const prog = audiobookProgress(allIdx.length);
const mp3 = new Map();
let done = 0;
const queue = allIdx.slice();
const worker = async () => {
while (queue.length && !_audiobook.cancel) {
const i = queue.shift();
const l = rehState.lines[i];
const { voice, instruct } = audiobookLineVoice(l);
const text = (typeof _rehInlineTone === 'function')
? _rehInlineTone(stripMarkdown(l.text), l.emotion)
: (typeof stripMarkdown === 'function' ? stripMarkdown(l.text) : l.text);
try { mp3.set(i, await fetchTtsPreviewBlob(voice, text, 'mp3', instruct, rehState.backend)); } catch (_) {}
prog.update(++done, `Synthesising line ${done} / ${allIdx.length}`);
}
};
try { await Promise.all(Array.from({ length: Math.min(2, allIdx.length) }, worker)); }
finally { prog.done(); _audiobook.running = false; }
if (_audiobook.cancel) { toast('Export cancelled', 'error'); return; }
const title = (typeof readerSafeName === 'function' ? readerSafeName($('reh-script-title')?.value || 'Audiobook') : ($('reh-script-title')?.value || 'Audiobook'));
const realChapters = buckets.filter(b => b.title).length > 0;
let files = 0;
for (let c = 0; c < buckets.length; c++) {
const blobs = buckets[c].idx.map(i => mp3.get(i)).filter(Boolean);
if (!blobs.length) continue;
const blob = new Blob(blobs, { type: 'audio/mpeg' });
const ch = buckets[c].title ? ' ' + readerSafeName(buckets[c].title) : '';
const name = (realChapters || buckets.length > 1)
? `${title} - ${String(c + 1).padStart(2, '0')}${ch}.mp3`
: `${title}.mp3`;
if (typeof readerDownload === 'function') readerDownload(blob, name);
files++;
await new Promise(r => setTimeout(r, 400));
}
toast('Exported audiobook · ' + files + (realChapters ? ' chapter MP3 file(s)' : ' MP3 file(s)'), 'success');
}
// ── Wiring ───────────────────────────────────────────────────────────────────
$('reader-audiobook-btn')?.addEventListener('click', audiobookOpenCastView);
$('reh-tb-audiobook')?.addEventListener('click', audiobookExport);
async function audiobookSaveAsRehearsal() {
const segs = _audiobook.segments;
if (!segs || !segs.length) { toast('No segments to save', 'error'); return; }
const { script, emotions } = audiobookBuildScript(segs);
const title = (typeof readerState !== 'undefined' && readerState.title) ? readerState.title : 'Audiobook';
// Need to parse lines to get cast
const lines = (typeof parseScript === 'function') ? parseScript(script) : [];
if (emotions && emotions.length) {
let eIdx = 0;
lines.forEach(l => {
if (l.type === 'dialog' && eIdx < emotions.length) {
if (emotions[eIdx]) l.emotion = emotions[eIdx];
eIdx++;
}
});
}
const cast = {};
if (typeof detectCharacters === 'function') {
const detected = detectCharacters(lines);
Object.entries(detected).forEach(([sp, def]) => {
cast[sp] = {
voice: def.voice, color: def.color, instruct: '',
lang: '', gender: '', tags: '', soul: '',
ignored: false, hidden: false, voiceData: null,
};
});
}
const rec = {
title: title,
script: script,
cast: cast,
emotions: {},
notes: {}, ignored: {}, hidden: {},
backend: '', narratorVoice: '',
lineIndex: 0, clips: [],
created: new Date(),
updated: new Date(),
};
// extract emotions for the record
lines.forEach((l, i) => {
if (l.type === 'dialog' && l.emotion) rec.emotions[i] = l.emotion;
});
if (typeof rehDbAdd === 'function') {
try {
await rehDbAdd(rec);
toast('Saved as Script Rehearsal', 'success');
if (typeof renderLibraryList === 'function') renderLibraryList();
} catch (e) {
toast('Failed to save rehearsal: ' + e.message, 'error');
}
} else {
toast('Rehearser DB not available', 'error');
}
}