feat: Audiobook Export Modal, UI sync fixes, and initialization stability (v1.22.0)
This commit is contained in:
parent
f04c8d5e2b
commit
1d8e0ff2a6
12
CHANGELOG.md
12
CHANGELOG.md
@ -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
|
||||
|
||||
8
static/dist/main.min.js
vendored
8
static/dist/main.min.js
vendored
File diff suppressed because one or more lines are too long
@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@ -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++) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 & 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">
|
||||
|
||||
@ -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 & 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 -->
|
||||
|
||||
Loading…
Reference in New Issue
Block a user