tts-voice-creator-clone-and.../static/js/rehearser.js
mARTin-B78 04dee87710 feat: auto-save audiobook to Rehearser IndexedDB + Edit in Rehearser (v1.12.6)
- Casting completion auto-saves to Rehearser IndexedDB (same format
  as script rehearsals). Manual speaker corrections in the cast view
  debounce-save after 1.5s.
- rehId tracked across session (stored in localStorage draft) so
  updates go to the same record instead of creating duplicates.
- Voice assignments made in Script Rehearser survive an auto-update:
  only script text and emotions are overwritten; voice/instruct/soul
  are merged from the existing record.
- "Edit in Rehearser" button in the completed cast footer opens the
  saved record directly in Script Rehearser, ready for voice casting.
- Expose rehDbGetById + rehLoadRecord globally from rehearser.js.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 20:21:21 +02:00

4416 lines
202 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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' },
];
// 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, '<strong>$1</strong>');
s = s.replace(/\*([^*\n]+?)\*/g, '<em>$1</em>');
s = s.replace(/__([^_\n]+?)__/g, '<u>$1</u>');
s = s.replace(/~~([^~\n]+?)~~/g, '<del>$1</del>');
s = s.replace(/==([^=\n]+?)==/g, '<mark class="reh-hl">$1</mark>');
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: true,
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 — IndexedDB ────────────────────────────────────────────────────
const REH_DB_NAME = 'reh-library';
const REH_STORE = 'rehearsals';
function rehDbOpen() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(REH_DB_NAME, 1);
req.onupgradeneeded = e => {
const db = e.target.result;
if (!db.objectStoreNames.contains(REH_STORE)) {
const store = db.createObjectStore(REH_STORE, { keyPath: 'id', autoIncrement: true });
store.createIndex('updated', 'updated', { unique: false });
}
};
req.onsuccess = e => resolve(e.target.result);
req.onerror = e => reject(e.target.error);
});
}
async function rehDbOp(mode, fn) {
const db = await rehDbOpen();
return new Promise((resolve, reject) => {
const tx = db.transaction(REH_STORE, mode);
const store = tx.objectStore(REH_STORE);
const req = fn(store);
req.onsuccess = e => resolve(e.target.result);
req.onerror = e => reject(e.target.error);
});
}
async function rehDbAdd(record) { return rehDbOp('readwrite', s => s.add(record)); }
async function rehDbPut(record) { return rehDbOp('readwrite', s => s.put(record)); }
async function rehDbDelete(id) { return rehDbOp('readwrite', s => s.delete(id)); }
async function rehDbGetById(id) { return rehDbOp('readonly', s => s.get(id)); }
window.rehDbGetById = rehDbGetById;
window.rehLoadRecord = loadRecord; // expose so audiobook.js can open a record directly
async function rehDbGetAll() {
const db = await rehDbOpen();
return new Promise((resolve, reject) => {
const tx = db.transaction(REH_STORE, 'readonly');
const req = tx.objectStore(REH_STORE).getAll();
req.onsuccess = e => resolve(e.target.result || []);
req.onerror = e => reject(e.target.error);
});
}
// ── Serialization ─────────────────────────────────────────────────────────
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
let xml = `<?xml version="1.0" encoding="UTF-8" standalone="no" ?>\n`;
xml += `<FinalDraft DocumentType="Script" Template="No" Version="1">\n<Content>\n`;
rehState.lines.forEach(line => {
const t = x(stripMarkdown(line.text));
switch (line.type) {
case 'act':
case 'scene': xml += ` <Paragraph Type="Scene Heading"><Text>${t}</Text></Paragraph>\n`; break;
case 'action': xml += ` <Paragraph Type="Action"><Text>${t}</Text></Paragraph>\n`; break;
case 'transition': xml += ` <Paragraph Type="Transition"><Text>${t}</Text></Paragraph>\n`; break;
case 'direction': xml += ` <Paragraph Type="Parenthetical"><Text>${t}</Text></Paragraph>\n`; break;
case 'dialog':
xml += ` <Paragraph Type="Character"><Text>${x(line.speaker)}</Text></Paragraph>\n`;
xml += ` <Paragraph Type="Dialogue"><Text>${t}</Text></Paragraph>\n`;
break;
}
});
xml += `</Content>\n</FinalDraft>`;
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
let xml = `<?xml version="1.0" encoding="UTF-8"?>\n<osf:script xmlns:osf="http://www.openscreenplayformat.org/ns/1.0">\n`;
xml += ` <osf:title>${x(title)}</osf:title>\n<osf:content>\n`;
rehState.lines.forEach(line => {
const t = x(stripMarkdown(line.text));
switch (line.type) {
case 'act':
case 'scene': xml += ` <osf:scene-heading>${t}</osf:scene-heading>\n`; break;
case 'action': xml += ` <osf:action>${t}</osf:action>\n`; break;
case 'transition': xml += ` <osf:transition>${t}</osf:transition>\n`; break;
case 'direction': xml += ` <osf:parenthetical speaker="${x(line.speaker)}">${t}</osf:parenthetical>\n`; break;
case 'dialog':
xml += ` <osf:dialogue>\n <osf:character>${x(line.speaker)}</osf:character>\n <osf:text>${t}</osf:text>\n </osf:dialogue>\n`;
break;
}
});
xml += `</osf:content>\n</osf:script>`;
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) {
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 => {
if (String(sp).includes('NARRATOR')) return;
const shared = roster[String(sp).toLowerCase()];
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 (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';
async function renderLibraryList() {
const list = $('reh-library-list'); if (!list) return;
let all;
try { all = await rehDbGetAll(); } catch(e) { all = []; }
all.sort((a, b) => new Date(b.updated || 0) - new Date(a.updated || 0));
list.classList.toggle('list-view', rehLibView === 'list');
const vt = $('reh-lib-view-toggle');
if (vt) vt.innerHTML = `<span class="mdi mdi-${rehLibView === 'list' ? 'view-grid' : 'view-list'}"></span>`;
if (!all.length) {
list.innerHTML = '<div class="reh-lib-empty"><span class="mdi mdi-book-open-outline"></span><p>No saved rehearsals yet.<br>Parse a script below or drop a file to start.</p></div>';
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 `<span style="background:${c.color}">${sp[0].toUpperCase()}</span>`;
}).join('');
return `<div class="reh-book" data-id="${rec.id}" style="--bk1:${c1};--bk2:${c2}" title="${escHtml(title)}">
${isCurrent ? '<span class="reh-book-active-tag">active</span>' : ''}
<div class="reh-book-actions">
<button class="reh-book-act reh-lib-export" data-id="${rec.id}" title="Export .reh"><span class="mdi mdi-export"></span></button>
<button class="reh-book-act reh-lib-delete" data-id="${rec.id}" title="Delete"><span class="mdi mdi-delete-outline"></span></button>
</div>
<div class="reh-book-title">${escHtml(title)}</div>
<div class="reh-book-meta">
<div class="reh-book-avatars">${avatars}</div>
${total} lines · ${speakers.length} cast${meCount ? ' · ' + meCount + ' me' : ''}${clipCount ? ' · ' + clipCount + '🎤' : ''}
<div class="reh-book-progress"><div style="width:${pct}%"></div></div>
<div style="margin-top:4px;opacity:.8">${pct}% · ${date}</div>
</div>
</div>`;
}).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);
const db = await rehDbOpen();
const rec = await new Promise((res, rej) => { const r = db.transaction(REH_STORE,'readonly').objectStore(REH_STORE).get(id); r.onsuccess=e=>res(e.target.result); r.onerror=e=>rej(e.target.error); });
loadRecord(rec);
}));
list.querySelectorAll('.reh-lib-export').forEach(btn => btn.addEventListener('click', async e => {
e.stopPropagation();
const id = parseInt(btn.dataset.id);
const db = await rehDbOpen();
const rec = await new Promise((res, rej) => { const r = db.transaction(REH_STORE,'readonly').objectStore(REH_STORE).get(id); r.onsuccess=e=>res(e.target.result); r.onerror=e=>rej(e.target.error); });
rec.version = 1;
rec.clips = await clipsToJson(rec.clips || []);
const json = JSON.stringify(rec, null, 2);
const a = document.createElement('a');
const t = (rec.title || 'rehearsal').replace(/[^a-z0-9_\- ]/gi, '_').slice(0, 40);
a.href = URL.createObjectURL(new Blob([json], { type: 'application/json' }));
a.download = t + '.reh';
a.click();
}));
list.querySelectorAll('.reh-lib-delete').forEach(btn => btn.addEventListener('click', async 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 = `
<div style="font-weight:600; margin-bottom:8px; font-size:14px;">Delete Rehearsal?</div>
<div style="font-size:11px; opacity:0.8; margin-bottom:12px; line-height:1.4;">This cannot be undone.</div>
<div style="display:flex; gap:8px;">
<button class="btn-secondary btn-sm" id="btn-cancel-del" style="background:rgba(255,255,255,0.15); border:none; color:#fff; padding:6px 12px;">Cancel</button>
<button class="btn-primary btn-sm" id="btn-confirm-del" style="background:var(--red,#ef4444); border:none; color:#fff; padding:6px 12px;">Delete</button>
</div>
`;
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 `<img src="/api/voice/picture/${encodeURIComponent(voiceId)}" class="reh-char-img" style="width:${s};height:${s};border-radius:${radius}px;object-fit:cover;flex-shrink:0" alt="${escHtml(voiceId)}">`;
}
const ic = (v?.avatar && window.VOICE_AVATAR_ICONS) ? window.VOICE_AVATAR_ICONS[v.avatar] : null;
if (ic) {
return `<span class="reh-char-img" style="width:${s};height:${s};border-radius:${radius}px;background:${color};color:#fff;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0"><span class="mdi ${ic}" style="font-size:${Math.round(size * 0.56)}px"></span></span>`;
}
const initial = (voiceId || '?')[0].toUpperCase();
return `<span class="reh-char-img" style="width:${s};height:${s};border-radius:${radius}px;background:${color};color:#fff;font-weight:700;font-size:${Math.round(size * 0.45)}px;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0">${initial}</span>`;
}
// ── 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 `<img src="/api/voice/picture/${encodeURIComponent(c.voice)}" style="width:36px;height:36px;border-radius:50%;object-fit:cover;border:2px solid ${c.color}" alt="">`;
const ic = (vd?.avatar && window.VOICE_AVATAR_ICONS) ? window.VOICE_AVATAR_ICONS[vd.avatar] : null;
if (ic) return `<span style="width:36px;height:36px;border-radius:50%;background:${c.color};color:#fff;display:inline-flex;align-items:center;justify-content:center;border:2px solid ${c.color}"><span class="mdi ${ic}" style="font-size:20px"></span></span>`;
}
const initial = sp[0].toUpperCase();
return `<span style="width:36px;height:36px;border-radius:50%;background:${c.color};color:#fff;font-weight:700;font-size:16px;display:inline-flex;align-items:center;justify-content:center;border:2px solid ${c.color}">${initial}</span>`;
}
// ── 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 = `<button type="button" class="reh-vo-play"${playUrl ? ` data-url="${escHtml(playUrl)}"` : ''}${playVoice ? ` data-voice="${escHtml(playVoice)}"` : ''} title="Preview voice"><span class="mdi mdi-play"></span></button>`;
const right = isCurrent
? `<span class="reh-vo-badge"><span class="mdi mdi-check-circle"></span> Selected</span>`
: `<button type="button" class="btn-secondary btn-sm reh-vo-use"${useAttrs || ''}>Use this</button>`;
return `<div class="reh-vo-row${isCurrent ? ' current' : ''}">${play}
<div class="reh-vo-info"><span class="reh-vo-name">${escHtml(name)}</span><span class="reh-vo-meta">${escHtml(meta)}</span></div>
${right}</div>`;
}
// 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 = `<div class="reh-vo-row"><div class="reh-vo-info"><span class="reh-vo-name">No voice assigned</span><span class="reh-vo-meta">pick one below</span></div></div>`;
}
// 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 `<div class="reh-cc-online">
<div class="reh-cc-online-head"><span class="mdi mdi-earth"></span> Voice — try &amp; change
${alts.length ? `<button type="button" class="reh-vo-toggle">${alts.length} match${alts.length !== 1 ? 'es' : ''} ▾</button>` : ''}</div>
<div class="reh-vo-list">${curRow}</div>
${alts.length ? `<div class="reh-vo-list reh-vo-alts" hidden>${altRows}</div>` : ''}
<div class="reh-vo-tools">
<button type="button" class="reh-vo-tool" data-tool="local"><span class="mdi mdi-bookshelf"></span> From library</button>
<button type="button" class="reh-vo-tool" data-tool="search"><span class="mdi mdi-magnify"></span> Search online</button>
</div>
<div class="reh-vo-tool-panel reh-vo-local-panel" hidden>
<input type="search" class="reh-vo-local-input" placeholder="Filter your library voices…" autocomplete="off">
<div class="reh-vo-results reh-vo-local-results"></div>
</div>
<div class="reh-vo-tool-panel reh-vo-search-panel" hidden>
<div class="reh-vo-search-row"><input type="search" class="reh-vo-search-input" placeholder="Search fish.audio (name, style, language)…" autocomplete="off"><button type="button" class="btn-secondary btn-sm reh-vo-search-go">Search</button></div>
<div class="reh-vo-results reh-vo-search-results"><div class="reh-vo-hint">Type a query and hit Search to browse fish.audio voices.</div></div>
</div>
</div>`;
}
// 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 ~20110 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), 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 = `<div class="reh-vo-hint">No library voices match.</div>`; 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 = `<div class="reh-vo-hint">Enter a search term first.</div>`; return; }
box.innerHTML = `<div class="reh-vo-hint"><span class="reh-imsdb-spinner"></span> Searching fish.audio…</div>`;
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 = `<div class="reh-vo-hint">No playable voices found for “${escHtml(q)}”.</div>`; 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 = '<span class="reh-imsdb-spinner"></span>';
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}_${(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 = '<span class="reh-imsdb-spinner"></span>';
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';
}
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() || '';
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 &amp; 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 => `<option${(c.lang||'')===l?' selected':''}>${l}</option>`).join('');
const genSel = REH_CAST_GENDERS.map(([v,t]) => `<option value="${v}"${(c.gender||'')===v?' selected':''}>${t}</option>`).join('');
const pickerId = 'reh-voice-sel-' + _safeDomId(sp);
const voiceSel = `<select id="${pickerId}" class="reh-voice-sel" data-speaker="${escHtml(sp)}"${isMe?' style="display:none"':''}>
<option value="">— ${isNarr ? 'none (skip descriptions)' : 'assign a voice'} —</option>
${_rehAllVoiceIds(c.voice).map(v=>`<option value="${escHtml(v)}"${v===c.voice?' selected':''}>${escHtml(v)}</option>`).join('')}
</select>`;
const voiceName = !isMe && c.voice ? (getVoiceData(c.voice)?.name || c.voice) : (isMe ? 'Your mic' : '');
return `<div class="${cardCls}" data-speaker="${escHtml(sp)}" style="--cc-color:${c.color}">
<div class="reh-cc-head">
<div class="reh-cc-avatar">${castAvatarHtml(sp)}</div>
<div class="reh-cc-titles">
<div class="reh-cc-name" style="color:${c.color}">${isNarr ? '<span class="mdi mdi-book-open-page-variant"></span> ' : ''}${escHtml(label)}</div>
<div class="reh-cc-sub">${sub}</div>
<div class="reh-cc-voicechip${(!isMe && !c.voice) ? ' empty' : ''}"><span class="mdi mdi-${isMe ? 'microphone' : 'account-music-outline'}"></span> ${escHtml(voiceName || 'No voice assigned')}</div>
${(!isMe && c.voice) ? `<button type="button" class="reh-cc-sample-btn" data-speaker="${escHtml(sp)}" title="Hear a sample line in this voice" aria-label="Hear a sample line in this voice"><span class="mdi mdi-play"></span> Hear a line</button>` : ''}
</div>
</div>
${!isNarr ? `<div class="reh-cc-actions">
<label class="reh-cc-me-row" title="Record this character with your own mic instead of a TTS voice"><input type="checkbox" class="reh-me-check" data-speaker="${escHtml(sp)}" ${isMe?'checked':''}><span><span class="mdi mdi-microphone"></span> I play this</span></label>
<span class="reh-cc-actions-spacer"></span>
<button class="reh-cc-iconbtn${c.ignored?' active':''}" data-act="ignore" title="Ignore — grey out &amp; skip this characters lines" aria-label="Ignore this characters lines"><span class="mdi mdi-eye-off-outline"></span></button>
<button class="reh-cc-iconbtn${c.hidden?' active':''}" data-act="hide" title="Hide this characters lines from the script" aria-label="Hide this characters lines"><span class="mdi mdi-minus-circle-outline"></span></button>
<button class="reh-cc-iconbtn reh-cc-del" data-act="delete" title="Delete this character &amp; all their lines" aria-label="Delete this character"><span class="mdi mdi-trash-can-outline"></span></button>
</div>` : ''}
<div class="reh-cc-body"${isMe?' style="display:none"':''}>
<div class="reh-cc-field reh-cc-voice"><label>Voice</label>${voiceSel}</div>
<div class="reh-cc-extra">
${!isNarr ? castOnlinePanelHtml(sp, c) : ''}
${!isNarr ? `<div class="reh-cc-grid">
<div class="reh-cc-field"><label>Language</label><select class="reh-cc-lang">${langSel}</select></div>
<div class="reh-cc-field"><label>Gender</label><select class="reh-cc-gender">${genSel}</select></div>
<div class="reh-cc-field"><label>Tags</label><input type="text" class="reh-cc-tags" value="${escHtml(c.tags||'')}" placeholder="e.g. villain, raspy"></div>
</div>` : ''}
<div class="reh-cc-field"><label>Speaking style · voice-design prompt</label>
<input type="text" class="reh-cast-instruct" value="${escHtml(c.instruct||'')}" placeholder="${isNarr ? 'Narrator style: calm, measured, cinematic storyteller…' : 'British accent, commanding baritone, quietly menacing…'}"></div>
${!isNarr ? `<details class="reh-cc-soul"${c.soul?' open':''}>
<summary><span class="mdi mdi-script-text-outline"></span> Character soul · LLM brief
<button class="btn-secondary btn-sm reh-cc-develop" type="button" title="Let the LLM read the script &amp; flesh out this character"><span class="mdi mdi-auto-fix"></span> Develop</button>
</summary>
<textarea class="reh-cc-soul-text" placeholder="Backstory, motivation, vocal manner… (used as the LLM brief when designing this voice)">${escHtml(c.soul||'')}</textarea>
</details>` : ''}
</div>
</div>
</div>`;
}).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`;
}
_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 = '<span class="reh-imsdb-spinner"></span> 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 => `<option value="${escHtml(b.id)}">${escHtml(b.label)}</option>`).join('')
: '<option value="">No backend available</option>';
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 => `<option value="${escHtml(b.id)}">${escHtml(b.label)}</option>`).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 = '<option value="">— none (skip or pause) —</option>' +
_rehAllVoiceIds(cur).map(v => `<option value="${escHtml(v)}"${v===cur?' selected':''}>${escHtml(v)}</option>`).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.
function _buildInstruct(voiceProfile, emotion) {
const p = (voiceProfile || '').trim();
const e = (emotion || '').trim();
if (!e && !p) return '';
if (!e) return p;
if (!p) return `Speak in a ${e} manner.`;
return `Speak in a ${e} manner. ${p}`;
}
// 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);
}
function _rehInlineTone(text, emotion) {
const e = (emotion || '').trim();
if (!e || !_rehBackendIsFish()) return text;
if (/^\s*\[/.test(text)) return text; // already carries an inline [tag]
return `[${e.toLowerCase()}] ${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 => `<option value="${e.url.replace(/"/g, '&quot;')}">${e.label}</option>`)
.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 = '<option value="">Loading…</option>';
try {
const r = await fetch('/api/conversation/llm-models?url=' + encodeURIComponent(url));
const d = await r.json();
const models = d.models || [];
sel.innerHTML = '<option value="">— default —</option>' +
models.map(m => `<option value="${escHtml(m)}">${escHtml(m)}</option>`).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 = '<option value="">— default —</option>';
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 = 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;
const designOnly = speakers.filter(sp => rehState.cast[sp].voice !== 'me');
for (const sp of designOnly) {
if (rehDesignCancelled) { toast('Cancelled', 'error'); break; }
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 = 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(()=>{});
renderCastList();
populateNarratorSelect();
if (prog) prog.hidden = true;
btn.disabled = false;
if (!rehDesignCancelled) toast(`Designed ${done} voice${done!==1?'s':''} — tagged "${tag}" + Rehearser`, 'success');
});
// ── 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 = '<span class="reh-imsdb-spinner"></span> 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 = '<span class="reh-imsdb-spinner"></span> 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 = '<span class="reh-imsdb-spinner"></span> 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 = `
<div class="reh-cdp-meta">
<span class="reh-cdp-chip reh-cdp-gender">${genderIcon} ${escHtml(genderLabel)}</span>
<span class="reh-cdp-chip reh-cdp-lang"><span class="mdi mdi-translate"></span> ${escHtml(info.language || 'EN')}</span>
<span class="reh-cdp-chip reh-cdp-name"><span class="mdi mdi-account-outline"></span> ${escHtml(info.voiceId || sp)}</span>
${info.age ? `<span class="reh-cdp-chip">${escHtml(info.age)}</span>` : ''}
</div>
<div class="reh-cdp-desc">${escHtml(info.desc || '')}</div>
<div class="reh-cdp-step"><span class="reh-imsdb-spinner"></span> <span class="reh-cdp-step-txt">${escHtml(info.step || 'Designing voice…')}</span></div>`;
} 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() {
const row = $('reh-cast-row');
if (!row) return;
const collapsed = localStorage.getItem(REH_CAST_COLLAPSED_KEY) === '1';
row.classList.toggle('reh-cast-collapsed', collapsed);
const btn = $('reh-cast-toggle');
if (btn) btn.setAttribute('aria-expanded', String(!collapsed));
}
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 ─────────────────────────────────────────────────────────
function buildScriptPage() {
const titleEl = $('reh-page-title');
if (titleEl) titleEl.textContent = $('reh-script-title')?.value.trim() || 'Script';
// Cast strip
const castStrip = $('reh-cast-strip');
if (castStrip) {
const names = Object.keys(rehState.cast);
castStrip.innerHTML = names.map(sp => {
const c = rehState.cast[sp], isMe = c.voice === 'me';
return `<div class="reh-strip-char" title="${escHtml(sp)}${isMe?'me':(c.voice||'no voice')}">
${voiceAvatarHtml(isMe?'':c.voice, c.color, 28)}
<span style="font-size:10px;font-weight:700;color:${c.color};max-width:56px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(sp)}</span>
${isMe?'<span class="mdi mdi-microphone" style="font-size:10px;color:var(--subtext)"></span>':''}
</div>`;
}).join('');
const lbl = $('reh-cast-toggle-label');
if (lbl) lbl.textContent = `Characters (${names.length})`;
}
rehApplyStageFont();
rehApplyCastCollapsed();
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 = `<span class="${dotCls}" id="reh-syd-${i}" style="${(isCached||isStale)?'':'display:none'}" title="${dotTitle}"></span>`;
const reSynthBtn = `<button class="reh-resynth-btn" id="reh-rsb-${i}" title="Re-synthesize this line" ${isStale?'':'hidden'}><span class="mdi mdi-refresh"></span></button>`;
const editBtn = `<button class="reh-edit-btn" data-index="${i}" title="Edit text (or double-click)"><span class="mdi mdi-pencil-outline"></span></button>`;
const note = rehState.lines[i].note || '';
// noteArea includes the edit button so both icons live in the right gutter
const noteArea = `<div class="reh-note-area" data-index="${i}">
${editBtn}
<button class="reh-note-btn${note?' has-note':''}" title="${note?'Edit note':'Add note'}"><span class="mdi mdi-note-edit-outline"></span></button>
<textarea class="reh-note-ta" placeholder="Personal note (not spoken)…" spellcheck="false">${escHtml(note)}</textarea>
</div>`;
// 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
? `<span class="reh-bulk-check${_bSel ? ' checked' : ''}" data-bulk="${i}" title="Select line"><span class="mdi ${_bSel ? 'mdi-checkbox-marked' : 'mdi-checkbox-blank-outline'}"></span></span>`
: '';
const lineFlags = (line.ignored ? ' reh-line-ignored' : '') + (line.hidden ? ' reh-line-hidden' : '') + (_bSel ? ' reh-selected' : '');
switch (line.type) {
case 'act':
return `<div class="reh-act${lineFlags}" data-index="${i}" style="position:relative">${bulkCheck}${escHtml(line.text)} ${editBtn} ${synthDot}</div>`;
case 'scene':
return `<div class="reh-scene${lineFlags}" data-index="${i}">
${bulkCheck}<span class="mdi mdi-film" style="flex-shrink:0"></span>
<span style="flex:1">${escHtml(line.text)}</span>
${editBtn} ${synthDot}
</div>`;
case 'action':
return `<div class="reh-action-block${lineFlags}" data-index="${i}">
${bulkCheck}<span class="reh-action-text">${renderMarkdownInline(line.text)}</span>
${editBtn} ${synthDot}
</div>`;
case 'pagebreak': {
const pbLabel = line.page ? `— Page ${line.page}` : '— Page break —';
return `<div class="reh-block-pagebreak${lineFlags}" data-index="${i}">${bulkCheck}<span class="reh-pagebreak-label">${pbLabel}</span></div>`;
}
case 'transition':
return `<div class="reh-transition${lineFlags}" data-index="${i}">${bulkCheck}${escHtml(line.text)}</div>`;
case 'direction': {
const indent = line.speaker ? 'text-align:center;' : '';
return `<div class="reh-direction${lineFlags}" data-index="${i}" style="${indent}">${bulkCheck}<em>${escHtml(line.text)}</em></div>`;
}
case 'dialog': {
const c = rehState.cast[line.speaker] || { voice:'', color:'#89b4fa' };
const isMe = c.voice === 'me';
const emoInfo = getEmotionInfo(line.emotion || '');
return `<div class="reh-block${lineFlags}${rehState.practiceEnd===i?' reh-range-end':''}${rehState.practiceStart===i?' reh-range-start':''}${isPracticeRange(i)?' reh-in-range':''}" data-index="${i}" data-speaker="${escHtml(line.speaker)}" style="cursor:pointer">
${bulkCheck}<button class="reh-gutter-btn" data-index="${i}" title="Click: play from here · Shift+click: set end marker">${rehState.practiceStart===i?'▶':rehState.practiceEnd===i?'■':''}</button>
<div class="reh-block-inner">
<div class="reh-block-head">
<button type="button" class="reh-line-play-avatar" data-index="${i}" title="Play or pause this line" aria-label="Play or pause this line">${voiceAvatarHtml(isMe?'':c.voice, c.color, 32)}</button>
<span class="reh-block-name" style="color:${c.color}">${escHtml(line.speaker)}</span>
<span class="reh-block-head-spacer"></span>
${isMe
? '<span class="reh-block-badge reh-badge-me"><span class="mdi mdi-microphone"></span> Me</span>'
: '<span class="reh-block-badge reh-badge-tts"><span class="mdi mdi-speaker-outline"></span> TTS</span>'
}
${!isMe
? `<button class="reh-emo-btn${line.emotion?' has-emotion':''}" data-index="${i}" title="Set speaking tone">
${emoInfo.emoji ? emoInfo.emoji + ' ' : ''}<span class="reh-emo-label">${escHtml(emoInfo.label)}</span>
<span class="mdi mdi-chevron-down" style="font-size:9px;opacity:.6"></span>
</button>`
: ''
}
${synthDot}
${reSynthBtn}
</div>
<div class="reh-block-dialog" id="reh-diag-${i}">${renderMarkdownInline(line.text)}</div>
</div>
${noteArea}
</div>`;
}
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'];
let _pageMode = localStorage.getItem('reh-page-mode') || 'auto';
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' };
btn.innerHTML = `<span class="mdi ${icons[_pageMode] || icons.auto}"></span> ${labels[_pageMode] || labels.auto}`;
btn.title = 'Switch view: ' + labels[_pageMode];
}
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 = `
<button class="reh-fmt-btn" data-b="**" title="Bold"><b>B</b></button>
<button class="reh-fmt-btn" data-b="*" title="Italic"><i>I</i></button>
<button class="reh-fmt-btn" data-b="__" title="Underline"><u>U</u></button>
<button class="reh-fmt-btn" data-b="~~" title="Strikethrough"><del>S</del></button>
<button class="reh-fmt-btn" data-b="==" title="Highlight" style="background:#fef3c7">H</button>
<kbd class="reh-fmt-hint">Ctrl+Enter save · Esc cancel</kbd>
`;
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 = `
<div class="reh-modal-box" style="max-width:720px;width:95vw;height:80vh">
<div class="reh-modal-head">
<h3 style="margin:0;font-size:15px"><span class="mdi mdi-script-text-outline"></span> Edit Script</h3>
<button class="btn-secondary btn-sm" id="_reh-mc">✕ Close</button>
</div>
<div style="padding:10px 14px;border-bottom:1px solid var(--border);display:flex;gap:10px;align-items:center">
<label style="font-size:12px;font-weight:600;flex-shrink:0">Title</label>
<input type="text" id="_reh-mt" value="${escHtml(currentTitle)}" style="flex:1;padding:5px 8px;border:1px solid var(--border);border-radius:4px;font-size:13px;background:var(--surface);color:var(--text)">
</div>
<textarea id="_reh-ms" style="flex:1;padding:14px;font-family:'Courier New',monospace;font-size:12.5px;border:none;outline:none;resize:none;background:var(--surface);color:var(--text);height:calc(80vh - 140px);width:100%;box-sizing:border-box;" spellcheck="false">${escHtml(currentScript)}</textarea>
<div style="padding:10px 14px;display:flex;gap:8px;justify-content:flex-end;border-top:1px solid var(--border)">
<button class="btn-secondary" id="_reh-mcan">Cancel</button>
<button class="btn-primary" id="_reh-mapp"><span class="mdi mdi-auto-fix"></span> Apply changes</button>
</div>
</div>
`;
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 = `<div class="reh-emo-pop-inner">
<input class="reh-emo-search" placeholder="Search or type custom tone…" autocomplete="off">
<div class="reh-emo-list"></div>
<div class="reh-emo-custom-row">
<button class="btn-secondary btn-sm" id="_emo-custom-apply" style="width:100%;font-size:11px">
<span class="mdi mdi-plus"></span> Use as custom tone
</button>
</div>
</div>`;
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 =>
`<div class="reh-emo-item${line.emotion===e.value?' selected':''}" data-value="${escHtml(e.value)}">
<span class="reh-emo-item-emoji">${e.emoji}</span>
<span class="reh-emo-item-label">${escHtml(e.label)}</span>
${line.emotion===e.value?'<span class="mdi mdi-check" style="margin-left:auto;font-size:13px;color:var(--accent)"></span>':''}
</div>`
).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;
}
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);
_showReSynthBtn(idx, false);
_markSynthDot(idx, 'synthesizing');
try {
const blob = await fetchTtsPreviewBlob(c.voice, _rehInlineTone(stripMarkdown(line.text), line.emotion), 'wav', instruct, 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 + ' ' : ''}<span class="reh-emo-label">${escHtml(info.label)}</span> <span class="mdi mdi-chevron-down" style="font-size:9px;opacity:.6"></span>`;
// Show warning if the active backend doesn't reliably support style
if (value) _checkToneStyleSupport();
}
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)
if (_rehBackendIsFish()) {
// Fish-Speech / OpenAudio S2 honours inline [tag] tones (15 000+ tags) injected per line
if (txtEl) txtEl.innerHTML = `<strong>${escHtml(b.label)}</strong> keeps each characters voice consistent <em>and</em> applies tone. Per-line tones are sent as inline <code>[tags]</code> (e.g. <code>[whisper]</code>, <code>[excited]</code>, <code>[laughing]</code>). You can also type a custom tone like <code>[professional broadcast tone]</code> — S2 supports free-form descriptions. <a href="https://huggingface.co/fishaudio/s2-pro" target="_blank" rel="noopener">Fish-Speech S2 ↗</a>`;
warn.hidden = false;
} else if (!b.style_aware && hasTone) {
const styleAware = all.find(x => x.style_aware);
const suggest = styleAware ? ` Switch to <strong>${escHtml(styleAware.label)}</strong> for reliable tone — but expect each voice to drift between lines.` : '';
if (txtEl) txtEl.innerHTML = `<strong>${escHtml(b.label)}</strong> keeps each characters voice consistent but has weak tone control — tone picks may have little effect.${suggest}`;
warn.hidden = false;
} else if (b.style_aware && !b.uses_wav) {
const wavBackend = all.find(x => x.uses_wav);
const suggest = wavBackend ? ` Switch to <strong>${escHtml(wavBackend.label)}</strong> to keep each characters voice identical throughout.` : '';
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 = `<strong>${escHtml(b.label)}</strong> gives strong tone but re-generates a fresh voice each line, so a character wont sound the same throughout.${qwenHint}${suggest}`;
warn.hidden = false;
} else {
warn.hidden = true;
}
}
$('reh-tone-warn-close')?.addEventListener('click', () => { const w = $('reh-tone-warn'); if (w) w.hidden = true; });
// ── 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) =>
`<span class="reh-word" data-wi="${i}">${escHtml(t.word)}</span>`
).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();
}
function pausePlay() {
rehState.playing = false;
updatePlayBtn();
stopAudioSource();
const audio = $('reh-tts-audio');
if (audio && !audio.paused) audio.pause();
hideStatusBar();
}
function stopPlay() {
rehState.playing = false;
updatePlayBtn();
stopAudioSource();
const audio = $('reh-tts-audio');
if (audio) { audio.pause(); audio.src = ''; }
hideStatusBar();
}
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 only when skip mode is on AND no narrator voice is set.
// When a narrator voice exists, every line with text gets spoken — never skip.
if (rehState.skipDescriptions && !rehState.narratorVoice) {
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;
}
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) {
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 blob = await fetchTtsPreviewBlob(rehState.narratorVoice, stripMarkdown(line.text), 'wav', '', rehState.backend);
if (!rehState.playing) return;
rehState.synthCache.set(rehState.lineIndex, blob);
_markSynthDot(rehState.lineIndex, 'ok');
await playPreDecoded(rehState.lineIndex, 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 (!rehState.playing) 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 (!rehState.playing) return;
rehState.lineIndex++; playNextLine(); return;
}
const profile = (cast.instruct || '').trim();
const instruct = _buildInstruct(profile, line.emotion);
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 blob = await fetchTtsPreviewBlob(cast.voice, _rehInlineTone(cleanTxt, line.emotion), 'wav', instruct, rehState.backend);
if (!rehState.playing) return;
rehState.synthCache.set(rehState.lineIndex, blob);
showStatusBar(line.speaker + ' is speaking…');
await playPreDecoded(rehState.lineIndex, blob, line.text); // also decodes + pre-fetches next
rehState.clips.push({ lineIndex: rehState.lineIndex, speaker: line.speaker, type: 'tts', blob });
} catch(e) {
showStatusBar('TTS failed: ' + e.message);
await new Promise(r => setTimeout(r, 1000));
}
}
if (!rehState.playing) 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);
});
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 ? '<span class="mdi mdi-pause"></span>' : '<span class="mdi mdi-play"></span>';
btn.title = rehState.playing ? 'Pause' : 'Play all';
}
// ── 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;
for (const { line, idx } of ttsLines) {
if (rehState.synthCancelled) break;
let voice, instruct;
if (line.type === 'dialog') {
const c = rehState.cast[line.speaker];
voice = c.voice;
instruct = _buildInstruct(c.instruct, line.emotion);
} else {
voice = rehState.narratorVoice; instruct = '';
}
_markSynthDot(idx, 'synthesizing');
document.getElementById('reh-syd-' + idx)?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
try {
const blob = await fetchTtsPreviewBlob(voice, _rehInlineTone(stripMarkdown(line.text), line.emotion), 'wav', instruct, rehState.backend);
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);
}
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;
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');
try {
const blob = await fetchTtsPreviewBlob(c.voice, _rehInlineTone(stripMarkdown(line.text), line.emotion), 'wav', instruct, rehState.backend);
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`;
}
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; });
// ── 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 = `<span class="reh-rec-cue-name" style="color:${c.color}">${escHtml(line.speaker)}</span> — your line:<div class="reh-rec-cue-text">${escHtml(stripMarkdown(line.text))}</div>`;
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<active){bar.classList.add('on');if(db>-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;i<ADD;i++) ring[RING-ADD+i]=data[Math.floor(i*data.length/ADD)];
const ctx=canvas.getContext('2d'),w=canvas.width,h=canvas.height;
ctx.clearRect(0,0,w,h); ctx.beginPath();
ctx.strokeStyle=peak>0.98?'#f38ba8':db>-12?'#f9e2af':'#a6e3a1'; ctx.lineWidth=1.5;
const mid=h/2;
for(let i=0;i<RING;i++){const x=(i/RING)*w,y=mid-ring[i]*mid*0.85;i===0?ctx.moveTo(x,y):ctx.lineTo(x,y);}
ctx.stroke();
}
rehState.recMeterRaf=requestAnimationFrame(tick);
};
tick();
}
async function startRehMic() {
if (rehState.recDestStream) return;
const AudioCtx = window.AudioContext || window.webkitAudioContext;
rehState.recStream = await requestMicrophoneStream({ raw: true });
if (AudioCtx) {
rehState.recAudioCtx = new AudioCtx();
rehState.recSourceNode = rehState.recAudioCtx.createMediaStreamSource(rehState.recStream);
rehState.recGainNode = rehState.recAudioCtx.createGain();
rehState.recAnalyser = rehState.recAudioCtx.createAnalyser();
rehState.recAnalyser.fftSize = 1024;
const dest = rehState.recAudioCtx.createMediaStreamDestination();
rehState.recSourceNode.connect(rehState.recGainNode);
rehState.recGainNode.connect(rehState.recAnalyser);
rehState.recGainNode.connect(dest);
rehState.recDestStream = dest.stream;
rehStartMeter();
} else { rehState.recDestStream = rehState.recStream; }
}
function stopRehMic() {
if (rehState.recMeterRaf) cancelAnimationFrame(rehState.recMeterRaf);
rehState.recMeterRaf = null;
[rehState.recSourceNode,rehState.recGainNode,rehState.recAnalyser].forEach(n=>{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='<p class="note">No clips in this session.</p>'; 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?`<audio controls src="${URL.createObjectURL(clip.blob)}" style="width:100%;max-width:320px;margin-top:4px"></audio>`:'';
const dlHtml=clip.blob?`<a class="btn-secondary" style="text-decoration:none;flex-shrink:0" download="line_${idx+1}_${clip.speaker}.webm" href="${URL.createObjectURL(clip.blob)}"><span class="mdi mdi-download"></span></a>`:'';
return `<div class="reh-summary-row">
<span style="width:10px;height:10px;border-radius:50%;background:${c.color};flex-shrink:0;margin-top:4px"></span>
<div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:13px;color:${c.color}">${escHtml(clip.speaker||'—')} <span style="font-weight:400;color:var(--subtext)">${typeLabel}</span></div>
<div style="font-size:13px;line-height:1.4;margin-top:2px">${escHtml(stripMarkdown(line.text))}</div>
${audioHtml}
</div>
${dlHtml}
</div>`;
}).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 = '<div class="reh-imsdb-loading">No matches.</div>'; 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 = `
<div class="reh-imsdb-poster-wrap">
<div class="reh-imsdb-fallback" style="background:linear-gradient(160deg, ${color1}, ${color2})">
<span class="mdi mdi-movie-roll"></span>
<span class="reh-imsdb-fallback-title">${escHtml(it.title)}</span>
</div>
</div>
<div class="reh-imsdb-meta">
<span class="reh-imsdb-title">${escHtml(it.title)}</span>
<span class="reh-imsdb-year"></span>
</div>`;
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 = `<div class="reh-imsdb-loading"><span class="reh-imsdb-spinner"></span> Fetching ${escHtml(label)}…</div>`;
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 = `<div class="reh-imsdb-load-error">Fetch failed: ${escHtml(e.message)}</div>`;
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 = '<div class="reh-imsdb-loading"><span class="reh-imsdb-spinner"></span> Loading catalogue…</div>';
try {
await _loadCatalogue();
} catch(e) {
grid.innerHTML = `<div class="reh-imsdb-loading">Failed to load: ${escHtml(e.message)}</div>`;
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)
? `<span class="tw-ok">${escHtml(w)}</span>`
: `<span class="tw-miss">${escHtml(w)}</span>`).join(' ');
const actHtml = aw.map((w, i) => matchedA.has(i)
? `<span class="tw-ok">${escHtml(w)}</span>`
: `<span class="tw-extra">${escHtml(w)}</span>`).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),
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 = '<span class="mdi mdi-volume-high" style="color:var(--accent)"></span>'; }
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, 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 =>
`<div class="reh-train-cue-block">
<span class="reh-train-cue-who" style="color:${c.color}">${escHtml(c.speaker)}</span>
<span class="reh-train-cue-line">${escHtml(c.text)}</span>
</div>`
).join('');
}
cueCard?.classList.remove('no-cue');
} else {
if (cueAvEl) cueAvEl.innerHTML = '<span class="mdi mdi-information-outline" style="font-size:24px;color:var(--subtext)"></span>';
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 `<span style="height:${h}px;background:${col};width:4px;border-radius:2px;display:inline-block;vertical-align:bottom;margin:0 1px"></span>`;
}).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 || '<em style="color:var(--subtext)">Nothing detected</em>';
if (scoreEl) {
const col = score >= 90 ? 'var(--green)' : score >= 60 ? '#f59e0b' : 'var(--red)';
scoreEl.innerHTML = `<span style="color:${col};font-weight:700">${score}%</span>`;
}
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);
}