Spaces:
Running
Running
Download src/main.js from davanstrien/phonon-reader: direct link, hf CLI and curl.
- Browser
- Download file 29.3 kB
-
https://huggingface.co/spaces/davanstrien/phonon-reader/resolve/main/src/main.js
- Command line
-
hf download hf://spaces/davanstrien/phonon-reader/src/main.js
-
curl -L -o main.js https://huggingface.co/spaces/davanstrien/phonon-reader/resolve/main/src/main.js
29.3 kB
| 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; | |
| } | |