Cast: card/list views, sort & filter, online voice picker, "Hear a line" sample button, AI character notes, import auto-save. Platform: WCAG 2.1 AA accessibility pass; German UI translation + language picker; installable PWA with offline shell; GZip + content-visibility virtualization + lazy images + Rehearser PCM memory cap (mobile stability); Playwright suite (desktop + iPhone); opt-in minified bundle build. Fixes: screenplay parser false characters; Fish-Speech inline-tag tones; narrator/voice pickers list full library; clone GUI rework; fish.audio import dedup; voice-ID rename; bulk-delete modal. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4205 lines
192 KiB
JavaScript
4205 lines
192 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
|
||
showHidden: false, // reveal hidden lines (so they can be restored)
|
||
recStream: null, recAudioCtx: null, recAnalyser: null,
|
||
recSourceNode: null, recGainNode: null, recDestStream: null,
|
||
recMeterRaf: null, recWaveRing: null, mediaRec: null,
|
||
recChunks: [], recTimer: null, recSecs: 0, lastRecBlob: null,
|
||
};
|
||
window.rehState = rehState;
|
||
|
||
// ── Library — IndexedDB ────────────────────────────────────────────────────
|
||
|
||
const REH_DB_NAME = 'reh-library';
|
||
const REH_STORE = 'rehearsals';
|
||
|
||
function rehDbOpen() {
|
||
return new Promise((resolve, reject) => {
|
||
const req = indexedDB.open(REH_DB_NAME, 1);
|
||
req.onupgradeneeded = e => {
|
||
const db = e.target.result;
|
||
if (!db.objectStoreNames.contains(REH_STORE)) {
|
||
const store = db.createObjectStore(REH_STORE, { keyPath: 'id', autoIncrement: true });
|
||
store.createIndex('updated', 'updated', { unique: false });
|
||
}
|
||
};
|
||
req.onsuccess = e => resolve(e.target.result);
|
||
req.onerror = e => reject(e.target.error);
|
||
});
|
||
}
|
||
|
||
async function rehDbOp(mode, fn) {
|
||
const db = await rehDbOpen();
|
||
return new Promise((resolve, reject) => {
|
||
const tx = db.transaction(REH_STORE, mode);
|
||
const store = tx.objectStore(REH_STORE);
|
||
const req = fn(store);
|
||
req.onsuccess = e => resolve(e.target.result);
|
||
req.onerror = e => reject(e.target.error);
|
||
});
|
||
}
|
||
|
||
async function rehDbAdd(record) { return rehDbOp('readwrite', s => s.add(record)); }
|
||
async function rehDbPut(record) { return rehDbOp('readwrite', s => s.put(record)); }
|
||
async function rehDbDelete(id) { return rehDbOp('readwrite', s => s.delete(id)); }
|
||
|
||
async function rehDbGetAll() {
|
||
const db = await rehDbOpen();
|
||
return new Promise((resolve, reject) => {
|
||
const tx = db.transaction(REH_STORE, 'readonly');
|
||
const req = tx.objectStore(REH_STORE).getAll();
|
||
req.onsuccess = e => resolve(e.target.result || []);
|
||
req.onerror = e => reject(e.target.error);
|
||
});
|
||
}
|
||
|
||
// ── Serialization ─────────────────────────────────────────────────────────
|
||
|
||
async function clipsToJson(clips) {
|
||
return Promise.all(clips.map(async c => {
|
||
if (!c.blob) return { lineIndex: c.lineIndex, speaker: c.speaker, type: c.type };
|
||
const ab = await c.blob.arrayBuffer();
|
||
const u8 = new Uint8Array(ab);
|
||
let bin = '';
|
||
const CHUNK = 8192;
|
||
for (let i = 0; i < u8.length; i += CHUNK)
|
||
bin += String.fromCharCode(...u8.subarray(i, i + CHUNK));
|
||
return { lineIndex: c.lineIndex, speaker: c.speaker, type: c.type, mime: c.blob.type, b64: btoa(bin) };
|
||
}));
|
||
}
|
||
|
||
function clipsFromJson(clips) {
|
||
return clips.map(c => {
|
||
if (!c.b64) return c;
|
||
const bin = atob(c.b64);
|
||
const u8 = new Uint8Array(bin.length);
|
||
for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
|
||
return { ...c, blob: new Blob([u8], { type: c.mime || 'audio/webm' }), b64: undefined, mime: undefined };
|
||
});
|
||
}
|
||
|
||
function rehCurrentRecord() {
|
||
const cast = {};
|
||
Object.entries(rehState.cast).forEach(([sp, c]) => {
|
||
cast[sp] = {
|
||
voice: c.voice, color: c.color, instruct: c.instruct || '',
|
||
lang: c.lang || '', gender: c.gender || '', tags: c.tags || '', soul: c.soul || '',
|
||
ignored: !!c.ignored, hidden: !!c.hidden,
|
||
};
|
||
});
|
||
const emotions = {};
|
||
const notes = {};
|
||
const ignored = {};
|
||
const hidden = {};
|
||
rehState.lines.forEach((l, i) => {
|
||
if (l.type === 'dialog' && l.emotion) emotions[i] = l.emotion;
|
||
if (l.type === 'dialog' && l.note) notes[i] = l.note;
|
||
if (l.ignored) ignored[i] = true;
|
||
if (l.hidden) hidden[i] = true;
|
||
});
|
||
return {
|
||
title: $('reh-script-title')?.value.trim() || $('reh-page-title')?.textContent || 'Untitled',
|
||
// Reconstruct from parsed lines so structural edits (deletes) persist; fall back to the
|
||
// raw textarea before the script has been parsed into lines.
|
||
script: rehState.lines.length ? linesToScriptText() : ($('reh-script-text')?.value.trim() || ''),
|
||
cast, emotions, notes, ignored, hidden,
|
||
backend: rehState.backend,
|
||
narratorVoice: rehState.narratorVoice,
|
||
lineIndex: rehState.lineIndex,
|
||
clips: rehState.clips.map(c => ({ lineIndex: c.lineIndex, speaker: c.speaker, type: c.type, blob: c.blob || null })),
|
||
updated: new Date(),
|
||
};
|
||
}
|
||
|
||
// Reconstruct script text from current parsed lines (preserves inline edits)
|
||
function linesToScriptText() {
|
||
return rehState.lines.map(line => {
|
||
switch (line.type) {
|
||
case 'act':
|
||
case 'transition': return '\n' + line.text + '\n';
|
||
case 'scene': return '\n' + line.text + '\n';
|
||
case 'action': return '\n' + line.text + '\n';
|
||
case 'dialog': return '\n' + line.speaker + '\n' + line.text + '\n';
|
||
case 'direction':
|
||
return line.speaker ? line.text + '\n' : '\n' + line.text + '\n';
|
||
case 'pagebreak': return '\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;
|
||
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 = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js';
|
||
s.onload = resolve; s.onerror = reject;
|
||
document.head.appendChild(s);
|
||
});
|
||
pdfjsLib.GlobalWorkerOptions.workerSrc =
|
||
'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/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'); blank(); }
|
||
|
||
const pageLns = allLinesByPage[pageIdx] || [];
|
||
for (const ln of pageLns) {
|
||
const t = ln.text.trim();
|
||
if (!t || isNoise(t)) continue;
|
||
|
||
const indent = (ln.x - leftMargin) / pageW; // fraction of page width
|
||
const rightFrac = ln.right / pageW;
|
||
const isCaps = t === t.toUpperCase() && /[A-Z]/.test(t);
|
||
const wordCount = t.split(/\s+/).length;
|
||
|
||
// Scene heading (slug line)
|
||
if (/^(INT|EXT|INT\.\/EXT|EXT\.\/INT|I\/E)[\. ]/i.test(t)) {
|
||
blank(); push(t.toUpperCase().replace(/\s+\d+[A-Z]?\.?$/, '')); blank(); nonAction();
|
||
continue;
|
||
}
|
||
// Act / scene number headings (theatre)
|
||
if (/^(ACT|SCENE)\s+(\d+|[IVXLC]+|ONE|TWO|THREE|FOUR|FIVE)\b/i.test(t)) {
|
||
blank(); push(t.toUpperCase()); blank(); nonAction();
|
||
continue;
|
||
}
|
||
// Transition (right-aligned, caps, ends with TO: / IN / OUT)
|
||
if (isCaps && (rightFrac > 0.72 || indent > 0.45) &&
|
||
/(TO:|CUT|FADE|DISSOLVE|SMASH|MATCH|WIPE|BLACKOUT|INTERMISSION)\b/.test(t)) {
|
||
blank(); push(t); blank(); nonAction();
|
||
continue;
|
||
}
|
||
// Parenthetical
|
||
if (/^\(.*\)?$/.test(t) || (indent > 0.18 && indent < 0.30 && /^\(/.test(t))) {
|
||
push(t.startsWith('(') ? t : '(' + t + ')', false); nonAction();
|
||
continue;
|
||
}
|
||
// Character cue: deeply indented + caps + short (often has (CONT'D)/(V.O.))
|
||
const nameCore = t.replace(/\s*\([^)]*\)\s*$/, '').trim();
|
||
if (indent > 0.22 && isCaps && wordCount <= 6 && nameCore.length <= 40 &&
|
||
/^[A-Z0-9 .'#\-]+$/.test(nameCore)) {
|
||
blank(); push(t.toUpperCase()); prevBlank = false; nonAction(); // name; dialog follows next line
|
||
continue;
|
||
}
|
||
// Dialogue: medium indent (under a character)
|
||
if (indent > 0.10 && indent < 0.34) {
|
||
push(t, false); nonAction();
|
||
continue;
|
||
}
|
||
// Default: action / description at the left margin.
|
||
// Consecutive action lines join into one paragraph (no blank between them);
|
||
// only start a new paragraph when coming from a non-action line.
|
||
if (prevWasAction) {
|
||
out.push(t); // continue same paragraph
|
||
} else {
|
||
blank(); push(t); // new paragraph
|
||
}
|
||
prevWasAction = true;
|
||
} // end per-page line loop
|
||
} // end page loop
|
||
|
||
return out.join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
||
}
|
||
|
||
async function importFromFile(file) {
|
||
try {
|
||
const text = await file.text();
|
||
const data = JSON.parse(text);
|
||
data.clips = clipsFromJson(data.clips || []);
|
||
data.created = data.created ? new Date(data.created) : new Date();
|
||
data.updated = new Date();
|
||
delete data.id;
|
||
const newId = await rehDbAdd(data);
|
||
toast('Imported: ' + (data.title || 'Untitled'), 'success');
|
||
await renderLibraryList();
|
||
loadRecord({ ...data, id: newId });
|
||
} catch(e) { toast('Import failed: ' + e.message, 'error'); }
|
||
}
|
||
|
||
function loadRecord(rec) {
|
||
if ($('reh-script-text')) $('reh-script-text').value = rec.script || '';
|
||
if ($('reh-script-title')) $('reh-script-title').value = rec.title || '';
|
||
rehState.savedId = rec.id || null;
|
||
rehState._keepSavedIdOnce = true; // re-parsing a loaded entry keeps updating it
|
||
rehState.backend = rec.backend || '';
|
||
rehState.lineIndex = rec.lineIndex || 0;
|
||
rehState.clips = (rec.clips || []).map(c => ({ ...c }));
|
||
rehState.synthCache.clear(); rehDecodedBuffers.clear();
|
||
rehState.narratorVoice = rec.narratorVoice || '';
|
||
const lines = parseScript(rec.script || '');
|
||
if (rec.emotions) {
|
||
lines.forEach((l, i) => { if (l.type === 'dialog' && rec.emotions[i]) l.emotion = rec.emotions[i]; });
|
||
}
|
||
if (rec.notes) {
|
||
lines.forEach((l, i) => { if (l.type === 'dialog' && rec.notes[i]) l.note = rec.notes[i]; });
|
||
}
|
||
if (rec.ignored) lines.forEach((l, i) => { if (rec.ignored[i]) l.ignored = true; });
|
||
if (rec.hidden) lines.forEach((l, i) => { if (rec.hidden[i]) l.hidden = true; });
|
||
rehState.lines = lines;
|
||
rehState.cast = {};
|
||
const detected = detectCharacters(lines);
|
||
Object.entries(detected).forEach(([sp, def]) => {
|
||
const saved = rec.cast?.[sp];
|
||
rehState.cast[sp] = {
|
||
voice: saved?.voice ?? def.voice,
|
||
color: saved?.color ?? def.color,
|
||
instruct: saved?.instruct || '',
|
||
lang: saved?.lang || '', gender: saved?.gender || '',
|
||
tags: saved?.tags || '', soul: saved?.soul || '',
|
||
ignored: !!saved?.ignored, hidden: !!saved?.hidden,
|
||
voiceData: saved?.voice && saved.voice !== 'me' ? getVoiceData(saved.voice) : null,
|
||
};
|
||
});
|
||
renderCastList();
|
||
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);
|
||
}
|
||
|
||
// ── Library UI ─────────────────────────────────────────────────────────────
|
||
|
||
// Deterministic cover gradient from the title string
|
||
function bookCover(title) {
|
||
let h = 0;
|
||
for (let i = 0; i < title.length; i++) h = (h * 31 + title.charCodeAt(i)) % 360;
|
||
const h2 = (h + 40) % 360;
|
||
return { c1: `hsl(${h}, 55%, 42%)`, c2: `hsl(${h2}, 58%, 30%)` };
|
||
}
|
||
|
||
let rehLibView = localStorage.getItem('reh-lib-view') || 'shelf';
|
||
|
||
async function renderLibraryList() {
|
||
const list = $('reh-library-list'); if (!list) return;
|
||
let all;
|
||
try { all = await rehDbGetAll(); } catch(e) { all = []; }
|
||
all.sort((a, b) => new Date(b.updated || 0) - new Date(a.updated || 0));
|
||
|
||
list.classList.toggle('list-view', rehLibView === 'list');
|
||
const vt = $('reh-lib-view-toggle');
|
||
if (vt) vt.innerHTML = `<span class="mdi mdi-${rehLibView === 'list' ? 'view-grid' : 'view-list'}"></span>`;
|
||
|
||
if (!all.length) {
|
||
list.innerHTML = '<div class="reh-lib-empty"><span class="mdi mdi-book-open-outline"></span><p>No saved rehearsals yet.<br>Parse a script below or drop a file to start.</p></div>';
|
||
return;
|
||
}
|
||
|
||
list.innerHTML = all.map(rec => {
|
||
const speakers = Object.keys(rec.cast || {});
|
||
const meCount = speakers.filter(sp => rec.cast[sp]?.voice === 'me').length;
|
||
const clipCount = (rec.clips || []).filter(c => c.type === 'me' && c.blob).length;
|
||
const total = parseScript(rec.script || '').filter(l => l.type === 'dialog').length;
|
||
const pct = total ? Math.round(((rec.lineIndex || 0) / total) * 100) : 0;
|
||
const date = rec.updated ? new Date(rec.updated).toLocaleDateString() : '—';
|
||
const isCurrent = rec.id === rehState.savedId;
|
||
const title = rec.title || 'Untitled';
|
||
const { c1, c2 } = bookCover(title);
|
||
const avatars = speakers.slice(0, 5).map(sp => {
|
||
const c = rec.cast[sp];
|
||
return `<span style="background:${c.color}">${sp[0].toUpperCase()}</span>`;
|
||
}).join('');
|
||
return `<div class="reh-book" data-id="${rec.id}" style="--bk1:${c1};--bk2:${c2}" title="${escHtml(title)}">
|
||
${isCurrent ? '<span class="reh-book-active-tag">active</span>' : ''}
|
||
<div class="reh-book-actions">
|
||
<button class="reh-book-act reh-lib-export" data-id="${rec.id}" title="Export .reh"><span class="mdi mdi-export"></span></button>
|
||
<button class="reh-book-act reh-lib-delete" data-id="${rec.id}" title="Delete"><span class="mdi mdi-delete-outline"></span></button>
|
||
</div>
|
||
<div class="reh-book-title">${escHtml(title)}</div>
|
||
<div class="reh-book-meta">
|
||
<div class="reh-book-avatars">${avatars}</div>
|
||
${total} lines · ${speakers.length} cast${meCount ? ' · ' + meCount + ' me' : ''}${clipCount ? ' · ' + clipCount + '🎤' : ''}
|
||
<div class="reh-book-progress"><div style="width:${pct}%"></div></div>
|
||
<div style="margin-top:4px;opacity:.8">${pct}% · ${date}</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
// Click book → open
|
||
list.querySelectorAll('.reh-book').forEach(book => book.addEventListener('click', async e => {
|
||
if (e.target.closest('.reh-book-act')) return;
|
||
const id = parseInt(book.dataset.id);
|
||
const db = await rehDbOpen();
|
||
const rec = await new Promise((res, rej) => { const r = db.transaction(REH_STORE,'readonly').objectStore(REH_STORE).get(id); r.onsuccess=e=>res(e.target.result); r.onerror=e=>rej(e.target.error); });
|
||
loadRecord(rec);
|
||
}));
|
||
|
||
list.querySelectorAll('.reh-lib-export').forEach(btn => btn.addEventListener('click', async e => {
|
||
e.stopPropagation();
|
||
const id = parseInt(btn.dataset.id);
|
||
const db = await rehDbOpen();
|
||
const rec = await new Promise((res, rej) => { const r = db.transaction(REH_STORE,'readonly').objectStore(REH_STORE).get(id); r.onsuccess=e=>res(e.target.result); r.onerror=e=>rej(e.target.error); });
|
||
rec.version = 1;
|
||
rec.clips = await clipsToJson(rec.clips || []);
|
||
const json = JSON.stringify(rec, null, 2);
|
||
const a = document.createElement('a');
|
||
const t = (rec.title || 'rehearsal').replace(/[^a-z0-9_\- ]/gi, '_').slice(0, 40);
|
||
a.href = URL.createObjectURL(new Blob([json], { type: 'application/json' }));
|
||
a.download = t + '.reh';
|
||
a.click();
|
||
}));
|
||
|
||
list.querySelectorAll('.reh-lib-delete').forEach(btn => btn.addEventListener('click', async e => {
|
||
e.stopPropagation();
|
||
if (!confirm('Delete this rehearsal from the library?')) return;
|
||
await rehDbDelete(parseInt(btn.dataset.id));
|
||
if (rehState.savedId === parseInt(btn.dataset.id)) rehState.savedId = null;
|
||
renderLibraryList();
|
||
}));
|
||
}
|
||
|
||
$('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 voiceAvatarHtml(voiceId, color, size = 32) {
|
||
const v = getVoiceData(voiceId);
|
||
const s = size + 'px';
|
||
const radius = Math.round(size / 2);
|
||
if (v?.has_picture) {
|
||
return `<img src="/api/voice/picture/${encodeURIComponent(voiceId)}" class="reh-char-img" style="width:${s};height:${s};border-radius:${radius}px;object-fit:cover;flex-shrink:0" alt="${escHtml(voiceId)}">`;
|
||
}
|
||
const ic = (v?.avatar && window.VOICE_AVATAR_ICONS) ? window.VOICE_AVATAR_ICONS[v.avatar] : null;
|
||
if (ic) {
|
||
return `<span class="reh-char-img" style="width:${s};height:${s};border-radius:${radius}px;background:${color};color:#fff;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0"><span class="mdi ${ic}" style="font-size:${Math.round(size * 0.56)}px"></span></span>`;
|
||
}
|
||
const initial = (voiceId || '?')[0].toUpperCase();
|
||
return `<span class="reh-char-img" style="width:${s};height:${s};border-radius:${radius}px;background:${color};color:#fff;font-weight:700;font-size:${Math.round(size * 0.45)}px;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0">${initial}</span>`;
|
||
}
|
||
|
||
// ── Script parser + detectCharacters → moved to rehearser-parse.js (loaded first) ──
|
||
|
||
// ── Phase navigation ───────────────────────────────────────────────────────
|
||
|
||
function showPhase(n) {
|
||
// Hide the import/export panel when switching to a numbered phase
|
||
const impex = $('reh-impex-panel'); if (impex) impex.hidden = true;
|
||
for (let i = 1; i <= 4; i++) { const el = $('reh-phase-' + i); if (el) el.hidden = i !== n; }
|
||
syncPhaseTabs(n);
|
||
if (typeof window.onRehearserPhaseChange === 'function') window.onRehearserPhaseChange(n);
|
||
}
|
||
|
||
window.showRehImpEx = function () {
|
||
stopPlay(); stopRehMic();
|
||
for (let i = 1; i <= 4; i++) { const el = $('reh-phase-' + i); if (el) el.hidden = true; }
|
||
const panel = $('reh-impex-panel'); if (panel) panel.hidden = false;
|
||
// Show export note based on state
|
||
const note = $('reh-impex-export-note');
|
||
if (note) note.textContent = rehState.lines.length
|
||
? `Current session: "${$('reh-script-title')?.value || 'Untitled'}" · ${rehState.lines.filter(l=>l.type==='dialog').length} lines`
|
||
: 'No active session — load a script first to export.';
|
||
};
|
||
|
||
// Keep the sub-tab bar in sync: highlight current, enable reachable phases
|
||
function syncPhaseTabs(n) {
|
||
const hasScript = rehState.lines.length > 0;
|
||
const hasClips = rehState.clips.length > 0;
|
||
document.querySelectorAll('.reh-subtab').forEach(tab => {
|
||
const p = parseInt(tab.dataset.phase);
|
||
tab.classList.toggle('active', p === n);
|
||
// Library always reachable; Cast/Stage need a parsed script; Summary needs clips (or being on it)
|
||
let enabled = p === 1
|
||
|| (p === 2 && hasScript)
|
||
|| (p === 3 && hasScript)
|
||
|| (p === 4 && (hasClips || n === 4));
|
||
tab.disabled = !enabled;
|
||
});
|
||
}
|
||
|
||
document.querySelectorAll('.reh-subtab').forEach(tab => {
|
||
tab.addEventListener('click', () => {
|
||
if (tab.disabled) return;
|
||
const p = parseInt(tab.dataset.phase);
|
||
// Leaving the stage: stop playback/mic so audio doesn't keep running
|
||
if (p !== 3) { stopPlay(); stopRehMic(); hideRecOverlay(); }
|
||
if (p === 1) renderLibraryList();
|
||
if (p === 3) {
|
||
// (Re)build the stage if we have a cast/script
|
||
if (rehState.lines.length) { buildScriptPage(); showPhase(3); highlightCurrentLine(); return; }
|
||
}
|
||
if (p === 4) { renderSummary(); }
|
||
showPhase(p);
|
||
});
|
||
});
|
||
|
||
// ── Phase 1 ────────────────────────────────────────────────────────────────
|
||
|
||
$('reh-file-input')?.addEventListener('change', function () {
|
||
const f = this.files?.[0]; if (!f) return;
|
||
const guess = f.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim();
|
||
const r = new FileReader();
|
||
r.onload = async e => {
|
||
$('reh-script-text').value = e.target.result;
|
||
await rehAutoSaveImport(guess);
|
||
toast('Script loaded & saved to your library — click Parse & cast', 'success');
|
||
};
|
||
r.readAsText(f); this.value = '';
|
||
});
|
||
|
||
$('reh-pdf-input')?.addEventListener('change', async function () {
|
||
const f = this.files?.[0]; if (!f) return;
|
||
this.value = '';
|
||
try {
|
||
const text = await importPDFScript(f);
|
||
if ($('reh-script-text')) $('reh-script-text').value = text;
|
||
const guess = f.name.replace(/\.pdf$/i, '').replace(/[-_]+/g, ' ').trim();
|
||
await rehAutoSaveImport(guess);
|
||
toast('PDF imported & saved to your library — check formatting, then click Parse & cast', 'success');
|
||
} catch(e) { toast('PDF import failed: ' + e.message, 'error'); }
|
||
});
|
||
|
||
$('reh-fdx-input')?.addEventListener('change', async function () {
|
||
const f = this.files?.[0]; if (!f) return;
|
||
this.value = '';
|
||
try {
|
||
const text = await importFDX(f);
|
||
if ($('reh-script-text')) $('reh-script-text').value = text;
|
||
if (!$('reh-script-title')?.value.trim()) {
|
||
const guess = f.name.replace(/\.fdx$/i, '').replace(/[-_]+/g, ' ').trim();
|
||
if ($('reh-script-title')) $('reh-script-title').value = guess;
|
||
}
|
||
toast('FDX imported — click Parse & cast', 'success');
|
||
} catch(e) { toast('FDX import failed: ' + e.message, 'error'); }
|
||
});
|
||
|
||
// ── Unified import router (used by file pickers + drag & drop) ──────────────
|
||
async function rehImportAnyFile(f) {
|
||
if (!f) return;
|
||
const name = f.name.toLowerCase();
|
||
const setTitle = strip => {
|
||
if (!$('reh-script-title')?.value.trim()) {
|
||
const guess = f.name.replace(strip, '').replace(/[-_]+/g, ' ').trim();
|
||
if ($('reh-script-title')) $('reh-script-title').value = guess;
|
||
}
|
||
};
|
||
try {
|
||
if (name.endsWith('.reh') || name.endsWith('.json')) {
|
||
await importFromFile(f); // full session → loads & jumps to cast
|
||
return;
|
||
}
|
||
if (name.endsWith('.pdf')) {
|
||
toast('Reading PDF…', 'success');
|
||
const text = await importPDFScript(f);
|
||
if ($('reh-script-text')) $('reh-script-text').value = text;
|
||
setTitle(/\.pdf$/i);
|
||
await rehAutoSaveImport();
|
||
toast('PDF imported & saved to your library — check formatting, then Parse & cast', 'success');
|
||
return;
|
||
}
|
||
if (name.endsWith('.fdx') || name.endsWith('.osf') || name.endsWith('.xml')) {
|
||
const text = await importFDX(f);
|
||
if ($('reh-script-text')) $('reh-script-text').value = text;
|
||
setTitle(/\.(fdx|osf|xml)$/i);
|
||
await rehAutoSaveImport();
|
||
toast('Script imported & saved to your library — click Parse & cast', 'success');
|
||
return;
|
||
}
|
||
// .txt / .md / .fountain / anything else → plain text
|
||
const text = await f.text();
|
||
if ($('reh-script-text')) $('reh-script-text').value = text;
|
||
setTitle(/\.(txt|md|fountain)$/i);
|
||
await rehAutoSaveImport();
|
||
toast('Script loaded & saved to your library — click Parse & cast', 'success');
|
||
} catch(e) { toast('Import failed: ' + e.message, 'error'); }
|
||
}
|
||
|
||
// Drag & drop over the dropzone AND the whole Phase-1 panel
|
||
(function setupRehDropzone() {
|
||
const dz = $('reh-dropzone');
|
||
const phase = $('reh-phase-1');
|
||
if (!dz || !phase) return;
|
||
let depth = 0;
|
||
|
||
const isFileDrag = e => e.dataTransfer && [...(e.dataTransfer.types || [])].includes('Files');
|
||
|
||
phase.addEventListener('dragenter', e => {
|
||
if (!isFileDrag(e)) return;
|
||
e.preventDefault(); depth++; dz.classList.add('dragover');
|
||
dz.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||
});
|
||
phase.addEventListener('dragover', e => { if (isFileDrag(e)) e.preventDefault(); });
|
||
phase.addEventListener('dragleave', e => {
|
||
if (!isFileDrag(e)) return;
|
||
depth = Math.max(0, depth - 1);
|
||
if (depth === 0) dz.classList.remove('dragover');
|
||
});
|
||
phase.addEventListener('drop', async e => {
|
||
if (!isFileDrag(e)) return;
|
||
e.preventDefault(); depth = 0; dz.classList.remove('dragover');
|
||
const f = e.dataTransfer.files?.[0];
|
||
await rehImportAnyFile(f);
|
||
});
|
||
})();
|
||
|
||
$('reh-parse-btn')?.addEventListener('click', () => {
|
||
const text = $('reh-script-text')?.value.trim();
|
||
if (!text) { toast('Paste or upload a script first', 'error'); return; }
|
||
rehState.lines = parseScript(text);
|
||
if (!rehState.lines.filter(l => l.type === 'dialog').length) {
|
||
toast('No dialog found. Use screenplay format (CAPS name + dialog) or CHAR: text.', 'error'); return;
|
||
}
|
||
// Preserve cast for returning characters
|
||
const detected = detectCharacters(rehState.lines);
|
||
Object.entries(detected).forEach(([sp]) => {
|
||
if (rehState.cast[sp]) detected[sp] = { ...detected[sp], ...rehState.cast[sp] };
|
||
});
|
||
rehState.cast = detected;
|
||
// Keep the imported/loaded library entry on the first parse so casting updates it
|
||
// instead of orphaning it; otherwise start a fresh entry.
|
||
if (rehState._keepSavedIdOnce) rehState._keepSavedIdOnce = false;
|
||
else rehState.savedId = null;
|
||
rehState.clips = [];
|
||
rehState.lineIndex = 0;
|
||
rehState.synthCache.clear(); rehDecodedBuffers.clear();
|
||
renderCastList();
|
||
showPhase(2);
|
||
refreshRehBackends();
|
||
});
|
||
|
||
// ── Phase 2 ────────────────────────────────────────────────────────────────
|
||
|
||
function castAvatarHtml(sp) {
|
||
const c = rehState.cast[sp]; if (!c) return '';
|
||
if (c.voice && c.voice !== 'me') {
|
||
const vd = getVoiceData(c.voice);
|
||
if (vd?.has_picture) return `<img src="/api/voice/picture/${encodeURIComponent(c.voice)}" style="width:36px;height:36px;border-radius:50%;object-fit:cover;border:2px solid ${c.color}" alt="">`;
|
||
const ic = (vd?.avatar && window.VOICE_AVATAR_ICONS) ? window.VOICE_AVATAR_ICONS[vd.avatar] : null;
|
||
if (ic) return `<span style="width:36px;height:36px;border-radius:50%;background:${c.color};color:#fff;display:inline-flex;align-items:center;justify-content:center;border:2px solid ${c.color}"><span class="mdi ${ic}" style="font-size:20px"></span></span>`;
|
||
}
|
||
const initial = sp[0].toUpperCase();
|
||
return `<span style="width:36px;height:36px;border-radius:50%;background:${c.color};color:#fff;font-weight:700;font-size:16px;display:inline-flex;align-items:center;justify-content:center;border:2px solid ${c.color}">${initial}</span>`;
|
||
}
|
||
|
||
// ── Online voice picker: audition a sample, browse alternatives, agree/switch ──
|
||
let _rehAudEl = null, _rehAudBtn = null;
|
||
function _rehStopAudition() {
|
||
if (_rehAudEl) { try { _rehAudEl.pause(); } catch (_) {} }
|
||
if (_rehAudBtn) { _rehAudBtn.classList.remove('playing'); const i = _rehAudBtn.querySelector('.mdi'); if (i) i.className = 'mdi mdi-play'; }
|
||
_rehAudBtn = null;
|
||
}
|
||
function _rehAudition(url, btn) {
|
||
if (!url) { toast('No preview audio for this voice', 'error'); return; }
|
||
if (_rehAudBtn === btn && _rehAudEl && !_rehAudEl.paused) { _rehStopAudition(); return; }
|
||
_rehStopAudition();
|
||
if (!_rehAudEl) { _rehAudEl = new Audio(); _rehAudEl.addEventListener('ended', _rehStopAudition); }
|
||
_rehAudEl.src = url;
|
||
_rehAudEl.play().then(() => {
|
||
_rehAudBtn = btn; btn.classList.add('playing');
|
||
const i = btn.querySelector('.mdi'); if (i) i.className = 'mdi mdi-stop';
|
||
}).catch(() => toast('Could not play preview', 'error'));
|
||
}
|
||
|
||
const _REH_PREVIEW_TEXT = 'Hello — this is how this voice sounds.';
|
||
const _rehSearchCache = {}; // sp -> last fish.audio search results (simplified candidates)
|
||
|
||
// One row of the voice picker. Plays via sample URL (online) or TTS (local library voice).
|
||
function _rehVoiceRow({ name, meta, playUrl, playVoice, isCurrent, useAttrs }) {
|
||
const play = `<button type="button" class="reh-vo-play"${playUrl ? ` data-url="${escHtml(playUrl)}"` : ''}${playVoice ? ` data-voice="${escHtml(playVoice)}"` : ''} title="Preview voice"><span class="mdi mdi-play"></span></button>`;
|
||
const right = isCurrent
|
||
? `<span class="reh-vo-badge"><span class="mdi mdi-check-circle"></span> Selected</span>`
|
||
: `<button type="button" class="btn-secondary btn-sm reh-vo-use"${useAttrs || ''}>Use this</button>`;
|
||
return `<div class="reh-vo-row${isCurrent ? ' current' : ''}">${play}
|
||
<div class="reh-vo-info"><span class="reh-vo-name">${escHtml(name)}</span><span class="reh-vo-meta">${escHtml(meta)}</span></div>
|
||
${right}</div>`;
|
||
}
|
||
|
||
// Render the "matched from fish.audio" picker: current voice + alternatives + change tools
|
||
function castOnlinePanelHtml(sp, c) {
|
||
const o = c.online;
|
||
if (!o || !Array.isArray(o.candidates) || c.voice === 'me') return '';
|
||
|
||
// Current assigned voice — an online candidate (has a sample) or a plain library voice
|
||
const curCand = o.candidates.find(x => x.voice_id && x.voice_id === c.voice);
|
||
let curRow;
|
||
if (curCand) {
|
||
curRow = _rehVoiceRow({ name: curCand.title || c.voice, meta: [curCand.gender, curCand.language].filter(Boolean).join(' · ') || 'fish.audio', playUrl: curCand.sample_audio, isCurrent: true });
|
||
} else if (c.voice) {
|
||
const vd = getVoiceData(c.voice);
|
||
curRow = _rehVoiceRow({ name: vd?.name || c.voice, meta: vd?.group || vd?.tag || 'library voice', playVoice: c.voice, isCurrent: true });
|
||
} else {
|
||
curRow = `<div class="reh-vo-row"><div class="reh-vo-info"><span class="reh-vo-name">No voice assigned</span><span class="reh-vo-meta">pick one below</span></div></div>`;
|
||
}
|
||
|
||
// Alternatives = the other matched candidates (not the current one)
|
||
const alts = o.candidates.map((cand, i) => ({ cand, i })).filter(({ cand }) => cand.voice_id !== c.voice || !cand.voice_id);
|
||
const altRows = alts.map(({ cand, i }) => _rehVoiceRow({
|
||
name: cand.title || 'Voice', meta: [cand.gender, cand.language].filter(Boolean).join(' · ') || 'fish.audio',
|
||
playUrl: cand.sample_audio, useAttrs: ` data-act="alt" data-idx="${i}"`,
|
||
})).join('');
|
||
|
||
return `<div class="reh-cc-online">
|
||
<div class="reh-cc-online-head"><span class="mdi mdi-earth"></span> Voice — try & 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 (v === cur) return false;
|
||
if (!ql) return true;
|
||
const vd = getVoiceData(v);
|
||
return v.toLowerCase().includes(ql) || (vd?.name || '').toLowerCase().includes(ql) || (vd?.group || '').toLowerCase().includes(ql);
|
||
}).slice(0, 40);
|
||
if (!matches.length) { box.innerHTML = `<div class="reh-vo-hint">No library voices match.</div>`; return; }
|
||
box.innerHTML = matches.map(v => {
|
||
const vd = getVoiceData(v);
|
||
return _rehVoiceRow({ name: vd?.name || v, meta: vd?.group || vd?.tag || 'library voice', playVoice: v, useAttrs: ` data-act="local" data-voice="${escHtml(v)}"` });
|
||
}).join('');
|
||
}
|
||
|
||
// Search fish.audio and render results inside a card's "Search online" panel
|
||
async function _rehSearchOnline(card, sp, q) {
|
||
const box = card.querySelector('.reh-vo-search-results'); if (!box) return;
|
||
q = (q || '').trim();
|
||
if (!q) { box.innerHTML = `<div class="reh-vo-hint">Enter a search term first.</div>`; return; }
|
||
box.innerHTML = `<div class="reh-vo-hint"><span class="reh-imsdb-spinner"></span> Searching fish.audio…</div>`;
|
||
const lang = REH_FISH_LANG[$('reh-design-lang')?.value || 'English'] ?? 'en';
|
||
let items = [];
|
||
try {
|
||
const qs = [`search=${encodeURIComponent(q)}`, `language=${lang}`, 'page_size=10', 'sort_by=score'];
|
||
items = (await fetch('/api/fishaudio/voices?' + qs.join('&')).then(r => r.json())).items || [];
|
||
} catch (_) {}
|
||
const cands = items.filter(v => v.sample_audio).slice(0, 10).map(v => ({
|
||
title: v.title, sample_audio: v.sample_audio, image: v.image || '', gender: v.gender || '',
|
||
language: v.language || lang || '', description: v.description || '', sample_text: v.sample_text || v.default_text || '',
|
||
}));
|
||
_rehSearchCache[sp] = cands;
|
||
if (!cands.length) { box.innerHTML = `<div class="reh-vo-hint">No playable voices found for “${escHtml(q)}”.</div>`; return; }
|
||
box.innerHTML = cands.map((cand, i) => _rehVoiceRow({
|
||
name: cand.title || 'Voice', meta: [cand.gender, cand.language].filter(Boolean).join(' · ') || 'fish.audio',
|
||
playUrl: cand.sample_audio, useAttrs: ` data-act="search" data-idx="${i}"`,
|
||
})).join('');
|
||
}
|
||
|
||
// Import a freshly searched fish.audio result, assign it, and fold it into candidates
|
||
async function _rehUseSearchResult(sp, idx, btn) {
|
||
const c = rehState.cast[sp]; if (!c) return;
|
||
const cand = (_rehSearchCache[sp] || [])[idx]; if (!cand) return;
|
||
_rehStopAudition();
|
||
const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '<span class="reh-imsdb-spinner"></span>';
|
||
try {
|
||
const vid = await _rehImportFishCandidate(sp, cand);
|
||
cand.voice_id = vid; c.voice = vid; c.voiceData = getVoiceData(vid);
|
||
if (!c.online) c.online = { candidates: [], picked: 0 };
|
||
c.online.candidates.push(cand);
|
||
c.online.picked = c.online.candidates.length - 1;
|
||
if (typeof loadVoiceLibrary === 'function') await loadVoiceLibrary().catch(() => {});
|
||
renderCastList(); if (typeof populateNarratorSelect === 'function') populateNarratorSelect();
|
||
toast(`Switched ${sp} to ${cand.title || 'voice'}`, 'success');
|
||
} catch (err) {
|
||
btn.disabled = false; btn.innerHTML = orig;
|
||
toast('Import failed: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
// Remember fish imports this session so the same voice isn't downloaded twice
|
||
const _rehFishImported = {};
|
||
|
||
// Import one fish.audio candidate into the library and return its voice_id.
|
||
// Reuses an already-imported voice (this session OR already in the library) so the
|
||
// same fish.audio voice doesn't pile up as Mortal_Kombat, Mortal_Kombat_2, …
|
||
async function _rehImportFishCandidate(sp, cand) {
|
||
const key = (cand.sample_audio || cand.title || '').toLowerCase();
|
||
if (key && _rehFishImported[key]) {
|
||
const id = _rehFishImported[key];
|
||
if (!rehState.voices.includes(id)) rehState.voices.push(id);
|
||
return id;
|
||
}
|
||
const title = (cand.title || '').toLowerCase().trim();
|
||
const existing = title && (window._voices || []).find(v =>
|
||
(v.group === 'fish-audio' || v.tag === 'fish-audio') && (v.name || '').toLowerCase().trim() === title);
|
||
if (existing) {
|
||
if (key) _rehFishImported[key] = existing.id;
|
||
if (!rehState.voices.includes(existing.id)) rehState.voices.push(existing.id);
|
||
return existing.id;
|
||
}
|
||
const lang2 = (cand.language || 'EN').slice(0, 2).toUpperCase();
|
||
const vid = `${lang2}_${(cand.title || sp).replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 36) || 'Voice'}`;
|
||
const d = await fetch('/api/quick-import-voice', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ voice_id: vid, audio_url: cand.sample_audio, transcript: cand.sample_text || '' }),
|
||
}).then(r => r.json());
|
||
if (!d.voice_id) throw new Error('import returned no id');
|
||
if (typeof saveMeta === 'function') await saveMeta(d.voice_id, { name: cand.title || sp, tag: 'fish-audio', group: 'fish-audio', origin: 'cloned', gender: (cand.gender || '').charAt(0).toUpperCase(), note: (cand.description || '').slice(0, 180) }).catch(e => logErr('fish saveMeta', e));
|
||
if (cand.image) await fetch('/api/voice/picture-url', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ voice_id: d.voice_id, image_url: cand.image }) }).catch(e => logErr('fish picture', e));
|
||
if (!rehState.voices.includes(d.voice_id)) rehState.voices.push(d.voice_id);
|
||
if (key) _rehFishImported[key] = d.voice_id;
|
||
return d.voice_id;
|
||
}
|
||
|
||
// "Disagree" → switch this character to one of the presented alternatives
|
||
async function _rehUseCandidate(sp, idx, btn) {
|
||
const c = rehState.cast[sp]; const o = c && c.online; if (!o) return;
|
||
const cand = o.candidates[idx]; if (!cand) return;
|
||
_rehStopAudition();
|
||
if (cand.voice_id) { // already imported earlier — just reassign
|
||
c.voice = cand.voice_id; c.voiceData = getVoiceData(cand.voice_id); o.picked = idx;
|
||
renderCastList(); if (typeof populateNarratorSelect === 'function') populateNarratorSelect();
|
||
return;
|
||
}
|
||
const orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '<span class="reh-imsdb-spinner"></span>';
|
||
try {
|
||
const vid = await _rehImportFishCandidate(sp, cand);
|
||
cand.voice_id = vid; c.voice = vid; c.voiceData = getVoiceData(vid); o.picked = idx;
|
||
if (typeof loadVoiceLibrary === 'function') await loadVoiceLibrary().catch(() => {});
|
||
renderCastList(); if (typeof populateNarratorSelect === 'function') populateNarratorSelect();
|
||
toast(`Switched ${sp} to ${cand.title || 'voice'}`, 'success');
|
||
} catch (err) {
|
||
btn.disabled = false; btn.innerHTML = orig;
|
||
toast('Import failed: ' + err.message, 'error');
|
||
}
|
||
}
|
||
|
||
const REH_NARRATOR_KEY = '\u{1F4D6}NARRATOR'; // unique sentinel kept out of real speaker names
|
||
|
||
function _ensureNarrator() {
|
||
if (!rehState.cast[REH_NARRATOR_KEY]) {
|
||
rehState.cast[REH_NARRATOR_KEY] = {
|
||
voice: rehState.narratorVoice || '',
|
||
color: '#6b7280',
|
||
instruct: '',
|
||
voiceData: rehState.narratorVoice ? getVoiceData(rehState.narratorVoice) : null,
|
||
_isNarrator: true,
|
||
};
|
||
}
|
||
// Bidirectional sync between the narrator cast row and the global narratorVoice.
|
||
// The cast row is the source of truth (auto-design assigns the voice there), but
|
||
// playback/synth read rehState.narratorVoice — keep them aligned both ways.
|
||
const n = rehState.cast[REH_NARRATOR_KEY];
|
||
if (n.voice) {
|
||
rehState.narratorVoice = n.voice;
|
||
if (!n.voiceData) n.voiceData = getVoiceData(n.voice);
|
||
} else if (rehState.narratorVoice) {
|
||
n.voice = rehState.narratorVoice;
|
||
n.voiceData = getVoiceData(rehState.narratorVoice);
|
||
}
|
||
}
|
||
|
||
const REH_CAST_LANGS = ['English', 'German', 'Auto', 'French', 'Spanish', 'Italian', 'Portuguese', 'Dutch', 'Polish'];
|
||
const REH_CAST_GENDERS = [['', '—'], ['F', '♀ Female'], ['M', '♂ Male'], ['N', '⚥ Diverse']];
|
||
|
||
// Apply a mutation to every dialog line spoken by a character
|
||
function _castApplyToLines(sp, fn) {
|
||
rehState.lines.forEach((l, i) => { if (l.speaker === sp && l.type === 'dialog') fn(l, i); });
|
||
}
|
||
|
||
function renderCastList() {
|
||
const list = $('reh-cast-list'); if (!list) return;
|
||
// Make sure the voice library is loaded so every picker (incl. the narrator) has
|
||
// voices to choose from even when the Rehearser was opened directly.
|
||
if (!(window._voices || []).length && typeof loadVoiceLibrary === 'function' && !rehState._castLibFetch) {
|
||
rehState._castLibFetch = true;
|
||
loadVoiceLibrary().then(() => renderCastList()).catch(() => {});
|
||
}
|
||
_ensureNarrator();
|
||
const narr = REH_NARRATOR_KEY;
|
||
const lineCount = sp => rehState.lines.filter(l => l.speaker === sp && l.type === 'dialog').length;
|
||
const allOthers = Object.keys(rehState.cast).filter(s => s !== narr);
|
||
const others = _castSortFilter(allOthers, lineCount); // narrator always stays pinned on top
|
||
const speakers = [narr, ...others];
|
||
const scriptTitle = $('reh-script-title')?.value.trim() || '';
|
||
|
||
list.innerHTML = speakers.map(sp => {
|
||
const c = rehState.cast[sp];
|
||
const isNarr = sp === narr;
|
||
const isMe = c.voice === 'me';
|
||
const label = isNarr ? 'Narrator' : sp;
|
||
const n = lineCount(sp);
|
||
const sub = isNarr ? 'scene headings & descriptions' : `${n} line${n!==1?'s':''}${scriptTitle ? ` · ${escHtml(scriptTitle)}` : ''}`;
|
||
const cardCls = 'reh-cast-card' + (isNarr ? ' reh-cast-narrator' : '') + (c.ignored ? ' reh-cast-ignored' : '') + (c.hidden ? ' reh-cast-hidden-c' : '');
|
||
const langSel = REH_CAST_LANGS.map(l => `<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 voiceSel = `<select class="reh-voice-sel" data-speaker="${escHtml(sp)}"${isMe?' style="display:none"':''}>
|
||
<option value="">— ${isNarr ? 'none (skip descriptions)' : 'assign a voice'} —</option>
|
||
${_rehAllVoiceIds(c.voice).map(v=>`<option value="${escHtml(v)}"${v===c.voice?' selected':''}>${escHtml(v)}</option>`).join('')}
|
||
</select>`;
|
||
|
||
const voiceName = !isMe && c.voice ? (getVoiceData(c.voice)?.name || c.voice) : (isMe ? 'Your mic' : '');
|
||
return `<div class="${cardCls}" data-speaker="${escHtml(sp)}" style="--cc-color:${c.color}">
|
||
<div class="reh-cc-head">
|
||
<div class="reh-cc-avatar">${castAvatarHtml(sp)}</div>
|
||
<div class="reh-cc-titles">
|
||
<div class="reh-cc-name" style="color:${c.color}">${isNarr ? '<span class="mdi mdi-book-open-page-variant"></span> ' : ''}${escHtml(label)}</div>
|
||
<div class="reh-cc-sub">${sub}</div>
|
||
<div class="reh-cc-voicechip${(!isMe && !c.voice) ? ' empty' : ''}"><span class="mdi mdi-${isMe ? 'microphone' : 'account-music-outline'}"></span> ${escHtml(voiceName || 'No voice assigned')}</div>
|
||
${(!isMe && c.voice) ? `<button type="button" class="reh-cc-sample-btn" data-speaker="${escHtml(sp)}" title="Hear a sample line in this voice" aria-label="Hear a sample line in this voice"><span class="mdi mdi-play"></span> Hear a line</button>` : ''}
|
||
</div>
|
||
</div>
|
||
${!isNarr ? `<div class="reh-cc-actions">
|
||
<label class="reh-cc-me-row" title="Record this character with your own mic instead of a TTS voice"><input type="checkbox" class="reh-me-check" data-speaker="${escHtml(sp)}" ${isMe?'checked':''}><span><span class="mdi mdi-microphone"></span> I play this</span></label>
|
||
<span class="reh-cc-actions-spacer"></span>
|
||
<button class="reh-cc-iconbtn${c.ignored?' active':''}" data-act="ignore" title="Ignore — grey out & 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>` : ''}
|
||
<div class="reh-cc-body"${isMe?' style="display:none"':''}>
|
||
<div class="reh-cc-field reh-cc-voice"><label>Voice</label>${voiceSel}</div>
|
||
<div class="reh-cc-extra">
|
||
${!isNarr ? castOnlinePanelHtml(sp, c) : ''}
|
||
${!isNarr ? `<div class="reh-cc-grid">
|
||
<div class="reh-cc-field"><label>Language</label><select class="reh-cc-lang">${langSel}</select></div>
|
||
<div class="reh-cc-field"><label>Gender</label><select class="reh-cc-gender">${genSel}</select></div>
|
||
<div class="reh-cc-field"><label>Tags</label><input type="text" class="reh-cc-tags" value="${escHtml(c.tags||'')}" placeholder="e.g. villain, raspy"></div>
|
||
</div>` : ''}
|
||
<div class="reh-cc-field"><label>Speaking style · voice-design prompt</label>
|
||
<input type="text" class="reh-cast-instruct" value="${escHtml(c.instruct||'')}" placeholder="${isNarr ? 'Narrator style: calm, measured, cinematic storyteller…' : 'British accent, commanding baritone, quietly menacing…'}"></div>
|
||
${!isNarr ? `<details class="reh-cc-soul"${c.soul?' open':''}>
|
||
<summary><span class="mdi mdi-script-text-outline"></span> Character soul · LLM brief
|
||
<button class="btn-secondary btn-sm reh-cc-develop" type="button" title="Let the LLM read the script & flesh out this character"><span class="mdi mdi-auto-fix"></span> Develop</button>
|
||
</summary>
|
||
<textarea class="reh-cc-soul-text" placeholder="Backstory, motivation, vocal manner… (used as the LLM brief when designing this voice)">${escHtml(c.soul||'')}</textarea>
|
||
</details>` : ''}
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
// Cast count (reflects active filter) + keep the chosen view (card / list)
|
||
const countEl = $('reh-cast-count');
|
||
if (countEl) {
|
||
const shown = others.length, total = allOthers.length;
|
||
countEl.textContent = shown === total
|
||
? `${total} ${total === 1 ? 'character' : 'characters'} + narrator`
|
||
: `${shown} of ${total} characters`;
|
||
}
|
||
_wireCastControls();
|
||
applyCastView();
|
||
|
||
// ── Wiring ────────────────────────────────────────────────────────────────
|
||
const card = el => el.closest('.reh-cast-card');
|
||
const spOf = el => card(el).dataset.speaker;
|
||
|
||
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 || []);
|
||
(window._voices || []).forEach(v => { if (v && v.id) 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 {
|
||
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))) : [];
|
||
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 = scriptTitle.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 24) || 'Script';
|
||
|
||
const btn = $('reh-autodesign-btn');
|
||
const prog = $('reh-autodesign-progress');
|
||
const fill = $('reh-autodesign-fill');
|
||
const label= $('reh-autodesign-label');
|
||
btn.disabled = true;
|
||
rehDesignCancelled = false;
|
||
if (prog) prog.hidden = false;
|
||
const setProg = (d, t, msg) => {
|
||
if (fill) fill.style.width = (t ? (d/t)*100 : 0) + '%';
|
||
if (label) label.textContent = msg || `${d} / ${t}`;
|
||
};
|
||
setProg(0, speakers.length, 'Analyzing script with LLM…');
|
||
|
||
// 1) Ask the LLM to describe each character
|
||
let characters;
|
||
try {
|
||
const r = await fetch('/api/analyze-characters', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ script, names: speakers, llm_url: llmUrl, model: llmModel, language }),
|
||
});
|
||
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
|
||
const d = await r.json();
|
||
characters = d.characters || [];
|
||
} catch(e) {
|
||
toast('Character analysis failed: ' + e.message, 'error');
|
||
btn.disabled = false; if (prog) prog.hidden = true;
|
||
return;
|
||
}
|
||
|
||
// Map analysis back to speakers (case-insensitive match)
|
||
const byName = {};
|
||
characters.forEach(c => { if (c.name) byName[String(c.name).toUpperCase().trim()] = c; });
|
||
|
||
// 2) For each character, design + save a voice
|
||
let done = 0;
|
||
const designOnly = speakers.filter(sp => rehState.cast[sp].voice !== 'me');
|
||
for (const sp of designOnly) {
|
||
if (rehDesignCancelled) { toast('Cancelled', 'error'); break; }
|
||
const info = byName[sp.toUpperCase().trim()] || {};
|
||
const gender = (info.gender || 'N').toUpperCase().charAt(0).replace(/[^MFN]/, 'N') || 'N';
|
||
const desc = info.description ||
|
||
`A ${info.age || 'adult'} ${gender === 'M' ? 'male' : gender === 'F' ? 'female' : ''} character named ${sp}, natural expressive voice.`;
|
||
const sampleLine = rehState.lines.find(l => l.type === 'dialog' && l.speaker === sp)?.text || `Hello, I am ${sp}.`;
|
||
|
||
const safeName = sp.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 24) || 'Char';
|
||
const voiceId = `${langCode}_${gender}_${safeName}_${tag}`.slice(0, 90);
|
||
rehMarkCastDesigning(sp, 'designing', null, {
|
||
gender, language, voiceId, desc,
|
||
age: info.age || '',
|
||
step: 'Generating voice audio…',
|
||
});
|
||
setProg(done, designOnly.length, `Designing ${sp}… (${done + 1}/${designOnly.length})`);
|
||
|
||
try {
|
||
// Generate the voice audio
|
||
const dr = await fetch('/api/voice-design', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ instruct: desc, sample_text: stripMarkdown(sampleLine).slice(0, 300), language, gender, dialogue: false }),
|
||
});
|
||
if (!dr.ok) { const e = await dr.json().catch(()=>({})); throw new Error(e.detail || dr.statusText); }
|
||
const dd = await dr.json();
|
||
|
||
// Save it to the voice library, tagged with script name + Rehearser
|
||
const sr = await fetch('/api/save', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: dd.id, voice_id: voiceId, transcript: stripMarkdown(sampleLine).slice(0, 300) }),
|
||
});
|
||
if (!sr.ok) { const e = await sr.json().catch(()=>({})); throw new Error(e.detail || sr.statusText); }
|
||
const saved = await sr.json();
|
||
|
||
// Tag metadata — display name = character, script as a tag, origin 'designed',
|
||
// grouped by play title, plus an auto-picked gender/type avatar icon.
|
||
const charName = (sp === REH_NARRATOR_KEY) ? 'Narrator' : sp;
|
||
if (typeof saveMeta === 'function') {
|
||
await saveMeta(saved.voice_id, {
|
||
gender,
|
||
name: charName,
|
||
avatar: _pickVoiceAvatar(gender, desc, sp),
|
||
origin: 'designed',
|
||
group: `Rehearser: ${scriptTitle}`,
|
||
note: `Rehearser · ${scriptTitle} · ${charName} — ${desc.slice(0, 180)}`,
|
||
transcript: stripMarkdown(sampleLine).slice(0, 300),
|
||
tag: scriptTitle,
|
||
}).catch(()=>{});
|
||
}
|
||
|
||
// Assign to cast + remember the LLM's research as the style note & character soul
|
||
rehState.cast[sp].voice = saved.voice_id;
|
||
rehState.cast[sp].instruct = rehState.cast[sp].instruct || desc;
|
||
rehState.cast[sp].soul = rehState.cast[sp].soul || [info.age ? `Age: ${info.age}` : '', desc].filter(Boolean).join(' · ');
|
||
rehState.cast[sp].voiceData = null;
|
||
if (!rehState.voices.includes(saved.voice_id)) rehState.voices.push(saved.voice_id);
|
||
rehMarkCastDesigning(sp, 'done');
|
||
} catch(e) {
|
||
rehMarkCastDesigning(sp, 'err', e.message);
|
||
}
|
||
done++;
|
||
setProg(done, designOnly.length);
|
||
}
|
||
|
||
// Refresh the broader voice library so avatars/pictures resolve
|
||
if (typeof loadVoiceLibrary === 'function') await loadVoiceLibrary().catch(()=>{});
|
||
|
||
renderCastList();
|
||
populateNarratorSelect();
|
||
if (prog) prog.hidden = true;
|
||
btn.disabled = false;
|
||
if (!rehDesignCancelled) toast(`Designed ${done} voice${done!==1?'s':''} — tagged "${tag}" + Rehearser`, 'success');
|
||
});
|
||
|
||
// ── Match from library — LLM picks the best EXISTING voice for each character ──
|
||
// ── Character research → per-character note ──────────────────────────────────
|
||
// Store the LLM's reading of the play as each character's "soul" note (+ gender / style).
|
||
function rehWriteCharacterNote(sp, info) {
|
||
const c = rehState.cast[sp]; if (!c || !info) return;
|
||
if (info.gender && !c.gender) c.gender = String(info.gender).toUpperCase().charAt(0).replace(/[^MFN]/, 'N');
|
||
const bits = [];
|
||
if (info.age) bits.push(`Age: ${info.age}`);
|
||
if (info.description) bits.push(info.description);
|
||
const note = bits.join(' · ');
|
||
if (note && !c.soul) c.soul = note; // fills the Character-soul / LLM-brief note
|
||
if (info.description && !c.instruct) c.instruct = info.description;
|
||
}
|
||
|
||
// Ask the LLM to read the script + cast, then annotate every character with a note.
|
||
// Returns a name→info map (used by the online/design flows that need gender too).
|
||
async function rehResearchCast(speakers) {
|
||
const names = speakers.filter(sp => sp !== REH_NARRATOR_KEY);
|
||
if (!names.length) return {};
|
||
let chars = [];
|
||
try {
|
||
const r = await fetch('/api/analyze-characters', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
script: $('reh-script-text')?.value.trim() || linesToScriptText(), names,
|
||
llm_url: $('reh-llm-url')?.value.trim() || rehDefaultLlmUrl(),
|
||
model: $('reh-llm-model')?.value || '', language: $('reh-design-lang')?.value || 'English',
|
||
}),
|
||
});
|
||
if (r.ok) chars = (await r.json()).characters || [];
|
||
} catch (_) {}
|
||
const byName = {};
|
||
chars.forEach(c => { if (c.name) byName[String(c.name).toUpperCase().trim()] = c; });
|
||
speakers.forEach(sp => rehWriteCharacterNote(sp, byName[sp.toUpperCase().trim()]));
|
||
return byName;
|
||
}
|
||
|
||
$('reh-matchlib-btn')?.addEventListener('click', async () => {
|
||
const btn = $('reh-matchlib-btn');
|
||
const speakers = Object.keys(rehState.cast).filter(sp => rehState.cast[sp].voice !== 'me');
|
||
if (!speakers.length) { toast('No characters to match', 'error'); return; }
|
||
let lib = (window._voices || []).filter(v => v.enabled !== false);
|
||
if (!lib.length) {
|
||
// Library metadata isn't loaded yet (opened the Rehearser directly) — fetch it now.
|
||
try { lib = (await fetch('/api/voices').then(r => r.json())).filter(v => v.enabled !== false); } catch (_) {}
|
||
}
|
||
if (!lib.length) { toast('Your voice library is empty — clone, design or import some voices first', 'error'); return; }
|
||
|
||
const candidates = lib.map(v => ({
|
||
id: v.id, gender: v.gender || '', language: v.lang || '',
|
||
tags: v.tag || '', description: (v.note || v.name || '').slice(0, 140),
|
||
}));
|
||
const nameFor = sp => (sp === REH_NARRATOR_KEY ? 'Narrator' : sp);
|
||
const byDisplay = {};
|
||
speakers.forEach(sp => { byDisplay[nameFor(sp).toUpperCase().trim()] = sp; });
|
||
|
||
const orig = btn.innerHTML;
|
||
btn.disabled = true; btn.innerHTML = '<span class="reh-imsdb-spinner"></span> Matching…';
|
||
try {
|
||
const r = await fetch('/api/match-characters-voices', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
script: $('reh-script-text')?.value.trim() || linesToScriptText(),
|
||
names: speakers.map(nameFor),
|
||
voices: candidates,
|
||
llm_url: $('reh-llm-url')?.value.trim() || rehDefaultLlmUrl(),
|
||
model: $('reh-llm-model')?.value || '',
|
||
language: $('reh-design-lang')?.value || 'English',
|
||
}),
|
||
});
|
||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
|
||
const assignments = (await r.json()).assignments || [];
|
||
const validIds = new Set(candidates.map(c => c.id));
|
||
let n = 0;
|
||
assignments.forEach(a => {
|
||
const sp = byDisplay[String(a.name || '').toUpperCase().trim()];
|
||
if (sp && a.voice_id && validIds.has(a.voice_id)) {
|
||
rehState.cast[sp].voice = a.voice_id;
|
||
rehState.cast[sp].voiceData = getVoiceData(a.voice_id);
|
||
if (!rehState.voices.includes(a.voice_id)) rehState.voices.push(a.voice_id);
|
||
if (sp === REH_NARRATOR_KEY) rehState.narratorVoice = a.voice_id;
|
||
n++;
|
||
}
|
||
});
|
||
// Research the play + cast and drop a note on each character
|
||
btn.innerHTML = '<span class="reh-imsdb-spinner"></span> Researching characters…';
|
||
await rehResearchCast(speakers);
|
||
renderCastList();
|
||
populateNarratorSelect();
|
||
toast(n ? `Matched ${n} character${n !== 1 ? 's' : ''} & added notes` : 'No good matches — try “Design all voices” instead', n ? 'success' : 'error');
|
||
} catch (e) {
|
||
toast('Match failed: ' + e.message, 'error');
|
||
} finally {
|
||
btn.disabled = false; btn.innerHTML = orig;
|
||
}
|
||
});
|
||
|
||
// ── Match from online — search fish.audio per character, import & assign ──────
|
||
const REH_FISH_LANG = { English: 'en', German: 'de', French: 'fr', Spanish: 'es', Italian: 'it', Portuguese: 'pt', Dutch: 'nl', Auto: '' };
|
||
$('reh-matchonline-btn')?.addEventListener('click', async () => {
|
||
const btn = $('reh-matchonline-btn');
|
||
const speakers = Object.keys(rehState.cast).filter(sp => rehState.cast[sp].voice !== 'me' && sp !== REH_NARRATOR_KEY);
|
||
if (!speakers.length) { toast('No characters to match', 'error'); return; }
|
||
const lang = REH_FISH_LANG[$('reh-design-lang')?.value || 'English'] ?? 'en';
|
||
const prog = $('reh-autodesign-progress'), fill = $('reh-autodesign-fill'), label = $('reh-autodesign-label');
|
||
const setProg = (d, t, msg) => { if (fill) fill.style.width = (t ? (d / t) * 100 : 0) + '%'; if (label) label.textContent = msg || `${d} / ${t}`; };
|
||
|
||
const orig = btn.innerHTML;
|
||
btn.disabled = true; btn.innerHTML = '<span class="reh-imsdb-spinner"></span> Analyzing…';
|
||
if (prog) prog.hidden = false;
|
||
rehDesignCancelled = false;
|
||
try {
|
||
// 1) Ask the LLM for each character's gender (drives the online search)
|
||
let chars = [];
|
||
try {
|
||
const ar = await fetch('/api/analyze-characters', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ script: $('reh-script-text')?.value.trim() || linesToScriptText(), names: speakers,
|
||
llm_url: $('reh-llm-url')?.value.trim() || rehDefaultLlmUrl(), model: $('reh-llm-model')?.value || '',
|
||
language: $('reh-design-lang')?.value || 'English' }),
|
||
});
|
||
if (ar.ok) chars = (await ar.json()).characters || [];
|
||
} catch (_) {}
|
||
const byName = {}; chars.forEach(c => { if (c.name) byName[String(c.name).toUpperCase().trim()] = c; });
|
||
const G = { M: 'male', F: 'female', N: 'neutral' };
|
||
|
||
let done = 0, n = 0;
|
||
for (let i = 0; i < speakers.length; i++) {
|
||
if (rehDesignCancelled) { toast('Cancelled', 'error'); break; }
|
||
const sp = speakers[i];
|
||
setProg(done, speakers.length, `Matching ${sp}… (${done + 1}/${speakers.length})`);
|
||
const info = byName[sp.toUpperCase().trim()] || {};
|
||
rehWriteCharacterNote(sp, info); // drop the LLM's character research into a note
|
||
const gender = G[(info.gender || '').toUpperCase().charAt(0)] || '';
|
||
// Search fish.audio: by character name first (catches known/celebrity names), then gender+lang
|
||
const qs = [`search=${encodeURIComponent(sp)}`, `language=${lang}`, 'page_size=8', 'sort_by=score'];
|
||
let items = [];
|
||
try { items = (await fetch('/api/fishaudio/voices?' + qs.join('&')).then(r => r.json())).items || []; } catch (_) {}
|
||
let pick = items.find(v => v.sample_audio);
|
||
let nameHit = !!pick; // got a result by searching the character's actual name
|
||
if (!pick) { // fallback: best by gender+language, offset per character to diversify
|
||
const fb = [`language=${lang}`, gender ? `gender=${gender}` : '', 'page_size=12', 'sort_by=score', `page=${(i % 4) + 1}`].filter(Boolean);
|
||
try { items = (await fetch('/api/fishaudio/voices?' + fb.join('&')).then(r => r.json())).items || []; } catch (_) {}
|
||
pick = items.find(v => v.sample_audio);
|
||
}
|
||
// Keep the top few playable results so the user can audition & swap them later
|
||
let cands = items.filter(v => v.sample_audio).slice(0, 6).map(v => ({
|
||
title: v.title, sample_audio: v.sample_audio, image: v.image || '', gender: v.gender || '',
|
||
language: v.language || lang || '', description: v.description || '', sample_text: v.sample_text || v.default_text || '',
|
||
}));
|
||
// When the name didn't match, the fallback returns the same popular voices for
|
||
// everyone — rotate the pick per character so they don't all sound identical,
|
||
// and skip voices already taken by earlier characters this run.
|
||
if (!nameHit && cands.length > 1) {
|
||
const taken = new Set(Object.values(rehState.cast).map(c => (c.online?.candidates?.[c.online.picked]?.sample_audio)).filter(Boolean));
|
||
const rotated = [...cands.slice(i % cands.length), ...cands.slice(0, i % cands.length)];
|
||
cands = rotated.sort((a, b) => (taken.has(a.sample_audio) ? 1 : 0) - (taken.has(b.sample_audio) ? 1 : 0));
|
||
}
|
||
if (cands.length) {
|
||
try {
|
||
const vid = await _rehImportFishCandidate(sp, cands[0]);
|
||
cands[0].voice_id = vid;
|
||
rehState.cast[sp].voice = vid;
|
||
rehState.cast[sp].voiceData = getVoiceData(vid);
|
||
rehState.cast[sp].online = { candidates: cands, picked: 0 };
|
||
n++;
|
||
} catch (e) { logErr('match-online import ' + sp, e); }
|
||
}
|
||
done++; setProg(done, speakers.length);
|
||
}
|
||
if (typeof loadVoiceLibrary === 'function') await loadVoiceLibrary().catch(() => {});
|
||
renderCastList();
|
||
populateNarratorSelect();
|
||
toast(n ? `Imported & matched ${n} online voice${n !== 1 ? 's' : ''}` : 'No online matches found', n ? 'success' : 'error');
|
||
} catch (e) {
|
||
toast('Online match failed: ' + e.message, 'error');
|
||
} finally {
|
||
btn.disabled = false; btn.innerHTML = orig;
|
||
if (prog) prog.hidden = true;
|
||
}
|
||
});
|
||
|
||
// Show per-character design progress card beneath the cast row
|
||
function rehMarkCastDesigning(sp, state, msg, info) {
|
||
const row = document.querySelector(`.reh-cast-row[data-speaker="${CSS.escape(sp)}"]`);
|
||
if (!row) return;
|
||
|
||
// Inline badge in the cast row header
|
||
let badge = row.querySelector('.reh-cast-design-badge');
|
||
if (!badge) {
|
||
badge = document.createElement('span');
|
||
badge.className = 'reh-cast-design-badge';
|
||
row.querySelector('strong')?.after(badge);
|
||
}
|
||
badge.className = 'reh-cast-design-badge' + (state === 'done' ? ' done' : state === 'err' ? ' err' : '');
|
||
badge.textContent = state === 'designing' ? '✨ designing…' : state === 'done' ? '✓ designed' : '✗ failed';
|
||
if (msg) badge.title = msg;
|
||
row.classList.toggle('reh-cast-designing', state === 'designing');
|
||
|
||
// Expanded detail panel beneath the instruct row
|
||
const wrap = row.closest('div');
|
||
let panel = wrap?.querySelector('.reh-cast-design-panel');
|
||
|
||
if (state === 'designing' && info) {
|
||
if (!panel) {
|
||
panel = document.createElement('div');
|
||
panel.className = 'reh-cast-design-panel';
|
||
wrap.appendChild(panel);
|
||
}
|
||
const genderIcon = info.gender === 'M' ? '♂' : info.gender === 'F' ? '♀' : '⚧';
|
||
const genderLabel = info.gender === 'M' ? 'Male' : info.gender === 'F' ? 'Female' : 'Neutral';
|
||
panel.innerHTML = `
|
||
<div class="reh-cdp-meta">
|
||
<span class="reh-cdp-chip reh-cdp-gender">${genderIcon} ${escHtml(genderLabel)}</span>
|
||
<span class="reh-cdp-chip reh-cdp-lang"><span class="mdi mdi-translate"></span> ${escHtml(info.language || 'EN')}</span>
|
||
<span class="reh-cdp-chip reh-cdp-name"><span class="mdi mdi-account-outline"></span> ${escHtml(info.voiceId || sp)}</span>
|
||
${info.age ? `<span class="reh-cdp-chip">${escHtml(info.age)}</span>` : ''}
|
||
</div>
|
||
<div class="reh-cdp-desc">${escHtml(info.desc || '')}</div>
|
||
<div class="reh-cdp-step"><span class="reh-imsdb-spinner"></span> <span class="reh-cdp-step-txt">${escHtml(info.step || 'Designing voice…')}</span></div>`;
|
||
} else if (state === 'done' && panel) {
|
||
// Collapse to a summary line
|
||
const descEl = panel.querySelector('.reh-cdp-desc');
|
||
const metaEl = panel.querySelector('.reh-cdp-meta');
|
||
const stepEl = panel.querySelector('.reh-cdp-step');
|
||
if (stepEl) stepEl.remove();
|
||
if (descEl) {
|
||
// Truncate
|
||
const t = descEl.textContent;
|
||
if (t.length > 160) descEl.textContent = t.slice(0, 157) + '…';
|
||
}
|
||
panel.classList.add('done');
|
||
} else if (state === 'err') {
|
||
if (panel) panel.remove();
|
||
}
|
||
}
|
||
|
||
$('reh-start-btn')?.addEventListener('click', () => {
|
||
const backend = $('reh-backend-select')?.value;
|
||
if (!backend) { toast('Select a backend first', 'error'); return; }
|
||
rehState.backend = backend;
|
||
rehState.narratorVoice = $('reh-narrator-voice')?.value || '';
|
||
rehState.lineIndex = 0;
|
||
rehState.clips = [];
|
||
rehState.playing = false;
|
||
rehState.synthCache.clear(); rehDecodedBuffers.clear();
|
||
rehState.practiceStart = null;
|
||
rehState.practiceEnd = null;
|
||
buildScriptPage();
|
||
showPhase(3);
|
||
highlightCurrentLine();
|
||
});
|
||
|
||
// ── A4 Script page ─────────────────────────────────────────────────────────
|
||
|
||
function buildScriptPage() {
|
||
const titleEl = $('reh-page-title');
|
||
if (titleEl) titleEl.textContent = $('reh-script-title')?.value.trim() || 'Script';
|
||
|
||
// Cast strip
|
||
const castStrip = $('reh-cast-strip');
|
||
if (castStrip) {
|
||
castStrip.innerHTML = Object.keys(rehState.cast).map(sp => {
|
||
const c = rehState.cast[sp], isMe = c.voice === 'me';
|
||
return `<div class="reh-strip-char" title="${escHtml(sp)} — ${isMe?'me':(c.voice||'no voice')}">
|
||
${voiceAvatarHtml(isMe?'':c.voice, c.color, 28)}
|
||
<span style="font-size:10px;font-weight:700;color:${c.color};max-width:56px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(sp)}</span>
|
||
${isMe?'<span class="mdi mdi-microphone" style="font-size:10px;color:var(--subtext)"></span>':''}
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
const 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' : '');
|
||
|
||
switch (line.type) {
|
||
case 'act':
|
||
return `<div class="reh-act${lineFlags}" data-index="${i}" style="position:relative">${bulkCheck}${escHtml(line.text)} ${editBtn} ${synthDot}</div>`;
|
||
|
||
case 'scene':
|
||
return `<div class="reh-scene${lineFlags}" data-index="${i}">
|
||
${bulkCheck}<span class="mdi mdi-film" style="flex-shrink:0"></span>
|
||
<span style="flex:1">${escHtml(line.text)}</span>
|
||
${editBtn} ${synthDot}
|
||
</div>`;
|
||
|
||
case 'action':
|
||
return `<div class="reh-action-block${lineFlags}" data-index="${i}">
|
||
${bulkCheck}<span class="reh-action-text">${renderMarkdownInline(line.text)}</span>
|
||
${editBtn} ${synthDot}
|
||
</div>`;
|
||
|
||
case 'pagebreak':
|
||
return `<div class="reh-block-pagebreak${lineFlags}" data-index="${i}">${bulkCheck}<span class="reh-pagebreak-label">— Page break —</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">
|
||
${voiceAvatarHtml(isMe?'':c.voice, c.color, 32)}
|
||
<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)); });
|
||
});
|
||
|
||
// 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
|
||
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, textarea, select')) return;
|
||
stopPlay();
|
||
hideRecOverlay();
|
||
rehState.lineIndex = parseInt(el.dataset.index);
|
||
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
|
||
linesEl.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));
|
||
});
|
||
});
|
||
}
|
||
|
||
// 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) {
|
||
if (rehState.bulkSel.has(i)) rehState.bulkSel.delete(i);
|
||
else rehState.bulkSel.add(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');
|
||
});
|
||
}
|
||
|
||
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();
|
||
const bar = $('reh-bulk-bar'); if (bar) bar.hidden = !on;
|
||
const btn = $('reh-bulk-toggle'); if (btn) btn.classList.toggle('active', on);
|
||
_updateBulkCount();
|
||
if (rehState.lines.length) buildScriptPage();
|
||
}
|
||
|
||
// Apply a mutation to all selected lines, then re-render.
|
||
function _bulkApply(fn, { keepSelection = false } = {}) {
|
||
if (!rehState.bulkSel.size) { toast('No lines selected', 'error'); return; }
|
||
[...rehState.bulkSel].forEach(i => { const l = rehState.lines[i]; if (l) fn(l, i); });
|
||
if (!keepSelection) rehState.bulkSel.clear();
|
||
_updateBulkCount();
|
||
buildScriptPage();
|
||
}
|
||
|
||
function _bulkDelete() {
|
||
if (!rehState.bulkSel.size) { toast('No lines selected', 'error'); return; }
|
||
const victims = [...rehState.bulkSel].sort((a, b) => b - a); // high → low so indices stay valid
|
||
victims.forEach(i => {
|
||
rehState.lines.splice(i, 1);
|
||
_reindexLineState(i);
|
||
});
|
||
rehState.bulkSel.clear();
|
||
// Keep the cursor in range
|
||
if (rehState.lineIndex >= rehState.lines.length) rehState.lineIndex = Math.max(0, rehState.lines.length - 1);
|
||
_updateBulkCount();
|
||
buildScriptPage();
|
||
highlightCurrentLine();
|
||
toast(`Deleted ${victims.length} line${victims.length !== 1 ? 's' : ''}`, 'success');
|
||
}
|
||
|
||
// After deleting line `d`, shift every index-keyed bit of state above it down by one.
|
||
function _reindexLineState(d) {
|
||
const shift = (collection, isMap) => {
|
||
const out = isMap ? new Map() : new Set();
|
||
for (const entry of collection) {
|
||
const k = isMap ? entry[0] : entry;
|
||
if (k === d) continue; // dropped line
|
||
const nk = k > d ? k - 1 : k;
|
||
if (isMap) out.set(nk, entry[1]); else out.add(nk);
|
||
}
|
||
return out;
|
||
};
|
||
rehState.synthCache = shift(rehState.synthCache, true);
|
||
rehState.staleLines = shift(rehState.staleLines, false);
|
||
const sel = shift(rehState.bulkSel, false);
|
||
rehState.bulkSel.clear(); sel.forEach(v => rehState.bulkSel.add(v));
|
||
if (rehState.practiceStart != null && rehState.practiceStart > d) rehState.practiceStart--;
|
||
if (rehState.practiceEnd != null && rehState.practiceEnd > d) rehState.practiceEnd--;
|
||
if (rehState.lineIndex > d) rehState.lineIndex--;
|
||
}
|
||
|
||
// ── Page mode (endless scroll | auto-pages | pdf-pages) ─────────────────────
|
||
const PAGE_MODES = ['auto', 'scroll', 'pdf'];
|
||
let _pageMode = localStorage.getItem('reh-page-mode') || 'auto';
|
||
|
||
function applyPageMode() {
|
||
const wrap = document.querySelector('.reh-page-wrap');
|
||
const a4 = $('reh-a4-page');
|
||
if (!wrap) return;
|
||
|
||
if (_pageMode === 'scroll') {
|
||
// Endless scroll — put all blocks back into the a4 div, no paper pages
|
||
wrap.querySelectorAll('.reh-paper').forEach(p => {
|
||
[...p.children].forEach(c => { if (!c.classList.contains('reh-paper-num')) a4?.appendChild(c); });
|
||
p.remove();
|
||
});
|
||
wrap.classList.remove('paginated');
|
||
if (a4) a4.style.display = '';
|
||
// Hide pagebreak visual dividers in scroll mode
|
||
document.querySelectorAll('.reh-block-pagebreak').forEach(el => el.style.display = 'none');
|
||
} else if (_pageMode === 'pdf') {
|
||
// PDF pages — paginate at pagebreak markers
|
||
paginateScript({ respectBreaks: true });
|
||
} else {
|
||
// Auto — paginate by content height
|
||
paginateScript({ respectBreaks: false });
|
||
}
|
||
_syncPageModeBtn();
|
||
}
|
||
|
||
function _syncPageModeBtn() {
|
||
const btn = $('reh-page-mode-btn');
|
||
if (!btn) return;
|
||
const icons = { auto: 'mdi-file-document-outline', scroll: 'mdi-format-align-justify', pdf: 'mdi-book-open-page-variant' };
|
||
const labels = { auto: 'A4 pages', scroll: 'Scroll', pdf: 'PDF pages' };
|
||
btn.innerHTML = `<span class="mdi ${icons[_pageMode] || icons.auto}"></span> ${labels[_pageMode] || labels.auto}`;
|
||
btn.title = 'Switch view: ' + labels[_pageMode];
|
||
}
|
||
|
||
function cyclePageMode() {
|
||
const idx = PAGE_MODES.indexOf(_pageMode);
|
||
_pageMode = PAGE_MODES[(idx + 1) % PAGE_MODES.length];
|
||
localStorage.setItem('reh-page-mode', _pageMode);
|
||
applyPageMode();
|
||
// Rebuild script page to apply cleanly
|
||
if (rehState.lines.length) buildScriptPage();
|
||
}
|
||
|
||
// ── Pagination: split the continuous script into A4 paper pages ─────────────
|
||
function paginateScript({ respectBreaks = false } = {}) {
|
||
const wrap = document.querySelector('.reh-page-wrap');
|
||
const linesEl = $('reh-script-lines');
|
||
const titleEl = $('reh-page-title');
|
||
if (!wrap || !linesEl) return;
|
||
|
||
// Collect the already-wired block elements (moving them keeps listeners)
|
||
const blocks = [...linesEl.children];
|
||
if (!blocks.length) return;
|
||
|
||
wrap.classList.add('paginated');
|
||
const a4 = $('reh-a4-page');
|
||
if (a4) a4.style.display = 'none';
|
||
wrap.querySelectorAll('.reh-paper').forEach(p => p.remove());
|
||
|
||
// Printable content height inside a page (A4 minus vertical padding)
|
||
const PAGE_CONTENT = 1027; // 1123 − 56 − 40
|
||
const TITLE_SPACE = 64; // page-1 title block allowance
|
||
|
||
let pageNum = 0, page = null, used = 0;
|
||
const newPage = () => {
|
||
pageNum++;
|
||
page = document.createElement('div');
|
||
page.className = 'reh-paper';
|
||
const num = document.createElement('div');
|
||
num.className = 'reh-paper-num';
|
||
num.textContent = pageNum + '.';
|
||
page.appendChild(num);
|
||
used = 0;
|
||
if (pageNum === 1 && titleEl) {
|
||
const t = titleEl.cloneNode(true);
|
||
t.style.display = '';
|
||
page.appendChild(t);
|
||
used += TITLE_SPACE;
|
||
}
|
||
wrap.appendChild(page);
|
||
};
|
||
newPage();
|
||
|
||
for (const block of blocks) {
|
||
// PDF page-break marker: always start a new page here
|
||
if (respectBreaks && block.classList.contains('reh-block-pagebreak')) {
|
||
newPage();
|
||
continue; // don't move the marker div into the page
|
||
}
|
||
|
||
// Measure block height (must be in DOM to measure → append then check)
|
||
page.appendChild(block);
|
||
const cs = getComputedStyle(block);
|
||
const h = block.offsetHeight + (parseFloat(cs.marginTop) || 0) + (parseFloat(cs.marginBottom) || 0);
|
||
|
||
// If adding this block overflows the page (and page already has content), move to a new page
|
||
if (!respectBreaks && used + h > PAGE_CONTENT && used > (pageNum === 1 ? TITLE_SPACE : 0)) {
|
||
newPage();
|
||
page.appendChild(block);
|
||
}
|
||
used += h;
|
||
}
|
||
}
|
||
|
||
function isPracticeRange(idx) {
|
||
const s = rehState.practiceStart, e = rehState.practiceEnd;
|
||
if (s === null) return false;
|
||
return idx >= s && (e === null || idx <= e);
|
||
}
|
||
|
||
// ── Inline editing ──────────────────────────────────────────────────────────
|
||
|
||
function startInlineEdit(idx) {
|
||
const line = rehState.lines[idx]; if (!line) return;
|
||
const dialogEl = document.getElementById('reh-diag-' + idx);
|
||
const actionEl = document.querySelector(`[data-index="${idx}"] .reh-action-text`);
|
||
const sceneEl = document.querySelector(`.reh-scene[data-index="${idx}"] span[style*="flex:1"]`);
|
||
const targetEl = dialogEl || actionEl || sceneEl;
|
||
if (!targetEl || targetEl.tagName === 'TEXTAREA') return;
|
||
|
||
const orig = line.text;
|
||
const ta = document.createElement('textarea');
|
||
ta.value = orig;
|
||
ta.style.cssText = 'width:100%;min-height:54px;font-size:14px;line-height:1.6;padding:5px 8px;border:1px solid var(--accent);border-top:none;border-radius:0 0 3px 3px;resize:vertical;background:#fffff8;font-family:inherit;display:block;box-sizing:border-box;outline:none;';
|
||
|
||
// Formatting toolbar
|
||
const toolbar = document.createElement('div');
|
||
toolbar.className = 'reh-fmt-toolbar';
|
||
toolbar.innerHTML = `
|
||
<button class="reh-fmt-btn" data-b="**" title="Bold"><b>B</b></button>
|
||
<button class="reh-fmt-btn" data-b="*" title="Italic"><i>I</i></button>
|
||
<button class="reh-fmt-btn" data-b="__" title="Underline"><u>U</u></button>
|
||
<button class="reh-fmt-btn" data-b="~~" title="Strikethrough"><del>S</del></button>
|
||
<button class="reh-fmt-btn" data-b="==" title="Highlight" style="background:#fef3c7">H</button>
|
||
<kbd class="reh-fmt-hint">Ctrl+Enter save · Esc cancel</kbd>
|
||
`;
|
||
toolbar.querySelectorAll('.reh-fmt-btn').forEach(btn => {
|
||
btn.addEventListener('mousedown', e => {
|
||
e.preventDefault();
|
||
const wrap = btn.dataset.b;
|
||
const s = ta.selectionStart, end = ta.selectionEnd;
|
||
const sel = ta.value.slice(s, end) || 'text';
|
||
ta.value = ta.value.slice(0, s) + wrap + sel + wrap + ta.value.slice(end);
|
||
ta.setSelectionRange(s + wrap.length, s + wrap.length + sel.length);
|
||
ta.focus();
|
||
});
|
||
});
|
||
|
||
targetEl.style.display = 'none';
|
||
targetEl.before(toolbar);
|
||
toolbar.after(ta);
|
||
ta.focus(); ta.select();
|
||
|
||
const save = () => {
|
||
const newText = ta.value.trim() || orig;
|
||
line.text = newText;
|
||
targetEl.innerHTML = renderMarkdownInline(newText);
|
||
targetEl.style.display = '';
|
||
toolbar.remove(); ta.remove();
|
||
rehState.synthCache.delete(idx);
|
||
const dot = document.getElementById('reh-syd-' + idx);
|
||
if (dot) dot.style.display = 'none';
|
||
};
|
||
|
||
ta.addEventListener('keydown', e => {
|
||
if (e.key === 'Escape') { ta.value = orig; save(); }
|
||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); save(); }
|
||
});
|
||
ta.addEventListener('blur', save);
|
||
}
|
||
|
||
// ── Script editor modal ─────────────────────────────────────────────────────
|
||
|
||
function openScriptEditorModal() {
|
||
const currentTitle = $('reh-page-title')?.textContent || $('reh-script-title')?.value || 'Script';
|
||
const currentScript = linesToScriptText() || $('reh-script-text')?.value || '';
|
||
|
||
const modal = document.createElement('div');
|
||
modal.className = 'reh-modal-overlay';
|
||
modal.innerHTML = `
|
||
<div class="reh-modal-box" style="max-width:720px;width:95vw;height:80vh">
|
||
<div class="reh-modal-head">
|
||
<h3 style="margin:0;font-size:15px"><span class="mdi mdi-script-text-outline"></span> Edit Script</h3>
|
||
<button class="btn-secondary btn-sm" id="_reh-mc">✕ Close</button>
|
||
</div>
|
||
<div style="padding:10px 14px;border-bottom:1px solid var(--border);display:flex;gap:10px;align-items:center">
|
||
<label style="font-size:12px;font-weight:600;flex-shrink:0">Title</label>
|
||
<input type="text" id="_reh-mt" value="${escHtml(currentTitle)}" style="flex:1;padding:5px 8px;border:1px solid var(--border);border-radius:4px;font-size:13px;background:var(--surface);color:var(--text)">
|
||
</div>
|
||
<textarea id="_reh-ms" style="flex:1;padding:14px;font-family:'Courier New',monospace;font-size:12.5px;border:none;outline:none;resize:none;background:var(--surface);color:var(--text);height:calc(80vh - 140px);width:100%;box-sizing:border-box;" spellcheck="false">${escHtml(currentScript)}</textarea>
|
||
<div style="padding:10px 14px;display:flex;gap:8px;justify-content:flex-end;border-top:1px solid var(--border)">
|
||
<button class="btn-secondary" id="_reh-mcan">Cancel</button>
|
||
<button class="btn-primary" id="_reh-mapp"><span class="mdi mdi-auto-fix"></span> Apply changes</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(modal);
|
||
|
||
const close = () => modal.remove();
|
||
modal.querySelector('#_reh-mc').onclick = close;
|
||
modal.querySelector('#_reh-mcan').onclick = close;
|
||
modal.addEventListener('click', e => { if (e.target === modal) close(); });
|
||
|
||
modal.querySelector('#_reh-mapp').onclick = () => {
|
||
const newTitle = modal.querySelector('#_reh-mt').value.trim() || 'Script';
|
||
const newScript = modal.querySelector('#_reh-ms').value;
|
||
|
||
const newLines = parseScript(newScript);
|
||
if (!newLines.filter(l => l.type === 'dialog').length) {
|
||
toast('No dialog found in the edited script', 'error'); return;
|
||
}
|
||
|
||
// Merge emotions for unchanged lines
|
||
newLines.forEach(nl => {
|
||
if (nl.type !== 'dialog') return;
|
||
const old = rehState.lines.find(ol => ol.type==='dialog' && ol.speaker===nl.speaker && ol.text===nl.text);
|
||
if (old) nl.emotion = old.emotion;
|
||
});
|
||
|
||
// Keep cast for matching characters
|
||
const detected = detectCharacters(newLines);
|
||
Object.entries(detected).forEach(([sp]) => {
|
||
if (rehState.cast[sp]) detected[sp] = { ...detected[sp], ...rehState.cast[sp] };
|
||
});
|
||
|
||
// Update state
|
||
rehState.lines = newLines;
|
||
rehState.cast = detected;
|
||
rehState.lineIndex = Math.min(rehState.lineIndex, newLines.length - 1);
|
||
rehState.synthCache.clear(); rehDecodedBuffers.clear();
|
||
rehState.practiceStart = null;
|
||
rehState.practiceEnd = null;
|
||
|
||
if ($('reh-script-title')) $('reh-script-title').value = newTitle;
|
||
if ($('reh-page-title')) $('reh-page-title').textContent = newTitle;
|
||
|
||
buildScriptPage();
|
||
highlightCurrentLine();
|
||
close();
|
||
toast('Script updated', 'success');
|
||
};
|
||
}
|
||
|
||
// ── Practice range ──────────────────────────────────────────────────────────
|
||
|
||
function updatePracticeRange() {
|
||
const start = rehState.practiceStart;
|
||
const end = rehState.practiceEnd;
|
||
|
||
document.querySelectorAll('.reh-block').forEach(el => {
|
||
const idx = parseInt(el.dataset.index);
|
||
el.classList.toggle('reh-in-range', start !== null && idx >= start && (end === null || idx <= end));
|
||
el.classList.toggle('reh-range-start', start !== null && idx === start);
|
||
el.classList.toggle('reh-range-end', end !== null && idx === end);
|
||
const gb = el.querySelector('.reh-gutter-btn');
|
||
if (gb) gb.textContent = idx === start ? '▶' : idx === end ? '■' : '';
|
||
});
|
||
|
||
const info = $('reh-practice-info');
|
||
if (info) {
|
||
const show = end !== null;
|
||
info.classList.toggle('visible', show);
|
||
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();
|
||
}
|
||
|
||
$('reh-practice-clear')?.addEventListener('click', clearPracticeRange);
|
||
|
||
// ── Emotion picker popover ──────────────────────────────────────────────────
|
||
|
||
let rehEmoPicker = null;
|
||
|
||
function openEmoPicker(idx, anchorBtn) {
|
||
closeEmoPicker();
|
||
const line = rehState.lines[idx];
|
||
const allEmos = [...REH_EMOTIONS, ...rehCustomEmotions];
|
||
const rect = anchorBtn.getBoundingClientRect();
|
||
const left = Math.min(rect.left, window.innerWidth - 265);
|
||
const topBelow = rect.bottom + 4;
|
||
const topAbove = rect.top - 4;
|
||
const spaceBelow = window.innerHeight - topBelow;
|
||
const top = spaceBelow >= 240 ? topBelow : (topAbove - 260);
|
||
|
||
const pop = document.createElement('div');
|
||
pop.className = 'reh-emo-popover';
|
||
pop.style.cssText = `position:fixed;z-index:1000;top:${top}px;left:${left}px;width:258px;`;
|
||
pop.innerHTML = `<div class="reh-emo-pop-inner">
|
||
<input class="reh-emo-search" placeholder="Search or type custom tone…" autocomplete="off">
|
||
<div class="reh-emo-list"></div>
|
||
<div class="reh-emo-custom-row">
|
||
<button class="btn-secondary btn-sm" id="_emo-custom-apply" style="width:100%;font-size:11px">
|
||
<span class="mdi mdi-plus"></span> Use as custom tone
|
||
</button>
|
||
</div>
|
||
</div>`;
|
||
document.body.appendChild(pop);
|
||
rehEmoPicker = pop;
|
||
|
||
const searchEl = pop.querySelector('.reh-emo-search');
|
||
const listEl = pop.querySelector('.reh-emo-list');
|
||
const applyBtn = pop.querySelector('#_emo-custom-apply');
|
||
|
||
function renderList(q = '') {
|
||
const lq = q.toLowerCase();
|
||
const filtered = allEmos.filter(e => !q || e.label.toLowerCase().includes(lq) || e.value.toLowerCase().includes(lq));
|
||
listEl.innerHTML = filtered.map(e =>
|
||
`<div class="reh-emo-item${line.emotion===e.value?' selected':''}" data-value="${escHtml(e.value)}">
|
||
<span class="reh-emo-item-emoji">${e.emoji}</span>
|
||
<span class="reh-emo-item-label">${escHtml(e.label)}</span>
|
||
${line.emotion===e.value?'<span class="mdi mdi-check" style="margin-left:auto;font-size:13px;color:var(--accent)"></span>':''}
|
||
</div>`
|
||
).join('');
|
||
listEl.querySelectorAll('.reh-emo-item').forEach(item => {
|
||
item.addEventListener('mousedown', e => {
|
||
e.preventDefault();
|
||
selectEmotion(idx, item.dataset.value, anchorBtn);
|
||
closeEmoPicker();
|
||
});
|
||
});
|
||
}
|
||
|
||
searchEl.addEventListener('input', () => renderList(searchEl.value));
|
||
searchEl.addEventListener('keydown', e => {
|
||
if (e.key === 'Escape') closeEmoPicker();
|
||
if (e.key === 'Enter') {
|
||
const val = searchEl.value.trim();
|
||
if (val) { selectEmotion(idx, val, anchorBtn); closeEmoPicker(); }
|
||
}
|
||
});
|
||
|
||
applyBtn.addEventListener('mousedown', e => {
|
||
e.preventDefault();
|
||
const val = searchEl.value.trim(); if (!val) return;
|
||
const exists = [...REH_EMOTIONS, ...rehCustomEmotions].find(e => e.value === val);
|
||
if (!exists) {
|
||
rehCustomEmotions.push({ emoji: '✨', label: val, value: val, custom: true });
|
||
try { localStorage.setItem('reh-custom-emotions', JSON.stringify(rehCustomEmotions)); } catch(_) {}
|
||
}
|
||
selectEmotion(idx, val, anchorBtn);
|
||
closeEmoPicker();
|
||
});
|
||
|
||
renderList();
|
||
searchEl.focus();
|
||
|
||
setTimeout(() => document.addEventListener('mousedown', _closePickerOnOutside), 50);
|
||
}
|
||
|
||
function _closePickerOnOutside(e) {
|
||
if (rehEmoPicker && !rehEmoPicker.contains(e.target)) closeEmoPicker();
|
||
}
|
||
|
||
function closeEmoPicker() {
|
||
if (rehEmoPicker) { rehEmoPicker.remove(); rehEmoPicker = null; }
|
||
document.removeEventListener('mousedown', _closePickerOnOutside);
|
||
}
|
||
|
||
function _markSynthDot(idx, state) {
|
||
const dot = document.getElementById('reh-syd-' + idx);
|
||
if (!dot) return;
|
||
dot.className = 'reh-synth-dot'
|
||
+ (state === 'stale' ? ' stale' : '')
|
||
+ (state === 'synthesizing' ? ' synthesizing' : '');
|
||
dot.style.display = state ? '' : 'none';
|
||
dot.title = state === 'stale' ? 'Tone changed — needs re-synthesis'
|
||
: state === 'synthesizing' ? 'Synthesizing…'
|
||
: 'Pre-synthesized';
|
||
// Also highlight the block row itself while synthesizing
|
||
const block = dot.closest('[data-index]');
|
||
if (block) block.classList.toggle('reh-line-synthesizing', state === 'synthesizing');
|
||
}
|
||
|
||
function _showReSynthBtn(idx, show) {
|
||
const btn = document.getElementById('reh-rsb-' + idx);
|
||
if (btn) btn.hidden = !show;
|
||
}
|
||
|
||
async function synthOneLine(idx) {
|
||
const line = rehState.lines[idx];
|
||
if (!line || line.type !== 'dialog') return;
|
||
const c = rehState.cast[line.speaker];
|
||
if (!c || !c.voice || c.voice === 'me') return;
|
||
const instruct = _buildInstruct(c.instruct, line.emotion);
|
||
_showReSynthBtn(idx, false);
|
||
_markSynthDot(idx, 'synthesizing');
|
||
try {
|
||
const blob = await fetchTtsPreviewBlob(c.voice, _rehInlineTone(stripMarkdown(line.text), line.emotion), 'wav', instruct, rehState.backend);
|
||
rehState.synthCache.set(idx, blob);
|
||
rehState.staleLines.delete(idx);
|
||
preDecodeBlob(idx, blob);
|
||
_markSynthDot(idx, 'ok');
|
||
toast('Re-synthesized line ' + (idx + 1), 'success');
|
||
} catch(e) {
|
||
_markSynthDot(idx, null);
|
||
_showReSynthBtn(idx, true);
|
||
toast('Synthesis failed: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
function selectEmotion(idx, value, anchorBtn) {
|
||
rehState.lines[idx].emotion = value;
|
||
if (rehState.synthCache.has(idx)) {
|
||
rehState.synthCache.delete(idx);
|
||
rehState.staleLines.add(idx);
|
||
_markSynthDot(idx, 'stale');
|
||
_updateStaleBatchBtn();
|
||
}
|
||
_showReSynthBtn(idx, true);
|
||
// Update button
|
||
const info = getEmotionInfo(value);
|
||
anchorBtn.className = 'reh-emo-btn' + (value ? ' has-emotion' : '');
|
||
anchorBtn.innerHTML = `${info.emoji ? info.emoji + ' ' : ''}<span class="reh-emo-label">${escHtml(info.label)}</span> <span class="mdi mdi-chevron-down" style="font-size:9px;opacity:.6"></span>`;
|
||
// Show warning if the active backend doesn't reliably support style
|
||
if (value) _checkToneStyleSupport();
|
||
}
|
||
|
||
function _checkToneStyleSupport() {
|
||
const warn = $('reh-tone-warn'); if (!warn) return;
|
||
const txtEl = $('reh-tone-warn-txt');
|
||
const b = (typeof backendById === 'function') ? backendById(rehState.backend) : null;
|
||
if (!b) { warn.hidden = true; return; }
|
||
const all = (typeof availableTtsBackends === 'function') ? availableTtsBackends() : [];
|
||
const hasTone = rehState.lines.some(l => l.type === 'dialog' && l.emotion);
|
||
|
||
// Two opposite engine trade-offs, surfaced so the user can choose knowingly:
|
||
// • clone backends → consistent character identity, but weak tone control
|
||
// • design backends → strong tone, but a fresh persona each call (voices drift)
|
||
if (_rehBackendIsFish()) {
|
||
// Fish-Speech / OpenAudio S2 honours inline [tag] tones (15 000+ tags) injected per line
|
||
if (txtEl) txtEl.innerHTML = `<strong>${escHtml(b.label)}</strong> keeps each 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.` : '';
|
||
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.${suggest}`;
|
||
warn.hidden = false;
|
||
} else {
|
||
warn.hidden = true;
|
||
}
|
||
}
|
||
|
||
$('reh-tone-warn-close')?.addEventListener('click', () => { const w = $('reh-tone-warn'); if (w) w.hidden = true; });
|
||
|
||
// ── Transport controls ──────────────────────────────────────────────────────
|
||
|
||
$('reh-tb-play')?.addEventListener('click', () => {
|
||
if (rehState.playing) pausePlay(); else startPlay();
|
||
});
|
||
|
||
$('reh-tb-stop')?.addEventListener('click', () => {
|
||
stopPlay();
|
||
rehState.lineIndex = rehState.practiceStart ?? 0;
|
||
highlightCurrentLine();
|
||
hideRecOverlay();
|
||
});
|
||
|
||
$('reh-tb-prev')?.addEventListener('click', () => {
|
||
stopPlay();
|
||
rehState.lineIndex = Math.max(0, rehState.lineIndex - 1);
|
||
highlightCurrentLine();
|
||
hideRecOverlay();
|
||
});
|
||
|
||
$('reh-tb-next')?.addEventListener('click', () => {
|
||
stopPlay();
|
||
rehState.lineIndex = Math.min(rehState.lines.length - 1, rehState.lineIndex + 1);
|
||
highlightCurrentLine();
|
||
hideRecOverlay();
|
||
});
|
||
|
||
$('reh-tb-repeat')?.addEventListener('click', () => {
|
||
rehState.repeat = !rehState.repeat;
|
||
$('reh-tb-repeat')?.classList.toggle('reh-btn-active', rehState.repeat);
|
||
});
|
||
|
||
$('reh-skip-desc-toggle')?.addEventListener('change', function () { rehState.skipDescriptions = this.checked; });
|
||
|
||
$('reh-edit-script-btn')?.addEventListener('click', openScriptEditorModal);
|
||
$('reh-fountain-btn')?.addEventListener('click', exportFountain);
|
||
$('reh-fountain-export-p4')?.addEventListener('click', exportFountain);
|
||
$('reh-fdx-export-btn')?.addEventListener('click', exportFDX);
|
||
$('reh-osf-export-btn')?.addEventListener('click', exportOSF);
|
||
|
||
$('reh-exit-btn')?.addEventListener('click', () => {
|
||
stopPlay(); stopRehMic();
|
||
if (rehState.clips.length) { renderSummary(); showPhase(4); }
|
||
else showPhase(2);
|
||
});
|
||
|
||
// ── Page title inline editing ───────────────────────────────────────────────
|
||
|
||
$('reh-page-title')?.addEventListener('dblclick', function () {
|
||
this.contentEditable = 'true';
|
||
this.style.outline = '2px solid var(--accent)';
|
||
this.style.borderRadius = '3px';
|
||
this.focus();
|
||
const range = document.createRange();
|
||
range.selectNodeContents(this);
|
||
window.getSelection().removeAllRanges();
|
||
window.getSelection().addRange(range);
|
||
});
|
||
|
||
$('reh-page-title')?.addEventListener('blur', function () {
|
||
if (this.contentEditable === 'true') {
|
||
this.contentEditable = 'false';
|
||
this.style.outline = '';
|
||
const v = this.textContent.trim() || 'Script';
|
||
this.textContent = v;
|
||
if ($('reh-script-title')) $('reh-script-title').value = v;
|
||
}
|
||
});
|
||
|
||
$('reh-page-title')?.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Enter') { e.preventDefault(); this.blur(); }
|
||
if (e.key === 'Escape') { this.textContent = $('reh-script-title')?.value || 'Script'; this.blur(); }
|
||
});
|
||
|
||
// ── WebAudio pre-decode (eliminates 2-3s silence between lines) ─────────────
|
||
|
||
let _rehPlayCtx = null;
|
||
const rehDecodedBuffers = new Map(); // lineIndex → AudioBuffer (pre-decoded PCM)
|
||
let rehCurrentSource = null; // active AudioBufferSourceNode
|
||
let rehWordHighlightRaf = null;
|
||
|
||
function rehPlayCtx() {
|
||
if (!_rehPlayCtx) _rehPlayCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||
if (_rehPlayCtx.state === 'suspended') _rehPlayCtx.resume().catch(() => {});
|
||
return _rehPlayCtx;
|
||
}
|
||
|
||
// Decoded PCM (Float32) is the heaviest cache and is re-derivable from the cached
|
||
// blob, so keep only a sliding window around the playhead. Without this, a long
|
||
// script holds every line's raw PCM in memory at once and crashes mobile Safari.
|
||
const REH_DECODE_WINDOW = 8;
|
||
function _rehEvictDecoded(keepIdx) {
|
||
if (rehDecodedBuffers.size <= REH_DECODE_WINDOW * 2 + 4) return;
|
||
const lo = keepIdx - REH_DECODE_WINDOW, hi = keepIdx + REH_DECODE_WINDOW;
|
||
for (const k of rehDecodedBuffers.keys()) {
|
||
if (k < lo || k > hi) rehDecodedBuffers.delete(k);
|
||
}
|
||
}
|
||
|
||
async function preDecodeBlob(lineIdx, blob) {
|
||
if (rehDecodedBuffers.has(lineIdx)) return;
|
||
try {
|
||
const ab = await blob.arrayBuffer();
|
||
const buf = await rehPlayCtx().decodeAudioData(ab);
|
||
rehDecodedBuffers.set(lineIdx, buf);
|
||
_rehEvictDecoded(lineIdx);
|
||
} catch(_) {}
|
||
}
|
||
|
||
function computeWordTimings(text, durationSec) {
|
||
const words = stripMarkdown(text).split(/\s+/).filter(Boolean);
|
||
if (words.length < 2) return [];
|
||
// Distribute proportionally by character length (longer words = more time)
|
||
const totalChars = words.reduce((s, w) => s + w.length, 0) || 1;
|
||
let t = 0;
|
||
return words.map(w => {
|
||
const start = t;
|
||
t += (w.length / totalChars) * durationSec;
|
||
return { word: w, start, end: t };
|
||
});
|
||
}
|
||
|
||
function stopAudioSource() {
|
||
if (rehCurrentSource) {
|
||
try { rehCurrentSource.stop(0); } catch(_) {}
|
||
rehCurrentSource = null;
|
||
}
|
||
if (rehWordHighlightRaf) { cancelAnimationFrame(rehWordHighlightRaf); rehWordHighlightRaf = null; }
|
||
}
|
||
|
||
// Instant playback from pre-decoded buffer + word-level highlight
|
||
async function playPreDecoded(lineIdx, blob, text) {
|
||
if (!rehDecodedBuffers.has(lineIdx)) await preDecodeBlob(lineIdx, blob);
|
||
_rehEvictDecoded(lineIdx); // keep the window centred on the playhead
|
||
const buf = rehDecodedBuffers.get(lineIdx);
|
||
if (!buf) { await playAudioBlobFallback(blob); return; }
|
||
|
||
const timings = computeWordTimings(text, buf.duration);
|
||
const dialogEl = document.getElementById('reh-diag-' + lineIdx);
|
||
|
||
if (dialogEl && timings.length >= 2) {
|
||
dialogEl.innerHTML = timings.map((t, i) =>
|
||
`<span class="reh-word" data-wi="${i}">${escHtml(t.word)}</span>`
|
||
).join(' ');
|
||
}
|
||
|
||
return new Promise(resolve => {
|
||
stopAudioSource();
|
||
const ctx = rehPlayCtx();
|
||
const src = ctx.createBufferSource();
|
||
src.buffer = buf;
|
||
src.connect(ctx.destination);
|
||
rehCurrentSource = src;
|
||
const t0 = ctx.currentTime;
|
||
|
||
src.onended = () => {
|
||
rehCurrentSource = null;
|
||
if (rehWordHighlightRaf) { cancelAnimationFrame(rehWordHighlightRaf); rehWordHighlightRaf = null; }
|
||
if (dialogEl && timings.length >= 2) dialogEl.innerHTML = renderMarkdownInline(text);
|
||
resolve();
|
||
};
|
||
|
||
src.start(0);
|
||
|
||
// Pre-decode the line AFTER this one while this is playing
|
||
const nextI = findNextCachedLine(lineIdx + 1);
|
||
if (nextI >= 0) preDecodeBlob(nextI, rehState.synthCache.get(nextI));
|
||
|
||
if (dialogEl && timings.length >= 2) {
|
||
const tick = () => {
|
||
if (rehCurrentSource !== src) return;
|
||
const elapsed = ctx.currentTime - t0;
|
||
let active = 0;
|
||
for (let i = timings.length - 1; i >= 0; i--) {
|
||
if (elapsed >= timings[i].start) { active = i; break; }
|
||
}
|
||
dialogEl.querySelectorAll('.reh-word').forEach((span, i) => {
|
||
span.classList.toggle('reh-word-active', i === active);
|
||
});
|
||
rehWordHighlightRaf = requestAnimationFrame(tick);
|
||
};
|
||
rehWordHighlightRaf = requestAnimationFrame(tick);
|
||
}
|
||
});
|
||
}
|
||
|
||
function findNextCachedLine(fromIdx) {
|
||
for (let i = fromIdx; i < rehState.lines.length; i++) {
|
||
if (rehState.lines[i].type === 'dialog' && rehState.synthCache.has(i)) return i;
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
// Fallback when AudioContext decode fails
|
||
async function playAudioBlobFallback(blob) {
|
||
const url = URL.createObjectURL(blob);
|
||
const audio = $('reh-tts-audio'); if (!audio) return;
|
||
audio.src = url; audio.style.display = '';
|
||
await new Promise(r => { audio.addEventListener('canplay', r, { once: true }); setTimeout(r, 3000); });
|
||
await audio.play().catch(() => {});
|
||
await waitForAudioEnd(audio);
|
||
}
|
||
|
||
// ── Auto-play sequence ──────────────────────────────────────────────────────
|
||
|
||
function startPlay() {
|
||
rehPlayCtx(); // warm up AudioContext on user gesture
|
||
_ensureNarrator(); // make sure rehState.narratorVoice reflects the narrator cast row
|
||
rehState.playing = true;
|
||
updatePlayBtn();
|
||
playNextLine();
|
||
}
|
||
|
||
function pausePlay() {
|
||
rehState.playing = false;
|
||
updatePlayBtn();
|
||
stopAudioSource();
|
||
const audio = $('reh-tts-audio');
|
||
if (audio && !audio.paused) audio.pause();
|
||
hideStatusBar();
|
||
}
|
||
|
||
function stopPlay() {
|
||
rehState.playing = false;
|
||
updatePlayBtn();
|
||
stopAudioSource();
|
||
const audio = $('reh-tts-audio');
|
||
if (audio) { audio.pause(); audio.src = ''; }
|
||
hideStatusBar();
|
||
}
|
||
|
||
function hideStatusBar() { const bar = $('reh-tts-status-bar'); if (bar) bar.hidden = true; }
|
||
|
||
async function playNextLine() {
|
||
if (!rehState.playing) return;
|
||
|
||
// Check practice end boundary
|
||
if (rehState.practiceEnd !== null && rehState.lineIndex > rehState.practiceEnd) {
|
||
rehState.playing = false;
|
||
updatePlayBtn();
|
||
if (rehState.repeat) {
|
||
rehState.lineIndex = rehState.practiceStart ?? 0;
|
||
startPlay();
|
||
} else {
|
||
rehState.lineIndex = rehState.practiceStart ?? 0;
|
||
highlightCurrentLine();
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Always skip pagebreak markers
|
||
if (rehState.lines[rehState.lineIndex]?.type === 'pagebreak') {
|
||
rehState.lineIndex++;
|
||
return playNextLine();
|
||
}
|
||
|
||
// Skip lines the user marked ignore / hide during bulk edit
|
||
const _cur = rehState.lines[rehState.lineIndex];
|
||
if (_cur && (_cur.ignored || _cur.hidden)) {
|
||
rehState.lineIndex++;
|
||
return playNextLine();
|
||
}
|
||
|
||
// Skip non-dialog lines only when skip mode is on AND no narrator voice is set.
|
||
// When a narrator voice exists, every line with text gets spoken — never skip.
|
||
if (rehState.skipDescriptions && !rehState.narratorVoice) {
|
||
while (
|
||
rehState.lineIndex < rehState.lines.length &&
|
||
rehState.lines[rehState.lineIndex].type !== 'dialog' &&
|
||
rehState.lines[rehState.lineIndex].type !== 'pagebreak' &&
|
||
!(rehState.practiceEnd !== null && rehState.lineIndex > rehState.practiceEnd)
|
||
) { rehState.lineIndex++; }
|
||
}
|
||
|
||
if (rehState.lineIndex >= rehState.lines.length) {
|
||
rehState.playing = false;
|
||
updatePlayBtn();
|
||
if (rehState.repeat) { rehState.lineIndex = 0; startPlay(); return; }
|
||
toast('Script finished', 'success');
|
||
return;
|
||
}
|
||
|
||
const line = rehState.lines[rehState.lineIndex];
|
||
highlightCurrentLine();
|
||
|
||
// Non-dialog lines
|
||
if (line.type !== 'dialog') {
|
||
// Any line with text can be narrated — direction/transition/scene/act/action all included
|
||
const hasText = !!(line.text || '').trim();
|
||
if (rehState.narratorVoice && hasText) {
|
||
showStatusBar('Narrator: ' + line.text.slice(0, 50) + (line.text.length > 50 ? '…' : ''));
|
||
const cached = rehState.synthCache.get(rehState.lineIndex);
|
||
if (cached) {
|
||
await playPreDecoded(rehState.lineIndex, cached, line.text);
|
||
} else {
|
||
try {
|
||
const blob = await fetchTtsPreviewBlob(rehState.narratorVoice, stripMarkdown(line.text), 'wav', '', rehState.backend);
|
||
if (!rehState.playing) return;
|
||
rehState.synthCache.set(rehState.lineIndex, blob);
|
||
_markSynthDot(rehState.lineIndex, 'ok');
|
||
await playPreDecoded(rehState.lineIndex, blob, line.text);
|
||
} catch(_) { await new Promise(r => setTimeout(r, 200)); }
|
||
}
|
||
} else if (!rehState.skipDescriptions) {
|
||
await new Promise(r => setTimeout(r, line.type === 'direction' ? 150 : 250));
|
||
}
|
||
if (!rehState.playing) return;
|
||
rehState.lineIndex++;
|
||
playNextLine();
|
||
return;
|
||
}
|
||
|
||
// Dialog line
|
||
const cast = rehState.cast[line.speaker] || { voice: '' };
|
||
|
||
if (cast.voice === 'me') {
|
||
rehState.playing = false; updatePlayBtn();
|
||
showRecOverlay(line);
|
||
return;
|
||
}
|
||
|
||
if (!cast.voice) {
|
||
showStatusBar(line.speaker + ' has no voice — skipping…');
|
||
await new Promise(r => setTimeout(r, 350));
|
||
if (!rehState.playing) return;
|
||
rehState.lineIndex++; playNextLine(); return;
|
||
}
|
||
|
||
const profile = (cast.instruct || '').trim();
|
||
const instruct = _buildInstruct(profile, line.emotion);
|
||
const cleanTxt = stripMarkdown(line.text);
|
||
|
||
const cached = rehState.synthCache.get(rehState.lineIndex);
|
||
if (cached) {
|
||
showStatusBar(line.speaker + ' is speaking…');
|
||
await playPreDecoded(rehState.lineIndex, cached, line.text); // ← instant: pre-decoded PCM
|
||
rehState.clips.push({ lineIndex: rehState.lineIndex, speaker: line.speaker, type: 'tts', blob: cached });
|
||
} else {
|
||
showStatusBar('Synthesizing…');
|
||
try {
|
||
const blob = await fetchTtsPreviewBlob(cast.voice, _rehInlineTone(cleanTxt, line.emotion), 'wav', instruct, rehState.backend);
|
||
if (!rehState.playing) return;
|
||
rehState.synthCache.set(rehState.lineIndex, blob);
|
||
showStatusBar(line.speaker + ' is speaking…');
|
||
await playPreDecoded(rehState.lineIndex, blob, line.text); // also decodes + pre-fetches next
|
||
rehState.clips.push({ lineIndex: rehState.lineIndex, speaker: line.speaker, type: 'tts', blob });
|
||
} catch(e) {
|
||
showStatusBar('TTS failed: ' + e.message);
|
||
await new Promise(r => setTimeout(r, 1000));
|
||
}
|
||
}
|
||
|
||
if (!rehState.playing) return;
|
||
rehState.lineIndex++;
|
||
playNextLine();
|
||
}
|
||
|
||
function waitForAudioEnd(audio) {
|
||
return new Promise(resolve => {
|
||
if (!audio || audio.paused || audio.ended) { resolve(); return; }
|
||
audio.addEventListener('ended', resolve, { once: true });
|
||
audio.addEventListener('pause', resolve, { once: true });
|
||
audio.addEventListener('error', resolve, { once: true });
|
||
});
|
||
}
|
||
|
||
function showStatusBar(msg) {
|
||
const bar = $('reh-tts-status-bar'); if (!bar) return;
|
||
bar.hidden = false;
|
||
const txt = $('reh-tts-status-txt'); if (txt) txt.textContent = msg;
|
||
}
|
||
|
||
function highlightCurrentLine() {
|
||
const i = rehState.lineIndex, total = rehState.lines.length;
|
||
const prog = $('reh-tb-progress');
|
||
if (prog) prog.style.width = (total ? (i / total) * 100 : 0) + '%';
|
||
const lbl = $('reh-tb-label');
|
||
if (lbl) lbl.textContent = `${i + 1} / ${total}`;
|
||
|
||
document.querySelectorAll('[data-index]').forEach(el => {
|
||
el.classList.toggle('reh-line-active', parseInt(el.dataset.index) === i);
|
||
});
|
||
|
||
const active = document.querySelector(`[data-index="${i}"]`);
|
||
if (active) active.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
updatePlayBtn();
|
||
}
|
||
|
||
function updatePlayBtn() {
|
||
const btn = $('reh-tb-play'); if (!btn) return;
|
||
btn.innerHTML = rehState.playing ? '<span class="mdi mdi-pause"></span>' : '<span class="mdi mdi-play"></span>';
|
||
btn.title = rehState.playing ? 'Pause' : 'Play all';
|
||
}
|
||
|
||
// ── Synthesize All ─────────────────────────────────────────────────────────
|
||
|
||
async function synthAll() {
|
||
if (rehState.synthRunning) return;
|
||
if (!rehState.backend) { toast('Select a TTS backend first', 'error'); return; }
|
||
_ensureNarrator(); // sync narrator voice from its cast row before synthesizing
|
||
|
||
const ttsLines = rehState.lines.map((l, i) => ({ line:l, idx:i })).filter(({ line }) => {
|
||
if (line.ignored || line.hidden) return false; // bulk-edit: never synth ignored/hidden lines
|
||
if (line.type === 'dialog') {
|
||
const c = rehState.cast[line.speaker];
|
||
return c && c.voice && c.voice !== 'me';
|
||
}
|
||
// All non-dialog lines with text are narrated when a narrator voice is set
|
||
return !!(rehState.narratorVoice && (line.text || '').trim());
|
||
});
|
||
|
||
if (!ttsLines.length) { toast('No TTS lines to synthesize', 'error'); return; }
|
||
|
||
rehState.synthRunning = true; rehState.synthCancelled = false;
|
||
const synthBar = $('reh-synth-bar'), fill = $('reh-synth-fill'), label = $('reh-synth-label');
|
||
if (synthBar) synthBar.hidden = false;
|
||
|
||
const prog = (d, t) => {
|
||
if (fill) fill.style.width = (t ? (d/t)*100 : 0) + '%';
|
||
if (label) label.textContent = `${d} / ${t} synthesized`;
|
||
};
|
||
prog(0, ttsLines.length);
|
||
|
||
let done = 0;
|
||
for (const { line, idx } of ttsLines) {
|
||
if (rehState.synthCancelled) break;
|
||
let voice, instruct;
|
||
if (line.type === 'dialog') {
|
||
const c = rehState.cast[line.speaker];
|
||
voice = c.voice;
|
||
instruct = _buildInstruct(c.instruct, line.emotion);
|
||
} else {
|
||
voice = rehState.narratorVoice; instruct = '';
|
||
}
|
||
_markSynthDot(idx, 'synthesizing');
|
||
document.getElementById('reh-syd-' + idx)?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||
try {
|
||
const blob = await fetchTtsPreviewBlob(voice, _rehInlineTone(stripMarkdown(line.text), line.emotion), 'wav', instruct, rehState.backend);
|
||
rehState.synthCache.set(idx, blob);
|
||
rehState.staleLines.delete(idx);
|
||
_markSynthDot(idx, 'ok');
|
||
_showReSynthBtn(idx, false);
|
||
preDecodeBlob(idx, blob); // decode to PCM immediately → zero-latency playback
|
||
} catch(_) { _markSynthDot(idx, null); }
|
||
prog(++done, ttsLines.length);
|
||
}
|
||
|
||
if (synthBar) synthBar.hidden = true;
|
||
rehState.synthRunning = false;
|
||
if (!rehState.synthCancelled) toast(`Pre-synthesized ${done} of ${ttsLines.length} lines — ready for instant playback`, 'success');
|
||
}
|
||
|
||
function _updateStaleBatchBtn() {
|
||
const btn = $('reh-tb-resynth-stale');
|
||
if (btn) btn.hidden = rehState.staleLines.size === 0;
|
||
}
|
||
|
||
$('reh-tb-synth-all')?.addEventListener('click', () => synthAll());
|
||
$('reh-tb-resynth-stale')?.addEventListener('click', async () => {
|
||
if (rehState.synthRunning) return;
|
||
const stale = [...rehState.staleLines];
|
||
if (!stale.length) return;
|
||
rehState.synthRunning = true; rehState.synthCancelled = false;
|
||
const synthBar = $('reh-synth-bar'), fill = $('reh-synth-fill'), label = $('reh-synth-label');
|
||
if (synthBar) synthBar.hidden = false;
|
||
let done = 0;
|
||
for (const idx of stale) {
|
||
if (rehState.synthCancelled) break;
|
||
const line = rehState.lines[idx];
|
||
if (!line || line.type !== 'dialog') { rehState.staleLines.delete(idx); continue; }
|
||
const c = rehState.cast[line.speaker];
|
||
if (!c || !c.voice || c.voice === 'me') { rehState.staleLines.delete(idx); continue; }
|
||
const instruct = [c.instruct||'', line.emotion||''].filter(Boolean).join('. ');
|
||
_markSynthDot(idx, 'synthesizing');
|
||
try {
|
||
const blob = await fetchTtsPreviewBlob(c.voice, _rehInlineTone(stripMarkdown(line.text), line.emotion), 'wav', instruct, rehState.backend);
|
||
rehState.synthCache.set(idx, blob);
|
||
rehState.staleLines.delete(idx);
|
||
preDecodeBlob(idx, blob);
|
||
_markSynthDot(idx, 'ok');
|
||
_showReSynthBtn(idx, false);
|
||
} catch(_) { _markSynthDot(idx, 'stale'); }
|
||
if (fill) fill.style.width = ((++done / stale.length) * 100) + '%';
|
||
if (label) label.textContent = `${done} / ${stale.length} synthesized`;
|
||
}
|
||
if (synthBar) synthBar.hidden = true;
|
||
rehState.synthRunning = false;
|
||
_updateStaleBatchBtn();
|
||
if (!rehState.synthCancelled) toast(`Re-synthesized ${done} stale line${done !== 1 ? 's' : ''}`, 'success');
|
||
});
|
||
$('reh-synth-cancel')?.addEventListener('click', () => { rehState.synthCancelled = true; rehState.synthRunning = false; });
|
||
|
||
// ── Recording overlay ───────────────────────────────────────────────────────
|
||
|
||
function showRecOverlay(line) {
|
||
const overlay = $('reh-rec-overlay'); if (!overlay) return;
|
||
overlay.hidden = false;
|
||
const cue = $('reh-rec-cue'), c = rehState.cast[line.speaker] || { color: '#89b4fa' };
|
||
if (cue) cue.innerHTML = `<span class="reh-rec-cue-name" style="color:${c.color}">${escHtml(line.speaker)}</span> — your line:<div class="reh-rec-cue-text">${escHtml(stripMarkdown(line.text))}</div>`;
|
||
if ($('reh-rec-preview')) { $('reh-rec-preview').style.display='none'; $('reh-rec-preview').src=''; }
|
||
if ($('reh-rec-confirm-row')) $('reh-rec-confirm-row').hidden = true;
|
||
if ($('reh-rec-start')) $('reh-rec-start').disabled = false;
|
||
if ($('reh-rec-stop')) $('reh-rec-stop').disabled = true;
|
||
if ($('reh-rec-time')) $('reh-rec-time').textContent = '0:00';
|
||
rehState.lastRecBlob = null;
|
||
}
|
||
|
||
function hideRecOverlay() {
|
||
const overlay = $('reh-rec-overlay'); if (overlay) overlay.hidden = true;
|
||
stopRehMic();
|
||
}
|
||
|
||
// ── Mic recording ───────────────────────────────────────────────────────────
|
||
|
||
function rehRenderMeter(level=0, db=-Infinity, clipped=false) {
|
||
const meter = $('reh-mic-meter'); if (!meter) return;
|
||
if (!meter.children.length) for (let i=0;i<18;i++){const b=document.createElement('div');b.className='bar';meter.appendChild(b);}
|
||
const active = Math.round(Math.max(0,Math.min(1,level))*meter.children.length);
|
||
[...meter.children].forEach((bar,i)=>{
|
||
bar.className='bar'; bar.style.height=(7+Math.min(i,active)*1.55)+'px';
|
||
if(i<active){bar.classList.add('on');if(db>-12&&i>11)bar.classList.add('hot');if(clipped&&i>14)bar.classList.add('clip');}
|
||
});
|
||
const el=$('reh-db-readout');if(el)el.textContent=Number.isFinite(db)?db.toFixed(1)+' dB':'-∞ dB';
|
||
}
|
||
|
||
function rehStartMeter() {
|
||
if (!rehState.recAnalyser) return;
|
||
if (rehState.recMeterRaf) cancelAnimationFrame(rehState.recMeterRaf);
|
||
const data=new Float32Array(rehState.recAnalyser.fftSize), canvas=$('reh-live-wave'), RING=300, ADD=10;
|
||
rehState.recWaveRing = new Float32Array(RING);
|
||
const tick=()=>{
|
||
rehState.recAnalyser.getFloatTimeDomainData(data);
|
||
let sum=0,peak=0; for(const s of data){sum+=s*s;peak=Math.max(peak,Math.abs(s));}
|
||
const rms=Math.sqrt(sum/data.length), db=rms>0?20*Math.log10(rms):-Infinity;
|
||
rehRenderMeter((db+60)/60,db,peak>0.98);
|
||
if(canvas&&rehState.recWaveRing){
|
||
const ring=rehState.recWaveRing; ring.copyWithin(0,ADD);
|
||
for(let i=0;i<ADD;i++) ring[RING-ADD+i]=data[Math.floor(i*data.length/ADD)];
|
||
const ctx=canvas.getContext('2d'),w=canvas.width,h=canvas.height;
|
||
ctx.clearRect(0,0,w,h); ctx.beginPath();
|
||
ctx.strokeStyle=peak>0.98?'#f38ba8':db>-12?'#f9e2af':'#a6e3a1'; ctx.lineWidth=1.5;
|
||
const mid=h/2;
|
||
for(let i=0;i<RING;i++){const x=(i/RING)*w,y=mid-ring[i]*mid*0.85;i===0?ctx.moveTo(x,y):ctx.lineTo(x,y);}
|
||
ctx.stroke();
|
||
}
|
||
rehState.recMeterRaf=requestAnimationFrame(tick);
|
||
};
|
||
tick();
|
||
}
|
||
|
||
async function startRehMic() {
|
||
if (rehState.recDestStream) return;
|
||
const AudioCtx = window.AudioContext || window.webkitAudioContext;
|
||
rehState.recStream = await requestMicrophoneStream({ raw: true });
|
||
if (AudioCtx) {
|
||
rehState.recAudioCtx = new AudioCtx();
|
||
rehState.recSourceNode = rehState.recAudioCtx.createMediaStreamSource(rehState.recStream);
|
||
rehState.recGainNode = rehState.recAudioCtx.createGain();
|
||
rehState.recAnalyser = rehState.recAudioCtx.createAnalyser();
|
||
rehState.recAnalyser.fftSize = 1024;
|
||
const dest = rehState.recAudioCtx.createMediaStreamDestination();
|
||
rehState.recSourceNode.connect(rehState.recGainNode);
|
||
rehState.recGainNode.connect(rehState.recAnalyser);
|
||
rehState.recGainNode.connect(dest);
|
||
rehState.recDestStream = dest.stream;
|
||
rehStartMeter();
|
||
} else { rehState.recDestStream = rehState.recStream; }
|
||
}
|
||
|
||
function stopRehMic() {
|
||
if (rehState.recMeterRaf) cancelAnimationFrame(rehState.recMeterRaf);
|
||
rehState.recMeterRaf = null;
|
||
[rehState.recSourceNode,rehState.recGainNode,rehState.recAnalyser].forEach(n=>{try{if(n)n.disconnect();}catch(_){}});
|
||
if (rehState.recStream) rehState.recStream.getTracks().forEach(t=>t.stop());
|
||
if (rehState.recDestStream) rehState.recDestStream.getTracks().forEach(t=>t.stop());
|
||
if (rehState.recAudioCtx) rehState.recAudioCtx.close().catch(()=>{});
|
||
Object.assign(rehState,{recStream:null,recDestStream:null,recSourceNode:null,recGainNode:null,recAnalyser:null,recAudioCtx:null,recWaveRing:null});
|
||
rehRenderMeter();
|
||
const wc=$('reh-live-wave');if(wc)wc.getContext('2d').clearRect(0,0,wc.width,wc.height);
|
||
}
|
||
|
||
$('reh-rec-start')?.addEventListener('click', async () => {
|
||
try {
|
||
await startRehMic();
|
||
rehState.recChunks=[]; rehState.recSecs=0;
|
||
if($('reh-rec-time'))$('reh-rec-time').textContent='0:00';
|
||
if($('reh-rec-start'))$('reh-rec-start').disabled=true;
|
||
if($('reh-rec-stop'))$('reh-rec-stop').disabled=false;
|
||
if($('reh-rec-confirm-row'))$('reh-rec-confirm-row').hidden=true;
|
||
rehState.recTimer=setInterval(()=>{
|
||
rehState.recSecs++;
|
||
if($('reh-rec-time'))$('reh-rec-time').textContent=Math.floor(rehState.recSecs/60)+':'+String(rehState.recSecs%60).padStart(2,'0');
|
||
},1000);
|
||
rehState.mediaRec=new MediaRecorder(rehState.recDestStream||rehState.recStream,{audioBitsPerSecond:256000});
|
||
rehState.mediaRec.ondataavailable=e=>{if(e.data.size)rehState.recChunks.push(e.data);};
|
||
rehState.mediaRec.onstop=()=>{
|
||
clearInterval(rehState.recTimer);
|
||
if($('reh-rec-start'))$('reh-rec-start').disabled=false;
|
||
if($('reh-rec-stop'))$('reh-rec-stop').disabled=true;
|
||
const blob=new Blob(rehState.recChunks,{type:rehState.mediaRec.mimeType||'audio/webm'});
|
||
const url=URL.createObjectURL(blob);
|
||
const p=$('reh-rec-preview');if(p){p.src=url;p.style.display='';}
|
||
if($('reh-rec-confirm-row'))$('reh-rec-confirm-row').hidden=false;
|
||
rehState.lastRecBlob=blob;
|
||
};
|
||
rehState.mediaRec.start(100);
|
||
} catch(e){stopRehMic();toast(await microphoneErrorMessage(e),'error');}
|
||
});
|
||
|
||
$('reh-rec-stop')?.addEventListener('click', () => { if(rehState.mediaRec?.state!=='inactive')rehState.mediaRec.stop(); });
|
||
|
||
$('reh-rec-keep')?.addEventListener('click', () => {
|
||
if (rehState.lastRecBlob)
|
||
rehState.clips.push({lineIndex:rehState.lineIndex, speaker:rehState.lines[rehState.lineIndex]?.speaker, type:'me', blob:rehState.lastRecBlob});
|
||
stopRehMic(); hideRecOverlay();
|
||
rehState.lineIndex++;
|
||
startPlay();
|
||
});
|
||
|
||
$('reh-rec-redo')?.addEventListener('click', () => { stopRehMic(); const l=rehState.lines[rehState.lineIndex]; if(l)showRecOverlay(l); });
|
||
|
||
$('reh-skip-line')?.addEventListener('click', () => {
|
||
rehState.clips.push({lineIndex:rehState.lineIndex, speaker:rehState.lines[rehState.lineIndex]?.speaker, type:'skip'});
|
||
stopRehMic(); hideRecOverlay();
|
||
rehState.lineIndex++;
|
||
startPlay();
|
||
});
|
||
|
||
// ── Phase 4: Summary ────────────────────────────────────────────────────────
|
||
|
||
function renderSummary() {
|
||
const list = $('reh-summary-list'); if (!list) return;
|
||
if (!rehState.clips.length) { list.innerHTML='<p class="note">No clips in this session.</p>'; return; }
|
||
list.innerHTML = rehState.clips.map((clip,idx)=>{
|
||
const line=rehState.lines[clip.lineIndex]||{text:'—',speaker:clip.speaker};
|
||
const c=rehState.cast[clip.speaker]||{color:'#89b4fa'};
|
||
const typeLabel=clip.type==='me'?'🎤 Recorded':clip.type==='tts'?'🔊 Synthesized':'⏭ Skipped';
|
||
const audioHtml=clip.blob?`<audio controls src="${URL.createObjectURL(clip.blob)}" style="width:100%;max-width:320px;margin-top:4px"></audio>`:'';
|
||
const dlHtml=clip.blob?`<a class="btn-secondary" style="text-decoration:none;flex-shrink:0" download="line_${idx+1}_${clip.speaker}.webm" href="${URL.createObjectURL(clip.blob)}"><span class="mdi mdi-download"></span></a>`:'';
|
||
return `<div class="reh-summary-row">
|
||
<span style="width:10px;height:10px;border-radius:50%;background:${c.color};flex-shrink:0;margin-top:4px"></span>
|
||
<div style="flex:1;min-width:0">
|
||
<div style="font-weight:600;font-size:13px;color:${c.color}">${escHtml(clip.speaker||'—')} <span style="font-weight:400;color:var(--subtext)">${typeLabel}</span></div>
|
||
<div style="font-size:13px;line-height:1.4;margin-top:2px">${escHtml(stripMarkdown(line.text))}</div>
|
||
${audioHtml}
|
||
</div>
|
||
${dlHtml}
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
$('reh-new-session-btn')?.addEventListener('click', () => {
|
||
stopPlay(); stopRehMic();
|
||
rehState.lines=[]; rehState.cast={}; rehState.clips=[]; rehState.lineIndex=0;
|
||
rehState.savedId=null; rehState.synthCache.clear(); rehDecodedBuffers.clear(); rehState.narratorVoice='';
|
||
rehState.practiceStart=null; rehState.practiceEnd=null;
|
||
if($('reh-script-text'))$('reh-script-text').value='';
|
||
if($('reh-script-title'))$('reh-script-title').value='';
|
||
renderLibraryList(); showPhase(1);
|
||
});
|
||
|
||
$('reh-resume-btn')?.addEventListener('click', ()=>{showPhase(3);highlightCurrentLine();});
|
||
$('reh-save-session-btn')?.addEventListener('click', saveToLibrary);
|
||
$('reh-cast-save-btn')?.addEventListener('click', saveToLibrary);
|
||
$('reh-export-session-btn')?.addEventListener('click', exportToFile);
|
||
$('reh-tb-save')?.addEventListener('click', saveToLibrary);
|
||
$('reh-tb-export')?.addEventListener('click', exportToFile);
|
||
|
||
$('reh-lib-import-file')?.addEventListener('change', async function(){
|
||
const f=this.files?.[0]; if(!f)return;
|
||
await importFromFile(f); this.value='';
|
||
});
|
||
|
||
// ── Import/Export panel wiring ─────────────────────────────────────────────
|
||
document.querySelectorAll('.reh-impex-file').forEach(inp => {
|
||
inp.addEventListener('change', async function () {
|
||
const f = this.files?.[0]; if (!f) return;
|
||
await importFromFile(f);
|
||
this.value = '';
|
||
// After import, return to Library
|
||
navRehearserPhase(1);
|
||
});
|
||
});
|
||
$('reh-impex-reh-btn')?.addEventListener('click', exportToFile);
|
||
$('reh-impex-fountain-btn')?.addEventListener('click', () => { $('reh-fountain-btn')?.click(); });
|
||
$('reh-impex-fdx-btn')?.addEventListener('click', () => { $('reh-fdx-export-btn')?.click(); });
|
||
$('reh-impex-osf-btn')?.addEventListener('click', () => { $('reh-osf-export-btn')?.click(); });
|
||
|
||
// ── Plain-text + Markdown export ───────────────────────────────────────────
|
||
function _downloadText(content, filename) {
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(new Blob([content], { type: 'text/plain;charset=utf-8' }));
|
||
a.download = filename; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 3000);
|
||
}
|
||
|
||
function exportAsTxt() {
|
||
const title = $('reh-script-title')?.value.trim() || $('reh-page-title')?.textContent || 'script';
|
||
_downloadText(linesToScriptText(), title.replace(/[^a-z0-9]/gi, '_') + '.txt');
|
||
}
|
||
|
||
function exportAsMd() {
|
||
const title = $('reh-script-title')?.value.trim() || 'Script';
|
||
const lines = rehState.lines.map(l => {
|
||
switch (l.type) {
|
||
case 'act': return `\n# ${l.text}\n`;
|
||
case 'scene': return `\n## ${l.text}\n`;
|
||
case 'transition': return `\n*${l.text}*\n`;
|
||
case 'direction': return `*(${l.text})*`;
|
||
case 'action': return `\n${l.text}\n`;
|
||
case 'dialog': return `\n**${l.speaker}**\n${l.text}\n`;
|
||
default: return '';
|
||
}
|
||
}).join('\n');
|
||
_downloadText(`# ${title}\n\n${lines.trim()}\n`, title.replace(/[^a-z0-9]/gi, '_') + '.md');
|
||
}
|
||
|
||
$('reh-impex-txt-btn')?.addEventListener('click', exportAsTxt);
|
||
$('reh-impex-md-btn')?.addEventListener('click', exportAsMd);
|
||
|
||
// ── IMSDb / URL import ─────────────────────────────────────────────────────
|
||
(function initUrlImport() {
|
||
const inp = $('reh-impex-url');
|
||
const btn = $('reh-impex-url-btn');
|
||
const status = $('reh-impex-url-status');
|
||
if (!inp || !btn) return;
|
||
|
||
async function fetchUrl() {
|
||
const url = inp.value.trim();
|
||
if (!url) { toast('Paste a URL first', 'error'); return; }
|
||
btn.disabled = true;
|
||
if (status) { status.textContent = 'Fetching…'; status.style.color = ''; }
|
||
try {
|
||
const r = await fetch('/api/fetch-web-script', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ url }),
|
||
});
|
||
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
|
||
const d = await r.json();
|
||
// Put script into the textarea and set title
|
||
const ta = $('reh-script-text');
|
||
if (ta) ta.value = d.text;
|
||
const titleInp = $('reh-script-title');
|
||
if (titleInp && d.title) titleInp.value = d.title.replace(/-/g,' ').replace(/\b\w/g,c=>c.toUpperCase());
|
||
if (status) {
|
||
status.textContent = `✓ Fetched ${(d.chars/1000).toFixed(0)} K chars — scroll down to Parse & cast`;
|
||
status.style.color = 'var(--green)';
|
||
}
|
||
// Navigate to Library phase so user sees the script text area
|
||
navRehearserPhase(1);
|
||
// Scroll the script textarea into view
|
||
setTimeout(() => ta?.scrollIntoView({ behavior:'smooth', block:'nearest' }), 300);
|
||
toast(`Fetched "${d.title}" — click "Parse & cast" to continue`, 'success');
|
||
} catch(e) {
|
||
if (status) { status.textContent = '✗ ' + e.message; status.style.color = 'var(--red)'; }
|
||
toast('Fetch failed: ' + e.message, 'error');
|
||
} finally { btn.disabled = false; }
|
||
}
|
||
|
||
btn.addEventListener('click', fetchUrl);
|
||
inp.addEventListener('keydown', e => { if (e.key === 'Enter') fetchUrl(); });
|
||
})();
|
||
|
||
// ── IMSDb library browser (poster grid) ────────────────────────────────────
|
||
(function initImsdbBrowser() {
|
||
const modal = $('reh-imsdb-modal');
|
||
const grid = $('reh-imsdb-grid');
|
||
const search = $('reh-imsdb-search');
|
||
const countEl = $('reh-imsdb-count');
|
||
const closeBtn = $('reh-imsdb-close');
|
||
const coverBtn = $('reh-imsdb-cover-btn');
|
||
const listBtn = $('reh-imsdb-list-btn');
|
||
if (!modal || !grid) return;
|
||
|
||
const CACHE_KEY = 'reh-imsdb-cat-v1';
|
||
const CACHE_TTL = 6 * 3600 * 1000;
|
||
|
||
let _catalogue = null;
|
||
let _posterObserver = null;
|
||
let _view = localStorage.getItem('reh-imsdb-view') || 'cover'; // 'cover' | 'list'
|
||
|
||
function _applyView() {
|
||
grid.classList.toggle('list-view', _view === 'list');
|
||
if (coverBtn) coverBtn.classList.toggle('active', _view === 'cover');
|
||
if (listBtn) listBtn.classList.toggle('active', _view === 'list');
|
||
}
|
||
|
||
function _ensurePosterObserver() {
|
||
if (_posterObserver) return _posterObserver;
|
||
_posterObserver = new IntersectionObserver((entries) => {
|
||
entries.forEach(async (ent) => {
|
||
if (!ent.isIntersecting) return;
|
||
const card = ent.target;
|
||
_posterObserver.unobserve(card);
|
||
const title = card.dataset.title;
|
||
try {
|
||
const d = await fetch('/api/movie-poster?title=' + encodeURIComponent(title)).then(r => r.json());
|
||
if (d.poster) {
|
||
const img = document.createElement('img');
|
||
img.className = 'reh-imsdb-poster';
|
||
img.loading = 'lazy'; img.src = d.poster; img.alt = title;
|
||
img.onload = () => {
|
||
const wrap = card.querySelector('.reh-imsdb-poster-wrap');
|
||
if (wrap) {
|
||
wrap.querySelector('.reh-imsdb-fallback')?.remove();
|
||
wrap.appendChild(img);
|
||
}
|
||
};
|
||
}
|
||
if (d.year) {
|
||
card.querySelectorAll('.reh-imsdb-year').forEach(y => { y.textContent = d.year; });
|
||
}
|
||
} catch(_) {}
|
||
});
|
||
}, { root: grid, rootMargin: '300px' });
|
||
return _posterObserver;
|
||
}
|
||
|
||
function _bookColor(title) {
|
||
let h = 0; for (let i = 0; i < title.length; i++) h = (h * 31 + title.charCodeAt(i)) >>> 0;
|
||
return `hsl(${h % 360}, 45%, 42%)`;
|
||
}
|
||
|
||
function renderGrid(items) {
|
||
const obs = _ensurePosterObserver();
|
||
grid.innerHTML = '';
|
||
if (!items.length) { grid.innerHTML = '<div class="reh-imsdb-loading">No matches.</div>'; return; }
|
||
const frag = document.createDocumentFragment();
|
||
items.slice(0, 400).forEach(it => {
|
||
const card = document.createElement('div');
|
||
card.className = 'reh-imsdb-card';
|
||
card.dataset.title = it.title;
|
||
card.dataset.url = it.fetch_url;
|
||
const color1 = _bookColor(it.title);
|
||
const color2 = _bookColor(it.title + '_');
|
||
card.innerHTML = `
|
||
<div class="reh-imsdb-poster-wrap">
|
||
<div class="reh-imsdb-fallback" style="background:linear-gradient(160deg, ${color1}, ${color2})">
|
||
<span class="mdi mdi-movie-roll"></span>
|
||
<span class="reh-imsdb-fallback-title">${escHtml(it.title)}</span>
|
||
</div>
|
||
</div>
|
||
<div class="reh-imsdb-meta">
|
||
<span class="reh-imsdb-title">${escHtml(it.title)}</span>
|
||
<span class="reh-imsdb-year"></span>
|
||
</div>`;
|
||
card.addEventListener('click', () => importImsdb(it));
|
||
frag.appendChild(card);
|
||
obs.observe(card);
|
||
});
|
||
grid.appendChild(frag);
|
||
if (countEl) countEl.textContent = `${items.length} script${items.length!==1?'s':''}${items.length>400?' (showing 400)':''}`;
|
||
_applyView();
|
||
}
|
||
|
||
async function importImsdb(it) {
|
||
// In-modal loading indicator — IMSDb resolution can take a couple seconds
|
||
// (detail page → real "Read Script" link → script page).
|
||
grid.innerHTML = `<div class="reh-imsdb-loading"><span class="reh-imsdb-spinner"></span> Fetching “${escHtml(it.title)}” from IMSDb…</div>`;
|
||
try {
|
||
const r = await fetch('/api/fetch-web-script', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ url: it.fetch_url }),
|
||
});
|
||
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
|
||
const d = await r.json();
|
||
const ta = $('reh-script-text'); if (ta) ta.value = d.text;
|
||
const ti = $('reh-script-title'); if (ti) ti.value = it.title;
|
||
closeModal();
|
||
navRehearserPhase(1);
|
||
setTimeout(() => ta?.scrollIntoView({ behavior:'smooth', block:'center' }), 250);
|
||
toast(`Loaded "${it.title}" (${(d.chars/1000).toFixed(0)} K) — click "Parse & cast"`, 'success');
|
||
} catch(e) {
|
||
renderGrid(_searchFilter(_catalogue)); // restore the grid so the user can retry
|
||
toast('IMSDb fetch failed: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
async function _loadCatalogue() {
|
||
// Try localStorage cache first
|
||
try {
|
||
const raw = localStorage.getItem(CACHE_KEY);
|
||
if (raw) {
|
||
const cached = JSON.parse(raw);
|
||
if (Date.now() - cached.ts < CACHE_TTL && Array.isArray(cached.items) && cached.items.length) {
|
||
_catalogue = cached.items;
|
||
return;
|
||
}
|
||
}
|
||
} catch(_) {}
|
||
// Fetch from server
|
||
const d = await fetch('/api/imsdb/list').then(r => r.json());
|
||
_catalogue = d.items || [];
|
||
try {
|
||
localStorage.setItem(CACHE_KEY, JSON.stringify({ ts: Date.now(), items: _catalogue }));
|
||
} catch(_) {}
|
||
}
|
||
|
||
async function openModal() {
|
||
modal.hidden = false;
|
||
_applyView();
|
||
if (!_catalogue) {
|
||
grid.innerHTML = '<div class="reh-imsdb-loading"><span class="reh-imsdb-spinner"></span> Loading catalogue…</div>';
|
||
try {
|
||
await _loadCatalogue();
|
||
} catch(e) {
|
||
grid.innerHTML = `<div class="reh-imsdb-loading">Failed to load: ${escHtml(e.message)}</div>`;
|
||
return;
|
||
}
|
||
}
|
||
renderGrid(_searchFilter(_catalogue));
|
||
search?.focus();
|
||
}
|
||
|
||
function closeModal() { modal.hidden = true; }
|
||
|
||
function _searchFilter(items) {
|
||
const q = search?.value.trim().toLowerCase() || '';
|
||
return q ? items.filter(it => it.title.toLowerCase().includes(q)) : items;
|
||
}
|
||
|
||
let _searchTimer = null;
|
||
search?.addEventListener('input', () => {
|
||
clearTimeout(_searchTimer);
|
||
_searchTimer = setTimeout(() => {
|
||
if (!_catalogue) return;
|
||
renderGrid(_searchFilter(_catalogue));
|
||
}, 150);
|
||
});
|
||
|
||
coverBtn?.addEventListener('click', () => {
|
||
_view = 'cover'; localStorage.setItem('reh-imsdb-view', _view); _applyView();
|
||
});
|
||
listBtn?.addEventListener('click', () => {
|
||
_view = 'list'; localStorage.setItem('reh-imsdb-view', _view); _applyView();
|
||
});
|
||
|
||
// Wire all open buttons (Library tab + Import/Export tab)
|
||
[$('reh-browse-imsdb-btn'), $('reh-browse-imsdb-btn-impex')].forEach(btn => {
|
||
btn?.addEventListener('click', openModal);
|
||
});
|
||
closeBtn?.addEventListener('click', closeModal);
|
||
modal.addEventListener('click', e => { if (e.target === modal) closeModal(); });
|
||
})();
|
||
|
||
// ── Train Mode ────────────────────────────────────────────────────────────
|
||
// State machine: idle → playing_cue → ready → recording → transcribing → done
|
||
// Each "turn" = one set of cue lines + one "me" line the user must speak.
|
||
|
||
const trainState = {
|
||
seq: [], // [{cueLines:[{index,speaker,text,voice,instruct}], myLine:{index,speaker,text}}]
|
||
turn: 0, // current turn index
|
||
phase: 'idle', // idle | playing_cue | ready | recording | transcribing | done
|
||
cueSource: null, // AudioBufferSourceNode currently playing
|
||
mediaRec: null, // MediaRecorder for train recording
|
||
recChunks: [],
|
||
recRaf: null, // rAF id for meter
|
||
recStream: null, // MediaStream
|
||
recCtx: null, // AudioContext for meter
|
||
recAnalyser: null,
|
||
recTimer: null,
|
||
recSecs: 0,
|
||
};
|
||
|
||
function _trainNormWords(str) {
|
||
return (str || '').toLowerCase().replace(/[^a-zäöüàáâãèéêëìíîïòóôõùúûüýÿæœßа-яёА-ЯЁ0-9\s]/g, '').trim().split(/\s+/).filter(Boolean);
|
||
}
|
||
|
||
function _trainLcs(a, b) {
|
||
const R = a.length, C = b.length;
|
||
const dp = Array.from({length: R + 1}, () => new Int16Array(C + 1));
|
||
for (let i = 1; i <= R; i++) for (let j = 1; j <= C; j++)
|
||
dp[i][j] = a[i-1] === b[j-1] ? dp[i-1][j-1] + 1 : Math.max(dp[i-1][j], dp[i][j-1]);
|
||
let i = R, j = C; const seq = [];
|
||
while (i > 0 && j > 0) {
|
||
if (a[i-1] === b[j-1]) { seq.unshift([i-1, j-1]); i--; j--; }
|
||
else if (dp[i-1][j] >= dp[i][j-1]) i--;
|
||
else j--;
|
||
}
|
||
return seq;
|
||
}
|
||
|
||
function _trainCompare(expected, actual) {
|
||
const ew = _trainNormWords(expected), aw = _trainNormWords(actual);
|
||
if (!ew.length) return { html_exp: '', html_act: '', score: 1 };
|
||
const pairs = _trainLcs(ew, aw);
|
||
const matchedE = new Set(pairs.map(p => p[0]));
|
||
const matchedA = new Set(pairs.map(p => p[1]));
|
||
const expHtml = ew.map((w, i) => matchedE.has(i)
|
||
? `<span class="tw-ok">${escHtml(w)}</span>`
|
||
: `<span class="tw-miss">${escHtml(w)}</span>`).join(' ');
|
||
const actHtml = aw.map((w, i) => matchedA.has(i)
|
||
? `<span class="tw-ok">${escHtml(w)}</span>`
|
||
: `<span class="tw-extra">${escHtml(w)}</span>`).join(' ');
|
||
const score = ew.length ? Math.round((matchedE.size / ew.length) * 100) : 100;
|
||
return { html_exp: expHtml, html_act: actHtml, score };
|
||
}
|
||
|
||
function buildTrainSeq() {
|
||
const seq = [];
|
||
const lines = rehState.lines;
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const line = lines[i];
|
||
if (line.type !== 'dialog') continue;
|
||
const cast = rehState.cast[line.speaker];
|
||
if (!cast || cast.voice !== 'me') continue;
|
||
// Gather preceding cue lines (last up to 3 TTS dialog lines going backwards)
|
||
const cueLines = [];
|
||
for (let j = i - 1; j >= 0 && cueLines.length < 3; j--) {
|
||
const prev = lines[j];
|
||
if (prev.type === 'scene' || prev.type === 'act') break;
|
||
if (prev.type !== 'dialog') continue;
|
||
const pc = rehState.cast[prev.speaker];
|
||
if (!pc || pc.voice === 'me') break;
|
||
cueLines.unshift({ index: j, speaker: prev.speaker, text: prev.text, emotion: prev.emotion || '',
|
||
voice: pc.voice, color: pc.color, instruct: _buildInstruct(pc.instruct, prev.emotion),
|
||
voiceData: pc.voiceData });
|
||
}
|
||
seq.push({ cueLines, myLine: { index: i, speaker: line.speaker, text: line.text, color: cast.color } });
|
||
}
|
||
return seq;
|
||
}
|
||
|
||
function _trainSetState(phase) {
|
||
trainState.phase = phase;
|
||
const stateEl = $('reh-train-rec-state');
|
||
const playBtn = $('reh-train-play');
|
||
const recBtn = $('reh-train-record');
|
||
const stopBtn = $('reh-train-stop-rec');
|
||
const cueState = $('reh-train-cue-state');
|
||
const map = {
|
||
idle: { state:'Read the cue above · press ▶ to hear it', play:true, rec:false, stop:false },
|
||
playing_cue: { state:'Playing cue…', play:false, rec:false, stop:false },
|
||
ready: { state:'Press 🎤 to record your line', play:false, rec:true, stop:false },
|
||
recording: { state:'Recording…', play:false, rec:false, stop:true },
|
||
transcribing: { state:'Transcribing…', play:false, rec:false, stop:false },
|
||
done: { state:'Done — press ▶ to replay or ⏭ for next', play:true, rec:true, stop:false },
|
||
};
|
||
const m = map[phase] || map.idle;
|
||
if (stateEl) stateEl.textContent = m.state;
|
||
if (cueState && phase === 'playing_cue') { cueState.innerHTML = '<span class="mdi mdi-volume-high" style="color:var(--accent)"></span>'; }
|
||
else if (cueState) cueState.innerHTML = '';
|
||
if (playBtn) playBtn.disabled = !m.play;
|
||
if (recBtn) { recBtn.disabled = !m.rec; recBtn.hidden = !!m.stop; }
|
||
if (stopBtn) { stopBtn.disabled = !m.stop; stopBtn.hidden = !m.stop; }
|
||
const timerEl = $('reh-train-rec-timer');
|
||
const metersEl = $('reh-train-meters');
|
||
if (phase === 'recording') {
|
||
if (timerEl) timerEl.hidden = false;
|
||
if (metersEl) metersEl.hidden = false;
|
||
} else if (phase !== 'done') {
|
||
if (timerEl) timerEl.hidden = true;
|
||
if (metersEl) metersEl.hidden = true;
|
||
}
|
||
}
|
||
|
||
async function _trainPlayCueLines(cueLines) {
|
||
_trainSetState('playing_cue');
|
||
for (const cue of cueLines) {
|
||
if (trainState.phase !== 'playing_cue') break;
|
||
let blob = rehState.synthCache.get(cue.index);
|
||
if (!blob) {
|
||
try { blob = await fetchTtsPreviewBlob(cue.voice, _rehInlineTone(stripMarkdown(cue.text), cue.emotion), 'wav', cue.instruct, rehState.backend); }
|
||
catch(e) { toast('Cue TTS failed: ' + e.message, 'error'); break; }
|
||
}
|
||
if (trainState.phase !== 'playing_cue') break;
|
||
await new Promise(resolve => {
|
||
const url = URL.createObjectURL(blob);
|
||
const audio = new Audio(url);
|
||
audio.onended = () => { URL.revokeObjectURL(url); resolve(); };
|
||
audio.onerror = () => { URL.revokeObjectURL(url); resolve(); };
|
||
trainState.cueAudio = audio;
|
||
audio.play().catch(resolve);
|
||
});
|
||
}
|
||
if (trainState.phase === 'playing_cue') _trainSetState('ready');
|
||
}
|
||
|
||
async function _trainTranscribe(blob) {
|
||
_trainSetState('transcribing');
|
||
try {
|
||
const fd = new FormData();
|
||
fd.append('file', blob, 'train_rec.webm');
|
||
fd.append('backend', _appSettings?.stt_preferred_backend || 'configured');
|
||
const r = await fetch('/api/transcribe-bytes', { method:'POST', body:fd });
|
||
if (!r.ok) throw new Error(r.statusText);
|
||
const d = await r.json();
|
||
return (d.text || '').trim();
|
||
} catch(e) {
|
||
toast('Transcription failed: ' + e.message, 'error');
|
||
return '';
|
||
}
|
||
}
|
||
|
||
async function trainGoTo(idx) {
|
||
// Stop any in-progress recording or playback
|
||
if (trainState.cueAudio) { trainState.cueAudio.pause(); trainState.cueAudio = null; }
|
||
_trainStopMic();
|
||
|
||
trainState.turn = Math.max(0, Math.min(idx, trainState.seq.length - 1));
|
||
const turn = trainState.seq[trainState.turn];
|
||
if (!turn) return;
|
||
|
||
// Update progress
|
||
const progEl = $('reh-train-progress');
|
||
if (progEl) progEl.textContent = `Turn ${trainState.turn + 1} / ${trainState.seq.length}`;
|
||
|
||
// Render cue card — show ALL cue lines so user can read the full context
|
||
const cueCard = $('reh-train-cue');
|
||
const cueAvEl = $('reh-train-cue-avatar');
|
||
const cueSpeaker = $('reh-train-cue-speaker');
|
||
const cueText = $('reh-train-cue-text');
|
||
if (turn.cueLines.length) {
|
||
const lastCue = turn.cueLines[turn.cueLines.length - 1];
|
||
if (cueAvEl) cueAvEl.innerHTML = voiceAvatarHtml(lastCue.voice, lastCue.color, 32);
|
||
if (cueSpeaker) cueSpeaker.textContent = lastCue.speaker;
|
||
// Show all cue lines as a readable block
|
||
if (cueText) {
|
||
cueText.innerHTML = turn.cueLines.map(c =>
|
||
`<div class="reh-train-cue-block">
|
||
<span class="reh-train-cue-who" style="color:${c.color}">${escHtml(c.speaker)}</span>
|
||
<span class="reh-train-cue-line">${escHtml(c.text)}</span>
|
||
</div>`
|
||
).join('');
|
||
}
|
||
cueCard?.classList.remove('no-cue');
|
||
} else {
|
||
if (cueAvEl) cueAvEl.innerHTML = '<span class="mdi mdi-information-outline" style="font-size:24px;color:var(--subtext)"></span>';
|
||
if (cueSpeaker) cueSpeaker.textContent = 'No preceding cue';
|
||
if (cueText) cueText.textContent = 'This is the first line — speak when ready.';
|
||
cueCard?.classList.add('no-cue');
|
||
}
|
||
|
||
// Render my line card
|
||
const mySpeaker = $('reh-train-my-speaker');
|
||
const myText = $('reh-train-my-text');
|
||
if (mySpeaker) mySpeaker.textContent = turn.myLine.speaker;
|
||
if (myText) myText.textContent = turn.myLine.text;
|
||
|
||
// Hide result from previous turn
|
||
const resultEl = $('reh-train-result');
|
||
if (resultEl) resultEl.hidden = true;
|
||
|
||
// Reset audio preview
|
||
const prevAudio = $('reh-train-audio-preview');
|
||
if (prevAudio) { prevAudio.src = ''; prevAudio.style.display = 'none'; }
|
||
|
||
// Show the script text immediately — user reads first, then clicks ▶ to hear the cue
|
||
_trainSetState(turn.cueLines.length ? 'idle' : 'ready');
|
||
}
|
||
|
||
function _trainStartMeter() {
|
||
const meterEl = $('reh-train-meter');
|
||
const dbEl = $('reh-train-db');
|
||
const waveEl = $('reh-train-wave');
|
||
if (!trainState.recAnalyser || !meterEl) return;
|
||
const analyser = trainState.recAnalyser;
|
||
const fft = new Uint8Array(analyser.frequencyBinCount);
|
||
const wCtx = waveEl?.getContext('2d');
|
||
const W = waveEl?.width || 300, H = waveEl?.height || 36;
|
||
const BARS = 18;
|
||
|
||
function tick() {
|
||
analyser.getByteFrequencyData(fft);
|
||
const rms = fft.reduce((s, v) => s + v * v, 0) / fft.length;
|
||
const db = rms > 0 ? 20 * Math.log10(Math.sqrt(rms) / 128) : -Infinity;
|
||
if (dbEl) dbEl.textContent = isFinite(db) ? db.toFixed(1) + ' dB' : '-∞ dB';
|
||
const slots = Array.from({length: BARS}, (_, i) => {
|
||
const s = Math.floor(i / BARS * fft.length), e = Math.floor((i+1) / BARS * fft.length);
|
||
return fft.slice(s, e).reduce((a, b) => a + b, 0) / (e - s) / 255;
|
||
});
|
||
meterEl.innerHTML = slots.map(v => {
|
||
const h = Math.max(2, Math.round(v * 28));
|
||
const col = v > 0.85 ? 'var(--red)' : v > 0.6 ? '#f59e0b' : 'var(--green)';
|
||
return `<span style="height:${h}px;background:${col};width:4px;border-radius:2px;display:inline-block;vertical-align:bottom;margin:0 1px"></span>`;
|
||
}).join('');
|
||
if (wCtx) {
|
||
analyser.getByteTimeDomainData(fft);
|
||
wCtx.clearRect(0, 0, W, H);
|
||
wCtx.beginPath(); wCtx.strokeStyle = 'var(--accent)'; wCtx.lineWidth = 1.5;
|
||
fft.forEach((v, i) => { const x = (i / fft.length) * W, y = (v / 255) * H; i ? wCtx.lineTo(x, y) : wCtx.moveTo(x, y); });
|
||
wCtx.stroke();
|
||
}
|
||
trainState.recRaf = requestAnimationFrame(tick);
|
||
}
|
||
trainState.recRaf = requestAnimationFrame(tick);
|
||
}
|
||
|
||
function _trainStopMic() {
|
||
if (trainState.recRaf) { cancelAnimationFrame(trainState.recRaf); trainState.recRaf = null; }
|
||
if (trainState.recTimer) { clearInterval(trainState.recTimer); trainState.recTimer = null; }
|
||
if (trainState.mediaRec && trainState.mediaRec.state !== 'inactive') {
|
||
try { trainState.mediaRec.stop(); } catch(_) {}
|
||
}
|
||
trainState.mediaRec = null;
|
||
if (trainState.recCtx) { try { trainState.recCtx.close(); } catch(_) {} trainState.recCtx = null; }
|
||
if (trainState.recStream) { trainState.recStream.getTracks().forEach(t => t.stop()); trainState.recStream = null; }
|
||
trainState.recAnalyser = null;
|
||
trainState.recChunks = [];
|
||
const timerEl = $('reh-train-rec-timer');
|
||
if (timerEl) { timerEl.hidden = true; timerEl.textContent = '0:00'; }
|
||
const metersEl = $('reh-train-meters');
|
||
if (metersEl) metersEl.hidden = true;
|
||
}
|
||
|
||
async function _trainStartRecording() {
|
||
if (trainState.phase !== 'ready') return;
|
||
try {
|
||
const stream = await requestMicrophoneStream({ raw: true });
|
||
trainState.recStream = stream;
|
||
const ctx = new AudioContext();
|
||
trainState.recCtx = ctx;
|
||
const src = ctx.createMediaStreamSource(stream);
|
||
const analyser = ctx.createAnalyser(); analyser.fftSize = 256;
|
||
trainState.recAnalyser = analyser;
|
||
const dst = ctx.createMediaStreamDestination();
|
||
src.connect(analyser); analyser.connect(dst);
|
||
_trainSetState('recording');
|
||
_trainStartMeter();
|
||
|
||
const timerEl = $('reh-train-rec-timer');
|
||
if (timerEl) timerEl.hidden = false;
|
||
trainState.recSecs = 0;
|
||
trainState.recTimer = setInterval(() => {
|
||
trainState.recSecs++;
|
||
const m = Math.floor(trainState.recSecs / 60), s = trainState.recSecs % 60;
|
||
if (timerEl) timerEl.textContent = `${m}:${String(s).padStart(2,'0')}`;
|
||
}, 1000);
|
||
|
||
trainState.recChunks = [];
|
||
const mr = new MediaRecorder(dst.stream, { audioBitsPerSecond: 128000 });
|
||
trainState.mediaRec = mr;
|
||
mr.ondataavailable = e => { if (e.data.size > 0) trainState.recChunks.push(e.data); };
|
||
mr.onstop = async () => {
|
||
_trainStopMic();
|
||
const blob = new Blob(trainState.recChunks, { type: 'audio/webm' });
|
||
trainState.recChunks = [];
|
||
const url = URL.createObjectURL(blob);
|
||
const prevAudio = $('reh-train-audio-preview');
|
||
if (prevAudio) { prevAudio.src = url; prevAudio.style.display = ''; }
|
||
|
||
// Transcribe
|
||
const transcript = await _trainTranscribe(blob);
|
||
const turn = trainState.seq[trainState.turn];
|
||
const { html_exp, html_act, score } = _trainCompare(turn?.myLine?.text || '', transcript);
|
||
|
||
const resultEl = $('reh-train-result');
|
||
const expEl = $('reh-train-expected-text');
|
||
const actEl = $('reh-train-actual-text');
|
||
const scoreEl = $('reh-train-score');
|
||
if (expEl) expEl.innerHTML = html_exp;
|
||
if (actEl) actEl.innerHTML = html_act || '<em style="color:var(--subtext)">Nothing detected</em>';
|
||
if (scoreEl) {
|
||
const col = score >= 90 ? 'var(--green)' : score >= 60 ? '#f59e0b' : 'var(--red)';
|
||
scoreEl.innerHTML = `<span style="color:${col};font-weight:700">${score}%</span>`;
|
||
}
|
||
if (resultEl) resultEl.hidden = false;
|
||
_trainSetState('done');
|
||
};
|
||
mr.start();
|
||
} catch(e) {
|
||
toast('Microphone error: ' + e.message, 'error');
|
||
_trainSetState('ready');
|
||
}
|
||
}
|
||
|
||
function _trainStopRecording() {
|
||
if (trainState.mediaRec && trainState.mediaRec.state === 'recording') {
|
||
trainState.mediaRec.stop();
|
||
}
|
||
}
|
||
|
||
function _trainHideStage(hide) {
|
||
// Show/hide the script content area and auxiliary bars — transport bar stays visible always
|
||
const els = ['.reh-stage-area', '#reh-tts-status-bar', '#reh-rec-overlay', '#reh-synth-bar'];
|
||
els.forEach(sel => {
|
||
const el = document.querySelector(sel) || document.getElementById(sel.replace('#',''));
|
||
if (el) el.style.display = hide ? 'none' : '';
|
||
});
|
||
}
|
||
|
||
function enterTrainMode() {
|
||
const seq = buildTrainSeq();
|
||
if (!seq.length) {
|
||
toast('No "I play this" lines found — check "I play this" on at least one character in the Cast tab.', 'error');
|
||
return;
|
||
}
|
||
trainState.seq = seq;
|
||
trainState.turn = 0;
|
||
trainState.phase = 'idle';
|
||
|
||
_trainHideStage(true);
|
||
const panel = $('reh-train-panel');
|
||
if (panel) panel.hidden = false;
|
||
|
||
trainGoTo(0);
|
||
}
|
||
|
||
function exitTrainMode() {
|
||
if (trainState.cueAudio) { trainState.cueAudio.pause(); trainState.cueAudio = null; }
|
||
_trainStopMic();
|
||
trainState.phase = 'idle';
|
||
trainState.seq = [];
|
||
|
||
_trainHideStage(false);
|
||
const panel = $('reh-train-panel');
|
||
if (panel) panel.hidden = true;
|
||
}
|
||
|
||
// ── Bulk-edit button wiring ─────────────────────────────────────────────────
|
||
$('reh-bulk-toggle')?.addEventListener('click', () => setBulkMode(!rehState.bulkMode));
|
||
$('reh-bulk-done')?.addEventListener('click', () => setBulkMode(false));
|
||
$('reh-bulk-all')?.addEventListener('click', () => {
|
||
// Select every line currently visible in the stage
|
||
document.querySelectorAll('#reh-script-lines .reh-bulk-check').forEach(cb => rehState.bulkSel.add(parseInt(cb.dataset.bulk)));
|
||
document.querySelectorAll('#reh-script-lines .reh-bulk-check').forEach(cb => _refreshBulkLine(parseInt(cb.dataset.bulk)));
|
||
_updateBulkCount();
|
||
});
|
||
$('reh-bulk-none')?.addEventListener('click', () => {
|
||
const had = [...rehState.bulkSel];
|
||
rehState.bulkSel.clear();
|
||
had.forEach(_refreshBulkLine);
|
||
_updateBulkCount();
|
||
});
|
||
$('reh-bulk-ignore')?.addEventListener('click', () => _bulkApply(l => { l.ignored = true; }));
|
||
$('reh-bulk-unignore')?.addEventListener('click', () => _bulkApply(l => { l.ignored = false; }));
|
||
$('reh-bulk-hide')?.addEventListener('click', () => _bulkApply(l => { l.hidden = true; }));
|
||
$('reh-bulk-delete')?.addEventListener('click', _bulkDelete);
|
||
$('reh-bulk-show-hidden')?.addEventListener('change', e => {
|
||
rehState.showHidden = e.target.checked;
|
||
buildScriptPage();
|
||
});
|
||
|
||
// ── Train button wiring ─────────────────────────────────────────────────────
|
||
$('reh-page-mode-btn')?.addEventListener('click', cyclePageMode);
|
||
$('reh-train-open-btn')?.addEventListener('click', enterTrainMode);
|
||
$('reh-train-exit-btn')?.addEventListener('click', exitTrainMode);
|
||
|
||
$('reh-train-play')?.addEventListener('click', () => {
|
||
const turn = trainState.seq[trainState.turn];
|
||
if (!turn) return;
|
||
if (trainState.phase === 'playing_cue') return;
|
||
if (trainState.cueAudio) { trainState.cueAudio.pause(); trainState.cueAudio = null; }
|
||
if (turn.cueLines.length) _trainPlayCueLines(turn.cueLines);
|
||
else _trainSetState('ready');
|
||
});
|
||
|
||
$('reh-train-record')?.addEventListener('click', () => {
|
||
if (trainState.phase === 'ready' || trainState.phase === 'done') _trainStartRecording();
|
||
});
|
||
|
||
$('reh-train-stop-rec')?.addEventListener('click', _trainStopRecording);
|
||
|
||
$('reh-train-prev')?.addEventListener('click', () => {
|
||
if (trainState.turn > 0) trainGoTo(trainState.turn - 1);
|
||
});
|
||
|
||
$('reh-train-next')?.addEventListener('click', () => {
|
||
if (trainState.turn < trainState.seq.length - 1) trainGoTo(trainState.turn + 1);
|
||
});
|
||
|
||
$('reh-train-repeat')?.addEventListener('click', () => {
|
||
if (trainState.cueAudio) { trainState.cueAudio.pause(); trainState.cueAudio = null; }
|
||
_trainStopMic();
|
||
const turn = trainState.seq[trainState.turn];
|
||
if (!turn) return;
|
||
const resultEl = $('reh-train-result');
|
||
if (resultEl) resultEl.hidden = true;
|
||
if (turn.cueLines.length) _trainPlayCueLines(turn.cueLines);
|
||
else _trainSetState('ready');
|
||
});
|
||
|
||
// ── Init ───────────────────────────────────────────────────────────────────
|
||
rehRenderMeter();
|
||
_syncPageModeBtn();
|
||
renderLibraryList().catch(()=>{});
|
||
if($('reh-skip-desc-toggle'))$('reh-skip-desc-toggle').checked=rehState.skipDescriptions;
|
||
if (window._rehearserStartImpEx) {
|
||
delete window._rehearserStartImpEx;
|
||
showRehImpEx();
|
||
} else {
|
||
const _initPhase = window._rehearserStartPhase || 1;
|
||
delete window._rehearserStartPhase;
|
||
showPhase(_initPhase);
|
||
}
|