App screens: - chat.tsx: Voice-enabled chat with TTS responses - debug.tsx: TTS debugging and testing screen - index.tsx: Updated home with voice indicators - _layout.tsx: Added TTS and error boundaries Config: - app.json: Microphone permissions for voice input - package.json: Added Sherpa ONNX dependencies - constants/theme.ts: Voice UI colors Features: - Voice input via speech recognition - TTS voice output for chat responses - Real-time voice activity indication - Debug screen for TTS testing - Error boundaries for stability User experience: - Hands-free chat interaction - Visual feedback during voice processing - Graceful error handling
526 lines
15 KiB
TypeScript
526 lines
15 KiB
TypeScript
import React, { useState, useEffect, useRef } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
StyleSheet,
|
|
FlatList,
|
|
TouchableOpacity,
|
|
TextInput,
|
|
Share,
|
|
Platform,
|
|
} from 'react-native';
|
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { debugLogger, type LogEntry } from '@/services/DebugLogger';
|
|
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
|
|
import sherpaTTS from '@/services/sherpaTTS';
|
|
|
|
export default function DebugScreen() {
|
|
const [logs, setLogs] = useState<LogEntry[]>([]);
|
|
const [filter, setFilter] = useState<string>('');
|
|
const [selectedCategory, setSelectedCategory] = useState<string>('All');
|
|
const [ttsState, setTtsState] = useState<ReturnType<typeof sherpaTTS.getState>>(sherpaTTS.getState());
|
|
const flatListRef = useRef<FlatList>(null);
|
|
|
|
// Initialize TTS and subscribe to state changes
|
|
useEffect(() => {
|
|
// Subscribe to TTS state changes
|
|
const unsubscribeTTS = sherpaTTS.addStateListener(setTtsState);
|
|
|
|
// Start initialization
|
|
sherpaTTS.initialize().catch(e =>
|
|
debugLogger.error('TTS', `Init failed: ${e}`)
|
|
);
|
|
|
|
return unsubscribeTTS;
|
|
}, []);
|
|
|
|
// Subscribe to log updates
|
|
useEffect(() => {
|
|
const unsubscribe = debugLogger.subscribe((newLogs) => {
|
|
setLogs(newLogs);
|
|
// Auto-scroll to bottom when new logs arrive
|
|
setTimeout(() => {
|
|
flatListRef.current?.scrollToEnd({ animated: true });
|
|
}, 100);
|
|
});
|
|
|
|
// Initial load
|
|
setLogs(debugLogger.getLogs());
|
|
|
|
return unsubscribe;
|
|
}, []);
|
|
|
|
// Get unique categories
|
|
const categories = ['All', ...new Set(logs.map(log => log.category))];
|
|
|
|
// Filter logs
|
|
const filteredLogs = logs.filter(log => {
|
|
const matchesCategory = selectedCategory === 'All' || log.category === selectedCategory;
|
|
const matchesFilter = !filter || log.message.toLowerCase().includes(filter.toLowerCase());
|
|
return matchesCategory && matchesFilter;
|
|
});
|
|
|
|
// Clear logs
|
|
const handleClear = () => {
|
|
debugLogger.clear();
|
|
};
|
|
|
|
// Export logs
|
|
const handleExport = async () => {
|
|
const text = debugLogger.exportAsText();
|
|
try {
|
|
await Share.share({
|
|
message: text,
|
|
title: 'Debug Logs Export',
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to export logs:', error);
|
|
}
|
|
};
|
|
|
|
// Test TTS - check if ready before speaking
|
|
const handleTestTTS = () => {
|
|
if (!ttsState.initialized) {
|
|
debugLogger.warn('TTS', 'Cannot test - TTS not ready yet');
|
|
return;
|
|
}
|
|
|
|
debugLogger.info('TTS', 'Testing voice...');
|
|
sherpaTTS.speak('Hello, this is a test message', {
|
|
onDone: () => debugLogger.info('TTS', 'Voice test complete'),
|
|
onError: (e) => debugLogger.error('TTS', `Voice test failed: ${e}`)
|
|
});
|
|
};
|
|
|
|
// Get TTS status display
|
|
const getTTSStatus = () => {
|
|
if (ttsState.initializing) {
|
|
return {
|
|
text: ttsState.error || 'Downloading voice model...',
|
|
color: AppColors.warning || '#FF9800',
|
|
icon: 'cloud-download' as const,
|
|
};
|
|
}
|
|
if (ttsState.initialized) {
|
|
return {
|
|
text: 'Ready',
|
|
color: '#4CAF50',
|
|
icon: 'checkmark-circle' as const,
|
|
};
|
|
}
|
|
if (ttsState.error) {
|
|
return {
|
|
text: ttsState.error,
|
|
color: AppColors.error || '#E53935',
|
|
icon: 'alert-circle' as const,
|
|
};
|
|
}
|
|
return {
|
|
text: 'Not initialized',
|
|
color: AppColors.textMuted,
|
|
icon: 'time' as const,
|
|
};
|
|
};
|
|
|
|
const ttsStatus = getTTSStatus();
|
|
|
|
// Get log level color
|
|
const getLevelColor = (level: LogEntry['level']): string => {
|
|
switch (level) {
|
|
case 'error':
|
|
return AppColors.error || '#E53935';
|
|
case 'warn':
|
|
return AppColors.warning || '#FF9800';
|
|
case 'info':
|
|
return AppColors.primary;
|
|
default:
|
|
return AppColors.textSecondary;
|
|
}
|
|
};
|
|
|
|
// Get level icon
|
|
const getLevelIcon = (level: LogEntry['level']): any => {
|
|
switch (level) {
|
|
case 'error':
|
|
return 'close-circle';
|
|
case 'warn':
|
|
return 'warning';
|
|
case 'info':
|
|
return 'information-circle';
|
|
default:
|
|
return 'chatbubble-ellipses';
|
|
}
|
|
};
|
|
|
|
// Render log item
|
|
const renderLog = ({ item }: { item: LogEntry }) => {
|
|
const time = item.timestamp.toLocaleTimeString();
|
|
const levelColor = getLevelColor(item.level);
|
|
|
|
return (
|
|
<View style={styles.logItem}>
|
|
<View style={styles.logHeader}>
|
|
<View style={styles.logInfo}>
|
|
<Ionicons name={getLevelIcon(item.level)} size={16} color={levelColor} />
|
|
<Text style={[styles.logLevel, { color: levelColor }]}>
|
|
{item.level.toUpperCase()}
|
|
</Text>
|
|
<Text style={styles.logCategory}>[{item.category}]</Text>
|
|
<Text style={styles.logTime}>{time}</Text>
|
|
</View>
|
|
</View>
|
|
<Text style={styles.logMessage}>{item.message}</Text>
|
|
{item.data && (
|
|
<Text style={styles.logData}>
|
|
{typeof item.data === 'object' ? JSON.stringify(item.data, null, 2) : String(item.data)}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<SafeAreaView style={styles.container} edges={['top']}>
|
|
{/* Header */}
|
|
<View style={styles.header}>
|
|
<Text style={styles.headerTitle}>Debug Console</Text>
|
|
<View style={styles.headerButtons}>
|
|
<TouchableOpacity style={styles.headerButton} onPress={handleExport}>
|
|
<Ionicons name="share-outline" size={22} color={AppColors.primary} />
|
|
</TouchableOpacity>
|
|
<TouchableOpacity style={styles.headerButton} onPress={handleClear}>
|
|
<Ionicons name="trash-outline" size={22} color={AppColors.error || '#E53935'} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
|
|
{/* TTS Test Button */}
|
|
<View style={styles.ttsTestContainer}>
|
|
{/* TTS Status Indicator */}
|
|
<View style={styles.ttsStatusRow}>
|
|
<Ionicons name={ttsStatus.icon} size={16} color={ttsStatus.color} />
|
|
<Text style={[styles.ttsStatusText, { color: ttsStatus.color }]}>
|
|
{ttsStatus.text}
|
|
</Text>
|
|
</View>
|
|
|
|
<TouchableOpacity
|
|
style={[
|
|
styles.testButton,
|
|
!ttsState.initialized && styles.testButtonDisabled
|
|
]}
|
|
onPress={handleTestTTS}
|
|
disabled={!ttsState.initialized}
|
|
>
|
|
<Ionicons
|
|
name={ttsState.speaking ? "stop-circle" : "volume-high"}
|
|
size={20}
|
|
color={ttsState.initialized ? AppColors.white : AppColors.textMuted}
|
|
/>
|
|
<Text
|
|
style={[
|
|
styles.testButtonText,
|
|
!ttsState.initialized && styles.testButtonTextDisabled
|
|
]}
|
|
>
|
|
{ttsState.speaking ? 'Stop' : 'Test Voice'}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
|
|
{/* Stats Bar */}
|
|
<View style={styles.statsBar}>
|
|
<View style={styles.statItem}>
|
|
<Text style={styles.statLabel}>Total:</Text>
|
|
<Text style={styles.statValue}>{logs.length}</Text>
|
|
</View>
|
|
<View style={styles.statItem}>
|
|
<Text style={[styles.statLabel, { color: AppColors.error || '#E53935' }]}>Errors:</Text>
|
|
<Text style={[styles.statValue, { color: AppColors.error || '#E53935' }]}>
|
|
{logs.filter(l => l.level === 'error').length}
|
|
</Text>
|
|
</View>
|
|
<View style={styles.statItem}>
|
|
<Text style={[styles.statLabel, { color: AppColors.warning || '#FF9800' }]}>Warns:</Text>
|
|
<Text style={[styles.statValue, { color: AppColors.warning || '#FF9800' }]}>
|
|
{logs.filter(l => l.level === 'warn').length}
|
|
</Text>
|
|
</View>
|
|
<View style={styles.statItem}>
|
|
<Text style={[styles.statLabel, { color: AppColors.primary }]}>Filtered:</Text>
|
|
<Text style={[styles.statValue, { color: AppColors.primary }]}>
|
|
{filteredLogs.length}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Category Filter */}
|
|
<View style={styles.filterContainer}>
|
|
<FlatList
|
|
horizontal
|
|
data={categories}
|
|
keyExtractor={(item) => item}
|
|
renderItem={({ item }) => (
|
|
<TouchableOpacity
|
|
style={[
|
|
styles.categoryChip,
|
|
selectedCategory === item && styles.categoryChipActive,
|
|
]}
|
|
onPress={() => setSelectedCategory(item)}
|
|
>
|
|
<Text
|
|
style={[
|
|
styles.categoryChipText,
|
|
selectedCategory === item && styles.categoryChipTextActive,
|
|
]}
|
|
>
|
|
{item}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
contentContainerStyle={styles.categoryList}
|
|
showsHorizontalScrollIndicator={false}
|
|
/>
|
|
</View>
|
|
|
|
{/* Search Filter */}
|
|
<View style={styles.searchContainer}>
|
|
<Ionicons name="search" size={20} color={AppColors.textMuted} style={styles.searchIcon} />
|
|
<TextInput
|
|
style={styles.searchInput}
|
|
placeholder="Filter logs..."
|
|
placeholderTextColor={AppColors.textMuted}
|
|
value={filter}
|
|
onChangeText={setFilter}
|
|
/>
|
|
{filter.length > 0 && (
|
|
<TouchableOpacity onPress={() => setFilter('')}>
|
|
<Ionicons name="close-circle" size={20} color={AppColors.textMuted} />
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
|
|
{/* Logs List */}
|
|
<FlatList
|
|
ref={flatListRef}
|
|
data={filteredLogs}
|
|
keyExtractor={(item) => item.id}
|
|
renderItem={renderLog}
|
|
contentContainerStyle={styles.logsList}
|
|
showsVerticalScrollIndicator={true}
|
|
ListEmptyComponent={
|
|
<View style={styles.emptyContainer}>
|
|
<Ionicons name="bug-outline" size={64} color={AppColors.textMuted} />
|
|
<Text style={styles.emptyText}>No logs yet</Text>
|
|
<Text style={styles.emptySubtext}>
|
|
Voice and system logs will appear here
|
|
</Text>
|
|
</View>
|
|
}
|
|
/>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: AppColors.background,
|
|
},
|
|
header: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
paddingHorizontal: Spacing.md,
|
|
paddingVertical: Spacing.sm,
|
|
backgroundColor: AppColors.surface,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: AppColors.border,
|
|
},
|
|
headerTitle: {
|
|
fontSize: FontSizes.xl,
|
|
fontWeight: '600',
|
|
color: AppColors.textPrimary,
|
|
},
|
|
headerButtons: {
|
|
flexDirection: 'row',
|
|
gap: Spacing.sm,
|
|
},
|
|
headerButton: {
|
|
padding: Spacing.xs,
|
|
},
|
|
statsBar: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-around',
|
|
paddingVertical: Spacing.sm,
|
|
paddingHorizontal: Spacing.md,
|
|
backgroundColor: AppColors.surface,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: AppColors.border,
|
|
},
|
|
statItem: {
|
|
alignItems: 'center',
|
|
},
|
|
statLabel: {
|
|
fontSize: FontSizes.xs,
|
|
color: AppColors.textSecondary,
|
|
marginBottom: 2,
|
|
},
|
|
statValue: {
|
|
fontSize: FontSizes.lg,
|
|
fontWeight: '600',
|
|
color: AppColors.textPrimary,
|
|
},
|
|
filterContainer: {
|
|
backgroundColor: AppColors.surface,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: AppColors.border,
|
|
},
|
|
categoryList: {
|
|
paddingHorizontal: Spacing.sm,
|
|
paddingVertical: Spacing.xs,
|
|
},
|
|
categoryChip: {
|
|
paddingHorizontal: Spacing.sm + 4,
|
|
paddingVertical: Spacing.xs,
|
|
borderRadius: BorderRadius.full,
|
|
backgroundColor: AppColors.background,
|
|
marginHorizontal: 4,
|
|
borderWidth: 1,
|
|
borderColor: AppColors.border,
|
|
},
|
|
categoryChipActive: {
|
|
backgroundColor: AppColors.primary,
|
|
borderColor: AppColors.primary,
|
|
},
|
|
categoryChipText: {
|
|
fontSize: FontSizes.sm,
|
|
color: AppColors.textSecondary,
|
|
fontWeight: '500',
|
|
},
|
|
categoryChipTextActive: {
|
|
color: AppColors.white,
|
|
},
|
|
searchContainer: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: Spacing.md,
|
|
paddingVertical: Spacing.sm,
|
|
backgroundColor: AppColors.surface,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: AppColors.border,
|
|
},
|
|
searchIcon: {
|
|
marginRight: Spacing.xs,
|
|
},
|
|
searchInput: {
|
|
flex: 1,
|
|
fontSize: FontSizes.base,
|
|
color: AppColors.textPrimary,
|
|
paddingVertical: 0,
|
|
},
|
|
logsList: {
|
|
padding: Spacing.sm,
|
|
},
|
|
logItem: {
|
|
backgroundColor: AppColors.surface,
|
|
borderRadius: BorderRadius.md,
|
|
padding: Spacing.sm,
|
|
marginBottom: Spacing.sm,
|
|
borderLeftWidth: 3,
|
|
borderLeftColor: AppColors.primary,
|
|
},
|
|
logHeader: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
marginBottom: Spacing.xs,
|
|
},
|
|
logInfo: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: Spacing.xs,
|
|
},
|
|
logLevel: {
|
|
fontSize: FontSizes.xs,
|
|
fontWeight: '600',
|
|
},
|
|
logCategory: {
|
|
fontSize: FontSizes.xs,
|
|
color: AppColors.textSecondary,
|
|
fontWeight: '500',
|
|
},
|
|
logTime: {
|
|
fontSize: FontSizes.xs,
|
|
color: AppColors.textMuted,
|
|
},
|
|
logMessage: {
|
|
fontSize: FontSizes.sm,
|
|
color: AppColors.textPrimary,
|
|
lineHeight: 20,
|
|
},
|
|
logData: {
|
|
fontSize: FontSizes.xs,
|
|
color: AppColors.textSecondary,
|
|
marginTop: Spacing.xs,
|
|
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
|
|
},
|
|
emptyContainer: {
|
|
flex: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
paddingTop: 100,
|
|
},
|
|
emptyText: {
|
|
fontSize: FontSizes.lg,
|
|
fontWeight: '600',
|
|
color: AppColors.textSecondary,
|
|
marginTop: Spacing.md,
|
|
},
|
|
emptySubtext: {
|
|
fontSize: FontSizes.sm,
|
|
color: AppColors.textMuted,
|
|
marginTop: Spacing.xs,
|
|
},
|
|
ttsTestContainer: {
|
|
paddingHorizontal: Spacing.md,
|
|
paddingVertical: Spacing.sm,
|
|
backgroundColor: AppColors.surface,
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: AppColors.border,
|
|
gap: Spacing.xs,
|
|
},
|
|
testButton: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: AppColors.primary,
|
|
paddingVertical: Spacing.sm,
|
|
paddingHorizontal: Spacing.md,
|
|
borderRadius: BorderRadius.md,
|
|
gap: Spacing.xs,
|
|
},
|
|
testButtonText: {
|
|
fontSize: FontSizes.base,
|
|
fontWeight: '600',
|
|
color: AppColors.white,
|
|
},
|
|
testButtonDisabled: {
|
|
backgroundColor: AppColors.border,
|
|
opacity: 0.6,
|
|
},
|
|
testButtonTextDisabled: {
|
|
color: AppColors.textMuted,
|
|
},
|
|
ttsStatusRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: Spacing.xs,
|
|
},
|
|
ttsStatusText: {
|
|
fontSize: FontSizes.sm,
|
|
fontWeight: '500',
|
|
},
|
|
});
|