import { PhononTTS, clearCache } from 'phonon-tts'; // ---------- constants ---------- const SR = 24000; const MAX_CHUNK = 260; // characters per synthesis chunk const LOOKAHEAD = 6; // chunks generated ahead of playback const VOICES_URL = 'https://huggingface.co/gradium/phonon-voices/resolve/main/voices.jsonl'; const VOICE_BASE = 'https://huggingface.co/gradium/phonon-voices/resolve/main/'; const VOICE_CACHE = 'reader-voices-v1'; const LS = 'phonon-reader-v1'; const $ = (id) => document.getElementById(id); const el = { input: $('input'), listen: $('btn-listen'), paste: $('btn-paste'), clear: $('btn-clear'), edit: $('btn-edit'), settingsBtn: $('btn-settings'), settings: $('settings'), viewEdit: $('view-edit'), viewRead: $('view-read'), reader: $('reader'), player: $('player'), status: $('status'), play: $('btn-play'), prev: $('btn-prev'), next: $('btn-next'), voice: $('voice'), rate: $('rate'), rateLabel: $('rate-label'), device: $('device'), format: $('format'), engineInfo: $('engine-info'), clearCacheBtn: $('btn-clear-cache'), modelBar: $('model-bar'), modelText: $('model-text'), modelProgress: $('model-progress'), shareNote: $('share-note'), audio: $('audio'), net: $('net'), readyTitle: $('ready-title'), readyNote: $('ready-note'), rApp: $('r-app'), rModel: $('r-model'), rVoice: $('r-voice'), title: $('title'), convertStart: $('btn-convert-start'), convert: $('btn-convert'), share: $('btn-share'), download: $('btn-download'), export: $('export'), exTitle: $('ex-title'), exMeta: $('ex-meta'), exNote: $('ex-note'), exBar: $('ex-bar'), exProgress: $('ex-progress'), }; // ---------- persisted settings ---------- const saved = (() => { try { return JSON.parse(localStorage.getItem(LS)) || {}; } catch { return {}; } })(); const store = (patch) => { Object.assign(saved, patch); try { localStorage.setItem(LS, JSON.stringify(saved)); } catch { /* storage unavailable */ } }; // ---------- state ---------- const state = { tts: null, chunks: [], // { text, para, pcm: Int16Array|null } idx: 0, playing: false, voice: saved.voice || '', rate: saved.rate || 1, gen: { abort: null, running: false, wake: null, prepareAll: false }, waiters: [], // resolved whenever a chunk finishes generating voiceMeta: new Map(), }; // ---------- text cleanup & chunking ---------- function htmlToText(html) { const doc = new DOMParser().parseFromString(html, 'text/html'); doc.querySelectorAll('script,style,noscript,nav,aside,footer,header,form,button,figure,figcaption,sup,svg,iframe,select') .forEach((n) => n.remove()); const blocks = new Set('P DIV LI H1 H2 H3 H4 H5 H6 BLOCKQUOTE TR SECTION ARTICLE PRE BR DD DT MAIN TABLE UL OL'.split(' ')); const out = []; const walk = (node) => { if (node.nodeType === 3) { out.push(node.nodeValue); return; } if (node.nodeType !== 1) return; const isBlock = blocks.has(node.nodeName); if (isBlock) out.push('\n\n'); node.childNodes.forEach(walk); if (isBlock) out.push('\n\n'); }; walk(doc.body); return out.join(''); } function cleanText(raw) { return raw .replace(/\r\n?/g, '\n') .replace(/\[(\d+|citation needed|edit)\]/gi, '') // wiki-style markers .replace(/[ \t ]+/g, ' ') .replace(/ *\n */g, '\n') .replace(/([^\n])\n(?!\n)/g, '$1 ') // join hard-wrapped lines .replace(/\n{3,}/g, '\n\n') .trim(); } const segmenter = typeof Intl !== 'undefined' && Intl.Segmenter ? new Intl.Segmenter('en', { granularity: 'sentence' }) : null; function sentences(p) { if (segmenter) return [...segmenter.segment(p)].map((s) => s.segment.trim()).filter(Boolean); return p.match(/[^.!?]+[.!?]+["')\]]*|\S[^.!?]*$/g)?.map((s) => s.trim()).filter(Boolean) || [p]; } function splitLong(s) { if (s.length <= MAX_CHUNK) return [s]; const parts = []; let rest = s; while (rest.length > MAX_CHUNK) { let cut = Math.max(rest.lastIndexOf(', ', MAX_CHUNK), rest.lastIndexOf('; ', MAX_CHUNK), rest.lastIndexOf(': ', MAX_CHUNK)); if (cut < MAX_CHUNK * 0.4) cut = rest.lastIndexOf(' ', MAX_CHUNK); if (cut <= 0) cut = MAX_CHUNK; parts.push(rest.slice(0, cut + 1).trim()); rest = rest.slice(cut + 1).trim(); } if (rest) parts.push(rest); return parts; } function chunkText(text) { const chunks = []; text.split(/\n\n+/).forEach((para, pi) => { let cur = ''; for (const s of sentences(para).flatMap(splitLong)) { if (!chunks.length && !cur) { chunks.push({ text: s, para: pi, pcm: null }); continue; } // fast start if (cur && cur.length + s.length + 1 > MAX_CHUNK) { chunks.push({ text: cur, para: pi, pcm: null }); cur = s; } else cur = cur ? cur + ' ' + s : s; } if (cur) chunks.push({ text: cur, para: pi, pcm: null }); }); return chunks; } // ---------- audio helpers ---------- function toInt16(f32) { const out = new Int16Array(f32.length); for (let i = 0; i < f32.length; i++) { const v = Math.max(-1, Math.min(1, f32[i])); out[i] = v < 0 ? v * 0x8000 : v * 0x7fff; } return out; } function wavBlob(int16Parts) { const len = int16Parts.reduce((n, p) => n + p.length, 0); const buf = new ArrayBuffer(44 + len * 2); const v = new DataView(buf); const w = (o, s) => { for (let i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i)); }; w(0, 'RIFF'); v.setUint32(4, 36 + len * 2, true); w(8, 'WAVE'); w(12, 'fmt '); v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true); v.setUint32(24, SR, true); v.setUint32(28, SR * 2, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true); w(36, 'data'); v.setUint32(40, len * 2, true); let off = 44; for (const p of int16Parts) { new Int16Array(buf, off, p.length).set(p); off += p.length * 2; } return new Blob([buf], { type: 'audio/wav' }); } // ---------- offline readiness ---------- const ready = { app: false, model: false, voice: false, modelBusy: true, downloadedNow: false, persisted: false }; function voiceLabel() { return state.voice ? `Voice (${state.voice})` : 'Voice (default)'; } async function checkVoiceCached() { if (!state.voice) { ready.voice = ready.model; return renderReadiness(); } const meta = state.voiceMeta.get(state.voice); try { const c = await caches.open(VOICE_CACHE); ready.voice = !!(meta && await c.match(VOICE_BASE + meta.filename)); } catch { ready.voice = false; } renderReadiness(); } function renderReadiness() { const set = (li, ok, busy) => { li.classList.toggle('yes', ok); li.classList.toggle('busy', !ok && !!busy); }; set(el.rApp, ready.app); set(el.rModel, ready.model, ready.modelBusy); el.rVoice.textContent = voiceLabel(); set(el.rVoice, ready.voice); const all = ready.app && ready.model && ready.voice; const online = navigator.onLine; el.net.className = 'pill ' + (!online ? 'off' : all ? 'ok' : ''); el.net.textContent = !online ? '✈ Offline' : all ? '✓ Offline-ready' : ''; if (all) { el.readyTitle.textContent = online ? 'Ready to work offline' : 'Offline — running entirely on this device'; el.readyNote.textContent = online ? 'The app, speech model and voice are saved on this device. You can turn on airplane mode or switch off Wi‑Fi and keep listening. Your text never leaves the phone.' : 'No network needed: speech is being generated on this device.'; } else if (!online) { el.readyTitle.textContent = 'Offline, but not everything is saved yet'; el.readyNote.textContent = 'Connect once so the missing parts can download. After that it works without a connection.'; } else { el.readyTitle.textContent = ready.modelBusy ? 'Saving everything for offline use…' : 'Almost ready for offline use'; el.readyNote.textContent = !ready.voice && ready.model && state.voice ? `The ${state.voice} voice downloads the first time it speaks (a few hundred KB).` : 'One-time download (about 65 MB). Your text is never uploaded.'; } } addEventListener('online', renderReadiness); addEventListener('offline', renderReadiness); // ---------- model ---------- function setModelStatus(text, frac) { el.modelText.textContent = text; if (frac != null) el.modelProgress.style.width = `${Math.round(frac * 100)}%`; } async function loadModel() { const files = {}; try { state.tts = await PhononTTS.load({ lang: 'en', device: saved.device || 'cpu', onProgress: ({ file, loaded, total, cached }) => { if (!cached) ready.downloadedNow = true; files[file] = { loaded, total: total || loaded }; const all = Object.values(files); const L = all.reduce((a, f) => a + f.loaded, 0); const T = all.reduce((a, f) => a + f.total, 0); setModelStatus(cached ? 'Loading speech model from this device…' : `Downloading speech model (one time) — ${(L / 1e6).toFixed(0)} of ${(T / 1e6).toFixed(0)} MB`, T ? L / T : null); }, }); const t = state.tts; el.engineInfo.textContent = `Engine: ${t.device.toUpperCase()}${t.device === 'cpu' ? `, ${t.threads} thread${t.threads > 1 ? 's' : ''}` : ''}. ${t.device === 'cpu' ? t.threadsReason : t.deviceReason}`; if (state.voice) await ensureVoice(state.voice).catch(() => { state.voice = ''; el.voice.value = ''; }); el.modelBar.classList.add('ready'); ready.model = true; ready.modelBusy = false; try { ready.persisted = await navigator.storage?.persist?.(); } catch { /* optional */ } checkVoiceCached(); kickGen(); } catch (e) { console.error(e); ready.modelBusy = false; renderReadiness(); setModelStatus(`Could not load the speech model: ${e.message || e}`, 0); } } // ---------- voices ---------- async function cachedFetch(url, type = 'arrayBuffer') { let cache = null; try { cache = await caches.open(VOICE_CACHE); } catch { /* no Cache API */ } try { const res = await fetch(url, { mode: 'cors' }); if (!res.ok) throw new Error(res.status); if (cache) await cache.put(url, res.clone()).catch(() => {}); return await res[type](); } catch (e) { const hit = cache && await cache.match(url); if (hit) return hit[type](); throw e; } } async function loadVoiceList() { try { const txt = await cachedFetch(VOICES_URL, 'text'); const voices = txt.split('\n').filter(Boolean).map((l) => JSON.parse(l)).filter((v) => v.language === 'en'); const groups = { 'en-gb': 'British', 'en-us': 'American', characters: 'Characters' }; for (const [loc, label] of Object.entries(groups)) { const og = document.createElement('optgroup'); og.label = label; for (const v of voices.filter((x) => x.locale === loc)) { state.voiceMeta.set(v.name, v); const o = document.createElement('option'); o.value = v.name; o.textContent = v.gender ? `${v.name} — ${v.gender}, ${v.age.toLowerCase()}` : v.name; o.title = v.description; og.append(o); } if (og.children.length) el.voice.append(og); } el.voice.value = state.voice; } catch (e) { console.warn('voice list unavailable', e); } if (ready.model) checkVoiceCached(); } let voicesReady = Promise.resolve(); async function ensureVoice(name) { if (!name || state.tts.voices.includes(name)) return; await voicesReady; const meta = state.voiceMeta.get(name); if (!meta) throw new Error('unknown voice'); const bytes = await cachedFetch(VOICE_BASE + meta.filename); await state.tts.addVoice(name, bytes); checkVoiceCached(); } // ---------- generation loop ---------- function notifyChunkReady() { const w = state.waiters; state.waiters = []; w.forEach((r) => r()); } function waitForChunk() { return new Promise((r) => state.waiters.push(r)); } function nextToGenerate() { const end = state.gen.prepareAll ? state.chunks.length : Math.min(state.chunks.length, state.idx + LOOKAHEAD); for (let i = state.idx; i < end; i++) if (!state.chunks[i].pcm) return i; if (state.gen.prepareAll) for (let i = 0; i < state.idx; i++) if (!state.chunks[i].pcm) return i; return -1; } function kickGen() { if (state.gen.wake) { const w = state.gen.wake; state.gen.wake = null; w(); } if (!state.gen.running) genLoop(); } /** Abort in-flight synthesis (e.g. after a jump or voice change). */ function restartGen() { state.gen.abort?.abort(); kickGen(); } async function genLoop() { if (!state.tts) return; state.gen.running = true; try { for (;;) { const i = nextToGenerate(); if (i < 0) { await new Promise((r) => { state.gen.wake = r; }); continue; } const chunk = state.chunks[i]; const ctrl = new AbortController(); state.gen.abort = ctrl; const opts = { signal: ctrl.signal }; if (state.voice) opts.voice = state.voice; const voiceAtStart = state.voice; try { try { await ensureVoice(state.voice); } catch (e) { console.warn('voice unavailable, using default', e); delete opts.voice; } const parts = []; const speech = state.tts.stream(chunk.text, opts); for await (const pcm of speech) parts.push(pcm); await speech.done; if (ctrl.signal.aborted || voiceAtStart !== state.voice || state.chunks[i] !== chunk) continue; let n = 0; parts.forEach((p) => (n += p.length)); const all = new Float32Array(n); let o = 0; for (const p of parts) { all.set(p, o); o += p.length; } chunk.pcm = toInt16(all); notifyChunkReady(); onChunkReady(chunk); } catch (e) { if (ctrl.signal.aborted) continue; console.error('synthesis failed', e); chunk.pcm = new Int16Array(SR / 4); // short silence so playback can move on notifyChunkReady(); onChunkReady(chunk); } } } finally { state.gen.running = false; } } // ---------- playback ---------- let currentUrl = null; let playToken = 0; function setStatus(t) { el.status.textContent = t; } function render() { el.reader.textContent = ''; let p = null, lastPara = -1; state.chunks.forEach((c, i) => { if (c.para !== lastPara) { p = document.createElement('p'); el.reader.append(p); lastPara = c.para; } const s = document.createElement('span'); s.className = 'c'; s.dataset.i = i; s.textContent = c.text + ' '; p.append(s); }); highlight(); } function highlight(scroll = true) { el.reader.querySelectorAll('.c').forEach((s) => { const i = +s.dataset.i; s.classList.toggle('now', i === state.idx); s.classList.toggle('played', i < state.idx); }); const now = el.reader.querySelector('.c.now'); if (scroll && now) { const r = now.getBoundingClientRect(); if (r.top < 70 || r.bottom > innerHeight - 220) now.scrollIntoView({ block: 'center', behavior: 'smooth' }); } store({ idx: state.idx }); updateMediaSession(); } function updatePlayButton() { el.play.textContent = state.playing ? '❚❚' : '▶'; el.play.setAttribute('aria-label', state.playing ? 'Pause' : 'Play'); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = state.playing ? 'playing' : 'paused'; } async function playChunk(i) { const token = ++playToken; state.idx = i; highlight(); kickGen(); const chunk = state.chunks[i]; while (!chunk.pcm) { setStatus(state.tts ? 'Generating…' : 'Waiting for the speech model…'); await waitForChunk(); if (token !== playToken) return; } if (currentUrl) URL.revokeObjectURL(currentUrl); currentUrl = URL.createObjectURL(wavBlob([chunk.pcm])); const a = el.audio; a.src = currentUrl; a.preservesPitch = true; a.playbackRate = state.rate; setStatus(`${i + 1} of ${state.chunks.length}`); try { await a.play(); } catch (e) { if (token === playToken) { state.playing = false; updatePlayButton(); setStatus('Tap play to continue'); } } } el.audio.addEventListener('ended', () => { if (!state.playing) return; if (state.idx + 1 < state.chunks.length) playChunk(state.idx + 1); else { state.playing = false; state.finished = true; updatePlayButton(); setStatus('Finished — tap play to start again'); } }); function play() { if (!state.chunks.length) return; state.playing = true; updatePlayButton(); const a = el.audio; if (a.src && currentUrl && a.paused && !a.ended && a.currentTime > 0) { a.playbackRate = state.rate; a.play().catch(() => {}); } else { if (state.finished) { state.finished = false; state.idx = 0; } playChunk(state.idx); } } function pause() { state.playing = false; playToken++; el.audio.pause(); updatePlayButton(); setStatus(`Paused — ${state.idx + 1} of ${state.chunks.length}`); } function jump(i) { state.finished = false; i = Math.max(0, Math.min(state.chunks.length - 1, i)); el.audio.pause(); el.audio.removeAttribute('src'); currentUrl && URL.revokeObjectURL(currentUrl); currentUrl = null; state.idx = i; restartGen(); if (state.playing) playChunk(i); else { highlight(); setStatus(`${i + 1} of ${state.chunks.length}`); } } function updateMediaSession() { if (!('mediaSession' in navigator) || !state.chunks.length) return; const title = getTitle(); navigator.mediaSession.metadata = new MediaMetadata({ title, artist: 'Phonon Reader', artwork: [{ src: './icons/icon-512.png', sizes: '512x512', type: 'image/png' }], }); } if ('mediaSession' in navigator) { const ms = navigator.mediaSession; ms.setActionHandler('play', play); ms.setActionHandler('pause', pause); ms.setActionHandler('nexttrack', () => jump(state.idx + 1)); ms.setActionHandler('previoustrack', () => jump(state.idx - 1)); } // ---------- views ---------- function startReading(text, idx = 0, autoplay = true) { const clean = cleanText(text); if (!clean) return; el.input.value = clean; state.chunks = chunkText(clean); state.idx = Math.min(idx, state.chunks.length - 1); store({ text: clean }); el.viewEdit.classList.add('hidden'); el.viewRead.classList.remove('hidden'); el.player.classList.remove('hidden'); el.edit.classList.remove('hidden'); render(); resetConversion(); restartGen(); if (autoplay) play(); else setStatus(state.idx ? `Tap play to resume — ${state.idx + 1} of ${state.chunks.length}` : 'Tap play to listen'); } function showEditor() { pause(); state.gen.abort?.abort(); resetConversion(); state.chunks = []; el.viewRead.classList.add('hidden'); el.player.classList.add('hidden'); el.edit.classList.add('hidden'); el.viewEdit.classList.remove('hidden'); scrollTo(0, 0); } el.listen.addEventListener('click', () => startReading(el.input.value, 0, true)); el.convertStart.addEventListener('click', () => { startReading(el.input.value, 0, false); startConversion(); }); el.title.value = saved.title || ''; el.title.addEventListener('input', () => store({ title: el.title.value.trim() })); el.edit.addEventListener('click', showEditor); el.clear.addEventListener('click', () => { el.input.value = ''; el.title.value = ''; el.shareNote.classList.add('hidden'); store({ text: '', idx: 0, title: '' }); el.input.focus(); }); el.paste.addEventListener('click', async () => { try { if (navigator.clipboard.read) { const items = await navigator.clipboard.read(); for (const it of items) { if (it.types.includes('text/html')) { el.input.value = cleanText(htmlToText(await (await it.getType('text/html')).text())); return; } } } el.input.value = cleanText(await navigator.clipboard.readText()); } catch { el.input.focus(); setStatus(''); } }); el.input.addEventListener('paste', (e) => { const html = e.clipboardData?.getData('text/html'); if (!html) return; e.preventDefault(); const text = cleanText(htmlToText(html)); el.input.setRangeText(text, el.input.selectionStart, el.input.selectionEnd, 'end'); }); el.reader.addEventListener('click', (e) => { const s = e.target.closest('.c'); if (!s) return; state.playing = true; updatePlayButton(); jump(+s.dataset.i); }); el.play.addEventListener('click', () => (state.playing ? pause() : play())); el.prev.addEventListener('click', () => jump(state.idx - 1)); el.next.addEventListener('click', () => jump(state.idx + 1)); // ---------- settings ---------- el.settingsBtn.addEventListener('click', () => el.settings.showModal()); el.rate.value = state.rate; el.rateLabel.textContent = `${(+state.rate).toFixed(1)}×`; el.rate.addEventListener('input', () => { state.rate = +el.rate.value; el.rateLabel.textContent = `${state.rate.toFixed(1)}×`; el.audio.playbackRate = state.rate; store({ rate: state.rate }); }); el.device.value = saved.device || 'cpu'; el.device.addEventListener('change', () => { store({ device: el.device.value }); el.engineInfo.textContent = 'Reload the app to switch engine.'; }); el.voice.addEventListener('change', async () => { state.voice = el.voice.value; store({ voice: state.voice }); // Throw away audio made with the old voice and regenerate from here. state.chunks.forEach((c, i) => { if (i !== state.idx || !state.playing) c.pcm = null; }); resetConversion(); checkVoiceCached(); if (state.tts) ensureVoice(state.voice).catch((e) => console.warn(e)); restartGen(); }); function fmtDur(samples) { const s = Math.round(samples / SR); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; } function fmtSize(bytes) { return bytes > 1e6 ? `${(bytes / 1e6).toFixed(1)} MB` : `${Math.max(1, Math.round(bytes / 1e3))} KB`; } // ---------- conversion to an audio file ---------- const conv = { job: 0, active: false, finishing: false, fed: 0, blob: null, format: 'mp3', started: 0, genSamples: 0, worker: null, wake: null }; function getTitle() { return (el.title.value.trim() || saved.title || state.chunks[0]?.text || 'Speech').slice(0, 80); } function fileName() { const base = getTitle().replace(/[\\/:*?"<>|#%]+/g, '').replace(/\s+/g, ' ').trim().slice(0, 60) || 'speech'; return `${base}.${conv.format}`; } function canShareFiles() { try { return !!navigator.canShare?.({ files: [new File([''], 'a.mp3', { type: 'audio/mpeg' })] }); } catch { return false; } } function mp3Worker() { if (!conv.worker) { conv.worker = new Worker(new URL('./mp3-worker.js', import.meta.url), { type: 'module' }); conv.worker.onmessage = ({ data }) => { if (data.job !== conv.job || data.type !== 'done') return; finishConversion(data.blob); }; conv.worker.onerror = (e) => { console.error('mp3 worker', e); el.exNote.textContent = 'MP3 encoding failed — switch the file format to WAV in Settings.'; }; } return conv.worker; } function resetConversion() { conv.job++; conv.active = false; conv.finishing = false; conv.fed = 0; conv.blob = null; conv.genSamples = 0; state.gen.prepareAll = false; conv.wake?.release?.().catch?.(() => {}); conv.wake = null; updateExport(); } async function startConversion() { if (!state.chunks.length) return; resetConversion(); conv.format = el.format.value; conv.active = true; conv.started = performance.now(); if (conv.format === 'mp3') mp3Worker().postMessage({ type: 'start', job: conv.job, sampleRate: SR, kbps: 64 }); state.gen.prepareAll = true; kickGen(); feedEncoder(); try { conv.wake = await navigator.wakeLock?.request('screen'); } catch { /* optional */ } } function feedEncoder() { if (!conv.active || conv.finishing) return updateExport(); while (conv.fed < state.chunks.length && state.chunks[conv.fed].pcm) { if (conv.format === 'mp3') { mp3Worker().postMessage({ type: 'chunk', job: conv.job, index: conv.fed, pcm: state.chunks[conv.fed].pcm }); } conv.fed++; } if (conv.fed === state.chunks.length) { conv.finishing = true; state.gen.prepareAll = false; if (conv.format === 'mp3') mp3Worker().postMessage({ type: 'finish', job: conv.job }); else finishConversion(wavBlob(state.chunks.map((c) => c.pcm))); } updateExport(); } function finishConversion(blob) { conv.blob = blob; conv.active = false; conv.finishing = false; conv.wake?.release?.().catch?.(() => {}); conv.wake = null; updateExport(); } function onChunkReady(chunk) { if (conv.active) conv.genSamples += chunk.pcm.length; feedEncoder(); } function updateExport() { const n = state.chunks.length; if (!n) return; const show = (e, v) => e.classList.toggle('hidden', !v); const done = state.chunks.filter((c) => c.pcm); const samples = done.reduce((a, c) => a + c.pcm.length, 0); const chars = state.chunks.reduce((a, c) => a + c.text.length, 0); el.exTitle.textContent = getTitle(); el.export.classList.toggle('done', !!conv.blob); if (conv.blob) { const share = canShareFiles(); el.exMeta.textContent = `✓ Ready · ${fmtDur(samples)} · ${fmtSize(conv.blob.size)} ${conv.format.toUpperCase()}`; show(el.exBar, false); show(el.convert, false); show(el.share, share); show(el.download, true); el.download.className = share ? 'ghost' : 'primary'; el.download.textContent = `Download ${conv.format.toUpperCase()}`; el.exNote.textContent = share ? 'Share sends the file to any app: a podcast player, Files, Drive, a chat. Or press play below to listen here.' : 'Saves to your Downloads folder. Or press play below to listen here.'; } else if (conv.active) { const doneChars = done.reduce((a, c) => a + c.text.length, 0); let eta = ''; const secs = (performance.now() - conv.started) / 1000; if (conv.genSamples > SR * 5 && doneChars && secs > 2) { const rate = conv.genSamples / secs; // audio samples per wall second const remaining = ((chars - doneChars) * (samples / doneChars)) / rate; eta = remaining > 90 ? ` · about ${Math.round(remaining / 60)} min left` : remaining > 5 ? ` · about ${Math.round(remaining)} s left` : ''; } el.exMeta.textContent = conv.finishing ? 'Finishing the file…' : `Converting… ${done.length} of ${n} parts · ${fmtDur(samples)} of audio so far${eta}`; show(el.exBar, true); el.exProgress.style.width = `${Math.round((done.length / n) * 100)}%`; show(el.convert, false); show(el.share, false); show(el.download, false); el.exNote.textContent = state.tts ? 'Running on this device, so it works offline. Keep the app open until it finishes.' : 'Waiting for the speech model…'; } else { el.exMeta.textContent = `${n} parts · about ${Math.max(1, Math.round(chars / 15 / 60))} min of audio`; show(el.exBar, false); show(el.convert, true); show(el.share, false); show(el.download, false); el.exNote.textContent = 'Makes an audio file you can share to another app or save. Or press play below to listen right away.'; } } el.convert.addEventListener('click', startConversion); el.share.addEventListener('click', async () => { if (!conv.blob) return; const file = new File([conv.blob], fileName(), { type: conv.blob.type }); try { await navigator.share({ files: [file], title: getTitle() }); } catch (e) { if (e.name !== 'AbortError') { console.warn(e); el.exNote.textContent = 'Sharing failed — use Download instead.'; } } }); el.download.addEventListener('click', () => { if (!conv.blob) return; const a = document.createElement('a'); a.href = URL.createObjectURL(conv.blob); a.download = fileName(); document.body.append(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(a.href), 30000); }); el.format.value = saved.format || 'mp3'; el.format.addEventListener('change', () => { store({ format: el.format.value }); if (!conv.active && conv.blob && conv.format !== el.format.value) resetConversion(); }); el.clearCacheBtn.addEventListener('click', async () => { await clearCache(); try { await caches.delete(VOICE_CACHE); } catch { /* ignore */ } el.engineInfo.textContent = 'Model deleted. It will download again next time the app opens.'; }); // ---------- startup ---------- function readShare() { const q = new URLSearchParams(location.search); if (!q.has('text') && !q.has('url') && !q.has('title')) return null; history.replaceState(null, '', location.pathname); const title = q.get('title') || ''; let text = (q.get('text') || '').trim(); const url = q.get('url') || ''; const onlyLink = /^https?:\/\/\S+$/.test(text) || (!text && url); return { title, text: onlyLink ? '' : text, link: onlyLink ? (url || text) : '' }; } const share = readShare(); voicesReady = loadVoiceList(); loadModel(); if (share) { store({ title: share.title }); el.title.value = share.title; if (share.text) { startReading(share.text, 0, false); setStatus('Tap play to listen'); } else if (share.link) { el.shareNote.textContent = 'Only a link was shared, and this app does not fetch web pages (that would send them through a server). Open the page, select the text, and share or paste the text instead.'; el.shareNote.classList.remove('hidden'); } } else if (saved.text) { startReading(saved.text, saved.idx || 0, false); } renderReadiness(); if ('serviceWorker' in navigator) { navigator.serviceWorker.register('./sw.js').catch((e) => console.warn('sw', e)); navigator.serviceWorker.ready.then(() => { ready.app = true; renderReadiness(); }); } else { ready.app = true; }