// ── 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 = '

No saved rehearsals yet.
Parse a script below to start a new one.

'; 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 `${initial}`; }).join(''); return `
${avatars}
${escHtml(rec.title || 'Untitled')}${isCurrent ? ' active' : ''}
${total} lines · ${meCount} me · ${clipCount} recorded · ${pct}% done · ${date}
`; }).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 `${escHtml(voiceId)}`; } const initial = (voiceId || '?')[0].toUpperCase(); return `${initial}`; } 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 ``; } const initial = sp[0].toUpperCase(); return `${initial}`; } 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 `
${castAvatarHtml(sp)}
${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' : ''; 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 => ``).join('') : ''; } $('reh-fetch-voices-btn')?.addEventListener('click', async () => { const backend = $('reh-backend-select')?.value; if (!backend) { toast('Select a backend first', 'error'); return; } $('reh-fetch-voices-btn').disabled = true; try { const raw = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json()); rehState.voices = Array.isArray(raw) ? raw.map(v => typeof v === 'string' ? v : (v.id || String(v))) : []; renderCastList(); 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 `
${imgHtml} ${escHtml(sp)} ${isMe ? '' : ''}
`; }).join(''); } // Build script lines const linesEl = $('reh-script-lines'); if (!linesEl) return; linesEl.innerHTML = rehState.lines.map((line, i) => { if (line.isDirection) { return `
${escHtml(line.text)}
`; } 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 `
${avatarHtml} ${escHtml(line.speaker)} ${isMe ? ' Me' : ' TTS'}
${escHtml(line.text)}
`; }).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 ? '' : ''; 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 = `${escHtml(line.speaker)} — your line:
${escHtml(line.text)}
`; // 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 = '

No clips in this session.

'; 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 ? `` : ''; const dlHtml = clip.blob ? `` : ''; return `
${escHtml(clip.speaker || '—')} ${typeLabel}
${escHtml(line.text)}
${audioHtml}
${dlHtml}
`; }).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(() => {});