3269 lines
153 KiB
JavaScript
3269 lines
153 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' },
|
||
];
|
||
|
||
// Exposed so the emotion quick-pickers in tts-preview.js / reader.js can read this
|
||
// list. They run in the same bundle scope but BEFORE this file, so they must go
|
||
// through window (and defer to a macrotask) rather than touch the const directly.
|
||
window.REH_EMOTIONS = REH_EMOTIONS;
|
||
|
||
// Load custom emotions from localStorage
|
||
let rehCustomEmotions = [];
|
||
try { rehCustomEmotions = JSON.parse(localStorage.getItem('reh-custom-emotions') || '[]'); } catch(_) {}
|
||
|
||
function getEmotionInfo(value) {
|
||
if (!value) return { emoji: '', label: 'Pick tone' };
|
||
const all = [...REH_EMOTIONS, ...rehCustomEmotions];
|
||
const found = all.find(e => e.value === value);
|
||
if (found) return { emoji: found.emoji, label: found.label };
|
||
return { emoji: '✨', label: value.length > 14 ? value.slice(0, 13) + '…' : value };
|
||
}
|
||
|
||
// ── Markdown helpers ───────────────────────────────────────────────────────
|
||
|
||
function renderMarkdownInline(text) {
|
||
// Escape HTML first, then apply inline markdown
|
||
let s = escHtml(text);
|
||
s = s.replace(/\*\*([^*\n]+?)\*\*/g, '<strong>$1</strong>');
|
||
s = s.replace(/\*([^*\n]+?)\*/g, '<em>$1</em>');
|
||
s = s.replace(/__([^_\n]+?)__/g, '<u>$1</u>');
|
||
s = s.replace(/~~([^~\n]+?)~~/g, '<del>$1</del>');
|
||
s = s.replace(/==([^=\n]+?)==/g, '<mark class="reh-hl">$1</mark>');
|
||
return s;
|
||
}
|
||
|
||
function stripMarkdown(text) {
|
||
return text
|
||
.replace(/\*\*([^*\n]+?)\*\*/g, '$1')
|
||
.replace(/\*([^*\n]+?)\*/g, '$1')
|
||
.replace(/__([^_\n]+?)__/g, '$1')
|
||
.replace(/~~([^~\n]+?)~~/g, '$1')
|
||
.replace(/==([^=\n]+?)==/g, '$1');
|
||
}
|
||
|
||
// ── State ─────────────────────────────────────────────────────────────────
|
||
class RehAudioCache extends Map {
|
||
static MAX_ENTRIES = 128;
|
||
get(key) {
|
||
if (!super.has(key)) return undefined;
|
||
const value = super.get(key);
|
||
super.delete(key); // Map insertion order is the LRU order.
|
||
super.set(key, value);
|
||
return value;
|
||
}
|
||
set(key, value) {
|
||
super.delete(key);
|
||
super.set(key, value);
|
||
while (this.size > RehAudioCache.MAX_ENTRIES) {
|
||
let oldest = this.keys().next().value;
|
||
const current = typeof rehState !== 'undefined' ? rehState.lineIndex : null;
|
||
if (oldest === current && this.size > 1) {
|
||
const iterator = this.keys();
|
||
iterator.next();
|
||
oldest = iterator.next().value;
|
||
}
|
||
super.delete(oldest);
|
||
}
|
||
return this;
|
||
}
|
||
}
|
||
const rehState = {
|
||
lines: [],
|
||
cast: {},
|
||
lineIndex: 0,
|
||
clips: [],
|
||
voices: [],
|
||
backend: '',
|
||
playing: false,
|
||
repeat: false,
|
||
savedId: null,
|
||
synthCache: new RehAudioCache(),
|
||
staleLines: new Set(), // lines that were cached but had their tone changed
|
||
synthCancelled: false,
|
||
synthRunning: false,
|
||
skipDescriptions: false,
|
||
narratorVoice: '',
|
||
practiceStart: null,
|
||
practiceEnd: null,
|
||
bulkMode: false, // bulk-edit (line selection) mode on/off
|
||
bulkSel: new Set(), // indices of currently selected lines
|
||
bulkAnchor: null, // last clicked index for Shift+click range selection
|
||
showHidden: false, // reveal hidden lines (so they can be restored)
|
||
recStream: null, recAudioCtx: null, recAnalyser: null,
|
||
recSourceNode: null, recGainNode: null, recDestStream: null,
|
||
recMeterRaf: null, recWaveRing: null, mediaRec: null,
|
||
recChunks: [], recTimer: null, recSecs: 0, lastRecBlob: null,
|
||
};
|
||
window.rehState = rehState;
|
||
|
||
window.rehLoadRecord = loadRecord; // expose so audiobook.js can open a record directly
|
||
|
||
// Fill any empty cast slots from the production's shared character roster
|
||
// (character library, matched by book OR tag). Only blanks are filled — an
|
||
// explicit voice/soul already on the rehearsal always wins. Re-renders if it
|
||
// changed anything.
|
||
async function rehApplySharedCast(title) {
|
||
if (typeof castForProduction !== 'function' || !title) return;
|
||
let roster;
|
||
try { roster = await castForProduction(title); } catch (_) { return; }
|
||
if (!roster) return;
|
||
let changed = false;
|
||
Object.keys(rehState.cast || {}).forEach(sp => {
|
||
// Narrator can now have a real, persisted Library record too (Assign
|
||
// Voices synthesizes one per production) — pull its voice the same way
|
||
// as any other shared cast entry instead of skipping it, so a voice
|
||
// picked there actually reaches the Rehearser/synthesis. The narrator's
|
||
// cast key is the emoji-prefixed REH_NARRATOR_KEY sentinel, not its
|
||
// plain "Narrator" library name, so the roster lookup below needs the
|
||
// same translation renderCastStrip() already uses elsewhere — without
|
||
// it, `roster["📖narrator"]` always misses and this silently never
|
||
// fires for narrator at all (confirmed live: synthAll() then skips
|
||
// every narration line since it reads rehState.narratorVoice directly,
|
||
// which this function is also the only place expected to set from a
|
||
// shared/library voice).
|
||
const lookupName = sp === REH_NARRATOR_KEY ? 'narrator' : String(sp).toLowerCase();
|
||
const shared = roster[lookupName];
|
||
if (!shared) return;
|
||
const slot = rehState.cast[sp];
|
||
if (shared.voice && !slot.voice) {
|
||
slot.voice = shared.voice;
|
||
slot.voiceData = (shared.voice !== 'me' && typeof getVoiceData === 'function') ? getVoiceData(shared.voice) : null;
|
||
changed = true;
|
||
if (sp === REH_NARRATOR_KEY) rehState.narratorVoice = shared.voice;
|
||
}
|
||
if (shared.gender && !slot.gender) { slot.gender = shared.gender; changed = true; }
|
||
if (shared.soul && !slot.soul) { slot.soul = shared.soul; changed = true; }
|
||
});
|
||
if (changed) renderCastList();
|
||
}
|
||
|
||
// ── Library UI ─────────────────────────────────────────────────────────────
|
||
|
||
// Deterministic cover gradient from the title string
|
||
function bookCover(title) {
|
||
let h = 0;
|
||
for (let i = 0; i < title.length; i++) h = (h * 31 + title.charCodeAt(i)) % 360;
|
||
const h2 = (h + 40) % 360;
|
||
return { c1: `hsl(${h}, 55%, 42%)`, c2: `hsl(${h2}, 58%, 30%)` };
|
||
}
|
||
|
||
let rehLibView = localStorage.getItem('reh-lib-view') || 'shelf';
|
||
|
||
function rehTitleKey(title) {
|
||
return String(title || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
||
}
|
||
|
||
function rehUniqueLibraryRecords(records) {
|
||
const byTitle = new Map();
|
||
const ordered = [...records].sort((a, b) => {
|
||
const at = new Date(a.updated || 0).getTime();
|
||
const bt = new Date(b.updated || 0).getTime();
|
||
if (at !== bt) return bt - at;
|
||
return (b.id || 0) - (a.id || 0);
|
||
});
|
||
for (const rec of ordered) {
|
||
const key = rehTitleKey(rec.title);
|
||
const bucketKey = key || `__reh__${rec.id}`;
|
||
if (!byTitle.has(bucketKey)) byTitle.set(bucketKey, rec);
|
||
}
|
||
return [...byTitle.values()].sort((a, b) => {
|
||
const at = new Date(a.updated || 0).getTime();
|
||
const bt = new Date(b.updated || 0).getTime();
|
||
if (at !== bt) return bt - at;
|
||
return (b.id || 0) - (a.id || 0);
|
||
});
|
||
}
|
||
|
||
async function renderLibraryList() {
|
||
const list = $('reh-library-list'); if (!list) return;
|
||
let all;
|
||
try { all = await rehDbGetAll(); } catch(e) { all = []; }
|
||
all = rehUniqueLibraryRecords(all);
|
||
|
||
list.classList.toggle('list-view', rehLibView === 'list');
|
||
const vt = $('reh-lib-view-toggle');
|
||
if (vt) vt.innerHTML = `<span class="mdi mdi-${rehLibView === 'list' ? 'view-grid' : 'view-list'}"></span>`;
|
||
|
||
if (!all.length) {
|
||
list.innerHTML = '<div class="reh-lib-empty"><span class="mdi mdi-book-open-outline"></span><p>No saved rehearsals yet.<br>Parse a script below or drop a file to start.</p></div>';
|
||
return;
|
||
}
|
||
|
||
list.innerHTML = all.map(rec => {
|
||
const speakers = Object.keys(rec.cast || {});
|
||
const meCount = speakers.filter(sp => rec.cast[sp]?.voice === 'me').length;
|
||
const clipCount = (rec.clips || []).filter(c => c.type === 'me' && c.blob).length;
|
||
const total = parseScript(rec.script || '').filter(l => l.type === 'dialog').length;
|
||
const pct = total ? Math.round(((rec.lineIndex || 0) / total) * 100) : 0;
|
||
const date = rec.updated ? new Date(rec.updated).toLocaleDateString() : '—';
|
||
const isCurrent = rec.id === rehState.savedId;
|
||
const title = rec.title || 'Untitled';
|
||
const { c1, c2 } = bookCover(title);
|
||
const avatars = speakers.slice(0, 5).map(sp => {
|
||
const c = rec.cast[sp];
|
||
return `<span style="background:${c.color}">${sp[0].toUpperCase()}</span>`;
|
||
}).join('');
|
||
return `<div class="reh-book" data-id="${rec.id}" style="--bk1:${c1};--bk2:${c2}" title="${escHtml(title)}">
|
||
${isCurrent ? '<span class="reh-book-active-tag">active</span>' : ''}
|
||
<div class="reh-book-actions">
|
||
<button class="reh-book-act reh-lib-export" data-id="${rec.id}" title="Export .reh"><span class="mdi mdi-export"></span></button>
|
||
<button class="reh-book-act reh-lib-delete" data-id="${rec.id}" title="Delete"><span class="mdi mdi-delete-outline"></span></button>
|
||
</div>
|
||
<div class="reh-book-title">${escHtml(title)}</div>
|
||
<div class="reh-book-meta">
|
||
<div class="reh-book-avatars">${avatars}</div>
|
||
${total} lines · ${speakers.length} cast${meCount ? ' · ' + meCount + ' me' : ''}${clipCount ? ' · ' + clipCount + '🎤' : ''}
|
||
<div class="reh-book-progress"><div style="width:${pct}%"></div></div>
|
||
<div style="margin-top:4px;opacity:.8">${pct}% · ${date}</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
// Click book → open
|
||
list.querySelectorAll('.reh-book').forEach(book => book.addEventListener('click', async e => {
|
||
if (e.target.closest('.reh-book-act')) return;
|
||
const id = parseInt(book.dataset.id);
|
||
// rehDbOpen()/REH_STORE were the pre-migration raw-IndexedDB API and no
|
||
// longer exist — rehearsals are server-backed now (rehDbGetById).
|
||
const rec = await rehDbGetById(id);
|
||
if (rec) loadRecord(rec);
|
||
else toast('Rehearsal not found', 'error');
|
||
}));
|
||
|
||
list.querySelectorAll('.reh-lib-export').forEach(btn => btn.addEventListener('click', async e => {
|
||
e.stopPropagation();
|
||
const id = parseInt(btn.dataset.id);
|
||
const rec = await rehDbGetById(id);
|
||
if (!rec) { toast('Rehearsal not found', 'error'); return; }
|
||
rec.version = 1;
|
||
rec.clips = await clipsToJson(rec.clips || []);
|
||
const json = JSON.stringify(rec, null, 2);
|
||
const a = document.createElement('a');
|
||
const t = (rec.title || 'rehearsal').replace(/[^a-z0-9_\- ]/gi, '_').slice(0, 40);
|
||
a.href = URL.createObjectURL(new Blob([json], { type: 'application/json' }));
|
||
a.download = t + '.reh';
|
||
a.click();
|
||
}));
|
||
|
||
list.querySelectorAll('.reh-lib-delete').forEach(btn => btn.addEventListener('click', async e => {
|
||
e.stopPropagation();
|
||
|
||
const bookEl = btn.closest('.reh-book');
|
||
if (bookEl.querySelector('.reh-book-del-confirm')) return;
|
||
|
||
const confirmOverlay = document.createElement('div');
|
||
confirmOverlay.className = 'reh-book-del-confirm';
|
||
confirmOverlay.style.cssText = 'position:absolute; inset:0; background:rgba(0,0,0,0.85); color:#fff; display:flex; flex-direction:column; justify-content:center; align-items:center; border-radius:inherit; z-index:10; padding:12px; text-align:center; box-sizing:border-box;';
|
||
confirmOverlay.innerHTML = `
|
||
<div style="font-weight:600; margin-bottom:8px; font-size:14px;">Delete Rehearsal?</div>
|
||
<div style="font-size:11px; opacity:0.8; margin-bottom:12px; line-height:1.4;">This cannot be undone.</div>
|
||
<div style="display:flex; gap:8px;">
|
||
<button class="btn-secondary btn-sm" id="btn-cancel-del" style="background:rgba(255,255,255,0.15); border:none; color:#fff; padding:6px 12px;">Cancel</button>
|
||
<button class="btn-primary btn-sm" id="btn-confirm-del" style="background:var(--red,#ef4444); border:none; color:#fff; padding:6px 12px;">Delete</button>
|
||
</div>
|
||
`;
|
||
|
||
confirmOverlay.addEventListener('click', ce => ce.stopPropagation());
|
||
|
||
confirmOverlay.querySelector('#btn-cancel-del').addEventListener('click', ce => {
|
||
ce.stopPropagation();
|
||
confirmOverlay.remove();
|
||
});
|
||
|
||
confirmOverlay.querySelector('#btn-confirm-del').addEventListener('click', async ce => {
|
||
ce.stopPropagation();
|
||
await rehDbDelete(parseInt(btn.dataset.id));
|
||
if (rehState.savedId === parseInt(btn.dataset.id)) rehState.savedId = null;
|
||
renderLibraryList();
|
||
});
|
||
|
||
bookEl.appendChild(confirmOverlay);
|
||
}));
|
||
}
|
||
|
||
$('reh-lib-view-toggle')?.addEventListener('click', () => {
|
||
rehLibView = rehLibView === 'shelf' ? 'list' : 'shelf';
|
||
localStorage.setItem('reh-lib-view', rehLibView);
|
||
renderLibraryList();
|
||
});
|
||
|
||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||
|
||
function getVoiceData(voiceId) {
|
||
return (window._voices || []).find(v => v.id === voiceId) || null;
|
||
}
|
||
|
||
function _rehVoiceVisibleId(voiceId, include = '') {
|
||
if (!voiceId) return false;
|
||
if (include && voiceId === include) return true;
|
||
const v = getVoiceData(voiceId);
|
||
return !v || v.enabled !== false;
|
||
}
|
||
|
||
function voiceAvatarHtml(voiceId, color, size = 32) {
|
||
const v = getVoiceData(voiceId);
|
||
const s = size + 'px';
|
||
const radius = Math.round(size / 2);
|
||
if (v?.has_picture) {
|
||
return `<img src="/api/voice/picture/${encodeURIComponent(voiceId)}" class="reh-char-img" style="width:${s};height:${s};border-radius:${radius}px;object-fit:cover;flex-shrink:0" alt="${escHtml(voiceId)}">`;
|
||
}
|
||
const ic = (v?.avatar && window.VOICE_AVATAR_ICONS) ? window.VOICE_AVATAR_ICONS[v.avatar] : null;
|
||
if (ic) {
|
||
return `<span class="reh-char-img" style="width:${s};height:${s};border-radius:${radius}px;background:${color};color:#fff;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0"><span class="mdi ${ic}" style="font-size:${Math.round(size * 0.56)}px"></span></span>`;
|
||
}
|
||
const initial = (voiceId || '?')[0].toUpperCase();
|
||
return `<span class="reh-char-img" style="width:${s};height:${s};border-radius:${radius}px;background:${color};color:#fff;font-weight:700;font-size:${Math.round(size * 0.45)}px;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0">${initial}</span>`;
|
||
}
|
||
|
||
// Stage line rows used a generic voice icon/initial for the play-avatar
|
||
// button — a plain "?" for any speaker whose assigned voice has no picture
|
||
// of its own, even when the CHARACTER already has a real portrait in the
|
||
// Library (same one shown everywhere else: Cast Audiobook, Assign Voices,
|
||
// the Cast sidebar strip right next to this same line). Prefer that.
|
||
function _rehCharAvatarHtml(speaker, voiceId, color, size = 32) {
|
||
const rec = (_rehLibCharsCache || []).find(r => String(r.name || '').trim().toLowerCase() === String(speaker || '').trim().toLowerCase());
|
||
if (rec && rec.image) {
|
||
const s = size + 'px'; const radius = Math.round(size / 2);
|
||
// Reference the image by URL, never inline the raw data: URL here — this
|
||
// renders once PER DIALOGUE LINE, and a character can speak hundreds of
|
||
// lines; embedding a multi-KB/MB base64 blob that many times ballooned a
|
||
// real 1968-line script's HTML to hundreds of megabytes and silently
|
||
// failed to render at all (confirmed live). The browser fetches/caches
|
||
// the URL once regardless of how many lines reference it.
|
||
return `<img src="/api/characters/${encodeURIComponent(rec.id)}/image" class="reh-char-img" style="width:${s};height:${s};border-radius:${radius}px;object-fit:cover;flex-shrink:0" alt="${escHtml(speaker)}">`;
|
||
}
|
||
return voiceAvatarHtml(voiceId, color, size);
|
||
}
|
||
|
||
// ── Script parser + detectCharacters → moved to rehearser-parse.js (loaded first) ──
|
||
|
||
// ── Phase navigation ───────────────────────────────────────────────────────
|
||
|
||
function showPhase(n) {
|
||
// Hide the import/export panel when switching to a numbered phase
|
||
const impex = $('reh-impex-panel'); if (impex) impex.hidden = true;
|
||
for (let i = 1; i <= 4; i++) { const el = $('reh-phase-' + i); if (el) el.hidden = i !== n; }
|
||
syncPhaseTabs(n);
|
||
if (typeof window.onRehearserPhaseChange === 'function') window.onRehearserPhaseChange(n);
|
||
}
|
||
|
||
window.showRehImpEx = function () {
|
||
stopPlay(); stopRehMic();
|
||
for (let i = 1; i <= 4; i++) { const el = $('reh-phase-' + i); if (el) el.hidden = true; }
|
||
const panel = $('reh-impex-panel'); if (panel) panel.hidden = false;
|
||
// Show export note based on state
|
||
const note = $('reh-impex-export-note');
|
||
if (note) note.textContent = rehState.lines.length
|
||
? `Current session: "${$('reh-script-title')?.value || 'Untitled'}" · ${rehState.lines.filter(l=>l.type==='dialog').length} lines`
|
||
: 'No active session — load a script first to export.';
|
||
};
|
||
|
||
// Keep the sub-tab bar in sync: highlight current, enable reachable phases
|
||
function syncPhaseTabs(n) {
|
||
const hasScript = rehState.lines.length > 0;
|
||
const hasClips = rehState.clips.length > 0;
|
||
document.querySelectorAll('.reh-subtab').forEach(tab => {
|
||
const p = parseInt(tab.dataset.phase);
|
||
tab.classList.toggle('active', p === n);
|
||
// Library always reachable; Cast/Stage need a parsed script; Summary needs clips (or being on it)
|
||
let enabled = p === 1
|
||
|| (p === 2 && hasScript)
|
||
|| (p === 3 && hasScript)
|
||
|| (p === 4 && (hasClips || n === 4));
|
||
tab.disabled = !enabled;
|
||
});
|
||
}
|
||
|
||
document.querySelectorAll('.reh-subtab').forEach(tab => {
|
||
tab.addEventListener('click', () => {
|
||
if (tab.disabled) return;
|
||
const p = parseInt(tab.dataset.phase);
|
||
// Leaving the stage: stop playback/mic so audio doesn't keep running
|
||
if (p !== 3) { stopPlay(); stopRehMic(); hideRecOverlay(); }
|
||
if (p === 1) renderLibraryList();
|
||
if (p === 3) {
|
||
// (Re)build the stage if we have a cast/script
|
||
if (rehState.lines.length) { buildScriptPage(); showPhase(3); highlightCurrentLine(); return; }
|
||
}
|
||
if (p === 4) { renderSummary(); }
|
||
showPhase(p);
|
||
});
|
||
});
|
||
|
||
// ── Phase 1 ────────────────────────────────────────────────────────────────
|
||
|
||
$('reh-file-input')?.addEventListener('change', function () {
|
||
const f = this.files?.[0]; if (!f) return;
|
||
const guess = f.name.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim();
|
||
const r = new FileReader();
|
||
r.onload = async e => {
|
||
$('reh-script-text').value = e.target.result;
|
||
await rehAutoSaveImport(guess);
|
||
toast('Script loaded & saved to your library — click Parse & cast', 'success');
|
||
};
|
||
r.readAsText(f); this.value = '';
|
||
});
|
||
|
||
$('reh-pdf-input')?.addEventListener('change', async function () {
|
||
const f = this.files?.[0]; if (!f) return;
|
||
this.value = '';
|
||
try {
|
||
const text = await importPDFScript(f);
|
||
if ($('reh-script-text')) $('reh-script-text').value = text;
|
||
const guess = f.name.replace(/\.pdf$/i, '').replace(/[-_]+/g, ' ').trim();
|
||
await rehAutoSaveImport(guess);
|
||
toast('PDF imported & saved to your library — check formatting, then click Parse & cast', 'success');
|
||
} catch(e) { toast('PDF import failed: ' + e.message, 'error'); }
|
||
});
|
||
|
||
$('reh-fdx-input')?.addEventListener('change', async function () {
|
||
const f = this.files?.[0]; if (!f) return;
|
||
this.value = '';
|
||
try {
|
||
const text = await importFDX(f);
|
||
if ($('reh-script-text')) $('reh-script-text').value = text;
|
||
if (!$('reh-script-title')?.value.trim()) {
|
||
const guess = f.name.replace(/\.fdx$/i, '').replace(/[-_]+/g, ' ').trim();
|
||
if ($('reh-script-title')) $('reh-script-title').value = guess;
|
||
}
|
||
toast('FDX imported — click Parse & cast', 'success');
|
||
} catch(e) { toast('FDX import failed: ' + e.message, 'error'); }
|
||
});
|
||
|
||
// ── Unified import router (used by file pickers + drag & drop) ──────────────
|
||
async function rehImportAnyFile(f) {
|
||
if (!f) return;
|
||
const name = f.name.toLowerCase();
|
||
const setTitle = strip => {
|
||
if (!$('reh-script-title')?.value.trim()) {
|
||
const guess = f.name.replace(strip, '').replace(/[-_]+/g, ' ').trim();
|
||
if ($('reh-script-title')) $('reh-script-title').value = guess;
|
||
}
|
||
};
|
||
try {
|
||
if (name.endsWith('.reh') || name.endsWith('.json')) {
|
||
await importFromFile(f); // full session → loads & jumps to cast
|
||
return;
|
||
}
|
||
if (name.endsWith('.pdf')) {
|
||
toast('Reading PDF…', 'success');
|
||
const text = await importPDFScript(f);
|
||
if ($('reh-script-text')) $('reh-script-text').value = text;
|
||
setTitle(/\.pdf$/i);
|
||
await rehAutoSaveImport();
|
||
toast('PDF imported & saved to your library — check formatting, then Parse & cast', 'success');
|
||
return;
|
||
}
|
||
if (name.endsWith('.fdx') || name.endsWith('.osf') || name.endsWith('.xml')) {
|
||
const text = await importFDX(f);
|
||
if ($('reh-script-text')) $('reh-script-text').value = text;
|
||
setTitle(/\.(fdx|osf|xml)$/i);
|
||
await rehAutoSaveImport();
|
||
toast('Script imported & saved to your library — click Parse & cast', 'success');
|
||
return;
|
||
}
|
||
// .txt / .md / .fountain / anything else → plain text
|
||
const text = await f.text();
|
||
if ($('reh-script-text')) $('reh-script-text').value = text;
|
||
setTitle(/\.(txt|md|fountain)$/i);
|
||
await rehAutoSaveImport();
|
||
toast('Script loaded & saved to your library — click Parse & cast', 'success');
|
||
} catch(e) { toast('Import failed: ' + e.message, 'error'); }
|
||
}
|
||
|
||
// Drag & drop over the dropzone AND the whole Phase-1 panel
|
||
(function setupRehDropzone() {
|
||
const dz = $('reh-dropzone');
|
||
const phase = $('reh-phase-1');
|
||
if (!dz || !phase) return;
|
||
let depth = 0;
|
||
|
||
const isFileDrag = e => e.dataTransfer && [...(e.dataTransfer.types || [])].includes('Files');
|
||
|
||
phase.addEventListener('dragenter', e => {
|
||
if (!isFileDrag(e)) return;
|
||
e.preventDefault(); depth++; dz.classList.add('dragover');
|
||
dz.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||
});
|
||
phase.addEventListener('dragover', e => { if (isFileDrag(e)) e.preventDefault(); });
|
||
phase.addEventListener('dragleave', e => {
|
||
if (!isFileDrag(e)) return;
|
||
depth = Math.max(0, depth - 1);
|
||
if (depth === 0) dz.classList.remove('dragover');
|
||
});
|
||
phase.addEventListener('drop', async e => {
|
||
if (!isFileDrag(e)) return;
|
||
e.preventDefault(); depth = 0; dz.classList.remove('dragover');
|
||
const f = e.dataTransfer.files?.[0];
|
||
await rehImportAnyFile(f);
|
||
});
|
||
})();
|
||
|
||
$('reh-parse-btn')?.addEventListener('click', () => {
|
||
const text = $('reh-script-text')?.value.trim();
|
||
if (!text) { toast('Paste or upload a script first', 'error'); return; }
|
||
rehState.lines = parseScript(text);
|
||
if (!rehState.lines.filter(l => l.type === 'dialog').length) {
|
||
toast('No dialog found. Use screenplay format (CAPS name + dialog) or CHAR: text.', 'error'); return;
|
||
}
|
||
// Preserve cast for returning characters
|
||
const detected = detectCharacters(rehState.lines);
|
||
Object.entries(detected).forEach(([sp]) => {
|
||
if (rehState.cast[sp]) detected[sp] = { ...detected[sp], ...rehState.cast[sp] };
|
||
});
|
||
rehState.cast = detected;
|
||
// Keep the imported/loaded library entry on the first parse so casting updates it
|
||
// instead of orphaning it; otherwise start a fresh entry.
|
||
if (rehState._keepSavedIdOnce) rehState._keepSavedIdOnce = false;
|
||
else rehState.savedId = null;
|
||
rehState.clips = [];
|
||
rehState.lineIndex = 0;
|
||
rehState.synthCache.clear(); rehDecodedBuffers.clear();
|
||
renderCastList();
|
||
showPhase(2);
|
||
refreshRehBackends();
|
||
});
|
||
|
||
// ── Phase 2 ────────────────────────────────────────────────────────────────
|
||
|
||
function castAvatarHtml(sp) {
|
||
const c = rehState.cast[sp]; if (!c) return '';
|
||
if (c.voice && c.voice !== 'me') {
|
||
const vd = getVoiceData(c.voice);
|
||
if (vd?.has_picture) return `<img src="/api/voice/picture/${encodeURIComponent(c.voice)}" style="width:36px;height:36px;border-radius:50%;object-fit:cover;border:2px solid ${c.color}" alt="">`;
|
||
const ic = (vd?.avatar && window.VOICE_AVATAR_ICONS) ? window.VOICE_AVATAR_ICONS[vd.avatar] : null;
|
||
if (ic) return `<span style="width:36px;height:36px;border-radius:50%;background:${c.color};color:#fff;display:inline-flex;align-items:center;justify-content:center;border:2px solid ${c.color}"><span class="mdi ${ic}" style="font-size:20px"></span></span>`;
|
||
}
|
||
const initial = sp[0].toUpperCase();
|
||
return `<span style="width:36px;height:36px;border-radius:50%;background:${c.color};color:#fff;font-weight:700;font-size:16px;display:inline-flex;align-items:center;justify-content:center;border:2px solid ${c.color}">${initial}</span>`;
|
||
}
|
||
|
||
|
||
const REH_NARRATOR_KEY = '\u{1F4D6}NARRATOR'; // unique sentinel kept out of real speaker names
|
||
|
||
function _ensureNarrator() {
|
||
if (!rehState.cast[REH_NARRATOR_KEY]) {
|
||
rehState.cast[REH_NARRATOR_KEY] = {
|
||
voice: rehState.narratorVoice || '',
|
||
color: '#6b7280',
|
||
instruct: '',
|
||
voiceData: rehState.narratorVoice ? getVoiceData(rehState.narratorVoice) : null,
|
||
_isNarrator: true,
|
||
};
|
||
}
|
||
// Bidirectional sync between the narrator cast row and the global narratorVoice.
|
||
// The cast row is the source of truth (auto-design assigns the voice there), but
|
||
// playback/synth read rehState.narratorVoice — keep them aligned both ways.
|
||
const n = rehState.cast[REH_NARRATOR_KEY];
|
||
if (n.voice) {
|
||
rehState.narratorVoice = n.voice;
|
||
if (!n.voiceData) n.voiceData = getVoiceData(n.voice);
|
||
} else if (rehState.narratorVoice) {
|
||
n.voice = rehState.narratorVoice;
|
||
n.voiceData = getVoiceData(rehState.narratorVoice);
|
||
}
|
||
}
|
||
|
||
const REH_CAST_LANGS = ['English', 'German', 'Auto', 'French', 'Spanish', 'Italian', 'Portuguese', 'Dutch', 'Polish'];
|
||
const REH_CAST_GENDERS = [['', '—'], ['F', '♀ Female'], ['M', '♂ Male'], ['N', '⚥ Diverse']];
|
||
|
||
// Apply a mutation to every dialog line spoken by a character
|
||
function _castApplyToLines(sp, fn) {
|
||
rehState.lines.forEach((l, i) => { if (l.speaker === sp && l.type === 'dialog') fn(l, i); });
|
||
}
|
||
|
||
function _safeDomId(value) {
|
||
let out = '';
|
||
const s = String(value || 'voice');
|
||
for (let i = 0; i < s.length; i++) out += s.charCodeAt(i).toString(36) + '-';
|
||
return out || 'voice';
|
||
}
|
||
|
||
// Cross-referencing the Character Library gives the cast cards a real
|
||
// portrait, tier badge, and occupation/archetype instead of just a colored
|
||
// initial — pulled from whatever book/script this cast shares a title with.
|
||
// Fetched once per script title (not per render) and cached. Two separate
|
||
// renderers share this one cache — renderCastList (the "Who's playing which
|
||
// character?" mecast panel) and renderCastStrip (the Stage sidebar's own
|
||
// character list, see below) — but only ONE fetch is ever kicked off per
|
||
// script title, guarded by _rehLibCharsCacheBook. Whichever renderer's guard
|
||
// check happens to run first "claims" that fetch; the other one sees the
|
||
// book already marked as fetched and skips starting its own — so BOTH must
|
||
// be re-run once the shared fetch resolves, not just whichever one started
|
||
// it. Confirmed live as a real bug: entering Perform & Export borrows both
|
||
// panels at once, renderCastList's guard usually wins the race, and its own
|
||
// old single-target callback left the Stage sidebar stuck on plain
|
||
// colored-letter dots forever (never re-rendered with portraits) even
|
||
// though the cache had genuinely finished loading with images moments
|
||
// later — only calling renderCastStrip() by hand fixed it.
|
||
let _rehLibCharsCache = null;
|
||
let _rehLibCharsCacheBook = null;
|
||
function _rehEnsureLibCharsCache(scriptTitle) {
|
||
if (!scriptTitle || _rehLibCharsCacheBook === scriptTitle || typeof clGetAllByTagOrBook !== 'function') return;
|
||
_rehLibCharsCacheBook = scriptTitle;
|
||
clGetAllByTagOrBook(scriptTitle).then(recs => {
|
||
_rehLibCharsCache = recs || [];
|
||
renderCastList();
|
||
if (typeof renderCastStrip === 'function') renderCastStrip();
|
||
// Stage's per-line portraits (_rehCharAvatarHtml) also read this cache
|
||
// directly, not just the sidebar row — re-run the full script page too
|
||
// so lines that rendered before the cache arrived pick up portraits.
|
||
if (typeof buildScriptPage === 'function') buildScriptPage();
|
||
}).catch(() => {});
|
||
}
|
||
|
||
|
||
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' }; }
|
||
|
||
// 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');
|
||
}
|
||
|
||
// All voice IDs offered in the cast/narrator pickers — backend voices PLUS everything
|
||
// in the library, so any saved/cloned/imported voice (incl. for the narrator) is pickable.
|
||
function _rehAllVoiceIds(include) {
|
||
const ids = new Set((rehState.voices || []).filter(id => _rehVoiceVisibleId(id, include)));
|
||
(window._voices || []).forEach(v => { if (v && v.id && (v.enabled !== false || v.id === include)) ids.add(v.id); });
|
||
if (include) ids.add(include);
|
||
return [...ids].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
|
||
}
|
||
|
||
function populateNarratorSelect() {
|
||
const sel = $('reh-narrator-voice'); if (!sel) return;
|
||
const cur = rehState.narratorVoice || sel.value;
|
||
sel.innerHTML = '<option value="">— none (skip or pause) —</option>' +
|
||
_rehAllVoiceIds(cur).map(v => `<option value="${escHtml(v)}"${v===cur?' selected':''}>${escHtml(v)}</option>`).join('');
|
||
}
|
||
|
||
$('reh-fetch-voices-btn')?.addEventListener('click', async () => {
|
||
const backend = $('reh-backend-select')?.value;
|
||
if (!backend) { toast('Select a backend first', 'error'); return; }
|
||
$('reh-fetch-voices-btn').disabled = true;
|
||
try {
|
||
if ((!window._voices || !window._voices.length) && typeof loadVoiceLibrary === 'function') {
|
||
await loadVoiceLibrary().catch(() => {});
|
||
}
|
||
const raw = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json());
|
||
rehState.voices = (Array.isArray(raw) ? raw.map(v => typeof v === 'string' ? v : (v.id || String(v))) : [])
|
||
.filter(id => _rehVoiceVisibleId(id));
|
||
renderCastList();
|
||
populateNarratorSelect();
|
||
toast('Fetched ' + rehState.voices.length + ' voices', 'success');
|
||
} catch(e) { toast('Fetch failed: ' + e.message, 'error'); }
|
||
finally { $('reh-fetch-voices-btn').disabled = false; }
|
||
});
|
||
|
||
$('reh-narrator-voice')?.addEventListener('change', function () { rehState.narratorVoice = this.value; });
|
||
$('reh-back-1-btn')?.addEventListener('click', () => showPhase(1));
|
||
|
||
// ── TTS instruct builder — emotion goes first as a directive ─────────────────
|
||
// voice_clone backends treat instruct as an IDENTITY description; putting the
|
||
// emotion first and using directive language ("Speak in a … manner") makes the
|
||
// model prioritise it over the static voice description, which otherwise wins.
|
||
// Casting tags emotions in the BOOK's own language (this app runs heavily
|
||
// with German books, so `emotion` here is usually a German word like
|
||
// "fordernd" or "entschlossen") — but the instruct sentence wrapping it was
|
||
// always hardcoded English ("Speak in a fordernd manner."), dropping a
|
||
// German adjective into an English carrier sentence. Confirmed as the
|
||
// likely reason emotion barely registered for designed voices even though
|
||
// isolated English-only test instructs ("speak in an extremely angry
|
||
// manner") clearly changed the output — a mixed-language instruct is a much
|
||
// weaker signal than a natural sentence in one language. `voiceId`'s own
|
||
// language-code prefix (DE_/EN_/...) picks the matching template.
|
||
const _BUILD_INSTRUCT_TEMPLATES = {
|
||
DE: e => `Sprich in einem ${e} Tonfall.`,
|
||
EN: e => `Speak in a ${e} manner.`,
|
||
};
|
||
// Same wording _buildVoicePrompt uses for the one-time design call — that
|
||
// clause was NEVER being resent on ongoing lines: the per-line instruct
|
||
// only ever carried the character's saved voice_design_prompt (a plain
|
||
// voice-quality description with no accent guidance in it at all), because
|
||
// the accent clause was built fresh at design time and never written back
|
||
// into that saved profile. Since each synthesis call is stateless — the
|
||
// engine has no memory of the original design call — omitting it here meant
|
||
// every ONGOING line got zero accent reinforcement, only the one-off
|
||
// creation call ever did. Confirmed as a real, separate cause of designed
|
||
// voices drifting back toward an American accent during actual narration.
|
||
const _BUILD_INSTRUCT_LANG_NAMES = { DE: 'German', FR: 'French', ES: 'Spanish', IT: 'Italian', PT: 'Portuguese', NL: 'Dutch', PL: 'Polish' };
|
||
function _buildAccentClause(langCode) {
|
||
const langName = _BUILD_INSTRUCT_LANG_NAMES[langCode];
|
||
if (langName) return `Speak with an authentic native ${langName} accent — not American-accented, not an English speaker doing ${langName}.`;
|
||
if (langCode === 'EN') return 'English with a neutral British or international accent, explicitly not American/US-accented.';
|
||
return '';
|
||
}
|
||
function _buildInstruct(voiceProfile, emotion, voiceId) {
|
||
const p = (voiceProfile || '').trim();
|
||
const e = (emotion || '').trim();
|
||
const langCode = String(voiceId || '').split('_')[0].toUpperCase();
|
||
const accent = _buildAccentClause(langCode);
|
||
if (!e && !p && !accent) return '';
|
||
// The emotion clause is now ALWAYS built in English, regardless of the voice's
|
||
// own language — confirmed via controlled A/B testing (same line, same voice,
|
||
// varying only instruct language) that Qwen3-TTS's emotional differentiation is
|
||
// dramatically stronger in English than German: sad/angry/shocked/happy formed a
|
||
// clean, coherent pitch gradient (274Hz -> 398Hz) in English, but stayed muddled
|
||
// together in German even with the exact same wording translated. The spoken
|
||
// TEXT and the accent clause below stay fully native-language — only the
|
||
// emotion instruction itself changes language. The raw per-line emotion is
|
||
// LLM-generated in the book's own language (see the casting prompt's "1-2
|
||
// deutsche Wörter"), so translate it before building the clause.
|
||
const emotionEn = e && langCode !== 'EN' && typeof _rehEmotionEnglishTag === 'function' ? _rehEmotionEnglishTag(e) : '';
|
||
const effectiveEmotion = emotionEn || e;
|
||
const emotionTmpl = _BUILD_INSTRUCT_TEMPLATES.EN;
|
||
// Emotion leads, accent trails — Qwen3-TTS's own prompting guidance warns
|
||
// it "does not follow instructions correctly when dealing with
|
||
// conflicting attributes... favoring one over the other." Putting the
|
||
// accent clause first (as the previous version did) made it the most
|
||
// prominent instruction on every single line, which lines up with the
|
||
// reported regression right after that fix landed: mood stopped coming
|
||
// through. Emotion is the one thing that MUST vary per line; accent is a
|
||
// constant reminder the voice's own identity should mostly already carry,
|
||
// so it goes last, not first.
|
||
// ...and when an emotion IS present, the accent clause is dropped entirely rather
|
||
// than merely demoted. Measured on the deterministic voice_clone backend (identical
|
||
// input reproduces bit-identical audio, so these differences are real, not sampling
|
||
// noise): with an emotion-only instruct the tones separate cleanly and in the right
|
||
// order — whisper 206Hz, sad 170Hz, neutral 225Hz, happy 234Hz, angry 233Hz, scared
|
||
// 239Hz, with sad also slower and quieter. Appending "Speak with an authentic native
|
||
// German accent." to those same instructs collapsed the whole range to 222-250Hz and
|
||
// erased sad almost completely. Two style directives in one instruct compete, and the
|
||
// trailing one wins — which is exactly the failure Qwen3-TTS's own prompting guidance
|
||
// warns about for conflicting attributes. A cloned voice already carries its accent
|
||
// from the reference WAV, so accent reinforcement is the cheaper of the two to lose;
|
||
// lines with no emotion still get it.
|
||
const parts = e
|
||
? [emotionTmpl(effectiveEmotion), p].filter(Boolean)
|
||
: [p, accent].filter(Boolean);
|
||
return parts.join(' ');
|
||
}
|
||
|
||
// Fish-Speech / OpenAudio S2 reads inline [tag] emotion markers straight from the
|
||
// input text (the OpenAI-style `instruct` field is ignored). For fish backends we
|
||
// therefore prepend the tone as a [tag] so per-line tones actually take effect.
|
||
function _rehBackendIsFish() {
|
||
let id = '';
|
||
try { id = (typeof backendById === 'function' && backendById(rehState.backend)?.id) || rehState.backend || ''; }
|
||
catch (_) { id = rehState.backend || ''; }
|
||
return /fish/i.test(id);
|
||
}
|
||
// Fish-Speech's docs are explicit: "Use English emotion tags in square brackets
|
||
// regardless of the spoken language." Per-line auto emotions are LLM-generated in the
|
||
// book's own language (the casting prompt literally asks for "1-2 deutsche Wörter"),
|
||
// which is what _buildInstruct's native-language sentence needs for Qwen3-TTS — but
|
||
// that same raw word is useless as a Fish [tag] untranslated. Confirmed live: German
|
||
// tags were silently ignored, producing flat/emotionless Fish-Speech output even
|
||
// though the audio itself was fine. This table only needs to cover common tone/mood
|
||
// adjectives, not a full dictionary — unrecognized words fall through to no tag at
|
||
// all (line 8 below), which is a NOP for Fish, safer than sending a foreign-language
|
||
// word it will just ignore anyway.
|
||
const _REH_EMOTION_DE_EN = {
|
||
'wütend':'angry','zornig':'angry','erzürnt':'angry','verärgert':'annoyed','gereizt':'irritated',
|
||
'traurig':'sad','melancholisch':'melancholic','betrübt':'sad','niedergeschlagen':'dejected',
|
||
'ängstlich':'scared','furchtsam':'fearful','verängstigt':'frightened','panisch':'panicked','nervös':'nervous',
|
||
'fröhlich':'happy','glücklich':'happy','freudig':'joyful','heiter':'cheerful','vergnügt':'delighted',
|
||
'flüsternd':'whispering','leise':'quiet','gedämpft':'hushed',
|
||
'aufgeregt':'excited','begeistert':'enthusiastic','euphorisch':'euphoric',
|
||
'überrascht':'surprised','erstaunt':'astonished','verblüfft':'amazed',
|
||
'genervt':'annoyed','frustriert':'frustrated',
|
||
'verzweifelt':'desperate','hoffnungslos':'hopeless','resigniert':'resigned',
|
||
'entschlossen':'determined','entschieden':'decisive',
|
||
'selbstbewusst':'confident','stolz':'proud','arrogant':'arrogant',
|
||
'schüchtern':'shy','verlegen':'embarrassed','unsicher':'uncertain',
|
||
'ironisch':'sarcastic','sarkastisch':'sarcastic','spöttisch':'mocking','höhnisch':'scornful','verächtlich':'contemptuous',
|
||
'ernst':'serious','streng':'stern','autoritär':'authoritative','befehlend':'commanding',
|
||
'sanft':'gentle','zärtlich':'tender','liebevoll':'loving','warm':'warm',
|
||
'kalt':'cold','distanziert':'distant','gleichgültig':'indifferent','gelangweilt':'bored',
|
||
'geheimnisvoll':'mysterious','unheimlich':'eerie','düster':'ominous','bedrohlich':'threatening',
|
||
'dramatisch':'dramatic','theatralisch':'theatrical','pathetisch':'melodramatic',
|
||
'ruhig':'calm','gelassen':'composed','besonnen':'measured',
|
||
'schockiert':'shocked','entsetzt':'horrified','fassungslos':'stunned',
|
||
'zögernd':'hesitant','verwirrt':'confused','ratlos':'bewildered','unschlüssig':'undecided',
|
||
'weinend':'tearful','schluchzend':'sobbing','trauernd':'grieving','gebrochen':'broken',
|
||
'schroff':'curt','barsch':'gruff','grob':'rough','abweisend':'dismissive',
|
||
'freundlich':'friendly','herzlich':'warm','einladend':'welcoming',
|
||
'spielerisch':'playful','neckend':'teasing','frech':'cheeky','schelmisch':'mischievous',
|
||
'romantisch':'romantic','sehnsüchtig':'longing','verliebt':'infatuated',
|
||
'triumphierend':'triumphant','siegessicher':'victorious',
|
||
'erleichtert':'relieved','beruhigt':'reassured',
|
||
'schuldbewusst':'guilty','reumütig':'remorseful',
|
||
'neugierig':'curious','interessiert':'interested',
|
||
'müde':'weary','erschöpft':'exhausted',
|
||
'wehmütig':'wistful','nostalgisch':'nostalgic',
|
||
'bemerkend':'remarking','feststellend':'noting','sachlich':'matter-of-fact','nüchtern':'plain',
|
||
'flehend':'pleading','bittend':'imploring',
|
||
'warnend':'warning','mahnend':'admonishing',
|
||
'trotzig':'defiant','rebellisch':'rebellious',
|
||
'erschrocken':'startled','verstört':'disturbed',
|
||
};
|
||
function _rehEmotionEnglishTag(emotion) {
|
||
const raw = (emotion || '').trim();
|
||
if (!raw) return '';
|
||
// Preset emotions (REH_EMOTIONS / custom) are already English descriptive phrases —
|
||
// the first, most tag-like word is what Fish actually needs.
|
||
const firstWord = raw.split(/[,;]\s*/)[0].trim();
|
||
const lower = firstWord.toLowerCase();
|
||
// Check the DE→EN table BEFORE assuming "looks ASCII" means "is English" — most
|
||
// German emotion adjectives (e.g. "bedrohlich") are pure a-z too, so charset alone
|
||
// can't distinguish them from English; the dict must win first.
|
||
if (_REH_EMOTION_DE_EN[lower]) return _REH_EMOTION_DE_EN[lower];
|
||
// Try stripping a common German adjective ending (declined forms LLMs sometimes
|
||
// produce, e.g. "ängstliche" instead of "ängstlich") and match on the stem.
|
||
const stem = lower.replace(/(e|er|es|en|em)$/, '');
|
||
if (stem.length >= 4) {
|
||
for (const key in _REH_EMOTION_DE_EN) {
|
||
if (key.startsWith(stem)) return _REH_EMOTION_DE_EN[key];
|
||
}
|
||
}
|
||
if (/^[a-z\- ]+$/.test(lower)) return lower; // not in the table, but looks English (e.g. REH_EMOTIONS presets)
|
||
return ''; // no reliable English tag — omit rather than send a word Fish will ignore
|
||
}
|
||
function _rehInlineTone(text, emotion) {
|
||
if (!_rehBackendIsFish()) return text;
|
||
if (/^\s*\[/.test(text)) return text; // already carries an inline [tag]
|
||
const tag = _rehEmotionEnglishTag(emotion);
|
||
return tag ? `[${tag}] ${text}` : text;
|
||
}
|
||
|
||
// ── Auto-design voices with LLM + Design a Voice ────────────────────────────
|
||
|
||
const REH_LANG_CODE = { English:'EN', German:'DE', French:'FR', Spanish:'ES', Italian:'IT', Auto:'EN' };
|
||
|
||
function rehDefaultLlmUrl() {
|
||
try {
|
||
if (typeof _appSettings !== 'undefined' && _appSettings && _appSettings.llm_url)
|
||
return _appSettings.llm_url;
|
||
} catch(_) {}
|
||
return 'http://localhost:11434/v1';
|
||
}
|
||
|
||
// Collect all LLM endpoints visible in the Language Models section
|
||
function rehCollectLlmEndpoints() {
|
||
const seen = new Set();
|
||
const results = [];
|
||
const add = (url, label) => {
|
||
if (!url) return;
|
||
url = url.trim();
|
||
if (!url || seen.has(url)) return;
|
||
seen.add(url);
|
||
results.push({ url, label: label || url });
|
||
};
|
||
// 1. Active LLM from settings
|
||
add(rehDefaultLlmUrl(), 'Active LLM');
|
||
// 2. All URL inputs in the Language Models section (llm-local-url-inp)
|
||
document.querySelectorAll('.llm-local-url-inp, [data-llm-local-key]').forEach(inp => {
|
||
const v = inp.value?.trim();
|
||
const def = inp.dataset.llmLocalDefault;
|
||
const key = inp.dataset.llmLocalKey || inp.dataset.dcUrlKey || '';
|
||
const card = inp.closest('[class*="llm-local-card"], [class*="llm-local"]');
|
||
const name = card?.querySelector('.llm-local-name')?.textContent?.trim() || key;
|
||
add(v || def, name);
|
||
});
|
||
// 3. The dc-url-inp fields with LLM role
|
||
document.querySelectorAll('.dc-url-inp').forEach(inp => {
|
||
const card = inp.closest('[class*="llm-local-card"]');
|
||
if (!card) return;
|
||
const name = card.querySelector('.llm-local-name')?.textContent?.trim() || '';
|
||
add(inp.value?.trim() || inp.dataset.dcDefault, name);
|
||
});
|
||
// 4. Always include common defaults as suggestions
|
||
const DEFAULTS = [
|
||
['http://localhost:11434/v1', 'Ollama'],
|
||
['http://localhost:8000/v1', 'vLLM'],
|
||
['http://localhost:1234/v1', 'LM Studio'],
|
||
['http://localhost:28080/v1', 'llama-swap'],
|
||
['http://localhost:14000/v1', 'LiteLLM'],
|
||
];
|
||
DEFAULTS.forEach(([u, l]) => add(u, l));
|
||
return results;
|
||
}
|
||
|
||
// Prefill LLM URL + datalist when Phase 2 first shows voices
|
||
function rehInitLlmField() {
|
||
const u = $('reh-llm-url');
|
||
if (!u) return;
|
||
if (!u.value) u.value = rehDefaultLlmUrl();
|
||
// Populate datalist with all known LLM endpoints
|
||
const dl = $('reh-llm-url-list');
|
||
if (dl) {
|
||
dl.innerHTML = rehCollectLlmEndpoints()
|
||
.map(e => `<option value="${e.url.replace(/"/g, '"')}">${e.label}</option>`)
|
||
.join('');
|
||
}
|
||
}
|
||
|
||
$('reh-llm-refresh')?.addEventListener('click', async () => {
|
||
const url = $('reh-llm-url')?.value.trim() || rehDefaultLlmUrl();
|
||
const sel = $('reh-llm-model'); if (!sel) return;
|
||
sel.innerHTML = '<option value="">Loading…</option>';
|
||
try {
|
||
const r = await fetch('/api/conversation/llm-models?url=' + encodeURIComponent(url));
|
||
const d = await r.json();
|
||
const models = d.models || [];
|
||
sel.innerHTML = '<option value="">— default —</option>' +
|
||
models.map(m => `<option value="${escHtml(m)}">${escHtml(m)}</option>`).join('');
|
||
// Default to the global Active LLM model when available (still overridable here)
|
||
const want = (typeof _appSettings !== 'undefined' && _appSettings) ? _appSettings.llm_model : '';
|
||
if (want && models.includes(want)) sel.value = want;
|
||
toast(models.length ? `Found ${models.length} models` : 'No models found', models.length ? 'success' : 'error');
|
||
} catch(e) {
|
||
sel.innerHTML = '<option value="">— default —</option>';
|
||
toast('Could not list models: ' + e.message, 'error');
|
||
}
|
||
});
|
||
|
||
// Avatar icon keys for designed voices — male / female / neutral / robot / animal.
|
||
// Picked from the LLM description (robot/animal cues) then gender.
|
||
const REH_AVATAR_ICONS = {
|
||
male: 'mdi-face-man',
|
||
female: 'mdi-face-woman',
|
||
neutral: 'mdi-account',
|
||
robot: 'mdi-robot-outline',
|
||
animal: 'mdi-paw',
|
||
};
|
||
function _pickVoiceAvatar(gender, desc, speaker) {
|
||
const d = ((desc || '') + ' ' + (speaker || '')).toLowerCase();
|
||
if (/\b(robot|android|synthetic|artificial|computer|machine|cyborg|a\.?i\.?|operating system|\bos\b|digital|hologram|drone)\b/.test(d)) return 'robot';
|
||
if (/\b(animal|creature|beast|dragon|monster|cat|dog|wolf|lion|bird|horse|dino|dinosaur|alien)\b/.test(d)) return 'animal';
|
||
if (gender === 'M') return 'male';
|
||
if (gender === 'F') return 'female';
|
||
return 'neutral';
|
||
}
|
||
|
||
let rehDesignCancelled = false;
|
||
|
||
$('reh-autodesign-cancel')?.addEventListener('click', () => { rehDesignCancelled = true; });
|
||
|
||
$('reh-autodesign-btn')?.addEventListener('click', async () => {
|
||
const backend = $('reh-backend-select')?.value;
|
||
if (!backend) { toast('Select a TTS backend first', 'error'); return; }
|
||
|
||
const speakers = Object.keys(rehState.cast);
|
||
if (!speakers.length) { toast('No characters to design for', 'error'); return; }
|
||
|
||
const script = $('reh-script-text')?.value.trim() || linesToScriptText();
|
||
const llmUrl = $('reh-llm-url')?.value.trim() || rehDefaultLlmUrl();
|
||
const llmModel= $('reh-llm-model')?.value || '';
|
||
const language= $('reh-design-lang')?.value || 'English';
|
||
const langCode= REH_LANG_CODE[language] || 'EN';
|
||
const scriptTitle = $('reh-script-title')?.value.trim() || 'Script';
|
||
const tag = (typeof _umlautSafe === 'function' ? _umlautSafe(scriptTitle) : scriptTitle).replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 24) || 'Script';
|
||
|
||
const btn = $('reh-autodesign-btn');
|
||
const prog = $('reh-autodesign-progress');
|
||
const fill = $('reh-autodesign-fill');
|
||
const label= $('reh-autodesign-label');
|
||
btn.disabled = true;
|
||
rehDesignCancelled = false;
|
||
if (prog) prog.hidden = false;
|
||
const setProg = (d, t, msg) => {
|
||
if (fill) fill.style.width = (t ? (d/t)*100 : 0) + '%';
|
||
if (label) label.textContent = msg || `${d} / ${t}`;
|
||
};
|
||
setProg(0, speakers.length, 'Analyzing script with LLM…');
|
||
|
||
// 1) Ask the LLM to describe each character
|
||
let characters;
|
||
try {
|
||
const r = await fetch('/api/analyze-characters', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ script, names: speakers, llm_url: llmUrl, model: llmModel, language }),
|
||
});
|
||
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
|
||
const d = await r.json();
|
||
characters = d.characters || [];
|
||
} catch(e) {
|
||
toast('Character analysis failed: ' + e.message, 'error');
|
||
btn.disabled = false; if (prog) prog.hidden = true;
|
||
return;
|
||
}
|
||
|
||
// Map analysis back to speakers (case-insensitive match)
|
||
const byName = {};
|
||
characters.forEach(c => { if (c.name) byName[String(c.name).toUpperCase().trim()] = c; });
|
||
|
||
// 2) For each character, design + save a voice
|
||
let done = 0;
|
||
// rehState.cast[sp] used to be assumed always present since `speakers` came
|
||
// from Object.keys(rehState.cast) moments earlier — but the analyze-characters
|
||
// await above can take many seconds for a large cast, and if anything
|
||
// removes/replaces a cast entry while that request is in flight, the plain
|
||
// `rehState.cast[sp].voice` access below threw a TypeError with nothing
|
||
// catching it (this whole loop sits outside the try/catch above), silently
|
||
// killing the click with no toast and no further requests — exactly the
|
||
// "clicked Design all and nothing happens" symptom, confirmed by server
|
||
// logs showing analyze-characters succeeding but voice-design never once
|
||
// being called. Guard it and keep going instead of crashing the whole run.
|
||
// The outer try/finally below is the same idea one level up: ANY unexpected
|
||
// exception here used to vanish into the console with the button stuck
|
||
// disabled — now it surfaces as a toast and always resets the UI.
|
||
try {
|
||
const designOnly = speakers.filter(sp => rehState.cast[sp]?.voice !== 'me');
|
||
for (const sp of designOnly) {
|
||
if (rehDesignCancelled) { toast('Cancelled', 'error'); break; }
|
||
if (!rehState.cast[sp]) { done++; continue; }
|
||
const info = byName[sp.toUpperCase().trim()] || {};
|
||
const gender = (info.gender || 'N').toUpperCase().charAt(0).replace(/[^MFN]/, 'N') || 'N';
|
||
const hardGender = gender === 'M' ? 'male' : gender === 'F' ? 'female' : 'gender-neutral';
|
||
const hardAge = info.age || 'age unspecified';
|
||
const hardLanguage = info.language || language || 'the script language';
|
||
const desc = [
|
||
`HARD CASTING CONSTRAINTS: ${hardGender} voice, apparent age ${hardAge}, spoken in ${hardLanguage}.`,
|
||
info.description || `Character named ${sp}; natural expressive audiobook voice.`,
|
||
`Make this voice audibly distinct from the other cast members: use a memorable vocal fingerprint, not a generic narrator voice.`,
|
||
].join(' ');
|
||
const sampleLine = rehState.lines.find(l => l.type === 'dialog' && l.speaker === sp)?.text || `Hello, I am ${sp}.`;
|
||
|
||
const safeName = (typeof _umlautSafe === 'function' ? _umlautSafe(sp) : sp).replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 24) || 'Char';
|
||
const voiceId = `${langCode}_${gender}_${safeName}_${tag}`.slice(0, 90);
|
||
rehMarkCastDesigning(sp, 'designing', null, {
|
||
gender, language, voiceId, desc,
|
||
age: info.age || '',
|
||
step: 'Generating voice audio…',
|
||
});
|
||
setProg(done, designOnly.length, `Designing ${sp}… (${done + 1}/${designOnly.length})`);
|
||
|
||
try {
|
||
// Generate the voice audio
|
||
const dr = await fetch('/api/voice-design', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ instruct: desc, sample_text: stripMarkdown(sampleLine).slice(0, 300), language, gender, dialogue: false }),
|
||
});
|
||
if (!dr.ok) { const e = await dr.json().catch(()=>({})); throw new Error(e.detail || dr.statusText); }
|
||
const dd = await dr.json();
|
||
|
||
// Save it to the voice library, tagged with script name + Rehearser
|
||
const sr = await fetch('/api/save', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ id: dd.id, voice_id: voiceId, transcript: stripMarkdown(sampleLine).slice(0, 300) }),
|
||
});
|
||
if (!sr.ok) { const e = await sr.json().catch(()=>({})); throw new Error(e.detail || sr.statusText); }
|
||
const saved = await sr.json();
|
||
|
||
// Tag metadata — display name = character, script as a tag, origin 'designed',
|
||
// grouped by play title, plus an auto-picked gender/type avatar icon.
|
||
const charName = (sp === REH_NARRATOR_KEY) ? 'Narrator' : sp;
|
||
if (typeof saveMeta === 'function') {
|
||
await saveMeta(saved.voice_id, {
|
||
gender,
|
||
age: info.age || '',
|
||
name: charName,
|
||
avatar: _pickVoiceAvatar(gender, desc, sp),
|
||
origin: 'designed',
|
||
group: `Rehearser: ${scriptTitle}`,
|
||
note: `Rehearser · ${scriptTitle} · ${charName} — ${desc.slice(0, 180)}`,
|
||
transcript: stripMarkdown(sampleLine).slice(0, 300),
|
||
tag: scriptTitle,
|
||
}).catch(()=>{});
|
||
}
|
||
|
||
// Assign to cast + remember the LLM's research as the style note & character soul
|
||
rehState.cast[sp].voice = saved.voice_id;
|
||
rehState.cast[sp].instruct = rehState.cast[sp].instruct || desc;
|
||
rehState.cast[sp].soul = rehState.cast[sp].soul || [info.age ? `Age: ${info.age}` : '', desc].filter(Boolean).join(' · ');
|
||
rehState.cast[sp].voiceData = null;
|
||
if (!rehState.voices.includes(saved.voice_id)) rehState.voices.push(saved.voice_id);
|
||
rehMarkCastDesigning(sp, 'done');
|
||
} catch(e) {
|
||
rehMarkCastDesigning(sp, 'err', e.message);
|
||
}
|
||
done++;
|
||
setProg(done, designOnly.length);
|
||
}
|
||
|
||
// Refresh the broader voice library so avatars/pictures resolve
|
||
if (typeof loadVoiceLibrary === 'function') await loadVoiceLibrary().catch(()=>{});
|
||
|
||
if (!rehDesignCancelled) toast(`Designed ${done} voice${done!==1?'s':''} — tagged "${tag}" + Rehearser`, 'success');
|
||
} catch (e) {
|
||
toast('Design all failed: ' + (e?.message || e), 'error');
|
||
} finally {
|
||
renderCastList();
|
||
populateNarratorSelect();
|
||
if (prog) prog.hidden = true;
|
||
btn.disabled = false;
|
||
}
|
||
});
|
||
|
||
// ── Match from library — LLM picks the best EXISTING voice for each character ──
|
||
// ── Character research → per-character note ──────────────────────────────────
|
||
// Store the LLM's reading of the play as each character's "soul" note (+ gender / style).
|
||
function rehWriteCharacterNote(sp, info) {
|
||
const c = rehState.cast[sp]; if (!c || !info) return;
|
||
if (info.gender && !c.gender) c.gender = String(info.gender).toUpperCase().charAt(0).replace(/[^MFN]/, 'N');
|
||
const bits = [];
|
||
if (info.age) bits.push(`Age: ${info.age}`);
|
||
if (info.description) bits.push(info.description);
|
||
const note = bits.join(' · ');
|
||
if (note && !c.soul) c.soul = note; // fills the Character-soul / LLM-brief note
|
||
if (info.description && !c.instruct) c.instruct = info.description;
|
||
}
|
||
|
||
// Ask the LLM to read the script + cast, then annotate every character with a note.
|
||
// Returns a name→info map (used by the online/design flows that need gender too).
|
||
async function rehResearchCast(speakers) {
|
||
const names = speakers.filter(sp => sp !== REH_NARRATOR_KEY);
|
||
if (!names.length) return {};
|
||
let chars = [];
|
||
try {
|
||
const r = await fetch('/api/analyze-characters', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
script: $('reh-script-text')?.value.trim() || linesToScriptText(), names,
|
||
llm_url: $('reh-llm-url')?.value.trim() || rehDefaultLlmUrl(),
|
||
model: $('reh-llm-model')?.value || '', language: $('reh-design-lang')?.value || 'English',
|
||
}),
|
||
});
|
||
if (r.ok) chars = (await r.json()).characters || [];
|
||
} catch (_) {}
|
||
const byName = {};
|
||
chars.forEach(c => { if (c.name) byName[String(c.name).toUpperCase().trim()] = c; });
|
||
speakers.forEach(sp => rehWriteCharacterNote(sp, byName[sp.toUpperCase().trim()]));
|
||
return byName;
|
||
}
|
||
|
||
$('reh-matchlib-btn')?.addEventListener('click', async () => {
|
||
const btn = $('reh-matchlib-btn');
|
||
const speakers = Object.keys(rehState.cast).filter(sp => rehState.cast[sp].voice !== 'me');
|
||
if (!speakers.length) { toast('No characters to match', 'error'); return; }
|
||
let lib = (window._voices || []).filter(v => v.enabled !== false);
|
||
if (!lib.length) {
|
||
// Library metadata isn't loaded yet (opened the Rehearser directly) — fetch it now.
|
||
try { lib = (await (typeof window.fetchVoiceLibrary === 'function' ? window.fetchVoiceLibrary() : 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 || '', age: v.age || '', 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();
|
||
});
|
||
|
||
function buildScriptPage() {
|
||
const titleEl = $('reh-page-title');
|
||
if (titleEl) titleEl.textContent = $('reh-script-title')?.value.trim() || 'Script';
|
||
|
||
renderCastStrip();
|
||
rehApplyStageFont();
|
||
rehApplyCastCollapsed();
|
||
|
||
// Fire-and-forget, guarded against re-running for the same script — see
|
||
// _lineAudioSyncDots for why this exists (green dots otherwise look reset
|
||
// after every reload even when the audio is safely cached on disk).
|
||
if (typeof _lineAudioSyncDots === 'function') _lineAudioSyncDots().catch(() => {});
|
||
|
||
const linesEl = $('reh-script-lines'); if (!linesEl) return;
|
||
|
||
linesEl.innerHTML = rehState.lines.map((line, i) => {
|
||
const isCached = rehState.synthCache.has(i);
|
||
const isStale = rehState.staleLines.has(i);
|
||
const dotCls = isStale ? 'reh-synth-dot stale' : 'reh-synth-dot';
|
||
const dotTitle = isStale ? 'Tone changed — needs re-synthesis' : 'Pre-synthesized';
|
||
const synthDot = `<span class="${dotCls}" id="reh-syd-${i}" style="${(isCached||isStale)?'':'display:none'}" title="${dotTitle}"></span>`;
|
||
const reSynthBtn = `<button class="reh-resynth-btn" id="reh-rsb-${i}" title="Re-synthesize this line" ${isStale?'':'hidden'}><span class="mdi mdi-refresh"></span></button>`;
|
||
const editBtn = `<button class="reh-edit-btn" data-index="${i}" title="Edit text (or double-click)"><span class="mdi mdi-pencil-outline"></span></button>`;
|
||
const note = rehState.lines[i].note || '';
|
||
// noteArea includes the edit button so both icons live in the right gutter
|
||
const noteArea = `<div class="reh-note-area" data-index="${i}">
|
||
${editBtn}
|
||
<button class="reh-note-btn${note?' has-note':''}" title="${note?'Edit note':'Add note'}"><span class="mdi mdi-note-edit-outline"></span></button>
|
||
<textarea class="reh-note-ta" placeholder="Personal note (not spoken)…" spellcheck="false">${escHtml(note)}</textarea>
|
||
</div>`;
|
||
|
||
// Bulk-edit: hidden lines drop out of the view entirely, unless we're revealing
|
||
// them inside bulk mode so they can be selected and restored.
|
||
if (line.hidden && !(rehState.bulkMode && rehState.showHidden)) return '';
|
||
const _bSel = rehState.bulkSel.has(i);
|
||
const bulkCheck = rehState.bulkMode
|
||
? `<span class="reh-bulk-check${_bSel ? ' checked' : ''}" data-bulk="${i}" title="Select line"><span class="mdi ${_bSel ? 'mdi-checkbox-marked' : 'mdi-checkbox-blank-outline'}"></span></span>`
|
||
: '';
|
||
const lineFlags = (line.ignored ? ' reh-line-ignored' : '') + (line.hidden ? ' reh-line-hidden' : '') + (_bSel ? ' reh-selected' : '');
|
||
|
||
// Same detection audiobookExport() uses to decide chapter/file boundaries
|
||
// (audiobook.js) — surfaced here too so a chapter is visible in the script
|
||
// itself, not just audible as a pause in the finished export. Runs before
|
||
// the normal type switch so a chapter heading (however it was tagged
|
||
// during parsing — 'act', 'scene', or plain 'action' text recovered via
|
||
// heading OCR) always gets this treatment instead of its usual rendering.
|
||
if (typeof audiobookIsChapter === 'function' && audiobookIsChapter(line)) {
|
||
const label = (typeof stripMarkdown === 'function' ? stripMarkdown(line.text || '') : (line.text || '')).trim();
|
||
return `<div class="reh-chapter-marker${lineFlags}" data-index="${i}">
|
||
${bulkCheck}
|
||
<span class="reh-chapter-marker-line"></span>
|
||
<span class="reh-chapter-marker-label">${label ? escHtml(label) : 'Chapter'}</span>
|
||
<span class="reh-chapter-marker-line"></span>
|
||
${editBtn} ${synthDot}
|
||
</div>`;
|
||
}
|
||
|
||
switch (line.type) {
|
||
case 'act':
|
||
return `<div class="reh-act${lineFlags}" data-index="${i}" style="position:relative">${bulkCheck}${escHtml(line.text)} ${editBtn} ${synthDot}</div>`;
|
||
|
||
case 'scene':
|
||
return `<div class="reh-scene${lineFlags}" data-index="${i}">
|
||
${bulkCheck}<span class="mdi mdi-film" style="flex-shrink:0"></span>
|
||
<span style="flex:1">${escHtml(line.text)}</span>
|
||
${editBtn} ${synthDot}
|
||
</div>`;
|
||
|
||
case 'action': {
|
||
// Narrator paragraphs get a play button too now, but deliberately
|
||
// NOT wrapped in .reh-block's boxed/indented dialogue treatment
|
||
// (avatar circle, name row, highlighted card) — just a small inline
|
||
// icon before the text, same weight as the edit button, so plain
|
||
// narration keeps reading like plain narration.
|
||
const narrPlayBtn = `<button type="button" class="reh-line-play-avatar reh-action-play-btn" data-index="${i}" title="Play or pause this line" aria-label="Play or pause this line"><span class="mdi mdi-play"></span></button>`;
|
||
return `<div class="reh-action-block${lineFlags}" data-index="${i}">
|
||
${bulkCheck}${narrPlayBtn}<span class="reh-action-text">${renderMarkdownInline(line.text)}</span>
|
||
${editBtn} ${synthDot}
|
||
</div>`;
|
||
}
|
||
|
||
case 'pagebreak': {
|
||
const pbLabel = line.page ? `— Page ${line.page} —` : '— Page break —';
|
||
return `<div class="reh-block-pagebreak${lineFlags}" data-index="${i}">${bulkCheck}<span class="reh-pagebreak-label">${pbLabel}</span></div>`;
|
||
}
|
||
|
||
case 'transition':
|
||
return `<div class="reh-transition${lineFlags}" data-index="${i}">${bulkCheck}${escHtml(line.text)}</div>`;
|
||
|
||
case 'direction': {
|
||
const indent = line.speaker ? 'text-align:center;' : '';
|
||
return `<div class="reh-direction${lineFlags}" data-index="${i}" style="${indent}">${bulkCheck}<em>${escHtml(line.text)}</em></div>`;
|
||
}
|
||
|
||
case 'dialog': {
|
||
const c = rehState.cast[line.speaker] || { voice:'', color:'#89b4fa' };
|
||
const isMe = c.voice === 'me';
|
||
const emoInfo = getEmotionInfo(line.emotion || '');
|
||
return `<div class="reh-block${lineFlags}${rehState.practiceEnd===i?' reh-range-end':''}${rehState.practiceStart===i?' reh-range-start':''}${isPracticeRange(i)?' reh-in-range':''}" data-index="${i}" data-speaker="${escHtml(line.speaker)}" style="cursor:pointer">
|
||
${bulkCheck}<button class="reh-gutter-btn" data-index="${i}" title="Click: play from here · Shift+click: set end marker">${rehState.practiceStart===i?'▶':rehState.practiceEnd===i?'■':''}</button>
|
||
<div class="reh-block-inner">
|
||
<div class="reh-block-head">
|
||
<button type="button" class="reh-line-play-avatar" data-index="${i}" title="Play or pause this line" aria-label="Play or pause this line">${_rehCharAvatarHtml(line.speaker, isMe?'':c.voice, c.color, 32)}</button>
|
||
<span class="reh-block-name" style="color:${c.color}">${escHtml(line.speaker)}</span>
|
||
<span class="reh-block-head-spacer"></span>
|
||
${isMe
|
||
? '<span class="reh-block-badge reh-badge-me"><span class="mdi mdi-microphone"></span> Me</span>'
|
||
: '<span class="reh-block-badge reh-badge-tts"><span class="mdi mdi-speaker-outline"></span> TTS</span>'
|
||
}
|
||
${!isMe
|
||
? `<button class="reh-emo-btn${line.emotion?' has-emotion':''}" data-index="${i}" title="Set speaking tone">
|
||
${emoInfo.emoji ? emoInfo.emoji + ' ' : ''}<span class="reh-emo-label">${escHtml(emoInfo.label)}</span>
|
||
<span class="mdi mdi-chevron-down" style="font-size:9px;opacity:.6"></span>
|
||
</button>`
|
||
: ''
|
||
}
|
||
${synthDot}
|
||
${reSynthBtn}
|
||
</div>
|
||
<div class="reh-block-dialog" id="reh-diag-${i}">${renderMarkdownInline(line.text)}</div>
|
||
</div>
|
||
${noteArea}
|
||
</div>`;
|
||
}
|
||
|
||
default: return '';
|
||
}
|
||
}).join('');
|
||
|
||
// Emotion picker buttons
|
||
linesEl.querySelectorAll('.reh-emo-btn').forEach(btn => {
|
||
btn.addEventListener('click', e => { e.stopPropagation(); openEmoPicker(parseInt(btn.dataset.index), btn); });
|
||
});
|
||
|
||
// Edit buttons
|
||
linesEl.querySelectorAll('.reh-edit-btn').forEach(btn => {
|
||
btn.addEventListener('click', e => { e.stopPropagation(); startInlineEdit(parseInt(btn.dataset.index)); });
|
||
});
|
||
|
||
// Double-click on dialog / action text to edit
|
||
linesEl.querySelectorAll('.reh-block-dialog').forEach(el => {
|
||
el.addEventListener('dblclick', e => { e.stopPropagation(); startInlineEdit(parseInt(el.closest('[data-index]').dataset.index)); });
|
||
});
|
||
linesEl.querySelectorAll('.reh-action-text').forEach(el => {
|
||
el.addEventListener('dblclick', e => { e.stopPropagation(); startInlineEdit(parseInt(el.closest('[data-index]').dataset.index)); });
|
||
});
|
||
|
||
// Avatar button: play/pause a single character line
|
||
linesEl.querySelectorAll('.reh-line-play-avatar').forEach(btn => {
|
||
btn.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
const idx = parseInt(btn.dataset.index);
|
||
if (rehState.playing && rehState.lineIndex === idx) { pausePlay(); return; }
|
||
stopPlay();
|
||
hideRecOverlay();
|
||
rehState.lineIndex = idx;
|
||
highlightCurrentLine();
|
||
startPlay();
|
||
});
|
||
});
|
||
|
||
// Gutter button: play from here / set end
|
||
linesEl.querySelectorAll('.reh-gutter-btn').forEach(btn => {
|
||
btn.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
const idx = parseInt(btn.dataset.index);
|
||
if (e.shiftKey) {
|
||
// Set / clear practice end
|
||
rehState.practiceEnd = rehState.practiceEnd === idx ? null : idx;
|
||
} else {
|
||
// Jump to this line and start playing
|
||
stopPlay();
|
||
hideRecOverlay();
|
||
rehState.lineIndex = idx;
|
||
highlightCurrentLine();
|
||
startPlay();
|
||
}
|
||
updatePracticeRange();
|
||
});
|
||
});
|
||
|
||
// Click block to jump; in Select mode, click toggles selection and Shift+click selects a range.
|
||
linesEl.querySelectorAll('[data-index]').forEach(el => {
|
||
el.addEventListener('click', e => {
|
||
if (e.target.closest('.reh-emo-btn, .reh-edit-btn, .reh-gutter-btn, .reh-note-btn, .reh-resynth-btn, .reh-line-play-avatar, textarea, select, .vp-root')) return;
|
||
const idx = parseInt(el.dataset.index);
|
||
if (rehState.bulkMode) {
|
||
_toggleBulkSel(idx, e.shiftKey);
|
||
return;
|
||
}
|
||
stopPlay();
|
||
hideRecOverlay();
|
||
rehState.lineIndex = idx;
|
||
highlightCurrentLine();
|
||
});
|
||
});
|
||
|
||
// Re-synth buttons (per line)
|
||
linesEl.querySelectorAll('.reh-resynth-btn').forEach(btn => {
|
||
btn.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
synthOneLine(parseInt(btn.id.replace('reh-rsb-', '')));
|
||
});
|
||
});
|
||
|
||
// Note area: toggle textarea visibility on button click, save on change
|
||
linesEl.querySelectorAll('.reh-note-area').forEach(area => {
|
||
const idx = parseInt(area.dataset.index);
|
||
const btn = area.querySelector('.reh-note-btn');
|
||
const ta = area.querySelector('.reh-note-ta');
|
||
if (!btn || !ta) return;
|
||
// Show if note has content
|
||
if (ta.value.trim()) ta.classList.add('open');
|
||
btn.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
ta.classList.toggle('open');
|
||
if (ta.classList.contains('open')) ta.focus();
|
||
});
|
||
ta.addEventListener('input', () => {
|
||
rehState.lines[idx].note = ta.value;
|
||
btn.classList.toggle('has-note', !!ta.value.trim());
|
||
});
|
||
ta.addEventListener('click', e => e.stopPropagation());
|
||
});
|
||
|
||
// Bulk-edit: leading checkboxes toggle line selection. The page wrapper also
|
||
// gets the mode class because A4/PDF modes move blocks out of #reh-script-lines.
|
||
linesEl.classList.toggle('reh-bulk-mode', rehState.bulkMode);
|
||
document.querySelector('.reh-page-wrap')?.classList.toggle('reh-bulk-mode', rehState.bulkMode);
|
||
if (rehState.bulkMode) {
|
||
linesEl.querySelectorAll('.reh-bulk-check').forEach(cb => {
|
||
cb.addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
_toggleBulkSel(parseInt(cb.dataset.bulk), e.shiftKey);
|
||
});
|
||
});
|
||
}
|
||
|
||
// Apply current pagination mode
|
||
applyPageMode();
|
||
|
||
// Warn if backend doesn't support tone/style changes
|
||
_checkToneStyleSupport();
|
||
}
|
||
|
||
// ── Bulk-edit (line selection: ignore / hide / delete) ──────────────────────
|
||
|
||
function _toggleBulkSel(i, range = false) {
|
||
if (!Number.isFinite(i)) return;
|
||
if (range && rehState.bulkAnchor !== null && Number.isFinite(rehState.bulkAnchor)) {
|
||
const a = Math.min(rehState.bulkAnchor, i);
|
||
const b = Math.max(rehState.bulkAnchor, i);
|
||
const indices = [];
|
||
for (let n = a; n <= b; n++) if (rehState.lines[n] && !rehState.lines[n].hidden) indices.push(n);
|
||
const deselect = indices.length && indices.every(n => rehState.bulkSel.has(n));
|
||
indices.forEach(n => {
|
||
if (deselect) rehState.bulkSel.delete(n);
|
||
else rehState.bulkSel.add(n);
|
||
_refreshBulkLine(n);
|
||
});
|
||
rehState.bulkAnchor = i;
|
||
} else {
|
||
if (rehState.bulkSel.has(i)) rehState.bulkSel.delete(i);
|
||
else rehState.bulkSel.add(i);
|
||
rehState.bulkAnchor = i;
|
||
_refreshBulkLine(i);
|
||
}
|
||
_updateBulkCount();
|
||
}
|
||
|
||
function _refreshBulkLine(i) {
|
||
const sel = rehState.bulkSel.has(i);
|
||
document.querySelectorAll(`.reh-bulk-check[data-bulk="${i}"]`).forEach(cb => {
|
||
cb.classList.toggle('checked', sel);
|
||
const icon = cb.querySelector('.mdi');
|
||
if (icon) icon.className = 'mdi ' + (sel ? 'mdi-checkbox-marked' : 'mdi-checkbox-blank-outline');
|
||
});
|
||
document.querySelectorAll(`[data-index="${i}"]`).forEach(el => el.classList.toggle('reh-selected', sel));
|
||
}
|
||
|
||
function _updateBulkCount() {
|
||
const el = $('reh-bulk-count');
|
||
if (el) el.textContent = `${rehState.bulkSel.size} selected`;
|
||
}
|
||
|
||
function setBulkMode(on) {
|
||
rehState.bulkMode = on;
|
||
if (!on) { rehState.bulkSel.clear(); rehState.bulkAnchor = null; }
|
||
const bar = $('reh-bulk-bar'); if (bar) bar.hidden = !on;
|
||
const btn = $('reh-bulk-toggle'); if (btn) btn.classList.toggle('active', on);
|
||
_updateBulkCount();
|
||
if (rehState.lines.length) buildScriptPage();
|
||
}
|
||
|
||
// Apply a mutation to all selected lines, then re-render.
|
||
function _bulkApply(fn, { keepSelection = false } = {}) {
|
||
if (!rehState.bulkSel.size) { toast('No lines selected', 'error'); return; }
|
||
[...rehState.bulkSel].forEach(i => { const l = rehState.lines[i]; if (l) fn(l, i); });
|
||
if (!keepSelection) rehState.bulkSel.clear();
|
||
_updateBulkCount();
|
||
buildScriptPage();
|
||
}
|
||
|
||
function _bulkDelete() {
|
||
if (!rehState.bulkSel.size) { toast('No lines selected', 'error'); return; }
|
||
const victims = [...rehState.bulkSel].sort((a, b) => b - a); // high → low so indices stay valid
|
||
victims.forEach(i => {
|
||
rehState.lines.splice(i, 1);
|
||
_reindexLineState(i);
|
||
});
|
||
rehState.bulkSel.clear();
|
||
// Keep the cursor in range
|
||
if (rehState.lineIndex >= rehState.lines.length) rehState.lineIndex = Math.max(0, rehState.lines.length - 1);
|
||
_updateBulkCount();
|
||
buildScriptPage();
|
||
highlightCurrentLine();
|
||
toast(`Deleted ${victims.length} line${victims.length !== 1 ? 's' : ''}`, 'success');
|
||
}
|
||
|
||
// After deleting line `d`, shift every index-keyed bit of state above it down by one.
|
||
function _reindexLineState(d) {
|
||
const shift = (collection, isMap) => {
|
||
const out = isMap ? new Map() : new Set();
|
||
for (const entry of collection) {
|
||
const k = isMap ? entry[0] : entry;
|
||
if (k === d) continue; // dropped line
|
||
const nk = k > d ? k - 1 : k;
|
||
if (isMap) out.set(nk, entry[1]); else out.add(nk);
|
||
}
|
||
return out;
|
||
};
|
||
rehState.synthCache = new RehAudioCache(shift(rehState.synthCache, true));
|
||
rehState.staleLines = shift(rehState.staleLines, false);
|
||
const sel = shift(rehState.bulkSel, false);
|
||
rehState.bulkSel.clear(); sel.forEach(v => rehState.bulkSel.add(v));
|
||
if (rehState.practiceStart != null && rehState.practiceStart > d) rehState.practiceStart--;
|
||
if (rehState.practiceEnd != null && rehState.practiceEnd > d) rehState.practiceEnd--;
|
||
if (rehState.lineIndex > d) rehState.lineIndex--;
|
||
}
|
||
|
||
// ── Page mode (endless scroll | auto-pages | pdf-pages) ─────────────────────
|
||
const PAGE_MODES = ['auto', 'scroll', 'pdf'];
|
||
// 'pdf' (break at the document's own real page marks) is the default now
|
||
// that parseScript's page-break detection actually works (see rehearser-parse.js) —
|
||
// 'auto' (break purely by content height, ignoring the source document's own
|
||
// pages entirely) was never really what most people mean by "pages".
|
||
let _pageMode = localStorage.getItem('reh-page-mode') || 'pdf';
|
||
|
||
function applyPageMode() {
|
||
const wrap = document.querySelector('.reh-page-wrap');
|
||
const a4 = $('reh-a4-page');
|
||
if (!wrap) return;
|
||
|
||
if (_pageMode === 'scroll') {
|
||
// Endless scroll — put all blocks back into the a4 div, no paper pages
|
||
wrap.querySelectorAll('.reh-paper').forEach(p => {
|
||
[...p.children].forEach(c => { if (!c.classList.contains('reh-paper-num')) a4?.appendChild(c); });
|
||
p.remove();
|
||
});
|
||
wrap.classList.remove('paginated');
|
||
if (a4) a4.style.display = '';
|
||
// Hide pagebreak visual dividers in scroll mode
|
||
document.querySelectorAll('.reh-block-pagebreak').forEach(el => el.style.display = 'none');
|
||
} else if (_pageMode === 'pdf') {
|
||
// PDF pages — paginate at pagebreak markers
|
||
paginateScript({ respectBreaks: true });
|
||
} else {
|
||
// Auto — paginate by content height
|
||
paginateScript({ respectBreaks: false });
|
||
}
|
||
_syncPageModeBtn();
|
||
}
|
||
|
||
function _syncPageModeBtn() {
|
||
const btn = $('reh-page-mode-btn');
|
||
if (!btn) return;
|
||
const icons = { auto: 'mdi-file-document-outline', scroll: 'mdi-format-align-justify', pdf: 'mdi-book-open-page-variant' };
|
||
const labels = { auto: 'A4 pages', scroll: 'Scroll', pdf: 'PDF pages' };
|
||
// Labeling this with the CURRENT mode made it look like a passive status
|
||
// indicator rather than a button — confirmed live as genuine confusion:
|
||
// stuck in "Scroll" (one continuous page, no page breaks) with no visible
|
||
// cue that clicking the very button saying "Scroll" is what would change
|
||
// it. Show what clicking it switches TO instead, the normal convention
|
||
// for a cycle/toggle button.
|
||
const nextMode = PAGE_MODES[(PAGE_MODES.indexOf(_pageMode) + 1) % PAGE_MODES.length];
|
||
btn.innerHTML = `<span class="mdi ${icons[nextMode] || icons.auto}"></span> ${labels[nextMode] || labels.auto}`;
|
||
btn.title = `Currently: ${labels[_pageMode]} — click to switch to ${labels[nextMode]}`;
|
||
}
|
||
|
||
function cyclePageMode() {
|
||
const idx = PAGE_MODES.indexOf(_pageMode);
|
||
_pageMode = PAGE_MODES[(idx + 1) % PAGE_MODES.length];
|
||
localStorage.setItem('reh-page-mode', _pageMode);
|
||
applyPageMode();
|
||
// Rebuild script page to apply cleanly
|
||
if (rehState.lines.length) buildScriptPage();
|
||
}
|
||
|
||
// ── Pagination: split the continuous script into A4 paper pages ─────────────
|
||
function paginateScript({ respectBreaks = false } = {}) {
|
||
const wrap = document.querySelector('.reh-page-wrap');
|
||
const linesEl = $('reh-script-lines');
|
||
const titleEl = $('reh-page-title');
|
||
if (!wrap || !linesEl) return;
|
||
|
||
// Collect the already-wired block elements (moving them keeps listeners)
|
||
const blocks = [...linesEl.children];
|
||
if (!blocks.length) return;
|
||
|
||
wrap.classList.add('paginated');
|
||
const a4 = $('reh-a4-page');
|
||
if (a4) a4.style.display = 'none';
|
||
wrap.querySelectorAll('.reh-paper').forEach(p => p.remove());
|
||
|
||
// Printable content height inside a page (A4 minus vertical padding)
|
||
const PAGE_CONTENT = 1027; // 1123 − 56 − 40
|
||
const TITLE_SPACE = 64; // page-1 title block allowance
|
||
|
||
let pageNum = 0, page = null, used = 0;
|
||
let isFirstPage = true;
|
||
|
||
// Pre-scan for the first PDF page number to start numbering correctly
|
||
if (respectBreaks) {
|
||
for (const b of blocks) {
|
||
if (b.classList.contains('reh-block-pagebreak')) {
|
||
const i = parseInt(b.dataset.index);
|
||
const l = rehState.lines[i];
|
||
if (l && l.page) {
|
||
// If the pagebreak is for page N, the content before it is N or N-1 depending on how breaks are placed.
|
||
pageNum = Math.max(0, l.page - (b === blocks[0] ? 0 : 1));
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
const newPage = (forcedNum) => {
|
||
if (forcedNum !== undefined) pageNum = forcedNum;
|
||
else 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 (isFirstPage && titleEl) {
|
||
const t = titleEl.cloneNode(true);
|
||
t.style.display = '';
|
||
page.appendChild(t);
|
||
used += TITLE_SPACE;
|
||
}
|
||
isFirstPage = false;
|
||
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')) {
|
||
const i = parseInt(block.dataset.index);
|
||
const l = rehState.lines[i];
|
||
newPage(l && l.page ? l.page : undefined);
|
||
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 > (isFirstPage ? TITLE_SPACE : 0)) {
|
||
newPage();
|
||
page.appendChild(block);
|
||
}
|
||
used += h;
|
||
}
|
||
}
|
||
|
||
function isPracticeRange(idx) {
|
||
const s = rehState.practiceStart, e = rehState.practiceEnd;
|
||
if (s === null) return false;
|
||
return idx >= s && (e === null || idx <= e);
|
||
}
|
||
|
||
// ── Inline editing ──────────────────────────────────────────────────────────
|
||
|
||
function startInlineEdit(idx) {
|
||
const line = rehState.lines[idx]; if (!line) return;
|
||
const dialogEl = document.getElementById('reh-diag-' + idx);
|
||
const actionEl = document.querySelector(`[data-index="${idx}"] .reh-action-text`);
|
||
const sceneEl = document.querySelector(`.reh-scene[data-index="${idx}"] span[style*="flex:1"]`);
|
||
const targetEl = dialogEl || actionEl || sceneEl;
|
||
if (!targetEl || targetEl.tagName === 'TEXTAREA') return;
|
||
|
||
const orig = line.text;
|
||
const ta = document.createElement('textarea');
|
||
ta.value = orig;
|
||
ta.style.cssText = 'width:100%;min-height:54px;font-size:14px;line-height:1.6;padding:5px 8px;border:1px solid var(--accent);border-top:none;border-radius:0 0 3px 3px;resize:vertical;background:#fffff8;font-family:inherit;display:block;box-sizing:border-box;outline:none;';
|
||
|
||
// Formatting toolbar
|
||
const toolbar = document.createElement('div');
|
||
toolbar.className = 'reh-fmt-toolbar';
|
||
toolbar.innerHTML = `
|
||
<button class="reh-fmt-btn" data-b="**" title="Bold"><b>B</b></button>
|
||
<button class="reh-fmt-btn" data-b="*" title="Italic"><i>I</i></button>
|
||
<button class="reh-fmt-btn" data-b="__" title="Underline"><u>U</u></button>
|
||
<button class="reh-fmt-btn" data-b="~~" title="Strikethrough"><del>S</del></button>
|
||
<button class="reh-fmt-btn" data-b="==" title="Highlight" style="background:#fef3c7">H</button>
|
||
<kbd class="reh-fmt-hint">Ctrl+Enter save · Esc cancel</kbd>
|
||
`;
|
||
toolbar.querySelectorAll('.reh-fmt-btn').forEach(btn => {
|
||
btn.addEventListener('mousedown', e => {
|
||
e.preventDefault();
|
||
const wrap = btn.dataset.b;
|
||
const s = ta.selectionStart, end = ta.selectionEnd;
|
||
const sel = ta.value.slice(s, end) || 'text';
|
||
ta.value = ta.value.slice(0, s) + wrap + sel + wrap + ta.value.slice(end);
|
||
ta.setSelectionRange(s + wrap.length, s + wrap.length + sel.length);
|
||
ta.focus();
|
||
});
|
||
});
|
||
|
||
targetEl.style.display = 'none';
|
||
targetEl.before(toolbar);
|
||
toolbar.after(ta);
|
||
ta.focus(); ta.select();
|
||
|
||
const save = () => {
|
||
const newText = ta.value.trim() || orig;
|
||
line.text = newText;
|
||
targetEl.innerHTML = renderMarkdownInline(newText);
|
||
targetEl.style.display = '';
|
||
toolbar.remove(); ta.remove();
|
||
rehState.synthCache.delete(idx);
|
||
const dot = document.getElementById('reh-syd-' + idx);
|
||
if (dot) dot.style.display = 'none';
|
||
};
|
||
|
||
ta.addEventListener('keydown', e => {
|
||
if (e.key === 'Escape') { ta.value = orig; save(); }
|
||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); save(); }
|
||
});
|
||
ta.addEventListener('blur', save);
|
||
}
|
||
|
||
// ── Script editor modal ─────────────────────────────────────────────────────
|
||
|
||
function openScriptEditorModal() {
|
||
const currentTitle = $('reh-page-title')?.textContent || $('reh-script-title')?.value || 'Script';
|
||
const currentScript = linesToScriptText() || $('reh-script-text')?.value || '';
|
||
|
||
const modal = document.createElement('div');
|
||
modal.className = 'reh-modal-overlay';
|
||
modal.innerHTML = `
|
||
<div class="reh-modal-box" style="max-width:720px;width:95vw;height:80vh">
|
||
<div class="reh-modal-head">
|
||
<h3 style="margin:0;font-size:15px"><span class="mdi mdi-script-text-outline"></span> Edit Script</h3>
|
||
<button class="btn-secondary btn-sm" id="_reh-mc">✕ Close</button>
|
||
</div>
|
||
<div style="padding:10px 14px;border-bottom:1px solid var(--border);display:flex;gap:10px;align-items:center">
|
||
<label style="font-size:12px;font-weight:600;flex-shrink:0">Title</label>
|
||
<input type="text" id="_reh-mt" value="${escHtml(currentTitle)}" style="flex:1;padding:5px 8px;border:1px solid var(--border);border-radius:4px;font-size:13px;background:var(--surface);color:var(--text)">
|
||
</div>
|
||
<textarea id="_reh-ms" style="flex:1;padding:14px;font-family:'Courier New',monospace;font-size:12.5px;border:none;outline:none;resize:none;background:var(--surface);color:var(--text);height:calc(80vh - 140px);width:100%;box-sizing:border-box;" spellcheck="false">${escHtml(currentScript)}</textarea>
|
||
<div style="padding:10px 14px;display:flex;gap:8px;justify-content:flex-end;border-top:1px solid var(--border)">
|
||
<button class="btn-secondary" id="_reh-mcan">Cancel</button>
|
||
<button class="btn-primary" id="_reh-mapp"><span class="mdi mdi-auto-fix"></span> Apply changes</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(modal);
|
||
|
||
const close = () => modal.remove();
|
||
modal.querySelector('#_reh-mc').onclick = close;
|
||
modal.querySelector('#_reh-mcan').onclick = close;
|
||
modal.addEventListener('click', e => { if (e.target === modal) close(); });
|
||
|
||
modal.querySelector('#_reh-mapp').onclick = () => {
|
||
const newTitle = modal.querySelector('#_reh-mt').value.trim() || 'Script';
|
||
const newScript = modal.querySelector('#_reh-ms').value;
|
||
|
||
const newLines = parseScript(newScript);
|
||
if (!newLines.filter(l => l.type === 'dialog').length) {
|
||
toast('No dialog found in the edited script', 'error'); return;
|
||
}
|
||
|
||
// Merge emotions for unchanged lines
|
||
newLines.forEach(nl => {
|
||
if (nl.type !== 'dialog') return;
|
||
const old = rehState.lines.find(ol => ol.type==='dialog' && ol.speaker===nl.speaker && ol.text===nl.text);
|
||
if (old) nl.emotion = old.emotion;
|
||
});
|
||
|
||
// Keep cast for matching characters
|
||
const detected = detectCharacters(newLines);
|
||
Object.entries(detected).forEach(([sp]) => {
|
||
if (rehState.cast[sp]) detected[sp] = { ...detected[sp], ...rehState.cast[sp] };
|
||
});
|
||
|
||
// Update state
|
||
rehState.lines = newLines;
|
||
rehState.cast = detected;
|
||
rehState.lineIndex = Math.min(rehState.lineIndex, newLines.length - 1);
|
||
rehState.synthCache.clear(); rehDecodedBuffers.clear();
|
||
rehState.practiceStart = null;
|
||
rehState.practiceEnd = null;
|
||
|
||
if ($('reh-script-title')) $('reh-script-title').value = newTitle;
|
||
if ($('reh-page-title')) $('reh-page-title').textContent = newTitle;
|
||
|
||
buildScriptPage();
|
||
highlightCurrentLine();
|
||
close();
|
||
toast('Script updated', 'success');
|
||
};
|
||
}
|
||
|
||
// ── Practice range ──────────────────────────────────────────────────────────
|
||
|
||
function updatePracticeRange() {
|
||
const start = rehState.practiceStart;
|
||
const end = rehState.practiceEnd;
|
||
|
||
document.querySelectorAll('.reh-block').forEach(el => {
|
||
const idx = parseInt(el.dataset.index);
|
||
el.classList.toggle('reh-in-range', start !== null && idx >= start && (end === null || idx <= end));
|
||
el.classList.toggle('reh-range-start', start !== null && idx === start);
|
||
el.classList.toggle('reh-range-end', end !== null && idx === end);
|
||
const gb = el.querySelector('.reh-gutter-btn');
|
||
if (gb) gb.textContent = idx === start ? '▶' : idx === end ? '■' : '';
|
||
});
|
||
|
||
const info = $('reh-practice-info');
|
||
if (info) {
|
||
const show = end !== null;
|
||
info.classList.toggle('visible', show);
|
||
info.hidden = !show;
|
||
if (show) {
|
||
const f = $('reh-practice-from'), t = $('reh-practice-to');
|
||
if (f) f.textContent = (start !== null ? start : 0) + 1;
|
||
if (t) t.textContent = end + 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
function clearPracticeRange() {
|
||
rehState.practiceStart = null;
|
||
rehState.practiceEnd = null;
|
||
updatePracticeRange();
|
||
}
|
||
|
||
function setPracticeRangeFromSelection() {
|
||
const picked = [...rehState.bulkSel].sort((a, b) => a - b);
|
||
if (!picked.length) { toast('Select the lines you want to rehearse first', 'error'); return; }
|
||
rehState.practiceStart = picked[0];
|
||
rehState.practiceEnd = picked[picked.length - 1];
|
||
rehState.lineIndex = rehState.practiceStart;
|
||
setBulkMode(false);
|
||
updatePracticeRange();
|
||
highlightCurrentLine();
|
||
toast(`Practice range set: lines ${rehState.practiceStart + 1}–${rehState.practiceEnd + 1}`, 'success');
|
||
}
|
||
|
||
$('reh-practice-clear')?.addEventListener('click', clearPracticeRange);
|
||
$('reh-bulk-range')?.addEventListener('click', setPracticeRangeFromSelection);
|
||
|
||
// ── Emotion picker popover ──────────────────────────────────────────────────
|
||
|
||
let rehEmoPicker = null;
|
||
|
||
function openEmoPicker(idx, anchorBtn) {
|
||
closeEmoPicker();
|
||
const line = rehState.lines[idx];
|
||
const allEmos = [...REH_EMOTIONS, ...rehCustomEmotions];
|
||
const rect = anchorBtn.getBoundingClientRect();
|
||
const left = Math.min(rect.left, window.innerWidth - 265);
|
||
const topBelow = rect.bottom + 4;
|
||
const topAbove = rect.top - 4;
|
||
const spaceBelow = window.innerHeight - topBelow;
|
||
const top = spaceBelow >= 240 ? topBelow : (topAbove - 260);
|
||
|
||
const pop = document.createElement('div');
|
||
pop.className = 'reh-emo-popover';
|
||
pop.style.cssText = `position:fixed;z-index:1000;top:${top}px;left:${left}px;width:258px;`;
|
||
pop.innerHTML = `<div class="reh-emo-pop-inner">
|
||
<input class="reh-emo-search" placeholder="Search or type custom tone…" autocomplete="off">
|
||
<div class="reh-emo-list"></div>
|
||
<div class="reh-emo-custom-row">
|
||
<button class="btn-secondary btn-sm" id="_emo-custom-apply" style="width:100%;font-size:11px">
|
||
<span class="mdi mdi-plus"></span> Use as custom tone
|
||
</button>
|
||
</div>
|
||
</div>`;
|
||
document.body.appendChild(pop);
|
||
rehEmoPicker = pop;
|
||
|
||
const searchEl = pop.querySelector('.reh-emo-search');
|
||
const listEl = pop.querySelector('.reh-emo-list');
|
||
const applyBtn = pop.querySelector('#_emo-custom-apply');
|
||
|
||
function renderList(q = '') {
|
||
const lq = q.toLowerCase();
|
||
const filtered = allEmos.filter(e => !q || e.label.toLowerCase().includes(lq) || e.value.toLowerCase().includes(lq));
|
||
listEl.innerHTML = filtered.map(e =>
|
||
`<div class="reh-emo-item${line.emotion===e.value?' selected':''}" data-value="${escHtml(e.value)}">
|
||
<span class="reh-emo-item-emoji">${e.emoji}</span>
|
||
<span class="reh-emo-item-label">${escHtml(e.label)}</span>
|
||
${line.emotion===e.value?'<span class="mdi mdi-check" style="margin-left:auto;font-size:13px;color:var(--accent)"></span>':''}
|
||
</div>`
|
||
).join('');
|
||
listEl.querySelectorAll('.reh-emo-item').forEach(item => {
|
||
item.addEventListener('mousedown', e => {
|
||
e.preventDefault();
|
||
selectEmotion(idx, item.dataset.value, anchorBtn);
|
||
closeEmoPicker();
|
||
});
|
||
});
|
||
}
|
||
|
||
searchEl.addEventListener('input', () => renderList(searchEl.value));
|
||
searchEl.addEventListener('keydown', e => {
|
||
if (e.key === 'Escape') closeEmoPicker();
|
||
if (e.key === 'Enter') {
|
||
const val = searchEl.value.trim();
|
||
if (val) { selectEmotion(idx, val, anchorBtn); closeEmoPicker(); }
|
||
}
|
||
});
|
||
|
||
applyBtn.addEventListener('mousedown', e => {
|
||
e.preventDefault();
|
||
const val = searchEl.value.trim(); if (!val) return;
|
||
const exists = [...REH_EMOTIONS, ...rehCustomEmotions].find(e => e.value === val);
|
||
if (!exists) {
|
||
rehCustomEmotions.push({ emoji: '✨', label: val, value: val, custom: true });
|
||
try { localStorage.setItem('reh-custom-emotions', JSON.stringify(rehCustomEmotions)); } catch(_) {}
|
||
}
|
||
selectEmotion(idx, val, anchorBtn);
|
||
closeEmoPicker();
|
||
});
|
||
|
||
renderList();
|
||
searchEl.focus();
|
||
|
||
setTimeout(() => document.addEventListener('mousedown', _closePickerOnOutside), 50);
|
||
}
|
||
|
||
function _closePickerOnOutside(e) {
|
||
if (rehEmoPicker && !rehEmoPicker.contains(e.target)) closeEmoPicker();
|
||
}
|
||
|
||
function closeEmoPicker() {
|
||
if (rehEmoPicker) { rehEmoPicker.remove(); rehEmoPicker = null; }
|
||
document.removeEventListener('mousedown', _closePickerOnOutside);
|
||
}
|
||
|
||
function _markSynthDot(idx, state) {
|
||
const dot = document.getElementById('reh-syd-' + idx);
|
||
if (!dot) return;
|
||
dot.className = 'reh-synth-dot'
|
||
+ (state === 'stale' ? ' stale' : '')
|
||
+ (state === 'synthesizing' ? ' synthesizing' : '');
|
||
dot.style.display = state ? '' : 'none';
|
||
dot.title = state === 'stale' ? 'Tone changed — needs re-synthesis'
|
||
: state === 'synthesizing' ? 'Synthesizing…'
|
||
: 'Pre-synthesized';
|
||
// Also highlight the block row itself while synthesizing
|
||
const block = dot.closest('[data-index]');
|
||
if (block) block.classList.toggle('reh-line-synthesizing', state === 'synthesizing');
|
||
}
|
||
|
||
function _showReSynthBtn(idx, show) {
|
||
const btn = document.getElementById('reh-rsb-' + idx);
|
||
if (btn) btn.hidden = !show;
|
||
}
|
||
|
||
// A voice reassigned in the Library/Studio Voices tab (clPut calls this
|
||
// after every character save) used to never reach an already-open
|
||
// rehearsal of the same book: rehState.cast[sp].voice is loaded once from
|
||
// the saved rehearsal record, and an explicit prior value always wins over
|
||
// a fresher one from the shared roster (see the `saved?.voice ?? def.voice`
|
||
// load above) — so the Stage/export kept reading, and kept the cached
|
||
// audio for, the OLD voice indefinitely. Confirmed live as the cause of an
|
||
// audiobook export that finished suspiciously fast right after changing a
|
||
// voice: the reassigned character's lines were still "cached" under the
|
||
// old voice and never got marked stale. Only acts when a rehearsal for the
|
||
// SAME book is actually open right now and the name matches a real speaker.
|
||
function _rehSyncCastVoiceFromLibrary(rec) {
|
||
if (!window.rehState || !rehState.lines || !rehState.lines.length) return;
|
||
if (!rec || !rec.name || !rec.voice) return;
|
||
const openBook = (typeof _lineAudioBookName === 'function') ? _lineAudioBookName() : '';
|
||
if (!openBook || String(rec.book || '').trim().toLowerCase() !== openBook.trim().toLowerCase()) return;
|
||
const target = String(rec.name).toUpperCase().trim();
|
||
const sp = Object.keys(rehState.cast).find(k => String(k).toUpperCase().trim() === target);
|
||
if (!sp) return;
|
||
const c = rehState.cast[sp];
|
||
if (!c || c.voice === rec.voice) return;
|
||
c.voice = rec.voice;
|
||
c.voiceData = getVoiceData(rec.voice);
|
||
rehState.lines.forEach((line, idx) => {
|
||
if (line.type === 'dialog' && line.speaker === sp && rehState.synthCache.has(idx)) {
|
||
rehState.synthCache.delete(idx);
|
||
rehState.staleLines.add(idx);
|
||
if (typeof _markSynthDot === 'function') _markSynthDot(idx, 'stale');
|
||
}
|
||
});
|
||
if (typeof _updateStaleBatchBtn === 'function') _updateStaleBatchBtn();
|
||
if (typeof renderCastList === 'function') renderCastList();
|
||
}
|
||
window._rehSyncCastVoiceFromLibrary = _rehSyncCastVoiceFromLibrary;
|
||
|
||
async function synthOneLine(idx) {
|
||
const line = rehState.lines[idx];
|
||
if (!line || line.type !== 'dialog') return;
|
||
const c = rehState.cast[line.speaker];
|
||
if (!c || !c.voice || c.voice === 'me') return;
|
||
const instruct = _buildInstruct(c.instruct, line.emotion, c.voice);
|
||
_showReSynthBtn(idx, false);
|
||
_markSynthDot(idx, 'synthesizing');
|
||
try {
|
||
const blob = await fetchTtsPreviewBlob(c.voice, _rehInlineTone(stripMarkdown(line.text), line.emotion), 'wav', instruct, _ttsBackendForVoice(c.voice, rehState.backend));
|
||
rehState.synthCache.set(idx, blob);
|
||
rehState.staleLines.delete(idx);
|
||
preDecodeBlob(idx, blob);
|
||
_markSynthDot(idx, 'ok');
|
||
toast('Re-synthesized line ' + (idx + 1), 'success');
|
||
} catch(e) {
|
||
_markSynthDot(idx, null);
|
||
_showReSynthBtn(idx, true);
|
||
toast('Synthesis failed: ' + e.message, 'error');
|
||
}
|
||
}
|
||
|
||
function selectEmotion(idx, value, anchorBtn) {
|
||
rehState.lines[idx].emotion = value;
|
||
if (rehState.synthCache.has(idx)) {
|
||
rehState.synthCache.delete(idx);
|
||
rehState.staleLines.add(idx);
|
||
_markSynthDot(idx, 'stale');
|
||
_updateStaleBatchBtn();
|
||
}
|
||
_showReSynthBtn(idx, true);
|
||
// Update button
|
||
const info = getEmotionInfo(value);
|
||
anchorBtn.className = 'reh-emo-btn' + (value ? ' has-emotion' : '');
|
||
anchorBtn.innerHTML = `${info.emoji ? info.emoji + ' ' : ''}<span class="reh-emo-label">${escHtml(info.label)}</span> <span class="mdi mdi-chevron-down" style="font-size:9px;opacity:.6"></span>`;
|
||
// Show warning if the active backend doesn't reliably support style
|
||
if (value) _checkToneStyleSupport();
|
||
}
|
||
|
||
// One row of the tone/identity comparison table.
|
||
function _rehToneCmpRow(backend, isCurrent) {
|
||
const check = (ok) => ok
|
||
? '<span class="reh-tone-cmp-yes"><span class="mdi mdi-check"></span></span>'
|
||
: '<span class="reh-tone-cmp-no"><span class="mdi mdi-close"></span></span>';
|
||
const action = isCurrent
|
||
? '<span class="reh-tone-cmp-current-tag">current</span>'
|
||
: `<button type="button" class="btn-secondary btn-sm reh-tone-switch-btn" data-backend-id="${escHtml(backend.id)}">Switch</button>`;
|
||
return `<tr${isCurrent ? ' class="reh-tone-cmp-current"' : ''}>
|
||
<td>${escHtml(backend.label)}</td>
|
||
<td>${check(backend.style_aware)}</td>
|
||
<td>${check(backend.uses_wav)}</td>
|
||
<td>${action}</td>
|
||
</tr>`;
|
||
}
|
||
|
||
function _checkToneStyleSupport() {
|
||
const warn = $('reh-tone-warn'); if (!warn) return;
|
||
const txtEl = $('reh-tone-warn-txt');
|
||
const b = (typeof backendById === 'function') ? backendById(rehState.backend) : null;
|
||
if (!b) { warn.hidden = true; return; }
|
||
const all = (typeof availableTtsBackends === 'function') ? availableTtsBackends() : [];
|
||
const hasTone = rehState.lines.some(l => l.type === 'dialog' && l.emotion);
|
||
|
||
// Two opposite engine trade-offs, surfaced so the user can choose knowingly:
|
||
// • clone backends → consistent character identity, but weak tone control
|
||
// • design backends → strong tone, but a fresh persona each call (voices drift)
|
||
// Originally a single run-on sentence with a button awkwardly wedged into
|
||
// the middle of it (confirmed live: read badly, wrapped worse). A table
|
||
// says the same two facts as two columns instead of two clauses.
|
||
if (_rehBackendIsFish()) {
|
||
// Fish-Speech / OpenAudio S2 honours inline [tag] tones (15 000+ tags) injected per line
|
||
if (txtEl) txtEl.innerHTML = `<strong>${escHtml(b.label)}</strong> keeps each 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) {
|
||
// Prefer a backend that fixes BOTH weaknesses at once (tone-aware AND
|
||
// clones from a reference WAV, e.g. Fish-Speech) over one that only
|
||
// fixes this one (tone-aware but re-rolls the voice each line, e.g.
|
||
// Voice Design) — confirmed live: with both available, `.find()` was
|
||
// silently suggesting whichever happened to come first in the backend
|
||
// list, which was never Fish-Speech despite it being the strictly
|
||
// better option whenever it's actually running.
|
||
const styleAware = all.find(x => x.style_aware && x.uses_wav) || all.find(x => x.style_aware);
|
||
if (txtEl) {
|
||
txtEl.innerHTML = styleAware
|
||
? `<table class="reh-tone-cmp"><thead><tr><th></th><th>Tone control</th><th>Voice stays identical</th><th></th></tr></thead><tbody>${_rehToneCmpRow(b, true)}${_rehToneCmpRow(styleAware, false)}</tbody></table>`
|
||
: `<strong>${escHtml(b.label)}</strong> keeps each character’s voice consistent but has weak tone control — tone picks may have little effect.`;
|
||
}
|
||
warn.hidden = false;
|
||
} else if (b.style_aware && !b.uses_wav) {
|
||
// Same preference as above, mirrored: a wav-cloning backend that's ALSO
|
||
// tone-aware (Fish-Speech) beats one that drops tone control entirely
|
||
// (Voice Clone) as the suggested alternative.
|
||
const wavBackend = all.find(x => x.uses_wav && x.style_aware) || all.find(x => x.uses_wav);
|
||
const qwenHint = /qwen|voice design|custom/i.test((b.id || '') + ' ' + (b.label || '')) ? '<p class="reh-tone-cmp-note">Qwen3TTS tone is sent as the per-line style/instruct text, so this is the right path for directed delivery.</p>' : '';
|
||
if (txtEl) {
|
||
txtEl.innerHTML = wavBackend
|
||
? `<table class="reh-tone-cmp"><thead><tr><th></th><th>Tone control</th><th>Voice stays identical</th><th></th></tr></thead><tbody>${_rehToneCmpRow(b, true)}${_rehToneCmpRow(wavBackend, false)}</tbody></table>${qwenHint}`
|
||
: `<strong>${escHtml(b.label)}</strong> gives strong tone but re-generates a fresh voice each line, so a character won’t sound the same throughout.${qwenHint}`;
|
||
}
|
||
warn.hidden = false;
|
||
} else {
|
||
warn.hidden = true;
|
||
}
|
||
}
|
||
|
||
$('reh-tone-warn-close')?.addEventListener('click', () => { const w = $('reh-tone-warn'); if (w) w.hidden = true; });
|
||
|
||
// The suggestion buttons above get rebuilt (via innerHTML) every time
|
||
// _checkToneStyleSupport() re-runs, so a delegated listener on the
|
||
// container — bound once — is the only reliable way to catch clicks on them.
|
||
$('reh-tone-warn')?.addEventListener('click', (e) => {
|
||
const btn = e.target.closest('.reh-tone-switch-btn');
|
||
if (!btn) return;
|
||
const id = btn.dataset.backendId;
|
||
const sel = $('reh-backend-select');
|
||
if (sel && [...sel.options].some(o => o.value === id)) sel.value = id;
|
||
rehState.backend = id;
|
||
_checkToneStyleSupport();
|
||
const b = (typeof backendById === 'function') ? backendById(id) : null;
|
||
toast('Switched to ' + (b ? b.label : id), 'success');
|
||
});
|
||
|
||
// ── Transport controls ──────────────────────────────────────────────────────
|
||
|
||
$('reh-tb-play')?.addEventListener('click', () => {
|
||
if (rehState.playing) pausePlay(); else startPlay();
|
||
});
|
||
|
||
$('reh-tb-stop')?.addEventListener('click', () => {
|
||
stopPlay();
|
||
rehState.lineIndex = rehState.practiceStart ?? 0;
|
||
highlightCurrentLine();
|
||
hideRecOverlay();
|
||
});
|
||
|
||
$('reh-tb-prev')?.addEventListener('click', () => {
|
||
stopPlay();
|
||
rehState.lineIndex = Math.max(0, rehState.lineIndex - 1);
|
||
highlightCurrentLine();
|
||
hideRecOverlay();
|
||
});
|
||
|
||
$('reh-tb-next')?.addEventListener('click', () => {
|
||
stopPlay();
|
||
rehState.lineIndex = Math.min(rehState.lines.length - 1, rehState.lineIndex + 1);
|
||
highlightCurrentLine();
|
||
hideRecOverlay();
|
||
});
|
||
|
||
$('reh-tb-repeat')?.addEventListener('click', () => {
|
||
rehState.repeat = !rehState.repeat;
|
||
$('reh-tb-repeat')?.classList.toggle('reh-btn-active', rehState.repeat);
|
||
});
|
||
|
||
$('reh-skip-desc-toggle')?.addEventListener('change', function () { rehState.skipDescriptions = this.checked; });
|
||
|
||
$('reh-edit-script-btn')?.addEventListener('click', openScriptEditorModal);
|
||
$('reh-fountain-btn')?.addEventListener('click', exportFountain);
|
||
$('reh-fountain-export-p4')?.addEventListener('click', exportFountain);
|
||
$('reh-fdx-export-btn')?.addEventListener('click', exportFDX);
|
||
$('reh-osf-export-btn')?.addEventListener('click', exportOSF);
|
||
|
||
$('reh-exit-btn')?.addEventListener('click', () => {
|
||
stopPlay(); stopRehMic();
|
||
if (rehState.clips.length) { renderSummary(); showPhase(4); }
|
||
else showPhase(2);
|
||
});
|
||
|
||
// ── Page title inline editing ───────────────────────────────────────────────
|
||
|
||
$('reh-page-title')?.addEventListener('dblclick', function () {
|
||
this.contentEditable = 'true';
|
||
this.style.outline = '2px solid var(--accent)';
|
||
this.style.borderRadius = '3px';
|
||
this.focus();
|
||
const range = document.createRange();
|
||
range.selectNodeContents(this);
|
||
window.getSelection().removeAllRanges();
|
||
window.getSelection().addRange(range);
|
||
});
|
||
|
||
$('reh-page-title')?.addEventListener('blur', function () {
|
||
if (this.contentEditable === 'true') {
|
||
this.contentEditable = 'false';
|
||
this.style.outline = '';
|
||
const v = this.textContent.trim() || 'Script';
|
||
this.textContent = v;
|
||
if ($('reh-script-title')) $('reh-script-title').value = v;
|
||
}
|
||
});
|
||
|
||
$('reh-page-title')?.addEventListener('keydown', function (e) {
|
||
if (e.key === 'Enter') { e.preventDefault(); this.blur(); }
|
||
if (e.key === 'Escape') { this.textContent = $('reh-script-title')?.value || 'Script'; this.blur(); }
|
||
});
|
||
|
||
// ── Recording overlay ───────────────────────────────────────────────────────
|
||
|
||
function showRecOverlay(line) {
|
||
const overlay = $('reh-rec-overlay'); if (!overlay) return;
|
||
overlay.hidden = false;
|
||
const cue = $('reh-rec-cue'), c = rehState.cast[line.speaker] || { color: '#89b4fa' };
|
||
if (cue) cue.innerHTML = `<span class="reh-rec-cue-name" style="color:${c.color}">${escHtml(line.speaker)}</span> — your line:<div class="reh-rec-cue-text">${escHtml(stripMarkdown(line.text))}</div>`;
|
||
if ($('reh-rec-preview')) { $('reh-rec-preview').style.display='none'; $('reh-rec-preview').src=''; }
|
||
if ($('reh-rec-confirm-row')) $('reh-rec-confirm-row').hidden = true;
|
||
if ($('reh-rec-start')) $('reh-rec-start').disabled = false;
|
||
if ($('reh-rec-stop')) $('reh-rec-stop').disabled = true;
|
||
if ($('reh-rec-time')) $('reh-rec-time').textContent = '0:00';
|
||
rehState.lastRecBlob = null;
|
||
}
|
||
|
||
function hideRecOverlay() {
|
||
const overlay = $('reh-rec-overlay'); if (overlay) overlay.hidden = true;
|
||
stopRehMic();
|
||
}
|
||
|
||
// ── Mic recording ───────────────────────────────────────────────────────────
|
||
|
||
function rehRenderMeter(level=0, db=-Infinity, clipped=false) {
|
||
const meter = $('reh-mic-meter'); if (!meter) return;
|
||
if (!meter.children.length) for (let i=0;i<18;i++){const b=document.createElement('div');b.className='bar';meter.appendChild(b);}
|
||
const active = Math.round(Math.max(0,Math.min(1,level))*meter.children.length);
|
||
[...meter.children].forEach((bar,i)=>{
|
||
bar.className='bar'; bar.style.height=(7+Math.min(i,active)*1.55)+'px';
|
||
if(i<active){bar.classList.add('on');if(db>-12&&i>11)bar.classList.add('hot');if(clipped&&i>14)bar.classList.add('clip');}
|
||
});
|
||
const el=$('reh-db-readout');if(el)el.textContent=Number.isFinite(db)?db.toFixed(1)+' dB':'-∞ dB';
|
||
}
|
||
|
||
function rehStartMeter() {
|
||
if (!rehState.recAnalyser) return;
|
||
if (rehState.recMeterRaf) cancelAnimationFrame(rehState.recMeterRaf);
|
||
const data=new Float32Array(rehState.recAnalyser.fftSize), canvas=$('reh-live-wave'), RING=300, ADD=10;
|
||
rehState.recWaveRing = new Float32Array(RING);
|
||
const tick=()=>{
|
||
rehState.recAnalyser.getFloatTimeDomainData(data);
|
||
let sum=0,peak=0; for(const s of data){sum+=s*s;peak=Math.max(peak,Math.abs(s));}
|
||
const rms=Math.sqrt(sum/data.length), db=rms>0?20*Math.log10(rms):-Infinity;
|
||
rehRenderMeter((db+60)/60,db,peak>0.98);
|
||
if(canvas&&rehState.recWaveRing){
|
||
const ring=rehState.recWaveRing; ring.copyWithin(0,ADD);
|
||
for(let i=0;i<ADD;i++) ring[RING-ADD+i]=data[Math.floor(i*data.length/ADD)];
|
||
const ctx=canvas.getContext('2d'),w=canvas.width,h=canvas.height;
|
||
ctx.clearRect(0,0,w,h); ctx.beginPath();
|
||
ctx.strokeStyle=peak>0.98?'#f38ba8':db>-12?'#f9e2af':'#a6e3a1'; ctx.lineWidth=1.5;
|
||
const mid=h/2;
|
||
for(let i=0;i<RING;i++){const x=(i/RING)*w,y=mid-ring[i]*mid*0.85;i===0?ctx.moveTo(x,y):ctx.lineTo(x,y);}
|
||
ctx.stroke();
|
||
}
|
||
rehState.recMeterRaf=requestAnimationFrame(tick);
|
||
};
|
||
tick();
|
||
}
|
||
|
||
async function startRehMic() {
|
||
if (rehState.recDestStream) return;
|
||
const AudioCtx = window.AudioContext || window.webkitAudioContext;
|
||
rehState.recStream = await requestMicrophoneStream({ raw: true });
|
||
if (AudioCtx) {
|
||
rehState.recAudioCtx = new AudioCtx();
|
||
rehState.recSourceNode = rehState.recAudioCtx.createMediaStreamSource(rehState.recStream);
|
||
rehState.recGainNode = rehState.recAudioCtx.createGain();
|
||
rehState.recAnalyser = rehState.recAudioCtx.createAnalyser();
|
||
rehState.recAnalyser.fftSize = 1024;
|
||
const dest = rehState.recAudioCtx.createMediaStreamDestination();
|
||
rehState.recSourceNode.connect(rehState.recGainNode);
|
||
rehState.recGainNode.connect(rehState.recAnalyser);
|
||
rehState.recGainNode.connect(dest);
|
||
rehState.recDestStream = dest.stream;
|
||
rehStartMeter();
|
||
} else { rehState.recDestStream = rehState.recStream; }
|
||
}
|
||
|
||
function stopRehMic() {
|
||
if (rehState.recMeterRaf) cancelAnimationFrame(rehState.recMeterRaf);
|
||
rehState.recMeterRaf = null;
|
||
[rehState.recSourceNode,rehState.recGainNode,rehState.recAnalyser].forEach(n=>{try{if(n)n.disconnect();}catch(_){}});
|
||
if (rehState.recStream) rehState.recStream.getTracks().forEach(t=>t.stop());
|
||
if (rehState.recDestStream) rehState.recDestStream.getTracks().forEach(t=>t.stop());
|
||
if (rehState.recAudioCtx) rehState.recAudioCtx.close().catch(()=>{});
|
||
Object.assign(rehState,{recStream:null,recDestStream:null,recSourceNode:null,recGainNode:null,recAnalyser:null,recAudioCtx:null,recWaveRing:null});
|
||
rehRenderMeter();
|
||
const wc=$('reh-live-wave');if(wc)wc.getContext('2d').clearRect(0,0,wc.width,wc.height);
|
||
}
|
||
|
||
$('reh-rec-start')?.addEventListener('click', async () => {
|
||
try {
|
||
await startRehMic();
|
||
rehState.recChunks=[]; rehState.recSecs=0;
|
||
if($('reh-rec-time'))$('reh-rec-time').textContent='0:00';
|
||
if($('reh-rec-start'))$('reh-rec-start').disabled=true;
|
||
if($('reh-rec-stop'))$('reh-rec-stop').disabled=false;
|
||
if($('reh-rec-confirm-row'))$('reh-rec-confirm-row').hidden=true;
|
||
rehState.recTimer=setInterval(()=>{
|
||
rehState.recSecs++;
|
||
if($('reh-rec-time'))$('reh-rec-time').textContent=Math.floor(rehState.recSecs/60)+':'+String(rehState.recSecs%60).padStart(2,'0');
|
||
},1000);
|
||
rehState.mediaRec=new MediaRecorder(rehState.recDestStream||rehState.recStream,{audioBitsPerSecond:256000});
|
||
rehState.mediaRec.ondataavailable=e=>{if(e.data.size)rehState.recChunks.push(e.data);};
|
||
rehState.mediaRec.onstop=()=>{
|
||
clearInterval(rehState.recTimer);
|
||
if($('reh-rec-start'))$('reh-rec-start').disabled=false;
|
||
if($('reh-rec-stop'))$('reh-rec-stop').disabled=true;
|
||
const blob=new Blob(rehState.recChunks,{type:rehState.mediaRec.mimeType||'audio/webm'});
|
||
const url=URL.createObjectURL(blob);
|
||
const p=$('reh-rec-preview');if(p){p.src=url;p.style.display='';}
|
||
if($('reh-rec-confirm-row'))$('reh-rec-confirm-row').hidden=false;
|
||
rehState.lastRecBlob=blob;
|
||
};
|
||
rehState.mediaRec.start(100);
|
||
} catch(e){stopRehMic();toast(await microphoneErrorMessage(e),'error');}
|
||
});
|
||
|
||
$('reh-rec-stop')?.addEventListener('click', () => { if(rehState.mediaRec?.state!=='inactive')rehState.mediaRec.stop(); });
|
||
|
||
$('reh-rec-keep')?.addEventListener('click', () => {
|
||
if (rehState.lastRecBlob)
|
||
rehState.clips.push({lineIndex:rehState.lineIndex, speaker:rehState.lines[rehState.lineIndex]?.speaker, type:'me', blob:rehState.lastRecBlob});
|
||
stopRehMic(); hideRecOverlay();
|
||
rehState.lineIndex++;
|
||
startPlay();
|
||
});
|
||
|
||
$('reh-rec-redo')?.addEventListener('click', () => { stopRehMic(); const l=rehState.lines[rehState.lineIndex]; if(l)showRecOverlay(l); });
|
||
|
||
$('reh-skip-line')?.addEventListener('click', () => {
|
||
rehState.clips.push({lineIndex:rehState.lineIndex, speaker:rehState.lines[rehState.lineIndex]?.speaker, type:'skip'});
|
||
stopRehMic(); hideRecOverlay();
|
||
rehState.lineIndex++;
|
||
startPlay();
|
||
});
|
||
|
||
// ── Phase 4: Summary ────────────────────────────────────────────────────────
|
||
|
||
function renderSummary() {
|
||
const list = $('reh-summary-list'); if (!list) return;
|
||
if (!rehState.clips.length) { list.innerHTML='<p class="note">No clips in this session.</p>'; return; }
|
||
list.innerHTML = rehState.clips.map((clip,idx)=>{
|
||
const line=rehState.lines[clip.lineIndex]||{text:'—',speaker:clip.speaker};
|
||
const c=rehState.cast[clip.speaker]||{color:'#89b4fa'};
|
||
const typeLabel=clip.type==='me'?'🎤 Recorded':clip.type==='tts'?'🔊 Synthesized':'⏭ Skipped';
|
||
const audioHtml=clip.blob?`<audio controls src="${URL.createObjectURL(clip.blob)}" style="width:100%;max-width:320px;margin-top:4px"></audio>`:'';
|
||
const dlHtml=clip.blob?`<a class="btn-secondary" style="text-decoration:none;flex-shrink:0" download="line_${idx+1}_${clip.speaker}.webm" href="${URL.createObjectURL(clip.blob)}"><span class="mdi mdi-download"></span></a>`:'';
|
||
return `<div class="reh-summary-row">
|
||
<span style="width:10px;height:10px;border-radius:50%;background:${c.color};flex-shrink:0;margin-top:4px"></span>
|
||
<div style="flex:1;min-width:0">
|
||
<div style="font-weight:600;font-size:13px;color:${c.color}">${escHtml(clip.speaker||'—')} <span style="font-weight:400;color:var(--subtext)">${typeLabel}</span></div>
|
||
<div style="font-size:13px;line-height:1.4;margin-top:2px">${escHtml(stripMarkdown(line.text))}</div>
|
||
${audioHtml}
|
||
</div>
|
||
${dlHtml}
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
$('reh-new-session-btn')?.addEventListener('click', () => {
|
||
stopPlay(); stopRehMic();
|
||
rehState.lines=[]; rehState.cast={}; rehState.clips=[]; rehState.lineIndex=0;
|
||
rehState.savedId=null; rehState.synthCache.clear(); rehDecodedBuffers.clear(); rehState.narratorVoice='';
|
||
rehState.practiceStart=null; rehState.practiceEnd=null;
|
||
if($('reh-script-text'))$('reh-script-text').value='';
|
||
if($('reh-script-title'))$('reh-script-title').value='';
|
||
renderLibraryList(); showPhase(1);
|
||
});
|
||
|
||
$('reh-resume-btn')?.addEventListener('click', ()=>{showPhase(3);highlightCurrentLine();});
|
||
$('reh-save-session-btn')?.addEventListener('click', saveToLibrary);
|
||
$('reh-cast-save-btn')?.addEventListener('click', saveToLibrary);
|
||
$('reh-export-session-btn')?.addEventListener('click', exportToFile);
|
||
$('reh-tb-save')?.addEventListener('click', saveToLibrary);
|
||
$('reh-tb-export')?.addEventListener('click', exportToFile);
|
||
|
||
$('reh-lib-import-file')?.addEventListener('change', async function(){
|
||
const f=this.files?.[0]; if(!f)return;
|
||
await importFromFile(f); this.value='';
|
||
});
|
||
|
||
// ── Import/Export panel wiring ─────────────────────────────────────────────
|
||
document.querySelectorAll('.reh-impex-file').forEach(inp => {
|
||
inp.addEventListener('change', async function () {
|
||
const f = this.files?.[0]; if (!f) return;
|
||
await importFromFile(f);
|
||
this.value = '';
|
||
// After import, return to Library
|
||
navRehearserPhase(1);
|
||
});
|
||
});
|
||
$('reh-impex-reh-btn')?.addEventListener('click', exportToFile);
|
||
$('reh-impex-fountain-btn')?.addEventListener('click', () => { $('reh-fountain-btn')?.click(); });
|
||
$('reh-impex-fdx-btn')?.addEventListener('click', () => { $('reh-fdx-export-btn')?.click(); });
|
||
$('reh-impex-osf-btn')?.addEventListener('click', () => { $('reh-osf-export-btn')?.click(); });
|
||
|
||
// ── Plain-text + Markdown export ───────────────────────────────────────────
|
||
function _downloadText(content, filename) {
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(new Blob([content], { type: 'text/plain;charset=utf-8' }));
|
||
a.download = filename; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 3000);
|
||
}
|
||
|
||
function exportAsTxt() {
|
||
const title = $('reh-script-title')?.value.trim() || $('reh-page-title')?.textContent || 'script';
|
||
_downloadText(linesToScriptText(), title.replace(/[^a-z0-9]/gi, '_') + '.txt');
|
||
}
|
||
|
||
function exportAsMd() {
|
||
const title = $('reh-script-title')?.value.trim() || 'Script';
|
||
const lines = rehState.lines.map(l => {
|
||
switch (l.type) {
|
||
case 'act': return `\n# ${l.text}\n`;
|
||
case 'scene': return `\n## ${l.text}\n`;
|
||
case 'transition': return `\n*${l.text}*\n`;
|
||
case 'direction': return `*(${l.text})*`;
|
||
case 'action': return `\n${l.text}\n`;
|
||
case 'dialog': return `\n**${l.speaker}**\n${l.text}\n`;
|
||
default: return '';
|
||
}
|
||
}).join('\n');
|
||
_downloadText(`# ${title}\n\n${lines.trim()}\n`, title.replace(/[^a-z0-9]/gi, '_') + '.md');
|
||
}
|
||
|
||
$('reh-impex-txt-btn')?.addEventListener('click', exportAsTxt);
|
||
$('reh-impex-md-btn')?.addEventListener('click', exportAsMd);
|
||
|
||
// ── IMSDb / URL import ─────────────────────────────────────────────────────
|
||
(function initUrlImport() {
|
||
const inp = $('reh-impex-url');
|
||
const btn = $('reh-impex-url-btn');
|
||
const status = $('reh-impex-url-status');
|
||
if (!inp || !btn) return;
|
||
|
||
async function fetchUrl() {
|
||
const url = inp.value.trim();
|
||
if (!url) { toast('Paste a URL first', 'error'); return; }
|
||
btn.disabled = true;
|
||
if (status) { status.textContent = 'Fetching…'; status.style.color = ''; }
|
||
try {
|
||
const r = await fetch('/api/fetch-web-script', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ url }),
|
||
});
|
||
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
|
||
const d = await r.json();
|
||
// Put script into the textarea and set title
|
||
const ta = $('reh-script-text');
|
||
if (ta) ta.value = d.text;
|
||
const titleInp = $('reh-script-title');
|
||
if (titleInp && d.title) titleInp.value = d.title.replace(/-/g,' ').replace(/\b\w/g,c=>c.toUpperCase());
|
||
if (status) {
|
||
status.textContent = `✓ Fetched ${(d.chars/1000).toFixed(0)} K chars — scroll down to Parse & cast`;
|
||
status.style.color = 'var(--green)';
|
||
}
|
||
// Navigate to Library phase so user sees the script text area
|
||
navRehearserPhase(1);
|
||
// Scroll the script textarea into view
|
||
setTimeout(() => ta?.scrollIntoView({ behavior:'smooth', block:'nearest' }), 300);
|
||
toast(`Fetched "${d.title}" — click "Parse & cast" to continue`, 'success');
|
||
} catch(e) {
|
||
if (status) { status.textContent = '✗ ' + e.message; status.style.color = 'var(--red)'; }
|
||
toast('Fetch failed: ' + e.message, 'error');
|
||
} finally { btn.disabled = false; }
|
||
}
|
||
|
||
btn.addEventListener('click', fetchUrl);
|
||
inp.addEventListener('keydown', e => { if (e.key === 'Enter') fetchUrl(); });
|
||
})();
|
||
|
||
// ── IMSDb library browser (poster grid) ────────────────────────────────────
|
||
(function initImsdbBrowser() {
|
||
const modal = $('reh-imsdb-modal');
|
||
const grid = $('reh-imsdb-grid');
|
||
const search = $('reh-imsdb-search');
|
||
const countEl = $('reh-imsdb-count');
|
||
const closeBtn = $('reh-imsdb-close');
|
||
const coverBtn = $('reh-imsdb-cover-btn');
|
||
const listBtn = $('reh-imsdb-list-btn');
|
||
const urlInp = $('reh-imsdb-url');
|
||
const urlBtn = $('reh-imsdb-url-fetch');
|
||
if (!modal || !grid) return;
|
||
|
||
const CACHE_KEY = 'reh-imsdb-cat-v1';
|
||
const CACHE_TTL = 6 * 3600 * 1000;
|
||
|
||
let _catalogue = null;
|
||
let _posterObserver = null;
|
||
let _view = localStorage.getItem('reh-imsdb-view') || 'list'; // 'cover' | 'list'
|
||
|
||
function _applyView() {
|
||
grid.classList.toggle('list-view', _view === 'list');
|
||
if (coverBtn) coverBtn.classList.toggle('active', _view === 'cover');
|
||
if (listBtn) listBtn.classList.toggle('active', _view === 'list');
|
||
}
|
||
|
||
function _ensurePosterObserver() {
|
||
if (_posterObserver) return _posterObserver;
|
||
_posterObserver = new IntersectionObserver((entries) => {
|
||
entries.forEach(async (ent) => {
|
||
if (!ent.isIntersecting) return;
|
||
const card = ent.target;
|
||
_posterObserver.unobserve(card);
|
||
const title = card.dataset.title;
|
||
try {
|
||
const d = await fetch('/api/movie-poster?title=' + encodeURIComponent(title)).then(r => r.json());
|
||
if (d.poster) {
|
||
const img = document.createElement('img');
|
||
img.className = 'reh-imsdb-poster';
|
||
img.loading = 'lazy'; img.src = d.poster; img.alt = title;
|
||
img.onload = () => {
|
||
const wrap = card.querySelector('.reh-imsdb-poster-wrap');
|
||
if (wrap) {
|
||
wrap.querySelector('.reh-imsdb-fallback')?.remove();
|
||
wrap.appendChild(img);
|
||
}
|
||
};
|
||
}
|
||
if (d.year) {
|
||
card.querySelectorAll('.reh-imsdb-year').forEach(y => { y.textContent = d.year; });
|
||
}
|
||
} catch(_) {}
|
||
});
|
||
}, { root: grid, rootMargin: '300px' });
|
||
return _posterObserver;
|
||
}
|
||
|
||
function _bookColor(title) {
|
||
let h = 0; for (let i = 0; i < title.length; i++) h = (h * 31 + title.charCodeAt(i)) >>> 0;
|
||
return `hsl(${h % 360}, 45%, 42%)`;
|
||
}
|
||
|
||
function renderGrid(items) {
|
||
const obs = _ensurePosterObserver();
|
||
grid.innerHTML = '';
|
||
if (!items.length) { grid.innerHTML = '<div class="reh-imsdb-loading">No matches.</div>'; return; }
|
||
const frag = document.createDocumentFragment();
|
||
items.slice(0, 400).forEach(it => {
|
||
const card = document.createElement('div');
|
||
card.className = 'reh-imsdb-card';
|
||
card.dataset.title = it.title;
|
||
card.dataset.url = it.fetch_url;
|
||
const color1 = _bookColor(it.title);
|
||
const color2 = _bookColor(it.title + '_');
|
||
card.innerHTML = `
|
||
<div class="reh-imsdb-poster-wrap">
|
||
<div class="reh-imsdb-fallback" style="background:linear-gradient(160deg, ${color1}, ${color2})">
|
||
<span class="mdi mdi-movie-roll"></span>
|
||
<span class="reh-imsdb-fallback-title">${escHtml(it.title)}</span>
|
||
</div>
|
||
</div>
|
||
<div class="reh-imsdb-meta">
|
||
<span class="reh-imsdb-title">${escHtml(it.title)}</span>
|
||
<span class="reh-imsdb-year"></span>
|
||
</div>`;
|
||
card.addEventListener('click', () => importImsdb(it));
|
||
frag.appendChild(card);
|
||
obs.observe(card);
|
||
});
|
||
grid.appendChild(frag);
|
||
if (countEl) countEl.textContent = `${items.length} script${items.length!==1?'s':''}${items.length>400?' (showing 400)':''}`;
|
||
_applyView();
|
||
}
|
||
|
||
async function importAnyWebUrl(url, label = 'script') {
|
||
url = String(url || '').trim();
|
||
if (!url) { toast('Paste a script URL first', 'error'); return; }
|
||
if (!/^https?:\/\//i.test(url)) { toast('URL must start with http:// or https://', 'error'); return; }
|
||
if (urlBtn) urlBtn.disabled = true;
|
||
grid.innerHTML = `<div class="reh-imsdb-loading"><span class="reh-imsdb-spinner"></span> Fetching ${escHtml(label)}…</div>`;
|
||
try {
|
||
const r = await fetch('/api/fetch-web-script', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ url }),
|
||
});
|
||
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
|
||
const d = await r.json();
|
||
const ta = $('reh-script-text'); if (ta) ta.value = d.text;
|
||
const ti = $('reh-script-title'); if (ti) ti.value = d.title || label;
|
||
closeModal();
|
||
navRehearserPhase(1);
|
||
setTimeout(() => ta?.scrollIntoView({ behavior:'smooth', block:'center' }), 250);
|
||
toast(`Loaded "${d.title || label}" (${(d.chars/1000).toFixed(0)} K) — click "Parse & cast"`, 'success');
|
||
} catch(e) {
|
||
grid.innerHTML = `<div class="reh-imsdb-load-error">Fetch failed: ${escHtml(e.message)}</div>`;
|
||
if (_catalogue) renderGrid(_searchFilter(_catalogue));
|
||
toast('Script fetch failed: ' + e.message, 'error');
|
||
} finally {
|
||
if (urlBtn) urlBtn.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function importImsdb(it) {
|
||
await importAnyWebUrl(it.fetch_url, it.title || 'IMSDb script');
|
||
}
|
||
|
||
async function _loadCatalogue() {
|
||
// Try localStorage cache first
|
||
try {
|
||
const raw = localStorage.getItem(CACHE_KEY);
|
||
if (raw) {
|
||
const cached = JSON.parse(raw);
|
||
if (Date.now() - cached.ts < CACHE_TTL && Array.isArray(cached.items) && cached.items.length) {
|
||
_catalogue = cached.items;
|
||
return;
|
||
}
|
||
}
|
||
} catch(_) {}
|
||
// Fetch from server
|
||
const d = await fetch('/api/imsdb/list').then(r => r.json());
|
||
_catalogue = d.items || [];
|
||
try {
|
||
localStorage.setItem(CACHE_KEY, JSON.stringify({ ts: Date.now(), items: _catalogue }));
|
||
} catch(_) {}
|
||
}
|
||
|
||
async function openModal() {
|
||
modal.hidden = false;
|
||
if (urlInp) urlInp.value = '';
|
||
_applyView();
|
||
if (!_catalogue) {
|
||
grid.innerHTML = '<div class="reh-imsdb-loading"><span class="reh-imsdb-spinner"></span> Loading catalogue…</div>';
|
||
try {
|
||
await _loadCatalogue();
|
||
} catch(e) {
|
||
grid.innerHTML = `<div class="reh-imsdb-loading">Failed to load: ${escHtml(e.message)}</div>`;
|
||
return;
|
||
}
|
||
}
|
||
renderGrid(_searchFilter(_catalogue));
|
||
search?.focus();
|
||
}
|
||
|
||
function closeModal() { modal.hidden = true; }
|
||
|
||
function _searchFilter(items) {
|
||
const q = search?.value.trim().toLowerCase() || '';
|
||
return q ? items.filter(it => it.title.toLowerCase().includes(q)) : items;
|
||
}
|
||
|
||
let _searchTimer = null;
|
||
search?.addEventListener('input', () => {
|
||
clearTimeout(_searchTimer);
|
||
_searchTimer = setTimeout(() => {
|
||
if (!_catalogue) return;
|
||
renderGrid(_searchFilter(_catalogue));
|
||
}, 150);
|
||
});
|
||
search?.addEventListener('keydown', e => {
|
||
if (e.key === 'Enter' && /^https?:\/\//i.test(search.value.trim())) {
|
||
e.preventDefault();
|
||
importAnyWebUrl(search.value.trim(), 'pasted URL');
|
||
}
|
||
});
|
||
urlBtn?.addEventListener('click', () => importAnyWebUrl(urlInp?.value || '', 'pasted URL'));
|
||
urlInp?.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); importAnyWebUrl(urlInp.value, 'pasted URL'); } });
|
||
|
||
coverBtn?.addEventListener('click', () => {
|
||
_view = 'cover'; localStorage.setItem('reh-imsdb-view', _view); _applyView();
|
||
});
|
||
listBtn?.addEventListener('click', () => {
|
||
_view = 'list'; localStorage.setItem('reh-imsdb-view', _view); _applyView();
|
||
});
|
||
|
||
// Wire all open buttons (Library tab + Import/Export tab)
|
||
[$('reh-browse-imsdb-btn'), $('reh-browse-imsdb-btn-impex')].forEach(btn => {
|
||
btn?.addEventListener('click', openModal);
|
||
});
|
||
closeBtn?.addEventListener('click', closeModal);
|
||
modal.addEventListener('click', e => { if (e.target === modal) closeModal(); });
|
||
})();
|
||
|
||
// ── Train Mode ────────────────────────────────────────────────────────────
|
||
// State machine: idle → playing_cue → ready → recording → transcribing → done
|
||
// Each "turn" = one set of cue lines + one "me" line the user must speak.
|
||
|
||
const trainState = {
|
||
seq: [], // [{cueLines:[{index,speaker,text,voice,instruct}], myLine:{index,speaker,text}}]
|
||
turn: 0, // current turn index
|
||
phase: 'idle', // idle | playing_cue | ready | recording | transcribing | done
|
||
cueSource: null, // AudioBufferSourceNode currently playing
|
||
mediaRec: null, // MediaRecorder for train recording
|
||
recChunks: [],
|
||
recRaf: null, // rAF id for meter
|
||
recStream: null, // MediaStream
|
||
recCtx: null, // AudioContext for meter
|
||
recAnalyser: null,
|
||
recTimer: null,
|
||
recSecs: 0,
|
||
};
|
||
|
||
function buildTrainSeq() {
|
||
const seq = [];
|
||
const lines = rehState.lines;
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const line = lines[i];
|
||
if (line.type !== 'dialog') continue;
|
||
const cast = rehState.cast[line.speaker];
|
||
if (!cast || cast.voice !== 'me') continue;
|
||
// Gather preceding cue lines (last up to 3 TTS dialog lines going backwards)
|
||
const cueLines = [];
|
||
for (let j = i - 1; j >= 0 && cueLines.length < 3; j--) {
|
||
const prev = lines[j];
|
||
if (prev.type === 'scene' || prev.type === 'act') break;
|
||
if (prev.type !== 'dialog') continue;
|
||
const pc = rehState.cast[prev.speaker];
|
||
if (!pc || pc.voice === 'me') break;
|
||
cueLines.unshift({ index: j, speaker: prev.speaker, text: prev.text, emotion: prev.emotion || '',
|
||
voice: pc.voice, color: pc.color, instruct: _buildInstruct(pc.instruct, prev.emotion, pc.voice),
|
||
voiceData: pc.voiceData });
|
||
}
|
||
seq.push({ cueLines, myLine: { index: i, speaker: line.speaker, text: line.text, color: cast.color } });
|
||
}
|
||
return seq;
|
||
}
|
||
|
||
function _trainSetState(phase) {
|
||
trainState.phase = phase;
|
||
const stateEl = $('reh-train-rec-state');
|
||
const playBtn = $('reh-train-play');
|
||
const recBtn = $('reh-train-record');
|
||
const stopBtn = $('reh-train-stop-rec');
|
||
const cueState = $('reh-train-cue-state');
|
||
const map = {
|
||
idle: { state:'Read the cue above · press ▶ to hear it', play:true, rec:false, stop:false },
|
||
playing_cue: { state:'Playing cue…', play:false, rec:false, stop:false },
|
||
ready: { state:'Press 🎤 to record your line', play:false, rec:true, stop:false },
|
||
recording: { state:'Recording…', play:false, rec:false, stop:true },
|
||
transcribing: { state:'Transcribing…', play:false, rec:false, stop:false },
|
||
done: { state:'Done — press ▶ to replay or ⏭ for next', play:true, rec:true, stop:false },
|
||
};
|
||
const m = map[phase] || map.idle;
|
||
if (stateEl) stateEl.textContent = m.state;
|
||
if (cueState && phase === 'playing_cue') { cueState.innerHTML = '<span class="mdi mdi-volume-high" style="color:var(--accent)"></span>'; }
|
||
else if (cueState) cueState.innerHTML = '';
|
||
if (playBtn) playBtn.disabled = !m.play;
|
||
if (recBtn) { recBtn.disabled = !m.rec; recBtn.hidden = !!m.stop; }
|
||
if (stopBtn) { stopBtn.disabled = !m.stop; stopBtn.hidden = !m.stop; }
|
||
const timerEl = $('reh-train-rec-timer');
|
||
const metersEl = $('reh-train-meters');
|
||
if (phase === 'recording') {
|
||
if (timerEl) timerEl.hidden = false;
|
||
if (metersEl) metersEl.hidden = false;
|
||
} else if (phase !== 'done') {
|
||
if (timerEl) timerEl.hidden = true;
|
||
if (metersEl) metersEl.hidden = true;
|
||
}
|
||
}
|
||
|
||
async function _trainPlayCueLines(cueLines) {
|
||
_trainSetState('playing_cue');
|
||
for (const cue of cueLines) {
|
||
if (trainState.phase !== 'playing_cue') break;
|
||
let blob = rehState.synthCache.get(cue.index);
|
||
if (!blob) {
|
||
try { blob = await fetchTtsPreviewBlob(cue.voice, _rehInlineTone(stripMarkdown(cue.text), cue.emotion), 'wav', cue.instruct, _ttsBackendForVoice(cue.voice, rehState.backend)); }
|
||
catch(e) { toast('Cue TTS failed: ' + e.message, 'error'); break; }
|
||
}
|
||
if (trainState.phase !== 'playing_cue') break;
|
||
await new Promise(resolve => {
|
||
const url = URL.createObjectURL(blob);
|
||
const audio = new Audio(url);
|
||
audio.onended = () => { URL.revokeObjectURL(url); resolve(); };
|
||
audio.onerror = () => { URL.revokeObjectURL(url); resolve(); };
|
||
trainState.cueAudio = audio;
|
||
audio.play().catch(resolve);
|
||
});
|
||
}
|
||
if (trainState.phase === 'playing_cue') _trainSetState('ready');
|
||
}
|
||
|
||
async function _trainTranscribe(blob) {
|
||
_trainSetState('transcribing');
|
||
try {
|
||
const fd = new FormData();
|
||
fd.append('file', blob, 'train_rec.webm');
|
||
fd.append('backend', _appSettings?.stt_preferred_backend || 'configured');
|
||
const r = await fetch('/api/transcribe-bytes', { method:'POST', body:fd });
|
||
if (!r.ok) throw new Error(r.statusText);
|
||
const d = await r.json();
|
||
return (d.text || '').trim();
|
||
} catch(e) {
|
||
toast('Transcription failed: ' + e.message, 'error');
|
||
return '';
|
||
}
|
||
}
|
||
|
||
async function trainGoTo(idx) {
|
||
// Stop any in-progress recording or playback
|
||
if (trainState.cueAudio) { trainState.cueAudio.pause(); trainState.cueAudio = null; }
|
||
_trainStopMic();
|
||
|
||
trainState.turn = Math.max(0, Math.min(idx, trainState.seq.length - 1));
|
||
const turn = trainState.seq[trainState.turn];
|
||
if (!turn) return;
|
||
|
||
// Update progress
|
||
const progEl = $('reh-train-progress');
|
||
if (progEl) progEl.textContent = `Turn ${trainState.turn + 1} / ${trainState.seq.length}`;
|
||
|
||
// Render cue card — show ALL cue lines so user can read the full context
|
||
const cueCard = $('reh-train-cue');
|
||
const cueAvEl = $('reh-train-cue-avatar');
|
||
const cueSpeaker = $('reh-train-cue-speaker');
|
||
const cueText = $('reh-train-cue-text');
|
||
if (turn.cueLines.length) {
|
||
const lastCue = turn.cueLines[turn.cueLines.length - 1];
|
||
if (cueAvEl) cueAvEl.innerHTML = voiceAvatarHtml(lastCue.voice, lastCue.color, 32);
|
||
if (cueSpeaker) cueSpeaker.textContent = lastCue.speaker;
|
||
// Show all cue lines as a readable block
|
||
if (cueText) {
|
||
cueText.innerHTML = turn.cueLines.map(c =>
|
||
`<div class="reh-train-cue-block">
|
||
<span class="reh-train-cue-who" style="color:${c.color}">${escHtml(c.speaker)}</span>
|
||
<span class="reh-train-cue-line">${escHtml(c.text)}</span>
|
||
</div>`
|
||
).join('');
|
||
}
|
||
cueCard?.classList.remove('no-cue');
|
||
} else {
|
||
if (cueAvEl) cueAvEl.innerHTML = '<span class="mdi mdi-information-outline" style="font-size:24px;color:var(--subtext)"></span>';
|
||
if (cueSpeaker) cueSpeaker.textContent = 'No preceding cue';
|
||
if (cueText) cueText.textContent = 'This is the first line — speak when ready.';
|
||
cueCard?.classList.add('no-cue');
|
||
}
|
||
|
||
// Render my line card
|
||
const mySpeaker = $('reh-train-my-speaker');
|
||
const myText = $('reh-train-my-text');
|
||
if (mySpeaker) mySpeaker.textContent = turn.myLine.speaker;
|
||
if (myText) myText.textContent = turn.myLine.text;
|
||
|
||
// Hide result from previous turn
|
||
const resultEl = $('reh-train-result');
|
||
if (resultEl) resultEl.hidden = true;
|
||
|
||
// Reset audio preview
|
||
const prevAudio = $('reh-train-audio-preview');
|
||
if (prevAudio) { prevAudio.src = ''; prevAudio.style.display = 'none'; }
|
||
|
||
// Show the script text immediately — user reads first, then clicks ▶ to hear the cue
|
||
_trainSetState(turn.cueLines.length ? 'idle' : 'ready');
|
||
}
|
||
|
||
function _trainStartMeter() {
|
||
const meterEl = $('reh-train-meter');
|
||
const dbEl = $('reh-train-db');
|
||
const waveEl = $('reh-train-wave');
|
||
if (!trainState.recAnalyser || !meterEl) return;
|
||
const analyser = trainState.recAnalyser;
|
||
const fft = new Uint8Array(analyser.frequencyBinCount);
|
||
const wCtx = waveEl?.getContext('2d');
|
||
const W = waveEl?.width || 300, H = waveEl?.height || 36;
|
||
const BARS = 18;
|
||
|
||
function tick() {
|
||
analyser.getByteFrequencyData(fft);
|
||
const rms = fft.reduce((s, v) => s + v * v, 0) / fft.length;
|
||
const db = rms > 0 ? 20 * Math.log10(Math.sqrt(rms) / 128) : -Infinity;
|
||
if (dbEl) dbEl.textContent = isFinite(db) ? db.toFixed(1) + ' dB' : '-∞ dB';
|
||
const slots = Array.from({length: BARS}, (_, i) => {
|
||
const s = Math.floor(i / BARS * fft.length), e = Math.floor((i+1) / BARS * fft.length);
|
||
return fft.slice(s, e).reduce((a, b) => a + b, 0) / (e - s) / 255;
|
||
});
|
||
meterEl.innerHTML = slots.map(v => {
|
||
const h = Math.max(2, Math.round(v * 28));
|
||
const col = v > 0.85 ? 'var(--red)' : v > 0.6 ? '#f59e0b' : 'var(--green)';
|
||
return `<span style="height:${h}px;background:${col};width:4px;border-radius:2px;display:inline-block;vertical-align:bottom;margin:0 1px"></span>`;
|
||
}).join('');
|
||
if (wCtx) {
|
||
analyser.getByteTimeDomainData(fft);
|
||
wCtx.clearRect(0, 0, W, H);
|
||
wCtx.beginPath(); wCtx.strokeStyle = 'var(--accent)'; wCtx.lineWidth = 1.5;
|
||
fft.forEach((v, i) => { const x = (i / fft.length) * W, y = (v / 255) * H; i ? wCtx.lineTo(x, y) : wCtx.moveTo(x, y); });
|
||
wCtx.stroke();
|
||
}
|
||
trainState.recRaf = requestAnimationFrame(tick);
|
||
}
|
||
trainState.recRaf = requestAnimationFrame(tick);
|
||
}
|
||
|
||
function _trainStopMic() {
|
||
if (trainState.recRaf) { cancelAnimationFrame(trainState.recRaf); trainState.recRaf = null; }
|
||
if (trainState.recTimer) { clearInterval(trainState.recTimer); trainState.recTimer = null; }
|
||
if (trainState.mediaRec && trainState.mediaRec.state !== 'inactive') {
|
||
try { trainState.mediaRec.stop(); } catch(_) {}
|
||
}
|
||
trainState.mediaRec = null;
|
||
if (trainState.recCtx) { try { trainState.recCtx.close(); } catch(_) {} trainState.recCtx = null; }
|
||
if (trainState.recStream) { trainState.recStream.getTracks().forEach(t => t.stop()); trainState.recStream = null; }
|
||
trainState.recAnalyser = null;
|
||
trainState.recChunks = [];
|
||
const timerEl = $('reh-train-rec-timer');
|
||
if (timerEl) { timerEl.hidden = true; timerEl.textContent = '0:00'; }
|
||
const metersEl = $('reh-train-meters');
|
||
if (metersEl) metersEl.hidden = true;
|
||
}
|
||
|
||
async function _trainStartRecording() {
|
||
if (trainState.phase !== 'ready') return;
|
||
try {
|
||
const stream = await requestMicrophoneStream({ raw: true });
|
||
trainState.recStream = stream;
|
||
const ctx = new AudioContext();
|
||
trainState.recCtx = ctx;
|
||
const src = ctx.createMediaStreamSource(stream);
|
||
const analyser = ctx.createAnalyser(); analyser.fftSize = 256;
|
||
trainState.recAnalyser = analyser;
|
||
const dst = ctx.createMediaStreamDestination();
|
||
src.connect(analyser); analyser.connect(dst);
|
||
_trainSetState('recording');
|
||
_trainStartMeter();
|
||
|
||
const timerEl = $('reh-train-rec-timer');
|
||
if (timerEl) timerEl.hidden = false;
|
||
trainState.recSecs = 0;
|
||
trainState.recTimer = setInterval(() => {
|
||
trainState.recSecs++;
|
||
const m = Math.floor(trainState.recSecs / 60), s = trainState.recSecs % 60;
|
||
if (timerEl) timerEl.textContent = `${m}:${String(s).padStart(2,'0')}`;
|
||
}, 1000);
|
||
|
||
trainState.recChunks = [];
|
||
const mr = new MediaRecorder(dst.stream, { audioBitsPerSecond: 128000 });
|
||
trainState.mediaRec = mr;
|
||
mr.ondataavailable = e => { if (e.data.size > 0) trainState.recChunks.push(e.data); };
|
||
mr.onstop = async () => {
|
||
_trainStopMic();
|
||
const blob = new Blob(trainState.recChunks, { type: 'audio/webm' });
|
||
trainState.recChunks = [];
|
||
const url = URL.createObjectURL(blob);
|
||
const prevAudio = $('reh-train-audio-preview');
|
||
if (prevAudio) { prevAudio.src = url; prevAudio.style.display = ''; }
|
||
|
||
// Transcribe
|
||
const transcript = await _trainTranscribe(blob);
|
||
const turn = trainState.seq[trainState.turn];
|
||
const { html_exp, html_act, score } = _trainCompare(turn?.myLine?.text || '', transcript);
|
||
|
||
const resultEl = $('reh-train-result');
|
||
const expEl = $('reh-train-expected-text');
|
||
const actEl = $('reh-train-actual-text');
|
||
const scoreEl = $('reh-train-score');
|
||
if (expEl) expEl.innerHTML = html_exp;
|
||
if (actEl) actEl.innerHTML = html_act || '<em style="color:var(--subtext)">Nothing detected</em>';
|
||
if (scoreEl) {
|
||
const col = score >= 90 ? 'var(--green)' : score >= 60 ? '#f59e0b' : 'var(--red)';
|
||
scoreEl.innerHTML = `<span style="color:${col};font-weight:700">${score}%</span>`;
|
||
}
|
||
if (resultEl) resultEl.hidden = false;
|
||
_trainSetState('done');
|
||
};
|
||
mr.start();
|
||
} catch(e) {
|
||
toast('Microphone error: ' + e.message, 'error');
|
||
_trainSetState('ready');
|
||
}
|
||
}
|
||
|
||
function _trainStopRecording() {
|
||
if (trainState.mediaRec && trainState.mediaRec.state === 'recording') {
|
||
trainState.mediaRec.stop();
|
||
}
|
||
}
|
||
|
||
function _trainHideStage(hide) {
|
||
// Show/hide the script content area and auxiliary bars — transport bar stays visible always
|
||
const els = ['.reh-stage-area', '#reh-tts-status-bar', '#reh-rec-overlay', '#reh-synth-bar'];
|
||
els.forEach(sel => {
|
||
const el = document.querySelector(sel) || document.getElementById(sel.replace('#',''));
|
||
if (el) el.style.display = hide ? 'none' : '';
|
||
});
|
||
}
|
||
|
||
function enterTrainMode() {
|
||
const seq = buildTrainSeq();
|
||
if (!seq.length) {
|
||
toast('No "I play this" lines found — check "I play this" on at least one character in the Cast tab.', 'error');
|
||
return;
|
||
}
|
||
trainState.seq = seq;
|
||
trainState.turn = 0;
|
||
trainState.phase = 'idle';
|
||
|
||
_trainHideStage(true);
|
||
const panel = $('reh-train-panel');
|
||
if (panel) panel.hidden = false;
|
||
|
||
trainGoTo(0);
|
||
}
|
||
|
||
function exitTrainMode() {
|
||
if (trainState.cueAudio) { trainState.cueAudio.pause(); trainState.cueAudio = null; }
|
||
_trainStopMic();
|
||
trainState.phase = 'idle';
|
||
trainState.seq = [];
|
||
|
||
_trainHideStage(false);
|
||
const panel = $('reh-train-panel');
|
||
if (panel) panel.hidden = true;
|
||
}
|
||
|
||
// ── Bulk-edit button wiring ─────────────────────────────────────────────────
|
||
$('reh-bulk-toggle')?.addEventListener('click', () => setBulkMode(!rehState.bulkMode));
|
||
$('reh-bulk-done')?.addEventListener('click', () => setBulkMode(false));
|
||
$('reh-bulk-all')?.addEventListener('click', () => {
|
||
// Select every line currently visible in the stage
|
||
document.querySelectorAll('#reh-script-lines .reh-bulk-check').forEach(cb => rehState.bulkSel.add(parseInt(cb.dataset.bulk)));
|
||
rehState.bulkAnchor = rehState.bulkSel.size ? Math.min(...rehState.bulkSel) : null;
|
||
document.querySelectorAll('#reh-script-lines .reh-bulk-check').forEach(cb => _refreshBulkLine(parseInt(cb.dataset.bulk)));
|
||
_updateBulkCount();
|
||
});
|
||
$('reh-bulk-none')?.addEventListener('click', () => {
|
||
const had = [...rehState.bulkSel];
|
||
rehState.bulkSel.clear();
|
||
rehState.bulkAnchor = null;
|
||
had.forEach(_refreshBulkLine);
|
||
_updateBulkCount();
|
||
});
|
||
$('reh-bulk-ignore')?.addEventListener('click', () => _bulkApply(l => { l.ignored = true; }));
|
||
$('reh-bulk-unignore')?.addEventListener('click', () => _bulkApply(l => { l.ignored = false; }));
|
||
$('reh-bulk-hide')?.addEventListener('click', () => _bulkApply(l => { l.hidden = true; }));
|
||
$('reh-bulk-delete')?.addEventListener('click', _bulkDelete);
|
||
$('reh-bulk-show-hidden')?.addEventListener('change', e => {
|
||
rehState.showHidden = e.target.checked;
|
||
buildScriptPage();
|
||
});
|
||
|
||
// ── Train button wiring ─────────────────────────────────────────────────────
|
||
$('reh-page-mode-btn')?.addEventListener('click', cyclePageMode);
|
||
$('reh-train-open-btn')?.addEventListener('click', enterTrainMode);
|
||
$('reh-train-exit-btn')?.addEventListener('click', exitTrainMode);
|
||
|
||
$('reh-train-play')?.addEventListener('click', () => {
|
||
const turn = trainState.seq[trainState.turn];
|
||
if (!turn) return;
|
||
if (trainState.phase === 'playing_cue') return;
|
||
if (trainState.cueAudio) { trainState.cueAudio.pause(); trainState.cueAudio = null; }
|
||
if (turn.cueLines.length) _trainPlayCueLines(turn.cueLines);
|
||
else _trainSetState('ready');
|
||
});
|
||
|
||
$('reh-train-record')?.addEventListener('click', () => {
|
||
if (trainState.phase === 'ready' || trainState.phase === 'done') _trainStartRecording();
|
||
});
|
||
|
||
$('reh-train-stop-rec')?.addEventListener('click', _trainStopRecording);
|
||
|
||
$('reh-train-prev')?.addEventListener('click', () => {
|
||
if (trainState.turn > 0) trainGoTo(trainState.turn - 1);
|
||
});
|
||
|
||
$('reh-train-next')?.addEventListener('click', () => {
|
||
if (trainState.turn < trainState.seq.length - 1) trainGoTo(trainState.turn + 1);
|
||
});
|
||
|
||
$('reh-train-repeat')?.addEventListener('click', () => {
|
||
if (trainState.cueAudio) { trainState.cueAudio.pause(); trainState.cueAudio = null; }
|
||
_trainStopMic();
|
||
const turn = trainState.seq[trainState.turn];
|
||
if (!turn) return;
|
||
const resultEl = $('reh-train-result');
|
||
if (resultEl) resultEl.hidden = true;
|
||
if (turn.cueLines.length) _trainPlayCueLines(turn.cueLines);
|
||
else _trainSetState('ready');
|
||
});
|
||
|
||
// ── Init ───────────────────────────────────────────────────────────────────
|
||
rehRenderMeter();
|
||
_syncPageModeBtn();
|
||
renderLibraryList().catch(()=>{});
|
||
if($('reh-skip-desc-toggle'))$('reh-skip-desc-toggle').checked=rehState.skipDescriptions;
|
||
if (window._rehearserStartImpEx) {
|
||
delete window._rehearserStartImpEx;
|
||
showRehImpEx();
|
||
} else {
|
||
const _initPhase = window._rehearserStartPhase || 1;
|
||
delete window._rehearserStartPhase;
|
||
showPhase(_initPhase);
|
||
}
|