phonon-reader / src /main.js
davanstrien's picture
davanstrien HF Staff
Convert to MP3/WAV with share and download
cc119f9 verified
Raw History Blame Contribute Delete
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;
}