// Rehearser training mode state, cue playback, and recording UI. // Loaded after train comparison helpers and before rehearser.js; dependencies resolve at call time. (function (root) { // ── Train Mode ──────────────────────────────────────────────────────────── // State machine: idle → playing_cue → ready → recording → transcribing → done // Each "turn" = one set of cue lines + one "me" line the user must speak. const trainState = { seq: [], // [{cueLines:[{index,speaker,text,voice,instruct}], myLine:{index,speaker,text}}] turn: 0, // current turn index phase: 'idle', // idle | playing_cue | ready | recording | transcribing | done cueSource: null, // AudioBufferSourceNode currently playing mediaRec: null, // MediaRecorder for train recording recChunks: [], recRaf: null, // rAF id for meter recStream: null, // MediaStream recCtx: null, // AudioContext for meter recAnalyser: null, recTimer: null, recSecs: 0, }; function buildTrainSeq() { const seq = []; const lines = rehState.lines; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (line.type !== 'dialog') continue; const cast = rehState.cast[line.speaker]; if (!cast || cast.voice !== 'me') continue; // Gather preceding cue lines (last up to 3 TTS dialog lines going backwards) const cueLines = []; for (let j = i - 1; j >= 0 && cueLines.length < 3; j--) { const prev = lines[j]; if (prev.type === 'scene' || prev.type === 'act') break; if (prev.type !== 'dialog') continue; const pc = rehState.cast[prev.speaker]; if (!pc || pc.voice === 'me') break; cueLines.unshift({ index: j, speaker: prev.speaker, text: prev.text, emotion: prev.emotion || '', voice: pc.voice, color: pc.color, instruct: _buildInstruct(pc.instruct, prev.emotion, pc.voice), voiceData: pc.voiceData }); } seq.push({ cueLines, myLine: { index: i, speaker: line.speaker, text: line.text, color: cast.color } }); } return seq; } function _trainSetState(phase) { trainState.phase = phase; const stateEl = $('reh-train-rec-state'); const playBtn = $('reh-train-play'); const recBtn = $('reh-train-record'); const stopBtn = $('reh-train-stop-rec'); const cueState = $('reh-train-cue-state'); const map = { idle: { state:'Read the cue above · press ▶ to hear it', play:true, rec:false, stop:false }, playing_cue: { state:'Playing cue…', play:false, rec:false, stop:false }, ready: { state:'Press 🎤 to record your line', play:false, rec:true, stop:false }, recording: { state:'Recording…', play:false, rec:false, stop:true }, transcribing: { state:'Transcribing…', play:false, rec:false, stop:false }, done: { state:'Done — press ▶ to replay or ⏭ for next', play:true, rec:true, stop:false }, }; const m = map[phase] || map.idle; if (stateEl) stateEl.textContent = m.state; if (cueState && phase === 'playing_cue') { cueState.innerHTML = ''; } else if (cueState) cueState.innerHTML = ''; if (playBtn) playBtn.disabled = !m.play; if (recBtn) { recBtn.disabled = !m.rec; recBtn.hidden = !!m.stop; } if (stopBtn) { stopBtn.disabled = !m.stop; stopBtn.hidden = !m.stop; } const timerEl = $('reh-train-rec-timer'); const metersEl = $('reh-train-meters'); if (phase === 'recording') { if (timerEl) timerEl.hidden = false; if (metersEl) metersEl.hidden = false; } else if (phase !== 'done') { if (timerEl) timerEl.hidden = true; if (metersEl) metersEl.hidden = true; } } async function _trainPlayCueLines(cueLines) { _trainSetState('playing_cue'); for (const cue of cueLines) { if (trainState.phase !== 'playing_cue') break; let blob = rehState.synthCache.get(cue.index); if (!blob) { try { blob = await fetchTtsPreviewBlob(cue.voice, _rehInlineTone(stripMarkdown(cue.text), cue.emotion), 'wav', cue.instruct, _ttsBackendForVoice(cue.voice, rehState.backend)); } catch(e) { toast('Cue TTS failed: ' + e.message, 'error'); break; } } if (trainState.phase !== 'playing_cue') break; await new Promise(resolve => { const url = URL.createObjectURL(blob); const audio = new Audio(url); audio.onended = () => { URL.revokeObjectURL(url); resolve(); }; audio.onerror = () => { URL.revokeObjectURL(url); resolve(); }; trainState.cueAudio = audio; audio.play().catch(resolve); }); } if (trainState.phase === 'playing_cue') _trainSetState('ready'); } async function _trainTranscribe(blob) { _trainSetState('transcribing'); try { const fd = new FormData(); fd.append('file', blob, 'train_rec.webm'); fd.append('backend', _appSettings?.stt_preferred_backend || 'configured'); const r = await fetch('/api/transcribe-bytes', { method:'POST', body:fd }); if (!r.ok) throw new Error(r.statusText); const d = await r.json(); return (d.text || '').trim(); } catch(e) { toast('Transcription failed: ' + e.message, 'error'); return ''; } } async function trainGoTo(idx) { // Stop any in-progress recording or playback if (trainState.cueAudio) { trainState.cueAudio.pause(); trainState.cueAudio = null; } _trainStopMic(); trainState.turn = Math.max(0, Math.min(idx, trainState.seq.length - 1)); const turn = trainState.seq[trainState.turn]; if (!turn) return; // Update progress const progEl = $('reh-train-progress'); if (progEl) progEl.textContent = `Turn ${trainState.turn + 1} / ${trainState.seq.length}`; // Render cue card — show ALL cue lines so user can read the full context const cueCard = $('reh-train-cue'); const cueAvEl = $('reh-train-cue-avatar'); const cueSpeaker = $('reh-train-cue-speaker'); const cueText = $('reh-train-cue-text'); if (turn.cueLines.length) { const lastCue = turn.cueLines[turn.cueLines.length - 1]; if (cueAvEl) cueAvEl.innerHTML = voiceAvatarHtml(lastCue.voice, lastCue.color, 32); if (cueSpeaker) cueSpeaker.textContent = lastCue.speaker; // Show all cue lines as a readable block if (cueText) { cueText.innerHTML = turn.cueLines.map(c => `
${escHtml(c.speaker)} ${escHtml(c.text)}
` ).join(''); } cueCard?.classList.remove('no-cue'); } else { if (cueAvEl) cueAvEl.innerHTML = ''; if (cueSpeaker) cueSpeaker.textContent = 'No preceding cue'; if (cueText) cueText.textContent = 'This is the first line — speak when ready.'; cueCard?.classList.add('no-cue'); } // Render my line card const mySpeaker = $('reh-train-my-speaker'); const myText = $('reh-train-my-text'); if (mySpeaker) mySpeaker.textContent = turn.myLine.speaker; if (myText) myText.textContent = turn.myLine.text; // Hide result from previous turn const resultEl = $('reh-train-result'); if (resultEl) resultEl.hidden = true; // Reset audio preview const prevAudio = $('reh-train-audio-preview'); if (prevAudio) { prevAudio.src = ''; prevAudio.style.display = 'none'; } // Show the script text immediately — user reads first, then clicks ▶ to hear the cue _trainSetState(turn.cueLines.length ? 'idle' : 'ready'); } function _trainStartMeter() { const meterEl = $('reh-train-meter'); const dbEl = $('reh-train-db'); const waveEl = $('reh-train-wave'); if (!trainState.recAnalyser || !meterEl) return; const analyser = trainState.recAnalyser; const fft = new Uint8Array(analyser.frequencyBinCount); const wCtx = waveEl?.getContext('2d'); const W = waveEl?.width || 300, H = waveEl?.height || 36; const BARS = 18; function tick() { analyser.getByteFrequencyData(fft); const rms = fft.reduce((s, v) => s + v * v, 0) / fft.length; const db = rms > 0 ? 20 * Math.log10(Math.sqrt(rms) / 128) : -Infinity; if (dbEl) dbEl.textContent = isFinite(db) ? db.toFixed(1) + ' dB' : '-∞ dB'; const slots = Array.from({length: BARS}, (_, i) => { const s = Math.floor(i / BARS * fft.length), e = Math.floor((i+1) / BARS * fft.length); return fft.slice(s, e).reduce((a, b) => a + b, 0) / (e - s) / 255; }); meterEl.innerHTML = slots.map(v => { const h = Math.max(2, Math.round(v * 28)); const col = v > 0.85 ? 'var(--red)' : v > 0.6 ? '#f59e0b' : 'var(--green)'; return ``; }).join(''); if (wCtx) { analyser.getByteTimeDomainData(fft); wCtx.clearRect(0, 0, W, H); wCtx.beginPath(); wCtx.strokeStyle = 'var(--accent)'; wCtx.lineWidth = 1.5; fft.forEach((v, i) => { const x = (i / fft.length) * W, y = (v / 255) * H; i ? wCtx.lineTo(x, y) : wCtx.moveTo(x, y); }); wCtx.stroke(); } trainState.recRaf = requestAnimationFrame(tick); } trainState.recRaf = requestAnimationFrame(tick); } function _trainStopMic() { if (trainState.recRaf) { cancelAnimationFrame(trainState.recRaf); trainState.recRaf = null; } if (trainState.recTimer) { clearInterval(trainState.recTimer); trainState.recTimer = null; } if (trainState.mediaRec && trainState.mediaRec.state !== 'inactive') { try { trainState.mediaRec.stop(); } catch(_) {} } trainState.mediaRec = null; if (trainState.recCtx) { try { trainState.recCtx.close(); } catch(_) {} trainState.recCtx = null; } if (trainState.recStream) { trainState.recStream.getTracks().forEach(t => t.stop()); trainState.recStream = null; } trainState.recAnalyser = null; trainState.recChunks = []; const timerEl = $('reh-train-rec-timer'); if (timerEl) { timerEl.hidden = true; timerEl.textContent = '0:00'; } const metersEl = $('reh-train-meters'); if (metersEl) metersEl.hidden = true; } async function _trainStartRecording() { if (trainState.phase !== 'ready') return; try { const stream = await requestMicrophoneStream({ raw: true }); trainState.recStream = stream; const ctx = new AudioContext(); trainState.recCtx = ctx; const src = ctx.createMediaStreamSource(stream); const analyser = ctx.createAnalyser(); analyser.fftSize = 256; trainState.recAnalyser = analyser; const dst = ctx.createMediaStreamDestination(); src.connect(analyser); analyser.connect(dst); _trainSetState('recording'); _trainStartMeter(); const timerEl = $('reh-train-rec-timer'); if (timerEl) timerEl.hidden = false; trainState.recSecs = 0; trainState.recTimer = setInterval(() => { trainState.recSecs++; const m = Math.floor(trainState.recSecs / 60), s = trainState.recSecs % 60; if (timerEl) timerEl.textContent = `${m}:${String(s).padStart(2,'0')}`; }, 1000); trainState.recChunks = []; const mr = new MediaRecorder(dst.stream, { audioBitsPerSecond: 128000 }); trainState.mediaRec = mr; mr.ondataavailable = e => { if (e.data.size > 0) trainState.recChunks.push(e.data); }; mr.onstop = async () => { _trainStopMic(); const blob = new Blob(trainState.recChunks, { type: 'audio/webm' }); trainState.recChunks = []; const url = URL.createObjectURL(blob); const prevAudio = $('reh-train-audio-preview'); if (prevAudio) { prevAudio.src = url; prevAudio.style.display = ''; } // Transcribe const transcript = await _trainTranscribe(blob); const turn = trainState.seq[trainState.turn]; const { html_exp, html_act, score } = _trainCompare(turn?.myLine?.text || '', transcript); const resultEl = $('reh-train-result'); const expEl = $('reh-train-expected-text'); const actEl = $('reh-train-actual-text'); const scoreEl = $('reh-train-score'); if (expEl) expEl.innerHTML = html_exp; if (actEl) actEl.innerHTML = html_act || 'Nothing detected'; if (scoreEl) { const col = score >= 90 ? 'var(--green)' : score >= 60 ? '#f59e0b' : 'var(--red)'; scoreEl.innerHTML = `${score}%`; } if (resultEl) resultEl.hidden = false; _trainSetState('done'); }; mr.start(); } catch(e) { toast('Microphone error: ' + e.message, 'error'); _trainSetState('ready'); } } function _trainStopRecording() { if (trainState.mediaRec && trainState.mediaRec.state === 'recording') { trainState.mediaRec.stop(); } } function _trainHideStage(hide) { // Show/hide the script content area and auxiliary bars — transport bar stays visible always const els = ['.reh-stage-area', '#reh-tts-status-bar', '#reh-rec-overlay', '#reh-synth-bar']; els.forEach(sel => { const el = document.querySelector(sel) || document.getElementById(sel.replace('#','')); if (el) el.style.display = hide ? 'none' : ''; }); } function enterTrainMode() { const seq = buildTrainSeq(); if (!seq.length) { toast('No "I play this" lines found — check "I play this" on at least one character in the Cast tab.', 'error'); return; } trainState.seq = seq; trainState.turn = 0; trainState.phase = 'idle'; _trainHideStage(true); const panel = $('reh-train-panel'); if (panel) panel.hidden = false; trainGoTo(0); } function exitTrainMode() { if (trainState.cueAudio) { trainState.cueAudio.pause(); trainState.cueAudio = null; } _trainStopMic(); trainState.phase = 'idle'; trainState.seq = []; _trainHideStage(false); const panel = $('reh-train-panel'); if (panel) panel.hidden = true; } root.trainState = trainState; root.buildTrainSeq = buildTrainSeq; root._trainSetState = _trainSetState; root._trainPlayCueLines = _trainPlayCueLines; root.trainGoTo = trainGoTo; root._trainStopMic = _trainStopMic; root._trainStartRecording = _trainStartRecording; root._trainStopRecording = _trainStopRecording; root._trainHideStage = _trainHideStage; root.enterTrainMode = enterTrainMode; root.exitTrainMode = exitTrainMode; root.TTVC=root.TTVC||{}; root.TTVC.rehearser=root.TTVC.rehearser||{}; Object.assign(root.TTVC.rehearser,{ training:{ state:trainState, enter:enterTrainMode, exit:exitTrainMode, goTo:trainGoTo, }, }); })(window);