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

798 lines
44 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 <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 &amp; lines
<span class="audiobook-count">${segs.length} segments · ${charCount} character${charCount !== 1 ? 's' : ''}</span></div>
<div class="audiobook-msg">Fix any wrong speaker or emotion, then open in the Script Rehearser to assign voices.${_audiobook.narratedPassages ? ` <span class="audiobook-narr-note">${_audiobook.narratedPassages} passage${_audiobook.narratedPassages !== 1 ? 's' : ''} had no dialogue (narrator).</span>` : ''}${_audiobook.degraded ? ` <span class="audiobook-narr-note">${_audiobook.degraded} passage${_audiobook.degraded !== 1 ? 's' : ''} used quick detection — set the “Unknown” speakers.</span>` : ''}${_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);
root.audiobookOpenCurrentInRehearser = audiobookOpenCurrentInRehearser;
root.audiobookShowPreview = audiobookShowPreview;
root.audiobookApplyPreviewAndOpen = audiobookApplyPreviewAndOpen;
root.audiobookBuildScript = audiobookBuildScript;
root.audiobookOpenInRehearser = audiobookOpenInRehearser;
root.audiobookIsChapter = audiobookIsChapter;
root.audiobookLineVoice = audiobookLineVoice;
root.audiobookExport = audiobookExport;
root.audiobookBrowseExports = audiobookBrowseExports;
root.TTVC=root.TTVC||{};
root.TTVC.audiobook=root.TTVC.audiobook||{};
Object.assign(root.TTVC.audiobook,{
preview:{
show:audiobookShowPreview,
apply:audiobookApplyPreviewAndOpen,
openInRehearser:audiobookOpenCurrentInRehearser,
},
exporting:{
export:audiobookExport,
browse:audiobookBrowseExports,
},
});
})(window);