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
### Added

View File

@ -26,7 +26,7 @@
<!-- ── 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.12.5">
<link rel="stylesheet" href="/static/style.css?v=1.12.6">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -336,7 +336,7 @@
<script src="/static/vendor/wavesurfer-regions.min.js"></script>
<!-- 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>
</html>

View File

@ -36,6 +36,7 @@ function _abSaveDraft(segs, roster, text, done, total) {
segments: segs,
roster: roster,
pageMarks: _audiobook.pageMarks || [],
rehId: _audiobook.rehId || null,
done: done,
total: total,
savedAt: Date.now()
@ -686,10 +687,11 @@ STRIKTE FORMAT- UND TEXTREGELN:
renderRoster();
toast(`Assigned to ${isNarrator ? 'Narrator' : name}`, 'success');
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) {
setTimeout(() => _abSaveDraft(_audiobook.segments || [], _audiobook.roster || [], _audiobook.lastText, -1, -1), 50);
}
_audiobookDebouncedSave();
}
feed.addEventListener('click', e => {
@ -993,7 +995,7 @@ STRIKTE FORMAT- UND TEXTREGELN:
if (cancelBtn) cancelBtn.hidden = true;
const foot = panel.querySelector('#ab-cv-foot');
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 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-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-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 newPrompt = panel.querySelector('#ab-cv-prompt-text').value;
@ -1188,6 +1205,7 @@ function audiobookOpenCastView() {
_audiobook.roster = _draft.roster || [];
_audiobook.lastText = text;
_audiobook.pageMarks = _draft.pageMarks || [];
_audiobook.rehId = _draft.rehId || null;
const view = audiobookCastView(chunks.length, llm_url, model, false);
view.addSegments(_draft.segments);
@ -1221,7 +1239,8 @@ async function audiobookCast(overrideUrl, overrideModel) {
: [text];
_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);
const ac = new AbortController();
_audiobook.abort = () => ac.abort();
@ -1411,6 +1430,7 @@ async function audiobookCast(overrideUrl, overrideModel) {
_audiobook.narratedPassages = narrationOnly;
_audiobook.degraded = degraded;
_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');
@ -1617,64 +1637,90 @@ async function audiobookExport() {
$('reader-audiobook-btn')?.addEventListener('click', audiobookOpenCastView);
$('reh-tb-audiobook')?.addEventListener('click', audiobookExport);
async function audiobookSaveAsRehearsal() {
const segs = _audiobook.segments;
if (!segs || !segs.length) { toast('No segments to save', 'error'); return; }
// Build a rehearsal record object from the current segments.
// If _audiobook.rehId is set, fetches the existing record so voice assignments
// made in Script Rehearser are preserved when we overwrite the script text.
async function _audiobookBuildRehRecord(segs) {
const { script, emotions } = audiobookBuildScript(segs);
const title = (typeof readerState !== 'undefined' && readerState.title) ? readerState.title : 'Audiobook';
// Need to parse lines to get cast
const lines = (typeof parseScript === 'function') ? parseScript(script) : [];
// Map emotions onto lines so detectCharacters picks them up
if (emotions && emotions.length) {
let eIdx = 0;
lines.forEach(l => {
if (l.type === 'dialog' && eIdx < emotions.length) {
if (emotions[eIdx]) l.emotion = emotions[eIdx];
eIdx++;
}
});
lines.forEach(l => { if (l.type === 'dialog' && eIdx < emotions.length) { if (emotions[eIdx]) l.emotion = emotions[eIdx]; eIdx++; } });
}
// Build cast from detected speakers
const cast = {};
if (typeof detectCharacters === 'function') {
const detected = detectCharacters(lines);
Object.entries(detected).forEach(([sp, def]) => {
cast[sp] = {
voice: def.voice, color: def.color, instruct: '',
lang: '', gender: '', tags: '', soul: '',
ignored: false, hidden: false, voiceData: null,
};
cast[sp] = { voice: def.voice, color: def.color, instruct: '', lang: '', gender: '', tags: '', soul: '', ignored: false, hidden: false, voiceData: null };
});
}
const rec = {
title: title,
script: script,
cast: cast,
emotions: {},
notes: {}, ignored: {}, hidden: {},
backend: '', narratorVoice: '',
lineIndex: 0, clips: [],
created: new Date(),
// Fetch the existing record and merge its cast so voice assignments survive an update
let existing = null;
if (_audiobook.rehId && typeof window.rehDbGetById === 'function') {
try { existing = await window.rehDbGetById(_audiobook.rehId); } catch (_) {}
}
if (existing && existing.cast) {
Object.entries(existing.cast).forEach(([sp, info]) => {
if (cast[sp]) cast[sp] = { ...cast[sp], ...info }; // overlay saved voice/instruct/etc.
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(),
};
}
// extract emotions for the record
lines.forEach((l, i) => {
if (l.type === 'dialog' && l.emotion) rec.emotions[i] = l.emotion;
});
let _abRehSaveTimer = null;
// 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 {
await rehDbAdd(rec);
_abClearDraft(); // work is committed to Rehearser — draft no longer needed
toast('Saved as Script Rehearsal', 'success');
if (typeof renderLibraryList === 'function') renderLibraryList();
} catch (e) {
toast('Failed to save rehearsal: ' + e.message, 'error');
}
const rec = await _audiobookBuildRehRecord(segs);
if (_audiobook.rehId) {
rec.id = _audiobook.rehId;
await rehDbPut(rec);
} 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 rehDbPut(record) { return rehDbOp('readwrite', s => s.put(record)); }
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() {
const db = await rehDbOpen();