tts-voice-creator-clone-and.../static/js/audiobook.js

484 lines
27 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) {
document.getElementById('audiobook-overlay')?.remove();
// A floating, NON-blocking, minimisable panel — keep working in the app and
// come back to watch progress (no backdrop, no modal lock).
const ov = document.createElement('div');
ov.id = 'audiobook-overlay'; ov.className = 'ab-castpanel';
ov.innerHTML = `
<div class="ab-castpanel-head" id="ab-cv-head">
<span class="mdi mdi-drama-masks"></span>
<span class="ab-castpanel-title">Casting audiobook</span>
<span class="ab-castpanel-count" id="ab-cv-count">passage 0 / ${total}</span>
<span style="flex:1"></span>
<button class="ab-castpanel-btn" id="ab-cv-min" title="Minimise — keep working, come back later"><span class="mdi mdi-window-minimize"></span></button>
<button class="ab-castpanel-btn" id="ab-cv-x" title="Cancel"><span class="mdi mdi-close"></span></button>
</div>
<div class="reader-synth-track ab-castpanel-bar"><div class="reader-synth-fill" id="ab-cv-fill"></div></div>
<div class="ab-cv-body">
<div class="ab-cv-feed" id="ab-cv-feed"></div>
<div class="ab-cv-side">
<div class="ab-cv-side-head">Characters found</div>
<div class="ab-cv-chars" id="ab-cv-chars"><span class="ab-cv-empty">listening…</span></div>
</div>
</div>
<div class="ab-castpanel-foot" id="ab-cv-foot" hidden></div>`;
document.body.appendChild(ov);
const setMin = min => {
ov.classList.toggle('minimized', min);
const ic = ov.querySelector('#ab-cv-min .mdi');
if (ic) ic.className = 'mdi ' + (min ? 'mdi-window-maximize' : 'mdi-window-minimize');
};
ov.querySelector('#ab-cv-min').addEventListener('click', () => setMin(!ov.classList.contains('minimized')));
ov.querySelector('#ab-cv-x').addEventListener('click', () => {
if (_audiobook.running) {
_audiobook.cancel = true;
if (typeof _audiobook.abort === 'function') _audiobook.abort();
ov.remove();
} else {
ov.remove();
}
});
ov.querySelector('#ab-cv-head').addEventListener('click', e => { if (ov.classList.contains('minimized') && !e.target.closest('button')) setMin(false); });
const fill = ov.querySelector('#ab-cv-fill'), count = ov.querySelector('#ab-cv-count');
const feed = ov.querySelector('#ab-cv-feed'), chars = ov.querySelector('#ab-cv-chars');
const roster = new Map(); // name -> { count, color }
const colorFor = name => {
if (!roster.has(name)) roster.set(name, { count: 0, color: _AB_PALETTE[roster.size % _AB_PALETTE.length] });
return roster.get(name).color;
};
const renderRoster = () => {
const items = [...roster.entries()].sort((a, b) => b[1].count - a[1].count);
chars.innerHTML = items.length
? items.map(([n, info]) => `<span class="ab-chip" style="--c:${info.color}"><span class="ab-chip-dot"></span>${escHtml(n)}<b>${info.count}</b></span>`).join('')
: '<span class="ab-cv-empty">listening…</span>';
};
const MAXROWS = 80;
const trim = () => { while (feed.childElementCount > MAXROWS) feed.removeChild(feed.firstChild); feed.scrollTop = feed.scrollHeight; };
return {
update(done) { if (fill) fill.style.width = (done / total * 100) + '%'; if (count) count.textContent = `passage ${done} / ${total}`; },
addSegments(segs) {
const frag = document.createDocumentFragment();
for (const s of segs) {
const dialog = s.type === 'dialogue' && s.speaker && s.speaker.toLowerCase() !== 'narrator';
const row = document.createElement('div');
row.className = 'ab-cv-row' + (dialog ? '' : ' is-narr');
if (dialog) { const c = colorFor(s.speaker); roster.get(s.speaker).count++;
row.innerHTML = `<span class="ab-cv-spk" style="color:${c}">${escHtml(s.speaker)}${s.emotion ? ' · ' + escHtml(s.emotion) : ''}</span><span class="ab-cv-txt">${escHtml((s.text || '').slice(0, 160))}</span>`;
} else {
row.innerHTML = `<span class="ab-cv-spk">Narrator</span><span class="ab-cv-txt">${escHtml((s.text || '').slice(0, 160))}</span>`;
}
frag.appendChild(row);
}
feed.appendChild(frag); trim(); renderRoster();
},
note(text) { const r = document.createElement('div'); r.className = 'ab-cv-note'; r.textContent = text; feed.appendChild(r); trim(); },
// Park the panel in a "done" state with a Review button instead of auto-popping
// the preview — so it waits for you if you wandered off to do something else.
complete(summary, onOpen) {
if (count) count.textContent = 'done';
if (fill) fill.style.width = '100%';
ov.querySelector('#ab-cv-min').hidden = true;
ov.querySelector('#ab-cv-x').title = 'Dismiss';
const foot = ov.querySelector('#ab-cv-foot');
foot.hidden = false;
foot.innerHTML = `<span class="ab-cv-done"><span class="mdi mdi-check-circle-outline"></span> ${escHtml(summary)}</span><span style="flex:1"></span><button class="btn-primary btn-sm" id="ab-cv-review"><span class="mdi mdi-account-music-outline"></span> Review &amp; cast</button>`;
foot.querySelector('#ab-cv-review').addEventListener('click', () => { ov.remove(); onOpen(); });
// gently nudge the panel open if it was minimised while you were away
ov.classList.add('ab-castpanel-done');
},
done() { ov.remove(); },
};
}
// ── Attribution → script handoff ─────────────────────────────────────────────
async function audiobookCast() {
if (_audiobook.running) return;
const text = audiobookScopeText();
if (!text) { toast('Import a document first', 'error'); return; }
if (typeof parseScript !== 'function') { toast('Rehearser not loaded yet — try again in a moment', 'error'); return; }
const chunks = (typeof splitTextIntoChunks === 'function')
? splitTextIntoChunks(text, AUDIOBOOK_CHUNK_CHARS)
: [text];
_audiobook.running = true; _audiobook.cancel = false;
const ac = new AbortController();
_audiobook.abort = () => ac.abort();
const view = audiobookCastView(chunks.length);
const llm_url = audiobookLlmUrl(), model = audiobookLlmModel(), language = audiobookLang();
const allSegments = [];
const roster = [];
let narrationOnly = 0; // passages with no quotes at all — legitimately all narration
let degraded = 0; // passages with dialogue the LLM couldn't analyse → quotes auto-extracted
try {
for (let i = 0; i < chunks.length; i++) {
if (_audiobook.cancel) break;
view.update(i);
// No quotation marks anywhere → pure narration; skip the LLM entirely (faster, not an error)
if (!audiobookHasDialogue(chunks[i])) {
const seg = { speaker: 'Narrator', type: 'narration', text: chunks[i], emotion: '' };
allSegments.push(seg); narrationOnly++; view.addSegments([seg]);
continue;
}
// recent attributed dialogue → lets the LLM continue turn-taking across the boundary
const recent = allSegments.filter(s => s.type === 'dialogue' && s.speaker && s.speaker !== 'Unknown')
.slice(-6).map(s => `${s.speaker}: ${(s.text || '').slice(0, 80)}`).join('\n');
let data = null;
try {
const r = await fetch('/api/attribute-dialogue', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
signal: ac.signal,
body: JSON.stringify({ text: chunks[i], known_characters: roster.slice(-40), recent, language, llm_url, model }),
});
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
data = await r.json();
} catch (err) {
if (err.name === 'AbortError') break;
data = null;
}
let segs = data && Array.isArray(data.segments) ? data.segments : [];
if (!segs.length) {
// LLM unavailable or returned nothing, but this passage HAS quotes —
// extract dialogue + attribute speakers from speech tags so it isn't lost.
segs = audiobookSplitByQuotes(chunks[i]);
degraded++;
const named = segs.filter(s => s.type === 'dialogue' && s.speaker !== 'Unknown').length;
view.note(`Passage ${i + 1} — auto-detected dialogue${named ? ` (${named} speaker${named !== 1 ? 's' : ''} from tags)` : ' (set speakers in review)'}`);
}
// harvest speaker names (from LLM or tag heuristic) into the running roster
(data && data.characters || []).forEach(n => { if (n && n !== 'Unknown' && !roster.includes(n)) roster.push(n); });
segs.forEach(s => { if (s.type === 'dialogue' && s.speaker && s.speaker !== 'Unknown' && !roster.includes(s.speaker)) roster.push(s.speaker); });
segs.forEach(s => allSegments.push(s));
view.addSegments(segs);
}
view.update(chunks.length);
} finally {
_audiobook.running = false;
}
if (_audiobook.cancel) { view.done(); toast('Casting cancelled', 'error'); return; }
if (!allSegments.length) { view.done(); toast('No segments produced', 'error'); return; }
_audiobook.segments = allSegments;
_audiobook.roster = roster;
_audiobook.narratedPassages = narrationOnly;
_audiobook.degraded = degraded;
// Park the panel with a "Review & cast" button (don't auto-pop, in case you wandered off)
const speakers = new Set(allSegments.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker));
const summary = `${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${allSegments.length} segments`;
view.complete(summary, audiobookShowPreview);
}
// ── Editable attribution preview ─────────────────────────────────────────────
function audiobookShowPreview() {
const segs = _audiobook.segments || [];
const roster = _audiobook.roster || [];
document.getElementById('audiobook-preview')?.remove();
const ov = document.createElement('div');
ov.id = 'audiobook-preview';
ov.className = 'audiobook-overlay';
// datalist = Narrator + LLM roster + any speakers present in the segments (incl. Unknown)
const speakerSet = [...new Set(['Narrator', ...roster, ...segs.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker)])];
const charCount = speakerSet.filter(n => n !== 'Narrator' && n !== 'Unknown').length;
const opts = speakerSet.map(n => `<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', audiobookCast);
$('reh-tb-audiobook')?.addEventListener('click', audiobookExport);