feat: Audiobook Export Modal, UI sync fixes, and initialization stability (v1.22.0)

This commit is contained in:
mARTin-B78 2026-08-23 10:15:46 +02:00
parent f04c8d5e2b
commit 1d8e0ff2a6
8 changed files with 206 additions and 57 deletions

View File

@ -5,6 +5,18 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
---
## [1.22.0] — 2026-08-23
### Added
- **Audiobook Export Modal.** Introduced a comprehensive GUI for exporting audiobooks directly from the Rehearser.
- **Granular Export Controls.** Users can now select specific chapters to export, define the output format (one MP3 per chapter vs. single book file), and configure custom server-side save locations.
- **Pacing Configuration.** Added UI settings to adjust pacing with precise pause durations between paragraphs and chapters.
- **Tone Warning Integration.** Warnings for out-of-bounds tone settings are now displayed within the export modal for safety before batch generation.
### Fixed
- **UI Synchronization.** Fixed an issue where the Rehearser's green dots for cached audio lines would not synchronize reliably during navigation updates.
- **Bundle Stability.** Addressed a script hoisting issue in the production bundle that caused silent initialization failures and a "Loading..." stall.
## [1.21.1] — 2026-08-23
### Added

View File

@ -1 +1 @@
1.21.1
1.22.0

File diff suppressed because one or more lines are too long

View File

@ -5736,7 +5736,7 @@ async function _abSynthesizeLongText(voice, text, instruct, backend) {
return mergeWavBlobs(blobs, 120);
}
async function audiobookExport() {
async function audiobookExport(opts = {}) {
if (_audiobook.running) 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; }
@ -5756,7 +5756,11 @@ async function audiobookExport() {
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); }
});
const allIdx = buckets.flatMap(b => b.idx);
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; }
_audiobook.running = true; _audiobook.cancel = false;
@ -5866,8 +5870,45 @@ async function audiobookExport() {
const bookForExport = _lineAudioBookName();
const encMsg = $('audiobook-msg');
const pauses = _abPauseSettings();
for (let c = 0; c < buckets.length; c++) {
const blobs = buckets[c].idx.map(i => wavClips.get(i)).filter(Boolean);
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 {
let exportUrl = `/api/audiobook-export/${encodeURIComponent(bookForExport)}/${encodeURIComponent(name)}`;
if (opts.folder) exportUrl += `?folder=${encodeURIComponent(opts.folder)}`;
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
@ -5876,7 +5917,7 @@ async function audiobookExport() {
// 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} / ${buckets.length}…`;
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
@ -5884,7 +5925,7 @@ async function audiobookExport() {
// 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 && buckets[c].title && (pauses.chapterMs > 0 || _abChapterSfxDataUrl())) {
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);
@ -5897,7 +5938,7 @@ async function audiobookExport() {
// 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} / ${buckets.length}…`;
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 });
@ -5922,6 +5963,7 @@ async function audiobookExport() {
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);
}
@ -5978,7 +6020,7 @@ $('reh-tb-browse-exports')?.addEventListener('click', audiobookBrowseExports);
// ── Wiring ───────────────────────────────────────────────────────────────────
$('reader-audiobook-btn')?.addEventListener('click', audiobookOpenCastView);
$('reh-tb-audiobook')?.addEventListener('click', audiobookExport);
$('reh-tb-audiobook')?.addEventListener('click', audiobookExportDialog);
// Build a rehearsal record object from the current segments.
// If _audiobook.rehId is set, fetches the existing record so voice assignments
@ -6068,3 +6110,68 @@ function _audiobookDebouncedSave() {
_abRehSaveTimer = setTimeout(() => audiobookSaveAsRehearsal({ silent: true }), 1500);
}
function audiobookExportDialog() {
if (_audiobook.running) 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; }
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());
};
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); }
});
const listEl = document.getElementById('ab-export-chapter-list');
if (listEl) {
listEl.innerHTML = '';
const countEl = document.getElementById('ab-export-chapter-count');
if (countEl) countEl.textContent = buckets.length;
buckets.forEach((b, i) => {
const lbl = document.createElement('label');
lbl.style.display = 'block';
lbl.style.marginBottom = '4px';
const chk = document.createElement('input');
chk.type = 'checkbox';
chk.checked = true;
chk.value = i;
chk.className = 'ab-export-chapter-cb';
lbl.appendChild(chk);
lbl.appendChild(document.createTextNode(' Chapter ' + (i+1) + (b.title ? ': ' + b.title : '')));
listEl.appendChild(lbl);
});
}
const warn = document.getElementById('reh-tone-warn');
const slot = document.getElementById('ab-export-tone-warn-slot');
if (warn && slot) {
warn.hidden = false;
slot.appendChild(warn);
}
if (typeof _abInitPauseUI === 'function') _abInitPauseUI();
const modal = document.getElementById('audiobook-export-modal');
if (modal) modal.hidden = false;
};
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('ab-export-modal-close')?.addEventListener('click', () => document.getElementById('audiobook-export-modal').hidden = true);
document.getElementById('ab-export-modal-cancel')?.addEventListener('click', () => document.getElementById('audiobook-export-modal').hidden = true);
document.getElementById('ab-export-modal-start')?.addEventListener('click', () => {
document.getElementById('audiobook-export-modal').hidden = true;
const formatEl = document.querySelector('input[name="ab_export_format"]:checked');
const format = formatEl ? formatEl.value : 'chapter';
const folderEl = document.getElementById('ab-export-server-folder');
const folder = folderEl ? folderEl.value : '';
const checkboxes = Array.from(document.querySelectorAll('.ab-export-chapter-cb'));
const selectedChapterIndices = checkboxes.filter(cb => cb.checked).map(cb => parseInt(cb.value, 10));
audiobookExport({ format, selectedChapterIndices, folder });
});
});

View File

@ -4065,12 +4065,9 @@ function _lineAudioCachePut(book, key, blob) {
// of the current script's lines already have a matching file, and lights
// up their dots — without downloading any actual audio (that still only
// happens lazily, right when a line is about to play).
let _lineAudioSyncedFor = null; // `${book}::${lineCount}` — avoid re-running the same scan on every small re-render
let _lineAudioSyncedFor = null; // unused now, kept for compat
async function _lineAudioSyncDots() {
const book = _lineAudioBookName();
const scanId = `${book}::${rehState.lines.length}`;
if (_lineAudioSyncedFor === scanId) return;
_lineAudioSyncedFor = scanId;
_ensureNarrator();
const idxToKey = new Map();
for (let i = 0; i < rehState.lines.length; i++) {

View File

@ -233,7 +233,6 @@ function _stuEnterPhase(n) {
// further down. Order matters: this must run after the #reh-phase-3
// borrow above, since that's what puts #reh-tone-warn somewhere this
// second, more specific borrow can find and re-extract it from.
_stuBorrow('reh-tone-warn', 'stu-tone-warn-slot');
if (typeof _abInitPauseUI === 'function') _abInitPauseUI();
// A stacked-sticky "toggle above the transport bar" version of this was
// tried and reverted — confirmed live to break scrolling entirely on a

View File

@ -67,46 +67,7 @@
<input type="checkbox" id="stu-mode-audiobook">
<span><span class="mdi mdi-headphones"></span> Generate full audiobook (voice the Narrator too)</span>
</label>
<!-- Borrowed here from the Stage toolbar (#reh-tone-warn) — it
used to render as its own full-width banner below the
toolbar, leaving this whole row's spare width unused right
above it. Same element, same id, just relocated. -->
<div id="stu-tone-warn-slot" style="flex:1; min-width:200px"></div>
</div>
<!-- Pause lengths applied when synthesising the full audiobook —
mergeWavBlobs otherwise butts every line together with zero
gap, which reads as scenes/paragraphs jump-cutting with no
beat between them. Settings persist per-browser (not per-book)
via localStorage since they're a pacing preference, not book
content. -->
<details class="card" id="stu-pause-details" style="margin-bottom:14px">
<summary style="cursor:pointer;font-weight:700;padding:4px 0"><span class="mdi mdi-timer-sand"></span> Pacing (pauses between paragraphs &amp; chapters)</summary>
<div class="settings-grid settings-behavior-grid" style="margin-top:10px">
<div class="s-field">
<label>Pause between paragraphs</label>
<div style="display:flex; align-items:center; gap:6px">
<input type="number" id="stu-pause-paragraph" min="0" max="20" step="0.5" style="width:80px">
<span>seconds</span>
</div>
</div>
<div class="s-field">
<label>Pause between chapters</label>
<div style="display:flex; align-items:center; gap:6px">
<input type="number" id="stu-pause-chapter" min="0" max="60" step="0.5" style="width:80px">
<span>seconds</span>
</div>
</div>
<div class="s-field">
<label>Chapter transition sound <small style="opacity:.6;font-weight:400">(optional)</small></label>
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap">
<input type="file" id="stu-chapter-sfx-file" accept="audio/*" style="max-width:220px">
<button type="button" class="btn-secondary btn-sm" id="stu-chapter-sfx-clear"><span class="mdi mdi-close"></span> Clear</button>
<span id="stu-chapter-sfx-current" class="s-hint"></span>
</div>
<span class="s-hint">Plays once before each chapter's pause, e.g. a page-turn or chime. Any audio format your browser can play.</span>
</div>
</div>
</details>
<!-- "I play this" lives on the per-character cast cards, not the
Stage line view — only relevant in Rehearse mode. -->
<details class="card" id="stu-mecast-details" style="margin-bottom:14px">

View File

@ -566,4 +566,77 @@
</div>
</div>
<!-- Audiobook Export Modal -->
<div id="audiobook-export-modal" class="audiobook-overlay" hidden style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);z-index:9999;display:flex;align-items:center;justify-content:center;">
<div class="audiobook-box card" style="max-width:600px; width:95%; max-height:90vh; overflow-y:auto; text-align:left; background:var(--bg-card); padding:20px; border-radius:8px;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
<h3 style="margin:0; font-size:1.25em"><span class="mdi mdi-book-music-outline"></span> Export Audiobook</h3>
<button class="btn-secondary btn-sm" id="ab-export-modal-close" style="padding:4px"><span class="mdi mdi-close"></span></button>
</div>
<div id="ab-export-pacing-slot">
<details class="card" id="stu-pause-details" style="margin-bottom:14px" open>
<summary style="cursor:pointer;font-weight:700;padding:4px 0"><span class="mdi mdi-timer-sand"></span> Pacing (pauses between paragraphs &amp; chapters)</summary>
<div class="settings-grid settings-behavior-grid" style="margin-top:10px">
<div class="s-field">
<label>Pause between paragraphs</label>
<div style="display:flex; align-items:center; gap:6px">
<input type="number" id="stu-pause-paragraph" min="0" max="20" step="0.5" style="width:80px">
<span>seconds</span>
</div>
</div>
<div class="s-field">
<label>Pause between chapters</label>
<div style="display:flex; align-items:center; gap:6px">
<input type="number" id="stu-pause-chapter" min="0" max="60" step="0.5" style="width:80px">
<span>seconds</span>
</div>
</div>
<div class="s-field">
<label>Chapter transition sound <small style="opacity:.6;font-weight:400">(optional)</small></label>
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap">
<input type="file" id="stu-chapter-sfx-file" accept="audio/*" style="max-width:220px">
<button type="button" class="btn-secondary btn-sm" id="stu-chapter-sfx-clear"><span class="mdi mdi-close"></span> Clear</button>
<span id="stu-chapter-sfx-current" class="s-hint"></span>
</div>
<span class="s-hint">Plays once before each chapter's pause, e.g. a page-turn or chime. Any audio format your browser can play.</span>
</div>
</div>
</details>
</div>
<div class="settings-grid" style="margin-top:14px">
<div class="s-field">
<label>Export Format</label>
<div style="display:flex; gap:16px; margin-top:8px">
<label><input type="radio" name="ab_export_format" value="chapter" checked> One MP3 per chapter</label>
<label><input type="radio" name="ab_export_format" value="single"> One single MP3 for the whole book</label>
</div>
</div>
<div class="s-field" style="margin-top:12px">
<label>Save Location (Server)</label>
<div style="display:flex; align-items:center; gap:8px; margin-top:8px">
<input type="text" id="ab-export-server-folder" style="flex:1" value="exports/" placeholder="Folder on server (e.g. exports/)">
</div>
<span class="s-hint">Where the files will be saved on the server. Browser downloads will also still trigger.</span>
</div>
</div>
<div id="ab-export-tone-warn-slot" style="margin-top:14px"></div>
<div style="margin-top:14px">
<label style="font-weight:700">Chapters Recognised (<span id="ab-export-chapter-count">0</span>)</label>
<div id="ab-export-chapter-list" style="max-height:200px; overflow-y:auto; border:1px solid var(--border, #ccc); padding:8px; border-radius:4px; margin-top:8px">
<!-- JS will populate chapters here with checkboxes -->
</div>
</div>
<div style="margin-top:20px; display:flex; justify-content:flex-end; gap:8px">
<button class="btn-secondary" id="ab-export-modal-cancel">Cancel</button>
<button class="btn-primary" id="ab-export-modal-start"><span class="mdi mdi-export"></span> Start Export</button>
</div>
</div>
</div>
</div><!-- /tab-rehearser -->