Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
120 changes: 118 additions & 2 deletions src/islands/media/TextToSpeech.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Lang, {
intro: string; placeholder: string; voice: string; rate: string; pitch: string;
speak: string; pause: string; resume: string; stop: string; unsupported: string; noVoices: string;
dlHeading: string; dlIntro: string; nVoice: string; pauseLen: string; pauseHint: string;
generate: string; loading: string; synth: string; dlWav: string; dlMp3: string; encoding: string; nErr: string;
}> = {
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.',
},
};

Expand All @@ -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<Uint8Array | null>(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 = (
<div className="space-y-3 border-t-2 border-border pt-4">
<div>
<h2 className="text-sm font-black uppercase tracking-wide">{t.dlHeading}</h2>
<p className="mt-1 text-xs text-muted-foreground">{t.dlIntro}</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="space-y-1 text-sm">
<span className="block font-semibold">{t.nVoice}</span>
<select value={nVoiceId} onChange={e => setNVoiceId(e.target.value)} disabled={nBusy}
className="w-full border-2 border-border bg-background p-2 text-sm">
{NEURAL_VOICES.map(v => <option key={v.id} value={v.id}>{v.label}</option>)}
</select>
</label>
<label className="space-y-1 text-sm">
<span className="block font-semibold">{t.pauseLen}: {pauseSec.toFixed(1)}s</span>
<input type="range" min={0} max={1.5} step={0.1} value={pauseSec} onChange={e => setPauseSec(Number(e.target.value))} className="w-full accent-accent" />
<span className="text-xs text-muted-foreground">{t.pauseHint}</span>
</label>
</div>
<Button onClick={generate} disabled={nBusy || !text.trim()}>{nBusy ? (nStatus || t.synth) : t.generate}</Button>
{nBusy && nProgress > 0 && nProgress < 100 && (
<div className="h-2 w-full overflow-hidden border-2 border-border">
<div className="h-full bg-accent transition-all" style={{ width: `${nProgress}%` }} />
</div>
)}
{nError && <Alert variant="error">{nError}</Alert>}
{wavUrl && !nBusy && (
<div className="space-y-2">
<audio controls src={wavUrl} className="w-full" />
<div className="flex flex-wrap gap-2">
<Button variant="secondary" onClick={downloadWav}>{t.dlWav}</Button>
<Button variant="secondary" onClick={downloadMp3}>{t.dlMp3}</Button>
</div>
</div>
)}
</div>
);

useEffect(() => {
if (typeof window === 'undefined' || !('speechSynthesis' in window)) {
setSupported(false);
Expand Down Expand Up @@ -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 (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">{t.intro}</p>
<Alert variant="error">{t.unsupported}</Alert>
<textarea value={text} onChange={e => setText(e.target.value)} rows={6} placeholder={t.placeholder}
className="w-full resize-y border-2 border-border bg-muted p-3 text-sm" />
{neuralSection}
</div>
);
}
Expand Down Expand Up @@ -140,6 +254,8 @@ export default function TextToSpeech({ lang = 'en' }: { lang?: Lang }) {
{speaking && paused && <Button variant="secondary" onClick={resume}>{t.resume}</Button>}
{speaking && <Button variant="ghost" onClick={stop}>{t.stop}</Button>}
</div>

{neuralSection}
</div>
);
}
6 changes: 4 additions & 2 deletions src/registry/tool-seo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2198,7 +2198,8 @@ const en: Record<string, ToolSeoContent> = {
],
faqs: [
{ q: 'Is my text sent to a server?', a: 'No. Speech is generated by your browser’s built-in speech engine, so your text stays on your device and nothing is uploaded.' },
{ q: 'Can I download the audio as an MP3?', a: 'Not here — browser speech synthesis plays audio but does not expose a downloadable file. This tool is for listening; for a recordable file you would need a different, server-based service.' },
{ q: 'Can I download the audio as an MP3 or WAV?', a: 'Yes. The browser (OS) voices can only be played, but the tool also includes an on-device AI voice: pick a language under “Download as audio”, click Generate, then save the result as WAV or MP3. The voice model downloads once and is cached for offline use.' },
{ q: 'Can I add pauses in the speech?', a: 'Yes, in the downloadable AI voice. Type [pause] (or leave a blank line) in your text and set the pause length — a short silence is inserted there.' },
{ q: 'Why do I see different voices than someone else?', a: 'The available voices come from your browser and operating system, so the list varies by device. Chrome, Edge, macOS and iOS each ship their own set.' },
{ q: 'Does it support languages other than English?', a: 'Yes. Any language your system provides a voice for will appear in the list — including Indonesian on many devices. Pick a matching voice for the best result.' },
{ q: 'Does it work offline?', a: 'The built-in system voices generally work offline; some browsers stream certain higher-quality voices, which then need a connection.' },
Expand Down Expand Up @@ -4621,7 +4622,8 @@ const id: Record<string, ToolSeoContent> = {
],
faqs: [
{ q: 'Apakah teks saya dikirim ke server?', a: 'Tidak. Suara dihasilkan oleh mesin suara bawaan browser Anda, jadi teks tetap di perangkat dan tidak ada yang diunggah.' },
{ q: 'Bisakah mengunduh audionya sebagai MP3?', a: 'Tidak di sini — sintesis suara browser memutar audio tetapi tidak menyediakan berkas yang bisa diunduh. Tool ini untuk mendengarkan; untuk berkas yang bisa direkam Anda perlu layanan berbasis server yang berbeda.' },
{ q: 'Bisakah mengunduh audionya sebagai MP3 atau WAV?', a: 'Bisa. Voice browser (OS) hanya bisa diputar, tetapi tool ini juga menyertakan voice AI di perangkat: pilih bahasa di bagian “Unduh sebagai audio”, klik Buat, lalu simpan hasilnya sebagai WAV atau MP3. Model voice diunduh sekali dan disimpan untuk pemakaian offline.' },
{ q: 'Bisakah menambahkan jeda pada ucapan?', a: 'Bisa, pada voice AI yang bisa diunduh. Ketik [pause] (atau biarkan baris kosong) di teks Anda dan atur panjang jeda — keheningan singkat disisipkan di sana.' },
{ q: 'Mengapa voice saya berbeda dari orang lain?', a: 'Voice yang tersedia berasal dari browser dan sistem operasi Anda, jadi daftarnya berbeda tiap perangkat. Chrome, Edge, macOS, dan iOS masing-masing punya set sendiri.' },
{ q: 'Apakah mendukung bahasa selain English?', a: 'Ya. Bahasa apa pun yang sistem Anda sediakan voice-nya akan muncul di daftar — termasuk Indonesia di banyak perangkat. Pilih voice yang sesuai untuk hasil terbaik.' },
{ q: 'Apakah bekerja offline?', a: 'Voice sistem bawaan umumnya bekerja offline; sebagian browser mengalirkan voice berkualitas lebih tinggi tertentu, yang lalu membutuhkan koneksi.' },
Expand Down
88 changes: 88 additions & 0 deletions src/tools/media/neural-tts.engine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* On-device neural text-to-speech using @huggingface/transformers (MMS-TTS VITS
* models). Runs fully in the browser and returns raw audio samples, so the
* result can be played, saved as WAV, or encoded to MP3 — unlike the OS Web
* Speech voices, which cannot be captured. Model weights load once (cached)
* through the same-origin /hf proxy, matching the Whisper setup.
*/
import { concatWithSilence, floatToPcm16, splitForPause } from './tts-audio.lib';

export interface NeuralVoice { id: string; label: string; model: string; }

// MMS-TTS is multilingual with one small model per language and needs no speaker
// embedding. A curated set that has ONNX ports on the Hugging Face hub.
export const NEURAL_VOICES: NeuralVoice[] = [
{ id: 'eng', label: 'English', model: 'Xenova/mms-tts-eng' },
{ id: 'ind', label: 'Bahasa Indonesia', model: 'Xenova/mms-tts-ind' },
{ id: 'spa', label: 'Español', model: 'Xenova/mms-tts-spa' },
{ id: 'fra', label: 'Français', model: 'Xenova/mms-tts-fra' },
{ id: 'deu', label: 'Deutsch', model: 'Xenova/mms-tts-deu' },
{ id: 'por', label: 'Português', model: 'Xenova/mms-tts-por' },
{ id: 'rus', label: 'Русский', model: 'Xenova/mms-tts-rus' },
{ id: 'ara', label: 'العربية', model: 'Xenova/mms-tts-ara' },
{ id: 'hin', label: 'हिन्दी', model: 'Xenova/mms-tts-hin' },
];

/* eslint-disable @typescript-eslint/no-explicit-any */
let cached: { model: string; synth: any } | null = null;

export interface SynthResult { audio: Float32Array; sampleRate: number; }

/** Synthesize `text` (splitting on pause markers) into one audio buffer. */
export async function synthesizeNeural(
text: string,
voice: NeuralVoice,
pauseSeconds: number,
onProgress?: (ratio: number) => void,
): Promise<SynthResult> {
const { pipeline, env } = await import('@huggingface/transformers');
try {
const origin = self.location?.origin ?? '';
if (origin && !/localhost|127\.0\.0\.1|\[::1\]/.test(origin)) env.remoteHost = `${origin}/hf`;
} catch { /* leave default */ }

if (!cached || cached.model !== voice.model) {
const synth = await pipeline('text-to-speech', voice.model, {
dtype: 'fp32',
progress_callback: (p: { status?: string; progress?: number }) => {
if (onProgress && p?.status === 'progress' && typeof p.progress === 'number') {
onProgress(Math.min(1, Math.max(0, p.progress / 100)));
}
},
});
cached = { model: voice.model, synth };
}

const segments = splitForPause(text);
if (!segments.length) throw new Error('Nothing to synthesize.');
const parts: Float32Array[] = [];
let sampleRate = 16000;
for (const seg of segments) {
const out = (await cached.synth(seg)) as { audio: Float32Array; sampling_rate?: number };
sampleRate = out.sampling_rate ?? sampleRate;
parts.push(out.audio);
}
const silence = Math.round(Math.max(0, pauseSeconds) * sampleRate);
return { audio: concatWithSilence(parts, silence), sampleRate };
}

/** Encode Float32 audio to MP3 bytes via lamejs (mono). */
export async function encodeMp3(audio: Float32Array, sampleRate: number, kbps = 96): Promise<Uint8Array> {
const lamejs = await import('@breezystack/lamejs');
const encoder = new lamejs.Mp3Encoder(1, sampleRate, kbps);
const pcm = floatToPcm16(audio);
const block = 1152;
const chunks: Uint8Array[] = [];
for (let i = 0; i < pcm.length; i += block) {
const buf = encoder.encodeBuffer(pcm.subarray(i, i + block));
if (buf.length) chunks.push(new Uint8Array(buf));
}
const end = encoder.flush();
if (end.length) chunks.push(new Uint8Array(end));
let total = 0;
for (const c of chunks) total += c.length;
const out = new Uint8Array(total);
let off = 0;
for (const c of chunks) { out.set(c, off); off += c.length; }
return out;
}
50 changes: 50 additions & 0 deletions src/tools/media/tts-audio.lib.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest';
import { floatToWav, concatWithSilence, splitForPause } from './tts-audio.lib';

describe('floatToWav', () => {
it('writes a valid mono PCM16 WAV header', () => {
const wav = floatToWav(new Float32Array([0, 0.5, -0.5]), 16000);
const str = (o: number, n: number) => String.fromCharCode(...wav.slice(o, o + n));
expect(str(0, 4)).toBe('RIFF');
expect(str(8, 4)).toBe('WAVE');
expect(str(36, 4)).toBe('data');
const dv = new DataView(wav.buffer);
expect(dv.getUint16(20, true)).toBe(1); // PCM
expect(dv.getUint16(22, true)).toBe(1); // mono
expect(dv.getUint32(24, true)).toBe(16000); // sample rate
expect(dv.getUint16(34, true)).toBe(16); // bits per sample
expect(wav.length).toBe(44 + 3 * 2);
});

it('quantises samples to 16-bit and clamps overflow', () => {
const wav = floatToWav(new Float32Array([0, 1, -1, 2]), 8000);
const dv = new DataView(wav.buffer);
expect(dv.getInt16(44, true)).toBe(0);
expect(dv.getInt16(46, true)).toBe(32767); // +1 full scale
expect(dv.getInt16(48, true)).toBe(-32768); // -1 full scale
expect(dv.getInt16(50, true)).toBe(32767); // 2 clamps to +1
});
});

describe('concatWithSilence', () => {
it('joins segments with N samples of silence between them', () => {
const out = concatWithSilence([new Float32Array([1, 2]), new Float32Array([3])], 2);
expect(Array.from(out)).toEqual([1, 2, 0, 0, 3]);
});
it('adds no trailing silence and handles a single segment', () => {
expect(Array.from(concatWithSilence([new Float32Array([5])], 3))).toEqual([5]);
expect(concatWithSilence([], 3).length).toBe(0);
});
});

describe('splitForPause', () => {
it('splits on [pause] markers and blank lines', () => {
expect(splitForPause('Hello [pause] world\n\nBye')).toEqual(['Hello', 'world', 'Bye']);
});
it('trims and drops empty segments', () => {
expect(splitForPause(' one [pause] [pause] two ')).toEqual(['one', 'two']);
});
it('returns the whole text when there are no markers', () => {
expect(splitForPause('just one line')).toEqual(['just one line']);
});
});
Loading
Loading