Core TTS infrastructure: - sherpaTTS.ts: Sherpa ONNX integration for offline TTS - TTSErrorBoundary.tsx: Error boundary for TTS failures - ErrorBoundary.tsx: Generic error boundary component - VoiceIndicator.tsx: Visual indicator for voice activity - useSpeechRecognition.ts: Speech-to-text hook - DebugLogger.ts: Debug logging utility Features: - Offline voice synthesis (no internet needed) - Multiple voices support - Real-time voice activity indication - Error recovery and fallback - Debug logging for troubleshooting Tech stack: - Sherpa ONNX runtime - React Native Audio - Expo modules
277 lines
7.3 KiB
TypeScript
277 lines
7.3 KiB
TypeScript
/**
|
|
* VoiceIndicator - Animated visual feedback for voice conversation
|
|
* Shows pulsing circles when listening or speaking
|
|
*/
|
|
|
|
import React, { useEffect, useRef } from 'react';
|
|
import { View, StyleSheet, Animated, Text, TouchableOpacity } from 'react-native';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
|
|
|
|
interface VoiceIndicatorProps {
|
|
mode: 'listening' | 'speaking' | 'idle';
|
|
onTap?: (currentMode: 'listening' | 'speaking') => void;
|
|
}
|
|
|
|
export function VoiceIndicator({ mode, onTap }: VoiceIndicatorProps) {
|
|
// Animation values for 3 concentric circles
|
|
const ring1Scale = useRef(new Animated.Value(1)).current;
|
|
const ring2Scale = useRef(new Animated.Value(1)).current;
|
|
const ring3Scale = useRef(new Animated.Value(1)).current;
|
|
const ring1Opacity = useRef(new Animated.Value(0.8)).current;
|
|
const ring2Opacity = useRef(new Animated.Value(0.6)).current;
|
|
const ring3Opacity = useRef(new Animated.Value(0.4)).current;
|
|
|
|
// Inner circle pulse
|
|
const innerPulse = useRef(new Animated.Value(1)).current;
|
|
|
|
useEffect(() => {
|
|
if (mode === 'idle') {
|
|
// Reset all animations
|
|
ring1Scale.setValue(1);
|
|
ring2Scale.setValue(1);
|
|
ring3Scale.setValue(1);
|
|
ring1Opacity.setValue(0);
|
|
ring2Opacity.setValue(0);
|
|
ring3Opacity.setValue(0);
|
|
innerPulse.setValue(1);
|
|
return;
|
|
}
|
|
|
|
// Create pulsing animation for rings
|
|
const createRingAnimation = (
|
|
scale: Animated.Value,
|
|
opacity: Animated.Value,
|
|
delay: number
|
|
) => {
|
|
return Animated.loop(
|
|
Animated.sequence([
|
|
Animated.delay(delay),
|
|
Animated.parallel([
|
|
Animated.timing(scale, {
|
|
toValue: 2.5,
|
|
duration: 1500,
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(opacity, {
|
|
toValue: 0,
|
|
duration: 1500,
|
|
useNativeDriver: true,
|
|
}),
|
|
]),
|
|
Animated.parallel([
|
|
Animated.timing(scale, {
|
|
toValue: 1,
|
|
duration: 0,
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(opacity, {
|
|
toValue: mode === 'listening' ? 0.8 : 0.6,
|
|
duration: 0,
|
|
useNativeDriver: true,
|
|
}),
|
|
]),
|
|
])
|
|
);
|
|
};
|
|
|
|
// Inner pulse animation
|
|
const innerPulseAnimation = Animated.loop(
|
|
Animated.sequence([
|
|
Animated.timing(innerPulse, {
|
|
toValue: 1.15,
|
|
duration: 400,
|
|
useNativeDriver: true,
|
|
}),
|
|
Animated.timing(innerPulse, {
|
|
toValue: 1,
|
|
duration: 400,
|
|
useNativeDriver: true,
|
|
}),
|
|
])
|
|
);
|
|
|
|
// Reset opacity values
|
|
ring1Opacity.setValue(mode === 'listening' ? 0.8 : 0.6);
|
|
ring2Opacity.setValue(mode === 'listening' ? 0.6 : 0.4);
|
|
ring3Opacity.setValue(mode === 'listening' ? 0.4 : 0.3);
|
|
|
|
// Start all animations
|
|
const anim1 = createRingAnimation(ring1Scale, ring1Opacity, 0);
|
|
const anim2 = createRingAnimation(ring2Scale, ring2Opacity, 500);
|
|
const anim3 = createRingAnimation(ring3Scale, ring3Opacity, 1000);
|
|
|
|
anim1.start();
|
|
anim2.start();
|
|
anim3.start();
|
|
innerPulseAnimation.start();
|
|
|
|
return () => {
|
|
anim1.stop();
|
|
anim2.stop();
|
|
anim3.stop();
|
|
innerPulseAnimation.stop();
|
|
};
|
|
}, [mode]);
|
|
|
|
if (mode === 'idle') {
|
|
return null;
|
|
}
|
|
|
|
const isListening = mode === 'listening';
|
|
const primaryColor = isListening ? AppColors.primary : '#4CAF50';
|
|
const secondaryColor = isListening ? '#2196F3' : '#66BB6A';
|
|
|
|
// Handle tap anywhere on the indicator
|
|
const handlePress = () => {
|
|
if (mode !== 'idle') {
|
|
onTap?.(mode as 'listening' | 'speaking');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<TouchableOpacity
|
|
style={styles.container}
|
|
onPress={handlePress}
|
|
activeOpacity={0.9}
|
|
>
|
|
{/* Animated rings */}
|
|
<View style={styles.ringsContainer}>
|
|
<Animated.View
|
|
style={[
|
|
styles.ring,
|
|
{
|
|
backgroundColor: primaryColor,
|
|
transform: [{ scale: ring1Scale }],
|
|
opacity: ring1Opacity,
|
|
},
|
|
]}
|
|
/>
|
|
<Animated.View
|
|
style={[
|
|
styles.ring,
|
|
{
|
|
backgroundColor: secondaryColor,
|
|
transform: [{ scale: ring2Scale }],
|
|
opacity: ring2Opacity,
|
|
},
|
|
]}
|
|
/>
|
|
<Animated.View
|
|
style={[
|
|
styles.ring,
|
|
{
|
|
backgroundColor: primaryColor,
|
|
transform: [{ scale: ring3Scale }],
|
|
opacity: ring3Opacity,
|
|
},
|
|
]}
|
|
/>
|
|
|
|
{/* Inner pulsing circle with icon */}
|
|
<Animated.View
|
|
style={[
|
|
styles.innerCircle,
|
|
{
|
|
backgroundColor: primaryColor,
|
|
transform: [{ scale: innerPulse }],
|
|
},
|
|
]}
|
|
>
|
|
<Ionicons
|
|
name={isListening ? 'mic' : 'volume-high'}
|
|
size={32}
|
|
color={AppColors.white}
|
|
/>
|
|
</Animated.View>
|
|
</View>
|
|
|
|
{/* Status text */}
|
|
<Text style={[styles.statusText, { color: primaryColor }]}>
|
|
{isListening ? 'Listening...' : 'Speaking...'}
|
|
</Text>
|
|
|
|
{/* Tap hint - shows what will happen when tapped */}
|
|
<View style={[styles.hintContainer, !isListening && styles.hintContainerSpeak]}>
|
|
<Ionicons
|
|
name={isListening ? 'close-circle' : 'mic'}
|
|
size={20}
|
|
color={isListening ? AppColors.error : AppColors.primary}
|
|
/>
|
|
<Text style={[styles.hintText, !isListening && styles.hintTextSpeak]}>
|
|
{isListening ? 'Tap to cancel' : 'Tap to interrupt & speak'}
|
|
</Text>
|
|
</View>
|
|
</TouchableOpacity>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
paddingVertical: Spacing.xl,
|
|
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
|
borderRadius: BorderRadius.lg,
|
|
marginHorizontal: Spacing.md,
|
|
marginBottom: Spacing.md,
|
|
shadowColor: '#000',
|
|
shadowOffset: { width: 0, height: 2 },
|
|
shadowOpacity: 0.1,
|
|
shadowRadius: 8,
|
|
elevation: 4,
|
|
},
|
|
ringsContainer: {
|
|
width: 120,
|
|
height: 120,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
ring: {
|
|
position: 'absolute',
|
|
width: 60,
|
|
height: 60,
|
|
borderRadius: 30,
|
|
},
|
|
innerCircle: {
|
|
width: 70,
|
|
height: 70,
|
|
borderRadius: 35,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
shadowColor: '#000',
|
|
shadowOffset: { width: 0, height: 4 },
|
|
shadowOpacity: 0.3,
|
|
shadowRadius: 6,
|
|
elevation: 8,
|
|
},
|
|
statusText: {
|
|
fontSize: FontSizes.lg,
|
|
fontWeight: '600',
|
|
marginTop: Spacing.md,
|
|
},
|
|
hintContainer: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
marginTop: Spacing.md,
|
|
paddingHorizontal: Spacing.md,
|
|
paddingVertical: Spacing.sm,
|
|
backgroundColor: 'rgba(244, 67, 54, 0.1)',
|
|
borderRadius: BorderRadius.full,
|
|
},
|
|
hintContainerSpeak: {
|
|
backgroundColor: 'rgba(33, 150, 243, 0.1)',
|
|
},
|
|
hintText: {
|
|
marginLeft: Spacing.xs,
|
|
fontSize: FontSizes.sm,
|
|
color: AppColors.error,
|
|
fontWeight: '500',
|
|
},
|
|
hintTextSpeak: {
|
|
color: AppColors.primary,
|
|
},
|
|
});
|
|
|
|
export default VoiceIndicator;
|