wellnua-lite/hooks/useTextToSpeech.ts
Sergei 05f872d067 fix: voice session improvements - FAB stop, echo prevention, chat TTS
- FAB button now correctly stops session during speaking/processing states
- Echo prevention: STT stopped during TTS playback, results ignored during speaking
- Chat TTS only speaks when voice session is active (no auto-speak for text chat)
- Session stop now aborts in-flight API requests and prevents race conditions
- STT restarts after TTS with 800ms delay for audio focus release
- Pending interrupt transcript processed after TTS completion
- ChatContext added for message persistence across tab navigation
- VoiceFAB redesigned with state-based animations
- console.error replaced with console.warn across voice pipeline
- no-speech STT errors silenced (normal silence behavior)
2026-01-27 22:59:55 -08:00

253 lines
6.7 KiB
TypeScript

/**
* 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.warn('[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.warn('[TTS] Could not get voices:', err);
return [];
}
}, []);
/**
* Clear error state
*/
const clearError = useCallback(() => {
setError(null);
}, []);
return {
speak,
stop,
isSpeaking,
isAvailable,
currentText,
error,
getVoices,
clearError,
};
}