Script Rehearser: A4 script view, transport controls, character avatars
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
d99395480a
commit
e8cb4d280a
@ -1,32 +1,42 @@
|
|||||||
// ── Script Rehearser ──────────────────────────────────────────────────────
|
// ── Script Rehearser ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
// ── State ────────────────────────────────────────────────────────────────
|
// ── State ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const rehState = {
|
const rehState = {
|
||||||
lines: [], // [{speaker, text}]
|
lines: [], // [{speaker, text, isDirection}]
|
||||||
cast: {}, // {SPEAKER: {voice:'...' | 'me', color:'#...'}}
|
cast: {}, // {SPEAKER: {voice, color, voiceData}}
|
||||||
lineIndex: 0,
|
lineIndex: 0,
|
||||||
clips: [], // [{lineIndex, speaker, type:'tts'|'me'|'skip', blob?}]
|
clips: [], // [{lineIndex, speaker, type, blob?}]
|
||||||
voices: [], // available TTS voices
|
voices: [],
|
||||||
recStream: null,
|
backend: '',
|
||||||
recAudioCtx: null,
|
playing: false,
|
||||||
recAnalyser: null,
|
repeat: false,
|
||||||
recSourceNode: null,
|
// mic
|
||||||
recGainNode: null,
|
recStream: null, recAudioCtx: null, recAnalyser: null,
|
||||||
recDestStream: null,
|
recSourceNode: null, recGainNode: null, recDestStream: null,
|
||||||
recMeterRaf: null,
|
recMeterRaf: null, recWaveRing: null, mediaRec: null,
|
||||||
recWaveRing: null,
|
recChunks: [], recTimer: null, recSecs: 0, lastRecBlob: null,
|
||||||
mediaRec: null,
|
|
||||||
recChunks: [],
|
|
||||||
recTimer: null,
|
|
||||||
recSecs: 0,
|
|
||||||
phase: 1,
|
|
||||||
};
|
};
|
||||||
window.rehState = rehState;
|
window.rehState = rehState;
|
||||||
|
|
||||||
const SPEAKER_COLORS = ['#89b4fa','#a6e3a1','#f38ba8','#fab387','#f9e2af','#cba6f7','#89dceb','#74c7ec'];
|
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 `<img src="/api/voice/picture/${encodeURIComponent(voiceId)}" class="reh-char-img" style="width:${s};height:${s};border-radius:${radius}px;object-fit:cover;flex-shrink:0" alt="${escHtml(voiceId)}">`;
|
||||||
|
}
|
||||||
|
const initial = (voiceId || '?')[0].toUpperCase();
|
||||||
|
return `<span class="reh-char-img" style="width:${s};height:${s};border-radius:${radius}px;background:${color};color:#fff;font-weight:700;font-size:${Math.round(size * 0.45)}px;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0">${initial}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
function parseScript(text) {
|
function parseScript(text) {
|
||||||
const lines = text.split('\n');
|
const lines = text.split('\n');
|
||||||
@ -37,32 +47,27 @@ function parseScript(text) {
|
|||||||
function flush() {
|
function flush() {
|
||||||
if (currentSpeaker && dialogBuffer.length) {
|
if (currentSpeaker && dialogBuffer.length) {
|
||||||
const t = dialogBuffer.join(' ').trim();
|
const t = dialogBuffer.join(' ').trim();
|
||||||
if (t) result.push({ speaker: currentSpeaker, text: t });
|
if (t) result.push({ speaker: currentSpeaker, text: t, isDirection: false });
|
||||||
}
|
}
|
||||||
dialogBuffer = [];
|
dialogBuffer = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const rawLine of lines) {
|
for (const rawLine of lines) {
|
||||||
const line = rawLine.trim();
|
const line = rawLine.trim();
|
||||||
if (!line || line.startsWith('#')) continue;
|
if (!line) continue;
|
||||||
|
if (line.startsWith('#')) {
|
||||||
// Format 1: "CHARACTER: dialog text"
|
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+(.+)$/);
|
const colonMatch = line.match(/^([A-Z][A-Z0-9 _\-]{0,39}):\s+(.+)$/);
|
||||||
if (colonMatch) {
|
if (colonMatch) { flush(); currentSpeaker = colonMatch[1].trim(); dialogBuffer = [colonMatch[2].trim()]; continue; }
|
||||||
flush();
|
// ALL-CAPS name on its own line (screenplay)
|
||||||
currentSpeaker = colonMatch[1].trim();
|
if (/^[A-Z][A-Z0-9 _\-]{0,39}$/.test(line) && line.length >= 2) { flush(); currentSpeaker = line; continue; }
|
||||||
dialogBuffer = [colonMatch[2].trim()];
|
// parenthetical direction "(quietly)"
|
||||||
continue;
|
if (/^\(.*\)$/.test(line)) { result.push({ speaker: currentSpeaker || '', text: line, isDirection: true }); 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);
|
if (currentSpeaker) dialogBuffer.push(line);
|
||||||
}
|
}
|
||||||
flush();
|
flush();
|
||||||
@ -70,91 +75,91 @@ function parseScript(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function detectCharacters(lines) {
|
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 = {};
|
const cast = {};
|
||||||
speakers.forEach((sp, i) => {
|
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;
|
return cast;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Phase navigation ──────────────────────────────────────────────────────
|
// ── Phase navigation ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
function showPhase(n) {
|
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 () {
|
$('reh-file-input')?.addEventListener('change', function () {
|
||||||
const f = this.files?.[0];
|
const f = this.files?.[0]; if (!f) return;
|
||||||
if (!f) return;
|
const r = new FileReader(); r.onload = e => { $('reh-script-text').value = e.target.result; };
|
||||||
const r = new FileReader();
|
r.readAsText(f); this.value = '';
|
||||||
r.onload = e => { $('reh-script-text').value = e.target.result; };
|
|
||||||
r.readAsText(f);
|
|
||||||
this.value = '';
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$('reh-parse-btn')?.addEventListener('click', () => {
|
$('reh-parse-btn')?.addEventListener('click', () => {
|
||||||
const text = $('reh-script-text')?.value.trim();
|
const text = $('reh-script-text')?.value.trim();
|
||||||
if (!text) { toast('Paste or upload a script first', 'error'); return; }
|
if (!text) { toast('Paste or upload a script first', 'error'); return; }
|
||||||
rehState.lines = parseScript(text);
|
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);
|
rehState.cast = detectCharacters(rehState.lines);
|
||||||
renderCastList();
|
renderCastList();
|
||||||
showPhase(2);
|
showPhase(2);
|
||||||
// Populate backend select
|
|
||||||
refreshRehBackends();
|
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 `<img src="/api/voice/picture/${encodeURIComponent(c.voice)}" style="width:36px;height:36px;border-radius:50%;object-fit:cover;border:2px solid ${c.color}" alt="">`;
|
||||||
|
}
|
||||||
|
const initial = sp[0].toUpperCase();
|
||||||
|
return `<span style="width:36px;height:36px;border-radius:50%;background:${c.color};color:#fff;font-weight:700;font-size:16px;display:inline-flex;align-items:center;justify-content:center;border:2px solid ${c.color}">${initial}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
function renderCastList() {
|
function renderCastList() {
|
||||||
const list = $('reh-cast-list');
|
const list = $('reh-cast-list'); if (!list) return;
|
||||||
if (!list) return;
|
|
||||||
const speakers = Object.keys(rehState.cast);
|
const speakers = Object.keys(rehState.cast);
|
||||||
const lineCount = sp => rehState.lines.filter(l => l.speaker === sp).length;
|
const lineCount = sp => rehState.lines.filter(l => l.speaker === sp).length;
|
||||||
list.innerHTML = speakers.map(sp => {
|
list.innerHTML = speakers.map(sp => {
|
||||||
const c = rehState.cast[sp];
|
const c = rehState.cast[sp];
|
||||||
const color = c.color;
|
|
||||||
return `<div class="reh-cast-row" data-speaker="${escHtml(sp)}">
|
return `<div class="reh-cast-row" data-speaker="${escHtml(sp)}">
|
||||||
<span class="reh-cast-dot" style="background:${color}"></span>
|
<div style="flex-shrink:0">${castAvatarHtml(sp)}</div>
|
||||||
<strong class="reh-cast-name">${escHtml(sp)}</strong>
|
<div style="display:flex;flex-direction:column;gap:2px;flex:0 0 auto">
|
||||||
<span class="note">${lineCount(sp)} line${lineCount(sp) !== 1 ? 's' : ''}</span>
|
<strong style="color:${c.color}">${escHtml(sp)}</strong>
|
||||||
<label class="chunk-toggle-label reh-me-toggle" title="Record this character yourself">
|
<span class="note">${lineCount(sp)} line${lineCount(sp) !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
<label class="chunk-toggle-label" title="Record this character yourself" style="margin-left:4px">
|
||||||
<input type="checkbox" class="reh-me-check" data-speaker="${escHtml(sp)}" ${c.voice === 'me' ? 'checked' : ''}>
|
<input type="checkbox" class="reh-me-check" data-speaker="${escHtml(sp)}" ${c.voice === 'me' ? 'checked' : ''}>
|
||||||
<span>I play this</span>
|
<span>I play this</span>
|
||||||
</label>
|
</label>
|
||||||
<select class="reh-voice-sel" data-speaker="${escHtml(sp)}" style="flex:1;min-width:140px;${c.voice === 'me' ? 'display:none' : ''}">
|
<select class="reh-voice-sel" data-speaker="${escHtml(sp)}" style="flex:1;min-width:160px;${c.voice === 'me' ? 'display:none' : ''}">
|
||||||
<option value="">— fetch voices —</option>
|
<option value="">— fetch voices first —</option>
|
||||||
${rehState.voices.map(v => `<option value="${escHtml(v)}" ${v === c.voice ? 'selected' : ''}>${escHtml(v)}</option>`).join('')}
|
${rehState.voices.map(v => `<option value="${escHtml(v)}" ${v === c.voice ? 'selected' : ''}>${escHtml(v)}</option>`).join('')}
|
||||||
</select>
|
</select>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
list.querySelectorAll('.reh-me-check').forEach(cb => {
|
list.querySelectorAll('.reh-me-check').forEach(cb => cb.addEventListener('change', function () {
|
||||||
cb.addEventListener('change', function () {
|
const sp = this.dataset.speaker;
|
||||||
const sp = this.dataset.speaker;
|
const row = this.closest('.reh-cast-row');
|
||||||
const row = this.closest('.reh-cast-row');
|
const sel = row.querySelector('.reh-voice-sel');
|
||||||
const sel = row.querySelector('.reh-voice-sel');
|
rehState.cast[sp].voice = this.checked ? 'me' : (sel.value || '');
|
||||||
rehState.cast[sp].voice = this.checked ? 'me' : (sel.value || '');
|
if (sel) sel.style.display = this.checked ? 'none' : '';
|
||||||
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 () {
|
||||||
list.querySelectorAll('.reh-voice-sel').forEach(sel => {
|
rehState.cast[this.dataset.speaker].voice = this.value;
|
||||||
sel.addEventListener('change', function () {
|
rehState.cast[this.dataset.speaker].voiceData = getVoiceData(this.value);
|
||||||
rehState.cast[this.dataset.speaker].voice = this.value;
|
renderCastList();
|
||||||
});
|
}));
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshRehBackends() {
|
async function refreshRehBackends() {
|
||||||
const sel = $('reh-backend-select');
|
const sel = $('reh-backend-select'); if (!sel) return;
|
||||||
if (!sel) return;
|
|
||||||
const backends = typeof availableTtsBackends === 'function' ? availableTtsBackends() : [];
|
const backends = typeof availableTtsBackends === 'function' ? availableTtsBackends() : [];
|
||||||
sel.innerHTML = backends.length
|
sel.innerHTML = backends.length
|
||||||
? backends.map(b => `<option value="${escHtml(b.id)}">${escHtml(b.label)}</option>`).join('')
|
? backends.map(b => `<option value="${escHtml(b.id)}">${escHtml(b.label)}</option>`).join('')
|
||||||
@ -166,8 +171,8 @@ $('reh-fetch-voices-btn')?.addEventListener('click', async () => {
|
|||||||
if (!backend) { toast('Select a backend first', 'error'); return; }
|
if (!backend) { toast('Select a backend first', 'error'); return; }
|
||||||
$('reh-fetch-voices-btn').disabled = true;
|
$('reh-fetch-voices-btn').disabled = true;
|
||||||
try {
|
try {
|
||||||
const rawVoices = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json());
|
const raw = 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))) : [];
|
rehState.voices = Array.isArray(raw) ? raw.map(v => typeof v === 'string' ? v : (v.id || String(v))) : [];
|
||||||
renderCastList();
|
renderCastList();
|
||||||
toast('Fetched ' + rehState.voices.length + ' voices', 'success');
|
toast('Fetched ' + rehState.voices.length + ' voices', 'success');
|
||||||
} catch(e) { toast('Fetch failed: ' + e.message, 'error'); }
|
} 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-back-1-btn')?.addEventListener('click', () => showPhase(1));
|
||||||
|
|
||||||
$('reh-start-btn')?.addEventListener('click', () => {
|
$('reh-start-btn')?.addEventListener('click', () => {
|
||||||
const backend = $('reh-backend-select')?.value;
|
const backend = $('reh-backend-select')?.value;
|
||||||
if (!backend) { toast('Select a backend first', 'error'); return; }
|
if (!backend) { toast('Select a backend first', 'error'); return; }
|
||||||
|
rehState.backend = backend;
|
||||||
rehState.lineIndex = 0;
|
rehState.lineIndex = 0;
|
||||||
rehState.clips = [];
|
rehState.clips = [];
|
||||||
rehState.backend = backend;
|
rehState.playing = false;
|
||||||
|
buildScriptPage();
|
||||||
showPhase(3);
|
showPhase(3);
|
||||||
renderCurrentLine();
|
highlightCurrentLine();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Phase 3: Rehearsal loop ───────────────────────────────────────────────
|
// ── A4 Script page ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function updateProgress() {
|
function buildScriptPage() {
|
||||||
const total = rehState.lines.length;
|
const page = $('reh-a4-page');
|
||||||
const cur = rehState.lineIndex;
|
const titleEl = $('reh-page-title');
|
||||||
const pct = total ? (cur / total) * 100 : 0;
|
if (titleEl) titleEl.textContent = $('reh-script-title')?.value.trim() || 'Script';
|
||||||
const bar = $('reh-progress-bar');
|
|
||||||
if (bar) bar.style.width = pct + '%';
|
// Build cast strip (transport bar)
|
||||||
const lbl = $('reh-progress-label');
|
const castStrip = $('reh-cast-strip');
|
||||||
if (lbl) lbl.textContent = `Line ${cur + 1} / ${total}`;
|
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 `<div class="reh-strip-char" data-speaker="${escHtml(sp)}" title="${escHtml(sp)} — ${isMe ? 'me' : c.voice}">
|
||||||
|
${imgHtml}
|
||||||
|
<span style="font-size:10px;font-weight:700;color:${c.color};max-width:56px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(sp)}</span>
|
||||||
|
${isMe ? '<span class="mdi mdi-microphone" style="font-size:10px;color:var(--subtext)"></span>' : ''}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build script lines
|
||||||
|
const linesEl = $('reh-script-lines');
|
||||||
|
if (!linesEl) return;
|
||||||
|
linesEl.innerHTML = rehState.lines.map((line, i) => {
|
||||||
|
if (line.isDirection) {
|
||||||
|
return `<div class="reh-block reh-direction" data-index="${i}"><em>${escHtml(line.text)}</em></div>`;
|
||||||
|
}
|
||||||
|
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 `<div class="reh-block" data-index="${i}" data-speaker="${escHtml(line.speaker)}">
|
||||||
|
<div class="reh-block-head">
|
||||||
|
${avatarHtml}
|
||||||
|
<span class="reh-block-name" style="color:${c.color}">${escHtml(line.speaker)}</span>
|
||||||
|
${isMe ? '<span class="reh-block-badge reh-badge-me"><span class="mdi mdi-microphone"></span> Me</span>' : '<span class="reh-block-badge reh-badge-tts"><span class="mdi mdi-speaker-outline"></span> TTS</span>'}
|
||||||
|
</div>
|
||||||
|
<div class="reh-block-dialog">${escHtml(line.text)}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCurrentLine() {
|
function highlightCurrentLine() {
|
||||||
const lines = rehState.lines;
|
|
||||||
const i = rehState.lineIndex;
|
const i = rehState.lineIndex;
|
||||||
if (i >= lines.length) { finishRehearsal(); return; }
|
const total = rehState.lines.length;
|
||||||
const line = lines[i];
|
// Update progress
|
||||||
const cast = rehState.cast[line.speaker] || { voice: 'me', color: '#89b4fa' };
|
const prog = $('reh-tb-progress');
|
||||||
const isMe = cast.voice === 'me';
|
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;
|
// Highlight in page
|
||||||
$('reh-line-speaker').style.color = cast.color;
|
document.querySelectorAll('.reh-block').forEach(el => {
|
||||||
$('reh-line-text').textContent = line.text;
|
const idx = parseInt(el.dataset.index);
|
||||||
|
el.classList.toggle('reh-block-active', idx === i);
|
||||||
|
});
|
||||||
|
|
||||||
const recPanel = $('reh-record-panel');
|
// Scroll active line into view
|
||||||
const ttsPanel = $('reh-tts-panel');
|
const active = document.querySelector(`.reh-block[data-index="${i}"]`);
|
||||||
if (recPanel) recPanel.hidden = !isMe;
|
if (active) active.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
if (ttsPanel) ttsPanel.hidden = isMe;
|
|
||||||
|
|
||||||
updateProgress();
|
updatePlayBtn();
|
||||||
|
}
|
||||||
|
|
||||||
if (!isMe) {
|
function updatePlayBtn() {
|
||||||
synthesizeLine(line.text, cast.voice, rehState.backend);
|
const btn = $('reh-tb-play');
|
||||||
|
if (!btn) return;
|
||||||
|
btn.innerHTML = rehState.playing
|
||||||
|
? '<span class="mdi mdi-pause"></span>'
|
||||||
|
: '<span class="mdi mdi-play"></span>';
|
||||||
|
btn.title = rehState.playing ? 'Pause' : 'Play all';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Transport controls ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
$('reh-tb-play')?.addEventListener('click', () => {
|
||||||
|
if (rehState.playing) {
|
||||||
|
pausePlay();
|
||||||
} else {
|
} else {
|
||||||
// Reset recording UI
|
startPlay();
|
||||||
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';
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('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) {
|
function pausePlay() {
|
||||||
const ttsAudio = $('reh-tts-audio');
|
rehState.playing = false;
|
||||||
const ttsStatus = $('reh-tts-status');
|
updatePlayBtn();
|
||||||
if (ttsStatus) ttsStatus.textContent = 'Synthesizing…';
|
const audio = $('reh-tts-audio');
|
||||||
if (ttsAudio) { ttsAudio.pause(); ttsAudio.src = ''; }
|
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 {
|
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);
|
const url = URL.createObjectURL(blob);
|
||||||
if (ttsAudio) { ttsAudio.src = url; ttsAudio.style.display = ''; ttsAudio.play().catch(() => {}); }
|
const audio = $('reh-tts-audio');
|
||||||
if (ttsStatus) ttsStatus.textContent = 'Playing…';
|
if (audio) {
|
||||||
rehState.clips.push({ lineIndex: rehState.lineIndex, speaker: rehState.lines[rehState.lineIndex].speaker, type: 'tts', blob });
|
audio.src = url;
|
||||||
ttsAudio?.addEventListener('ended', () => { if (ttsStatus) ttsStatus.textContent = 'Done'; }, { once: true });
|
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) {
|
} catch(e) {
|
||||||
if (ttsStatus) ttsStatus.textContent = 'Synthesis failed: ' + e.message;
|
showStatusBar('TTS failed: ' + e.message);
|
||||||
toast('TTS failed: ' + e.message, 'error');
|
await new Promise(r => setTimeout(r, 1200));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!rehState.playing) return;
|
||||||
|
rehState.lineIndex++;
|
||||||
|
playNextLine();
|
||||||
}
|
}
|
||||||
|
|
||||||
$('reh-tts-next')?.addEventListener('click', () => { advanceLine(1); });
|
function waitForAudioEnd(audio) {
|
||||||
$('reh-tts-replay')?.addEventListener('click', () => { $('reh-tts-audio')?.play().catch(() => {}); });
|
return new Promise(resolve => {
|
||||||
$('reh-prev-line')?.addEventListener('click', () => { advanceLine(-1); });
|
if (!audio || audio.paused || audio.ended) { resolve(); return; }
|
||||||
$('reh-skip-line')?.addEventListener('click', () => {
|
audio.addEventListener('ended', resolve, { once: true });
|
||||||
rehState.clips.push({ lineIndex: rehState.lineIndex, speaker: rehState.lines[rehState.lineIndex]?.speaker, type: 'skip' });
|
audio.addEventListener('pause', resolve, { once: true });
|
||||||
advanceLine(1);
|
audio.addEventListener('error', resolve, { once: true });
|
||||||
});
|
});
|
||||||
$('reh-stop-reh')?.addEventListener('click', () => {
|
}
|
||||||
|
|
||||||
|
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 = `<span class="reh-rec-cue-name" style="color:${c.color}">${escHtml(line.speaker)}</span> — your line:<div class="reh-rec-cue-text">${escHtml(line.text)}</div>`;
|
||||||
|
// 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();
|
stopRehMic();
|
||||||
finishRehearsal();
|
|
||||||
});
|
|
||||||
|
|
||||||
function advanceLine(delta) {
|
|
||||||
rehState.lineIndex = Math.max(0, Math.min(rehState.lines.length - 1, rehState.lineIndex + delta));
|
|
||||||
renderCurrentLine();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function finishRehearsal() {
|
// ── Mic recording ───────────────────────────────────────────────────────────
|
||||||
stopRehMic();
|
|
||||||
renderSummary();
|
|
||||||
showPhase(4);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Mic recording (my lines) ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
function rehRenderMeter(level = 0, db = -Infinity, clipped = false) {
|
function rehRenderMeter(level = 0, db = -Infinity, clipped = false) {
|
||||||
const meter = $('reh-mic-meter');
|
const meter = $('reh-mic-meter'); if (!meter) return;
|
||||||
if (!meter) return;
|
|
||||||
if (!meter.children.length) {
|
if (!meter.children.length) {
|
||||||
for (let i = 0; i < 18; i++) { const b = document.createElement('div'); b.className = 'bar'; meter.appendChild(b); }
|
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);
|
const active = Math.round(Math.max(0, Math.min(1, level)) * meter.children.length);
|
||||||
[...meter.children].forEach((bar, i) => {
|
[...meter.children].forEach((bar, i) => {
|
||||||
bar.className = 'bar';
|
bar.className = 'bar'; bar.style.height = (7 + Math.min(i, active) * 1.55) + 'px';
|
||||||
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'); }
|
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');
|
const el = $('reh-db-readout'); if (el) el.textContent = Number.isFinite(db) ? db.toFixed(1) + ' dB' : '-∞ dB';
|
||||||
if (el) el.textContent = Number.isFinite(db) ? db.toFixed(1) + ' dB' : '-∞ dB';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function rehStartMeter() {
|
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)];
|
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;
|
const ctx = canvas.getContext('2d'), w = canvas.width, h = canvas.height;
|
||||||
ctx.clearRect(0, 0, w, h);
|
ctx.clearRect(0, 0, w, h);
|
||||||
ctx.beginPath();
|
ctx.beginPath(); ctx.strokeStyle = peak > 0.98 ? '#f38ba8' : db > -12 ? '#f9e2af' : '#a6e3a1'; ctx.lineWidth = 1.5;
|
||||||
ctx.strokeStyle = peak > 0.98 ? '#f38ba8' : db > -12 ? '#f9e2af' : '#a6e3a1';
|
|
||||||
ctx.lineWidth = 1.5;
|
|
||||||
const mid = h / 2;
|
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();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
rehState.recMeterRaf = requestAnimationFrame(tick);
|
rehState.recMeterRaf = requestAnimationFrame(tick);
|
||||||
@ -338,9 +492,7 @@ async function startRehMic() {
|
|||||||
rehState.recGainNode.connect(dest);
|
rehState.recGainNode.connect(dest);
|
||||||
rehState.recDestStream = dest.stream;
|
rehState.recDestStream = dest.stream;
|
||||||
rehStartMeter();
|
rehStartMeter();
|
||||||
} else {
|
} else { rehState.recDestStream = rehState.recStream; }
|
||||||
rehState.recDestStream = rehState.recStream;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopRehMic() {
|
function stopRehMic() {
|
||||||
@ -350,7 +502,7 @@ function stopRehMic() {
|
|||||||
if (rehState.recStream) rehState.recStream.getTracks().forEach(t => t.stop());
|
if (rehState.recStream) rehState.recStream.getTracks().forEach(t => t.stop());
|
||||||
if (rehState.recDestStream) rehState.recDestStream.getTracks().forEach(t => t.stop());
|
if (rehState.recDestStream) rehState.recDestStream.getTracks().forEach(t => t.stop());
|
||||||
if (rehState.recAudioCtx) rehState.recAudioCtx.close().catch(() => {});
|
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();
|
rehRenderMeter();
|
||||||
const wc = $('reh-live-wave'); if (wc) wc.getContext('2d').clearRect(0, 0, wc.width, wc.height);
|
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 () => {
|
$('reh-rec-start')?.addEventListener('click', async () => {
|
||||||
try {
|
try {
|
||||||
await startRehMic();
|
await startRehMic();
|
||||||
rehState.recChunks = [];
|
rehState.recChunks = []; rehState.recSecs = 0;
|
||||||
rehState.recSecs = 0;
|
|
||||||
if ($('reh-rec-time')) $('reh-rec-time').textContent = '0:00';
|
if ($('reh-rec-time')) $('reh-rec-time').textContent = '0:00';
|
||||||
if ($('reh-rec-start')) $('reh-rec-start').disabled = true;
|
if ($('reh-rec-start')) $('reh-rec-start').disabled = true;
|
||||||
if ($('reh-rec-stop')) $('reh-rec-stop').disabled = false;
|
if ($('reh-rec-stop')) $('reh-rec-stop').disabled = false;
|
||||||
if ($('reh-rec-confirm-row')) $('reh-rec-confirm-row').hidden = true;
|
if ($('reh-rec-confirm-row')) $('reh-rec-confirm-row').hidden = true;
|
||||||
rehState.recTimer = setInterval(() => {
|
rehState.recTimer = setInterval(() => {
|
||||||
rehState.recSecs++;
|
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);
|
}, 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.ondataavailable = e => { if (e.data.size) rehState.recChunks.push(e.data); };
|
||||||
rehState.mediaRec.onstop = () => {
|
rehState.mediaRec.onstop = () => {
|
||||||
clearInterval(rehState.recTimer);
|
clearInterval(rehState.recTimer);
|
||||||
if ($('reh-rec-start')) $('reh-rec-start').disabled = false;
|
if ($('reh-rec-start')) $('reh-rec-start').disabled = false;
|
||||||
if ($('reh-rec-stop')) $('reh-rec-stop').disabled = true;
|
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 url = URL.createObjectURL(blob);
|
||||||
const preview = $('reh-rec-preview');
|
const p = $('reh-rec-preview'); if (p) { p.src = url; p.style.display = ''; }
|
||||||
if (preview) { preview.src = url; preview.style.display = ''; }
|
|
||||||
if ($('reh-rec-confirm-row')) $('reh-rec-confirm-row').hidden = false;
|
if ($('reh-rec-confirm-row')) $('reh-rec-confirm-row').hidden = false;
|
||||||
rehState.lastRecBlob = blob;
|
rehState.lastRecBlob = blob;
|
||||||
};
|
};
|
||||||
@ -385,60 +535,61 @@ $('reh-rec-start')?.addEventListener('click', async () => {
|
|||||||
} catch(e) { stopRehMic(); toast(await microphoneErrorMessage(e), 'error'); }
|
} catch(e) { stopRehMic(); toast(await microphoneErrorMessage(e), 'error'); }
|
||||||
});
|
});
|
||||||
|
|
||||||
$('reh-rec-stop')?.addEventListener('click', () => {
|
$('reh-rec-stop')?.addEventListener('click', () => { if (rehState.mediaRec?.state !== 'inactive') rehState.mediaRec.stop(); });
|
||||||
if (rehState.mediaRec?.state !== 'inactive') rehState.mediaRec.stop();
|
|
||||||
});
|
|
||||||
|
|
||||||
$('reh-rec-keep')?.addEventListener('click', () => {
|
$('reh-rec-keep')?.addEventListener('click', () => {
|
||||||
const blob = rehState.lastRecBlob;
|
if (rehState.lastRecBlob) {
|
||||||
if (blob) {
|
rehState.clips.push({ lineIndex: rehState.lineIndex, speaker: rehState.lines[rehState.lineIndex]?.speaker, type: 'me', blob: rehState.lastRecBlob });
|
||||||
rehState.clips.push({ lineIndex: rehState.lineIndex, speaker: rehState.lines[rehState.lineIndex]?.speaker, type: 'me', blob });
|
|
||||||
}
|
}
|
||||||
stopRehMic();
|
stopRehMic();
|
||||||
advanceLine(1);
|
hideRecOverlay();
|
||||||
|
rehState.lineIndex++;
|
||||||
|
startPlay();
|
||||||
});
|
});
|
||||||
|
|
||||||
$('reh-rec-redo')?.addEventListener('click', () => {
|
$('reh-rec-redo')?.addEventListener('click', () => {
|
||||||
stopRehMic();
|
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() {
|
function renderSummary() {
|
||||||
const list = $('reh-summary-list');
|
const list = $('reh-summary-list'); if (!list) return;
|
||||||
if (!list) return;
|
if (!rehState.clips.length) { list.innerHTML = '<p class="note">No clips in this session.</p>'; 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) => {
|
list.innerHTML = rehState.clips.map((clip, idx) => {
|
||||||
const line = rehState.lines[clip.lineIndex] || { text: '—', speaker: clip.speaker };
|
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 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;margin-top:4px"></audio>` : '';
|
||||||
const audioHtml = clip.blob
|
const dlHtml = clip.blob ? `<a class="btn-secondary" style="text-decoration:none;flex-shrink:0" download="line_${idx+1}_${clip.speaker}.webm" href="${URL.createObjectURL(clip.blob)}"><span class="mdi mdi-download"></span></a>` : '';
|
||||||
? `<audio controls src="${URL.createObjectURL(clip.blob)}" style="width:100%;max-width:320px"></audio>`
|
|
||||||
: '';
|
|
||||||
return `<div class="reh-summary-row">
|
return `<div class="reh-summary-row">
|
||||||
<span class="reh-cast-dot" style="background:${color}"></span>
|
<span style="width:10px;height:10px;border-radius:50%;background:${c.color};flex-shrink:0;margin-top:4px"></span>
|
||||||
<div style="flex:1;min-width:0">
|
<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-weight:600;font-size:13px;color:${c.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>
|
<div style="font-size:13px;line-height:1.4;margin-top:2px">${escHtml(line.text)}</div>
|
||||||
${audioHtml}
|
${audioHtml}
|
||||||
</div>
|
</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>` : ''}
|
${dlHtml}
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
$('reh-new-session-btn')?.addEventListener('click', () => {
|
$('reh-new-session-btn')?.addEventListener('click', () => {
|
||||||
stopRehMic();
|
stopPlay(); stopRehMic();
|
||||||
rehState.lines = []; rehState.cast = {}; rehState.clips = []; rehState.lineIndex = 0;
|
rehState.lines=[]; rehState.cast={}; rehState.clips=[]; rehState.lineIndex=0;
|
||||||
if ($('reh-script-text')) $('reh-script-text').value = '';
|
if ($('reh-script-text')) $('reh-script-text').value = '';
|
||||||
showPhase(1);
|
showPhase(1);
|
||||||
});
|
});
|
||||||
|
$('reh-resume-btn')?.addEventListener('click', () => { showPhase(3); highlightCurrentLine(); });
|
||||||
$('reh-resume-btn')?.addEventListener('click', () => {
|
|
||||||
showPhase(3);
|
|
||||||
renderCurrentLine();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Init
|
// Init
|
||||||
rehRenderMeter();
|
rehRenderMeter();
|
||||||
|
|||||||
@ -460,6 +460,7 @@ async function loadVoiceLibrary() {
|
|||||||
const r = await fetch('/api/voices');
|
const r = await fetch('/api/voices');
|
||||||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
|
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
|
||||||
_voices = await r.json();
|
_voices = await r.json();
|
||||||
|
window._voices = _voices; // expose for cross-module access (Script Rehearser etc.)
|
||||||
if (typeof window.updateVoiceTree === 'function') window.updateVoiceTree(_voices);
|
if (typeof window.updateVoiceTree === 'function') window.updateVoiceTree(_voices);
|
||||||
renderVoiceList();
|
renderVoiceList();
|
||||||
updatePreviewVoiceMatchPanel();
|
updatePreviewVoiceMatchPanel();
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
<span class="section-icon"><span class="mdi mdi-theater"></span></span>
|
<span class="section-icon"><span class="mdi mdi-theater"></span></span>
|
||||||
<div class="section-title">
|
<div class="section-title">
|
||||||
<h2>Script Rehearser</h2>
|
<h2>Script Rehearser</h2>
|
||||||
<p>Upload a script, assign voices to characters, then synthesize the other parts while you record your own.</p>
|
<p>Upload a script, assign voices to characters, then rehearse with TTS synthesis and microphone recording.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -12,7 +12,7 @@
|
|||||||
<div class="reh-phase" id="reh-phase-1">
|
<div class="reh-phase" id="reh-phase-1">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2><span class="mdi mdi-script-text-outline"></span> Step 1 — Paste or upload your script</h2>
|
<h2><span class="mdi mdi-script-text-outline"></span> Step 1 — Paste or upload your script</h2>
|
||||||
<p class="card-subtitle">Supported formats: <code>CHARACTER: dialog</code> on one line, or screenplay style (character name on its own ALL-CAPS line followed by dialog). Each speaker is detected automatically.</p>
|
<p class="card-subtitle">Format: <code>CHARACTER: dialog</code> on one line, or screenplay style (ALL-CAPS character name on its own line, dialog below). Lines starting with <code>#</code> are stage directions.</p>
|
||||||
<div class="reh-input-row">
|
<div class="reh-input-row">
|
||||||
<textarea id="reh-script-text" placeholder="ALICE Hello, how are you today? BOB I'm doing great, thank you for asking! ALICE That's wonderful to hear." spellcheck="false"></textarea>
|
<textarea id="reh-script-text" placeholder="ALICE Hello, how are you today? BOB I'm doing great, thank you for asking! ALICE That's wonderful to hear." spellcheck="false"></textarea>
|
||||||
<div class="reh-input-side">
|
<div class="reh-input-side">
|
||||||
@ -20,7 +20,11 @@
|
|||||||
<span class="mdi mdi-upload"></span> Upload .txt
|
<span class="mdi mdi-upload"></span> Upload .txt
|
||||||
<input type="file" id="reh-file-input" accept=".txt,.md" style="display:none">
|
<input type="file" id="reh-file-input" accept=".txt,.md" style="display:none">
|
||||||
</label>
|
</label>
|
||||||
<p class="note" style="margin-top:8px">Format hints:<br>• <code>CHARACTER: text</code><br>• All-caps name on own line + dialog below<br>• Lines starting with <code>#</code> are stage directions (skipped)</p>
|
<div class="field" style="margin-top:10px">
|
||||||
|
<label style="font-size:12px">Script title (optional)</label>
|
||||||
|
<input type="text" id="reh-script-title" placeholder="My Script">
|
||||||
|
</div>
|
||||||
|
<p class="note" style="margin-top:8px;font-size:12px">Supported formats:<br>• <code>CHARACTER: text</code><br>• All-caps name + dialog below<br>• <code># stage direction</code></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-row" style="margin-top:12px">
|
<div class="btn-row" style="margin-top:12px">
|
||||||
@ -33,40 +37,57 @@
|
|||||||
<div class="reh-phase" id="reh-phase-2" hidden>
|
<div class="reh-phase" id="reh-phase-2" hidden>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2><span class="mdi mdi-account-group-outline"></span> Step 2 — Cast your characters</h2>
|
<h2><span class="mdi mdi-account-group-outline"></span> Step 2 — Cast your characters</h2>
|
||||||
<p class="card-subtitle">Assign a TTS voice to each character, or mark a character as <strong>Me</strong> — those lines will be recorded from your microphone during rehearsal.</p>
|
<p class="card-subtitle">Assign a TTS voice to each character, or toggle <strong>I play this role</strong> — those lines will pause and wait for your microphone recording.</p>
|
||||||
<div id="reh-cast-list" class="reh-cast-list"></div>
|
<div class="field" style="margin-bottom:14px">
|
||||||
<div class="field" style="margin-top:14px">
|
|
||||||
<label>TTS backend for synthesis</label>
|
<label>TTS backend for synthesis</label>
|
||||||
<div style="display:flex;gap:8px;align-items:center">
|
<div style="display:flex;gap:8px;align-items:center">
|
||||||
<select id="reh-backend-select"><option value="">Checking…</option></select>
|
<select id="reh-backend-select"><option value="">Checking…</option></select>
|
||||||
<button class="btn-secondary" id="reh-fetch-voices-btn">Fetch voices</button>
|
<button class="btn-secondary" id="reh-fetch-voices-btn"><span class="mdi mdi-refresh"></span> Fetch voices</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="reh-cast-list" class="reh-cast-list"></div>
|
||||||
<div class="btn-row" style="margin-top:14px">
|
<div class="btn-row" style="margin-top:14px">
|
||||||
<button class="btn-secondary" id="reh-back-1-btn"><span class="mdi mdi-arrow-left"></span> Back</button>
|
<button class="btn-secondary" id="reh-back-1-btn"><span class="mdi mdi-arrow-left"></span> Back</button>
|
||||||
<button class="btn-primary" id="reh-start-btn"><span class="mdi mdi-play"></span> Start rehearsal</button>
|
<button class="btn-primary" id="reh-start-btn"><span class="mdi mdi-theater"></span> Open rehearsal</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Phase 3: Rehearsal ─────────────────────────────────────── -->
|
<!-- ── Phase 3: Rehearsal — A4 script view ────────────────────── -->
|
||||||
<div class="reh-phase" id="reh-phase-3" hidden>
|
<div class="reh-phase" id="reh-phase-3" hidden>
|
||||||
<div class="card">
|
|
||||||
<h2><span class="mdi mdi-microphone-outline"></span> Step 3 — Rehearse</h2>
|
|
||||||
<div class="reh-progress-bar-wrap">
|
|
||||||
<div class="reh-progress-bar" id="reh-progress-bar"></div>
|
|
||||||
</div>
|
|
||||||
<div class="reh-progress-label" id="reh-progress-label">Line 0 / 0</div>
|
|
||||||
|
|
||||||
<!-- Current line display -->
|
<!-- Transport controls -->
|
||||||
<div class="reh-line-card" id="reh-line-card">
|
<div class="reh-transport-bar" id="reh-transport-bar">
|
||||||
<div class="reh-line-speaker" id="reh-line-speaker"></div>
|
<!-- Cast strip -->
|
||||||
<div class="reh-line-text" id="reh-line-text"></div>
|
<div class="reh-cast-strip" id="reh-cast-strip"></div>
|
||||||
<div class="reh-line-actions" id="reh-line-actions"></div>
|
<!-- Transport buttons -->
|
||||||
|
<div class="reh-transport-btns">
|
||||||
|
<button class="reh-tb-btn" id="reh-tb-prev" title="Previous line"><span class="mdi mdi-skip-previous"></span></button>
|
||||||
|
<button class="reh-tb-btn reh-tb-play" id="reh-tb-play" title="Play all"><span class="mdi mdi-play"></span></button>
|
||||||
|
<button class="reh-tb-btn" id="reh-tb-stop" title="Stop & reset"><span class="mdi mdi-stop"></span></button>
|
||||||
|
<button class="reh-tb-btn" id="reh-tb-next" title="Next line"><span class="mdi mdi-skip-next"></span></button>
|
||||||
|
<button class="reh-tb-btn" id="reh-tb-repeat" title="Loop script"><span class="mdi mdi-repeat"></span></button>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Progress -->
|
||||||
|
<div class="reh-transport-progress">
|
||||||
|
<div class="reh-tb-progress-wrap"><div class="reh-tb-progress" id="reh-tb-progress"></div></div>
|
||||||
|
<span class="reh-tb-label" id="reh-tb-label">0 / 0</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn-secondary btn-sm" id="reh-exit-btn" style="margin-left:auto">Exit</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- My-turn recording controls -->
|
<!-- A4 script page -->
|
||||||
<div class="reh-record-panel" id="reh-record-panel" hidden>
|
<div class="reh-page-wrap">
|
||||||
|
<div class="reh-a4-page" id="reh-a4-page">
|
||||||
|
<div class="reh-page-title" id="reh-page-title"></div>
|
||||||
|
<div class="reh-script-lines" id="reh-script-lines"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recording overlay (visible when my turn) -->
|
||||||
|
<div class="reh-rec-overlay" id="reh-rec-overlay" hidden>
|
||||||
|
<div class="reh-rec-overlay-inner">
|
||||||
|
<div class="reh-rec-cue" id="reh-rec-cue"></div>
|
||||||
<div class="mic-monitor-box" style="margin-bottom:10px">
|
<div class="mic-monitor-box" style="margin-bottom:10px">
|
||||||
<div class="mic-monitor-head">
|
<div class="mic-monitor-head">
|
||||||
<span>Input level</span>
|
<span>Input level</span>
|
||||||
@ -75,48 +96,37 @@
|
|||||||
<div class="mic-meter" id="reh-mic-meter" aria-hidden="true"></div>
|
<div class="mic-meter" id="reh-mic-meter" aria-hidden="true"></div>
|
||||||
<canvas id="reh-live-wave" class="mic-live-wave" width="300" height="48" aria-hidden="true"></canvas>
|
<canvas id="reh-live-wave" class="mic-live-wave" width="300" height="48" aria-hidden="true"></canvas>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-row">
|
<div class="btn-row" style="gap:8px">
|
||||||
<button class="btn-red" id="reh-rec-start"><span class="mdi mdi-record-circle-outline"></span> Record my line</button>
|
<button class="btn-red" id="reh-rec-start"><span class="mdi mdi-record-circle-outline"></span> Record</button>
|
||||||
<button class="btn-secondary" id="reh-rec-stop" disabled><span class="mdi mdi-stop-circle-outline"></span> Stop</button>
|
<button class="btn-secondary" id="reh-rec-stop" disabled><span class="mdi mdi-stop-circle-outline"></span> Stop</button>
|
||||||
<span class="mic-timer" id="reh-rec-time">0:00</span>
|
<span class="mic-timer" id="reh-rec-time">0:00</span>
|
||||||
<button class="btn-secondary" id="reh-skip-line">Skip</button>
|
<button class="btn-secondary" id="reh-skip-line"><span class="mdi mdi-skip-next"></span> Skip</button>
|
||||||
</div>
|
</div>
|
||||||
<audio id="reh-rec-preview" controls style="display:none;margin-top:8px;width:100%"></audio>
|
<audio id="reh-rec-preview" controls style="display:none;margin-top:8px;width:100%"></audio>
|
||||||
<div class="btn-row" style="margin-top:6px" id="reh-rec-confirm-row" hidden>
|
<div class="btn-row" style="margin-top:6px" id="reh-rec-confirm-row" hidden>
|
||||||
<button class="btn-green" id="reh-rec-keep"><span class="mdi mdi-check"></span> Keep & next</button>
|
<button class="btn-green" id="reh-rec-keep"><span class="mdi mdi-check"></span> Keep & continue</button>
|
||||||
<button class="btn-secondary" id="reh-rec-redo"><span class="mdi mdi-refresh"></span> Re-record</button>
|
<button class="btn-secondary" id="reh-rec-redo"><span class="mdi mdi-refresh"></span> Re-record</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- TTS playback status -->
|
|
||||||
<div class="reh-tts-panel" id="reh-tts-panel" hidden>
|
|
||||||
<div id="reh-tts-status" class="note" style="margin-bottom:8px">Synthesizing…</div>
|
|
||||||
<audio id="reh-tts-audio" controls style="width:100%"></audio>
|
|
||||||
<div class="btn-row" style="margin-top:8px">
|
|
||||||
<button class="btn-primary" id="reh-tts-next"><span class="mdi mdi-skip-next"></span> Next</button>
|
|
||||||
<button class="btn-secondary" id="reh-tts-replay"><span class="mdi mdi-replay"></span> Replay</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Navigation -->
|
|
||||||
<div class="btn-row" style="margin-top:14px;border-top:1px solid var(--border);padding-top:14px">
|
|
||||||
<button class="btn-secondary" id="reh-prev-line"><span class="mdi mdi-chevron-left"></span> Prev</button>
|
|
||||||
<button class="btn-secondary" id="reh-stop-reh"><span class="mdi mdi-stop"></span> Stop rehearsal</button>
|
|
||||||
<div style="flex:1"></div>
|
|
||||||
<span id="reh-session-info" class="note"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<!-- TTS playback status (slim bar below transport) -->
|
||||||
|
<div class="reh-tts-status-bar" id="reh-tts-status-bar" hidden>
|
||||||
|
<span id="reh-tts-status-txt"></span>
|
||||||
|
<audio id="reh-tts-audio" controls style="flex:1;min-width:0;max-width:320px"></audio>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /phase-3 -->
|
||||||
|
|
||||||
<!-- ── Phase 4: Session summary ────────────────────────────────── -->
|
<!-- ── Phase 4: Session summary ────────────────────────────────── -->
|
||||||
<div class="reh-phase" id="reh-phase-4" hidden>
|
<div class="reh-phase" id="reh-phase-4" hidden>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2><span class="mdi mdi-check-circle-outline"></span> Rehearsal complete</h2>
|
<h2><span class="mdi mdi-check-circle-outline"></span> Rehearsal complete</h2>
|
||||||
<p class="card-subtitle">Your recorded lines and all synthesized lines from this session.</p>
|
<p class="card-subtitle">All recorded and synthesized clips from this session.</p>
|
||||||
<div id="reh-summary-list" class="reh-summary-list"></div>
|
<div id="reh-summary-list" class="reh-summary-list"></div>
|
||||||
<div class="btn-row" style="margin-top:14px">
|
<div class="btn-row" style="margin-top:14px">
|
||||||
<button class="btn-secondary" id="reh-new-session-btn"><span class="mdi mdi-refresh"></span> New script</button>
|
<button class="btn-secondary" id="reh-new-session-btn"><span class="mdi mdi-refresh"></span> New script</button>
|
||||||
<button class="btn-secondary" id="reh-resume-btn"><span class="mdi mdi-play"></span> Resume rehearsal</button>
|
<button class="btn-secondary" id="reh-resume-btn"><span class="mdi mdi-theater"></span> Resume rehearsal</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2303,21 +2303,88 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
|||||||
|
|
||||||
/* ── Script Rehearser ───────────────────────────────────────────────────── */
|
/* ── Script Rehearser ───────────────────────────────────────────────────── */
|
||||||
.reh-phase[hidden] { display: none; }
|
.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 { 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-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; }
|
.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-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-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); }
|
||||||
.reh-cast-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
|
|
||||||
.reh-cast-name { font-weight: 700; font-size: 14px; }
|
/* Transport bar */
|
||||||
.reh-me-toggle { margin-left: auto; }
|
.reh-transport-bar {
|
||||||
.reh-progress-bar-wrap { height: 6px; background: var(--border); border-radius: 3px; overflow: hidden; margin-bottom: 4px; }
|
position: sticky; top: 0; z-index: 50;
|
||||||
.reh-progress-bar { height: 100%; background: var(--accent); border-radius: 3px; transition: width .3s ease; width: 0%; }
|
background: var(--bg); border-bottom: 1px solid var(--border);
|
||||||
.reh-progress-label { font-size: 12px; color: var(--subtext); margin-bottom: 14px; }
|
display: flex; align-items: center; gap: 12px; padding: 8px 16px;
|
||||||
.reh-line-card { border: 2px solid var(--accent); border-radius: 8px; padding: 16px 20px; margin-bottom: 14px; background: rgba(137,180,250,.05); }
|
flex-wrap: wrap;
|
||||||
.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-cast-strip { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||||
.reh-tts-panel, .reh-record-panel { padding: 12px; background: var(--panel); border-radius: 6px; }
|
.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-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; }
|
.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; }
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user