davanstrien HF Staff commited on
Commit
cc119f9
·
verified ·
1 Parent(s): 318176e

Convert to MP3/WAV with share and download

Browse files
Files changed (1) hide show
  1. src/main.js +151 -50
src/main.js CHANGED
@@ -16,11 +16,14 @@ const el = {
16
  viewEdit: $('view-edit'), viewRead: $('view-read'), reader: $('reader'),
17
  player: $('player'), status: $('status'), play: $('btn-play'), prev: $('btn-prev'), next: $('btn-next'),
18
  voice: $('voice'), rate: $('rate'), rateLabel: $('rate-label'), device: $('device'),
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 ----------
@@ -324,12 +327,13 @@ async function genLoop() {
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);
331
  chunk.pcm = new Int16Array(SR / 4); // short silence so playback can move on
332
  notifyChunkReady();
 
333
  }
334
  }
335
  } finally {
@@ -441,7 +445,7 @@ function jump(i) {
441
 
442
  function updateMediaSession() {
443
  if (!('mediaSession' in navigator) || !state.chunks.length) return;
444
- const title = saved.title || state.chunks[0].text.slice(0, 60);
445
  navigator.mediaSession.metadata = new MediaMetadata({
446
  title, artist: 'Phonon Reader',
447
  artwork: [{ src: './icons/icon-512.png', sizes: '512x512', type: 'image/png' }],
@@ -468,15 +472,16 @@ function startReading(text, idx = 0, autoplay = true) {
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}`);
475
  }
476
 
477
  function showEditor() {
478
  pause();
479
  state.gen.abort?.abort();
 
480
  state.chunks = [];
481
  el.viewRead.classList.add('hidden');
482
  el.player.classList.add('hidden');
@@ -485,12 +490,15 @@ function showEditor() {
485
  scrollTo(0, 0);
486
  }
487
 
488
- el.listen.addEventListener('click', () => {
489
- if (el.input.value.trim() !== (saved.text || '')) store({ title: '' });
490
- startReading(el.input.value, 0, true);
 
491
  });
 
 
492
  el.edit.addEventListener('click', showEditor);
493
- el.clear.addEventListener('click', () => { el.input.value = ''; el.shareNote.classList.add('hidden'); store({ text: '', idx: 0, title: '' }); el.input.focus(); });
494
  el.paste.addEventListener('click', async () => {
495
  try {
496
  if (navigator.clipboard.read) {
@@ -539,7 +547,7 @@ el.voice.addEventListener('change', async () => {
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();
@@ -548,55 +556,147 @@ 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);
594
- a.download = `${(saved.title || state.chunks[0].text).slice(0, 40).replace(/[^\w -]+/g, '').trim() || 'speech'}.wav`;
595
- a.click();
596
- setTimeout(() => URL.revokeObjectURL(a.href), 10000);
597
- el.exportBtn.textContent = 'Download as WAV';
598
- el.exportBtn.disabled = false;
599
  });
 
 
 
 
 
 
600
  el.clearCacheBtn.addEventListener('click', async () => {
601
  await clearCache();
602
  try { await caches.delete(VOICE_CACHE); } catch { /* ignore */ }
@@ -621,9 +721,10 @@ loadModel();
621
 
622
  if (share) {
623
  store({ title: share.title });
 
624
  if (share.text) {
625
  startReading(share.text, 0, false);
626
- setStatus('Shared text ready — tap play');
627
  } else if (share.link) {
628
  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.';
629
  el.shareNote.classList.remove('hidden');
 
16
  viewEdit: $('view-edit'), viewRead: $('view-read'), reader: $('reader'),
17
  player: $('player'), status: $('status'), play: $('btn-play'), prev: $('btn-prev'), next: $('btn-next'),
18
  voice: $('voice'), rate: $('rate'), rateLabel: $('rate-label'), device: $('device'),
19
+ format: $('format'), 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'),
24
+ title: $('title'), convertStart: $('btn-convert-start'), convert: $('btn-convert'),
25
+ share: $('btn-share'), download: $('btn-download'), export: $('export'),
26
+ exTitle: $('ex-title'), exMeta: $('ex-meta'), exNote: $('ex-note'), exBar: $('ex-bar'), exProgress: $('ex-progress'),
27
  };
28
 
29
  // ---------- persisted settings ----------
 
327
  for (const p of parts) { all.set(p, o); o += p.length; }
328
  chunk.pcm = toInt16(all);
329
  notifyChunkReady();
330
+ onChunkReady(chunk);
331
  } catch (e) {
332
  if (ctrl.signal.aborted) continue;
333
  console.error('synthesis failed', e);
334
  chunk.pcm = new Int16Array(SR / 4); // short silence so playback can move on
335
  notifyChunkReady();
336
+ onChunkReady(chunk);
337
  }
338
  }
339
  } finally {
 
445
 
446
  function updateMediaSession() {
447
  if (!('mediaSession' in navigator) || !state.chunks.length) return;
448
+ const title = getTitle();
449
  navigator.mediaSession.metadata = new MediaMetadata({
450
  title, artist: 'Phonon Reader',
451
  artwork: [{ src: './icons/icon-512.png', sizes: '512x512', type: 'image/png' }],
 
472
  el.player.classList.remove('hidden');
473
  el.edit.classList.remove('hidden');
474
  render();
475
+ resetConversion();
476
  restartGen();
477
  if (autoplay) play();
478
+ else setStatus(state.idx ? `Tap play to resume — ${state.idx + 1} of ${state.chunks.length}` : 'Tap play to listen');
479
  }
480
 
481
  function showEditor() {
482
  pause();
483
  state.gen.abort?.abort();
484
+ resetConversion();
485
  state.chunks = [];
486
  el.viewRead.classList.add('hidden');
487
  el.player.classList.add('hidden');
 
490
  scrollTo(0, 0);
491
  }
492
 
493
+ el.listen.addEventListener('click', () => startReading(el.input.value, 0, true));
494
+ el.convertStart.addEventListener('click', () => {
495
+ startReading(el.input.value, 0, false);
496
+ startConversion();
497
  });
498
+ el.title.value = saved.title || '';
499
+ el.title.addEventListener('input', () => store({ title: el.title.value.trim() }));
500
  el.edit.addEventListener('click', showEditor);
501
+ el.clear.addEventListener('click', () => { el.input.value = ''; el.title.value = ''; el.shareNote.classList.add('hidden'); store({ text: '', idx: 0, title: '' }); el.input.focus(); });
502
  el.paste.addEventListener('click', async () => {
503
  try {
504
  if (navigator.clipboard.read) {
 
547
  store({ voice: state.voice });
548
  // Throw away audio made with the old voice and regenerate from here.
549
  state.chunks.forEach((c, i) => { if (i !== state.idx || !state.playing) c.pcm = null; });
550
+ resetConversion();
551
  checkVoiceCached();
552
  if (state.tts) ensureVoice(state.voice).catch((e) => console.warn(e));
553
  restartGen();
 
556
  const s = Math.round(samples / SR);
557
  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
558
  }
559
+ function fmtSize(bytes) {
560
+ return bytes > 1e6 ? `${(bytes / 1e6).toFixed(1)} MB` : `${Math.max(1, Math.round(bytes / 1e3))} KB`;
561
+ }
562
 
563
+ // ---------- conversion to an audio file ----------
564
+ const conv = { job: 0, active: false, finishing: false, fed: 0, blob: null, format: 'mp3', started: 0, genSamples: 0, worker: null, wake: null };
565
+
566
+ function getTitle() {
567
+ return (el.title.value.trim() || saved.title || state.chunks[0]?.text || 'Speech').slice(0, 80);
568
+ }
569
+ function fileName() {
570
+ const base = getTitle().replace(/[\\/:*?"<>|#%]+/g, '').replace(/\s+/g, ' ').trim().slice(0, 60) || 'speech';
571
+ return `${base}.${conv.format}`;
572
+ }
573
+ function canShareFiles() {
574
+ try { return !!navigator.canShare?.({ files: [new File([''], 'a.mp3', { type: 'audio/mpeg' })] }); } catch { return false; }
575
+ }
576
+
577
+ function mp3Worker() {
578
+ if (!conv.worker) {
579
+ conv.worker = new Worker(new URL('./mp3-worker.js', import.meta.url), { type: 'module' });
580
+ conv.worker.onmessage = ({ data }) => {
581
+ if (data.job !== conv.job || data.type !== 'done') return;
582
+ finishConversion(data.blob);
583
+ };
584
+ conv.worker.onerror = (e) => { console.error('mp3 worker', e); el.exNote.textContent = 'MP3 encoding failed — switch the file format to WAV in Settings.'; };
585
  }
586
+ return conv.worker;
587
  }
588
 
589
+ function resetConversion() {
590
+ conv.job++;
591
+ conv.active = false; conv.finishing = false; conv.fed = 0; conv.blob = null; conv.genSamples = 0;
592
+ state.gen.prepareAll = false;
593
+ conv.wake?.release?.().catch?.(() => {}); conv.wake = null;
594
+ updateExport();
595
+ }
596
+
597
+ async function startConversion() {
598
+ if (!state.chunks.length) return;
599
+ resetConversion();
600
+ conv.format = el.format.value;
601
+ conv.active = true;
602
+ conv.started = performance.now();
603
+ if (conv.format === 'mp3') mp3Worker().postMessage({ type: 'start', job: conv.job, sampleRate: SR, kbps: 64 });
604
  state.gen.prepareAll = true;
605
  kickGen();
606
+ feedEncoder();
607
+ try { conv.wake = await navigator.wakeLock?.request('screen'); } catch { /* optional */ }
608
+ }
609
+
610
+ function feedEncoder() {
611
+ if (!conv.active || conv.finishing) return updateExport();
612
+ while (conv.fed < state.chunks.length && state.chunks[conv.fed].pcm) {
613
+ if (conv.format === 'mp3') {
614
+ mp3Worker().postMessage({ type: 'chunk', job: conv.job, index: conv.fed, pcm: state.chunks[conv.fed].pcm });
615
+ }
616
+ conv.fed++;
617
  }
618
+ if (conv.fed === state.chunks.length) {
619
+ conv.finishing = true;
620
+ state.gen.prepareAll = false;
621
+ if (conv.format === 'mp3') mp3Worker().postMessage({ type: 'finish', job: conv.job });
622
+ else finishConversion(wavBlob(state.chunks.map((c) => c.pcm)));
623
+ }
624
+ updateExport();
625
  }
 
626
 
627
+ function finishConversion(blob) {
628
+ conv.blob = blob;
629
+ conv.active = false; conv.finishing = false;
630
+ conv.wake?.release?.().catch?.(() => {}); conv.wake = null;
631
+ updateExport();
632
+ }
633
+
634
+ function onChunkReady(chunk) {
635
+ if (conv.active) conv.genSamples += chunk.pcm.length;
636
+ feedEncoder();
637
+ }
638
+
639
+ function updateExport() {
640
+ const n = state.chunks.length;
641
+ if (!n) return;
642
+ const show = (e, v) => e.classList.toggle('hidden', !v);
643
+ const done = state.chunks.filter((c) => c.pcm);
644
+ const samples = done.reduce((a, c) => a + c.pcm.length, 0);
645
+ const chars = state.chunks.reduce((a, c) => a + c.text.length, 0);
646
+ el.exTitle.textContent = getTitle();
647
+ el.export.classList.toggle('done', !!conv.blob);
648
+
649
+ if (conv.blob) {
650
+ const share = canShareFiles();
651
+ el.exMeta.textContent = `✓ Ready · ${fmtDur(samples)} · ${fmtSize(conv.blob.size)} ${conv.format.toUpperCase()}`;
652
+ show(el.exBar, false); show(el.convert, false); show(el.share, share); show(el.download, true);
653
+ el.download.className = share ? 'ghost' : 'primary';
654
+ el.download.textContent = `Download ${conv.format.toUpperCase()}`;
655
+ el.exNote.textContent = share
656
+ ? 'Share sends the file to any app: a podcast player, Files, Drive, a chat. Or press play below to listen here.'
657
+ : 'Saves to your Downloads folder. Or press play below to listen here.';
658
+ } else if (conv.active) {
659
+ const doneChars = done.reduce((a, c) => a + c.text.length, 0);
660
+ let eta = '';
661
+ const secs = (performance.now() - conv.started) / 1000;
662
+ if (conv.genSamples > SR * 5 && doneChars && secs > 2) {
663
+ const rate = conv.genSamples / secs; // audio samples per wall second
664
+ const remaining = ((chars - doneChars) * (samples / doneChars)) / rate;
665
+ eta = remaining > 90 ? ` · about ${Math.round(remaining / 60)} min left` : remaining > 5 ? ` · about ${Math.round(remaining)} s left` : '';
666
+ }
667
+ el.exMeta.textContent = conv.finishing ? 'Finishing the file…'
668
+ : `Converting… ${done.length} of ${n} parts · ${fmtDur(samples)} of audio so far${eta}`;
669
+ show(el.exBar, true); el.exProgress.style.width = `${Math.round((done.length / n) * 100)}%`;
670
+ show(el.convert, false); show(el.share, false); show(el.download, false);
671
+ 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…';
672
+ } else {
673
+ el.exMeta.textContent = `${n} parts · about ${Math.max(1, Math.round(chars / 15 / 60))} min of audio`;
674
+ show(el.exBar, false); show(el.convert, true); show(el.share, false); show(el.download, false);
675
+ el.exNote.textContent = 'Makes an audio file you can share to another app or save. Or press play below to listen right away.';
676
+ }
677
+ }
678
+
679
+ el.convert.addEventListener('click', startConversion);
680
+ el.share.addEventListener('click', async () => {
681
+ if (!conv.blob) return;
682
+ const file = new File([conv.blob], fileName(), { type: conv.blob.type });
683
+ try { await navigator.share({ files: [file], title: getTitle() }); }
684
+ catch (e) { if (e.name !== 'AbortError') { console.warn(e); el.exNote.textContent = 'Sharing failed — use Download instead.'; } }
685
+ });
686
+ el.download.addEventListener('click', () => {
687
+ if (!conv.blob) return;
688
  const a = document.createElement('a');
689
+ a.href = URL.createObjectURL(conv.blob);
690
+ a.download = fileName();
691
+ document.body.append(a); a.click(); a.remove();
692
+ setTimeout(() => URL.revokeObjectURL(a.href), 30000);
 
 
693
  });
694
+ el.format.value = saved.format || 'mp3';
695
+ el.format.addEventListener('change', () => {
696
+ store({ format: el.format.value });
697
+ if (!conv.active && conv.blob && conv.format !== el.format.value) resetConversion();
698
+ });
699
+
700
  el.clearCacheBtn.addEventListener('click', async () => {
701
  await clearCache();
702
  try { await caches.delete(VOICE_CACHE); } catch { /* ignore */ }
 
721
 
722
  if (share) {
723
  store({ title: share.title });
724
+ el.title.value = share.title;
725
  if (share.text) {
726
  startReading(share.text, 0, false);
727
+ setStatus('Tap play to listen');
728
  } else if (share.link) {
729
  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.';
730
  el.shareNote.classList.remove('hidden');