tts-voice-creator-clone-and.../static/js/rehearser.js
mARTin-B78 193cbb202d Fix Fish-Speech 0-voices bug, add inline emotion-tag reference to Try It Out (v1.20.8)
Fish-Speech clones from the app's own saved WAV library but was missing from
the set of backends populated with those voices, so the UI probed the Fish
server for a voice-listing endpoint it does not have, showed "Fetched 0
voices" and left the engine unusable despite being healthy. It now reports
the full library (218 voices).

Also adds a Fish-only panel listing all 49 documented emotion tags with
click-to-insert and a multi-emotion example, and corrects the backend's
advertised capabilities: it was flagged style_aware with "emotion markers are
honoured per request", but measurement shows a reference clip's in-context
prosody overwhelms inline tags (10x loudness spread across emotions without a
reference, 1.2x with one). It is now described by what it does well:
deterministic, byte-reproducible cloning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 00:08:36 +02:00

5184 lines
245 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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

// ── Script Rehearser ──────────────────────────────────────────────────────
// ── Constants ─────────────────────────────────────────────────────────────
const SPEAKER_COLORS = ['#89b4fa','#a6e3a1','#f38ba8','#fab387','#f9e2af','#cba6f7','#89dceb','#74c7ec'];
const REH_EMOTIONS = [
{ value: '', emoji: '😐', label: 'Neutral' },
{ value: 'happy, cheerful and upbeat', emoji: '😊', label: 'Happy' },
{ value: 'sad, melancholy, somber', emoji: '😢', label: 'Sad' },
{ value: 'angry, forceful, aggressive', emoji: '😠', label: 'Angry' },
{ value: 'whisper, hushed and intimate', emoji: '🤫', label: 'Whisper' },
{ value: 'excited, enthusiastic, energetic', emoji: '🤩', label: 'Excited' },
{ value: 'scared, nervous, trembling voice', emoji: '😨', label: 'Scared' },
{ value: 'sarcastic, dry, ironic delivery', emoji: '😏', label: 'Sarcastic' },
{ value: 'dramatic, theatrical, intense', emoji: '🎭', label: 'Dramatic' },
{ value: 'gentle, warm, tender', emoji: '🥰', label: 'Gentle' },
{ value: 'confused, uncertain, hesitant', emoji: '😕', label: 'Confused' },
{ value: 'bored, flat, disinterested', emoji: '😑', label: 'Bored' },
{ value: 'surprised, shocked, astonished', emoji: '😲', label: 'Surprised' },
{ value: 'confident, authoritative, bold', emoji: '💪', label: 'Confident' },
{ value: 'mysterious, dark, ominous', emoji: '🌑', label: 'Mysterious' },
{ value: 'romantic, loving, passionate', emoji: '❤️', label: 'Romantic' },
{ value: 'playful, teasing, mischievous', emoji: '😈', label: 'Playful' },
{ value: 'calm, composed, measured', emoji: '🧘', label: 'Calm' },
{ value: 'commanding, authoritative, military', emoji: '⚔️', label: 'Commanding' },
{ value: 'grieving, tearful, broken', emoji: '😭', label: 'Grieving' },
];
// Exposed so the emotion quick-pickers in tts-preview.js / reader.js can read this
// list. They run in the same bundle scope but BEFORE this file, so they must go
// through window (and defer to a macrotask) rather than touch the const directly.
window.REH_EMOTIONS = REH_EMOTIONS;
// Load custom emotions from localStorage
let rehCustomEmotions = [];
try { rehCustomEmotions = JSON.parse(localStorage.getItem('reh-custom-emotions') || '[]'); } catch(_) {}
function getEmotionInfo(value) {
if (!value) return { emoji: '', label: 'Pick tone' };
const all = [...REH_EMOTIONS, ...rehCustomEmotions];
const found = all.find(e => e.value === value);
if (found) return { emoji: found.emoji, label: found.label };
return { emoji: '✨', label: value.length > 14 ? value.slice(0, 13) + '…' : value };
}
// ── Markdown helpers ───────────────────────────────────────────────────────
function renderMarkdownInline(text) {
// Escape HTML first, then apply inline markdown
let s = escHtml(text);
s = s.replace(/\*\*([^*\n]+?)\*\*/g, '<strong>$1</strong>');
s = s.replace(/\*([^*\n]+?)\*/g, '<em>$1</em>');
s = s.replace(/__([^_\n]+?)__/g, '<u>$1</u>');
s = s.replace(/~~([^~\n]+?)~~/g, '<del>$1</del>');
s = s.replace(/==([^=\n]+?)==/g, '<mark class="reh-hl">$1</mark>');
return s;
}
function stripMarkdown(text) {
return text
.replace(/\*\*([^*\n]+?)\*\*/g, '$1')
.replace(/\*([^*\n]+?)\*/g, '$1')
.replace(/__([^_\n]+?)__/g, '$1')
.replace(/~~([^~\n]+?)~~/g, '$1')
.replace(/==([^=\n]+?)==/g, '$1');
}
// ── State ─────────────────────────────────────────────────────────────────
const rehState = {
lines: [],
cast: {},
lineIndex: 0,
clips: [],
voices: [],
backend: '',
playing: false,
repeat: false,
savedId: null,
synthCache: new Map(),
staleLines: new Set(), // lines that were cached but had their tone changed
synthCancelled: false,
synthRunning: false,
skipDescriptions: false,
narratorVoice: '',
practiceStart: null,
practiceEnd: null,
bulkMode: false, // bulk-edit (line selection) mode on/off
bulkSel: new Set(), // indices of currently selected lines
bulkAnchor: null, // last clicked index for Shift+click range selection
showHidden: false, // reveal hidden lines (so they can be restored)
recStream: null, recAudioCtx: null, recAnalyser: null,
recSourceNode: null, recGainNode: null, recDestStream: null,
recMeterRaf: null, recWaveRing: null, mediaRec: null,
recChunks: [], recTimer: null, recSecs: 0, lastRecBlob: null,
};
window.rehState = rehState;
// ── Library — SQLite via /api/rehearsals ──────────────────────────────────
async function rehDbGetAll() {
const r = await fetch('/api/rehearsals');
if (!r.ok) throw new Error('rehDbGetAll failed: ' + r.status);
const d = await r.json();
return d.rehearsals || [];
}
async function rehDbGetById(id) {
const r = await fetch('/api/rehearsals/' + id);
if (r.status === 404) return undefined;
if (!r.ok) throw new Error('rehDbGetById failed: ' + r.status);
return r.json();
}
async function rehDbAdd(record) {
const r = await fetch('/api/rehearsals', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(_rehSanitize(record)),
});
if (!r.ok) throw new Error('rehDbAdd failed: ' + r.status);
const d = await r.json();
return d.id;
}
async function rehDbPut(record) {
if (!record.id) { return rehDbAdd(record); }
const r = await fetch('/api/rehearsals/' + record.id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(_rehSanitize(record)),
});
if (!r.ok) throw new Error('rehDbPut failed: ' + r.status);
}
async function rehDbDelete(id) {
const r = await fetch('/api/rehearsals/' + id, { method: 'DELETE' });
if (!r.ok) throw new Error('rehDbDelete failed: ' + r.status);
}
// Strip binary clip blobs before sending — blobs can't be JSON-serialised and
// are session-only anyway (they live in rehState.clips, not the library record).
function _rehSanitize(rec) {
const out = { ...rec };
if (out.clips) out.clips = (out.clips || []).map(c => ({ lineIndex: c.lineIndex, speaker: c.speaker, type: c.type }));
return out;
}
// One-time IndexedDB → SQLite migration. Runs silently on page load.
(async function _rehMigrateIfNeeded() {
try {
const serverRecs = await rehDbGetAll();
if (serverRecs.length > 0) return;
const idbRecs = await _rehIdbGetAll().catch(() => []);
if (!idbRecs.length) return;
const r = await fetch('/api/rehearsals/migrate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(idbRecs.map(_rehSanitize)),
});
if (r.ok) {
const d = await r.json();
console.log(`[rehearser] migrated ${d.imported} records from IndexedDB → SQLite`);
}
} catch (e) {
console.warn('[rehearser] migration skipped:', e);
}
})();
function _rehIdbGetAll() {
return new Promise((resolve) => {
const req = indexedDB.open('reh-library', 1);
req.onerror = () => resolve([]);
req.onsuccess = e => {
const db = e.target.result;
if (!db.objectStoreNames.contains('rehearsals')) { db.close(); resolve([]); return; }
const all = db.transaction('rehearsals', 'readonly').objectStore('rehearsals').getAll();
all.onsuccess = ev => { db.close(); resolve(ev.target.result || []); };
all.onerror = () => { db.close(); resolve([]); };
};
});
}
window.rehDbGetById = rehDbGetById;
window.rehLoadRecord = loadRecord; // expose so audiobook.js can open a record directly
// ── Serialization ─────────────────────────────────────────────────────────
async function clipsToJson(clips) {
return Promise.all(clips.map(async c => {
if (!c.blob) return { lineIndex: c.lineIndex, speaker: c.speaker, type: c.type };
const ab = await c.blob.arrayBuffer();
const u8 = new Uint8Array(ab);
let bin = '';
const CHUNK = 8192;
for (let i = 0; i < u8.length; i += CHUNK)
bin += String.fromCharCode(...u8.subarray(i, i + CHUNK));
return { lineIndex: c.lineIndex, speaker: c.speaker, type: c.type, mime: c.blob.type, b64: btoa(bin) };
}));
}
function clipsFromJson(clips) {
return clips.map(c => {
if (!c.b64) return c;
const bin = atob(c.b64);
const u8 = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
return { ...c, blob: new Blob([u8], { type: c.mime || 'audio/webm' }), b64: undefined, mime: undefined };
});
}
function rehCurrentRecord() {
const cast = {};
Object.entries(rehState.cast).forEach(([sp, c]) => {
cast[sp] = {
voice: c.voice, color: c.color, instruct: c.instruct || '',
lang: c.lang || '', gender: c.gender || '', tags: c.tags || '', soul: c.soul || '',
ignored: !!c.ignored, hidden: !!c.hidden,
};
});
const emotions = {};
const notes = {};
const ignored = {};
const hidden = {};
rehState.lines.forEach((l, i) => {
if (l.type === 'dialog' && l.emotion) emotions[i] = l.emotion;
if (l.type === 'dialog' && l.note) notes[i] = l.note;
if (l.ignored) ignored[i] = true;
if (l.hidden) hidden[i] = true;
});
return {
title: $('reh-script-title')?.value.trim() || $('reh-page-title')?.textContent || 'Untitled',
// Reconstruct from parsed lines so structural edits (deletes) persist; fall back to the
// raw textarea before the script has been parsed into lines.
script: rehState.lines.length ? linesToScriptText() : ($('reh-script-text')?.value.trim() || ''),
cast, emotions, notes, ignored, hidden,
backend: rehState.backend,
narratorVoice: rehState.narratorVoice,
lineIndex: rehState.lineIndex,
clips: rehState.clips.map(c => ({ lineIndex: c.lineIndex, speaker: c.speaker, type: c.type, blob: c.blob || null })),
updated: new Date(),
};
}
// Reconstruct script text from current parsed lines (preserves inline edits)
function linesToScriptText() {
return rehState.lines.map(line => {
switch (line.type) {
case 'act':
case 'transition': return '\n' + line.text + '\n';
case 'scene': return '\n' + line.text + '\n';
case 'action': return '\n' + line.text + '\n';
case 'dialog': return '\n' + line.speaker + '\n' + line.text + '\n';
case 'direction':
return line.speaker ? line.text + '\n' : '\n' + line.text + '\n';
case 'pagebreak': return line.page ? `\n\f${line.page}\n` : '\n\f\n';
default: return '';
}
}).join('').trim();
}
async function saveToLibrary() {
const rec = rehCurrentRecord();
if (rehState.savedId) {
rec.id = rehState.savedId;
if (!rec.created) rec.created = new Date();
await rehDbPut(rec);
} else {
rec.created = new Date();
const newId = await rehDbAdd(rec);
rehState.savedId = newId;
}
toast('Saved to library', 'success');
rehState._keepSavedIdOnce = true;
// Sync cast voice choices into the shared character roster (existing chars only)
if (typeof castWriteBack === 'function') { try { await castWriteBack(rec.title, rehState.cast); } catch (_) {} }
await renderLibraryList();
}
// Auto-save a freshly imported/pasted script as its own library entry so it shows up
// in the Library and can be reopened. Returns the new id (or null on failure).
async function rehAutoSaveImport(title) {
const text = ($('reh-script-text')?.value || '').trim();
if (!text) return null;
if (title && $('reh-script-title') && !$('reh-script-title').value.trim()) $('reh-script-title').value = title;
const rec = {
title: $('reh-script-title')?.value.trim() || title || 'Untitled',
script: text,
cast: {}, emotions: {}, notes: {}, ignored: {}, hidden: {},
backend: rehState.backend || '', narratorVoice: rehState.narratorVoice || '',
lineIndex: 0, clips: [], created: new Date(), updated: new Date(),
};
try {
const id = await rehDbAdd(rec);
rehState.savedId = id;
rehState._keepSavedIdOnce = true; // keep this entry through the next Parse & cast
await renderLibraryList();
return id;
} catch (e) { return null; }
}
async function exportToFile() {
const rec = rehCurrentRecord();
rec.version = 1;
rec.created = rec.created || new Date();
rec.clips = await clipsToJson(rec.clips);
const json = JSON.stringify(rec, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const a = document.createElement('a');
const title = (rec.title || 'rehearsal').replace(/[^a-z0-9_\- ]/gi, '_').slice(0, 40);
a.href = URL.createObjectURL(blob);
a.download = title + '.reh';
a.click();
}
function exportFountain() {
const title = $('reh-script-title')?.value.trim() || $('reh-page-title')?.textContent || 'Untitled';
let out = `Title: ${title}\n\n`;
rehState.lines.forEach(line => {
const t = stripMarkdown(line.text);
switch (line.type) {
case 'act': out += '\n' + t + '\n'; break;
case 'scene': out += '\n' + t + '\n'; break;
case 'action': out += '\n' + t + '\n'; break;
case 'transition': out += '\n' + t + '\n'; break;
case 'dialog': out += '\n' + line.speaker + '\n' + t + '\n'; break;
case 'direction':
out += (line.speaker ? '' : '\n') + t + '\n'; break;
}
});
const blob = new Blob([out.trim()], { type: 'text/plain' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = (title.replace(/[^a-z0-9_\- ]/gi, '_') || 'script') + '.fountain';
a.click();
toast('Exported as .fountain', 'success');
}
// ── FDX (Final Draft XML) ──────────────────────────────────────────────────
function exportFDX() {
const title = $('reh-script-title')?.value.trim() || 'Untitled';
const x = s => (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
let xml = `<?xml version="1.0" encoding="UTF-8" standalone="no" ?>\n`;
xml += `<FinalDraft DocumentType="Script" Template="No" Version="1">\n<Content>\n`;
rehState.lines.forEach(line => {
const t = x(stripMarkdown(line.text));
switch (line.type) {
case 'act':
case 'scene': xml += ` <Paragraph Type="Scene Heading"><Text>${t}</Text></Paragraph>\n`; break;
case 'action': xml += ` <Paragraph Type="Action"><Text>${t}</Text></Paragraph>\n`; break;
case 'transition': xml += ` <Paragraph Type="Transition"><Text>${t}</Text></Paragraph>\n`; break;
case 'direction': xml += ` <Paragraph Type="Parenthetical"><Text>${t}</Text></Paragraph>\n`; break;
case 'dialog':
xml += ` <Paragraph Type="Character"><Text>${x(line.speaker)}</Text></Paragraph>\n`;
xml += ` <Paragraph Type="Dialogue"><Text>${t}</Text></Paragraph>\n`;
break;
}
});
xml += `</Content>\n</FinalDraft>`;
const blob = new Blob([xml], { type: 'text/xml' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = (title.replace(/[^a-z0-9_\- ]/gi, '_') || 'script') + '.fdx';
a.click();
toast('Exported as .fdx (Final Draft XML)', 'success');
}
async function importFDX(file) {
const text = await file.text();
const parser = new DOMParser();
const doc = parser.parseFromString(text, 'text/xml');
const paras = doc.querySelectorAll('Paragraph');
let result = '';
paras.forEach(para => {
const type = para.getAttribute('Type') || '';
const t = [...para.querySelectorAll('Text')].map(el => el.textContent).join('');
if (!t.trim()) return;
switch (type) {
case 'Scene Heading': result += '\n' + t + '\n'; break;
case 'Action': result += '\n' + t + '\n'; break;
case 'Character': result += '\n' + t + '\n'; break;
case 'Dialogue': result += t + '\n'; break;
case 'Parenthetical': result += t + '\n'; break;
case 'Transition': result += '\n' + t + '\n'; break;
}
});
return result.trim();
}
// OSF (Open Screenplay Format — application-agnostic XML)
function exportOSF() {
const title = $('reh-script-title')?.value.trim() || 'Untitled';
const x = s => (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
let xml = `<?xml version="1.0" encoding="UTF-8"?>\n<osf:script xmlns:osf="http://www.openscreenplayformat.org/ns/1.0">\n`;
xml += ` <osf:title>${x(title)}</osf:title>\n<osf:content>\n`;
rehState.lines.forEach(line => {
const t = x(stripMarkdown(line.text));
switch (line.type) {
case 'act':
case 'scene': xml += ` <osf:scene-heading>${t}</osf:scene-heading>\n`; break;
case 'action': xml += ` <osf:action>${t}</osf:action>\n`; break;
case 'transition': xml += ` <osf:transition>${t}</osf:transition>\n`; break;
case 'direction': xml += ` <osf:parenthetical speaker="${x(line.speaker)}">${t}</osf:parenthetical>\n`; break;
case 'dialog':
xml += ` <osf:dialogue>\n <osf:character>${x(line.speaker)}</osf:character>\n <osf:text>${t}</osf:text>\n </osf:dialogue>\n`;
break;
}
});
xml += `</osf:content>\n</osf:script>`;
const blob = new Blob([xml], { type: 'text/xml' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = (title.replace(/[^a-z0-9_\- ]/gi, '_') || 'script') + '.osf';
a.click();
toast('Exported as .osf (Open Screenplay Format)', 'success');
}
// ── PDF import (via pdf.js from CDN) ──────────────────────────────────────
async function loadPdfJs() {
if (window.pdfjsLib) return;
await new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = '/static/js/pdf/pdf.min.js';
s.onload = resolve; s.onerror = reject;
document.head.appendChild(s);
});
pdfjsLib.GlobalWorkerOptions.workerSrc = '/static/js/pdf/pdf.worker.min.js';
}
// Extract a screenplay from a PDF, reconstructing element types from the
// horizontal indentation that screenplays use (the layout *is* the meaning):
// action/scene ~ left margin character ~ deeply indented (caps)
// dialogue ~ medium indent parenthetical ~ indented "(...)"
// transition ~ right-aligned
async function importPDFScript(file) {
toast('Loading PDF reader…', 'success');
await loadPdfJs();
const ab = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument({ data: ab }).promise;
// First pass: collect every line {x, right, text} per page
const allLinesByPage = [];
const allLines = []; // flat list for margin/width detection
for (let p = 1; p <= pdf.numPages; p++) {
const page = await pdf.getPage(p);
const content = await page.getTextContent();
const viewport = page.getViewport({ scale: 1 });
const pageW = viewport.width;
// Group items into visual rows by Y (PDF Y is bottom-up)
const rows = new Map();
content.items.forEach(item => {
if (!item.str) return;
const y = Math.round(item.transform[5] / 3) * 3;
if (!rows.has(y)) rows.set(y, []);
rows.get(y).push({ x: item.transform[4], w: item.width || 0, str: item.str });
});
const pageLines = [];
[...rows.entries()].sort((a, b) => b[0] - a[0]).forEach(([, items]) => {
items.sort((a, b) => a.x - b.x);
const text = items.map(i => i.str).join('').replace(/\s+/g, ' ').trim();
if (!text) return;
const x = items[0].x;
const last = items[items.length - 1];
const right = last.x + (last.w || 0);
pageLines.push({ x, right, pageW, text });
allLines.push({ x, right, pageW, text });
});
allLinesByPage.push(pageLines);
}
if (!allLines.length) return '';
// Detect the left margin (the most common / smallest x of body lines)
const leftMargin = Math.min(...allLines.map(l => l.x));
const pageW = allLines[0].pageW || 612;
// Noise filter: lone page numbers, scene numbers, "CONTINUED", revision marks
const isNoise = t => {
const s = t.trim();
if (/^\d{1,4}\.?$/.test(s)) return true; // bare page/scene number
if (/^\(?CONTINUED\)?:?$/i.test(s)) return true;
if (/^\d+\.$/.test(s)) return true;
if (/^(rev\.|revised|draft)\b/i.test(s) && s.length < 24) return true;
return false;
};
const out = [];
let prevBlank = true;
let prevWasAction = false; // track consecutive action lines so we don't split paragraphs
const push = (line, blankBefore) => {
if (blankBefore && !prevBlank) { out.push(''); }
out.push(line);
prevBlank = false;
};
const blank = () => { if (!prevBlank) { out.push(''); prevBlank = true; } };
const nonAction = () => { prevWasAction = false; };
for (let pageIdx = 0; pageIdx < pdf.numPages; pageIdx++) {
// Emit a page-break marker between PDF pages so the rehearser can paginate exactly
if (pageIdx > 0) { blank(); out.push(`\f${pageIdx + 1}`); blank(); }
const pageLns = allLinesByPage[pageIdx] || [];
for (const ln of pageLns) {
const t = ln.text.trim();
if (!t || isNoise(t)) continue;
const indent = (ln.x - leftMargin) / pageW; // fraction of page width
const rightFrac = ln.right / pageW;
const isCaps = t === t.toUpperCase() && /[A-Z]/.test(t);
const wordCount = t.split(/\s+/).length;
// Scene heading (slug line)
if (/^(INT|EXT|INT\.\/EXT|EXT\.\/INT|I\/E)[\. ]/i.test(t)) {
blank(); push(t.toUpperCase().replace(/\s+\d+[A-Z]?\.?$/, '')); blank(); nonAction();
continue;
}
// Act / scene number headings (theatre)
if (/^(ACT|SCENE)\s+(\d+|[IVXLC]+|ONE|TWO|THREE|FOUR|FIVE)\b/i.test(t)) {
blank(); push(t.toUpperCase()); blank(); nonAction();
continue;
}
// Transition (right-aligned, caps, ends with TO: / IN / OUT)
if (isCaps && (rightFrac > 0.72 || indent > 0.45) &&
/(TO:|CUT|FADE|DISSOLVE|SMASH|MATCH|WIPE|BLACKOUT|INTERMISSION)\b/.test(t)) {
blank(); push(t); blank(); nonAction();
continue;
}
// Parenthetical
if (/^\(.*\)?$/.test(t) || (indent > 0.18 && indent < 0.30 && /^\(/.test(t))) {
push(t.startsWith('(') ? t : '(' + t + ')', false); nonAction();
continue;
}
// Character cue: deeply indented + caps + short (often has (CONT'D)/(V.O.))
const nameCore = t.replace(/\s*\([^)]*\)\s*$/, '').trim();
if (indent > 0.22 && isCaps && wordCount <= 6 && nameCore.length <= 40 &&
/^[A-Z0-9 .'#\-]+$/.test(nameCore)) {
blank(); push(t.toUpperCase()); prevBlank = false; nonAction(); // name; dialog follows next line
continue;
}
// Dialogue: medium indent (under a character)
if (indent > 0.10 && indent < 0.34) {
push(t, false); nonAction();
continue;
}
// Default: action / description at the left margin.
// Consecutive action lines join into one paragraph (no blank between them);
// only start a new paragraph when coming from a non-action line.
if (prevWasAction) {
out.push(t); // continue same paragraph
} else {
blank(); push(t); // new paragraph
}
prevWasAction = true;
} // end per-page line loop
} // end page loop
return out.join('\n').replace(/\n{3,}/g, '\n\n').trim();
}
async function importFromFile(file) {
try {
const text = await file.text();
const data = JSON.parse(text);
data.clips = clipsFromJson(data.clips || []);
data.created = data.created ? new Date(data.created) : new Date();
data.updated = new Date();
delete data.id;
const newId = await rehDbAdd(data);
toast('Imported: ' + (data.title || 'Untitled'), 'success');
await renderLibraryList();
loadRecord({ ...data, id: newId });
} catch(e) { toast('Import failed: ' + e.message, 'error'); }
}
function loadRecord(rec) {
// Opening a record from the Library (s-library) must bring the Rehearser
// section into view — without this, showPhase(2) below just swaps a phase
// div inside #s-rehearser while that whole section stays display:none,
// so nothing visibly happens and the click looks like it did nothing.
if (typeof navTo === 'function') navTo('s-rehearser');
if ($('reh-script-text')) $('reh-script-text').value = rec.script || '';
if ($('reh-script-title')) $('reh-script-title').value = rec.title || '';
rehState.savedId = rec.id || null;
rehState._keepSavedIdOnce = true; // re-parsing a loaded entry keeps updating it
rehState.backend = rec.backend || '';
rehState.lineIndex = rec.lineIndex || 0;
rehState.clips = (rec.clips || []).map(c => ({ ...c }));
rehState.synthCache.clear(); rehDecodedBuffers.clear();
rehState.narratorVoice = rec.narratorVoice || '';
const lines = parseScript(rec.script || '');
if (rec.emotions) {
lines.forEach((l, i) => { if (l.type === 'dialog' && rec.emotions[i]) l.emotion = rec.emotions[i]; });
}
if (rec.notes) {
lines.forEach((l, i) => { if (l.type === 'dialog' && rec.notes[i]) l.note = rec.notes[i]; });
}
if (rec.ignored) lines.forEach((l, i) => { if (rec.ignored[i]) l.ignored = true; });
if (rec.hidden) lines.forEach((l, i) => { if (rec.hidden[i]) l.hidden = true; });
rehState.lines = lines;
rehState.cast = {};
const detected = detectCharacters(lines);
Object.entries(detected).forEach(([sp, def]) => {
const saved = rec.cast?.[sp];
rehState.cast[sp] = {
voice: saved?.voice ?? def.voice,
color: saved?.color ?? def.color,
instruct: saved?.instruct || '',
lang: saved?.lang || '', gender: saved?.gender || '',
tags: saved?.tags || '', soul: saved?.soul || '',
ignored: !!saved?.ignored, hidden: !!saved?.hidden,
voiceData: saved?.voice && saved.voice !== 'me' ? getVoiceData(saved.voice) : null,
};
});
renderCastList();
rehApplySharedCast(rec.title);
refreshRehBackends().then(() => {
if (rec.backend && $('reh-backend-select')) $('reh-backend-select').value = rec.backend;
if (rec.narratorVoice && $('reh-narrator-voice')) $('reh-narrator-voice').value = rec.narratorVoice;
});
showPhase(2);
}
// Fill any empty cast slots from the production's shared character roster
// (character library, matched by book OR tag). Only blanks are filled — an
// explicit voice/soul already on the rehearsal always wins. Re-renders if it
// changed anything.
async function rehApplySharedCast(title) {
if (typeof castForProduction !== 'function' || !title) return;
let roster;
try { roster = await castForProduction(title); } catch (_) { return; }
if (!roster) return;
let changed = false;
Object.keys(rehState.cast || {}).forEach(sp => {
// Narrator can now have a real, persisted Library record too (Assign
// Voices synthesizes one per production) — pull its voice the same way
// as any other shared cast entry instead of skipping it, so a voice
// picked there actually reaches the Rehearser/synthesis. The narrator's
// cast key is the emoji-prefixed REH_NARRATOR_KEY sentinel, not its
// plain "Narrator" library name, so the roster lookup below needs the
// same translation renderCastStrip() already uses elsewhere — without
// it, `roster["📖narrator"]` always misses and this silently never
// fires for narrator at all (confirmed live: synthAll() then skips
// every narration line since it reads rehState.narratorVoice directly,
// which this function is also the only place expected to set from a
// shared/library voice).
const lookupName = sp === REH_NARRATOR_KEY ? 'narrator' : String(sp).toLowerCase();
const shared = roster[lookupName];
if (!shared) return;
const slot = rehState.cast[sp];
if (shared.voice && !slot.voice) {
slot.voice = shared.voice;
slot.voiceData = (shared.voice !== 'me' && typeof getVoiceData === 'function') ? getVoiceData(shared.voice) : null;
changed = true;
if (sp === REH_NARRATOR_KEY) rehState.narratorVoice = shared.voice;
}
if (shared.gender && !slot.gender) { slot.gender = shared.gender; changed = true; }
if (shared.soul && !slot.soul) { slot.soul = shared.soul; changed = true; }
});
if (changed) renderCastList();
}
// ── Library UI ─────────────────────────────────────────────────────────────
// Deterministic cover gradient from the title string
function bookCover(title) {
let h = 0;
for (let i = 0; i < title.length; i++) h = (h * 31 + title.charCodeAt(i)) % 360;
const h2 = (h + 40) % 360;
return { c1: `hsl(${h}, 55%, 42%)`, c2: `hsl(${h2}, 58%, 30%)` };
}
let rehLibView = localStorage.getItem('reh-lib-view') || 'shelf';
function rehTitleKey(title) {
return String(title || '').replace(/\s+/g, ' ').trim().toLowerCase();
}
function rehUniqueLibraryRecords(records) {
const byTitle = new Map();
const ordered = [...records].sort((a, b) => {
const at = new Date(a.updated || 0).getTime();
const bt = new Date(b.updated || 0).getTime();
if (at !== bt) return bt - at;
return (b.id || 0) - (a.id || 0);
});
for (const rec of ordered) {
const key = rehTitleKey(rec.title);
const bucketKey = key || `__reh__${rec.id}`;
if (!byTitle.has(bucketKey)) byTitle.set(bucketKey, rec);
}
return [...byTitle.values()].sort((a, b) => {
const at = new Date(a.updated || 0).getTime();
const bt = new Date(b.updated || 0).getTime();
if (at !== bt) return bt - at;
return (b.id || 0) - (a.id || 0);
});
}
async function renderLibraryList() {
const list = $('reh-library-list'); if (!list) return;
let all;
try { all = await rehDbGetAll(); } catch(e) { all = []; }
all = rehUniqueLibraryRecords(all);
list.classList.toggle('list-view', rehLibView === 'list');
const vt = $('reh-lib-view-toggle');
if (vt) vt.innerHTML = `<span class="mdi mdi-${rehLibView === 'list' ? 'view-grid' : 'view-list'}"></span>`;
if (!all.length) {
list.innerHTML = '<div class="reh-lib-empty"><span class="mdi mdi-book-open-outline"></span><p>No saved rehearsals yet.<br>Parse a script below or drop a file to start.</p></div>';
return;
}
list.innerHTML = all.map(rec => {
const speakers = Object.keys(rec.cast || {});
const meCount = speakers.filter(sp => rec.cast[sp]?.voice === 'me').length;
const clipCount = (rec.clips || []).filter(c => c.type === 'me' && c.blob).length;
const total = parseScript(rec.script || '').filter(l => l.type === 'dialog').length;
const pct = total ? Math.round(((rec.lineIndex || 0) / total) * 100) : 0;
const date = rec.updated ? new Date(rec.updated).toLocaleDateString() : '—';
const isCurrent = rec.id === rehState.savedId;
const title = rec.title || 'Untitled';
const { c1, c2 } = bookCover(title);
const avatars = speakers.slice(0, 5).map(sp => {
const c = rec.cast[sp];
return `<span style="background:${c.color}">${sp[0].toUpperCase()}</span>`;
}).join('');
return `<div class="reh-book" data-id="${rec.id}" style="--bk1:${c1};--bk2:${c2}" title="${escHtml(title)}">
${isCurrent ? '<span class="reh-book-active-tag">active</span>' : ''}
<div class="reh-book-actions">
<button class="reh-book-act reh-lib-export" data-id="${rec.id}" title="Export .reh"><span class="mdi mdi-export"></span></button>
<button class="reh-book-act reh-lib-delete" data-id="${rec.id}" title="Delete"><span class="mdi mdi-delete-outline"></span></button>
</div>
<div class="reh-book-title">${escHtml(title)}</div>
<div class="reh-book-meta">
<div class="reh-book-avatars">${avatars}</div>
${total} lines · ${speakers.length} cast${meCount ? ' · ' + meCount + ' me' : ''}${clipCount ? ' · ' + clipCount + '🎤' : ''}
<div class="reh-book-progress"><div style="width:${pct}%"></div></div>
<div style="margin-top:4px;opacity:.8">${pct}% · ${date}</div>
</div>
</div>`;
}).join('');
// Click book → open
list.querySelectorAll('.reh-book').forEach(book => book.addEventListener('click', async e => {
if (e.target.closest('.reh-book-act')) return;
const id = parseInt(book.dataset.id);
// rehDbOpen()/REH_STORE were the pre-migration raw-IndexedDB API and no
// longer exist — rehearsals are server-backed now (rehDbGetById).
const rec = await rehDbGetById(id);
if (rec) loadRecord(rec);
else toast('Rehearsal not found', 'error');
}));
list.querySelectorAll('.reh-lib-export').forEach(btn => btn.addEventListener('click', async e => {
e.stopPropagation();
const id = parseInt(btn.dataset.id);
const rec = await rehDbGetById(id);
if (!rec) { toast('Rehearsal not found', 'error'); return; }
rec.version = 1;
rec.clips = await clipsToJson(rec.clips || []);
const json = JSON.stringify(rec, null, 2);
const a = document.createElement('a');
const t = (rec.title || 'rehearsal').replace(/[^a-z0-9_\- ]/gi, '_').slice(0, 40);
a.href = URL.createObjectURL(new Blob([json], { type: 'application/json' }));
a.download = t + '.reh';
a.click();
}));
list.querySelectorAll('.reh-lib-delete').forEach(btn => btn.addEventListener('click', async e => {
e.stopPropagation();
const bookEl = btn.closest('.reh-book');
if (bookEl.querySelector('.reh-book-del-confirm')) return;
const confirmOverlay = document.createElement('div');
confirmOverlay.className = 'reh-book-del-confirm';
confirmOverlay.style.cssText = 'position:absolute; inset:0; background:rgba(0,0,0,0.85); color:#fff; display:flex; flex-direction:column; justify-content:center; align-items:center; border-radius:inherit; z-index:10; padding:12px; text-align:center; box-sizing:border-box;';
confirmOverlay.innerHTML = `
<div style="font-weight:600; margin-bottom:8px; font-size:14px;">Delete Rehearsal?</div>
<div style="font-size:11px; opacity:0.8; margin-bottom:12px; line-height:1.4;">This cannot be undone.</div>
<div style="display:flex; gap:8px;">
<button class="btn-secondary btn-sm" id="btn-cancel-del" style="background:rgba(255,255,255,0.15); border:none; color:#fff; padding:6px 12px;">Cancel</button>
<button class="btn-primary btn-sm" id="btn-confirm-del" style="background:var(--red,#ef4444); border:none; color:#fff; padding:6px 12px;">Delete</button>
</div>
`;
confirmOverlay.addEventListener('click', ce => ce.stopPropagation());
confirmOverlay.querySelector('#btn-cancel-del').addEventListener('click', ce => {
ce.stopPropagation();
confirmOverlay.remove();
});
confirmOverlay.querySelector('#btn-confirm-del').addEventListener('click', async ce => {
ce.stopPropagation();
await rehDbDelete(parseInt(btn.dataset.id));
if (rehState.savedId === parseInt(btn.dataset.id)) rehState.savedId = null;
renderLibraryList();
});
bookEl.appendChild(confirmOverlay);
}));
}
$('reh-lib-view-toggle')?.addEventListener('click', () => {
rehLibView = rehLibView === 'shelf' ? 'list' : 'shelf';
localStorage.setItem('reh-lib-view', rehLibView);
renderLibraryList();
});
// ── Helpers ────────────────────────────────────────────────────────────────
function getVoiceData(voiceId) {
return (window._voices || []).find(v => v.id === voiceId) || null;
}
function _rehVoiceVisibleId(voiceId, include = '') {
if (!voiceId) return false;
if (include && voiceId === include) return true;
const v = getVoiceData(voiceId);
return !v || v.enabled !== false;
}
function voiceAvatarHtml(voiceId, color, size = 32) {
const v = getVoiceData(voiceId);
const s = size + 'px';
const radius = Math.round(size / 2);
if (v?.has_picture) {
return `<img src="/api/voice/picture/${encodeURIComponent(voiceId)}" class="reh-char-img" style="width:${s};height:${s};border-radius:${radius}px;object-fit:cover;flex-shrink:0" alt="${escHtml(voiceId)}">`;
}
const ic = (v?.avatar && window.VOICE_AVATAR_ICONS) ? window.VOICE_AVATAR_ICONS[v.avatar] : null;
if (ic) {
return `<span class="reh-char-img" style="width:${s};height:${s};border-radius:${radius}px;background:${color};color:#fff;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0"><span class="mdi ${ic}" style="font-size:${Math.round(size * 0.56)}px"></span></span>`;
}
const initial = (voiceId || '?')[0].toUpperCase();
return `<span class="reh-char-img" style="width:${s};height:${s};border-radius:${radius}px;background:${color};color:#fff;font-weight:700;font-size:${Math.round(size * 0.45)}px;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0">${initial}</span>`;
}
// Stage line rows used a generic voice icon/initial for the play-avatar
// button — a plain "?" for any speaker whose assigned voice has no picture
// of its own, even when the CHARACTER already has a real portrait in the
// Library (same one shown everywhere else: Cast Audiobook, Assign Voices,
// the Cast sidebar strip right next to this same line). Prefer that.
function _rehCharAvatarHtml(speaker, voiceId, color, size = 32) {
const rec = (_rehLibCharsCache || []).find(r => String(r.name || '').trim().toLowerCase() === String(speaker || '').trim().toLowerCase());
if (rec && rec.image) {
const s = size + 'px'; const radius = Math.round(size / 2);
// Reference the image by URL, never inline the raw data: URL here — this
// renders once PER DIALOGUE LINE, and a character can speak hundreds of
// lines; embedding a multi-KB/MB base64 blob that many times ballooned a
// real 1968-line script's HTML to hundreds of megabytes and silently
// failed to render at all (confirmed live). The browser fetches/caches
// the URL once regardless of how many lines reference it.
return `<img src="/api/characters/${encodeURIComponent(rec.id)}/image" class="reh-char-img" style="width:${s};height:${s};border-radius:${radius}px;object-fit:cover;flex-shrink:0" alt="${escHtml(speaker)}">`;
}
return voiceAvatarHtml(voiceId, color, size);
}
// ── Script parser + detectCharacters → moved to rehearser-parse.js (loaded first) ──
// ── Phase navigation ───────────────────────────────────────────────────────
function showPhase(n) {
// Hide the import/export panel when switching to a numbered phase
const impex = $('reh-impex-panel'); if (impex) impex.hidden = true;
for (let i = 1; i <= 4; i++) { const el = $('reh-phase-' + i); if (el) el.hidden = i !== n; }
syncPhaseTabs(n);
if (typeof window.onRehearserPhaseChange === 'function') window.onRehearserPhaseChange(n);
}
window.showRehImpEx = function () {
stopPlay(); stopRehMic();
for (let i = 1; i <= 4; i++) { const el = $('reh-phase-' + i); if (el) el.hidden = true; }
const panel = $('reh-impex-panel'); if (panel) panel.hidden = false;
// Show export note based on state
const note = $('reh-impex-export-note');
if (note) note.textContent = rehState.lines.length
? `Current session: "${$('reh-script-title')?.value || 'Untitled'}" · ${rehState.lines.filter(l=>l.type==='dialog').length} lines`
: 'No active session — load a script first to export.';
};
// Keep the sub-tab bar in sync: highlight current, enable reachable phases
function syncPhaseTabs(n) {
const hasScript = rehState.lines.length > 0;
const hasClips = rehState.clips.length > 0;
document.querySelectorAll('.reh-subtab').forEach(tab => {
const p = parseInt(tab.dataset.phase);
tab.classList.toggle('active', p === n);
// Library always reachable; Cast/Stage need a parsed script; Summary needs clips (or being on it)
let enabled = p === 1
|| (p === 2 && hasScript)
|| (p === 3 && hasScript)
|| (p === 4 && (hasClips || n === 4));
tab.disabled = !enabled;
});
}
document.querySelectorAll('.reh-subtab').forEach(tab => {
tab.addEventListener('click', () => {
if (tab.disabled) return;
const p = parseInt(tab.dataset.phase);
// Leaving the stage: stop playback/mic so audio doesn't keep running
if (p !== 3) { stopPlay(); stopRehMic(); hideRecOverlay(); }
if (p === 1) renderLibraryList();
if (p === 3) {
// (Re)build the stage if we have a cast/script
if (rehState.lines.length) { buildScriptPage(); showPhase(3); highlightCurrentLine(); return; }
}
if (p === 4) { renderSummary(); }
showPhase(p);
});
});
// ── Phase 1 ────────────────────────────────────────────────────────────────
$('reh-file-input')?.addEventListener('change', function () {
const f = this.files?.[0]; if (!f) return;
const guess = f.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim();
const r = new FileReader();
r.onload = async e => {
$('reh-script-text').value = e.target.result;
await rehAutoSaveImport(guess);
toast('Script loaded & saved to your library — click Parse & cast', 'success');
};
r.readAsText(f); this.value = '';
});
$('reh-pdf-input')?.addEventListener('change', async function () {
const f = this.files?.[0]; if (!f) return;
this.value = '';
try {
const text = await importPDFScript(f);
if ($('reh-script-text')) $('reh-script-text').value = text;
const guess = f.name.replace(/\.pdf$/i, '').replace(/[-_]+/g, ' ').trim();
await rehAutoSaveImport(guess);
toast('PDF imported & saved to your library — check formatting, then click Parse & cast', 'success');
} catch(e) { toast('PDF import failed: ' + e.message, 'error'); }
});
$('reh-fdx-input')?.addEventListener('change', async function () {
const f = this.files?.[0]; if (!f) return;
this.value = '';
try {
const text = await importFDX(f);
if ($('reh-script-text')) $('reh-script-text').value = text;
if (!$('reh-script-title')?.value.trim()) {
const guess = f.name.replace(/\.fdx$/i, '').replace(/[-_]+/g, ' ').trim();
if ($('reh-script-title')) $('reh-script-title').value = guess;
}
toast('FDX imported — click Parse & cast', 'success');
} catch(e) { toast('FDX import failed: ' + e.message, 'error'); }
});
// ── Unified import router (used by file pickers + drag & drop) ──────────────
async function rehImportAnyFile(f) {
if (!f) return;
const name = f.name.toLowerCase();
const setTitle = strip => {
if (!$('reh-script-title')?.value.trim()) {
const guess = f.name.replace(strip, '').replace(/[-_]+/g, ' ').trim();
if ($('reh-script-title')) $('reh-script-title').value = guess;
}
};
try {
if (name.endsWith('.reh') || name.endsWith('.json')) {
await importFromFile(f); // full session → loads & jumps to cast
return;
}
if (name.endsWith('.pdf')) {
toast('Reading PDF…', 'success');
const text = await importPDFScript(f);
if ($('reh-script-text')) $('reh-script-text').value = text;
setTitle(/\.pdf$/i);
await rehAutoSaveImport();
toast('PDF imported & saved to your library — check formatting, then Parse & cast', 'success');
return;
}
if (name.endsWith('.fdx') || name.endsWith('.osf') || name.endsWith('.xml')) {
const text = await importFDX(f);
if ($('reh-script-text')) $('reh-script-text').value = text;
setTitle(/\.(fdx|osf|xml)$/i);
await rehAutoSaveImport();
toast('Script imported & saved to your library — click Parse & cast', 'success');
return;
}
// .txt / .md / .fountain / anything else → plain text
const text = await f.text();
if ($('reh-script-text')) $('reh-script-text').value = text;
setTitle(/\.(txt|md|fountain)$/i);
await rehAutoSaveImport();
toast('Script loaded & saved to your library — click Parse & cast', 'success');
} catch(e) { toast('Import failed: ' + e.message, 'error'); }
}
// Drag & drop over the dropzone AND the whole Phase-1 panel
(function setupRehDropzone() {
const dz = $('reh-dropzone');
const phase = $('reh-phase-1');
if (!dz || !phase) return;
let depth = 0;
const isFileDrag = e => e.dataTransfer && [...(e.dataTransfer.types || [])].includes('Files');
phase.addEventListener('dragenter', e => {
if (!isFileDrag(e)) return;
e.preventDefault(); depth++; dz.classList.add('dragover');
dz.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
phase.addEventListener('dragover', e => { if (isFileDrag(e)) e.preventDefault(); });
phase.addEventListener('dragleave', e => {
if (!isFileDrag(e)) return;
depth = Math.max(0, depth - 1);
if (depth === 0) dz.classList.remove('dragover');
});
phase.addEventListener('drop', async e => {
if (!isFileDrag(e)) return;
e.preventDefault(); depth = 0; dz.classList.remove('dragover');
const f = e.dataTransfer.files?.[0];
await rehImportAnyFile(f);
});
})();
$('reh-parse-btn')?.addEventListener('click', () => {
const text = $('reh-script-text')?.value.trim();
if (!text) { toast('Paste or upload a script first', 'error'); return; }
rehState.lines = parseScript(text);
if (!rehState.lines.filter(l => l.type === 'dialog').length) {
toast('No dialog found. Use screenplay format (CAPS name + dialog) or CHAR: text.', 'error'); return;
}
// Preserve cast for returning characters
const detected = detectCharacters(rehState.lines);
Object.entries(detected).forEach(([sp]) => {
if (rehState.cast[sp]) detected[sp] = { ...detected[sp], ...rehState.cast[sp] };
});
rehState.cast = detected;
// Keep the imported/loaded library entry on the first parse so casting updates it
// instead of orphaning it; otherwise start a fresh entry.
if (rehState._keepSavedIdOnce) rehState._keepSavedIdOnce = false;
else rehState.savedId = null;
rehState.clips = [];
rehState.lineIndex = 0;
rehState.synthCache.clear(); rehDecodedBuffers.clear();
renderCastList();
showPhase(2);
refreshRehBackends();
});
// ── Phase 2 ────────────────────────────────────────────────────────────────
function castAvatarHtml(sp) {
const c = rehState.cast[sp]; if (!c) return '';
if (c.voice && c.voice !== 'me') {
const vd = getVoiceData(c.voice);
if (vd?.has_picture) return `<img src="/api/voice/picture/${encodeURIComponent(c.voice)}" style="width:36px;height:36px;border-radius:50%;object-fit:cover;border:2px solid ${c.color}" alt="">`;
const ic = (vd?.avatar && window.VOICE_AVATAR_ICONS) ? window.VOICE_AVATAR_ICONS[vd.avatar] : null;
if (ic) return `<span style="width:36px;height:36px;border-radius:50%;background:${c.color};color:#fff;display:inline-flex;align-items:center;justify-content:center;border:2px solid ${c.color}"><span class="mdi ${ic}" style="font-size:20px"></span></span>`;
}
const initial = sp[0].toUpperCase();
return `<span style="width:36px;height:36px;border-radius:50%;background:${c.color};color:#fff;font-weight:700;font-size:16px;display:inline-flex;align-items:center;justify-content:center;border:2px solid ${c.color}">${initial}</span>`;
}
// ── Online voice picker: audition a sample, browse alternatives, agree/switch ──
let _rehAudEl = null, _rehAudBtn = null;
function _rehStopAudition() {
if (_rehAudEl) { try { _rehAudEl.pause(); } catch (_) {} }
if (_rehAudBtn) { _rehAudBtn.classList.remove('playing'); const i = _rehAudBtn.querySelector('.mdi'); if (i) i.className = 'mdi mdi-play'; }
_rehAudBtn = null;
}
function _rehAudition(url, btn) {
if (!url) { toast('No preview audio for this voice', 'error'); return; }
if (_rehAudBtn === btn && _rehAudEl && !_rehAudEl.paused) { _rehStopAudition(); return; }
_rehStopAudition();
if (!_rehAudEl) { _rehAudEl = new Audio(); _rehAudEl.addEventListener('ended', _rehStopAudition); }
_rehAudEl.src = url;
_rehAudEl.play().then(() => {
_rehAudBtn = btn; btn.classList.add('playing');
const i = btn.querySelector('.mdi'); if (i) i.className = 'mdi mdi-stop';
}).catch(() => toast('Could not play preview', 'error'));
}
const _REH_PREVIEW_TEXT = 'Hello — this is how this voice sounds.';
const _rehSearchCache = {}; // sp -> last fish.audio search results (simplified candidates)
// One row of the voice picker. Plays via sample URL (online) or TTS (local library voice).
function _rehVoiceRow({ name, meta, playUrl, playVoice, isCurrent, useAttrs }) {
const play = `<button type="button" class="reh-vo-play"${playUrl ? ` data-url="${escHtml(playUrl)}"` : ''}${playVoice ? ` data-voice="${escHtml(playVoice)}"` : ''} title="Preview voice"><span class="mdi mdi-play"></span></button>`;
const right = isCurrent
? `<span class="reh-vo-badge"><span class="mdi mdi-check-circle"></span> Selected</span>`
: `<button type="button" class="btn-secondary btn-sm reh-vo-use"${useAttrs || ''}>Use this</button>`;
return `<div class="reh-vo-row${isCurrent ? ' current' : ''}">${play}
<div class="reh-vo-info"><span class="reh-vo-name">${escHtml(name)}</span><span class="reh-vo-meta">${escHtml(meta)}</span></div>
${right}</div>`;
}
// Render the "matched from fish.audio" picker: current voice + alternatives + change tools
function castOnlinePanelHtml(sp, c) {
const o = c.online;
if (!o || !Array.isArray(o.candidates) || c.voice === 'me') return '';
// Current assigned voice — an online candidate (has a sample) or a plain library voice
const curCand = o.candidates.find(x => x.voice_id && x.voice_id === c.voice);
let curRow;
if (curCand) {
curRow = _rehVoiceRow({ name: curCand.title || c.voice, meta: [curCand.gender, curCand.language].filter(Boolean).join(' · ') || 'fish.audio', playUrl: curCand.sample_audio, isCurrent: true });
} else if (c.voice) {
const vd = getVoiceData(c.voice);
curRow = _rehVoiceRow({ name: vd?.name || c.voice, meta: vd?.group || vd?.tag || 'library voice', playVoice: c.voice, isCurrent: true });
} else {
curRow = `<div class="reh-vo-row"><div class="reh-vo-info"><span class="reh-vo-name">No voice assigned</span><span class="reh-vo-meta">pick one below</span></div></div>`;
}
// Alternatives = the other matched candidates (not the current one)
const alts = o.candidates.map((cand, i) => ({ cand, i })).filter(({ cand }) => cand.voice_id !== c.voice || !cand.voice_id);
const altRows = alts.map(({ cand, i }) => _rehVoiceRow({
name: cand.title || 'Voice', meta: [cand.gender, cand.language].filter(Boolean).join(' · ') || 'fish.audio',
playUrl: cand.sample_audio, useAttrs: ` data-act="alt" data-idx="${i}"`,
})).join('');
return `<div class="reh-cc-online">
<div class="reh-cc-online-head"><span class="mdi mdi-earth"></span> Voice — try &amp; change
${alts.length ? `<button type="button" class="reh-vo-toggle">${alts.length} match${alts.length !== 1 ? 'es' : ''} ▾</button>` : ''}</div>
<div class="reh-vo-list">${curRow}</div>
${alts.length ? `<div class="reh-vo-list reh-vo-alts" hidden>${altRows}</div>` : ''}
<div class="reh-vo-tools">
<button type="button" class="reh-vo-tool" data-tool="local"><span class="mdi mdi-bookshelf"></span> From library</button>
<button type="button" class="reh-vo-tool" data-tool="search"><span class="mdi mdi-magnify"></span> Search online</button>
</div>
<div class="reh-vo-tool-panel reh-vo-local-panel" hidden>
<input type="search" class="reh-vo-local-input" placeholder="Filter your library voices…" autocomplete="off">
<div class="reh-vo-results reh-vo-local-results"></div>
</div>
<div class="reh-vo-tool-panel reh-vo-search-panel" hidden>
<div class="reh-vo-search-row"><input type="search" class="reh-vo-search-input" placeholder="Search fish.audio (name, style, language)…" autocomplete="off"><button type="button" class="btn-secondary btn-sm reh-vo-search-go">Search</button></div>
<div class="reh-vo-results reh-vo-search-results"><div class="reh-vo-hint">Type a query and hit Search to browse fish.audio voices.</div></div>
</div>
</div>`;
}
// Preview a local library voice by synthesizing a short sample with the chosen backend
async function _rehPreviewLocal(voiceId, btn) {
const backend = $('reh-backend-select')?.value;
if (!backend) { toast('Select a TTS backend first', 'error'); return; }
if (_rehAudBtn === btn && _rehAudEl && !_rehAudEl.paused) { _rehStopAudition(); return; }
_rehStopAudition();
const icon = btn.querySelector('.mdi');
btn.classList.add('loading'); if (icon) icon.className = 'mdi mdi-loading';
try {
const blob = await fetchTtsPreviewBlob(voiceId, _REH_PREVIEW_TEXT, 'wav', '', backend);
if (!_rehAudEl) { _rehAudEl = new Audio(); _rehAudEl.addEventListener('ended', _rehStopAudition); }
_rehAudEl.src = URL.createObjectURL(blob);
await _rehAudEl.play();
btn.classList.remove('loading'); _rehAudBtn = btn; btn.classList.add('playing');
if (icon) icon.className = 'mdi mdi-stop';
} catch (e) {
btn.classList.remove('loading'); if (icon) icon.className = 'mdi mdi-play';
toast('Preview failed: ' + (e.message || e), 'error');
}
}
// Pick a representative short line from a character's own dialogue (narrator: from
// the action/scene lines it narrates). Prefers a self-contained ~20110 char sentence.
function _rehPickOneLiner(sp) {
const isNarr = sp === REH_NARRATOR_KEY;
const pool = rehState.lines
.filter(l => isNarr ? (l.type !== 'dialog' && l.type !== 'pagebreak' && !l.ignored) : (l.type === 'dialog' && l.speaker === sp))
.map(l => ({ text: stripMarkdown(l.text || '').trim(), emotion: l.emotion || '' }))
.filter(l => l.text.length >= 4);
if (!pool.length) return null;
const scored = pool.map(l => {
const len = l.text.length;
let score = (len >= 20 && len <= 110) ? 3 : (len < 20 ? 1 : 0);
if (/[.!?]["']?$/.test(l.text)) score += 1; // complete sentence
score -= Math.abs(len - 60) / 120; // prefer ~60 chars
return { l, score };
}).sort((a, b) => b.score - a.score);
const best = scored[0].l;
return { text: best.text.slice(0, 220), emotion: best.emotion };
}
// Synthesize and play a one-liner from this character's lines, in their assigned voice.
async function _rehPreviewCastLine(sp, btn) {
const c = rehState.cast[sp];
if (!c || !c.voice || c.voice === 'me') { toast('Assign a voice first', 'error'); return; }
const backend = $('reh-backend-select')?.value;
if (!backend) { toast('Select a TTS backend first', 'error'); return; }
if (_rehAudBtn === btn && _rehAudEl && !_rehAudEl.paused) { _rehStopAudition(); return; }
_rehStopAudition();
const pick = _rehPickOneLiner(sp);
if (!pick) { toast('No lines to preview yet', 'error'); return; }
const icon = btn.querySelector('.mdi');
btn.classList.add('loading'); if (icon) icon.className = 'mdi mdi-loading';
try {
const blob = await fetchTtsPreviewBlob(c.voice, _rehInlineTone(pick.text, pick.emotion), 'wav', _buildInstruct(c.instruct, pick.emotion, c.voice), backend);
if (!_rehAudEl) { _rehAudEl = new Audio(); _rehAudEl.addEventListener('ended', _rehStopAudition); }
_rehAudEl.src = URL.createObjectURL(blob);
await _rehAudEl.play();
btn.classList.remove('loading'); _rehAudBtn = btn; btn.classList.add('playing');
if (icon) icon.className = 'mdi mdi-stop';
} catch (e) {
btn.classList.remove('loading'); if (icon) icon.className = 'mdi mdi-play';
toast('Preview failed: ' + (e.message || e), 'error');
}
}
// Assign an existing library voice to a character (keeps the picker open)
function _rehAssignLocal(sp, voiceId) {
const c = rehState.cast[sp]; if (!c) return;
_rehStopAudition();
c.voice = voiceId; c.voiceData = getVoiceData(voiceId);
if (c.online) c.online.picked = c.online.candidates.findIndex(x => x.voice_id === voiceId);
renderCastList(); if (typeof populateNarratorSelect === 'function') populateNarratorSelect();
}
// Render filtered library voices inside a card's "From library" panel
function _rehRenderLocalResults(card, sp, q) {
const box = card.querySelector('.reh-vo-local-results'); if (!box) return;
const cur = rehState.cast[sp]?.voice;
const ql = (q || '').trim().toLowerCase();
const matches = rehState.voices.filter(v => {
if (!_rehVoiceVisibleId(v, cur)) return false;
if (v === cur) return false;
if (!ql) return true;
const vd = getVoiceData(v);
return v.toLowerCase().includes(ql) || (vd?.name || '').toLowerCase().includes(ql) || (vd?.group || '').toLowerCase().includes(ql);
}).slice(0, 40);
if (!matches.length) { box.innerHTML = `<div class="reh-vo-hint">No library voices match.</div>`; return; }
box.innerHTML = matches.map(v => {
const vd = getVoiceData(v);
return _rehVoiceRow({ name: vd?.name || v, meta: vd?.group || vd?.tag || 'library voice', playVoice: v, useAttrs: ` data-act="local" data-voice="${escHtml(v)}"` });
}).join('');
}
// Search fish.audio and render results inside a card's "Search online" panel
async function _rehSearchOnline(card, sp, q) {
const box = card.querySelector('.reh-vo-search-results'); if (!box) return;
q = (q || '').trim();
if (!q) { box.innerHTML = `<div class="reh-vo-hint">Enter a search term first.</div>`; return; }
box.innerHTML = `<div class="reh-vo-hint"><span class="reh-imsdb-spinner"></span> Searching fish.audio…</div>`;
const lang = REH_FISH_LANG[$('reh-design-lang')?.value || 'English'] ?? 'en';
let items = [];
try {
const qs = [`search=${encodeURIComponent(q)}`, `language=${lang}`, 'page_size=10', 'sort_by=score'];
items = (await fetch('/api/fishaudio/voices?' + qs.join('&')).then(r => r.json())).items || [];
} catch (_) {}
const cands = items.filter(v => v.sample_audio).slice(0, 10).map(v => ({
title: v.title, sample_audio: v.sample_audio, image: v.image || '', gender: v.gender || '',
language: v.language || lang || '', description: v.description || '', sample_text: v.sample_text || v.default_text || '',
}));
_rehSearchCache[sp] = cands;
if (!cands.length) { box.innerHTML = `<div class="reh-vo-hint">No playable voices found for “${escHtml(q)}”.</div>`; return; }
box.innerHTML = cands.map((cand, i) => _rehVoiceRow({
name: cand.title || 'Voice', meta: [cand.gender, cand.language].filter(Boolean).join(' · ') || 'fish.audio',
playUrl: cand.sample_audio, useAttrs: ` data-act="search" data-idx="${i}"`,
})).join('');
}
// Import a freshly searched fish.audio result, assign it, and fold it into candidates
async function _rehUseSearchResult(sp, idx, btn) {
const c = rehState.cast[sp]; if (!c) return;
const cand = (_rehSearchCache[sp] || [])[idx]; if (!cand) return;
_rehStopAudition();
const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '<span class="reh-imsdb-spinner"></span>';
try {
const vid = await _rehImportFishCandidate(sp, cand);
cand.voice_id = vid; c.voice = vid; c.voiceData = getVoiceData(vid);
if (!c.online) c.online = { candidates: [], picked: 0 };
c.online.candidates.push(cand);
c.online.picked = c.online.candidates.length - 1;
if (typeof loadVoiceLibrary === 'function') await loadVoiceLibrary().catch(() => {});
renderCastList(); if (typeof populateNarratorSelect === 'function') populateNarratorSelect();
toast(`Switched ${sp} to ${cand.title || 'voice'}`, 'success');
} catch (err) {
btn.disabled = false; btn.innerHTML = orig;
toast('Import failed: ' + err.message, 'error');
}
}
// Remember fish imports this session so the same voice isn't downloaded twice
const _rehFishImported = {};
// Import one fish.audio candidate into the library and return its voice_id.
// Reuses an already-imported voice (this session OR already in the library) so the
// same fish.audio voice doesn't pile up as Mortal_Kombat, Mortal_Kombat_2, …
async function _rehImportFishCandidate(sp, cand) {
const key = (cand.sample_audio || cand.title || '').toLowerCase();
if (key && _rehFishImported[key]) {
const id = _rehFishImported[key];
if (!rehState.voices.includes(id)) rehState.voices.push(id);
return id;
}
const title = (cand.title || '').toLowerCase().trim();
const existing = title && (window._voices || []).find(v =>
(v.group === 'fish-audio' || v.tag === 'fish-audio') && (v.name || '').toLowerCase().trim() === title);
if (existing) {
if (key) _rehFishImported[key] = existing.id;
if (!rehState.voices.includes(existing.id)) rehState.voices.push(existing.id);
return existing.id;
}
const lang2 = (cand.language || 'EN').slice(0, 2).toUpperCase();
const vid = `${lang2}_${(typeof _umlautSafe === 'function' ? _umlautSafe(cand.title || sp) : (cand.title || sp)).replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 36) || 'Voice'}`;
const d = await fetch('/api/quick-import-voice', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice_id: vid, audio_url: cand.sample_audio, transcript: cand.sample_text || '' }),
}).then(r => r.json());
if (!d.voice_id) throw new Error('import returned no id');
if (typeof saveMeta === 'function') await saveMeta(d.voice_id, { name: cand.title || sp, tag: 'fish-audio', group: 'fish-audio', origin: 'cloned', gender: (cand.gender || '').charAt(0).toUpperCase(), note: (cand.description || '').slice(0, 180) }).catch(e => logErr('fish saveMeta', e));
if (cand.image) await fetch('/api/voice/picture-url', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ voice_id: d.voice_id, image_url: cand.image }) }).catch(e => logErr('fish picture', e));
if (!rehState.voices.includes(d.voice_id)) rehState.voices.push(d.voice_id);
if (key) _rehFishImported[key] = d.voice_id;
return d.voice_id;
}
// "Disagree" → switch this character to one of the presented alternatives
async function _rehUseCandidate(sp, idx, btn) {
const c = rehState.cast[sp]; const o = c && c.online; if (!o) return;
const cand = o.candidates[idx]; if (!cand) return;
_rehStopAudition();
if (cand.voice_id) { // already imported earlier — just reassign
c.voice = cand.voice_id; c.voiceData = getVoiceData(cand.voice_id); o.picked = idx;
renderCastList(); if (typeof populateNarratorSelect === 'function') populateNarratorSelect();
return;
}
const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '<span class="reh-imsdb-spinner"></span>';
try {
const vid = await _rehImportFishCandidate(sp, cand);
cand.voice_id = vid; c.voice = vid; c.voiceData = getVoiceData(vid); o.picked = idx;
if (typeof loadVoiceLibrary === 'function') await loadVoiceLibrary().catch(() => {});
renderCastList(); if (typeof populateNarratorSelect === 'function') populateNarratorSelect();
toast(`Switched ${sp} to ${cand.title || 'voice'}`, 'success');
} catch (err) {
btn.disabled = false; btn.innerHTML = orig;
toast('Import failed: ' + err.message, 'error');
}
}
const REH_NARRATOR_KEY = '\u{1F4D6}NARRATOR'; // unique sentinel kept out of real speaker names
function _ensureNarrator() {
if (!rehState.cast[REH_NARRATOR_KEY]) {
rehState.cast[REH_NARRATOR_KEY] = {
voice: rehState.narratorVoice || '',
color: '#6b7280',
instruct: '',
voiceData: rehState.narratorVoice ? getVoiceData(rehState.narratorVoice) : null,
_isNarrator: true,
};
}
// Bidirectional sync between the narrator cast row and the global narratorVoice.
// The cast row is the source of truth (auto-design assigns the voice there), but
// playback/synth read rehState.narratorVoice — keep them aligned both ways.
const n = rehState.cast[REH_NARRATOR_KEY];
if (n.voice) {
rehState.narratorVoice = n.voice;
if (!n.voiceData) n.voiceData = getVoiceData(n.voice);
} else if (rehState.narratorVoice) {
n.voice = rehState.narratorVoice;
n.voiceData = getVoiceData(rehState.narratorVoice);
}
}
const REH_CAST_LANGS = ['English', 'German', 'Auto', 'French', 'Spanish', 'Italian', 'Portuguese', 'Dutch', 'Polish'];
const REH_CAST_GENDERS = [['', '—'], ['F', '♀ Female'], ['M', '♂ Male'], ['N', '⚥ Diverse']];
// Apply a mutation to every dialog line spoken by a character
function _castApplyToLines(sp, fn) {
rehState.lines.forEach((l, i) => { if (l.speaker === sp && l.type === 'dialog') fn(l, i); });
}
function _safeDomId(value) {
let out = '';
const s = String(value || 'voice');
for (let i = 0; i < s.length; i++) out += s.charCodeAt(i).toString(36) + '-';
return out || 'voice';
}
// Cross-referencing the Character Library gives the cast cards a real
// portrait, tier badge, and occupation/archetype instead of just a colored
// initial — pulled from whatever book/script this cast shares a title with.
// Fetched once per script title (not per render) and cached. Two separate
// renderers share this one cache — renderCastList (the "Who's playing which
// character?" mecast panel) and renderCastStrip (the Stage sidebar's own
// character list, see below) — but only ONE fetch is ever kicked off per
// script title, guarded by _rehLibCharsCacheBook. Whichever renderer's guard
// check happens to run first "claims" that fetch; the other one sees the
// book already marked as fetched and skips starting its own — so BOTH must
// be re-run once the shared fetch resolves, not just whichever one started
// it. Confirmed live as a real bug: entering Perform & Export borrows both
// panels at once, renderCastList's guard usually wins the race, and its own
// old single-target callback left the Stage sidebar stuck on plain
// colored-letter dots forever (never re-rendered with portraits) even
// though the cache had genuinely finished loading with images moments
// later — only calling renderCastStrip() by hand fixed it.
let _rehLibCharsCache = null;
let _rehLibCharsCacheBook = null;
function _rehEnsureLibCharsCache(scriptTitle) {
if (!scriptTitle || _rehLibCharsCacheBook === scriptTitle || typeof clGetAllByTagOrBook !== 'function') return;
_rehLibCharsCacheBook = scriptTitle;
clGetAllByTagOrBook(scriptTitle).then(recs => {
_rehLibCharsCache = recs || [];
renderCastList();
if (typeof renderCastStrip === 'function') renderCastStrip();
// Stage's per-line portraits (_rehCharAvatarHtml) also read this cache
// directly, not just the sidebar row — re-run the full script page too
// so lines that rendered before the cache arrived pick up portraits.
if (typeof buildScriptPage === 'function') buildScriptPage();
}).catch(() => {});
}
function renderCastList() {
const list = $('reh-cast-list'); if (!list) return;
// Make sure the voice library is loaded so every picker (incl. the narrator) has
// voices to choose from even when the Rehearser was opened directly.
if (!(window._voices || []).length && typeof loadVoiceLibrary === 'function' && !rehState._castLibFetch) {
rehState._castLibFetch = true;
loadVoiceLibrary().then(() => renderCastList()).catch(() => {});
}
_ensureNarrator();
const narr = REH_NARRATOR_KEY;
const lineCount = sp => rehState.lines.filter(l => l.speaker === sp && l.type === 'dialog').length;
const allOthers = Object.keys(rehState.cast).filter(s => s !== narr);
const others = _castSortFilter(allOthers, lineCount); // narrator always stays pinned on top
const speakers = [narr, ...others];
const scriptTitle = $('reh-script-title')?.value.trim() || '';
_rehEnsureLibCharsCache(scriptTitle);
const libRecByName = new Map((_rehLibCharsCache || []).map(r => [String(r.name || '').trim().toLowerCase(), r]));
const emotionsFor = sp => {
const seen = new Map();
rehState.lines.forEach(l => {
if (l.speaker === sp && l.type === 'dialog' && l.emotion && !seen.has(l.emotion)) {
seen.set(l.emotion, getEmotionInfo(l.emotion));
}
});
return [...seen.values()];
};
list.innerHTML = speakers.map(sp => {
const c = rehState.cast[sp];
const isNarr = sp === narr;
const isMe = c.voice === 'me';
const label = isNarr ? 'Narrator' : sp;
const n = lineCount(sp);
const sub = isNarr ? 'scene headings &amp; descriptions' : `${n} line${n!==1?'s':''}${scriptTitle ? ` · ${escHtml(scriptTitle)}` : ''}`;
const cardCls = 'reh-cast-card' + (isNarr ? ' reh-cast-narrator' : '') + (c.ignored ? ' reh-cast-ignored' : '') + (c.hidden ? ' reh-cast-hidden-c' : '');
const langSel = REH_CAST_LANGS.map(l => `<option${(c.lang||'')===l?' selected':''}>${l}</option>`).join('');
const genSel = REH_CAST_GENDERS.map(([v,t]) => `<option value="${v}"${(c.gender||'')===v?' selected':''}>${t}</option>`).join('');
const pickerId = 'reh-voice-sel-' + _safeDomId(sp);
const voiceSel = `<select id="${pickerId}" class="reh-voice-sel" data-speaker="${escHtml(sp)}"${isMe?' style="display:none"':''}>
<option value="">— ${isNarr ? 'none (skip descriptions)' : 'assign a voice'} —</option>
${_rehAllVoiceIds(c.voice).map(v=>`<option value="${escHtml(v)}"${v===c.voice?' selected':''}>${escHtml(v)}</option>`).join('')}
</select>`;
const voiceName = !isMe && c.voice ? (getVoiceData(c.voice)?.name || c.voice) : (isMe ? 'Your mic' : '');
// Library cross-reference — same book/script, matched by name. When a
// real character record exists, the card IS the exact Library card
// (_charCardHtml) — same colorful portrait/tier/occupation/alignment
// design already used in Library → Cast, not a separate look-alike —
// and voice assignment happens by clicking into the full profile (or
// the bulk match tools above) rather than a dropdown on the card.
// Narrator/unmatched speakers (no Library record to point at) keep the
// simple fallback header with an inline voice dropdown, since there's
// no profile page for them to assign a voice from.
const libRec = !isNarr ? libRecByName.get(String(sp).trim().toLowerCase()) : null;
if (libRec) {
const libVoice = typeof libRec.voice === 'object' ? (libRec.voice?.id || '') : (libRec.voice || '');
if (libVoice) c.voice = libVoice;
}
const emotions = isNarr ? [] : emotionsFor(sp);
const emotionsHtml = emotions.length
? `<div class="reh-cc-emotions">${emotions.map(info => `<span class="reh-cc-emo-chip">${info.emoji} ${escHtml(info.label)}</span>`).join('')}</div>`
: '';
const controlsHtml = `<div class="reh-cc-controls">
<label class="reh-cc-me-row" title="Record this character with your own mic instead of a TTS voice"><input type="checkbox" class="reh-me-check" data-speaker="${escHtml(sp)}" ${isMe?'checked':''}><span><span class="mdi mdi-microphone"></span> I play this</span></label>
${(!isMe && c.voice) ? `<button type="button" class="reh-cc-sample-btn" data-speaker="${escHtml(sp)}" title="Hear a sample line in this voice" aria-label="Hear a sample line in this voice"><span class="mdi mdi-play"></span> Hear a line</button>` : ''}
<span class="reh-cc-actions-spacer"></span>
<button class="reh-cc-iconbtn${c.ignored?' active':''}" data-act="ignore" title="Ignore — grey out &amp; skip this characters lines" aria-label="Ignore this characters lines"><span class="mdi mdi-eye-off-outline"></span></button>
<button class="reh-cc-iconbtn${c.hidden?' active':''}" data-act="hide" title="Hide this characters lines from the script" aria-label="Hide this characters lines"><span class="mdi mdi-minus-circle-outline"></span></button>
<button class="reh-cc-iconbtn reh-cc-del" data-act="delete" title="Delete this character &amp; all their lines" aria-label="Delete this character"><span class="mdi mdi-trash-can-outline"></span></button>
</div>
${emotionsHtml}`;
if (libRec) {
return `<div class="${cardCls} reh-cast-card-libcard" data-speaker="${escHtml(sp)}" data-char-id="${escHtml(libRec.id)}" style="--cc-color:${c.color}">
${(typeof _charCardHtml === 'function') ? _charCardHtml(libRec, _rehLibCharsCache || []) : ''}
<div class="reh-cc-body">${controlsHtml}</div>
</div>`;
}
return `<div class="${cardCls}" data-speaker="${escHtml(sp)}" style="--cc-color:${c.color}">
<div class="reh-cc-head">
<div class="reh-cc-avatar">${castAvatarHtml(sp)}</div>
<div class="reh-cc-titles">
<div class="reh-cc-name" style="color:${c.color}">${isNarr ? '<span class="mdi mdi-book-open-page-variant"></span> ' : ''}${escHtml(label)}</div>
<div class="reh-cc-sub">${sub}</div>
<div class="reh-cc-voicechip${(!isMe && !c.voice) ? ' empty' : ''}"><span class="mdi mdi-${isMe ? 'microphone' : 'account-music-outline'}"></span> ${escHtml(voiceName || 'No voice assigned')}</div>
</div>
</div>
<div class="reh-cc-body"${isMe?' style="display:none"':''}>
<div class="reh-cc-field reh-cc-voice"><label>Voice</label>${voiceSel}</div>
${controlsHtml}
<details class="reh-cc-more">
<summary><span class="mdi mdi-tune-variant"></span> More options</summary>
<div class="reh-cc-extra">
${!isNarr ? castOnlinePanelHtml(sp, c) : ''}
${!isNarr ? `<div class="reh-cc-grid">
<div class="reh-cc-field"><label>Language</label><select class="reh-cc-lang">${langSel}</select></div>
<div class="reh-cc-field"><label>Gender</label><select class="reh-cc-gender">${genSel}</select></div>
<div class="reh-cc-field"><label>Tags</label><input type="text" class="reh-cc-tags" value="${escHtml(c.tags||'')}" placeholder="e.g. villain, raspy"></div>
</div>` : ''}
<div class="reh-cc-field"><label>Speaking style · voice-design prompt</label>
<input type="text" class="reh-cast-instruct" value="${escHtml(c.instruct||'')}" placeholder="${isNarr ? 'Narrator style: calm, measured, cinematic storyteller…' : 'British accent, commanding baritone, quietly menacing…'}"></div>
${!isNarr ? `<details class="reh-cc-soul"${c.soul?' open':''}>
<summary><span class="mdi mdi-script-text-outline"></span> Character soul · LLM brief
<button class="btn-secondary btn-sm reh-cc-develop" type="button" title="Let the LLM read the script &amp; flesh out this character"><span class="mdi mdi-auto-fix"></span> Develop</button>
</summary>
<textarea class="reh-cc-soul-text" placeholder="Backstory, motivation, vocal manner… (used as the LLM brief when designing this voice)">${escHtml(c.soul||'')}</textarea>
</details>` : ''}
</div>
</details>
</div>
</div>`;
}).join('');
// Cast count (reflects active filter) + keep the chosen view (card / list)
const countEl = $('reh-cast-count');
if (countEl) {
const shown = others.length, total = allOthers.length;
countEl.textContent = shown === total
? `${total} ${total === 1 ? 'character' : 'characters'} + narrator`
: `${shown} of ${total} characters`;
}
// Wire the reused Library cards (portrait upload, export, click → full
// profile) exactly like the Library grid does — the profile page opens
// in place of this list and "back" re-renders the cast list, same pattern
// used for the post-recast results grid on the Read Aloud page.
if (typeof _wireCharCards === 'function' && (_rehLibCharsCache || []).length) {
const recsById = new Map(_rehLibCharsCache.map(r => [r.id, r]));
_wireCharCards(list, recsById, _rehLibCharsCache, renderCastList, { container: list, onBack: renderCastList });
}
_wireCastControls();
applyCastView();
// ── Wiring ────────────────────────────────────────────────────────────────
const card = el => el.closest('.reh-cast-card');
const spOf = el => card(el).dataset.speaker;
if (window.VoicePicker) {
list.querySelectorAll('.reh-voice-sel[id]').forEach(sel => {
const cur = sel.value;
VoicePicker.upgrade(sel.id);
if (cur) VoicePicker.setValue(sel.id, cur);
});
}
list.querySelectorAll('.reh-me-check').forEach(cb => cb.addEventListener('change', function () {
const sp = spOf(this);
rehState.cast[sp].voice = this.checked ? 'me' : (card(this).querySelector('.reh-voice-sel')?.value || '');
rehState.cast[sp].voiceData = this.checked ? null : getVoiceData(rehState.cast[sp].voice);
renderCastList();
}));
list.querySelectorAll('.reh-voice-sel').forEach(sel => sel.addEventListener('change', function () {
const sp = this.dataset.speaker;
rehState.cast[sp].voice = this.value;
rehState.cast[sp].voiceData = getVoiceData(this.value);
delete rehState.cast[sp].online; // manual override → drop the online-match picker context
if (sp === REH_NARRATOR_KEY) rehState.narratorVoice = this.value;
renderCastList();
}));
list.querySelectorAll('.reh-cast-instruct').forEach(inp => inp.addEventListener('input', function () {
rehState.cast[spOf(this)].instruct = this.value;
}));
list.querySelectorAll('.reh-cc-lang').forEach(s => s.addEventListener('change', function () { rehState.cast[spOf(this)].lang = this.value; }));
list.querySelectorAll('.reh-cc-gender').forEach(s => s.addEventListener('change', function () { rehState.cast[spOf(this)].gender = this.value; }));
list.querySelectorAll('.reh-cc-tags').forEach(i => i.addEventListener('input', function () { rehState.cast[spOf(this)].tags = this.value; }));
list.querySelectorAll('.reh-cc-soul-text').forEach(t => t.addEventListener('input', function () { rehState.cast[spOf(this)].soul = this.value; }));
list.querySelectorAll('.reh-cc-develop').forEach(b => b.addEventListener('click', function (e) { e.preventDefault(); _castDevelop(spOf(this), this); }));
list.querySelectorAll('.reh-cc-iconbtn').forEach(b => b.addEventListener('click', function () {
const sp = spOf(this), act = this.dataset.act, c = rehState.cast[sp];
if (act === 'ignore') { c.ignored = !c.ignored; _castApplyToLines(sp, l => l.ignored = c.ignored); renderCastList(); }
else if (act === 'hide') { c.hidden = !c.hidden; _castApplyToLines(sp, l => l.hidden = c.hidden); renderCastList(); }
else if (act === 'delete') { _castDeleteCharacter(sp); }
}));
// Online voice picker — delegated so dynamically-injected result rows work too.
// `list` persists across re-renders, so attach these handlers only once.
if (!list._voDelegated) {
list._voDelegated = true;
list.addEventListener('click', e => {
const sampleBtn = e.target.closest('.reh-cc-sample-btn');
if (sampleBtn) { e.preventDefault(); _rehPreviewCastLine(sampleBtn.dataset.speaker, sampleBtn); return; }
const playBtn = e.target.closest('.reh-vo-play');
if (playBtn) { e.preventDefault(); if (playBtn.dataset.voice) _rehPreviewLocal(playBtn.dataset.voice, playBtn); else _rehAudition(playBtn.dataset.url, playBtn); return; }
const toggle = e.target.closest('.reh-vo-toggle');
if (toggle) {
e.preventDefault();
const alts = toggle.closest('.reh-cc-online')?.querySelector('.reh-vo-alts');
if (alts) { alts.hidden = !alts.hidden; toggle.textContent = toggle.textContent.replace(/[▾▴]\s*$/, '') + (alts.hidden ? '▾' : '▴'); }
return;
}
const tool = e.target.closest('.reh-vo-tool');
if (tool) {
e.preventDefault();
const panel = tool.closest('.reh-cc-online');
const want = tool.dataset.tool;
const localP = panel.querySelector('.reh-vo-local-panel');
const searchP = panel.querySelector('.reh-vo-search-panel');
const showLocal = want === 'local' && (localP?.hidden ?? true);
const showSearch = want === 'search' && (searchP?.hidden ?? true);
if (localP) localP.hidden = !showLocal;
if (searchP) searchP.hidden = !showSearch;
panel.querySelectorAll('.reh-vo-tool').forEach(t => t.classList.toggle('active', (t.dataset.tool === 'local' && showLocal) || (t.dataset.tool === 'search' && showSearch)));
if (showLocal) { const card = e.target.closest('.reh-cast-card'); _rehRenderLocalResults(card, spOf(tool), ''); card.querySelector('.reh-vo-local-input')?.focus(); }
if (showSearch) panel.querySelector('.reh-vo-search-input')?.focus();
return;
}
const goBtn = e.target.closest('.reh-vo-search-go');
if (goBtn) { e.preventDefault(); const card = e.target.closest('.reh-cast-card'); _rehSearchOnline(card, spOf(goBtn), card.querySelector('.reh-vo-search-input')?.value); return; }
const use = e.target.closest('.reh-vo-use');
if (use) {
e.preventDefault();
const sp = spOf(use), act = use.dataset.act;
if (act === 'local') _rehAssignLocal(sp, use.dataset.voice);
else if (act === 'search') _rehUseSearchResult(sp, parseInt(use.dataset.idx, 10), use);
else _rehUseCandidate(sp, parseInt(use.dataset.idx, 10), use); // 'alt'
}
});
list.addEventListener('input', e => {
const li = e.target.closest('.reh-vo-local-input');
if (li) _rehRenderLocalResults(e.target.closest('.reh-cast-card'), spOf(li), li.value);
});
list.addEventListener('keydown', e => {
const si = e.target.closest('.reh-vo-search-input');
if (si && e.key === 'Enter') { e.preventDefault(); _rehSearchOnline(e.target.closest('.reh-cast-card'), spOf(si), si.value); }
});
}
}
// Cast card / list view toggle
function applyCastView() {
const list = $('reh-cast-list'); if (!list) return;
const view = rehState.castView === 'list' ? 'list' : 'card';
list.classList.toggle('reh-cast-view-card', view === 'card');
list.classList.toggle('reh-cast-view-list', view === 'list');
document.querySelectorAll('#reh-cast-view-toggle .reh-view-btn').forEach(b => b.classList.toggle('active', b.dataset.view === view));
}
try { rehState.castView = localStorage.getItem('reh-cast-view') || 'card'; } catch (_) { rehState.castView = 'card'; }
// Cast sort + filter state (narrator excluded — it's pinned on top by renderCastList)
rehState.castFilter = { search: '', gender: '', lang: '' };
try { rehState.castSort = JSON.parse(localStorage.getItem('reh-cast-sort')) || { by: 'name', dir: 'asc' }; }
catch (_) { rehState.castSort = { by: 'name', dir: 'asc' }; }
// Wire the cast toolbar (view toggle + sort/filter). Called from renderCastList so it
// always binds once the section is mounted; guarded to attach only once.
function _wireCastControls() {
const toggle = $('reh-cast-view-toggle');
if (!toggle || toggle._wired) return;
toggle._wired = true;
toggle.querySelectorAll('.reh-view-btn').forEach(b => b.addEventListener('click', () => {
rehState.castView = b.dataset.view;
try { localStorage.setItem('reh-cast-view', b.dataset.view); } catch (_) {}
applyCastView();
}));
const search = $('reh-cast-search'), fg = $('reh-cast-filter-gender'), fl = $('reh-cast-filter-lang');
const sortSel = $('reh-cast-sort'), dirBtn = $('reh-cast-sort-dir');
const setDirIcon = () => { if (dirBtn) { dirBtn.dataset.dir = rehState.castSort.dir; dirBtn.querySelector('.mdi').className = 'mdi mdi-sort-' + (rehState.castSort.dir === 'desc' ? 'descending' : 'ascending'); } };
const persist = () => { try { localStorage.setItem('reh-cast-sort', JSON.stringify(rehState.castSort)); } catch (_) {} };
if (sortSel) sortSel.value = rehState.castSort.by;
setDirIcon();
search?.addEventListener('input', () => { rehState.castFilter.search = search.value.trim(); renderCastList(); search.focus(); });
fg?.addEventListener('change', () => { rehState.castFilter.gender = fg.value; renderCastList(); });
fl?.addEventListener('change', () => { rehState.castFilter.lang = fl.value; renderCastList(); });
sortSel?.addEventListener('change', () => {
rehState.castSort.by = sortSel.value;
rehState.castSort.dir = sortSel.value === 'lines' ? 'desc' : 'asc'; // lines → most first
setDirIcon(); persist(); renderCastList();
});
dirBtn?.addEventListener('click', () => {
rehState.castSort.dir = rehState.castSort.dir === 'desc' ? 'asc' : 'desc';
setDirIcon(); persist(); renderCastList();
});
}
function _castSortFilter(speakers, lineCount) {
const f = rehState.castFilter || {}, s = rehState.castSort || { by: 'name', dir: 'asc' };
const out = speakers.filter(sp => {
const c = rehState.cast[sp] || {};
if (f.search) { const q = f.search.toLowerCase(); if (!sp.toLowerCase().includes(q) && !(c.tags || '').toLowerCase().includes(q)) return false; }
if (f.gender && (c.gender || '') !== f.gender) return false;
if (f.lang && (c.lang || '') !== f.lang) return false;
return true;
});
const byName = (a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' });
const cmp = {
name: byName,
gender: (a, b) => (rehState.cast[a].gender || '~').localeCompare(rehState.cast[b].gender || '~') || byName(a, b),
lang: (a, b) => (rehState.cast[a].lang || '~').localeCompare(rehState.cast[b].lang || '~') || byName(a, b),
lines: (a, b) => (lineCount(a) - lineCount(b)) || byName(a, b),
tag: (a, b) => (rehState.cast[a].tags || '~').localeCompare(rehState.cast[b].tags || '~') || byName(a, b),
}[s.by] || byName;
out.sort(cmp);
if (s.dir === 'desc') out.reverse();
return out;
}
// Remove a character and all of their dialog lines (with index-state remap)
function _castDeleteCharacter(sp) {
const n = rehState.lines.filter(l => l.speaker === sp && l.type === 'dialog').length;
if (!confirm(`Delete “${sp}” and their ${n} line${n!==1?'s':''}? This cannot be undone.`)) return;
for (let i = rehState.lines.length - 1; i >= 0; i--) {
if (rehState.lines[i].speaker === sp && rehState.lines[i].type === 'dialog') {
rehState.lines.splice(i, 1);
_reindexLineState(i);
}
}
delete rehState.cast[sp];
renderCastList();
if (rehState.lines.length) buildScriptPage();
toast(`Removed ${sp}`, 'success');
}
// Ask the LLM to flesh out one character → fills the soul brief + design prompt
async function _castDevelop(sp, btn) {
const script = $('reh-script-text')?.value.trim() || linesToScriptText();
if (!script) { toast('Load a script first', 'error'); return; }
const c = rehState.cast[sp];
const orig = btn.innerHTML;
btn.disabled = true; btn.innerHTML = '<span class="reh-imsdb-spinner"></span> Developing…';
try {
const r = await fetch('/api/analyze-characters', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
script, names: [sp],
llm_url: $('reh-llm-url')?.value.trim() || rehDefaultLlmUrl(),
model: $('reh-llm-model')?.value || '',
language: c.lang || $('reh-design-lang')?.value || 'English',
}),
});
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const info = (await r.json()).characters?.[0] || {};
if (info.gender) c.gender = String(info.gender).toUpperCase().charAt(0).replace(/[^MFN]/, 'N');
if (info.description) { c.soul = info.description; c.instruct = c.instruct || info.description; }
renderCastList();
toast(`Developed ${sp}`, 'success');
} catch (e) {
btn.disabled = false; btn.innerHTML = orig;
toast('Develop failed: ' + e.message, 'error');
}
}
async function refreshRehBackends() {
rehInitLlmField();
const sel = $('reh-backend-select'); if (!sel) return;
let backends = typeof availableTtsBackends === 'function' ? availableTtsBackends() : [];
// If the global list hasn't been probed yet, trigger the probe now
if (!backends.length && typeof refreshTtsBackendAvailability === 'function') {
await refreshTtsBackendAvailability().catch(() => {});
backends = typeof availableTtsBackends === 'function' ? availableTtsBackends() : [];
}
// Last resort: show all known backends (even unavailable ones) so the user can try
if (!backends.length && typeof _ttsBackends !== 'undefined' && Array.isArray(_ttsBackends)) {
backends = _ttsBackends;
}
const prev = sel.value || rehState.backend;
sel.innerHTML = backends.length
? backends.map(b => `<option value="${escHtml(b.id)}">${escHtml(b.label)}</option>`).join('')
: '<option value="">No backend available</option>';
if (prev && [...sel.options].some(o => o.value === prev)) {
// Restore a previously chosen backend
sel.value = prev;
} else {
// Default backend preference. fishspeech is best — it clones each voice's saved WAV
// (consistent identity) AND honours per-line emotion markers (tone), so a character
// sounds the same across the rehearsal while still reacting to tone. voice_clone is the
// fallback for consistency (weak tone); voice_design gives tone but drifts each line.
const preferred = ['fishspeech', 'voice_clone', 'customvoice', 'voice_design'];
const pick = preferred.find(id => [...sel.options].some(o => o.value === id));
if (pick) sel.value = pick;
}
rehState.backend = sel.value || ''; // keep state in sync with the (possibly auto-picked) value
_checkToneStyleSupport();
}
// Keep the rehearser select in sync whenever the global backend list refreshes
(window._ttsRefreshHooks = window._ttsRefreshHooks || []).push(() => {
const sel = $('reh-backend-select'); if (!sel) return;
const backends = typeof availableTtsBackends === 'function' ? availableTtsBackends() : [];
if (!backends.length) return;
const prev = sel.value || rehState.backend;
sel.innerHTML = backends.map(b => `<option value="${escHtml(b.id)}">${escHtml(b.label)}</option>`).join('');
if (prev && [...sel.options].some(o => o.value === prev)) sel.value = prev;
else if (sel.options.length) sel.value = sel.options[0].value;
rehState.backend = sel.value || '';
});
// All voice IDs offered in the cast/narrator pickers — backend voices PLUS everything
// in the library, so any saved/cloned/imported voice (incl. for the narrator) is pickable.
function _rehAllVoiceIds(include) {
const ids = new Set((rehState.voices || []).filter(id => _rehVoiceVisibleId(id, include)));
(window._voices || []).forEach(v => { if (v && v.id && (v.enabled !== false || v.id === include)) ids.add(v.id); });
if (include) ids.add(include);
return [...ids].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
}
function populateNarratorSelect() {
const sel = $('reh-narrator-voice'); if (!sel) return;
const cur = rehState.narratorVoice || sel.value;
sel.innerHTML = '<option value="">— none (skip or pause) —</option>' +
_rehAllVoiceIds(cur).map(v => `<option value="${escHtml(v)}"${v===cur?' selected':''}>${escHtml(v)}</option>`).join('');
}
$('reh-fetch-voices-btn')?.addEventListener('click', async () => {
const backend = $('reh-backend-select')?.value;
if (!backend) { toast('Select a backend first', 'error'); return; }
$('reh-fetch-voices-btn').disabled = true;
try {
if ((!window._voices || !window._voices.length) && typeof loadVoiceLibrary === 'function') {
await loadVoiceLibrary().catch(() => {});
}
const raw = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json());
rehState.voices = (Array.isArray(raw) ? raw.map(v => typeof v === 'string' ? v : (v.id || String(v))) : [])
.filter(id => _rehVoiceVisibleId(id));
renderCastList();
populateNarratorSelect();
toast('Fetched ' + rehState.voices.length + ' voices', 'success');
} catch(e) { toast('Fetch failed: ' + e.message, 'error'); }
finally { $('reh-fetch-voices-btn').disabled = false; }
});
$('reh-narrator-voice')?.addEventListener('change', function () { rehState.narratorVoice = this.value; });
$('reh-back-1-btn')?.addEventListener('click', () => showPhase(1));
// ── TTS instruct builder — emotion goes first as a directive ─────────────────
// voice_clone backends treat instruct as an IDENTITY description; putting the
// emotion first and using directive language ("Speak in a … manner") makes the
// model prioritise it over the static voice description, which otherwise wins.
// Casting tags emotions in the BOOK's own language (this app runs heavily
// with German books, so `emotion` here is usually a German word like
// "fordernd" or "entschlossen") — but the instruct sentence wrapping it was
// always hardcoded English ("Speak in a fordernd manner."), dropping a
// German adjective into an English carrier sentence. Confirmed as the
// likely reason emotion barely registered for designed voices even though
// isolated English-only test instructs ("speak in an extremely angry
// manner") clearly changed the output — a mixed-language instruct is a much
// weaker signal than a natural sentence in one language. `voiceId`'s own
// language-code prefix (DE_/EN_/...) picks the matching template.
const _BUILD_INSTRUCT_TEMPLATES = {
DE: e => `Sprich in einem ${e} Tonfall.`,
EN: e => `Speak in a ${e} manner.`,
};
// Same wording _buildVoicePrompt uses for the one-time design call — that
// clause was NEVER being resent on ongoing lines: the per-line instruct
// only ever carried the character's saved voice_design_prompt (a plain
// voice-quality description with no accent guidance in it at all), because
// the accent clause was built fresh at design time and never written back
// into that saved profile. Since each synthesis call is stateless — the
// engine has no memory of the original design call — omitting it here meant
// every ONGOING line got zero accent reinforcement, only the one-off
// creation call ever did. Confirmed as a real, separate cause of designed
// voices drifting back toward an American accent during actual narration.
const _BUILD_INSTRUCT_LANG_NAMES = { DE: 'German', FR: 'French', ES: 'Spanish', IT: 'Italian', PT: 'Portuguese', NL: 'Dutch', PL: 'Polish' };
function _buildAccentClause(langCode) {
const langName = _BUILD_INSTRUCT_LANG_NAMES[langCode];
if (langName) return `Speak with an authentic native ${langName} accent — not American-accented, not an English speaker doing ${langName}.`;
if (langCode === 'EN') return 'English with a neutral British or international accent, explicitly not American/US-accented.';
return '';
}
function _buildInstruct(voiceProfile, emotion, voiceId) {
const p = (voiceProfile || '').trim();
const e = (emotion || '').trim();
const langCode = String(voiceId || '').split('_')[0].toUpperCase();
const accent = _buildAccentClause(langCode);
if (!e && !p && !accent) return '';
// The emotion clause is now ALWAYS built in English, regardless of the voice's
// own language — confirmed via controlled A/B testing (same line, same voice,
// varying only instruct language) that Qwen3-TTS's emotional differentiation is
// dramatically stronger in English than German: sad/angry/shocked/happy formed a
// clean, coherent pitch gradient (274Hz -> 398Hz) in English, but stayed muddled
// together in German even with the exact same wording translated. The spoken
// TEXT and the accent clause below stay fully native-language — only the
// emotion instruction itself changes language. The raw per-line emotion is
// LLM-generated in the book's own language (see the casting prompt's "1-2
// deutsche Wörter"), so translate it before building the clause.
const emotionEn = e && langCode !== 'EN' && typeof _rehEmotionEnglishTag === 'function' ? _rehEmotionEnglishTag(e) : '';
const effectiveEmotion = emotionEn || e;
const emotionTmpl = _BUILD_INSTRUCT_TEMPLATES.EN;
// Emotion leads, accent trails — Qwen3-TTS's own prompting guidance warns
// it "does not follow instructions correctly when dealing with
// conflicting attributes... favoring one over the other." Putting the
// accent clause first (as the previous version did) made it the most
// prominent instruction on every single line, which lines up with the
// reported regression right after that fix landed: mood stopped coming
// through. Emotion is the one thing that MUST vary per line; accent is a
// constant reminder the voice's own identity should mostly already carry,
// so it goes last, not first.
// ...and when an emotion IS present, the accent clause is dropped entirely rather
// than merely demoted. Measured on the deterministic voice_clone backend (identical
// input reproduces bit-identical audio, so these differences are real, not sampling
// noise): with an emotion-only instruct the tones separate cleanly and in the right
// order — whisper 206Hz, sad 170Hz, neutral 225Hz, happy 234Hz, angry 233Hz, scared
// 239Hz, with sad also slower and quieter. Appending "Speak with an authentic native
// German accent." to those same instructs collapsed the whole range to 222-250Hz and
// erased sad almost completely. Two style directives in one instruct compete, and the
// trailing one wins — which is exactly the failure Qwen3-TTS's own prompting guidance
// warns about for conflicting attributes. A cloned voice already carries its accent
// from the reference WAV, so accent reinforcement is the cheaper of the two to lose;
// lines with no emotion still get it.
const parts = e
? [emotionTmpl(effectiveEmotion), p].filter(Boolean)
: [p, accent].filter(Boolean);
return parts.join(' ');
}
// Fish-Speech / OpenAudio S2 reads inline [tag] emotion markers straight from the
// input text (the OpenAI-style `instruct` field is ignored). For fish backends we
// therefore prepend the tone as a [tag] so per-line tones actually take effect.
function _rehBackendIsFish() {
let id = '';
try { id = (typeof backendById === 'function' && backendById(rehState.backend)?.id) || rehState.backend || ''; }
catch (_) { id = rehState.backend || ''; }
return /fish/i.test(id);
}
// Fish-Speech's docs are explicit: "Use English emotion tags in square brackets
// regardless of the spoken language." Per-line auto emotions are LLM-generated in the
// book's own language (the casting prompt literally asks for "1-2 deutsche Wörter"),
// which is what _buildInstruct's native-language sentence needs for Qwen3-TTS — but
// that same raw word is useless as a Fish [tag] untranslated. Confirmed live: German
// tags were silently ignored, producing flat/emotionless Fish-Speech output even
// though the audio itself was fine. This table only needs to cover common tone/mood
// adjectives, not a full dictionary — unrecognized words fall through to no tag at
// all (line 8 below), which is a NOP for Fish, safer than sending a foreign-language
// word it will just ignore anyway.
const _REH_EMOTION_DE_EN = {
'wütend':'angry','zornig':'angry','erzürnt':'angry','verärgert':'annoyed','gereizt':'irritated',
'traurig':'sad','melancholisch':'melancholic','betrübt':'sad','niedergeschlagen':'dejected',
'ängstlich':'scared','furchtsam':'fearful','verängstigt':'frightened','panisch':'panicked','nervös':'nervous',
'fröhlich':'happy','glücklich':'happy','freudig':'joyful','heiter':'cheerful','vergnügt':'delighted',
'flüsternd':'whispering','leise':'quiet','gedämpft':'hushed',
'aufgeregt':'excited','begeistert':'enthusiastic','euphorisch':'euphoric',
'überrascht':'surprised','erstaunt':'astonished','verblüfft':'amazed',
'genervt':'annoyed','frustriert':'frustrated',
'verzweifelt':'desperate','hoffnungslos':'hopeless','resigniert':'resigned',
'entschlossen':'determined','entschieden':'decisive',
'selbstbewusst':'confident','stolz':'proud','arrogant':'arrogant',
'schüchtern':'shy','verlegen':'embarrassed','unsicher':'uncertain',
'ironisch':'sarcastic','sarkastisch':'sarcastic','spöttisch':'mocking','höhnisch':'scornful','verächtlich':'contemptuous',
'ernst':'serious','streng':'stern','autoritär':'authoritative','befehlend':'commanding',
'sanft':'gentle','zärtlich':'tender','liebevoll':'loving','warm':'warm',
'kalt':'cold','distanziert':'distant','gleichgültig':'indifferent','gelangweilt':'bored',
'geheimnisvoll':'mysterious','unheimlich':'eerie','düster':'ominous','bedrohlich':'threatening',
'dramatisch':'dramatic','theatralisch':'theatrical','pathetisch':'melodramatic',
'ruhig':'calm','gelassen':'composed','besonnen':'measured',
'schockiert':'shocked','entsetzt':'horrified','fassungslos':'stunned',
'zögernd':'hesitant','verwirrt':'confused','ratlos':'bewildered','unschlüssig':'undecided',
'weinend':'tearful','schluchzend':'sobbing','trauernd':'grieving','gebrochen':'broken',
'schroff':'curt','barsch':'gruff','grob':'rough','abweisend':'dismissive',
'freundlich':'friendly','herzlich':'warm','einladend':'welcoming',
'spielerisch':'playful','neckend':'teasing','frech':'cheeky','schelmisch':'mischievous',
'romantisch':'romantic','sehnsüchtig':'longing','verliebt':'infatuated',
'triumphierend':'triumphant','siegessicher':'victorious',
'erleichtert':'relieved','beruhigt':'reassured',
'schuldbewusst':'guilty','reumütig':'remorseful',
'neugierig':'curious','interessiert':'interested',
'müde':'weary','erschöpft':'exhausted',
'wehmütig':'wistful','nostalgisch':'nostalgic',
'bemerkend':'remarking','feststellend':'noting','sachlich':'matter-of-fact','nüchtern':'plain',
'flehend':'pleading','bittend':'imploring',
'warnend':'warning','mahnend':'admonishing',
'trotzig':'defiant','rebellisch':'rebellious',
'erschrocken':'startled','verstört':'disturbed',
};
function _rehEmotionEnglishTag(emotion) {
const raw = (emotion || '').trim();
if (!raw) return '';
// Preset emotions (REH_EMOTIONS / custom) are already English descriptive phrases —
// the first, most tag-like word is what Fish actually needs.
const firstWord = raw.split(/[,;]\s*/)[0].trim();
const lower = firstWord.toLowerCase();
// Check the DE→EN table BEFORE assuming "looks ASCII" means "is English" — most
// German emotion adjectives (e.g. "bedrohlich") are pure a-z too, so charset alone
// can't distinguish them from English; the dict must win first.
if (_REH_EMOTION_DE_EN[lower]) return _REH_EMOTION_DE_EN[lower];
// Try stripping a common German adjective ending (declined forms LLMs sometimes
// produce, e.g. "ängstliche" instead of "ängstlich") and match on the stem.
const stem = lower.replace(/(e|er|es|en|em)$/, '');
if (stem.length >= 4) {
for (const key in _REH_EMOTION_DE_EN) {
if (key.startsWith(stem)) return _REH_EMOTION_DE_EN[key];
}
}
if (/^[a-z\- ]+$/.test(lower)) return lower; // not in the table, but looks English (e.g. REH_EMOTIONS presets)
return ''; // no reliable English tag — omit rather than send a word Fish will ignore
}
function _rehInlineTone(text, emotion) {
if (!_rehBackendIsFish()) return text;
if (/^\s*\[/.test(text)) return text; // already carries an inline [tag]
const tag = _rehEmotionEnglishTag(emotion);
return tag ? `[${tag}] ${text}` : text;
}
// ── Auto-design voices with LLM + Design a Voice ────────────────────────────
const REH_LANG_CODE = { English:'EN', German:'DE', French:'FR', Spanish:'ES', Italian:'IT', Auto:'EN' };
function rehDefaultLlmUrl() {
try {
if (typeof _appSettings !== 'undefined' && _appSettings && _appSettings.llm_url)
return _appSettings.llm_url;
} catch(_) {}
return 'http://localhost:11434/v1';
}
// Collect all LLM endpoints visible in the Language Models section
function rehCollectLlmEndpoints() {
const seen = new Set();
const results = [];
const add = (url, label) => {
if (!url) return;
url = url.trim();
if (!url || seen.has(url)) return;
seen.add(url);
results.push({ url, label: label || url });
};
// 1. Active LLM from settings
add(rehDefaultLlmUrl(), 'Active LLM');
// 2. All URL inputs in the Language Models section (llm-local-url-inp)
document.querySelectorAll('.llm-local-url-inp, [data-llm-local-key]').forEach(inp => {
const v = inp.value?.trim();
const def = inp.dataset.llmLocalDefault;
const key = inp.dataset.llmLocalKey || inp.dataset.dcUrlKey || '';
const card = inp.closest('[class*="llm-local-card"], [class*="llm-local"]');
const name = card?.querySelector('.llm-local-name')?.textContent?.trim() || key;
add(v || def, name);
});
// 3. The dc-url-inp fields with LLM role
document.querySelectorAll('.dc-url-inp').forEach(inp => {
const card = inp.closest('[class*="llm-local-card"]');
if (!card) return;
const name = card.querySelector('.llm-local-name')?.textContent?.trim() || '';
add(inp.value?.trim() || inp.dataset.dcDefault, name);
});
// 4. Always include common defaults as suggestions
const DEFAULTS = [
['http://localhost:11434/v1', 'Ollama'],
['http://localhost:8000/v1', 'vLLM'],
['http://localhost:1234/v1', 'LM Studio'],
['http://localhost:28080/v1', 'llama-swap'],
['http://localhost:14000/v1', 'LiteLLM'],
];
DEFAULTS.forEach(([u, l]) => add(u, l));
return results;
}
// Prefill LLM URL + datalist when Phase 2 first shows voices
function rehInitLlmField() {
const u = $('reh-llm-url');
if (!u) return;
if (!u.value) u.value = rehDefaultLlmUrl();
// Populate datalist with all known LLM endpoints
const dl = $('reh-llm-url-list');
if (dl) {
dl.innerHTML = rehCollectLlmEndpoints()
.map(e => `<option value="${e.url.replace(/"/g, '&quot;')}">${e.label}</option>`)
.join('');
}
}
$('reh-llm-refresh')?.addEventListener('click', async () => {
const url = $('reh-llm-url')?.value.trim() || rehDefaultLlmUrl();
const sel = $('reh-llm-model'); if (!sel) return;
sel.innerHTML = '<option value="">Loading…</option>';
try {
const r = await fetch('/api/conversation/llm-models?url=' + encodeURIComponent(url));
const d = await r.json();
const models = d.models || [];
sel.innerHTML = '<option value="">— default —</option>' +
models.map(m => `<option value="${escHtml(m)}">${escHtml(m)}</option>`).join('');
// Default to the global Active LLM model when available (still overridable here)
const want = (typeof _appSettings !== 'undefined' && _appSettings) ? _appSettings.llm_model : '';
if (want && models.includes(want)) sel.value = want;
toast(models.length ? `Found ${models.length} models` : 'No models found', models.length ? 'success' : 'error');
} catch(e) {
sel.innerHTML = '<option value="">— default —</option>';
toast('Could not list models: ' + e.message, 'error');
}
});
// Avatar icon keys for designed voices — male / female / neutral / robot / animal.
// Picked from the LLM description (robot/animal cues) then gender.
const REH_AVATAR_ICONS = {
male: 'mdi-face-man',
female: 'mdi-face-woman',
neutral: 'mdi-account',
robot: 'mdi-robot-outline',
animal: 'mdi-paw',
};
function _pickVoiceAvatar(gender, desc, speaker) {
const d = ((desc || '') + ' ' + (speaker || '')).toLowerCase();
if (/\b(robot|android|synthetic|artificial|computer|machine|cyborg|a\.?i\.?|operating system|\bos\b|digital|hologram|drone)\b/.test(d)) return 'robot';
if (/\b(animal|creature|beast|dragon|monster|cat|dog|wolf|lion|bird|horse|dino|dinosaur|alien)\b/.test(d)) return 'animal';
if (gender === 'M') return 'male';
if (gender === 'F') return 'female';
return 'neutral';
}
let rehDesignCancelled = false;
$('reh-autodesign-cancel')?.addEventListener('click', () => { rehDesignCancelled = true; });
$('reh-autodesign-btn')?.addEventListener('click', async () => {
const backend = $('reh-backend-select')?.value;
if (!backend) { toast('Select a TTS backend first', 'error'); return; }
const speakers = Object.keys(rehState.cast);
if (!speakers.length) { toast('No characters to design for', 'error'); return; }
const script = $('reh-script-text')?.value.trim() || linesToScriptText();
const llmUrl = $('reh-llm-url')?.value.trim() || rehDefaultLlmUrl();
const llmModel= $('reh-llm-model')?.value || '';
const language= $('reh-design-lang')?.value || 'English';
const langCode= REH_LANG_CODE[language] || 'EN';
const scriptTitle = $('reh-script-title')?.value.trim() || 'Script';
const tag = (typeof _umlautSafe === 'function' ? _umlautSafe(scriptTitle) : scriptTitle).replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 24) || 'Script';
const btn = $('reh-autodesign-btn');
const prog = $('reh-autodesign-progress');
const fill = $('reh-autodesign-fill');
const label= $('reh-autodesign-label');
btn.disabled = true;
rehDesignCancelled = false;
if (prog) prog.hidden = false;
const setProg = (d, t, msg) => {
if (fill) fill.style.width = (t ? (d/t)*100 : 0) + '%';
if (label) label.textContent = msg || `${d} / ${t}`;
};
setProg(0, speakers.length, 'Analyzing script with LLM…');
// 1) Ask the LLM to describe each character
let characters;
try {
const r = await fetch('/api/analyze-characters', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ script, names: speakers, llm_url: llmUrl, model: llmModel, language }),
});
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
characters = d.characters || [];
} catch(e) {
toast('Character analysis failed: ' + e.message, 'error');
btn.disabled = false; if (prog) prog.hidden = true;
return;
}
// Map analysis back to speakers (case-insensitive match)
const byName = {};
characters.forEach(c => { if (c.name) byName[String(c.name).toUpperCase().trim()] = c; });
// 2) For each character, design + save a voice
let done = 0;
// rehState.cast[sp] used to be assumed always present since `speakers` came
// from Object.keys(rehState.cast) moments earlier — but the analyze-characters
// await above can take many seconds for a large cast, and if anything
// removes/replaces a cast entry while that request is in flight, the plain
// `rehState.cast[sp].voice` access below threw a TypeError with nothing
// catching it (this whole loop sits outside the try/catch above), silently
// killing the click with no toast and no further requests — exactly the
// "clicked Design all and nothing happens" symptom, confirmed by server
// logs showing analyze-characters succeeding but voice-design never once
// being called. Guard it and keep going instead of crashing the whole run.
// The outer try/finally below is the same idea one level up: ANY unexpected
// exception here used to vanish into the console with the button stuck
// disabled — now it surfaces as a toast and always resets the UI.
try {
const designOnly = speakers.filter(sp => rehState.cast[sp]?.voice !== 'me');
for (const sp of designOnly) {
if (rehDesignCancelled) { toast('Cancelled', 'error'); break; }
if (!rehState.cast[sp]) { done++; continue; }
const info = byName[sp.toUpperCase().trim()] || {};
const gender = (info.gender || 'N').toUpperCase().charAt(0).replace(/[^MFN]/, 'N') || 'N';
const desc = info.description ||
`A ${info.age || 'adult'} ${gender === 'M' ? 'male' : gender === 'F' ? 'female' : ''} character named ${sp}, natural expressive voice.`;
const sampleLine = rehState.lines.find(l => l.type === 'dialog' && l.speaker === sp)?.text || `Hello, I am ${sp}.`;
const safeName = (typeof _umlautSafe === 'function' ? _umlautSafe(sp) : sp).replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 24) || 'Char';
const voiceId = `${langCode}_${gender}_${safeName}_${tag}`.slice(0, 90);
rehMarkCastDesigning(sp, 'designing', null, {
gender, language, voiceId, desc,
age: info.age || '',
step: 'Generating voice audio…',
});
setProg(done, designOnly.length, `Designing ${sp}… (${done + 1}/${designOnly.length})`);
try {
// Generate the voice audio
const dr = await fetch('/api/voice-design', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ instruct: desc, sample_text: stripMarkdown(sampleLine).slice(0, 300), language, gender, dialogue: false }),
});
if (!dr.ok) { const e = await dr.json().catch(()=>({})); throw new Error(e.detail || dr.statusText); }
const dd = await dr.json();
// Save it to the voice library, tagged with script name + Rehearser
const sr = await fetch('/api/save', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: dd.id, voice_id: voiceId, transcript: stripMarkdown(sampleLine).slice(0, 300) }),
});
if (!sr.ok) { const e = await sr.json().catch(()=>({})); throw new Error(e.detail || sr.statusText); }
const saved = await sr.json();
// Tag metadata — display name = character, script as a tag, origin 'designed',
// grouped by play title, plus an auto-picked gender/type avatar icon.
const charName = (sp === REH_NARRATOR_KEY) ? 'Narrator' : sp;
if (typeof saveMeta === 'function') {
await saveMeta(saved.voice_id, {
gender,
name: charName,
avatar: _pickVoiceAvatar(gender, desc, sp),
origin: 'designed',
group: `Rehearser: ${scriptTitle}`,
note: `Rehearser · ${scriptTitle} · ${charName}${desc.slice(0, 180)}`,
transcript: stripMarkdown(sampleLine).slice(0, 300),
tag: scriptTitle,
}).catch(()=>{});
}
// Assign to cast + remember the LLM's research as the style note & character soul
rehState.cast[sp].voice = saved.voice_id;
rehState.cast[sp].instruct = rehState.cast[sp].instruct || desc;
rehState.cast[sp].soul = rehState.cast[sp].soul || [info.age ? `Age: ${info.age}` : '', desc].filter(Boolean).join(' · ');
rehState.cast[sp].voiceData = null;
if (!rehState.voices.includes(saved.voice_id)) rehState.voices.push(saved.voice_id);
rehMarkCastDesigning(sp, 'done');
} catch(e) {
rehMarkCastDesigning(sp, 'err', e.message);
}
done++;
setProg(done, designOnly.length);
}
// Refresh the broader voice library so avatars/pictures resolve
if (typeof loadVoiceLibrary === 'function') await loadVoiceLibrary().catch(()=>{});
if (!rehDesignCancelled) toast(`Designed ${done} voice${done!==1?'s':''} — tagged "${tag}" + Rehearser`, 'success');
} catch (e) {
toast('Design all failed: ' + (e?.message || e), 'error');
} finally {
renderCastList();
populateNarratorSelect();
if (prog) prog.hidden = true;
btn.disabled = false;
}
});
// ── Match from library — LLM picks the best EXISTING voice for each character ──
// ── Character research → per-character note ──────────────────────────────────
// Store the LLM's reading of the play as each character's "soul" note (+ gender / style).
function rehWriteCharacterNote(sp, info) {
const c = rehState.cast[sp]; if (!c || !info) return;
if (info.gender && !c.gender) c.gender = String(info.gender).toUpperCase().charAt(0).replace(/[^MFN]/, 'N');
const bits = [];
if (info.age) bits.push(`Age: ${info.age}`);
if (info.description) bits.push(info.description);
const note = bits.join(' · ');
if (note && !c.soul) c.soul = note; // fills the Character-soul / LLM-brief note
if (info.description && !c.instruct) c.instruct = info.description;
}
// Ask the LLM to read the script + cast, then annotate every character with a note.
// Returns a name→info map (used by the online/design flows that need gender too).
async function rehResearchCast(speakers) {
const names = speakers.filter(sp => sp !== REH_NARRATOR_KEY);
if (!names.length) return {};
let chars = [];
try {
const r = await fetch('/api/analyze-characters', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
script: $('reh-script-text')?.value.trim() || linesToScriptText(), names,
llm_url: $('reh-llm-url')?.value.trim() || rehDefaultLlmUrl(),
model: $('reh-llm-model')?.value || '', language: $('reh-design-lang')?.value || 'English',
}),
});
if (r.ok) chars = (await r.json()).characters || [];
} catch (_) {}
const byName = {};
chars.forEach(c => { if (c.name) byName[String(c.name).toUpperCase().trim()] = c; });
speakers.forEach(sp => rehWriteCharacterNote(sp, byName[sp.toUpperCase().trim()]));
return byName;
}
$('reh-matchlib-btn')?.addEventListener('click', async () => {
const btn = $('reh-matchlib-btn');
const speakers = Object.keys(rehState.cast).filter(sp => rehState.cast[sp].voice !== 'me');
if (!speakers.length) { toast('No characters to match', 'error'); return; }
let lib = (window._voices || []).filter(v => v.enabled !== false);
if (!lib.length) {
// Library metadata isn't loaded yet (opened the Rehearser directly) — fetch it now.
try { lib = (await fetch('/api/voices').then(r => r.json())).filter(v => v.enabled !== false); } catch (_) {}
}
if (!lib.length) { toast('Your voice library is empty — clone, design or import some voices first', 'error'); return; }
const candidates = lib.map(v => ({
id: v.id, gender: v.gender || '', language: v.lang || '',
tags: v.tag || '', description: (v.note || v.name || '').slice(0, 140),
}));
const nameFor = sp => (sp === REH_NARRATOR_KEY ? 'Narrator' : sp);
const byDisplay = {};
speakers.forEach(sp => { byDisplay[nameFor(sp).toUpperCase().trim()] = sp; });
const orig = btn.innerHTML;
btn.disabled = true; btn.innerHTML = '<span class="reh-imsdb-spinner"></span> Matching…';
try {
const r = await fetch('/api/match-characters-voices', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
script: $('reh-script-text')?.value.trim() || linesToScriptText(),
names: speakers.map(nameFor),
voices: candidates,
llm_url: $('reh-llm-url')?.value.trim() || rehDefaultLlmUrl(),
model: $('reh-llm-model')?.value || '',
language: $('reh-design-lang')?.value || 'English',
}),
});
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
const assignments = (await r.json()).assignments || [];
const validIds = new Set(candidates.map(c => c.id));
let n = 0;
assignments.forEach(a => {
const sp = byDisplay[String(a.name || '').toUpperCase().trim()];
if (sp && a.voice_id && validIds.has(a.voice_id)) {
rehState.cast[sp].voice = a.voice_id;
rehState.cast[sp].voiceData = getVoiceData(a.voice_id);
if (!rehState.voices.includes(a.voice_id)) rehState.voices.push(a.voice_id);
if (sp === REH_NARRATOR_KEY) rehState.narratorVoice = a.voice_id;
n++;
}
});
// Research the play + cast and drop a note on each character
btn.innerHTML = '<span class="reh-imsdb-spinner"></span> Researching characters…';
await rehResearchCast(speakers);
renderCastList();
populateNarratorSelect();
toast(n ? `Matched ${n} character${n !== 1 ? 's' : ''} & added notes` : 'No good matches — try “Design all voices” instead', n ? 'success' : 'error');
} catch (e) {
toast('Match failed: ' + e.message, 'error');
} finally {
btn.disabled = false; btn.innerHTML = orig;
}
});
// ── Match from online — search fish.audio per character, import & assign ──────
const REH_FISH_LANG = { English: 'en', German: 'de', French: 'fr', Spanish: 'es', Italian: 'it', Portuguese: 'pt', Dutch: 'nl', Auto: '' };
$('reh-matchonline-btn')?.addEventListener('click', async () => {
const btn = $('reh-matchonline-btn');
const speakers = Object.keys(rehState.cast).filter(sp => rehState.cast[sp].voice !== 'me' && sp !== REH_NARRATOR_KEY);
if (!speakers.length) { toast('No characters to match', 'error'); return; }
const lang = REH_FISH_LANG[$('reh-design-lang')?.value || 'English'] ?? 'en';
const prog = $('reh-autodesign-progress'), fill = $('reh-autodesign-fill'), label = $('reh-autodesign-label');
const setProg = (d, t, msg) => { if (fill) fill.style.width = (t ? (d / t) * 100 : 0) + '%'; if (label) label.textContent = msg || `${d} / ${t}`; };
const orig = btn.innerHTML;
btn.disabled = true; btn.innerHTML = '<span class="reh-imsdb-spinner"></span> Analyzing…';
if (prog) prog.hidden = false;
rehDesignCancelled = false;
try {
// 1) Ask the LLM for each character's gender (drives the online search)
let chars = [];
try {
const ar = await fetch('/api/analyze-characters', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ script: $('reh-script-text')?.value.trim() || linesToScriptText(), names: speakers,
llm_url: $('reh-llm-url')?.value.trim() || rehDefaultLlmUrl(), model: $('reh-llm-model')?.value || '',
language: $('reh-design-lang')?.value || 'English' }),
});
if (ar.ok) chars = (await ar.json()).characters || [];
} catch (_) {}
const byName = {}; chars.forEach(c => { if (c.name) byName[String(c.name).toUpperCase().trim()] = c; });
const G = { M: 'male', F: 'female', N: 'neutral' };
let done = 0, n = 0;
for (let i = 0; i < speakers.length; i++) {
if (rehDesignCancelled) { toast('Cancelled', 'error'); break; }
const sp = speakers[i];
setProg(done, speakers.length, `Matching ${sp}… (${done + 1}/${speakers.length})`);
const info = byName[sp.toUpperCase().trim()] || {};
rehWriteCharacterNote(sp, info); // drop the LLM's character research into a note
const gender = G[(info.gender || '').toUpperCase().charAt(0)] || '';
// Search fish.audio: by character name first (catches known/celebrity names), then gender+lang
const qs = [`search=${encodeURIComponent(sp)}`, `language=${lang}`, 'page_size=8', 'sort_by=score'];
let items = [];
try { items = (await fetch('/api/fishaudio/voices?' + qs.join('&')).then(r => r.json())).items || []; } catch (_) {}
let pick = items.find(v => v.sample_audio);
let nameHit = !!pick; // got a result by searching the character's actual name
if (!pick) { // fallback: best by gender+language, offset per character to diversify
const fb = [`language=${lang}`, gender ? `gender=${gender}` : '', 'page_size=12', 'sort_by=score', `page=${(i % 4) + 1}`].filter(Boolean);
try { items = (await fetch('/api/fishaudio/voices?' + fb.join('&')).then(r => r.json())).items || []; } catch (_) {}
pick = items.find(v => v.sample_audio);
}
// Keep the top few playable results so the user can audition & swap them later
let cands = items.filter(v => v.sample_audio).slice(0, 6).map(v => ({
title: v.title, sample_audio: v.sample_audio, image: v.image || '', gender: v.gender || '',
language: v.language || lang || '', description: v.description || '', sample_text: v.sample_text || v.default_text || '',
}));
// When the name didn't match, the fallback returns the same popular voices for
// everyone — rotate the pick per character so they don't all sound identical,
// and skip voices already taken by earlier characters this run.
if (!nameHit && cands.length > 1) {
const taken = new Set(Object.values(rehState.cast).map(c => (c.online?.candidates?.[c.online.picked]?.sample_audio)).filter(Boolean));
const rotated = [...cands.slice(i % cands.length), ...cands.slice(0, i % cands.length)];
cands = rotated.sort((a, b) => (taken.has(a.sample_audio) ? 1 : 0) - (taken.has(b.sample_audio) ? 1 : 0));
}
if (cands.length) {
try {
const vid = await _rehImportFishCandidate(sp, cands[0]);
cands[0].voice_id = vid;
rehState.cast[sp].voice = vid;
rehState.cast[sp].voiceData = getVoiceData(vid);
rehState.cast[sp].online = { candidates: cands, picked: 0 };
n++;
} catch (e) { logErr('match-online import ' + sp, e); }
}
done++; setProg(done, speakers.length);
}
if (typeof loadVoiceLibrary === 'function') await loadVoiceLibrary().catch(() => {});
renderCastList();
populateNarratorSelect();
toast(n ? `Imported & matched ${n} online voice${n !== 1 ? 's' : ''}` : 'No online matches found', n ? 'success' : 'error');
} catch (e) {
toast('Online match failed: ' + e.message, 'error');
} finally {
btn.disabled = false; btn.innerHTML = orig;
if (prog) prog.hidden = true;
}
});
// Show per-character design progress card beneath the cast row
function rehMarkCastDesigning(sp, state, msg, info) {
const row = document.querySelector(`.reh-cast-row[data-speaker="${CSS.escape(sp)}"]`);
if (!row) return;
// Inline badge in the cast row header
let badge = row.querySelector('.reh-cast-design-badge');
if (!badge) {
badge = document.createElement('span');
badge.className = 'reh-cast-design-badge';
row.querySelector('strong')?.after(badge);
}
badge.className = 'reh-cast-design-badge' + (state === 'done' ? ' done' : state === 'err' ? ' err' : '');
badge.textContent = state === 'designing' ? '✨ designing…' : state === 'done' ? '✓ designed' : '✗ failed';
if (msg) badge.title = msg;
row.classList.toggle('reh-cast-designing', state === 'designing');
// Expanded detail panel beneath the instruct row
const wrap = row.closest('div');
let panel = wrap?.querySelector('.reh-cast-design-panel');
if (state === 'designing' && info) {
if (!panel) {
panel = document.createElement('div');
panel.className = 'reh-cast-design-panel';
wrap.appendChild(panel);
}
const genderIcon = info.gender === 'M' ? '♂' : info.gender === 'F' ? '♀' : '⚧';
const genderLabel = info.gender === 'M' ? 'Male' : info.gender === 'F' ? 'Female' : 'Neutral';
panel.innerHTML = `
<div class="reh-cdp-meta">
<span class="reh-cdp-chip reh-cdp-gender">${genderIcon} ${escHtml(genderLabel)}</span>
<span class="reh-cdp-chip reh-cdp-lang"><span class="mdi mdi-translate"></span> ${escHtml(info.language || 'EN')}</span>
<span class="reh-cdp-chip reh-cdp-name"><span class="mdi mdi-account-outline"></span> ${escHtml(info.voiceId || sp)}</span>
${info.age ? `<span class="reh-cdp-chip">${escHtml(info.age)}</span>` : ''}
</div>
<div class="reh-cdp-desc">${escHtml(info.desc || '')}</div>
<div class="reh-cdp-step"><span class="reh-imsdb-spinner"></span> <span class="reh-cdp-step-txt">${escHtml(info.step || 'Designing voice…')}</span></div>`;
} else if (state === 'done' && panel) {
// Collapse to a summary line
const descEl = panel.querySelector('.reh-cdp-desc');
const metaEl = panel.querySelector('.reh-cdp-meta');
const stepEl = panel.querySelector('.reh-cdp-step');
if (stepEl) stepEl.remove();
if (descEl) {
// Truncate
const t = descEl.textContent;
if (t.length > 160) descEl.textContent = t.slice(0, 157) + '…';
}
panel.classList.add('done');
} else if (state === 'err') {
if (panel) panel.remove();
}
}
$('reh-start-btn')?.addEventListener('click', () => {
const backend = $('reh-backend-select')?.value;
if (!backend) { toast('Select a backend first', 'error'); return; }
rehState.backend = backend;
rehState.narratorVoice = $('reh-narrator-voice')?.value || '';
rehState.lineIndex = 0;
rehState.clips = [];
rehState.playing = false;
rehState.synthCache.clear(); rehDecodedBuffers.clear();
rehState.practiceStart = null;
rehState.practiceEnd = null;
buildScriptPage();
showPhase(3);
highlightCurrentLine();
});
// ── Stage display controls (font size + collapsible cast) ───────────────────
const REH_STAGE_FONT_KEY = 'ttsvc_reh_stage_scale';
const REH_CAST_COLLAPSED_KEY = 'ttsvc_reh_cast_collapsed';
function rehStageScale() {
const v = parseFloat(localStorage.getItem(REH_STAGE_FONT_KEY) || '1');
return isNaN(v) ? 1 : Math.max(0.7, Math.min(2, v));
}
function rehApplyStageFont() {
// Set on the page wrapper so the scale inherits to BOTH the single A4 page
// (#reh-a4-page) and the paginated `.reh-paper` pages built by page-mode.
const scale = String(rehStageScale());
const wrap = document.querySelector('.reh-page-wrap');
if (wrap) wrap.style.setProperty('--reh-stage-scale', scale);
const page = $('reh-a4-page');
if (page) page.style.setProperty('--reh-stage-scale', scale);
}
function rehStageFontStep(delta) {
const next = Math.max(0.7, Math.min(2, Math.round((rehStageScale() + delta) * 100) / 100));
localStorage.setItem(REH_STAGE_FONT_KEY, String(next));
rehApplyStageFont();
}
function rehApplyCastCollapsed() {
// Same collapse pattern as Read Aloud's Casting sidebar (.ab-cv-side.is-collapsed
// shrinks to just the avatar dots); the stage area's grid column narrows to match.
const row = $('reh-cast-row');
const stage = $('reh-stage-area');
if (!row) return;
const collapsed = localStorage.getItem(REH_CAST_COLLAPSED_KEY) === '1';
row.classList.toggle('is-collapsed', collapsed);
if (stage) stage.classList.toggle('side-collapsed', collapsed);
const btn = $('reh-cast-toggle');
if (btn) {
btn.setAttribute('aria-expanded', String(!collapsed));
btn.title = collapsed ? 'Expand character list' : 'Collapse to avatars';
const icon = btn.querySelector('.mdi');
if (icon) icon.className = 'mdi ' + (collapsed ? 'mdi-chevron-right' : 'mdi-chevron-left');
}
}
function rehToggleCast() {
const collapsed = localStorage.getItem(REH_CAST_COLLAPSED_KEY) === '1';
localStorage.setItem(REH_CAST_COLLAPSED_KEY, collapsed ? '0' : '1');
rehApplyCastCollapsed();
}
$('reh-font-inc')?.addEventListener('click', () => rehStageFontStep(0.1));
$('reh-font-dec')?.addEventListener('click', () => rehStageFontStep(-0.1));
$('reh-cast-toggle')?.addEventListener('click', rehToggleCast);
// ── A4 Script page ─────────────────────────────────────────────────────────
// Cast sidebar — same look (.ab-char-item rows) as Read Aloud's Casting
// sidebar, but with real portraits (cross-referenced from the Library, same
// cache the Cast tab already builds) instead of plain colored-letter dots,
// plus search and sort. Split out from buildScriptPage so typing in the
// search box doesn't re-render the whole (potentially 1000+ line) script
// page on every keystroke — just this strip.
function renderCastStrip() {
const castStrip = $('reh-cast-strip');
if (!castStrip) return;
const scriptTitle = $('reh-script-title')?.value.trim() || '';
_rehEnsureLibCharsCache(scriptTitle);
const names = Object.keys(rehState.cast);
const lineCountFor = sp => rehState.lines.filter(l => l.speaker === sp && l.type === 'dialog').length;
const libRecByNameStage = new Map((_rehLibCharsCache || []).map(r => [String(r.name || '').trim().toLowerCase(), r]));
const sortMode = $('reh-cast-side-sort')?.value || localStorage.getItem('reh_cast_side_sort') || 'lines';
const query = ($('reh-cast-side-search')?.value || '').trim().toLowerCase();
let rows = names.map(sp => {
const displayName = (sp === REH_NARRATOR_KEY) ? 'Narrator' : sp;
const libRec = sp === REH_NARRATOR_KEY ? null : libRecByNameStage.get(String(sp).trim().toLowerCase());
// `sp` is the raw speaker key straight out of script parsing, which
// follows screenplay convention (ALL CAPS speaker tags) — rendering it
// verbatim meant every name in this sidebar showed shouting-case
// regardless of how it's actually spelled anywhere else in the app.
// Prefer the Library's own properly-cased name when there's a match;
// a plain per-word title-case is still better than shouting-case for
// the rarer speaker key with no Library record to match against.
const fallbackName = displayName === 'Narrator' ? displayName
: displayName.replace(/\w\S*/g, w => w[0].toUpperCase() + w.slice(1).toLowerCase());
return { sp, displayName: libRec?.name || fallbackName, n: lineCountFor(sp), libRec };
});
if (query) rows = rows.filter(r => r.displayName.toLowerCase().includes(query));
rows.sort((a, b) => sortMode === 'name'
? a.displayName.localeCompare(b.displayName)
: (b.n - a.n) || a.displayName.localeCompare(b.displayName));
castStrip.innerHTML = rows.map(({ sp, displayName, n, libRec }) => {
const c = rehState.cast[sp], isMe = c.voice === 'me';
const avatarHtml = libRec?.image
? `<span class="ab-char-dot ab-char-dot-img" style="border-color:${c.color}"><img src="${libRec.image}" alt=""></span>`
: `<span class="ab-char-dot" style="background:${c.color}">${escHtml((displayName||'?')[0].toUpperCase())}</span>`;
return `<div class="ab-char-item" data-speaker="${escHtml(sp)}" title="${escHtml(displayName)}${isMe?'me':(c.voice||'no voice')}">
${avatarHtml}
<span class="ab-char-name">${escHtml(displayName)}</span>
${isMe?'<span class="mdi mdi-microphone" style="font-size:11px;color:var(--subtext)"></span>':''}
<b class="ab-char-count">${n}</b>
</div>`;
}).join('');
castStrip.querySelectorAll('.ab-char-item').forEach(item => {
item.addEventListener('click', () => {
const line = document.querySelector(`.reh-block[data-speaker="${CSS.escape(item.dataset.speaker)}"]`);
if (line) line.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
});
const lbl = $('reh-cast-toggle-label');
if (lbl) lbl.textContent = `Characters (${names.length})`;
const searchInp = $('reh-cast-side-search');
const sortSel = $('reh-cast-side-sort');
if (searchInp && !searchInp.dataset.wired) {
searchInp.dataset.wired = '1';
searchInp.addEventListener('input', () => renderCastStrip());
}
if (sortSel && !sortSel.dataset.wired) {
sortSel.dataset.wired = '1';
sortSel.value = sortMode;
sortSel.addEventListener('change', () => { localStorage.setItem('reh_cast_side_sort', sortSel.value); renderCastStrip(); });
}
}
function buildScriptPage() {
const titleEl = $('reh-page-title');
if (titleEl) titleEl.textContent = $('reh-script-title')?.value.trim() || 'Script';
renderCastStrip();
rehApplyStageFont();
rehApplyCastCollapsed();
// Fire-and-forget, guarded against re-running for the same script — see
// _lineAudioSyncDots for why this exists (green dots otherwise look reset
// after every reload even when the audio is safely cached on disk).
if (typeof _lineAudioSyncDots === 'function') _lineAudioSyncDots().catch(() => {});
const linesEl = $('reh-script-lines'); if (!linesEl) return;
linesEl.innerHTML = rehState.lines.map((line, i) => {
const isCached = rehState.synthCache.has(i);
const isStale = rehState.staleLines.has(i);
const dotCls = isStale ? 'reh-synth-dot stale' : 'reh-synth-dot';
const dotTitle = isStale ? 'Tone changed — needs re-synthesis' : 'Pre-synthesized';
const synthDot = `<span class="${dotCls}" id="reh-syd-${i}" style="${(isCached||isStale)?'':'display:none'}" title="${dotTitle}"></span>`;
const reSynthBtn = `<button class="reh-resynth-btn" id="reh-rsb-${i}" title="Re-synthesize this line" ${isStale?'':'hidden'}><span class="mdi mdi-refresh"></span></button>`;
const editBtn = `<button class="reh-edit-btn" data-index="${i}" title="Edit text (or double-click)"><span class="mdi mdi-pencil-outline"></span></button>`;
const note = rehState.lines[i].note || '';
// noteArea includes the edit button so both icons live in the right gutter
const noteArea = `<div class="reh-note-area" data-index="${i}">
${editBtn}
<button class="reh-note-btn${note?' has-note':''}" title="${note?'Edit note':'Add note'}"><span class="mdi mdi-note-edit-outline"></span></button>
<textarea class="reh-note-ta" placeholder="Personal note (not spoken)…" spellcheck="false">${escHtml(note)}</textarea>
</div>`;
// Bulk-edit: hidden lines drop out of the view entirely, unless we're revealing
// them inside bulk mode so they can be selected and restored.
if (line.hidden && !(rehState.bulkMode && rehState.showHidden)) return '';
const _bSel = rehState.bulkSel.has(i);
const bulkCheck = rehState.bulkMode
? `<span class="reh-bulk-check${_bSel ? ' checked' : ''}" data-bulk="${i}" title="Select line"><span class="mdi ${_bSel ? 'mdi-checkbox-marked' : 'mdi-checkbox-blank-outline'}"></span></span>`
: '';
const lineFlags = (line.ignored ? ' reh-line-ignored' : '') + (line.hidden ? ' reh-line-hidden' : '') + (_bSel ? ' reh-selected' : '');
// Same detection audiobookExport() uses to decide chapter/file boundaries
// (audiobook.js) — surfaced here too so a chapter is visible in the script
// itself, not just audible as a pause in the finished export. Runs before
// the normal type switch so a chapter heading (however it was tagged
// during parsing — 'act', 'scene', or plain 'action' text recovered via
// heading OCR) always gets this treatment instead of its usual rendering.
if (typeof audiobookIsChapter === 'function' && audiobookIsChapter(line)) {
const label = (typeof stripMarkdown === 'function' ? stripMarkdown(line.text || '') : (line.text || '')).trim();
return `<div class="reh-chapter-marker${lineFlags}" data-index="${i}">
${bulkCheck}
<span class="reh-chapter-marker-line"></span>
<span class="reh-chapter-marker-label">${label ? escHtml(label) : 'Chapter'}</span>
<span class="reh-chapter-marker-line"></span>
${editBtn} ${synthDot}
</div>`;
}
switch (line.type) {
case 'act':
return `<div class="reh-act${lineFlags}" data-index="${i}" style="position:relative">${bulkCheck}${escHtml(line.text)} ${editBtn} ${synthDot}</div>`;
case 'scene':
return `<div class="reh-scene${lineFlags}" data-index="${i}">
${bulkCheck}<span class="mdi mdi-film" style="flex-shrink:0"></span>
<span style="flex:1">${escHtml(line.text)}</span>
${editBtn} ${synthDot}
</div>`;
case 'action': {
// Narrator paragraphs get a play button too now, but deliberately
// NOT wrapped in .reh-block's boxed/indented dialogue treatment
// (avatar circle, name row, highlighted card) — just a small inline
// icon before the text, same weight as the edit button, so plain
// narration keeps reading like plain narration.
const narrPlayBtn = `<button type="button" class="reh-line-play-avatar reh-action-play-btn" data-index="${i}" title="Play or pause this line" aria-label="Play or pause this line"><span class="mdi mdi-play"></span></button>`;
return `<div class="reh-action-block${lineFlags}" data-index="${i}">
${bulkCheck}${narrPlayBtn}<span class="reh-action-text">${renderMarkdownInline(line.text)}</span>
${editBtn} ${synthDot}
</div>`;
}
case 'pagebreak': {
const pbLabel = line.page ? `— Page ${line.page}` : '— Page break —';
return `<div class="reh-block-pagebreak${lineFlags}" data-index="${i}">${bulkCheck}<span class="reh-pagebreak-label">${pbLabel}</span></div>`;
}
case 'transition':
return `<div class="reh-transition${lineFlags}" data-index="${i}">${bulkCheck}${escHtml(line.text)}</div>`;
case 'direction': {
const indent = line.speaker ? 'text-align:center;' : '';
return `<div class="reh-direction${lineFlags}" data-index="${i}" style="${indent}">${bulkCheck}<em>${escHtml(line.text)}</em></div>`;
}
case 'dialog': {
const c = rehState.cast[line.speaker] || { voice:'', color:'#89b4fa' };
const isMe = c.voice === 'me';
const emoInfo = getEmotionInfo(line.emotion || '');
return `<div class="reh-block${lineFlags}${rehState.practiceEnd===i?' reh-range-end':''}${rehState.practiceStart===i?' reh-range-start':''}${isPracticeRange(i)?' reh-in-range':''}" data-index="${i}" data-speaker="${escHtml(line.speaker)}" style="cursor:pointer">
${bulkCheck}<button class="reh-gutter-btn" data-index="${i}" title="Click: play from here · Shift+click: set end marker">${rehState.practiceStart===i?'▶':rehState.practiceEnd===i?'■':''}</button>
<div class="reh-block-inner">
<div class="reh-block-head">
<button type="button" class="reh-line-play-avatar" data-index="${i}" title="Play or pause this line" aria-label="Play or pause this line">${_rehCharAvatarHtml(line.speaker, isMe?'':c.voice, c.color, 32)}</button>
<span class="reh-block-name" style="color:${c.color}">${escHtml(line.speaker)}</span>
<span class="reh-block-head-spacer"></span>
${isMe
? '<span class="reh-block-badge reh-badge-me"><span class="mdi mdi-microphone"></span> Me</span>'
: '<span class="reh-block-badge reh-badge-tts"><span class="mdi mdi-speaker-outline"></span> TTS</span>'
}
${!isMe
? `<button class="reh-emo-btn${line.emotion?' has-emotion':''}" data-index="${i}" title="Set speaking tone">
${emoInfo.emoji ? emoInfo.emoji + ' ' : ''}<span class="reh-emo-label">${escHtml(emoInfo.label)}</span>
<span class="mdi mdi-chevron-down" style="font-size:9px;opacity:.6"></span>
</button>`
: ''
}
${synthDot}
${reSynthBtn}
</div>
<div class="reh-block-dialog" id="reh-diag-${i}">${renderMarkdownInline(line.text)}</div>
</div>
${noteArea}
</div>`;
}
default: return '';
}
}).join('');
// Emotion picker buttons
linesEl.querySelectorAll('.reh-emo-btn').forEach(btn => {
btn.addEventListener('click', e => { e.stopPropagation(); openEmoPicker(parseInt(btn.dataset.index), btn); });
});
// Edit buttons
linesEl.querySelectorAll('.reh-edit-btn').forEach(btn => {
btn.addEventListener('click', e => { e.stopPropagation(); startInlineEdit(parseInt(btn.dataset.index)); });
});
// Double-click on dialog / action text to edit
linesEl.querySelectorAll('.reh-block-dialog').forEach(el => {
el.addEventListener('dblclick', e => { e.stopPropagation(); startInlineEdit(parseInt(el.closest('[data-index]').dataset.index)); });
});
linesEl.querySelectorAll('.reh-action-text').forEach(el => {
el.addEventListener('dblclick', e => { e.stopPropagation(); startInlineEdit(parseInt(el.closest('[data-index]').dataset.index)); });
});
// Avatar button: play/pause a single character line
linesEl.querySelectorAll('.reh-line-play-avatar').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const idx = parseInt(btn.dataset.index);
if (rehState.playing && rehState.lineIndex === idx) { pausePlay(); return; }
stopPlay();
hideRecOverlay();
rehState.lineIndex = idx;
highlightCurrentLine();
startPlay();
});
});
// Gutter button: play from here / set end
linesEl.querySelectorAll('.reh-gutter-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
const idx = parseInt(btn.dataset.index);
if (e.shiftKey) {
// Set / clear practice end
rehState.practiceEnd = rehState.practiceEnd === idx ? null : idx;
} else {
// Jump to this line and start playing
stopPlay();
hideRecOverlay();
rehState.lineIndex = idx;
highlightCurrentLine();
startPlay();
}
updatePracticeRange();
});
});
// Click block to jump; in Select mode, click toggles selection and Shift+click selects a range.
linesEl.querySelectorAll('[data-index]').forEach(el => {
el.addEventListener('click', e => {
if (e.target.closest('.reh-emo-btn, .reh-edit-btn, .reh-gutter-btn, .reh-note-btn, .reh-resynth-btn, .reh-line-play-avatar, textarea, select, .vp-root')) return;
const idx = parseInt(el.dataset.index);
if (rehState.bulkMode) {
_toggleBulkSel(idx, e.shiftKey);
return;
}
stopPlay();
hideRecOverlay();
rehState.lineIndex = idx;
highlightCurrentLine();
});
});
// Re-synth buttons (per line)
linesEl.querySelectorAll('.reh-resynth-btn').forEach(btn => {
btn.addEventListener('click', e => {
e.stopPropagation();
synthOneLine(parseInt(btn.id.replace('reh-rsb-', '')));
});
});
// Note area: toggle textarea visibility on button click, save on change
linesEl.querySelectorAll('.reh-note-area').forEach(area => {
const idx = parseInt(area.dataset.index);
const btn = area.querySelector('.reh-note-btn');
const ta = area.querySelector('.reh-note-ta');
if (!btn || !ta) return;
// Show if note has content
if (ta.value.trim()) ta.classList.add('open');
btn.addEventListener('click', e => {
e.stopPropagation();
ta.classList.toggle('open');
if (ta.classList.contains('open')) ta.focus();
});
ta.addEventListener('input', () => {
rehState.lines[idx].note = ta.value;
btn.classList.toggle('has-note', !!ta.value.trim());
});
ta.addEventListener('click', e => e.stopPropagation());
});
// Bulk-edit: leading checkboxes toggle line selection. The page wrapper also
// gets the mode class because A4/PDF modes move blocks out of #reh-script-lines.
linesEl.classList.toggle('reh-bulk-mode', rehState.bulkMode);
document.querySelector('.reh-page-wrap')?.classList.toggle('reh-bulk-mode', rehState.bulkMode);
if (rehState.bulkMode) {
linesEl.querySelectorAll('.reh-bulk-check').forEach(cb => {
cb.addEventListener('click', e => {
e.stopPropagation();
_toggleBulkSel(parseInt(cb.dataset.bulk), e.shiftKey);
});
});
}
// Apply current pagination mode
applyPageMode();
// Warn if backend doesn't support tone/style changes
_checkToneStyleSupport();
}
// ── Bulk-edit (line selection: ignore / hide / delete) ──────────────────────
function _toggleBulkSel(i, range = false) {
if (!Number.isFinite(i)) return;
if (range && rehState.bulkAnchor !== null && Number.isFinite(rehState.bulkAnchor)) {
const a = Math.min(rehState.bulkAnchor, i);
const b = Math.max(rehState.bulkAnchor, i);
const indices = [];
for (let n = a; n <= b; n++) if (rehState.lines[n] && !rehState.lines[n].hidden) indices.push(n);
const deselect = indices.length && indices.every(n => rehState.bulkSel.has(n));
indices.forEach(n => {
if (deselect) rehState.bulkSel.delete(n);
else rehState.bulkSel.add(n);
_refreshBulkLine(n);
});
rehState.bulkAnchor = i;
} else {
if (rehState.bulkSel.has(i)) rehState.bulkSel.delete(i);
else rehState.bulkSel.add(i);
rehState.bulkAnchor = i;
_refreshBulkLine(i);
}
_updateBulkCount();
}
function _refreshBulkLine(i) {
const sel = rehState.bulkSel.has(i);
document.querySelectorAll(`.reh-bulk-check[data-bulk="${i}"]`).forEach(cb => {
cb.classList.toggle('checked', sel);
const icon = cb.querySelector('.mdi');
if (icon) icon.className = 'mdi ' + (sel ? 'mdi-checkbox-marked' : 'mdi-checkbox-blank-outline');
});
document.querySelectorAll(`[data-index="${i}"]`).forEach(el => el.classList.toggle('reh-selected', sel));
}
function _updateBulkCount() {
const el = $('reh-bulk-count');
if (el) el.textContent = `${rehState.bulkSel.size} selected`;
}
function setBulkMode(on) {
rehState.bulkMode = on;
if (!on) { rehState.bulkSel.clear(); rehState.bulkAnchor = null; }
const bar = $('reh-bulk-bar'); if (bar) bar.hidden = !on;
const btn = $('reh-bulk-toggle'); if (btn) btn.classList.toggle('active', on);
_updateBulkCount();
if (rehState.lines.length) buildScriptPage();
}
// Apply a mutation to all selected lines, then re-render.
function _bulkApply(fn, { keepSelection = false } = {}) {
if (!rehState.bulkSel.size) { toast('No lines selected', 'error'); return; }
[...rehState.bulkSel].forEach(i => { const l = rehState.lines[i]; if (l) fn(l, i); });
if (!keepSelection) rehState.bulkSel.clear();
_updateBulkCount();
buildScriptPage();
}
function _bulkDelete() {
if (!rehState.bulkSel.size) { toast('No lines selected', 'error'); return; }
const victims = [...rehState.bulkSel].sort((a, b) => b - a); // high → low so indices stay valid
victims.forEach(i => {
rehState.lines.splice(i, 1);
_reindexLineState(i);
});
rehState.bulkSel.clear();
// Keep the cursor in range
if (rehState.lineIndex >= rehState.lines.length) rehState.lineIndex = Math.max(0, rehState.lines.length - 1);
_updateBulkCount();
buildScriptPage();
highlightCurrentLine();
toast(`Deleted ${victims.length} line${victims.length !== 1 ? 's' : ''}`, 'success');
}
// After deleting line `d`, shift every index-keyed bit of state above it down by one.
function _reindexLineState(d) {
const shift = (collection, isMap) => {
const out = isMap ? new Map() : new Set();
for (const entry of collection) {
const k = isMap ? entry[0] : entry;
if (k === d) continue; // dropped line
const nk = k > d ? k - 1 : k;
if (isMap) out.set(nk, entry[1]); else out.add(nk);
}
return out;
};
rehState.synthCache = shift(rehState.synthCache, true);
rehState.staleLines = shift(rehState.staleLines, false);
const sel = shift(rehState.bulkSel, false);
rehState.bulkSel.clear(); sel.forEach(v => rehState.bulkSel.add(v));
if (rehState.practiceStart != null && rehState.practiceStart > d) rehState.practiceStart--;
if (rehState.practiceEnd != null && rehState.practiceEnd > d) rehState.practiceEnd--;
if (rehState.lineIndex > d) rehState.lineIndex--;
}
// ── Page mode (endless scroll | auto-pages | pdf-pages) ─────────────────────
const PAGE_MODES = ['auto', 'scroll', 'pdf'];
// 'pdf' (break at the document's own real page marks) is the default now
// that parseScript's page-break detection actually works (see rehearser-parse.js) —
// 'auto' (break purely by content height, ignoring the source document's own
// pages entirely) was never really what most people mean by "pages".
let _pageMode = localStorage.getItem('reh-page-mode') || 'pdf';
function applyPageMode() {
const wrap = document.querySelector('.reh-page-wrap');
const a4 = $('reh-a4-page');
if (!wrap) return;
if (_pageMode === 'scroll') {
// Endless scroll — put all blocks back into the a4 div, no paper pages
wrap.querySelectorAll('.reh-paper').forEach(p => {
[...p.children].forEach(c => { if (!c.classList.contains('reh-paper-num')) a4?.appendChild(c); });
p.remove();
});
wrap.classList.remove('paginated');
if (a4) a4.style.display = '';
// Hide pagebreak visual dividers in scroll mode
document.querySelectorAll('.reh-block-pagebreak').forEach(el => el.style.display = 'none');
} else if (_pageMode === 'pdf') {
// PDF pages — paginate at pagebreak markers
paginateScript({ respectBreaks: true });
} else {
// Auto — paginate by content height
paginateScript({ respectBreaks: false });
}
_syncPageModeBtn();
}
function _syncPageModeBtn() {
const btn = $('reh-page-mode-btn');
if (!btn) return;
const icons = { auto: 'mdi-file-document-outline', scroll: 'mdi-format-align-justify', pdf: 'mdi-book-open-page-variant' };
const labels = { auto: 'A4 pages', scroll: 'Scroll', pdf: 'PDF pages' };
// Labeling this with the CURRENT mode made it look like a passive status
// indicator rather than a button — confirmed live as genuine confusion:
// stuck in "Scroll" (one continuous page, no page breaks) with no visible
// cue that clicking the very button saying "Scroll" is what would change
// it. Show what clicking it switches TO instead, the normal convention
// for a cycle/toggle button.
const nextMode = PAGE_MODES[(PAGE_MODES.indexOf(_pageMode) + 1) % PAGE_MODES.length];
btn.innerHTML = `<span class="mdi ${icons[nextMode] || icons.auto}"></span> ${labels[nextMode] || labels.auto}`;
btn.title = `Currently: ${labels[_pageMode]} — click to switch to ${labels[nextMode]}`;
}
function cyclePageMode() {
const idx = PAGE_MODES.indexOf(_pageMode);
_pageMode = PAGE_MODES[(idx + 1) % PAGE_MODES.length];
localStorage.setItem('reh-page-mode', _pageMode);
applyPageMode();
// Rebuild script page to apply cleanly
if (rehState.lines.length) buildScriptPage();
}
// ── Pagination: split the continuous script into A4 paper pages ─────────────
function paginateScript({ respectBreaks = false } = {}) {
const wrap = document.querySelector('.reh-page-wrap');
const linesEl = $('reh-script-lines');
const titleEl = $('reh-page-title');
if (!wrap || !linesEl) return;
// Collect the already-wired block elements (moving them keeps listeners)
const blocks = [...linesEl.children];
if (!blocks.length) return;
wrap.classList.add('paginated');
const a4 = $('reh-a4-page');
if (a4) a4.style.display = 'none';
wrap.querySelectorAll('.reh-paper').forEach(p => p.remove());
// Printable content height inside a page (A4 minus vertical padding)
const PAGE_CONTENT = 1027; // 1123 56 40
const TITLE_SPACE = 64; // page-1 title block allowance
let pageNum = 0, page = null, used = 0;
const newPage = () => {
pageNum++;
page = document.createElement('div');
page.className = 'reh-paper';
const num = document.createElement('div');
num.className = 'reh-paper-num';
num.textContent = pageNum + '.';
page.appendChild(num);
used = 0;
if (pageNum === 1 && titleEl) {
const t = titleEl.cloneNode(true);
t.style.display = '';
page.appendChild(t);
used += TITLE_SPACE;
}
wrap.appendChild(page);
};
newPage();
for (const block of blocks) {
// PDF page-break marker: always start a new page here
if (respectBreaks && block.classList.contains('reh-block-pagebreak')) {
newPage();
continue; // don't move the marker div into the page
}
// Measure block height (must be in DOM to measure → append then check)
page.appendChild(block);
const cs = getComputedStyle(block);
const h = block.offsetHeight + (parseFloat(cs.marginTop) || 0) + (parseFloat(cs.marginBottom) || 0);
// If adding this block overflows the page (and page already has content), move to a new page
if (!respectBreaks && used + h > PAGE_CONTENT && used > (pageNum === 1 ? TITLE_SPACE : 0)) {
newPage();
page.appendChild(block);
}
used += h;
}
}
function isPracticeRange(idx) {
const s = rehState.practiceStart, e = rehState.practiceEnd;
if (s === null) return false;
return idx >= s && (e === null || idx <= e);
}
// ── Inline editing ──────────────────────────────────────────────────────────
function startInlineEdit(idx) {
const line = rehState.lines[idx]; if (!line) return;
const dialogEl = document.getElementById('reh-diag-' + idx);
const actionEl = document.querySelector(`[data-index="${idx}"] .reh-action-text`);
const sceneEl = document.querySelector(`.reh-scene[data-index="${idx}"] span[style*="flex:1"]`);
const targetEl = dialogEl || actionEl || sceneEl;
if (!targetEl || targetEl.tagName === 'TEXTAREA') return;
const orig = line.text;
const ta = document.createElement('textarea');
ta.value = orig;
ta.style.cssText = 'width:100%;min-height:54px;font-size:14px;line-height:1.6;padding:5px 8px;border:1px solid var(--accent);border-top:none;border-radius:0 0 3px 3px;resize:vertical;background:#fffff8;font-family:inherit;display:block;box-sizing:border-box;outline:none;';
// Formatting toolbar
const toolbar = document.createElement('div');
toolbar.className = 'reh-fmt-toolbar';
toolbar.innerHTML = `
<button class="reh-fmt-btn" data-b="**" title="Bold"><b>B</b></button>
<button class="reh-fmt-btn" data-b="*" title="Italic"><i>I</i></button>
<button class="reh-fmt-btn" data-b="__" title="Underline"><u>U</u></button>
<button class="reh-fmt-btn" data-b="~~" title="Strikethrough"><del>S</del></button>
<button class="reh-fmt-btn" data-b="==" title="Highlight" style="background:#fef3c7">H</button>
<kbd class="reh-fmt-hint">Ctrl+Enter save · Esc cancel</kbd>
`;
toolbar.querySelectorAll('.reh-fmt-btn').forEach(btn => {
btn.addEventListener('mousedown', e => {
e.preventDefault();
const wrap = btn.dataset.b;
const s = ta.selectionStart, end = ta.selectionEnd;
const sel = ta.value.slice(s, end) || 'text';
ta.value = ta.value.slice(0, s) + wrap + sel + wrap + ta.value.slice(end);
ta.setSelectionRange(s + wrap.length, s + wrap.length + sel.length);
ta.focus();
});
});
targetEl.style.display = 'none';
targetEl.before(toolbar);
toolbar.after(ta);
ta.focus(); ta.select();
const save = () => {
const newText = ta.value.trim() || orig;
line.text = newText;
targetEl.innerHTML = renderMarkdownInline(newText);
targetEl.style.display = '';
toolbar.remove(); ta.remove();
rehState.synthCache.delete(idx);
const dot = document.getElementById('reh-syd-' + idx);
if (dot) dot.style.display = 'none';
};
ta.addEventListener('keydown', e => {
if (e.key === 'Escape') { ta.value = orig; save(); }
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); save(); }
});
ta.addEventListener('blur', save);
}
// ── Script editor modal ─────────────────────────────────────────────────────
function openScriptEditorModal() {
const currentTitle = $('reh-page-title')?.textContent || $('reh-script-title')?.value || 'Script';
const currentScript = linesToScriptText() || $('reh-script-text')?.value || '';
const modal = document.createElement('div');
modal.className = 'reh-modal-overlay';
modal.innerHTML = `
<div class="reh-modal-box" style="max-width:720px;width:95vw;height:80vh">
<div class="reh-modal-head">
<h3 style="margin:0;font-size:15px"><span class="mdi mdi-script-text-outline"></span> Edit Script</h3>
<button class="btn-secondary btn-sm" id="_reh-mc">✕ Close</button>
</div>
<div style="padding:10px 14px;border-bottom:1px solid var(--border);display:flex;gap:10px;align-items:center">
<label style="font-size:12px;font-weight:600;flex-shrink:0">Title</label>
<input type="text" id="_reh-mt" value="${escHtml(currentTitle)}" style="flex:1;padding:5px 8px;border:1px solid var(--border);border-radius:4px;font-size:13px;background:var(--surface);color:var(--text)">
</div>
<textarea id="_reh-ms" style="flex:1;padding:14px;font-family:'Courier New',monospace;font-size:12.5px;border:none;outline:none;resize:none;background:var(--surface);color:var(--text);height:calc(80vh - 140px);width:100%;box-sizing:border-box;" spellcheck="false">${escHtml(currentScript)}</textarea>
<div style="padding:10px 14px;display:flex;gap:8px;justify-content:flex-end;border-top:1px solid var(--border)">
<button class="btn-secondary" id="_reh-mcan">Cancel</button>
<button class="btn-primary" id="_reh-mapp"><span class="mdi mdi-auto-fix"></span> Apply changes</button>
</div>
</div>
`;
document.body.appendChild(modal);
const close = () => modal.remove();
modal.querySelector('#_reh-mc').onclick = close;
modal.querySelector('#_reh-mcan').onclick = close;
modal.addEventListener('click', e => { if (e.target === modal) close(); });
modal.querySelector('#_reh-mapp').onclick = () => {
const newTitle = modal.querySelector('#_reh-mt').value.trim() || 'Script';
const newScript = modal.querySelector('#_reh-ms').value;
const newLines = parseScript(newScript);
if (!newLines.filter(l => l.type === 'dialog').length) {
toast('No dialog found in the edited script', 'error'); return;
}
// Merge emotions for unchanged lines
newLines.forEach(nl => {
if (nl.type !== 'dialog') return;
const old = rehState.lines.find(ol => ol.type==='dialog' && ol.speaker===nl.speaker && ol.text===nl.text);
if (old) nl.emotion = old.emotion;
});
// Keep cast for matching characters
const detected = detectCharacters(newLines);
Object.entries(detected).forEach(([sp]) => {
if (rehState.cast[sp]) detected[sp] = { ...detected[sp], ...rehState.cast[sp] };
});
// Update state
rehState.lines = newLines;
rehState.cast = detected;
rehState.lineIndex = Math.min(rehState.lineIndex, newLines.length - 1);
rehState.synthCache.clear(); rehDecodedBuffers.clear();
rehState.practiceStart = null;
rehState.practiceEnd = null;
if ($('reh-script-title')) $('reh-script-title').value = newTitle;
if ($('reh-page-title')) $('reh-page-title').textContent = newTitle;
buildScriptPage();
highlightCurrentLine();
close();
toast('Script updated', 'success');
};
}
// ── Practice range ──────────────────────────────────────────────────────────
function updatePracticeRange() {
const start = rehState.practiceStart;
const end = rehState.practiceEnd;
document.querySelectorAll('.reh-block').forEach(el => {
const idx = parseInt(el.dataset.index);
el.classList.toggle('reh-in-range', start !== null && idx >= start && (end === null || idx <= end));
el.classList.toggle('reh-range-start', start !== null && idx === start);
el.classList.toggle('reh-range-end', end !== null && idx === end);
const gb = el.querySelector('.reh-gutter-btn');
if (gb) gb.textContent = idx === start ? '▶' : idx === end ? '■' : '';
});
const info = $('reh-practice-info');
if (info) {
const show = end !== null;
info.classList.toggle('visible', show);
info.hidden = !show;
if (show) {
const f = $('reh-practice-from'), t = $('reh-practice-to');
if (f) f.textContent = (start !== null ? start : 0) + 1;
if (t) t.textContent = end + 1;
}
}
}
function clearPracticeRange() {
rehState.practiceStart = null;
rehState.practiceEnd = null;
updatePracticeRange();
}
function setPracticeRangeFromSelection() {
const picked = [...rehState.bulkSel].sort((a, b) => a - b);
if (!picked.length) { toast('Select the lines you want to rehearse first', 'error'); return; }
rehState.practiceStart = picked[0];
rehState.practiceEnd = picked[picked.length - 1];
rehState.lineIndex = rehState.practiceStart;
setBulkMode(false);
updatePracticeRange();
highlightCurrentLine();
toast(`Practice range set: lines ${rehState.practiceStart + 1}${rehState.practiceEnd + 1}`, 'success');
}
$('reh-practice-clear')?.addEventListener('click', clearPracticeRange);
$('reh-bulk-range')?.addEventListener('click', setPracticeRangeFromSelection);
// ── Emotion picker popover ──────────────────────────────────────────────────
let rehEmoPicker = null;
function openEmoPicker(idx, anchorBtn) {
closeEmoPicker();
const line = rehState.lines[idx];
const allEmos = [...REH_EMOTIONS, ...rehCustomEmotions];
const rect = anchorBtn.getBoundingClientRect();
const left = Math.min(rect.left, window.innerWidth - 265);
const topBelow = rect.bottom + 4;
const topAbove = rect.top - 4;
const spaceBelow = window.innerHeight - topBelow;
const top = spaceBelow >= 240 ? topBelow : (topAbove - 260);
const pop = document.createElement('div');
pop.className = 'reh-emo-popover';
pop.style.cssText = `position:fixed;z-index:1000;top:${top}px;left:${left}px;width:258px;`;
pop.innerHTML = `<div class="reh-emo-pop-inner">
<input class="reh-emo-search" placeholder="Search or type custom tone…" autocomplete="off">
<div class="reh-emo-list"></div>
<div class="reh-emo-custom-row">
<button class="btn-secondary btn-sm" id="_emo-custom-apply" style="width:100%;font-size:11px">
<span class="mdi mdi-plus"></span> Use as custom tone
</button>
</div>
</div>`;
document.body.appendChild(pop);
rehEmoPicker = pop;
const searchEl = pop.querySelector('.reh-emo-search');
const listEl = pop.querySelector('.reh-emo-list');
const applyBtn = pop.querySelector('#_emo-custom-apply');
function renderList(q = '') {
const lq = q.toLowerCase();
const filtered = allEmos.filter(e => !q || e.label.toLowerCase().includes(lq) || e.value.toLowerCase().includes(lq));
listEl.innerHTML = filtered.map(e =>
`<div class="reh-emo-item${line.emotion===e.value?' selected':''}" data-value="${escHtml(e.value)}">
<span class="reh-emo-item-emoji">${e.emoji}</span>
<span class="reh-emo-item-label">${escHtml(e.label)}</span>
${line.emotion===e.value?'<span class="mdi mdi-check" style="margin-left:auto;font-size:13px;color:var(--accent)"></span>':''}
</div>`
).join('');
listEl.querySelectorAll('.reh-emo-item').forEach(item => {
item.addEventListener('mousedown', e => {
e.preventDefault();
selectEmotion(idx, item.dataset.value, anchorBtn);
closeEmoPicker();
});
});
}
searchEl.addEventListener('input', () => renderList(searchEl.value));
searchEl.addEventListener('keydown', e => {
if (e.key === 'Escape') closeEmoPicker();
if (e.key === 'Enter') {
const val = searchEl.value.trim();
if (val) { selectEmotion(idx, val, anchorBtn); closeEmoPicker(); }
}
});
applyBtn.addEventListener('mousedown', e => {
e.preventDefault();
const val = searchEl.value.trim(); if (!val) return;
const exists = [...REH_EMOTIONS, ...rehCustomEmotions].find(e => e.value === val);
if (!exists) {
rehCustomEmotions.push({ emoji: '✨', label: val, value: val, custom: true });
try { localStorage.setItem('reh-custom-emotions', JSON.stringify(rehCustomEmotions)); } catch(_) {}
}
selectEmotion(idx, val, anchorBtn);
closeEmoPicker();
});
renderList();
searchEl.focus();
setTimeout(() => document.addEventListener('mousedown', _closePickerOnOutside), 50);
}
function _closePickerOnOutside(e) {
if (rehEmoPicker && !rehEmoPicker.contains(e.target)) closeEmoPicker();
}
function closeEmoPicker() {
if (rehEmoPicker) { rehEmoPicker.remove(); rehEmoPicker = null; }
document.removeEventListener('mousedown', _closePickerOnOutside);
}
function _markSynthDot(idx, state) {
const dot = document.getElementById('reh-syd-' + idx);
if (!dot) return;
dot.className = 'reh-synth-dot'
+ (state === 'stale' ? ' stale' : '')
+ (state === 'synthesizing' ? ' synthesizing' : '');
dot.style.display = state ? '' : 'none';
dot.title = state === 'stale' ? 'Tone changed — needs re-synthesis'
: state === 'synthesizing' ? 'Synthesizing…'
: 'Pre-synthesized';
// Also highlight the block row itself while synthesizing
const block = dot.closest('[data-index]');
if (block) block.classList.toggle('reh-line-synthesizing', state === 'synthesizing');
}
function _showReSynthBtn(idx, show) {
const btn = document.getElementById('reh-rsb-' + idx);
if (btn) btn.hidden = !show;
}
// A voice reassigned in the Library/Studio Voices tab (clPut calls this
// after every character save) used to never reach an already-open
// rehearsal of the same book: rehState.cast[sp].voice is loaded once from
// the saved rehearsal record, and an explicit prior value always wins over
// a fresher one from the shared roster (see the `saved?.voice ?? def.voice`
// load above) — so the Stage/export kept reading, and kept the cached
// audio for, the OLD voice indefinitely. Confirmed live as the cause of an
// audiobook export that finished suspiciously fast right after changing a
// voice: the reassigned character's lines were still "cached" under the
// old voice and never got marked stale. Only acts when a rehearsal for the
// SAME book is actually open right now and the name matches a real speaker.
function _rehSyncCastVoiceFromLibrary(rec) {
if (!window.rehState || !rehState.lines || !rehState.lines.length) return;
if (!rec || !rec.name || !rec.voice) return;
const openBook = (typeof _lineAudioBookName === 'function') ? _lineAudioBookName() : '';
if (!openBook || String(rec.book || '').trim().toLowerCase() !== openBook.trim().toLowerCase()) return;
const target = String(rec.name).toUpperCase().trim();
const sp = Object.keys(rehState.cast).find(k => String(k).toUpperCase().trim() === target);
if (!sp) return;
const c = rehState.cast[sp];
if (!c || c.voice === rec.voice) return;
c.voice = rec.voice;
c.voiceData = getVoiceData(rec.voice);
rehState.lines.forEach((line, idx) => {
if (line.type === 'dialog' && line.speaker === sp && rehState.synthCache.has(idx)) {
rehState.synthCache.delete(idx);
rehState.staleLines.add(idx);
if (typeof _markSynthDot === 'function') _markSynthDot(idx, 'stale');
}
});
if (typeof _updateStaleBatchBtn === 'function') _updateStaleBatchBtn();
if (typeof renderCastList === 'function') renderCastList();
}
window._rehSyncCastVoiceFromLibrary = _rehSyncCastVoiceFromLibrary;
async function synthOneLine(idx) {
const line = rehState.lines[idx];
if (!line || line.type !== 'dialog') return;
const c = rehState.cast[line.speaker];
if (!c || !c.voice || c.voice === 'me') return;
const instruct = _buildInstruct(c.instruct, line.emotion, c.voice);
_showReSynthBtn(idx, false);
_markSynthDot(idx, 'synthesizing');
try {
const blob = await fetchTtsPreviewBlob(c.voice, _rehInlineTone(stripMarkdown(line.text), line.emotion), 'wav', instruct, _ttsBackendForVoice(c.voice, rehState.backend));
rehState.synthCache.set(idx, blob);
rehState.staleLines.delete(idx);
preDecodeBlob(idx, blob);
_markSynthDot(idx, 'ok');
toast('Re-synthesized line ' + (idx + 1), 'success');
} catch(e) {
_markSynthDot(idx, null);
_showReSynthBtn(idx, true);
toast('Synthesis failed: ' + e.message, 'error');
}
}
function selectEmotion(idx, value, anchorBtn) {
rehState.lines[idx].emotion = value;
if (rehState.synthCache.has(idx)) {
rehState.synthCache.delete(idx);
rehState.staleLines.add(idx);
_markSynthDot(idx, 'stale');
_updateStaleBatchBtn();
}
_showReSynthBtn(idx, true);
// Update button
const info = getEmotionInfo(value);
anchorBtn.className = 'reh-emo-btn' + (value ? ' has-emotion' : '');
anchorBtn.innerHTML = `${info.emoji ? info.emoji + ' ' : ''}<span class="reh-emo-label">${escHtml(info.label)}</span> <span class="mdi mdi-chevron-down" style="font-size:9px;opacity:.6"></span>`;
// Show warning if the active backend doesn't reliably support style
if (value) _checkToneStyleSupport();
}
// One row of the tone/identity comparison table.
function _rehToneCmpRow(backend, isCurrent) {
const check = (ok) => ok
? '<span class="reh-tone-cmp-yes"><span class="mdi mdi-check"></span></span>'
: '<span class="reh-tone-cmp-no"><span class="mdi mdi-close"></span></span>';
const action = isCurrent
? '<span class="reh-tone-cmp-current-tag">current</span>'
: `<button type="button" class="btn-secondary btn-sm reh-tone-switch-btn" data-backend-id="${escHtml(backend.id)}">Switch</button>`;
return `<tr${isCurrent ? ' class="reh-tone-cmp-current"' : ''}>
<td>${escHtml(backend.label)}</td>
<td>${check(backend.style_aware)}</td>
<td>${check(backend.uses_wav)}</td>
<td>${action}</td>
</tr>`;
}
function _checkToneStyleSupport() {
const warn = $('reh-tone-warn'); if (!warn) return;
const txtEl = $('reh-tone-warn-txt');
const b = (typeof backendById === 'function') ? backendById(rehState.backend) : null;
if (!b) { warn.hidden = true; return; }
const all = (typeof availableTtsBackends === 'function') ? availableTtsBackends() : [];
const hasTone = rehState.lines.some(l => l.type === 'dialog' && l.emotion);
// Two opposite engine trade-offs, surfaced so the user can choose knowingly:
// • clone backends → consistent character identity, but weak tone control
// • design backends → strong tone, but a fresh persona each call (voices drift)
// Originally a single run-on sentence with a button awkwardly wedged into
// the middle of it (confirmed live: read badly, wrapped worse). A table
// says the same two facts as two columns instead of two clauses.
if (_rehBackendIsFish()) {
// Fish-Speech / OpenAudio S2 honours inline [tag] tones (15 000+ tags) injected per line
if (txtEl) txtEl.innerHTML = `<strong>${escHtml(b.label)}</strong> keeps each characters voice consistent <em>and</em> applies tone. Per-line tones are sent as inline <code>[tags]</code> (e.g. <code>[whisper]</code>, <code>[excited]</code>, <code>[laughing]</code>). You can also type a custom tone like <code>[professional broadcast tone]</code> — S2 supports free-form descriptions. <a href="https://huggingface.co/fishaudio/s2-pro" target="_blank" rel="noopener">Fish-Speech S2 ↗</a>`;
warn.hidden = false;
} else if (!b.style_aware && hasTone) {
// Prefer a backend that fixes BOTH weaknesses at once (tone-aware AND
// clones from a reference WAV, e.g. Fish-Speech) over one that only
// fixes this one (tone-aware but re-rolls the voice each line, e.g.
// Voice Design) — confirmed live: with both available, `.find()` was
// silently suggesting whichever happened to come first in the backend
// list, which was never Fish-Speech despite it being the strictly
// better option whenever it's actually running.
const styleAware = all.find(x => x.style_aware && x.uses_wav) || all.find(x => x.style_aware);
if (txtEl) {
txtEl.innerHTML = styleAware
? `<table class="reh-tone-cmp"><thead><tr><th></th><th>Tone control</th><th>Voice stays identical</th><th></th></tr></thead><tbody>${_rehToneCmpRow(b, true)}${_rehToneCmpRow(styleAware, false)}</tbody></table>`
: `<strong>${escHtml(b.label)}</strong> keeps each characters voice consistent but has weak tone control — tone picks may have little effect.`;
}
warn.hidden = false;
} else if (b.style_aware && !b.uses_wav) {
// Same preference as above, mirrored: a wav-cloning backend that's ALSO
// tone-aware (Fish-Speech) beats one that drops tone control entirely
// (Voice Clone) as the suggested alternative.
const wavBackend = all.find(x => x.uses_wav && x.style_aware) || all.find(x => x.uses_wav);
const qwenHint = /qwen|voice design|custom/i.test((b.id || '') + ' ' + (b.label || '')) ? '<p class="reh-tone-cmp-note">Qwen3TTS tone is sent as the per-line style/instruct text, so this is the right path for directed delivery.</p>' : '';
if (txtEl) {
txtEl.innerHTML = wavBackend
? `<table class="reh-tone-cmp"><thead><tr><th></th><th>Tone control</th><th>Voice stays identical</th><th></th></tr></thead><tbody>${_rehToneCmpRow(b, true)}${_rehToneCmpRow(wavBackend, false)}</tbody></table>${qwenHint}`
: `<strong>${escHtml(b.label)}</strong> gives strong tone but re-generates a fresh voice each line, so a character wont sound the same throughout.${qwenHint}`;
}
warn.hidden = false;
} else {
warn.hidden = true;
}
}
$('reh-tone-warn-close')?.addEventListener('click', () => { const w = $('reh-tone-warn'); if (w) w.hidden = true; });
// The suggestion buttons above get rebuilt (via innerHTML) every time
// _checkToneStyleSupport() re-runs, so a delegated listener on the
// container — bound once — is the only reliable way to catch clicks on them.
$('reh-tone-warn')?.addEventListener('click', (e) => {
const btn = e.target.closest('.reh-tone-switch-btn');
if (!btn) return;
const id = btn.dataset.backendId;
const sel = $('reh-backend-select');
if (sel && [...sel.options].some(o => o.value === id)) sel.value = id;
rehState.backend = id;
_checkToneStyleSupport();
const b = (typeof backendById === 'function') ? backendById(id) : null;
toast('Switched to ' + (b ? b.label : id), 'success');
});
// ── Transport controls ──────────────────────────────────────────────────────
$('reh-tb-play')?.addEventListener('click', () => {
if (rehState.playing) pausePlay(); else startPlay();
});
$('reh-tb-stop')?.addEventListener('click', () => {
stopPlay();
rehState.lineIndex = rehState.practiceStart ?? 0;
highlightCurrentLine();
hideRecOverlay();
});
$('reh-tb-prev')?.addEventListener('click', () => {
stopPlay();
rehState.lineIndex = Math.max(0, rehState.lineIndex - 1);
highlightCurrentLine();
hideRecOverlay();
});
$('reh-tb-next')?.addEventListener('click', () => {
stopPlay();
rehState.lineIndex = Math.min(rehState.lines.length - 1, rehState.lineIndex + 1);
highlightCurrentLine();
hideRecOverlay();
});
$('reh-tb-repeat')?.addEventListener('click', () => {
rehState.repeat = !rehState.repeat;
$('reh-tb-repeat')?.classList.toggle('reh-btn-active', rehState.repeat);
});
$('reh-skip-desc-toggle')?.addEventListener('change', function () { rehState.skipDescriptions = this.checked; });
$('reh-edit-script-btn')?.addEventListener('click', openScriptEditorModal);
$('reh-fountain-btn')?.addEventListener('click', exportFountain);
$('reh-fountain-export-p4')?.addEventListener('click', exportFountain);
$('reh-fdx-export-btn')?.addEventListener('click', exportFDX);
$('reh-osf-export-btn')?.addEventListener('click', exportOSF);
$('reh-exit-btn')?.addEventListener('click', () => {
stopPlay(); stopRehMic();
if (rehState.clips.length) { renderSummary(); showPhase(4); }
else showPhase(2);
});
// ── Page title inline editing ───────────────────────────────────────────────
$('reh-page-title')?.addEventListener('dblclick', function () {
this.contentEditable = 'true';
this.style.outline = '2px solid var(--accent)';
this.style.borderRadius = '3px';
this.focus();
const range = document.createRange();
range.selectNodeContents(this);
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
});
$('reh-page-title')?.addEventListener('blur', function () {
if (this.contentEditable === 'true') {
this.contentEditable = 'false';
this.style.outline = '';
const v = this.textContent.trim() || 'Script';
this.textContent = v;
if ($('reh-script-title')) $('reh-script-title').value = v;
}
});
$('reh-page-title')?.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { e.preventDefault(); this.blur(); }
if (e.key === 'Escape') { this.textContent = $('reh-script-title')?.value || 'Script'; this.blur(); }
});
// ── WebAudio pre-decode (eliminates 2-3s silence between lines) ─────────────
let _rehPlayCtx = null;
const rehDecodedBuffers = new Map(); // lineIndex → AudioBuffer (pre-decoded PCM)
let rehCurrentSource = null; // active AudioBufferSourceNode
let rehWordHighlightRaf = null;
function rehPlayCtx() {
if (!_rehPlayCtx) _rehPlayCtx = new (window.AudioContext || window.webkitAudioContext)();
if (_rehPlayCtx.state === 'suspended') _rehPlayCtx.resume().catch(() => {});
return _rehPlayCtx;
}
// Decoded PCM (Float32) is the heaviest cache and is re-derivable from the cached
// blob, so keep only a sliding window around the playhead. Without this, a long
// script holds every line's raw PCM in memory at once and crashes mobile Safari.
const REH_DECODE_WINDOW = 8;
function _rehEvictDecoded(keepIdx) {
if (rehDecodedBuffers.size <= REH_DECODE_WINDOW * 2 + 4) return;
const lo = keepIdx - REH_DECODE_WINDOW, hi = keepIdx + REH_DECODE_WINDOW;
for (const k of rehDecodedBuffers.keys()) {
if (k < lo || k > hi) rehDecodedBuffers.delete(k);
}
}
async function preDecodeBlob(lineIdx, blob) {
if (rehDecodedBuffers.has(lineIdx)) return;
try {
const ab = await blob.arrayBuffer();
const buf = await rehPlayCtx().decodeAudioData(ab);
rehDecodedBuffers.set(lineIdx, buf);
_rehEvictDecoded(lineIdx);
} catch(_) {}
}
function computeWordTimings(text, durationSec) {
const words = stripMarkdown(text).split(/\s+/).filter(Boolean);
if (words.length < 2) return [];
// Distribute proportionally by character length (longer words = more time)
const totalChars = words.reduce((s, w) => s + w.length, 0) || 1;
let t = 0;
return words.map(w => {
const start = t;
t += (w.length / totalChars) * durationSec;
return { word: w, start, end: t };
});
}
function stopAudioSource() {
if (rehCurrentSource) {
try { rehCurrentSource.stop(0); } catch(_) {}
rehCurrentSource = null;
}
if (rehWordHighlightRaf) { cancelAnimationFrame(rehWordHighlightRaf); rehWordHighlightRaf = null; }
}
// Instant playback from pre-decoded buffer + word-level highlight
async function playPreDecoded(lineIdx, blob, text) {
if (!rehDecodedBuffers.has(lineIdx)) await preDecodeBlob(lineIdx, blob);
_rehEvictDecoded(lineIdx); // keep the window centred on the playhead
const buf = rehDecodedBuffers.get(lineIdx);
if (!buf) { await playAudioBlobFallback(blob); return; }
const timings = computeWordTimings(text, buf.duration);
const dialogEl = document.getElementById('reh-diag-' + lineIdx);
if (dialogEl && timings.length >= 2) {
dialogEl.innerHTML = timings.map((t, i) =>
`<span class="reh-word" data-wi="${i}">${escHtml(t.word)}</span>`
).join(' ');
}
return new Promise(resolve => {
stopAudioSource();
const ctx = rehPlayCtx();
const src = ctx.createBufferSource();
src.buffer = buf;
src.connect(ctx.destination);
rehCurrentSource = src;
const t0 = ctx.currentTime;
src.onended = () => {
rehCurrentSource = null;
if (rehWordHighlightRaf) { cancelAnimationFrame(rehWordHighlightRaf); rehWordHighlightRaf = null; }
if (dialogEl && timings.length >= 2) dialogEl.innerHTML = renderMarkdownInline(text);
resolve();
};
src.start(0);
// Pre-decode the line AFTER this one while this is playing
const nextI = findNextCachedLine(lineIdx + 1);
if (nextI >= 0) preDecodeBlob(nextI, rehState.synthCache.get(nextI));
if (dialogEl && timings.length >= 2) {
const tick = () => {
if (rehCurrentSource !== src) return;
const elapsed = ctx.currentTime - t0;
let active = 0;
for (let i = timings.length - 1; i >= 0; i--) {
if (elapsed >= timings[i].start) { active = i; break; }
}
dialogEl.querySelectorAll('.reh-word').forEach((span, i) => {
span.classList.toggle('reh-word-active', i === active);
});
rehWordHighlightRaf = requestAnimationFrame(tick);
};
rehWordHighlightRaf = requestAnimationFrame(tick);
}
});
}
function findNextCachedLine(fromIdx) {
for (let i = fromIdx; i < rehState.lines.length; i++) {
if (rehState.lines[i].type === 'dialog' && rehState.synthCache.has(i)) return i;
}
return -1;
}
// Fallback when AudioContext decode fails
async function playAudioBlobFallback(blob) {
const url = URL.createObjectURL(blob);
const audio = $('reh-tts-audio'); if (!audio) return;
audio.src = url; audio.style.display = '';
await new Promise(r => { audio.addEventListener('canplay', r, { once: true }); setTimeout(r, 3000); });
await audio.play().catch(() => {});
await waitForAudioEnd(audio);
}
// ── Auto-play sequence ──────────────────────────────────────────────────────
function startPlay() {
rehPlayCtx(); // warm up AudioContext on user gesture
_ensureNarrator(); // make sure rehState.narratorVoice reflects the narrator cast row
rehState.playing = true;
updatePlayBtn();
playNextLine();
}
// Resets the narrator play button's icon back to "play" — separate from
// highlightCurrentLine() (which also moves the active-line highlight and
// scrolls) since pausing/stopping shouldn't jump the page around, just
// stop claiming a line is still playing.
function _rehResetActionPlayIcons() {
document.querySelectorAll('.reh-action-play-btn').forEach(btn => {
const icon = btn.querySelector('.mdi');
if (icon) icon.className = 'mdi mdi-play';
btn.title = 'Play or pause this line';
});
}
function pausePlay() {
rehState.playing = false;
updatePlayBtn();
stopAudioSource();
const audio = $('reh-tts-audio');
if (audio && !audio.paused) audio.pause();
hideStatusBar();
_rehResetActionPlayIcons();
}
function stopPlay() {
rehState.playing = false;
updatePlayBtn();
stopAudioSource();
const audio = $('reh-tts-audio');
if (audio) { audio.pause(); audio.src = ''; }
hideStatusBar();
_rehResetActionPlayIcons();
}
function hideStatusBar() { const bar = $('reh-tts-status-bar'); if (bar) bar.hidden = true; }
async function playNextLine() {
if (!rehState.playing) return;
// Check practice end boundary
if (rehState.practiceEnd !== null && rehState.lineIndex > rehState.practiceEnd) {
rehState.playing = false;
updatePlayBtn();
if (rehState.repeat) {
rehState.lineIndex = rehState.practiceStart ?? 0;
startPlay();
} else {
rehState.lineIndex = rehState.practiceStart ?? 0;
highlightCurrentLine();
}
return;
}
// Always skip pagebreak markers
if (rehState.lines[rehState.lineIndex]?.type === 'pagebreak') {
rehState.lineIndex++;
return playNextLine();
}
// Skip lines the user marked ignore / hide during bulk edit
const _cur = rehState.lines[rehState.lineIndex];
if (_cur && (_cur.ignored || _cur.hidden)) {
rehState.lineIndex++;
return playNextLine();
}
// Skip non-dialog lines whenever skip mode is on, full stop — regardless of
// whether a narrator voice happens to be assigned. This used to also require
// !rehState.narratorVoice, on the theory that skipping was only meaningful
// when there was nothing to skip TO — but the individual-line branch below
// never re-checked skipDescriptions at all, so once ANY narrator voice was
// configured (the common case once a book is actually set up), narration
// played regardless of this toggle. Confirmed live: Studio's "Rehearse ⇄
// Audiobook" toggle (which just flips this same flag) had zero observable
// effect once a narrator voice existed — exactly the reported "doesn't
// read the narrator" / toggle-does-nothing behavior, just inverted from
// what it looked like (narration was stuck ON, not stuck OFF).
if (rehState.skipDescriptions) {
while (
rehState.lineIndex < rehState.lines.length &&
rehState.lines[rehState.lineIndex].type !== 'dialog' &&
rehState.lines[rehState.lineIndex].type !== 'pagebreak' &&
!(rehState.practiceEnd !== null && rehState.lineIndex > rehState.practiceEnd)
) { rehState.lineIndex++; }
}
if (rehState.lineIndex >= rehState.lines.length) {
rehState.playing = false;
updatePlayBtn();
if (rehState.repeat) { rehState.lineIndex = 0; startPlay(); return; }
toast('Script finished', 'success');
return;
}
// Captured up front so a later `await` (waiting on a fresh TTS synthesis)
// can tell whether the user has since clicked a DIFFERENT line's play
// button — `rehState.lineIndex` itself gets overwritten by that click, so
// re-reading it after the await always looks "current" even when it
// isn't. Checking only `rehState.playing` (a bare boolean, flipped false
// then true again by the new click's own stopPlay()/startPlay() pair
// before this await ever resumes) let this stale continuation slip
// through and actually play — confirmed live as the reported bug: click
// a line while a previous, not-yet-synthesized line is still loading, and
// once that first synthesis finally finishes it cuts in and starts
// playing anyway, on top of (or right over) the line the user actually
// asked for, with no way to stop just that stray one.
const myLineIndex = rehState.lineIndex;
const stillCurrent = () => rehState.playing && rehState.lineIndex === myLineIndex;
const line = rehState.lines[rehState.lineIndex];
highlightCurrentLine();
// Non-dialog lines
if (line.type !== 'dialog') {
// Any line with text can be narrated — direction/transition/scene/act/action all included
const hasText = !!(line.text || '').trim();
if (rehState.narratorVoice && hasText && !rehState.skipDescriptions) {
showStatusBar('Narrator: ' + line.text.slice(0, 50) + (line.text.length > 50 ? '…' : ''));
const cached = rehState.synthCache.get(rehState.lineIndex);
if (cached) {
await playPreDecoded(rehState.lineIndex, cached, line.text);
} else {
try {
const book = _lineAudioBookName();
const cleanNarr = stripMarkdown(line.text);
const cacheKey = await _lineAudioCacheKey(cleanNarr, rehState.narratorVoice, '');
if (!stillCurrent()) return;
let blob = await _lineAudioCacheGet(book, cacheKey);
if (!stillCurrent()) return;
if (!blob) {
blob = await fetchTtsPreviewBlob(rehState.narratorVoice, cleanNarr, 'wav', '', _ttsBackendForVoice(rehState.narratorVoice, rehState.backend));
if (!stillCurrent()) return;
_lineAudioCachePut(book, cacheKey, blob);
}
rehState.synthCache.set(myLineIndex, blob);
_markSynthDot(myLineIndex, 'ok');
await playPreDecoded(myLineIndex, blob, line.text);
} catch(_) { await new Promise(r => setTimeout(r, 200)); }
}
} else if (!rehState.skipDescriptions) {
await new Promise(r => setTimeout(r, line.type === 'direction' ? 150 : 250));
}
if (!stillCurrent()) return;
rehState.lineIndex++;
playNextLine();
return;
}
// Dialog line
const cast = rehState.cast[line.speaker] || { voice: '' };
if (cast.voice === 'me') {
rehState.playing = false; updatePlayBtn();
showRecOverlay(line);
return;
}
if (!cast.voice) {
showStatusBar(line.speaker + ' has no voice — skipping…');
await new Promise(r => setTimeout(r, 350));
if (!stillCurrent()) return;
rehState.lineIndex++; playNextLine(); return;
}
const profile = (cast.instruct || '').trim();
const instruct = _buildInstruct(profile, line.emotion, cast.voice);
const cleanTxt = stripMarkdown(line.text);
const cached = rehState.synthCache.get(rehState.lineIndex);
if (cached) {
showStatusBar(line.speaker + ' is speaking…');
await playPreDecoded(rehState.lineIndex, cached, line.text); // ← instant: pre-decoded PCM
rehState.clips.push({ lineIndex: rehState.lineIndex, speaker: line.speaker, type: 'tts', blob: cached });
} else {
showStatusBar('Synthesizing…');
try {
const toneText = _rehInlineTone(cleanTxt, line.emotion);
const book = _lineAudioBookName();
const cacheKey = await _lineAudioCacheKey(toneText, cast.voice, instruct);
if (!stillCurrent()) return;
let blob = await _lineAudioCacheGet(book, cacheKey);
if (!stillCurrent()) return;
if (!blob) {
blob = await fetchTtsPreviewBlob(cast.voice, toneText, 'wav', instruct, _ttsBackendForVoice(cast.voice, rehState.backend));
if (!stillCurrent()) return;
_lineAudioCachePut(book, cacheKey, blob);
}
rehState.synthCache.set(myLineIndex, blob);
showStatusBar(line.speaker + ' is speaking…');
await playPreDecoded(myLineIndex, blob, line.text); // also decodes + pre-fetches next
rehState.clips.push({ lineIndex: myLineIndex, speaker: line.speaker, type: 'tts', blob });
} catch(e) {
showStatusBar('TTS failed: ' + e.message);
await new Promise(r => setTimeout(r, 1000));
}
}
if (!stillCurrent()) return;
rehState.lineIndex++;
playNextLine();
}
function waitForAudioEnd(audio) {
return new Promise(resolve => {
if (!audio || audio.paused || audio.ended) { resolve(); return; }
audio.addEventListener('ended', resolve, { once: true });
audio.addEventListener('pause', resolve, { once: true });
audio.addEventListener('error', resolve, { once: true });
});
}
function showStatusBar(msg) {
const bar = $('reh-tts-status-bar'); if (!bar) return;
bar.hidden = false;
const txt = $('reh-tts-status-txt'); if (txt) txt.textContent = msg;
}
function highlightCurrentLine() {
const i = rehState.lineIndex, total = rehState.lines.length;
const prog = $('reh-tb-progress');
if (prog) prog.style.width = (total ? (i / total) * 100 : 0) + '%';
const lbl = $('reh-tb-label');
if (lbl) lbl.textContent = `${i + 1} / ${total}`;
document.querySelectorAll('[data-index]').forEach(el => {
el.classList.toggle('reh-line-active', parseInt(el.dataset.index) === i);
});
// Dialogue's own play button shows play/pause via a pure-CSS badge
// overlay on the avatar (.reh-line-active .reh-line-play-avatar::after),
// but the plain narrator play button (.reh-action-play-btn, no avatar to
// overlay onto) never got the same treatment — its icon just stayed a
// static play triangle even while that exact line was the one actively
// playing, with nothing to show that clicking it again would stop it.
// Confirmed live as the reported "no way to stop it" complaint.
document.querySelectorAll('.reh-action-play-btn').forEach(btn => {
const icon = btn.querySelector('.mdi');
if (!icon) return;
const isActive = rehState.playing && parseInt(btn.dataset.index) === i;
icon.className = isActive ? 'mdi mdi-stop' : 'mdi mdi-play';
btn.title = isActive ? 'Stop' : 'Play or pause this line';
});
const active = document.querySelector(`[data-index="${i}"]`);
if (active) active.scrollIntoView({ behavior: 'smooth', block: 'center' });
updatePlayBtn();
}
function updatePlayBtn() {
const btn = $('reh-tb-play'); if (!btn) return;
btn.innerHTML = rehState.playing ? '<span class="mdi mdi-pause"></span>' : '<span class="mdi mdi-play"></span>';
btn.title = rehState.playing ? 'Pause' : 'Play all';
}
// ── Persistent per-paragraph audio cache ────────────────────────────────────
//
// rehState.synthCache only ever lived in the browser tab's memory — closing
// the tab, reloading, or a crash threw away everything "Synth all" had
// already paid GPU time for, forcing a full re-synthesis (and the pause
// between paragraphs that comes with it) the next time regardless. This
// persists each line's audio to disk, keyed by a hash of exactly what
// determines its sound (text + voice + tone/instruct) rather than its
// position in the script. An untouched paragraph's key never changes, so it
// keeps reusing the same cached file indefinitely; an edited paragraph's
// key changes the instant the text (or voice/tone) does, so it simply never
// matches a cached file again and gets synthesized fresh next time — no
// separate "delete the old file" step needed, the old file just becomes
// unreachable dead weight rather than ever being served again.
async function _lineAudioCacheKey(text, voice, instruct) {
const enc = new TextEncoder().encode(`${text}${voice}${instruct || ''}`);
const digest = await crypto.subtle.digest('SHA-256', enc);
return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 32);
}
function _lineAudioBookName() {
return $('reh-script-title')?.value.trim() || 'Untitled';
}
async function _lineAudioCacheGet(book, key) {
try {
const r = await fetch(`/api/line-audio/${encodeURIComponent(book)}/${key}`);
if (!r.ok) return null;
return await r.blob();
} catch (_) { return null; }
}
function _lineAudioCachePut(book, key, blob) {
// Fire-and-forget — a failed save just means this line resynthesizes next
// time too, no worse than before this cache existed at all.
fetch(`/api/line-audio/${encodeURIComponent(book)}/${key}`, { method: 'POST', body: blob }).catch(() => {});
}
// Every line's "pre-synthesized" green dot only ever reflected
// rehState.synthCache — this browser tab's own memory, empty on every fresh
// page load — so a script that was fully "Synth all"-ed and safely
// persisted to disk in an EARLIER session still looked completely
// unsynthesized after a reload, with no way to tell the cached audio was
// actually right there. This asks the server, in one batch request, which
// of the current script's lines already have a matching file, and lights
// up their dots — without downloading any actual audio (that still only
// happens lazily, right when a line is about to play).
let _lineAudioSyncedFor = null; // `${book}::${lineCount}` — avoid re-running the same scan on every small re-render
async function _lineAudioSyncDots() {
const book = _lineAudioBookName();
const scanId = `${book}::${rehState.lines.length}`;
if (_lineAudioSyncedFor === scanId) return;
_lineAudioSyncedFor = scanId;
_ensureNarrator();
const idxToKey = new Map();
for (let i = 0; i < rehState.lines.length; i++) {
const line = rehState.lines[i];
if (line.ignored || line.hidden) continue;
if (rehState.synthCache.has(i)) continue; // already known-good this session
let voice, instruct, text;
if (line.type === 'dialog') {
const c = rehState.cast[line.speaker];
if (!c || !c.voice || c.voice === 'me') continue;
voice = c.voice;
instruct = _buildInstruct(c.instruct, line.emotion, c.voice);
text = _rehInlineTone(stripMarkdown(line.text), line.emotion);
} else {
if (!rehState.narratorVoice || !(line.text || '').trim()) continue;
voice = rehState.narratorVoice; instruct = '';
text = stripMarkdown(line.text);
}
idxToKey.set(i, await _lineAudioCacheKey(text, voice, instruct));
}
if (!idxToKey.size) return;
const keyToIdx = new Map([...idxToKey].map(([i, k]) => [k, i]));
try {
const r = await fetch(`/api/line-audio/${encodeURIComponent(book)}/check`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keys: [...idxToKey.values()] }),
});
if (!r.ok) return;
const { existing } = await r.json();
(existing || []).forEach(key => {
const idx = keyToIdx.get(key);
if (idx == null) return;
rehState.staleLines.delete(idx);
_markSynthDot(idx, 'ok');
});
} catch (_) { /* dots just stay as-is; playback still checks the disk cache lazily either way */ }
}
// ── Synthesize All ─────────────────────────────────────────────────────────
async function synthAll() {
if (rehState.synthRunning) return;
if (!rehState.backend) { toast('Select a TTS backend first', 'error'); return; }
_ensureNarrator(); // sync narrator voice from its cast row before synthesizing
const ttsLines = rehState.lines.map((l, i) => ({ line:l, idx:i })).filter(({ line }) => {
if (line.ignored || line.hidden) return false; // bulk-edit: never synth ignored/hidden lines
if (line.type === 'dialog') {
const c = rehState.cast[line.speaker];
return c && c.voice && c.voice !== 'me';
}
// All non-dialog lines with text are narrated when a narrator voice is set
return !!(rehState.narratorVoice && (line.text || '').trim());
});
if (!ttsLines.length) { toast('No TTS lines to synthesize', 'error'); return; }
rehState.synthRunning = true; rehState.synthCancelled = false;
const synthBar = $('reh-synth-bar'), fill = $('reh-synth-fill'), label = $('reh-synth-label');
if (synthBar) synthBar.hidden = false;
const prog = (d, t) => {
if (fill) fill.style.width = (t ? (d/t)*100 : 0) + '%';
if (label) label.textContent = `${d} / ${t} synthesized`;
};
prog(0, ttsLines.length);
let done = 0;
const book = _lineAudioBookName();
// Wrapped in try/finally so a thrown error (e.g. the speaker's cast entry
// got deleted mid-run — see the `!c` guard below) can never leave
// synthRunning stuck true forever, which would silently no-op every future
// "Synthesize All" / "Re-synthesize stale" click with no error shown, same
// failure shape as the character-sheets generation-lock bug fixed earlier.
try {
for (const { line, idx } of ttsLines) {
if (rehState.synthCancelled) break;
let voice, instruct;
if (line.type === 'dialog') {
const c = rehState.cast[line.speaker];
if (!c || !c.voice || c.voice === 'me') { _markSynthDot(idx, null); prog(++done, ttsLines.length); continue; }
voice = c.voice;
instruct = _buildInstruct(c.instruct, line.emotion, c.voice);
} else {
voice = rehState.narratorVoice; instruct = '';
}
_markSynthDot(idx, 'synthesizing');
document.getElementById('reh-syd-' + idx)?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
const toneText = _rehInlineTone(stripMarkdown(line.text), line.emotion);
try {
const cacheKey = await _lineAudioCacheKey(toneText, voice, instruct);
let blob = await _lineAudioCacheGet(book, cacheKey);
if (!blob) {
blob = await fetchTtsPreviewBlob(voice, toneText, 'wav', instruct, _ttsBackendForVoice(voice, rehState.backend));
_lineAudioCachePut(book, cacheKey, blob);
}
rehState.synthCache.set(idx, blob);
rehState.staleLines.delete(idx);
_markSynthDot(idx, 'ok');
_showReSynthBtn(idx, false);
preDecodeBlob(idx, blob); // decode to PCM immediately → zero-latency playback
} catch(_) { _markSynthDot(idx, null); }
prog(++done, ttsLines.length);
}
} finally {
if (synthBar) synthBar.hidden = true;
rehState.synthRunning = false;
}
if (!rehState.synthCancelled) toast(`Pre-synthesized ${done} of ${ttsLines.length} lines — ready for instant playback`, 'success');
}
function _updateStaleBatchBtn() {
const btn = $('reh-tb-resynth-stale');
if (btn) btn.hidden = rehState.staleLines.size === 0;
}
$('reh-tb-synth-all')?.addEventListener('click', () => synthAll());
$('reh-tb-resynth-stale')?.addEventListener('click', async () => {
if (rehState.synthRunning) return;
const stale = [...rehState.staleLines];
if (!stale.length) return;
rehState.synthRunning = true; rehState.synthCancelled = false;
const synthBar = $('reh-synth-bar'), fill = $('reh-synth-fill'), label = $('reh-synth-label');
if (synthBar) synthBar.hidden = false;
let done = 0;
const book = _lineAudioBookName();
try {
for (const idx of stale) {
if (rehState.synthCancelled) break;
const line = rehState.lines[idx];
if (!line || line.type !== 'dialog') { rehState.staleLines.delete(idx); continue; }
const c = rehState.cast[line.speaker];
if (!c || !c.voice || c.voice === 'me') { rehState.staleLines.delete(idx); continue; }
const instruct = [c.instruct||'', line.emotion||''].filter(Boolean).join('. ');
_markSynthDot(idx, 'synthesizing');
const toneText = _rehInlineTone(stripMarkdown(line.text), line.emotion);
try {
const cacheKey = await _lineAudioCacheKey(toneText, c.voice, instruct);
let blob = await _lineAudioCacheGet(book, cacheKey);
if (!blob) {
blob = await fetchTtsPreviewBlob(c.voice, toneText, 'wav', instruct, _ttsBackendForVoice(c.voice, rehState.backend));
_lineAudioCachePut(book, cacheKey, blob);
}
rehState.synthCache.set(idx, blob);
rehState.staleLines.delete(idx);
preDecodeBlob(idx, blob);
_markSynthDot(idx, 'ok');
_showReSynthBtn(idx, false);
} catch(_) { _markSynthDot(idx, 'stale'); }
if (fill) fill.style.width = ((++done / stale.length) * 100) + '%';
if (label) label.textContent = `${done} / ${stale.length} synthesized`;
}
} finally {
if (synthBar) synthBar.hidden = true;
rehState.synthRunning = false;
_updateStaleBatchBtn();
}
if (!rehState.synthCancelled) toast(`Re-synthesized ${done} stale line${done !== 1 ? 's' : ''}`, 'success');
});
$('reh-synth-cancel')?.addEventListener('click', () => { rehState.synthCancelled = true; rehState.synthRunning = false; });
// Editing a paragraph doesn't delete its OLD cached audio file — the write
// path only ever knows the NEW content's hash, not whatever the line used
// to hash to before the edit, so the stale file just sits there unreferenced
// forever. This computes every key the CURRENT script would actually use
// and asks the server to delete anything else on disk for this book.
$('reh-tb-clean-cache')?.addEventListener('click', async () => {
const btn = $('reh-tb-clean-cache');
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Scanning…'; }
try {
_ensureNarrator();
const keep = [];
for (const line of rehState.lines) {
if (line.ignored || line.hidden) continue;
let voice, instruct, text;
if (line.type === 'dialog') {
const c = rehState.cast[line.speaker];
if (!c || !c.voice || c.voice === 'me') continue;
voice = c.voice;
instruct = _buildInstruct(c.instruct, line.emotion, c.voice);
text = _rehInlineTone(stripMarkdown(line.text), line.emotion);
} else {
if (!rehState.narratorVoice || !(line.text || '').trim()) continue;
voice = rehState.narratorVoice; instruct = '';
text = stripMarkdown(line.text);
}
keep.push(await _lineAudioCacheKey(text, voice, instruct));
}
const book = _lineAudioBookName();
const r = await fetch(`/api/line-audio/${encodeURIComponent(book)}/prune`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keep }),
});
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText);
const d = await r.json();
toast(d.deleted ? `Cleaned up ${d.deleted} unused cached audio file${d.deleted !== 1 ? 's' : ''}` : 'Nothing to clean up — every cached file is still in use', 'success');
} catch (e) {
toast('Cache cleanup failed: ' + (e.message || e), 'error');
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = '<span class="mdi mdi-broom"></span> Clean cache'; }
}
});
// ── Recording overlay ───────────────────────────────────────────────────────
function showRecOverlay(line) {
const overlay = $('reh-rec-overlay'); if (!overlay) return;
overlay.hidden = false;
const cue = $('reh-rec-cue'), c = rehState.cast[line.speaker] || { color: '#89b4fa' };
if (cue) cue.innerHTML = `<span class="reh-rec-cue-name" style="color:${c.color}">${escHtml(line.speaker)}</span> — your line:<div class="reh-rec-cue-text">${escHtml(stripMarkdown(line.text))}</div>`;
if ($('reh-rec-preview')) { $('reh-rec-preview').style.display='none'; $('reh-rec-preview').src=''; }
if ($('reh-rec-confirm-row')) $('reh-rec-confirm-row').hidden = true;
if ($('reh-rec-start')) $('reh-rec-start').disabled = false;
if ($('reh-rec-stop')) $('reh-rec-stop').disabled = true;
if ($('reh-rec-time')) $('reh-rec-time').textContent = '0:00';
rehState.lastRecBlob = null;
}
function hideRecOverlay() {
const overlay = $('reh-rec-overlay'); if (overlay) overlay.hidden = true;
stopRehMic();
}
// ── Mic recording ───────────────────────────────────────────────────────────
function rehRenderMeter(level=0, db=-Infinity, clipped=false) {
const meter = $('reh-mic-meter'); if (!meter) return;
if (!meter.children.length) for (let i=0;i<18;i++){const b=document.createElement('div');b.className='bar';meter.appendChild(b);}
const active = Math.round(Math.max(0,Math.min(1,level))*meter.children.length);
[...meter.children].forEach((bar,i)=>{
bar.className='bar'; bar.style.height=(7+Math.min(i,active)*1.55)+'px';
if(i<active){bar.classList.add('on');if(db>-12&&i>11)bar.classList.add('hot');if(clipped&&i>14)bar.classList.add('clip');}
});
const el=$('reh-db-readout');if(el)el.textContent=Number.isFinite(db)?db.toFixed(1)+' dB':'-∞ dB';
}
function rehStartMeter() {
if (!rehState.recAnalyser) return;
if (rehState.recMeterRaf) cancelAnimationFrame(rehState.recMeterRaf);
const data=new Float32Array(rehState.recAnalyser.fftSize), canvas=$('reh-live-wave'), RING=300, ADD=10;
rehState.recWaveRing = new Float32Array(RING);
const tick=()=>{
rehState.recAnalyser.getFloatTimeDomainData(data);
let sum=0,peak=0; for(const s of data){sum+=s*s;peak=Math.max(peak,Math.abs(s));}
const rms=Math.sqrt(sum/data.length), db=rms>0?20*Math.log10(rms):-Infinity;
rehRenderMeter((db+60)/60,db,peak>0.98);
if(canvas&&rehState.recWaveRing){
const ring=rehState.recWaveRing; ring.copyWithin(0,ADD);
for(let i=0;i<ADD;i++) ring[RING-ADD+i]=data[Math.floor(i*data.length/ADD)];
const ctx=canvas.getContext('2d'),w=canvas.width,h=canvas.height;
ctx.clearRect(0,0,w,h); ctx.beginPath();
ctx.strokeStyle=peak>0.98?'#f38ba8':db>-12?'#f9e2af':'#a6e3a1'; ctx.lineWidth=1.5;
const mid=h/2;
for(let i=0;i<RING;i++){const x=(i/RING)*w,y=mid-ring[i]*mid*0.85;i===0?ctx.moveTo(x,y):ctx.lineTo(x,y);}
ctx.stroke();
}
rehState.recMeterRaf=requestAnimationFrame(tick);
};
tick();
}
async function startRehMic() {
if (rehState.recDestStream) return;
const AudioCtx = window.AudioContext || window.webkitAudioContext;
rehState.recStream = await requestMicrophoneStream({ raw: true });
if (AudioCtx) {
rehState.recAudioCtx = new AudioCtx();
rehState.recSourceNode = rehState.recAudioCtx.createMediaStreamSource(rehState.recStream);
rehState.recGainNode = rehState.recAudioCtx.createGain();
rehState.recAnalyser = rehState.recAudioCtx.createAnalyser();
rehState.recAnalyser.fftSize = 1024;
const dest = rehState.recAudioCtx.createMediaStreamDestination();
rehState.recSourceNode.connect(rehState.recGainNode);
rehState.recGainNode.connect(rehState.recAnalyser);
rehState.recGainNode.connect(dest);
rehState.recDestStream = dest.stream;
rehStartMeter();
} else { rehState.recDestStream = rehState.recStream; }
}
function stopRehMic() {
if (rehState.recMeterRaf) cancelAnimationFrame(rehState.recMeterRaf);
rehState.recMeterRaf = null;
[rehState.recSourceNode,rehState.recGainNode,rehState.recAnalyser].forEach(n=>{try{if(n)n.disconnect();}catch(_){}});
if (rehState.recStream) rehState.recStream.getTracks().forEach(t=>t.stop());
if (rehState.recDestStream) rehState.recDestStream.getTracks().forEach(t=>t.stop());
if (rehState.recAudioCtx) rehState.recAudioCtx.close().catch(()=>{});
Object.assign(rehState,{recStream:null,recDestStream:null,recSourceNode:null,recGainNode:null,recAnalyser:null,recAudioCtx:null,recWaveRing:null});
rehRenderMeter();
const wc=$('reh-live-wave');if(wc)wc.getContext('2d').clearRect(0,0,wc.width,wc.height);
}
$('reh-rec-start')?.addEventListener('click', async () => {
try {
await startRehMic();
rehState.recChunks=[]; rehState.recSecs=0;
if($('reh-rec-time'))$('reh-rec-time').textContent='0:00';
if($('reh-rec-start'))$('reh-rec-start').disabled=true;
if($('reh-rec-stop'))$('reh-rec-stop').disabled=false;
if($('reh-rec-confirm-row'))$('reh-rec-confirm-row').hidden=true;
rehState.recTimer=setInterval(()=>{
rehState.recSecs++;
if($('reh-rec-time'))$('reh-rec-time').textContent=Math.floor(rehState.recSecs/60)+':'+String(rehState.recSecs%60).padStart(2,'0');
},1000);
rehState.mediaRec=new MediaRecorder(rehState.recDestStream||rehState.recStream,{audioBitsPerSecond:256000});
rehState.mediaRec.ondataavailable=e=>{if(e.data.size)rehState.recChunks.push(e.data);};
rehState.mediaRec.onstop=()=>{
clearInterval(rehState.recTimer);
if($('reh-rec-start'))$('reh-rec-start').disabled=false;
if($('reh-rec-stop'))$('reh-rec-stop').disabled=true;
const blob=new Blob(rehState.recChunks,{type:rehState.mediaRec.mimeType||'audio/webm'});
const url=URL.createObjectURL(blob);
const p=$('reh-rec-preview');if(p){p.src=url;p.style.display='';}
if($('reh-rec-confirm-row'))$('reh-rec-confirm-row').hidden=false;
rehState.lastRecBlob=blob;
};
rehState.mediaRec.start(100);
} catch(e){stopRehMic();toast(await microphoneErrorMessage(e),'error');}
});
$('reh-rec-stop')?.addEventListener('click', () => { if(rehState.mediaRec?.state!=='inactive')rehState.mediaRec.stop(); });
$('reh-rec-keep')?.addEventListener('click', () => {
if (rehState.lastRecBlob)
rehState.clips.push({lineIndex:rehState.lineIndex, speaker:rehState.lines[rehState.lineIndex]?.speaker, type:'me', blob:rehState.lastRecBlob});
stopRehMic(); hideRecOverlay();
rehState.lineIndex++;
startPlay();
});
$('reh-rec-redo')?.addEventListener('click', () => { stopRehMic(); const l=rehState.lines[rehState.lineIndex]; if(l)showRecOverlay(l); });
$('reh-skip-line')?.addEventListener('click', () => {
rehState.clips.push({lineIndex:rehState.lineIndex, speaker:rehState.lines[rehState.lineIndex]?.speaker, type:'skip'});
stopRehMic(); hideRecOverlay();
rehState.lineIndex++;
startPlay();
});
// ── Phase 4: Summary ────────────────────────────────────────────────────────
function renderSummary() {
const list = $('reh-summary-list'); if (!list) return;
if (!rehState.clips.length) { list.innerHTML='<p class="note">No clips in this session.</p>'; return; }
list.innerHTML = rehState.clips.map((clip,idx)=>{
const line=rehState.lines[clip.lineIndex]||{text:'—',speaker:clip.speaker};
const c=rehState.cast[clip.speaker]||{color:'#89b4fa'};
const typeLabel=clip.type==='me'?'🎤 Recorded':clip.type==='tts'?'🔊 Synthesized':'⏭ Skipped';
const audioHtml=clip.blob?`<audio controls src="${URL.createObjectURL(clip.blob)}" style="width:100%;max-width:320px;margin-top:4px"></audio>`:'';
const dlHtml=clip.blob?`<a class="btn-secondary" style="text-decoration:none;flex-shrink:0" download="line_${idx+1}_${clip.speaker}.webm" href="${URL.createObjectURL(clip.blob)}"><span class="mdi mdi-download"></span></a>`:'';
return `<div class="reh-summary-row">
<span style="width:10px;height:10px;border-radius:50%;background:${c.color};flex-shrink:0;margin-top:4px"></span>
<div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:13px;color:${c.color}">${escHtml(clip.speaker||'—')} <span style="font-weight:400;color:var(--subtext)">${typeLabel}</span></div>
<div style="font-size:13px;line-height:1.4;margin-top:2px">${escHtml(stripMarkdown(line.text))}</div>
${audioHtml}
</div>
${dlHtml}
</div>`;
}).join('');
}
$('reh-new-session-btn')?.addEventListener('click', () => {
stopPlay(); stopRehMic();
rehState.lines=[]; rehState.cast={}; rehState.clips=[]; rehState.lineIndex=0;
rehState.savedId=null; rehState.synthCache.clear(); rehDecodedBuffers.clear(); rehState.narratorVoice='';
rehState.practiceStart=null; rehState.practiceEnd=null;
if($('reh-script-text'))$('reh-script-text').value='';
if($('reh-script-title'))$('reh-script-title').value='';
renderLibraryList(); showPhase(1);
});
$('reh-resume-btn')?.addEventListener('click', ()=>{showPhase(3);highlightCurrentLine();});
$('reh-save-session-btn')?.addEventListener('click', saveToLibrary);
$('reh-cast-save-btn')?.addEventListener('click', saveToLibrary);
$('reh-export-session-btn')?.addEventListener('click', exportToFile);
$('reh-tb-save')?.addEventListener('click', saveToLibrary);
$('reh-tb-export')?.addEventListener('click', exportToFile);
$('reh-lib-import-file')?.addEventListener('change', async function(){
const f=this.files?.[0]; if(!f)return;
await importFromFile(f); this.value='';
});
// ── Import/Export panel wiring ─────────────────────────────────────────────
document.querySelectorAll('.reh-impex-file').forEach(inp => {
inp.addEventListener('change', async function () {
const f = this.files?.[0]; if (!f) return;
await importFromFile(f);
this.value = '';
// After import, return to Library
navRehearserPhase(1);
});
});
$('reh-impex-reh-btn')?.addEventListener('click', exportToFile);
$('reh-impex-fountain-btn')?.addEventListener('click', () => { $('reh-fountain-btn')?.click(); });
$('reh-impex-fdx-btn')?.addEventListener('click', () => { $('reh-fdx-export-btn')?.click(); });
$('reh-impex-osf-btn')?.addEventListener('click', () => { $('reh-osf-export-btn')?.click(); });
// ── Plain-text + Markdown export ───────────────────────────────────────────
function _downloadText(content, filename) {
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([content], { type: 'text/plain;charset=utf-8' }));
a.download = filename; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 3000);
}
function exportAsTxt() {
const title = $('reh-script-title')?.value.trim() || $('reh-page-title')?.textContent || 'script';
_downloadText(linesToScriptText(), title.replace(/[^a-z0-9]/gi, '_') + '.txt');
}
function exportAsMd() {
const title = $('reh-script-title')?.value.trim() || 'Script';
const lines = rehState.lines.map(l => {
switch (l.type) {
case 'act': return `\n# ${l.text}\n`;
case 'scene': return `\n## ${l.text}\n`;
case 'transition': return `\n*${l.text}*\n`;
case 'direction': return `*(${l.text})*`;
case 'action': return `\n${l.text}\n`;
case 'dialog': return `\n**${l.speaker}**\n${l.text}\n`;
default: return '';
}
}).join('\n');
_downloadText(`# ${title}\n\n${lines.trim()}\n`, title.replace(/[^a-z0-9]/gi, '_') + '.md');
}
$('reh-impex-txt-btn')?.addEventListener('click', exportAsTxt);
$('reh-impex-md-btn')?.addEventListener('click', exportAsMd);
// ── IMSDb / URL import ─────────────────────────────────────────────────────
(function initUrlImport() {
const inp = $('reh-impex-url');
const btn = $('reh-impex-url-btn');
const status = $('reh-impex-url-status');
if (!inp || !btn) return;
async function fetchUrl() {
const url = inp.value.trim();
if (!url) { toast('Paste a URL first', 'error'); return; }
btn.disabled = true;
if (status) { status.textContent = 'Fetching…'; status.style.color = ''; }
try {
const r = await fetch('/api/fetch-web-script', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
// Put script into the textarea and set title
const ta = $('reh-script-text');
if (ta) ta.value = d.text;
const titleInp = $('reh-script-title');
if (titleInp && d.title) titleInp.value = d.title.replace(/-/g,' ').replace(/\b\w/g,c=>c.toUpperCase());
if (status) {
status.textContent = `✓ Fetched ${(d.chars/1000).toFixed(0)} K chars — scroll down to Parse & cast`;
status.style.color = 'var(--green)';
}
// Navigate to Library phase so user sees the script text area
navRehearserPhase(1);
// Scroll the script textarea into view
setTimeout(() => ta?.scrollIntoView({ behavior:'smooth', block:'nearest' }), 300);
toast(`Fetched "${d.title}" — click "Parse & cast" to continue`, 'success');
} catch(e) {
if (status) { status.textContent = '✗ ' + e.message; status.style.color = 'var(--red)'; }
toast('Fetch failed: ' + e.message, 'error');
} finally { btn.disabled = false; }
}
btn.addEventListener('click', fetchUrl);
inp.addEventListener('keydown', e => { if (e.key === 'Enter') fetchUrl(); });
})();
// ── IMSDb library browser (poster grid) ────────────────────────────────────
(function initImsdbBrowser() {
const modal = $('reh-imsdb-modal');
const grid = $('reh-imsdb-grid');
const search = $('reh-imsdb-search');
const countEl = $('reh-imsdb-count');
const closeBtn = $('reh-imsdb-close');
const coverBtn = $('reh-imsdb-cover-btn');
const listBtn = $('reh-imsdb-list-btn');
const urlInp = $('reh-imsdb-url');
const urlBtn = $('reh-imsdb-url-fetch');
if (!modal || !grid) return;
const CACHE_KEY = 'reh-imsdb-cat-v1';
const CACHE_TTL = 6 * 3600 * 1000;
let _catalogue = null;
let _posterObserver = null;
let _view = localStorage.getItem('reh-imsdb-view') || 'list'; // 'cover' | 'list'
function _applyView() {
grid.classList.toggle('list-view', _view === 'list');
if (coverBtn) coverBtn.classList.toggle('active', _view === 'cover');
if (listBtn) listBtn.classList.toggle('active', _view === 'list');
}
function _ensurePosterObserver() {
if (_posterObserver) return _posterObserver;
_posterObserver = new IntersectionObserver((entries) => {
entries.forEach(async (ent) => {
if (!ent.isIntersecting) return;
const card = ent.target;
_posterObserver.unobserve(card);
const title = card.dataset.title;
try {
const d = await fetch('/api/movie-poster?title=' + encodeURIComponent(title)).then(r => r.json());
if (d.poster) {
const img = document.createElement('img');
img.className = 'reh-imsdb-poster';
img.loading = 'lazy'; img.src = d.poster; img.alt = title;
img.onload = () => {
const wrap = card.querySelector('.reh-imsdb-poster-wrap');
if (wrap) {
wrap.querySelector('.reh-imsdb-fallback')?.remove();
wrap.appendChild(img);
}
};
}
if (d.year) {
card.querySelectorAll('.reh-imsdb-year').forEach(y => { y.textContent = d.year; });
}
} catch(_) {}
});
}, { root: grid, rootMargin: '300px' });
return _posterObserver;
}
function _bookColor(title) {
let h = 0; for (let i = 0; i < title.length; i++) h = (h * 31 + title.charCodeAt(i)) >>> 0;
return `hsl(${h % 360}, 45%, 42%)`;
}
function renderGrid(items) {
const obs = _ensurePosterObserver();
grid.innerHTML = '';
if (!items.length) { grid.innerHTML = '<div class="reh-imsdb-loading">No matches.</div>'; return; }
const frag = document.createDocumentFragment();
items.slice(0, 400).forEach(it => {
const card = document.createElement('div');
card.className = 'reh-imsdb-card';
card.dataset.title = it.title;
card.dataset.url = it.fetch_url;
const color1 = _bookColor(it.title);
const color2 = _bookColor(it.title + '_');
card.innerHTML = `
<div class="reh-imsdb-poster-wrap">
<div class="reh-imsdb-fallback" style="background:linear-gradient(160deg, ${color1}, ${color2})">
<span class="mdi mdi-movie-roll"></span>
<span class="reh-imsdb-fallback-title">${escHtml(it.title)}</span>
</div>
</div>
<div class="reh-imsdb-meta">
<span class="reh-imsdb-title">${escHtml(it.title)}</span>
<span class="reh-imsdb-year"></span>
</div>`;
card.addEventListener('click', () => importImsdb(it));
frag.appendChild(card);
obs.observe(card);
});
grid.appendChild(frag);
if (countEl) countEl.textContent = `${items.length} script${items.length!==1?'s':''}${items.length>400?' (showing 400)':''}`;
_applyView();
}
async function importAnyWebUrl(url, label = 'script') {
url = String(url || '').trim();
if (!url) { toast('Paste a script URL first', 'error'); return; }
if (!/^https?:\/\//i.test(url)) { toast('URL must start with http:// or https://', 'error'); return; }
if (urlBtn) urlBtn.disabled = true;
grid.innerHTML = `<div class="reh-imsdb-loading"><span class="reh-imsdb-spinner"></span> Fetching ${escHtml(label)}…</div>`;
try {
const r = await fetch('/api/fetch-web-script', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
const ta = $('reh-script-text'); if (ta) ta.value = d.text;
const ti = $('reh-script-title'); if (ti) ti.value = d.title || label;
closeModal();
navRehearserPhase(1);
setTimeout(() => ta?.scrollIntoView({ behavior:'smooth', block:'center' }), 250);
toast(`Loaded "${d.title || label}" (${(d.chars/1000).toFixed(0)} K) — click "Parse & cast"`, 'success');
} catch(e) {
grid.innerHTML = `<div class="reh-imsdb-load-error">Fetch failed: ${escHtml(e.message)}</div>`;
if (_catalogue) renderGrid(_searchFilter(_catalogue));
toast('Script fetch failed: ' + e.message, 'error');
} finally {
if (urlBtn) urlBtn.disabled = false;
}
}
async function importImsdb(it) {
await importAnyWebUrl(it.fetch_url, it.title || 'IMSDb script');
}
async function _loadCatalogue() {
// Try localStorage cache first
try {
const raw = localStorage.getItem(CACHE_KEY);
if (raw) {
const cached = JSON.parse(raw);
if (Date.now() - cached.ts < CACHE_TTL && Array.isArray(cached.items) && cached.items.length) {
_catalogue = cached.items;
return;
}
}
} catch(_) {}
// Fetch from server
const d = await fetch('/api/imsdb/list').then(r => r.json());
_catalogue = d.items || [];
try {
localStorage.setItem(CACHE_KEY, JSON.stringify({ ts: Date.now(), items: _catalogue }));
} catch(_) {}
}
async function openModal() {
modal.hidden = false;
if (urlInp) urlInp.value = '';
_applyView();
if (!_catalogue) {
grid.innerHTML = '<div class="reh-imsdb-loading"><span class="reh-imsdb-spinner"></span> Loading catalogue…</div>';
try {
await _loadCatalogue();
} catch(e) {
grid.innerHTML = `<div class="reh-imsdb-loading">Failed to load: ${escHtml(e.message)}</div>`;
return;
}
}
renderGrid(_searchFilter(_catalogue));
search?.focus();
}
function closeModal() { modal.hidden = true; }
function _searchFilter(items) {
const q = search?.value.trim().toLowerCase() || '';
return q ? items.filter(it => it.title.toLowerCase().includes(q)) : items;
}
let _searchTimer = null;
search?.addEventListener('input', () => {
clearTimeout(_searchTimer);
_searchTimer = setTimeout(() => {
if (!_catalogue) return;
renderGrid(_searchFilter(_catalogue));
}, 150);
});
search?.addEventListener('keydown', e => {
if (e.key === 'Enter' && /^https?:\/\//i.test(search.value.trim())) {
e.preventDefault();
importAnyWebUrl(search.value.trim(), 'pasted URL');
}
});
urlBtn?.addEventListener('click', () => importAnyWebUrl(urlInp?.value || '', 'pasted URL'));
urlInp?.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); importAnyWebUrl(urlInp.value, 'pasted URL'); } });
coverBtn?.addEventListener('click', () => {
_view = 'cover'; localStorage.setItem('reh-imsdb-view', _view); _applyView();
});
listBtn?.addEventListener('click', () => {
_view = 'list'; localStorage.setItem('reh-imsdb-view', _view); _applyView();
});
// Wire all open buttons (Library tab + Import/Export tab)
[$('reh-browse-imsdb-btn'), $('reh-browse-imsdb-btn-impex')].forEach(btn => {
btn?.addEventListener('click', openModal);
});
closeBtn?.addEventListener('click', closeModal);
modal.addEventListener('click', e => { if (e.target === modal) closeModal(); });
})();
// ── Train Mode ────────────────────────────────────────────────────────────
// State machine: idle → playing_cue → ready → recording → transcribing → done
// Each "turn" = one set of cue lines + one "me" line the user must speak.
const trainState = {
seq: [], // [{cueLines:[{index,speaker,text,voice,instruct}], myLine:{index,speaker,text}}]
turn: 0, // current turn index
phase: 'idle', // idle | playing_cue | ready | recording | transcribing | done
cueSource: null, // AudioBufferSourceNode currently playing
mediaRec: null, // MediaRecorder for train recording
recChunks: [],
recRaf: null, // rAF id for meter
recStream: null, // MediaStream
recCtx: null, // AudioContext for meter
recAnalyser: null,
recTimer: null,
recSecs: 0,
};
function _trainNormWords(str) {
return (str || '').toLowerCase().replace(/[^a-zäöüàáâãèéêëìíîïòóôõùúûüýÿæœßа-яёА-ЯЁ0-9\s]/g, '').trim().split(/\s+/).filter(Boolean);
}
function _trainLcs(a, b) {
const R = a.length, C = b.length;
const dp = Array.from({length: R + 1}, () => new Int16Array(C + 1));
for (let i = 1; i <= R; i++) for (let j = 1; j <= C; j++)
dp[i][j] = a[i-1] === b[j-1] ? dp[i-1][j-1] + 1 : Math.max(dp[i-1][j], dp[i][j-1]);
let i = R, j = C; const seq = [];
while (i > 0 && j > 0) {
if (a[i-1] === b[j-1]) { seq.unshift([i-1, j-1]); i--; j--; }
else if (dp[i-1][j] >= dp[i][j-1]) i--;
else j--;
}
return seq;
}
function _trainCompare(expected, actual) {
const ew = _trainNormWords(expected), aw = _trainNormWords(actual);
if (!ew.length) return { html_exp: '', html_act: '', score: 1 };
const pairs = _trainLcs(ew, aw);
const matchedE = new Set(pairs.map(p => p[0]));
const matchedA = new Set(pairs.map(p => p[1]));
const expHtml = ew.map((w, i) => matchedE.has(i)
? `<span class="tw-ok">${escHtml(w)}</span>`
: `<span class="tw-miss">${escHtml(w)}</span>`).join(' ');
const actHtml = aw.map((w, i) => matchedA.has(i)
? `<span class="tw-ok">${escHtml(w)}</span>`
: `<span class="tw-extra">${escHtml(w)}</span>`).join(' ');
const score = ew.length ? Math.round((matchedE.size / ew.length) * 100) : 100;
return { html_exp: expHtml, html_act: actHtml, score };
}
function buildTrainSeq() {
const seq = [];
const lines = rehState.lines;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.type !== 'dialog') continue;
const cast = rehState.cast[line.speaker];
if (!cast || cast.voice !== 'me') continue;
// Gather preceding cue lines (last up to 3 TTS dialog lines going backwards)
const cueLines = [];
for (let j = i - 1; j >= 0 && cueLines.length < 3; j--) {
const prev = lines[j];
if (prev.type === 'scene' || prev.type === 'act') break;
if (prev.type !== 'dialog') continue;
const pc = rehState.cast[prev.speaker];
if (!pc || pc.voice === 'me') break;
cueLines.unshift({ index: j, speaker: prev.speaker, text: prev.text, emotion: prev.emotion || '',
voice: pc.voice, color: pc.color, instruct: _buildInstruct(pc.instruct, prev.emotion, pc.voice),
voiceData: pc.voiceData });
}
seq.push({ cueLines, myLine: { index: i, speaker: line.speaker, text: line.text, color: cast.color } });
}
return seq;
}
function _trainSetState(phase) {
trainState.phase = phase;
const stateEl = $('reh-train-rec-state');
const playBtn = $('reh-train-play');
const recBtn = $('reh-train-record');
const stopBtn = $('reh-train-stop-rec');
const cueState = $('reh-train-cue-state');
const map = {
idle: { state:'Read the cue above · press ▶ to hear it', play:true, rec:false, stop:false },
playing_cue: { state:'Playing cue…', play:false, rec:false, stop:false },
ready: { state:'Press 🎤 to record your line', play:false, rec:true, stop:false },
recording: { state:'Recording…', play:false, rec:false, stop:true },
transcribing: { state:'Transcribing…', play:false, rec:false, stop:false },
done: { state:'Done — press ▶ to replay or ⏭ for next', play:true, rec:true, stop:false },
};
const m = map[phase] || map.idle;
if (stateEl) stateEl.textContent = m.state;
if (cueState && phase === 'playing_cue') { cueState.innerHTML = '<span class="mdi mdi-volume-high" style="color:var(--accent)"></span>'; }
else if (cueState) cueState.innerHTML = '';
if (playBtn) playBtn.disabled = !m.play;
if (recBtn) { recBtn.disabled = !m.rec; recBtn.hidden = !!m.stop; }
if (stopBtn) { stopBtn.disabled = !m.stop; stopBtn.hidden = !m.stop; }
const timerEl = $('reh-train-rec-timer');
const metersEl = $('reh-train-meters');
if (phase === 'recording') {
if (timerEl) timerEl.hidden = false;
if (metersEl) metersEl.hidden = false;
} else if (phase !== 'done') {
if (timerEl) timerEl.hidden = true;
if (metersEl) metersEl.hidden = true;
}
}
async function _trainPlayCueLines(cueLines) {
_trainSetState('playing_cue');
for (const cue of cueLines) {
if (trainState.phase !== 'playing_cue') break;
let blob = rehState.synthCache.get(cue.index);
if (!blob) {
try { blob = await fetchTtsPreviewBlob(cue.voice, _rehInlineTone(stripMarkdown(cue.text), cue.emotion), 'wav', cue.instruct, _ttsBackendForVoice(cue.voice, rehState.backend)); }
catch(e) { toast('Cue TTS failed: ' + e.message, 'error'); break; }
}
if (trainState.phase !== 'playing_cue') break;
await new Promise(resolve => {
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
audio.onended = () => { URL.revokeObjectURL(url); resolve(); };
audio.onerror = () => { URL.revokeObjectURL(url); resolve(); };
trainState.cueAudio = audio;
audio.play().catch(resolve);
});
}
if (trainState.phase === 'playing_cue') _trainSetState('ready');
}
async function _trainTranscribe(blob) {
_trainSetState('transcribing');
try {
const fd = new FormData();
fd.append('file', blob, 'train_rec.webm');
fd.append('backend', _appSettings?.stt_preferred_backend || 'configured');
const r = await fetch('/api/transcribe-bytes', { method:'POST', body:fd });
if (!r.ok) throw new Error(r.statusText);
const d = await r.json();
return (d.text || '').trim();
} catch(e) {
toast('Transcription failed: ' + e.message, 'error');
return '';
}
}
async function trainGoTo(idx) {
// Stop any in-progress recording or playback
if (trainState.cueAudio) { trainState.cueAudio.pause(); trainState.cueAudio = null; }
_trainStopMic();
trainState.turn = Math.max(0, Math.min(idx, trainState.seq.length - 1));
const turn = trainState.seq[trainState.turn];
if (!turn) return;
// Update progress
const progEl = $('reh-train-progress');
if (progEl) progEl.textContent = `Turn ${trainState.turn + 1} / ${trainState.seq.length}`;
// Render cue card — show ALL cue lines so user can read the full context
const cueCard = $('reh-train-cue');
const cueAvEl = $('reh-train-cue-avatar');
const cueSpeaker = $('reh-train-cue-speaker');
const cueText = $('reh-train-cue-text');
if (turn.cueLines.length) {
const lastCue = turn.cueLines[turn.cueLines.length - 1];
if (cueAvEl) cueAvEl.innerHTML = voiceAvatarHtml(lastCue.voice, lastCue.color, 32);
if (cueSpeaker) cueSpeaker.textContent = lastCue.speaker;
// Show all cue lines as a readable block
if (cueText) {
cueText.innerHTML = turn.cueLines.map(c =>
`<div class="reh-train-cue-block">
<span class="reh-train-cue-who" style="color:${c.color}">${escHtml(c.speaker)}</span>
<span class="reh-train-cue-line">${escHtml(c.text)}</span>
</div>`
).join('');
}
cueCard?.classList.remove('no-cue');
} else {
if (cueAvEl) cueAvEl.innerHTML = '<span class="mdi mdi-information-outline" style="font-size:24px;color:var(--subtext)"></span>';
if (cueSpeaker) cueSpeaker.textContent = 'No preceding cue';
if (cueText) cueText.textContent = 'This is the first line — speak when ready.';
cueCard?.classList.add('no-cue');
}
// Render my line card
const mySpeaker = $('reh-train-my-speaker');
const myText = $('reh-train-my-text');
if (mySpeaker) mySpeaker.textContent = turn.myLine.speaker;
if (myText) myText.textContent = turn.myLine.text;
// Hide result from previous turn
const resultEl = $('reh-train-result');
if (resultEl) resultEl.hidden = true;
// Reset audio preview
const prevAudio = $('reh-train-audio-preview');
if (prevAudio) { prevAudio.src = ''; prevAudio.style.display = 'none'; }
// Show the script text immediately — user reads first, then clicks ▶ to hear the cue
_trainSetState(turn.cueLines.length ? 'idle' : 'ready');
}
function _trainStartMeter() {
const meterEl = $('reh-train-meter');
const dbEl = $('reh-train-db');
const waveEl = $('reh-train-wave');
if (!trainState.recAnalyser || !meterEl) return;
const analyser = trainState.recAnalyser;
const fft = new Uint8Array(analyser.frequencyBinCount);
const wCtx = waveEl?.getContext('2d');
const W = waveEl?.width || 300, H = waveEl?.height || 36;
const BARS = 18;
function tick() {
analyser.getByteFrequencyData(fft);
const rms = fft.reduce((s, v) => s + v * v, 0) / fft.length;
const db = rms > 0 ? 20 * Math.log10(Math.sqrt(rms) / 128) : -Infinity;
if (dbEl) dbEl.textContent = isFinite(db) ? db.toFixed(1) + ' dB' : '-∞ dB';
const slots = Array.from({length: BARS}, (_, i) => {
const s = Math.floor(i / BARS * fft.length), e = Math.floor((i+1) / BARS * fft.length);
return fft.slice(s, e).reduce((a, b) => a + b, 0) / (e - s) / 255;
});
meterEl.innerHTML = slots.map(v => {
const h = Math.max(2, Math.round(v * 28));
const col = v > 0.85 ? 'var(--red)' : v > 0.6 ? '#f59e0b' : 'var(--green)';
return `<span style="height:${h}px;background:${col};width:4px;border-radius:2px;display:inline-block;vertical-align:bottom;margin:0 1px"></span>`;
}).join('');
if (wCtx) {
analyser.getByteTimeDomainData(fft);
wCtx.clearRect(0, 0, W, H);
wCtx.beginPath(); wCtx.strokeStyle = 'var(--accent)'; wCtx.lineWidth = 1.5;
fft.forEach((v, i) => { const x = (i / fft.length) * W, y = (v / 255) * H; i ? wCtx.lineTo(x, y) : wCtx.moveTo(x, y); });
wCtx.stroke();
}
trainState.recRaf = requestAnimationFrame(tick);
}
trainState.recRaf = requestAnimationFrame(tick);
}
function _trainStopMic() {
if (trainState.recRaf) { cancelAnimationFrame(trainState.recRaf); trainState.recRaf = null; }
if (trainState.recTimer) { clearInterval(trainState.recTimer); trainState.recTimer = null; }
if (trainState.mediaRec && trainState.mediaRec.state !== 'inactive') {
try { trainState.mediaRec.stop(); } catch(_) {}
}
trainState.mediaRec = null;
if (trainState.recCtx) { try { trainState.recCtx.close(); } catch(_) {} trainState.recCtx = null; }
if (trainState.recStream) { trainState.recStream.getTracks().forEach(t => t.stop()); trainState.recStream = null; }
trainState.recAnalyser = null;
trainState.recChunks = [];
const timerEl = $('reh-train-rec-timer');
if (timerEl) { timerEl.hidden = true; timerEl.textContent = '0:00'; }
const metersEl = $('reh-train-meters');
if (metersEl) metersEl.hidden = true;
}
async function _trainStartRecording() {
if (trainState.phase !== 'ready') return;
try {
const stream = await requestMicrophoneStream({ raw: true });
trainState.recStream = stream;
const ctx = new AudioContext();
trainState.recCtx = ctx;
const src = ctx.createMediaStreamSource(stream);
const analyser = ctx.createAnalyser(); analyser.fftSize = 256;
trainState.recAnalyser = analyser;
const dst = ctx.createMediaStreamDestination();
src.connect(analyser); analyser.connect(dst);
_trainSetState('recording');
_trainStartMeter();
const timerEl = $('reh-train-rec-timer');
if (timerEl) timerEl.hidden = false;
trainState.recSecs = 0;
trainState.recTimer = setInterval(() => {
trainState.recSecs++;
const m = Math.floor(trainState.recSecs / 60), s = trainState.recSecs % 60;
if (timerEl) timerEl.textContent = `${m}:${String(s).padStart(2,'0')}`;
}, 1000);
trainState.recChunks = [];
const mr = new MediaRecorder(dst.stream, { audioBitsPerSecond: 128000 });
trainState.mediaRec = mr;
mr.ondataavailable = e => { if (e.data.size > 0) trainState.recChunks.push(e.data); };
mr.onstop = async () => {
_trainStopMic();
const blob = new Blob(trainState.recChunks, { type: 'audio/webm' });
trainState.recChunks = [];
const url = URL.createObjectURL(blob);
const prevAudio = $('reh-train-audio-preview');
if (prevAudio) { prevAudio.src = url; prevAudio.style.display = ''; }
// Transcribe
const transcript = await _trainTranscribe(blob);
const turn = trainState.seq[trainState.turn];
const { html_exp, html_act, score } = _trainCompare(turn?.myLine?.text || '', transcript);
const resultEl = $('reh-train-result');
const expEl = $('reh-train-expected-text');
const actEl = $('reh-train-actual-text');
const scoreEl = $('reh-train-score');
if (expEl) expEl.innerHTML = html_exp;
if (actEl) actEl.innerHTML = html_act || '<em style="color:var(--subtext)">Nothing detected</em>';
if (scoreEl) {
const col = score >= 90 ? 'var(--green)' : score >= 60 ? '#f59e0b' : 'var(--red)';
scoreEl.innerHTML = `<span style="color:${col};font-weight:700">${score}%</span>`;
}
if (resultEl) resultEl.hidden = false;
_trainSetState('done');
};
mr.start();
} catch(e) {
toast('Microphone error: ' + e.message, 'error');
_trainSetState('ready');
}
}
function _trainStopRecording() {
if (trainState.mediaRec && trainState.mediaRec.state === 'recording') {
trainState.mediaRec.stop();
}
}
function _trainHideStage(hide) {
// Show/hide the script content area and auxiliary bars — transport bar stays visible always
const els = ['.reh-stage-area', '#reh-tts-status-bar', '#reh-rec-overlay', '#reh-synth-bar'];
els.forEach(sel => {
const el = document.querySelector(sel) || document.getElementById(sel.replace('#',''));
if (el) el.style.display = hide ? 'none' : '';
});
}
function enterTrainMode() {
const seq = buildTrainSeq();
if (!seq.length) {
toast('No "I play this" lines found — check "I play this" on at least one character in the Cast tab.', 'error');
return;
}
trainState.seq = seq;
trainState.turn = 0;
trainState.phase = 'idle';
_trainHideStage(true);
const panel = $('reh-train-panel');
if (panel) panel.hidden = false;
trainGoTo(0);
}
function exitTrainMode() {
if (trainState.cueAudio) { trainState.cueAudio.pause(); trainState.cueAudio = null; }
_trainStopMic();
trainState.phase = 'idle';
trainState.seq = [];
_trainHideStage(false);
const panel = $('reh-train-panel');
if (panel) panel.hidden = true;
}
// ── Bulk-edit button wiring ─────────────────────────────────────────────────
$('reh-bulk-toggle')?.addEventListener('click', () => setBulkMode(!rehState.bulkMode));
$('reh-bulk-done')?.addEventListener('click', () => setBulkMode(false));
$('reh-bulk-all')?.addEventListener('click', () => {
// Select every line currently visible in the stage
document.querySelectorAll('#reh-script-lines .reh-bulk-check').forEach(cb => rehState.bulkSel.add(parseInt(cb.dataset.bulk)));
rehState.bulkAnchor = rehState.bulkSel.size ? Math.min(...rehState.bulkSel) : null;
document.querySelectorAll('#reh-script-lines .reh-bulk-check').forEach(cb => _refreshBulkLine(parseInt(cb.dataset.bulk)));
_updateBulkCount();
});
$('reh-bulk-none')?.addEventListener('click', () => {
const had = [...rehState.bulkSel];
rehState.bulkSel.clear();
rehState.bulkAnchor = null;
had.forEach(_refreshBulkLine);
_updateBulkCount();
});
$('reh-bulk-ignore')?.addEventListener('click', () => _bulkApply(l => { l.ignored = true; }));
$('reh-bulk-unignore')?.addEventListener('click', () => _bulkApply(l => { l.ignored = false; }));
$('reh-bulk-hide')?.addEventListener('click', () => _bulkApply(l => { l.hidden = true; }));
$('reh-bulk-delete')?.addEventListener('click', _bulkDelete);
$('reh-bulk-show-hidden')?.addEventListener('change', e => {
rehState.showHidden = e.target.checked;
buildScriptPage();
});
// ── Train button wiring ─────────────────────────────────────────────────────
$('reh-page-mode-btn')?.addEventListener('click', cyclePageMode);
$('reh-train-open-btn')?.addEventListener('click', enterTrainMode);
$('reh-train-exit-btn')?.addEventListener('click', exitTrainMode);
$('reh-train-play')?.addEventListener('click', () => {
const turn = trainState.seq[trainState.turn];
if (!turn) return;
if (trainState.phase === 'playing_cue') return;
if (trainState.cueAudio) { trainState.cueAudio.pause(); trainState.cueAudio = null; }
if (turn.cueLines.length) _trainPlayCueLines(turn.cueLines);
else _trainSetState('ready');
});
$('reh-train-record')?.addEventListener('click', () => {
if (trainState.phase === 'ready' || trainState.phase === 'done') _trainStartRecording();
});
$('reh-train-stop-rec')?.addEventListener('click', _trainStopRecording);
$('reh-train-prev')?.addEventListener('click', () => {
if (trainState.turn > 0) trainGoTo(trainState.turn - 1);
});
$('reh-train-next')?.addEventListener('click', () => {
if (trainState.turn < trainState.seq.length - 1) trainGoTo(trainState.turn + 1);
});
$('reh-train-repeat')?.addEventListener('click', () => {
if (trainState.cueAudio) { trainState.cueAudio.pause(); trainState.cueAudio = null; }
_trainStopMic();
const turn = trainState.seq[trainState.turn];
if (!turn) return;
const resultEl = $('reh-train-result');
if (resultEl) resultEl.hidden = true;
if (turn.cueLines.length) _trainPlayCueLines(turn.cueLines);
else _trainSetState('ready');
});
// ── Init ───────────────────────────────────────────────────────────────────
rehRenderMeter();
_syncPageModeBtn();
renderLibraryList().catch(()=>{});
if($('reh-skip-desc-toggle'))$('reh-skip-desc-toggle').checked=rehState.skipDescriptions;
if (window._rehearserStartImpEx) {
delete window._rehearserStartImpEx;
showRehImpEx();
} else {
const _initPhase = window._rehearserStartPhase || 1;
delete window._rehearserStartPhase;
showPhase(_initPhase);
}