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