788 lines
44 KiB
JavaScript
788 lines
44 KiB
JavaScript
// Audiobook cast preview, Rehearser handoff, and chapter export UI.
|
||
// Loaded after the casting pipeline and before audiobook.js; dependencies resolve at call time.
|
||
|
||
// ── Editable attribution preview ─────────────────────────────────────────────
|
||
|
||
async function audiobookOpenCurrentInRehearser() {
|
||
const segs = _audiobook.segments || [];
|
||
if (!segs.length) { toast('No cast to open', 'error'); return false; }
|
||
|
||
// Build the record straight from the CURRENT in-memory segments and load
|
||
// it directly — a prior version saved this to IndexedDB and then
|
||
// re-fetched it by id before loading, which could hand back a stale
|
||
// record (a read-after-write race, or a stale `_audiobook.rehId` left
|
||
// over from an earlier pass) instead of the just-corrected data. Building
|
||
// once and loading that same object removes the possibility entirely;
|
||
// the DB write below is now just for persistence, not the source of what
|
||
// gets loaded.
|
||
if (typeof window.rehLoadRecord === 'function' && typeof _audiobookBuildRehRecord === 'function') {
|
||
try {
|
||
const rec = await _audiobookBuildRehRecord(segs);
|
||
if (_audiobook.rehId) rec.id = _audiobook.rehId;
|
||
document.getElementById('audiobook-preview')?.remove();
|
||
if (typeof navTo === 'function') navTo('s-rehearser');
|
||
window.rehLoadRecord(rec);
|
||
// rehLoadRecord always lands on the Cast phase, but by the time you're
|
||
// opening the Rehearser from the Audiobook pipeline, voices were already
|
||
// assigned in Assign Voices — Cast there is pure redundant re-work.
|
||
// Jump straight to Stage (phase 3), the line-by-line editing view.
|
||
// showPhase() alone only toggles which phase <div> is visible — the
|
||
// actual script/character panes are built by buildScriptPage(), same
|
||
// as the Stage sub-tab's own click handler does.
|
||
if (rehState.lines.length && typeof buildScriptPage === 'function') {
|
||
buildScriptPage();
|
||
if (typeof showPhase === 'function') showPhase(3);
|
||
if (typeof highlightCurrentLine === 'function') highlightCurrentLine();
|
||
} else if (typeof showPhase === 'function') showPhase(3);
|
||
toast('Opened cast in Script Rehearser', 'success');
|
||
audiobookSaveAsRehearsal({ silent: true }).catch(() => {});
|
||
return true;
|
||
} catch (err) {
|
||
console.warn('[audiobook] failed to build rehearser record directly, falling back:', err);
|
||
}
|
||
}
|
||
|
||
await audiobookSaveAsRehearsal({ silent: true });
|
||
const { script, emotions } = audiobookBuildScript(segs);
|
||
await audiobookOpenInRehearser(script, (readerState.title || 'Audiobook'), emotions);
|
||
if (rehState.lines.length && typeof buildScriptPage === 'function') {
|
||
buildScriptPage();
|
||
if (typeof showPhase === 'function') showPhase(3);
|
||
if (typeof highlightCurrentLine === 'function') highlightCurrentLine();
|
||
} else if (typeof showPhase === 'function') showPhase(3);
|
||
return true;
|
||
}
|
||
|
||
function audiobookShowPreview() {
|
||
audiobookOpenCurrentInRehearser();
|
||
return;
|
||
const segs = _audiobook.segments || [];
|
||
const roster = _audiobook.roster || [];
|
||
document.getElementById('audiobook-preview')?.remove();
|
||
const ov = document.createElement('div');
|
||
ov.id = 'audiobook-preview';
|
||
ov.className = 'audiobook-overlay';
|
||
// datalist = Narrator + LLM roster + any speakers present in the segments (incl. Unknown)
|
||
const speakerSet = [...new Set(['Narrator', ...roster, ...segs.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker)])];
|
||
const charCount = speakerSet.filter(n => !/^Unknown|Unbekannt/i.test(n)).length;
|
||
const opts = speakerSet.map(n => `<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>` : ''}${_audiobook.recoveredGaps ? ` <span class="audiobook-narr-note">${_audiobook.recoveredGaps} dropped sentence/paragraph gap${_audiobook.recoveredGaps !== 1 ? 's' : ''} recovered as Narrator — review placement.</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">${_abMarkQuotes(highlightText(_abDisplayText(s)))}</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).
|
||
// Inserts \f page-break markers at the segment boundary nearest each source PDF
|
||
// page start (so saved/opened scripts keep the book's pagination). Page positions
|
||
// are realigned per-segment against the source text to avoid cumulative drift.
|
||
function audiobookBuildScript(segments) {
|
||
let script = '';
|
||
const emotions = [];
|
||
const marks = _audiobook.pageMarks || [];
|
||
const src = _audiobook.lastText || '';
|
||
let markIdx = 0, searchPos = 0;
|
||
// The first page mark sits at the start of the document — no leading break.
|
||
if (marks.length && marks[0].offset <= 2) markIdx = 1;
|
||
|
||
for (const s of segments) {
|
||
const t = (s.text || '').trim(); if (!t) continue;
|
||
// Where does this segment sit in the source text? (verbatim narration matches
|
||
// exactly; dialogue text — quotes stripped — still occurs in the source.)
|
||
if (src && markIdx < marks.length) {
|
||
const probe = t.slice(0, 24);
|
||
const at = probe ? src.indexOf(probe, searchPos) : -1;
|
||
const pos = at >= 0 ? at : searchPos;
|
||
if (at >= 0) searchPos = at + probe.length;
|
||
while (markIdx < marks.length && pos >= marks[markIdx].offset) {
|
||
if (script.trim()) {
|
||
script += `\n\f${marks[markIdx].page + 1}\n`; // page break — 1-indexed page number
|
||
}
|
||
markIdx++;
|
||
}
|
||
}
|
||
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;
|
||
// OCR'd headings (chapter titles baked into the PDF as images, recovered via
|
||
// Tesseract) commonly lead with the chapter number rather than the keyword —
|
||
// "1.Kapitel", "I. Kapitel", "Kapitel 1" — so the keyword can't be required
|
||
// at position 0. Allow an optional leading number/roman numeral (with an
|
||
// optional trailing period, and no requirement for a space before the
|
||
// keyword, since OCR frequently drops the space in "1.Kapitel").
|
||
// A lone leading letter from the roman-numeral set is also just the first
|
||
// letter of an ordinary word — "C" (100) is real Roman notation, but it's
|
||
// also literally how "Chapter" starts. Confirmed live: without the
|
||
// lookahead requiring whitespace/period/end right after the numeral run,
|
||
// this silently ate the "C" off "Chapter 1: The Beginning", leaving
|
||
// "hapter 1..." — which then obviously failed the keyword match entirely.
|
||
const NUM = /^(?:[ivxlcdm]+(?=[.\s]|$)|\d{1,3})\.?\s*/i;
|
||
const rest = t.replace(NUM, '');
|
||
const KEYWORD = /^(chapter|kapitel|chap\.?|part|book|prologue|epilogue|prolog|epilog|teil)\b/i;
|
||
if (!KEYWORD.test(rest)) return false;
|
||
// "part"/"book"/"teil" ("teil" = German "part") are common ordinary words,
|
||
// not just chapter markers — "Teil des Grundes war unklar." ("Part of the
|
||
// reason was unclear.") is a perfectly normal, short narration sentence
|
||
// that starts with the same word. Requiring the keyword at position 0 was
|
||
// never enough on its own; this used to only affect export file-splitting
|
||
// (harmless — one fewer bucket boundary), but wiring the same check into
|
||
// the Stage view's rendering made it consequential: an ordinary sentence
|
||
// getting misdetected now silently replaces real paragraph text with a
|
||
// thin chapter-marker line (confirmed live: "lots of empty pages" in the
|
||
// A4 pagination view, from real narration vanishing this way). Whatever
|
||
// follows the keyword must therefore look like part of a heading — empty,
|
||
// a bare trailing number/numeral, or a colon/dash-separated subtitle —
|
||
// never a normal grammatical continuation.
|
||
const afterKeyword = rest.replace(KEYWORD, '').trim();
|
||
if (afterKeyword === '') return true;
|
||
if (/^[ivxlcdm\d]{1,6}\.?$/i.test(afterKeyword)) return true;
|
||
return /^[ivxlcdm\d]{0,6}\.?\s*[-:–—]\s*.{1,40}$/i.test(afterKeyword);
|
||
}
|
||
|
||
// German ordinal STEM for 1-99 (no gender ending yet — that depends on the
|
||
// noun it modifies, see _abGermanOrdinalWord) — covers any chapter count a
|
||
// real novel would have. Returns null outside that range so the caller can
|
||
// fall back to the original digits rather than guessing.
|
||
const _AB_DE_ORDINAL_STEM_1_19 = ['', 'erst', 'zweit', 'dritt', 'viert', 'fünft', 'sechst', 'siebt',
|
||
'acht', 'neunt', 'zehnt', 'elft', 'zwölft', 'dreizehnt', 'vierzehnt', 'fünfzehnt',
|
||
'sechzehnt', 'siebzehnt', 'achtzehnt', 'neunzehnt'];
|
||
const _AB_DE_CARDINAL_TENS = { 2: 'zwanzig', 3: 'dreißig', 4: 'vierzig', 5: 'fünfzig', 6: 'sechzig', 7: 'siebzig', 8: 'achtzig', 9: 'neunzig' };
|
||
const _AB_DE_CARDINAL_ONES = ['', 'ein', 'zwei', 'drei', 'vier', 'fünf', 'sechs', 'sieben', 'acht', 'neun'];
|
||
|
||
// gender: 'n' (neuter, "-tes" — das Kapitel/Buch) or 'm' (masculine, "-ter" — der Teil).
|
||
function _abGermanOrdinalWord(n, gender) {
|
||
let stem;
|
||
if (n >= 1 && n <= 19) stem = _AB_DE_ORDINAL_STEM_1_19[n];
|
||
else if (n >= 20 && n <= 99) {
|
||
const tens = _AB_DE_CARDINAL_TENS[Math.floor(n / 10)];
|
||
const ones = n % 10;
|
||
if (!tens) return null;
|
||
stem = (ones ? _AB_DE_CARDINAL_ONES[ones] + 'und' : '') + tens + 'st';
|
||
} else return null;
|
||
return stem + (gender === 'm' ? 'er' : 'es');
|
||
}
|
||
|
||
// "Kapitel"/"Buch" are neuter (das), "Teil" is masculine (der) — "Dritter
|
||
// Teil" not "Drittes Teil".
|
||
const _AB_DE_HEADING_GENDER = { kapitel: 'n', buch: 'n', teil: 'm' };
|
||
|
||
// A German chapter/part heading like "1. Kapitel" or "3.Teil" reads badly
|
||
// through TTS as-is: the bare "<digits>." is exactly the shape a TTS
|
||
// frontend's number/list normalizer treats as a list marker rather than a
|
||
// number to speak, so it goes silent and only "Kapitel" gets voiced after an
|
||
// odd pause (confirmed live/reported). Spelling the ordinal out — "Erstes
|
||
// Kapitel" — sidesteps that normalizer entirely. Only rewrites the TTS-bound
|
||
// copy of the text (see the call site in audiobookExport); the underlying
|
||
// segment text is left alone everywhere else (casting, display, export
|
||
// filenames, audiobookIsChapter's own detection).
|
||
function _abSpokenChapterHeading(text, language) {
|
||
if (!/^de/i.test(language || '')) return text;
|
||
const m = /^(\d{1,2})\.\s*(Kapitel|Teil|Buch)\b(.*)$/i.exec((text || '').trim());
|
||
if (!m) return text;
|
||
const gender = _AB_DE_HEADING_GENDER[m[2].toLowerCase()] || 'n';
|
||
const word = _abGermanOrdinalWord(parseInt(m[1], 10), gender);
|
||
if (!word) return text;
|
||
return `${word[0].toUpperCase()}${word.slice(1)} ${m[2]}${m[3]}`;
|
||
}
|
||
|
||
// Shared between the export dialog's checklist (audiobookExportDialog) and
|
||
// the export progress popup's per-chapter label (audiobookExport) so
|
||
// "Chapter N" always means the same bucket in both places. The leading
|
||
// untitled bucket — speakable text before the first recognised chapter
|
||
// heading (title page/cover blurb OCR, a preface with no heading of its
|
||
// own) — is labeled "Front matter" instead of stealing "Chapter 1" and
|
||
// pushing every real chapter's number off by one (confirmed live).
|
||
function _abChapterLabels(buckets) {
|
||
const frontMatter = buckets.length > 1 && !buckets[0].title;
|
||
let num = 0;
|
||
return buckets.map((b, i) => (frontMatter && i === 0)
|
||
? 'Front matter'
|
||
: ('Chapter ' + (++num) + (b.title ? ': ' + b.title : '')));
|
||
}
|
||
|
||
// ── Pause settings (paragraph/chapter gaps + optional chapter sound) ────────
|
||
// Persisted per-browser (not per-book) via localStorage — these are pacing
|
||
// preferences, not book content, so they should carry over between exports.
|
||
|
||
function _abPauseSettings() {
|
||
const num = (key, def) => {
|
||
let v;
|
||
try { v = parseFloat(localStorage.getItem(key)); } catch (_) { v = NaN; }
|
||
return Number.isFinite(v) && v >= 0 ? v : def;
|
||
};
|
||
return {
|
||
paragraphMs: num('ttsvc_ab_pause_paragraph_s', 2) * 1000,
|
||
chapterMs: num('ttsvc_ab_pause_chapter_s', 4) * 1000,
|
||
};
|
||
}
|
||
|
||
function _abSetPauseSetting(key, seconds) {
|
||
try { localStorage.setItem(key, String(seconds)); } catch (_) {}
|
||
}
|
||
|
||
function _abChapterSfxDataUrl() {
|
||
try { return localStorage.getItem('ttsvc_ab_chapter_sfx') || null; } catch (_) { return null; }
|
||
}
|
||
|
||
function _abSetChapterSfx(dataUrl) {
|
||
try {
|
||
if (dataUrl) localStorage.setItem('ttsvc_ab_chapter_sfx', dataUrl);
|
||
else localStorage.removeItem('ttsvc_ab_chapter_sfx');
|
||
} catch (e) { toast('Could not save chapter sound — file may be too large', 'error'); }
|
||
}
|
||
|
||
// Decodes the user's custom chapter-transition sound (any format the browser
|
||
// can decode) and re-renders it at the EXACT sample rate/channels the
|
||
// narration clips use — mergeWavBlobs concatenates raw PCM bytes assuming a
|
||
// single shared format, so a mismatched sample rate here would play back
|
||
// pitched/sped-up or corrupt the merge, not just sound wrong.
|
||
async function _abChapterSfxWavBlob(fmt) {
|
||
const dataUrl = _abChapterSfxDataUrl();
|
||
if (!dataUrl) return null;
|
||
try {
|
||
const resp = await fetch(dataUrl);
|
||
const arrayBuf = await resp.arrayBuffer();
|
||
const AC = window.AudioContext || window.webkitAudioContext;
|
||
const OAC = window.OfflineAudioContext || window.webkitOfflineAudioContext;
|
||
const tmpCtx = new AC();
|
||
const decoded = await tmpCtx.decodeAudioData(arrayBuf.slice(0));
|
||
if (typeof tmpCtx.close === 'function') tmpCtx.close();
|
||
const offline = new OAC(fmt.channels, Math.max(1, Math.ceil(decoded.duration * fmt.sampleRate)), fmt.sampleRate);
|
||
const src = offline.createBufferSource();
|
||
src.buffer = decoded;
|
||
src.connect(offline.destination);
|
||
src.start();
|
||
const rendered = await offline.startRendering();
|
||
const frames = rendered.length;
|
||
const pcm = new Int16Array(frames * fmt.channels);
|
||
for (let ch = 0; ch < fmt.channels; ch++) {
|
||
const data = rendered.getChannelData(Math.min(ch, rendered.numberOfChannels - 1));
|
||
for (let i = 0; i < frames; i++) {
|
||
const s = Math.max(-1, Math.min(1, data[i]));
|
||
pcm[i * fmt.channels + ch] = s < 0 ? s * 0x8000 : s * 0x7fff;
|
||
}
|
||
}
|
||
return _buildWavBlob(new Uint8Array(pcm.buffer), fmt);
|
||
} catch (e) {
|
||
console.error('[audiobook export] chapter sound decode failed', e);
|
||
toast('Could not use the custom chapter sound — check the audio file', 'error');
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Wires the Perform & Export pause-settings fields to localStorage. Called
|
||
// every time Studio's phase 4 is shown (studio.js) — guarded so listeners
|
||
// are only attached once, since the fields themselves are never reparented
|
||
// away (unlike the borrowed Stage/tone-warning elements).
|
||
let _abPauseUIInited = false;
|
||
function _abInitPauseUI() {
|
||
const pEl = $('stu-pause-paragraph'), cEl = $('stu-pause-chapter');
|
||
const sfxFile = $('stu-chapter-sfx-file'), sfxClear = $('stu-chapter-sfx-clear'), sfxCurrent = $('stu-chapter-sfx-current');
|
||
if (!pEl || !cEl) return;
|
||
const s = _abPauseSettings();
|
||
pEl.value = s.paragraphMs / 1000;
|
||
cEl.value = s.chapterMs / 1000;
|
||
if (sfxCurrent) sfxCurrent.textContent = _abChapterSfxDataUrl() ? 'Custom sound set' : 'No custom sound';
|
||
if (_abPauseUIInited) return;
|
||
_abPauseUIInited = true;
|
||
pEl.addEventListener('change', () => _abSetPauseSetting('ttsvc_ab_pause_paragraph_s', Math.max(0, parseFloat(pEl.value) || 0)));
|
||
cEl.addEventListener('change', () => _abSetPauseSetting('ttsvc_ab_pause_chapter_s', Math.max(0, parseFloat(cEl.value) || 0)));
|
||
sfxClear?.addEventListener('click', () => {
|
||
_abSetChapterSfx(null);
|
||
if (sfxFile) sfxFile.value = '';
|
||
if (sfxCurrent) sfxCurrent.textContent = 'No custom sound';
|
||
});
|
||
sfxFile?.addEventListener('change', () => {
|
||
const file = sfxFile.files && sfxFile.files[0];
|
||
if (!file) return;
|
||
// Capped well under localStorage's ~5-10MB origin quota (base64 inflates
|
||
// size by ~33%) — a chapter chime doesn't need to be a whole song.
|
||
if (file.size > 2 * 1024 * 1024) {
|
||
toast('Chapter sound is too large (max 2 MB) — trim it to a short chime/sting', 'error');
|
||
sfxFile.value = '';
|
||
return;
|
||
}
|
||
const reader = new FileReader();
|
||
reader.onload = () => {
|
||
_abSetChapterSfx(reader.result);
|
||
if (sfxCurrent) sfxCurrent.textContent = file.name;
|
||
};
|
||
reader.onerror = () => toast('Could not read that audio file', 'error');
|
||
reader.readAsDataURL(file);
|
||
});
|
||
}
|
||
window._abInitPauseUI = _abInitPauseUI;
|
||
|
||
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, c.voice) : '') };
|
||
}
|
||
return { voice: rehState.narratorVoice, instruct: '' };
|
||
}
|
||
|
||
// Long narrator paragraphs must be split before they reach the TTS service.
|
||
// The service has to restart its decoder/model context for a very long
|
||
// request, and its own emergency cut can land mid-sentence. Keep the split
|
||
// sentence-aware and add only a short, intentional breath between generated
|
||
// pieces; normal paragraph gaps are added later when chapters are merged.
|
||
async function _abSynthesizeLongText(voice, text, instruct, backend) {
|
||
const pieces = (typeof splitTextIntoChunks === 'function')
|
||
? splitTextIntoChunks(text, 900)
|
||
: [text];
|
||
if (pieces.length <= 1) return fetchTtsPreviewBlob(voice, text, 'wav', instruct, backend);
|
||
const blobs = [];
|
||
for (const piece of pieces) {
|
||
blobs.push(await fetchTtsPreviewBlob(voice, piece, 'wav', instruct, backend));
|
||
}
|
||
return mergeWavBlobs(blobs, 120);
|
||
}
|
||
|
||
async function audiobookExport(opts = {}) {
|
||
if (_abBusyGuard('Export')) 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); }
|
||
});
|
||
let activeBuckets = buckets;
|
||
if (opts.selectedChapterIndices) {
|
||
activeBuckets = buckets.filter((_, i) => opts.selectedChapterIndices.includes(i));
|
||
}
|
||
const allIdx = activeBuckets.flatMap(b => b.idx);
|
||
if (!allIdx.length) { toast('Nothing to synthesise — cast voices first', 'error'); return; }
|
||
|
||
// "Synthesising line 812 / 2351…" alone gives no sense of where in the
|
||
// book that is for a multi-hour export — map each line back to its
|
||
// chapter so the progress popup can show "Chapter 5 / 13 · line …" too
|
||
// (requested live).
|
||
const activeLabels = _abChapterLabels(activeBuckets);
|
||
const lineChapterPos = new Map();
|
||
activeBuckets.forEach((b, pos) => b.idx.forEach(idx => lineChapterPos.set(idx, pos)));
|
||
const progLabel = (doneCount, i) => {
|
||
const label = activeLabels[lineChapterPos.get(i)];
|
||
return (label ? label + ' · ' : '') + `Synthesising line ${doneCount} / ${allIdx.length}…`;
|
||
};
|
||
|
||
_audiobook.running = true; _audiobook.cancel = false;
|
||
const prog = audiobookProgress(allIdx.length);
|
||
// WAV, not mp3, per line: each clip needs to be losslessly merged before
|
||
// any lossy encoding happens (see the merge step below for why) — encoding
|
||
// to mp3 here, one clip at a time, is exactly what produced a file most
|
||
// players couldn't play past the first clip.
|
||
const wavClips = new Map();
|
||
const failedLines = [];
|
||
let done = 0;
|
||
const queue = allIdx.slice();
|
||
const worker = async () => {
|
||
while (queue.length && !_audiobook.cancel) {
|
||
const i = queue.shift();
|
||
// "Synth all" already pre-synthesizes every line into rehState.synthCache
|
||
// for instant playback — this export used to ignore that entirely and
|
||
// re-hit the TTS API for every single line from scratch regardless,
|
||
// turning what should be an instant export (for anyone who already
|
||
// pre-synthesized) into a full re-synthesis of the whole book, easily
|
||
// 20+ minutes for a real novel with no visible sign anything had gone
|
||
// wrong — confirmed as the actual cause behind "clicked Audiobook and
|
||
// got no file": it was still working, just silently redoing work that
|
||
// was already done. A cached, non-stale clip is reused as-is; a stale
|
||
// one (tone edited, or the speaker's voice reassigned — see
|
||
// _rehInvalidateCastVoice) is skipped so the branch below re-synthesizes.
|
||
if (rehState.synthCache.has(i) && !rehState.staleLines.has(i)) {
|
||
wavClips.set(i, rehState.synthCache.get(i));
|
||
prog.update(++done, progLabel(done, i));
|
||
continue;
|
||
}
|
||
try {
|
||
// audiobookLineVoice/_rehInlineTone/stripMarkdown used to run OUTSIDE
|
||
// this try — any of them throwing on one unusual line (confirmed live:
|
||
// an export died silently around line ~600/2351) escaped worker()
|
||
// entirely uncaught. That unwound straight past the caller's `finally`
|
||
// (which calls prog.done(), removing the progress popup) with no catch
|
||
// downstream to show a toast — the whole multi-hour export vanishing
|
||
// with zero explanation, worse than the "stop instead of merging a
|
||
// file with silent gaps" this file already stops other failures for.
|
||
// Now it's just one more line added to failedLines like any other.
|
||
const l = rehState.lines[i];
|
||
const { voice, instruct } = audiobookLineVoice(l);
|
||
let text = (typeof _rehInlineTone === 'function')
|
||
? _rehInlineTone(stripMarkdown(l.text), l.emotion)
|
||
: (typeof stripMarkdown === 'function' ? stripMarkdown(l.text) : l.text);
|
||
if (l.type !== 'dialog' && audiobookIsChapter(l)) text = _abSpokenChapterHeading(text, audiobookLang());
|
||
// Beyond rehState.synthCache (this browser tab's memory, gone on
|
||
// reload), lines synthesized via "Synth all" in an EARLIER session
|
||
// persist to disk too — check there before hitting TTS at all.
|
||
let blob = null, cacheKey = null, book = null;
|
||
if (typeof _lineAudioCacheKey === 'function') {
|
||
book = _lineAudioBookName();
|
||
cacheKey = await _lineAudioCacheKey(text, voice, instruct);
|
||
blob = await _lineAudioCacheGet(book, cacheKey);
|
||
}
|
||
if (!blob) {
|
||
// fetchTtsPreviewBlob's own retry (_ttsPreviewFetchWithRetry) only
|
||
// covers a connection-level failure — fetch() doesn't throw on a
|
||
// non-2xx response, so a backend hiccup (confirmed live: transient
|
||
// 500s during the first ~50 lines, most likely GPU/engine warm-up
|
||
// contention from the two parallel workers both starting cold)
|
||
// throws straight out of fetchTtsPreviewBlob itself, past that
|
||
// retry layer entirely. A single blip like that used to doom the
|
||
// ENTIRE multi-hour export — confirmed live, different ordinary,
|
||
// unremarkable lines failed on repeated attempts, so this isn't
|
||
// about any one line's content. Give a transient failure a few
|
||
// seconds to pass before giving up on this line for real.
|
||
let lastErr;
|
||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||
try {
|
||
blob = await _abSynthesizeLongText(voice, text, instruct, _ttsBackendForVoice(voice, rehState.backend));
|
||
lastErr = null;
|
||
break;
|
||
} catch (e) {
|
||
lastErr = e;
|
||
if (attempt < 3) await new Promise(r => setTimeout(r, 3000 * attempt));
|
||
}
|
||
}
|
||
if (lastErr) throw lastErr;
|
||
if (cacheKey) _lineAudioCachePut(book, cacheKey, blob);
|
||
}
|
||
wavClips.set(i, blob);
|
||
rehState.synthCache.set(i, blob); // benefits a retry/re-export too
|
||
}
|
||
catch (e) { failedLines.push(i); console.error('[audiobook export] synth failed for line', i, e); }
|
||
prog.update(++done, progLabel(done, i));
|
||
}
|
||
};
|
||
// Used to run 2 workers concurrently to speed up bulk export. Every TTS
|
||
// backend this app talks to (voice_clone, voice_design, fishspeech, the
|
||
// other local engines) is a single self-hosted GPU model instance, not a
|
||
// horizontally-scaled service — confirmed live, twice, with two different
|
||
// backends: 2 concurrent requests reliably push at least one past the
|
||
// reverse proxy's 60s timeout under real load, producing exactly the
|
||
// "some lines just fail, no clear reason" pattern that made a multi-hour
|
||
// book export fail outright. Serial is slower per line but doesn't waste
|
||
// time on doomed, retried, ultimately-failing requests — net faster and
|
||
// actually finishes.
|
||
// Defense in depth beyond the per-line try above: an export this long
|
||
// (thousands of lines, potentially hours) failing SILENTLY — progress
|
||
// popup just gone, no toast, nothing in server logs because the crash
|
||
// never even reached a network call — is far worse than any other failure
|
||
// mode this file already guards against. Whatever still gets through here
|
||
// is at least reported instead of vanishing.
|
||
try { await worker(); }
|
||
catch (e) {
|
||
console.error('[audiobook export] worker crashed:', e);
|
||
toast('Export stopped unexpectedly: ' + (e.message || e), 'error');
|
||
return;
|
||
}
|
||
finally { prog.done(); _audiobook.running = false; }
|
||
|
||
if (_audiobook.cancel) { toast('Export cancelled', 'error'); return; }
|
||
// A line whose synthesis failed used to just be missing from `mp3`, so
|
||
// `buckets[c].idx.map(...).filter(Boolean)` silently dropped it out of the
|
||
// merged file — the export "succeeded" while quietly missing dialogue/
|
||
// narration, discoverable only by listening to the whole thing. Stop
|
||
// instead of merging a file with silent gaps in it.
|
||
if (failedLines.length) {
|
||
toast(failedLines.length + ' line(s) failed to synthesise — retry them (Script Rehearser → re-synthesise stale) before exporting, or they will silently drop from the audiobook', '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;
|
||
const savedFiles = []; // {name, url} — for the results panel below
|
||
const bookForExport = _lineAudioBookName();
|
||
// The "Save Location (Server)" field's default value literally reads
|
||
// "exports/" — treat that (and blank) as "use the app's normal export
|
||
// folder" rather than actually nesting into a folder named "exports",
|
||
// which would otherwise silently relocate every default export the moment
|
||
// this field started being honored server-side (it used to be collected
|
||
// and sent but ignored — confirmed live, ` /api/audiobook-export` never
|
||
// read its `folder` query param).
|
||
const _folderRaw = (opts.folder || '').trim().replace(/\/+$/, '');
|
||
const folder = (_folderRaw && _folderRaw.toLowerCase() !== 'exports') ? _folderRaw : '';
|
||
const folderQS = folder ? '?folder=' + encodeURIComponent(folder) : '';
|
||
// Remembered per book so "Browse saved exports" and the zip-all link find
|
||
// files saved under a custom folder later, without the user re-entering it.
|
||
try {
|
||
const key = 'ttsvc_export_folder:' + bookForExport;
|
||
if (folder) localStorage.setItem(key, folder); else localStorage.removeItem(key);
|
||
} catch (_) {}
|
||
const encMsg = $('audiobook-msg');
|
||
const pauses = _abPauseSettings();
|
||
if (opts.format === 'single') {
|
||
let masterBlobs = [];
|
||
for (let c = 0; c < activeBuckets.length; c++) {
|
||
const blobs = activeBuckets[c].idx.map(i => wavClips.get(i)).filter(Boolean);
|
||
if (!blobs.length) continue;
|
||
if (encMsg) encMsg.textContent = `Merging chapter ${c + 1} / ${activeBuckets.length}…`;
|
||
let mergedWav = await mergeWavBlobs(blobs, pauses.paragraphMs);
|
||
if (c > 0 && activeBuckets[c].title && (pauses.chapterMs > 0 || _abChapterSfxDataUrl())) {
|
||
const ref = _parseWavBytes(new Uint8Array(await mergedWav.arrayBuffer())).fmt;
|
||
const lead = [];
|
||
const sfx = await _abChapterSfxWavBlob(ref);
|
||
if (sfx) lead.push(sfx);
|
||
if (pauses.chapterMs > 0) lead.push(_buildWavBlob(_silencePcmBytes(pauses.chapterMs, ref), ref));
|
||
if (lead.length) mergedWav = await mergeWavBlobs([...lead, mergedWav]);
|
||
}
|
||
masterBlobs.push(mergedWav);
|
||
}
|
||
if (masterBlobs.length) {
|
||
if (encMsg) encMsg.textContent = `Encoding full book to MP3…`;
|
||
let finalWav = await mergeWavBlobs(masterBlobs, 0);
|
||
let blob = finalWav;
|
||
try {
|
||
const encResp = await fetch('/api/audio/encode-mp3', { method: 'POST', body: finalWav });
|
||
if (encResp.ok) blob = await encResp.blob();
|
||
} catch (e) {}
|
||
const ext = blob === finalWav ? 'wav' : 'mp3';
|
||
const name = `${title}.${ext}`;
|
||
if (typeof readerDownload === 'function') readerDownload(blob, name);
|
||
try {
|
||
const exportUrl = `/api/audiobook-export/${encodeURIComponent(bookForExport)}/${encodeURIComponent(name)}${folderQS}`;
|
||
const r = await fetch(exportUrl, { method: 'POST', body: blob });
|
||
if (r.ok) savedFiles.push({ name, url: exportUrl });
|
||
} catch (e) {}
|
||
files++;
|
||
}
|
||
} else {
|
||
for (let c = 0; c < activeBuckets.length; c++) {
|
||
const blobs = activeBuckets[c].idx.map(i => wavClips.get(i)).filter(Boolean);
|
||
if (!blobs.length) continue;
|
||
// mergeWavBlobs (generation.js) properly concatenates raw PCM sample
|
||
// data under one RIFF header — unlike naively Blob-concatenating
|
||
// separately-encoded mp3 clips (each with its own frame/ID3 headers),
|
||
// this produces one genuinely continuous, seekable audio stream.
|
||
// A silent gap is inserted between every pair of lines (`pauses.paragraphMs`)
|
||
// — mergeWavBlobs used to butt clips together with literally zero space,
|
||
// which read as characters teleporting mid-scene with no beat between them.
|
||
if (encMsg) encMsg.textContent = `Merging chapter ${c + 1} / ${activeBuckets.length}…`;
|
||
let mergedWav = await mergeWavBlobs(blobs, pauses.paragraphMs);
|
||
// Every chapter after the first gets a longer lead-in pause (and, if the
|
||
// user configured one, a custom transition sound) prepended — the first
|
||
// chapter needs no lead-in since there's nothing before it to separate
|
||
// from. This survives even when chapters export as separate files (the
|
||
// normal case — see `realChapters` below): most audiobook players queue
|
||
// consecutive tracks back-to-back with no gap of their own.
|
||
if (c > 0 && activeBuckets[c].title && (pauses.chapterMs > 0 || _abChapterSfxDataUrl())) {
|
||
const ref = _parseWavBytes(new Uint8Array(await mergedWav.arrayBuffer())).fmt;
|
||
const lead = [];
|
||
const sfx = await _abChapterSfxWavBlob(ref);
|
||
if (sfx) lead.push(sfx);
|
||
if (pauses.chapterMs > 0) lead.push(_buildWavBlob(_silencePcmBytes(pauses.chapterMs, ref), ref));
|
||
if (lead.length) mergedWav = await mergeWavBlobs([...lead, mergedWav]);
|
||
}
|
||
// One real mp3 encode pass over the whole merged chapter, at an
|
||
// explicit bitrate (routes/tts.py's /api/audio/encode-mp3) — the old
|
||
// per-line encoding left the bitrate at ffmpeg/lame's unset default,
|
||
// which came out to 32kbps, well below what even a 24kHz mono voice
|
||
// source needs to avoid audible compression artifacts.
|
||
if (encMsg) encMsg.textContent = `Encoding chapter ${c + 1} / ${activeBuckets.length}…`;
|
||
let blob = mergedWav;
|
||
try {
|
||
const encResp = await fetch('/api/audio/encode-mp3', { method: 'POST', body: mergedWav });
|
||
if (encResp.ok) blob = await encResp.blob();
|
||
else console.error('[audiobook export] mp3 encode failed, shipping wav instead:', encResp.status);
|
||
} catch (e) { console.error('[audiobook export] mp3 encode request failed, shipping wav instead:', e); }
|
||
const ext = blob === mergedWav ? 'wav' : 'mp3';
|
||
const ch = buckets[c].title ? ' ' + readerSafeName(buckets[c].title) : '';
|
||
const name = (realChapters || buckets.length > 1)
|
||
? `${title} - ${String(c + 1).padStart(2, '0')}${ch}.${ext}`
|
||
: `${title}.${ext}`;
|
||
if (typeof readerDownload === 'function') readerDownload(blob, name);
|
||
// Also persist server-side — the browser download alone lands wherever
|
||
// the user's browser settings put it, with nothing in the app itself
|
||
// saying where, and re-finding it later means re-running the whole
|
||
// export from scratch. Best-effort: the browser download above already
|
||
// succeeded either way.
|
||
try {
|
||
const saveUrl = `/api/audiobook-export/${encodeURIComponent(bookForExport)}/${encodeURIComponent(name)}${folderQS}`;
|
||
const saveResp = await fetch(saveUrl, { method: 'POST', body: blob });
|
||
if (saveResp.ok) savedFiles.push({ name, url: saveUrl });
|
||
} catch (_) {}
|
||
files++;
|
||
await new Promise(r => setTimeout(r, 400));
|
||
}
|
||
} // close else block
|
||
toast('Exported audiobook · ' + files + (realChapters ? ' chapter file(s)' : ' file(s)'), 'success');
|
||
if (savedFiles.length && typeof _abShowExportResults === 'function') _abShowExportResults(bookForExport, savedFiles, { folder });
|
||
}
|
||
|
||
// A small results panel after "Audiobook" finishes — separate from the
|
||
// success toast, which is plain text and disappears after a few seconds
|
||
// with no way to actually get back to the files. Lists each saved chapter
|
||
// with a real download link and shows the server-side folder they live in,
|
||
// so the export doesn't just vanish into whatever the browser's download
|
||
// settings did with it.
|
||
function _abShowExportResults(book, files, opts = {}) {
|
||
document.getElementById('ab-export-results')?.remove();
|
||
const ov = document.createElement('div');
|
||
ov.id = 'ab-export-results';
|
||
ov.className = 'audiobook-overlay';
|
||
const folderQS = opts.folder ? '?folder=' + encodeURIComponent(opts.folder) : '';
|
||
const zipUrl = `/api/audiobook-export/${encodeURIComponent(book)}/zip${folderQS}`;
|
||
ov.innerHTML = `<div class="audiobook-box" style="max-width:520px;">
|
||
<div class="audiobook-title"><span class="mdi mdi-book-music-outline"></span> ${opts.browsing ? 'Saved audiobook exports' : 'Audiobook exported'}<span style="flex:1"></span><button type="button" class="btn-secondary btn-sm" id="ab-export-close">Close</button></div>
|
||
${opts.browsing ? '' : `<div style="display:flex; align-items:center; gap:8px; margin:10px 0 4px; color:#2e7d32; font-weight:600; font-size:13px;"><span class="mdi mdi-check-circle-outline" style="font-size:18px;"></span> Export complete — ${files.length} file${files.length === 1 ? '' : 's'} saved</div>`}
|
||
<p class="card-subtitle" style="margin:4px 0 14px;">Saved on the server${opts.browsing ? '' : ', in case the browser’s own download went somewhere you don’t check'} — come back and download or play any of these again any time, without re-exporting.</p>
|
||
<div style="display:flex; flex-direction:column; gap:8px; max-height:50vh; overflow-y:auto;">
|
||
${files.map((f, i) => `
|
||
<div class="ab-export-file-row" data-idx="${i}" style="display:flex; align-items:center; gap:10px; padding:8px 10px; border:1px solid var(--border); border-radius:8px;">
|
||
<button type="button" class="btn-secondary btn-sm ab-export-play-btn" title="Play"><span class="mdi mdi-play"></span></button>
|
||
<span style="flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:13px;" title="${escHtml(f.name)}">${escHtml(f.name)}</span>
|
||
<a class="btn-primary btn-sm" href="${f.url}" download="${escHtml(f.name)}"><span class="mdi mdi-download"></span> Download</a>
|
||
</div>`).join('')}
|
||
</div>
|
||
${files.length > 1 ? `<div style="margin-top:14px; text-align:right;"><a class="btn-secondary btn-sm" href="${zipUrl}"><span class="mdi mdi-folder-zip-outline"></span> Download all (.zip)</a></div>` : ''}
|
||
</div>`;
|
||
document.body.appendChild(ov);
|
||
// One shared <audio> element for the whole panel — only one row plays at
|
||
// a time, requested live alongside a clearer "it actually finished" state
|
||
// (the plain success toast alone disappears in a few seconds with no way
|
||
// back to the files, and there was previously no way to preview a result
|
||
// without downloading it first).
|
||
const audioEl = new Audio();
|
||
let playingIdx = null;
|
||
const setIcon = (idx, playing) => {
|
||
const icon = ov.querySelector(`.ab-export-file-row[data-idx="${idx}"] .ab-export-play-btn .mdi`);
|
||
if (icon) icon.className = 'mdi ' + (playing ? 'mdi-pause' : 'mdi-play');
|
||
};
|
||
const stop = () => { audioEl.pause(); if (playingIdx !== null) setIcon(playingIdx, false); playingIdx = null; };
|
||
ov.querySelectorAll('.ab-export-play-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const idx = parseInt(btn.closest('.ab-export-file-row').dataset.idx, 10);
|
||
if (playingIdx === idx && !audioEl.paused) { stop(); return; }
|
||
const prevIdx = playingIdx;
|
||
audioEl.src = files[idx].url;
|
||
audioEl.play().catch(e => toast('Could not play: ' + (e.message || e), 'error'));
|
||
if (prevIdx !== null) setIcon(prevIdx, false);
|
||
playingIdx = idx;
|
||
setIcon(idx, true);
|
||
});
|
||
});
|
||
audioEl.addEventListener('ended', stop);
|
||
const close = () => { stop(); ov.remove(); };
|
||
ov.querySelector('#ab-export-close').addEventListener('click', close);
|
||
ov.addEventListener('click', e => { if (e.target === ov) close(); });
|
||
}
|
||
|
||
// "Browse" the exports already saved for the current book, without
|
||
// re-running the export — the entry point for finding a chapter you
|
||
// downloaded in an earlier session and can't find in your Downloads folder.
|
||
async function audiobookBrowseExports() {
|
||
const book = (typeof _lineAudioBookName === 'function') ? _lineAudioBookName() : ($('reh-script-title')?.value.trim() || 'Untitled');
|
||
// A custom "Save Location" moves where this book's exports live server-side
|
||
// (see audiobookExport's folderQS) — without also passing it here, this
|
||
// would always look in the fixed default dir and report "no saved exports"
|
||
// for a book that was actually exported to a custom folder.
|
||
let folder = '';
|
||
try { folder = localStorage.getItem('ttsvc_export_folder:' + book) || ''; } catch (_) {}
|
||
const folderQS = folder ? '?folder=' + encodeURIComponent(folder) : '';
|
||
try {
|
||
const r = await fetch(`/api/audiobook-export/${encodeURIComponent(book)}${folderQS}`);
|
||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText);
|
||
const d = await r.json();
|
||
if (!d.files || !d.files.length) { toast('No saved exports yet for this book — run "Audiobook" first', 'info'); return; }
|
||
const files = d.files.map(f => ({ name: f.name, url: `/api/audiobook-export/${encodeURIComponent(book)}/${encodeURIComponent(f.name)}${folderQS}` }));
|
||
_abShowExportResults(book, files, { browsing: true, folder });
|
||
} catch (e) {
|
||
toast('Could not load saved exports: ' + (e.message || e), 'error');
|
||
}
|
||
}
|
||
$('reh-tb-browse-exports')?.addEventListener('click', audiobookBrowseExports);
|
||
|
||
|
||
window.TTVC=window.TTVC||{};
|
||
window.TTVC.audiobook=window.TTVC.audiobook||{};
|
||
Object.assign(window.TTVC.audiobook,{
|
||
preview:{
|
||
show:audiobookShowPreview,
|
||
apply:audiobookApplyPreviewAndOpen,
|
||
openInRehearser:audiobookOpenCurrentInRehearser,
|
||
},
|
||
exporting:{
|
||
export:audiobookExport,
|
||
browse:audiobookBrowseExports,
|
||
},
|
||
});
|
||
|