tts-voice-creator-clone-and.../static/js/rehearser.js
mARTin-B78 b64baf2d0b Script Rehearser: library with save / load / import / export
Storage: IndexedDB ('reh-library' DB, 'rehearsals' store).
Blobs (recorded mic audio) stored natively; no base64 overhead.

Library panel (Phase 1):
- List of saved rehearsals sorted by last update
- Each row: character-colour avatars, title, progress bar,
  line count, recorded-clip count, date
- Active record highlighted in accent colour
- Open / Export .reh / Delete per-row buttons
- Import .reh button at the top

Transport bar (Phase 3):
- Save button (updates existing record if savedId set, else creates new)
- Export button (downloads current state as .reh JSON file)

Phase 4 (session complete):
- Save to library and Export .reh buttons

Data format (.reh file):
- JSON with version=1, title, script text, cast map, backend, lineIndex
- clips serialized with audio as base64 strings (mime + b64 fields)
- clipsFromJson restores Blob objects on import

Behaviour:
- Parsing a brand-new script resets savedId → safe to save as new record
- loadRecord() restores script, cast, lineIndex, clips and jumps to Phase 2
- renderLibraryList() called on init, after save, after delete, after import

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 20:32:24 +02:00

851 lines
36 KiB
JavaScript

// ── Script Rehearser ──────────────────────────────────────────────────────
// ── State ─────────────────────────────────────────────────────────────────
const rehState = {
lines: [], // [{speaker, text, isDirection}]
cast: {}, // {SPEAKER: {voice, color, voiceData}}
lineIndex: 0,
clips: [], // [{lineIndex, speaker, type, blob?}]
voices: [],
backend: '',
playing: false,
repeat: false,
savedId: null, // current library record ID (null = unsaved)
// mic
recStream: null, recAudioCtx: null, recAnalyser: null,
recSourceNode: null, recGainNode: null, recDestStream: null,
recMeterRaf: null, recWaveRing: null, mediaRec: null,
recChunks: [], recTimer: null, recSecs: 0, lastRecBlob: null,
};
window.rehState = rehState;
// ── Library — IndexedDB ────────────────────────────────────────────────────
const REH_DB_NAME = 'reh-library';
const REH_STORE = 'rehearsals';
function rehDbOpen() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(REH_DB_NAME, 1);
req.onupgradeneeded = e => {
const db = e.target.result;
if (!db.objectStoreNames.contains(REH_STORE)) {
const store = db.createObjectStore(REH_STORE, { keyPath: 'id', autoIncrement: true });
store.createIndex('updated', 'updated', { unique: false });
}
};
req.onsuccess = e => resolve(e.target.result);
req.onerror = e => reject(e.target.error);
});
}
async function rehDbOp(mode, fn) {
const db = await rehDbOpen();
return new Promise((resolve, reject) => {
const tx = db.transaction(REH_STORE, mode);
const store = tx.objectStore(REH_STORE);
const req = fn(store);
req.onsuccess = e => resolve(e.target.result);
req.onerror = e => reject(e.target.error);
});
}
async function rehDbAdd(record) {
return rehDbOp('readwrite', s => s.add(record));
}
async function rehDbPut(record) {
return rehDbOp('readwrite', s => s.put(record));
}
async function rehDbDelete(id) {
return rehDbOp('readwrite', s => s.delete(id));
}
async function rehDbGetAll() {
const db = await rehDbOpen();
return new Promise((resolve, reject) => {
const tx = db.transaction(REH_STORE, 'readonly');
const req = tx.objectStore(REH_STORE).getAll();
req.onsuccess = e => resolve(e.target.result || []);
req.onerror = e => reject(e.target.error);
});
}
// ── Serialization (export / import) ──────────────────────────────────────
async function clipsToJson(clips) {
return Promise.all(clips.map(async c => {
if (!c.blob) return { lineIndex: c.lineIndex, speaker: c.speaker, type: c.type };
const ab = await c.blob.arrayBuffer();
const u8 = new Uint8Array(ab);
let bin = '';
const CHUNK = 8192;
for (let i = 0; i < u8.length; i += CHUNK)
bin += String.fromCharCode(...u8.subarray(i, i + CHUNK));
return { lineIndex: c.lineIndex, speaker: c.speaker, type: c.type, mime: c.blob.type, b64: btoa(bin) };
}));
}
function clipsFromJson(clips) {
return clips.map(c => {
if (!c.b64) return c;
const bin = atob(c.b64);
const u8 = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
return { ...c, blob: new Blob([u8], { type: c.mime || 'audio/webm' }), b64: undefined, mime: undefined };
});
}
function rehCurrentRecord() {
const cast = {};
Object.entries(rehState.cast).forEach(([sp, c]) => { cast[sp] = { voice: c.voice, color: c.color }; });
return {
title: $('reh-script-title')?.value.trim() || $('reh-page-title')?.textContent || 'Untitled',
script: $('reh-script-text')?.value.trim() || '',
cast,
backend: rehState.backend,
lineIndex: rehState.lineIndex,
clips: rehState.clips.map(c => ({ lineIndex: c.lineIndex, speaker: c.speaker, type: c.type, blob: c.blob || null })),
updated: new Date(),
};
}
async function saveToLibrary() {
const rec = rehCurrentRecord();
if (rehState.savedId) {
rec.id = rehState.savedId;
if (!rec.created) rec.created = new Date(); // preserve original
await rehDbPut(rec);
} else {
rec.created = new Date();
const newId = await rehDbAdd(rec);
rehState.savedId = newId;
}
toast('Saved to library', 'success');
await renderLibraryList();
}
async function exportToFile() {
const rec = rehCurrentRecord();
rec.version = 1;
rec.created = rec.created || new Date();
rec.clips = await clipsToJson(rec.clips);
const json = JSON.stringify(rec, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const a = document.createElement('a');
const title = (rec.title || 'rehearsal').replace(/[^a-z0-9_\- ]/gi, '_').slice(0, 40);
a.href = URL.createObjectURL(blob);
a.download = title + '.reh';
a.click();
}
async function importFromFile(file) {
try {
const text = await file.text();
const data = JSON.parse(text);
data.clips = clipsFromJson(data.clips || []);
data.created = data.created ? new Date(data.created) : new Date();
data.updated = new Date();
delete data.id;
const newId = await rehDbAdd(data);
toast('Imported: ' + (data.title || 'Untitled'), 'success');
await renderLibraryList();
loadRecord({ ...data, id: newId });
} catch(e) { toast('Import failed: ' + e.message, 'error'); }
}
function loadRecord(rec) {
if ($('reh-script-text')) $('reh-script-text').value = rec.script || '';
if ($('reh-script-title')) $('reh-script-title').value = rec.title || '';
rehState.savedId = rec.id || null;
rehState.backend = rec.backend || '';
rehState.lineIndex = rec.lineIndex || 0;
rehState.clips = (rec.clips || []).map(c => ({ ...c }));
// Parse script and restore cast
const lines = parseScript(rec.script || '');
rehState.lines = lines;
rehState.cast = {};
const detected = detectCharacters(lines);
Object.entries(detected).forEach(([sp, def]) => {
const saved = rec.cast?.[sp];
rehState.cast[sp] = {
voice: saved?.voice ?? def.voice,
color: saved?.color ?? def.color,
voiceData: saved?.voice && saved.voice !== 'me' ? getVoiceData(saved.voice) : null,
};
});
renderCastList();
refreshRehBackends().then(() => {
if (rec.backend && $('reh-backend-select')) $('reh-backend-select').value = rec.backend;
});
showPhase(2);
}
// ── Library UI ─────────────────────────────────────────────────────────────
async function renderLibraryList() {
const list = $('reh-library-list'); if (!list) return;
let all;
try { all = await rehDbGetAll(); } catch(e) { all = []; }
all.sort((a, b) => new Date(b.updated || 0) - new Date(a.updated || 0));
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 to start a new one.</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.isDirection).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 avatars = speakers.slice(0, 4).map(sp => {
const c = rec.cast[sp];
const initial = sp[0].toUpperCase();
return `<span style="width:22px;height:22px;border-radius:50%;background:${c.color};color:#fff;font-size:10px;font-weight:700;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0">${initial}</span>`;
}).join('');
return `<div class="reh-lib-row${isCurrent ? ' reh-lib-current' : ''}" data-id="${rec.id}">
<div class="reh-lib-row-main">
<div class="reh-lib-avatars">${avatars}</div>
<div class="reh-lib-info">
<strong>${escHtml(rec.title || 'Untitled')}</strong>${isCurrent ? ' <span class="backend-tag good" style="font-size:10px">active</span>' : ''}
<div class="note" style="margin-top:2px">${total} lines · ${meCount} me · ${clipCount} recorded · ${pct}% done · ${date}</div>
<div class="reh-lib-progress-wrap"><div class="reh-lib-progress" style="width:${pct}%"></div></div>
</div>
</div>
<div class="reh-lib-row-actions">
<button class="btn-primary btn-sm reh-lib-load" data-id="${rec.id}"><span class="mdi mdi-folder-open-outline"></span> Open</button>
<button class="btn-secondary btn-sm reh-lib-export" data-id="${rec.id}" title="Export to .reh file"><span class="mdi mdi-export"></span></button>
<button class="btn-secondary btn-sm reh-lib-delete" data-id="${rec.id}" title="Delete"><span class="mdi mdi-delete-outline"></span></button>
</div>
</div>`;
}).join('');
list.querySelectorAll('.reh-lib-load').forEach(btn => btn.addEventListener('click', async () => {
const id = parseInt(btn.dataset.id);
const db = await rehDbOpen();
const rec = await new Promise((res, rej) => { const r = db.transaction(REH_STORE,'readonly').objectStore(REH_STORE).get(id); r.onsuccess=e=>res(e.target.result); r.onerror=e=>rej(e.target.error); });
loadRecord(rec);
}));
list.querySelectorAll('.reh-lib-export').forEach(btn => btn.addEventListener('click', async () => {
const id = parseInt(btn.dataset.id);
const db = await rehDbOpen();
const rec = await new Promise((res, rej) => { const r = db.transaction(REH_STORE,'readonly').objectStore(REH_STORE).get(id); r.onsuccess=e=>res(e.target.result); r.onerror=e=>rej(e.target.error); });
rec.version = 1;
rec.clips = await clipsToJson(rec.clips || []);
const json = JSON.stringify(rec, null, 2);
const a = document.createElement('a');
const t = (rec.title || 'rehearsal').replace(/[^a-z0-9_\- ]/gi, '_').slice(0, 40);
a.href = URL.createObjectURL(new Blob([json], { type: 'application/json' }));
a.download = t + '.reh';
a.click();
}));
list.querySelectorAll('.reh-lib-delete').forEach(btn => btn.addEventListener('click', async () => {
if (!confirm('Delete this rehearsal from the library?')) return;
await rehDbDelete(parseInt(btn.dataset.id));
if (rehState.savedId === parseInt(btn.dataset.id)) rehState.savedId = null;
renderLibraryList();
}));
}
const SPEAKER_COLORS = ['#89b4fa','#a6e3a1','#f38ba8','#fab387','#f9e2af','#cba6f7','#89dceb','#74c7ec'];
// ── Helpers ────────────────────────────────────────────────────────────────
function getVoiceData(voiceId) {
const all = window._voices || [];
return all.find(v => v.id === voiceId) || null;
}
function voiceAvatarHtml(voiceId, color, size = 32) {
const v = getVoiceData(voiceId);
const s = size + 'px';
const radius = Math.round(size / 2);
if (v?.has_picture) {
return `<img src="/api/voice/picture/${encodeURIComponent(voiceId)}" class="reh-char-img" style="width:${s};height:${s};border-radius:${radius}px;object-fit:cover;flex-shrink:0" alt="${escHtml(voiceId)}">`;
}
const 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>`;
}
function parseScript(text) {
const lines = text.split('\n');
const result = [];
let currentSpeaker = null;
let dialogBuffer = [];
function flush() {
if (currentSpeaker && dialogBuffer.length) {
const t = dialogBuffer.join(' ').trim();
if (t) result.push({ speaker: currentSpeaker, text: t, isDirection: false });
}
dialogBuffer = [];
}
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line) continue;
if (line.startsWith('#')) {
flush();
const dir = line.slice(1).trim();
if (dir) result.push({ speaker: '', text: dir, isDirection: true });
continue;
}
// "CHARACTER: dialog text"
const colonMatch = line.match(/^([A-Z][A-Z0-9 _\-]{0,39}):\s+(.+)$/);
if (colonMatch) { flush(); currentSpeaker = colonMatch[1].trim(); dialogBuffer = [colonMatch[2].trim()]; continue; }
// ALL-CAPS name on its own line (screenplay)
if (/^[A-Z][A-Z0-9 _\-]{0,39}$/.test(line) && line.length >= 2) { flush(); currentSpeaker = line; continue; }
// parenthetical direction "(quietly)"
if (/^\(.*\)$/.test(line)) { result.push({ speaker: currentSpeaker || '', text: line, isDirection: true }); continue; }
if (currentSpeaker) dialogBuffer.push(line);
}
flush();
return result;
}
function detectCharacters(lines) {
const speakers = [...new Set(lines.filter(l => l.speaker && !l.isDirection).map(l => l.speaker))];
const cast = {};
speakers.forEach((sp, i) => {
cast[sp] = { voice: 'me', color: SPEAKER_COLORS[i % SPEAKER_COLORS.length], voiceData: null };
});
return cast;
}
// ── Phase navigation ───────────────────────────────────────────────────────
function showPhase(n) {
for (let i = 1; i <= 4; i++) { const el = $('reh-phase-' + i); if (el) el.hidden = i !== n; }
}
// ── Phase 1 ────────────────────────────────────────────────────────────────
$('reh-file-input')?.addEventListener('change', function () {
const f = this.files?.[0]; if (!f) return;
const r = new FileReader(); r.onload = e => { $('reh-script-text').value = e.target.result; };
r.readAsText(f); this.value = '';
});
$('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.isDirection).length) {
toast('No dialog lines found. Check format: "CHARACTER: text" or screenplay.', 'error'); return;
}
rehState.cast = detectCharacters(rehState.lines);
rehState.savedId = null; // brand-new script → not yet saved
rehState.clips = [];
rehState.lineIndex = 0;
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 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>`;
}
function renderCastList() {
const list = $('reh-cast-list'); if (!list) return;
const speakers = Object.keys(rehState.cast);
const lineCount = sp => rehState.lines.filter(l => l.speaker === sp).length;
list.innerHTML = speakers.map(sp => {
const c = rehState.cast[sp];
return `<div class="reh-cast-row" data-speaker="${escHtml(sp)}">
<div style="flex-shrink:0">${castAvatarHtml(sp)}</div>
<div style="display:flex;flex-direction:column;gap:2px;flex:0 0 auto">
<strong style="color:${c.color}">${escHtml(sp)}</strong>
<span class="note">${lineCount(sp)} line${lineCount(sp) !== 1 ? 's' : ''}</span>
</div>
<label class="chunk-toggle-label" title="Record this character yourself" style="margin-left:4px">
<input type="checkbox" class="reh-me-check" data-speaker="${escHtml(sp)}" ${c.voice === 'me' ? 'checked' : ''}>
<span>I play this</span>
</label>
<select class="reh-voice-sel" data-speaker="${escHtml(sp)}" style="flex:1;min-width:160px;${c.voice === 'me' ? 'display:none' : ''}">
<option value="">— fetch voices first —</option>
${rehState.voices.map(v => `<option value="${escHtml(v)}" ${v === c.voice ? 'selected' : ''}>${escHtml(v)}</option>`).join('')}
</select>
</div>`;
}).join('');
list.querySelectorAll('.reh-me-check').forEach(cb => cb.addEventListener('change', function () {
const sp = this.dataset.speaker;
const row = this.closest('.reh-cast-row');
const sel = row.querySelector('.reh-voice-sel');
rehState.cast[sp].voice = this.checked ? 'me' : (sel.value || '');
if (sel) sel.style.display = this.checked ? 'none' : '';
rehState.cast[sp].voiceData = this.checked ? null : getVoiceData(rehState.cast[sp].voice);
}));
list.querySelectorAll('.reh-voice-sel').forEach(sel => sel.addEventListener('change', function () {
rehState.cast[this.dataset.speaker].voice = this.value;
rehState.cast[this.dataset.speaker].voiceData = getVoiceData(this.value);
renderCastList();
}));
}
async function refreshRehBackends() {
const sel = $('reh-backend-select'); if (!sel) return;
const backends = typeof availableTtsBackends === 'function' ? availableTtsBackends() : [];
sel.innerHTML = backends.length
? backends.map(b => `<option value="${escHtml(b.id)}">${escHtml(b.label)}</option>`).join('')
: '<option value="">No backend available</option>';
}
$('reh-fetch-voices-btn')?.addEventListener('click', async () => {
const backend = $('reh-backend-select')?.value;
if (!backend) { toast('Select a backend first', 'error'); return; }
$('reh-fetch-voices-btn').disabled = true;
try {
const raw = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json());
rehState.voices = Array.isArray(raw) ? raw.map(v => typeof v === 'string' ? v : (v.id || String(v))) : [];
renderCastList();
toast('Fetched ' + rehState.voices.length + ' voices', 'success');
} catch(e) { toast('Fetch failed: ' + e.message, 'error'); }
finally { $('reh-fetch-voices-btn').disabled = false; }
});
$('reh-back-1-btn')?.addEventListener('click', () => showPhase(1));
$('reh-start-btn')?.addEventListener('click', () => {
const backend = $('reh-backend-select')?.value;
if (!backend) { toast('Select a backend first', 'error'); return; }
rehState.backend = backend;
rehState.lineIndex = 0;
rehState.clips = [];
rehState.playing = false;
buildScriptPage();
showPhase(3);
highlightCurrentLine();
});
// ── A4 Script page ─────────────────────────────────────────────────────────
function buildScriptPage() {
const page = $('reh-a4-page');
const titleEl = $('reh-page-title');
if (titleEl) titleEl.textContent = $('reh-script-title')?.value.trim() || 'Script';
// Build cast strip (transport bar)
const castStrip = $('reh-cast-strip');
if (castStrip) {
castStrip.innerHTML = Object.keys(rehState.cast).map(sp => {
const c = rehState.cast[sp];
const isMe = c.voice === 'me';
const imgHtml = voiceAvatarHtml(isMe ? '' : c.voice, c.color, 28);
return `<div class="reh-strip-char" data-speaker="${escHtml(sp)}" title="${escHtml(sp)}${isMe ? 'me' : c.voice}">
${imgHtml}
<span style="font-size:10px;font-weight:700;color:${c.color};max-width:56px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(sp)}</span>
${isMe ? '<span class="mdi mdi-microphone" style="font-size:10px;color:var(--subtext)"></span>' : ''}
</div>`;
}).join('');
}
// Build script lines
const linesEl = $('reh-script-lines');
if (!linesEl) return;
linesEl.innerHTML = rehState.lines.map((line, i) => {
if (line.isDirection) {
return `<div class="reh-block reh-direction" data-index="${i}"><em>${escHtml(line.text)}</em></div>`;
}
const c = rehState.cast[line.speaker] || { voice: 'me', color: '#89b4fa' };
const isMe = c.voice === 'me';
const avatarHtml = voiceAvatarHtml(isMe ? '' : c.voice, c.color, 32);
return `<div class="reh-block" data-index="${i}" data-speaker="${escHtml(line.speaker)}">
<div class="reh-block-head">
${avatarHtml}
<span class="reh-block-name" style="color:${c.color}">${escHtml(line.speaker)}</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>'}
</div>
<div class="reh-block-dialog">${escHtml(line.text)}</div>
</div>`;
}).join('');
}
function highlightCurrentLine() {
const i = rehState.lineIndex;
const total = rehState.lines.length;
// Update progress
const prog = $('reh-tb-progress');
if (prog) prog.style.width = (total ? (i / total) * 100 : 0) + '%';
const lbl = $('reh-tb-label');
if (lbl) lbl.textContent = `${i + 1} / ${total}`;
// Highlight in page
document.querySelectorAll('.reh-block').forEach(el => {
const idx = parseInt(el.dataset.index);
el.classList.toggle('reh-block-active', idx === i);
});
// Scroll active line into view
const active = document.querySelector(`.reh-block[data-index="${i}"]`);
if (active) active.scrollIntoView({ behavior: 'smooth', block: 'center' });
updatePlayBtn();
}
function updatePlayBtn() {
const btn = $('reh-tb-play');
if (!btn) return;
btn.innerHTML = rehState.playing
? '<span class="mdi mdi-pause"></span>'
: '<span class="mdi mdi-play"></span>';
btn.title = rehState.playing ? 'Pause' : 'Play all';
}
// ── Transport controls ──────────────────────────────────────────────────────
$('reh-tb-play')?.addEventListener('click', () => {
if (rehState.playing) {
pausePlay();
} else {
startPlay();
}
});
$('reh-tb-stop')?.addEventListener('click', () => {
stopPlay();
rehState.lineIndex = 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;
const btn = $('reh-tb-repeat');
if (btn) btn.classList.toggle('reh-btn-active', rehState.repeat);
});
$('reh-exit-btn')?.addEventListener('click', () => {
stopPlay();
stopRehMic();
if (rehState.clips.length) { renderSummary(); showPhase(4); }
else showPhase(2);
});
// ── Auto-play sequence ──────────────────────────────────────────────────────
function startPlay() {
rehState.playing = true;
updatePlayBtn();
playNextLine();
}
function pausePlay() {
rehState.playing = false;
updatePlayBtn();
const audio = $('reh-tts-audio');
if (audio && !audio.paused) audio.pause();
hideStatusBar();
}
function stopPlay() {
rehState.playing = false;
updatePlayBtn();
const audio = $('reh-tts-audio');
if (audio) { audio.pause(); audio.src = ''; }
hideStatusBar();
}
function hideStatusBar() {
const bar = $('reh-tts-status-bar');
if (bar) bar.hidden = true;
}
async function playNextLine() {
if (!rehState.playing) return;
if (rehState.lineIndex >= rehState.lines.length) {
rehState.playing = false;
updatePlayBtn();
if (rehState.repeat) { rehState.lineIndex = 0; startPlay(); return; }
toast('Script finished', 'success');
return;
}
const line = rehState.lines[rehState.lineIndex];
highlightCurrentLine();
if (line.isDirection) {
// Stage directions: brief pause then advance
await new Promise(r => setTimeout(r, 800));
if (!rehState.playing) return;
rehState.lineIndex++;
playNextLine();
return;
}
const cast = rehState.cast[line.speaker] || { voice: 'me' };
if (cast.voice === 'me') {
// My turn — pause auto-play and show recording panel
rehState.playing = false;
updatePlayBtn();
showRecOverlay(line);
return;
}
// TTS line
showStatusBar('Synthesizing…');
try {
const blob = await fetchTtsPreviewBlob(cast.voice, line.text, 'wav', '', rehState.backend);
if (!rehState.playing) return;
const url = URL.createObjectURL(blob);
const audio = $('reh-tts-audio');
if (audio) {
audio.src = url;
audio.style.display = '';
showStatusBar(line.speaker + ' is speaking…');
await audio.play().catch(() => {});
await waitForAudioEnd(audio);
}
rehState.clips.push({ lineIndex: rehState.lineIndex, speaker: line.speaker, type: 'tts', blob });
} catch(e) {
showStatusBar('TTS failed: ' + e.message);
await new Promise(r => setTimeout(r, 1200));
}
if (!rehState.playing) return;
rehState.lineIndex++;
playNextLine();
}
function waitForAudioEnd(audio) {
return new Promise(resolve => {
if (!audio || audio.paused || audio.ended) { resolve(); return; }
audio.addEventListener('ended', resolve, { once: true });
audio.addEventListener('pause', resolve, { once: true });
audio.addEventListener('error', resolve, { once: true });
});
}
function showStatusBar(msg) {
const bar = $('reh-tts-status-bar'); if (!bar) return;
bar.hidden = false;
const txt = $('reh-tts-status-txt'); if (txt) txt.textContent = msg;
}
// ── Recording overlay ───────────────────────────────────────────────────────
function showRecOverlay(line) {
const overlay = $('reh-rec-overlay'); if (!overlay) return;
overlay.hidden = false;
const cue = $('reh-rec-cue');
const 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(line.text)}</div>`;
// Reset recording UI
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);
const canvas = $('reh-live-wave');
const 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);
const 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(e){} });
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 line = rehState.lines[rehState.lineIndex];
if (line) showRecOverlay(line);
});
$('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(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;
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-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 = '';
});
// mark savedId on save so new-session resets it
$('reh-parse-btn')?.removeEventListener; // just guard
// Init
rehRenderMeter();
renderLibraryList().catch(() => {});