From 62eb7c4de0e42f60998d25a4c8520dfea0ea370b Mon Sep 17 00:00:00 2001 From: Sergei Date: Tue, 27 Jan 2026 16:20:51 -0800 Subject: [PATCH] Add useTextToSpeech hook for TTS operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create a reusable hook wrapping expo-speech that provides: - speak/stop controls - isSpeaking state tracking - Voice listing support - Promise-based API for async flows - Proper cleanup on unmount 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- hooks/useTextToSpeech.ts | 252 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 hooks/useTextToSpeech.ts diff --git a/hooks/useTextToSpeech.ts b/hooks/useTextToSpeech.ts new file mode 100644 index 0000000..c3ed449 --- /dev/null +++ b/hooks/useTextToSpeech.ts @@ -0,0 +1,252 @@ +/** + * 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, + }; +}