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 ` `;
+ }
+ 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' : ''}
-
+ ${castAvatarHtml(sp)}
+
+ ${escHtml(sp)}
+ ${lineCount(sp)} line${lineCount(sp) !== 1 ? 's' : ''}
+
+
I play this
-
- — fetch voices —
+
+ — fetch voices first —
${rehState.voices.map(v => `${escHtml(v)} `).join('')}
`;
}).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 => `${escHtml(b.label)} `).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.
@@ -33,40 +37,57 @@
Step 2 — Cast your characters
-
Assign a TTS voice to each character, or mark a character as Me — those lines will be recorded from your microphone during rehearsal.
-
-
+
Assign a TTS voice to each character, or toggle I play this role — those lines will pause and wait for your microphone recording.
+
TTS backend for synthesis
Checking…
- Fetch voices
+ Fetch voices
+
Back
- Start rehearsal
+ Open rehearsal
-
+
-
-
Step 3 — Rehearse
-
-
Line 0 / 0
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Exit
+
-
-
+
+
+
+
+
+
+
Input level
@@ -75,48 +96,37 @@
-
-
Record my line
+
+ Record
Stop
0:00
- Skip
+ Skip
- Keep & next
+ Keep & continue
Re-record
-
-
-
-
Synthesizing…
-
-
- Next
- Replay
-
-
-
-
-
-
Prev
-
Stop rehearsal
-
-
-
-
+
+
+
+
+
Rehearsal complete
-
Your recorded lines and all synthesized lines from this session.
+
All recorded and synthesized clips from this session.
New script
- Resume rehearsal
+ Resume rehearsal
diff --git a/static/style.css b/static/style.css
index eb9d667..814dc6c 100644
--- a/static/style.css
+++ b/static/style.css
@@ -2303,21 +2303,88 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
/* ── Script Rehearser ───────────────────────────────────────────────────── */
.reh-phase[hidden] { display: none; }
+
+/* Phase 1 */
.reh-input-row { display: grid; grid-template-columns: 1fr 200px; gap: 14px; align-items: start; }
.reh-input-row textarea { min-height: 220px; font-family: monospace; font-size: 13px; width: 100%; resize: vertical; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 10px; color: var(--text); }
.reh-input-side { display: flex; flex-direction: column; gap: 8px; }
+
+/* Phase 2 cast */
.reh-cast-list { display: flex; flex-direction: column; gap: 8px; }
-.reh-cast-row { display: flex; align-items: center; gap: 10px; padding: 8px 10px; background: var(--panel); border-radius: 6px; flex-wrap: wrap; }
-.reh-cast-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
-.reh-cast-name { font-weight: 700; font-size: 14px; }
-.reh-me-toggle { margin-left: auto; }
-.reh-progress-bar-wrap { height: 6px; background: var(--border); border-radius: 3px; overflow: hidden; margin-bottom: 4px; }
-.reh-progress-bar { height: 100%; background: var(--accent); border-radius: 3px; transition: width .3s ease; width: 0%; }
-.reh-progress-label { font-size: 12px; color: var(--subtext); margin-bottom: 14px; }
-.reh-line-card { border: 2px solid var(--accent); border-radius: 8px; padding: 16px 20px; margin-bottom: 14px; background: rgba(137,180,250,.05); }
-.reh-line-speaker { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .1em; margin-bottom: 6px; }
-.reh-line-text { font-size: 18px; line-height: 1.5; color: var(--text); }
-.reh-tts-panel, .reh-record-panel { padding: 12px; background: var(--panel); border-radius: 6px; }
+.reh-cast-row { display: flex; align-items: center; gap: 10px; padding: 8px 12px; background: var(--panel); border-radius: 8px; flex-wrap: wrap; border: 1px solid var(--border); }
+
+/* Transport bar */
+.reh-transport-bar {
+ position: sticky; top: 0; z-index: 50;
+ background: var(--bg); border-bottom: 1px solid var(--border);
+ display: flex; align-items: center; gap: 12px; padding: 8px 16px;
+ flex-wrap: wrap;
+}
+.reh-cast-strip { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
+.reh-strip-char { display: flex; flex-direction: column; align-items: center; gap: 2px; cursor: default; }
+.reh-transport-btns { display: flex; gap: 4px; align-items: center; }
+.reh-tb-btn { width: 36px; height: 36px; border-radius: 50%; border: 1px solid var(--border); background: var(--panel); color: var(--text); cursor: pointer; font-size: 16px; display: flex; align-items: center; justify-content: center; transition: background .15s; }
+.reh-tb-btn:hover { background: var(--border); }
+.reh-tb-play { background: var(--accent); color: #fff; border-color: var(--accent); font-size: 18px; }
+.reh-tb-play:hover { opacity: .85; }
+.reh-btn-active { background: var(--accent) !important; color: #fff !important; border-color: var(--accent) !important; }
+.reh-transport-progress { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 80px; }
+.reh-tb-progress-wrap { flex: 1; height: 4px; background: var(--border); border-radius: 2px; overflow: hidden; }
+.reh-tb-progress { height: 100%; background: var(--accent); width: 0%; transition: width .3s ease; }
+.reh-tb-label { font-size: 12px; color: var(--subtext); white-space: nowrap; font-variant-numeric: tabular-nums; }
+
+/* A4 page */
+.reh-page-wrap { padding: 24px 16px; background: var(--panel); min-height: 60vh; }
+.reh-a4-page {
+ width: min(794px, 100%); min-height: 400px;
+ background: #fff; color: #1a1a2e;
+ padding: 56px 72px;
+ box-shadow: 0 4px 24px rgba(0,0,0,.18);
+ margin: 0 auto;
+ font-family: 'Georgia', 'Times New Roman', serif;
+ border-radius: 2px;
+}
+[data-theme="dark"] .reh-a4-page { background: #f8f8f2; color: #1a1a2e; }
+.reh-page-title { font-size: 22px; font-weight: 700; text-align: center; margin-bottom: 36px; letter-spacing: .04em; text-transform: uppercase; color: #1a1a2e; border-bottom: 2px solid #1a1a2e; padding-bottom: 12px; }
+.reh-script-lines { display: flex; flex-direction: column; gap: 0; }
+
+/* Each dialog block */
+.reh-block { padding: 10px 12px; border-left: 3px solid transparent; border-radius: 0 4px 4px 0; margin-bottom: 2px; transition: background .2s, border-color .2s; }
+.reh-block-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
+.reh-block-name { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .12em; }
+.reh-block-badge { font-size: 10px; padding: 1px 6px; border-radius: 10px; font-family: sans-serif; margin-left: 4px; }
+.reh-badge-tts { background: rgba(137,180,250,.2); color: #2563eb; }
+.reh-badge-me { background: rgba(166,227,161,.25); color: #16a34a; }
+.reh-block-dialog { font-size: 15px; line-height: 1.65; padding-left: 40px; color: #1a1a2e; }
+.reh-direction { font-style: italic; color: #666; font-size: 13px; padding: 4px 12px; }
+.reh-char-img { vertical-align: middle; }
+
+/* Active line highlight */
+.reh-block-active { background: rgba(37,99,235,.07); border-left-color: #2563eb; }
+.reh-block-active .reh-block-dialog { font-weight: 500; }
+
+/* TTS status bar */
+.reh-tts-status-bar { display: flex; align-items: center; gap: 12px; padding: 8px 16px; background: var(--panel); border-top: 1px solid var(--border); font-size: 13px; }
+.reh-tts-status-bar[hidden] { display: none; }
+
+/* Recording overlay */
+.reh-rec-overlay {
+ position: sticky; bottom: 0; z-index: 60;
+ background: var(--bg); border-top: 2px solid var(--green);
+ box-shadow: 0 -4px 20px rgba(0,0,0,.15);
+}
+.reh-rec-overlay[hidden] { display: none; }
+.reh-rec-overlay-inner { padding: 14px 20px; max-width: 860px; margin: 0 auto; }
+.reh-rec-cue { font-size: 14px; margin-bottom: 10px; line-height: 1.5; }
+.reh-rec-cue-name { font-weight: 700; font-size: 12px; text-transform: uppercase; letter-spacing: .1em; }
+.reh-rec-cue-text { font-size: 17px; margin-top: 4px; padding: 8px 12px; background: rgba(166,227,161,.1); border-left: 3px solid var(--green); border-radius: 0 4px 4px 0; font-style: italic; }
+
+/* Summary */
.reh-summary-list { display: flex; flex-direction: column; gap: 10px; }
.reh-summary-row { display: flex; align-items: flex-start; gap: 10px; padding: 10px; background: var(--panel); border-radius: 6px; }
-@media (max-width: 700px) { .reh-input-row { grid-template-columns: 1fr; } }
+
+@media (max-width: 700px) {
+ .reh-input-row { grid-template-columns: 1fr; }
+ .reh-a4-page { padding: 28px 20px; }
+ .reh-transport-bar { gap: 8px; }
+}