- Install @mintplex-labs/piper-tts-web as UI dependency - Create usePiperTts hook with prewarm/speak/stop/status/progress (VOICE-01, VOICE-02) - tts.stored() checks IndexedDB cache to skip re-download - tts.download() with progress callback for visible download progress - tts.predict() returns WAV blob URL for CPU-safe WASM synthesis - Create TtsButton component showing download progress during prewarm - TtsButton shows Volume2/VolumeX icons for idle/speaking states
66 lines
1.9 KiB
TypeScript
66 lines
1.9 KiB
TypeScript
import { useState, useCallback, useRef } from "react";
|
|
import { tts } from "@mintplex-labs/piper-tts-web";
|
|
|
|
const DEFAULT_VOICE = "en_US-hfc_female-medium";
|
|
|
|
export type TtsStatus = "idle" | "downloading" | "ready" | "speaking" | "error";
|
|
|
|
export function usePiperTts() {
|
|
const [status, setStatus] = useState<TtsStatus>("idle");
|
|
const [progress, setProgress] = useState(0);
|
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
|
|
const prewarm = useCallback(async () => {
|
|
if (status === "ready" || status === "downloading") return;
|
|
setStatus("downloading");
|
|
setProgress(0);
|
|
try {
|
|
const stored = await tts.stored();
|
|
if (!stored.includes(DEFAULT_VOICE)) {
|
|
await tts.download(DEFAULT_VOICE, (p: { loaded: number; total: number }) => {
|
|
setProgress(Math.round((p.loaded / p.total) * 100));
|
|
});
|
|
}
|
|
setStatus("ready");
|
|
setProgress(100);
|
|
} catch {
|
|
setStatus("error");
|
|
}
|
|
}, [status]);
|
|
|
|
const speak = useCallback(async (text: string) => {
|
|
if (status !== "ready") return;
|
|
// Stop any currently playing audio
|
|
if (audioRef.current) {
|
|
audioRef.current.pause();
|
|
audioRef.current = null;
|
|
}
|
|
setStatus("speaking");
|
|
try {
|
|
const wav = await tts.predict({ text, voiceId: DEFAULT_VOICE });
|
|
const audio = new Audio(wav);
|
|
audioRef.current = audio;
|
|
audio.onended = () => {
|
|
audioRef.current = null;
|
|
setStatus("ready");
|
|
};
|
|
audio.onerror = () => {
|
|
audioRef.current = null;
|
|
setStatus("ready");
|
|
};
|
|
await audio.play();
|
|
} catch {
|
|
setStatus("ready");
|
|
}
|
|
}, [status]);
|
|
|
|
const stop = useCallback(() => {
|
|
if (audioRef.current) {
|
|
audioRef.current.pause();
|
|
audioRef.current = null;
|
|
}
|
|
if (status === "speaking") setStatus("ready");
|
|
}, [status]);
|
|
|
|
return { status, progress, prewarm, speak, stop };
|
|
}
|