## Script Rehearser (new feature)
- New section s-rehearser.html + rehearser.js + nav/loader wiring
- Phase 1: paste/upload script (.txt), auto-detect characters from
'CHARACTER: dialog' or ALL-CAPS screenplay format
- Phase 2: assign a TTS voice per character, or mark 'I play this'
- Phase 3: step-through rehearsal — synthesizes other characters via TTS,
shows level-meter + oscilloscope for your own lines, records them from mic
- Phase 4: session summary with per-line audio playback + download
## Connect Apps
- Removed duplicate standalone MCP/speak/hotkey full-width cards
- Kept the integration-grid cards (they use the real server URL from JS)
- Added Global Hotkey Daemon as a proper integration card with snippet-hotkey
populated by integrations.js (uses proxyBase URL dynamically)
## About page
- GET /api/changelog endpoint reads CHANGELOG.md and returns it as text
- Collapsible 'Changelog' <details> card fetches and displays it lazily
## Try It Out
- Reorganised into three cards: Voice & backend / Text to synthesize / Generate
- Backend help panel moved below the voice row (not in the same flex row)
- Style instruction field gains a dynamic badge ('style-aware ✓' / 'weak style')
and a yellow warning when a non-style-aware backend is selected while the
field is filled — wired to both backend-select change and input events
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
445 lines
18 KiB
JavaScript
445 lines
18 KiB
JavaScript
// ── Script Rehearser ──────────────────────────────────────────────────────
|
|
|
|
// ── State ────────────────────────────────────────────────────────────────
|
|
|
|
const rehState = {
|
|
lines: [], // [{speaker, text}]
|
|
cast: {}, // {SPEAKER: {voice:'...' | 'me', color:'#...'}}
|
|
lineIndex: 0,
|
|
clips: [], // [{lineIndex, speaker, type:'tts'|'me'|'skip', blob?}]
|
|
voices: [], // available TTS voices
|
|
recStream: null,
|
|
recAudioCtx: null,
|
|
recAnalyser: null,
|
|
recSourceNode: null,
|
|
recGainNode: null,
|
|
recDestStream: null,
|
|
recMeterRaf: null,
|
|
recWaveRing: null,
|
|
mediaRec: null,
|
|
recChunks: [],
|
|
recTimer: null,
|
|
recSecs: 0,
|
|
phase: 1,
|
|
};
|
|
window.rehState = rehState;
|
|
|
|
const SPEAKER_COLORS = ['#89b4fa','#a6e3a1','#f38ba8','#fab387','#f9e2af','#cba6f7','#89dceb','#74c7ec'];
|
|
|
|
// ── Script parsing ────────────────────────────────────────────────────────
|
|
|
|
function parseScript(text) {
|
|
const lines = text.split('\n');
|
|
const result = [];
|
|
let currentSpeaker = null;
|
|
let dialogBuffer = [];
|
|
|
|
function flush() {
|
|
if (currentSpeaker && dialogBuffer.length) {
|
|
const t = dialogBuffer.join(' ').trim();
|
|
if (t) result.push({ speaker: currentSpeaker, text: t });
|
|
}
|
|
dialogBuffer = [];
|
|
}
|
|
|
|
for (const rawLine of lines) {
|
|
const line = rawLine.trim();
|
|
if (!line || line.startsWith('#')) continue;
|
|
|
|
// Format 1: "CHARACTER: dialog text"
|
|
const colonMatch = line.match(/^([A-Z][A-Z0-9 _\-]{0,39}):\s+(.+)$/);
|
|
if (colonMatch) {
|
|
flush();
|
|
currentSpeaker = colonMatch[1].trim();
|
|
dialogBuffer = [colonMatch[2].trim()];
|
|
continue;
|
|
}
|
|
|
|
// Format 2: ALL-CAPS name on its own line (screenplay)
|
|
if (/^[A-Z][A-Z0-9 _\-]{0,39}$/.test(line) && line.length >= 2) {
|
|
flush();
|
|
currentSpeaker = line;
|
|
continue;
|
|
}
|
|
|
|
// Continuation of dialog
|
|
if (currentSpeaker) dialogBuffer.push(line);
|
|
}
|
|
flush();
|
|
return result;
|
|
}
|
|
|
|
function detectCharacters(lines) {
|
|
const speakers = [...new Set(lines.map(l => l.speaker))];
|
|
const cast = {};
|
|
speakers.forEach((sp, i) => {
|
|
cast[sp] = { voice: 'me', color: SPEAKER_COLORS[i % SPEAKER_COLORS.length] };
|
|
});
|
|
return cast;
|
|
}
|
|
|
|
// ── Phase navigation ──────────────────────────────────────────────────────
|
|
|
|
function showPhase(n) {
|
|
rehState.phase = n;
|
|
for (let i = 1; i <= 4; i++) {
|
|
const el = $('reh-phase-' + i);
|
|
if (el) el.hidden = i !== n;
|
|
}
|
|
}
|
|
|
|
// ── Phase 1: Script input ─────────────────────────────────────────────────
|
|
|
|
$('reh-file-input')?.addEventListener('change', function () {
|
|
const f = this.files?.[0];
|
|
if (!f) return;
|
|
const r = new FileReader();
|
|
r.onload = e => { $('reh-script-text').value = e.target.result; };
|
|
r.readAsText(f);
|
|
this.value = '';
|
|
});
|
|
|
|
$('reh-parse-btn')?.addEventListener('click', () => {
|
|
const text = $('reh-script-text')?.value.trim();
|
|
if (!text) { toast('Paste or upload a script first', 'error'); return; }
|
|
rehState.lines = parseScript(text);
|
|
if (!rehState.lines.length) { toast('No dialog lines found. Check format: "CHARACTER: text" or screenplay.', 'error'); return; }
|
|
rehState.cast = detectCharacters(rehState.lines);
|
|
renderCastList();
|
|
showPhase(2);
|
|
// Populate backend select
|
|
refreshRehBackends();
|
|
});
|
|
|
|
// ── Phase 2: Cast assignment ──────────────────────────────────────────────
|
|
|
|
function renderCastList() {
|
|
const list = $('reh-cast-list');
|
|
if (!list) return;
|
|
const speakers = Object.keys(rehState.cast);
|
|
const lineCount = sp => rehState.lines.filter(l => l.speaker === sp).length;
|
|
list.innerHTML = speakers.map(sp => {
|
|
const c = rehState.cast[sp];
|
|
const color = c.color;
|
|
return `<div class="reh-cast-row" data-speaker="${escHtml(sp)}">
|
|
<span class="reh-cast-dot" style="background:${color}"></span>
|
|
<strong class="reh-cast-name">${escHtml(sp)}</strong>
|
|
<span class="note">${lineCount(sp)} line${lineCount(sp) !== 1 ? 's' : ''}</span>
|
|
<label class="chunk-toggle-label reh-me-toggle" title="Record this character yourself">
|
|
<input type="checkbox" class="reh-me-check" data-speaker="${escHtml(sp)}" ${c.voice === 'me' ? 'checked' : ''}>
|
|
<span>I play this</span>
|
|
</label>
|
|
<select class="reh-voice-sel" data-speaker="${escHtml(sp)}" style="flex:1;min-width:140px;${c.voice === 'me' ? 'display:none' : ''}">
|
|
<option value="">— fetch voices —</option>
|
|
${rehState.voices.map(v => `<option value="${escHtml(v)}" ${v === c.voice ? 'selected' : ''}>${escHtml(v)}</option>`).join('')}
|
|
</select>
|
|
</div>`;
|
|
}).join('');
|
|
|
|
list.querySelectorAll('.reh-me-check').forEach(cb => {
|
|
cb.addEventListener('change', function () {
|
|
const sp = this.dataset.speaker;
|
|
const row = this.closest('.reh-cast-row');
|
|
const sel = row.querySelector('.reh-voice-sel');
|
|
rehState.cast[sp].voice = this.checked ? 'me' : (sel.value || '');
|
|
if (sel) sel.style.display = this.checked ? 'none' : '';
|
|
});
|
|
});
|
|
list.querySelectorAll('.reh-voice-sel').forEach(sel => {
|
|
sel.addEventListener('change', function () {
|
|
rehState.cast[this.dataset.speaker].voice = this.value;
|
|
});
|
|
});
|
|
}
|
|
|
|
async function refreshRehBackends() {
|
|
const sel = $('reh-backend-select');
|
|
if (!sel) return;
|
|
const backends = typeof availableTtsBackends === 'function' ? availableTtsBackends() : [];
|
|
sel.innerHTML = backends.length
|
|
? backends.map(b => `<option value="${escHtml(b.id)}">${escHtml(b.label)}</option>`).join('')
|
|
: '<option value="">No backend available</option>';
|
|
}
|
|
|
|
$('reh-fetch-voices-btn')?.addEventListener('click', async () => {
|
|
const backend = $('reh-backend-select')?.value;
|
|
if (!backend) { toast('Select a backend first', 'error'); return; }
|
|
$('reh-fetch-voices-btn').disabled = true;
|
|
try {
|
|
const rawVoices = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json());
|
|
rehState.voices = Array.isArray(rawVoices) ? rawVoices.map(v => typeof v === 'string' ? v : (v.id || v.name || String(v))) : [];
|
|
renderCastList();
|
|
toast('Fetched ' + rehState.voices.length + ' voices', 'success');
|
|
} catch(e) { toast('Fetch failed: ' + e.message, 'error'); }
|
|
finally { $('reh-fetch-voices-btn').disabled = false; }
|
|
});
|
|
|
|
$('reh-back-1-btn')?.addEventListener('click', () => showPhase(1));
|
|
|
|
$('reh-start-btn')?.addEventListener('click', () => {
|
|
const backend = $('reh-backend-select')?.value;
|
|
if (!backend) { toast('Select a backend first', 'error'); return; }
|
|
rehState.lineIndex = 0;
|
|
rehState.clips = [];
|
|
rehState.backend = backend;
|
|
showPhase(3);
|
|
renderCurrentLine();
|
|
});
|
|
|
|
// ── Phase 3: Rehearsal loop ───────────────────────────────────────────────
|
|
|
|
function updateProgress() {
|
|
const total = rehState.lines.length;
|
|
const cur = rehState.lineIndex;
|
|
const pct = total ? (cur / total) * 100 : 0;
|
|
const bar = $('reh-progress-bar');
|
|
if (bar) bar.style.width = pct + '%';
|
|
const lbl = $('reh-progress-label');
|
|
if (lbl) lbl.textContent = `Line ${cur + 1} / ${total}`;
|
|
}
|
|
|
|
function renderCurrentLine() {
|
|
const lines = rehState.lines;
|
|
const i = rehState.lineIndex;
|
|
if (i >= lines.length) { finishRehearsal(); return; }
|
|
const line = lines[i];
|
|
const cast = rehState.cast[line.speaker] || { voice: 'me', color: '#89b4fa' };
|
|
const isMe = cast.voice === 'me';
|
|
|
|
$('reh-line-speaker').textContent = line.speaker;
|
|
$('reh-line-speaker').style.color = cast.color;
|
|
$('reh-line-text').textContent = line.text;
|
|
|
|
const recPanel = $('reh-record-panel');
|
|
const ttsPanel = $('reh-tts-panel');
|
|
if (recPanel) recPanel.hidden = !isMe;
|
|
if (ttsPanel) ttsPanel.hidden = isMe;
|
|
|
|
updateProgress();
|
|
|
|
if (!isMe) {
|
|
synthesizeLine(line.text, cast.voice, rehState.backend);
|
|
} else {
|
|
// Reset recording UI
|
|
const recPreview = $('reh-rec-preview');
|
|
if (recPreview) { recPreview.style.display = 'none'; recPreview.src = ''; }
|
|
const confirmRow = $('reh-rec-confirm-row');
|
|
if (confirmRow) confirmRow.hidden = true;
|
|
if ($('reh-rec-start')) $('reh-rec-start').disabled = false;
|
|
if ($('reh-rec-stop')) $('reh-rec-stop').disabled = true;
|
|
if ($('reh-rec-time')) $('reh-rec-time').textContent = '0:00';
|
|
}
|
|
}
|
|
|
|
async function synthesizeLine(text, voice, backend) {
|
|
const ttsAudio = $('reh-tts-audio');
|
|
const ttsStatus = $('reh-tts-status');
|
|
if (ttsStatus) ttsStatus.textContent = 'Synthesizing…';
|
|
if (ttsAudio) { ttsAudio.pause(); ttsAudio.src = ''; }
|
|
try {
|
|
const blob = await fetchTtsPreviewBlob(voice, text, 'wav', '', backend);
|
|
const url = URL.createObjectURL(blob);
|
|
if (ttsAudio) { ttsAudio.src = url; ttsAudio.style.display = ''; ttsAudio.play().catch(() => {}); }
|
|
if (ttsStatus) ttsStatus.textContent = 'Playing…';
|
|
rehState.clips.push({ lineIndex: rehState.lineIndex, speaker: rehState.lines[rehState.lineIndex].speaker, type: 'tts', blob });
|
|
ttsAudio?.addEventListener('ended', () => { if (ttsStatus) ttsStatus.textContent = 'Done'; }, { once: true });
|
|
} catch(e) {
|
|
if (ttsStatus) ttsStatus.textContent = 'Synthesis failed: ' + e.message;
|
|
toast('TTS failed: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
$('reh-tts-next')?.addEventListener('click', () => { advanceLine(1); });
|
|
$('reh-tts-replay')?.addEventListener('click', () => { $('reh-tts-audio')?.play().catch(() => {}); });
|
|
$('reh-prev-line')?.addEventListener('click', () => { advanceLine(-1); });
|
|
$('reh-skip-line')?.addEventListener('click', () => {
|
|
rehState.clips.push({ lineIndex: rehState.lineIndex, speaker: rehState.lines[rehState.lineIndex]?.speaker, type: 'skip' });
|
|
advanceLine(1);
|
|
});
|
|
$('reh-stop-reh')?.addEventListener('click', () => {
|
|
stopRehMic();
|
|
finishRehearsal();
|
|
});
|
|
|
|
function advanceLine(delta) {
|
|
rehState.lineIndex = Math.max(0, Math.min(rehState.lines.length - 1, rehState.lineIndex + delta));
|
|
renderCurrentLine();
|
|
}
|
|
|
|
function finishRehearsal() {
|
|
stopRehMic();
|
|
renderSummary();
|
|
showPhase(4);
|
|
}
|
|
|
|
// ── Mic recording (my lines) ──────────────────────────────────────────────
|
|
|
|
function rehRenderMeter(level = 0, db = -Infinity, clipped = false) {
|
|
const meter = $('reh-mic-meter');
|
|
if (!meter) return;
|
|
if (!meter.children.length) {
|
|
for (let i = 0; i < 18; i++) { const b = document.createElement('div'); b.className = 'bar'; meter.appendChild(b); }
|
|
}
|
|
const active = Math.round(Math.max(0, Math.min(1, level)) * meter.children.length);
|
|
[...meter.children].forEach((bar, i) => {
|
|
bar.className = 'bar';
|
|
bar.style.height = (7 + Math.min(i, active) * 1.55) + 'px';
|
|
if (i < active) { bar.classList.add('on'); if (db > -12 && i > 11) bar.classList.add('hot'); if (clipped && i > 14) bar.classList.add('clip'); }
|
|
});
|
|
const el = $('reh-db-readout');
|
|
if (el) el.textContent = Number.isFinite(db) ? db.toFixed(1) + ' dB' : '-∞ dB';
|
|
}
|
|
|
|
function rehStartMeter() {
|
|
if (!rehState.recAnalyser) return;
|
|
if (rehState.recMeterRaf) cancelAnimationFrame(rehState.recMeterRaf);
|
|
const data = new Float32Array(rehState.recAnalyser.fftSize);
|
|
const canvas = $('reh-live-wave');
|
|
const RING = 300, ADD = 10;
|
|
rehState.recWaveRing = new Float32Array(RING);
|
|
const tick = () => {
|
|
rehState.recAnalyser.getFloatTimeDomainData(data);
|
|
let sum = 0, peak = 0;
|
|
for (const s of data) { sum += s * s; peak = Math.max(peak, Math.abs(s)); }
|
|
const rms = Math.sqrt(sum / data.length);
|
|
const db = rms > 0 ? 20 * Math.log10(rms) : -Infinity;
|
|
rehRenderMeter((db + 60) / 60, db, peak > 0.98);
|
|
if (canvas && rehState.recWaveRing) {
|
|
const ring = rehState.recWaveRing;
|
|
ring.copyWithin(0, ADD);
|
|
for (let i = 0; i < ADD; i++) ring[RING - ADD + i] = data[Math.floor(i * data.length / ADD)];
|
|
const ctx = canvas.getContext('2d'), w = canvas.width, h = canvas.height;
|
|
ctx.clearRect(0, 0, w, h);
|
|
ctx.beginPath();
|
|
ctx.strokeStyle = peak > 0.98 ? '#f38ba8' : db > -12 ? '#f9e2af' : '#a6e3a1';
|
|
ctx.lineWidth = 1.5;
|
|
const mid = h / 2;
|
|
for (let i = 0; i < RING; i++) { const x = (i / RING) * w, y = mid - ring[i] * mid * 0.85; i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }
|
|
ctx.stroke();
|
|
}
|
|
rehState.recMeterRaf = requestAnimationFrame(tick);
|
|
};
|
|
tick();
|
|
}
|
|
|
|
async function startRehMic() {
|
|
if (rehState.recDestStream) return;
|
|
const AudioCtx = window.AudioContext || window.webkitAudioContext;
|
|
rehState.recStream = await requestMicrophoneStream({ raw: true });
|
|
if (AudioCtx) {
|
|
rehState.recAudioCtx = new AudioCtx();
|
|
rehState.recSourceNode = rehState.recAudioCtx.createMediaStreamSource(rehState.recStream);
|
|
rehState.recGainNode = rehState.recAudioCtx.createGain();
|
|
rehState.recAnalyser = rehState.recAudioCtx.createAnalyser();
|
|
rehState.recAnalyser.fftSize = 1024;
|
|
const dest = rehState.recAudioCtx.createMediaStreamDestination();
|
|
rehState.recSourceNode.connect(rehState.recGainNode);
|
|
rehState.recGainNode.connect(rehState.recAnalyser);
|
|
rehState.recGainNode.connect(dest);
|
|
rehState.recDestStream = dest.stream;
|
|
rehStartMeter();
|
|
} else {
|
|
rehState.recDestStream = rehState.recStream;
|
|
}
|
|
}
|
|
|
|
function stopRehMic() {
|
|
if (rehState.recMeterRaf) cancelAnimationFrame(rehState.recMeterRaf);
|
|
rehState.recMeterRaf = null;
|
|
[rehState.recSourceNode, rehState.recGainNode, rehState.recAnalyser].forEach(n => { try { if(n) n.disconnect(); } catch(e){} });
|
|
if (rehState.recStream) rehState.recStream.getTracks().forEach(t => t.stop());
|
|
if (rehState.recDestStream) rehState.recDestStream.getTracks().forEach(t => t.stop());
|
|
if (rehState.recAudioCtx) rehState.recAudioCtx.close().catch(() => {});
|
|
Object.assign(rehState, { recStream: null, recDestStream: null, recSourceNode: null, recGainNode: null, recAnalyser: null, recAudioCtx: null, recWaveRing: null });
|
|
rehRenderMeter();
|
|
const wc = $('reh-live-wave'); if (wc) wc.getContext('2d').clearRect(0, 0, wc.width, wc.height);
|
|
}
|
|
|
|
$('reh-rec-start')?.addEventListener('click', async () => {
|
|
try {
|
|
await startRehMic();
|
|
rehState.recChunks = [];
|
|
rehState.recSecs = 0;
|
|
if ($('reh-rec-time')) $('reh-rec-time').textContent = '0:00';
|
|
if ($('reh-rec-start')) $('reh-rec-start').disabled = true;
|
|
if ($('reh-rec-stop')) $('reh-rec-stop').disabled = false;
|
|
if ($('reh-rec-confirm-row')) $('reh-rec-confirm-row').hidden = true;
|
|
rehState.recTimer = setInterval(() => {
|
|
rehState.recSecs++;
|
|
if ($('reh-rec-time')) $('reh-rec-time').textContent = Math.floor(rehState.recSecs / 60) + ':' + String(rehState.recSecs % 60).padStart(2, '0');
|
|
}, 1000);
|
|
rehState.mediaRec = new MediaRecorder(rehState.recDestStream || rehState.recStream, { audioBitsPerSecond: 256000 });
|
|
rehState.mediaRec.ondataavailable = e => { if (e.data.size) rehState.recChunks.push(e.data); };
|
|
rehState.mediaRec.onstop = () => {
|
|
clearInterval(rehState.recTimer);
|
|
if ($('reh-rec-start')) $('reh-rec-start').disabled = false;
|
|
if ($('reh-rec-stop')) $('reh-rec-stop').disabled = true;
|
|
const blob = new Blob(rehState.recChunks, { type: rehState.mediaRec.mimeType || 'audio/webm' });
|
|
const url = URL.createObjectURL(blob);
|
|
const preview = $('reh-rec-preview');
|
|
if (preview) { preview.src = url; preview.style.display = ''; }
|
|
if ($('reh-rec-confirm-row')) $('reh-rec-confirm-row').hidden = false;
|
|
rehState.lastRecBlob = blob;
|
|
};
|
|
rehState.mediaRec.start(100);
|
|
} catch(e) { stopRehMic(); toast(await microphoneErrorMessage(e), 'error'); }
|
|
});
|
|
|
|
$('reh-rec-stop')?.addEventListener('click', () => {
|
|
if (rehState.mediaRec?.state !== 'inactive') rehState.mediaRec.stop();
|
|
});
|
|
|
|
$('reh-rec-keep')?.addEventListener('click', () => {
|
|
const blob = rehState.lastRecBlob;
|
|
if (blob) {
|
|
rehState.clips.push({ lineIndex: rehState.lineIndex, speaker: rehState.lines[rehState.lineIndex]?.speaker, type: 'me', blob });
|
|
}
|
|
stopRehMic();
|
|
advanceLine(1);
|
|
});
|
|
|
|
$('reh-rec-redo')?.addEventListener('click', () => {
|
|
stopRehMic();
|
|
renderCurrentLine(); // re-show the same line
|
|
});
|
|
|
|
// ── Phase 4: Summary ──────────────────────────────────────────────────────
|
|
|
|
function renderSummary() {
|
|
const list = $('reh-summary-list');
|
|
if (!list) return;
|
|
if (!rehState.clips.length) { list.innerHTML = '<p class="note">No clips recorded in this session.</p>'; return; }
|
|
list.innerHTML = rehState.clips.map((clip, idx) => {
|
|
const line = rehState.lines[clip.lineIndex] || { text: '—', speaker: clip.speaker };
|
|
const typeLabel = clip.type === 'me' ? '🎤 Recorded' : clip.type === 'tts' ? '🔊 Synthesized' : '⏭ Skipped';
|
|
const color = rehState.cast[clip.speaker]?.color || '#89b4fa';
|
|
const audioHtml = clip.blob
|
|
? `<audio controls src="${URL.createObjectURL(clip.blob)}" style="width:100%;max-width:320px"></audio>`
|
|
: '';
|
|
return `<div class="reh-summary-row">
|
|
<span class="reh-cast-dot" style="background:${color}"></span>
|
|
<div style="flex:1;min-width:0">
|
|
<div style="font-weight:600;font-size:13px;color:${color}">${escHtml(clip.speaker)} <span style="font-weight:400;color:var(--subtext)">${typeLabel}</span></div>
|
|
<div style="font-size:13px;line-height:1.4;margin-top:2px">${escHtml(line.text)}</div>
|
|
${audioHtml}
|
|
</div>
|
|
${clip.blob ? `<a class="btn-secondary" style="text-decoration:none" download="line_${idx + 1}_${clip.speaker}.webm" href="${URL.createObjectURL(clip.blob)}"><span class="mdi mdi-download"></span></a>` : ''}
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
$('reh-new-session-btn')?.addEventListener('click', () => {
|
|
stopRehMic();
|
|
rehState.lines = []; rehState.cast = {}; rehState.clips = []; rehState.lineIndex = 0;
|
|
if ($('reh-script-text')) $('reh-script-text').value = '';
|
|
showPhase(1);
|
|
});
|
|
|
|
$('reh-resume-btn')?.addEventListener('click', () => {
|
|
showPhase(3);
|
|
renderCurrentLine();
|
|
});
|
|
|
|
// Init
|
|
rehRenderMeter();
|