Spaces:
Running
Running
Offline readiness indicator and Prepare all audio
Browse files- src/main.js +109 -12
src/main.js
CHANGED
|
@@ -19,6 +19,8 @@ const el = {
|
|
| 19 |
exportBtn: $('btn-export'), engineInfo: $('engine-info'), clearCacheBtn: $('btn-clear-cache'),
|
| 20 |
modelBar: $('model-bar'), modelText: $('model-text'), modelProgress: $('model-progress'),
|
| 21 |
shareNote: $('share-note'), audio: $('audio'),
|
|
|
|
|
|
|
| 22 |
};
|
| 23 |
|
| 24 |
// ---------- persisted settings ----------
|
|
@@ -36,7 +38,7 @@ const state = {
|
|
| 36 |
playing: false,
|
| 37 |
voice: saved.voice || '',
|
| 38 |
rate: saved.rate || 1,
|
| 39 |
-
gen: { abort: null, running: false, wake: null,
|
| 40 |
waiters: [], // resolved whenever a chunk finishes generating
|
| 41 |
voiceMeta: new Map(),
|
| 42 |
};
|
|
@@ -132,6 +134,54 @@ function wavBlob(int16Parts) {
|
|
| 132 |
return new Blob([buf], { type: 'audio/wav' });
|
| 133 |
}
|
| 134 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
// ---------- model ----------
|
| 136 |
function setModelStatus(text, frac) {
|
| 137 |
el.modelText.textContent = text;
|
|
@@ -145,6 +195,7 @@ async function loadModel() {
|
|
| 145 |
lang: 'en',
|
| 146 |
device: saved.device || 'cpu',
|
| 147 |
onProgress: ({ file, loaded, total, cached }) => {
|
|
|
|
| 148 |
files[file] = { loaded, total: total || loaded };
|
| 149 |
const all = Object.values(files);
|
| 150 |
const L = all.reduce((a, f) => a + f.loaded, 0);
|
|
@@ -157,9 +208,13 @@ async function loadModel() {
|
|
| 157 |
el.engineInfo.textContent = `Engine: ${t.device.toUpperCase()}${t.device === 'cpu' ? `, ${t.threads} thread${t.threads > 1 ? 's' : ''}` : ''}. ${t.device === 'cpu' ? t.threadsReason : t.deviceReason}`;
|
| 158 |
if (state.voice) await ensureVoice(state.voice).catch(() => { state.voice = ''; el.voice.value = ''; });
|
| 159 |
el.modelBar.classList.add('ready');
|
|
|
|
|
|
|
|
|
|
| 160 |
kickGen();
|
| 161 |
} catch (e) {
|
| 162 |
console.error(e);
|
|
|
|
| 163 |
setModelStatus(`Could not load the speech model: ${e.message || e}`, 0);
|
| 164 |
}
|
| 165 |
}
|
|
@@ -202,6 +257,7 @@ async function loadVoiceList() {
|
|
| 202 |
} catch (e) {
|
| 203 |
console.warn('voice list unavailable', e);
|
| 204 |
}
|
|
|
|
| 205 |
}
|
| 206 |
|
| 207 |
let voicesReady = Promise.resolve();
|
|
@@ -212,6 +268,7 @@ async function ensureVoice(name) {
|
|
| 212 |
if (!meta) throw new Error('unknown voice');
|
| 213 |
const bytes = await cachedFetch(VOICE_BASE + meta.filename);
|
| 214 |
await state.tts.addVoice(name, bytes);
|
|
|
|
| 215 |
}
|
| 216 |
|
| 217 |
// ---------- generation loop ----------
|
|
@@ -222,9 +279,9 @@ function notifyChunkReady() {
|
|
| 222 |
function waitForChunk() { return new Promise((r) => state.waiters.push(r)); }
|
| 223 |
|
| 224 |
function nextToGenerate() {
|
| 225 |
-
const end = state.gen.
|
| 226 |
for (let i = state.idx; i < end; i++) if (!state.chunks[i].pcm) return i;
|
| 227 |
-
if (state.gen.
|
| 228 |
return -1;
|
| 229 |
}
|
| 230 |
|
|
@@ -267,6 +324,7 @@ async function genLoop() {
|
|
| 267 |
for (const p of parts) { all.set(p, o); o += p.length; }
|
| 268 |
chunk.pcm = toInt16(all);
|
| 269 |
notifyChunkReady();
|
|
|
|
| 270 |
} catch (e) {
|
| 271 |
if (ctrl.signal.aborted) continue;
|
| 272 |
console.error('synthesis failed', e);
|
|
@@ -306,7 +364,7 @@ function highlight(scroll = true) {
|
|
| 306 |
const now = el.reader.querySelector('.c.now');
|
| 307 |
if (scroll && now) {
|
| 308 |
const r = now.getBoundingClientRect();
|
| 309 |
-
if (r.top < 70 || r.bottom > innerHeight -
|
| 310 |
}
|
| 311 |
store({ idx: state.idx });
|
| 312 |
updateMediaSession();
|
|
@@ -410,6 +468,7 @@ function startReading(text, idx = 0, autoplay = true) {
|
|
| 410 |
el.player.classList.remove('hidden');
|
| 411 |
el.edit.classList.remove('hidden');
|
| 412 |
render();
|
|
|
|
| 413 |
restartGen();
|
| 414 |
if (autoplay) play();
|
| 415 |
else setStatus(`Tap play to resume — ${state.idx + 1} of ${state.chunks.length}`);
|
|
@@ -480,21 +539,55 @@ el.voice.addEventListener('change', async () => {
|
|
| 480 |
store({ voice: state.voice });
|
| 481 |
// Throw away audio made with the old voice and regenerate from here.
|
| 482 |
state.chunks.forEach((c, i) => { if (i !== state.idx || !state.playing) c.pcm = null; });
|
|
|
|
|
|
|
| 483 |
if (state.tts) ensureVoice(state.voice).catch((e) => console.warn(e));
|
| 484 |
restartGen();
|
| 485 |
});
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 490 |
kickGen();
|
| 491 |
for (;;) {
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
if (
|
| 495 |
await waitForChunk();
|
| 496 |
}
|
| 497 |
-
state.gen.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 498 |
const blob = wavBlob(state.chunks.map((c) => c.pcm));
|
| 499 |
const a = document.createElement('a');
|
| 500 |
a.href = URL.createObjectURL(blob);
|
|
@@ -539,6 +632,10 @@ if (share) {
|
|
| 539 |
startReading(saved.text, saved.idx || 0, false);
|
| 540 |
}
|
| 541 |
|
|
|
|
| 542 |
if ('serviceWorker' in navigator) {
|
| 543 |
navigator.serviceWorker.register('./sw.js').catch((e) => console.warn('sw', e));
|
|
|
|
|
|
|
|
|
|
| 544 |
}
|
|
|
|
| 19 |
exportBtn: $('btn-export'), engineInfo: $('engine-info'), clearCacheBtn: $('btn-clear-cache'),
|
| 20 |
modelBar: $('model-bar'), modelText: $('model-text'), modelProgress: $('model-progress'),
|
| 21 |
shareNote: $('share-note'), audio: $('audio'),
|
| 22 |
+
net: $('net'), readyTitle: $('ready-title'), readyNote: $('ready-note'),
|
| 23 |
+
rApp: $('r-app'), rModel: $('r-model'), rVoice: $('r-voice'), prepare: $('btn-prepare'),
|
| 24 |
};
|
| 25 |
|
| 26 |
// ---------- persisted settings ----------
|
|
|
|
| 38 |
playing: false,
|
| 39 |
voice: saved.voice || '',
|
| 40 |
rate: saved.rate || 1,
|
| 41 |
+
gen: { abort: null, running: false, wake: null, prepareAll: false },
|
| 42 |
waiters: [], // resolved whenever a chunk finishes generating
|
| 43 |
voiceMeta: new Map(),
|
| 44 |
};
|
|
|
|
| 134 |
return new Blob([buf], { type: 'audio/wav' });
|
| 135 |
}
|
| 136 |
|
| 137 |
+
// ---------- offline readiness ----------
|
| 138 |
+
const ready = { app: false, model: false, voice: false, modelBusy: true, downloadedNow: false, persisted: false };
|
| 139 |
+
|
| 140 |
+
function voiceLabel() {
|
| 141 |
+
return state.voice ? `Voice (${state.voice})` : 'Voice (default)';
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
async function checkVoiceCached() {
|
| 145 |
+
if (!state.voice) { ready.voice = ready.model; return renderReadiness(); }
|
| 146 |
+
const meta = state.voiceMeta.get(state.voice);
|
| 147 |
+
try {
|
| 148 |
+
const c = await caches.open(VOICE_CACHE);
|
| 149 |
+
ready.voice = !!(meta && await c.match(VOICE_BASE + meta.filename));
|
| 150 |
+
} catch { ready.voice = false; }
|
| 151 |
+
renderReadiness();
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
function renderReadiness() {
|
| 155 |
+
const set = (li, ok, busy) => { li.classList.toggle('yes', ok); li.classList.toggle('busy', !ok && !!busy); };
|
| 156 |
+
set(el.rApp, ready.app);
|
| 157 |
+
set(el.rModel, ready.model, ready.modelBusy);
|
| 158 |
+
el.rVoice.textContent = voiceLabel();
|
| 159 |
+
set(el.rVoice, ready.voice);
|
| 160 |
+
const all = ready.app && ready.model && ready.voice;
|
| 161 |
+
const online = navigator.onLine;
|
| 162 |
+
|
| 163 |
+
el.net.className = 'pill ' + (!online ? 'off' : all ? 'ok' : '');
|
| 164 |
+
el.net.textContent = !online ? '✈ Offline' : all ? '✓ Offline-ready' : '';
|
| 165 |
+
|
| 166 |
+
if (all) {
|
| 167 |
+
el.readyTitle.textContent = online ? 'Ready to work offline' : 'Offline — running entirely on this device';
|
| 168 |
+
el.readyNote.textContent = online
|
| 169 |
+
? '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.'
|
| 170 |
+
: 'No network needed: speech is being generated on this device.';
|
| 171 |
+
} else if (!online) {
|
| 172 |
+
el.readyTitle.textContent = 'Offline, but not everything is saved yet';
|
| 173 |
+
el.readyNote.textContent = 'Connect once so the missing parts can download. After that it works without a connection.';
|
| 174 |
+
} else {
|
| 175 |
+
el.readyTitle.textContent = ready.modelBusy ? 'Saving everything for offline use…' : 'Almost ready for offline use';
|
| 176 |
+
el.readyNote.textContent = !ready.voice && ready.model && state.voice
|
| 177 |
+
? `The ${state.voice} voice downloads the first time it speaks (a few hundred KB).`
|
| 178 |
+
: 'One-time download (about 65 MB). Your text is never uploaded.';
|
| 179 |
+
}
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
addEventListener('online', renderReadiness);
|
| 183 |
+
addEventListener('offline', renderReadiness);
|
| 184 |
+
|
| 185 |
// ---------- model ----------
|
| 186 |
function setModelStatus(text, frac) {
|
| 187 |
el.modelText.textContent = text;
|
|
|
|
| 195 |
lang: 'en',
|
| 196 |
device: saved.device || 'cpu',
|
| 197 |
onProgress: ({ file, loaded, total, cached }) => {
|
| 198 |
+
if (!cached) ready.downloadedNow = true;
|
| 199 |
files[file] = { loaded, total: total || loaded };
|
| 200 |
const all = Object.values(files);
|
| 201 |
const L = all.reduce((a, f) => a + f.loaded, 0);
|
|
|
|
| 208 |
el.engineInfo.textContent = `Engine: ${t.device.toUpperCase()}${t.device === 'cpu' ? `, ${t.threads} thread${t.threads > 1 ? 's' : ''}` : ''}. ${t.device === 'cpu' ? t.threadsReason : t.deviceReason}`;
|
| 209 |
if (state.voice) await ensureVoice(state.voice).catch(() => { state.voice = ''; el.voice.value = ''; });
|
| 210 |
el.modelBar.classList.add('ready');
|
| 211 |
+
ready.model = true; ready.modelBusy = false;
|
| 212 |
+
try { ready.persisted = await navigator.storage?.persist?.(); } catch { /* optional */ }
|
| 213 |
+
checkVoiceCached();
|
| 214 |
kickGen();
|
| 215 |
} catch (e) {
|
| 216 |
console.error(e);
|
| 217 |
+
ready.modelBusy = false; renderReadiness();
|
| 218 |
setModelStatus(`Could not load the speech model: ${e.message || e}`, 0);
|
| 219 |
}
|
| 220 |
}
|
|
|
|
| 257 |
} catch (e) {
|
| 258 |
console.warn('voice list unavailable', e);
|
| 259 |
}
|
| 260 |
+
if (ready.model) checkVoiceCached();
|
| 261 |
}
|
| 262 |
|
| 263 |
let voicesReady = Promise.resolve();
|
|
|
|
| 268 |
if (!meta) throw new Error('unknown voice');
|
| 269 |
const bytes = await cachedFetch(VOICE_BASE + meta.filename);
|
| 270 |
await state.tts.addVoice(name, bytes);
|
| 271 |
+
checkVoiceCached();
|
| 272 |
}
|
| 273 |
|
| 274 |
// ---------- generation loop ----------
|
|
|
|
| 279 |
function waitForChunk() { return new Promise((r) => state.waiters.push(r)); }
|
| 280 |
|
| 281 |
function nextToGenerate() {
|
| 282 |
+
const end = state.gen.prepareAll ? state.chunks.length : Math.min(state.chunks.length, state.idx + LOOKAHEAD);
|
| 283 |
for (let i = state.idx; i < end; i++) if (!state.chunks[i].pcm) return i;
|
| 284 |
+
if (state.gen.prepareAll) for (let i = 0; i < state.idx; i++) if (!state.chunks[i].pcm) return i;
|
| 285 |
return -1;
|
| 286 |
}
|
| 287 |
|
|
|
|
| 324 |
for (const p of parts) { all.set(p, o); o += p.length; }
|
| 325 |
chunk.pcm = toInt16(all);
|
| 326 |
notifyChunkReady();
|
| 327 |
+
updatePrepareButton();
|
| 328 |
} catch (e) {
|
| 329 |
if (ctrl.signal.aborted) continue;
|
| 330 |
console.error('synthesis failed', e);
|
|
|
|
| 364 |
const now = el.reader.querySelector('.c.now');
|
| 365 |
if (scroll && now) {
|
| 366 |
const r = now.getBoundingClientRect();
|
| 367 |
+
if (r.top < 70 || r.bottom > innerHeight - 220) now.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
| 368 |
}
|
| 369 |
store({ idx: state.idx });
|
| 370 |
updateMediaSession();
|
|
|
|
| 468 |
el.player.classList.remove('hidden');
|
| 469 |
el.edit.classList.remove('hidden');
|
| 470 |
render();
|
| 471 |
+
updatePrepareButton();
|
| 472 |
restartGen();
|
| 473 |
if (autoplay) play();
|
| 474 |
else setStatus(`Tap play to resume — ${state.idx + 1} of ${state.chunks.length}`);
|
|
|
|
| 539 |
store({ voice: state.voice });
|
| 540 |
// Throw away audio made with the old voice and regenerate from here.
|
| 541 |
state.chunks.forEach((c, i) => { if (i !== state.idx || !state.playing) c.pcm = null; });
|
| 542 |
+
updatePrepareButton();
|
| 543 |
+
checkVoiceCached();
|
| 544 |
if (state.tts) ensureVoice(state.voice).catch((e) => console.warn(e));
|
| 545 |
restartGen();
|
| 546 |
});
|
| 547 |
+
function fmtDur(samples) {
|
| 548 |
+
const s = Math.round(samples / SR);
|
| 549 |
+
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
function updatePrepareButton() {
|
| 553 |
+
const n = state.chunks.length;
|
| 554 |
+
const done = state.chunks.filter((c) => c.pcm).length;
|
| 555 |
+
const b = el.prepare;
|
| 556 |
+
b.classList.toggle('done', n > 0 && done === n);
|
| 557 |
+
if (n && done === n) {
|
| 558 |
+
const samples = state.chunks.reduce((a, c) => a + c.pcm.length, 0);
|
| 559 |
+
b.textContent = `✓ All audio ready (${fmtDur(samples)})`;
|
| 560 |
+
b.disabled = true;
|
| 561 |
+
} else if (state.gen.prepareAll) {
|
| 562 |
+
b.textContent = `Preparing… ${done} of ${n}`;
|
| 563 |
+
b.disabled = true;
|
| 564 |
+
} else {
|
| 565 |
+
b.textContent = 'Prepare all audio';
|
| 566 |
+
b.disabled = false;
|
| 567 |
+
}
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
/** Generate every chunk now (e.g. before going offline, or to avoid pauses on a slow phone). */
|
| 571 |
+
async function prepareAll(onTick) {
|
| 572 |
+
state.gen.prepareAll = true;
|
| 573 |
kickGen();
|
| 574 |
for (;;) {
|
| 575 |
+
updatePrepareButton();
|
| 576 |
+
onTick?.();
|
| 577 |
+
if (state.chunks.every((c) => c.pcm)) break;
|
| 578 |
await waitForChunk();
|
| 579 |
}
|
| 580 |
+
state.gen.prepareAll = false;
|
| 581 |
+
updatePrepareButton();
|
| 582 |
+
}
|
| 583 |
+
el.prepare.addEventListener('click', () => prepareAll());
|
| 584 |
+
|
| 585 |
+
el.exportBtn.addEventListener('click', async () => {
|
| 586 |
+
if (!state.chunks.length) { el.engineInfo.textContent = 'Press Listen first, then export.'; return; }
|
| 587 |
+
el.exportBtn.disabled = true;
|
| 588 |
+
await prepareAll(() => {
|
| 589 |
+
el.exportBtn.textContent = `Preparing WAV… ${state.chunks.filter((c) => c.pcm).length}/${state.chunks.length}`;
|
| 590 |
+
});
|
| 591 |
const blob = wavBlob(state.chunks.map((c) => c.pcm));
|
| 592 |
const a = document.createElement('a');
|
| 593 |
a.href = URL.createObjectURL(blob);
|
|
|
|
| 632 |
startReading(saved.text, saved.idx || 0, false);
|
| 633 |
}
|
| 634 |
|
| 635 |
+
renderReadiness();
|
| 636 |
if ('serviceWorker' in navigator) {
|
| 637 |
navigator.serviceWorker.register('./sw.js').catch((e) => console.warn('sw', e));
|
| 638 |
+
navigator.serviceWorker.ready.then(() => { ready.app = true; renderReadiness(); });
|
| 639 |
+
} else {
|
| 640 |
+
ready.app = true;
|
| 641 |
}
|