Removes 43 small Sonar findings (unused locals, dead helpers, a misread comment); behaviour unchanged. CHANGELOG entry was already swept into 9f78a15.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
734 lines
30 KiB
JavaScript
734 lines
30 KiB
JavaScript
// Rehearser playback, transport, and persistent line-audio cache.
|
|
// Loaded before rehearser.js; handlers resolve shared state and helpers at call time.
|
|
|
|
(function (root) {
|
|
|
|
// ── WebAudio pre-decode (eliminates 2-3s silence between lines) ─────────────
|
|
|
|
let _rehPlayCtx = null;
|
|
const rehDecodedBuffers = new Map(); // lineIndex → AudioBuffer (pre-decoded PCM)
|
|
let rehCurrentSource = null; // active AudioBufferSourceNode
|
|
let rehWordHighlightRaf = null;
|
|
|
|
function rehPlayCtx() {
|
|
if (!_rehPlayCtx) _rehPlayCtx = new (window.AudioContext || window.webkitAudioContext)();
|
|
if (_rehPlayCtx.state === 'suspended') _rehPlayCtx.resume().catch(() => {});
|
|
return _rehPlayCtx;
|
|
}
|
|
|
|
// Decoded PCM (Float32) is the heaviest cache and is re-derivable from the cached
|
|
// blob, so keep only a sliding window around the playhead. Without this, a long
|
|
// script holds every line's raw PCM in memory at once and crashes mobile Safari.
|
|
const REH_DECODE_WINDOW = 8;
|
|
function _rehEvictDecoded(keepIdx) {
|
|
if (rehDecodedBuffers.size <= REH_DECODE_WINDOW * 2 + 4) return;
|
|
const lo = keepIdx - REH_DECODE_WINDOW, hi = keepIdx + REH_DECODE_WINDOW;
|
|
for (const k of rehDecodedBuffers.keys()) {
|
|
if (k < lo || k > hi) rehDecodedBuffers.delete(k);
|
|
}
|
|
}
|
|
|
|
async function preDecodeBlob(lineIdx, blob) {
|
|
if (rehDecodedBuffers.has(lineIdx)) return;
|
|
try {
|
|
const ab = await blob.arrayBuffer();
|
|
const buf = await rehPlayCtx().decodeAudioData(ab);
|
|
rehDecodedBuffers.set(lineIdx, buf);
|
|
_rehEvictDecoded(lineIdx);
|
|
} catch(_) {}
|
|
}
|
|
|
|
function computeWordTimings(text, durationSec) {
|
|
const words = stripMarkdown(text).split(/\s+/).filter(Boolean);
|
|
if (words.length < 2) return [];
|
|
// Distribute proportionally by character length (longer words = more time)
|
|
const totalChars = words.reduce((s, w) => s + w.length, 0) || 1;
|
|
let t = 0;
|
|
return words.map(w => {
|
|
const start = t;
|
|
t += (w.length / totalChars) * durationSec;
|
|
return { word: w, start, end: t };
|
|
});
|
|
}
|
|
|
|
function stopAudioSource() {
|
|
if (rehCurrentSource) {
|
|
try { rehCurrentSource.stop(0); } catch(_) {}
|
|
rehCurrentSource = null;
|
|
}
|
|
if (rehWordHighlightRaf) { cancelAnimationFrame(rehWordHighlightRaf); rehWordHighlightRaf = null; }
|
|
}
|
|
|
|
// Instant playback from pre-decoded buffer + word-level highlight
|
|
async function playPreDecoded(lineIdx, blob, text) {
|
|
if (!rehDecodedBuffers.has(lineIdx)) await preDecodeBlob(lineIdx, blob);
|
|
_rehEvictDecoded(lineIdx); // keep the window centred on the playhead
|
|
const buf = rehDecodedBuffers.get(lineIdx);
|
|
if (!buf) { await playAudioBlobFallback(blob); return; }
|
|
|
|
const timings = computeWordTimings(text, buf.duration);
|
|
const dialogEl = document.getElementById('reh-diag-' + lineIdx);
|
|
|
|
if (dialogEl && timings.length >= 2) {
|
|
dialogEl.innerHTML = timings.map((t, i) =>
|
|
`<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).
|
|
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'; }
|
|
}
|
|
});
|
|
|
|
|
|
// Keep the classic-script integration surface available while the implementation
|
|
// itself stays private to this module. New code should use TTVC.rehearser.playback.
|
|
Object.assign(root, {
|
|
rehPlayCtx,
|
|
rehDecodedBuffers,
|
|
preDecodeBlob,
|
|
computeWordTimings,
|
|
stopAudioSource,
|
|
playPreDecoded,
|
|
startPlay,
|
|
pausePlay,
|
|
stopPlay,
|
|
playNextLine,
|
|
highlightCurrentLine,
|
|
updatePlayBtn,
|
|
_lineAudioCacheKey,
|
|
_lineAudioBookName,
|
|
_lineAudioCacheGet,
|
|
_lineAudioCachePut,
|
|
_lineAudioSyncDots,
|
|
synthAll,
|
|
_updateStaleBatchBtn,
|
|
});
|
|
|
|
root.TTVC = root.TTVC || {};
|
|
root.TTVC.rehearser = root.TTVC.rehearser || {};
|
|
Object.assign(root.TTVC.rehearser, {
|
|
playback: {
|
|
start: startPlay,
|
|
pause: pausePlay,
|
|
stop: stopPlay,
|
|
playNext: playNextLine,
|
|
synthAll,
|
|
syncCacheDots: _lineAudioSyncDots,
|
|
},
|
|
});
|
|
})(window);
|