diff --git a/static/js/rehearser.js b/static/js/rehearser.js
index c56bd46..735de12 100644
--- a/static/js/rehearser.js
+++ b/static/js/rehearser.js
@@ -10,6 +10,7 @@ const rehState = {
backend: '',
playing: false,
repeat: false,
+ savedId: null, // current library record ID (null = unsaved)
// mic
recStream: null, recAudioCtx: null, recAnalyser: null,
recSourceNode: null, recGainNode: null, recDestStream: null,
@@ -18,6 +19,241 @@ const rehState = {
};
window.rehState = rehState;
+// ── Library — IndexedDB ────────────────────────────────────────────────────
+
+const REH_DB_NAME = 'reh-library';
+const REH_STORE = 'rehearsals';
+
+function rehDbOpen() {
+ return new Promise((resolve, reject) => {
+ const req = indexedDB.open(REH_DB_NAME, 1);
+ req.onupgradeneeded = e => {
+ const db = e.target.result;
+ if (!db.objectStoreNames.contains(REH_STORE)) {
+ const store = db.createObjectStore(REH_STORE, { keyPath: 'id', autoIncrement: true });
+ store.createIndex('updated', 'updated', { unique: false });
+ }
+ };
+ req.onsuccess = e => resolve(e.target.result);
+ req.onerror = e => reject(e.target.error);
+ });
+}
+
+async function rehDbOp(mode, fn) {
+ const db = await rehDbOpen();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(REH_STORE, mode);
+ const store = tx.objectStore(REH_STORE);
+ const req = fn(store);
+ req.onsuccess = e => resolve(e.target.result);
+ req.onerror = e => reject(e.target.error);
+ });
+}
+
+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 rehDbGetAll() {
+ const db = await rehDbOpen();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(REH_STORE, 'readonly');
+ const req = tx.objectStore(REH_STORE).getAll();
+ req.onsuccess = e => resolve(e.target.result || []);
+ req.onerror = e => reject(e.target.error);
+ });
+}
+
+// ── Serialization (export / import) ──────────────────────────────────────
+
+async function clipsToJson(clips) {
+ return Promise.all(clips.map(async c => {
+ if (!c.blob) return { lineIndex: c.lineIndex, speaker: c.speaker, type: c.type };
+ const ab = await c.blob.arrayBuffer();
+ const u8 = new Uint8Array(ab);
+ let bin = '';
+ const CHUNK = 8192;
+ for (let i = 0; i < u8.length; i += CHUNK)
+ bin += String.fromCharCode(...u8.subarray(i, i + CHUNK));
+ return { lineIndex: c.lineIndex, speaker: c.speaker, type: c.type, mime: c.blob.type, b64: btoa(bin) };
+ }));
+}
+
+function clipsFromJson(clips) {
+ return clips.map(c => {
+ if (!c.b64) return c;
+ const bin = atob(c.b64);
+ const u8 = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
+ return { ...c, blob: new Blob([u8], { type: c.mime || 'audio/webm' }), b64: undefined, mime: undefined };
+ });
+}
+
+function rehCurrentRecord() {
+ const cast = {};
+ Object.entries(rehState.cast).forEach(([sp, c]) => { cast[sp] = { voice: c.voice, color: c.color }; });
+ return {
+ title: $('reh-script-title')?.value.trim() || $('reh-page-title')?.textContent || 'Untitled',
+ script: $('reh-script-text')?.value.trim() || '',
+ cast,
+ backend: rehState.backend,
+ lineIndex: rehState.lineIndex,
+ clips: rehState.clips.map(c => ({ lineIndex: c.lineIndex, speaker: c.speaker, type: c.type, blob: c.blob || null })),
+ updated: new Date(),
+ };
+}
+
+async function saveToLibrary() {
+ const rec = rehCurrentRecord();
+ if (rehState.savedId) {
+ rec.id = rehState.savedId;
+ if (!rec.created) rec.created = new Date(); // preserve original
+ await rehDbPut(rec);
+ } else {
+ rec.created = new Date();
+ const newId = await rehDbAdd(rec);
+ rehState.savedId = newId;
+ }
+ toast('Saved to library', 'success');
+ await renderLibraryList();
+}
+
+async function exportToFile() {
+ const rec = rehCurrentRecord();
+ rec.version = 1;
+ rec.created = rec.created || new Date();
+ rec.clips = await clipsToJson(rec.clips);
+ const json = JSON.stringify(rec, null, 2);
+ const blob = new Blob([json], { type: 'application/json' });
+ const a = document.createElement('a');
+ const title = (rec.title || 'rehearsal').replace(/[^a-z0-9_\- ]/gi, '_').slice(0, 40);
+ a.href = URL.createObjectURL(blob);
+ a.download = title + '.reh';
+ a.click();
+}
+
+async function importFromFile(file) {
+ try {
+ const text = await file.text();
+ const data = JSON.parse(text);
+ data.clips = clipsFromJson(data.clips || []);
+ data.created = data.created ? new Date(data.created) : new Date();
+ data.updated = new Date();
+ delete data.id;
+ const newId = await rehDbAdd(data);
+ toast('Imported: ' + (data.title || 'Untitled'), 'success');
+ await renderLibraryList();
+ loadRecord({ ...data, id: newId });
+ } catch(e) { toast('Import failed: ' + e.message, 'error'); }
+}
+
+function loadRecord(rec) {
+ if ($('reh-script-text')) $('reh-script-text').value = rec.script || '';
+ if ($('reh-script-title')) $('reh-script-title').value = rec.title || '';
+ rehState.savedId = rec.id || null;
+ rehState.backend = rec.backend || '';
+ rehState.lineIndex = rec.lineIndex || 0;
+ rehState.clips = (rec.clips || []).map(c => ({ ...c }));
+ // Parse script and restore cast
+ const lines = parseScript(rec.script || '');
+ rehState.lines = lines;
+ rehState.cast = {};
+ const detected = detectCharacters(lines);
+ Object.entries(detected).forEach(([sp, def]) => {
+ const saved = rec.cast?.[sp];
+ rehState.cast[sp] = {
+ voice: saved?.voice ?? def.voice,
+ color: saved?.color ?? def.color,
+ voiceData: saved?.voice && saved.voice !== 'me' ? getVoiceData(saved.voice) : null,
+ };
+ });
+ renderCastList();
+ refreshRehBackends().then(() => {
+ if (rec.backend && $('reh-backend-select')) $('reh-backend-select').value = rec.backend;
+ });
+ showPhase(2);
+}
+
+// ── Library UI ─────────────────────────────────────────────────────────────
+
+async function renderLibraryList() {
+ const list = $('reh-library-list'); if (!list) return;
+ let all;
+ try { all = await rehDbGetAll(); } catch(e) { all = []; }
+ all.sort((a, b) => new Date(b.updated || 0) - new Date(a.updated || 0));
+
+ if (!all.length) {
+ list.innerHTML = '
No saved rehearsals yet. Parse a script below to start a new one.
';
+ return;
+ }
+
+ list.innerHTML = all.map(rec => {
+ const speakers = Object.keys(rec.cast || {});
+ const meCount = speakers.filter(sp => rec.cast[sp]?.voice === 'me').length;
+ const clipCount = (rec.clips || []).filter(c => c.type === 'me' && c.blob).length;
+ const total = (parseScript(rec.script || '')).filter(l => !l.isDirection).length;
+ const pct = total ? Math.round(((rec.lineIndex || 0) / total) * 100) : 0;
+ const date = rec.updated ? new Date(rec.updated).toLocaleDateString() : '—';
+ const isCurrent = rec.id === rehState.savedId;
+ const avatars = speakers.slice(0, 4).map(sp => {
+ const c = rec.cast[sp];
+ const initial = sp[0].toUpperCase();
+ return `${initial} `;
+ }).join('');
+ return `
+
+
${avatars}
+
+
${escHtml(rec.title || 'Untitled')} ${isCurrent ? '
active ' : ''}
+
${total} lines · ${meCount} me · ${clipCount} recorded · ${pct}% done · ${date}
+
+
+
+
+ Open
+
+
+
+
`;
+ }).join('');
+
+ list.querySelectorAll('.reh-lib-load').forEach(btn => btn.addEventListener('click', async () => {
+ const id = parseInt(btn.dataset.id);
+ const db = await rehDbOpen();
+ const rec = await new Promise((res, rej) => { const r = db.transaction(REH_STORE,'readonly').objectStore(REH_STORE).get(id); r.onsuccess=e=>res(e.target.result); r.onerror=e=>rej(e.target.error); });
+ loadRecord(rec);
+ }));
+
+ list.querySelectorAll('.reh-lib-export').forEach(btn => btn.addEventListener('click', async () => {
+ const id = parseInt(btn.dataset.id);
+ const db = await rehDbOpen();
+ const rec = await new Promise((res, rej) => { const r = db.transaction(REH_STORE,'readonly').objectStore(REH_STORE).get(id); r.onsuccess=e=>res(e.target.result); r.onerror=e=>rej(e.target.error); });
+ rec.version = 1;
+ rec.clips = await clipsToJson(rec.clips || []);
+ const json = JSON.stringify(rec, null, 2);
+ const a = document.createElement('a');
+ const t = (rec.title || 'rehearsal').replace(/[^a-z0-9_\- ]/gi, '_').slice(0, 40);
+ a.href = URL.createObjectURL(new Blob([json], { type: 'application/json' }));
+ a.download = t + '.reh';
+ a.click();
+ }));
+
+ list.querySelectorAll('.reh-lib-delete').forEach(btn => btn.addEventListener('click', async () => {
+ if (!confirm('Delete this rehearsal from the library?')) return;
+ await rehDbDelete(parseInt(btn.dataset.id));
+ if (rehState.savedId === parseInt(btn.dataset.id)) rehState.savedId = null;
+ renderLibraryList();
+ }));
+}
+
const SPEAKER_COLORS = ['#89b4fa','#a6e3a1','#f38ba8','#fab387','#f9e2af','#cba6f7','#89dceb','#74c7ec'];
// ── Helpers ────────────────────────────────────────────────────────────────
@@ -102,7 +338,10 @@ $('reh-parse-btn')?.addEventListener('click', () => {
if (!rehState.lines.filter(l => !l.isDirection).length) {
toast('No dialog lines found. Check format: "CHARACTER: text" or screenplay.', 'error'); return;
}
- rehState.cast = detectCharacters(rehState.lines);
+ rehState.cast = detectCharacters(rehState.lines);
+ rehState.savedId = null; // brand-new script → not yet saved
+ rehState.clips = [];
+ rehState.lineIndex = 0;
renderCastList();
showPhase(2);
refreshRehBackends();
@@ -585,11 +824,27 @@ function renderSummary() {
$('reh-new-session-btn')?.addEventListener('click', () => {
stopPlay(); stopRehMic();
- rehState.lines=[]; rehState.cast={}; rehState.clips=[]; rehState.lineIndex=0;
+ rehState.lines=[]; rehState.cast={}; rehState.clips=[]; rehState.lineIndex=0; rehState.savedId=null;
if ($('reh-script-text')) $('reh-script-text').value = '';
+ if ($('reh-script-title')) $('reh-script-title').value = '';
+ renderLibraryList();
showPhase(1);
});
$('reh-resume-btn')?.addEventListener('click', () => { showPhase(3); highlightCurrentLine(); });
+$('reh-save-session-btn')?.addEventListener('click', saveToLibrary);
+$('reh-export-session-btn')?.addEventListener('click', exportToFile);
+$('reh-tb-save')?.addEventListener('click', saveToLibrary);
+$('reh-tb-export')?.addEventListener('click', exportToFile);
+
+$('reh-lib-import-file')?.addEventListener('change', async function () {
+ const f = this.files?.[0]; if (!f) return;
+ await importFromFile(f);
+ this.value = '';
+});
+
+// mark savedId on save so new-session resets it
+$('reh-parse-btn')?.removeEventListener; // just guard
// Init
rehRenderMeter();
+renderLibraryList().catch(() => {});
diff --git a/static/sections/s-rehearser.html b/static/sections/s-rehearser.html
index 24473bd..db213a9 100644
--- a/static/sections/s-rehearser.html
+++ b/static/sections/s-rehearser.html
@@ -8,10 +8,26 @@
-
+
-
Step 1 — Paste or upload your script
+
+
+
No saved rehearsals yet. Parse a script below to start a new one.
+
+
+
+
+
+
New script
Format: CHARACTER: dialog on one line, or screenplay style (ALL-CAPS character name on its own line, dialog below). Lines starting with # are stage directions.
-
Exit
+
+ Save
+
+ Exit
+
@@ -125,8 +145,10 @@
All recorded and synthesized clips from this session.
+ Save to library
+ Export .reh
+ Resume
New script
- Resume rehearsal
diff --git a/static/style.css b/static/style.css
index 814dc6c..553159d 100644
--- a/static/style.css
+++ b/static/style.css
@@ -2304,6 +2304,22 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
/* ── Script Rehearser ───────────────────────────────────────────────────── */
.reh-phase[hidden] { display: none; }
+/* Library */
+.reh-lib-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; }
+.reh-lib-list { display: flex; flex-direction: column; gap: 8px; }
+.reh-lib-empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 28px 0; color: var(--subtext); }
+.reh-lib-empty .mdi { font-size: 36px; opacity: .4; }
+.reh-lib-empty p { text-align: center; font-size: 13px; line-height: 1.5; margin: 0; }
+.reh-lib-row { display: flex; align-items: center; gap: 12px; padding: 10px 12px; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; flex-wrap: wrap; }
+.reh-lib-current { border-color: var(--accent); background: rgba(137,180,250,.06); }
+.reh-lib-row-main { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; }
+.reh-lib-avatars { display: flex; gap: 4px; flex-shrink: 0; }
+.reh-lib-info { min-width: 0; flex: 1; }
+.reh-lib-info strong { display: block; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.reh-lib-progress-wrap { height: 3px; background: var(--border); border-radius: 2px; overflow: hidden; margin-top: 5px; }
+.reh-lib-progress { height: 100%; background: var(--accent); }
+.reh-lib-row-actions { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
+
/* Phase 1 */
.reh-input-row { display: grid; grid-template-columns: 1fr 200px; gap: 14px; align-items: start; }
.reh-input-row textarea { min-height: 220px; font-family: monospace; font-size: 13px; width: 100%; resize: vertical; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 10px; color: var(--text); }