/** * Text-to-Speech Hook * * Wraps expo-speech for easy use in components. * Provides speak/stop controls, status states, and queue management. * * Usage: * ```typescript * const { speak, stop, isSpeaking, error } = useTextToSpeech(); * * // Speak text * await speak('Hello world'); * * // Stop speaking * stop(); * * // Check if speaking * if (isSpeaking) { ... } * ``` */ import { useState, useCallback, useRef, useEffect } from 'react'; import * as Speech from 'expo-speech'; export interface UseTextToSpeechOptions { /** Language for speech (default: 'en-US') */ language?: string; /** Speech rate, 0.5-2.0 (default: 0.9) */ rate?: number; /** Speech pitch, 0.5-2.0 (default: 1.0) */ pitch?: number; /** Voice identifier (optional, uses system default) */ voice?: string; /** Callback when speech starts */ onStart?: () => void; /** Callback when speech ends */ onDone?: () => void; /** Callback when speech is stopped */ onStopped?: () => void; /** Callback when an error occurs */ onError?: (error: string) => void; } export interface UseTextToSpeechReturn { /** Speak text using TTS */ speak: (text: string, options?: Partial) => Promise; /** Stop speaking */ stop: () => void; /** Whether currently speaking */ isSpeaking: boolean; /** Whether TTS is available on this device */ isAvailable: boolean; /** Current text being spoken */ currentText: string | null; /** Error message if any */ error: string | null; /** Get available voices */ getVoices: () => Promise; /** Clear error state */ clearError: () => void; } export function useTextToSpeech( options: UseTextToSpeechOptions = {} ): UseTextToSpeechReturn { const { language = 'en-US', rate = 0.9, pitch = 1.0, voice, onStart, onDone, onStopped, onError, } = options; const [isSpeaking, setIsSpeaking] = useState(false); const [isAvailable, setIsAvailable] = useState(true); const [currentText, setCurrentText] = useState(null); const [error, setError] = useState(null); // Track if component is mounted to prevent state updates after unmount const isMountedRef = useRef(true); // Track current speech promise resolve const resolveRef = useRef<(() => void) | null>(null); // Check if currently speaking on mount and cleanup useEffect(() => { isMountedRef.current = true; const checkSpeaking = async () => { try { const speaking = await Speech.isSpeakingAsync(); if (isMountedRef.current) { setIsSpeaking(speaking); } } catch (err) { console.warn('[TTS] Could not check speaking status:', err); } }; checkSpeaking(); return () => { isMountedRef.current = false; // Stop any ongoing speech when unmounting Speech.stop(); }; }, []); /** * Speak text using TTS * @param text - Text to speak * @param overrideOptions - Override default options for this call * @returns Promise that resolves when speech completes */ const speak = useCallback( async ( text: string, overrideOptions?: Partial ): Promise => { const trimmedText = text.trim(); if (!trimmedText) { console.log('[TTS] Empty text, skipping'); return; } // Merge options const opts = { language: overrideOptions?.language ?? language, rate: overrideOptions?.rate ?? rate, pitch: overrideOptions?.pitch ?? pitch, voice: overrideOptions?.voice ?? voice, onStart: overrideOptions?.onStart ?? onStart, onDone: overrideOptions?.onDone ?? onDone, onStopped: overrideOptions?.onStopped ?? onStopped, onError: overrideOptions?.onError ?? onError, }; // Stop any current speech before starting new if (isSpeaking) { Speech.stop(); // Wait a bit for cleanup await new Promise((r) => setTimeout(r, 50)); } console.log('[TTS] Speaking:', trimmedText.slice(0, 50) + (trimmedText.length > 50 ? '...' : '')); if (isMountedRef.current) { setCurrentText(trimmedText); setIsSpeaking(true); setError(null); } return new Promise((resolve) => { resolveRef.current = resolve; Speech.speak(trimmedText, { language: opts.language, rate: opts.rate, pitch: opts.pitch, voice: opts.voice, onStart: () => { console.log('[TTS] Started'); opts.onStart?.(); }, onDone: () => { console.log('[TTS] Completed'); if (isMountedRef.current) { setIsSpeaking(false); setCurrentText(null); } opts.onDone?.(); resolveRef.current = null; resolve(); }, onStopped: () => { console.log('[TTS] Stopped'); if (isMountedRef.current) { setIsSpeaking(false); setCurrentText(null); } opts.onStopped?.(); resolveRef.current = null; resolve(); }, onError: (err) => { const errorMsg = typeof err === 'string' ? err : 'Speech synthesis error'; console.error('[TTS] Error:', errorMsg); if (isMountedRef.current) { setIsSpeaking(false); setCurrentText(null); setError(errorMsg); } opts.onError?.(errorMsg); resolveRef.current = null; resolve(); }, }); }); }, [language, rate, pitch, voice, isSpeaking, onStart, onDone, onStopped, onError] ); /** * Stop speaking */ const stop = useCallback(() => { console.log('[TTS] Stop requested'); Speech.stop(); if (isMountedRef.current) { setIsSpeaking(false); setCurrentText(null); } // Resolve pending promise if (resolveRef.current) { resolveRef.current(); resolveRef.current = null; } }, []); /** * Get available voices for speech synthesis */ const getVoices = useCallback(async (): Promise => { try { const voices = await Speech.getAvailableVoicesAsync(); console.log('[TTS] Available voices:', voices.length); return voices; } catch (err) { console.error('[TTS] Could not get voices:', err); return []; } }, []); /** * Clear error state */ const clearError = useCallback(() => { setError(null); }, []); return { speak, stop, isSpeaking, isAvailable, currentText, error, getVoices, clearError, }; }