// 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);