// ── STATE ────────────────────────────────────────────────────────── const state = { sentences: [], // [{text, isPara}] currentIdx: 0, isPlaying: false, speed: 1.0, model: 'openai-tts-1', voice: 'nova', fontSize: 18, }; let currentAudio = null; const audioCache = new Map(); // idx → blob URL let allModels = {}; // ── DOM ──────────────────────────────────────────────────────────── const $ = id => document.getElementById(id); const textViewer = $('text-viewer'); const modelSelect = $('model-select'); const voiceSelect = $('voice-select'); const speedSlider = $('speed-slider'); const speedLabel = $('speed-label'); const btnPlay = $('btn-play'); const btnPrev = $('btn-prev'); const btnNext = $('btn-next'); const nowPlaying = $('now-playing'); const curSent = $('cur-sent'); const totSent = $('tot-sent'); const progressFill = $('progress-fill'); const loadingOv = $('loading-overlay'); const loadingText = $('loading-text'); // ── LOADING ──────────────────────────────────────────────────────── const showLoading = (msg = 'Loading…') => { loadingText.textContent = msg; loadingOv.classList.add('show'); }; const hideLoading = () => loadingOv.classList.remove('show'); // ── VOICES INIT ──────────────────────────────────────────────────── async function initVoices() { const resp = await fetch('/api/voices'); allModels = await resp.json(); modelSelect.innerHTML = ''; for (const [id, info] of Object.entries(allModels)) { const o = document.createElement('option'); o.value = id; o.textContent = `${info.name} — ${info.quality}`; modelSelect.appendChild(o); } modelSelect.value = 'openai-tts-1'; refreshVoices(); } function refreshVoices() { state.model = modelSelect.value; const voices = allModels[state.model]?.voices || []; voiceSelect.innerHTML = ''; for (const v of voices) { const o = document.createElement('option'); o.value = v.id; o.textContent = v.name; voiceSelect.appendChild(o); } state.voice = voiceSelect.value; audioCache.clear(); } modelSelect.addEventListener('change', refreshVoices); voiceSelect.addEventListener('change', () => { state.voice = voiceSelect.value; audioCache.clear(); }); // ── SENTENCE SPLITTING ───────────────────────────────────────────── function splitSentences(text) { text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); const paras = text.split(/\n{2,}/); const result = []; for (let pi = 0; pi < paras.length; pi++) { const para = paras[pi].trim(); if (!para) continue; // Split on sentence-ending punctuation followed by space + capital const parts = para.split(/(?<=[.!?…])\s+(?=[A-Z"'"'(])/); for (const part of parts) { const t = part.trim(); if (t) result.push({ text: t, isPara: false }); } if (pi < paras.length - 1) result.push({ text: '', isPara: true }); } return result; } // ── RENDER TEXT ──────────────────────────────────────────────────── function renderText(sentences) { textViewer.innerHTML = ''; sentences.forEach((s, i) => { if (s.isPara) { textViewer.appendChild(Object.assign(document.createElement('span'), { className: 'para-gap' })); return; } const span = document.createElement('span'); span.className = 's'; span.dataset.i = i; span.textContent = s.text + ' '; span.addEventListener('click', () => jumpTo(i)); textViewer.appendChild(span); }); } // ── LOAD DOCUMENT ────────────────────────────────────────────────── function loadDoc(text, title = 'Document') { state.sentences = splitSentences(text); state.currentIdx = 0; audioCache.clear(); stopAudio(); const real = state.sentences.filter(s => !s.isPara).length; $('document-header').style.display = ''; $('document-title').textContent = title; $('document-meta').textContent = `${real} sentences`; totSent.textContent = real; curSent.textContent = 0; progressFill.style.width = '0%'; nowPlaying.textContent = 'Ready — press play'; renderText(state.sentences); textViewer.style.fontSize = `${state.fontSize}px`; } // ── AUDIO ────────────────────────────────────────────────────────── async function generateAudio(idx) { if (audioCache.has(idx)) return audioCache.get(idx); const s = state.sentences[idx]; const resp = await fetch('/api/tts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: s.text, model: state.model, voice: state.voice, speed: state.speed }), }); if (!resp.ok) throw new Error(await resp.text()); const blob = await resp.blob(); const url = URL.createObjectURL(blob); audioCache.set(idx, url); return url; } function prefetch(fromIdx, count = 3) { let n = 0, i = fromIdx + 1; while (n < count && i < state.sentences.length) { const s = state.sentences[i]; if (!s.isPara && !audioCache.has(i)) generateAudio(i).catch(() => {}); if (!s.isPara) n++; i++; } } function stopAudio() { if (currentAudio) { currentAudio.pause(); currentAudio = null; } } // ── HIGHLIGHT ────────────────────────────────────────────────────── function highlight(idx) { document.querySelectorAll('.s.active').forEach(el => el.classList.remove('active')); const el = document.querySelector(`.s[data-i="${idx}"]`); if (el) { el.classList.add('active'); el.scrollIntoView({ behavior: 'smooth', block: 'center' }); } state.currentIdx = idx; const s = state.sentences[idx]; if (s) nowPlaying.textContent = s.text; // Update counter const realIdx = state.sentences.slice(0, idx + 1).filter(s => !s.isPara).length; const realTotal = state.sentences.filter(s => !s.isPara).length; curSent.textContent = realIdx; progressFill.style.width = realTotal ? `${(realIdx / realTotal) * 100}%` : '0%'; } // ── NAVIGATION HELPERS ───────────────────────────────────────────── function nextReal(from) { for (let i = from; i < state.sentences.length; i++) if (!state.sentences[i].isPara) return i; return -1; } function prevReal(from) { for (let i = from; i >= 0; i--) if (!state.sentences[i].isPara) return i; return -1; } // ── PLAYBACK ─────────────────────────────────────────────────────── async function playSentence(idx) { if (idx < 0 || idx >= state.sentences.length) { stop(); return; } const s = state.sentences[idx]; if (s.isPara) { playSentence(nextReal(idx + 1)); return; } highlight(idx); stopAudio(); try { const url = await generateAudio(idx); if (!state.isPlaying) return; const audio = new Audio(url); audio.playbackRate = state.speed; currentAudio = audio; prefetch(idx); audio.play().catch(console.error); audio.onended = () => { if (state.isPlaying) playSentence(nextReal(idx + 1)); }; audio.onerror = () => { if (state.isPlaying) playSentence(nextReal(idx + 1)); }; } catch (e) { console.error('TTS error:', e); if (state.isPlaying) playSentence(nextReal(idx + 1)); } } function stop() { state.isPlaying = false; stopAudio(); updatePlayBtn(); document.querySelectorAll('.s.active').forEach(el => el.classList.remove('active')); nowPlaying.textContent = 'Finished'; } function updatePlayBtn() { btnPlay.querySelector('.icon-play').style.display = state.isPlaying ? 'none' : ''; btnPlay.querySelector('.icon-pause').style.display = state.isPlaying ? '' : 'none'; } function jumpTo(idx) { state.currentIdx = idx; if (state.isPlaying) { stopAudio(); playSentence(idx); } else { highlight(idx); } } // ── PLAYER CONTROLS ──────────────────────────────────────────────── btnPlay.addEventListener('click', () => { if (!state.sentences.length) return; state.isPlaying = !state.isPlaying; updatePlayBtn(); if (state.isPlaying) { const idx = state.sentences[state.currentIdx]?.isPara ? nextReal(state.currentIdx) : state.currentIdx; playSentence(idx >= 0 ? idx : nextReal(0)); } else { stopAudio(); nowPlaying.textContent = 'Paused'; } }); btnNext.addEventListener('click', () => { const n = nextReal(state.currentIdx + 1); if (n >= 0) jumpTo(n); }); btnPrev.addEventListener('click', () => { const p = prevReal(state.currentIdx - 1); if (p >= 0) jumpTo(p); }); // Progress bar click $('progress-track').addEventListener('click', e => { const rect = e.currentTarget.getBoundingClientRect(); const ratio = (e.clientX - rect.left) / rect.width; const reals = state.sentences.map((s, i) => s.isPara ? null : i).filter(i => i !== null); const target = reals[Math.floor(ratio * reals.length)]; if (target !== undefined) jumpTo(target); }); // ── SPEED ────────────────────────────────────────────────────────── function setSpeed(v) { state.speed = v; speedSlider.value = v; speedLabel.textContent = `${v}×`; if (currentAudio) currentAudio.playbackRate = v; document.querySelectorAll('.speed-btn').forEach(b => { b.classList.toggle('active', parseFloat(b.dataset.speed) === v); }); } speedSlider.addEventListener('input', () => setSpeed(parseFloat(speedSlider.value))); document.querySelectorAll('.speed-btn').forEach(b => { b.addEventListener('click', () => setSpeed(parseFloat(b.dataset.speed))); }); // ── FONT SIZE ────────────────────────────────────────────────────── $('font-increase').addEventListener('click', () => { state.fontSize = Math.min(state.fontSize + 2, 34); textViewer.style.fontSize = `${state.fontSize}px`; $('font-size-label').textContent = `${state.fontSize}px`; }); $('font-decrease').addEventListener('click', () => { state.fontSize = Math.max(state.fontSize - 2, 12); textViewer.style.fontSize = `${state.fontSize}px`; $('font-size-label').textContent = `${state.fontSize}px`; }); // ── SIDEBAR TOGGLES ──────────────────────────────────────────────── $('btn-sidebar-toggle').addEventListener('click', () => $('sidebar-left').classList.toggle('collapsed')); $('btn-settings-toggle').addEventListener('click', () => $('sidebar-right').classList.toggle('collapsed')); // ── TABS ─────────────────────────────────────────────────────────── document.querySelectorAll('.tab').forEach(tab => { tab.addEventListener('click', () => { document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active')); tab.classList.add('active'); $(`tab-${tab.dataset.tab}`).classList.add('active'); }); }); // ── TEXT INPUT ───────────────────────────────────────────────────── $('btn-load-text').addEventListener('click', () => { const text = $('text-input').value.trim(); if (text) loadDoc(text, 'Pasted Text'); }); // ── URL INPUT ────────────────────────────────────────────────────── $('btn-load-url').addEventListener('click', async () => { const url = $('url-input').value.trim(); if (!url) return; const status = $('url-status'); showLoading('Fetching article…'); try { const resp = await fetch('/api/extract-url', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }), }); if (!resp.ok) throw new Error(await resp.text()); const data = await resp.json(); loadDoc(data.text, data.title || url); status.textContent = '✓ Loaded'; } catch (e) { status.textContent = '✗ ' + e.message; } finally { hideLoading(); } }); // ── PDF INPUT ────────────────────────────────────────────────────── const uploadArea = $('upload-area'); const pdfInput = $('pdf-input'); uploadArea.addEventListener('click', () => pdfInput.click()); uploadArea.addEventListener('dragover', e => { e.preventDefault(); uploadArea.classList.add('drag-over'); }); uploadArea.addEventListener('dragleave', () => uploadArea.classList.remove('drag-over')); uploadArea.addEventListener('drop', e => { e.preventDefault(); uploadArea.classList.remove('drag-over'); const f = e.dataTransfer.files[0]; if (f?.type === 'application/pdf') uploadPDF(f); }); pdfInput.addEventListener('change', () => { if (pdfInput.files[0]) uploadPDF(pdfInput.files[0]); }); async function uploadPDF(file) { const status = $('pdf-status'); showLoading('Extracting PDF…'); const form = new FormData(); form.append('file', file); try { const resp = await fetch('/api/extract-pdf', { method: 'POST', body: form }); if (!resp.ok) throw new Error(await resp.text()); const data = await resp.json(); loadDoc(data.text, data.title || file.name); status.textContent = '✓ Loaded'; } catch (e) { status.textContent = '✗ ' + e.message; } finally { hideLoading(); } } // ── VOICE PREVIEW ───────────────────────────────────────────────── $('btn-preview').addEventListener('click', async () => { showLoading('Generating preview…'); try { const resp = await fetch('/api/tts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: 'Hello! This is a preview of the selected voice. How does it sound to you?', model: state.model, voice: state.voice, speed: state.speed, }), }); const blob = await resp.blob(); const url = URL.createObjectURL(blob); const a = new Audio(url); a.playbackRate = state.speed; a.play(); a.onended = () => URL.revokeObjectURL(url); } catch (e) { console.error('Preview failed:', e); } finally { hideLoading(); } }); // ── KEYBOARD SHORTCUTS ───────────────────────────────────────────── document.addEventListener('keydown', e => { if (['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return; if (e.code === 'Space') { e.preventDefault(); btnPlay.click(); } else if (e.code === 'ArrowRight') btnNext.click(); else if (e.code === 'ArrowLeft') btnPrev.click(); }); // ── BOOT ─────────────────────────────────────────────────────────── initVoices();