diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b9c736..ed60db9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ## [Unreleased] ### Changed +- **Rehearser playback extraction.** WebAudio playback, transport controls, bounded decoded-audio prefetch, persistent + line-audio caching, and synth-all controls now live in `rehearser-playback.js`. - **Audiobook namespace wiring.** Network helpers and public casting, recast, export, and save actions now register after their lazy modules load, completing the namespace bridge without removing legacy global entry points. - **Audiobook live cast-view extraction.** The live cast feed, roster, settings, and prompt controls now live in diff --git a/TODO.md b/TODO.md index 9bd0229..39b1ced 100644 --- a/TODO.md +++ b/TODO.md @@ -120,8 +120,9 @@ architecture change: the larger Audiobook/Rehearser/style split. ## P1 — Modular frontend architecture -- [ ] Split `audiobook.js`, `rehearser.js`, and `style.css` by responsibility; the Rehearser cast renderer is now isolated, - with the remaining transport/matching blocks still in the monolith. +- [ ] Split `audiobook.js`, `rehearser.js`, and `style.css` by responsibility; Rehearser casting, playback, training, + voice design, library, and stage blocks are now isolated, with import/event wiring and remaining Audiobook orchestration + still in the monolith. - [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 exposes the existing Audiobook, Rehearser, and job APIs while responsibility-by-responsibility migration continues. diff --git a/VERSION b/VERSION index a359225..e607a19 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.24.49 +1.24.50 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d30255a..c7aebd3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -32,7 +32,8 @@ The global scope + monkey-patching is the root cause of the recurring "wiring/ti Done incrementally as global-scope ordered files (no ES-module change needed) — each loaded before `rehearser.js` in `loader.js` + added to the bundle list in `scripts/minify.mjs`, verified by `tests/rehearser.spec.js` (parse → cast) and the smoke suite: - ✅ `rehearser-parse.js` — `parseScript`, `detectCharacters` (pure; only call-time `SPEAKER_COLORS`). - ✅ `rehearser-cast.js` — `renderCastList`, sort/filter, online voice picker, and character research. -- ✅ `rehearser-stage.js` — stage font/cast sidebar controls and cast-strip rendering; playback and transport remain in `rehearser.js`. +- ✅ `rehearser-stage.js` — stage font/cast sidebar controls and cast-strip rendering. +- ✅ `rehearser-playback.js` — WebAudio playback, transport state, bounded decoded-audio prefetch, and persistent line-audio cache/synthesis controls. - ✅ `rehearser-voice-picker.js` — voice preview state, local matching, fish.audio search/import, and candidate assignment; DOM event registration remains in `rehearser-cast.js`. - ✅ `rehearser-cast.js` — cast view, full cast-card rendering, sort/filter state handlers, and delegated cast toolbar wiring. - ✅ `rehearser-voice-design.js` — character development, backend selection, and backend-refresh synchronization. diff --git a/static/index.html b/static/index.html index 655721c..577fd77 100644 --- a/static/index.html +++ b/static/index.html @@ -10,7 +10,7 @@ - + @@ -27,12 +27,12 @@ - - - - - - + + + + + + @@ -397,7 +397,7 @@ window.toggleNavTree = function(treeId, chevronId) { - + diff --git a/static/js/rehearser-playback.js b/static/js/rehearser-playback.js new file mode 100644 index 0000000..542c084 --- /dev/null +++ b/static/js/rehearser-playback.js @@ -0,0 +1,707 @@ +// 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, + }, +}); diff --git a/static/js/rehearser.js b/static/js/rehearser.js index 351e8a8..f4566ee 100644 --- a/static/js/rehearser.js +++ b/static/js/rehearser.js @@ -2826,697 +2826,6 @@ $('reh-page-title')?.addEventListener('keydown', function (e) { if (e.key === 'Escape') { this.textContent = $('reh-script-title')?.value || 'Script'; this.blur(); } }); -// ── 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}${voice}${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'; } - } -}); - // ── Recording overlay ─────────────────────────────────────────────────────── function showRecOverlay(line) { diff --git a/static/module-manifest.json b/static/module-manifest.json index a786651..971da8d 100644 --- a/static/module-manifest.json +++ b/static/module-manifest.json @@ -4,9 +4,9 @@ "integrations", "routing", "voice-clone", "voice-library", "tts-preview", "generation", "benchmark", "stt", "namespace" ], "lazy": { - "s-rehearser": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker"], - "s-reader": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook", "character-sheets"], - "s-library": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern"], - "s-caststudio": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern", "studio"] + "s-rehearser": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser-playback", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker"], + "s-reader": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser-playback", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook", "character-sheets"], + "s-library": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser-playback", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern"], + "s-caststudio": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser-playback", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern", "studio"] } } diff --git a/tests/lazy-loading.spec.js b/tests/lazy-loading.spec.js index 4b0ca12..3d68943 100644 --- a/tests/lazy-loading.spec.js +++ b/tests/lazy-loading.spec.js @@ -36,3 +36,16 @@ test('Audiobook lazy modules publish their namespace APIs after loading', async startCast: typeof window.TTVC?.audiobook?.startCast, }))).toEqual({ castView: 'function', stream: 'function', startCast: 'function' }); }); + +test('Rehearser playback module publishes transport APIs after loading', async ({ page }) => { + await page.goto('/', { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 }); + await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 }); + await page.evaluate(() => window.navTo('s-rehearser')); + await page.waitForFunction(() => window.rehState?.synthCache, null, { timeout: 15_000 }); + await expect.poll(() => page.evaluate(() => ({ + start: typeof window.TTVC?.rehearser?.playback?.start, + stop: typeof window.TTVC?.rehearser?.playback?.stop, + synthAll: typeof window.TTVC?.rehearser?.playback?.synthAll, + }))).toEqual({ start: 'function', stop: 'function', synthAll: 'function' }); +});