Spaces:
Running
Running
Convert to MP3/WAV with share and download
Browse files- 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 |
-
|
| 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 |
};
|
| 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 |
-
|
| 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 =
|
| 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 |
-
|
| 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 |
-
|
| 490 |
-
startReading(el.input.value, 0,
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 567 |
}
|
|
|
|
| 568 |
}
|
| 569 |
|
| 570 |
-
|
| 571 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 572 |
state.gen.prepareAll = true;
|
| 573 |
kickGen();
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 579 |
}
|
| 580 |
-
|
| 581 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 582 |
}
|
| 583 |
-
el.prepare.addEventListener('click', () => prepareAll());
|
| 584 |
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 592 |
const a = document.createElement('a');
|
| 593 |
-
a.href = URL.createObjectURL(blob);
|
| 594 |
-
a.download =
|
| 595 |
-
a.click();
|
| 596 |
-
setTimeout(() => URL.revokeObjectURL(a.href),
|
| 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('
|
| 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');
|