// ── Script Rehearser ────────────────────────────────────────────────────── // ── State ──────────────────────────────────────────────────────────────── const rehState = { lines: [], // [{speaker, text}] cast: {}, // {SPEAKER: {voice:'...' | 'me', color:'#...'}} lineIndex: 0, clips: [], // [{lineIndex, speaker, type:'tts'|'me'|'skip', blob?}] voices: [], // available TTS voices recStream: null, recAudioCtx: null, recAnalyser: null, recSourceNode: null, recGainNode: null, recDestStream: null, recMeterRaf: null, recWaveRing: null, mediaRec: null, recChunks: [], recTimer: null, recSecs: 0, phase: 1, }; window.rehState = rehState; const SPEAKER_COLORS = ['#89b4fa','#a6e3a1','#f38ba8','#fab387','#f9e2af','#cba6f7','#89dceb','#74c7ec']; // ── Script parsing ──────────────────────────────────────────────────────── 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 }); } dialogBuffer = []; } for (const rawLine of lines) { const line = rawLine.trim(); if (!line || line.startsWith('#')) continue; // Format 1: "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; } // Format 2: 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; } // Continuation of dialog if (currentSpeaker) dialogBuffer.push(line); } flush(); return result; } function detectCharacters(lines) { const speakers = [...new Set(lines.map(l => l.speaker))]; const cast = {}; speakers.forEach((sp, i) => { cast[sp] = { voice: 'me', color: SPEAKER_COLORS[i % SPEAKER_COLORS.length] }; }); return cast; } // ── Phase navigation ────────────────────────────────────────────────────── function showPhase(n) { rehState.phase = n; for (let i = 1; i <= 4; i++) { const el = $('reh-phase-' + i); if (el) el.hidden = i !== n; } } // ── Phase 1: Script input ───────────────────────────────────────────────── $('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.length) { toast('No dialog lines found. Check format: "CHARACTER: text" or screenplay.', 'error'); return; } rehState.cast = detectCharacters(rehState.lines); renderCastList(); showPhase(2); // Populate backend select refreshRehBackends(); }); // ── Phase 2: Cast assignment ────────────────────────────────────────────── 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]; const color = c.color; return `
${escHtml(sp)} ${lineCount(sp)} line${lineCount(sp) !== 1 ? 's' : ''}
`; }).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' : ''; }); }); list.querySelectorAll('.reh-voice-sel').forEach(sel => { sel.addEventListener('change', function () { rehState.cast[this.dataset.speaker].voice = this.value; }); }); } async function refreshRehBackends() { const sel = $('reh-backend-select'); if (!sel) return; const backends = typeof availableTtsBackends === 'function' ? availableTtsBackends() : []; sel.innerHTML = backends.length ? backends.map(b => ``).join('') : ''; } $('reh-fetch-voices-btn')?.addEventListener('click', async () => { const backend = $('reh-backend-select')?.value; if (!backend) { toast('Select a backend first', 'error'); return; } $('reh-fetch-voices-btn').disabled = true; try { const rawVoices = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json()); rehState.voices = Array.isArray(rawVoices) ? rawVoices.map(v => typeof v === 'string' ? v : (v.id || v.name || 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.lineIndex = 0; rehState.clips = []; rehState.backend = backend; showPhase(3); renderCurrentLine(); }); // ── Phase 3: Rehearsal loop ─────────────────────────────────────────────── function updateProgress() { const total = rehState.lines.length; const cur = rehState.lineIndex; const pct = total ? (cur / total) * 100 : 0; const bar = $('reh-progress-bar'); if (bar) bar.style.width = pct + '%'; const lbl = $('reh-progress-label'); if (lbl) lbl.textContent = `Line ${cur + 1} / ${total}`; } function renderCurrentLine() { const lines = rehState.lines; const i = rehState.lineIndex; if (i >= lines.length) { finishRehearsal(); return; } const line = lines[i]; const cast = rehState.cast[line.speaker] || { voice: 'me', color: '#89b4fa' }; const isMe = cast.voice === 'me'; $('reh-line-speaker').textContent = line.speaker; $('reh-line-speaker').style.color = cast.color; $('reh-line-text').textContent = line.text; const recPanel = $('reh-record-panel'); const ttsPanel = $('reh-tts-panel'); if (recPanel) recPanel.hidden = !isMe; if (ttsPanel) ttsPanel.hidden = isMe; updateProgress(); if (!isMe) { synthesizeLine(line.text, cast.voice, rehState.backend); } else { // Reset recording UI const recPreview = $('reh-rec-preview'); if (recPreview) { recPreview.style.display = 'none'; recPreview.src = ''; } const confirmRow = $('reh-rec-confirm-row'); if (confirmRow) confirmRow.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'; } } async function synthesizeLine(text, voice, backend) { const ttsAudio = $('reh-tts-audio'); const ttsStatus = $('reh-tts-status'); if (ttsStatus) ttsStatus.textContent = 'Synthesizing…'; if (ttsAudio) { ttsAudio.pause(); ttsAudio.src = ''; } try { const blob = await fetchTtsPreviewBlob(voice, text, 'wav', '', backend); const url = URL.createObjectURL(blob); if (ttsAudio) { ttsAudio.src = url; ttsAudio.style.display = ''; ttsAudio.play().catch(() => {}); } if (ttsStatus) ttsStatus.textContent = 'Playing…'; rehState.clips.push({ lineIndex: rehState.lineIndex, speaker: rehState.lines[rehState.lineIndex].speaker, type: 'tts', blob }); ttsAudio?.addEventListener('ended', () => { if (ttsStatus) ttsStatus.textContent = 'Done'; }, { once: true }); } catch(e) { if (ttsStatus) ttsStatus.textContent = 'Synthesis failed: ' + e.message; toast('TTS failed: ' + e.message, 'error'); } } $('reh-tts-next')?.addEventListener('click', () => { advanceLine(1); }); $('reh-tts-replay')?.addEventListener('click', () => { $('reh-tts-audio')?.play().catch(() => {}); }); $('reh-prev-line')?.addEventListener('click', () => { advanceLine(-1); }); $('reh-skip-line')?.addEventListener('click', () => { rehState.clips.push({ lineIndex: rehState.lineIndex, speaker: rehState.lines[rehState.lineIndex]?.speaker, type: 'skip' }); advanceLine(1); }); $('reh-stop-reh')?.addEventListener('click', () => { stopRehMic(); finishRehearsal(); }); function advanceLine(delta) { rehState.lineIndex = Math.max(0, Math.min(rehState.lines.length - 1, rehState.lineIndex + delta)); renderCurrentLine(); } function finishRehearsal() { stopRehMic(); renderSummary(); showPhase(4); } // ── Mic recording (my lines) ────────────────────────────────────────────── 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 preview = $('reh-rec-preview'); if (preview) { preview.src = url; preview.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', () => { const blob = rehState.lastRecBlob; if (blob) { rehState.clips.push({ lineIndex: rehState.lineIndex, speaker: rehState.lines[rehState.lineIndex]?.speaker, type: 'me', blob }); } stopRehMic(); advanceLine(1); }); $('reh-rec-redo')?.addEventListener('click', () => { stopRehMic(); renderCurrentLine(); // re-show the same line }); // ── Phase 4: Summary ────────────────────────────────────────────────────── function renderSummary() { const list = $('reh-summary-list'); if (!list) return; if (!rehState.clips.length) { list.innerHTML = '

No clips recorded in this session.

'; return; } list.innerHTML = rehState.clips.map((clip, idx) => { const line = rehState.lines[clip.lineIndex] || { text: '—', speaker: clip.speaker }; const typeLabel = clip.type === 'me' ? '🎤 Recorded' : clip.type === 'tts' ? '🔊 Synthesized' : '⏭ Skipped'; const color = rehState.cast[clip.speaker]?.color || '#89b4fa'; const audioHtml = clip.blob ? `` : ''; return `
${escHtml(clip.speaker)} ${typeLabel}
${escHtml(line.text)}
${audioHtml}
${clip.blob ? `` : ''}
`; }).join(''); } $('reh-new-session-btn')?.addEventListener('click', () => { stopRehMic(); rehState.lines = []; rehState.cast = {}; rehState.clips = []; rehState.lineIndex = 0; if ($('reh-script-text')) $('reh-script-text').value = ''; showPhase(1); }); $('reh-resume-btn')?.addEventListener('click', () => { showPhase(3); renderCurrentLine(); }); // Init rehRenderMeter();