diff --git a/CHANGELOG.md b/CHANGELOG.md index 4779c22..a676dd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ## [Unreleased] ### Changed +- **Rehearser training extraction.** Training-mode state, cue playback, transcription, microphone recording, and phase + controls now live in `rehearser-training-ui.js`. - **Production bundle refresh.** Rebuilt `static/dist/main.min.js` from the current manifest so the release path includes the validated frontend fixes and retry-history behavior. - **Rehearser recording extraction.** Microphone recording, level metering, overlay state, and recorded-line controls diff --git a/TODO.md b/TODO.md index 77dd762..940312c 100644 --- a/TODO.md +++ b/TODO.md @@ -121,7 +121,7 @@ architecture change: the larger Audiobook/Rehearser/style split. ## P1 — Modular frontend architecture - [ ] Split `audiobook.js`, `rehearser.js`, and `style.css` by responsibility; Rehearser casting, playback, training, - voice design, I/O, library, stage, and recording blocks are now isolated, with import/event wiring and remaining Audiobook orchestration + voice design, I/O, library, stage, recording, and training blocks are now isolated, with import/event wiring and remaining Audiobook orchestration still in the monolith; Audiobook LLM orchestration and preview/export are now isolated from core Reader wiring. - [x] Extract Rehearser stage display and cast-sidebar controls into `rehearser-stage.js`. - [ ] Replace broad globals with namespaces such as `window.TTVC.audiobook`; a compatibility namespace bridge now diff --git a/VERSION b/VERSION index 1bbb1cb..b318d2d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.24.54 +1.24.55 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1fb62b7..34af00c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -36,6 +36,7 @@ Done incrementally as global-scope ordered files (no ES-module change needed) - ✅ `rehearser-playback.js` — WebAudio playback, transport state, bounded decoded-audio prefetch, and persistent line-audio cache/synthesis controls. - ✅ `rehearser-io.js` — rehearsal serialization, library save/load, autosave, session/file export, and screenplay/PDF imports. - ✅ `rehearser-recording.js` — microphone recording overlay, level meter, and recorded-line controls. +- ✅ `rehearser-training-ui.js` — training-mode state, cue playback, transcription, recording, and phase controls. - ✅ `rehearser-voice-picker.js` — voice preview state, local matching, fish.audio search/import, and candidate assignment; DOM event registration remains in `rehearser-cast.js`. - ✅ `rehearser-cast.js` — cast view, full cast-card rendering, sort/filter state handlers, and delegated cast toolbar wiring. - ✅ `rehearser-voice-design.js` — character development, backend selection, and backend-refresh synchronization. diff --git a/static/index.html b/static/index.html index f15d7eb..0b4ad0e 100644 --- a/static/index.html +++ b/static/index.html @@ -10,7 +10,7 @@ - + @@ -27,12 +27,12 @@ - - - - - - + + + + + + @@ -397,7 +397,7 @@ window.toggleNavTree = function(treeId, chevronId) { - + diff --git a/static/js/rehearser-training-ui.js b/static/js/rehearser-training-ui.js new file mode 100644 index 0000000..2d9f709 --- /dev/null +++ b/static/js/rehearser-training-ui.js @@ -0,0 +1,345 @@ +// Rehearser training mode state, cue playback, and recording UI. +// Loaded after train comparison helpers and before rehearser.js; dependencies resolve at call time. + +// ── 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; +} + + +window.TTVC=window.TTVC||{}; +window.TTVC.rehearser=window.TTVC.rehearser||{}; +Object.assign(window.TTVC.rehearser,{ + training:{ + state:trainState, + enter:enterTrainMode, + exit:exitTrainMode, + goTo:trainGoTo, + }, +}); + diff --git a/static/js/rehearser.js b/static/js/rehearser.js index c5c8723..52d8147 100644 --- a/static/js/rehearser.js +++ b/static/js/rehearser.js @@ -2722,336 +2722,6 @@ $('reh-impex-md-btn')?.addEventListener('click', exportAsMd); modal.addEventListener('click', e => { if (e.target === modal) closeModal(); }); })(); -// ── 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; -} - // ── Bulk-edit button wiring ───────────────────────────────────────────────── $('reh-bulk-toggle')?.addEventListener('click', () => setBulkMode(!rehState.bulkMode)); $('reh-bulk-done')?.addEventListener('click', () => setBulkMode(false)); diff --git a/static/module-manifest.json b/static/module-manifest.json index bb67e8f..aaf8f95 100644 --- a/static/module-manifest.json +++ b/static/module-manifest.json @@ -4,9 +4,9 @@ "integrations", "routing", "voice-clone", "voice-library", "tts-preview", "generation", "benchmark", "stt", "namespace" ], "lazy": { - "s-rehearser": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser-io", "rehearser-playback", "rehearser-recording", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker"], - "s-reader": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser-io", "rehearser-playback", "rehearser-recording", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook-pipeline", "audiobook-preview-export", "audiobook", "character-sheets"], - "s-library": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser-io", "rehearser-playback", "rehearser-recording", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook-pipeline", "audiobook-preview-export", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern"], - "s-caststudio": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser-io", "rehearser-playback", "rehearser-recording", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook-pipeline", "audiobook-preview-export", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern", "studio"] + "s-rehearser": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-training-ui", "rehearser-voice-design", "rehearser-io", "rehearser-playback", "rehearser-recording", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker"], + "s-reader": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-training-ui", "rehearser-voice-design", "rehearser-io", "rehearser-playback", "rehearser-recording", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook-pipeline", "audiobook-preview-export", "audiobook", "character-sheets"], + "s-library": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-training-ui", "rehearser-voice-design", "rehearser-io", "rehearser-playback", "rehearser-recording", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook-pipeline", "audiobook-preview-export", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern"], + "s-caststudio": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-training-ui", "rehearser-voice-design", "rehearser-io", "rehearser-playback", "rehearser-recording", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook-pipeline", "audiobook-preview-export", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern", "studio"] } } diff --git a/tests/lazy-loading.spec.js b/tests/lazy-loading.spec.js index 1ee4d79..b27d612 100644 --- a/tests/lazy-loading.spec.js +++ b/tests/lazy-loading.spec.js @@ -53,5 +53,6 @@ test('Rehearser playback module publishes transport APIs after loading', async ( loadRecord: typeof window.TTVC?.rehearser?.io?.loadRecord, exportSession: typeof window.TTVC?.rehearser?.io?.exportSession, recording: typeof window.TTVC?.rehearser?.recording?.start, - }))).toEqual({ start: 'function', stop: 'function', synthAll: 'function', loadRecord: 'function', exportSession: 'function', recording: 'function' }); + training: typeof window.TTVC?.rehearser?.training?.enter, + }))).toEqual({ start: 'function', stop: 'function', synthAll: 'function', loadRecord: 'function', exportSession: 'function', recording: 'function', training: 'function' }); });