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