/** * useTextToSpeech hook * Manages TTS API integration with synthesis, caching, and playback state */ import { useState, useCallback, useRef, useEffect } from "react"; import { synthesizeSpeech } from "@/lib/api/speech"; export interface SynthesizeOptions { voice?: string; speed?: number; format?: string; tier?: string; } export interface UseTextToSpeechReturn { /** Synthesize text to speech audio */ synthesize: (text: string, options?: SynthesizeOptions) => Promise; /** The URL of the synthesized audio blob */ audioUrl: string | null; /** Whether synthesis is in progress */ isLoading: boolean; /** Error message if synthesis failed */ error: string | null; /** Start or resume audio playback */ play: () => Promise; /** Pause audio playback */ pause: () => void; /** Stop audio and reset to beginning */ stop: () => void; /** Whether audio is currently playing */ isPlaying: boolean; /** Total duration of the audio in seconds */ duration: number; /** Current playback position in seconds */ currentTime: number; } /** Cache key generator for text + options combination */ function getCacheKey(text: string, options?: SynthesizeOptions): string { return JSON.stringify({ text, ...options }); } /** * Hook for text-to-speech API integration with caching and playback controls */ export function useTextToSpeech(): UseTextToSpeechReturn { const [audioUrl, setAudioUrl] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [isPlaying, setIsPlaying] = useState(false); const [duration, setDuration] = useState(0); const [currentTime, setCurrentTime] = useState(0); // Audio element ref for playback control const audioRef = useRef(null); // Cache: maps cache key -> blob URL const cacheRef = useRef>(new Map()); // Track all blob URLs for cleanup const blobUrlsRef = useRef>(new Set()); /** * Clean up audio element event listeners and state */ const cleanupAudio = useCallback(() => { const audio = audioRef.current; if (audio) { audio.pause(); audio.removeEventListener("ended", handleEnded); audio.removeEventListener("timeupdate", handleTimeUpdate); audio.removeEventListener("loadedmetadata", handleLoadedMetadata); audioRef.current = null; } setIsPlaying(false); }, []); /** * Handle audio ended event */ function handleEnded(): void { setIsPlaying(false); setCurrentTime(0); } /** * Handle audio time update event */ function handleTimeUpdate(): void { const audio = audioRef.current; if (audio) { setCurrentTime(audio.currentTime); } } /** * Handle audio metadata loaded event */ function handleLoadedMetadata(): void { const audio = audioRef.current; if (audio && isFinite(audio.duration)) { setDuration(audio.duration); } } /** * Set up a new Audio element for a given URL */ const setupAudio = useCallback( (url: string) => { cleanupAudio(); const audio = new Audio(url); audio.addEventListener("ended", handleEnded); audio.addEventListener("timeupdate", handleTimeUpdate); audio.addEventListener("loadedmetadata", handleLoadedMetadata); audioRef.current = audio; }, [cleanupAudio] ); /** * Synthesize text to speech */ const synthesize = useCallback( async (text: string, options?: SynthesizeOptions): Promise => { setError(null); // Check cache first const cacheKey = getCacheKey(text, options); const cachedUrl = cacheRef.current.get(cacheKey); if (cachedUrl) { setAudioUrl(cachedUrl); setupAudio(cachedUrl); return; } setIsLoading(true); try { const blob = await synthesizeSpeech({ text, ...(options?.voice !== undefined && { voice: options.voice }), ...(options?.speed !== undefined && { speed: options.speed }), ...(options?.format !== undefined && { format: options.format }), ...(options?.tier !== undefined && { tier: options.tier }), }); const url = URL.createObjectURL(blob); // Store in cache and track for cleanup cacheRef.current.set(cacheKey, url); blobUrlsRef.current.add(url); setAudioUrl(url); setupAudio(url); } catch (err) { const errorMsg = err instanceof Error ? err.message : "Speech synthesis failed"; setError(errorMsg); setAudioUrl(null); } finally { setIsLoading(false); } }, [setupAudio] ); /** * Start or resume audio playback */ const play = useCallback(async (): Promise => { const audio = audioRef.current; if (audio) { try { await audio.play(); setIsPlaying(true); } catch (err) { const message = err instanceof DOMException && err.name === "NotAllowedError" ? "Playback was blocked by the browser. Try interacting with the page first." : "Unable to play audio. The format may not be supported."; setError(message); setIsPlaying(false); } } }, []); /** * Pause audio playback */ const pause = useCallback((): void => { const audio = audioRef.current; if (audio) { audio.pause(); setIsPlaying(false); } }, []); /** * Stop audio and reset to beginning */ const stop = useCallback((): void => { const audio = audioRef.current; if (audio) { audio.pause(); audio.currentTime = 0; setIsPlaying(false); setCurrentTime(0); } }, []); // Cleanup on unmount: revoke all blob URLs and clean up audio useEffect((): (() => void) => { return (): void => { // Clean up audio element const audio = audioRef.current; if (audio) { audio.pause(); audio.removeEventListener("ended", handleEnded); audio.removeEventListener("timeupdate", handleTimeUpdate); audio.removeEventListener("loadedmetadata", handleLoadedMetadata); audioRef.current = null; } // Revoke all blob URLs for (const url of blobUrlsRef.current) { URL.revokeObjectURL(url); } blobUrlsRef.current.clear(); cacheRef.current.clear(); }; }, []); return { synthesize, audioUrl, isLoading, error, play, pause, stop, isPlaying, duration, currentTime, }; }