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:
parent
9c975a651c
commit
04dee87710
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 & 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';
|
||||
const lines = (typeof parseScript === 'function') ? parseScript(script) : [];
|
||||
|
||||
// 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;
|
||||
|
||||
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');
|
||||
// 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);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user