davanstrien HF Staff commited on
Commit
271c933
·
verified ·
1 Parent(s): def56bb

Offline readiness indicator and Prepare all audio

Browse files
Files changed (1) hide show
  1. 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, exportAll: false },
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.exportAll ? state.chunks.length : Math.min(state.chunks.length, state.idx + LOOKAHEAD);
226
  for (let i = state.idx; i < end; i++) if (!state.chunks[i].pcm) return i;
227
- if (state.gen.exportAll) for (let i = 0; i < state.idx; i++) if (!state.chunks[i].pcm) return i;
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 - 170) now.scrollIntoView({ block: 'center', behavior: 'smooth' });
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
- el.exportBtn.addEventListener('click', async () => {
487
- if (!state.chunks.length) { el.engineInfo.textContent = 'Press Listen first, then export.'; return; }
488
- el.exportBtn.disabled = true;
489
- state.gen.exportAll = true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
490
  kickGen();
491
  for (;;) {
492
- const done = state.chunks.filter((c) => c.pcm).length;
493
- el.exportBtn.textContent = `Preparing WAV… ${done}/${state.chunks.length}`;
494
- if (done === state.chunks.length) break;
495
  await waitForChunk();
496
  }
497
- state.gen.exportAll = false;
 
 
 
 
 
 
 
 
 
 
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
  }