diff --git a/CHANGELOG.md b/CHANGELOG.md index 024b3c7..5aef859 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/static/index.html b/static/index.html index 0f9f1dc..aaca335 100644 --- a/static/index.html +++ b/static/index.html @@ -26,7 +26,7 @@ - + @@ -336,7 +336,7 @@ - + diff --git a/static/js/audiobook.js b/static/js/audiobook.js index d4bd02b..84e04a1 100644 --- a/static/js/audiobook.js +++ b/static/js/audiobook.js @@ -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 = ` ${escHtml(summary)}`; + foot.innerHTML = ` ${escHtml(summary)}`; 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) : []; + 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; - }); +} - 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'); +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; } + + try { + const rec = await _audiobookBuildRehRecord(segs); + if (_audiobook.rehId) { + rec.id = _audiobook.rehId; + await rehDbPut(rec); + } else { + _audiobook.rehId = await rehDbAdd(rec); } - } else { - toast('Rehearser DB not available', 'error'); + 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); +} diff --git a/static/js/rehearser.js b/static/js/rehearser.js index 91f80ea..cd7f613 100644 --- a/static/js/rehearser.js +++ b/static/js/rehearser.js @@ -121,9 +121,12 @@ 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 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();