Add useTextToSpeech hook for TTS operations
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 <noreply@anthropic.com>
This commit is contained in:
parent
54bff8d9d5
commit
62eb7c4de0
252
hooks/useTextToSpeech.ts
Normal file
252
hooks/useTextToSpeech.ts
Normal file
@ -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<UseTextToSpeechOptions>) => Promise<void>;
|
||||
/** 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<Speech.Voice[]>;
|
||||
/** 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<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(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<UseTextToSpeechOptions>
|
||||
): Promise<void> => {
|
||||
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<void>((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<Speech.Voice[]> => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user