// ── Script Rehearser ────────────────────────────────────────────────────── // ── Constants ───────────────────────────────────────────────────────────── const SPEAKER_COLORS = ['#89b4fa','#a6e3a1','#f38ba8','#fab387','#f9e2af','#cba6f7','#89dceb','#74c7ec']; const REH_EMOTIONS = [ { value: '', emoji: '😐', label: 'Neutral' }, { value: 'happy, cheerful and upbeat', emoji: '😊', label: 'Happy' }, { value: 'sad, melancholy, somber', emoji: '😢', label: 'Sad' }, { value: 'angry, forceful, aggressive', emoji: '😠', label: 'Angry' }, { value: 'whisper, hushed and intimate', emoji: '🤫', label: 'Whisper' }, { value: 'excited, enthusiastic, energetic', emoji: '🤩', label: 'Excited' }, { value: 'scared, nervous, trembling voice', emoji: '😨', label: 'Scared' }, { value: 'sarcastic, dry, ironic delivery', emoji: '😏', label: 'Sarcastic' }, { value: 'dramatic, theatrical, intense', emoji: '🎭', label: 'Dramatic' }, { value: 'gentle, warm, tender', emoji: '🥰', label: 'Gentle' }, { value: 'confused, uncertain, hesitant', emoji: '😕', label: 'Confused' }, { value: 'bored, flat, disinterested', emoji: '😑', label: 'Bored' }, { value: 'surprised, shocked, astonished', emoji: '😲', label: 'Surprised' }, { value: 'confident, authoritative, bold', emoji: '💪', label: 'Confident' }, { value: 'mysterious, dark, ominous', emoji: '🌑', label: 'Mysterious' }, { value: 'romantic, loving, passionate', emoji: '❤️', label: 'Romantic' }, { value: 'playful, teasing, mischievous', emoji: '😈', label: 'Playful' }, { value: 'calm, composed, measured', emoji: '🧘', label: 'Calm' }, { value: 'commanding, authoritative, military', emoji: '⚔️', label: 'Commanding' }, { value: 'grieving, tearful, broken', emoji: '😭', label: 'Grieving' }, ]; // Exposed so the emotion quick-pickers in tts-preview.js / reader.js can read this // list. They run in the same bundle scope but BEFORE this file, so they must go // through window (and defer to a macrotask) rather than touch the const directly. window.REH_EMOTIONS = REH_EMOTIONS; // Load custom emotions from localStorage let rehCustomEmotions = []; try { rehCustomEmotions = JSON.parse(localStorage.getItem('reh-custom-emotions') || '[]'); } catch(_) {} function getEmotionInfo(value) { if (!value) return { emoji: '', label: 'Pick tone' }; const all = [...REH_EMOTIONS, ...rehCustomEmotions]; const found = all.find(e => e.value === value); if (found) return { emoji: found.emoji, label: found.label }; return { emoji: '✨', label: value.length > 14 ? value.slice(0, 13) + '…' : value }; } // ── Markdown helpers ─────────────────────────────────────────────────────── function renderMarkdownInline(text) { // Escape HTML first, then apply inline markdown let s = escHtml(text); s = s.replace(/\*\*([^*\n]+?)\*\*/g, '$1'); s = s.replace(/\*([^*\n]+?)\*/g, '$1'); s = s.replace(/__([^_\n]+?)__/g, '$1'); s = s.replace(/~~([^~\n]+?)~~/g, '$1'); s = s.replace(/==([^=\n]+?)==/g, '$1'); return s; } function stripMarkdown(text) { return text .replace(/\*\*([^*\n]+?)\*\*/g, '$1') .replace(/\*([^*\n]+?)\*/g, '$1') .replace(/__([^_\n]+?)__/g, '$1') .replace(/~~([^~\n]+?)~~/g, '$1') .replace(/==([^=\n]+?)==/g, '$1'); } // ── State ───────────────────────────────────────────────────────────────── const rehState = { lines: [], cast: {}, lineIndex: 0, clips: [], voices: [], backend: '', playing: false, repeat: false, savedId: null, synthCache: new Map(), staleLines: new Set(), // lines that were cached but had their tone changed synthCancelled: false, synthRunning: false, skipDescriptions: false, narratorVoice: '', practiceStart: null, practiceEnd: null, bulkMode: false, // bulk-edit (line selection) mode on/off bulkSel: new Set(), // indices of currently selected lines bulkAnchor: null, // last clicked index for Shift+click range selection showHidden: false, // reveal hidden lines (so they can be restored) recStream: null, recAudioCtx: null, recAnalyser: null, recSourceNode: null, recGainNode: null, recDestStream: null, recMeterRaf: null, recWaveRing: null, mediaRec: null, recChunks: [], recTimer: null, recSecs: 0, lastRecBlob: null, }; window.rehState = rehState; // ── Library — SQLite via /api/rehearsals ────────────────────────────────── async function rehDbGetAll() { const r = await fetch('/api/rehearsals'); if (!r.ok) throw new Error('rehDbGetAll failed: ' + r.status); const d = await r.json(); return d.rehearsals || []; } async function rehDbGetById(id) { const r = await fetch('/api/rehearsals/' + id); if (r.status === 404) return undefined; if (!r.ok) throw new Error('rehDbGetById failed: ' + r.status); return r.json(); } async function rehDbAdd(record) { const r = await fetch('/api/rehearsals', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(_rehSanitize(record)), }); if (!r.ok) throw new Error('rehDbAdd failed: ' + r.status); const d = await r.json(); return d.id; } async function rehDbPut(record) { if (!record.id) { return rehDbAdd(record); } const r = await fetch('/api/rehearsals/' + record.id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(_rehSanitize(record)), }); if (!r.ok) throw new Error('rehDbPut failed: ' + r.status); } async function rehDbDelete(id) { const r = await fetch('/api/rehearsals/' + id, { method: 'DELETE' }); if (!r.ok) throw new Error('rehDbDelete failed: ' + r.status); } // Strip binary clip blobs before sending — blobs can't be JSON-serialised and // are session-only anyway (they live in rehState.clips, not the library record). function _rehSanitize(rec) { const out = { ...rec }; if (out.clips) out.clips = (out.clips || []).map(c => ({ lineIndex: c.lineIndex, speaker: c.speaker, type: c.type })); return out; } // One-time IndexedDB → SQLite migration. Runs silently on page load. (async function _rehMigrateIfNeeded() { try { const serverRecs = await rehDbGetAll(); if (serverRecs.length > 0) return; const idbRecs = await _rehIdbGetAll().catch(() => []); if (!idbRecs.length) return; const r = await fetch('/api/rehearsals/migrate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(idbRecs.map(_rehSanitize)), }); if (r.ok) { const d = await r.json(); console.log(`[rehearser] migrated ${d.imported} records from IndexedDB → SQLite`); } } catch (e) { console.warn('[rehearser] migration skipped:', e); } })(); function _rehIdbGetAll() { return new Promise((resolve) => { const req = indexedDB.open('reh-library', 1); req.onerror = () => resolve([]); req.onsuccess = e => { const db = e.target.result; if (!db.objectStoreNames.contains('rehearsals')) { db.close(); resolve([]); return; } const all = db.transaction('rehearsals', 'readonly').objectStore('rehearsals').getAll(); all.onsuccess = ev => { db.close(); resolve(ev.target.result || []); }; all.onerror = () => { db.close(); resolve([]); }; }; }); } window.rehDbGetById = rehDbGetById; window.rehLoadRecord = loadRecord; // expose so audiobook.js can open a record directly // ── Serialization ───────────────────────────────────────────────────────── 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, instruct: c.instruct || '', lang: c.lang || '', gender: c.gender || '', tags: c.tags || '', soul: c.soul || '', ignored: !!c.ignored, hidden: !!c.hidden, }; }); const emotions = {}; const notes = {}; const ignored = {}; const hidden = {}; rehState.lines.forEach((l, i) => { if (l.type === 'dialog' && l.emotion) emotions[i] = l.emotion; if (l.type === 'dialog' && l.note) notes[i] = l.note; if (l.ignored) ignored[i] = true; if (l.hidden) hidden[i] = true; }); return { title: $('reh-script-title')?.value.trim() || $('reh-page-title')?.textContent || 'Untitled', // Reconstruct from parsed lines so structural edits (deletes) persist; fall back to the // raw textarea before the script has been parsed into lines. script: rehState.lines.length ? linesToScriptText() : ($('reh-script-text')?.value.trim() || ''), cast, emotions, notes, ignored, hidden, backend: rehState.backend, narratorVoice: rehState.narratorVoice, lineIndex: rehState.lineIndex, clips: rehState.clips.map(c => ({ lineIndex: c.lineIndex, speaker: c.speaker, type: c.type, blob: c.blob || null })), updated: new Date(), }; } // Reconstruct script text from current parsed lines (preserves inline edits) function linesToScriptText() { return rehState.lines.map(line => { switch (line.type) { case 'act': case 'transition': return '\n' + line.text + '\n'; case 'scene': return '\n' + line.text + '\n'; case 'action': return '\n' + line.text + '\n'; case 'dialog': return '\n' + line.speaker + '\n' + line.text + '\n'; case 'direction': return line.speaker ? line.text + '\n' : '\n' + line.text + '\n'; case 'pagebreak': return line.page ? `\n\f${line.page}\n` : '\n\f\n'; default: return ''; } }).join('').trim(); } async function saveToLibrary() { const rec = rehCurrentRecord(); if (rehState.savedId) { rec.id = rehState.savedId; if (!rec.created) rec.created = new Date(); await rehDbPut(rec); } else { rec.created = new Date(); const newId = await rehDbAdd(rec); rehState.savedId = newId; } toast('Saved to library', 'success'); rehState._keepSavedIdOnce = true; // Sync cast voice choices into the shared character roster (existing chars only) if (typeof castWriteBack === 'function') { try { await castWriteBack(rec.title, rehState.cast); } catch (_) {} } await renderLibraryList(); } // Auto-save a freshly imported/pasted script as its own library entry so it shows up // in the Library and can be reopened. Returns the new id (or null on failure). async function rehAutoSaveImport(title) { const text = ($('reh-script-text')?.value || '').trim(); if (!text) return null; if (title && $('reh-script-title') && !$('reh-script-title').value.trim()) $('reh-script-title').value = title; const rec = { title: $('reh-script-title')?.value.trim() || title || 'Untitled', script: text, cast: {}, emotions: {}, notes: {}, ignored: {}, hidden: {}, backend: rehState.backend || '', narratorVoice: rehState.narratorVoice || '', lineIndex: 0, clips: [], created: new Date(), updated: new Date(), }; try { const id = await rehDbAdd(rec); rehState.savedId = id; rehState._keepSavedIdOnce = true; // keep this entry through the next Parse & cast await renderLibraryList(); return id; } catch (e) { return null; } } 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(); } function exportFountain() { const title = $('reh-script-title')?.value.trim() || $('reh-page-title')?.textContent || 'Untitled'; let out = `Title: ${title}\n\n`; rehState.lines.forEach(line => { const t = stripMarkdown(line.text); switch (line.type) { case 'act': out += '\n' + t + '\n'; break; case 'scene': out += '\n' + t + '\n'; break; case 'action': out += '\n' + t + '\n'; break; case 'transition': out += '\n' + t + '\n'; break; case 'dialog': out += '\n' + line.speaker + '\n' + t + '\n'; break; case 'direction': out += (line.speaker ? '' : '\n') + t + '\n'; break; } }); const blob = new Blob([out.trim()], { type: 'text/plain' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = (title.replace(/[^a-z0-9_\- ]/gi, '_') || 'script') + '.fountain'; a.click(); toast('Exported as .fountain', 'success'); } // ── FDX (Final Draft XML) ────────────────────────────────────────────────── function exportFDX() { const title = $('reh-script-title')?.value.trim() || 'Untitled'; const x = s => (s||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); let xml = `\n`; xml += `\n\n`; rehState.lines.forEach(line => { const t = x(stripMarkdown(line.text)); switch (line.type) { case 'act': case 'scene': xml += ` ${t}\n`; break; case 'action': xml += ` ${t}\n`; break; case 'transition': xml += ` ${t}\n`; break; case 'direction': xml += ` ${t}\n`; break; case 'dialog': xml += ` ${x(line.speaker)}\n`; xml += ` ${t}\n`; break; } }); xml += `\n`; const blob = new Blob([xml], { type: 'text/xml' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = (title.replace(/[^a-z0-9_\- ]/gi, '_') || 'script') + '.fdx'; a.click(); toast('Exported as .fdx (Final Draft XML)', 'success'); } async function importFDX(file) { const text = await file.text(); const parser = new DOMParser(); const doc = parser.parseFromString(text, 'text/xml'); const paras = doc.querySelectorAll('Paragraph'); let result = ''; paras.forEach(para => { const type = para.getAttribute('Type') || ''; const t = [...para.querySelectorAll('Text')].map(el => el.textContent).join(''); if (!t.trim()) return; switch (type) { case 'Scene Heading': result += '\n' + t + '\n'; break; case 'Action': result += '\n' + t + '\n'; break; case 'Character': result += '\n' + t + '\n'; break; case 'Dialogue': result += t + '\n'; break; case 'Parenthetical': result += t + '\n'; break; case 'Transition': result += '\n' + t + '\n'; break; } }); return result.trim(); } // OSF (Open Screenplay Format — application-agnostic XML) function exportOSF() { const title = $('reh-script-title')?.value.trim() || 'Untitled'; const x = s => (s||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); let xml = `\n\n`; xml += ` ${x(title)}\n\n`; rehState.lines.forEach(line => { const t = x(stripMarkdown(line.text)); switch (line.type) { case 'act': case 'scene': xml += ` ${t}\n`; break; case 'action': xml += ` ${t}\n`; break; case 'transition': xml += ` ${t}\n`; break; case 'direction': xml += ` ${t}\n`; break; case 'dialog': xml += ` \n ${x(line.speaker)}\n ${t}\n \n`; break; } }); xml += `\n`; const blob = new Blob([xml], { type: 'text/xml' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = (title.replace(/[^a-z0-9_\- ]/gi, '_') || 'script') + '.osf'; a.click(); toast('Exported as .osf (Open Screenplay Format)', 'success'); } // ── PDF import (via pdf.js from CDN) ────────────────────────────────────── async function loadPdfJs() { if (window.pdfjsLib) return; await new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = '/static/js/pdf/pdf.min.js'; s.onload = resolve; s.onerror = reject; document.head.appendChild(s); }); pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/js/pdf/pdf.worker.min.js'; } // Extract a screenplay from a PDF, reconstructing element types from the // horizontal indentation that screenplays use (the layout *is* the meaning): // action/scene ~ left margin character ~ deeply indented (caps) // dialogue ~ medium indent parenthetical ~ indented "(...)" // transition ~ right-aligned async function importPDFScript(file) { toast('Loading PDF reader…', 'success'); await loadPdfJs(); const ab = await file.arrayBuffer(); const pdf = await pdfjsLib.getDocument({ data: ab }).promise; // First pass: collect every line {x, right, text} per page const allLinesByPage = []; const allLines = []; // flat list for margin/width detection for (let p = 1; p <= pdf.numPages; p++) { const page = await pdf.getPage(p); const content = await page.getTextContent(); const viewport = page.getViewport({ scale: 1 }); const pageW = viewport.width; // Group items into visual rows by Y (PDF Y is bottom-up) const rows = new Map(); content.items.forEach(item => { if (!item.str) return; const y = Math.round(item.transform[5] / 3) * 3; if (!rows.has(y)) rows.set(y, []); rows.get(y).push({ x: item.transform[4], w: item.width || 0, str: item.str }); }); const pageLines = []; [...rows.entries()].sort((a, b) => b[0] - a[0]).forEach(([, items]) => { items.sort((a, b) => a.x - b.x); const text = items.map(i => i.str).join('').replace(/\s+/g, ' ').trim(); if (!text) return; const x = items[0].x; const last = items[items.length - 1]; const right = last.x + (last.w || 0); pageLines.push({ x, right, pageW, text }); allLines.push({ x, right, pageW, text }); }); allLinesByPage.push(pageLines); } if (!allLines.length) return ''; // Detect the left margin (the most common / smallest x of body lines) const leftMargin = Math.min(...allLines.map(l => l.x)); const pageW = allLines[0].pageW || 612; // Noise filter: lone page numbers, scene numbers, "CONTINUED", revision marks const isNoise = t => { const s = t.trim(); if (/^\d{1,4}\.?$/.test(s)) return true; // bare page/scene number if (/^\(?CONTINUED\)?:?$/i.test(s)) return true; if (/^\d+\.$/.test(s)) return true; if (/^(rev\.|revised|draft)\b/i.test(s) && s.length < 24) return true; return false; }; const out = []; let prevBlank = true; let prevWasAction = false; // track consecutive action lines so we don't split paragraphs const push = (line, blankBefore) => { if (blankBefore && !prevBlank) { out.push(''); } out.push(line); prevBlank = false; }; const blank = () => { if (!prevBlank) { out.push(''); prevBlank = true; } }; const nonAction = () => { prevWasAction = false; }; for (let pageIdx = 0; pageIdx < pdf.numPages; pageIdx++) { // Emit a page-break marker between PDF pages so the rehearser can paginate exactly if (pageIdx > 0) { blank(); out.push(`\f${pageIdx + 1}`); blank(); } const pageLns = allLinesByPage[pageIdx] || []; for (const ln of pageLns) { const t = ln.text.trim(); if (!t || isNoise(t)) continue; const indent = (ln.x - leftMargin) / pageW; // fraction of page width const rightFrac = ln.right / pageW; const isCaps = t === t.toUpperCase() && /[A-Z]/.test(t); const wordCount = t.split(/\s+/).length; // Scene heading (slug line) if (/^(INT|EXT|INT\.\/EXT|EXT\.\/INT|I\/E)[\. ]/i.test(t)) { blank(); push(t.toUpperCase().replace(/\s+\d+[A-Z]?\.?$/, '')); blank(); nonAction(); continue; } // Act / scene number headings (theatre) if (/^(ACT|SCENE)\s+(\d+|[IVXLC]+|ONE|TWO|THREE|FOUR|FIVE)\b/i.test(t)) { blank(); push(t.toUpperCase()); blank(); nonAction(); continue; } // Transition (right-aligned, caps, ends with TO: / IN / OUT) if (isCaps && (rightFrac > 0.72 || indent > 0.45) && /(TO:|CUT|FADE|DISSOLVE|SMASH|MATCH|WIPE|BLACKOUT|INTERMISSION)\b/.test(t)) { blank(); push(t); blank(); nonAction(); continue; } // Parenthetical if (/^\(.*\)?$/.test(t) || (indent > 0.18 && indent < 0.30 && /^\(/.test(t))) { push(t.startsWith('(') ? t : '(' + t + ')', false); nonAction(); continue; } // Character cue: deeply indented + caps + short (often has (CONT'D)/(V.O.)) const nameCore = t.replace(/\s*\([^)]*\)\s*$/, '').trim(); if (indent > 0.22 && isCaps && wordCount <= 6 && nameCore.length <= 40 && /^[A-Z0-9 .'#\-]+$/.test(nameCore)) { blank(); push(t.toUpperCase()); prevBlank = false; nonAction(); // name; dialog follows next line continue; } // Dialogue: medium indent (under a character) if (indent > 0.10 && indent < 0.34) { push(t, false); nonAction(); continue; } // Default: action / description at the left margin. // Consecutive action lines join into one paragraph (no blank between them); // only start a new paragraph when coming from a non-action line. if (prevWasAction) { out.push(t); // continue same paragraph } else { blank(); push(t); // new paragraph } prevWasAction = true; } // end per-page line loop } // end page loop return out.join('\n').replace(/\n{3,}/g, '\n\n').trim(); } 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) { // Opening a record from the Library (s-library) must bring the Rehearser // section into view — without this, showPhase(2) below just swaps a phase // div inside #s-rehearser while that whole section stays display:none, // so nothing visibly happens and the click looks like it did nothing. if (typeof navTo === 'function') navTo('s-rehearser'); 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._keepSavedIdOnce = true; // re-parsing a loaded entry keeps updating it rehState.backend = rec.backend || ''; rehState.lineIndex = rec.lineIndex || 0; rehState.clips = (rec.clips || []).map(c => ({ ...c })); rehState.synthCache.clear(); rehDecodedBuffers.clear(); rehState.narratorVoice = rec.narratorVoice || ''; const lines = parseScript(rec.script || ''); if (rec.emotions) { lines.forEach((l, i) => { if (l.type === 'dialog' && rec.emotions[i]) l.emotion = rec.emotions[i]; }); } if (rec.notes) { lines.forEach((l, i) => { if (l.type === 'dialog' && rec.notes[i]) l.note = rec.notes[i]; }); } if (rec.ignored) lines.forEach((l, i) => { if (rec.ignored[i]) l.ignored = true; }); if (rec.hidden) lines.forEach((l, i) => { if (rec.hidden[i]) l.hidden = true; }); 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, instruct: saved?.instruct || '', lang: saved?.lang || '', gender: saved?.gender || '', tags: saved?.tags || '', soul: saved?.soul || '', ignored: !!saved?.ignored, hidden: !!saved?.hidden, voiceData: saved?.voice && saved.voice !== 'me' ? getVoiceData(saved.voice) : null, }; }); renderCastList(); rehApplySharedCast(rec.title); refreshRehBackends().then(() => { if (rec.backend && $('reh-backend-select')) $('reh-backend-select').value = rec.backend; if (rec.narratorVoice && $('reh-narrator-voice')) $('reh-narrator-voice').value = rec.narratorVoice; }); showPhase(2); } // Fill any empty cast slots from the production's shared character roster // (character library, matched by book OR tag). Only blanks are filled — an // explicit voice/soul already on the rehearsal always wins. Re-renders if it // changed anything. async function rehApplySharedCast(title) { if (typeof castForProduction !== 'function' || !title) return; let roster; try { roster = await castForProduction(title); } catch (_) { return; } if (!roster) return; let changed = false; Object.keys(rehState.cast || {}).forEach(sp => { // Narrator can now have a real, persisted Library record too (Assign // Voices synthesizes one per production) — pull its voice the same way // as any other shared cast entry instead of skipping it, so a voice // picked there actually reaches the Rehearser/synthesis. The narrator's // cast key is the emoji-prefixed REH_NARRATOR_KEY sentinel, not its // plain "Narrator" library name, so the roster lookup below needs the // same translation renderCastStrip() already uses elsewhere — without // it, `roster["📖narrator"]` always misses and this silently never // fires for narrator at all (confirmed live: synthAll() then skips // every narration line since it reads rehState.narratorVoice directly, // which this function is also the only place expected to set from a // shared/library voice). const lookupName = sp === REH_NARRATOR_KEY ? 'narrator' : String(sp).toLowerCase(); const shared = roster[lookupName]; if (!shared) return; const slot = rehState.cast[sp]; if (shared.voice && !slot.voice) { slot.voice = shared.voice; slot.voiceData = (shared.voice !== 'me' && typeof getVoiceData === 'function') ? getVoiceData(shared.voice) : null; changed = true; if (sp === REH_NARRATOR_KEY) rehState.narratorVoice = shared.voice; } if (shared.gender && !slot.gender) { slot.gender = shared.gender; changed = true; } if (shared.soul && !slot.soul) { slot.soul = shared.soul; changed = true; } }); if (changed) renderCastList(); } // ── Library UI ───────────────────────────────────────────────────────────── // Deterministic cover gradient from the title string function bookCover(title) { let h = 0; for (let i = 0; i < title.length; i++) h = (h * 31 + title.charCodeAt(i)) % 360; const h2 = (h + 40) % 360; return { c1: `hsl(${h}, 55%, 42%)`, c2: `hsl(${h2}, 58%, 30%)` }; } let rehLibView = localStorage.getItem('reh-lib-view') || 'shelf'; function rehTitleKey(title) { return String(title || '').replace(/\s+/g, ' ').trim().toLowerCase(); } function rehUniqueLibraryRecords(records) { const byTitle = new Map(); const ordered = [...records].sort((a, b) => { const at = new Date(a.updated || 0).getTime(); const bt = new Date(b.updated || 0).getTime(); if (at !== bt) return bt - at; return (b.id || 0) - (a.id || 0); }); for (const rec of ordered) { const key = rehTitleKey(rec.title); const bucketKey = key || `__reh__${rec.id}`; if (!byTitle.has(bucketKey)) byTitle.set(bucketKey, rec); } return [...byTitle.values()].sort((a, b) => { const at = new Date(a.updated || 0).getTime(); const bt = new Date(b.updated || 0).getTime(); if (at !== bt) return bt - at; return (b.id || 0) - (a.id || 0); }); } async function renderLibraryList() { const list = $('reh-library-list'); if (!list) return; let all; try { all = await rehDbGetAll(); } catch(e) { all = []; } all = rehUniqueLibraryRecords(all); list.classList.toggle('list-view', rehLibView === 'list'); const vt = $('reh-lib-view-toggle'); if (vt) vt.innerHTML = ``; if (!all.length) { list.innerHTML = '

No saved rehearsals yet.
Parse a script below or drop a file to start.

'; 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.type === 'dialog').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 title = rec.title || 'Untitled'; const { c1, c2 } = bookCover(title); const avatars = speakers.slice(0, 5).map(sp => { const c = rec.cast[sp]; return `${sp[0].toUpperCase()}`; }).join(''); return `
${isCurrent ? 'active' : ''}
${escHtml(title)}
${avatars}
${total} lines · ${speakers.length} cast${meCount ? ' · ' + meCount + ' me' : ''}${clipCount ? ' · ' + clipCount + '🎤' : ''}
${pct}% · ${date}
`; }).join(''); // Click book → open list.querySelectorAll('.reh-book').forEach(book => book.addEventListener('click', async e => { if (e.target.closest('.reh-book-act')) return; const id = parseInt(book.dataset.id); // rehDbOpen()/REH_STORE were the pre-migration raw-IndexedDB API and no // longer exist — rehearsals are server-backed now (rehDbGetById). const rec = await rehDbGetById(id); if (rec) loadRecord(rec); else toast('Rehearsal not found', 'error'); })); list.querySelectorAll('.reh-lib-export').forEach(btn => btn.addEventListener('click', async e => { e.stopPropagation(); const id = parseInt(btn.dataset.id); const rec = await rehDbGetById(id); if (!rec) { toast('Rehearsal not found', 'error'); return; } 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 e => { e.stopPropagation(); const bookEl = btn.closest('.reh-book'); if (bookEl.querySelector('.reh-book-del-confirm')) return; const confirmOverlay = document.createElement('div'); confirmOverlay.className = 'reh-book-del-confirm'; confirmOverlay.style.cssText = 'position:absolute; inset:0; background:rgba(0,0,0,0.85); color:#fff; display:flex; flex-direction:column; justify-content:center; align-items:center; border-radius:inherit; z-index:10; padding:12px; text-align:center; box-sizing:border-box;'; confirmOverlay.innerHTML = `
Delete Rehearsal?
This cannot be undone.
`; confirmOverlay.addEventListener('click', ce => ce.stopPropagation()); confirmOverlay.querySelector('#btn-cancel-del').addEventListener('click', ce => { ce.stopPropagation(); confirmOverlay.remove(); }); confirmOverlay.querySelector('#btn-confirm-del').addEventListener('click', async ce => { ce.stopPropagation(); await rehDbDelete(parseInt(btn.dataset.id)); if (rehState.savedId === parseInt(btn.dataset.id)) rehState.savedId = null; renderLibraryList(); }); bookEl.appendChild(confirmOverlay); })); } $('reh-lib-view-toggle')?.addEventListener('click', () => { rehLibView = rehLibView === 'shelf' ? 'list' : 'shelf'; localStorage.setItem('reh-lib-view', rehLibView); renderLibraryList(); }); // ── Helpers ──────────────────────────────────────────────────────────────── function getVoiceData(voiceId) { return (window._voices || []).find(v => v.id === voiceId) || null; } function _rehVoiceVisibleId(voiceId, include = '') { if (!voiceId) return false; if (include && voiceId === include) return true; const v = getVoiceData(voiceId); return !v || v.enabled !== false; } function voiceAvatarHtml(voiceId, color, size = 32) { const v = getVoiceData(voiceId); const s = size + 'px'; const radius = Math.round(size / 2); if (v?.has_picture) { return `${escHtml(voiceId)}`; } const ic = (v?.avatar && window.VOICE_AVATAR_ICONS) ? window.VOICE_AVATAR_ICONS[v.avatar] : null; if (ic) { return ``; } const initial = (voiceId || '?')[0].toUpperCase(); return `${initial}`; } // Stage line rows used a generic voice icon/initial for the play-avatar // button — a plain "?" for any speaker whose assigned voice has no picture // of its own, even when the CHARACTER already has a real portrait in the // Library (same one shown everywhere else: Cast Audiobook, Assign Voices, // the Cast sidebar strip right next to this same line). Prefer that. function _rehCharAvatarHtml(speaker, voiceId, color, size = 32) { const rec = (_rehLibCharsCache || []).find(r => String(r.name || '').trim().toLowerCase() === String(speaker || '').trim().toLowerCase()); if (rec && rec.image) { const s = size + 'px'; const radius = Math.round(size / 2); // Reference the image by URL, never inline the raw data: URL here — this // renders once PER DIALOGUE LINE, and a character can speak hundreds of // lines; embedding a multi-KB/MB base64 blob that many times ballooned a // real 1968-line script's HTML to hundreds of megabytes and silently // failed to render at all (confirmed live). The browser fetches/caches // the URL once regardless of how many lines reference it. return `${escHtml(speaker)}`; } return voiceAvatarHtml(voiceId, color, size); } // ── Script parser + detectCharacters → moved to rehearser-parse.js (loaded first) ── // ── Phase navigation ─────────────────────────────────────────────────────── function showPhase(n) { // Hide the import/export panel when switching to a numbered phase const impex = $('reh-impex-panel'); if (impex) impex.hidden = true; for (let i = 1; i <= 4; i++) { const el = $('reh-phase-' + i); if (el) el.hidden = i !== n; } syncPhaseTabs(n); if (typeof window.onRehearserPhaseChange === 'function') window.onRehearserPhaseChange(n); } window.showRehImpEx = function () { stopPlay(); stopRehMic(); for (let i = 1; i <= 4; i++) { const el = $('reh-phase-' + i); if (el) el.hidden = true; } const panel = $('reh-impex-panel'); if (panel) panel.hidden = false; // Show export note based on state const note = $('reh-impex-export-note'); if (note) note.textContent = rehState.lines.length ? `Current session: "${$('reh-script-title')?.value || 'Untitled'}" · ${rehState.lines.filter(l=>l.type==='dialog').length} lines` : 'No active session — load a script first to export.'; }; // Keep the sub-tab bar in sync: highlight current, enable reachable phases function syncPhaseTabs(n) { const hasScript = rehState.lines.length > 0; const hasClips = rehState.clips.length > 0; document.querySelectorAll('.reh-subtab').forEach(tab => { const p = parseInt(tab.dataset.phase); tab.classList.toggle('active', p === n); // Library always reachable; Cast/Stage need a parsed script; Summary needs clips (or being on it) let enabled = p === 1 || (p === 2 && hasScript) || (p === 3 && hasScript) || (p === 4 && (hasClips || n === 4)); tab.disabled = !enabled; }); } document.querySelectorAll('.reh-subtab').forEach(tab => { tab.addEventListener('click', () => { if (tab.disabled) return; const p = parseInt(tab.dataset.phase); // Leaving the stage: stop playback/mic so audio doesn't keep running if (p !== 3) { stopPlay(); stopRehMic(); hideRecOverlay(); } if (p === 1) renderLibraryList(); if (p === 3) { // (Re)build the stage if we have a cast/script if (rehState.lines.length) { buildScriptPage(); showPhase(3); highlightCurrentLine(); return; } } if (p === 4) { renderSummary(); } showPhase(p); }); }); // ── Phase 1 ──────────────────────────────────────────────────────────────── $('reh-file-input')?.addEventListener('change', function () { const f = this.files?.[0]; if (!f) return; const guess = f.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim(); const r = new FileReader(); r.onload = async e => { $('reh-script-text').value = e.target.result; await rehAutoSaveImport(guess); toast('Script loaded & saved to your library — click Parse & cast', 'success'); }; r.readAsText(f); this.value = ''; }); $('reh-pdf-input')?.addEventListener('change', async function () { const f = this.files?.[0]; if (!f) return; this.value = ''; try { const text = await importPDFScript(f); if ($('reh-script-text')) $('reh-script-text').value = text; const guess = f.name.replace(/\.pdf$/i, '').replace(/[-_]+/g, ' ').trim(); await rehAutoSaveImport(guess); toast('PDF imported & saved to your library — check formatting, then click Parse & cast', 'success'); } catch(e) { toast('PDF import failed: ' + e.message, 'error'); } }); $('reh-fdx-input')?.addEventListener('change', async function () { const f = this.files?.[0]; if (!f) return; this.value = ''; try { const text = await importFDX(f); if ($('reh-script-text')) $('reh-script-text').value = text; if (!$('reh-script-title')?.value.trim()) { const guess = f.name.replace(/\.fdx$/i, '').replace(/[-_]+/g, ' ').trim(); if ($('reh-script-title')) $('reh-script-title').value = guess; } toast('FDX imported — click Parse & cast', 'success'); } catch(e) { toast('FDX import failed: ' + e.message, 'error'); } }); // ── Unified import router (used by file pickers + drag & drop) ────────────── async function rehImportAnyFile(f) { if (!f) return; const name = f.name.toLowerCase(); const setTitle = strip => { if (!$('reh-script-title')?.value.trim()) { const guess = f.name.replace(strip, '').replace(/[-_]+/g, ' ').trim(); if ($('reh-script-title')) $('reh-script-title').value = guess; } }; try { if (name.endsWith('.reh') || name.endsWith('.json')) { await importFromFile(f); // full session → loads & jumps to cast return; } if (name.endsWith('.pdf')) { toast('Reading PDF…', 'success'); const text = await importPDFScript(f); if ($('reh-script-text')) $('reh-script-text').value = text; setTitle(/\.pdf$/i); await rehAutoSaveImport(); toast('PDF imported & saved to your library — check formatting, then Parse & cast', 'success'); return; } if (name.endsWith('.fdx') || name.endsWith('.osf') || name.endsWith('.xml')) { const text = await importFDX(f); if ($('reh-script-text')) $('reh-script-text').value = text; setTitle(/\.(fdx|osf|xml)$/i); await rehAutoSaveImport(); toast('Script imported & saved to your library — click Parse & cast', 'success'); return; } // .txt / .md / .fountain / anything else → plain text const text = await f.text(); if ($('reh-script-text')) $('reh-script-text').value = text; setTitle(/\.(txt|md|fountain)$/i); await rehAutoSaveImport(); toast('Script loaded & saved to your library — click Parse & cast', 'success'); } catch(e) { toast('Import failed: ' + e.message, 'error'); } } // Drag & drop over the dropzone AND the whole Phase-1 panel (function setupRehDropzone() { const dz = $('reh-dropzone'); const phase = $('reh-phase-1'); if (!dz || !phase) return; let depth = 0; const isFileDrag = e => e.dataTransfer && [...(e.dataTransfer.types || [])].includes('Files'); phase.addEventListener('dragenter', e => { if (!isFileDrag(e)) return; e.preventDefault(); depth++; dz.classList.add('dragover'); dz.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }); phase.addEventListener('dragover', e => { if (isFileDrag(e)) e.preventDefault(); }); phase.addEventListener('dragleave', e => { if (!isFileDrag(e)) return; depth = Math.max(0, depth - 1); if (depth === 0) dz.classList.remove('dragover'); }); phase.addEventListener('drop', async e => { if (!isFileDrag(e)) return; e.preventDefault(); depth = 0; dz.classList.remove('dragover'); const f = e.dataTransfer.files?.[0]; await rehImportAnyFile(f); }); })(); $('reh-parse-btn')?.addEventListener('click', () => { const text = $('reh-script-text')?.value.trim(); if (!text) { toast('Paste or upload a script first', 'error'); return; } rehState.lines = parseScript(text); if (!rehState.lines.filter(l => l.type === 'dialog').length) { toast('No dialog found. Use screenplay format (CAPS name + dialog) or CHAR: text.', 'error'); return; } // Preserve cast for returning characters const detected = detectCharacters(rehState.lines); Object.entries(detected).forEach(([sp]) => { if (rehState.cast[sp]) detected[sp] = { ...detected[sp], ...rehState.cast[sp] }; }); rehState.cast = detected; // Keep the imported/loaded library entry on the first parse so casting updates it // instead of orphaning it; otherwise start a fresh entry. if (rehState._keepSavedIdOnce) rehState._keepSavedIdOnce = false; else rehState.savedId = null; rehState.clips = []; rehState.lineIndex = 0; rehState.synthCache.clear(); rehDecodedBuffers.clear(); renderCastList(); showPhase(2); refreshRehBackends(); }); // ── Phase 2 ──────────────────────────────────────────────────────────────── function castAvatarHtml(sp) { const c = rehState.cast[sp]; if (!c) return ''; if (c.voice && c.voice !== 'me') { const vd = getVoiceData(c.voice); if (vd?.has_picture) return ``; const ic = (vd?.avatar && window.VOICE_AVATAR_ICONS) ? window.VOICE_AVATAR_ICONS[vd.avatar] : null; if (ic) return ``; } const initial = sp[0].toUpperCase(); return `${initial}`; } // ── Online voice picker: audition a sample, browse alternatives, agree/switch ── let _rehAudEl = null, _rehAudBtn = null; function _rehStopAudition() { if (_rehAudEl) { try { _rehAudEl.pause(); } catch (_) {} } if (_rehAudBtn) { _rehAudBtn.classList.remove('playing'); const i = _rehAudBtn.querySelector('.mdi'); if (i) i.className = 'mdi mdi-play'; } _rehAudBtn = null; } function _rehAudition(url, btn) { if (!url) { toast('No preview audio for this voice', 'error'); return; } if (_rehAudBtn === btn && _rehAudEl && !_rehAudEl.paused) { _rehStopAudition(); return; } _rehStopAudition(); if (!_rehAudEl) { _rehAudEl = new Audio(); _rehAudEl.addEventListener('ended', _rehStopAudition); } _rehAudEl.src = url; _rehAudEl.play().then(() => { _rehAudBtn = btn; btn.classList.add('playing'); const i = btn.querySelector('.mdi'); if (i) i.className = 'mdi mdi-stop'; }).catch(() => toast('Could not play preview', 'error')); } const _REH_PREVIEW_TEXT = 'Hello — this is how this voice sounds.'; const _rehSearchCache = {}; // sp -> last fish.audio search results (simplified candidates) // One row of the voice picker. Plays via sample URL (online) or TTS (local library voice). function _rehVoiceRow({ name, meta, playUrl, playVoice, isCurrent, useAttrs }) { const play = ``; const right = isCurrent ? ` Selected` : ``; return `
${play}
${escHtml(name)}${escHtml(meta)}
${right}
`; } // Render the "matched from fish.audio" picker: current voice + alternatives + change tools function castOnlinePanelHtml(sp, c) { const o = c.online; if (!o || !Array.isArray(o.candidates) || c.voice === 'me') return ''; // Current assigned voice — an online candidate (has a sample) or a plain library voice const curCand = o.candidates.find(x => x.voice_id && x.voice_id === c.voice); let curRow; if (curCand) { curRow = _rehVoiceRow({ name: curCand.title || c.voice, meta: [curCand.gender, curCand.language].filter(Boolean).join(' · ') || 'fish.audio', playUrl: curCand.sample_audio, isCurrent: true }); } else if (c.voice) { const vd = getVoiceData(c.voice); curRow = _rehVoiceRow({ name: vd?.name || c.voice, meta: vd?.group || vd?.tag || 'library voice', playVoice: c.voice, isCurrent: true }); } else { curRow = `
No voice assignedpick one below
`; } // Alternatives = the other matched candidates (not the current one) const alts = o.candidates.map((cand, i) => ({ cand, i })).filter(({ cand }) => cand.voice_id !== c.voice || !cand.voice_id); const altRows = alts.map(({ cand, i }) => _rehVoiceRow({ name: cand.title || 'Voice', meta: [cand.gender, cand.language].filter(Boolean).join(' · ') || 'fish.audio', playUrl: cand.sample_audio, useAttrs: ` data-act="alt" data-idx="${i}"`, })).join(''); return `
Voice — try & change ${alts.length ? `` : ''}
${curRow}
${alts.length ? `` : ''}
`; } // Preview a local library voice by synthesizing a short sample with the chosen backend async function _rehPreviewLocal(voiceId, btn) { const backend = $('reh-backend-select')?.value; if (!backend) { toast('Select a TTS backend first', 'error'); return; } if (_rehAudBtn === btn && _rehAudEl && !_rehAudEl.paused) { _rehStopAudition(); return; } _rehStopAudition(); const icon = btn.querySelector('.mdi'); btn.classList.add('loading'); if (icon) icon.className = 'mdi mdi-loading'; try { const blob = await fetchTtsPreviewBlob(voiceId, _REH_PREVIEW_TEXT, 'wav', '', backend); if (!_rehAudEl) { _rehAudEl = new Audio(); _rehAudEl.addEventListener('ended', _rehStopAudition); } _rehAudEl.src = URL.createObjectURL(blob); await _rehAudEl.play(); btn.classList.remove('loading'); _rehAudBtn = btn; btn.classList.add('playing'); if (icon) icon.className = 'mdi mdi-stop'; } catch (e) { btn.classList.remove('loading'); if (icon) icon.className = 'mdi mdi-play'; toast('Preview failed: ' + (e.message || e), 'error'); } } // Pick a representative short line from a character's own dialogue (narrator: from // the action/scene lines it narrates). Prefers a self-contained ~20–110 char sentence. function _rehPickOneLiner(sp) { const isNarr = sp === REH_NARRATOR_KEY; const pool = rehState.lines .filter(l => isNarr ? (l.type !== 'dialog' && l.type !== 'pagebreak' && !l.ignored) : (l.type === 'dialog' && l.speaker === sp)) .map(l => ({ text: stripMarkdown(l.text || '').trim(), emotion: l.emotion || '' })) .filter(l => l.text.length >= 4); if (!pool.length) return null; const scored = pool.map(l => { const len = l.text.length; let score = (len >= 20 && len <= 110) ? 3 : (len < 20 ? 1 : 0); if (/[.!?]["']?$/.test(l.text)) score += 1; // complete sentence score -= Math.abs(len - 60) / 120; // prefer ~60 chars return { l, score }; }).sort((a, b) => b.score - a.score); const best = scored[0].l; return { text: best.text.slice(0, 220), emotion: best.emotion }; } // Synthesize and play a one-liner from this character's lines, in their assigned voice. async function _rehPreviewCastLine(sp, btn) { const c = rehState.cast[sp]; if (!c || !c.voice || c.voice === 'me') { toast('Assign a voice first', 'error'); return; } const backend = $('reh-backend-select')?.value; if (!backend) { toast('Select a TTS backend first', 'error'); return; } if (_rehAudBtn === btn && _rehAudEl && !_rehAudEl.paused) { _rehStopAudition(); return; } _rehStopAudition(); const pick = _rehPickOneLiner(sp); if (!pick) { toast('No lines to preview yet', 'error'); return; } const icon = btn.querySelector('.mdi'); btn.classList.add('loading'); if (icon) icon.className = 'mdi mdi-loading'; try { const blob = await fetchTtsPreviewBlob(c.voice, _rehInlineTone(pick.text, pick.emotion), 'wav', _buildInstruct(c.instruct, pick.emotion, c.voice), backend); if (!_rehAudEl) { _rehAudEl = new Audio(); _rehAudEl.addEventListener('ended', _rehStopAudition); } _rehAudEl.src = URL.createObjectURL(blob); await _rehAudEl.play(); btn.classList.remove('loading'); _rehAudBtn = btn; btn.classList.add('playing'); if (icon) icon.className = 'mdi mdi-stop'; } catch (e) { btn.classList.remove('loading'); if (icon) icon.className = 'mdi mdi-play'; toast('Preview failed: ' + (e.message || e), 'error'); } } // Assign an existing library voice to a character (keeps the picker open) function _rehAssignLocal(sp, voiceId) { const c = rehState.cast[sp]; if (!c) return; _rehStopAudition(); c.voice = voiceId; c.voiceData = getVoiceData(voiceId); if (c.online) c.online.picked = c.online.candidates.findIndex(x => x.voice_id === voiceId); renderCastList(); if (typeof populateNarratorSelect === 'function') populateNarratorSelect(); } // Render filtered library voices inside a card's "From library" panel function _rehRenderLocalResults(card, sp, q) { const box = card.querySelector('.reh-vo-local-results'); if (!box) return; const cur = rehState.cast[sp]?.voice; const ql = (q || '').trim().toLowerCase(); const matches = rehState.voices.filter(v => { if (!_rehVoiceVisibleId(v, cur)) return false; if (v === cur) return false; if (!ql) return true; const vd = getVoiceData(v); return v.toLowerCase().includes(ql) || (vd?.name || '').toLowerCase().includes(ql) || (vd?.group || '').toLowerCase().includes(ql); }).slice(0, 40); if (!matches.length) { box.innerHTML = `
No library voices match.
`; return; } box.innerHTML = matches.map(v => { const vd = getVoiceData(v); return _rehVoiceRow({ name: vd?.name || v, meta: vd?.group || vd?.tag || 'library voice', playVoice: v, useAttrs: ` data-act="local" data-voice="${escHtml(v)}"` }); }).join(''); } // Search fish.audio and render results inside a card's "Search online" panel async function _rehSearchOnline(card, sp, q) { const box = card.querySelector('.reh-vo-search-results'); if (!box) return; q = (q || '').trim(); if (!q) { box.innerHTML = `
Enter a search term first.
`; return; } box.innerHTML = `
Searching fish.audio…
`; const lang = REH_FISH_LANG[$('reh-design-lang')?.value || 'English'] ?? 'en'; let items = []; try { const qs = [`search=${encodeURIComponent(q)}`, `language=${lang}`, 'page_size=10', 'sort_by=score']; items = (await fetch('/api/fishaudio/voices?' + qs.join('&')).then(r => r.json())).items || []; } catch (_) {} const cands = items.filter(v => v.sample_audio).slice(0, 10).map(v => ({ title: v.title, sample_audio: v.sample_audio, image: v.image || '', gender: v.gender || '', language: v.language || lang || '', description: v.description || '', sample_text: v.sample_text || v.default_text || '', })); _rehSearchCache[sp] = cands; if (!cands.length) { box.innerHTML = `
No playable voices found for “${escHtml(q)}”.
`; return; } box.innerHTML = cands.map((cand, i) => _rehVoiceRow({ name: cand.title || 'Voice', meta: [cand.gender, cand.language].filter(Boolean).join(' · ') || 'fish.audio', playUrl: cand.sample_audio, useAttrs: ` data-act="search" data-idx="${i}"`, })).join(''); } // Import a freshly searched fish.audio result, assign it, and fold it into candidates async function _rehUseSearchResult(sp, idx, btn) { const c = rehState.cast[sp]; if (!c) return; const cand = (_rehSearchCache[sp] || [])[idx]; if (!cand) return; _rehStopAudition(); const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = ''; try { const vid = await _rehImportFishCandidate(sp, cand); cand.voice_id = vid; c.voice = vid; c.voiceData = getVoiceData(vid); if (!c.online) c.online = { candidates: [], picked: 0 }; c.online.candidates.push(cand); c.online.picked = c.online.candidates.length - 1; if (typeof loadVoiceLibrary === 'function') await loadVoiceLibrary().catch(() => {}); renderCastList(); if (typeof populateNarratorSelect === 'function') populateNarratorSelect(); toast(`Switched ${sp} to ${cand.title || 'voice'}`, 'success'); } catch (err) { btn.disabled = false; btn.innerHTML = orig; toast('Import failed: ' + err.message, 'error'); } } // Remember fish imports this session so the same voice isn't downloaded twice const _rehFishImported = {}; // Import one fish.audio candidate into the library and return its voice_id. // Reuses an already-imported voice (this session OR already in the library) so the // same fish.audio voice doesn't pile up as Mortal_Kombat, Mortal_Kombat_2, … async function _rehImportFishCandidate(sp, cand) { const key = (cand.sample_audio || cand.title || '').toLowerCase(); if (key && _rehFishImported[key]) { const id = _rehFishImported[key]; if (!rehState.voices.includes(id)) rehState.voices.push(id); return id; } const title = (cand.title || '').toLowerCase().trim(); const existing = title && (window._voices || []).find(v => (v.group === 'fish-audio' || v.tag === 'fish-audio') && (v.name || '').toLowerCase().trim() === title); if (existing) { if (key) _rehFishImported[key] = existing.id; if (!rehState.voices.includes(existing.id)) rehState.voices.push(existing.id); return existing.id; } const lang2 = (cand.language || 'EN').slice(0, 2).toUpperCase(); const vid = `${lang2}_${(typeof _umlautSafe === 'function' ? _umlautSafe(cand.title || sp) : (cand.title || sp)).replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 36) || 'Voice'}`; const d = await fetch('/api/quick-import-voice', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ voice_id: vid, audio_url: cand.sample_audio, transcript: cand.sample_text || '' }), }).then(r => r.json()); if (!d.voice_id) throw new Error('import returned no id'); if (typeof saveMeta === 'function') await saveMeta(d.voice_id, { name: cand.title || sp, tag: 'fish-audio', group: 'fish-audio', origin: 'cloned', gender: (cand.gender || '').charAt(0).toUpperCase(), note: (cand.description || '').slice(0, 180) }).catch(e => logErr('fish saveMeta', e)); if (cand.image) await fetch('/api/voice/picture-url', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ voice_id: d.voice_id, image_url: cand.image }) }).catch(e => logErr('fish picture', e)); if (!rehState.voices.includes(d.voice_id)) rehState.voices.push(d.voice_id); if (key) _rehFishImported[key] = d.voice_id; return d.voice_id; } // "Disagree" → switch this character to one of the presented alternatives async function _rehUseCandidate(sp, idx, btn) { const c = rehState.cast[sp]; const o = c && c.online; if (!o) return; const cand = o.candidates[idx]; if (!cand) return; _rehStopAudition(); if (cand.voice_id) { // already imported earlier — just reassign c.voice = cand.voice_id; c.voiceData = getVoiceData(cand.voice_id); o.picked = idx; renderCastList(); if (typeof populateNarratorSelect === 'function') populateNarratorSelect(); return; } const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = ''; try { const vid = await _rehImportFishCandidate(sp, cand); cand.voice_id = vid; c.voice = vid; c.voiceData = getVoiceData(vid); o.picked = idx; if (typeof loadVoiceLibrary === 'function') await loadVoiceLibrary().catch(() => {}); renderCastList(); if (typeof populateNarratorSelect === 'function') populateNarratorSelect(); toast(`Switched ${sp} to ${cand.title || 'voice'}`, 'success'); } catch (err) { btn.disabled = false; btn.innerHTML = orig; toast('Import failed: ' + err.message, 'error'); } } const REH_NARRATOR_KEY = '\u{1F4D6}NARRATOR'; // unique sentinel kept out of real speaker names function _ensureNarrator() { if (!rehState.cast[REH_NARRATOR_KEY]) { rehState.cast[REH_NARRATOR_KEY] = { voice: rehState.narratorVoice || '', color: '#6b7280', instruct: '', voiceData: rehState.narratorVoice ? getVoiceData(rehState.narratorVoice) : null, _isNarrator: true, }; } // Bidirectional sync between the narrator cast row and the global narratorVoice. // The cast row is the source of truth (auto-design assigns the voice there), but // playback/synth read rehState.narratorVoice — keep them aligned both ways. const n = rehState.cast[REH_NARRATOR_KEY]; if (n.voice) { rehState.narratorVoice = n.voice; if (!n.voiceData) n.voiceData = getVoiceData(n.voice); } else if (rehState.narratorVoice) { n.voice = rehState.narratorVoice; n.voiceData = getVoiceData(rehState.narratorVoice); } } const REH_CAST_LANGS = ['English', 'German', 'Auto', 'French', 'Spanish', 'Italian', 'Portuguese', 'Dutch', 'Polish']; const REH_CAST_GENDERS = [['', '—'], ['F', '♀ Female'], ['M', '♂ Male'], ['N', '⚥ Diverse']]; // Apply a mutation to every dialog line spoken by a character function _castApplyToLines(sp, fn) { rehState.lines.forEach((l, i) => { if (l.speaker === sp && l.type === 'dialog') fn(l, i); }); } function _safeDomId(value) { let out = ''; const s = String(value || 'voice'); for (let i = 0; i < s.length; i++) out += s.charCodeAt(i).toString(36) + '-'; return out || 'voice'; } // Cross-referencing the Character Library gives the cast cards a real // portrait, tier badge, and occupation/archetype instead of just a colored // initial — pulled from whatever book/script this cast shares a title with. // Fetched once per script title (not per render) and cached. Two separate // renderers share this one cache — renderCastList (the "Who's playing which // character?" mecast panel) and renderCastStrip (the Stage sidebar's own // character list, see below) — but only ONE fetch is ever kicked off per // script title, guarded by _rehLibCharsCacheBook. Whichever renderer's guard // check happens to run first "claims" that fetch; the other one sees the // book already marked as fetched and skips starting its own — so BOTH must // be re-run once the shared fetch resolves, not just whichever one started // it. Confirmed live as a real bug: entering Perform & Export borrows both // panels at once, renderCastList's guard usually wins the race, and its own // old single-target callback left the Stage sidebar stuck on plain // colored-letter dots forever (never re-rendered with portraits) even // though the cache had genuinely finished loading with images moments // later — only calling renderCastStrip() by hand fixed it. let _rehLibCharsCache = null; let _rehLibCharsCacheBook = null; function _rehEnsureLibCharsCache(scriptTitle) { if (!scriptTitle || _rehLibCharsCacheBook === scriptTitle || typeof clGetAllByTagOrBook !== 'function') return; _rehLibCharsCacheBook = scriptTitle; clGetAllByTagOrBook(scriptTitle).then(recs => { _rehLibCharsCache = recs || []; renderCastList(); if (typeof renderCastStrip === 'function') renderCastStrip(); // Stage's per-line portraits (_rehCharAvatarHtml) also read this cache // directly, not just the sidebar row — re-run the full script page too // so lines that rendered before the cache arrived pick up portraits. if (typeof buildScriptPage === 'function') buildScriptPage(); }).catch(() => {}); } function renderCastList() { const list = $('reh-cast-list'); if (!list) return; // Make sure the voice library is loaded so every picker (incl. the narrator) has // voices to choose from even when the Rehearser was opened directly. if (!(window._voices || []).length && typeof loadVoiceLibrary === 'function' && !rehState._castLibFetch) { rehState._castLibFetch = true; loadVoiceLibrary().then(() => renderCastList()).catch(() => {}); } _ensureNarrator(); const narr = REH_NARRATOR_KEY; const lineCount = sp => rehState.lines.filter(l => l.speaker === sp && l.type === 'dialog').length; const allOthers = Object.keys(rehState.cast).filter(s => s !== narr); const others = _castSortFilter(allOthers, lineCount); // narrator always stays pinned on top const speakers = [narr, ...others]; const scriptTitle = $('reh-script-title')?.value.trim() || ''; _rehEnsureLibCharsCache(scriptTitle); const libRecByName = new Map((_rehLibCharsCache || []).map(r => [String(r.name || '').trim().toLowerCase(), r])); const emotionsFor = sp => { const seen = new Map(); rehState.lines.forEach(l => { if (l.speaker === sp && l.type === 'dialog' && l.emotion && !seen.has(l.emotion)) { seen.set(l.emotion, getEmotionInfo(l.emotion)); } }); return [...seen.values()]; }; list.innerHTML = speakers.map(sp => { const c = rehState.cast[sp]; const isNarr = sp === narr; const isMe = c.voice === 'me'; const label = isNarr ? 'Narrator' : sp; const n = lineCount(sp); const sub = isNarr ? 'scene headings & descriptions' : `${n} line${n!==1?'s':''}${scriptTitle ? ` · ${escHtml(scriptTitle)}` : ''}`; const cardCls = 'reh-cast-card' + (isNarr ? ' reh-cast-narrator' : '') + (c.ignored ? ' reh-cast-ignored' : '') + (c.hidden ? ' reh-cast-hidden-c' : ''); const langSel = REH_CAST_LANGS.map(l => `${l}`).join(''); const genSel = REH_CAST_GENDERS.map(([v,t]) => ``).join(''); const pickerId = 'reh-voice-sel-' + _safeDomId(sp); const voiceSel = ``; const voiceName = !isMe && c.voice ? (getVoiceData(c.voice)?.name || c.voice) : (isMe ? 'Your mic' : ''); // Library cross-reference — same book/script, matched by name. When a // real character record exists, the card IS the exact Library card // (_charCardHtml) — same colorful portrait/tier/occupation/alignment // design already used in Library → Cast, not a separate look-alike — // and voice assignment happens by clicking into the full profile (or // the bulk match tools above) rather than a dropdown on the card. // Narrator/unmatched speakers (no Library record to point at) keep the // simple fallback header with an inline voice dropdown, since there's // no profile page for them to assign a voice from. const libRec = !isNarr ? libRecByName.get(String(sp).trim().toLowerCase()) : null; if (libRec) { const libVoice = typeof libRec.voice === 'object' ? (libRec.voice?.id || '') : (libRec.voice || ''); if (libVoice) c.voice = libVoice; } const emotions = isNarr ? [] : emotionsFor(sp); const emotionsHtml = emotions.length ? `
${emotions.map(info => `${info.emoji} ${escHtml(info.label)}`).join('')}
` : ''; const controlsHtml = `
${(!isMe && c.voice) ? `` : ''}
${emotionsHtml}`; if (libRec) { return `
${(typeof _charCardHtml === 'function') ? _charCardHtml(libRec, _rehLibCharsCache || []) : ''}
${controlsHtml}
`; } return `
${castAvatarHtml(sp)}
${isNarr ? ' ' : ''}${escHtml(label)}
${sub}
${escHtml(voiceName || 'No voice assigned')}
`; }).join(''); // Cast count (reflects active filter) + keep the chosen view (card / list) const countEl = $('reh-cast-count'); if (countEl) { const shown = others.length, total = allOthers.length; countEl.textContent = shown === total ? `${total} ${total === 1 ? 'character' : 'characters'} + narrator` : `${shown} of ${total} characters`; } // Wire the reused Library cards (portrait upload, export, click → full // profile) exactly like the Library grid does — the profile page opens // in place of this list and "back" re-renders the cast list, same pattern // used for the post-recast results grid on the Read Aloud page. if (typeof _wireCharCards === 'function' && (_rehLibCharsCache || []).length) { const recsById = new Map(_rehLibCharsCache.map(r => [r.id, r])); _wireCharCards(list, recsById, _rehLibCharsCache, renderCastList, { container: list, onBack: renderCastList }); } _wireCastControls(); applyCastView(); // ── Wiring ──────────────────────────────────────────────────────────────── const card = el => el.closest('.reh-cast-card'); const spOf = el => card(el).dataset.speaker; if (window.VoicePicker) { list.querySelectorAll('.reh-voice-sel[id]').forEach(sel => { const cur = sel.value; VoicePicker.upgrade(sel.id); if (cur) VoicePicker.setValue(sel.id, cur); }); } list.querySelectorAll('.reh-me-check').forEach(cb => cb.addEventListener('change', function () { const sp = spOf(this); rehState.cast[sp].voice = this.checked ? 'me' : (card(this).querySelector('.reh-voice-sel')?.value || ''); rehState.cast[sp].voiceData = this.checked ? null : getVoiceData(rehState.cast[sp].voice); renderCastList(); })); list.querySelectorAll('.reh-voice-sel').forEach(sel => sel.addEventListener('change', function () { const sp = this.dataset.speaker; rehState.cast[sp].voice = this.value; rehState.cast[sp].voiceData = getVoiceData(this.value); delete rehState.cast[sp].online; // manual override → drop the online-match picker context if (sp === REH_NARRATOR_KEY) rehState.narratorVoice = this.value; renderCastList(); })); list.querySelectorAll('.reh-cast-instruct').forEach(inp => inp.addEventListener('input', function () { rehState.cast[spOf(this)].instruct = this.value; })); list.querySelectorAll('.reh-cc-lang').forEach(s => s.addEventListener('change', function () { rehState.cast[spOf(this)].lang = this.value; })); list.querySelectorAll('.reh-cc-gender').forEach(s => s.addEventListener('change', function () { rehState.cast[spOf(this)].gender = this.value; })); list.querySelectorAll('.reh-cc-tags').forEach(i => i.addEventListener('input', function () { rehState.cast[spOf(this)].tags = this.value; })); list.querySelectorAll('.reh-cc-soul-text').forEach(t => t.addEventListener('input', function () { rehState.cast[spOf(this)].soul = this.value; })); list.querySelectorAll('.reh-cc-develop').forEach(b => b.addEventListener('click', function (e) { e.preventDefault(); _castDevelop(spOf(this), this); })); list.querySelectorAll('.reh-cc-iconbtn').forEach(b => b.addEventListener('click', function () { const sp = spOf(this), act = this.dataset.act, c = rehState.cast[sp]; if (act === 'ignore') { c.ignored = !c.ignored; _castApplyToLines(sp, l => l.ignored = c.ignored); renderCastList(); } else if (act === 'hide') { c.hidden = !c.hidden; _castApplyToLines(sp, l => l.hidden = c.hidden); renderCastList(); } else if (act === 'delete') { _castDeleteCharacter(sp); } })); // Online voice picker — delegated so dynamically-injected result rows work too. // `list` persists across re-renders, so attach these handlers only once. if (!list._voDelegated) { list._voDelegated = true; list.addEventListener('click', e => { const sampleBtn = e.target.closest('.reh-cc-sample-btn'); if (sampleBtn) { e.preventDefault(); _rehPreviewCastLine(sampleBtn.dataset.speaker, sampleBtn); return; } const playBtn = e.target.closest('.reh-vo-play'); if (playBtn) { e.preventDefault(); if (playBtn.dataset.voice) _rehPreviewLocal(playBtn.dataset.voice, playBtn); else _rehAudition(playBtn.dataset.url, playBtn); return; } const toggle = e.target.closest('.reh-vo-toggle'); if (toggle) { e.preventDefault(); const alts = toggle.closest('.reh-cc-online')?.querySelector('.reh-vo-alts'); if (alts) { alts.hidden = !alts.hidden; toggle.textContent = toggle.textContent.replace(/[▾▴]\s*$/, '') + (alts.hidden ? '▾' : '▴'); } return; } const tool = e.target.closest('.reh-vo-tool'); if (tool) { e.preventDefault(); const panel = tool.closest('.reh-cc-online'); const want = tool.dataset.tool; const localP = panel.querySelector('.reh-vo-local-panel'); const searchP = panel.querySelector('.reh-vo-search-panel'); const showLocal = want === 'local' && (localP?.hidden ?? true); const showSearch = want === 'search' && (searchP?.hidden ?? true); if (localP) localP.hidden = !showLocal; if (searchP) searchP.hidden = !showSearch; panel.querySelectorAll('.reh-vo-tool').forEach(t => t.classList.toggle('active', (t.dataset.tool === 'local' && showLocal) || (t.dataset.tool === 'search' && showSearch))); if (showLocal) { const card = e.target.closest('.reh-cast-card'); _rehRenderLocalResults(card, spOf(tool), ''); card.querySelector('.reh-vo-local-input')?.focus(); } if (showSearch) panel.querySelector('.reh-vo-search-input')?.focus(); return; } const goBtn = e.target.closest('.reh-vo-search-go'); if (goBtn) { e.preventDefault(); const card = e.target.closest('.reh-cast-card'); _rehSearchOnline(card, spOf(goBtn), card.querySelector('.reh-vo-search-input')?.value); return; } const use = e.target.closest('.reh-vo-use'); if (use) { e.preventDefault(); const sp = spOf(use), act = use.dataset.act; if (act === 'local') _rehAssignLocal(sp, use.dataset.voice); else if (act === 'search') _rehUseSearchResult(sp, parseInt(use.dataset.idx, 10), use); else _rehUseCandidate(sp, parseInt(use.dataset.idx, 10), use); // 'alt' } }); list.addEventListener('input', e => { const li = e.target.closest('.reh-vo-local-input'); if (li) _rehRenderLocalResults(e.target.closest('.reh-cast-card'), spOf(li), li.value); }); list.addEventListener('keydown', e => { const si = e.target.closest('.reh-vo-search-input'); if (si && e.key === 'Enter') { e.preventDefault(); _rehSearchOnline(e.target.closest('.reh-cast-card'), spOf(si), si.value); } }); } } // Cast card / list view toggle function applyCastView() { const list = $('reh-cast-list'); if (!list) return; const view = rehState.castView === 'list' ? 'list' : 'card'; list.classList.toggle('reh-cast-view-card', view === 'card'); list.classList.toggle('reh-cast-view-list', view === 'list'); document.querySelectorAll('#reh-cast-view-toggle .reh-view-btn').forEach(b => b.classList.toggle('active', b.dataset.view === view)); } try { rehState.castView = localStorage.getItem('reh-cast-view') || 'card'; } catch (_) { rehState.castView = 'card'; } // Cast sort + filter state (narrator excluded — it's pinned on top by renderCastList) rehState.castFilter = { search: '', gender: '', lang: '' }; try { rehState.castSort = JSON.parse(localStorage.getItem('reh-cast-sort')) || { by: 'name', dir: 'asc' }; } catch (_) { rehState.castSort = { by: 'name', dir: 'asc' }; } // Wire the cast toolbar (view toggle + sort/filter). Called from renderCastList so it // always binds once the section is mounted; guarded to attach only once. function _wireCastControls() { const toggle = $('reh-cast-view-toggle'); if (!toggle || toggle._wired) return; toggle._wired = true; toggle.querySelectorAll('.reh-view-btn').forEach(b => b.addEventListener('click', () => { rehState.castView = b.dataset.view; try { localStorage.setItem('reh-cast-view', b.dataset.view); } catch (_) {} applyCastView(); })); const search = $('reh-cast-search'), fg = $('reh-cast-filter-gender'), fl = $('reh-cast-filter-lang'); const sortSel = $('reh-cast-sort'), dirBtn = $('reh-cast-sort-dir'); const setDirIcon = () => { if (dirBtn) { dirBtn.dataset.dir = rehState.castSort.dir; dirBtn.querySelector('.mdi').className = 'mdi mdi-sort-' + (rehState.castSort.dir === 'desc' ? 'descending' : 'ascending'); } }; const persist = () => { try { localStorage.setItem('reh-cast-sort', JSON.stringify(rehState.castSort)); } catch (_) {} }; if (sortSel) sortSel.value = rehState.castSort.by; setDirIcon(); search?.addEventListener('input', () => { rehState.castFilter.search = search.value.trim(); renderCastList(); search.focus(); }); fg?.addEventListener('change', () => { rehState.castFilter.gender = fg.value; renderCastList(); }); fl?.addEventListener('change', () => { rehState.castFilter.lang = fl.value; renderCastList(); }); sortSel?.addEventListener('change', () => { rehState.castSort.by = sortSel.value; rehState.castSort.dir = sortSel.value === 'lines' ? 'desc' : 'asc'; // lines → most first setDirIcon(); persist(); renderCastList(); }); dirBtn?.addEventListener('click', () => { rehState.castSort.dir = rehState.castSort.dir === 'desc' ? 'asc' : 'desc'; setDirIcon(); persist(); renderCastList(); }); } function _castSortFilter(speakers, lineCount) { const f = rehState.castFilter || {}, s = rehState.castSort || { by: 'name', dir: 'asc' }; const out = speakers.filter(sp => { const c = rehState.cast[sp] || {}; if (f.search) { const q = f.search.toLowerCase(); if (!sp.toLowerCase().includes(q) && !(c.tags || '').toLowerCase().includes(q)) return false; } if (f.gender && (c.gender || '') !== f.gender) return false; if (f.lang && (c.lang || '') !== f.lang) return false; return true; }); const byName = (a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }); const cmp = { name: byName, gender: (a, b) => (rehState.cast[a].gender || '~').localeCompare(rehState.cast[b].gender || '~') || byName(a, b), lang: (a, b) => (rehState.cast[a].lang || '~').localeCompare(rehState.cast[b].lang || '~') || byName(a, b), lines: (a, b) => (lineCount(a) - lineCount(b)) || byName(a, b), tag: (a, b) => (rehState.cast[a].tags || '~').localeCompare(rehState.cast[b].tags || '~') || byName(a, b), }[s.by] || byName; out.sort(cmp); if (s.dir === 'desc') out.reverse(); return out; } // Remove a character and all of their dialog lines (with index-state remap) function _castDeleteCharacter(sp) { const n = rehState.lines.filter(l => l.speaker === sp && l.type === 'dialog').length; if (!confirm(`Delete “${sp}” and their ${n} line${n!==1?'s':''}? This cannot be undone.`)) return; for (let i = rehState.lines.length - 1; i >= 0; i--) { if (rehState.lines[i].speaker === sp && rehState.lines[i].type === 'dialog') { rehState.lines.splice(i, 1); _reindexLineState(i); } } delete rehState.cast[sp]; renderCastList(); if (rehState.lines.length) buildScriptPage(); toast(`Removed ${sp}`, 'success'); } // Ask the LLM to flesh out one character → fills the soul brief + design prompt async function _castDevelop(sp, btn) { const script = $('reh-script-text')?.value.trim() || linesToScriptText(); if (!script) { toast('Load a script first', 'error'); return; } const c = rehState.cast[sp]; const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = ' Developing…'; try { const r = await fetch('/api/analyze-characters', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ script, names: [sp], llm_url: $('reh-llm-url')?.value.trim() || rehDefaultLlmUrl(), model: $('reh-llm-model')?.value || '', language: c.lang || $('reh-design-lang')?.value || 'English', }), }); if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } const info = (await r.json()).characters?.[0] || {}; if (info.gender) c.gender = String(info.gender).toUpperCase().charAt(0).replace(/[^MFN]/, 'N'); if (info.description) { c.soul = info.description; c.instruct = c.instruct || info.description; } renderCastList(); toast(`Developed ${sp}`, 'success'); } catch (e) { btn.disabled = false; btn.innerHTML = orig; toast('Develop failed: ' + e.message, 'error'); } } async function refreshRehBackends() { rehInitLlmField(); const sel = $('reh-backend-select'); if (!sel) return; let backends = typeof availableTtsBackends === 'function' ? availableTtsBackends() : []; // If the global list hasn't been probed yet, trigger the probe now if (!backends.length && typeof refreshTtsBackendAvailability === 'function') { await refreshTtsBackendAvailability().catch(() => {}); backends = typeof availableTtsBackends === 'function' ? availableTtsBackends() : []; } // Last resort: show all known backends (even unavailable ones) so the user can try if (!backends.length && typeof _ttsBackends !== 'undefined' && Array.isArray(_ttsBackends)) { backends = _ttsBackends; } const prev = sel.value || rehState.backend; sel.innerHTML = backends.length ? backends.map(b => ``).join('') : ''; if (prev && [...sel.options].some(o => o.value === prev)) { // Restore a previously chosen backend sel.value = prev; } else { // Default backend preference. fishspeech is best — it clones each voice's saved WAV // (consistent identity) AND honours per-line emotion markers (tone), so a character // sounds the same across the rehearsal while still reacting to tone. voice_clone is the // fallback for consistency (weak tone); voice_design gives tone but drifts each line. const preferred = ['fishspeech', 'voice_clone', 'customvoice', 'voice_design']; const pick = preferred.find(id => [...sel.options].some(o => o.value === id)); if (pick) sel.value = pick; } rehState.backend = sel.value || ''; // keep state in sync with the (possibly auto-picked) value _checkToneStyleSupport(); } // Keep the rehearser select in sync whenever the global backend list refreshes (window._ttsRefreshHooks = window._ttsRefreshHooks || []).push(() => { const sel = $('reh-backend-select'); if (!sel) return; const backends = typeof availableTtsBackends === 'function' ? availableTtsBackends() : []; if (!backends.length) return; const prev = sel.value || rehState.backend; sel.innerHTML = backends.map(b => ``).join(''); if (prev && [...sel.options].some(o => o.value === prev)) sel.value = prev; else if (sel.options.length) sel.value = sel.options[0].value; rehState.backend = sel.value || ''; }); // All voice IDs offered in the cast/narrator pickers — backend voices PLUS everything // in the library, so any saved/cloned/imported voice (incl. for the narrator) is pickable. function _rehAllVoiceIds(include) { const ids = new Set((rehState.voices || []).filter(id => _rehVoiceVisibleId(id, include))); (window._voices || []).forEach(v => { if (v && v.id && (v.enabled !== false || v.id === include)) ids.add(v.id); }); if (include) ids.add(include); return [...ids].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); } function populateNarratorSelect() { const sel = $('reh-narrator-voice'); if (!sel) return; const cur = rehState.narratorVoice || sel.value; sel.innerHTML = '' + _rehAllVoiceIds(cur).map(v => ``).join(''); } $('reh-fetch-voices-btn')?.addEventListener('click', async () => { const backend = $('reh-backend-select')?.value; if (!backend) { toast('Select a backend first', 'error'); return; } $('reh-fetch-voices-btn').disabled = true; try { if ((!window._voices || !window._voices.length) && typeof loadVoiceLibrary === 'function') { await loadVoiceLibrary().catch(() => {}); } const raw = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json()); rehState.voices = (Array.isArray(raw) ? raw.map(v => typeof v === 'string' ? v : (v.id || String(v))) : []) .filter(id => _rehVoiceVisibleId(id)); renderCastList(); populateNarratorSelect(); toast('Fetched ' + rehState.voices.length + ' voices', 'success'); } catch(e) { toast('Fetch failed: ' + e.message, 'error'); } finally { $('reh-fetch-voices-btn').disabled = false; } }); $('reh-narrator-voice')?.addEventListener('change', function () { rehState.narratorVoice = this.value; }); $('reh-back-1-btn')?.addEventListener('click', () => showPhase(1)); // ── TTS instruct builder — emotion goes first as a directive ───────────────── // voice_clone backends treat instruct as an IDENTITY description; putting the // emotion first and using directive language ("Speak in a … manner") makes the // model prioritise it over the static voice description, which otherwise wins. // Casting tags emotions in the BOOK's own language (this app runs heavily // with German books, so `emotion` here is usually a German word like // "fordernd" or "entschlossen") — but the instruct sentence wrapping it was // always hardcoded English ("Speak in a fordernd manner."), dropping a // German adjective into an English carrier sentence. Confirmed as the // likely reason emotion barely registered for designed voices even though // isolated English-only test instructs ("speak in an extremely angry // manner") clearly changed the output — a mixed-language instruct is a much // weaker signal than a natural sentence in one language. `voiceId`'s own // language-code prefix (DE_/EN_/...) picks the matching template. const _BUILD_INSTRUCT_TEMPLATES = { DE: e => `Sprich in einem ${e} Tonfall.`, EN: e => `Speak in a ${e} manner.`, }; // Same wording _buildVoicePrompt uses for the one-time design call — that // clause was NEVER being resent on ongoing lines: the per-line instruct // only ever carried the character's saved voice_design_prompt (a plain // voice-quality description with no accent guidance in it at all), because // the accent clause was built fresh at design time and never written back // into that saved profile. Since each synthesis call is stateless — the // engine has no memory of the original design call — omitting it here meant // every ONGOING line got zero accent reinforcement, only the one-off // creation call ever did. Confirmed as a real, separate cause of designed // voices drifting back toward an American accent during actual narration. const _BUILD_INSTRUCT_LANG_NAMES = { DE: 'German', FR: 'French', ES: 'Spanish', IT: 'Italian', PT: 'Portuguese', NL: 'Dutch', PL: 'Polish' }; function _buildAccentClause(langCode) { const langName = _BUILD_INSTRUCT_LANG_NAMES[langCode]; if (langName) return `Speak with an authentic native ${langName} accent — not American-accented, not an English speaker doing ${langName}.`; if (langCode === 'EN') return 'English with a neutral British or international accent, explicitly not American/US-accented.'; return ''; } function _buildInstruct(voiceProfile, emotion, voiceId) { const p = (voiceProfile || '').trim(); const e = (emotion || '').trim(); const langCode = String(voiceId || '').split('_')[0].toUpperCase(); const accent = _buildAccentClause(langCode); if (!e && !p && !accent) return ''; // The emotion clause is now ALWAYS built in English, regardless of the voice's // own language — confirmed via controlled A/B testing (same line, same voice, // varying only instruct language) that Qwen3-TTS's emotional differentiation is // dramatically stronger in English than German: sad/angry/shocked/happy formed a // clean, coherent pitch gradient (274Hz -> 398Hz) in English, but stayed muddled // together in German even with the exact same wording translated. The spoken // TEXT and the accent clause below stay fully native-language — only the // emotion instruction itself changes language. The raw per-line emotion is // LLM-generated in the book's own language (see the casting prompt's "1-2 // deutsche Wörter"), so translate it before building the clause. const emotionEn = e && langCode !== 'EN' && typeof _rehEmotionEnglishTag === 'function' ? _rehEmotionEnglishTag(e) : ''; const effectiveEmotion = emotionEn || e; const emotionTmpl = _BUILD_INSTRUCT_TEMPLATES.EN; // Emotion leads, accent trails — Qwen3-TTS's own prompting guidance warns // it "does not follow instructions correctly when dealing with // conflicting attributes... favoring one over the other." Putting the // accent clause first (as the previous version did) made it the most // prominent instruction on every single line, which lines up with the // reported regression right after that fix landed: mood stopped coming // through. Emotion is the one thing that MUST vary per line; accent is a // constant reminder the voice's own identity should mostly already carry, // so it goes last, not first. // ...and when an emotion IS present, the accent clause is dropped entirely rather // than merely demoted. Measured on the deterministic voice_clone backend (identical // input reproduces bit-identical audio, so these differences are real, not sampling // noise): with an emotion-only instruct the tones separate cleanly and in the right // order — whisper 206Hz, sad 170Hz, neutral 225Hz, happy 234Hz, angry 233Hz, scared // 239Hz, with sad also slower and quieter. Appending "Speak with an authentic native // German accent." to those same instructs collapsed the whole range to 222-250Hz and // erased sad almost completely. Two style directives in one instruct compete, and the // trailing one wins — which is exactly the failure Qwen3-TTS's own prompting guidance // warns about for conflicting attributes. A cloned voice already carries its accent // from the reference WAV, so accent reinforcement is the cheaper of the two to lose; // lines with no emotion still get it. const parts = e ? [emotionTmpl(effectiveEmotion), p].filter(Boolean) : [p, accent].filter(Boolean); return parts.join(' '); } // Fish-Speech / OpenAudio S2 reads inline [tag] emotion markers straight from the // input text (the OpenAI-style `instruct` field is ignored). For fish backends we // therefore prepend the tone as a [tag] so per-line tones actually take effect. function _rehBackendIsFish() { let id = ''; try { id = (typeof backendById === 'function' && backendById(rehState.backend)?.id) || rehState.backend || ''; } catch (_) { id = rehState.backend || ''; } return /fish/i.test(id); } // Fish-Speech's docs are explicit: "Use English emotion tags in square brackets // regardless of the spoken language." Per-line auto emotions are LLM-generated in the // book's own language (the casting prompt literally asks for "1-2 deutsche Wörter"), // which is what _buildInstruct's native-language sentence needs for Qwen3-TTS — but // that same raw word is useless as a Fish [tag] untranslated. Confirmed live: German // tags were silently ignored, producing flat/emotionless Fish-Speech output even // though the audio itself was fine. This table only needs to cover common tone/mood // adjectives, not a full dictionary — unrecognized words fall through to no tag at // all (line 8 below), which is a NOP for Fish, safer than sending a foreign-language // word it will just ignore anyway. const _REH_EMOTION_DE_EN = { 'wütend':'angry','zornig':'angry','erzürnt':'angry','verärgert':'annoyed','gereizt':'irritated', 'traurig':'sad','melancholisch':'melancholic','betrübt':'sad','niedergeschlagen':'dejected', 'ängstlich':'scared','furchtsam':'fearful','verängstigt':'frightened','panisch':'panicked','nervös':'nervous', 'fröhlich':'happy','glücklich':'happy','freudig':'joyful','heiter':'cheerful','vergnügt':'delighted', 'flüsternd':'whispering','leise':'quiet','gedämpft':'hushed', 'aufgeregt':'excited','begeistert':'enthusiastic','euphorisch':'euphoric', 'überrascht':'surprised','erstaunt':'astonished','verblüfft':'amazed', 'genervt':'annoyed','frustriert':'frustrated', 'verzweifelt':'desperate','hoffnungslos':'hopeless','resigniert':'resigned', 'entschlossen':'determined','entschieden':'decisive', 'selbstbewusst':'confident','stolz':'proud','arrogant':'arrogant', 'schüchtern':'shy','verlegen':'embarrassed','unsicher':'uncertain', 'ironisch':'sarcastic','sarkastisch':'sarcastic','spöttisch':'mocking','höhnisch':'scornful','verächtlich':'contemptuous', 'ernst':'serious','streng':'stern','autoritär':'authoritative','befehlend':'commanding', 'sanft':'gentle','zärtlich':'tender','liebevoll':'loving','warm':'warm', 'kalt':'cold','distanziert':'distant','gleichgültig':'indifferent','gelangweilt':'bored', 'geheimnisvoll':'mysterious','unheimlich':'eerie','düster':'ominous','bedrohlich':'threatening', 'dramatisch':'dramatic','theatralisch':'theatrical','pathetisch':'melodramatic', 'ruhig':'calm','gelassen':'composed','besonnen':'measured', 'schockiert':'shocked','entsetzt':'horrified','fassungslos':'stunned', 'zögernd':'hesitant','verwirrt':'confused','ratlos':'bewildered','unschlüssig':'undecided', 'weinend':'tearful','schluchzend':'sobbing','trauernd':'grieving','gebrochen':'broken', 'schroff':'curt','barsch':'gruff','grob':'rough','abweisend':'dismissive', 'freundlich':'friendly','herzlich':'warm','einladend':'welcoming', 'spielerisch':'playful','neckend':'teasing','frech':'cheeky','schelmisch':'mischievous', 'romantisch':'romantic','sehnsüchtig':'longing','verliebt':'infatuated', 'triumphierend':'triumphant','siegessicher':'victorious', 'erleichtert':'relieved','beruhigt':'reassured', 'schuldbewusst':'guilty','reumütig':'remorseful', 'neugierig':'curious','interessiert':'interested', 'müde':'weary','erschöpft':'exhausted', 'wehmütig':'wistful','nostalgisch':'nostalgic', 'bemerkend':'remarking','feststellend':'noting','sachlich':'matter-of-fact','nüchtern':'plain', 'flehend':'pleading','bittend':'imploring', 'warnend':'warning','mahnend':'admonishing', 'trotzig':'defiant','rebellisch':'rebellious', 'erschrocken':'startled','verstört':'disturbed', }; function _rehEmotionEnglishTag(emotion) { const raw = (emotion || '').trim(); if (!raw) return ''; // Preset emotions (REH_EMOTIONS / custom) are already English descriptive phrases — // the first, most tag-like word is what Fish actually needs. const firstWord = raw.split(/[,;]\s*/)[0].trim(); const lower = firstWord.toLowerCase(); // Check the DE→EN table BEFORE assuming "looks ASCII" means "is English" — most // German emotion adjectives (e.g. "bedrohlich") are pure a-z too, so charset alone // can't distinguish them from English; the dict must win first. if (_REH_EMOTION_DE_EN[lower]) return _REH_EMOTION_DE_EN[lower]; // Try stripping a common German adjective ending (declined forms LLMs sometimes // produce, e.g. "ängstliche" instead of "ängstlich") and match on the stem. const stem = lower.replace(/(e|er|es|en|em)$/, ''); if (stem.length >= 4) { for (const key in _REH_EMOTION_DE_EN) { if (key.startsWith(stem)) return _REH_EMOTION_DE_EN[key]; } } if (/^[a-z\- ]+$/.test(lower)) return lower; // not in the table, but looks English (e.g. REH_EMOTIONS presets) return ''; // no reliable English tag — omit rather than send a word Fish will ignore } function _rehInlineTone(text, emotion) { if (!_rehBackendIsFish()) return text; if (/^\s*\[/.test(text)) return text; // already carries an inline [tag] const tag = _rehEmotionEnglishTag(emotion); return tag ? `[${tag}] ${text}` : text; } // ── Auto-design voices with LLM + Design a Voice ──────────────────────────── const REH_LANG_CODE = { English:'EN', German:'DE', French:'FR', Spanish:'ES', Italian:'IT', Auto:'EN' }; function rehDefaultLlmUrl() { try { if (typeof _appSettings !== 'undefined' && _appSettings && _appSettings.llm_url) return _appSettings.llm_url; } catch(_) {} return 'http://localhost:11434/v1'; } // Collect all LLM endpoints visible in the Language Models section function rehCollectLlmEndpoints() { const seen = new Set(); const results = []; const add = (url, label) => { if (!url) return; url = url.trim(); if (!url || seen.has(url)) return; seen.add(url); results.push({ url, label: label || url }); }; // 1. Active LLM from settings add(rehDefaultLlmUrl(), 'Active LLM'); // 2. All URL inputs in the Language Models section (llm-local-url-inp) document.querySelectorAll('.llm-local-url-inp, [data-llm-local-key]').forEach(inp => { const v = inp.value?.trim(); const def = inp.dataset.llmLocalDefault; const key = inp.dataset.llmLocalKey || inp.dataset.dcUrlKey || ''; const card = inp.closest('[class*="llm-local-card"], [class*="llm-local"]'); const name = card?.querySelector('.llm-local-name')?.textContent?.trim() || key; add(v || def, name); }); // 3. The dc-url-inp fields with LLM role document.querySelectorAll('.dc-url-inp').forEach(inp => { const card = inp.closest('[class*="llm-local-card"]'); if (!card) return; const name = card.querySelector('.llm-local-name')?.textContent?.trim() || ''; add(inp.value?.trim() || inp.dataset.dcDefault, name); }); // 4. Always include common defaults as suggestions const DEFAULTS = [ ['http://localhost:11434/v1', 'Ollama'], ['http://localhost:8000/v1', 'vLLM'], ['http://localhost:1234/v1', 'LM Studio'], ['http://localhost:28080/v1', 'llama-swap'], ['http://localhost:14000/v1', 'LiteLLM'], ]; DEFAULTS.forEach(([u, l]) => add(u, l)); return results; } // Prefill LLM URL + datalist when Phase 2 first shows voices function rehInitLlmField() { const u = $('reh-llm-url'); if (!u) return; if (!u.value) u.value = rehDefaultLlmUrl(); // Populate datalist with all known LLM endpoints const dl = $('reh-llm-url-list'); if (dl) { dl.innerHTML = rehCollectLlmEndpoints() .map(e => ``) .join(''); } } $('reh-llm-refresh')?.addEventListener('click', async () => { const url = $('reh-llm-url')?.value.trim() || rehDefaultLlmUrl(); const sel = $('reh-llm-model'); if (!sel) return; sel.innerHTML = ''; try { const r = await fetch('/api/conversation/llm-models?url=' + encodeURIComponent(url)); const d = await r.json(); const models = d.models || []; sel.innerHTML = '' + models.map(m => ``).join(''); // Default to the global Active LLM model when available (still overridable here) const want = (typeof _appSettings !== 'undefined' && _appSettings) ? _appSettings.llm_model : ''; if (want && models.includes(want)) sel.value = want; toast(models.length ? `Found ${models.length} models` : 'No models found', models.length ? 'success' : 'error'); } catch(e) { sel.innerHTML = ''; toast('Could not list models: ' + e.message, 'error'); } }); // Avatar icon keys for designed voices — male / female / neutral / robot / animal. // Picked from the LLM description (robot/animal cues) then gender. const REH_AVATAR_ICONS = { male: 'mdi-face-man', female: 'mdi-face-woman', neutral: 'mdi-account', robot: 'mdi-robot-outline', animal: 'mdi-paw', }; function _pickVoiceAvatar(gender, desc, speaker) { const d = ((desc || '') + ' ' + (speaker || '')).toLowerCase(); if (/\b(robot|android|synthetic|artificial|computer|machine|cyborg|a\.?i\.?|operating system|\bos\b|digital|hologram|drone)\b/.test(d)) return 'robot'; if (/\b(animal|creature|beast|dragon|monster|cat|dog|wolf|lion|bird|horse|dino|dinosaur|alien)\b/.test(d)) return 'animal'; if (gender === 'M') return 'male'; if (gender === 'F') return 'female'; return 'neutral'; } let rehDesignCancelled = false; $('reh-autodesign-cancel')?.addEventListener('click', () => { rehDesignCancelled = true; }); $('reh-autodesign-btn')?.addEventListener('click', async () => { const backend = $('reh-backend-select')?.value; if (!backend) { toast('Select a TTS backend first', 'error'); return; } const speakers = Object.keys(rehState.cast); if (!speakers.length) { toast('No characters to design for', 'error'); return; } const script = $('reh-script-text')?.value.trim() || linesToScriptText(); const llmUrl = $('reh-llm-url')?.value.trim() || rehDefaultLlmUrl(); const llmModel= $('reh-llm-model')?.value || ''; const language= $('reh-design-lang')?.value || 'English'; const langCode= REH_LANG_CODE[language] || 'EN'; const scriptTitle = $('reh-script-title')?.value.trim() || 'Script'; const tag = (typeof _umlautSafe === 'function' ? _umlautSafe(scriptTitle) : scriptTitle).replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 24) || 'Script'; const btn = $('reh-autodesign-btn'); const prog = $('reh-autodesign-progress'); const fill = $('reh-autodesign-fill'); const label= $('reh-autodesign-label'); btn.disabled = true; rehDesignCancelled = false; if (prog) prog.hidden = false; const setProg = (d, t, msg) => { if (fill) fill.style.width = (t ? (d/t)*100 : 0) + '%'; if (label) label.textContent = msg || `${d} / ${t}`; }; setProg(0, speakers.length, 'Analyzing script with LLM…'); // 1) Ask the LLM to describe each character let characters; try { const r = await fetch('/api/analyze-characters', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ script, names: speakers, llm_url: llmUrl, model: llmModel, language }), }); if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); characters = d.characters || []; } catch(e) { toast('Character analysis failed: ' + e.message, 'error'); btn.disabled = false; if (prog) prog.hidden = true; return; } // Map analysis back to speakers (case-insensitive match) const byName = {}; characters.forEach(c => { if (c.name) byName[String(c.name).toUpperCase().trim()] = c; }); // 2) For each character, design + save a voice let done = 0; // rehState.cast[sp] used to be assumed always present since `speakers` came // from Object.keys(rehState.cast) moments earlier — but the analyze-characters // await above can take many seconds for a large cast, and if anything // removes/replaces a cast entry while that request is in flight, the plain // `rehState.cast[sp].voice` access below threw a TypeError with nothing // catching it (this whole loop sits outside the try/catch above), silently // killing the click with no toast and no further requests — exactly the // "clicked Design all and nothing happens" symptom, confirmed by server // logs showing analyze-characters succeeding but voice-design never once // being called. Guard it and keep going instead of crashing the whole run. // The outer try/finally below is the same idea one level up: ANY unexpected // exception here used to vanish into the console with the button stuck // disabled — now it surfaces as a toast and always resets the UI. try { const designOnly = speakers.filter(sp => rehState.cast[sp]?.voice !== 'me'); for (const sp of designOnly) { if (rehDesignCancelled) { toast('Cancelled', 'error'); break; } if (!rehState.cast[sp]) { done++; continue; } const info = byName[sp.toUpperCase().trim()] || {}; const gender = (info.gender || 'N').toUpperCase().charAt(0).replace(/[^MFN]/, 'N') || 'N'; const desc = info.description || `A ${info.age || 'adult'} ${gender === 'M' ? 'male' : gender === 'F' ? 'female' : ''} character named ${sp}, natural expressive voice.`; const sampleLine = rehState.lines.find(l => l.type === 'dialog' && l.speaker === sp)?.text || `Hello, I am ${sp}.`; const safeName = (typeof _umlautSafe === 'function' ? _umlautSafe(sp) : sp).replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 24) || 'Char'; const voiceId = `${langCode}_${gender}_${safeName}_${tag}`.slice(0, 90); rehMarkCastDesigning(sp, 'designing', null, { gender, language, voiceId, desc, age: info.age || '', step: 'Generating voice audio…', }); setProg(done, designOnly.length, `Designing ${sp}… (${done + 1}/${designOnly.length})`); try { // Generate the voice audio const dr = await fetch('/api/voice-design', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ instruct: desc, sample_text: stripMarkdown(sampleLine).slice(0, 300), language, gender, dialogue: false }), }); if (!dr.ok) { const e = await dr.json().catch(()=>({})); throw new Error(e.detail || dr.statusText); } const dd = await dr.json(); // Save it to the voice library, tagged with script name + Rehearser const sr = await fetch('/api/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: dd.id, voice_id: voiceId, transcript: stripMarkdown(sampleLine).slice(0, 300) }), }); if (!sr.ok) { const e = await sr.json().catch(()=>({})); throw new Error(e.detail || sr.statusText); } const saved = await sr.json(); // Tag metadata — display name = character, script as a tag, origin 'designed', // grouped by play title, plus an auto-picked gender/type avatar icon. const charName = (sp === REH_NARRATOR_KEY) ? 'Narrator' : sp; if (typeof saveMeta === 'function') { await saveMeta(saved.voice_id, { gender, name: charName, avatar: _pickVoiceAvatar(gender, desc, sp), origin: 'designed', group: `Rehearser: ${scriptTitle}`, note: `Rehearser · ${scriptTitle} · ${charName} — ${desc.slice(0, 180)}`, transcript: stripMarkdown(sampleLine).slice(0, 300), tag: scriptTitle, }).catch(()=>{}); } // Assign to cast + remember the LLM's research as the style note & character soul rehState.cast[sp].voice = saved.voice_id; rehState.cast[sp].instruct = rehState.cast[sp].instruct || desc; rehState.cast[sp].soul = rehState.cast[sp].soul || [info.age ? `Age: ${info.age}` : '', desc].filter(Boolean).join(' · '); rehState.cast[sp].voiceData = null; if (!rehState.voices.includes(saved.voice_id)) rehState.voices.push(saved.voice_id); rehMarkCastDesigning(sp, 'done'); } catch(e) { rehMarkCastDesigning(sp, 'err', e.message); } done++; setProg(done, designOnly.length); } // Refresh the broader voice library so avatars/pictures resolve if (typeof loadVoiceLibrary === 'function') await loadVoiceLibrary().catch(()=>{}); if (!rehDesignCancelled) toast(`Designed ${done} voice${done!==1?'s':''} — tagged "${tag}" + Rehearser`, 'success'); } catch (e) { toast('Design all failed: ' + (e?.message || e), 'error'); } finally { renderCastList(); populateNarratorSelect(); if (prog) prog.hidden = true; btn.disabled = false; } }); // ── Match from library — LLM picks the best EXISTING voice for each character ── // ── Character research → per-character note ────────────────────────────────── // Store the LLM's reading of the play as each character's "soul" note (+ gender / style). function rehWriteCharacterNote(sp, info) { const c = rehState.cast[sp]; if (!c || !info) return; if (info.gender && !c.gender) c.gender = String(info.gender).toUpperCase().charAt(0).replace(/[^MFN]/, 'N'); const bits = []; if (info.age) bits.push(`Age: ${info.age}`); if (info.description) bits.push(info.description); const note = bits.join(' · '); if (note && !c.soul) c.soul = note; // fills the Character-soul / LLM-brief note if (info.description && !c.instruct) c.instruct = info.description; } // Ask the LLM to read the script + cast, then annotate every character with a note. // Returns a name→info map (used by the online/design flows that need gender too). async function rehResearchCast(speakers) { const names = speakers.filter(sp => sp !== REH_NARRATOR_KEY); if (!names.length) return {}; let chars = []; try { const r = await fetch('/api/analyze-characters', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ script: $('reh-script-text')?.value.trim() || linesToScriptText(), names, llm_url: $('reh-llm-url')?.value.trim() || rehDefaultLlmUrl(), model: $('reh-llm-model')?.value || '', language: $('reh-design-lang')?.value || 'English', }), }); if (r.ok) chars = (await r.json()).characters || []; } catch (_) {} const byName = {}; chars.forEach(c => { if (c.name) byName[String(c.name).toUpperCase().trim()] = c; }); speakers.forEach(sp => rehWriteCharacterNote(sp, byName[sp.toUpperCase().trim()])); return byName; } $('reh-matchlib-btn')?.addEventListener('click', async () => { const btn = $('reh-matchlib-btn'); const speakers = Object.keys(rehState.cast).filter(sp => rehState.cast[sp].voice !== 'me'); if (!speakers.length) { toast('No characters to match', 'error'); return; } let lib = (window._voices || []).filter(v => v.enabled !== false); if (!lib.length) { // Library metadata isn't loaded yet (opened the Rehearser directly) — fetch it now. try { lib = (await fetch('/api/voices').then(r => r.json())).filter(v => v.enabled !== false); } catch (_) {} } if (!lib.length) { toast('Your voice library is empty — clone, design or import some voices first', 'error'); return; } const candidates = lib.map(v => ({ id: v.id, gender: v.gender || '', language: v.lang || '', tags: v.tag || '', description: (v.note || v.name || '').slice(0, 140), })); const nameFor = sp => (sp === REH_NARRATOR_KEY ? 'Narrator' : sp); const byDisplay = {}; speakers.forEach(sp => { byDisplay[nameFor(sp).toUpperCase().trim()] = sp; }); const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = ' Matching…'; try { const r = await fetch('/api/match-characters-voices', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ script: $('reh-script-text')?.value.trim() || linesToScriptText(), names: speakers.map(nameFor), voices: candidates, llm_url: $('reh-llm-url')?.value.trim() || rehDefaultLlmUrl(), model: $('reh-llm-model')?.value || '', language: $('reh-design-lang')?.value || 'English', }), }); if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); } const assignments = (await r.json()).assignments || []; const validIds = new Set(candidates.map(c => c.id)); let n = 0; assignments.forEach(a => { const sp = byDisplay[String(a.name || '').toUpperCase().trim()]; if (sp && a.voice_id && validIds.has(a.voice_id)) { rehState.cast[sp].voice = a.voice_id; rehState.cast[sp].voiceData = getVoiceData(a.voice_id); if (!rehState.voices.includes(a.voice_id)) rehState.voices.push(a.voice_id); if (sp === REH_NARRATOR_KEY) rehState.narratorVoice = a.voice_id; n++; } }); // Research the play + cast and drop a note on each character btn.innerHTML = ' Researching characters…'; await rehResearchCast(speakers); renderCastList(); populateNarratorSelect(); toast(n ? `Matched ${n} character${n !== 1 ? 's' : ''} & added notes` : 'No good matches — try “Design all voices” instead', n ? 'success' : 'error'); } catch (e) { toast('Match failed: ' + e.message, 'error'); } finally { btn.disabled = false; btn.innerHTML = orig; } }); // ── Match from online — search fish.audio per character, import & assign ────── const REH_FISH_LANG = { English: 'en', German: 'de', French: 'fr', Spanish: 'es', Italian: 'it', Portuguese: 'pt', Dutch: 'nl', Auto: '' }; $('reh-matchonline-btn')?.addEventListener('click', async () => { const btn = $('reh-matchonline-btn'); const speakers = Object.keys(rehState.cast).filter(sp => rehState.cast[sp].voice !== 'me' && sp !== REH_NARRATOR_KEY); if (!speakers.length) { toast('No characters to match', 'error'); return; } const lang = REH_FISH_LANG[$('reh-design-lang')?.value || 'English'] ?? 'en'; const prog = $('reh-autodesign-progress'), fill = $('reh-autodesign-fill'), label = $('reh-autodesign-label'); const setProg = (d, t, msg) => { if (fill) fill.style.width = (t ? (d / t) * 100 : 0) + '%'; if (label) label.textContent = msg || `${d} / ${t}`; }; const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = ' Analyzing…'; if (prog) prog.hidden = false; rehDesignCancelled = false; try { // 1) Ask the LLM for each character's gender (drives the online search) let chars = []; try { const ar = await fetch('/api/analyze-characters', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ script: $('reh-script-text')?.value.trim() || linesToScriptText(), names: speakers, llm_url: $('reh-llm-url')?.value.trim() || rehDefaultLlmUrl(), model: $('reh-llm-model')?.value || '', language: $('reh-design-lang')?.value || 'English' }), }); if (ar.ok) chars = (await ar.json()).characters || []; } catch (_) {} const byName = {}; chars.forEach(c => { if (c.name) byName[String(c.name).toUpperCase().trim()] = c; }); const G = { M: 'male', F: 'female', N: 'neutral' }; let done = 0, n = 0; for (let i = 0; i < speakers.length; i++) { if (rehDesignCancelled) { toast('Cancelled', 'error'); break; } const sp = speakers[i]; setProg(done, speakers.length, `Matching ${sp}… (${done + 1}/${speakers.length})`); const info = byName[sp.toUpperCase().trim()] || {}; rehWriteCharacterNote(sp, info); // drop the LLM's character research into a note const gender = G[(info.gender || '').toUpperCase().charAt(0)] || ''; // Search fish.audio: by character name first (catches known/celebrity names), then gender+lang const qs = [`search=${encodeURIComponent(sp)}`, `language=${lang}`, 'page_size=8', 'sort_by=score']; let items = []; try { items = (await fetch('/api/fishaudio/voices?' + qs.join('&')).then(r => r.json())).items || []; } catch (_) {} let pick = items.find(v => v.sample_audio); let nameHit = !!pick; // got a result by searching the character's actual name if (!pick) { // fallback: best by gender+language, offset per character to diversify const fb = [`language=${lang}`, gender ? `gender=${gender}` : '', 'page_size=12', 'sort_by=score', `page=${(i % 4) + 1}`].filter(Boolean); try { items = (await fetch('/api/fishaudio/voices?' + fb.join('&')).then(r => r.json())).items || []; } catch (_) {} pick = items.find(v => v.sample_audio); } // Keep the top few playable results so the user can audition & swap them later let cands = items.filter(v => v.sample_audio).slice(0, 6).map(v => ({ title: v.title, sample_audio: v.sample_audio, image: v.image || '', gender: v.gender || '', language: v.language || lang || '', description: v.description || '', sample_text: v.sample_text || v.default_text || '', })); // When the name didn't match, the fallback returns the same popular voices for // everyone — rotate the pick per character so they don't all sound identical, // and skip voices already taken by earlier characters this run. if (!nameHit && cands.length > 1) { const taken = new Set(Object.values(rehState.cast).map(c => (c.online?.candidates?.[c.online.picked]?.sample_audio)).filter(Boolean)); const rotated = [...cands.slice(i % cands.length), ...cands.slice(0, i % cands.length)]; cands = rotated.sort((a, b) => (taken.has(a.sample_audio) ? 1 : 0) - (taken.has(b.sample_audio) ? 1 : 0)); } if (cands.length) { try { const vid = await _rehImportFishCandidate(sp, cands[0]); cands[0].voice_id = vid; rehState.cast[sp].voice = vid; rehState.cast[sp].voiceData = getVoiceData(vid); rehState.cast[sp].online = { candidates: cands, picked: 0 }; n++; } catch (e) { logErr('match-online import ' + sp, e); } } done++; setProg(done, speakers.length); } if (typeof loadVoiceLibrary === 'function') await loadVoiceLibrary().catch(() => {}); renderCastList(); populateNarratorSelect(); toast(n ? `Imported & matched ${n} online voice${n !== 1 ? 's' : ''}` : 'No online matches found', n ? 'success' : 'error'); } catch (e) { toast('Online match failed: ' + e.message, 'error'); } finally { btn.disabled = false; btn.innerHTML = orig; if (prog) prog.hidden = true; } }); // Show per-character design progress card beneath the cast row function rehMarkCastDesigning(sp, state, msg, info) { const row = document.querySelector(`.reh-cast-row[data-speaker="${CSS.escape(sp)}"]`); if (!row) return; // Inline badge in the cast row header let badge = row.querySelector('.reh-cast-design-badge'); if (!badge) { badge = document.createElement('span'); badge.className = 'reh-cast-design-badge'; row.querySelector('strong')?.after(badge); } badge.className = 'reh-cast-design-badge' + (state === 'done' ? ' done' : state === 'err' ? ' err' : ''); badge.textContent = state === 'designing' ? '✨ designing…' : state === 'done' ? '✓ designed' : '✗ failed'; if (msg) badge.title = msg; row.classList.toggle('reh-cast-designing', state === 'designing'); // Expanded detail panel beneath the instruct row const wrap = row.closest('div'); let panel = wrap?.querySelector('.reh-cast-design-panel'); if (state === 'designing' && info) { if (!panel) { panel = document.createElement('div'); panel.className = 'reh-cast-design-panel'; wrap.appendChild(panel); } const genderIcon = info.gender === 'M' ? '♂' : info.gender === 'F' ? '♀' : '⚧'; const genderLabel = info.gender === 'M' ? 'Male' : info.gender === 'F' ? 'Female' : 'Neutral'; panel.innerHTML = `
${genderIcon} ${escHtml(genderLabel)} ${escHtml(info.language || 'EN')} ${escHtml(info.voiceId || sp)} ${info.age ? `${escHtml(info.age)}` : ''}
${escHtml(info.desc || '')}
${escHtml(info.step || 'Designing voice…')}
`; } else if (state === 'done' && panel) { // Collapse to a summary line const descEl = panel.querySelector('.reh-cdp-desc'); const metaEl = panel.querySelector('.reh-cdp-meta'); const stepEl = panel.querySelector('.reh-cdp-step'); if (stepEl) stepEl.remove(); if (descEl) { // Truncate const t = descEl.textContent; if (t.length > 160) descEl.textContent = t.slice(0, 157) + '…'; } panel.classList.add('done'); } else if (state === 'err') { if (panel) panel.remove(); } } $('reh-start-btn')?.addEventListener('click', () => { const backend = $('reh-backend-select')?.value; if (!backend) { toast('Select a backend first', 'error'); return; } rehState.backend = backend; rehState.narratorVoice = $('reh-narrator-voice')?.value || ''; rehState.lineIndex = 0; rehState.clips = []; rehState.playing = false; rehState.synthCache.clear(); rehDecodedBuffers.clear(); rehState.practiceStart = null; rehState.practiceEnd = null; buildScriptPage(); showPhase(3); highlightCurrentLine(); }); // ── Stage display controls (font size + collapsible cast) ─────────────────── const REH_STAGE_FONT_KEY = 'ttsvc_reh_stage_scale'; const REH_CAST_COLLAPSED_KEY = 'ttsvc_reh_cast_collapsed'; function rehStageScale() { const v = parseFloat(localStorage.getItem(REH_STAGE_FONT_KEY) || '1'); return isNaN(v) ? 1 : Math.max(0.7, Math.min(2, v)); } function rehApplyStageFont() { // Set on the page wrapper so the scale inherits to BOTH the single A4 page // (#reh-a4-page) and the paginated `.reh-paper` pages built by page-mode. const scale = String(rehStageScale()); const wrap = document.querySelector('.reh-page-wrap'); if (wrap) wrap.style.setProperty('--reh-stage-scale', scale); const page = $('reh-a4-page'); if (page) page.style.setProperty('--reh-stage-scale', scale); } function rehStageFontStep(delta) { const next = Math.max(0.7, Math.min(2, Math.round((rehStageScale() + delta) * 100) / 100)); localStorage.setItem(REH_STAGE_FONT_KEY, String(next)); rehApplyStageFont(); } function rehApplyCastCollapsed() { // Same collapse pattern as Read Aloud's Casting sidebar (.ab-cv-side.is-collapsed // shrinks to just the avatar dots); the stage area's grid column narrows to match. const row = $('reh-cast-row'); const stage = $('reh-stage-area'); if (!row) return; const collapsed = localStorage.getItem(REH_CAST_COLLAPSED_KEY) === '1'; row.classList.toggle('is-collapsed', collapsed); if (stage) stage.classList.toggle('side-collapsed', collapsed); const btn = $('reh-cast-toggle'); if (btn) { btn.setAttribute('aria-expanded', String(!collapsed)); btn.title = collapsed ? 'Expand character list' : 'Collapse to avatars'; const icon = btn.querySelector('.mdi'); if (icon) icon.className = 'mdi ' + (collapsed ? 'mdi-chevron-right' : 'mdi-chevron-left'); } } function rehToggleCast() { const collapsed = localStorage.getItem(REH_CAST_COLLAPSED_KEY) === '1'; localStorage.setItem(REH_CAST_COLLAPSED_KEY, collapsed ? '0' : '1'); rehApplyCastCollapsed(); } $('reh-font-inc')?.addEventListener('click', () => rehStageFontStep(0.1)); $('reh-font-dec')?.addEventListener('click', () => rehStageFontStep(-0.1)); $('reh-cast-toggle')?.addEventListener('click', rehToggleCast); // ── A4 Script page ───────────────────────────────────────────────────────── // Cast sidebar — same look (.ab-char-item rows) as Read Aloud's Casting // sidebar, but with real portraits (cross-referenced from the Library, same // cache the Cast tab already builds) instead of plain colored-letter dots, // plus search and sort. Split out from buildScriptPage so typing in the // search box doesn't re-render the whole (potentially 1000+ line) script // page on every keystroke — just this strip. function renderCastStrip() { const castStrip = $('reh-cast-strip'); if (!castStrip) return; const scriptTitle = $('reh-script-title')?.value.trim() || ''; _rehEnsureLibCharsCache(scriptTitle); const names = Object.keys(rehState.cast); const lineCountFor = sp => rehState.lines.filter(l => l.speaker === sp && l.type === 'dialog').length; const libRecByNameStage = new Map((_rehLibCharsCache || []).map(r => [String(r.name || '').trim().toLowerCase(), r])); const sortMode = $('reh-cast-side-sort')?.value || localStorage.getItem('reh_cast_side_sort') || 'lines'; const query = ($('reh-cast-side-search')?.value || '').trim().toLowerCase(); let rows = names.map(sp => { const displayName = (sp === REH_NARRATOR_KEY) ? 'Narrator' : sp; const libRec = sp === REH_NARRATOR_KEY ? null : libRecByNameStage.get(String(sp).trim().toLowerCase()); // `sp` is the raw speaker key straight out of script parsing, which // follows screenplay convention (ALL CAPS speaker tags) — rendering it // verbatim meant every name in this sidebar showed shouting-case // regardless of how it's actually spelled anywhere else in the app. // Prefer the Library's own properly-cased name when there's a match; // a plain per-word title-case is still better than shouting-case for // the rarer speaker key with no Library record to match against. const fallbackName = displayName === 'Narrator' ? displayName : displayName.replace(/\w\S*/g, w => w[0].toUpperCase() + w.slice(1).toLowerCase()); return { sp, displayName: libRec?.name || fallbackName, n: lineCountFor(sp), libRec }; }); if (query) rows = rows.filter(r => r.displayName.toLowerCase().includes(query)); rows.sort((a, b) => sortMode === 'name' ? a.displayName.localeCompare(b.displayName) : (b.n - a.n) || a.displayName.localeCompare(b.displayName)); castStrip.innerHTML = rows.map(({ sp, displayName, n, libRec }) => { const c = rehState.cast[sp], isMe = c.voice === 'me'; const avatarHtml = libRec?.image ? `` : `${escHtml((displayName||'?')[0].toUpperCase())}`; return `
${avatarHtml} ${escHtml(displayName)} ${isMe?'':''} ${n}
`; }).join(''); castStrip.querySelectorAll('.ab-char-item').forEach(item => { item.addEventListener('click', () => { const line = document.querySelector(`.reh-block[data-speaker="${CSS.escape(item.dataset.speaker)}"]`); if (line) line.scrollIntoView({ behavior: 'smooth', block: 'center' }); }); }); const lbl = $('reh-cast-toggle-label'); if (lbl) lbl.textContent = `Characters (${names.length})`; const searchInp = $('reh-cast-side-search'); const sortSel = $('reh-cast-side-sort'); if (searchInp && !searchInp.dataset.wired) { searchInp.dataset.wired = '1'; searchInp.addEventListener('input', () => renderCastStrip()); } if (sortSel && !sortSel.dataset.wired) { sortSel.dataset.wired = '1'; sortSel.value = sortMode; sortSel.addEventListener('change', () => { localStorage.setItem('reh_cast_side_sort', sortSel.value); renderCastStrip(); }); } } function buildScriptPage() { const titleEl = $('reh-page-title'); if (titleEl) titleEl.textContent = $('reh-script-title')?.value.trim() || 'Script'; renderCastStrip(); rehApplyStageFont(); rehApplyCastCollapsed(); // Fire-and-forget, guarded against re-running for the same script — see // _lineAudioSyncDots for why this exists (green dots otherwise look reset // after every reload even when the audio is safely cached on disk). if (typeof _lineAudioSyncDots === 'function') _lineAudioSyncDots().catch(() => {}); const linesEl = $('reh-script-lines'); if (!linesEl) return; linesEl.innerHTML = rehState.lines.map((line, i) => { const isCached = rehState.synthCache.has(i); const isStale = rehState.staleLines.has(i); const dotCls = isStale ? 'reh-synth-dot stale' : 'reh-synth-dot'; const dotTitle = isStale ? 'Tone changed — needs re-synthesis' : 'Pre-synthesized'; const synthDot = ``; const reSynthBtn = ``; const editBtn = ``; const note = rehState.lines[i].note || ''; // noteArea includes the edit button so both icons live in the right gutter const noteArea = `
${editBtn}
`; // Bulk-edit: hidden lines drop out of the view entirely, unless we're revealing // them inside bulk mode so they can be selected and restored. if (line.hidden && !(rehState.bulkMode && rehState.showHidden)) return ''; const _bSel = rehState.bulkSel.has(i); const bulkCheck = rehState.bulkMode ? `` : ''; const lineFlags = (line.ignored ? ' reh-line-ignored' : '') + (line.hidden ? ' reh-line-hidden' : '') + (_bSel ? ' reh-selected' : ''); // Same detection audiobookExport() uses to decide chapter/file boundaries // (audiobook.js) — surfaced here too so a chapter is visible in the script // itself, not just audible as a pause in the finished export. Runs before // the normal type switch so a chapter heading (however it was tagged // during parsing — 'act', 'scene', or plain 'action' text recovered via // heading OCR) always gets this treatment instead of its usual rendering. if (typeof audiobookIsChapter === 'function' && audiobookIsChapter(line)) { const label = (typeof stripMarkdown === 'function' ? stripMarkdown(line.text || '') : (line.text || '')).trim(); return `
${bulkCheck} ${label ? escHtml(label) : 'Chapter'} ${editBtn} ${synthDot}
`; } switch (line.type) { case 'act': return `
${bulkCheck}${escHtml(line.text)} ${editBtn} ${synthDot}
`; case 'scene': return `
${bulkCheck} ${escHtml(line.text)} ${editBtn} ${synthDot}
`; case 'action': { // Narrator paragraphs get a play button too now, but deliberately // NOT wrapped in .reh-block's boxed/indented dialogue treatment // (avatar circle, name row, highlighted card) — just a small inline // icon before the text, same weight as the edit button, so plain // narration keeps reading like plain narration. const narrPlayBtn = ``; return `
${bulkCheck}${narrPlayBtn}${renderMarkdownInline(line.text)} ${editBtn} ${synthDot}
`; } case 'pagebreak': { const pbLabel = line.page ? `— Page ${line.page} —` : '— Page break —'; return `
${bulkCheck}${pbLabel}
`; } case 'transition': return `
${bulkCheck}${escHtml(line.text)}
`; case 'direction': { const indent = line.speaker ? 'text-align:center;' : ''; return `
${bulkCheck}${escHtml(line.text)}
`; } case 'dialog': { const c = rehState.cast[line.speaker] || { voice:'', color:'#89b4fa' }; const isMe = c.voice === 'me'; const emoInfo = getEmotionInfo(line.emotion || ''); return `
${bulkCheck}
${escHtml(line.speaker)} ${isMe ? ' Me' : ' TTS' } ${!isMe ? `` : '' } ${synthDot} ${reSynthBtn}
${renderMarkdownInline(line.text)}
${noteArea}
`; } default: return ''; } }).join(''); // Emotion picker buttons linesEl.querySelectorAll('.reh-emo-btn').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); openEmoPicker(parseInt(btn.dataset.index), btn); }); }); // Edit buttons linesEl.querySelectorAll('.reh-edit-btn').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); startInlineEdit(parseInt(btn.dataset.index)); }); }); // Double-click on dialog / action text to edit linesEl.querySelectorAll('.reh-block-dialog').forEach(el => { el.addEventListener('dblclick', e => { e.stopPropagation(); startInlineEdit(parseInt(el.closest('[data-index]').dataset.index)); }); }); linesEl.querySelectorAll('.reh-action-text').forEach(el => { el.addEventListener('dblclick', e => { e.stopPropagation(); startInlineEdit(parseInt(el.closest('[data-index]').dataset.index)); }); }); // Avatar button: play/pause a single character line linesEl.querySelectorAll('.reh-line-play-avatar').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); const idx = parseInt(btn.dataset.index); if (rehState.playing && rehState.lineIndex === idx) { pausePlay(); return; } stopPlay(); hideRecOverlay(); rehState.lineIndex = idx; highlightCurrentLine(); startPlay(); }); }); // Gutter button: play from here / set end linesEl.querySelectorAll('.reh-gutter-btn').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); const idx = parseInt(btn.dataset.index); if (e.shiftKey) { // Set / clear practice end rehState.practiceEnd = rehState.practiceEnd === idx ? null : idx; } else { // Jump to this line and start playing stopPlay(); hideRecOverlay(); rehState.lineIndex = idx; highlightCurrentLine(); startPlay(); } updatePracticeRange(); }); }); // Click block to jump; in Select mode, click toggles selection and Shift+click selects a range. linesEl.querySelectorAll('[data-index]').forEach(el => { el.addEventListener('click', e => { if (e.target.closest('.reh-emo-btn, .reh-edit-btn, .reh-gutter-btn, .reh-note-btn, .reh-resynth-btn, .reh-line-play-avatar, textarea, select, .vp-root')) return; const idx = parseInt(el.dataset.index); if (rehState.bulkMode) { _toggleBulkSel(idx, e.shiftKey); return; } stopPlay(); hideRecOverlay(); rehState.lineIndex = idx; highlightCurrentLine(); }); }); // Re-synth buttons (per line) linesEl.querySelectorAll('.reh-resynth-btn').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); synthOneLine(parseInt(btn.id.replace('reh-rsb-', ''))); }); }); // Note area: toggle textarea visibility on button click, save on change linesEl.querySelectorAll('.reh-note-area').forEach(area => { const idx = parseInt(area.dataset.index); const btn = area.querySelector('.reh-note-btn'); const ta = area.querySelector('.reh-note-ta'); if (!btn || !ta) return; // Show if note has content if (ta.value.trim()) ta.classList.add('open'); btn.addEventListener('click', e => { e.stopPropagation(); ta.classList.toggle('open'); if (ta.classList.contains('open')) ta.focus(); }); ta.addEventListener('input', () => { rehState.lines[idx].note = ta.value; btn.classList.toggle('has-note', !!ta.value.trim()); }); ta.addEventListener('click', e => e.stopPropagation()); }); // Bulk-edit: leading checkboxes toggle line selection. The page wrapper also // gets the mode class because A4/PDF modes move blocks out of #reh-script-lines. linesEl.classList.toggle('reh-bulk-mode', rehState.bulkMode); document.querySelector('.reh-page-wrap')?.classList.toggle('reh-bulk-mode', rehState.bulkMode); if (rehState.bulkMode) { linesEl.querySelectorAll('.reh-bulk-check').forEach(cb => { cb.addEventListener('click', e => { e.stopPropagation(); _toggleBulkSel(parseInt(cb.dataset.bulk), e.shiftKey); }); }); } // Apply current pagination mode applyPageMode(); // Warn if backend doesn't support tone/style changes _checkToneStyleSupport(); } // ── Bulk-edit (line selection: ignore / hide / delete) ────────────────────── function _toggleBulkSel(i, range = false) { if (!Number.isFinite(i)) return; if (range && rehState.bulkAnchor !== null && Number.isFinite(rehState.bulkAnchor)) { const a = Math.min(rehState.bulkAnchor, i); const b = Math.max(rehState.bulkAnchor, i); const indices = []; for (let n = a; n <= b; n++) if (rehState.lines[n] && !rehState.lines[n].hidden) indices.push(n); const deselect = indices.length && indices.every(n => rehState.bulkSel.has(n)); indices.forEach(n => { if (deselect) rehState.bulkSel.delete(n); else rehState.bulkSel.add(n); _refreshBulkLine(n); }); rehState.bulkAnchor = i; } else { if (rehState.bulkSel.has(i)) rehState.bulkSel.delete(i); else rehState.bulkSel.add(i); rehState.bulkAnchor = i; _refreshBulkLine(i); } _updateBulkCount(); } function _refreshBulkLine(i) { const sel = rehState.bulkSel.has(i); document.querySelectorAll(`.reh-bulk-check[data-bulk="${i}"]`).forEach(cb => { cb.classList.toggle('checked', sel); const icon = cb.querySelector('.mdi'); if (icon) icon.className = 'mdi ' + (sel ? 'mdi-checkbox-marked' : 'mdi-checkbox-blank-outline'); }); document.querySelectorAll(`[data-index="${i}"]`).forEach(el => el.classList.toggle('reh-selected', sel)); } function _updateBulkCount() { const el = $('reh-bulk-count'); if (el) el.textContent = `${rehState.bulkSel.size} selected`; } function setBulkMode(on) { rehState.bulkMode = on; if (!on) { rehState.bulkSel.clear(); rehState.bulkAnchor = null; } const bar = $('reh-bulk-bar'); if (bar) bar.hidden = !on; const btn = $('reh-bulk-toggle'); if (btn) btn.classList.toggle('active', on); _updateBulkCount(); if (rehState.lines.length) buildScriptPage(); } // Apply a mutation to all selected lines, then re-render. function _bulkApply(fn, { keepSelection = false } = {}) { if (!rehState.bulkSel.size) { toast('No lines selected', 'error'); return; } [...rehState.bulkSel].forEach(i => { const l = rehState.lines[i]; if (l) fn(l, i); }); if (!keepSelection) rehState.bulkSel.clear(); _updateBulkCount(); buildScriptPage(); } function _bulkDelete() { if (!rehState.bulkSel.size) { toast('No lines selected', 'error'); return; } const victims = [...rehState.bulkSel].sort((a, b) => b - a); // high → low so indices stay valid victims.forEach(i => { rehState.lines.splice(i, 1); _reindexLineState(i); }); rehState.bulkSel.clear(); // Keep the cursor in range if (rehState.lineIndex >= rehState.lines.length) rehState.lineIndex = Math.max(0, rehState.lines.length - 1); _updateBulkCount(); buildScriptPage(); highlightCurrentLine(); toast(`Deleted ${victims.length} line${victims.length !== 1 ? 's' : ''}`, 'success'); } // After deleting line `d`, shift every index-keyed bit of state above it down by one. function _reindexLineState(d) { const shift = (collection, isMap) => { const out = isMap ? new Map() : new Set(); for (const entry of collection) { const k = isMap ? entry[0] : entry; if (k === d) continue; // dropped line const nk = k > d ? k - 1 : k; if (isMap) out.set(nk, entry[1]); else out.add(nk); } return out; }; rehState.synthCache = shift(rehState.synthCache, true); rehState.staleLines = shift(rehState.staleLines, false); const sel = shift(rehState.bulkSel, false); rehState.bulkSel.clear(); sel.forEach(v => rehState.bulkSel.add(v)); if (rehState.practiceStart != null && rehState.practiceStart > d) rehState.practiceStart--; if (rehState.practiceEnd != null && rehState.practiceEnd > d) rehState.practiceEnd--; if (rehState.lineIndex > d) rehState.lineIndex--; } // ── Page mode (endless scroll | auto-pages | pdf-pages) ───────────────────── const PAGE_MODES = ['auto', 'scroll', 'pdf']; // 'pdf' (break at the document's own real page marks) is the default now // that parseScript's page-break detection actually works (see rehearser-parse.js) — // 'auto' (break purely by content height, ignoring the source document's own // pages entirely) was never really what most people mean by "pages". let _pageMode = localStorage.getItem('reh-page-mode') || 'pdf'; function applyPageMode() { const wrap = document.querySelector('.reh-page-wrap'); const a4 = $('reh-a4-page'); if (!wrap) return; if (_pageMode === 'scroll') { // Endless scroll — put all blocks back into the a4 div, no paper pages wrap.querySelectorAll('.reh-paper').forEach(p => { [...p.children].forEach(c => { if (!c.classList.contains('reh-paper-num')) a4?.appendChild(c); }); p.remove(); }); wrap.classList.remove('paginated'); if (a4) a4.style.display = ''; // Hide pagebreak visual dividers in scroll mode document.querySelectorAll('.reh-block-pagebreak').forEach(el => el.style.display = 'none'); } else if (_pageMode === 'pdf') { // PDF pages — paginate at pagebreak markers paginateScript({ respectBreaks: true }); } else { // Auto — paginate by content height paginateScript({ respectBreaks: false }); } _syncPageModeBtn(); } function _syncPageModeBtn() { const btn = $('reh-page-mode-btn'); if (!btn) return; const icons = { auto: 'mdi-file-document-outline', scroll: 'mdi-format-align-justify', pdf: 'mdi-book-open-page-variant' }; const labels = { auto: 'A4 pages', scroll: 'Scroll', pdf: 'PDF pages' }; // Labeling this with the CURRENT mode made it look like a passive status // indicator rather than a button — confirmed live as genuine confusion: // stuck in "Scroll" (one continuous page, no page breaks) with no visible // cue that clicking the very button saying "Scroll" is what would change // it. Show what clicking it switches TO instead, the normal convention // for a cycle/toggle button. const nextMode = PAGE_MODES[(PAGE_MODES.indexOf(_pageMode) + 1) % PAGE_MODES.length]; btn.innerHTML = ` ${labels[nextMode] || labels.auto}`; btn.title = `Currently: ${labels[_pageMode]} — click to switch to ${labels[nextMode]}`; } function cyclePageMode() { const idx = PAGE_MODES.indexOf(_pageMode); _pageMode = PAGE_MODES[(idx + 1) % PAGE_MODES.length]; localStorage.setItem('reh-page-mode', _pageMode); applyPageMode(); // Rebuild script page to apply cleanly if (rehState.lines.length) buildScriptPage(); } // ── Pagination: split the continuous script into A4 paper pages ───────────── function paginateScript({ respectBreaks = false } = {}) { const wrap = document.querySelector('.reh-page-wrap'); const linesEl = $('reh-script-lines'); const titleEl = $('reh-page-title'); if (!wrap || !linesEl) return; // Collect the already-wired block elements (moving them keeps listeners) const blocks = [...linesEl.children]; if (!blocks.length) return; wrap.classList.add('paginated'); const a4 = $('reh-a4-page'); if (a4) a4.style.display = 'none'; wrap.querySelectorAll('.reh-paper').forEach(p => p.remove()); // Printable content height inside a page (A4 minus vertical padding) const PAGE_CONTENT = 1027; // 1123 − 56 − 40 const TITLE_SPACE = 64; // page-1 title block allowance let pageNum = 0, page = null, used = 0; const newPage = () => { pageNum++; page = document.createElement('div'); page.className = 'reh-paper'; const num = document.createElement('div'); num.className = 'reh-paper-num'; num.textContent = pageNum + '.'; page.appendChild(num); used = 0; if (pageNum === 1 && titleEl) { const t = titleEl.cloneNode(true); t.style.display = ''; page.appendChild(t); used += TITLE_SPACE; } wrap.appendChild(page); }; newPage(); for (const block of blocks) { // PDF page-break marker: always start a new page here if (respectBreaks && block.classList.contains('reh-block-pagebreak')) { newPage(); continue; // don't move the marker div into the page } // Measure block height (must be in DOM to measure → append then check) page.appendChild(block); const cs = getComputedStyle(block); const h = block.offsetHeight + (parseFloat(cs.marginTop) || 0) + (parseFloat(cs.marginBottom) || 0); // If adding this block overflows the page (and page already has content), move to a new page if (!respectBreaks && used + h > PAGE_CONTENT && used > (pageNum === 1 ? TITLE_SPACE : 0)) { newPage(); page.appendChild(block); } used += h; } } function isPracticeRange(idx) { const s = rehState.practiceStart, e = rehState.practiceEnd; if (s === null) return false; return idx >= s && (e === null || idx <= e); } // ── Inline editing ────────────────────────────────────────────────────────── function startInlineEdit(idx) { const line = rehState.lines[idx]; if (!line) return; const dialogEl = document.getElementById('reh-diag-' + idx); const actionEl = document.querySelector(`[data-index="${idx}"] .reh-action-text`); const sceneEl = document.querySelector(`.reh-scene[data-index="${idx}"] span[style*="flex:1"]`); const targetEl = dialogEl || actionEl || sceneEl; if (!targetEl || targetEl.tagName === 'TEXTAREA') return; const orig = line.text; const ta = document.createElement('textarea'); ta.value = orig; ta.style.cssText = 'width:100%;min-height:54px;font-size:14px;line-height:1.6;padding:5px 8px;border:1px solid var(--accent);border-top:none;border-radius:0 0 3px 3px;resize:vertical;background:#fffff8;font-family:inherit;display:block;box-sizing:border-box;outline:none;'; // Formatting toolbar const toolbar = document.createElement('div'); toolbar.className = 'reh-fmt-toolbar'; toolbar.innerHTML = ` Ctrl+Enter save · Esc cancel `; toolbar.querySelectorAll('.reh-fmt-btn').forEach(btn => { btn.addEventListener('mousedown', e => { e.preventDefault(); const wrap = btn.dataset.b; const s = ta.selectionStart, end = ta.selectionEnd; const sel = ta.value.slice(s, end) || 'text'; ta.value = ta.value.slice(0, s) + wrap + sel + wrap + ta.value.slice(end); ta.setSelectionRange(s + wrap.length, s + wrap.length + sel.length); ta.focus(); }); }); targetEl.style.display = 'none'; targetEl.before(toolbar); toolbar.after(ta); ta.focus(); ta.select(); const save = () => { const newText = ta.value.trim() || orig; line.text = newText; targetEl.innerHTML = renderMarkdownInline(newText); targetEl.style.display = ''; toolbar.remove(); ta.remove(); rehState.synthCache.delete(idx); const dot = document.getElementById('reh-syd-' + idx); if (dot) dot.style.display = 'none'; }; ta.addEventListener('keydown', e => { if (e.key === 'Escape') { ta.value = orig; save(); } if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); save(); } }); ta.addEventListener('blur', save); } // ── Script editor modal ───────────────────────────────────────────────────── function openScriptEditorModal() { const currentTitle = $('reh-page-title')?.textContent || $('reh-script-title')?.value || 'Script'; const currentScript = linesToScriptText() || $('reh-script-text')?.value || ''; const modal = document.createElement('div'); modal.className = 'reh-modal-overlay'; modal.innerHTML = `

Edit Script

`; document.body.appendChild(modal); const close = () => modal.remove(); modal.querySelector('#_reh-mc').onclick = close; modal.querySelector('#_reh-mcan').onclick = close; modal.addEventListener('click', e => { if (e.target === modal) close(); }); modal.querySelector('#_reh-mapp').onclick = () => { const newTitle = modal.querySelector('#_reh-mt').value.trim() || 'Script'; const newScript = modal.querySelector('#_reh-ms').value; const newLines = parseScript(newScript); if (!newLines.filter(l => l.type === 'dialog').length) { toast('No dialog found in the edited script', 'error'); return; } // Merge emotions for unchanged lines newLines.forEach(nl => { if (nl.type !== 'dialog') return; const old = rehState.lines.find(ol => ol.type==='dialog' && ol.speaker===nl.speaker && ol.text===nl.text); if (old) nl.emotion = old.emotion; }); // Keep cast for matching characters const detected = detectCharacters(newLines); Object.entries(detected).forEach(([sp]) => { if (rehState.cast[sp]) detected[sp] = { ...detected[sp], ...rehState.cast[sp] }; }); // Update state rehState.lines = newLines; rehState.cast = detected; rehState.lineIndex = Math.min(rehState.lineIndex, newLines.length - 1); rehState.synthCache.clear(); rehDecodedBuffers.clear(); rehState.practiceStart = null; rehState.practiceEnd = null; if ($('reh-script-title')) $('reh-script-title').value = newTitle; if ($('reh-page-title')) $('reh-page-title').textContent = newTitle; buildScriptPage(); highlightCurrentLine(); close(); toast('Script updated', 'success'); }; } // ── Practice range ────────────────────────────────────────────────────────── function updatePracticeRange() { const start = rehState.practiceStart; const end = rehState.practiceEnd; document.querySelectorAll('.reh-block').forEach(el => { const idx = parseInt(el.dataset.index); el.classList.toggle('reh-in-range', start !== null && idx >= start && (end === null || idx <= end)); el.classList.toggle('reh-range-start', start !== null && idx === start); el.classList.toggle('reh-range-end', end !== null && idx === end); const gb = el.querySelector('.reh-gutter-btn'); if (gb) gb.textContent = idx === start ? '▶' : idx === end ? '■' : ''; }); const info = $('reh-practice-info'); if (info) { const show = end !== null; info.classList.toggle('visible', show); info.hidden = !show; if (show) { const f = $('reh-practice-from'), t = $('reh-practice-to'); if (f) f.textContent = (start !== null ? start : 0) + 1; if (t) t.textContent = end + 1; } } } function clearPracticeRange() { rehState.practiceStart = null; rehState.practiceEnd = null; updatePracticeRange(); } function setPracticeRangeFromSelection() { const picked = [...rehState.bulkSel].sort((a, b) => a - b); if (!picked.length) { toast('Select the lines you want to rehearse first', 'error'); return; } rehState.practiceStart = picked[0]; rehState.practiceEnd = picked[picked.length - 1]; rehState.lineIndex = rehState.practiceStart; setBulkMode(false); updatePracticeRange(); highlightCurrentLine(); toast(`Practice range set: lines ${rehState.practiceStart + 1}–${rehState.practiceEnd + 1}`, 'success'); } $('reh-practice-clear')?.addEventListener('click', clearPracticeRange); $('reh-bulk-range')?.addEventListener('click', setPracticeRangeFromSelection); // ── Emotion picker popover ────────────────────────────────────────────────── let rehEmoPicker = null; function openEmoPicker(idx, anchorBtn) { closeEmoPicker(); const line = rehState.lines[idx]; const allEmos = [...REH_EMOTIONS, ...rehCustomEmotions]; const rect = anchorBtn.getBoundingClientRect(); const left = Math.min(rect.left, window.innerWidth - 265); const topBelow = rect.bottom + 4; const topAbove = rect.top - 4; const spaceBelow = window.innerHeight - topBelow; const top = spaceBelow >= 240 ? topBelow : (topAbove - 260); const pop = document.createElement('div'); pop.className = 'reh-emo-popover'; pop.style.cssText = `position:fixed;z-index:1000;top:${top}px;left:${left}px;width:258px;`; pop.innerHTML = `
`; document.body.appendChild(pop); rehEmoPicker = pop; const searchEl = pop.querySelector('.reh-emo-search'); const listEl = pop.querySelector('.reh-emo-list'); const applyBtn = pop.querySelector('#_emo-custom-apply'); function renderList(q = '') { const lq = q.toLowerCase(); const filtered = allEmos.filter(e => !q || e.label.toLowerCase().includes(lq) || e.value.toLowerCase().includes(lq)); listEl.innerHTML = filtered.map(e => `
${e.emoji} ${escHtml(e.label)} ${line.emotion===e.value?'':''}
` ).join(''); listEl.querySelectorAll('.reh-emo-item').forEach(item => { item.addEventListener('mousedown', e => { e.preventDefault(); selectEmotion(idx, item.dataset.value, anchorBtn); closeEmoPicker(); }); }); } searchEl.addEventListener('input', () => renderList(searchEl.value)); searchEl.addEventListener('keydown', e => { if (e.key === 'Escape') closeEmoPicker(); if (e.key === 'Enter') { const val = searchEl.value.trim(); if (val) { selectEmotion(idx, val, anchorBtn); closeEmoPicker(); } } }); applyBtn.addEventListener('mousedown', e => { e.preventDefault(); const val = searchEl.value.trim(); if (!val) return; const exists = [...REH_EMOTIONS, ...rehCustomEmotions].find(e => e.value === val); if (!exists) { rehCustomEmotions.push({ emoji: '✨', label: val, value: val, custom: true }); try { localStorage.setItem('reh-custom-emotions', JSON.stringify(rehCustomEmotions)); } catch(_) {} } selectEmotion(idx, val, anchorBtn); closeEmoPicker(); }); renderList(); searchEl.focus(); setTimeout(() => document.addEventListener('mousedown', _closePickerOnOutside), 50); } function _closePickerOnOutside(e) { if (rehEmoPicker && !rehEmoPicker.contains(e.target)) closeEmoPicker(); } function closeEmoPicker() { if (rehEmoPicker) { rehEmoPicker.remove(); rehEmoPicker = null; } document.removeEventListener('mousedown', _closePickerOnOutside); } function _markSynthDot(idx, state) { const dot = document.getElementById('reh-syd-' + idx); if (!dot) return; dot.className = 'reh-synth-dot' + (state === 'stale' ? ' stale' : '') + (state === 'synthesizing' ? ' synthesizing' : ''); dot.style.display = state ? '' : 'none'; dot.title = state === 'stale' ? 'Tone changed — needs re-synthesis' : state === 'synthesizing' ? 'Synthesizing…' : 'Pre-synthesized'; // Also highlight the block row itself while synthesizing const block = dot.closest('[data-index]'); if (block) block.classList.toggle('reh-line-synthesizing', state === 'synthesizing'); } function _showReSynthBtn(idx, show) { const btn = document.getElementById('reh-rsb-' + idx); if (btn) btn.hidden = !show; } // A voice reassigned in the Library/Studio Voices tab (clPut calls this // after every character save) used to never reach an already-open // rehearsal of the same book: rehState.cast[sp].voice is loaded once from // the saved rehearsal record, and an explicit prior value always wins over // a fresher one from the shared roster (see the `saved?.voice ?? def.voice` // load above) — so the Stage/export kept reading, and kept the cached // audio for, the OLD voice indefinitely. Confirmed live as the cause of an // audiobook export that finished suspiciously fast right after changing a // voice: the reassigned character's lines were still "cached" under the // old voice and never got marked stale. Only acts when a rehearsal for the // SAME book is actually open right now and the name matches a real speaker. function _rehSyncCastVoiceFromLibrary(rec) { if (!window.rehState || !rehState.lines || !rehState.lines.length) return; if (!rec || !rec.name || !rec.voice) return; const openBook = (typeof _lineAudioBookName === 'function') ? _lineAudioBookName() : ''; if (!openBook || String(rec.book || '').trim().toLowerCase() !== openBook.trim().toLowerCase()) return; const target = String(rec.name).toUpperCase().trim(); const sp = Object.keys(rehState.cast).find(k => String(k).toUpperCase().trim() === target); if (!sp) return; const c = rehState.cast[sp]; if (!c || c.voice === rec.voice) return; c.voice = rec.voice; c.voiceData = getVoiceData(rec.voice); rehState.lines.forEach((line, idx) => { if (line.type === 'dialog' && line.speaker === sp && rehState.synthCache.has(idx)) { rehState.synthCache.delete(idx); rehState.staleLines.add(idx); if (typeof _markSynthDot === 'function') _markSynthDot(idx, 'stale'); } }); if (typeof _updateStaleBatchBtn === 'function') _updateStaleBatchBtn(); if (typeof renderCastList === 'function') renderCastList(); } window._rehSyncCastVoiceFromLibrary = _rehSyncCastVoiceFromLibrary; async function synthOneLine(idx) { const line = rehState.lines[idx]; if (!line || line.type !== 'dialog') return; const c = rehState.cast[line.speaker]; if (!c || !c.voice || c.voice === 'me') return; const instruct = _buildInstruct(c.instruct, line.emotion, c.voice); _showReSynthBtn(idx, false); _markSynthDot(idx, 'synthesizing'); try { const blob = await fetchTtsPreviewBlob(c.voice, _rehInlineTone(stripMarkdown(line.text), line.emotion), 'wav', instruct, _ttsBackendForVoice(c.voice, rehState.backend)); rehState.synthCache.set(idx, blob); rehState.staleLines.delete(idx); preDecodeBlob(idx, blob); _markSynthDot(idx, 'ok'); toast('Re-synthesized line ' + (idx + 1), 'success'); } catch(e) { _markSynthDot(idx, null); _showReSynthBtn(idx, true); toast('Synthesis failed: ' + e.message, 'error'); } } function selectEmotion(idx, value, anchorBtn) { rehState.lines[idx].emotion = value; if (rehState.synthCache.has(idx)) { rehState.synthCache.delete(idx); rehState.staleLines.add(idx); _markSynthDot(idx, 'stale'); _updateStaleBatchBtn(); } _showReSynthBtn(idx, true); // Update button const info = getEmotionInfo(value); anchorBtn.className = 'reh-emo-btn' + (value ? ' has-emotion' : ''); anchorBtn.innerHTML = `${info.emoji ? info.emoji + ' ' : ''}${escHtml(info.label)} `; // Show warning if the active backend doesn't reliably support style if (value) _checkToneStyleSupport(); } // One row of the tone/identity comparison table. function _rehToneCmpRow(backend, isCurrent) { const check = (ok) => ok ? '' : ''; const action = isCurrent ? 'current' : ``; return ` ${escHtml(backend.label)} ${check(backend.style_aware)} ${check(backend.uses_wav)} ${action} `; } function _checkToneStyleSupport() { const warn = $('reh-tone-warn'); if (!warn) return; const txtEl = $('reh-tone-warn-txt'); const b = (typeof backendById === 'function') ? backendById(rehState.backend) : null; if (!b) { warn.hidden = true; return; } const all = (typeof availableTtsBackends === 'function') ? availableTtsBackends() : []; const hasTone = rehState.lines.some(l => l.type === 'dialog' && l.emotion); // Two opposite engine trade-offs, surfaced so the user can choose knowingly: // • clone backends → consistent character identity, but weak tone control // • design backends → strong tone, but a fresh persona each call (voices drift) // Originally a single run-on sentence with a button awkwardly wedged into // the middle of it (confirmed live: read badly, wrapped worse). A table // says the same two facts as two columns instead of two clauses. if (_rehBackendIsFish()) { // Fish-Speech / OpenAudio S2 honours inline [tag] tones (15 000+ tags) injected per line if (txtEl) txtEl.innerHTML = `${escHtml(b.label)} keeps each character’s voice consistent and applies tone. Per-line tones are sent as inline [tags] (e.g. [whisper], [excited], [laughing]). You can also type a custom tone like [professional broadcast tone] — S2 supports free-form descriptions. Fish-Speech S2 ↗`; warn.hidden = false; } else if (!b.style_aware && hasTone) { // Prefer a backend that fixes BOTH weaknesses at once (tone-aware AND // clones from a reference WAV, e.g. Fish-Speech) over one that only // fixes this one (tone-aware but re-rolls the voice each line, e.g. // Voice Design) — confirmed live: with both available, `.find()` was // silently suggesting whichever happened to come first in the backend // list, which was never Fish-Speech despite it being the strictly // better option whenever it's actually running. const styleAware = all.find(x => x.style_aware && x.uses_wav) || all.find(x => x.style_aware); if (txtEl) { txtEl.innerHTML = styleAware ? `${_rehToneCmpRow(b, true)}${_rehToneCmpRow(styleAware, false)}
Tone controlVoice stays identical
` : `${escHtml(b.label)} keeps each character’s voice consistent but has weak tone control — tone picks may have little effect.`; } warn.hidden = false; } else if (b.style_aware && !b.uses_wav) { // Same preference as above, mirrored: a wav-cloning backend that's ALSO // tone-aware (Fish-Speech) beats one that drops tone control entirely // (Voice Clone) as the suggested alternative. const wavBackend = all.find(x => x.uses_wav && x.style_aware) || all.find(x => x.uses_wav); const qwenHint = /qwen|voice design|custom/i.test((b.id || '') + ' ' + (b.label || '')) ? '

Qwen3TTS tone is sent as the per-line style/instruct text, so this is the right path for directed delivery.

' : ''; if (txtEl) { txtEl.innerHTML = wavBackend ? `${_rehToneCmpRow(b, true)}${_rehToneCmpRow(wavBackend, false)}
Tone controlVoice stays identical
${qwenHint}` : `${escHtml(b.label)} gives strong tone but re-generates a fresh voice each line, so a character won’t sound the same throughout.${qwenHint}`; } warn.hidden = false; } else { warn.hidden = true; } } $('reh-tone-warn-close')?.addEventListener('click', () => { const w = $('reh-tone-warn'); if (w) w.hidden = true; }); // The suggestion buttons above get rebuilt (via innerHTML) every time // _checkToneStyleSupport() re-runs, so a delegated listener on the // container — bound once — is the only reliable way to catch clicks on them. $('reh-tone-warn')?.addEventListener('click', (e) => { const btn = e.target.closest('.reh-tone-switch-btn'); if (!btn) return; const id = btn.dataset.backendId; const sel = $('reh-backend-select'); if (sel && [...sel.options].some(o => o.value === id)) sel.value = id; rehState.backend = id; _checkToneStyleSupport(); const b = (typeof backendById === 'function') ? backendById(id) : null; toast('Switched to ' + (b ? b.label : id), 'success'); }); // ── Transport controls ────────────────────────────────────────────────────── $('reh-tb-play')?.addEventListener('click', () => { if (rehState.playing) pausePlay(); else startPlay(); }); $('reh-tb-stop')?.addEventListener('click', () => { stopPlay(); rehState.lineIndex = rehState.practiceStart ?? 0; highlightCurrentLine(); hideRecOverlay(); }); $('reh-tb-prev')?.addEventListener('click', () => { stopPlay(); rehState.lineIndex = Math.max(0, rehState.lineIndex - 1); highlightCurrentLine(); hideRecOverlay(); }); $('reh-tb-next')?.addEventListener('click', () => { stopPlay(); rehState.lineIndex = Math.min(rehState.lines.length - 1, rehState.lineIndex + 1); highlightCurrentLine(); hideRecOverlay(); }); $('reh-tb-repeat')?.addEventListener('click', () => { rehState.repeat = !rehState.repeat; $('reh-tb-repeat')?.classList.toggle('reh-btn-active', rehState.repeat); }); $('reh-skip-desc-toggle')?.addEventListener('change', function () { rehState.skipDescriptions = this.checked; }); $('reh-edit-script-btn')?.addEventListener('click', openScriptEditorModal); $('reh-fountain-btn')?.addEventListener('click', exportFountain); $('reh-fountain-export-p4')?.addEventListener('click', exportFountain); $('reh-fdx-export-btn')?.addEventListener('click', exportFDX); $('reh-osf-export-btn')?.addEventListener('click', exportOSF); $('reh-exit-btn')?.addEventListener('click', () => { stopPlay(); stopRehMic(); if (rehState.clips.length) { renderSummary(); showPhase(4); } else showPhase(2); }); // ── Page title inline editing ─────────────────────────────────────────────── $('reh-page-title')?.addEventListener('dblclick', function () { this.contentEditable = 'true'; this.style.outline = '2px solid var(--accent)'; this.style.borderRadius = '3px'; this.focus(); const range = document.createRange(); range.selectNodeContents(this); window.getSelection().removeAllRanges(); window.getSelection().addRange(range); }); $('reh-page-title')?.addEventListener('blur', function () { if (this.contentEditable === 'true') { this.contentEditable = 'false'; this.style.outline = ''; const v = this.textContent.trim() || 'Script'; this.textContent = v; if ($('reh-script-title')) $('reh-script-title').value = v; } }); $('reh-page-title')?.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); this.blur(); } if (e.key === 'Escape') { this.textContent = $('reh-script-title')?.value || 'Script'; this.blur(); } }); // ── WebAudio pre-decode (eliminates 2-3s silence between lines) ───────────── let _rehPlayCtx = null; const rehDecodedBuffers = new Map(); // lineIndex → AudioBuffer (pre-decoded PCM) let rehCurrentSource = null; // active AudioBufferSourceNode let rehWordHighlightRaf = null; function rehPlayCtx() { if (!_rehPlayCtx) _rehPlayCtx = new (window.AudioContext || window.webkitAudioContext)(); if (_rehPlayCtx.state === 'suspended') _rehPlayCtx.resume().catch(() => {}); return _rehPlayCtx; } // Decoded PCM (Float32) is the heaviest cache and is re-derivable from the cached // blob, so keep only a sliding window around the playhead. Without this, a long // script holds every line's raw PCM in memory at once and crashes mobile Safari. const REH_DECODE_WINDOW = 8; function _rehEvictDecoded(keepIdx) { if (rehDecodedBuffers.size <= REH_DECODE_WINDOW * 2 + 4) return; const lo = keepIdx - REH_DECODE_WINDOW, hi = keepIdx + REH_DECODE_WINDOW; for (const k of rehDecodedBuffers.keys()) { if (k < lo || k > hi) rehDecodedBuffers.delete(k); } } async function preDecodeBlob(lineIdx, blob) { if (rehDecodedBuffers.has(lineIdx)) return; try { const ab = await blob.arrayBuffer(); const buf = await rehPlayCtx().decodeAudioData(ab); rehDecodedBuffers.set(lineIdx, buf); _rehEvictDecoded(lineIdx); } catch(_) {} } function computeWordTimings(text, durationSec) { const words = stripMarkdown(text).split(/\s+/).filter(Boolean); if (words.length < 2) return []; // Distribute proportionally by character length (longer words = more time) const totalChars = words.reduce((s, w) => s + w.length, 0) || 1; let t = 0; return words.map(w => { const start = t; t += (w.length / totalChars) * durationSec; return { word: w, start, end: t }; }); } function stopAudioSource() { if (rehCurrentSource) { try { rehCurrentSource.stop(0); } catch(_) {} rehCurrentSource = null; } if (rehWordHighlightRaf) { cancelAnimationFrame(rehWordHighlightRaf); rehWordHighlightRaf = null; } } // Instant playback from pre-decoded buffer + word-level highlight async function playPreDecoded(lineIdx, blob, text) { if (!rehDecodedBuffers.has(lineIdx)) await preDecodeBlob(lineIdx, blob); _rehEvictDecoded(lineIdx); // keep the window centred on the playhead const buf = rehDecodedBuffers.get(lineIdx); if (!buf) { await playAudioBlobFallback(blob); return; } const timings = computeWordTimings(text, buf.duration); const dialogEl = document.getElementById('reh-diag-' + lineIdx); if (dialogEl && timings.length >= 2) { dialogEl.innerHTML = timings.map((t, i) => `${escHtml(t.word)}` ).join(' '); } return new Promise(resolve => { stopAudioSource(); const ctx = rehPlayCtx(); const src = ctx.createBufferSource(); src.buffer = buf; src.connect(ctx.destination); rehCurrentSource = src; const t0 = ctx.currentTime; src.onended = () => { rehCurrentSource = null; if (rehWordHighlightRaf) { cancelAnimationFrame(rehWordHighlightRaf); rehWordHighlightRaf = null; } if (dialogEl && timings.length >= 2) dialogEl.innerHTML = renderMarkdownInline(text); resolve(); }; src.start(0); // Pre-decode the line AFTER this one while this is playing const nextI = findNextCachedLine(lineIdx + 1); if (nextI >= 0) preDecodeBlob(nextI, rehState.synthCache.get(nextI)); if (dialogEl && timings.length >= 2) { const tick = () => { if (rehCurrentSource !== src) return; const elapsed = ctx.currentTime - t0; let active = 0; for (let i = timings.length - 1; i >= 0; i--) { if (elapsed >= timings[i].start) { active = i; break; } } dialogEl.querySelectorAll('.reh-word').forEach((span, i) => { span.classList.toggle('reh-word-active', i === active); }); rehWordHighlightRaf = requestAnimationFrame(tick); }; rehWordHighlightRaf = requestAnimationFrame(tick); } }); } function findNextCachedLine(fromIdx) { for (let i = fromIdx; i < rehState.lines.length; i++) { if (rehState.lines[i].type === 'dialog' && rehState.synthCache.has(i)) return i; } return -1; } // Fallback when AudioContext decode fails async function playAudioBlobFallback(blob) { const url = URL.createObjectURL(blob); const audio = $('reh-tts-audio'); if (!audio) return; audio.src = url; audio.style.display = ''; await new Promise(r => { audio.addEventListener('canplay', r, { once: true }); setTimeout(r, 3000); }); await audio.play().catch(() => {}); await waitForAudioEnd(audio); } // ── Auto-play sequence ────────────────────────────────────────────────────── function startPlay() { rehPlayCtx(); // warm up AudioContext on user gesture _ensureNarrator(); // make sure rehState.narratorVoice reflects the narrator cast row rehState.playing = true; updatePlayBtn(); playNextLine(); } // Resets the narrator play button's icon back to "play" — separate from // highlightCurrentLine() (which also moves the active-line highlight and // scrolls) since pausing/stopping shouldn't jump the page around, just // stop claiming a line is still playing. function _rehResetActionPlayIcons() { document.querySelectorAll('.reh-action-play-btn').forEach(btn => { const icon = btn.querySelector('.mdi'); if (icon) icon.className = 'mdi mdi-play'; btn.title = 'Play or pause this line'; }); } function pausePlay() { rehState.playing = false; updatePlayBtn(); stopAudioSource(); const audio = $('reh-tts-audio'); if (audio && !audio.paused) audio.pause(); hideStatusBar(); _rehResetActionPlayIcons(); } function stopPlay() { rehState.playing = false; updatePlayBtn(); stopAudioSource(); const audio = $('reh-tts-audio'); if (audio) { audio.pause(); audio.src = ''; } hideStatusBar(); _rehResetActionPlayIcons(); } function hideStatusBar() { const bar = $('reh-tts-status-bar'); if (bar) bar.hidden = true; } async function playNextLine() { if (!rehState.playing) return; // Check practice end boundary if (rehState.practiceEnd !== null && rehState.lineIndex > rehState.practiceEnd) { rehState.playing = false; updatePlayBtn(); if (rehState.repeat) { rehState.lineIndex = rehState.practiceStart ?? 0; startPlay(); } else { rehState.lineIndex = rehState.practiceStart ?? 0; highlightCurrentLine(); } return; } // Always skip pagebreak markers if (rehState.lines[rehState.lineIndex]?.type === 'pagebreak') { rehState.lineIndex++; return playNextLine(); } // Skip lines the user marked ignore / hide during bulk edit const _cur = rehState.lines[rehState.lineIndex]; if (_cur && (_cur.ignored || _cur.hidden)) { rehState.lineIndex++; return playNextLine(); } // Skip non-dialog lines whenever skip mode is on, full stop — regardless of // whether a narrator voice happens to be assigned. This used to also require // !rehState.narratorVoice, on the theory that skipping was only meaningful // when there was nothing to skip TO — but the individual-line branch below // never re-checked skipDescriptions at all, so once ANY narrator voice was // configured (the common case once a book is actually set up), narration // played regardless of this toggle. Confirmed live: Studio's "Rehearse ⇄ // Audiobook" toggle (which just flips this same flag) had zero observable // effect once a narrator voice existed — exactly the reported "doesn't // read the narrator" / toggle-does-nothing behavior, just inverted from // what it looked like (narration was stuck ON, not stuck OFF). if (rehState.skipDescriptions) { while ( rehState.lineIndex < rehState.lines.length && rehState.lines[rehState.lineIndex].type !== 'dialog' && rehState.lines[rehState.lineIndex].type !== 'pagebreak' && !(rehState.practiceEnd !== null && rehState.lineIndex > rehState.practiceEnd) ) { rehState.lineIndex++; } } if (rehState.lineIndex >= rehState.lines.length) { rehState.playing = false; updatePlayBtn(); if (rehState.repeat) { rehState.lineIndex = 0; startPlay(); return; } toast('Script finished', 'success'); return; } // Captured up front so a later `await` (waiting on a fresh TTS synthesis) // can tell whether the user has since clicked a DIFFERENT line's play // button — `rehState.lineIndex` itself gets overwritten by that click, so // re-reading it after the await always looks "current" even when it // isn't. Checking only `rehState.playing` (a bare boolean, flipped false // then true again by the new click's own stopPlay()/startPlay() pair // before this await ever resumes) let this stale continuation slip // through and actually play — confirmed live as the reported bug: click // a line while a previous, not-yet-synthesized line is still loading, and // once that first synthesis finally finishes it cuts in and starts // playing anyway, on top of (or right over) the line the user actually // asked for, with no way to stop just that stray one. const myLineIndex = rehState.lineIndex; const stillCurrent = () => rehState.playing && rehState.lineIndex === myLineIndex; const line = rehState.lines[rehState.lineIndex]; highlightCurrentLine(); // Non-dialog lines if (line.type !== 'dialog') { // Any line with text can be narrated — direction/transition/scene/act/action all included const hasText = !!(line.text || '').trim(); if (rehState.narratorVoice && hasText && !rehState.skipDescriptions) { showStatusBar('Narrator: ' + line.text.slice(0, 50) + (line.text.length > 50 ? '…' : '')); const cached = rehState.synthCache.get(rehState.lineIndex); if (cached) { await playPreDecoded(rehState.lineIndex, cached, line.text); } else { try { const book = _lineAudioBookName(); const cleanNarr = stripMarkdown(line.text); const cacheKey = await _lineAudioCacheKey(cleanNarr, rehState.narratorVoice, ''); if (!stillCurrent()) return; let blob = await _lineAudioCacheGet(book, cacheKey); if (!stillCurrent()) return; if (!blob) { blob = await fetchTtsPreviewBlob(rehState.narratorVoice, cleanNarr, 'wav', '', _ttsBackendForVoice(rehState.narratorVoice, rehState.backend)); if (!stillCurrent()) return; _lineAudioCachePut(book, cacheKey, blob); } rehState.synthCache.set(myLineIndex, blob); _markSynthDot(myLineIndex, 'ok'); await playPreDecoded(myLineIndex, blob, line.text); } catch(_) { await new Promise(r => setTimeout(r, 200)); } } } else if (!rehState.skipDescriptions) { await new Promise(r => setTimeout(r, line.type === 'direction' ? 150 : 250)); } if (!stillCurrent()) return; rehState.lineIndex++; playNextLine(); return; } // Dialog line const cast = rehState.cast[line.speaker] || { voice: '' }; if (cast.voice === 'me') { rehState.playing = false; updatePlayBtn(); showRecOverlay(line); return; } if (!cast.voice) { showStatusBar(line.speaker + ' has no voice — skipping…'); await new Promise(r => setTimeout(r, 350)); if (!stillCurrent()) return; rehState.lineIndex++; playNextLine(); return; } const profile = (cast.instruct || '').trim(); const instruct = _buildInstruct(profile, line.emotion, cast.voice); const cleanTxt = stripMarkdown(line.text); const cached = rehState.synthCache.get(rehState.lineIndex); if (cached) { showStatusBar(line.speaker + ' is speaking…'); await playPreDecoded(rehState.lineIndex, cached, line.text); // ← instant: pre-decoded PCM rehState.clips.push({ lineIndex: rehState.lineIndex, speaker: line.speaker, type: 'tts', blob: cached }); } else { showStatusBar('Synthesizing…'); try { const toneText = _rehInlineTone(cleanTxt, line.emotion); const book = _lineAudioBookName(); const cacheKey = await _lineAudioCacheKey(toneText, cast.voice, instruct); if (!stillCurrent()) return; let blob = await _lineAudioCacheGet(book, cacheKey); if (!stillCurrent()) return; if (!blob) { blob = await fetchTtsPreviewBlob(cast.voice, toneText, 'wav', instruct, _ttsBackendForVoice(cast.voice, rehState.backend)); if (!stillCurrent()) return; _lineAudioCachePut(book, cacheKey, blob); } rehState.synthCache.set(myLineIndex, blob); showStatusBar(line.speaker + ' is speaking…'); await playPreDecoded(myLineIndex, blob, line.text); // also decodes + pre-fetches next rehState.clips.push({ lineIndex: myLineIndex, speaker: line.speaker, type: 'tts', blob }); } catch(e) { showStatusBar('TTS failed: ' + e.message); await new Promise(r => setTimeout(r, 1000)); } } if (!stillCurrent()) return; rehState.lineIndex++; playNextLine(); } function waitForAudioEnd(audio) { return new Promise(resolve => { if (!audio || audio.paused || audio.ended) { resolve(); return; } audio.addEventListener('ended', resolve, { once: true }); audio.addEventListener('pause', resolve, { once: true }); audio.addEventListener('error', resolve, { once: true }); }); } function showStatusBar(msg) { const bar = $('reh-tts-status-bar'); if (!bar) return; bar.hidden = false; const txt = $('reh-tts-status-txt'); if (txt) txt.textContent = msg; } function highlightCurrentLine() { const i = rehState.lineIndex, total = rehState.lines.length; const prog = $('reh-tb-progress'); if (prog) prog.style.width = (total ? (i / total) * 100 : 0) + '%'; const lbl = $('reh-tb-label'); if (lbl) lbl.textContent = `${i + 1} / ${total}`; document.querySelectorAll('[data-index]').forEach(el => { el.classList.toggle('reh-line-active', parseInt(el.dataset.index) === i); }); // Dialogue's own play button shows play/pause via a pure-CSS badge // overlay on the avatar (.reh-line-active .reh-line-play-avatar::after), // but the plain narrator play button (.reh-action-play-btn, no avatar to // overlay onto) never got the same treatment — its icon just stayed a // static play triangle even while that exact line was the one actively // playing, with nothing to show that clicking it again would stop it. // Confirmed live as the reported "no way to stop it" complaint. document.querySelectorAll('.reh-action-play-btn').forEach(btn => { const icon = btn.querySelector('.mdi'); if (!icon) return; const isActive = rehState.playing && parseInt(btn.dataset.index) === i; icon.className = isActive ? 'mdi mdi-stop' : 'mdi mdi-play'; btn.title = isActive ? 'Stop' : 'Play or pause this line'; }); const active = document.querySelector(`[data-index="${i}"]`); if (active) active.scrollIntoView({ behavior: 'smooth', block: 'center' }); updatePlayBtn(); } function updatePlayBtn() { const btn = $('reh-tb-play'); if (!btn) return; btn.innerHTML = rehState.playing ? '' : ''; btn.title = rehState.playing ? 'Pause' : 'Play all'; } // ── Persistent per-paragraph audio cache ──────────────────────────────────── // // rehState.synthCache only ever lived in the browser tab's memory — closing // the tab, reloading, or a crash threw away everything "Synth all" had // already paid GPU time for, forcing a full re-synthesis (and the pause // between paragraphs that comes with it) the next time regardless. This // persists each line's audio to disk, keyed by a hash of exactly what // determines its sound (text + voice + tone/instruct) rather than its // position in the script. An untouched paragraph's key never changes, so it // keeps reusing the same cached file indefinitely; an edited paragraph's // key changes the instant the text (or voice/tone) does, so it simply never // matches a cached file again and gets synthesized fresh next time — no // separate "delete the old file" step needed, the old file just becomes // unreachable dead weight rather than ever being served again. async function _lineAudioCacheKey(text, voice, instruct) { const enc = new TextEncoder().encode(`${text}${voice}${instruct || ''}`); const digest = await crypto.subtle.digest('SHA-256', enc); return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 32); } function _lineAudioBookName() { return $('reh-script-title')?.value.trim() || 'Untitled'; } async function _lineAudioCacheGet(book, key) { try { const r = await fetch(`/api/line-audio/${encodeURIComponent(book)}/${key}`); if (!r.ok) return null; return await r.blob(); } catch (_) { return null; } } function _lineAudioCachePut(book, key, blob) { // Fire-and-forget — a failed save just means this line resynthesizes next // time too, no worse than before this cache existed at all. fetch(`/api/line-audio/${encodeURIComponent(book)}/${key}`, { method: 'POST', body: blob }).catch(() => {}); } // Every line's "pre-synthesized" green dot only ever reflected // rehState.synthCache — this browser tab's own memory, empty on every fresh // page load — so a script that was fully "Synth all"-ed and safely // persisted to disk in an EARLIER session still looked completely // unsynthesized after a reload, with no way to tell the cached audio was // actually right there. This asks the server, in one batch request, which // of the current script's lines already have a matching file, and lights // up their dots — without downloading any actual audio (that still only // happens lazily, right when a line is about to play). let _lineAudioSyncedFor = null; // `${book}::${lineCount}` — avoid re-running the same scan on every small re-render async function _lineAudioSyncDots() { const book = _lineAudioBookName(); const scanId = `${book}::${rehState.lines.length}`; if (_lineAudioSyncedFor === scanId) return; _lineAudioSyncedFor = scanId; _ensureNarrator(); const idxToKey = new Map(); for (let i = 0; i < rehState.lines.length; i++) { const line = rehState.lines[i]; if (line.ignored || line.hidden) continue; if (rehState.synthCache.has(i)) continue; // already known-good this session let voice, instruct, text; if (line.type === 'dialog') { const c = rehState.cast[line.speaker]; if (!c || !c.voice || c.voice === 'me') continue; voice = c.voice; instruct = _buildInstruct(c.instruct, line.emotion, c.voice); text = _rehInlineTone(stripMarkdown(line.text), line.emotion); } else { if (!rehState.narratorVoice || !(line.text || '').trim()) continue; voice = rehState.narratorVoice; instruct = ''; text = stripMarkdown(line.text); } idxToKey.set(i, await _lineAudioCacheKey(text, voice, instruct)); } if (!idxToKey.size) return; const keyToIdx = new Map([...idxToKey].map(([i, k]) => [k, i])); try { const r = await fetch(`/api/line-audio/${encodeURIComponent(book)}/check`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ keys: [...idxToKey.values()] }), }); if (!r.ok) return; const { existing } = await r.json(); (existing || []).forEach(key => { const idx = keyToIdx.get(key); if (idx == null) return; rehState.staleLines.delete(idx); _markSynthDot(idx, 'ok'); }); } catch (_) { /* dots just stay as-is; playback still checks the disk cache lazily either way */ } } // ── Synthesize All ───────────────────────────────────────────────────────── async function synthAll() { if (rehState.synthRunning) return; if (!rehState.backend) { toast('Select a TTS backend first', 'error'); return; } _ensureNarrator(); // sync narrator voice from its cast row before synthesizing const ttsLines = rehState.lines.map((l, i) => ({ line:l, idx:i })).filter(({ line }) => { if (line.ignored || line.hidden) return false; // bulk-edit: never synth ignored/hidden lines if (line.type === 'dialog') { const c = rehState.cast[line.speaker]; return c && c.voice && c.voice !== 'me'; } // All non-dialog lines with text are narrated when a narrator voice is set return !!(rehState.narratorVoice && (line.text || '').trim()); }); if (!ttsLines.length) { toast('No TTS lines to synthesize', 'error'); return; } rehState.synthRunning = true; rehState.synthCancelled = false; const synthBar = $('reh-synth-bar'), fill = $('reh-synth-fill'), label = $('reh-synth-label'); if (synthBar) synthBar.hidden = false; const prog = (d, t) => { if (fill) fill.style.width = (t ? (d/t)*100 : 0) + '%'; if (label) label.textContent = `${d} / ${t} synthesized`; }; prog(0, ttsLines.length); let done = 0; const book = _lineAudioBookName(); // Wrapped in try/finally so a thrown error (e.g. the speaker's cast entry // got deleted mid-run — see the `!c` guard below) can never leave // synthRunning stuck true forever, which would silently no-op every future // "Synthesize All" / "Re-synthesize stale" click with no error shown, same // failure shape as the character-sheets generation-lock bug fixed earlier. try { for (const { line, idx } of ttsLines) { if (rehState.synthCancelled) break; let voice, instruct; if (line.type === 'dialog') { const c = rehState.cast[line.speaker]; if (!c || !c.voice || c.voice === 'me') { _markSynthDot(idx, null); prog(++done, ttsLines.length); continue; } voice = c.voice; instruct = _buildInstruct(c.instruct, line.emotion, c.voice); } else { voice = rehState.narratorVoice; instruct = ''; } _markSynthDot(idx, 'synthesizing'); document.getElementById('reh-syd-' + idx)?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); const toneText = _rehInlineTone(stripMarkdown(line.text), line.emotion); try { const cacheKey = await _lineAudioCacheKey(toneText, voice, instruct); let blob = await _lineAudioCacheGet(book, cacheKey); if (!blob) { blob = await fetchTtsPreviewBlob(voice, toneText, 'wav', instruct, _ttsBackendForVoice(voice, rehState.backend)); _lineAudioCachePut(book, cacheKey, blob); } rehState.synthCache.set(idx, blob); rehState.staleLines.delete(idx); _markSynthDot(idx, 'ok'); _showReSynthBtn(idx, false); preDecodeBlob(idx, blob); // decode to PCM immediately → zero-latency playback } catch(_) { _markSynthDot(idx, null); } prog(++done, ttsLines.length); } } finally { if (synthBar) synthBar.hidden = true; rehState.synthRunning = false; } if (!rehState.synthCancelled) toast(`Pre-synthesized ${done} of ${ttsLines.length} lines — ready for instant playback`, 'success'); } function _updateStaleBatchBtn() { const btn = $('reh-tb-resynth-stale'); if (btn) btn.hidden = rehState.staleLines.size === 0; } $('reh-tb-synth-all')?.addEventListener('click', () => synthAll()); $('reh-tb-resynth-stale')?.addEventListener('click', async () => { if (rehState.synthRunning) return; const stale = [...rehState.staleLines]; if (!stale.length) return; rehState.synthRunning = true; rehState.synthCancelled = false; const synthBar = $('reh-synth-bar'), fill = $('reh-synth-fill'), label = $('reh-synth-label'); if (synthBar) synthBar.hidden = false; let done = 0; const book = _lineAudioBookName(); try { for (const idx of stale) { if (rehState.synthCancelled) break; const line = rehState.lines[idx]; if (!line || line.type !== 'dialog') { rehState.staleLines.delete(idx); continue; } const c = rehState.cast[line.speaker]; if (!c || !c.voice || c.voice === 'me') { rehState.staleLines.delete(idx); continue; } const instruct = [c.instruct||'', line.emotion||''].filter(Boolean).join('. '); _markSynthDot(idx, 'synthesizing'); const toneText = _rehInlineTone(stripMarkdown(line.text), line.emotion); try { const cacheKey = await _lineAudioCacheKey(toneText, c.voice, instruct); let blob = await _lineAudioCacheGet(book, cacheKey); if (!blob) { blob = await fetchTtsPreviewBlob(c.voice, toneText, 'wav', instruct, _ttsBackendForVoice(c.voice, rehState.backend)); _lineAudioCachePut(book, cacheKey, blob); } rehState.synthCache.set(idx, blob); rehState.staleLines.delete(idx); preDecodeBlob(idx, blob); _markSynthDot(idx, 'ok'); _showReSynthBtn(idx, false); } catch(_) { _markSynthDot(idx, 'stale'); } if (fill) fill.style.width = ((++done / stale.length) * 100) + '%'; if (label) label.textContent = `${done} / ${stale.length} synthesized`; } } finally { if (synthBar) synthBar.hidden = true; rehState.synthRunning = false; _updateStaleBatchBtn(); } if (!rehState.synthCancelled) toast(`Re-synthesized ${done} stale line${done !== 1 ? 's' : ''}`, 'success'); }); $('reh-synth-cancel')?.addEventListener('click', () => { rehState.synthCancelled = true; rehState.synthRunning = false; }); // Editing a paragraph doesn't delete its OLD cached audio file — the write // path only ever knows the NEW content's hash, not whatever the line used // to hash to before the edit, so the stale file just sits there unreferenced // forever. This computes every key the CURRENT script would actually use // and asks the server to delete anything else on disk for this book. $('reh-tb-clean-cache')?.addEventListener('click', async () => { const btn = $('reh-tb-clean-cache'); if (btn) { btn.disabled = true; btn.innerHTML = ' Scanning…'; } try { _ensureNarrator(); const keep = []; for (const line of rehState.lines) { if (line.ignored || line.hidden) continue; let voice, instruct, text; if (line.type === 'dialog') { const c = rehState.cast[line.speaker]; if (!c || !c.voice || c.voice === 'me') continue; voice = c.voice; instruct = _buildInstruct(c.instruct, line.emotion, c.voice); text = _rehInlineTone(stripMarkdown(line.text), line.emotion); } else { if (!rehState.narratorVoice || !(line.text || '').trim()) continue; voice = rehState.narratorVoice; instruct = ''; text = stripMarkdown(line.text); } keep.push(await _lineAudioCacheKey(text, voice, instruct)); } const book = _lineAudioBookName(); const r = await fetch(`/api/line-audio/${encodeURIComponent(book)}/prune`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ keep }), }); if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText); const d = await r.json(); toast(d.deleted ? `Cleaned up ${d.deleted} unused cached audio file${d.deleted !== 1 ? 's' : ''}` : 'Nothing to clean up — every cached file is still in use', 'success'); } catch (e) { toast('Cache cleanup failed: ' + (e.message || e), 'error'); } finally { if (btn) { btn.disabled = false; btn.innerHTML = ' Clean cache'; } } }); // ── Recording overlay ─────────────────────────────────────────────────────── function showRecOverlay(line) { const overlay = $('reh-rec-overlay'); if (!overlay) return; overlay.hidden = false; const cue = $('reh-rec-cue'), c = rehState.cast[line.speaker] || { color: '#89b4fa' }; if (cue) cue.innerHTML = `${escHtml(line.speaker)} — your line:
${escHtml(stripMarkdown(line.text))}
`; if ($('reh-rec-preview')) { $('reh-rec-preview').style.display='none'; $('reh-rec-preview').src=''; } if ($('reh-rec-confirm-row')) $('reh-rec-confirm-row').hidden = true; if ($('reh-rec-start')) $('reh-rec-start').disabled = false; if ($('reh-rec-stop')) $('reh-rec-stop').disabled = true; if ($('reh-rec-time')) $('reh-rec-time').textContent = '0:00'; rehState.lastRecBlob = null; } function hideRecOverlay() { const overlay = $('reh-rec-overlay'); if (overlay) overlay.hidden = true; stopRehMic(); } // ── Mic recording ─────────────────────────────────────────────────────────── function rehRenderMeter(level=0, db=-Infinity, clipped=false) { const meter = $('reh-mic-meter'); if (!meter) return; if (!meter.children.length) for (let i=0;i<18;i++){const b=document.createElement('div');b.className='bar';meter.appendChild(b);} const active = Math.round(Math.max(0,Math.min(1,level))*meter.children.length); [...meter.children].forEach((bar,i)=>{ bar.className='bar'; bar.style.height=(7+Math.min(i,active)*1.55)+'px'; if(i-12&&i>11)bar.classList.add('hot');if(clipped&&i>14)bar.classList.add('clip');} }); const el=$('reh-db-readout');if(el)el.textContent=Number.isFinite(db)?db.toFixed(1)+' dB':'-∞ dB'; } function rehStartMeter() { if (!rehState.recAnalyser) return; if (rehState.recMeterRaf) cancelAnimationFrame(rehState.recMeterRaf); const data=new Float32Array(rehState.recAnalyser.fftSize), canvas=$('reh-live-wave'), RING=300, ADD=10; rehState.recWaveRing = new Float32Array(RING); const tick=()=>{ rehState.recAnalyser.getFloatTimeDomainData(data); let sum=0,peak=0; for(const s of data){sum+=s*s;peak=Math.max(peak,Math.abs(s));} const rms=Math.sqrt(sum/data.length), db=rms>0?20*Math.log10(rms):-Infinity; rehRenderMeter((db+60)/60,db,peak>0.98); if(canvas&&rehState.recWaveRing){ const ring=rehState.recWaveRing; ring.copyWithin(0,ADD); for(let i=0;i0.98?'#f38ba8':db>-12?'#f9e2af':'#a6e3a1'; ctx.lineWidth=1.5; const mid=h/2; for(let i=0;i{try{if(n)n.disconnect();}catch(_){}}); if (rehState.recStream) rehState.recStream.getTracks().forEach(t=>t.stop()); if (rehState.recDestStream) rehState.recDestStream.getTracks().forEach(t=>t.stop()); if (rehState.recAudioCtx) rehState.recAudioCtx.close().catch(()=>{}); Object.assign(rehState,{recStream:null,recDestStream:null,recSourceNode:null,recGainNode:null,recAnalyser:null,recAudioCtx:null,recWaveRing:null}); rehRenderMeter(); const wc=$('reh-live-wave');if(wc)wc.getContext('2d').clearRect(0,0,wc.width,wc.height); } $('reh-rec-start')?.addEventListener('click', async () => { try { await startRehMic(); rehState.recChunks=[]; rehState.recSecs=0; if($('reh-rec-time'))$('reh-rec-time').textContent='0:00'; if($('reh-rec-start'))$('reh-rec-start').disabled=true; if($('reh-rec-stop'))$('reh-rec-stop').disabled=false; if($('reh-rec-confirm-row'))$('reh-rec-confirm-row').hidden=true; rehState.recTimer=setInterval(()=>{ rehState.recSecs++; if($('reh-rec-time'))$('reh-rec-time').textContent=Math.floor(rehState.recSecs/60)+':'+String(rehState.recSecs%60).padStart(2,'0'); },1000); rehState.mediaRec=new MediaRecorder(rehState.recDestStream||rehState.recStream,{audioBitsPerSecond:256000}); rehState.mediaRec.ondataavailable=e=>{if(e.data.size)rehState.recChunks.push(e.data);}; rehState.mediaRec.onstop=()=>{ clearInterval(rehState.recTimer); if($('reh-rec-start'))$('reh-rec-start').disabled=false; if($('reh-rec-stop'))$('reh-rec-stop').disabled=true; const blob=new Blob(rehState.recChunks,{type:rehState.mediaRec.mimeType||'audio/webm'}); const url=URL.createObjectURL(blob); const p=$('reh-rec-preview');if(p){p.src=url;p.style.display='';} if($('reh-rec-confirm-row'))$('reh-rec-confirm-row').hidden=false; rehState.lastRecBlob=blob; }; rehState.mediaRec.start(100); } catch(e){stopRehMic();toast(await microphoneErrorMessage(e),'error');} }); $('reh-rec-stop')?.addEventListener('click', () => { if(rehState.mediaRec?.state!=='inactive')rehState.mediaRec.stop(); }); $('reh-rec-keep')?.addEventListener('click', () => { if (rehState.lastRecBlob) rehState.clips.push({lineIndex:rehState.lineIndex, speaker:rehState.lines[rehState.lineIndex]?.speaker, type:'me', blob:rehState.lastRecBlob}); stopRehMic(); hideRecOverlay(); rehState.lineIndex++; startPlay(); }); $('reh-rec-redo')?.addEventListener('click', () => { stopRehMic(); const l=rehState.lines[rehState.lineIndex]; if(l)showRecOverlay(l); }); $('reh-skip-line')?.addEventListener('click', () => { rehState.clips.push({lineIndex:rehState.lineIndex, speaker:rehState.lines[rehState.lineIndex]?.speaker, type:'skip'}); stopRehMic(); hideRecOverlay(); rehState.lineIndex++; startPlay(); }); // ── Phase 4: Summary ──────────────────────────────────────────────────────── function renderSummary() { const list = $('reh-summary-list'); if (!list) return; if (!rehState.clips.length) { list.innerHTML='

No clips in this session.

'; return; } list.innerHTML = rehState.clips.map((clip,idx)=>{ const line=rehState.lines[clip.lineIndex]||{text:'—',speaker:clip.speaker}; const c=rehState.cast[clip.speaker]||{color:'#89b4fa'}; const typeLabel=clip.type==='me'?'🎤 Recorded':clip.type==='tts'?'🔊 Synthesized':'⏭ Skipped'; const audioHtml=clip.blob?``:''; const dlHtml=clip.blob?``:''; return `
${escHtml(clip.speaker||'—')} ${typeLabel}
${escHtml(stripMarkdown(line.text))}
${audioHtml}
${dlHtml}
`; }).join(''); } $('reh-new-session-btn')?.addEventListener('click', () => { stopPlay(); stopRehMic(); rehState.lines=[]; rehState.cast={}; rehState.clips=[]; rehState.lineIndex=0; rehState.savedId=null; rehState.synthCache.clear(); rehDecodedBuffers.clear(); rehState.narratorVoice=''; rehState.practiceStart=null; rehState.practiceEnd=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-cast-save-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=''; }); // ── Import/Export panel wiring ───────────────────────────────────────────── document.querySelectorAll('.reh-impex-file').forEach(inp => { inp.addEventListener('change', async function () { const f = this.files?.[0]; if (!f) return; await importFromFile(f); this.value = ''; // After import, return to Library navRehearserPhase(1); }); }); $('reh-impex-reh-btn')?.addEventListener('click', exportToFile); $('reh-impex-fountain-btn')?.addEventListener('click', () => { $('reh-fountain-btn')?.click(); }); $('reh-impex-fdx-btn')?.addEventListener('click', () => { $('reh-fdx-export-btn')?.click(); }); $('reh-impex-osf-btn')?.addEventListener('click', () => { $('reh-osf-export-btn')?.click(); }); // ── Plain-text + Markdown export ─────────────────────────────────────────── function _downloadText(content, filename) { const a = document.createElement('a'); a.href = URL.createObjectURL(new Blob([content], { type: 'text/plain;charset=utf-8' })); a.download = filename; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 3000); } function exportAsTxt() { const title = $('reh-script-title')?.value.trim() || $('reh-page-title')?.textContent || 'script'; _downloadText(linesToScriptText(), title.replace(/[^a-z0-9]/gi, '_') + '.txt'); } function exportAsMd() { const title = $('reh-script-title')?.value.trim() || 'Script'; const lines = rehState.lines.map(l => { switch (l.type) { case 'act': return `\n# ${l.text}\n`; case 'scene': return `\n## ${l.text}\n`; case 'transition': return `\n*${l.text}*\n`; case 'direction': return `*(${l.text})*`; case 'action': return `\n${l.text}\n`; case 'dialog': return `\n**${l.speaker}**\n${l.text}\n`; default: return ''; } }).join('\n'); _downloadText(`# ${title}\n\n${lines.trim()}\n`, title.replace(/[^a-z0-9]/gi, '_') + '.md'); } $('reh-impex-txt-btn')?.addEventListener('click', exportAsTxt); $('reh-impex-md-btn')?.addEventListener('click', exportAsMd); // ── IMSDb / URL import ───────────────────────────────────────────────────── (function initUrlImport() { const inp = $('reh-impex-url'); const btn = $('reh-impex-url-btn'); const status = $('reh-impex-url-status'); if (!inp || !btn) return; async function fetchUrl() { const url = inp.value.trim(); if (!url) { toast('Paste a URL first', 'error'); return; } btn.disabled = true; if (status) { status.textContent = 'Fetching…'; status.style.color = ''; } try { const r = await fetch('/api/fetch-web-script', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }), }); if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); // Put script into the textarea and set title const ta = $('reh-script-text'); if (ta) ta.value = d.text; const titleInp = $('reh-script-title'); if (titleInp && d.title) titleInp.value = d.title.replace(/-/g,' ').replace(/\b\w/g,c=>c.toUpperCase()); if (status) { status.textContent = `✓ Fetched ${(d.chars/1000).toFixed(0)} K chars — scroll down to Parse & cast`; status.style.color = 'var(--green)'; } // Navigate to Library phase so user sees the script text area navRehearserPhase(1); // Scroll the script textarea into view setTimeout(() => ta?.scrollIntoView({ behavior:'smooth', block:'nearest' }), 300); toast(`Fetched "${d.title}" — click "Parse & cast" to continue`, 'success'); } catch(e) { if (status) { status.textContent = '✗ ' + e.message; status.style.color = 'var(--red)'; } toast('Fetch failed: ' + e.message, 'error'); } finally { btn.disabled = false; } } btn.addEventListener('click', fetchUrl); inp.addEventListener('keydown', e => { if (e.key === 'Enter') fetchUrl(); }); })(); // ── IMSDb library browser (poster grid) ──────────────────────────────────── (function initImsdbBrowser() { const modal = $('reh-imsdb-modal'); const grid = $('reh-imsdb-grid'); const search = $('reh-imsdb-search'); const countEl = $('reh-imsdb-count'); const closeBtn = $('reh-imsdb-close'); const coverBtn = $('reh-imsdb-cover-btn'); const listBtn = $('reh-imsdb-list-btn'); const urlInp = $('reh-imsdb-url'); const urlBtn = $('reh-imsdb-url-fetch'); if (!modal || !grid) return; const CACHE_KEY = 'reh-imsdb-cat-v1'; const CACHE_TTL = 6 * 3600 * 1000; let _catalogue = null; let _posterObserver = null; let _view = localStorage.getItem('reh-imsdb-view') || 'list'; // 'cover' | 'list' function _applyView() { grid.classList.toggle('list-view', _view === 'list'); if (coverBtn) coverBtn.classList.toggle('active', _view === 'cover'); if (listBtn) listBtn.classList.toggle('active', _view === 'list'); } function _ensurePosterObserver() { if (_posterObserver) return _posterObserver; _posterObserver = new IntersectionObserver((entries) => { entries.forEach(async (ent) => { if (!ent.isIntersecting) return; const card = ent.target; _posterObserver.unobserve(card); const title = card.dataset.title; try { const d = await fetch('/api/movie-poster?title=' + encodeURIComponent(title)).then(r => r.json()); if (d.poster) { const img = document.createElement('img'); img.className = 'reh-imsdb-poster'; img.loading = 'lazy'; img.src = d.poster; img.alt = title; img.onload = () => { const wrap = card.querySelector('.reh-imsdb-poster-wrap'); if (wrap) { wrap.querySelector('.reh-imsdb-fallback')?.remove(); wrap.appendChild(img); } }; } if (d.year) { card.querySelectorAll('.reh-imsdb-year').forEach(y => { y.textContent = d.year; }); } } catch(_) {} }); }, { root: grid, rootMargin: '300px' }); return _posterObserver; } function _bookColor(title) { let h = 0; for (let i = 0; i < title.length; i++) h = (h * 31 + title.charCodeAt(i)) >>> 0; return `hsl(${h % 360}, 45%, 42%)`; } function renderGrid(items) { const obs = _ensurePosterObserver(); grid.innerHTML = ''; if (!items.length) { grid.innerHTML = '
No matches.
'; return; } const frag = document.createDocumentFragment(); items.slice(0, 400).forEach(it => { const card = document.createElement('div'); card.className = 'reh-imsdb-card'; card.dataset.title = it.title; card.dataset.url = it.fetch_url; const color1 = _bookColor(it.title); const color2 = _bookColor(it.title + '_'); card.innerHTML = `
${escHtml(it.title)}
${escHtml(it.title)}
`; card.addEventListener('click', () => importImsdb(it)); frag.appendChild(card); obs.observe(card); }); grid.appendChild(frag); if (countEl) countEl.textContent = `${items.length} script${items.length!==1?'s':''}${items.length>400?' (showing 400)':''}`; _applyView(); } async function importAnyWebUrl(url, label = 'script') { url = String(url || '').trim(); if (!url) { toast('Paste a script URL first', 'error'); return; } if (!/^https?:\/\//i.test(url)) { toast('URL must start with http:// or https://', 'error'); return; } if (urlBtn) urlBtn.disabled = true; grid.innerHTML = `
Fetching ${escHtml(label)}…
`; try { const r = await fetch('/api/fetch-web-script', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }), }); if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); } const d = await r.json(); const ta = $('reh-script-text'); if (ta) ta.value = d.text; const ti = $('reh-script-title'); if (ti) ti.value = d.title || label; closeModal(); navRehearserPhase(1); setTimeout(() => ta?.scrollIntoView({ behavior:'smooth', block:'center' }), 250); toast(`Loaded "${d.title || label}" (${(d.chars/1000).toFixed(0)} K) — click "Parse & cast"`, 'success'); } catch(e) { grid.innerHTML = `
Fetch failed: ${escHtml(e.message)}
`; if (_catalogue) renderGrid(_searchFilter(_catalogue)); toast('Script fetch failed: ' + e.message, 'error'); } finally { if (urlBtn) urlBtn.disabled = false; } } async function importImsdb(it) { await importAnyWebUrl(it.fetch_url, it.title || 'IMSDb script'); } async function _loadCatalogue() { // Try localStorage cache first try { const raw = localStorage.getItem(CACHE_KEY); if (raw) { const cached = JSON.parse(raw); if (Date.now() - cached.ts < CACHE_TTL && Array.isArray(cached.items) && cached.items.length) { _catalogue = cached.items; return; } } } catch(_) {} // Fetch from server const d = await fetch('/api/imsdb/list').then(r => r.json()); _catalogue = d.items || []; try { localStorage.setItem(CACHE_KEY, JSON.stringify({ ts: Date.now(), items: _catalogue })); } catch(_) {} } async function openModal() { modal.hidden = false; if (urlInp) urlInp.value = ''; _applyView(); if (!_catalogue) { grid.innerHTML = '
Loading catalogue…
'; try { await _loadCatalogue(); } catch(e) { grid.innerHTML = `
Failed to load: ${escHtml(e.message)}
`; return; } } renderGrid(_searchFilter(_catalogue)); search?.focus(); } function closeModal() { modal.hidden = true; } function _searchFilter(items) { const q = search?.value.trim().toLowerCase() || ''; return q ? items.filter(it => it.title.toLowerCase().includes(q)) : items; } let _searchTimer = null; search?.addEventListener('input', () => { clearTimeout(_searchTimer); _searchTimer = setTimeout(() => { if (!_catalogue) return; renderGrid(_searchFilter(_catalogue)); }, 150); }); search?.addEventListener('keydown', e => { if (e.key === 'Enter' && /^https?:\/\//i.test(search.value.trim())) { e.preventDefault(); importAnyWebUrl(search.value.trim(), 'pasted URL'); } }); urlBtn?.addEventListener('click', () => importAnyWebUrl(urlInp?.value || '', 'pasted URL')); urlInp?.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); importAnyWebUrl(urlInp.value, 'pasted URL'); } }); coverBtn?.addEventListener('click', () => { _view = 'cover'; localStorage.setItem('reh-imsdb-view', _view); _applyView(); }); listBtn?.addEventListener('click', () => { _view = 'list'; localStorage.setItem('reh-imsdb-view', _view); _applyView(); }); // Wire all open buttons (Library tab + Import/Export tab) [$('reh-browse-imsdb-btn'), $('reh-browse-imsdb-btn-impex')].forEach(btn => { btn?.addEventListener('click', openModal); }); closeBtn?.addEventListener('click', closeModal); modal.addEventListener('click', e => { if (e.target === modal) closeModal(); }); })(); // ── Train Mode ──────────────────────────────────────────────────────────── // State machine: idle → playing_cue → ready → recording → transcribing → done // Each "turn" = one set of cue lines + one "me" line the user must speak. const trainState = { seq: [], // [{cueLines:[{index,speaker,text,voice,instruct}], myLine:{index,speaker,text}}] turn: 0, // current turn index phase: 'idle', // idle | playing_cue | ready | recording | transcribing | done cueSource: null, // AudioBufferSourceNode currently playing mediaRec: null, // MediaRecorder for train recording recChunks: [], recRaf: null, // rAF id for meter recStream: null, // MediaStream recCtx: null, // AudioContext for meter recAnalyser: null, recTimer: null, recSecs: 0, }; function _trainNormWords(str) { return (str || '').toLowerCase().replace(/[^a-zäöüàáâãèéêëìíîïòóôõùúûüýÿæœßа-яёА-ЯЁ0-9\s]/g, '').trim().split(/\s+/).filter(Boolean); } function _trainLcs(a, b) { const R = a.length, C = b.length; const dp = Array.from({length: R + 1}, () => new Int16Array(C + 1)); for (let i = 1; i <= R; i++) for (let j = 1; j <= C; j++) dp[i][j] = a[i-1] === b[j-1] ? dp[i-1][j-1] + 1 : Math.max(dp[i-1][j], dp[i][j-1]); let i = R, j = C; const seq = []; while (i > 0 && j > 0) { if (a[i-1] === b[j-1]) { seq.unshift([i-1, j-1]); i--; j--; } else if (dp[i-1][j] >= dp[i][j-1]) i--; else j--; } return seq; } function _trainCompare(expected, actual) { const ew = _trainNormWords(expected), aw = _trainNormWords(actual); if (!ew.length) return { html_exp: '', html_act: '', score: 1 }; const pairs = _trainLcs(ew, aw); const matchedE = new Set(pairs.map(p => p[0])); const matchedA = new Set(pairs.map(p => p[1])); const expHtml = ew.map((w, i) => matchedE.has(i) ? `${escHtml(w)}` : `${escHtml(w)}`).join(' '); const actHtml = aw.map((w, i) => matchedA.has(i) ? `${escHtml(w)}` : `${escHtml(w)}`).join(' '); const score = ew.length ? Math.round((matchedE.size / ew.length) * 100) : 100; return { html_exp: expHtml, html_act: actHtml, score }; } function buildTrainSeq() { const seq = []; const lines = rehState.lines; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line.type !== 'dialog') continue; const cast = rehState.cast[line.speaker]; if (!cast || cast.voice !== 'me') continue; // Gather preceding cue lines (last up to 3 TTS dialog lines going backwards) const cueLines = []; for (let j = i - 1; j >= 0 && cueLines.length < 3; j--) { const prev = lines[j]; if (prev.type === 'scene' || prev.type === 'act') break; if (prev.type !== 'dialog') continue; const pc = rehState.cast[prev.speaker]; if (!pc || pc.voice === 'me') break; cueLines.unshift({ index: j, speaker: prev.speaker, text: prev.text, emotion: prev.emotion || '', voice: pc.voice, color: pc.color, instruct: _buildInstruct(pc.instruct, prev.emotion, pc.voice), voiceData: pc.voiceData }); } seq.push({ cueLines, myLine: { index: i, speaker: line.speaker, text: line.text, color: cast.color } }); } return seq; } function _trainSetState(phase) { trainState.phase = phase; const stateEl = $('reh-train-rec-state'); const playBtn = $('reh-train-play'); const recBtn = $('reh-train-record'); const stopBtn = $('reh-train-stop-rec'); const cueState = $('reh-train-cue-state'); const map = { idle: { state:'Read the cue above · press ▶ to hear it', play:true, rec:false, stop:false }, playing_cue: { state:'Playing cue…', play:false, rec:false, stop:false }, ready: { state:'Press 🎤 to record your line', play:false, rec:true, stop:false }, recording: { state:'Recording…', play:false, rec:false, stop:true }, transcribing: { state:'Transcribing…', play:false, rec:false, stop:false }, done: { state:'Done — press ▶ to replay or ⏭ for next', play:true, rec:true, stop:false }, }; const m = map[phase] || map.idle; if (stateEl) stateEl.textContent = m.state; if (cueState && phase === 'playing_cue') { cueState.innerHTML = ''; } else if (cueState) cueState.innerHTML = ''; if (playBtn) playBtn.disabled = !m.play; if (recBtn) { recBtn.disabled = !m.rec; recBtn.hidden = !!m.stop; } if (stopBtn) { stopBtn.disabled = !m.stop; stopBtn.hidden = !m.stop; } const timerEl = $('reh-train-rec-timer'); const metersEl = $('reh-train-meters'); if (phase === 'recording') { if (timerEl) timerEl.hidden = false; if (metersEl) metersEl.hidden = false; } else if (phase !== 'done') { if (timerEl) timerEl.hidden = true; if (metersEl) metersEl.hidden = true; } } async function _trainPlayCueLines(cueLines) { _trainSetState('playing_cue'); for (const cue of cueLines) { if (trainState.phase !== 'playing_cue') break; let blob = rehState.synthCache.get(cue.index); if (!blob) { try { blob = await fetchTtsPreviewBlob(cue.voice, _rehInlineTone(stripMarkdown(cue.text), cue.emotion), 'wav', cue.instruct, _ttsBackendForVoice(cue.voice, rehState.backend)); } catch(e) { toast('Cue TTS failed: ' + e.message, 'error'); break; } } if (trainState.phase !== 'playing_cue') break; await new Promise(resolve => { const url = URL.createObjectURL(blob); const audio = new Audio(url); audio.onended = () => { URL.revokeObjectURL(url); resolve(); }; audio.onerror = () => { URL.revokeObjectURL(url); resolve(); }; trainState.cueAudio = audio; audio.play().catch(resolve); }); } if (trainState.phase === 'playing_cue') _trainSetState('ready'); } async function _trainTranscribe(blob) { _trainSetState('transcribing'); try { const fd = new FormData(); fd.append('file', blob, 'train_rec.webm'); fd.append('backend', _appSettings?.stt_preferred_backend || 'configured'); const r = await fetch('/api/transcribe-bytes', { method:'POST', body:fd }); if (!r.ok) throw new Error(r.statusText); const d = await r.json(); return (d.text || '').trim(); } catch(e) { toast('Transcription failed: ' + e.message, 'error'); return ''; } } async function trainGoTo(idx) { // Stop any in-progress recording or playback if (trainState.cueAudio) { trainState.cueAudio.pause(); trainState.cueAudio = null; } _trainStopMic(); trainState.turn = Math.max(0, Math.min(idx, trainState.seq.length - 1)); const turn = trainState.seq[trainState.turn]; if (!turn) return; // Update progress const progEl = $('reh-train-progress'); if (progEl) progEl.textContent = `Turn ${trainState.turn + 1} / ${trainState.seq.length}`; // Render cue card — show ALL cue lines so user can read the full context const cueCard = $('reh-train-cue'); const cueAvEl = $('reh-train-cue-avatar'); const cueSpeaker = $('reh-train-cue-speaker'); const cueText = $('reh-train-cue-text'); if (turn.cueLines.length) { const lastCue = turn.cueLines[turn.cueLines.length - 1]; if (cueAvEl) cueAvEl.innerHTML = voiceAvatarHtml(lastCue.voice, lastCue.color, 32); if (cueSpeaker) cueSpeaker.textContent = lastCue.speaker; // Show all cue lines as a readable block if (cueText) { cueText.innerHTML = turn.cueLines.map(c => `
${escHtml(c.speaker)} ${escHtml(c.text)}
` ).join(''); } cueCard?.classList.remove('no-cue'); } else { if (cueAvEl) cueAvEl.innerHTML = ''; if (cueSpeaker) cueSpeaker.textContent = 'No preceding cue'; if (cueText) cueText.textContent = 'This is the first line — speak when ready.'; cueCard?.classList.add('no-cue'); } // Render my line card const mySpeaker = $('reh-train-my-speaker'); const myText = $('reh-train-my-text'); if (mySpeaker) mySpeaker.textContent = turn.myLine.speaker; if (myText) myText.textContent = turn.myLine.text; // Hide result from previous turn const resultEl = $('reh-train-result'); if (resultEl) resultEl.hidden = true; // Reset audio preview const prevAudio = $('reh-train-audio-preview'); if (prevAudio) { prevAudio.src = ''; prevAudio.style.display = 'none'; } // Show the script text immediately — user reads first, then clicks ▶ to hear the cue _trainSetState(turn.cueLines.length ? 'idle' : 'ready'); } function _trainStartMeter() { const meterEl = $('reh-train-meter'); const dbEl = $('reh-train-db'); const waveEl = $('reh-train-wave'); if (!trainState.recAnalyser || !meterEl) return; const analyser = trainState.recAnalyser; const fft = new Uint8Array(analyser.frequencyBinCount); const wCtx = waveEl?.getContext('2d'); const W = waveEl?.width || 300, H = waveEl?.height || 36; const BARS = 18; function tick() { analyser.getByteFrequencyData(fft); const rms = fft.reduce((s, v) => s + v * v, 0) / fft.length; const db = rms > 0 ? 20 * Math.log10(Math.sqrt(rms) / 128) : -Infinity; if (dbEl) dbEl.textContent = isFinite(db) ? db.toFixed(1) + ' dB' : '-∞ dB'; const slots = Array.from({length: BARS}, (_, i) => { const s = Math.floor(i / BARS * fft.length), e = Math.floor((i+1) / BARS * fft.length); return fft.slice(s, e).reduce((a, b) => a + b, 0) / (e - s) / 255; }); meterEl.innerHTML = slots.map(v => { const h = Math.max(2, Math.round(v * 28)); const col = v > 0.85 ? 'var(--red)' : v > 0.6 ? '#f59e0b' : 'var(--green)'; return ``; }).join(''); if (wCtx) { analyser.getByteTimeDomainData(fft); wCtx.clearRect(0, 0, W, H); wCtx.beginPath(); wCtx.strokeStyle = 'var(--accent)'; wCtx.lineWidth = 1.5; fft.forEach((v, i) => { const x = (i / fft.length) * W, y = (v / 255) * H; i ? wCtx.lineTo(x, y) : wCtx.moveTo(x, y); }); wCtx.stroke(); } trainState.recRaf = requestAnimationFrame(tick); } trainState.recRaf = requestAnimationFrame(tick); } function _trainStopMic() { if (trainState.recRaf) { cancelAnimationFrame(trainState.recRaf); trainState.recRaf = null; } if (trainState.recTimer) { clearInterval(trainState.recTimer); trainState.recTimer = null; } if (trainState.mediaRec && trainState.mediaRec.state !== 'inactive') { try { trainState.mediaRec.stop(); } catch(_) {} } trainState.mediaRec = null; if (trainState.recCtx) { try { trainState.recCtx.close(); } catch(_) {} trainState.recCtx = null; } if (trainState.recStream) { trainState.recStream.getTracks().forEach(t => t.stop()); trainState.recStream = null; } trainState.recAnalyser = null; trainState.recChunks = []; const timerEl = $('reh-train-rec-timer'); if (timerEl) { timerEl.hidden = true; timerEl.textContent = '0:00'; } const metersEl = $('reh-train-meters'); if (metersEl) metersEl.hidden = true; } async function _trainStartRecording() { if (trainState.phase !== 'ready') return; try { const stream = await requestMicrophoneStream({ raw: true }); trainState.recStream = stream; const ctx = new AudioContext(); trainState.recCtx = ctx; const src = ctx.createMediaStreamSource(stream); const analyser = ctx.createAnalyser(); analyser.fftSize = 256; trainState.recAnalyser = analyser; const dst = ctx.createMediaStreamDestination(); src.connect(analyser); analyser.connect(dst); _trainSetState('recording'); _trainStartMeter(); const timerEl = $('reh-train-rec-timer'); if (timerEl) timerEl.hidden = false; trainState.recSecs = 0; trainState.recTimer = setInterval(() => { trainState.recSecs++; const m = Math.floor(trainState.recSecs / 60), s = trainState.recSecs % 60; if (timerEl) timerEl.textContent = `${m}:${String(s).padStart(2,'0')}`; }, 1000); trainState.recChunks = []; const mr = new MediaRecorder(dst.stream, { audioBitsPerSecond: 128000 }); trainState.mediaRec = mr; mr.ondataavailable = e => { if (e.data.size > 0) trainState.recChunks.push(e.data); }; mr.onstop = async () => { _trainStopMic(); const blob = new Blob(trainState.recChunks, { type: 'audio/webm' }); trainState.recChunks = []; const url = URL.createObjectURL(blob); const prevAudio = $('reh-train-audio-preview'); if (prevAudio) { prevAudio.src = url; prevAudio.style.display = ''; } // Transcribe const transcript = await _trainTranscribe(blob); const turn = trainState.seq[trainState.turn]; const { html_exp, html_act, score } = _trainCompare(turn?.myLine?.text || '', transcript); const resultEl = $('reh-train-result'); const expEl = $('reh-train-expected-text'); const actEl = $('reh-train-actual-text'); const scoreEl = $('reh-train-score'); if (expEl) expEl.innerHTML = html_exp; if (actEl) actEl.innerHTML = html_act || 'Nothing detected'; if (scoreEl) { const col = score >= 90 ? 'var(--green)' : score >= 60 ? '#f59e0b' : 'var(--red)'; scoreEl.innerHTML = `${score}%`; } if (resultEl) resultEl.hidden = false; _trainSetState('done'); }; mr.start(); } catch(e) { toast('Microphone error: ' + e.message, 'error'); _trainSetState('ready'); } } function _trainStopRecording() { if (trainState.mediaRec && trainState.mediaRec.state === 'recording') { trainState.mediaRec.stop(); } } function _trainHideStage(hide) { // Show/hide the script content area and auxiliary bars — transport bar stays visible always const els = ['.reh-stage-area', '#reh-tts-status-bar', '#reh-rec-overlay', '#reh-synth-bar']; els.forEach(sel => { const el = document.querySelector(sel) || document.getElementById(sel.replace('#','')); if (el) el.style.display = hide ? 'none' : ''; }); } function enterTrainMode() { const seq = buildTrainSeq(); if (!seq.length) { toast('No "I play this" lines found — check "I play this" on at least one character in the Cast tab.', 'error'); return; } trainState.seq = seq; trainState.turn = 0; trainState.phase = 'idle'; _trainHideStage(true); const panel = $('reh-train-panel'); if (panel) panel.hidden = false; trainGoTo(0); } function exitTrainMode() { if (trainState.cueAudio) { trainState.cueAudio.pause(); trainState.cueAudio = null; } _trainStopMic(); trainState.phase = 'idle'; trainState.seq = []; _trainHideStage(false); const panel = $('reh-train-panel'); if (panel) panel.hidden = true; } // ── Bulk-edit button wiring ───────────────────────────────────────────────── $('reh-bulk-toggle')?.addEventListener('click', () => setBulkMode(!rehState.bulkMode)); $('reh-bulk-done')?.addEventListener('click', () => setBulkMode(false)); $('reh-bulk-all')?.addEventListener('click', () => { // Select every line currently visible in the stage document.querySelectorAll('#reh-script-lines .reh-bulk-check').forEach(cb => rehState.bulkSel.add(parseInt(cb.dataset.bulk))); rehState.bulkAnchor = rehState.bulkSel.size ? Math.min(...rehState.bulkSel) : null; document.querySelectorAll('#reh-script-lines .reh-bulk-check').forEach(cb => _refreshBulkLine(parseInt(cb.dataset.bulk))); _updateBulkCount(); }); $('reh-bulk-none')?.addEventListener('click', () => { const had = [...rehState.bulkSel]; rehState.bulkSel.clear(); rehState.bulkAnchor = null; had.forEach(_refreshBulkLine); _updateBulkCount(); }); $('reh-bulk-ignore')?.addEventListener('click', () => _bulkApply(l => { l.ignored = true; })); $('reh-bulk-unignore')?.addEventListener('click', () => _bulkApply(l => { l.ignored = false; })); $('reh-bulk-hide')?.addEventListener('click', () => _bulkApply(l => { l.hidden = true; })); $('reh-bulk-delete')?.addEventListener('click', _bulkDelete); $('reh-bulk-show-hidden')?.addEventListener('change', e => { rehState.showHidden = e.target.checked; buildScriptPage(); }); // ── Train button wiring ───────────────────────────────────────────────────── $('reh-page-mode-btn')?.addEventListener('click', cyclePageMode); $('reh-train-open-btn')?.addEventListener('click', enterTrainMode); $('reh-train-exit-btn')?.addEventListener('click', exitTrainMode); $('reh-train-play')?.addEventListener('click', () => { const turn = trainState.seq[trainState.turn]; if (!turn) return; if (trainState.phase === 'playing_cue') return; if (trainState.cueAudio) { trainState.cueAudio.pause(); trainState.cueAudio = null; } if (turn.cueLines.length) _trainPlayCueLines(turn.cueLines); else _trainSetState('ready'); }); $('reh-train-record')?.addEventListener('click', () => { if (trainState.phase === 'ready' || trainState.phase === 'done') _trainStartRecording(); }); $('reh-train-stop-rec')?.addEventListener('click', _trainStopRecording); $('reh-train-prev')?.addEventListener('click', () => { if (trainState.turn > 0) trainGoTo(trainState.turn - 1); }); $('reh-train-next')?.addEventListener('click', () => { if (trainState.turn < trainState.seq.length - 1) trainGoTo(trainState.turn + 1); }); $('reh-train-repeat')?.addEventListener('click', () => { if (trainState.cueAudio) { trainState.cueAudio.pause(); trainState.cueAudio = null; } _trainStopMic(); const turn = trainState.seq[trainState.turn]; if (!turn) return; const resultEl = $('reh-train-result'); if (resultEl) resultEl.hidden = true; if (turn.cueLines.length) _trainPlayCueLines(turn.cueLines); else _trainSetState('ready'); }); // ── Init ─────────────────────────────────────────────────────────────────── rehRenderMeter(); _syncPageModeBtn(); renderLibraryList().catch(()=>{}); if($('reh-skip-desc-toggle'))$('reh-skip-desc-toggle').checked=rehState.skipDescriptions; if (window._rehearserStartImpEx) { delete window._rehearserStartImpEx; showRehImpEx(); } else { const _initPhase = window._rehearserStartPhase || 1; delete window._rehearserStartPhase; showPhase(_initPhase); }