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