1199 lines
62 KiB
JavaScript
1199 lines
62 KiB
JavaScript
// ── Book → multi-speaker audiobook ──────────────────────────────────────────
|
||
//
|
||
// Bridges the Read Aloud reader and the Script Rehearser: an LLM scans the
|
||
// document (in the current scope — selection / page range / whole book),
|
||
// attributes every segment to a speaker ("Narrator" or a character) with an
|
||
// emotion, then hands the result to the Script Rehearser as a cast-able script
|
||
// so each character gets its own voice. The rehearser is the editable preview:
|
||
// you fix any mis-attribution, cast voices, and synthesise there.
|
||
//
|
||
// Reuses: /api/attribute-dialogue (LLM), readerState + readerScopeIndices()
|
||
// (reader.js), parseScript / detectCharacters / rehState / rehDefaultLlmUrl
|
||
// (rehearser.js), splitTextIntoChunks (generation.js), $ / toast (utils.js).
|
||
|
||
const AUDIOBOOK_CHUNK_CHARS = 3000; // passage size per LLM attribution call
|
||
const _audiobook = { running: false, cancel: false };
|
||
|
||
// Opening/closing quote glyphs across book conventions: English "..."/“...”,
|
||
// German »...«/„...“, French «...», single ‘...’/›...‹, CJK 「...」『...』, em-dash speech.
|
||
const AB_DIALOGUE_RE = /[«»„“”"‟‚‘’›‹『「]|(?:^|\n)\s*[—–]\s/;
|
||
function audiobookHasDialogue(t) { return AB_DIALOGUE_RE.test(t || ''); }
|
||
|
||
// Join words hyphenated across a PDF line break ("Schwer- tes" → "Schwertes")
|
||
// so the audiobook reads cleanly and speaker tags aren't split.
|
||
function audiobookDehyphenate(t) { return (t || '').replace(/([a-zäöüß])-\s+(?=[a-zäöüßA-ZÄÖÜ])/g, '$1'); }
|
||
|
||
// Speech-tag heuristic so the book still casts with REAL names when the LLM is down.
|
||
const AB_SPEECH_VERBS = '(?:sagte|fragte|rief|antwortete|erwiderte|entgegnete|meinte|flüsterte|wisperte|raunte|murmelte|brummte|knurrte|brüllte|schrie|stammelte|fauchte|zischte|seufzte|lachte|kicherte|befahl|wiederholte|fuhr\\s+fort|said|asked|replied|answered|whispered|murmured|muttered|shouted|cried|called|exclaimed|added|continued)';
|
||
const AB_NOTNAME = new Set([
|
||
'Der', 'Die', 'Das', 'Den', 'Dem', 'Ein', 'Eine', 'Einen', 'Er', 'Sie', 'Es', 'Ich', 'Du', 'Wir', 'Ihr', 'Man',
|
||
'Und', 'Aber', 'Da', 'Dann', 'Doch', 'So', 'Nun', 'Jetzt', 'The', 'He', 'She', 'It', 'They', 'A', 'An', 'And', 'But', 'Then', 'Now',
|
||
// common sentence-initial adverbs / interjections / abstractions that are NOT characters
|
||
'Sofort', 'Plötzlich', 'Endlich', 'Schließlich', 'Stille', 'Schweigen', 'Stimme', 'Stimmen', 'Frage', 'Antwort',
|
||
'Gelächter', 'Wieder', 'Gleich', 'Sogleich', 'Langsam', 'Leise', 'Laut', 'Kaum', 'Vielleicht', 'Natürlich', 'Wirklich',
|
||
'Ja', 'Nein', 'Komm', 'Warte', 'Halt', 'Geh', 'Hier', 'Dort', 'Oben', 'Unten', 'Schon', 'Noch', 'Auch', 'Nur', 'Immer', 'Nie']);
|
||
const _AB_NAME = "([A-ZÄÖÜ][A-Za-zäöüß'\\-]+)";
|
||
function audiobookGuessSpeaker(after, before) {
|
||
let m;
|
||
const ok = n => (n && !AB_NOTNAME.has(n)) ? n : null;
|
||
// NOTE: no 'i' flag — names must be genuinely capitalized; German speech verbs
|
||
// after a quote are lowercase, so this rejects pronouns like "sagte er".
|
||
// after the quote: ", sagte Riskan" / "sagte Riskan" (verb → name)
|
||
if ((m = new RegExp('^[\\s,;–-]*' + AB_SPEECH_VERBS + '\\s+(?:der|die|das|ein|eine)?\\s*' + _AB_NAME).exec(after || ''))) { const r = ok(m[1]); if (r) return r; }
|
||
// after the quote: ", Riskan sagte" (name → verb)
|
||
if ((m = new RegExp('^[\\s,;–-]*' + _AB_NAME + '\\s+' + AB_SPEECH_VERBS).exec(after || ''))) { const r = ok(m[1]); if (r) return r; }
|
||
// before the quote: "Riskan sagte:" / "Riskan fragte"
|
||
if ((m = new RegExp(_AB_NAME + '\\s+' + AB_SPEECH_VERBS + '[\\s:,–-]*$').exec(before || ''))) { const r = ok(m[1]); if (r) return r; }
|
||
return null;
|
||
}
|
||
|
||
// Deterministic fallback: split a passage into narration + dialogue by quotation
|
||
// spans and attribute speakers from the surrounding speech tags. Used when the LLM
|
||
// is unavailable so dialogue — and as many speakers as possible — are never lost.
|
||
const AB_QUOTE_SPAN = /»([^«]+)«|«([^»]+)»|„([^“”]+)[“”]|“([^”]+)”|"([^"]+)"|「([^」]+)」|『([^』]+)』/g;
|
||
function audiobookSplitByQuotes(text) {
|
||
const spans = []; let m;
|
||
AB_QUOTE_SPAN.lastIndex = 0;
|
||
while ((m = AB_QUOTE_SPAN.exec(text))) {
|
||
spans.push({ start: m.index, end: AB_QUOTE_SPAN.lastIndex, quote: (m[1] || m[2] || m[3] || m[4] || m[5] || m[6] || m[7] || '').trim() });
|
||
}
|
||
if (!spans.length) return [{ speaker: 'Narrator', type: 'narration', text, emotion: '' }];
|
||
const out = []; let last = 0;
|
||
for (let k = 0; k < spans.length; k++) {
|
||
const sp = spans[k];
|
||
const pre = text.slice(last, sp.start);
|
||
if (pre.trim()) out.push({ speaker: 'Narrator', type: 'narration', text: pre.trim(), emotion: '' });
|
||
if (sp.quote) {
|
||
const after = text.slice(sp.end, k + 1 < spans.length ? spans[k + 1].start : text.length);
|
||
const speaker = audiobookGuessSpeaker(after, pre) || 'Unknown';
|
||
out.push({ speaker, type: 'dialogue', text: sp.quote, emotion: '' });
|
||
}
|
||
last = sp.end;
|
||
}
|
||
const tail = text.slice(last);
|
||
if (tail.trim()) out.push({ speaker: 'Narrator', type: 'narration', text: tail.trim(), emotion: '' });
|
||
return audiobookTurnTaking(out);
|
||
}
|
||
|
||
// Fill 'Unknown' dialogue speakers by two-person alternation — but only once TWO
|
||
// distinct named speakers are established nearby (conservative: won't guess in a
|
||
// monologue, so it rarely invents a wrong name).
|
||
function audiobookTurnTaking(segs) {
|
||
let a = null, b = null; // two most recent distinct named speakers (b = latest)
|
||
for (const s of segs) {
|
||
if (s.type !== 'dialogue') continue;
|
||
if (s.speaker && s.speaker !== 'Unknown') {
|
||
if (s.speaker !== b) { a = b; b = s.speaker; }
|
||
} else if (a && b && a !== b) {
|
||
s.speaker = a; // the other of the two → alternate
|
||
const t = a; a = b; b = t; // rotate so the next Unknown alternates back
|
||
}
|
||
}
|
||
return segs;
|
||
}
|
||
|
||
function audiobookLlmUrl() { return $('reh-llm-url')?.value.trim() || (typeof rehDefaultLlmUrl === 'function' ? rehDefaultLlmUrl() : ''); }
|
||
function audiobookLlmModel() { return $('reh-llm-model')?.value || ''; }
|
||
function audiobookLang() { return $('reh-design-lang')?.value || ''; }
|
||
|
||
// Gather the plain text of the current reader scope (selection > page range > all).
|
||
function audiobookScopeText() {
|
||
if (typeof readerScopeIndices !== 'function' || !readerState?.sentences?.length) return '';
|
||
const raw = readerScopeIndices().map(i => readerState.sentences[i].text).join(' ').replace(/\s+/g, ' ').trim();
|
||
return audiobookDehyphenate(raw); // mend PDF line-break hyphenation for clean speech + tag matching
|
||
}
|
||
|
||
// ── Progress overlay ─────────────────────────────────────────────────────────
|
||
|
||
function audiobookProgress(total) {
|
||
let ov = document.getElementById('audiobook-overlay');
|
||
if (!ov) {
|
||
ov = document.createElement('div');
|
||
ov.id = 'audiobook-overlay';
|
||
ov.className = 'audiobook-overlay';
|
||
ov.innerHTML = `<div class="audiobook-box">
|
||
<div class="audiobook-title"><span class="mdi mdi-drama-masks"></span> Casting audiobook</div>
|
||
<div class="audiobook-msg" id="audiobook-msg">Analysing…</div>
|
||
<div class="reader-synth-track"><div class="reader-synth-fill" id="audiobook-fill"></div></div>
|
||
<div class="audiobook-actions"><button class="btn-secondary btn-sm" id="audiobook-cancel">Cancel</button></div>
|
||
</div>`;
|
||
document.body.appendChild(ov);
|
||
ov.querySelector('#audiobook-cancel').addEventListener('click', () => { _audiobook.cancel = true; });
|
||
}
|
||
ov.hidden = false;
|
||
const fill = ov.querySelector('#audiobook-fill');
|
||
const msg = ov.querySelector('#audiobook-msg');
|
||
return {
|
||
update(done, label) { if (fill) fill.style.width = (done / total * 100) + '%'; if (msg && label) msg.textContent = label; },
|
||
done() { ov.hidden = true; },
|
||
};
|
||
}
|
||
|
||
const _AB_PALETTE = ['#3b82f6', '#10b981', '#8b5cf6', '#f59e0b', '#ef4444', '#ec4899', '#06b6d4', '#84cc16', '#f97316', '#14b8a6', '#6366f1', '#d946ef'];
|
||
|
||
// Live casting view: a scrolling feed of attributed lines + a character roster
|
||
// that fills up as speakers are discovered. Far clearer than a bare bar.
|
||
function audiobookCastView(total, 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 & 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 & 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');
|
||
}
|
||
}
|