tts-voice-creator-clone-and.../static/js/reader.js
mARTin-B78 36437478a6 feat: Read Aloud reader, multi-voice audiobook casting, character sheets (v1.7.0)
Read Aloud (new "Vorlesen" tab):
- PDF (real page render + overlay highlight) / TXT reader with live word
  highlighting, voice + speed, per-sentence synthesis-state colours, zoom
  (fit-width/height, two-page, ±), resume, and a server-side book library
  (syncs across devices; per-unit MP3 audio fetched on demand).

Book -> multi-speaker audiobook:
- "Cast as audiobook" attributes dialogue to characters via the LLM
  (guillemet/quote-style aware, turn-taking, recent-context), with a
  deterministic speech-tag fallback. Editable preview, non-blocking live
  casting panel, then auto-saved as a reopenable Script Rehearser play.
- Audiobook export: synthesise every cast line -> one MP3 per chapter.

Character sheets:
- LLM-extracted, self-filling RPG-style sheets (with page+quote sources)
  in both Read Aloud and the Rehearser.

Also: MP3 storage + per-page/sentence export, voice-library "Precompute
embeddings" pre-warm, German "Vorlesen" i18n + flag language toggle,
large-PDF performance (lazy raster, buffer/canvas eviction, yielded parse),
and the Seed Finder changelog entry.

New: routes/reader.py, POST /api/attribute-dialogue, POST /api/character-sheets,
static/js/{reader,audiobook,character-sheets}.js, static/sections/s-reader.html.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 15:08:46 +02:00

1325 lines
60 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── Read Aloud — PDF/TXT document reader with live word highlighting ────────
//
// Imports a PDF (rendered to canvas via pdf.js with a highlight overlay on the
// real text) or a plain-text file, then reads it sentence-by-sentence with a
// chosen voice/backend/speed while highlighting the word being spoken.
//
// Per-sentence synthesis state is colour-coded: red = not synthesised,
// yellow = synthesising, green = ready (cached), blue = currently reading.
// PDF view supports zoom (fit-width / fit-height / two-page / ±). Reading
// position is remembered so the same document resumes where it left off.
//
// Reuses existing globals: loadPdfJs() + computeWordTimings() (rehearser.js),
// fetchTtsPreviewBlob() (tts-preview.js), ttsBackendOptions()/availableTtsBackends()
// (settings.js), and $ / escHtml / toast (utils.js).
const readerState = {
mode: null, // 'pdf' | 'text'
title: '',
pdfDoc: null,
pages: [], // [{ pageDiv, overlay, page, base, rendered, renderTask }]
sentences: [], // [{ text, words:[{page,x,top,w,h} | {el}], status, _stat }]
idx: 0,
readingIdx: -1, // the single unit currently shown as "reading" (avoids full scans)
playing: false,
speed: 1,
scale: 1,
zoomMode:'fit-width', // 'fit-width' | 'fit-height' | 'two' | 'custom'
audioCtx: null,
currentSource: null,
raf: null,
blobCache: new Map(), // idx -> Blob
bufCache: new Map(), // idx -> AudioBuffer
gainCache: new Map(), // idx -> normalisation gain
unitsByPage: new Map(),// page -> [unit indices] for lazy status-box creation
baseSentences: [], // raw per-sentence units (before chunk grouping)
chunkMode: 'sentence',// 'sentence' | 'paragraph' | 'page' — synthesis chunk size
normalize: true, // even out loudness between chunks on playback
io: null, // IntersectionObserver for lazy page raster
_seq: 0, // import generation guard
synthRunning: false,
synthCancel: false,
selecting: false, // click-to-select-range mode (stays on until Done/Esc)
selStart: null,
selEnd: null,
selAnchored: false, // true after the first click, waiting for the end click
fileBlob: null, // original imported file (Blob) — kept so PDFs can be re-rendered
docText: '', // original text (for text-mode docs)
savedId: null, // server library doc id when this doc came from / was saved to the library
savedAudioIdx: new Set(), // unit indices whose audio is already on the server (incremental save / lazy fetch)
sourceUploaded: false, // the source document has been uploaded to the server
};
window.readerState = readerState;
const READER_RESUME_KEY = 'reader-resume';
const READER_FMT = 'mp3'; // synthesise + store as MP3 (compact; decodeAudioData plays it)
const READER_BUF_WINDOW = 3; // keep decoded PCM only for ±N units around the playhead
const READER_CANVAS_MARGIN = 2200; // px; raster canvases beyond this from view are released
function readerCtx() {
if (!readerState.audioCtx) readerState.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (readerState.audioCtx.state === 'suspended') readerState.audioCtx.resume().catch(() => {});
return readerState.audioCtx;
}
// ── Section enter / leave hooks (wired from nav.js) ─────────────────────────
window.readerOnShow = async function () {
const sel = $('reader-backend-select');
if (sel && (!sel.value || sel.options.length <= 1)) {
// Backends may not have loaded yet (or loaded before this section existed) —
// fetch them on demand so the dropdown fills instead of staying on "Checking…".
if (typeof availableTtsBackends === 'function' && !availableTtsBackends().length &&
typeof refreshTtsBackendAvailability === 'function') {
try { await refreshTtsBackendAvailability(); } catch (_) {}
}
if (typeof availableTtsBackends === 'function' && availableTtsBackends().length) {
sel.innerHTML = ttsBackendOptions(sel.value);
sel.disabled = false;
}
}
readerUpdateBackendHint();
readerRenderLibrary();
};
window.readerStop = function () { readerSaveResume(); readerPersistProgress(); readerStopPlayback(); };
// ── Backend + voice pickers ─────────────────────────────────────────────────
async function readerFetchVoices() {
const btn = $('reader-fetch-voices-btn');
const backend = $('reader-backend-select')?.value;
if (!backend) { toast('No available TTS backend', 'error'); return; }
if (btn) btn.disabled = true;
try {
const raw = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json());
const ids = (Array.isArray(raw) ? raw : []).map(v => (typeof v === 'string' ? v : (v.id || v.name || v.voice_id))).filter(Boolean);
const sel = $('reader-voice-select'); if (!sel) return;
const prev = sel.value;
sel.innerHTML = '<option value="">— select a voice —</option>';
ids.forEach(id => { const o = document.createElement('option'); o.value = o.textContent = id; sel.appendChild(o); });
if (prev && ids.includes(prev)) sel.value = prev;
toast('Fetched ' + ids.length + ' voices', 'success');
} catch (e) { toast('Fetch failed: ' + (e.message || e), 'error'); }
finally { if (btn) btn.disabled = false; }
}
// Guidance for steady long-form narration (addresses voice drift between chunks):
// some backends re-sample timbre per request; flag those and suggest remedies.
function readerUpdateBackendHint() {
const el = $('reader-backend-hint'); if (!el) return;
const id = $('reader-backend-select')?.value || '';
const b = (typeof backendById === 'function') ? backendById(id) : null;
const steady = id && ((b && b.style_aware) || /design|custom|kokoro|magpie/i.test(id));
el.innerHTML = steady
? '<span class="mdi mdi-check-circle-outline"></span> Good for long-form narration. For maximum steadiness, increase “Voice consistency”.'
: '<span class="mdi mdi-information-outline"></span> Cloned / zero-shot voices re-sample each chunk, so the voice can drift between sections. To steady it: raise “Voice consistency” to paragraph/page, set a fixed <b>Seed</b> with a low <b>Temperature</b> (e.g. 0.3), keep “Normalise loudness” on, or pick a style-aware backend.';
el.classList.toggle('reader-hint-warn', !steady);
}
// ── Import ──────────────────────────────────────────────────────────────────
async function readerImportFile(file) {
if (!file) return;
const name = (file.name || '').toLowerCase();
readerResetDoc();
readerState.title = file.name.replace(/\.[^.]+$/, '');
readerState.fileBlob = file;
const titleEl = $('reader-doc-title'); if (titleEl) titleEl.textContent = readerState.title;
try {
if (name.endsWith('.pdf')) {
toast('Reading PDF…', 'success');
await readerLoadPdf(file);
} else {
const text = await file.text();
readerState.docText = text;
readerLoadText(text);
}
if (!readerState.sentences.length) { toast('No readable text found in this document', 'error'); return; }
if (readerState.mode === 'text') readerState.sentences.forEach((s, i) => readerSetStatus(i, 'pending'));
$('reader-transport').hidden = false;
$('reader-dropzone').hidden = true;
$('reader-toolbar').hidden = readerState.mode !== 'pdf';
$('reader-synthbar').hidden = false;
const pr = $('reader-page-range');
if (pr) {
pr.hidden = readerState.mode !== 'pdf';
if (readerState.mode === 'pdf') {
const n = readerState.pages.length;
const pf = $('reader-page-from'), pt = $('reader-page-to');
if (pf) { pf.max = n; pf.value = 1; }
if (pt) { pt.max = n; pt.value = n; }
}
}
readerUpdateScopeLabel();
const resumed = readerLoadResume();
readerUpdateProgress();
readerHighlightSentence(readerState.sentences[readerState.idx]);
toast(resumed
? 'Resumed at sentence ' + (readerState.idx + 1) + ' / ' + readerState.sentences.length
: 'Loaded · ' + readerState.sentences.length + ' sentences — press play', 'success');
} catch (e) { toast('Import failed: ' + (e.message || e), 'error'); }
}
function readerResetDoc() {
readerStopPlayback();
readerState._seq++;
readerState.mode = null;
readerState.pdfDoc = null;
readerState.pages = [];
readerState.sentences = [];
readerState.idx = 0;
readerState.readingIdx = -1;
readerState.scale = 1;
readerState.zoomMode = 'fit-width';
readerState.blobCache.clear();
readerState.bufCache.clear();
readerState.gainCache.clear();
readerState.unitsByPage = new Map();
readerState.baseSentences = [];
readerState.fileBlob = null;
readerState.docText = '';
readerState.savedId = null;
readerState.savedAudioIdx = new Set();
readerState.sourceUploaded = false;
if (readerState.io) { readerState.io.disconnect(); readerState.io = null; }
readerState.synthRunning = false;
readerState.synthCancel = false;
readerSetSelecting(false);
readerState.selStart = readerState.selEnd = null; readerState.selAnchored = false;
const sb = $('reader-synthbar'); if (sb) sb.hidden = true;
const sp = $('reader-synth-prog'); if (sp) sp.hidden = true;
const si = $('reader-sel-info'); if (si) si.hidden = true;
const doc = $('reader-doc'); if (doc) { doc.innerHTML = ''; doc.classList.remove('two-page', 'reader-selecting'); }
}
// Build sentence list from a flat array of word objects (each carries geometry).
function readerBuildSentences(words) {
const sentences = [];
let cur = null;
const endRe = /[.!?]["'”’)\]]?$/;
const abbrev = /^(mr|mrs|ms|dr|prof|sr|jr|vs|etc|e\.g|i\.e|no|vol|st|fig)\.?$/i;
for (const w of words) {
if (!cur) cur = { text: '', words: [], status: 'pending', _stat: null };
cur.words.push(w);
cur.text += (cur.text ? ' ' : '') + w.text;
const isEnd = endRe.test(w.text) && !abbrev.test(w.text.replace(/[^a-z.]/gi, ''));
if ((isEnd && cur.words.length >= 2) || cur.words.length >= 45) {
sentences.push(cur); cur = null;
}
}
if (cur && cur.words.length) sentences.push(cur);
return sentences;
}
// Group base sentences into synthesis units. Larger units = one continuous
// generation = steadier voice (fewer seams where timbre/prosody can drift).
// • sentence — one unit per sentence (most responsive)
// • paragraph — merge sentences within a paragraph (text) / page (PDF), ~500 chars
// • page — merge a whole PDF page (or ~2000-char blocks of text)
function readerGroupUnits(base, mode) {
if (mode === 'sentence' || !base.length) {
return base.map(s => ({ text: s.text, words: s.words, status: 'pending', _stat: null }));
}
const maxChars = mode === 'page' ? 2000 : 500;
const isPdf = readerState.mode === 'pdf';
const keyOf = s => isPdf ? (s.words[0]?.page ?? 0) : (s.words[0]?.para ?? 0);
const units = [];
let cur = null;
for (const s of base) {
const k = keyOf(s);
// boundary break: paragraph mode breaks on para/page change; page mode breaks
// only on PDF page change (text page mode flows by size alone)
const boundary = cur && (
(mode === 'paragraph' && k !== cur._key) ||
(mode === 'page' && isPdf && k !== cur._key)
);
const tooLong = cur && cur.words.length && (cur.text.length + s.text.length + 1 > maxChars);
if (boundary || tooLong) { units.push(cur); cur = null; }
if (!cur) cur = { text: '', words: [], status: 'pending', _stat: null, _key: k };
cur.text += (cur.text ? ' ' : '') + s.text;
cur.words.push(...s.words);
}
if (cur) units.push(cur);
return units;
}
// ── PDF import: build scale-1 geometry; render lazily at current scale ───────
async function readerLoadPdf(file) {
await loadPdfJs();
const seq = readerState._seq;
const ab = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument({ data: ab }).promise;
if (seq !== readerState._seq) return;
readerState.mode = 'pdf';
readerState.pdfDoc = pdf;
const doc = $('reader-doc');
const nPages = pdf.numPages;
const progress = readerShowParseProgress(nPages);
// Sentences/units are built incrementally per page (page-local) and appended,
// so there is no big synchronous spike at the end and memory work is spread
// across yields — keeps huge books responsive. Earlier indices never shift
// (later pages only append), so caches/status stay aligned.
for (let p = 1; p <= nPages; p++) {
const page = await pdf.getPage(p);
if (seq !== readerState._seq) { progress.done(); return; }
const base = page.getViewport({ scale: 1 }); // geometry reference (scale-independent)
const pageDiv = document.createElement('div');
pageDiv.className = 'reader-page';
const overlay = document.createElement('div');
overlay.className = 'reader-overlay';
pageDiv.appendChild(overlay);
doc.appendChild(pageDiv);
const pageIdx = readerState.pages.length;
readerState.pages.push({ pageDiv, overlay, page, base, rendered: false, renderTask: null });
// Per-word geometry in scale-1 device px (top-left origin) → multiply by scale later
const content = await page.getTextContent();
if (seq !== readerState._seq) return;
const pageWords = [];
for (const item of content.items) {
const str = (item.str || '');
if (!str.trim()) continue;
const m = pdfjsLib.Util.transform(base.transform, item.transform);
const h = Math.hypot(m[2], m[3]) || Math.hypot(m[0], m[1]) || 12;
const x = m[4];
const top = m[5] - h;
const w = (item.width || 0);
const tokens = str.split(/(\s+)/); // keep spaces for width proportion
const totalLen = str.length || 1;
let offX = x;
for (const tok of tokens) {
const tw = (tok.length / totalLen) * w;
if (tok.trim()) pageWords.push({ page: pageIdx, x: offX, top, w: Math.max(tw, 2), h, text: tok });
offX += tw;
}
}
// Build + append this page's sentences and units
const pageBase = readerBuildSentences(pageWords);
const pageUnits = readerGroupUnits(pageBase, readerState.chunkMode);
const startUnit = readerState.sentences.length;
readerState.baseSentences.push(...pageBase);
readerState.sentences.push(...pageUnits);
const idxs = [];
for (let u = startUnit; u < readerState.sentences.length; u++) idxs.push(u);
readerState.unitsByPage.set(pageIdx, idxs);
// Yield to the event loop every few pages so the browser stays responsive
// on big books (prevents the "page is not responding" prompt).
progress.update(p);
if (p % 5 === 0) await new Promise(r => setTimeout(r, 0));
}
progress.done();
if (seq !== readerState._seq) return;
readerApplyZoom('fit-width');
readerSetupLazyRaster();
}
// Lightweight parse-progress line shown in the document pane for big PDFs.
function readerShowParseProgress(total) {
const doc = $('reader-doc'); if (!doc) return { update() {}, done() {} };
const el = document.createElement('div');
el.className = 'reader-parsing';
el.innerHTML = '<span class="reh-imsdb-spinner"></span> <span>Reading PDF… page 0 / ' + total + '</span>';
doc.appendChild(el);
const label = el.querySelector('span:last-child');
return {
update(p) { if (label) label.textContent = 'Reading PDF… page ' + p + ' / ' + total; },
done() { el.remove(); },
};
}
// ── Zoom ─────────────────────────────────────────────────────────────────────
function readerComputeScale(mode) {
const doc = $('reader-doc');
const base = readerState.pages[0]?.base;
if (!doc || !base) return 1;
const padW = 36, gap = 16;
const availW = (doc.clientWidth || 900) - padW;
const availH = (doc.clientHeight || 600) - 36;
if (mode === 'fit-width') return Math.max(0.2, availW / base.width);
if (mode === 'fit-height') return Math.max(0.2, availH / base.height);
if (mode === 'two') return Math.max(0.2, (availW - gap) / 2 / base.width);
return readerState.scale;
}
function readerApplyZoom(mode) {
if (!readerState.pages.length) return;
if (mode) readerState.zoomMode = mode;
if (mode && mode !== 'custom') readerState.scale = readerComputeScale(mode);
const scale = readerState.scale;
const doc = $('reader-doc');
doc.classList.toggle('two-page', readerState.zoomMode === 'two');
readerState.pages.forEach(pg => {
pg.pageDiv.style.width = (pg.base.width * scale) + 'px';
pg.pageDiv.style.height = (pg.base.height * scale) + 'px';
const old = pg.pageDiv.querySelector('canvas.reader-canvas');
if (old) old.remove();
if (pg.renderTask) { try { pg.renderTask.cancel(); } catch (_) {} pg.renderTask = null; }
pg.rendered = false;
});
// reposition only the status boxes that actually exist (lazy/visible pages)
readerState.pages.forEach(pg => pg.overlay.querySelectorAll('.reader-stat').forEach(el => {
const i = parseInt(el.dataset.si); if (!isNaN(i)) readerPaintStatus(i);
}));
if (readerState.idx < readerState.sentences.length) readerHighlightSentence(readerState.sentences[readerState.idx]);
const pct = $('reader-zoom-pct'); if (pct) pct.textContent = Math.round(scale * 100) + '%';
readerRenderVisible();
}
function readerSetupLazyRaster() {
if (readerState.io) readerState.io.disconnect();
readerState.io = new IntersectionObserver(entries => {
entries.forEach(en => {
if (!en.isIntersecting) return;
const idx = readerState.pages.findIndex(p => p.pageDiv === en.target);
if (idx >= 0) readerRenderPage(idx);
});
}, { root: $('reader-doc'), rootMargin: '600px 0px' });
readerState.pages.forEach(p => readerState.io.observe(p.pageDiv));
}
function readerRenderVisible() {
const doc = $('reader-doc'); if (!doc) return;
const dr = doc.getBoundingClientRect();
readerState.pages.forEach((pg, i) => {
const r = pg.pageDiv.getBoundingClientRect();
if (r.bottom > dr.top - 800 && r.top < dr.bottom + 800) readerRenderPage(i);
});
readerEvictCanvases(dr);
}
// Release canvas bitmaps for pages far outside the viewport so a long book's
// raster memory stays bounded; they re-render when scrolled back into view.
function readerEvictCanvases(dr) {
const doc = $('reader-doc'); if (!doc) return;
dr = dr || doc.getBoundingClientRect();
readerState.pages.forEach(pg => {
if (!pg.rendered) return;
const r = pg.pageDiv.getBoundingClientRect();
if (r.bottom < dr.top - READER_CANVAS_MARGIN || r.top > dr.bottom + READER_CANVAS_MARGIN) {
if (pg.renderTask) { try { pg.renderTask.cancel(); } catch (_) {} pg.renderTask = null; }
const c = pg.pageDiv.querySelector('canvas.reader-canvas');
if (c) c.remove();
pg.rendered = false; // allow re-raster when it returns to view
}
});
}
async function readerRenderPage(idx) {
const pg = readerState.pages[idx];
if (!pg || pg.rendered) return;
pg.rendered = true; // claim immediately to avoid double render
const scale = readerState.scale;
const viewport = pg.page.getViewport({ scale });
const canvas = document.createElement('canvas');
canvas.className = 'reader-canvas';
canvas.width = Math.floor(viewport.width);
canvas.height = Math.floor(viewport.height);
pg.pageDiv.insertBefore(canvas, pg.overlay);
readerCreatePageStatus(idx); // create this page's status boxes lazily (cheap for big books)
try {
pg.renderTask = pg.page.render({ canvasContext: canvas.getContext('2d'), viewport });
await pg.renderTask.promise;
} catch (_) { pg.rendered = false; canvas.remove(); }
finally { pg.renderTask = null; }
}
// ── Text import: render word spans, highlight via classes ────────────────────
function readerLoadText(text) {
readerState.mode = 'text';
readerState.docText = text; // keep the source so it can be saved to the library
const doc = $('reader-doc');
const pane = document.createElement('div');
pane.className = 'reader-text';
doc.appendChild(pane);
const words = [];
const paras = text.replace(/\r\n/g, '\n').split(/\n{2,}/);
paras.forEach((para, pi) => {
const pEl = document.createElement('p');
pEl.className = 'reader-text-para';
para.split('\n').forEach((line, li, arr) => {
line.trim().split(/\s+/).forEach(tok => {
if (!tok) return;
const span = document.createElement('span');
span.className = 'reader-word';
span.textContent = tok;
pEl.appendChild(span);
pEl.appendChild(document.createTextNode(' '));
words.push({ el: span, text: tok, page: 0, para: pi });
});
if (li < arr.length - 1) pEl.appendChild(document.createElement('br'));
});
pane.appendChild(pEl);
});
readerState.baseSentences = readerBuildSentences(words);
readerState.sentences = readerGroupUnits(readerState.baseSentences, readerState.chunkMode);
pane.addEventListener('click', e => {
const span = e.target.closest('.reader-word'); if (!span) return;
const si = readerState.sentences.findIndex(s => s.words.some(w => w.el === span));
if (si < 0) return;
if (readerState.selecting) readerPickSentence(si);
else readerJumpTo(si);
});
}
// ── Per-sentence synthesis-state visualization ───────────────────────────────
function readerSentBBox(sentence) {
const pageIdx = sentence.words[0]?.page ?? 0;
const on = sentence.words.filter(w => w.page === pageIdx);
return {
page: pageIdx,
x: Math.min(...on.map(w => w.x)),
top: Math.min(...on.map(w => w.top)),
w: Math.max(...on.map(w => w.x + w.w)) - Math.min(...on.map(w => w.x)),
h: Math.max(...on.map(w => w.top + w.h)) - Math.min(...on.map(w => w.top)),
};
}
// Index units by their page so status boxes can be created lazily per page
// (creating thousands of overlay divs up front would jank big books).
function readerBuildUnitIndex() {
readerState.unitsByPage = new Map();
if (readerState.mode !== 'pdf') return;
readerState.sentences.forEach((s, i) => {
const pg = s.words[0]?.page ?? 0;
if (!readerState.unitsByPage.has(pg)) readerState.unitsByPage.set(pg, []);
readerState.unitsByPage.get(pg).push(i);
});
}
// Create + paint the status boxes for one page (called when the page rasters).
function readerCreatePageStatus(pageIdx) {
if (readerState.mode !== 'pdf') return;
const pg = readerState.pages[pageIdx]; if (!pg) return;
const idxs = readerState.unitsByPage?.get(pageIdx) || [];
for (const i of idxs) {
const s = readerState.sentences[i];
if (!s || s._stat) continue;
const el = document.createElement('div');
el.className = 'reader-stat';
el.dataset.si = i;
pg.overlay.appendChild(el);
s._stat = el;
readerPaintStatus(i);
}
}
// effective colour: reading wins, else cached/synth/pending
function readerEffectiveStatus(s) { return s.status; }
function readerSetStatus(idx, status) {
const s = readerState.sentences[idx]; if (!s) return;
s.status = status;
readerPaintStatus(idx);
}
function readerPaintStatus(idx) {
const s = readerState.sentences[idx]; if (!s) return;
const eff = readerEffectiveStatus(s);
if (readerState.mode === 'pdf') {
if (!s._stat) return;
const b = readerSentBBox(s);
const scale = readerState.scale;
Object.assign(s._stat.style, { left: (b.x * scale) + 'px', top: (b.top * scale) + 'px', width: (b.w * scale) + 'px', height: (b.h * scale) + 'px' });
s._stat.className = 'reader-stat reader-stat-' + eff;
} else {
s.words.forEach(w => {
if (!w.el) return;
w.el.classList.remove('stat-pending', 'stat-synth', 'stat-ready', 'stat-reading');
w.el.classList.add('stat-' + eff);
});
}
}
// ── Active reading highlight (word + scroll) ─────────────────────────────────
let _readerWordBox = null;
function readerEnsureBox() {
if (!_readerWordBox) { _readerWordBox = document.createElement('div'); _readerWordBox.className = 'reader-hl-word'; }
}
function readerClearHighlights() {
if (readerState.mode === 'text') {
document.querySelectorAll('.reader-word.is-word').forEach(el => el.classList.remove('is-word'));
} else if (_readerWordBox) {
_readerWordBox.hidden = true;
}
}
// Scroll ONLY the #reader-doc pane (never the window) so the active text stays
// inside the visible frame and not under the app header. Keeps the target at
// ~38% from the top when it would otherwise be clipped top or bottom.
function readerEnsureVisible(el) {
const doc = $('reader-doc'); if (!doc || !el) return;
const dr = doc.getBoundingClientRect();
const er = el.getBoundingClientRect();
if (er.top < dr.top + 70 || er.bottom > dr.bottom - 50) {
const delta = (er.top - dr.top) - doc.clientHeight * 0.38;
doc.scrollTo({ top: doc.scrollTop + delta, behavior: 'smooth' });
}
}
function readerHighlightSentence(sentence) {
if (!sentence) return;
if (readerState.mode === 'text') readerEnsureVisible(sentence.words[0]?.el);
else if (sentence._stat) readerEnsureVisible(sentence._stat);
}
function readerHighlightWord(sentence, wi) {
const w = sentence.words[wi]; if (!w) return;
if (readerState.mode === 'text') {
document.querySelectorAll('.reader-word.is-word').forEach(el => el.classList.remove('is-word'));
if (w.el) { w.el.classList.add('is-word'); readerEnsureVisible(w.el); }
return;
}
readerEnsureBox();
const pg = readerState.pages[w.page]; if (!pg) return;
const scale = readerState.scale;
if (_readerWordBox.parentNode !== pg.overlay) pg.overlay.appendChild(_readerWordBox);
Object.assign(_readerWordBox.style, { left: (w.x * scale) + 'px', top: (w.top * scale) + 'px', width: (w.w * scale) + 'px', height: (w.h * scale) + 'px' });
_readerWordBox.hidden = false;
readerEnsureVisible(_readerWordBox);
}
// ── Scope: select range / page range / all + batch synthesis ─────────────────
// Select mode STAYS ON until the user explicitly finishes (Done button / Esc).
// Step 1: click a start sentence (the "anchor"). Step 2: click the end sentence.
// The anchor pulses while waiting; after a range is set, the next click starts a
// fresh anchor so you can keep refining without leaving the mode.
function readerSetSelecting(on) {
readerState.selecting = !!on;
readerState.selAnchored = false;
const doc = $('reader-doc'); if (doc) doc.classList.toggle('reader-selecting', readerState.selecting);
const btn = $('reader-select-toggle'); if (btn) btn.classList.toggle('active', readerState.selecting);
const lbl = $('reader-select-label'); if (lbl) lbl.textContent = readerState.selecting ? 'Done' : 'Select range';
const ic = $('reader-select-icon'); if (ic) ic.className = 'mdi ' + (readerState.selecting ? 'mdi-check' : 'mdi-cursor-default-click-outline');
readerSelHint(readerState.selecting ? 'Click the <b>start</b> sentence…' : '');
if (!readerState.selecting) readerPaintSelection(); // drop the transient anchor styling
}
function readerSelHint(html) {
const h = $('reader-sel-hint'); if (!h) return;
h.innerHTML = html; h.hidden = !html;
}
function readerPickSentence(si) {
if (!readerState.selAnchored) {
// start a fresh range
readerState.selStart = si; readerState.selEnd = si; readerState.selAnchored = true;
readerSelHint('Start at sentence <b>' + (si + 1) + '</b> — now click the <b>end</b> sentence');
} else {
readerState.selEnd = si; readerState.selAnchored = false;
const r = readerSelRange();
readerSelHint('Selected <b>' + (r[1] - r[0] + 1) + '</b> sentences — click a new start, or <b>Done</b>');
}
readerPaintSelection();
readerUpdateScopeLabel();
}
function readerClearSelection() {
readerState.selStart = readerState.selEnd = null; readerState.selAnchored = false;
readerPaintSelection();
readerUpdateScopeLabel();
if (readerState.selecting) readerSelHint('Click the <b>start</b> sentence…');
}
function readerSelRange() {
if (readerState.selStart === null || readerState.selEnd === null) return null;
return [Math.min(readerState.selStart, readerState.selEnd), Math.max(readerState.selStart, readerState.selEnd)];
}
function readerPaintSelection() {
const range = readerSelRange();
const inSel = i => range && i >= range[0] && i <= range[1];
// While waiting for the second click, the lone start sentence is the "anchor".
const anchorI = (readerState.selAnchored && range && range[0] === range[1]) ? range[0] : -1;
readerState.sentences.forEach((s, i) => {
const sel = inSel(i) && i !== anchorI;
const anchor = i === anchorI;
if (readerState.mode === 'pdf') {
if (s._stat) { s._stat.classList.toggle('sel', sel); s._stat.classList.toggle('sel-anchor', anchor); }
} else {
s.words.forEach(w => { if (w.el) { w.el.classList.toggle('in-sel', sel); w.el.classList.toggle('sel-anchor', anchor); } });
}
});
}
// Indices to act on: an active selection wins, else PDF page range, else everything.
function readerScopeIndices() {
const all = readerState.sentences.map((_, i) => i);
const range = readerSelRange();
if (range) return all.filter(i => i >= range[0] && i <= range[1]);
if (readerState.mode === 'pdf') {
const n = readerState.pages.length;
let from = parseInt($('reader-page-from')?.value) || 1;
let to = parseInt($('reader-page-to')?.value) || n;
from = Math.max(1, Math.min(from, n));
to = Math.max(from, Math.min(to, n));
return all.filter(i => { const p = (readerState.sentences[i].words[0]?.page ?? 0) + 1; return p >= from && p <= to; });
}
return all;
}
function readerUpdateScopeLabel() {
const scopeEl = $('reader-synth-scope');
const range = readerSelRange();
if (scopeEl) {
if (range) scopeEl.textContent = 'selection';
else if (readerState.mode === 'pdf') scopeEl.textContent = 'pages';
else scopeEl.textContent = 'all';
}
const info = $('reader-sel-info'), txt = $('reader-sel-text');
if (info) info.hidden = !range;
if (txt && range) txt.textContent = 'sentences ' + (range[0] + 1) + '' + (range[1] + 1) + ' (' + (range[1] - range[0] + 1) + ')';
}
// Synthesise the given sentence indices (skips already-cached). Returns count done.
async function readerSynthIndices(targets) {
targets = targets.filter(i => !readerState.blobCache.has(i));
if (!targets.length || readerState.synthRunning) return 0;
const voice = $('reader-voice-select')?.value;
const backend = $('reader-backend-select')?.value;
if (!voice) { toast('Pick a voice first', 'error'); return 0; }
if (!backend) { toast('No TTS backend selected', 'error'); return 0; }
const instruct = $('reader-instruct')?.value.trim() || '';
readerState.synthRunning = true; readerState.synthCancel = false;
const prog = $('reader-synth-prog'); if (prog) prog.hidden = false;
const total = targets.length; let done = 0;
const update = () => {
const f = $('reader-synth-fill'); if (f) f.style.width = (done / total * 100) + '%';
const l = $('reader-synth-label'); if (l) l.textContent = done + ' / ' + total;
};
update();
const queue = targets.slice();
const worker = async () => {
while (queue.length && !readerState.synthCancel) {
const i = queue.shift();
if (readerState.blobCache.has(i)) { done++; update(); continue; }
if (readerState.sentences[i].status === 'pending') readerSetStatus(i, 'synth');
try {
const blob = await fetchTtsPreviewBlob(voice, readerState.sentences[i].text, READER_FMT, instruct, backend, false, readerGenParams());
readerState.blobCache.set(i, blob);
if (readerState.sentences[i].status === 'synth') readerSetStatus(i, 'ready');
} catch (_) {
if (readerState.sentences[i].status === 'synth') readerSetStatus(i, 'pending');
}
done++; update();
}
};
const N = Math.min(2, targets.length); // bounded concurrency — don't overload the engine
await Promise.all(Array.from({ length: N }, worker));
readerState.synthRunning = false;
if (prog) prog.hidden = true;
return done;
}
async function readerSynthAll() {
const targets = readerScopeIndices().filter(i => !readerState.blobCache.has(i));
if (!targets.length) { toast('Selected range is already synthesised', 'success'); return; }
const done = await readerSynthIndices(targets);
if (done || !readerState.synthCancel) toast(readerState.synthCancel ? 'Synthesis cancelled (' + done + ' done)' : 'Synthesised ' + done + ' sentences', readerState.synthCancel ? 'error' : 'success');
}
// ── Export as MP3 (per page, or per sentence) with meaningful filenames ──────
function readerSafeName(s) {
return (s || 'audio').replace(/[\/\\:*?"<>|]+/g, '_').replace(/\s+/g, ' ').trim().slice(0, 60) || 'audio';
}
function readerPad(n) { return String(n).padStart(2, '0'); }
function readerPageOf(i) { return readerState.mode === 'pdf' ? (readerState.sentences[i].words[0]?.page ?? 0) + 1 : 1; }
function readerDownload(blob, filename) {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1500);
}
const _readerDelay = ms => new Promise(r => setTimeout(r, ms));
async function readerExport(mode) {
if (!readerState.sentences.length) { toast('Import a document first', 'error'); return; }
const indices = readerScopeIndices();
const missing = indices.filter(i => !readerState.blobCache.has(i));
if (missing.length) {
toast('Synthesising ' + missing.length + ' missing sentence(s) before export…', 'success');
await readerSynthIndices(missing);
if (readerState.synthCancel) { toast('Export cancelled', 'error'); return; }
}
const ready = indices.filter(i => readerState.blobCache.has(i));
if (!ready.length) { toast('Nothing to export', 'error'); return; }
const title = readerSafeName(readerState.title);
if (mode === 'sentence') {
const perPage = {};
for (const i of ready) {
const pg = readerPageOf(i);
perPage[pg] = (perPage[pg] || 0) + 1;
const name = readerState.mode === 'pdf'
? `${title} - p${readerPad(pg)} - ${readerPad(perPage[pg])}.mp3`
: `${title} - ${readerPad(perPage[pg])}.mp3`;
readerDownload(readerState.blobCache.get(i), name);
await _readerDelay(350);
}
toast('Exported ' + ready.length + ' MP3 files', 'success');
return;
}
// mode === 'page' → one combined MP3 per page (text mode → one file for the doc)
const byPage = new Map();
ready.forEach(i => { const pg = readerPageOf(i); if (!byPage.has(pg)) byPage.set(pg, []); byPage.get(pg).push(i); });
let files = 0;
for (const [pg, idxs] of [...byPage.entries()].sort((a, b) => a[0] - b[0])) {
const blob = new Blob(idxs.map(i => readerState.blobCache.get(i)), { type: 'audio/mpeg' });
const name = readerState.mode === 'pdf' ? `${title} - p${readerPad(pg)}.mp3` : `${title}.mp3`;
readerDownload(blob, name);
files++;
await _readerDelay(400);
}
toast('Exported ' + files + (readerState.mode === 'pdf' ? ' page MP3 file(s)' : ' MP3'), 'success');
}
// ── Chunk mode (voice consistency) ───────────────────────────────────────────
function readerRechunk(mode) {
readerState.chunkMode = mode;
if (!readerState.baseSentences.length) return;
readerStopPlayback();
// drop synthesised audio (chunk text changed) and old per-unit status boxes
readerState.blobCache.clear(); readerState.bufCache.clear(); readerState.gainCache.clear();
readerState.savedAudioIdx = new Set(); // chunking changed → previously-saved audio no longer aligns
readerState.pages.forEach(p => p.overlay.querySelectorAll('.reader-stat').forEach(e => e.remove()));
readerClearSelection();
readerState.sentences = readerGroupUnits(readerState.baseSentences, mode);
readerBuildUnitIndex();
// recreate status boxes only for pages already rendered (rest come on raster)
readerState.pages.forEach((p, i) => { if (p.rendered) readerCreatePageStatus(i); });
if (readerState.mode === 'text') readerState.sentences.forEach((s, i) => readerSetStatus(i, 'pending'));
readerState.idx = 0;
readerUpdateScopeLabel();
readerUpdateProgress();
readerHighlightSentence(readerState.sentences[0]);
toast('Voice consistency: ' + mode + ' — audio cleared, re-synthesise', 'success');
}
// ── Generation params (seed / temperature) + loudness normalisation ──────────
function readerGenParams() {
const out = {};
const seed = $('reader-seed')?.value.trim();
const temp = $('reader-temp')?.value.trim();
if (seed !== '' && seed != null && !isNaN(+seed)) out.seed = parseInt(seed, 10);
if (temp !== '' && temp != null && !isNaN(+temp)) out.temperature = parseFloat(temp);
return Object.keys(out).length ? out : null;
}
// RMS-based gain so each chunk plays at a similar perceived loudness (clamped,
// peak-limited to avoid clipping). Cached per unit.
function readerComputeGain(idx, buf) {
if (readerState.gainCache.has(idx)) return readerState.gainCache.get(idx);
const ch = buf.getChannelData(0);
const step = Math.max(1, Math.floor(ch.length / 8000));
let sum = 0, n = 0, peak = 1e-6;
for (let i = 0; i < ch.length; i += step) { const v = ch[i]; sum += v * v; n++; if (Math.abs(v) > peak) peak = Math.abs(v); }
const rms = Math.sqrt(sum / Math.max(1, n)) || 0.0001;
let gain = 0.10 / rms; // target RMS ≈ 0.10
gain = Math.max(0.5, Math.min(gain, 4));
gain = Math.min(gain, 0.99 / peak); // never clip
readerState.gainCache.set(idx, gain);
return gain;
}
// ── Playback ─────────────────────────────────────────────────────────────────
// Fetch a chunk's audio that's already persisted on the server (synced device).
async function readerFetchServerAudio(idx) {
if (!readerState.savedId || !readerState.savedAudioIdx.has(idx)) return null;
try {
const r = await fetch(`${READER_API}/${readerState.savedId}/audio/${idx}`);
if (r.ok) { const b = await r.blob(); readerState.blobCache.set(idx, b); return b; }
} catch (_) {}
return null;
}
async function readerGetBuffer(idx) {
if (readerState.bufCache.has(idx)) return readerState.bufCache.get(idx);
let blob = readerState.blobCache.get(idx) || await readerFetchServerAudio(idx);
if (!blob) {
const voice = $('reader-voice-select')?.value;
const backend = $('reader-backend-select')?.value;
if (!voice) throw new Error('Pick a voice first');
if (!backend) throw new Error('No TTS backend selected');
const instruct = $('reader-instruct')?.value.trim() || '';
if (readerState.sentences[idx].status !== 'reading') readerSetStatus(idx, 'synth');
blob = await fetchTtsPreviewBlob(voice, readerState.sentences[idx].text, READER_FMT, instruct, backend, false, readerGenParams());
readerState.blobCache.set(idx, blob);
if (readerState.sentences[idx].status === 'synth') readerSetStatus(idx, 'ready');
}
const buf = await readerCtx().decodeAudioData(await blob.arrayBuffer());
readerState.bufCache.set(idx, buf);
return buf;
}
// Keep decoded PCM (large) only for a small window around the playhead; the
// compact MP3 blobs stay cached so re-entry just re-decodes (cheap).
function readerEvictBuffers(center) {
if (readerState.bufCache.size <= READER_BUF_WINDOW * 2 + 1) return;
for (const i of readerState.bufCache.keys()) {
if (Math.abs(i - center) > READER_BUF_WINDOW) readerState.bufCache.delete(i);
}
}
// Fetch the next chunk's audio AND pre-decode it so playback starts gaplessly.
async function readerPrefetch(idx) {
if (idx < 0 || idx >= readerState.sentences.length) return;
if (!readerState.blobCache.has(idx)) {
// prefer audio already persisted on the server, else synthesise
if (!(await readerFetchServerAudio(idx))) {
const voice = $('reader-voice-select')?.value;
const backend = $('reader-backend-select')?.value;
if (!voice || !backend) return;
const instruct = $('reader-instruct')?.value.trim() || '';
if (readerState.sentences[idx].status === 'pending') readerSetStatus(idx, 'synth');
try {
const b = await fetchTtsPreviewBlob(voice, readerState.sentences[idx].text, READER_FMT, instruct, backend, false, readerGenParams());
readerState.blobCache.set(idx, b);
if (readerState.sentences[idx].status === 'synth') readerSetStatus(idx, 'ready');
} catch (_) { if (readerState.sentences[idx].status === 'synth') readerSetStatus(idx, 'pending'); return; }
}
}
// pre-decode (only matters while it's the immediate look-ahead within the window)
if (!readerState.bufCache.has(idx) && Math.abs(idx - readerState.idx) <= READER_BUF_WINDOW) {
try { readerState.bufCache.set(idx, await readerCtx().decodeAudioData(await readerState.blobCache.get(idx).arrayBuffer())); }
catch (_) {}
}
}
// Revert the single "reading" unit to its idle colour (no full-array scan).
function readerClearReading() {
const i = readerState.readingIdx;
if (i >= 0 && i < readerState.sentences.length && readerState.sentences[i].status === 'reading') {
readerSetStatus(i, readerState.blobCache.has(i) ? 'ready' : 'pending');
}
readerState.readingIdx = -1;
}
async function readerPlayCurrent() {
if (!readerState.playing) return;
if (readerState.idx >= readerState.sentences.length) { readerStopPlayback(); readerState.idx = 0; readerUpdateProgress(); return; }
const idx = readerState.idx;
const sentence = readerState.sentences[idx];
readerUpdateProgress();
readerSaveResume();
readerState.readingIdx = idx;
readerSetStatus(idx, 'reading');
readerHighlightSentence(sentence);
let buf;
try { buf = await readerGetBuffer(idx); }
catch (e) { toast(e.message || String(e), 'error'); readerSetStatus(idx, 'pending'); readerStopPlayback(); return; }
if (!readerState.playing || readerState.idx !== idx) return;
readerEvictBuffers(idx);
const timings = computeWordTimings(sentence.text, buf.duration);
const ctx = readerCtx();
readerStopSource();
const src = ctx.createBufferSource();
src.buffer = buf;
src.playbackRate.value = readerState.speed;
if (readerState.normalize) {
const g = ctx.createGain();
g.gain.value = readerComputeGain(idx, buf);
src.connect(g); g.connect(ctx.destination);
} else {
src.connect(ctx.destination);
}
readerState.currentSource = src;
const t0 = ctx.currentTime;
readerPrefetch(idx + 1);
src.onended = () => {
if (readerState.currentSource !== src) return;
readerState.currentSource = null;
if (readerState.raf) { cancelAnimationFrame(readerState.raf); readerState.raf = null; }
readerSetStatus(idx, 'ready'); // synthesised → green
if (readerState.readingIdx === idx) readerState.readingIdx = -1;
if (!readerState.playing) return;
readerState.idx++;
readerPlayCurrent();
};
src.start(0);
const tick = () => {
if (readerState.currentSource !== src) return;
const elapsed = (ctx.currentTime - t0) * readerState.speed;
let active = 0;
for (let i = timings.length - 1; i >= 0; i--) { if (elapsed >= timings[i].start) { active = i; break; } }
readerHighlightWord(sentence, Math.min(active, sentence.words.length - 1));
readerState.raf = requestAnimationFrame(tick);
};
readerState.raf = requestAnimationFrame(tick);
}
function readerStopSource() {
if (readerState.currentSource) { try { readerState.currentSource.onended = null; readerState.currentSource.stop(0); } catch (_) {} readerState.currentSource = null; }
if (readerState.raf) { cancelAnimationFrame(readerState.raf); readerState.raf = null; }
}
function readerStopPlayback() {
readerState.playing = false;
readerStopSource();
readerClearHighlights();
// a sentence left mid-read reverts to its cached/uncached colour
readerClearReading();
readerUpdatePlayBtn();
}
function readerPlay() {
if (!readerState.sentences.length) { toast('Import a document first', 'error'); return; }
if (!$('reader-voice-select')?.value) { toast('Pick a voice first', 'error'); return; }
readerCtx();
readerState.playing = true;
readerUpdatePlayBtn();
readerPlayCurrent();
}
function readerPause() {
readerState.playing = false;
readerStopSource();
readerSaveResume();
readerPersistProgress();
readerClearReading();
readerUpdatePlayBtn();
}
function readerJumpTo(idx) {
readerState.idx = Math.max(0, Math.min(idx, readerState.sentences.length - 1));
readerStopSource();
readerClearHighlights();
readerClearReading();
readerUpdateProgress();
readerSaveResume();
if (readerState.playing) readerPlayCurrent();
else readerHighlightSentence(readerState.sentences[readerState.idx]);
}
function readerUpdatePlayBtn() {
const btn = $('reader-play'); if (!btn) return;
const ic = btn.querySelector('.mdi');
if (ic) ic.className = 'mdi ' + (readerState.playing ? 'mdi-pause' : 'mdi-play');
}
function readerUpdateProgress() {
const total = readerState.sentences.length;
const cur = total ? readerState.idx + 1 : 0;
const lbl = $('reader-progress-label'); if (lbl) lbl.textContent = cur + ' / ' + total;
const fill = $('reader-progress-fill'); if (fill) fill.style.width = (total ? (readerState.idx / total) * 100 : 0) + '%';
}
// ── Resume (persist last position per document title) ────────────────────────
function readerSaveResume() {
if (!readerState.title || !readerState.sentences.length) return;
try { localStorage.setItem(READER_RESUME_KEY, JSON.stringify({ title: readerState.title, idx: readerState.idx, total: readerState.sentences.length })); } catch (_) {}
}
function readerLoadResume() {
try {
const r = JSON.parse(localStorage.getItem(READER_RESUME_KEY) || 'null');
if (r && r.title === readerState.title && r.idx > 0 && r.idx < readerState.sentences.length) {
readerState.idx = r.idx;
return true;
}
} catch (_) {}
return false;
}
// ── Library (server-side under the config volume → syncs across devices) ──────
// Each book lives at <config>/reader_library/<id>/ (meta.json + source + audio/).
// Audio is fetched per-unit on demand so opening a book is instant on any device.
const READER_API = '/api/reader/docs';
function readerSyntheticCount() {
let n = 0;
for (let i = 0; i < readerState.sentences.length; i++) if (readerState.blobCache.has(i)) n++;
return n;
}
async function readerSaveLibrary() {
if (!readerState.sentences.length) { toast('Import a document first', 'error'); return; }
if (readerState.mode === 'pdf' && !readerState.fileBlob) { toast('Original PDF unavailable to save', 'error'); return; }
const meta = {
title: readerState.title || 'Untitled',
kind: readerState.mode,
idx: readerState.idx,
voice: $('reader-voice-select')?.value || '',
backend: $('reader-backend-select')?.value || '',
speed: readerState.speed,
instruct: $('reader-instruct')?.value.trim() || '',
chunkMode: readerState.chunkMode,
seed: $('reader-seed')?.value.trim() || '',
temperature: $('reader-temp')?.value.trim() || '',
normalize: readerState.normalize,
sentenceCount: readerState.sentences.length,
pageCount: readerState.pages.length,
synthCount: readerSyntheticCount(),
updated: new Date().toISOString(),
};
const btn = $('reader-save-lib'); if (btn) btn.disabled = true;
try {
if (readerState.savedId) meta.id = readerState.savedId;
const r = await fetch(READER_API, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(meta) });
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText);
const fresh = !readerState.savedId;
readerState.savedId = (await r.json()).id;
// upload the source document once (it's needed to re-render on another device)
if (fresh || !readerState.sourceUploaded) {
const ext = readerState.mode === 'pdf' ? 'pdf' : 'txt';
const body = readerState.mode === 'pdf' ? readerState.fileBlob : new Blob([readerState.docText || ''], { type: 'text/plain' });
const sr = await fetch(`${READER_API}/${readerState.savedId}/source?ext=${ext}`, { method: 'PUT', body });
if (sr.ok) readerState.sourceUploaded = true;
}
// upload ONLY chunks not already on the server (incremental)
let uploaded = 0;
for (let i = 0; i < readerState.sentences.length; i++) {
if (readerState.blobCache.has(i) && !readerState.savedAudioIdx.has(i)) {
const ar = await fetch(`${READER_API}/${readerState.savedId}/audio/${i}`, { method: 'PUT', body: readerState.blobCache.get(i) });
if (ar.ok) { readerState.savedAudioIdx.add(i); uploaded++; }
}
}
toast('Saved (' + meta.synthCount + ' / ' + meta.sentenceCount + ' synthesised' + (uploaded ? ', +' + uploaded + ' new' : '') + ')', 'success');
readerRenderLibrary();
} catch (e) { toast('Save failed: ' + (e.message || e), 'error'); }
finally { if (btn) btn.disabled = false; }
}
// Light update of just the reading position (called on stop/pause/leave)
async function readerPersistProgress() {
if (!readerState.savedId) return;
try {
await fetch(`${READER_API}/${readerState.savedId}/progress`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ idx: readerState.idx, updated: new Date().toISOString() }),
});
} catch (_) {}
}
async function readerDeleteLibraryDoc(id) {
try {
await fetch(`${READER_API}/${id}`, { method: 'DELETE' });
if (readerState.savedId === id) { readerState.savedId = null; readerState.savedAudioIdx = new Set(); readerState.sourceUploaded = false; }
readerRenderLibrary();
} catch (e) { toast('Delete failed: ' + (e.message || e), 'error'); }
}
async function readerOpenLibraryDoc(id) {
let rec;
try { const r = await fetch(`${READER_API}/${id}`); if (!r.ok) throw new Error(r.statusText); rec = await r.json(); }
catch (e) { toast('Open failed: ' + (e.message || e), 'error'); return; }
readerResetDoc();
readerState.title = rec.title;
const titleEl = $('reader-doc-title'); if (titleEl) titleEl.textContent = rec.title;
// restore controls (chunk mode must be set BEFORE the document is grouped)
readerState.chunkMode = rec.chunkMode || 'sentence';
if ($('reader-chunk-mode')) $('reader-chunk-mode').value = readerState.chunkMode;
if ($('reader-seed')) $('reader-seed').value = rec.seed || '';
if ($('reader-temp')) $('reader-temp').value = rec.temperature || '';
readerState.normalize = rec.normalize !== false;
if ($('reader-normalize')) $('reader-normalize').checked = readerState.normalize;
if (rec.backend && $('reader-backend-select')) readerSetSelectValue('reader-backend-select', rec.backend);
if (rec.voice && $('reader-voice-select')) readerSetSelectValue('reader-voice-select', rec.voice);
if (typeof rec.speed === 'number') {
readerState.speed = rec.speed;
if ($('reader-speed')) $('reader-speed').value = rec.speed;
const sl = $('reader-speed-label'); if (sl) sl.textContent = rec.speed.toFixed(2).replace(/0$/, '') + '×';
}
if ($('reader-instruct')) $('reader-instruct').value = rec.instruct || '';
// fetch the source document from the server
let sourceBlob = null;
try { const sr = await fetch(`${READER_API}/${id}/source`); if (sr.ok) sourceBlob = await sr.blob(); } catch (_) {}
try {
if (rec.kind === 'pdf') {
if (!sourceBlob) { toast('Saved PDF data missing', 'error'); return; }
readerState.fileBlob = sourceBlob;
toast('Opening book…', 'success');
await readerLoadPdf(sourceBlob);
} else {
const text = sourceBlob ? await sourceBlob.text() : (rec.text || '');
readerState.docText = text;
readerLoadText(text);
}
} catch (e) { toast('Open failed: ' + (e.message || e), 'error'); return; }
if (!readerState.sentences.length) { toast('Document had no readable text', 'error'); return; }
if (readerState.mode === 'text') readerState.sentences.forEach((s, i) => readerSetStatus(i, 'pending'));
readerState.savedId = id;
readerState.sourceUploaded = true; // already on the server
readerState.savedAudioIdx = new Set();
// mark which units already have audio on the server (fetched lazily on play)
if (rec.sentenceCount === readerState.sentences.length) {
(rec.audioIdx || []).forEach(i => { if (i < readerState.sentences.length) { readerState.savedAudioIdx.add(i); readerSetStatus(i, 'ready'); } });
} else if ((rec.audioIdx || []).length) {
toast('Document changed — re-synthesis needed', 'error');
}
readerState.idx = Math.max(0, Math.min(rec.idx || 0, readerState.sentences.length - 1));
$('reader-transport').hidden = false;
$('reader-dropzone').hidden = true;
$('reader-toolbar').hidden = readerState.mode !== 'pdf';
$('reader-synthbar').hidden = false;
const pr = $('reader-page-range');
if (pr) {
pr.hidden = readerState.mode !== 'pdf';
if (readerState.mode === 'pdf') {
const n = readerState.pages.length;
if ($('reader-page-from')) { $('reader-page-from').max = n; $('reader-page-from').value = 1; }
if ($('reader-page-to')) { $('reader-page-to').max = n; $('reader-page-to').value = n; }
}
}
readerUpdateScopeLabel();
readerUpdateProgress();
readerHighlightSentence(readerState.sentences[readerState.idx]);
toast('Opened "' + rec.title + '" · resuming at sentence ' + (readerState.idx + 1), 'success');
}
function readerSetSelectValue(id, value) {
const sel = $(id); if (!sel) return;
if (![...sel.options].some(o => o.value === value)) {
const o = document.createElement('option'); o.value = o.textContent = value; sel.appendChild(o);
}
sel.value = value;
}
async function readerRenderLibrary() {
const card = $('reader-library-card'), list = $('reader-lib-list');
if (!card || !list) return;
let all = [];
try { const r = await fetch(READER_API); if (r.ok) all = (await r.json()).docs || []; } catch (_) { all = []; }
if (!all.length) { card.hidden = true; list.innerHTML = ''; return; }
card.hidden = false;
list.innerHTML = all.map(rec => {
const total = rec.sentenceCount || 0;
const synth = rec.synthCount || 0;
const readPct = total ? Math.round(((rec.idx || 0) / total) * 100) : 0;
const synthPct = total ? Math.round((synth / total) * 100) : 0;
const date = rec.updated ? new Date(rec.updated).toLocaleDateString() : '';
return `<div class="reader-book" data-id="${rec.id}" title="${escHtml(rec.title)}">
<button class="reader-book-del" data-del="${rec.id}" title="Delete"><span class="mdi mdi-delete-outline"></span></button>
<div class="reader-book-icon"><span class="mdi ${rec.kind === 'pdf' ? 'mdi-file-pdf-box' : 'mdi-text-box-outline'}"></span></div>
<div class="reader-book-title">${escHtml(rec.title)}</div>
<div class="reader-book-meta">${total} sentences${rec.pageCount ? ' · ' + rec.pageCount + ' pg' : ''} · ${date}</div>
<div class="reader-book-bar" title="${synthPct}% synthesised"><div class="reader-book-bar-synth" style="width:${synthPct}%"></div><div class="reader-book-bar-read" style="width:${readPct}%"></div></div>
<div class="reader-book-stats"><span><span class="mdi mdi-lightning-bolt"></span> ${synthPct}% audio</span><span><span class="mdi mdi-bookmark-outline"></span> ${readPct}% read</span></div>
</div>`;
}).join('');
list.querySelectorAll('.reader-book').forEach(el => el.addEventListener('click', e => {
if (e.target.closest('.reader-book-del')) return;
readerOpenLibraryDoc(el.dataset.id);
}));
list.querySelectorAll('.reader-book-del').forEach(btn => btn.addEventListener('click', e => {
e.stopPropagation();
if (confirm('Delete this saved document and its audio?')) readerDeleteLibraryDoc(btn.dataset.del);
}));
}
// ── Wiring ───────────────────────────────────────────────────────────────────
$('reader-file-input')?.addEventListener('change', function () {
const f = this.files?.[0]; this.value = '';
readerImportFile(f);
});
$('reader-fetch-voices-btn')?.addEventListener('click', readerFetchVoices);
$('reader-backend-select')?.addEventListener('change', () => {
const sel = $('reader-voice-select'); if (sel) sel.innerHTML = '<option value="">— fetch voices —</option>';
readerUpdateBackendHint();
});
$('reader-chunk-mode')?.addEventListener('change', function () { readerRechunk(this.value); });
$('reader-normalize')?.addEventListener('change', function () { readerState.normalize = this.checked; });
$('reader-play')?.addEventListener('click', () => { readerState.playing ? readerPause() : readerPlay(); });
$('reader-stop')?.addEventListener('click', () => { readerStopPlayback(); readerState.idx = 0; readerSaveResume(); readerUpdateProgress(); });
$('reader-prev')?.addEventListener('click', () => readerJumpTo(readerState.idx - 1));
$('reader-next')?.addEventListener('click', () => readerJumpTo(readerState.idx + 1));
$('reader-speed')?.addEventListener('input', function () {
readerState.speed = parseFloat(this.value) || 1;
const lbl = $('reader-speed-label'); if (lbl) lbl.textContent = readerState.speed.toFixed(2).replace(/0$/, '') + '×';
if (readerState.currentSource) readerState.currentSource.playbackRate.value = readerState.speed;
});
// Zoom controls
$('reader-zoom-fitw')?.addEventListener('click', () => readerApplyZoom('fit-width'));
$('reader-zoom-fith')?.addEventListener('click', () => readerApplyZoom('fit-height'));
$('reader-zoom-two') ?.addEventListener('click', () => readerApplyZoom('two'));
$('reader-zoom-in') ?.addEventListener('click', () => { readerState.scale = Math.min(4, readerState.scale * 1.2); readerApplyZoom('custom'); });
$('reader-zoom-out') ?.addEventListener('click', () => { readerState.scale = Math.max(0.2, readerState.scale / 1.2); readerApplyZoom('custom'); });
// Library + export
$('reader-save-lib')?.addEventListener('click', readerSaveLibrary);
$('reader-export-btn')?.addEventListener('click', () => readerExport($('reader-export-mode')?.value || 'page'));
// Batch synthesis + range selection
$('reader-synth-all')?.addEventListener('click', readerSynthAll);
$('reader-synth-cancel')?.addEventListener('click', () => { readerState.synthCancel = true; });
$('reader-select-toggle')?.addEventListener('click', () => {
if (readerState.selecting) readerSetSelecting(false); // "Done"
else { readerClearSelection(); readerSetSelecting(true); }
});
$('reader-sel-clear')?.addEventListener('click', readerClearSelection);
// Esc leaves select mode
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && readerState.selecting) { readerSetSelecting(false); }
});
$('reader-page-from')?.addEventListener('change', readerUpdateScopeLabel);
$('reader-page-to') ?.addEventListener('change', readerUpdateScopeLabel);
// Click a PDF sentence's status box to set a selection point (in select mode)
$('reader-doc')?.addEventListener('click', e => {
if (!readerState.selecting || readerState.mode !== 'pdf') return;
const stat = e.target.closest('.reader-stat'); if (!stat) return;
const si = parseInt(stat.dataset.si);
if (!isNaN(si)) readerPickSentence(si);
});
// Re-raster pages that scroll into view (zoom invalidates cached canvases)
$('reader-doc')?.addEventListener('scroll', (function () {
let t = null;
return () => { if (t) return; t = setTimeout(() => { t = null; readerRenderVisible(); }, 120); };
})());
// Keep fit-width/height/two correct on window resize
window.addEventListener('resize', (function () {
let t = null;
return () => { if (readerState.mode !== 'pdf' || readerState.zoomMode === 'custom') return; clearTimeout(t); t = setTimeout(() => readerApplyZoom(readerState.zoomMode), 200); };
})());
// Drag & drop over the dropzone + the whole reader doc
(function setupReaderDropzone() {
const targets = ['reader-dropzone', 'reader-doc'].map(id => $(id)).filter(Boolean);
targets.forEach(el => {
el.addEventListener('dragover', e => { if (e.dataTransfer && [...(e.dataTransfer.types || [])].includes('Files')) { e.preventDefault(); el.classList.add('dragover'); } });
el.addEventListener('dragleave', () => el.classList.remove('dragover'));
el.addEventListener('drop', e => {
if (!(e.dataTransfer && [...(e.dataTransfer.types || [])].includes('Files'))) return;
e.preventDefault(); el.classList.remove('dragover');
readerImportFile(e.dataTransfer.files?.[0]);
});
});
})();