// Rehearser playback, transport, and persistent line-audio cache. // Loaded before rehearser.js; handlers resolve shared state and helpers at call time. (function (root) { // ── 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). 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'; } } }); // Keep the classic-script integration surface available while the implementation // itself stays private to this module. New code should use TTVC.rehearser.playback. Object.assign(root, { rehPlayCtx, rehDecodedBuffers, preDecodeBlob, computeWordTimings, stopAudioSource, playPreDecoded, startPlay, pausePlay, stopPlay, playNextLine, highlightCurrentLine, updatePlayBtn, _lineAudioCacheKey, _lineAudioBookName, _lineAudioCacheGet, _lineAudioCachePut, _lineAudioSyncDots, synthAll, _updateStaleBatchBtn, }); root.TTVC = root.TTVC || {}; root.TTVC.rehearser = root.TTVC.rehearser || {}; Object.assign(root.TTVC.rehearser, { playback: { start: startPlay, pause: pausePlay, stop: stopPlay, playNext: playNextLine, synthAll, syncCacheDots: _lineAudioSyncDots, }, }); })(window);