358 lines
14 KiB
JavaScript
358 lines
14 KiB
JavaScript
// 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 = '<span class="mdi mdi-volume-high" style="color:var(--accent)"></span>'; }
|
|
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 =>
|
|
`<div class="reh-train-cue-block">
|
|
<span class="reh-train-cue-who" style="color:${c.color}">${escHtml(c.speaker)}</span>
|
|
<span class="reh-train-cue-line">${escHtml(c.text)}</span>
|
|
</div>`
|
|
).join('');
|
|
}
|
|
cueCard?.classList.remove('no-cue');
|
|
} else {
|
|
if (cueAvEl) cueAvEl.innerHTML = '<span class="mdi mdi-information-outline" style="font-size:24px;color:var(--subtext)"></span>';
|
|
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 `<span style="height:${h}px;background:${col};width:4px;border-radius:2px;display:inline-block;vertical-align:bottom;margin:0 1px"></span>`;
|
|
}).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 || '<em style="color:var(--subtext)">Nothing detected</em>';
|
|
if (scoreEl) {
|
|
const col = score >= 90 ? 'var(--green)' : score >= 60 ? '#f59e0b' : 'var(--red)';
|
|
scoreEl.innerHTML = `<span style="color:${col};font-weight:700">${score}%</span>`;
|
|
}
|
|
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);
|