Introduces the new Studio section (Source -> Characters -> Voices -> Perform & Export) that reuses the existing Read Aloud/Library/Script Rehearsal code via DOM reparenting instead of duplicating it, and rolls up a long tail of bugs found while producing a real audiobook through it: umlaut-eating name sanitizers, a voice picker that mispositioned itself and capped results at 60, PDF pagination silently breaking on trimmed \f markers, a race letting stale audio keep playing after a new line was clicked, an alias-overlap bug that could silently redirect a voice/image save onto the wrong character, voice design failing outright during brief TTS backend restarts instead of retrying, sparse cast entries defaulting to English/wrong gender, and a reassigned voice never reaching an already-open Stage session or invalidating its cached audio. Also adds a persistent per-line audio cache, audiobook export browsing/download, and an inline voice-design prompt editor. Full details in CHANGELOG.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
4964 lines
230 KiB
JavaScript
4964 lines
230 KiB
JavaScript
// ── Script Rehearser ──────────────────────────────────────────────────────
|
||
|
||
// ── Constants ─────────────────────────────────────────────────────────────
|
||
const SPEAKER_COLORS = ['#89b4fa','#a6e3a1','#f38ba8','#fab387','#f9e2af','#cba6f7','#89dceb','#74c7ec'];
|
||
|
||
const REH_EMOTIONS = [
|
||
{ value: '', emoji: '😐', label: 'Neutral' },
|
||
{ value: 'happy, cheerful and upbeat', emoji: '😊', label: 'Happy' },
|
||
{ value: 'sad, melancholy, somber', emoji: '😢', label: 'Sad' },
|
||
{ value: 'angry, forceful, aggressive', emoji: '😠', label: 'Angry' },
|
||
{ value: 'whisper, hushed and intimate', emoji: '🤫', label: 'Whisper' },
|
||
{ value: 'excited, enthusiastic, energetic', emoji: '🤩', label: 'Excited' },
|
||
{ value: 'scared, nervous, trembling voice', emoji: '😨', label: 'Scared' },
|
||
{ value: 'sarcastic, dry, ironic delivery', emoji: '😏', label: 'Sarcastic' },
|
||
{ value: 'dramatic, theatrical, intense', emoji: '🎭', label: 'Dramatic' },
|
||
{ value: 'gentle, warm, tender', emoji: '🥰', label: 'Gentle' },
|
||
{ value: 'confused, uncertain, hesitant', emoji: '😕', label: 'Confused' },
|
||
{ value: 'bored, flat, disinterested', emoji: '😑', label: 'Bored' },
|
||
{ value: 'surprised, shocked, astonished', emoji: '😲', label: 'Surprised' },
|
||
{ value: 'confident, authoritative, bold', emoji: '💪', label: 'Confident' },
|
||
{ value: 'mysterious, dark, ominous', emoji: '🌑', label: 'Mysterious' },
|
||
{ value: 'romantic, loving, passionate', emoji: '❤️', label: 'Romantic' },
|
||
{ value: 'playful, teasing, mischievous', emoji: '😈', label: 'Playful' },
|
||
{ value: 'calm, composed, measured', emoji: '🧘', label: 'Calm' },
|
||
{ value: 'commanding, authoritative, military', emoji: '⚔️', label: 'Commanding' },
|
||
{ value: 'grieving, tearful, broken', emoji: '😭', label: 'Grieving' },
|
||
];
|
||
|
||
// Load custom emotions from localStorage
|
||
let rehCustomEmotions = [];
|
||
try { rehCustomEmotions = JSON.parse(localStorage.getItem('reh-custom-emotions') || '[]'); } catch(_) {}
|
||
|
||
function getEmotionInfo(value) {
|
||
if (!value) return { emoji: '', label: 'Pick tone' };
|
||
const all = [...REH_EMOTIONS, ...rehCustomEmotions];
|
||
const found = all.find(e => e.value === value);
|
||
if (found) return { emoji: found.emoji, label: found.label };
|
||
return { emoji: '✨', label: value.length > 14 ? value.slice(0, 13) + '…' : value };
|
||
}
|
||
|
||
// ── Markdown helpers ───────────────────────────────────────────────────────
|
||
|
||
function renderMarkdownInline(text) {
|
||
// Escape HTML first, then apply inline markdown
|
||
let s = escHtml(text);
|
||
s = s.replace(/\*\*([^*\n]+?)\*\*/g, '<strong>$1</strong>');
|
||
s = s.replace(/\*([^*\n]+?)\*/g, '<em>$1</em>');
|
||
s = s.replace(/__([^_\n]+?)__/g, '<u>$1</u>');
|
||
s = s.replace(/~~([^~\n]+?)~~/g, '<del>$1</del>');
|
||
s = s.replace(/==([^=\n]+?)==/g, '<mark class="reh-hl">$1</mark>');
|
||
return s;
|
||
}
|
||
|
||
function stripMarkdown(text) {
|
||
return text
|
||
.replace(/\*\*([^*\n]+?)\*\*/g, '$1')
|
||
.replace(/\*([^*\n]+?)\*/g, '$1')
|
||
.replace(/__([^_\n]+?)__/g, '$1')
|
||
.replace(/~~([^~\n]+?)~~/g, '$1')
|
||
.replace(/==([^=\n]+?)==/g, '$1');
|
||
}
|
||
|
||
// ── State ─────────────────────────────────────────────────────────────────
|
||
const rehState = {
|
||
lines: [],
|
||
cast: {},
|
||
lineIndex: 0,
|
||
clips: [],
|
||
voices: [],
|
||
backend: '',
|
||
playing: false,
|
||
repeat: false,
|
||
savedId: null,
|
||
synthCache: new Map(),
|
||
staleLines: new Set(), // lines that were cached but had their tone changed
|
||
synthCancelled: false,
|
||
synthRunning: false,
|
||
skipDescriptions: true,
|
||
narratorVoice: '',
|
||
practiceStart: null,
|
||
practiceEnd: null,
|
||
bulkMode: false, // bulk-edit (line selection) mode on/off
|
||
bulkSel: new Set(), // indices of currently selected lines
|
||
bulkAnchor: null, // last clicked index for Shift+click range selection
|
||
showHidden: false, // reveal hidden lines (so they can be restored)
|
||
recStream: null, recAudioCtx: null, recAnalyser: null,
|
||
recSourceNode: null, recGainNode: null, recDestStream: null,
|
||
recMeterRaf: null, recWaveRing: null, mediaRec: null,
|
||
recChunks: [], recTimer: null, recSecs: 0, lastRecBlob: null,
|
||
};
|
||
window.rehState = rehState;
|
||
|
||
// ── Library — SQLite via /api/rehearsals ──────────────────────────────────
|
||
|
||
async function rehDbGetAll() {
|
||
const r = await fetch('/api/rehearsals');
|
||
if (!r.ok) throw new Error('rehDbGetAll failed: ' + r.status);
|
||
const d = await r.json();
|
||
return d.rehearsals || [];
|
||
}
|
||
|
||
async function rehDbGetById(id) {
|
||
const r = await fetch('/api/rehearsals/' + id);
|
||
if (r.status === 404) return undefined;
|
||
if (!r.ok) throw new Error('rehDbGetById failed: ' + r.status);
|
||
return r.json();
|
||
}
|
||
|
||
async function rehDbAdd(record) {
|
||
const r = await fetch('/api/rehearsals', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(_rehSanitize(record)),
|
||
});
|
||
if (!r.ok) throw new Error('rehDbAdd failed: ' + r.status);
|
||
const d = await r.json();
|
||
return d.id;
|
||
}
|
||
|
||
async function rehDbPut(record) {
|
||
if (!record.id) { return rehDbAdd(record); }
|
||
const r = await fetch('/api/rehearsals/' + record.id, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(_rehSanitize(record)),
|
||
});
|
||
if (!r.ok) throw new Error('rehDbPut failed: ' + r.status);
|
||
}
|
||
|
||
async function rehDbDelete(id) {
|
||
const r = await fetch('/api/rehearsals/' + id, { method: 'DELETE' });
|
||
if (!r.ok) throw new Error('rehDbDelete failed: ' + r.status);
|
||
}
|
||
|
||
// Strip binary clip blobs before sending — blobs can't be JSON-serialised and
|
||
// are session-only anyway (they live in rehState.clips, not the library record).
|
||
function _rehSanitize(rec) {
|
||
const out = { ...rec };
|
||
if (out.clips) out.clips = (out.clips || []).map(c => ({ lineIndex: c.lineIndex, speaker: c.speaker, type: c.type }));
|
||
return out;
|
||
}
|
||
|
||
// One-time IndexedDB → SQLite migration. Runs silently on page load.
|
||
(async function _rehMigrateIfNeeded() {
|
||
try {
|
||
const serverRecs = await rehDbGetAll();
|
||
if (serverRecs.length > 0) return;
|
||
const idbRecs = await _rehIdbGetAll().catch(() => []);
|
||
if (!idbRecs.length) return;
|
||
const r = await fetch('/api/rehearsals/migrate', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(idbRecs.map(_rehSanitize)),
|
||
});
|
||
if (r.ok) {
|
||
const d = await r.json();
|
||
console.log(`[rehearser] migrated ${d.imported} records from IndexedDB → SQLite`);
|
||
}
|
||
} catch (e) {
|
||
console.warn('[rehearser] migration skipped:', e);
|
||
}
|
||
})();
|
||
|
||
function _rehIdbGetAll() {
|
||
return new Promise((resolve) => {
|
||
const req = indexedDB.open('reh-library', 1);
|
||
req.onerror = () => resolve([]);
|
||
req.onsuccess = e => {
|
||
const db = e.target.result;
|
||
if (!db.objectStoreNames.contains('rehearsals')) { db.close(); resolve([]); return; }
|
||
const all = db.transaction('rehearsals', 'readonly').objectStore('rehearsals').getAll();
|
||
all.onsuccess = ev => { db.close(); resolve(ev.target.result || []); };
|
||
all.onerror = () => { db.close(); resolve([]); };
|
||
};
|
||
});
|
||
}
|
||
|
||
window.rehDbGetById = rehDbGetById;
|
||
window.rehLoadRecord = loadRecord; // expose so audiobook.js can open a record directly
|
||
|
||
// ── Serialization ─────────────────────────────────────────────────────────
|
||
|
||
async function clipsToJson(clips) {
|
||
return Promise.all(clips.map(async c => {
|
||
if (!c.blob) return { lineIndex: c.lineIndex, speaker: c.speaker, type: c.type };
|
||
const ab = await c.blob.arrayBuffer();
|
||
const u8 = new Uint8Array(ab);
|
||
let bin = '';
|
||
const CHUNK = 8192;
|
||
for (let i = 0; i < u8.length; i += CHUNK)
|
||
bin += String.fromCharCode(...u8.subarray(i, i + CHUNK));
|
||
return { lineIndex: c.lineIndex, speaker: c.speaker, type: c.type, mime: c.blob.type, b64: btoa(bin) };
|
||
}));
|
||
}
|
||
|
||
function clipsFromJson(clips) {
|
||
return clips.map(c => {
|
||
if (!c.b64) return c;
|
||
const bin = atob(c.b64);
|
||
const u8 = new Uint8Array(bin.length);
|
||
for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
|
||
return { ...c, blob: new Blob([u8], { type: c.mime || 'audio/webm' }), b64: undefined, mime: undefined };
|
||
});
|
||
}
|
||
|
||
function rehCurrentRecord() {
|
||
const cast = {};
|
||
Object.entries(rehState.cast).forEach(([sp, c]) => {
|
||
cast[sp] = {
|
||
voice: c.voice, color: c.color, instruct: c.instruct || '',
|
||
lang: c.lang || '', gender: c.gender || '', tags: c.tags || '', soul: c.soul || '',
|
||
ignored: !!c.ignored, hidden: !!c.hidden,
|
||
};
|
||
});
|
||
const emotions = {};
|
||
const notes = {};
|
||
const ignored = {};
|
||
const hidden = {};
|
||
rehState.lines.forEach((l, i) => {
|
||
if (l.type === 'dialog' && l.emotion) emotions[i] = l.emotion;
|
||
if (l.type === 'dialog' && l.note) notes[i] = l.note;
|
||
if (l.ignored) ignored[i] = true;
|
||
if (l.hidden) hidden[i] = true;
|
||
});
|
||
return {
|
||
title: $('reh-script-title')?.value.trim() || $('reh-page-title')?.textContent || 'Untitled',
|
||
// Reconstruct from parsed lines so structural edits (deletes) persist; fall back to the
|
||
// raw textarea before the script has been parsed into lines.
|
||
script: rehState.lines.length ? linesToScriptText() : ($('reh-script-text')?.value.trim() || ''),
|
||
cast, emotions, notes, ignored, hidden,
|
||
backend: rehState.backend,
|
||
narratorVoice: rehState.narratorVoice,
|
||
lineIndex: rehState.lineIndex,
|
||
clips: rehState.clips.map(c => ({ lineIndex: c.lineIndex, speaker: c.speaker, type: c.type, blob: c.blob || null })),
|
||
updated: new Date(),
|
||
};
|
||
}
|
||
|
||
// Reconstruct script text from current parsed lines (preserves inline edits)
|
||
function linesToScriptText() {
|
||
return rehState.lines.map(line => {
|
||
switch (line.type) {
|
||
case 'act':
|
||
case 'transition': return '\n' + line.text + '\n';
|
||
case 'scene': return '\n' + line.text + '\n';
|
||
case 'action': return '\n' + line.text + '\n';
|
||
case 'dialog': return '\n' + line.speaker + '\n' + line.text + '\n';
|
||
case 'direction':
|
||
return line.speaker ? line.text + '\n' : '\n' + line.text + '\n';
|
||
case 'pagebreak': return line.page ? `\n\f${line.page}\n` : '\n\f\n';
|
||
default: return '';
|
||
}
|
||
}).join('').trim();
|
||
}
|
||
|
||
async function saveToLibrary() {
|
||
const rec = rehCurrentRecord();
|
||
if (rehState.savedId) {
|
||
rec.id = rehState.savedId;
|
||
if (!rec.created) rec.created = new Date();
|
||
await rehDbPut(rec);
|
||
} else {
|
||
rec.created = new Date();
|
||
const newId = await rehDbAdd(rec);
|
||
rehState.savedId = newId;
|
||
}
|
||
toast('Saved to library', 'success');
|
||
rehState._keepSavedIdOnce = true;
|
||
// Sync cast voice choices into the shared character roster (existing chars only)
|
||
if (typeof castWriteBack === 'function') { try { await castWriteBack(rec.title, rehState.cast); } catch (_) {} }
|
||
await renderLibraryList();
|
||
}
|
||
|
||
// Auto-save a freshly imported/pasted script as its own library entry so it shows up
|
||
// in the Library and can be reopened. Returns the new id (or null on failure).
|
||
async function rehAutoSaveImport(title) {
|
||
const text = ($('reh-script-text')?.value || '').trim();
|
||
if (!text) return null;
|
||
if (title && $('reh-script-title') && !$('reh-script-title').value.trim()) $('reh-script-title').value = title;
|
||
const rec = {
|
||
title: $('reh-script-title')?.value.trim() || title || 'Untitled',
|
||
script: text,
|
||
cast: {}, emotions: {}, notes: {}, ignored: {}, hidden: {},
|
||
backend: rehState.backend || '', narratorVoice: rehState.narratorVoice || '',
|
||
lineIndex: 0, clips: [], created: new Date(), updated: new Date(),
|
||
};
|
||
try {
|
||
const id = await rehDbAdd(rec);
|
||
rehState.savedId = id;
|
||
rehState._keepSavedIdOnce = true; // keep this entry through the next Parse & cast
|
||
await renderLibraryList();
|
||
return id;
|
||
} catch (e) { return null; }
|
||
}
|
||
|
||
async function exportToFile() {
|
||
const rec = rehCurrentRecord();
|
||
rec.version = 1;
|
||
rec.created = rec.created || new Date();
|
||
rec.clips = await clipsToJson(rec.clips);
|
||
const json = JSON.stringify(rec, null, 2);
|
||
const blob = new Blob([json], { type: 'application/json' });
|
||
const a = document.createElement('a');
|
||
const title = (rec.title || 'rehearsal').replace(/[^a-z0-9_\- ]/gi, '_').slice(0, 40);
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = title + '.reh';
|
||
a.click();
|
||
}
|
||
|
||
function exportFountain() {
|
||
const title = $('reh-script-title')?.value.trim() || $('reh-page-title')?.textContent || 'Untitled';
|
||
let out = `Title: ${title}\n\n`;
|
||
rehState.lines.forEach(line => {
|
||
const t = stripMarkdown(line.text);
|
||
switch (line.type) {
|
||
case 'act': out += '\n' + t + '\n'; break;
|
||
case 'scene': out += '\n' + t + '\n'; break;
|
||
case 'action': out += '\n' + t + '\n'; break;
|
||
case 'transition': out += '\n' + t + '\n'; break;
|
||
case 'dialog': out += '\n' + line.speaker + '\n' + t + '\n'; break;
|
||
case 'direction':
|
||
out += (line.speaker ? '' : '\n') + t + '\n'; break;
|
||
}
|
||
});
|
||
const blob = new Blob([out.trim()], { type: 'text/plain' });
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = (title.replace(/[^a-z0-9_\- ]/gi, '_') || 'script') + '.fountain';
|
||
a.click();
|
||
toast('Exported as .fountain', 'success');
|
||
}
|
||
|
||
// ── FDX (Final Draft XML) ──────────────────────────────────────────────────
|
||
|
||
function exportFDX() {
|
||
const title = $('reh-script-title')?.value.trim() || 'Untitled';
|
||
const x = s => (s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
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 & 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 ~20–110 char sentence.
|
||
function _rehPickOneLiner(sp) {
|
||
const isNarr = sp === REH_NARRATOR_KEY;
|
||
const pool = rehState.lines
|
||
.filter(l => isNarr ? (l.type !== 'dialog' && l.type !== 'pagebreak' && !l.ignored) : (l.type === 'dialog' && l.speaker === sp))
|
||
.map(l => ({ text: stripMarkdown(l.text || '').trim(), emotion: l.emotion || '' }))
|
||
.filter(l => l.text.length >= 4);
|
||
if (!pool.length) return null;
|
||
const scored = pool.map(l => {
|
||
const len = l.text.length;
|
||
let score = (len >= 20 && len <= 110) ? 3 : (len < 20 ? 1 : 0);
|
||
if (/[.!?]["']?$/.test(l.text)) score += 1; // complete sentence
|
||
score -= Math.abs(len - 60) / 120; // prefer ~60 chars
|
||
return { l, score };
|
||
}).sort((a, b) => b.score - a.score);
|
||
const best = scored[0].l;
|
||
return { text: best.text.slice(0, 220), emotion: best.emotion };
|
||
}
|
||
|
||
// Synthesize and play a one-liner from this character's lines, in their assigned voice.
|
||
async function _rehPreviewCastLine(sp, btn) {
|
||
const c = rehState.cast[sp];
|
||
if (!c || !c.voice || c.voice === 'me') { toast('Assign a voice first', 'error'); return; }
|
||
const backend = $('reh-backend-select')?.value;
|
||
if (!backend) { toast('Select a TTS backend first', 'error'); return; }
|
||
if (_rehAudBtn === btn && _rehAudEl && !_rehAudEl.paused) { _rehStopAudition(); return; }
|
||
_rehStopAudition();
|
||
const pick = _rehPickOneLiner(sp);
|
||
if (!pick) { toast('No lines to preview yet', 'error'); return; }
|
||
const icon = btn.querySelector('.mdi');
|
||
btn.classList.add('loading'); if (icon) icon.className = 'mdi mdi-loading';
|
||
try {
|
||
const blob = await fetchTtsPreviewBlob(c.voice, _rehInlineTone(pick.text, pick.emotion), 'wav', _buildInstruct(c.instruct, pick.emotion), backend);
|
||
if (!_rehAudEl) { _rehAudEl = new Audio(); _rehAudEl.addEventListener('ended', _rehStopAudition); }
|
||
_rehAudEl.src = URL.createObjectURL(blob);
|
||
await _rehAudEl.play();
|
||
btn.classList.remove('loading'); _rehAudBtn = btn; btn.classList.add('playing');
|
||
if (icon) icon.className = 'mdi mdi-stop';
|
||
} catch (e) {
|
||
btn.classList.remove('loading'); if (icon) icon.className = 'mdi mdi-play';
|
||
toast('Preview failed: ' + (e.message || e), 'error');
|
||
}
|
||
}
|
||
|
||
// Assign an existing library voice to a character (keeps the picker open)
|
||
function _rehAssignLocal(sp, voiceId) {
|
||
const c = rehState.cast[sp]; if (!c) return;
|
||
_rehStopAudition();
|
||
c.voice = voiceId; c.voiceData = getVoiceData(voiceId);
|
||
if (c.online) c.online.picked = c.online.candidates.findIndex(x => x.voice_id === voiceId);
|
||
renderCastList(); if (typeof populateNarratorSelect === 'function') populateNarratorSelect();
|
||
}
|
||
|
||
// Render filtered library voices inside a card's "From library" panel
|
||
function _rehRenderLocalResults(card, sp, q) {
|
||
const box = card.querySelector('.reh-vo-local-results'); if (!box) return;
|
||
const cur = rehState.cast[sp]?.voice;
|
||
const ql = (q || '').trim().toLowerCase();
|
||
const matches = rehState.voices.filter(v => {
|
||
if (!_rehVoiceVisibleId(v, cur)) return false;
|
||
if (v === cur) return false;
|
||
if (!ql) return true;
|
||
const vd = getVoiceData(v);
|
||
return v.toLowerCase().includes(ql) || (vd?.name || '').toLowerCase().includes(ql) || (vd?.group || '').toLowerCase().includes(ql);
|
||
}).slice(0, 40);
|
||
if (!matches.length) { box.innerHTML = `<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 & 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 & skip this character’s lines" aria-label="Ignore this character’s lines"><span class="mdi mdi-eye-off-outline"></span></button>
|
||
<button class="reh-cc-iconbtn${c.hidden?' active':''}" data-act="hide" title="Hide this character’s lines from the script" aria-label="Hide this character’s 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 & 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 & 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.
|
||
function _buildInstruct(voiceProfile, emotion) {
|
||
const p = (voiceProfile || '').trim();
|
||
const e = (emotion || '').trim();
|
||
if (!e && !p) return '';
|
||
if (!e) return p;
|
||
if (!p) return `Speak in a ${e} manner.`;
|
||
return `Speak in a ${e} manner. ${p}`;
|
||
}
|
||
|
||
// Fish-Speech / OpenAudio S2 reads inline [tag] emotion markers straight from the
|
||
// input text (the OpenAI-style `instruct` field is ignored). For fish backends we
|
||
// therefore prepend the tone as a [tag] so per-line tones actually take effect.
|
||
function _rehBackendIsFish() {
|
||
let id = '';
|
||
try { id = (typeof backendById === 'function' && backendById(rehState.backend)?.id) || rehState.backend || ''; }
|
||
catch (_) { id = rehState.backend || ''; }
|
||
return /fish/i.test(id);
|
||
}
|
||
function _rehInlineTone(text, emotion) {
|
||
const e = (emotion || '').trim();
|
||
if (!e || !_rehBackendIsFish()) return text;
|
||
if (/^\s*\[/.test(text)) return text; // already carries an inline [tag]
|
||
return `[${e.toLowerCase()}] ${text}`;
|
||
}
|
||
|
||
// ── Auto-design voices with LLM + Design a Voice ────────────────────────────
|
||
|
||
const REH_LANG_CODE = { English:'EN', German:'DE', French:'FR', Spanish:'ES', Italian:'IT', Auto:'EN' };
|
||
|
||
function rehDefaultLlmUrl() {
|
||
try {
|
||
if (typeof _appSettings !== 'undefined' && _appSettings && _appSettings.llm_url)
|
||
return _appSettings.llm_url;
|
||
} catch(_) {}
|
||
return 'http://localhost:11434/v1';
|
||
}
|
||
|
||
// Collect all LLM endpoints visible in the Language Models section
|
||
function rehCollectLlmEndpoints() {
|
||
const seen = new Set();
|
||
const results = [];
|
||
const add = (url, label) => {
|
||
if (!url) return;
|
||
url = url.trim();
|
||
if (!url || seen.has(url)) return;
|
||
seen.add(url);
|
||
results.push({ url, label: label || url });
|
||
};
|
||
// 1. Active LLM from settings
|
||
add(rehDefaultLlmUrl(), 'Active LLM');
|
||
// 2. All URL inputs in the Language Models section (llm-local-url-inp)
|
||
document.querySelectorAll('.llm-local-url-inp, [data-llm-local-key]').forEach(inp => {
|
||
const v = inp.value?.trim();
|
||
const def = inp.dataset.llmLocalDefault;
|
||
const key = inp.dataset.llmLocalKey || inp.dataset.dcUrlKey || '';
|
||
const card = inp.closest('[class*="llm-local-card"], [class*="llm-local"]');
|
||
const name = card?.querySelector('.llm-local-name')?.textContent?.trim() || key;
|
||
add(v || def, name);
|
||
});
|
||
// 3. The dc-url-inp fields with LLM role
|
||
document.querySelectorAll('.dc-url-inp').forEach(inp => {
|
||
const card = inp.closest('[class*="llm-local-card"]');
|
||
if (!card) return;
|
||
const name = card.querySelector('.llm-local-name')?.textContent?.trim() || '';
|
||
add(inp.value?.trim() || inp.dataset.dcDefault, name);
|
||
});
|
||
// 4. Always include common defaults as suggestions
|
||
const DEFAULTS = [
|
||
['http://localhost:11434/v1', 'Ollama'],
|
||
['http://localhost:8000/v1', 'vLLM'],
|
||
['http://localhost:1234/v1', 'LM Studio'],
|
||
['http://localhost:28080/v1', 'llama-swap'],
|
||
['http://localhost:14000/v1', 'LiteLLM'],
|
||
];
|
||
DEFAULTS.forEach(([u, l]) => add(u, l));
|
||
return results;
|
||
}
|
||
|
||
// Prefill LLM URL + datalist when Phase 2 first shows voices
|
||
function rehInitLlmField() {
|
||
const u = $('reh-llm-url');
|
||
if (!u) return;
|
||
if (!u.value) u.value = rehDefaultLlmUrl();
|
||
// Populate datalist with all known LLM endpoints
|
||
const dl = $('reh-llm-url-list');
|
||
if (dl) {
|
||
dl.innerHTML = rehCollectLlmEndpoints()
|
||
.map(e => `<option value="${e.url.replace(/"/g, '"')}">${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' : '');
|
||
|
||
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);
|
||
_showReSynthBtn(idx, false);
|
||
_markSynthDot(idx, 'synthesizing');
|
||
try {
|
||
const blob = await fetchTtsPreviewBlob(c.voice, _rehInlineTone(stripMarkdown(line.text), line.emotion), 'wav', instruct, rehState.backend);
|
||
rehState.synthCache.set(idx, blob);
|
||
rehState.staleLines.delete(idx);
|
||
preDecodeBlob(idx, blob);
|
||
_markSynthDot(idx, 'ok');
|
||
toast('Re-synthesized line ' + (idx + 1), 'success');
|
||
} catch(e) {
|
||
_markSynthDot(idx, null);
|
||
_showReSynthBtn(idx, true);
|
||
toast('Synthesis failed: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
function selectEmotion(idx, value, anchorBtn) {
|
||
rehState.lines[idx].emotion = value;
|
||
if (rehState.synthCache.has(idx)) {
|
||
rehState.synthCache.delete(idx);
|
||
rehState.staleLines.add(idx);
|
||
_markSynthDot(idx, 'stale');
|
||
_updateStaleBatchBtn();
|
||
}
|
||
_showReSynthBtn(idx, true);
|
||
// Update button
|
||
const info = getEmotionInfo(value);
|
||
anchorBtn.className = 'reh-emo-btn' + (value ? ' has-emotion' : '');
|
||
anchorBtn.innerHTML = `${info.emoji ? info.emoji + ' ' : ''}<span class="reh-emo-label">${escHtml(info.label)}</span> <span class="mdi mdi-chevron-down" style="font-size:9px;opacity:.6"></span>`;
|
||
// Show warning if the active backend doesn't reliably support style
|
||
if (value) _checkToneStyleSupport();
|
||
}
|
||
|
||
function _checkToneStyleSupport() {
|
||
const warn = $('reh-tone-warn'); if (!warn) return;
|
||
const txtEl = $('reh-tone-warn-txt');
|
||
const b = (typeof backendById === 'function') ? backendById(rehState.backend) : null;
|
||
if (!b) { warn.hidden = true; return; }
|
||
const all = (typeof availableTtsBackends === 'function') ? availableTtsBackends() : [];
|
||
const hasTone = rehState.lines.some(l => l.type === 'dialog' && l.emotion);
|
||
|
||
// Two opposite engine trade-offs, surfaced so the user can choose knowingly:
|
||
// • clone backends → consistent character identity, but weak tone control
|
||
// • design backends → strong tone, but a fresh persona each call (voices drift)
|
||
if (_rehBackendIsFish()) {
|
||
// Fish-Speech / OpenAudio S2 honours inline [tag] tones (15 000+ tags) injected per line
|
||
if (txtEl) txtEl.innerHTML = `<strong>${escHtml(b.label)}</strong> keeps each character’s voice consistent <em>and</em> applies tone. Per-line tones are sent as inline <code>[tags]</code> (e.g. <code>[whisper]</code>, <code>[excited]</code>, <code>[laughing]</code>). You can also type a custom tone like <code>[professional broadcast tone]</code> — S2 supports free-form descriptions. <a href="https://huggingface.co/fishaudio/s2-pro" target="_blank" rel="noopener">Fish-Speech S2 ↗</a>`;
|
||
warn.hidden = false;
|
||
} else if (!b.style_aware && hasTone) {
|
||
const styleAware = all.find(x => x.style_aware);
|
||
const suggest = styleAware ? ` Switch to <strong>${escHtml(styleAware.label)}</strong> for reliable tone — but expect each voice to drift between lines.` : '';
|
||
if (txtEl) txtEl.innerHTML = `<strong>${escHtml(b.label)}</strong> keeps each character’s voice consistent but has weak tone control — tone picks may have little effect.${suggest}`;
|
||
warn.hidden = false;
|
||
} else if (b.style_aware && !b.uses_wav) {
|
||
const wavBackend = all.find(x => x.uses_wav);
|
||
const suggest = wavBackend ? ` Switch to <strong>${escHtml(wavBackend.label)}</strong> to keep each character’s voice identical throughout.` : '';
|
||
const qwenHint = /qwen|voice design|custom/i.test((b.id || '') + ' ' + (b.label || '')) ? ' Qwen3TTS tone is sent as the per-line style/instruct text, so this is the right path for directed delivery.' : '';
|
||
if (txtEl) txtEl.innerHTML = `<strong>${escHtml(b.label)}</strong> gives strong tone but re-generates a fresh voice each line, so a character won’t sound the same throughout.${qwenHint}${suggest}`;
|
||
warn.hidden = false;
|
||
} else {
|
||
warn.hidden = true;
|
||
}
|
||
}
|
||
|
||
$('reh-tone-warn-close')?.addEventListener('click', () => { const w = $('reh-tone-warn'); if (w) w.hidden = true; });
|
||
|
||
// ── Transport controls ──────────────────────────────────────────────────────
|
||
|
||
$('reh-tb-play')?.addEventListener('click', () => {
|
||
if (rehState.playing) pausePlay(); else startPlay();
|
||
});
|
||
|
||
$('reh-tb-stop')?.addEventListener('click', () => {
|
||
stopPlay();
|
||
rehState.lineIndex = rehState.practiceStart ?? 0;
|
||
highlightCurrentLine();
|
||
hideRecOverlay();
|
||
});
|
||
|
||
$('reh-tb-prev')?.addEventListener('click', () => {
|
||
stopPlay();
|
||
rehState.lineIndex = Math.max(0, rehState.lineIndex - 1);
|
||
highlightCurrentLine();
|
||
hideRecOverlay();
|
||
});
|
||
|
||
$('reh-tb-next')?.addEventListener('click', () => {
|
||
stopPlay();
|
||
rehState.lineIndex = Math.min(rehState.lines.length - 1, rehState.lineIndex + 1);
|
||
highlightCurrentLine();
|
||
hideRecOverlay();
|
||
});
|
||
|
||
$('reh-tb-repeat')?.addEventListener('click', () => {
|
||
rehState.repeat = !rehState.repeat;
|
||
$('reh-tb-repeat')?.classList.toggle('reh-btn-active', rehState.repeat);
|
||
});
|
||
|
||
$('reh-skip-desc-toggle')?.addEventListener('change', function () { rehState.skipDescriptions = this.checked; });
|
||
|
||
$('reh-edit-script-btn')?.addEventListener('click', openScriptEditorModal);
|
||
$('reh-fountain-btn')?.addEventListener('click', exportFountain);
|
||
$('reh-fountain-export-p4')?.addEventListener('click', exportFountain);
|
||
$('reh-fdx-export-btn')?.addEventListener('click', exportFDX);
|
||
$('reh-osf-export-btn')?.addEventListener('click', exportOSF);
|
||
|
||
$('reh-exit-btn')?.addEventListener('click', () => {
|
||
stopPlay(); stopRehMic();
|
||
if (rehState.clips.length) { renderSummary(); showPhase(4); }
|
||
else showPhase(2);
|
||
});
|
||
|
||
// ── Page title inline editing ───────────────────────────────────────────────
|
||
|
||
$('reh-page-title')?.addEventListener('dblclick', function () {
|
||
this.contentEditable = 'true';
|
||
this.style.outline = '2px solid var(--accent)';
|
||
this.style.borderRadius = '3px';
|
||
this.focus();
|
||
const range = document.createRange();
|
||
range.selectNodeContents(this);
|
||
window.getSelection().removeAllRanges();
|
||
window.getSelection().addRange(range);
|
||
});
|
||
|
||
$('reh-page-title')?.addEventListener('blur', function () {
|
||
if (this.contentEditable === 'true') {
|
||
this.contentEditable = 'false';
|
||
this.style.outline = '';
|
||
const v = this.textContent.trim() || 'Script';
|
||
this.textContent = v;
|
||
if ($('reh-script-title')) $('reh-script-title').value = v;
|
||
}
|
||
});
|
||
|
||
$('reh-page-title')?.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Enter') { e.preventDefault(); this.blur(); }
|
||
if (e.key === 'Escape') { this.textContent = $('reh-script-title')?.value || 'Script'; this.blur(); }
|
||
});
|
||
|
||
// ── WebAudio pre-decode (eliminates 2-3s silence between lines) ─────────────
|
||
|
||
let _rehPlayCtx = null;
|
||
const rehDecodedBuffers = new Map(); // lineIndex → AudioBuffer (pre-decoded PCM)
|
||
let rehCurrentSource = null; // active AudioBufferSourceNode
|
||
let rehWordHighlightRaf = null;
|
||
|
||
function rehPlayCtx() {
|
||
if (!_rehPlayCtx) _rehPlayCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||
if (_rehPlayCtx.state === 'suspended') _rehPlayCtx.resume().catch(() => {});
|
||
return _rehPlayCtx;
|
||
}
|
||
|
||
// Decoded PCM (Float32) is the heaviest cache and is re-derivable from the cached
|
||
// blob, so keep only a sliding window around the playhead. Without this, a long
|
||
// script holds every line's raw PCM in memory at once and crashes mobile Safari.
|
||
const REH_DECODE_WINDOW = 8;
|
||
function _rehEvictDecoded(keepIdx) {
|
||
if (rehDecodedBuffers.size <= REH_DECODE_WINDOW * 2 + 4) return;
|
||
const lo = keepIdx - REH_DECODE_WINDOW, hi = keepIdx + REH_DECODE_WINDOW;
|
||
for (const k of rehDecodedBuffers.keys()) {
|
||
if (k < lo || k > hi) rehDecodedBuffers.delete(k);
|
||
}
|
||
}
|
||
|
||
async function preDecodeBlob(lineIdx, blob) {
|
||
if (rehDecodedBuffers.has(lineIdx)) return;
|
||
try {
|
||
const ab = await blob.arrayBuffer();
|
||
const buf = await rehPlayCtx().decodeAudioData(ab);
|
||
rehDecodedBuffers.set(lineIdx, buf);
|
||
_rehEvictDecoded(lineIdx);
|
||
} catch(_) {}
|
||
}
|
||
|
||
function computeWordTimings(text, durationSec) {
|
||
const words = stripMarkdown(text).split(/\s+/).filter(Boolean);
|
||
if (words.length < 2) return [];
|
||
// Distribute proportionally by character length (longer words = more time)
|
||
const totalChars = words.reduce((s, w) => s + w.length, 0) || 1;
|
||
let t = 0;
|
||
return words.map(w => {
|
||
const start = t;
|
||
t += (w.length / totalChars) * durationSec;
|
||
return { word: w, start, end: t };
|
||
});
|
||
}
|
||
|
||
function stopAudioSource() {
|
||
if (rehCurrentSource) {
|
||
try { rehCurrentSource.stop(0); } catch(_) {}
|
||
rehCurrentSource = null;
|
||
}
|
||
if (rehWordHighlightRaf) { cancelAnimationFrame(rehWordHighlightRaf); rehWordHighlightRaf = null; }
|
||
}
|
||
|
||
// Instant playback from pre-decoded buffer + word-level highlight
|
||
async function playPreDecoded(lineIdx, blob, text) {
|
||
if (!rehDecodedBuffers.has(lineIdx)) await preDecodeBlob(lineIdx, blob);
|
||
_rehEvictDecoded(lineIdx); // keep the window centred on the playhead
|
||
const buf = rehDecodedBuffers.get(lineIdx);
|
||
if (!buf) { await playAudioBlobFallback(blob); return; }
|
||
|
||
const timings = computeWordTimings(text, buf.duration);
|
||
const dialogEl = document.getElementById('reh-diag-' + lineIdx);
|
||
|
||
if (dialogEl && timings.length >= 2) {
|
||
dialogEl.innerHTML = timings.map((t, i) =>
|
||
`<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 only when skip mode is on AND no narrator voice is set.
|
||
// When a narrator voice exists, every line with text gets spoken — never skip.
|
||
if (rehState.skipDescriptions && !rehState.narratorVoice) {
|
||
while (
|
||
rehState.lineIndex < rehState.lines.length &&
|
||
rehState.lines[rehState.lineIndex].type !== 'dialog' &&
|
||
rehState.lines[rehState.lineIndex].type !== 'pagebreak' &&
|
||
!(rehState.practiceEnd !== null && rehState.lineIndex > rehState.practiceEnd)
|
||
) { rehState.lineIndex++; }
|
||
}
|
||
|
||
if (rehState.lineIndex >= rehState.lines.length) {
|
||
rehState.playing = false;
|
||
updatePlayBtn();
|
||
if (rehState.repeat) { rehState.lineIndex = 0; startPlay(); return; }
|
||
toast('Script finished', 'success');
|
||
return;
|
||
}
|
||
|
||
// 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) {
|
||
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', '', 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);
|
||
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, 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} |