nexus/ui/src/hooks/usePiperTts.ts
Nexus Dev 8f8257e143 feat(34-01): create usePiperTts hook and TtsButton component with piper-tts-web
- 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
2026-04-03 22:34:44 +00:00

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 };
}