338 lines
11 KiB
JavaScript
338 lines
11 KiB
JavaScript
|
|
'use strict';
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Read-aloud (TTS) — server-side Piper synthesis, one sentence per request.
|
||
|
|
// EPUB only (no block model for PDFs). Highlights the sentence currently
|
||
|
|
// playing and scrolls it into view; playback advances sentence-by-sentence,
|
||
|
|
// then block-by-block, until stopped or the book ends.
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
const TTS_MAX_CHARS = 480; // stay under the server's 500-char cap with margin
|
||
|
|
|
||
|
|
let ttsActive = false;
|
||
|
|
let ttsPaused = false;
|
||
|
|
let ttsRunToken = 0;
|
||
|
|
let ttsAudio = null;
|
||
|
|
let ttsCurrentMark = null;
|
||
|
|
let ttsBarEl = null;
|
||
|
|
let ttsLang = localStorage.getItem('diora_tts_lang') || 'de';
|
||
|
|
let _ttsBlocks = null; // the book's blocks while reading aloud
|
||
|
|
let _ttsCur = null; // what's being read: {bi, si, sentence, block}
|
||
|
|
|
||
|
|
function setTtsLang(lang) {
|
||
|
|
ttsLang = lang;
|
||
|
|
localStorage.setItem('diora_tts_lang', lang);
|
||
|
|
}
|
||
|
|
|
||
|
|
function _ttsSplitSentences(text) {
|
||
|
|
const raw = text.split(/(?<=[.!?])\s+/).map(s => s.trim()).filter(Boolean);
|
||
|
|
const pieces = raw.length ? raw : [text];
|
||
|
|
const out = [];
|
||
|
|
for (const piece of pieces) {
|
||
|
|
let rest = piece;
|
||
|
|
while (rest.length > TTS_MAX_CHARS) {
|
||
|
|
let cut = rest.lastIndexOf(' ', TTS_MAX_CHARS);
|
||
|
|
if (cut <= 0) cut = TTS_MAX_CHARS;
|
||
|
|
out.push(rest.slice(0, cut).trim());
|
||
|
|
rest = rest.slice(cut).trim();
|
||
|
|
}
|
||
|
|
if (rest) out.push(rest);
|
||
|
|
}
|
||
|
|
return out;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function _ttsFetchAudio(sentence) {
|
||
|
|
const resp = await fetch('/tts/synthesize/', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: {'Content-Type': 'application/json'},
|
||
|
|
body: JSON.stringify({text: sentence, lang: ttsLang}),
|
||
|
|
});
|
||
|
|
if (!resp.ok) throw new Error('tts request failed');
|
||
|
|
return await resp.blob();
|
||
|
|
}
|
||
|
|
|
||
|
|
function _ttsClearHighlight() {
|
||
|
|
if (ttsCurrentMark && ttsCurrentMark.parentNode) {
|
||
|
|
const parent = ttsCurrentMark.parentNode;
|
||
|
|
while (ttsCurrentMark.firstChild) parent.insertBefore(ttsCurrentMark.firstChild, ttsCurrentMark);
|
||
|
|
parent.removeChild(ttsCurrentMark);
|
||
|
|
parent.normalize();
|
||
|
|
}
|
||
|
|
ttsCurrentMark = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Finds `sentence` as a substring of block.textContent starting at fromOffset
|
||
|
|
// (so repeated sentence text earlier in the block isn't matched again), turns
|
||
|
|
// it into a DOM Range via the same char-offset addressing highlights use, and
|
||
|
|
// wraps it in a <mark>. Returns the offset to resume searching from.
|
||
|
|
function _ttsHighlightSentence(block, sentence, fromOffset) {
|
||
|
|
// Listening is reading: where the voice is counts as the reading position.
|
||
|
|
_noteReaderInteraction();
|
||
|
|
_ttsClearHighlight();
|
||
|
|
const full = block.textContent;
|
||
|
|
const idx = full.indexOf(sentence, fromOffset);
|
||
|
|
if (idx === -1) return fromOffset;
|
||
|
|
const start = _nodeAtCharOffset(block, idx);
|
||
|
|
const end = _nodeAtCharOffset(block, idx + sentence.length);
|
||
|
|
if (!start || !end) return idx + sentence.length;
|
||
|
|
try {
|
||
|
|
const range = document.createRange();
|
||
|
|
range.setStart(start.node, start.offset);
|
||
|
|
range.setEnd(end.node, end.offset);
|
||
|
|
const mark = document.createElement('mark');
|
||
|
|
mark.className = 'tts-current';
|
||
|
|
range.surroundContents(mark);
|
||
|
|
ttsCurrentMark = mark;
|
||
|
|
_ttsScrollIntoView(mark);
|
||
|
|
} catch (e) {}
|
||
|
|
return idx + sentence.length;
|
||
|
|
}
|
||
|
|
|
||
|
|
function _ttsScrollIntoView(el) {
|
||
|
|
const contentEl = $('reader-content');
|
||
|
|
if (!contentEl) return;
|
||
|
|
const top = el.getBoundingClientRect().top - contentEl.getBoundingClientRect().top;
|
||
|
|
if (top < 40 || top > contentEl.clientHeight - 80) {
|
||
|
|
_suppressScrollJumpDetect();
|
||
|
|
contentEl.scrollBy({top: top - contentEl.clientHeight * 0.3, behavior: 'smooth'});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function _ttsPlayBlob(blob) {
|
||
|
|
return new Promise((resolve) => {
|
||
|
|
const url = URL.createObjectURL(blob);
|
||
|
|
const audio = new Audio(url);
|
||
|
|
ttsAudio = audio;
|
||
|
|
const done = () => { URL.revokeObjectURL(url); resolve(); };
|
||
|
|
audio.addEventListener('ended', done);
|
||
|
|
audio.addEventListener('error', done);
|
||
|
|
if (!ttsPaused) audio.play().catch(done);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Walks blocks/sentences from startBlockIndex (and its sentence startSentence)
|
||
|
|
// onward, fetching one sentence ahead while the current one plays so there's
|
||
|
|
// no gap between them.
|
||
|
|
async function _ttsPlayLoop(blocks, startBlockIndex, runToken, startSentence = 0) {
|
||
|
|
function* sentenceStream() {
|
||
|
|
for (let bi = startBlockIndex; bi < blocks.length; bi++) {
|
||
|
|
const block = blocks[bi];
|
||
|
|
const text = (block.textContent || '').trim();
|
||
|
|
if (!text) continue;
|
||
|
|
const sentences = _ttsSplitSentences(text);
|
||
|
|
for (let si = bi === startBlockIndex ? startSentence : 0; si < sentences.length; si++) {
|
||
|
|
yield {block, sentence: sentences[si], bi, si};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const iter = sentenceStream();
|
||
|
|
let cur = iter.next();
|
||
|
|
if (cur.done) { stopReadAloud(); return; }
|
||
|
|
let curFetch = _ttsFetchAudio(cur.value.sentence);
|
||
|
|
let lastBlock = null;
|
||
|
|
let blockSearchOffset = 0;
|
||
|
|
|
||
|
|
while (!cur.done) {
|
||
|
|
if (runToken !== ttsRunToken) return;
|
||
|
|
const {block, sentence} = cur.value;
|
||
|
|
const next = iter.next();
|
||
|
|
const nextFetch = next.done ? null : _ttsFetchAudio(next.value.sentence).catch(() => null);
|
||
|
|
|
||
|
|
let audioBlob;
|
||
|
|
try {
|
||
|
|
audioBlob = await curFetch;
|
||
|
|
} catch (e) {
|
||
|
|
stopReadAloud();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (runToken !== ttsRunToken) return;
|
||
|
|
|
||
|
|
if (block !== lastBlock) {
|
||
|
|
lastBlock = block;
|
||
|
|
// Started in the middle of a block (previous/next sentence): find it from there.
|
||
|
|
blockSearchOffset = _ttsOffsetOf(block, cur.value.si);
|
||
|
|
}
|
||
|
|
_ttsCur = {bi: cur.value.bi, si: cur.value.si, sentence, block};
|
||
|
|
blockSearchOffset = _ttsHighlightSentence(block, sentence, blockSearchOffset);
|
||
|
|
_ttsNotify();
|
||
|
|
|
||
|
|
await _ttsPlayBlob(audioBlob);
|
||
|
|
if (runToken !== ttsRunToken) return;
|
||
|
|
|
||
|
|
cur = next;
|
||
|
|
curFetch = nextFetch;
|
||
|
|
}
|
||
|
|
if (runToken === ttsRunToken) stopReadAloud();
|
||
|
|
}
|
||
|
|
|
||
|
|
function _ttsTogglePause() {
|
||
|
|
ttsPaused = !ttsPaused;
|
||
|
|
if (ttsAudio) {
|
||
|
|
if (ttsPaused) ttsAudio.pause();
|
||
|
|
else ttsAudio.play().catch(() => {});
|
||
|
|
}
|
||
|
|
_ttsUpdateBar();
|
||
|
|
_ttsNotify();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---- read-aloud for a player bar (the shared dock in native-podcast.js) ----------
|
||
|
|
// window.__dioraTts: state(), toggle(), stop(), next(), prev(); a 'diora-tts'
|
||
|
|
// event on window after every change. A dock that shows it sets
|
||
|
|
// window.__dioraTtsDock = true, and the reader's own little bar stays away.
|
||
|
|
|
||
|
|
function _ttsState() {
|
||
|
|
const meta = (currentBookId && bookMetaCache[currentBookId]) || {};
|
||
|
|
const cur = _ttsCur;
|
||
|
|
const chapterEl = cur && cur.block.closest('[data-epub-src]');
|
||
|
|
const blocks = _ttsBlocks || [];
|
||
|
|
return {
|
||
|
|
active: ttsActive,
|
||
|
|
paused: ttsPaused,
|
||
|
|
bookId: currentBookId,
|
||
|
|
title: meta.title || '',
|
||
|
|
author: meta.author || '',
|
||
|
|
chapter: chapterEl ? _tocTitleForChapterSrc(chapterEl.dataset.epubSrc) : '',
|
||
|
|
sentence: cur ? cur.sentence : '',
|
||
|
|
progress: cur && blocks.length ? cur.bi / blocks.length : 0,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function _ttsNotify() {
|
||
|
|
try { window.dispatchEvent(new CustomEvent('diora-tts', {detail: _ttsState()})); } catch (e) {}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Read from another sentence on: the one before/after the current one.
|
||
|
|
function _ttsRestartAt(bi, si) {
|
||
|
|
const blocks = _ttsBlocks;
|
||
|
|
if (!ttsActive || !blocks) return;
|
||
|
|
ttsRunToken++;
|
||
|
|
const runToken = ttsRunToken;
|
||
|
|
if (ttsAudio) {
|
||
|
|
try { ttsAudio.pause(); ttsAudio.src = ''; } catch (e) {}
|
||
|
|
ttsAudio = null;
|
||
|
|
}
|
||
|
|
_ttsPlayLoop(blocks, bi, runToken, si);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Where sentence si of a block starts in its text (for the highlight).
|
||
|
|
function _ttsOffsetOf(block, si) {
|
||
|
|
const full = block.textContent || '';
|
||
|
|
let off = 0;
|
||
|
|
_ttsSplitSentences(full.trim()).slice(0, si).forEach(s => {
|
||
|
|
const k = full.indexOf(s, off);
|
||
|
|
if (k >= 0) off = k + s.length;
|
||
|
|
});
|
||
|
|
return off;
|
||
|
|
}
|
||
|
|
|
||
|
|
function _ttsSentenceCount(block) {
|
||
|
|
const text = (block.textContent || '').trim();
|
||
|
|
return text ? _ttsSplitSentences(text).length : 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
function _ttsStep(dir) {
|
||
|
|
const cur = _ttsCur;
|
||
|
|
const blocks = _ttsBlocks;
|
||
|
|
if (!cur || !blocks) return;
|
||
|
|
if (dir > 0) {
|
||
|
|
if (cur.si + 1 < _ttsSentenceCount(cur.block)) return _ttsRestartAt(cur.bi, cur.si + 1);
|
||
|
|
for (let bi = cur.bi + 1; bi < blocks.length; bi++) if (_ttsSentenceCount(blocks[bi])) return _ttsRestartAt(bi, 0);
|
||
|
|
stopReadAloud();
|
||
|
|
} else {
|
||
|
|
if (cur.si > 0) return _ttsRestartAt(cur.bi, cur.si - 1);
|
||
|
|
for (let bi = cur.bi - 1; bi >= 0; bi--) {
|
||
|
|
const n = _ttsSentenceCount(blocks[bi]);
|
||
|
|
if (n) return _ttsRestartAt(bi, n - 1);
|
||
|
|
}
|
||
|
|
_ttsRestartAt(cur.bi, 0);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
window.__dioraTts = {
|
||
|
|
state: _ttsState,
|
||
|
|
toggle: () => (ttsActive ? _ttsTogglePause() : startReadAloud()),
|
||
|
|
stop: () => stopReadAloud(),
|
||
|
|
next: () => _ttsStep(1),
|
||
|
|
prev: () => _ttsStep(-1),
|
||
|
|
};
|
||
|
|
|
||
|
|
function _ttsUpdateBar() {
|
||
|
|
if (!ttsBarEl) return;
|
||
|
|
const btn = ttsBarEl.querySelector('.tts-pause-btn');
|
||
|
|
if (btn) btn.textContent = ttsPaused ? '▶' : '⏸';
|
||
|
|
}
|
||
|
|
|
||
|
|
function _ttsShowBar() {
|
||
|
|
_ttsRemoveBar();
|
||
|
|
if (window.__dioraTtsDock) return; // the player bar shows it
|
||
|
|
const bar = document.createElement('div');
|
||
|
|
bar.className = 'tts-bar';
|
||
|
|
bar.innerHTML = `
|
||
|
|
<button type="button" class="tts-pause-btn" title="Pause/Weiter">⏸</button>
|
||
|
|
<button type="button" class="tts-stop-btn" title="Vorlesen beenden">■</button>
|
||
|
|
`;
|
||
|
|
document.body.appendChild(bar);
|
||
|
|
ttsBarEl = bar;
|
||
|
|
bar.querySelector('.tts-pause-btn').addEventListener('click', _ttsTogglePause);
|
||
|
|
bar.querySelector('.tts-stop-btn').addEventListener('click', stopReadAloud);
|
||
|
|
}
|
||
|
|
|
||
|
|
function _ttsRemoveBar() {
|
||
|
|
if (ttsBarEl) { ttsBarEl.remove(); ttsBarEl = null; }
|
||
|
|
}
|
||
|
|
|
||
|
|
function toggleReadAloud() {
|
||
|
|
if (ttsActive) stopReadAloud();
|
||
|
|
else startReadAloud();
|
||
|
|
}
|
||
|
|
|
||
|
|
function startReadAloud() {
|
||
|
|
if (ttsActive) return;
|
||
|
|
if (currentPdfDoc) {
|
||
|
|
const toast = document.createElement('div');
|
||
|
|
toast.className = 'reader-toast';
|
||
|
|
toast.textContent = 'Vorlesen ist aktuell nur für EPUB-Bücher verfügbar.';
|
||
|
|
document.body.appendChild(toast);
|
||
|
|
setTimeout(() => toast.remove(), 2200);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const contentEl = $('reader-content');
|
||
|
|
if (!contentEl) return;
|
||
|
|
const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR));
|
||
|
|
if (!blocks.length) return;
|
||
|
|
|
||
|
|
const [anchorBlock] = _anchorParts(getPositionAnchor(contentEl));
|
||
|
|
const startIndex = (anchorBlock >= 0 && anchorBlock < blocks.length) ? anchorBlock : 0;
|
||
|
|
|
||
|
|
ttsActive = true;
|
||
|
|
ttsPaused = false;
|
||
|
|
ttsRunToken++;
|
||
|
|
const runToken = ttsRunToken;
|
||
|
|
_ttsBlocks = blocks;
|
||
|
|
_ttsCur = null;
|
||
|
|
|
||
|
|
const btn = $('reader-tts-btn');
|
||
|
|
if (btn) { btn.classList.add('active'); btn.title = 'Vorlesen beenden'; }
|
||
|
|
_ttsShowBar();
|
||
|
|
_ttsNotify();
|
||
|
|
|
||
|
|
_ttsPlayLoop(blocks, startIndex, runToken);
|
||
|
|
}
|
||
|
|
|
||
|
|
function stopReadAloud() {
|
||
|
|
ttsRunToken++;
|
||
|
|
ttsActive = false;
|
||
|
|
ttsPaused = false;
|
||
|
|
if (ttsAudio) {
|
||
|
|
try { ttsAudio.pause(); ttsAudio.src = ''; } catch (e) {}
|
||
|
|
ttsAudio = null;
|
||
|
|
}
|
||
|
|
_ttsClearHighlight();
|
||
|
|
_ttsRemoveBar();
|
||
|
|
_ttsBlocks = null;
|
||
|
|
_ttsCur = null;
|
||
|
|
const btn = $('reader-tts-btn');
|
||
|
|
if (btn) { btn.classList.remove('active'); btn.title = 'Vorlesen'; }
|
||
|
|
_ttsNotify();
|
||
|
|
}
|