- 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)
253 lines
6.7 KiB
TypeScript
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,
|
|
};
|
|
}
|