wellnua-lite/components/VoiceIndicator.tsx
Sergei b2639dd540 Add Sherpa TTS voice synthesis system
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
2026-01-14 19:09:27 -08:00

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;