refactor: extract rehearser training mode
This commit is contained in:
parent
0c7cc04643
commit
cefd4bd494
@ -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
|
||||
|
||||
2
TODO.md
2
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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="theme-color" content="#2563EB">
|
||||
<meta name="app-version" content="1.24.54">
|
||||
<meta name="app-version" content="1.24.55">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/static/icon.svg">
|
||||
@ -27,12 +27,12 @@
|
||||
|
||||
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
||||
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
||||
<link rel="stylesheet" href="/static/style.css?v=1.24.54">
|
||||
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.24.54">
|
||||
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.24.54">
|
||||
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.24.54">
|
||||
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.24.54">
|
||||
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.24.54">
|
||||
<link rel="stylesheet" href="/static/style.css?v=1.24.55">
|
||||
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.24.55">
|
||||
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.24.55">
|
||||
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.24.55">
|
||||
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.24.55">
|
||||
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.24.55">
|
||||
|
||||
|
||||
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
||||
@ -397,7 +397,7 @@ window.toggleNavTree = function(treeId, chevronId) {
|
||||
</script>
|
||||
|
||||
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
||||
<script src="/static/loader.js?v=1.24.54"></script>
|
||||
<script src="/static/loader.js?v=1.24.55"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
345
static/js/rehearser-training-ui.js
Normal file
345
static/js/rehearser-training-ui.js
Normal file
@ -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 = '<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;
|
||||
}
|
||||
|
||||
|
||||
window.TTVC=window.TTVC||{};
|
||||
window.TTVC.rehearser=window.TTVC.rehearser||{};
|
||||
Object.assign(window.TTVC.rehearser,{
|
||||
training:{
|
||||
state:trainState,
|
||||
enter:enterTrainMode,
|
||||
exit:exitTrainMode,
|
||||
goTo:trainGoTo,
|
||||
},
|
||||
});
|
||||
|
||||
@ -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 = '<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;
|
||||
}
|
||||
|
||||
// ── Bulk-edit button wiring ─────────────────────────────────────────────────
|
||||
$('reh-bulk-toggle')?.addEventListener('click', () => setBulkMode(!rehState.bulkMode));
|
||||
$('reh-bulk-done')?.addEventListener('click', () => setBulkMode(false));
|
||||
|
||||
@ -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"]
|
||||
}
|
||||
}
|
||||
|
||||
@ -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' });
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user