// 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 (_) {} } 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 (_) {} 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 = `
${opts.browsing ? 'Saved audiobook exports' : 'Audiobook exported'}
${opts.browsing ? '' : `
Export complete — ${files.length} file${files.length === 1 ? '' : 's'} saved
`}

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.

${files.map((f, i) => `
${escHtml(f.name)} Download
`).join('')}
${files.length > 1 ? `
Download all (.zip)
` : ''}
`; document.body.appendChild(ov); // One shared