// ── 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, '$1');
s = s.replace(/\*([^*\n]+?)\*/g, '$1');
s = s.replace(/__([^_\n]+?)__/g, '$1');
s = s.replace(/~~([^~\n]+?)~~/g, '$1');
s = s.replace(/==([^=\n]+?)==/g, '$1');
return s;
}
function stripMarkdown(text) {
return text
.replace(/\*\*([^*\n]+?)\*\*/g, '$1')
.replace(/\*([^*\n]+?)\*/g, '$1')
.replace(/__([^_\n]+?)__/g, '$1')
.replace(/~~([^~\n]+?)~~/g, '$1')
.replace(/==([^=\n]+?)==/g, '$1');
}
// ── State ─────────────────────────────────────────────────────────────────
const rehState = {
lines: [],
cast: {},
lineIndex: 0,
clips: [],
voices: [],
backend: '',
playing: false,
repeat: false,
savedId: null,
synthCache: new Map(),
staleLines: new Set(), // lines that were cached but had their tone changed
synthCancelled: false,
synthRunning: false,
skipDescriptions: 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,'&').replace(//g,'>').replace(/"/g,'"');
let xml = `\n`;
xml += `\n\n`;
rehState.lines.forEach(line => {
const t = x(stripMarkdown(line.text));
switch (line.type) {
case 'act':
case 'scene': xml += ` ${t}\n`; break;
case 'action': xml += ` ${t}\n`; break;
case 'transition': xml += ` ${t}\n`; break;
case 'direction': xml += ` ${t}\n`; break;
case 'dialog':
xml += ` ${x(line.speaker)}\n`;
xml += ` ${t}\n`;
break;
}
});
xml += `\n`;
const blob = new Blob([xml], { type: 'text/xml' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = (title.replace(/[^a-z0-9_\- ]/gi, '_') || 'script') + '.fdx';
a.click();
toast('Exported as .fdx (Final Draft XML)', 'success');
}
async function importFDX(file) {
const text = await file.text();
const parser = new DOMParser();
const doc = parser.parseFromString(text, 'text/xml');
const paras = doc.querySelectorAll('Paragraph');
let result = '';
paras.forEach(para => {
const type = para.getAttribute('Type') || '';
const t = [...para.querySelectorAll('Text')].map(el => el.textContent).join('');
if (!t.trim()) return;
switch (type) {
case 'Scene Heading': result += '\n' + t + '\n'; break;
case 'Action': result += '\n' + t + '\n'; break;
case 'Character': result += '\n' + t + '\n'; break;
case 'Dialogue': result += t + '\n'; break;
case 'Parenthetical': result += t + '\n'; break;
case 'Transition': result += '\n' + t + '\n'; break;
}
});
return result.trim();
}
// OSF (Open Screenplay Format — application-agnostic XML)
function exportOSF() {
const title = $('reh-script-title')?.value.trim() || 'Untitled';
const x = s => (s||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
let xml = `\n\n`;
xml += ` ${x(title)}\n\n`;
rehState.lines.forEach(line => {
const t = x(stripMarkdown(line.text));
switch (line.type) {
case 'act':
case 'scene': xml += ` ${t}\n`; break;
case 'action': xml += ` ${t}\n`; break;
case 'transition': xml += ` ${t}\n`; break;
case 'direction': xml += ` ${t}\n`; break;
case 'dialog':
xml += ` \n ${x(line.speaker)}\n ${t}\n \n`;
break;
}
});
xml += `\n`;
const blob = new Blob([xml], { type: 'text/xml' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = (title.replace(/[^a-z0-9_\- ]/gi, '_') || 'script') + '.osf';
a.click();
toast('Exported as .osf (Open Screenplay Format)', 'success');
}
// ── PDF import (via pdf.js from CDN) ──────────────────────────────────────
async function loadPdfJs() {
if (window.pdfjsLib) return;
await new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = '/static/js/pdf/pdf.min.js';
s.onload = resolve; s.onerror = reject;
document.head.appendChild(s);
});
pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/js/pdf/pdf.worker.min.js';
}
// Extract a screenplay from a PDF, reconstructing element types from the
// horizontal indentation that screenplays use (the layout *is* the meaning):
// action/scene ~ left margin character ~ deeply indented (caps)
// dialogue ~ medium indent parenthetical ~ indented "(...)"
// transition ~ right-aligned
async function importPDFScript(file) {
toast('Loading PDF reader…', 'success');
await loadPdfJs();
const ab = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument({ data: ab }).promise;
// First pass: collect every line {x, right, text} per page
const allLinesByPage = [];
const allLines = []; // flat list for margin/width detection
for (let p = 1; p <= pdf.numPages; p++) {
const page = await pdf.getPage(p);
const content = await page.getTextContent();
const viewport = page.getViewport({ scale: 1 });
const pageW = viewport.width;
// Group items into visual rows by Y (PDF Y is bottom-up)
const rows = new Map();
content.items.forEach(item => {
if (!item.str) return;
const y = Math.round(item.transform[5] / 3) * 3;
if (!rows.has(y)) rows.set(y, []);
rows.get(y).push({ x: item.transform[4], w: item.width || 0, str: item.str });
});
const pageLines = [];
[...rows.entries()].sort((a, b) => b[0] - a[0]).forEach(([, items]) => {
items.sort((a, b) => a.x - b.x);
const text = items.map(i => i.str).join('').replace(/\s+/g, ' ').trim();
if (!text) return;
const x = items[0].x;
const last = items[items.length - 1];
const right = last.x + (last.w || 0);
pageLines.push({ x, right, pageW, text });
allLines.push({ x, right, pageW, text });
});
allLinesByPage.push(pageLines);
}
if (!allLines.length) return '';
// Detect the left margin (the most common / smallest x of body lines)
const leftMargin = Math.min(...allLines.map(l => l.x));
const pageW = allLines[0].pageW || 612;
// Noise filter: lone page numbers, scene numbers, "CONTINUED", revision marks
const isNoise = t => {
const s = t.trim();
if (/^\d{1,4}\.?$/.test(s)) return true; // bare page/scene number
if (/^\(?CONTINUED\)?:?$/i.test(s)) return true;
if (/^\d+\.$/.test(s)) return true;
if (/^(rev\.|revised|draft)\b/i.test(s) && s.length < 24) return true;
return false;
};
const out = [];
let prevBlank = true;
let prevWasAction = false; // track consecutive action lines so we don't split paragraphs
const push = (line, blankBefore) => {
if (blankBefore && !prevBlank) { out.push(''); }
out.push(line);
prevBlank = false;
};
const blank = () => { if (!prevBlank) { out.push(''); prevBlank = true; } };
const nonAction = () => { prevWasAction = false; };
for (let pageIdx = 0; pageIdx < pdf.numPages; pageIdx++) {
// Emit a page-break marker between PDF pages so the rehearser can paginate exactly
if (pageIdx > 0) { blank(); out.push(`\f${pageIdx + 1}`); blank(); }
const pageLns = allLinesByPage[pageIdx] || [];
for (const ln of pageLns) {
const t = ln.text.trim();
if (!t || isNoise(t)) continue;
const indent = (ln.x - leftMargin) / pageW; // fraction of page width
const rightFrac = ln.right / pageW;
const isCaps = t === t.toUpperCase() && /[A-Z]/.test(t);
const wordCount = t.split(/\s+/).length;
// Scene heading (slug line)
if (/^(INT|EXT|INT\.\/EXT|EXT\.\/INT|I\/E)[\. ]/i.test(t)) {
blank(); push(t.toUpperCase().replace(/\s+\d+[A-Z]?\.?$/, '')); blank(); nonAction();
continue;
}
// Act / scene number headings (theatre)
if (/^(ACT|SCENE)\s+(\d+|[IVXLC]+|ONE|TWO|THREE|FOUR|FIVE)\b/i.test(t)) {
blank(); push(t.toUpperCase()); blank(); nonAction();
continue;
}
// Transition (right-aligned, caps, ends with TO: / IN / OUT)
if (isCaps && (rightFrac > 0.72 || indent > 0.45) &&
/(TO:|CUT|FADE|DISSOLVE|SMASH|MATCH|WIPE|BLACKOUT|INTERMISSION)\b/.test(t)) {
blank(); push(t); blank(); nonAction();
continue;
}
// Parenthetical
if (/^\(.*\)?$/.test(t) || (indent > 0.18 && indent < 0.30 && /^\(/.test(t))) {
push(t.startsWith('(') ? t : '(' + t + ')', false); nonAction();
continue;
}
// Character cue: deeply indented + caps + short (often has (CONT'D)/(V.O.))
const nameCore = t.replace(/\s*\([^)]*\)\s*$/, '').trim();
if (indent > 0.22 && isCaps && wordCount <= 6 && nameCore.length <= 40 &&
/^[A-Z0-9 .'#\-]+$/.test(nameCore)) {
blank(); push(t.toUpperCase()); prevBlank = false; nonAction(); // name; dialog follows next line
continue;
}
// Dialogue: medium indent (under a character)
if (indent > 0.10 && indent < 0.34) {
push(t, false); nonAction();
continue;
}
// Default: action / description at the left margin.
// Consecutive action lines join into one paragraph (no blank between them);
// only start a new paragraph when coming from a non-action line.
if (prevWasAction) {
out.push(t); // continue same paragraph
} else {
blank(); push(t); // new paragraph
}
prevWasAction = true;
} // end per-page line loop
} // end page loop
return out.join('\n').replace(/\n{3,}/g, '\n\n').trim();
}
async function importFromFile(file) {
try {
const text = await file.text();
const data = JSON.parse(text);
data.clips = clipsFromJson(data.clips || []);
data.created = data.created ? new Date(data.created) : new Date();
data.updated = new Date();
delete data.id;
const newId = await rehDbAdd(data);
toast('Imported: ' + (data.title || 'Untitled'), 'success');
await renderLibraryList();
loadRecord({ ...data, id: newId });
} catch(e) { toast('Import failed: ' + e.message, 'error'); }
}
function loadRecord(rec) {
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 = ``;
if (!all.length) {
list.innerHTML = '
No saved rehearsals yet.
Parse a script below or drop a file to start.
';
return;
}
list.innerHTML = all.map(rec => {
const speakers = Object.keys(rec.cast || {});
const meCount = speakers.filter(sp => rec.cast[sp]?.voice === 'me').length;
const clipCount = (rec.clips || []).filter(c => c.type === 'me' && c.blob).length;
const total = parseScript(rec.script || '').filter(l => l.type === 'dialog').length;
const pct = total ? Math.round(((rec.lineIndex || 0) / total) * 100) : 0;
const date = rec.updated ? new Date(rec.updated).toLocaleDateString() : '—';
const isCurrent = rec.id === rehState.savedId;
const title = rec.title || 'Untitled';
const { c1, c2 } = bookCover(title);
const avatars = speakers.slice(0, 5).map(sp => {
const c = rec.cast[sp];
return `${sp[0].toUpperCase()}`;
}).join('');
return `
${isCurrent ? '
active' : ''}
${escHtml(title)}
`;
}).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 = `
Delete Rehearsal?
This cannot be undone.
`;
confirmOverlay.addEventListener('click', ce => ce.stopPropagation());
confirmOverlay.querySelector('#btn-cancel-del').addEventListener('click', ce => {
ce.stopPropagation();
confirmOverlay.remove();
});
confirmOverlay.querySelector('#btn-confirm-del').addEventListener('click', async ce => {
ce.stopPropagation();
await rehDbDelete(parseInt(btn.dataset.id));
if (rehState.savedId === parseInt(btn.dataset.id)) rehState.savedId = null;
renderLibraryList();
});
bookEl.appendChild(confirmOverlay);
}));
}
$('reh-lib-view-toggle')?.addEventListener('click', () => {
rehLibView = rehLibView === 'shelf' ? 'list' : 'shelf';
localStorage.setItem('reh-lib-view', rehLibView);
renderLibraryList();
});
// ── Helpers ────────────────────────────────────────────────────────────────
function getVoiceData(voiceId) {
return (window._voices || []).find(v => v.id === voiceId) || null;
}
function _rehVoiceVisibleId(voiceId, include = '') {
if (!voiceId) return false;
if (include && voiceId === include) return true;
const v = getVoiceData(voiceId);
return !v || v.enabled !== false;
}
function voiceAvatarHtml(voiceId, color, size = 32) {
const v = getVoiceData(voiceId);
const s = size + 'px';
const radius = Math.round(size / 2);
if (v?.has_picture) {
return `
`;
}
const ic = (v?.avatar && window.VOICE_AVATAR_ICONS) ? window.VOICE_AVATAR_ICONS[v.avatar] : null;
if (ic) {
return ``;
}
const initial = (voiceId || '?')[0].toUpperCase();
return `${initial}`;
}
// ── Script parser + detectCharacters → moved to rehearser-parse.js (loaded first) ──
// ── Phase navigation ───────────────────────────────────────────────────────
function showPhase(n) {
// Hide the import/export panel when switching to a numbered phase
const impex = $('reh-impex-panel'); if (impex) impex.hidden = true;
for (let i = 1; i <= 4; i++) { const el = $('reh-phase-' + i); if (el) el.hidden = i !== n; }
syncPhaseTabs(n);
if (typeof window.onRehearserPhaseChange === 'function') window.onRehearserPhaseChange(n);
}
window.showRehImpEx = function () {
stopPlay(); stopRehMic();
for (let i = 1; i <= 4; i++) { const el = $('reh-phase-' + i); if (el) el.hidden = true; }
const panel = $('reh-impex-panel'); if (panel) panel.hidden = false;
// Show export note based on state
const note = $('reh-impex-export-note');
if (note) note.textContent = rehState.lines.length
? `Current session: "${$('reh-script-title')?.value || 'Untitled'}" · ${rehState.lines.filter(l=>l.type==='dialog').length} lines`
: 'No active session — load a script first to export.';
};
// Keep the sub-tab bar in sync: highlight current, enable reachable phases
function syncPhaseTabs(n) {
const hasScript = rehState.lines.length > 0;
const hasClips = rehState.clips.length > 0;
document.querySelectorAll('.reh-subtab').forEach(tab => {
const p = parseInt(tab.dataset.phase);
tab.classList.toggle('active', p === n);
// Library always reachable; Cast/Stage need a parsed script; Summary needs clips (or being on it)
let enabled = p === 1
|| (p === 2 && hasScript)
|| (p === 3 && hasScript)
|| (p === 4 && (hasClips || n === 4));
tab.disabled = !enabled;
});
}
document.querySelectorAll('.reh-subtab').forEach(tab => {
tab.addEventListener('click', () => {
if (tab.disabled) return;
const p = parseInt(tab.dataset.phase);
// Leaving the stage: stop playback/mic so audio doesn't keep running
if (p !== 3) { stopPlay(); stopRehMic(); hideRecOverlay(); }
if (p === 1) renderLibraryList();
if (p === 3) {
// (Re)build the stage if we have a cast/script
if (rehState.lines.length) { buildScriptPage(); showPhase(3); highlightCurrentLine(); return; }
}
if (p === 4) { renderSummary(); }
showPhase(p);
});
});
// ── Phase 1 ────────────────────────────────────────────────────────────────
$('reh-file-input')?.addEventListener('change', function () {
const f = this.files?.[0]; if (!f) return;
const guess = f.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim();
const r = new FileReader();
r.onload = async e => {
$('reh-script-text').value = e.target.result;
await rehAutoSaveImport(guess);
toast('Script loaded & saved to your library — click Parse & cast', 'success');
};
r.readAsText(f); this.value = '';
});
$('reh-pdf-input')?.addEventListener('change', async function () {
const f = this.files?.[0]; if (!f) return;
this.value = '';
try {
const text = await importPDFScript(f);
if ($('reh-script-text')) $('reh-script-text').value = text;
const guess = f.name.replace(/\.pdf$/i, '').replace(/[-_]+/g, ' ').trim();
await rehAutoSaveImport(guess);
toast('PDF imported & saved to your library — check formatting, then click Parse & cast', 'success');
} catch(e) { toast('PDF import failed: ' + e.message, 'error'); }
});
$('reh-fdx-input')?.addEventListener('change', async function () {
const f = this.files?.[0]; if (!f) return;
this.value = '';
try {
const text = await importFDX(f);
if ($('reh-script-text')) $('reh-script-text').value = text;
if (!$('reh-script-title')?.value.trim()) {
const guess = f.name.replace(/\.fdx$/i, '').replace(/[-_]+/g, ' ').trim();
if ($('reh-script-title')) $('reh-script-title').value = guess;
}
toast('FDX imported — click Parse & cast', 'success');
} catch(e) { toast('FDX import failed: ' + e.message, 'error'); }
});
// ── Unified import router (used by file pickers + drag & drop) ──────────────
async function rehImportAnyFile(f) {
if (!f) return;
const name = f.name.toLowerCase();
const setTitle = strip => {
if (!$('reh-script-title')?.value.trim()) {
const guess = f.name.replace(strip, '').replace(/[-_]+/g, ' ').trim();
if ($('reh-script-title')) $('reh-script-title').value = guess;
}
};
try {
if (name.endsWith('.reh') || name.endsWith('.json')) {
await importFromFile(f); // full session → loads & jumps to cast
return;
}
if (name.endsWith('.pdf')) {
toast('Reading PDF…', 'success');
const text = await importPDFScript(f);
if ($('reh-script-text')) $('reh-script-text').value = text;
setTitle(/\.pdf$/i);
await rehAutoSaveImport();
toast('PDF imported & saved to your library — check formatting, then Parse & cast', 'success');
return;
}
if (name.endsWith('.fdx') || name.endsWith('.osf') || name.endsWith('.xml')) {
const text = await importFDX(f);
if ($('reh-script-text')) $('reh-script-text').value = text;
setTitle(/\.(fdx|osf|xml)$/i);
await rehAutoSaveImport();
toast('Script imported & saved to your library — click Parse & cast', 'success');
return;
}
// .txt / .md / .fountain / anything else → plain text
const text = await f.text();
if ($('reh-script-text')) $('reh-script-text').value = text;
setTitle(/\.(txt|md|fountain)$/i);
await rehAutoSaveImport();
toast('Script loaded & saved to your library — click Parse & cast', 'success');
} catch(e) { toast('Import failed: ' + e.message, 'error'); }
}
// Drag & drop over the dropzone AND the whole Phase-1 panel
(function setupRehDropzone() {
const dz = $('reh-dropzone');
const phase = $('reh-phase-1');
if (!dz || !phase) return;
let depth = 0;
const isFileDrag = e => e.dataTransfer && [...(e.dataTransfer.types || [])].includes('Files');
phase.addEventListener('dragenter', e => {
if (!isFileDrag(e)) return;
e.preventDefault(); depth++; dz.classList.add('dragover');
dz.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
phase.addEventListener('dragover', e => { if (isFileDrag(e)) e.preventDefault(); });
phase.addEventListener('dragleave', e => {
if (!isFileDrag(e)) return;
depth = Math.max(0, depth - 1);
if (depth === 0) dz.classList.remove('dragover');
});
phase.addEventListener('drop', async e => {
if (!isFileDrag(e)) return;
e.preventDefault(); depth = 0; dz.classList.remove('dragover');
const f = e.dataTransfer.files?.[0];
await rehImportAnyFile(f);
});
})();
$('reh-parse-btn')?.addEventListener('click', () => {
const text = $('reh-script-text')?.value.trim();
if (!text) { toast('Paste or upload a script first', 'error'); return; }
rehState.lines = parseScript(text);
if (!rehState.lines.filter(l => l.type === 'dialog').length) {
toast('No dialog found. Use screenplay format (CAPS name + dialog) or CHAR: text.', 'error'); return;
}
// Preserve cast for returning characters
const detected = detectCharacters(rehState.lines);
Object.entries(detected).forEach(([sp]) => {
if (rehState.cast[sp]) detected[sp] = { ...detected[sp], ...rehState.cast[sp] };
});
rehState.cast = detected;
// Keep the imported/loaded library entry on the first parse so casting updates it
// instead of orphaning it; otherwise start a fresh entry.
if (rehState._keepSavedIdOnce) rehState._keepSavedIdOnce = false;
else rehState.savedId = null;
rehState.clips = [];
rehState.lineIndex = 0;
rehState.synthCache.clear(); rehDecodedBuffers.clear();
renderCastList();
showPhase(2);
refreshRehBackends();
});
// ── Phase 2 ────────────────────────────────────────────────────────────────
function castAvatarHtml(sp) {
const c = rehState.cast[sp]; if (!c) return '';
if (c.voice && c.voice !== 'me') {
const vd = getVoiceData(c.voice);
if (vd?.has_picture) return `
`;
const ic = (vd?.avatar && window.VOICE_AVATAR_ICONS) ? window.VOICE_AVATAR_ICONS[vd.avatar] : null;
if (ic) return ``;
}
const initial = sp[0].toUpperCase();
return `${initial}`;
}
// ── Online voice picker: audition a sample, browse alternatives, agree/switch ──
let _rehAudEl = null, _rehAudBtn = null;
function _rehStopAudition() {
if (_rehAudEl) { try { _rehAudEl.pause(); } catch (_) {} }
if (_rehAudBtn) { _rehAudBtn.classList.remove('playing'); const i = _rehAudBtn.querySelector('.mdi'); if (i) i.className = 'mdi mdi-play'; }
_rehAudBtn = null;
}
function _rehAudition(url, btn) {
if (!url) { toast('No preview audio for this voice', 'error'); return; }
if (_rehAudBtn === btn && _rehAudEl && !_rehAudEl.paused) { _rehStopAudition(); return; }
_rehStopAudition();
if (!_rehAudEl) { _rehAudEl = new Audio(); _rehAudEl.addEventListener('ended', _rehStopAudition); }
_rehAudEl.src = url;
_rehAudEl.play().then(() => {
_rehAudBtn = btn; btn.classList.add('playing');
const i = btn.querySelector('.mdi'); if (i) i.className = 'mdi mdi-stop';
}).catch(() => toast('Could not play preview', 'error'));
}
const _REH_PREVIEW_TEXT = 'Hello — this is how this voice sounds.';
const _rehSearchCache = {}; // sp -> last fish.audio search results (simplified candidates)
// One row of the voice picker. Plays via sample URL (online) or TTS (local library voice).
function _rehVoiceRow({ name, meta, playUrl, playVoice, isCurrent, useAttrs }) {
const play = ``;
const right = isCurrent
? ` Selected`
: ``;
return `${play}
${escHtml(name)}${escHtml(meta)}
${right}
`;
}
// Render the "matched from fish.audio" picker: current voice + alternatives + change tools
function castOnlinePanelHtml(sp, c) {
const o = c.online;
if (!o || !Array.isArray(o.candidates) || c.voice === 'me') return '';
// Current assigned voice — an online candidate (has a sample) or a plain library voice
const curCand = o.candidates.find(x => x.voice_id && x.voice_id === c.voice);
let curRow;
if (curCand) {
curRow = _rehVoiceRow({ name: curCand.title || c.voice, meta: [curCand.gender, curCand.language].filter(Boolean).join(' · ') || 'fish.audio', playUrl: curCand.sample_audio, isCurrent: true });
} else if (c.voice) {
const vd = getVoiceData(c.voice);
curRow = _rehVoiceRow({ name: vd?.name || c.voice, meta: vd?.group || vd?.tag || 'library voice', playVoice: c.voice, isCurrent: true });
} else {
curRow = `No voice assignedpick one below
`;
}
// Alternatives = the other matched candidates (not the current one)
const alts = o.candidates.map((cand, i) => ({ cand, i })).filter(({ cand }) => cand.voice_id !== c.voice || !cand.voice_id);
const altRows = alts.map(({ cand, i }) => _rehVoiceRow({
name: cand.title || 'Voice', meta: [cand.gender, cand.language].filter(Boolean).join(' · ') || 'fish.audio',
playUrl: cand.sample_audio, useAttrs: ` data-act="alt" data-idx="${i}"`,
})).join('');
return `
Voice — try & change
${alts.length ? `` : ''}
${curRow}
${alts.length ? `
${altRows}
` : ''}
`;
}
// Preview a local library voice by synthesizing a short sample with the chosen backend
async function _rehPreviewLocal(voiceId, btn) {
const backend = $('reh-backend-select')?.value;
if (!backend) { toast('Select a TTS backend first', 'error'); return; }
if (_rehAudBtn === btn && _rehAudEl && !_rehAudEl.paused) { _rehStopAudition(); return; }
_rehStopAudition();
const icon = btn.querySelector('.mdi');
btn.classList.add('loading'); if (icon) icon.className = 'mdi mdi-loading';
try {
const blob = await fetchTtsPreviewBlob(voiceId, _REH_PREVIEW_TEXT, 'wav', '', backend);
if (!_rehAudEl) { _rehAudEl = new Audio(); _rehAudEl.addEventListener('ended', _rehStopAudition); }
_rehAudEl.src = URL.createObjectURL(blob);
await _rehAudEl.play();
btn.classList.remove('loading'); _rehAudBtn = btn; btn.classList.add('playing');
if (icon) icon.className = 'mdi mdi-stop';
} catch (e) {
btn.classList.remove('loading'); if (icon) icon.className = 'mdi mdi-play';
toast('Preview failed: ' + (e.message || e), 'error');
}
}
// Pick a representative short line from a character's own dialogue (narrator: from
// the action/scene lines it narrates). Prefers a self-contained ~20–110 char sentence.
function _rehPickOneLiner(sp) {
const isNarr = sp === REH_NARRATOR_KEY;
const pool = rehState.lines
.filter(l => isNarr ? (l.type !== 'dialog' && l.type !== 'pagebreak' && !l.ignored) : (l.type === 'dialog' && l.speaker === sp))
.map(l => ({ text: stripMarkdown(l.text || '').trim(), emotion: l.emotion || '' }))
.filter(l => l.text.length >= 4);
if (!pool.length) return null;
const scored = pool.map(l => {
const len = l.text.length;
let score = (len >= 20 && len <= 110) ? 3 : (len < 20 ? 1 : 0);
if (/[.!?]["']?$/.test(l.text)) score += 1; // complete sentence
score -= Math.abs(len - 60) / 120; // prefer ~60 chars
return { l, score };
}).sort((a, b) => b.score - a.score);
const best = scored[0].l;
return { text: best.text.slice(0, 220), emotion: best.emotion };
}
// Synthesize and play a one-liner from this character's lines, in their assigned voice.
async function _rehPreviewCastLine(sp, btn) {
const c = rehState.cast[sp];
if (!c || !c.voice || c.voice === 'me') { toast('Assign a voice first', 'error'); return; }
const backend = $('reh-backend-select')?.value;
if (!backend) { toast('Select a TTS backend first', 'error'); return; }
if (_rehAudBtn === btn && _rehAudEl && !_rehAudEl.paused) { _rehStopAudition(); return; }
_rehStopAudition();
const pick = _rehPickOneLiner(sp);
if (!pick) { toast('No lines to preview yet', 'error'); return; }
const icon = btn.querySelector('.mdi');
btn.classList.add('loading'); if (icon) icon.className = 'mdi mdi-loading';
try {
const blob = await fetchTtsPreviewBlob(c.voice, _rehInlineTone(pick.text, pick.emotion), 'wav', _buildInstruct(c.instruct, pick.emotion), backend);
if (!_rehAudEl) { _rehAudEl = new Audio(); _rehAudEl.addEventListener('ended', _rehStopAudition); }
_rehAudEl.src = URL.createObjectURL(blob);
await _rehAudEl.play();
btn.classList.remove('loading'); _rehAudBtn = btn; btn.classList.add('playing');
if (icon) icon.className = 'mdi mdi-stop';
} catch (e) {
btn.classList.remove('loading'); if (icon) icon.className = 'mdi mdi-play';
toast('Preview failed: ' + (e.message || e), 'error');
}
}
// Assign an existing library voice to a character (keeps the picker open)
function _rehAssignLocal(sp, voiceId) {
const c = rehState.cast[sp]; if (!c) return;
_rehStopAudition();
c.voice = voiceId; c.voiceData = getVoiceData(voiceId);
if (c.online) c.online.picked = c.online.candidates.findIndex(x => x.voice_id === voiceId);
renderCastList(); if (typeof populateNarratorSelect === 'function') populateNarratorSelect();
}
// Render filtered library voices inside a card's "From library" panel
function _rehRenderLocalResults(card, sp, q) {
const box = card.querySelector('.reh-vo-local-results'); if (!box) return;
const cur = rehState.cast[sp]?.voice;
const ql = (q || '').trim().toLowerCase();
const matches = rehState.voices.filter(v => {
if (!_rehVoiceVisibleId(v, cur)) return false;
if (v === cur) return false;
if (!ql) return true;
const vd = getVoiceData(v);
return v.toLowerCase().includes(ql) || (vd?.name || '').toLowerCase().includes(ql) || (vd?.group || '').toLowerCase().includes(ql);
}).slice(0, 40);
if (!matches.length) { box.innerHTML = `No library voices match.
`; return; }
box.innerHTML = matches.map(v => {
const vd = getVoiceData(v);
return _rehVoiceRow({ name: vd?.name || v, meta: vd?.group || vd?.tag || 'library voice', playVoice: v, useAttrs: ` data-act="local" data-voice="${escHtml(v)}"` });
}).join('');
}
// Search fish.audio and render results inside a card's "Search online" panel
async function _rehSearchOnline(card, sp, q) {
const box = card.querySelector('.reh-vo-search-results'); if (!box) return;
q = (q || '').trim();
if (!q) { box.innerHTML = `Enter a search term first.
`; return; }
box.innerHTML = ` Searching fish.audio…
`;
const lang = REH_FISH_LANG[$('reh-design-lang')?.value || 'English'] ?? 'en';
let items = [];
try {
const qs = [`search=${encodeURIComponent(q)}`, `language=${lang}`, 'page_size=10', 'sort_by=score'];
items = (await fetch('/api/fishaudio/voices?' + qs.join('&')).then(r => r.json())).items || [];
} catch (_) {}
const cands = items.filter(v => v.sample_audio).slice(0, 10).map(v => ({
title: v.title, sample_audio: v.sample_audio, image: v.image || '', gender: v.gender || '',
language: v.language || lang || '', description: v.description || '', sample_text: v.sample_text || v.default_text || '',
}));
_rehSearchCache[sp] = cands;
if (!cands.length) { box.innerHTML = `No playable voices found for “${escHtml(q)}”.
`; return; }
box.innerHTML = cands.map((cand, i) => _rehVoiceRow({
name: cand.title || 'Voice', meta: [cand.gender, cand.language].filter(Boolean).join(' · ') || 'fish.audio',
playUrl: cand.sample_audio, useAttrs: ` data-act="search" data-idx="${i}"`,
})).join('');
}
// Import a freshly searched fish.audio result, assign it, and fold it into candidates
async function _rehUseSearchResult(sp, idx, btn) {
const c = rehState.cast[sp]; if (!c) return;
const cand = (_rehSearchCache[sp] || [])[idx]; if (!cand) return;
_rehStopAudition();
const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '';
try {
const vid = await _rehImportFishCandidate(sp, cand);
cand.voice_id = vid; c.voice = vid; c.voiceData = getVoiceData(vid);
if (!c.online) c.online = { candidates: [], picked: 0 };
c.online.candidates.push(cand);
c.online.picked = c.online.candidates.length - 1;
if (typeof loadVoiceLibrary === 'function') await loadVoiceLibrary().catch(() => {});
renderCastList(); if (typeof populateNarratorSelect === 'function') populateNarratorSelect();
toast(`Switched ${sp} to ${cand.title || 'voice'}`, 'success');
} catch (err) {
btn.disabled = false; btn.innerHTML = orig;
toast('Import failed: ' + err.message, 'error');
}
}
// Remember fish imports this session so the same voice isn't downloaded twice
const _rehFishImported = {};
// Import one fish.audio candidate into the library and return its voice_id.
// Reuses an already-imported voice (this session OR already in the library) so the
// same fish.audio voice doesn't pile up as Mortal_Kombat, Mortal_Kombat_2, …
async function _rehImportFishCandidate(sp, cand) {
const key = (cand.sample_audio || cand.title || '').toLowerCase();
if (key && _rehFishImported[key]) {
const id = _rehFishImported[key];
if (!rehState.voices.includes(id)) rehState.voices.push(id);
return id;
}
const title = (cand.title || '').toLowerCase().trim();
const existing = title && (window._voices || []).find(v =>
(v.group === 'fish-audio' || v.tag === 'fish-audio') && (v.name || '').toLowerCase().trim() === title);
if (existing) {
if (key) _rehFishImported[key] = existing.id;
if (!rehState.voices.includes(existing.id)) rehState.voices.push(existing.id);
return existing.id;
}
const lang2 = (cand.language || 'EN').slice(0, 2).toUpperCase();
const vid = `${lang2}_${(cand.title || sp).replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 36) || 'Voice'}`;
const d = await fetch('/api/quick-import-voice', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice_id: vid, audio_url: cand.sample_audio, transcript: cand.sample_text || '' }),
}).then(r => r.json());
if (!d.voice_id) throw new Error('import returned no id');
if (typeof saveMeta === 'function') await saveMeta(d.voice_id, { name: cand.title || sp, tag: 'fish-audio', group: 'fish-audio', origin: 'cloned', gender: (cand.gender || '').charAt(0).toUpperCase(), note: (cand.description || '').slice(0, 180) }).catch(e => logErr('fish saveMeta', e));
if (cand.image) await fetch('/api/voice/picture-url', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ voice_id: d.voice_id, image_url: cand.image }) }).catch(e => logErr('fish picture', e));
if (!rehState.voices.includes(d.voice_id)) rehState.voices.push(d.voice_id);
if (key) _rehFishImported[key] = d.voice_id;
return d.voice_id;
}
// "Disagree" → switch this character to one of the presented alternatives
async function _rehUseCandidate(sp, idx, btn) {
const c = rehState.cast[sp]; const o = c && c.online; if (!o) return;
const cand = o.candidates[idx]; if (!cand) return;
_rehStopAudition();
if (cand.voice_id) { // already imported earlier — just reassign
c.voice = cand.voice_id; c.voiceData = getVoiceData(cand.voice_id); o.picked = idx;
renderCastList(); if (typeof populateNarratorSelect === 'function') populateNarratorSelect();
return;
}
const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '';
try {
const vid = await _rehImportFishCandidate(sp, cand);
cand.voice_id = vid; c.voice = vid; c.voiceData = getVoiceData(vid); o.picked = idx;
if (typeof loadVoiceLibrary === 'function') await loadVoiceLibrary().catch(() => {});
renderCastList(); if (typeof populateNarratorSelect === 'function') populateNarratorSelect();
toast(`Switched ${sp} to ${cand.title || 'voice'}`, 'success');
} catch (err) {
btn.disabled = false; btn.innerHTML = orig;
toast('Import failed: ' + err.message, 'error');
}
}
const REH_NARRATOR_KEY = '\u{1F4D6}NARRATOR'; // unique sentinel kept out of real speaker names
function _ensureNarrator() {
if (!rehState.cast[REH_NARRATOR_KEY]) {
rehState.cast[REH_NARRATOR_KEY] = {
voice: rehState.narratorVoice || '',
color: '#6b7280',
instruct: '',
voiceData: rehState.narratorVoice ? getVoiceData(rehState.narratorVoice) : null,
_isNarrator: true,
};
}
// Bidirectional sync between the narrator cast row and the global narratorVoice.
// The cast row is the source of truth (auto-design assigns the voice there), but
// playback/synth read rehState.narratorVoice — keep them aligned both ways.
const n = rehState.cast[REH_NARRATOR_KEY];
if (n.voice) {
rehState.narratorVoice = n.voice;
if (!n.voiceData) n.voiceData = getVoiceData(n.voice);
} else if (rehState.narratorVoice) {
n.voice = rehState.narratorVoice;
n.voiceData = getVoiceData(rehState.narratorVoice);
}
}
const REH_CAST_LANGS = ['English', 'German', 'Auto', 'French', 'Spanish', 'Italian', 'Portuguese', 'Dutch', 'Polish'];
const REH_CAST_GENDERS = [['', '—'], ['F', '♀ Female'], ['M', '♂ Male'], ['N', '⚥ Diverse']];
// Apply a mutation to every dialog line spoken by a character
function _castApplyToLines(sp, fn) {
rehState.lines.forEach((l, i) => { if (l.speaker === sp && l.type === 'dialog') fn(l, i); });
}
function _safeDomId(value) {
let out = '';
const s = String(value || 'voice');
for (let i = 0; i < s.length; i++) out += s.charCodeAt(i).toString(36) + '-';
return out || 'voice';
}
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 & 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 => ``).join('');
const genSel = REH_CAST_GENDERS.map(([v,t]) => ``).join('');
const pickerId = 'reh-voice-sel-' + _safeDomId(sp);
const voiceSel = ``;
const voiceName = !isMe && c.voice ? (getVoiceData(c.voice)?.name || c.voice) : (isMe ? 'Your mic' : '');
return `
${castAvatarHtml(sp)}
${isNarr ? ' ' : ''}${escHtml(label)}
${sub}
${escHtml(voiceName || 'No voice assigned')}
${(!isMe && c.voice) ? `
` : ''}
${!isNarr ? `
` : ''}
`;
}).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 = ' Developing…';
try {
const r = await fetch('/api/analyze-characters', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
script, names: [sp],
llm_url: $('reh-llm-url')?.value.trim() || rehDefaultLlmUrl(),
model: $('reh-llm-model')?.value || '',
language: c.lang || $('reh-design-lang')?.value || 'English',
}),
});
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const info = (await r.json()).characters?.[0] || {};
if (info.gender) c.gender = String(info.gender).toUpperCase().charAt(0).replace(/[^MFN]/, 'N');
if (info.description) { c.soul = info.description; c.instruct = c.instruct || info.description; }
renderCastList();
toast(`Developed ${sp}`, 'success');
} catch (e) {
btn.disabled = false; btn.innerHTML = orig;
toast('Develop failed: ' + e.message, 'error');
}
}
async function refreshRehBackends() {
rehInitLlmField();
const sel = $('reh-backend-select'); if (!sel) return;
let backends = typeof availableTtsBackends === 'function' ? availableTtsBackends() : [];
// If the global list hasn't been probed yet, trigger the probe now
if (!backends.length && typeof refreshTtsBackendAvailability === 'function') {
await refreshTtsBackendAvailability().catch(() => {});
backends = typeof availableTtsBackends === 'function' ? availableTtsBackends() : [];
}
// Last resort: show all known backends (even unavailable ones) so the user can try
if (!backends.length && typeof _ttsBackends !== 'undefined' && Array.isArray(_ttsBackends)) {
backends = _ttsBackends;
}
const prev = sel.value || rehState.backend;
sel.innerHTML = backends.length
? backends.map(b => ``).join('')
: '';
if (prev && [...sel.options].some(o => o.value === prev)) {
// Restore a previously chosen backend
sel.value = prev;
} else {
// Default backend preference. fishspeech is best — it clones each voice's saved WAV
// (consistent identity) AND honours per-line emotion markers (tone), so a character
// sounds the same across the rehearsal while still reacting to tone. voice_clone is the
// fallback for consistency (weak tone); voice_design gives tone but drifts each line.
const preferred = ['fishspeech', 'voice_clone', 'customvoice', 'voice_design'];
const pick = preferred.find(id => [...sel.options].some(o => o.value === id));
if (pick) sel.value = pick;
}
rehState.backend = sel.value || ''; // keep state in sync with the (possibly auto-picked) value
_checkToneStyleSupport();
}
// Keep the rehearser select in sync whenever the global backend list refreshes
(window._ttsRefreshHooks = window._ttsRefreshHooks || []).push(() => {
const sel = $('reh-backend-select'); if (!sel) return;
const backends = typeof availableTtsBackends === 'function' ? availableTtsBackends() : [];
if (!backends.length) return;
const prev = sel.value || rehState.backend;
sel.innerHTML = backends.map(b => ``).join('');
if (prev && [...sel.options].some(o => o.value === prev)) sel.value = prev;
else if (sel.options.length) sel.value = sel.options[0].value;
rehState.backend = sel.value || '';
});
// All voice IDs offered in the cast/narrator pickers — backend voices PLUS everything
// in the library, so any saved/cloned/imported voice (incl. for the narrator) is pickable.
function _rehAllVoiceIds(include) {
const ids = new Set((rehState.voices || []).filter(id => _rehVoiceVisibleId(id, include)));
(window._voices || []).forEach(v => { if (v && v.id && (v.enabled !== false || v.id === include)) ids.add(v.id); });
if (include) ids.add(include);
return [...ids].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
}
function populateNarratorSelect() {
const sel = $('reh-narrator-voice'); if (!sel) return;
const cur = rehState.narratorVoice || sel.value;
sel.innerHTML = '' +
_rehAllVoiceIds(cur).map(v => ``).join('');
}
$('reh-fetch-voices-btn')?.addEventListener('click', async () => {
const backend = $('reh-backend-select')?.value;
if (!backend) { toast('Select a backend first', 'error'); return; }
$('reh-fetch-voices-btn').disabled = true;
try {
if ((!window._voices || !window._voices.length) && typeof loadVoiceLibrary === 'function') {
await loadVoiceLibrary().catch(() => {});
}
const raw = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json());
rehState.voices = (Array.isArray(raw) ? raw.map(v => typeof v === 'string' ? v : (v.id || String(v))) : [])
.filter(id => _rehVoiceVisibleId(id));
renderCastList();
populateNarratorSelect();
toast('Fetched ' + rehState.voices.length + ' voices', 'success');
} catch(e) { toast('Fetch failed: ' + e.message, 'error'); }
finally { $('reh-fetch-voices-btn').disabled = false; }
});
$('reh-narrator-voice')?.addEventListener('change', function () { rehState.narratorVoice = this.value; });
$('reh-back-1-btn')?.addEventListener('click', () => showPhase(1));
// ── TTS instruct builder — emotion goes first as a directive ─────────────────
// voice_clone backends treat instruct as an IDENTITY description; putting the
// emotion first and using directive language ("Speak in a … manner") makes the
// model prioritise it over the static voice description, which otherwise wins.
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 => ``)
.join('');
}
}
$('reh-llm-refresh')?.addEventListener('click', async () => {
const url = $('reh-llm-url')?.value.trim() || rehDefaultLlmUrl();
const sel = $('reh-llm-model'); if (!sel) return;
sel.innerHTML = '';
try {
const r = await fetch('/api/conversation/llm-models?url=' + encodeURIComponent(url));
const d = await r.json();
const models = d.models || [];
sel.innerHTML = '' +
models.map(m => ``).join('');
// Default to the global Active LLM model when available (still overridable here)
const want = (typeof _appSettings !== 'undefined' && _appSettings) ? _appSettings.llm_model : '';
if (want && models.includes(want)) sel.value = want;
toast(models.length ? `Found ${models.length} models` : 'No models found', models.length ? 'success' : 'error');
} catch(e) {
sel.innerHTML = '';
toast('Could not list models: ' + e.message, 'error');
}
});
// Avatar icon keys for designed voices — male / female / neutral / robot / animal.
// Picked from the LLM description (robot/animal cues) then gender.
const REH_AVATAR_ICONS = {
male: 'mdi-face-man',
female: 'mdi-face-woman',
neutral: 'mdi-account',
robot: 'mdi-robot-outline',
animal: 'mdi-paw',
};
function _pickVoiceAvatar(gender, desc, speaker) {
const d = ((desc || '') + ' ' + (speaker || '')).toLowerCase();
if (/\b(robot|android|synthetic|artificial|computer|machine|cyborg|a\.?i\.?|operating system|\bos\b|digital|hologram|drone)\b/.test(d)) return 'robot';
if (/\b(animal|creature|beast|dragon|monster|cat|dog|wolf|lion|bird|horse|dino|dinosaur|alien)\b/.test(d)) return 'animal';
if (gender === 'M') return 'male';
if (gender === 'F') return 'female';
return 'neutral';
}
let rehDesignCancelled = false;
$('reh-autodesign-cancel')?.addEventListener('click', () => { rehDesignCancelled = true; });
$('reh-autodesign-btn')?.addEventListener('click', async () => {
const backend = $('reh-backend-select')?.value;
if (!backend) { toast('Select a TTS backend first', 'error'); return; }
const speakers = Object.keys(rehState.cast);
if (!speakers.length) { toast('No characters to design for', 'error'); return; }
const script = $('reh-script-text')?.value.trim() || linesToScriptText();
const llmUrl = $('reh-llm-url')?.value.trim() || rehDefaultLlmUrl();
const llmModel= $('reh-llm-model')?.value || '';
const language= $('reh-design-lang')?.value || 'English';
const langCode= REH_LANG_CODE[language] || 'EN';
const scriptTitle = $('reh-script-title')?.value.trim() || 'Script';
const tag = 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 = ' Matching…';
try {
const r = await fetch('/api/match-characters-voices', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
script: $('reh-script-text')?.value.trim() || linesToScriptText(),
names: speakers.map(nameFor),
voices: candidates,
llm_url: $('reh-llm-url')?.value.trim() || rehDefaultLlmUrl(),
model: $('reh-llm-model')?.value || '',
language: $('reh-design-lang')?.value || 'English',
}),
});
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
const assignments = (await r.json()).assignments || [];
const validIds = new Set(candidates.map(c => c.id));
let n = 0;
assignments.forEach(a => {
const sp = byDisplay[String(a.name || '').toUpperCase().trim()];
if (sp && a.voice_id && validIds.has(a.voice_id)) {
rehState.cast[sp].voice = a.voice_id;
rehState.cast[sp].voiceData = getVoiceData(a.voice_id);
if (!rehState.voices.includes(a.voice_id)) rehState.voices.push(a.voice_id);
if (sp === REH_NARRATOR_KEY) rehState.narratorVoice = a.voice_id;
n++;
}
});
// Research the play + cast and drop a note on each character
btn.innerHTML = ' Researching characters…';
await rehResearchCast(speakers);
renderCastList();
populateNarratorSelect();
toast(n ? `Matched ${n} character${n !== 1 ? 's' : ''} & added notes` : 'No good matches — try “Design all voices” instead', n ? 'success' : 'error');
} catch (e) {
toast('Match failed: ' + e.message, 'error');
} finally {
btn.disabled = false; btn.innerHTML = orig;
}
});
// ── Match from online — search fish.audio per character, import & assign ──────
const REH_FISH_LANG = { English: 'en', German: 'de', French: 'fr', Spanish: 'es', Italian: 'it', Portuguese: 'pt', Dutch: 'nl', Auto: '' };
$('reh-matchonline-btn')?.addEventListener('click', async () => {
const btn = $('reh-matchonline-btn');
const speakers = Object.keys(rehState.cast).filter(sp => rehState.cast[sp].voice !== 'me' && sp !== REH_NARRATOR_KEY);
if (!speakers.length) { toast('No characters to match', 'error'); return; }
const lang = REH_FISH_LANG[$('reh-design-lang')?.value || 'English'] ?? 'en';
const prog = $('reh-autodesign-progress'), fill = $('reh-autodesign-fill'), label = $('reh-autodesign-label');
const setProg = (d, t, msg) => { if (fill) fill.style.width = (t ? (d / t) * 100 : 0) + '%'; if (label) label.textContent = msg || `${d} / ${t}`; };
const orig = btn.innerHTML;
btn.disabled = true; btn.innerHTML = ' Analyzing…';
if (prog) prog.hidden = false;
rehDesignCancelled = false;
try {
// 1) Ask the LLM for each character's gender (drives the online search)
let chars = [];
try {
const ar = await fetch('/api/analyze-characters', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ script: $('reh-script-text')?.value.trim() || linesToScriptText(), names: speakers,
llm_url: $('reh-llm-url')?.value.trim() || rehDefaultLlmUrl(), model: $('reh-llm-model')?.value || '',
language: $('reh-design-lang')?.value || 'English' }),
});
if (ar.ok) chars = (await ar.json()).characters || [];
} catch (_) {}
const byName = {}; chars.forEach(c => { if (c.name) byName[String(c.name).toUpperCase().trim()] = c; });
const G = { M: 'male', F: 'female', N: 'neutral' };
let done = 0, n = 0;
for (let i = 0; i < speakers.length; i++) {
if (rehDesignCancelled) { toast('Cancelled', 'error'); break; }
const sp = speakers[i];
setProg(done, speakers.length, `Matching ${sp}… (${done + 1}/${speakers.length})`);
const info = byName[sp.toUpperCase().trim()] || {};
rehWriteCharacterNote(sp, info); // drop the LLM's character research into a note
const gender = G[(info.gender || '').toUpperCase().charAt(0)] || '';
// Search fish.audio: by character name first (catches known/celebrity names), then gender+lang
const qs = [`search=${encodeURIComponent(sp)}`, `language=${lang}`, 'page_size=8', 'sort_by=score'];
let items = [];
try { items = (await fetch('/api/fishaudio/voices?' + qs.join('&')).then(r => r.json())).items || []; } catch (_) {}
let pick = items.find(v => v.sample_audio);
let nameHit = !!pick; // got a result by searching the character's actual name
if (!pick) { // fallback: best by gender+language, offset per character to diversify
const fb = [`language=${lang}`, gender ? `gender=${gender}` : '', 'page_size=12', 'sort_by=score', `page=${(i % 4) + 1}`].filter(Boolean);
try { items = (await fetch('/api/fishaudio/voices?' + fb.join('&')).then(r => r.json())).items || []; } catch (_) {}
pick = items.find(v => v.sample_audio);
}
// Keep the top few playable results so the user can audition & swap them later
let cands = items.filter(v => v.sample_audio).slice(0, 6).map(v => ({
title: v.title, sample_audio: v.sample_audio, image: v.image || '', gender: v.gender || '',
language: v.language || lang || '', description: v.description || '', sample_text: v.sample_text || v.default_text || '',
}));
// When the name didn't match, the fallback returns the same popular voices for
// everyone — rotate the pick per character so they don't all sound identical,
// and skip voices already taken by earlier characters this run.
if (!nameHit && cands.length > 1) {
const taken = new Set(Object.values(rehState.cast).map(c => (c.online?.candidates?.[c.online.picked]?.sample_audio)).filter(Boolean));
const rotated = [...cands.slice(i % cands.length), ...cands.slice(0, i % cands.length)];
cands = rotated.sort((a, b) => (taken.has(a.sample_audio) ? 1 : 0) - (taken.has(b.sample_audio) ? 1 : 0));
}
if (cands.length) {
try {
const vid = await _rehImportFishCandidate(sp, cands[0]);
cands[0].voice_id = vid;
rehState.cast[sp].voice = vid;
rehState.cast[sp].voiceData = getVoiceData(vid);
rehState.cast[sp].online = { candidates: cands, picked: 0 };
n++;
} catch (e) { logErr('match-online import ' + sp, e); }
}
done++; setProg(done, speakers.length);
}
if (typeof loadVoiceLibrary === 'function') await loadVoiceLibrary().catch(() => {});
renderCastList();
populateNarratorSelect();
toast(n ? `Imported & matched ${n} online voice${n !== 1 ? 's' : ''}` : 'No online matches found', n ? 'success' : 'error');
} catch (e) {
toast('Online match failed: ' + e.message, 'error');
} finally {
btn.disabled = false; btn.innerHTML = orig;
if (prog) prog.hidden = true;
}
});
// Show per-character design progress card beneath the cast row
function rehMarkCastDesigning(sp, state, msg, info) {
const row = document.querySelector(`.reh-cast-row[data-speaker="${CSS.escape(sp)}"]`);
if (!row) return;
// Inline badge in the cast row header
let badge = row.querySelector('.reh-cast-design-badge');
if (!badge) {
badge = document.createElement('span');
badge.className = 'reh-cast-design-badge';
row.querySelector('strong')?.after(badge);
}
badge.className = 'reh-cast-design-badge' + (state === 'done' ? ' done' : state === 'err' ? ' err' : '');
badge.textContent = state === 'designing' ? '✨ designing…' : state === 'done' ? '✓ designed' : '✗ failed';
if (msg) badge.title = msg;
row.classList.toggle('reh-cast-designing', state === 'designing');
// Expanded detail panel beneath the instruct row
const wrap = row.closest('div');
let panel = wrap?.querySelector('.reh-cast-design-panel');
if (state === 'designing' && info) {
if (!panel) {
panel = document.createElement('div');
panel.className = 'reh-cast-design-panel';
wrap.appendChild(panel);
}
const genderIcon = info.gender === 'M' ? '♂' : info.gender === 'F' ? '♀' : '⚧';
const genderLabel = info.gender === 'M' ? 'Male' : info.gender === 'F' ? 'Female' : 'Neutral';
panel.innerHTML = `
${genderIcon} ${escHtml(genderLabel)}
${escHtml(info.language || 'EN')}
${escHtml(info.voiceId || sp)}
${info.age ? `${escHtml(info.age)}` : ''}
${escHtml(info.desc || '')}
${escHtml(info.step || 'Designing voice…')}
`;
} else if (state === 'done' && panel) {
// Collapse to a summary line
const descEl = panel.querySelector('.reh-cdp-desc');
const metaEl = panel.querySelector('.reh-cdp-meta');
const stepEl = panel.querySelector('.reh-cdp-step');
if (stepEl) stepEl.remove();
if (descEl) {
// Truncate
const t = descEl.textContent;
if (t.length > 160) descEl.textContent = t.slice(0, 157) + '…';
}
panel.classList.add('done');
} else if (state === 'err') {
if (panel) panel.remove();
}
}
$('reh-start-btn')?.addEventListener('click', () => {
const backend = $('reh-backend-select')?.value;
if (!backend) { toast('Select a backend first', 'error'); return; }
rehState.backend = backend;
rehState.narratorVoice = $('reh-narrator-voice')?.value || '';
rehState.lineIndex = 0;
rehState.clips = [];
rehState.playing = false;
rehState.synthCache.clear(); rehDecodedBuffers.clear();
rehState.practiceStart = null;
rehState.practiceEnd = null;
buildScriptPage();
showPhase(3);
highlightCurrentLine();
});
// ── Stage display controls (font size + collapsible cast) ───────────────────
const REH_STAGE_FONT_KEY = 'ttsvc_reh_stage_scale';
const REH_CAST_COLLAPSED_KEY = 'ttsvc_reh_cast_collapsed';
function rehStageScale() {
const v = parseFloat(localStorage.getItem(REH_STAGE_FONT_KEY) || '1');
return isNaN(v) ? 1 : Math.max(0.7, Math.min(2, v));
}
function rehApplyStageFont() {
// Set on the page wrapper so the scale inherits to BOTH the single A4 page
// (#reh-a4-page) and the paginated `.reh-paper` pages built by page-mode.
const scale = String(rehStageScale());
const wrap = document.querySelector('.reh-page-wrap');
if (wrap) wrap.style.setProperty('--reh-stage-scale', scale);
const page = $('reh-a4-page');
if (page) page.style.setProperty('--reh-stage-scale', scale);
}
function rehStageFontStep(delta) {
const next = Math.max(0.7, Math.min(2, Math.round((rehStageScale() + delta) * 100) / 100));
localStorage.setItem(REH_STAGE_FONT_KEY, String(next));
rehApplyStageFont();
}
function rehApplyCastCollapsed() {
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 `
${voiceAvatarHtml(isMe?'':c.voice, c.color, 28)}
${escHtml(sp)}
${isMe?'':''}
`;
}).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 = ``;
const reSynthBtn = ``;
const editBtn = ``;
const note = rehState.lines[i].note || '';
// noteArea includes the edit button so both icons live in the right gutter
const noteArea = `
${editBtn}
`;
// Bulk-edit: hidden lines drop out of the view entirely, unless we're revealing
// them inside bulk mode so they can be selected and restored.
if (line.hidden && !(rehState.bulkMode && rehState.showHidden)) return '';
const _bSel = rehState.bulkSel.has(i);
const bulkCheck = rehState.bulkMode
? ``
: '';
const lineFlags = (line.ignored ? ' reh-line-ignored' : '') + (line.hidden ? ' reh-line-hidden' : '') + (_bSel ? ' reh-selected' : '');
switch (line.type) {
case 'act':
return `${bulkCheck}${escHtml(line.text)} ${editBtn} ${synthDot}
`;
case 'scene':
return `
${bulkCheck}
${escHtml(line.text)}
${editBtn} ${synthDot}
`;
case 'action':
return `
${bulkCheck}${renderMarkdownInline(line.text)}
${editBtn} ${synthDot}
`;
case 'pagebreak': {
const pbLabel = line.page ? `— Page ${line.page} —` : '— Page break —';
return `${bulkCheck}${pbLabel}
`;
}
case 'transition':
return `${bulkCheck}${escHtml(line.text)}
`;
case 'direction': {
const indent = line.speaker ? 'text-align:center;' : '';
return `${bulkCheck}${escHtml(line.text)}
`;
}
case 'dialog': {
const c = rehState.cast[line.speaker] || { voice:'', color:'#89b4fa' };
const isMe = c.voice === 'me';
const emoInfo = getEmotionInfo(line.emotion || '');
return `
${bulkCheck}
${escHtml(line.speaker)}
${isMe
? ' Me'
: ' TTS'
}
${!isMe
? ``
: ''
}
${synthDot}
${reSynthBtn}
${renderMarkdownInline(line.text)}
${noteArea}
`;
}
default: return '';
}
}).join('');
// Emotion picker buttons
linesEl.querySelectorAll('.reh-emo-btn').forEach(btn => {
btn.addEventListener('click', e => { e.stopPropagation(); openEmoPicker(parseInt(btn.dataset.index), btn); });
});
// Edit buttons
linesEl.querySelectorAll('.reh-edit-btn').forEach(btn => {
btn.addEventListener('click', e => { e.stopPropagation(); startInlineEdit(parseInt(btn.dataset.index)); });
});
// Double-click on dialog / action text to edit
linesEl.querySelectorAll('.reh-block-dialog').forEach(el => {
el.addEventListener('dblclick', e => { e.stopPropagation(); startInlineEdit(parseInt(el.closest('[data-index]').dataset.index)); });
});
linesEl.querySelectorAll('.reh-action-text').forEach(el => {
el.addEventListener('dblclick', e => { e.stopPropagation(); startInlineEdit(parseInt(el.closest('[data-index]').dataset.index)); });
});
// Avatar button: play/pause a single character line
linesEl.querySelectorAll('.reh-line-play-avatar').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const idx = parseInt(btn.dataset.index);
if (rehState.playing && rehState.lineIndex === idx) { pausePlay(); return; }
stopPlay();
hideRecOverlay();
rehState.lineIndex = idx;
highlightCurrentLine();
startPlay();
});
});
// Gutter button: play from here / set end
linesEl.querySelectorAll('.reh-gutter-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const idx = parseInt(btn.dataset.index);
if (e.shiftKey) {
// Set / clear practice end
rehState.practiceEnd = rehState.practiceEnd === idx ? null : idx;
} else {
// Jump to this line and start playing
stopPlay();
hideRecOverlay();
rehState.lineIndex = idx;
highlightCurrentLine();
startPlay();
}
updatePracticeRange();
});
});
// Click block to jump; in Select mode, click toggles selection and Shift+click selects a range.
linesEl.querySelectorAll('[data-index]').forEach(el => {
el.addEventListener('click', e => {
if (e.target.closest('.reh-emo-btn, .reh-edit-btn, .reh-gutter-btn, .reh-note-btn, .reh-resynth-btn, .reh-line-play-avatar, textarea, select, .vp-root')) return;
const idx = parseInt(el.dataset.index);
if (rehState.bulkMode) {
_toggleBulkSel(idx, e.shiftKey);
return;
}
stopPlay();
hideRecOverlay();
rehState.lineIndex = idx;
highlightCurrentLine();
});
});
// Re-synth buttons (per line)
linesEl.querySelectorAll('.reh-resynth-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
synthOneLine(parseInt(btn.id.replace('reh-rsb-', '')));
});
});
// Note area: toggle textarea visibility on button click, save on change
linesEl.querySelectorAll('.reh-note-area').forEach(area => {
const idx = parseInt(area.dataset.index);
const btn = area.querySelector('.reh-note-btn');
const ta = area.querySelector('.reh-note-ta');
if (!btn || !ta) return;
// Show if note has content
if (ta.value.trim()) ta.classList.add('open');
btn.addEventListener('click', e => {
e.stopPropagation();
ta.classList.toggle('open');
if (ta.classList.contains('open')) ta.focus();
});
ta.addEventListener('input', () => {
rehState.lines[idx].note = ta.value;
btn.classList.toggle('has-note', !!ta.value.trim());
});
ta.addEventListener('click', e => e.stopPropagation());
});
// Bulk-edit: leading checkboxes toggle line selection. The page wrapper also
// gets the mode class because A4/PDF modes move blocks out of #reh-script-lines.
linesEl.classList.toggle('reh-bulk-mode', rehState.bulkMode);
document.querySelector('.reh-page-wrap')?.classList.toggle('reh-bulk-mode', rehState.bulkMode);
if (rehState.bulkMode) {
linesEl.querySelectorAll('.reh-bulk-check').forEach(cb => {
cb.addEventListener('click', e => {
e.stopPropagation();
_toggleBulkSel(parseInt(cb.dataset.bulk), e.shiftKey);
});
});
}
// Apply current pagination mode
applyPageMode();
// Warn if backend doesn't support tone/style changes
_checkToneStyleSupport();
}
// ── Bulk-edit (line selection: ignore / hide / delete) ──────────────────────
function _toggleBulkSel(i, range = false) {
if (!Number.isFinite(i)) return;
if (range && rehState.bulkAnchor !== null && Number.isFinite(rehState.bulkAnchor)) {
const a = Math.min(rehState.bulkAnchor, i);
const b = Math.max(rehState.bulkAnchor, i);
const indices = [];
for (let n = a; n <= b; n++) if (rehState.lines[n] && !rehState.lines[n].hidden) indices.push(n);
const deselect = indices.length && indices.every(n => rehState.bulkSel.has(n));
indices.forEach(n => {
if (deselect) rehState.bulkSel.delete(n);
else rehState.bulkSel.add(n);
_refreshBulkLine(n);
});
rehState.bulkAnchor = i;
} else {
if (rehState.bulkSel.has(i)) rehState.bulkSel.delete(i);
else rehState.bulkSel.add(i);
rehState.bulkAnchor = i;
_refreshBulkLine(i);
}
_updateBulkCount();
}
function _refreshBulkLine(i) {
const sel = rehState.bulkSel.has(i);
document.querySelectorAll(`.reh-bulk-check[data-bulk="${i}"]`).forEach(cb => {
cb.classList.toggle('checked', sel);
const icon = cb.querySelector('.mdi');
if (icon) icon.className = 'mdi ' + (sel ? 'mdi-checkbox-marked' : 'mdi-checkbox-blank-outline');
});
document.querySelectorAll(`[data-index="${i}"]`).forEach(el => el.classList.toggle('reh-selected', sel));
}
function _updateBulkCount() {
const el = $('reh-bulk-count');
if (el) el.textContent = `${rehState.bulkSel.size} selected`;
}
function setBulkMode(on) {
rehState.bulkMode = on;
if (!on) { rehState.bulkSel.clear(); rehState.bulkAnchor = null; }
const bar = $('reh-bulk-bar'); if (bar) bar.hidden = !on;
const btn = $('reh-bulk-toggle'); if (btn) btn.classList.toggle('active', on);
_updateBulkCount();
if (rehState.lines.length) buildScriptPage();
}
// Apply a mutation to all selected lines, then re-render.
function _bulkApply(fn, { keepSelection = false } = {}) {
if (!rehState.bulkSel.size) { toast('No lines selected', 'error'); return; }
[...rehState.bulkSel].forEach(i => { const l = rehState.lines[i]; if (l) fn(l, i); });
if (!keepSelection) rehState.bulkSel.clear();
_updateBulkCount();
buildScriptPage();
}
function _bulkDelete() {
if (!rehState.bulkSel.size) { toast('No lines selected', 'error'); return; }
const victims = [...rehState.bulkSel].sort((a, b) => b - a); // high → low so indices stay valid
victims.forEach(i => {
rehState.lines.splice(i, 1);
_reindexLineState(i);
});
rehState.bulkSel.clear();
// Keep the cursor in range
if (rehState.lineIndex >= rehState.lines.length) rehState.lineIndex = Math.max(0, rehState.lines.length - 1);
_updateBulkCount();
buildScriptPage();
highlightCurrentLine();
toast(`Deleted ${victims.length} line${victims.length !== 1 ? 's' : ''}`, 'success');
}
// After deleting line `d`, shift every index-keyed bit of state above it down by one.
function _reindexLineState(d) {
const shift = (collection, isMap) => {
const out = isMap ? new Map() : new Set();
for (const entry of collection) {
const k = isMap ? entry[0] : entry;
if (k === d) continue; // dropped line
const nk = k > d ? k - 1 : k;
if (isMap) out.set(nk, entry[1]); else out.add(nk);
}
return out;
};
rehState.synthCache = shift(rehState.synthCache, true);
rehState.staleLines = shift(rehState.staleLines, false);
const sel = shift(rehState.bulkSel, false);
rehState.bulkSel.clear(); sel.forEach(v => rehState.bulkSel.add(v));
if (rehState.practiceStart != null && rehState.practiceStart > d) rehState.practiceStart--;
if (rehState.practiceEnd != null && rehState.practiceEnd > d) rehState.practiceEnd--;
if (rehState.lineIndex > d) rehState.lineIndex--;
}
// ── Page mode (endless scroll | auto-pages | pdf-pages) ─────────────────────
const PAGE_MODES = ['auto', 'scroll', 'pdf'];
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 = ` ${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 = `
Ctrl+Enter save · Esc cancel
`;
toolbar.querySelectorAll('.reh-fmt-btn').forEach(btn => {
btn.addEventListener('mousedown', e => {
e.preventDefault();
const wrap = btn.dataset.b;
const s = ta.selectionStart, end = ta.selectionEnd;
const sel = ta.value.slice(s, end) || 'text';
ta.value = ta.value.slice(0, s) + wrap + sel + wrap + ta.value.slice(end);
ta.setSelectionRange(s + wrap.length, s + wrap.length + sel.length);
ta.focus();
});
});
targetEl.style.display = 'none';
targetEl.before(toolbar);
toolbar.after(ta);
ta.focus(); ta.select();
const save = () => {
const newText = ta.value.trim() || orig;
line.text = newText;
targetEl.innerHTML = renderMarkdownInline(newText);
targetEl.style.display = '';
toolbar.remove(); ta.remove();
rehState.synthCache.delete(idx);
const dot = document.getElementById('reh-syd-' + idx);
if (dot) dot.style.display = 'none';
};
ta.addEventListener('keydown', e => {
if (e.key === 'Escape') { ta.value = orig; save(); }
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); save(); }
});
ta.addEventListener('blur', save);
}
// ── Script editor modal ─────────────────────────────────────────────────────
function openScriptEditorModal() {
const currentTitle = $('reh-page-title')?.textContent || $('reh-script-title')?.value || 'Script';
const currentScript = linesToScriptText() || $('reh-script-text')?.value || '';
const modal = document.createElement('div');
modal.className = 'reh-modal-overlay';
modal.innerHTML = `
Edit Script
`;
document.body.appendChild(modal);
const close = () => modal.remove();
modal.querySelector('#_reh-mc').onclick = close;
modal.querySelector('#_reh-mcan').onclick = close;
modal.addEventListener('click', e => { if (e.target === modal) close(); });
modal.querySelector('#_reh-mapp').onclick = () => {
const newTitle = modal.querySelector('#_reh-mt').value.trim() || 'Script';
const newScript = modal.querySelector('#_reh-ms').value;
const newLines = parseScript(newScript);
if (!newLines.filter(l => l.type === 'dialog').length) {
toast('No dialog found in the edited script', 'error'); return;
}
// Merge emotions for unchanged lines
newLines.forEach(nl => {
if (nl.type !== 'dialog') return;
const old = rehState.lines.find(ol => ol.type==='dialog' && ol.speaker===nl.speaker && ol.text===nl.text);
if (old) nl.emotion = old.emotion;
});
// Keep cast for matching characters
const detected = detectCharacters(newLines);
Object.entries(detected).forEach(([sp]) => {
if (rehState.cast[sp]) detected[sp] = { ...detected[sp], ...rehState.cast[sp] };
});
// Update state
rehState.lines = newLines;
rehState.cast = detected;
rehState.lineIndex = Math.min(rehState.lineIndex, newLines.length - 1);
rehState.synthCache.clear(); rehDecodedBuffers.clear();
rehState.practiceStart = null;
rehState.practiceEnd = null;
if ($('reh-script-title')) $('reh-script-title').value = newTitle;
if ($('reh-page-title')) $('reh-page-title').textContent = newTitle;
buildScriptPage();
highlightCurrentLine();
close();
toast('Script updated', 'success');
};
}
// ── Practice range ──────────────────────────────────────────────────────────
function updatePracticeRange() {
const start = rehState.practiceStart;
const end = rehState.practiceEnd;
document.querySelectorAll('.reh-block').forEach(el => {
const idx = parseInt(el.dataset.index);
el.classList.toggle('reh-in-range', start !== null && idx >= start && (end === null || idx <= end));
el.classList.toggle('reh-range-start', start !== null && idx === start);
el.classList.toggle('reh-range-end', end !== null && idx === end);
const gb = el.querySelector('.reh-gutter-btn');
if (gb) gb.textContent = idx === start ? '▶' : idx === end ? '■' : '';
});
const info = $('reh-practice-info');
if (info) {
const show = end !== null;
info.classList.toggle('visible', show);
info.hidden = !show;
if (show) {
const f = $('reh-practice-from'), t = $('reh-practice-to');
if (f) f.textContent = (start !== null ? start : 0) + 1;
if (t) t.textContent = end + 1;
}
}
}
function clearPracticeRange() {
rehState.practiceStart = null;
rehState.practiceEnd = null;
updatePracticeRange();
}
function setPracticeRangeFromSelection() {
const picked = [...rehState.bulkSel].sort((a, b) => a - b);
if (!picked.length) { toast('Select the lines you want to rehearse first', 'error'); return; }
rehState.practiceStart = picked[0];
rehState.practiceEnd = picked[picked.length - 1];
rehState.lineIndex = rehState.practiceStart;
setBulkMode(false);
updatePracticeRange();
highlightCurrentLine();
toast(`Practice range set: lines ${rehState.practiceStart + 1}–${rehState.practiceEnd + 1}`, 'success');
}
$('reh-practice-clear')?.addEventListener('click', clearPracticeRange);
$('reh-bulk-range')?.addEventListener('click', setPracticeRangeFromSelection);
// ── Emotion picker popover ──────────────────────────────────────────────────
let rehEmoPicker = null;
function openEmoPicker(idx, anchorBtn) {
closeEmoPicker();
const line = rehState.lines[idx];
const allEmos = [...REH_EMOTIONS, ...rehCustomEmotions];
const rect = anchorBtn.getBoundingClientRect();
const left = Math.min(rect.left, window.innerWidth - 265);
const topBelow = rect.bottom + 4;
const topAbove = rect.top - 4;
const spaceBelow = window.innerHeight - topBelow;
const top = spaceBelow >= 240 ? topBelow : (topAbove - 260);
const pop = document.createElement('div');
pop.className = 'reh-emo-popover';
pop.style.cssText = `position:fixed;z-index:1000;top:${top}px;left:${left}px;width:258px;`;
pop.innerHTML = ``;
document.body.appendChild(pop);
rehEmoPicker = pop;
const searchEl = pop.querySelector('.reh-emo-search');
const listEl = pop.querySelector('.reh-emo-list');
const applyBtn = pop.querySelector('#_emo-custom-apply');
function renderList(q = '') {
const lq = q.toLowerCase();
const filtered = allEmos.filter(e => !q || e.label.toLowerCase().includes(lq) || e.value.toLowerCase().includes(lq));
listEl.innerHTML = filtered.map(e =>
`
${e.emoji}
${escHtml(e.label)}
${line.emotion===e.value?'':''}
`
).join('');
listEl.querySelectorAll('.reh-emo-item').forEach(item => {
item.addEventListener('mousedown', e => {
e.preventDefault();
selectEmotion(idx, item.dataset.value, anchorBtn);
closeEmoPicker();
});
});
}
searchEl.addEventListener('input', () => renderList(searchEl.value));
searchEl.addEventListener('keydown', e => {
if (e.key === 'Escape') closeEmoPicker();
if (e.key === 'Enter') {
const val = searchEl.value.trim();
if (val) { selectEmotion(idx, val, anchorBtn); closeEmoPicker(); }
}
});
applyBtn.addEventListener('mousedown', e => {
e.preventDefault();
const val = searchEl.value.trim(); if (!val) return;
const exists = [...REH_EMOTIONS, ...rehCustomEmotions].find(e => e.value === val);
if (!exists) {
rehCustomEmotions.push({ emoji: '✨', label: val, value: val, custom: true });
try { localStorage.setItem('reh-custom-emotions', JSON.stringify(rehCustomEmotions)); } catch(_) {}
}
selectEmotion(idx, val, anchorBtn);
closeEmoPicker();
});
renderList();
searchEl.focus();
setTimeout(() => document.addEventListener('mousedown', _closePickerOnOutside), 50);
}
function _closePickerOnOutside(e) {
if (rehEmoPicker && !rehEmoPicker.contains(e.target)) closeEmoPicker();
}
function closeEmoPicker() {
if (rehEmoPicker) { rehEmoPicker.remove(); rehEmoPicker = null; }
document.removeEventListener('mousedown', _closePickerOnOutside);
}
function _markSynthDot(idx, state) {
const dot = document.getElementById('reh-syd-' + idx);
if (!dot) return;
dot.className = 'reh-synth-dot'
+ (state === 'stale' ? ' stale' : '')
+ (state === 'synthesizing' ? ' synthesizing' : '');
dot.style.display = state ? '' : 'none';
dot.title = state === 'stale' ? 'Tone changed — needs re-synthesis'
: state === 'synthesizing' ? 'Synthesizing…'
: 'Pre-synthesized';
// Also highlight the block row itself while synthesizing
const block = dot.closest('[data-index]');
if (block) block.classList.toggle('reh-line-synthesizing', state === 'synthesizing');
}
function _showReSynthBtn(idx, show) {
const btn = document.getElementById('reh-rsb-' + idx);
if (btn) btn.hidden = !show;
}
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 + ' ' : ''}${escHtml(info.label)} `;
// 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 = `${escHtml(b.label)} keeps each character’s voice consistent and applies tone. Per-line tones are sent as inline [tags] (e.g. [whisper], [excited], [laughing]). You can also type a custom tone like [professional broadcast tone] — S2 supports free-form descriptions. Fish-Speech S2 ↗`;
warn.hidden = false;
} else if (!b.style_aware && hasTone) {
const styleAware = all.find(x => x.style_aware);
const suggest = styleAware ? ` Switch to ${escHtml(styleAware.label)} for reliable tone — but expect each voice to drift between lines.` : '';
if (txtEl) txtEl.innerHTML = `${escHtml(b.label)} keeps each character’s 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 ${escHtml(wavBackend.label)} to keep each character’s 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 = `${escHtml(b.label)} gives strong tone but re-generates a fresh voice each line, so a character won’t 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) =>
`${escHtml(t.word)}`
).join(' ');
}
return new Promise(resolve => {
stopAudioSource();
const ctx = rehPlayCtx();
const src = ctx.createBufferSource();
src.buffer = buf;
src.connect(ctx.destination);
rehCurrentSource = src;
const t0 = ctx.currentTime;
src.onended = () => {
rehCurrentSource = null;
if (rehWordHighlightRaf) { cancelAnimationFrame(rehWordHighlightRaf); rehWordHighlightRaf = null; }
if (dialogEl && timings.length >= 2) dialogEl.innerHTML = renderMarkdownInline(text);
resolve();
};
src.start(0);
// Pre-decode the line AFTER this one while this is playing
const nextI = findNextCachedLine(lineIdx + 1);
if (nextI >= 0) preDecodeBlob(nextI, rehState.synthCache.get(nextI));
if (dialogEl && timings.length >= 2) {
const tick = () => {
if (rehCurrentSource !== src) return;
const elapsed = ctx.currentTime - t0;
let active = 0;
for (let i = timings.length - 1; i >= 0; i--) {
if (elapsed >= timings[i].start) { active = i; break; }
}
dialogEl.querySelectorAll('.reh-word').forEach((span, i) => {
span.classList.toggle('reh-word-active', i === active);
});
rehWordHighlightRaf = requestAnimationFrame(tick);
};
rehWordHighlightRaf = requestAnimationFrame(tick);
}
});
}
function findNextCachedLine(fromIdx) {
for (let i = fromIdx; i < rehState.lines.length; i++) {
if (rehState.lines[i].type === 'dialog' && rehState.synthCache.has(i)) return i;
}
return -1;
}
// Fallback when AudioContext decode fails
async function playAudioBlobFallback(blob) {
const url = URL.createObjectURL(blob);
const audio = $('reh-tts-audio'); if (!audio) return;
audio.src = url; audio.style.display = '';
await new Promise(r => { audio.addEventListener('canplay', r, { once: true }); setTimeout(r, 3000); });
await audio.play().catch(() => {});
await waitForAudioEnd(audio);
}
// ── Auto-play sequence ──────────────────────────────────────────────────────
function startPlay() {
rehPlayCtx(); // warm up AudioContext on user gesture
_ensureNarrator(); // make sure rehState.narratorVoice reflects the narrator cast row
rehState.playing = true;
updatePlayBtn();
playNextLine();
}
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 ? '' : '';
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 = `${escHtml(line.speaker)} — your line:${escHtml(stripMarkdown(line.text))}
`;
if ($('reh-rec-preview')) { $('reh-rec-preview').style.display='none'; $('reh-rec-preview').src=''; }
if ($('reh-rec-confirm-row')) $('reh-rec-confirm-row').hidden = true;
if ($('reh-rec-start')) $('reh-rec-start').disabled = false;
if ($('reh-rec-stop')) $('reh-rec-stop').disabled = true;
if ($('reh-rec-time')) $('reh-rec-time').textContent = '0:00';
rehState.lastRecBlob = null;
}
function hideRecOverlay() {
const overlay = $('reh-rec-overlay'); if (overlay) overlay.hidden = true;
stopRehMic();
}
// ── Mic recording ───────────────────────────────────────────────────────────
function rehRenderMeter(level=0, db=-Infinity, clipped=false) {
const meter = $('reh-mic-meter'); if (!meter) return;
if (!meter.children.length) for (let i=0;i<18;i++){const b=document.createElement('div');b.className='bar';meter.appendChild(b);}
const active = Math.round(Math.max(0,Math.min(1,level))*meter.children.length);
[...meter.children].forEach((bar,i)=>{
bar.className='bar'; bar.style.height=(7+Math.min(i,active)*1.55)+'px';
if(i-12&&i>11)bar.classList.add('hot');if(clipped&&i>14)bar.classList.add('clip');}
});
const el=$('reh-db-readout');if(el)el.textContent=Number.isFinite(db)?db.toFixed(1)+' dB':'-∞ dB';
}
function rehStartMeter() {
if (!rehState.recAnalyser) return;
if (rehState.recMeterRaf) cancelAnimationFrame(rehState.recMeterRaf);
const data=new Float32Array(rehState.recAnalyser.fftSize), canvas=$('reh-live-wave'), RING=300, ADD=10;
rehState.recWaveRing = new Float32Array(RING);
const tick=()=>{
rehState.recAnalyser.getFloatTimeDomainData(data);
let sum=0,peak=0; for(const s of data){sum+=s*s;peak=Math.max(peak,Math.abs(s));}
const rms=Math.sqrt(sum/data.length), db=rms>0?20*Math.log10(rms):-Infinity;
rehRenderMeter((db+60)/60,db,peak>0.98);
if(canvas&&rehState.recWaveRing){
const ring=rehState.recWaveRing; ring.copyWithin(0,ADD);
for(let i=0;i0.98?'#f38ba8':db>-12?'#f9e2af':'#a6e3a1'; ctx.lineWidth=1.5;
const mid=h/2;
for(let i=0;i{try{if(n)n.disconnect();}catch(_){}});
if (rehState.recStream) rehState.recStream.getTracks().forEach(t=>t.stop());
if (rehState.recDestStream) rehState.recDestStream.getTracks().forEach(t=>t.stop());
if (rehState.recAudioCtx) rehState.recAudioCtx.close().catch(()=>{});
Object.assign(rehState,{recStream:null,recDestStream:null,recSourceNode:null,recGainNode:null,recAnalyser:null,recAudioCtx:null,recWaveRing:null});
rehRenderMeter();
const wc=$('reh-live-wave');if(wc)wc.getContext('2d').clearRect(0,0,wc.width,wc.height);
}
$('reh-rec-start')?.addEventListener('click', async () => {
try {
await startRehMic();
rehState.recChunks=[]; rehState.recSecs=0;
if($('reh-rec-time'))$('reh-rec-time').textContent='0:00';
if($('reh-rec-start'))$('reh-rec-start').disabled=true;
if($('reh-rec-stop'))$('reh-rec-stop').disabled=false;
if($('reh-rec-confirm-row'))$('reh-rec-confirm-row').hidden=true;
rehState.recTimer=setInterval(()=>{
rehState.recSecs++;
if($('reh-rec-time'))$('reh-rec-time').textContent=Math.floor(rehState.recSecs/60)+':'+String(rehState.recSecs%60).padStart(2,'0');
},1000);
rehState.mediaRec=new MediaRecorder(rehState.recDestStream||rehState.recStream,{audioBitsPerSecond:256000});
rehState.mediaRec.ondataavailable=e=>{if(e.data.size)rehState.recChunks.push(e.data);};
rehState.mediaRec.onstop=()=>{
clearInterval(rehState.recTimer);
if($('reh-rec-start'))$('reh-rec-start').disabled=false;
if($('reh-rec-stop'))$('reh-rec-stop').disabled=true;
const blob=new Blob(rehState.recChunks,{type:rehState.mediaRec.mimeType||'audio/webm'});
const url=URL.createObjectURL(blob);
const p=$('reh-rec-preview');if(p){p.src=url;p.style.display='';}
if($('reh-rec-confirm-row'))$('reh-rec-confirm-row').hidden=false;
rehState.lastRecBlob=blob;
};
rehState.mediaRec.start(100);
} catch(e){stopRehMic();toast(await microphoneErrorMessage(e),'error');}
});
$('reh-rec-stop')?.addEventListener('click', () => { if(rehState.mediaRec?.state!=='inactive')rehState.mediaRec.stop(); });
$('reh-rec-keep')?.addEventListener('click', () => {
if (rehState.lastRecBlob)
rehState.clips.push({lineIndex:rehState.lineIndex, speaker:rehState.lines[rehState.lineIndex]?.speaker, type:'me', blob:rehState.lastRecBlob});
stopRehMic(); hideRecOverlay();
rehState.lineIndex++;
startPlay();
});
$('reh-rec-redo')?.addEventListener('click', () => { stopRehMic(); const l=rehState.lines[rehState.lineIndex]; if(l)showRecOverlay(l); });
$('reh-skip-line')?.addEventListener('click', () => {
rehState.clips.push({lineIndex:rehState.lineIndex, speaker:rehState.lines[rehState.lineIndex]?.speaker, type:'skip'});
stopRehMic(); hideRecOverlay();
rehState.lineIndex++;
startPlay();
});
// ── Phase 4: Summary ────────────────────────────────────────────────────────
function renderSummary() {
const list = $('reh-summary-list'); if (!list) return;
if (!rehState.clips.length) { list.innerHTML='No clips in this session.
'; return; }
list.innerHTML = rehState.clips.map((clip,idx)=>{
const line=rehState.lines[clip.lineIndex]||{text:'—',speaker:clip.speaker};
const c=rehState.cast[clip.speaker]||{color:'#89b4fa'};
const typeLabel=clip.type==='me'?'🎤 Recorded':clip.type==='tts'?'🔊 Synthesized':'⏭ Skipped';
const audioHtml=clip.blob?``:'';
const dlHtml=clip.blob?``:'';
return `
${escHtml(clip.speaker||'—')} ${typeLabel}
${escHtml(stripMarkdown(line.text))}
${audioHtml}
${dlHtml}
`;
}).join('');
}
$('reh-new-session-btn')?.addEventListener('click', () => {
stopPlay(); stopRehMic();
rehState.lines=[]; rehState.cast={}; rehState.clips=[]; rehState.lineIndex=0;
rehState.savedId=null; rehState.synthCache.clear(); rehDecodedBuffers.clear(); rehState.narratorVoice='';
rehState.practiceStart=null; rehState.practiceEnd=null;
if($('reh-script-text'))$('reh-script-text').value='';
if($('reh-script-title'))$('reh-script-title').value='';
renderLibraryList(); showPhase(1);
});
$('reh-resume-btn')?.addEventListener('click', ()=>{showPhase(3);highlightCurrentLine();});
$('reh-save-session-btn')?.addEventListener('click', saveToLibrary);
$('reh-cast-save-btn')?.addEventListener('click', saveToLibrary);
$('reh-export-session-btn')?.addEventListener('click', exportToFile);
$('reh-tb-save')?.addEventListener('click', saveToLibrary);
$('reh-tb-export')?.addEventListener('click', exportToFile);
$('reh-lib-import-file')?.addEventListener('change', async function(){
const f=this.files?.[0]; if(!f)return;
await importFromFile(f); this.value='';
});
// ── Import/Export panel wiring ─────────────────────────────────────────────
document.querySelectorAll('.reh-impex-file').forEach(inp => {
inp.addEventListener('change', async function () {
const f = this.files?.[0]; if (!f) return;
await importFromFile(f);
this.value = '';
// After import, return to Library
navRehearserPhase(1);
});
});
$('reh-impex-reh-btn')?.addEventListener('click', exportToFile);
$('reh-impex-fountain-btn')?.addEventListener('click', () => { $('reh-fountain-btn')?.click(); });
$('reh-impex-fdx-btn')?.addEventListener('click', () => { $('reh-fdx-export-btn')?.click(); });
$('reh-impex-osf-btn')?.addEventListener('click', () => { $('reh-osf-export-btn')?.click(); });
// ── Plain-text + Markdown export ───────────────────────────────────────────
function _downloadText(content, filename) {
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([content], { type: 'text/plain;charset=utf-8' }));
a.download = filename; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 3000);
}
function exportAsTxt() {
const title = $('reh-script-title')?.value.trim() || $('reh-page-title')?.textContent || 'script';
_downloadText(linesToScriptText(), title.replace(/[^a-z0-9]/gi, '_') + '.txt');
}
function exportAsMd() {
const title = $('reh-script-title')?.value.trim() || 'Script';
const lines = rehState.lines.map(l => {
switch (l.type) {
case 'act': return `\n# ${l.text}\n`;
case 'scene': return `\n## ${l.text}\n`;
case 'transition': return `\n*${l.text}*\n`;
case 'direction': return `*(${l.text})*`;
case 'action': return `\n${l.text}\n`;
case 'dialog': return `\n**${l.speaker}**\n${l.text}\n`;
default: return '';
}
}).join('\n');
_downloadText(`# ${title}\n\n${lines.trim()}\n`, title.replace(/[^a-z0-9]/gi, '_') + '.md');
}
$('reh-impex-txt-btn')?.addEventListener('click', exportAsTxt);
$('reh-impex-md-btn')?.addEventListener('click', exportAsMd);
// ── IMSDb / URL import ─────────────────────────────────────────────────────
(function initUrlImport() {
const inp = $('reh-impex-url');
const btn = $('reh-impex-url-btn');
const status = $('reh-impex-url-status');
if (!inp || !btn) return;
async function fetchUrl() {
const url = inp.value.trim();
if (!url) { toast('Paste a URL first', 'error'); return; }
btn.disabled = true;
if (status) { status.textContent = 'Fetching…'; status.style.color = ''; }
try {
const r = await fetch('/api/fetch-web-script', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
// Put script into the textarea and set title
const ta = $('reh-script-text');
if (ta) ta.value = d.text;
const titleInp = $('reh-script-title');
if (titleInp && d.title) titleInp.value = d.title.replace(/-/g,' ').replace(/\b\w/g,c=>c.toUpperCase());
if (status) {
status.textContent = `✓ Fetched ${(d.chars/1000).toFixed(0)} K chars — scroll down to Parse & cast`;
status.style.color = 'var(--green)';
}
// Navigate to Library phase so user sees the script text area
navRehearserPhase(1);
// Scroll the script textarea into view
setTimeout(() => ta?.scrollIntoView({ behavior:'smooth', block:'nearest' }), 300);
toast(`Fetched "${d.title}" — click "Parse & cast" to continue`, 'success');
} catch(e) {
if (status) { status.textContent = '✗ ' + e.message; status.style.color = 'var(--red)'; }
toast('Fetch failed: ' + e.message, 'error');
} finally { btn.disabled = false; }
}
btn.addEventListener('click', fetchUrl);
inp.addEventListener('keydown', e => { if (e.key === 'Enter') fetchUrl(); });
})();
// ── IMSDb library browser (poster grid) ────────────────────────────────────
(function initImsdbBrowser() {
const modal = $('reh-imsdb-modal');
const grid = $('reh-imsdb-grid');
const search = $('reh-imsdb-search');
const countEl = $('reh-imsdb-count');
const closeBtn = $('reh-imsdb-close');
const coverBtn = $('reh-imsdb-cover-btn');
const listBtn = $('reh-imsdb-list-btn');
const urlInp = $('reh-imsdb-url');
const urlBtn = $('reh-imsdb-url-fetch');
if (!modal || !grid) return;
const CACHE_KEY = 'reh-imsdb-cat-v1';
const CACHE_TTL = 6 * 3600 * 1000;
let _catalogue = null;
let _posterObserver = null;
let _view = localStorage.getItem('reh-imsdb-view') || 'list'; // 'cover' | 'list'
function _applyView() {
grid.classList.toggle('list-view', _view === 'list');
if (coverBtn) coverBtn.classList.toggle('active', _view === 'cover');
if (listBtn) listBtn.classList.toggle('active', _view === 'list');
}
function _ensurePosterObserver() {
if (_posterObserver) return _posterObserver;
_posterObserver = new IntersectionObserver((entries) => {
entries.forEach(async (ent) => {
if (!ent.isIntersecting) return;
const card = ent.target;
_posterObserver.unobserve(card);
const title = card.dataset.title;
try {
const d = await fetch('/api/movie-poster?title=' + encodeURIComponent(title)).then(r => r.json());
if (d.poster) {
const img = document.createElement('img');
img.className = 'reh-imsdb-poster';
img.loading = 'lazy'; img.src = d.poster; img.alt = title;
img.onload = () => {
const wrap = card.querySelector('.reh-imsdb-poster-wrap');
if (wrap) {
wrap.querySelector('.reh-imsdb-fallback')?.remove();
wrap.appendChild(img);
}
};
}
if (d.year) {
card.querySelectorAll('.reh-imsdb-year').forEach(y => { y.textContent = d.year; });
}
} catch(_) {}
});
}, { root: grid, rootMargin: '300px' });
return _posterObserver;
}
function _bookColor(title) {
let h = 0; for (let i = 0; i < title.length; i++) h = (h * 31 + title.charCodeAt(i)) >>> 0;
return `hsl(${h % 360}, 45%, 42%)`;
}
function renderGrid(items) {
const obs = _ensurePosterObserver();
grid.innerHTML = '';
if (!items.length) { grid.innerHTML = 'No matches.
'; return; }
const frag = document.createDocumentFragment();
items.slice(0, 400).forEach(it => {
const card = document.createElement('div');
card.className = 'reh-imsdb-card';
card.dataset.title = it.title;
card.dataset.url = it.fetch_url;
const color1 = _bookColor(it.title);
const color2 = _bookColor(it.title + '_');
card.innerHTML = `
${escHtml(it.title)}
`;
card.addEventListener('click', () => importImsdb(it));
frag.appendChild(card);
obs.observe(card);
});
grid.appendChild(frag);
if (countEl) countEl.textContent = `${items.length} script${items.length!==1?'s':''}${items.length>400?' (showing 400)':''}`;
_applyView();
}
async function importAnyWebUrl(url, label = 'script') {
url = String(url || '').trim();
if (!url) { toast('Paste a script URL first', 'error'); return; }
if (!/^https?:\/\//i.test(url)) { toast('URL must start with http:// or https://', 'error'); return; }
if (urlBtn) urlBtn.disabled = true;
grid.innerHTML = ` Fetching ${escHtml(label)}…
`;
try {
const r = await fetch('/api/fetch-web-script', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
const ta = $('reh-script-text'); if (ta) ta.value = d.text;
const ti = $('reh-script-title'); if (ti) ti.value = d.title || label;
closeModal();
navRehearserPhase(1);
setTimeout(() => ta?.scrollIntoView({ behavior:'smooth', block:'center' }), 250);
toast(`Loaded "${d.title || label}" (${(d.chars/1000).toFixed(0)} K) — click "Parse & cast"`, 'success');
} catch(e) {
grid.innerHTML = `Fetch failed: ${escHtml(e.message)}
`;
if (_catalogue) renderGrid(_searchFilter(_catalogue));
toast('Script fetch failed: ' + e.message, 'error');
} finally {
if (urlBtn) urlBtn.disabled = false;
}
}
async function importImsdb(it) {
await importAnyWebUrl(it.fetch_url, it.title || 'IMSDb script');
}
async function _loadCatalogue() {
// Try localStorage cache first
try {
const raw = localStorage.getItem(CACHE_KEY);
if (raw) {
const cached = JSON.parse(raw);
if (Date.now() - cached.ts < CACHE_TTL && Array.isArray(cached.items) && cached.items.length) {
_catalogue = cached.items;
return;
}
}
} catch(_) {}
// Fetch from server
const d = await fetch('/api/imsdb/list').then(r => r.json());
_catalogue = d.items || [];
try {
localStorage.setItem(CACHE_KEY, JSON.stringify({ ts: Date.now(), items: _catalogue }));
} catch(_) {}
}
async function openModal() {
modal.hidden = false;
if (urlInp) urlInp.value = '';
_applyView();
if (!_catalogue) {
grid.innerHTML = ' Loading catalogue…
';
try {
await _loadCatalogue();
} catch(e) {
grid.innerHTML = `Failed to load: ${escHtml(e.message)}
`;
return;
}
}
renderGrid(_searchFilter(_catalogue));
search?.focus();
}
function closeModal() { modal.hidden = true; }
function _searchFilter(items) {
const q = search?.value.trim().toLowerCase() || '';
return q ? items.filter(it => it.title.toLowerCase().includes(q)) : items;
}
let _searchTimer = null;
search?.addEventListener('input', () => {
clearTimeout(_searchTimer);
_searchTimer = setTimeout(() => {
if (!_catalogue) return;
renderGrid(_searchFilter(_catalogue));
}, 150);
});
search?.addEventListener('keydown', e => {
if (e.key === 'Enter' && /^https?:\/\//i.test(search.value.trim())) {
e.preventDefault();
importAnyWebUrl(search.value.trim(), 'pasted URL');
}
});
urlBtn?.addEventListener('click', () => importAnyWebUrl(urlInp?.value || '', 'pasted URL'));
urlInp?.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); importAnyWebUrl(urlInp.value, 'pasted URL'); } });
coverBtn?.addEventListener('click', () => {
_view = 'cover'; localStorage.setItem('reh-imsdb-view', _view); _applyView();
});
listBtn?.addEventListener('click', () => {
_view = 'list'; localStorage.setItem('reh-imsdb-view', _view); _applyView();
});
// Wire all open buttons (Library tab + Import/Export tab)
[$('reh-browse-imsdb-btn'), $('reh-browse-imsdb-btn-impex')].forEach(btn => {
btn?.addEventListener('click', openModal);
});
closeBtn?.addEventListener('click', closeModal);
modal.addEventListener('click', e => { if (e.target === modal) closeModal(); });
})();
// ── Train Mode ────────────────────────────────────────────────────────────
// State machine: idle → playing_cue → ready → recording → transcribing → done
// Each "turn" = one set of cue lines + one "me" line the user must speak.
const trainState = {
seq: [], // [{cueLines:[{index,speaker,text,voice,instruct}], myLine:{index,speaker,text}}]
turn: 0, // current turn index
phase: 'idle', // idle | playing_cue | ready | recording | transcribing | done
cueSource: null, // AudioBufferSourceNode currently playing
mediaRec: null, // MediaRecorder for train recording
recChunks: [],
recRaf: null, // rAF id for meter
recStream: null, // MediaStream
recCtx: null, // AudioContext for meter
recAnalyser: null,
recTimer: null,
recSecs: 0,
};
function _trainNormWords(str) {
return (str || '').toLowerCase().replace(/[^a-zäöüàáâãèéêëìíîïòóôõùúûüýÿæœßа-яёА-ЯЁ0-9\s]/g, '').trim().split(/\s+/).filter(Boolean);
}
function _trainLcs(a, b) {
const R = a.length, C = b.length;
const dp = Array.from({length: R + 1}, () => new Int16Array(C + 1));
for (let i = 1; i <= R; i++) for (let j = 1; j <= C; j++)
dp[i][j] = a[i-1] === b[j-1] ? dp[i-1][j-1] + 1 : Math.max(dp[i-1][j], dp[i][j-1]);
let i = R, j = C; const seq = [];
while (i > 0 && j > 0) {
if (a[i-1] === b[j-1]) { seq.unshift([i-1, j-1]); i--; j--; }
else if (dp[i-1][j] >= dp[i][j-1]) i--;
else j--;
}
return seq;
}
function _trainCompare(expected, actual) {
const ew = _trainNormWords(expected), aw = _trainNormWords(actual);
if (!ew.length) return { html_exp: '', html_act: '', score: 1 };
const pairs = _trainLcs(ew, aw);
const matchedE = new Set(pairs.map(p => p[0]));
const matchedA = new Set(pairs.map(p => p[1]));
const expHtml = ew.map((w, i) => matchedE.has(i)
? `${escHtml(w)}`
: `${escHtml(w)}`).join(' ');
const actHtml = aw.map((w, i) => matchedA.has(i)
? `${escHtml(w)}`
: ``).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 = ''; }
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 =>
`
${escHtml(c.speaker)}
${escHtml(c.text)}
`
).join('');
}
cueCard?.classList.remove('no-cue');
} else {
if (cueAvEl) cueAvEl.innerHTML = '';
if (cueSpeaker) cueSpeaker.textContent = 'No preceding cue';
if (cueText) cueText.textContent = 'This is the first line — speak when ready.';
cueCard?.classList.add('no-cue');
}
// Render my line card
const mySpeaker = $('reh-train-my-speaker');
const myText = $('reh-train-my-text');
if (mySpeaker) mySpeaker.textContent = turn.myLine.speaker;
if (myText) myText.textContent = turn.myLine.text;
// Hide result from previous turn
const resultEl = $('reh-train-result');
if (resultEl) resultEl.hidden = true;
// Reset audio preview
const prevAudio = $('reh-train-audio-preview');
if (prevAudio) { prevAudio.src = ''; prevAudio.style.display = 'none'; }
// Show the script text immediately — user reads first, then clicks ▶ to hear the cue
_trainSetState(turn.cueLines.length ? 'idle' : 'ready');
}
function _trainStartMeter() {
const meterEl = $('reh-train-meter');
const dbEl = $('reh-train-db');
const waveEl = $('reh-train-wave');
if (!trainState.recAnalyser || !meterEl) return;
const analyser = trainState.recAnalyser;
const fft = new Uint8Array(analyser.frequencyBinCount);
const wCtx = waveEl?.getContext('2d');
const W = waveEl?.width || 300, H = waveEl?.height || 36;
const BARS = 18;
function tick() {
analyser.getByteFrequencyData(fft);
const rms = fft.reduce((s, v) => s + v * v, 0) / fft.length;
const db = rms > 0 ? 20 * Math.log10(Math.sqrt(rms) / 128) : -Infinity;
if (dbEl) dbEl.textContent = isFinite(db) ? db.toFixed(1) + ' dB' : '-∞ dB';
const slots = Array.from({length: BARS}, (_, i) => {
const s = Math.floor(i / BARS * fft.length), e = Math.floor((i+1) / BARS * fft.length);
return fft.slice(s, e).reduce((a, b) => a + b, 0) / (e - s) / 255;
});
meterEl.innerHTML = slots.map(v => {
const h = Math.max(2, Math.round(v * 28));
const col = v > 0.85 ? 'var(--red)' : v > 0.6 ? '#f59e0b' : 'var(--green)';
return ``;
}).join('');
if (wCtx) {
analyser.getByteTimeDomainData(fft);
wCtx.clearRect(0, 0, W, H);
wCtx.beginPath(); wCtx.strokeStyle = 'var(--accent)'; wCtx.lineWidth = 1.5;
fft.forEach((v, i) => { const x = (i / fft.length) * W, y = (v / 255) * H; i ? wCtx.lineTo(x, y) : wCtx.moveTo(x, y); });
wCtx.stroke();
}
trainState.recRaf = requestAnimationFrame(tick);
}
trainState.recRaf = requestAnimationFrame(tick);
}
function _trainStopMic() {
if (trainState.recRaf) { cancelAnimationFrame(trainState.recRaf); trainState.recRaf = null; }
if (trainState.recTimer) { clearInterval(trainState.recTimer); trainState.recTimer = null; }
if (trainState.mediaRec && trainState.mediaRec.state !== 'inactive') {
try { trainState.mediaRec.stop(); } catch(_) {}
}
trainState.mediaRec = null;
if (trainState.recCtx) { try { trainState.recCtx.close(); } catch(_) {} trainState.recCtx = null; }
if (trainState.recStream) { trainState.recStream.getTracks().forEach(t => t.stop()); trainState.recStream = null; }
trainState.recAnalyser = null;
trainState.recChunks = [];
const timerEl = $('reh-train-rec-timer');
if (timerEl) { timerEl.hidden = true; timerEl.textContent = '0:00'; }
const metersEl = $('reh-train-meters');
if (metersEl) metersEl.hidden = true;
}
async function _trainStartRecording() {
if (trainState.phase !== 'ready') return;
try {
const stream = await requestMicrophoneStream({ raw: true });
trainState.recStream = stream;
const ctx = new AudioContext();
trainState.recCtx = ctx;
const src = ctx.createMediaStreamSource(stream);
const analyser = ctx.createAnalyser(); analyser.fftSize = 256;
trainState.recAnalyser = analyser;
const dst = ctx.createMediaStreamDestination();
src.connect(analyser); analyser.connect(dst);
_trainSetState('recording');
_trainStartMeter();
const timerEl = $('reh-train-rec-timer');
if (timerEl) timerEl.hidden = false;
trainState.recSecs = 0;
trainState.recTimer = setInterval(() => {
trainState.recSecs++;
const m = Math.floor(trainState.recSecs / 60), s = trainState.recSecs % 60;
if (timerEl) timerEl.textContent = `${m}:${String(s).padStart(2,'0')}`;
}, 1000);
trainState.recChunks = [];
const mr = new MediaRecorder(dst.stream, { audioBitsPerSecond: 128000 });
trainState.mediaRec = mr;
mr.ondataavailable = e => { if (e.data.size > 0) trainState.recChunks.push(e.data); };
mr.onstop = async () => {
_trainStopMic();
const blob = new Blob(trainState.recChunks, { type: 'audio/webm' });
trainState.recChunks = [];
const url = URL.createObjectURL(blob);
const prevAudio = $('reh-train-audio-preview');
if (prevAudio) { prevAudio.src = url; prevAudio.style.display = ''; }
// Transcribe
const transcript = await _trainTranscribe(blob);
const turn = trainState.seq[trainState.turn];
const { html_exp, html_act, score } = _trainCompare(turn?.myLine?.text || '', transcript);
const resultEl = $('reh-train-result');
const expEl = $('reh-train-expected-text');
const actEl = $('reh-train-actual-text');
const scoreEl = $('reh-train-score');
if (expEl) expEl.innerHTML = html_exp;
if (actEl) actEl.innerHTML = html_act || 'Nothing detected';
if (scoreEl) {
const col = score >= 90 ? 'var(--green)' : score >= 60 ? '#f59e0b' : 'var(--red)';
scoreEl.innerHTML = `${score}%`;
}
if (resultEl) resultEl.hidden = false;
_trainSetState('done');
};
mr.start();
} catch(e) {
toast('Microphone error: ' + e.message, 'error');
_trainSetState('ready');
}
}
function _trainStopRecording() {
if (trainState.mediaRec && trainState.mediaRec.state === 'recording') {
trainState.mediaRec.stop();
}
}
function _trainHideStage(hide) {
// Show/hide the script content area and auxiliary bars — transport bar stays visible always
const els = ['.reh-stage-area', '#reh-tts-status-bar', '#reh-rec-overlay', '#reh-synth-bar'];
els.forEach(sel => {
const el = document.querySelector(sel) || document.getElementById(sel.replace('#',''));
if (el) el.style.display = hide ? 'none' : '';
});
}
function enterTrainMode() {
const seq = buildTrainSeq();
if (!seq.length) {
toast('No "I play this" lines found — check "I play this" on at least one character in the Cast tab.', 'error');
return;
}
trainState.seq = seq;
trainState.turn = 0;
trainState.phase = 'idle';
_trainHideStage(true);
const panel = $('reh-train-panel');
if (panel) panel.hidden = false;
trainGoTo(0);
}
function exitTrainMode() {
if (trainState.cueAudio) { trainState.cueAudio.pause(); trainState.cueAudio = null; }
_trainStopMic();
trainState.phase = 'idle';
trainState.seq = [];
_trainHideStage(false);
const panel = $('reh-train-panel');
if (panel) panel.hidden = true;
}
// ── Bulk-edit button wiring ─────────────────────────────────────────────────
$('reh-bulk-toggle')?.addEventListener('click', () => setBulkMode(!rehState.bulkMode));
$('reh-bulk-done')?.addEventListener('click', () => setBulkMode(false));
$('reh-bulk-all')?.addEventListener('click', () => {
// Select every line currently visible in the stage
document.querySelectorAll('#reh-script-lines .reh-bulk-check').forEach(cb => rehState.bulkSel.add(parseInt(cb.dataset.bulk)));
rehState.bulkAnchor = rehState.bulkSel.size ? Math.min(...rehState.bulkSel) : null;
document.querySelectorAll('#reh-script-lines .reh-bulk-check').forEach(cb => _refreshBulkLine(parseInt(cb.dataset.bulk)));
_updateBulkCount();
});
$('reh-bulk-none')?.addEventListener('click', () => {
const had = [...rehState.bulkSel];
rehState.bulkSel.clear();
rehState.bulkAnchor = null;
had.forEach(_refreshBulkLine);
_updateBulkCount();
});
$('reh-bulk-ignore')?.addEventListener('click', () => _bulkApply(l => { l.ignored = true; }));
$('reh-bulk-unignore')?.addEventListener('click', () => _bulkApply(l => { l.ignored = false; }));
$('reh-bulk-hide')?.addEventListener('click', () => _bulkApply(l => { l.hidden = true; }));
$('reh-bulk-delete')?.addEventListener('click', _bulkDelete);
$('reh-bulk-show-hidden')?.addEventListener('change', e => {
rehState.showHidden = e.target.checked;
buildScriptPage();
});
// ── Train button wiring ─────────────────────────────────────────────────────
$('reh-page-mode-btn')?.addEventListener('click', cyclePageMode);
$('reh-train-open-btn')?.addEventListener('click', enterTrainMode);
$('reh-train-exit-btn')?.addEventListener('click', exitTrainMode);
$('reh-train-play')?.addEventListener('click', () => {
const turn = trainState.seq[trainState.turn];
if (!turn) return;
if (trainState.phase === 'playing_cue') return;
if (trainState.cueAudio) { trainState.cueAudio.pause(); trainState.cueAudio = null; }
if (turn.cueLines.length) _trainPlayCueLines(turn.cueLines);
else _trainSetState('ready');
});
$('reh-train-record')?.addEventListener('click', () => {
if (trainState.phase === 'ready' || trainState.phase === 'done') _trainStartRecording();
});
$('reh-train-stop-rec')?.addEventListener('click', _trainStopRecording);
$('reh-train-prev')?.addEventListener('click', () => {
if (trainState.turn > 0) trainGoTo(trainState.turn - 1);
});
$('reh-train-next')?.addEventListener('click', () => {
if (trainState.turn < trainState.seq.length - 1) trainGoTo(trainState.turn + 1);
});
$('reh-train-repeat')?.addEventListener('click', () => {
if (trainState.cueAudio) { trainState.cueAudio.pause(); trainState.cueAudio = null; }
_trainStopMic();
const turn = trainState.seq[trainState.turn];
if (!turn) return;
const resultEl = $('reh-train-result');
if (resultEl) resultEl.hidden = true;
if (turn.cueLines.length) _trainPlayCueLines(turn.cueLines);
else _trainSetState('ready');
});
// ── Init ───────────────────────────────────────────────────────────────────
rehRenderMeter();
_syncPageModeBtn();
renderLibraryList().catch(()=>{});
if($('reh-skip-desc-toggle'))$('reh-skip-desc-toggle').checked=rehState.skipDescriptions;
if (window._rehearserStartImpEx) {
delete window._rehearserStartImpEx;
showRehImpEx();
} else {
const _initPhase = window._rehearserStartPhase || 1;
delete window._rehearserStartPhase;
showPhase(_initPhase);
}