feat: auto-save audiobook to Rehearser IndexedDB + Edit in Rehearser (v1.12.6)

- Casting completion auto-saves to Rehearser IndexedDB (same format
  as script rehearsals). Manual speaker corrections in the cast view
  debounce-save after 1.5s.
- rehId tracked across session (stored in localStorage draft) so
  updates go to the same record instead of creating duplicates.
- Voice assignments made in Script Rehearser survive an auto-update:
  only script text and emotions are overwritten; voice/instruct/soul
  are merged from the existing record.
- "Edit in Rehearser" button in the completed cast footer opens the
  saved record directly in Script Rehearser, ready for voice casting.
- Expose rehDbGetById + rehLoadRecord globally from rehearser.js.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-06-27 20:21:21 +02:00
parent 9c975a651c
commit 04dee87710
4 changed files with 112 additions and 54 deletions

View File

@ -9,6 +9,15 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
--- ---
## [1.12.6] — 2026-06-27
### Added
- **Audiobook auto-saves to Script Rehearser**: when casting completes, the result is automatically written to the Script Rehearser IndexedDB (same format, same library). The record is updated — not duplicated — whenever speaker corrections are made in the cast view. The record appears immediately in Script Rehearser → Library.
- **"Edit in Rehearser" button**: replaces "Review & cast" in the completed cast panel. Opens the saved record directly in Script Rehearser with all speakers, voices, and page markers intact, ready to assign voices and synthesise.
- Voice assignments made in Script Rehearser are **preserved** when the audiobook auto-saves again (only the script text and emotions are overwritten; voice/instruct/soul fields survive the update).
---
## [1.12.5] — 2026-06-27 ## [1.12.5] — 2026-06-27
### Added ### Added

View File

@ -26,7 +26,7 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── --> <!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css"> <link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.12.5"> <link rel="stylesheet" href="/static/style.css?v=1.12.6">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -336,7 +336,7 @@
<script src="/static/vendor/wavesurfer-regions.min.js"></script> <script src="/static/vendor/wavesurfer-regions.min.js"></script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton --> <!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js?v=1.12.5"></script> <script src="/static/loader.js?v=1.12.6"></script>
</body> </body>
</html> </html>

View File

@ -36,6 +36,7 @@ function _abSaveDraft(segs, roster, text, done, total) {
segments: segs, segments: segs,
roster: roster, roster: roster,
pageMarks: _audiobook.pageMarks || [], pageMarks: _audiobook.pageMarks || [],
rehId: _audiobook.rehId || null,
done: done, done: done,
total: total, total: total,
savedAt: Date.now() savedAt: Date.now()
@ -686,10 +687,11 @@ STRIKTE FORMAT- UND TEXTREGELN:
renderRoster(); renderRoster();
toast(`Assigned to ${isNarrator ? 'Narrator' : name}`, 'success'); toast(`Assigned to ${isNarrator ? 'Narrator' : name}`, 'success');
closeAssignPopup(); closeAssignPopup();
// Persist manual corrections immediately so a refresh doesn't lose them // Persist manual corrections to both localStorage (fast) and IndexedDB (durable)
if (_audiobook.lastText) { if (_audiobook.lastText) {
setTimeout(() => _abSaveDraft(_audiobook.segments || [], _audiobook.roster || [], _audiobook.lastText, -1, -1), 50); setTimeout(() => _abSaveDraft(_audiobook.segments || [], _audiobook.roster || [], _audiobook.lastText, -1, -1), 50);
} }
_audiobookDebouncedSave();
} }
feed.addEventListener('click', e => { feed.addEventListener('click', e => {
@ -993,7 +995,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
if (cancelBtn) cancelBtn.hidden = true; if (cancelBtn) cancelBtn.hidden = true;
const foot = panel.querySelector('#ab-cv-foot'); const foot = panel.querySelector('#ab-cv-foot');
foot.hidden = false; foot.hidden = false;
foot.innerHTML = `<span class="ab-cv-done"><span class="mdi mdi-check-circle-outline"></span> ${escHtml(summary)}</span><span style="flex:1"></span><button class="btn-secondary btn-sm" id="ab-cv-save-script" style="margin-right:8px;" title="Save to Script Rehearsals without leaving this page"><span class="mdi mdi-content-save-outline"></span> Save script</button><button class="btn-secondary btn-sm" id="ab-cv-recast-unk" style="margin-right:8px; color:var(--error);" title="Re-run only the Unknown segments with the current prompt"><span class="mdi mdi-account-question-outline"></span> Recast unknown</button><button class="btn-secondary btn-sm" id="ab-cv-recast" style="margin-right:8px;" title="Re-run the entire document to apply new settings or prompt tweaks"><span class="mdi mdi-refresh"></span> Recast all</button><button class="btn-primary btn-sm" id="ab-cv-review"><span class="mdi mdi-account-music-outline"></span> Review &amp; cast</button><button class="btn-secondary btn-sm" id="ab-cv-verify" style="margin-left:8px; border-color:var(--accent); color:var(--accent); font-weight:600;" title="2nd Quality increase run — re-checks every speaker assignment against context and resolves all Unknowns"><span class="mdi mdi-shield-check-outline"></span> 2nd Quality Run — Verify</button>`; foot.innerHTML = `<span class="ab-cv-done"><span class="mdi mdi-check-circle-outline"></span> ${escHtml(summary)}</span><span style="flex:1"></span><button class="btn-secondary btn-sm" id="ab-cv-recast-unk" style="margin-right:8px; color:var(--error);" title="Re-run only the Unknown segments with the current prompt"><span class="mdi mdi-account-question-outline"></span> Recast unknown</button><button class="btn-secondary btn-sm" id="ab-cv-recast" style="margin-right:8px;" title="Re-run the entire document to apply new settings or prompt tweaks"><span class="mdi mdi-refresh"></span> Recast all</button><button class="btn-secondary btn-sm" id="ab-cv-verify" style="margin-right:8px; border-color:var(--accent); color:var(--accent); font-weight:600;" title="2nd Quality increase run — re-checks every speaker assignment against context and resolves all Unknowns"><span class="mdi mdi-shield-check-outline"></span> 2nd Quality Run</button><button class="btn-primary btn-sm" id="ab-cv-open-reh" title="Open in Script Rehearser to assign voices and synthesise"><span class="mdi mdi-drama-masks"></span> Edit in Rehearser</button>`;
const runVerificationPass = () => { const runVerificationPass = () => {
const verificationPrompt = `Du bist ein Qualitätsprüfer für die Analyse eines deutschen Hörbuchs. Eine erste KI hat den Textauszug bereits in Segmente unterteilt und jedem Segment einen Sprecher zugewiesen. Deine Aufgabe ist es JEDEN Sprecher-Zuweisung kritisch zu überprüfen und zu korrigieren. const verificationPrompt = `Du bist ein Qualitätsprüfer für die Analyse eines deutschen Hörbuchs. Eine erste KI hat den Textauszug bereits in Segmente unterteilt und jedem Segment einen Sprecher zugewiesen. Deine Aufgabe ist es JEDEN Sprecher-Zuweisung kritisch zu überprüfen und zu korrigieren.
@ -1021,9 +1023,24 @@ ABSOLUTE REGELN:
foot.querySelector('#ab-cv-recast-unk').click(); foot.querySelector('#ab-cv-recast-unk').click();
}; };
foot.querySelector('#ab-cv-review').addEventListener('click', () => { closePanel(); onOpen(); });
foot.querySelector('#ab-cv-save-script').addEventListener('click', audiobookSaveAsRehearsal);
foot.querySelector('#ab-cv-verify').addEventListener('click', runVerificationPass); foot.querySelector('#ab-cv-verify').addEventListener('click', runVerificationPass);
foot.querySelector('#ab-cv-open-reh').addEventListener('click', async () => {
// Ensure the record is saved, then open it in Script Rehearser
if (!_audiobook.rehId) await audiobookSaveAsRehearsal({ silent: true });
if (_audiobook.rehId && typeof window.rehDbGetById === 'function' && typeof window.rehLoadRecord === 'function') {
try {
const rec = await window.rehDbGetById(_audiobook.rehId);
if (rec) {
closePanel();
window.rehLoadRecord(rec);
if (typeof navTo === 'function') navTo('s-rehearser');
return;
}
} catch (_) {}
}
// Fallback: open rehearser via the old preview path
closePanel(); onOpen();
});
const applyPromptAndRun = (callback) => { const applyPromptAndRun = (callback) => {
const newPrompt = panel.querySelector('#ab-cv-prompt-text').value; const newPrompt = panel.querySelector('#ab-cv-prompt-text').value;
@ -1188,6 +1205,7 @@ function audiobookOpenCastView() {
_audiobook.roster = _draft.roster || []; _audiobook.roster = _draft.roster || [];
_audiobook.lastText = text; _audiobook.lastText = text;
_audiobook.pageMarks = _draft.pageMarks || []; _audiobook.pageMarks = _draft.pageMarks || [];
_audiobook.rehId = _draft.rehId || null;
const view = audiobookCastView(chunks.length, llm_url, model, false); const view = audiobookCastView(chunks.length, llm_url, model, false);
view.addSegments(_draft.segments); view.addSegments(_draft.segments);
@ -1221,7 +1239,8 @@ async function audiobookCast(overrideUrl, overrideModel) {
: [text]; : [text];
_audiobook.running = true; _audiobook.cancel = false; _audiobook.running = true; _audiobook.cancel = false;
_abClearDraft(); // fresh cast — discard any previous draft for this document _audiobook.rehId = null; // fresh cast creates a new rehearsal record
_abClearDraft();
if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(true); if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(true);
const ac = new AbortController(); const ac = new AbortController();
_audiobook.abort = () => ac.abort(); _audiobook.abort = () => ac.abort();
@ -1411,6 +1430,7 @@ async function audiobookCast(overrideUrl, overrideModel) {
_audiobook.narratedPassages = narrationOnly; _audiobook.narratedPassages = narrationOnly;
_audiobook.degraded = degraded; _audiobook.degraded = degraded;
_abSaveDraft(allSegments, roster, text, chunks.length, chunks.length); // mark 100% complete _abSaveDraft(allSegments, roster, text, chunks.length, chunks.length); // mark 100% complete
audiobookSaveAsRehearsal({ silent: true }); // persist to Rehearser IndexedDB
if (_audiobook.cancel) toast('Casting stopped early. Progress preserved.', 'info'); if (_audiobook.cancel) toast('Casting stopped early. Progress preserved.', 'info');
@ -1617,64 +1637,90 @@ async function audiobookExport() {
$('reader-audiobook-btn')?.addEventListener('click', audiobookOpenCastView); $('reader-audiobook-btn')?.addEventListener('click', audiobookOpenCastView);
$('reh-tb-audiobook')?.addEventListener('click', audiobookExport); $('reh-tb-audiobook')?.addEventListener('click', audiobookExport);
async function audiobookSaveAsRehearsal() { // Build a rehearsal record object from the current segments.
const segs = _audiobook.segments; // If _audiobook.rehId is set, fetches the existing record so voice assignments
if (!segs || !segs.length) { toast('No segments to save', 'error'); return; } // made in Script Rehearser are preserved when we overwrite the script text.
async function _audiobookBuildRehRecord(segs) {
const { script, emotions } = audiobookBuildScript(segs); const { script, emotions } = audiobookBuildScript(segs);
const title = (typeof readerState !== 'undefined' && readerState.title) ? readerState.title : 'Audiobook'; const title = (typeof readerState !== 'undefined' && readerState.title) ? readerState.title : 'Audiobook';
// Need to parse lines to get cast
const lines = (typeof parseScript === 'function') ? parseScript(script) : []; const lines = (typeof parseScript === 'function') ? parseScript(script) : [];
// Map emotions onto lines so detectCharacters picks them up
if (emotions && emotions.length) { if (emotions && emotions.length) {
let eIdx = 0; let eIdx = 0;
lines.forEach(l => { lines.forEach(l => { if (l.type === 'dialog' && eIdx < emotions.length) { if (emotions[eIdx]) l.emotion = emotions[eIdx]; eIdx++; } });
if (l.type === 'dialog' && eIdx < emotions.length) {
if (emotions[eIdx]) l.emotion = emotions[eIdx];
eIdx++;
}
});
} }
// Build cast from detected speakers
const cast = {}; const cast = {};
if (typeof detectCharacters === 'function') { if (typeof detectCharacters === 'function') {
const detected = detectCharacters(lines); const detected = detectCharacters(lines);
Object.entries(detected).forEach(([sp, def]) => { Object.entries(detected).forEach(([sp, def]) => {
cast[sp] = { cast[sp] = { voice: def.voice, color: def.color, instruct: '', lang: '', gender: '', tags: '', soul: '', ignored: false, hidden: false, voiceData: null };
voice: def.voice, color: def.color, instruct: '',
lang: '', gender: '', tags: '', soul: '',
ignored: false, hidden: false, voiceData: null,
};
}); });
} }
const rec = { // Fetch the existing record and merge its cast so voice assignments survive an update
title: title, let existing = null;
script: script, if (_audiobook.rehId && typeof window.rehDbGetById === 'function') {
cast: cast, try { existing = await window.rehDbGetById(_audiobook.rehId); } catch (_) {}
emotions: {}, }
notes: {}, ignored: {}, hidden: {}, if (existing && existing.cast) {
backend: '', narratorVoice: '', Object.entries(existing.cast).forEach(([sp, info]) => {
lineIndex: 0, clips: [], if (cast[sp]) cast[sp] = { ...cast[sp], ...info }; // overlay saved voice/instruct/etc.
created: new Date(), else cast[sp] = info; // speaker was removed from segments but keep their info
});
}
const emotions_map = {};
lines.forEach((l, i) => { if (l.type === 'dialog' && l.emotion) emotions_map[i] = l.emotion; });
return {
title, script, cast,
emotions: emotions_map,
notes: existing ? (existing.notes || {}) : {},
ignored: existing ? (existing.ignored || {}) : {},
hidden: existing ? (existing.hidden || {}) : {},
backend: existing ? (existing.backend || '') : '',
narratorVoice: existing ? (existing.narratorVoice || '') : '',
lineIndex: existing ? (existing.lineIndex || 0) : 0,
clips: existing ? (existing.clips || []) : [],
created: existing ? existing.created : new Date(),
updated: new Date(), updated: new Date(),
}; };
}
// extract emotions for the record let _abRehSaveTimer = null;
lines.forEach((l, i) => {
if (l.type === 'dialog' && l.emotion) rec.emotions[i] = l.emotion; // Save segments to the Script Rehearser IndexedDB.
}); // { silent: true } suppresses the toast and draft-clear (used for auto-saves).
async function audiobookSaveAsRehearsal(opts) {
const silent = opts && opts.silent;
const segs = _audiobook.segments;
if (!segs || !segs.length) { if (!silent) toast('No segments to save', 'error'); return; }
if (typeof rehDbAdd !== 'function') { if (!silent) toast('Rehearser DB not available', 'error'); return; }
if (typeof rehDbAdd === 'function') {
try { try {
await rehDbAdd(rec); const rec = await _audiobookBuildRehRecord(segs);
_abClearDraft(); // work is committed to Rehearser — draft no longer needed if (_audiobook.rehId) {
toast('Saved as Script Rehearsal', 'success'); rec.id = _audiobook.rehId;
if (typeof renderLibraryList === 'function') renderLibraryList(); await rehDbPut(rec);
} catch (e) {
toast('Failed to save rehearsal: ' + e.message, 'error');
}
} else { } else {
toast('Rehearser DB not available', 'error'); _audiobook.rehId = await rehDbAdd(rec);
}
if (typeof renderLibraryList === 'function') renderLibraryList();
if (!silent) {
_abClearDraft();
toast('Saved as Script Rehearsal', 'success');
}
} catch (e) {
if (!silent) toast('Failed to save rehearsal: ' + e.message, 'error');
else console.warn('[audiobook] auto-save failed:', e);
} }
} }
// Debounced silent save — called after each manual speaker correction
function _audiobookDebouncedSave() {
clearTimeout(_abRehSaveTimer);
_abRehSaveTimer = setTimeout(() => audiobookSaveAsRehearsal({ silent: true }), 1500);
}

View File

@ -124,6 +124,9 @@ async function rehDbOp(mode, fn) {
async function rehDbAdd(record) { return rehDbOp('readwrite', s => s.add(record)); } async function rehDbAdd(record) { return rehDbOp('readwrite', s => s.add(record)); }
async function rehDbPut(record) { return rehDbOp('readwrite', s => s.put(record)); } async function rehDbPut(record) { return rehDbOp('readwrite', s => s.put(record)); }
async function rehDbDelete(id) { return rehDbOp('readwrite', s => s.delete(id)); } async function rehDbDelete(id) { return rehDbOp('readwrite', s => s.delete(id)); }
async function rehDbGetById(id) { return rehDbOp('readonly', s => s.get(id)); }
window.rehDbGetById = rehDbGetById;
window.rehLoadRecord = loadRecord; // expose so audiobook.js can open a record directly
async function rehDbGetAll() { async function rehDbGetAll() {
const db = await rehDbOpen(); const db = await rehDbOpen();