From 5c093960f6ad0f7cfc8e0e87289d8e0f9154c108 Mon Sep 17 00:00:00 2001 From: Kresna <13603341+slaveofcode@users.noreply.github.com> Date: Tue, 18 Aug 2026 06:31:31 +0700 Subject: [PATCH] feat(tts): add on-device neural voice with WAV/MP3 download + pauses Browser (OS) voices can't be recorded, so this adds an on-device neural TTS (MMS-TTS via @huggingface/transformers, loaded through the same /hf proxy as Whisper) that generates real audio samples. Users can play the result, download WAV or MP3 (lamejs), and insert pauses with [pause] markers or blank lines (silence spliced into the buffer). OS-voice playback is unchanged. Pure tts-audio.lib.ts (WAV encode, PCM, silence, pause-split) unit-tested. New dep: @breezystack/lamejs. --- package-lock.json | 7 ++ package.json | 1 + src/islands/media/TextToSpeech.tsx | 120 +++++++++++++++++++++++++- src/registry/tool-seo.ts | 6 +- src/tools/media/neural-tts.engine.ts | 88 +++++++++++++++++++ src/tools/media/tts-audio.lib.test.ts | 50 +++++++++++ src/tools/media/tts-audio.lib.ts | 69 +++++++++++++++ 7 files changed, 337 insertions(+), 4 deletions(-) create mode 100644 src/tools/media/neural-tts.engine.ts create mode 100644 src/tools/media/tts-audio.lib.test.ts create mode 100644 src/tools/media/tts-audio.lib.ts diff --git a/package-lock.json b/package-lock.json index 2dc0537..113b668 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@astrojs/react": "^3.6.3", "@astrojs/sitemap": "^3.2.1", "@astrojs/tailwind": "^5.1.5", + "@breezystack/lamejs": "^1.2.7", "@dagrejs/dagre": "^3.0.0", "@dbml/core": "^8.3.1", "@excalidraw/excalidraw": "^0.18.1", @@ -1908,6 +1909,12 @@ "integrity": "sha512-Tbsj02wXCbqGmzdnXNk0SOF19ChhRU70BsroIi4Pm6Ehp56in6vch94mfbdQ17DozxkL3BAVjbZ4Qc1a0HFRAg==", "license": "MIT" }, + "node_modules/@breezystack/lamejs": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@breezystack/lamejs/-/lamejs-1.2.7.tgz", + "integrity": "sha512-6wc7ck65ctA75Hq7FYHTtTvGnYs6msgdxiSUICQ+A01nVOWg6rqouZB8IdyteRlfpYYiFovkf67dIeOgWIUzTA==", + "license": "LGPL-3.0" + }, "node_modules/@chevrotain/cst-dts-gen": { "version": "11.0.3", "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz", diff --git a/package.json b/package.json index 6aa8fbc..d6131b3 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@astrojs/react": "^3.6.3", "@astrojs/sitemap": "^3.2.1", "@astrojs/tailwind": "^5.1.5", + "@breezystack/lamejs": "^1.2.7", "@dagrejs/dagre": "^3.0.0", "@dbml/core": "^8.3.1", "@excalidraw/excalidraw": "^0.18.1", diff --git a/src/islands/media/TextToSpeech.tsx b/src/islands/media/TextToSpeech.tsx index bf396ab..c032d66 100644 --- a/src/islands/media/TextToSpeech.tsx +++ b/src/islands/media/TextToSpeech.tsx @@ -2,27 +2,43 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { Button } from '@/components/ui/Button'; import { Alert } from '@/components/ui/Alert'; import { splitIntoChunks } from '@/tools/media/tts.lib'; +import { floatToWav } from '@/tools/media/tts-audio.lib'; +import { NEURAL_VOICES } from '@/tools/media/neural-tts.engine'; import type { Lang } from '@/i18n/config'; const TR: Record = { en: { intro: 'Turn text into natural speech with your browser’s built-in voices. Type or paste text, pick a voice, adjust speed and pitch, and press Speak. It runs entirely on your device — nothing is uploaded.', - placeholder: 'Type or paste text to read aloud…', + placeholder: 'Type or paste text to read aloud… (type [pause] for a short silence)', voice: 'Voice', rate: 'Speed', pitch: 'Pitch', speak: 'Speak', pause: 'Pause', resume: 'Resume', stop: 'Stop', unsupported: 'Your browser does not support speech synthesis. Try a recent Chrome, Edge or Safari.', noVoices: 'No voices found in this browser yet — try reloading the page.', + dlHeading: 'Download as audio (on-device AI voice)', + dlIntro: 'The browser voices above can’t be saved to a file, so this uses an on-device AI voice to generate downloadable audio. The voice model downloads once (~30–60 MB) and is cached for offline use; add [pause] in your text for a silence.', + nVoice: 'AI voice language', pauseLen: 'Pause length', pauseHint: 'Silence inserted at each [pause] and blank line.', + generate: 'Generate audio', loading: 'Downloading voice model…', synth: 'Generating audio…', + dlWav: 'Download WAV', dlMp3: 'Download MP3', encoding: 'Encoding MP3…', + nErr: 'Could not generate audio. Try again or a different language.', }, id: { intro: 'Ubah teks menjadi suara alami dengan voice bawaan browser Anda. Ketik atau tempel teks, pilih voice, atur kecepatan dan nada, lalu tekan Bacakan. Berjalan sepenuhnya di perangkat Anda — tidak ada yang diunggah.', - placeholder: 'Ketik atau tempel teks untuk dibacakan…', + placeholder: 'Ketik atau tempel teks untuk dibacakan… (ketik [pause] untuk jeda singkat)', voice: 'Voice', rate: 'Kecepatan', pitch: 'Nada', speak: 'Bacakan', pause: 'Jeda', resume: 'Lanjut', stop: 'Hentikan', unsupported: 'Browser Anda tidak mendukung sintesis suara. Coba Chrome, Edge, atau Safari terbaru.', noVoices: 'Belum ada voice ditemukan di browser ini — coba muat ulang halaman.', + dlHeading: 'Unduh sebagai audio (voice AI di perangkat)', + dlIntro: 'Voice browser di atas tidak bisa disimpan ke berkas, jadi ini memakai voice AI di perangkat untuk menghasilkan audio yang bisa diunduh. Model voice diunduh sekali (~30–60 MB) dan disimpan untuk pemakaian offline; tambahkan [pause] di teks untuk jeda.', + nVoice: 'Bahasa voice AI', pauseLen: 'Panjang jeda', pauseHint: 'Keheningan disisipkan di tiap [pause] dan baris kosong.', + generate: 'Buat audio', loading: 'Mengunduh model voice…', synth: 'Menghasilkan audio…', + dlWav: 'Unduh WAV', dlMp3: 'Unduh MP3', encoding: 'Meng-encode MP3…', + nErr: 'Tidak dapat membuat audio. Coba lagi atau pilih bahasa lain.', }, }; @@ -38,6 +54,100 @@ export default function TextToSpeech({ lang = 'en' }: { lang?: Lang }) { const [paused, setPaused] = useState(false); const doneRef = useRef(0); + // Neural (downloadable) TTS state. + const [nVoiceId, setNVoiceId] = useState(lang === 'id' ? 'ind' : 'eng'); + const [pauseSec, setPauseSec] = useState(0.4); + const [nBusy, setNBusy] = useState(false); + const [nStatus, setNStatus] = useState(''); + const [nProgress, setNProgress] = useState(0); + const [nError, setNError] = useState(''); + const [wavUrl, setWavUrl] = useState(''); + const audioRef = useRef<{ audio: Float32Array; sampleRate: number } | null>(null); + const wavBytesRef = useRef(null); + + useEffect(() => () => { if (wavUrl) URL.revokeObjectURL(wavUrl); }, [wavUrl]); + + const generate = async () => { + const src = text.trim(); + if (!src) return; + setNBusy(true); setNError(''); setNProgress(0); setNStatus(t.loading); + setWavUrl(prev => { if (prev) URL.revokeObjectURL(prev); return ''; }); + try { + const { synthesizeNeural } = await import('@/tools/media/neural-tts.engine'); + const voice = NEURAL_VOICES.find(v => v.id === nVoiceId) ?? NEURAL_VOICES[0]; + const res = await synthesizeNeural(src, voice, pauseSec, r => { + setNProgress(Math.round(r * 100)); + if (r >= 1) setNStatus(t.synth); + }); + audioRef.current = res; + const wav = floatToWav(res.audio, res.sampleRate); + wavBytesRef.current = wav; + setWavUrl(URL.createObjectURL(new Blob([wav], { type: 'audio/wav' }))); + } catch (e) { + setNError(e instanceof Error && e.message ? e.message : t.nErr); + } finally { + setNBusy(false); setNStatus(''); + } + }; + + const saveBlob = (bytes: Uint8Array, type: string, name: string) => { + const url = URL.createObjectURL(new Blob([bytes], { type })); + const a = document.createElement('a'); + a.href = url; a.download = name; a.click(); + URL.revokeObjectURL(url); + }; + + const downloadWav = () => { if (wavBytesRef.current) saveBlob(wavBytesRef.current, 'audio/wav', 'speech.wav'); }; + const downloadMp3 = async () => { + if (!audioRef.current) return; + setNBusy(true); setNStatus(t.encoding); + try { + const { encodeMp3 } = await import('@/tools/media/neural-tts.engine'); + saveBlob(await encodeMp3(audioRef.current.audio, audioRef.current.sampleRate), 'audio/mpeg', 'speech.mp3'); + } finally { + setNBusy(false); setNStatus(''); + } + }; + + const neuralSection = ( +
+
+

{t.dlHeading}

+

{t.dlIntro}

+
+
+ + +
+ + {nBusy && nProgress > 0 && nProgress < 100 && ( +
+
+
+ )} + {nError && {nError}} + {wavUrl && !nBusy && ( +
+
+ )} +
+ ); + useEffect(() => { if (typeof window === 'undefined' || !('speechSynthesis' in window)) { setSupported(false); @@ -91,10 +201,14 @@ export default function TextToSpeech({ lang = 'en' }: { lang?: Lang }) { const stop = () => { window.speechSynthesis.cancel(); setSpeaking(false); setPaused(false); }; if (!supported) { + // OS voices unavailable, but the neural download voice still works. return (

{t.intro}

{t.unsupported} +