From e8cb4d280af9f678188d280420ed69b37fabfd6c Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Sun, 31 May 2026 20:27:44 +0200 Subject: [PATCH] Script Rehearser: A4 script view, transport controls, character avatars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A4 paper page (white, serif font, shadow) renders the full script at once so you can read ahead while rehearsing - Each dialog block shows: character avatar (voice library photo or initial letter), character name in their colour, TTS/Me badge, and dialog text - Active line gets a blue left-border highlight and auto-scrolls into view - Transport bar (sticky, above the script): - Cast strip: all character avatars at a glance - โฎ Prev / โ–ถ Play all / โน Stop / โญ Next / ๐Ÿ” Repeat - Progress bar + line counter - Auto-play: TTS lines synthesize, play, auto-advance; 'me' lines pause and slide up a sticky recording overlay at the bottom of the page - Recording overlay shows the line to speak, oscilloscope + meter, Record / Stop / Keep & continue / Re-record / Skip - voice-library.js now exports window._voices so the rehearser can resolve voice IDs to has_picture flags and fetch /api/voice/picture/{id} Co-Authored-By: Claude Sonnet 4.6 --- static/js/rehearser.js | 555 ++++++++++++++++++++----------- static/js/voice-library.js | 1 + static/sections/s-rehearser.html | 104 +++--- static/style.css | 91 ++++- 4 files changed, 490 insertions(+), 261 deletions(-) diff --git a/static/js/rehearser.js b/static/js/rehearser.js index 4125b97..c56bd46 100644 --- a/static/js/rehearser.js +++ b/static/js/rehearser.js @@ -1,32 +1,42 @@ // โ”€โ”€ Script Rehearser โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -// โ”€โ”€ State โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - +// โ”€โ”€ State โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const rehState = { - lines: [], // [{speaker, text}] - cast: {}, // {SPEAKER: {voice:'...' | 'me', color:'#...'}} + lines: [], // [{speaker, text, isDirection}] + cast: {}, // {SPEAKER: {voice, color, voiceData}} 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, + clips: [], // [{lineIndex, speaker, type, blob?}] + voices: [], + backend: '', + playing: false, + repeat: false, + // 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; const SPEAKER_COLORS = ['#89b4fa','#a6e3a1','#f38ba8','#fab387','#f9e2af','#cba6f7','#89dceb','#74c7ec']; -// โ”€โ”€ Script parsing โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// โ”€โ”€ 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'); @@ -37,32 +47,27 @@ function parseScript(text) { function flush() { if (currentSpeaker && dialogBuffer.length) { const t = dialogBuffer.join(' ').trim(); - if (t) result.push({ speaker: currentSpeaker, text: t }); + if (t) result.push({ speaker: currentSpeaker, text: t, isDirection: false }); } dialogBuffer = []; } for (const rawLine of lines) { const line = rawLine.trim(); - if (!line || line.startsWith('#')) continue; - - // Format 1: "CHARACTER: dialog text" + 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; - } - - // 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 (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(); @@ -70,91 +75,91 @@ function parseScript(text) { } function detectCharacters(lines) { - const speakers = [...new Set(lines.map(l => l.speaker))]; + 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] }; + cast[sp] = { voice: 'me', color: SPEAKER_COLORS[i % SPEAKER_COLORS.length], voiceData: null }; }); return cast; } -// โ”€โ”€ Phase navigation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - +// โ”€โ”€ 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; - } + for (let i = 1; i <= 4; i++) { const el = $('reh-phase-' + i); if (el) el.hidden = i !== n; } } -// โ”€โ”€ Phase 1: Script input โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - +// โ”€โ”€ 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 = ''; + 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; } + 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); renderCastList(); showPhase(2); - // Populate backend select refreshRehBackends(); }); -// โ”€โ”€ Phase 2: Cast assignment โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// โ”€โ”€ 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 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; - }); - }); + 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 sel = $('reh-backend-select'); if (!sel) return; const backends = typeof availableTtsBackends === 'function' ? availableTtsBackends() : []; sel.innerHTML = backends.length ? backends.map(b => ``).join('') @@ -166,8 +171,8 @@ $('reh-fetch-voices-btn')?.addEventListener('click', async () => { 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))) : []; + 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'); } @@ -175,119 +180,270 @@ $('reh-fetch-voices-btn')?.addEventListener('click', async () => { }); $('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.backend = backend; + rehState.playing = false; + buildScriptPage(); showPhase(3); - renderCurrentLine(); + highlightCurrentLine(); }); -// โ”€โ”€ Phase 3: Rehearsal loop โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// โ”€โ”€ A4 Script page โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -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 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 renderCurrentLine() { - const lines = rehState.lines; +function highlightCurrentLine() { 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'; + 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}`; - $('reh-line-speaker').textContent = line.speaker; - $('reh-line-speaker').style.color = cast.color; - $('reh-line-text').textContent = line.text; + // Highlight in page + document.querySelectorAll('.reh-block').forEach(el => { + const idx = parseInt(el.dataset.index); + el.classList.toggle('reh-block-active', idx === i); + }); - const recPanel = $('reh-record-panel'); - const ttsPanel = $('reh-tts-panel'); - if (recPanel) recPanel.hidden = !isMe; - if (ttsPanel) ttsPanel.hidden = isMe; + // Scroll active line into view + const active = document.querySelector(`.reh-block[data-index="${i}"]`); + if (active) active.scrollIntoView({ behavior: 'smooth', block: 'center' }); - updateProgress(); + updatePlayBtn(); +} - if (!isMe) { - synthesizeLine(line.text, cast.voice, rehState.backend); +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 { - // 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'; + 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(); } -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 = ''; } +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(voice, text, 'wav', '', backend); + const blob = await fetchTtsPreviewBlob(cast.voice, line.text, 'wav', '', rehState.backend); + if (!rehState.playing) return; 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 }); + 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) { - if (ttsStatus) ttsStatus.textContent = 'Synthesis failed: ' + e.message; - toast('TTS failed: ' + e.message, 'error'); + showStatusBar('TTS failed: ' + e.message); + await new Promise(r => setTimeout(r, 1200)); } + + if (!rehState.playing) return; + rehState.lineIndex++; + playNextLine(); } -$('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', () => { +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(); - 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) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// โ”€โ”€ Mic recording โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function rehRenderMeter(level = 0, db = -Infinity, clipped = false) { - const meter = $('reh-mic-meter'); - if (!meter) return; + 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'; + 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'; + const el = $('reh-db-readout'); if (el) el.textContent = Number.isFinite(db) ? db.toFixed(1) + ' dB' : '-โˆž dB'; } function rehStartMeter() { @@ -310,11 +466,9 @@ function rehStartMeter() { 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; + 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); } + 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); @@ -338,9 +492,7 @@ async function startRehMic() { rehState.recGainNode.connect(dest); rehState.recDestStream = dest.stream; rehStartMeter(); - } else { - rehState.recDestStream = rehState.recStream; - } + } else { rehState.recDestStream = rehState.recStream; } } function stopRehMic() { @@ -350,7 +502,7 @@ function stopRehMic() { 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 }); + 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); } @@ -358,26 +510,24 @@ function stopRehMic() { $('reh-rec-start')?.addEventListener('click', async () => { try { await startRehMic(); - rehState.recChunks = []; - rehState.recSecs = 0; + 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'); + 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 = 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 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 = ''; } + 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; }; @@ -385,60 +535,61 @@ $('reh-rec-start')?.addEventListener('click', async () => { } catch(e) { stopRehMic(); toast(await microphoneErrorMessage(e), 'error'); } }); -$('reh-rec-stop')?.addEventListener('click', () => { - if (rehState.mediaRec?.state !== 'inactive') rehState.mediaRec.stop(); -}); +$('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 }); + if (rehState.lastRecBlob) { + rehState.clips.push({ lineIndex: rehState.lineIndex, speaker: rehState.lines[rehState.lineIndex]?.speaker, type: 'me', blob: rehState.lastRecBlob }); } stopRehMic(); - advanceLine(1); + hideRecOverlay(); + rehState.lineIndex++; + startPlay(); }); $('reh-rec-redo')?.addEventListener('click', () => { stopRehMic(); - renderCurrentLine(); // re-show the same line + const line = rehState.lines[rehState.lineIndex]; + if (line) showRecOverlay(line); }); -// โ”€โ”€ Phase 4: Summary โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +$('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 recorded in this session.

'; return; } + 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 color = rehState.cast[clip.speaker]?.color || '#89b4fa'; - const audioHtml = clip.blob - ? `` - : ''; + const audioHtml = clip.blob ? `` : ''; + const dlHtml = clip.blob ? `` : ''; return `
- +
-
${escHtml(clip.speaker)} ${typeLabel}
+
${escHtml(clip.speaker || 'โ€”')} ${typeLabel}
${escHtml(line.text)}
${audioHtml}
- ${clip.blob ? `` : ''} + ${dlHtml}
`; }).join(''); } $('reh-new-session-btn')?.addEventListener('click', () => { - stopRehMic(); - rehState.lines = []; rehState.cast = {}; rehState.clips = []; rehState.lineIndex = 0; + stopPlay(); 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(); -}); +$('reh-resume-btn')?.addEventListener('click', () => { showPhase(3); highlightCurrentLine(); }); // Init rehRenderMeter(); diff --git a/static/js/voice-library.js b/static/js/voice-library.js index 2f0689f..f774fac 100644 --- a/static/js/voice-library.js +++ b/static/js/voice-library.js @@ -460,6 +460,7 @@ async function loadVoiceLibrary() { const r = await fetch('/api/voices'); if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); } _voices = await r.json(); + window._voices = _voices; // expose for cross-module access (Script Rehearser etc.) if (typeof window.updateVoiceTree === 'function') window.updateVoiceTree(_voices); renderVoiceList(); updatePreviewVoiceMatchPanel(); diff --git a/static/sections/s-rehearser.html b/static/sections/s-rehearser.html index 5f4c3f6..24473bd 100644 --- a/static/sections/s-rehearser.html +++ b/static/sections/s-rehearser.html @@ -2,7 +2,7 @@

Script Rehearser

-

Upload a script, assign voices to characters, then synthesize the other parts while you record your own.

+

Upload a script, assign voices to characters, then rehearse with TTS synthesis and microphone recording.

@@ -12,7 +12,7 @@

Step 1 โ€” Paste or upload your script

-

Supported formats: CHARACTER: dialog on one line, or screenplay style (character name on its own ALL-CAPS line followed by dialog). Each speaker is detected automatically.

+

Format: CHARACTER: dialog on one line, or screenplay style (ALL-CAPS character name on its own line, dialog below). Lines starting with # are stage directions.

@@ -20,7 +20,11 @@ Upload .txt -

Format hints:
โ€ข CHARACTER: text
โ€ข All-caps name on own line + dialog below
โ€ข Lines starting with # are stage directions (skipped)

+
+ + +
+

Supported formats:
โ€ข CHARACTER: text
โ€ข All-caps name + dialog below
โ€ข # stage direction

@@ -33,40 +37,57 @@