import { useState, useRef, useCallback, useEffect } from "react"; import WaveformPlayer from "./WaveformPlayer"; const MODELS: Record = { "Nano (15M - Fastest)": "onnx-community/KittenTTS-Nano-v0.8-ONNX", "Micro (40M - Balanced)": "onnx-community/KittenTTS-Micro-v0.8-ONNX", "Mini (80M - Best Quality)": "onnx-community/KittenTTS-Mini-v0.8-ONNX", }; const DEFAULT_MODEL = "Nano (15M - Fastest)"; const EXAMPLES = [ { text: "Space is a three-dimensional continuum containing positions and directions.", model: "Micro (40M - Balanced)", voice: "Jasper", speed: 1.0, }, { text: "She picked up her coffee and walked toward the window.", model: "Mini (80M - Best Quality)", voice: "Luna", speed: 1.0, }, { text: "The sun set slowly over the calm, quiet lake", model: "Nano (15M - Fastest)", voice: "Bella", speed: 1.1, }, ]; type Status = "idle" | "loading" | "ready" | "generating" | "error"; export default function App() { const [text, setText] = useState(""); const [model, setModel] = useState(DEFAULT_MODEL); const [voice, setVoice] = useState("Jasper"); const [speed, setSpeed] = useState(1.0); const [voices, setVoices] = useState([]); const [status, setStatus] = useState("idle"); const [statusMsg, setStatusMsg] = useState(""); const [, setDevice] = useState(""); const [progress, setProgress] = useState({ current: 0, total: 0 }); const [audioUrl, setAudioUrl] = useState(null); const [error, setError] = useState(null); const [duration, setDuration] = useState(null); const workerRef = useRef(null); const genStartRef = useRef(0); const initWorker = useCallback(() => { if (workerRef.current) workerRef.current.terminate(); const worker = new Worker(new URL("./worker.ts", import.meta.url), { type: "module", }); workerRef.current = worker; worker.addEventListener("error", (e) => { console.error("Worker error:", e); setError(`Worker failed: ${e.message}`); setStatus("error"); setStatusMsg(""); }); worker.addEventListener("message", (e) => { const msg = e.data; switch (msg.type) { case "status": setStatusMsg(msg.message); break; case "device": setDevice(msg.device); break; case "ready": setStatus("ready"); setVoices(msg.voices); setStatusMsg(`${msg.modelName} loaded`); break; case "progress": setProgress({ current: msg.current, total: msg.total }); break; case "audio": { const audioData = new Float32Array(msg.audio); const blob = float32ToWav(audioData, msg.sampleRate); const url = URL.createObjectURL(blob); setAudioUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return url; }); setDuration( Math.round(performance.now() - genStartRef.current) ); setStatus("ready"); setStatusMsg("Done!"); break; } case "error": setError(msg.error); setStatus("error"); setStatusMsg(""); break; } }); return worker; }, []); const loadModel = useCallback( (modelKey: string) => { const worker = workerRef.current || initWorker(); setStatus("loading"); setError(null); setAudioUrl(null); setDuration(null); setStatusMsg("Starting..."); worker.postMessage({ action: "load", repoId: MODELS[modelKey] }); }, [initWorker] ); useEffect(() => { loadModel(model); return () => { workerRef.current?.terminate(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const handleModelChange = (newModel: string) => { setModel(newModel); loadModel(newModel); }; const handleGenerate = () => { if (!text.trim() || status !== "ready") return; setStatus("generating"); setError(null); setDuration(null); setProgress({ current: 0, total: 0 }); genStartRef.current = performance.now(); workerRef.current?.postMessage({ action: "generate", text, voice, speed }); }; const handleExample = (ex: (typeof EXAMPLES)[0]) => { setText(ex.text); setVoice(ex.voice); setSpeed(ex.speed); if (ex.model !== model) { handleModelChange(ex.model); } }; const handleKeyDown = (e: React.KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); handleGenerate(); } }; return (

🐱 KittenTTS

Text-to-speech running entirely in your browser