refactor: extract rehearser playback

This commit is contained in:
mARTin-B78 2026-09-04 11:59:09 +02:00
parent e096b3e529
commit f97fd60e43
9 changed files with 740 additions and 707 deletions

View File

@ -8,6 +8,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased] ## [Unreleased]
### Changed ### 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 - **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. 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 - **Audiobook live cast-view extraction.** The live cast feed, roster, settings, and prompt controls now live in

View File

@ -120,8 +120,9 @@ architecture change: the larger Audiobook/Rehearser/style split.
## P1 — Modular frontend architecture ## P1 — Modular frontend architecture
- [ ] Split `audiobook.js`, `rehearser.js`, and `style.css` by responsibility; the Rehearser cast renderer is now isolated, - [ ] Split `audiobook.js`, `rehearser.js`, and `style.css` by responsibility; Rehearser casting, playback, training,
with the remaining transport/matching blocks still in the monolith. 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`. - [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 - [ ] 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. exposes the existing Audiobook, Rehearser, and job APIs while responsibility-by-responsibility migration continues.

View File

@ -1 +1 @@
1.24.49 1.24.50

View File

@ -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: 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-parse.js` — `parseScript`, `detectCharacters` (pure; only call-time `SPEAKER_COLORS`).
- ✅ `rehearser-cast.js` — `renderCastList`, sort/filter, online voice picker, and character research. - ✅ `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-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-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. - ✅ `rehearser-voice-design.js` — character development, backend selection, and backend-refresh synchronization.

View File

@ -10,7 +10,7 @@
<meta name="format-detection" content="telephone=no"> <meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2563EB"> <meta name="theme-color" content="#2563EB">
<meta name="app-version" content="1.24.49"> <meta name="app-version" content="1.24.50">
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml"> <link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon.svg"> <link rel="apple-touch-icon" href="/static/icon.svg">
@ -27,12 +27,12 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── --> <!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css"> <link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.24.49"> <link rel="stylesheet" href="/static/style.css?v=1.24.50">
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.24.49"> <link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.24.50">
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.24.49"> <link rel="stylesheet" href="/static/css/background-jobs.css?v=1.24.50">
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.24.49"> <link rel="stylesheet" href="/static/css/voice-design.css?v=1.24.50">
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.24.49"> <link rel="stylesheet" href="/static/css/rehearser.css?v=1.24.50">
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.24.49"> <link rel="stylesheet" href="/static/css/audiobook.css?v=1.24.50">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -397,7 +397,7 @@ window.toggleNavTree = function(treeId, chevronId) {
</script> </script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton --> <!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js?v=1.24.49"></script> <script src="/static/loader.js?v=1.24.50"></script>
</body> </body>
</html> </html>

View File

@ -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) =>
`<span class="reh-word" data-wi="${i}">${escHtml(t.word)}</span>`
).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 ? '<span class="mdi mdi-pause"></span>' : '<span class="mdi mdi-play"></span>';
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 = '<span class="mdi mdi-loading mdi-spin"></span> 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 = '<span class="mdi mdi-broom"></span> 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,
},
});

View File

@ -2826,697 +2826,6 @@ $('reh-page-title')?.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { this.textContent = $('reh-script-title')?.value || 'Script'; this.blur(); } 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) =>
`<span class="reh-word" data-wi="${i}">${escHtml(t.word)}</span>`
).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 ? '<span class="mdi mdi-pause"></span>' : '<span class="mdi mdi-play"></span>';
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 = '<span class="mdi mdi-loading mdi-spin"></span> 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 = '<span class="mdi mdi-broom"></span> Clean cache'; }
}
});
// ── Recording overlay ─────────────────────────────────────────────────────── // ── Recording overlay ───────────────────────────────────────────────────────
function showRecOverlay(line) { function showRecOverlay(line) {

View File

@ -4,9 +4,9 @@
"integrations", "routing", "voice-clone", "voice-library", "tts-preview", "generation", "benchmark", "stt", "namespace" "integrations", "routing", "voice-clone", "voice-library", "tts-preview", "generation", "benchmark", "stt", "namespace"
], ],
"lazy": { "lazy": {
"s-rehearser": ["rehearser-parse", "rehearser-cast", "rehearser-train", "rehearser-voice-design", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker"], "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", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook-segmentation", "audiobook-cast", "audiobook", "character-sheets"], "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", "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-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", "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-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"]
} }
} }

View File

@ -36,3 +36,16 @@ test('Audiobook lazy modules publish their namespace APIs after loading', async
startCast: typeof window.TTVC?.audiobook?.startCast, startCast: typeof window.TTVC?.audiobook?.startCast,
}))).toEqual({ castView: 'function', stream: 'function', startCast: 'function' }); }))).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' });
});