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]
|
## [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
|
||||||
|
|||||||
5
TODO.md
5
TODO.md
@ -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.
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
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(); }
|
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} | |||||||