// Audiobook cast preview, Rehearser handoff, and chapter export UI.
// Loaded after the casting pipeline and before audiobook.js; dependencies resolve at call time.
(function (root) {
// ── 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
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();
}
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 (_) { logErr('rehearsal-save', _); }
}
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 "." 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 (_) { logErr('export-server-save', _); }
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 = `
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.