// Rehearser playback, transport, and persistent line-audio cache.
// Loaded before rehearser.js; handlers resolve shared state and helpers at call time.
// ── WebAudio pre-decode (eliminates 2-3s silence between lines) ─────────────
let _rehPlayCtx = null;
const rehDecodedBuffers = new Map(); // lineIndex → AudioBuffer (pre-decoded PCM)
let rehCurrentSource = null; // active AudioBufferSourceNode
let rehWordHighlightRaf = null;
function rehPlayCtx() {
if (!_rehPlayCtx) _rehPlayCtx = new (window.AudioContext || window.webkitAudioContext)();
if (_rehPlayCtx.state === 'suspended') _rehPlayCtx.resume().catch(() => {});
return _rehPlayCtx;
}
// Decoded PCM (Float32) is the heaviest cache and is re-derivable from the cached
// blob, so keep only a sliding window around the playhead. Without this, a long
// script holds every line's raw PCM in memory at once and crashes mobile Safari.
const REH_DECODE_WINDOW = 8;
function _rehEvictDecoded(keepIdx) {
if (rehDecodedBuffers.size <= REH_DECODE_WINDOW * 2 + 4) return;
const lo = keepIdx - REH_DECODE_WINDOW, hi = keepIdx + REH_DECODE_WINDOW;
for (const k of rehDecodedBuffers.keys()) {
if (k < lo || k > hi) rehDecodedBuffers.delete(k);
}
}
async function preDecodeBlob(lineIdx, blob) {
if (rehDecodedBuffers.has(lineIdx)) return;
try {
const ab = await blob.arrayBuffer();
const buf = await rehPlayCtx().decodeAudioData(ab);
rehDecodedBuffers.set(lineIdx, buf);
_rehEvictDecoded(lineIdx);
} catch(_) {}
}
function computeWordTimings(text, durationSec) {
const words = stripMarkdown(text).split(/\s+/).filter(Boolean);
if (words.length < 2) return [];
// Distribute proportionally by character length (longer words = more time)
const totalChars = words.reduce((s, w) => s + w.length, 0) || 1;
let t = 0;
return words.map(w => {
const start = t;
t += (w.length / totalChars) * durationSec;
return { word: w, start, end: t };
});
}
function stopAudioSource() {
if (rehCurrentSource) {
try { rehCurrentSource.stop(0); } catch(_) {}
rehCurrentSource = null;
}
if (rehWordHighlightRaf) { cancelAnimationFrame(rehWordHighlightRaf); rehWordHighlightRaf = null; }
}
// Instant playback from pre-decoded buffer + word-level highlight
async function playPreDecoded(lineIdx, blob, text) {
if (!rehDecodedBuffers.has(lineIdx)) await preDecodeBlob(lineIdx, blob);
_rehEvictDecoded(lineIdx); // keep the window centred on the playhead
const buf = rehDecodedBuffers.get(lineIdx);
if (!buf) { await playAudioBlobFallback(blob); return; }
const timings = computeWordTimings(text, buf.duration);
const dialogEl = document.getElementById('reh-diag-' + lineIdx);
if (dialogEl && timings.length >= 2) {
dialogEl.innerHTML = timings.map((t, i) =>
`${escHtml(t.word)}`
).join(' ');
}
return new Promise(resolve => {
stopAudioSource();
const ctx = rehPlayCtx();
const src = ctx.createBufferSource();
src.buffer = buf;
src.connect(ctx.destination);
rehCurrentSource = src;
const t0 = ctx.currentTime;
src.onended = () => {
rehCurrentSource = null;
if (rehWordHighlightRaf) { cancelAnimationFrame(rehWordHighlightRaf); rehWordHighlightRaf = null; }
if (dialogEl && timings.length >= 2) dialogEl.innerHTML = renderMarkdownInline(text);
resolve();
};
src.start(0);
// Pre-decode the line AFTER this one while this is playing
const nextI = findNextCachedLine(lineIdx + 1);
if (nextI >= 0) preDecodeBlob(nextI, rehState.synthCache.get(nextI));
if (dialogEl && timings.length >= 2) {
const tick = () => {
if (rehCurrentSource !== src) return;
const elapsed = ctx.currentTime - t0;
let active = 0;
for (let i = timings.length - 1; i >= 0; i--) {
if (elapsed >= timings[i].start) { active = i; break; }
}
dialogEl.querySelectorAll('.reh-word').forEach((span, i) => {
span.classList.toggle('reh-word-active', i === active);
});
rehWordHighlightRaf = requestAnimationFrame(tick);
};
rehWordHighlightRaf = requestAnimationFrame(tick);
}
});
}
function findNextCachedLine(fromIdx) {
for (let i = fromIdx; i < rehState.lines.length; i++) {
if (rehState.synthCache.has(i)) return i;
}
return -1;
}
// Fallback when AudioContext decode fails
async function playAudioBlobFallback(blob) {
const url = URL.createObjectURL(blob);
const audio = $('reh-tts-audio'); if (!audio) return;
audio.src = url; audio.style.display = '';
await new Promise(r => { audio.addEventListener('canplay', r, { once: true }); setTimeout(r, 3000); });
await audio.play().catch(() => {});
await waitForAudioEnd(audio);
}
// ── Auto-play sequence ──────────────────────────────────────────────────────
function startPlay() {
rehPlayCtx(); // warm up AudioContext on user gesture
_ensureNarrator(); // make sure rehState.narratorVoice reflects the narrator cast row
rehState.playing = true;
updatePlayBtn();
playNextLine();
}
// Resets the narrator play button's icon back to "play" — separate from
// highlightCurrentLine() (which also moves the active-line highlight and
// scrolls) since pausing/stopping shouldn't jump the page around, just
// stop claiming a line is still playing.
function _rehResetActionPlayIcons() {
document.querySelectorAll('.reh-action-play-btn').forEach(btn => {
const icon = btn.querySelector('.mdi');
if (icon) icon.className = 'mdi mdi-play';
btn.title = 'Play or pause this line';
});
}
function pausePlay() {
rehState.playing = false;
updatePlayBtn();
stopAudioSource();
const audio = $('reh-tts-audio');
if (audio && !audio.paused) audio.pause();
hideStatusBar();
_rehResetActionPlayIcons();
}
function stopPlay() {
rehState.playing = false;
updatePlayBtn();
stopAudioSource();
const audio = $('reh-tts-audio');
if (audio) { audio.pause(); audio.src = ''; }
hideStatusBar();
_rehResetActionPlayIcons();
}
function hideStatusBar() { const bar = $('reh-tts-status-bar'); if (bar) bar.hidden = true; }
let _rehPrefetchRunning = false;
async function _rehPrefetchLines(fromIndex) {
if (_rehPrefetchRunning) return;
_rehPrefetchRunning = true;
try {
let prefetched = 0;
for (let i = fromIndex; i < rehState.lines.length && prefetched < 2; i++) {
if (!rehState.playing) break;
const l = rehState.lines[i];
if (!l || l.ignored || l.hidden || l.type === 'pagebreak') continue;
const isDialog = l.type === 'dialog';
const cast = isDialog ? (rehState.cast[l.speaker] || { voice: '' }) : null;
if (isDialog && (!cast.voice || cast.voice === 'me')) continue;
if (!isDialog && (!rehState.narratorVoice || !(l.text || '').trim() || rehState.skipDescriptions)) continue;
if (rehState.synthCache.has(i) && !rehState.staleLines.has(i)) {
prefetched++;
continue;
}
const voice = isDialog ? cast.voice : rehState.narratorVoice;
const cleanTxt = stripMarkdown(l.text);
const instruct = isDialog ? _buildInstruct((cast.instruct || '').trim(), l.emotion, voice) : '';
const text = isDialog ? _rehInlineTone(cleanTxt, l.emotion) : cleanTxt;
const book = _lineAudioBookName();
const cacheKey = await _lineAudioCacheKey(text, voice, instruct);
let blob = await _lineAudioCacheGet(book, cacheKey);
if (!blob) {
try {
blob = await fetchTtsPreviewBlob(voice, text, 'wav', instruct, _ttsBackendForVoice(voice, rehState.backend));
_lineAudioCachePut(book, cacheKey, blob);
} catch(e) {
console.warn('Prefetch failed for line', i, e);
continue; // skip counting as prefetched if it failed
}
}
rehState.synthCache.set(i, blob);
rehState.staleLines.delete(i);
_markSynthDot(i, 'ok');
prefetched++;
}
} finally {
_rehPrefetchRunning = false;
}
}
async function playNextLine() {
if (!rehState.playing) return;
// Check practice end boundary
if (rehState.practiceEnd !== null && rehState.lineIndex > rehState.practiceEnd) {
rehState.playing = false;
updatePlayBtn();
if (rehState.repeat) {
rehState.lineIndex = rehState.practiceStart ?? 0;
startPlay();
} else {
rehState.lineIndex = rehState.practiceStart ?? 0;
highlightCurrentLine();
}
return;
}
// Always skip pagebreak markers
if (rehState.lines[rehState.lineIndex]?.type === 'pagebreak') {
rehState.lineIndex++;
return playNextLine();
}
// Skip lines the user marked ignore / hide during bulk edit
const _cur = rehState.lines[rehState.lineIndex];
if (_cur && (_cur.ignored || _cur.hidden)) {
rehState.lineIndex++;
return playNextLine();
}
// Skip non-dialog lines whenever skip mode is on, full stop — regardless of
// whether a narrator voice happens to be assigned. This used to also require
// !rehState.narratorVoice, on the theory that skipping was only meaningful
// when there was nothing to skip TO — but the individual-line branch below
// never re-checked skipDescriptions at all, so once ANY narrator voice was
// configured (the common case once a book is actually set up), narration
// played regardless of this toggle. Confirmed live: Studio's "Rehearse ⇄
// Audiobook" toggle (which just flips this same flag) had zero observable
// effect once a narrator voice existed — exactly the reported "doesn't
// read the narrator" / toggle-does-nothing behavior, just inverted from
// what it looked like (narration was stuck ON, not stuck OFF).
if (rehState.skipDescriptions) {
while (
rehState.lineIndex < rehState.lines.length &&
rehState.lines[rehState.lineIndex].type !== 'dialog' &&
rehState.lines[rehState.lineIndex].type !== 'pagebreak' &&
!(rehState.practiceEnd !== null && rehState.lineIndex > rehState.practiceEnd)
) { rehState.lineIndex++; }
}
if (rehState.lineIndex >= rehState.lines.length) {
rehState.playing = false;
updatePlayBtn();
if (rehState.repeat) { rehState.lineIndex = 0; startPlay(); return; }
toast('Script finished', 'success');
return;
}
// Captured up front so a later `await` (waiting on a fresh TTS synthesis)
// can tell whether the user has since clicked a DIFFERENT line's play
// button — `rehState.lineIndex` itself gets overwritten by that click, so
// re-reading it after the await always looks "current" even when it
// isn't. Checking only `rehState.playing` (a bare boolean, flipped false
// then true again by the new click's own stopPlay()/startPlay() pair
// before this await ever resumes) let this stale continuation slip
// through and actually play — confirmed live as the reported bug: click
// a line while a previous, not-yet-synthesized line is still loading, and
// once that first synthesis finally finishes it cuts in and starts
// playing anyway, on top of (or right over) the line the user actually
// asked for, with no way to stop just that stray one.
const myLineIndex = rehState.lineIndex;
const stillCurrent = () => rehState.playing && rehState.lineIndex === myLineIndex;
const line = rehState.lines[rehState.lineIndex];
highlightCurrentLine();
_rehPrefetchLines(myLineIndex + 1).catch(e => console.warn('Prefetch error:', e));
// Non-dialog lines
if (line.type !== 'dialog') {
// Any line with text can be narrated — direction/transition/scene/act/action all included
const hasText = !!(line.text || '').trim();
if (rehState.narratorVoice && hasText && !rehState.skipDescriptions) {
showStatusBar('Narrator: ' + line.text.slice(0, 50) + (line.text.length > 50 ? '…' : ''));
const cached = rehState.synthCache.get(rehState.lineIndex);
if (cached) {
await playPreDecoded(rehState.lineIndex, cached, line.text);
} else {
try {
const book = _lineAudioBookName();
const cleanNarr = stripMarkdown(line.text);
const cacheKey = await _lineAudioCacheKey(cleanNarr, rehState.narratorVoice, '');
if (!stillCurrent()) return;
let blob = await _lineAudioCacheGet(book, cacheKey);
if (!stillCurrent()) return;
if (!blob) {
blob = await fetchTtsPreviewBlob(rehState.narratorVoice, cleanNarr, 'wav', '', _ttsBackendForVoice(rehState.narratorVoice, rehState.backend));
if (!stillCurrent()) return;
_lineAudioCachePut(book, cacheKey, blob);
}
rehState.synthCache.set(myLineIndex, blob);
_markSynthDot(myLineIndex, 'ok');
await playPreDecoded(myLineIndex, blob, line.text);
} catch(_) { await new Promise(r => setTimeout(r, 200)); }
}
} else if (!rehState.skipDescriptions) {
await new Promise(r => setTimeout(r, line.type === 'direction' ? 150 : 250));
}
if (!stillCurrent()) return;
rehState.lineIndex++;
playNextLine();
return;
}
// Dialog line
const cast = rehState.cast[line.speaker] || { voice: '' };
if (cast.voice === 'me') {
rehState.playing = false; updatePlayBtn();
showRecOverlay(line);
return;
}
if (!cast.voice) {
showStatusBar(line.speaker + ' has no voice — skipping…');
await new Promise(r => setTimeout(r, 350));
if (!stillCurrent()) return;
rehState.lineIndex++; playNextLine(); return;
}
const profile = (cast.instruct || '').trim();
const instruct = _buildInstruct(profile, line.emotion, cast.voice);
const cleanTxt = stripMarkdown(line.text);
const cached = rehState.synthCache.get(rehState.lineIndex);
if (cached) {
showStatusBar(line.speaker + ' is speaking…');
await playPreDecoded(rehState.lineIndex, cached, line.text); // ← instant: pre-decoded PCM
rehState.clips.push({ lineIndex: rehState.lineIndex, speaker: line.speaker, type: 'tts', blob: cached });
} else {
showStatusBar('Synthesizing…');
try {
const toneText = _rehInlineTone(cleanTxt, line.emotion);
const book = _lineAudioBookName();
const cacheKey = await _lineAudioCacheKey(toneText, cast.voice, instruct);
if (!stillCurrent()) return;
let blob = await _lineAudioCacheGet(book, cacheKey);
if (!stillCurrent()) return;
if (!blob) {
blob = await fetchTtsPreviewBlob(cast.voice, toneText, 'wav', instruct, _ttsBackendForVoice(cast.voice, rehState.backend));
if (!stillCurrent()) return;
_lineAudioCachePut(book, cacheKey, blob);
}
rehState.synthCache.set(myLineIndex, blob);
showStatusBar(line.speaker + ' is speaking…');
await playPreDecoded(myLineIndex, blob, line.text); // also decodes + pre-fetches next
rehState.clips.push({ lineIndex: myLineIndex, speaker: line.speaker, type: 'tts', blob });
} catch(e) {
showStatusBar('TTS failed: ' + e.message);
await new Promise(r => setTimeout(r, 1000));
}
}
if (!stillCurrent()) return;
rehState.lineIndex++;
playNextLine();
}
function waitForAudioEnd(audio) {
return new Promise(resolve => {
if (!audio || audio.paused || audio.ended) { resolve(); return; }
audio.addEventListener('ended', resolve, { once: true });
audio.addEventListener('pause', resolve, { once: true });
audio.addEventListener('error', resolve, { once: true });
});
}
function showStatusBar(msg) {
const bar = $('reh-tts-status-bar'); if (!bar) return;
bar.hidden = false;
const txt = $('reh-tts-status-txt'); if (txt) txt.textContent = msg;
}
function highlightCurrentLine() {
const i = rehState.lineIndex, total = rehState.lines.length;
const prog = $('reh-tb-progress');
if (prog) prog.style.width = (total ? (i / total) * 100 : 0) + '%';
const lbl = $('reh-tb-label');
if (lbl) lbl.textContent = `${i + 1} / ${total}`;
document.querySelectorAll('[data-index]').forEach(el => {
el.classList.toggle('reh-line-active', parseInt(el.dataset.index) === i);
});
// Dialogue's own play button shows play/pause via a pure-CSS badge
// overlay on the avatar (.reh-line-active .reh-line-play-avatar::after),
// but the plain narrator play button (.reh-action-play-btn, no avatar to
// overlay onto) never got the same treatment — its icon just stayed a
// static play triangle even while that exact line was the one actively
// playing, with nothing to show that clicking it again would stop it.
// Confirmed live as the reported "no way to stop it" complaint.
document.querySelectorAll('.reh-action-play-btn').forEach(btn => {
const icon = btn.querySelector('.mdi');
if (!icon) return;
const isActive = rehState.playing && parseInt(btn.dataset.index) === i;
icon.className = isActive ? 'mdi mdi-stop' : 'mdi mdi-play';
btn.title = isActive ? 'Stop' : 'Play or pause this line';
});
const active = document.querySelector(`[data-index="${i}"]`);
if (active) active.scrollIntoView({ behavior: 'smooth', block: 'center' });
updatePlayBtn();
}
function updatePlayBtn() {
const btn = $('reh-tb-play'); if (!btn) return;
btn.innerHTML = rehState.playing ? '' : '';
btn.title = rehState.playing ? 'Pause' : 'Play all';
}
// ── Persistent per-paragraph audio cache ────────────────────────────────────
//
// rehState.synthCache only ever lived in the browser tab's memory — closing
// the tab, reloading, or a crash threw away everything "Synth all" had
// already paid GPU time for, forcing a full re-synthesis (and the pause
// between paragraphs that comes with it) the next time regardless. This
// persists each line's audio to disk, keyed by a hash of exactly what
// determines its sound (text + voice + tone/instruct) rather than its
// position in the script. An untouched paragraph's key never changes, so it
// keeps reusing the same cached file indefinitely; an edited paragraph's
// key changes the instant the text (or voice/tone) does, so it simply never
// matches a cached file again and gets synthesized fresh next time — no
// separate "delete the old file" step needed, the old file just becomes
// unreachable dead weight rather than ever being served again.
async function _lineAudioCacheKey(text, voice, instruct) {
const enc = new TextEncoder().encode(`${text}\0${voice}\0${instruct || ''}`);
const digest = await crypto.subtle.digest('SHA-256', enc);
return [...new Uint8Array(digest)].map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 32);
}
function _lineAudioBookName() {
return $('reh-script-title')?.value.trim() || 'Untitled';
}
async function _lineAudioCacheGet(book, key) {
try {
const r = await fetch(`/api/line-audio/${encodeURIComponent(book)}/${key}`);
if (!r.ok) return null;
return await r.blob();
} catch (_) { return null; }
}
function _lineAudioCachePut(book, key, blob) {
// Fire-and-forget — a failed save just means this line resynthesizes next
// time too, no worse than before this cache existed at all.
fetch(`/api/line-audio/${encodeURIComponent(book)}/${key}`, { method: 'POST', body: blob }).catch(() => {});
}
// Every line's "pre-synthesized" green dot only ever reflected
// rehState.synthCache — this browser tab's own memory, empty on every fresh
// page load — so a script that was fully "Synth all"-ed and safely
// persisted to disk in an EARLIER session still looked completely
// unsynthesized after a reload, with no way to tell the cached audio was
// actually right there. This asks the server, in one batch request, which
// of the current script's lines already have a matching file, and lights
// up their dots — without downloading any actual audio (that still only
// happens lazily, right when a line is about to play).
let _lineAudioSyncedFor = null; // unused now, kept for compat
async function _lineAudioSyncDots() {
const book = _lineAudioBookName();
_ensureNarrator();
const idxToKey = new Map();
for (let i = 0; i < rehState.lines.length; i++) {
const line = rehState.lines[i];
if (line.ignored || line.hidden) continue;
if (rehState.synthCache.has(i)) continue; // already known-good this session
let voice, instruct, text;
if (line.type === 'dialog') {
const c = rehState.cast[line.speaker];
if (!c || !c.voice || c.voice === 'me') continue;
voice = c.voice;
instruct = _buildInstruct(c.instruct, line.emotion, c.voice);
text = _rehInlineTone(stripMarkdown(line.text), line.emotion);
} else {
if (!rehState.narratorVoice || !(line.text || '').trim()) continue;
voice = rehState.narratorVoice; instruct = '';
text = stripMarkdown(line.text);
}
idxToKey.set(i, await _lineAudioCacheKey(text, voice, instruct));
}
if (!idxToKey.size) return;
const keyToIdx = new Map([...idxToKey].map(([i, k]) => [k, i]));
try {
const r = await fetch(`/api/line-audio/${encodeURIComponent(book)}/check`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keys: [...idxToKey.values()] }),
});
if (!r.ok) return;
const { existing } = await r.json();
(existing || []).forEach(key => {
const idx = keyToIdx.get(key);
if (idx == null) return;
rehState.staleLines.delete(idx);
_markSynthDot(idx, 'ok');
});
} catch (_) { /* dots just stay as-is; playback still checks the disk cache lazily either way */ }
}
// ── Synthesize All ─────────────────────────────────────────────────────────
async function synthAll() {
if (rehState.synthRunning) return;
if (!rehState.backend) { toast('Select a TTS backend first', 'error'); return; }
_ensureNarrator(); // sync narrator voice from its cast row before synthesizing
const ttsLines = rehState.lines.map((l, i) => ({ line:l, idx:i })).filter(({ line }) => {
if (line.ignored || line.hidden) return false; // bulk-edit: never synth ignored/hidden lines
if (line.type === 'dialog') {
const c = rehState.cast[line.speaker];
return c && c.voice && c.voice !== 'me';
}
// All non-dialog lines with text are narrated when a narrator voice is set
return !!(rehState.narratorVoice && (line.text || '').trim());
});
if (!ttsLines.length) { toast('No TTS lines to synthesize', 'error'); return; }
rehState.synthRunning = true; rehState.synthCancelled = false;
const synthBar = $('reh-synth-bar'), fill = $('reh-synth-fill'), label = $('reh-synth-label');
if (synthBar) synthBar.hidden = false;
const prog = (d, t) => {
if (fill) fill.style.width = (t ? (d/t)*100 : 0) + '%';
if (label) label.textContent = `${d} / ${t} synthesized`;
};
prog(0, ttsLines.length);
let done = 0;
const book = _lineAudioBookName();
// Wrapped in try/finally so a thrown error (e.g. the speaker's cast entry
// got deleted mid-run — see the `!c` guard below) can never leave
// synthRunning stuck true forever, which would silently no-op every future
// "Synthesize All" / "Re-synthesize stale" click with no error shown, same
// failure shape as the character-sheets generation-lock bug fixed earlier.
try {
for (const { line, idx } of ttsLines) {
if (rehState.synthCancelled) break;
let voice, instruct;
if (line.type === 'dialog') {
const c = rehState.cast[line.speaker];
if (!c || !c.voice || c.voice === 'me') { _markSynthDot(idx, null); prog(++done, ttsLines.length); continue; }
voice = c.voice;
instruct = _buildInstruct(c.instruct, line.emotion, c.voice);
} else {
voice = rehState.narratorVoice; instruct = '';
}
_markSynthDot(idx, 'synthesizing');
document.getElementById('reh-syd-' + idx)?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
const toneText = _rehInlineTone(stripMarkdown(line.text), line.emotion);
try {
const cacheKey = await _lineAudioCacheKey(toneText, voice, instruct);
let blob = await _lineAudioCacheGet(book, cacheKey);
if (!blob) {
blob = await fetchTtsPreviewBlob(voice, toneText, 'wav', instruct, _ttsBackendForVoice(voice, rehState.backend));
_lineAudioCachePut(book, cacheKey, blob);
}
rehState.synthCache.set(idx, blob);
rehState.staleLines.delete(idx);
_markSynthDot(idx, 'ok');
_showReSynthBtn(idx, false);
preDecodeBlob(idx, blob); // decode to PCM immediately → zero-latency playback
} catch(_) { _markSynthDot(idx, null); }
prog(++done, ttsLines.length);
}
} finally {
if (synthBar) synthBar.hidden = true;
rehState.synthRunning = false;
}
if (!rehState.synthCancelled) toast(`Pre-synthesized ${done} of ${ttsLines.length} lines — ready for instant playback`, 'success');
}
function _updateStaleBatchBtn() {
const btn = $('reh-tb-resynth-stale');
if (btn) btn.hidden = rehState.staleLines.size === 0;
}
$('reh-tb-synth-all')?.addEventListener('click', () => synthAll());
$('reh-tb-resynth-stale')?.addEventListener('click', async () => {
if (rehState.synthRunning) return;
const stale = [...rehState.staleLines];
if (!stale.length) return;
rehState.synthRunning = true; rehState.synthCancelled = false;
const synthBar = $('reh-synth-bar'), fill = $('reh-synth-fill'), label = $('reh-synth-label');
if (synthBar) synthBar.hidden = false;
let done = 0;
const book = _lineAudioBookName();
try {
for (const idx of stale) {
if (rehState.synthCancelled) break;
const line = rehState.lines[idx];
if (!line || line.type !== 'dialog') { rehState.staleLines.delete(idx); continue; }
const c = rehState.cast[line.speaker];
if (!c || !c.voice || c.voice === 'me') { rehState.staleLines.delete(idx); continue; }
const instruct = [c.instruct||'', line.emotion||''].filter(Boolean).join('. ');
_markSynthDot(idx, 'synthesizing');
const toneText = _rehInlineTone(stripMarkdown(line.text), line.emotion);
try {
const cacheKey = await _lineAudioCacheKey(toneText, c.voice, instruct);
let blob = await _lineAudioCacheGet(book, cacheKey);
if (!blob) {
blob = await fetchTtsPreviewBlob(c.voice, toneText, 'wav', instruct, _ttsBackendForVoice(c.voice, rehState.backend));
_lineAudioCachePut(book, cacheKey, blob);
}
rehState.synthCache.set(idx, blob);
rehState.staleLines.delete(idx);
preDecodeBlob(idx, blob);
_markSynthDot(idx, 'ok');
_showReSynthBtn(idx, false);
} catch(_) { _markSynthDot(idx, 'stale'); }
if (fill) fill.style.width = ((++done / stale.length) * 100) + '%';
if (label) label.textContent = `${done} / ${stale.length} synthesized`;
}
} finally {
if (synthBar) synthBar.hidden = true;
rehState.synthRunning = false;
_updateStaleBatchBtn();
}
if (!rehState.synthCancelled) toast(`Re-synthesized ${done} stale line${done !== 1 ? 's' : ''}`, 'success');
});
$('reh-synth-cancel')?.addEventListener('click', () => { rehState.synthCancelled = true; rehState.synthRunning = false; });
// Editing a paragraph doesn't delete its OLD cached audio file — the write
// path only ever knows the NEW content's hash, not whatever the line used
// to hash to before the edit, so the stale file just sits there unreferenced
// forever. This computes every key the CURRENT script would actually use
// and asks the server to delete anything else on disk for this book.
$('reh-tb-clean-cache')?.addEventListener('click', async () => {
const btn = $('reh-tb-clean-cache');
if (btn) { btn.disabled = true; btn.innerHTML = ' Scanning…'; }
try {
_ensureNarrator();
const keep = [];
for (const line of rehState.lines) {
if (line.ignored || line.hidden) continue;
let voice, instruct, text;
if (line.type === 'dialog') {
const c = rehState.cast[line.speaker];
if (!c || !c.voice || c.voice === 'me') continue;
voice = c.voice;
instruct = _buildInstruct(c.instruct, line.emotion, c.voice);
text = _rehInlineTone(stripMarkdown(line.text), line.emotion);
} else {
if (!rehState.narratorVoice || !(line.text || '').trim()) continue;
voice = rehState.narratorVoice; instruct = '';
text = stripMarkdown(line.text);
}
keep.push(await _lineAudioCacheKey(text, voice, instruct));
}
const book = _lineAudioBookName();
const r = await fetch(`/api/line-audio/${encodeURIComponent(book)}/prune`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keep }),
});
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText);
const d = await r.json();
toast(d.deleted ? `Cleaned up ${d.deleted} unused cached audio file${d.deleted !== 1 ? 's' : ''}` : 'Nothing to clean up — every cached file is still in use', 'success');
} catch (e) {
toast('Cache cleanup failed: ' + (e.message || e), 'error');
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = ' Clean cache'; }
}
});
window.TTVC = window.TTVC || {};
window.TTVC.rehearser = window.TTVC.rehearser || {};
Object.assign(window.TTVC.rehearser, {
playback: {
start: startPlay,
pause: pausePlay,
stop: stopPlay,
playNext: playNextLine,
synthAll,
syncCacheDots: _lineAudioSyncDots,
},
});