wellnua-lite/app/(tabs)/voice-debug.tsx
Sergei dccf05947c Add Voice API type to debug UI and logs
Changes:
- Show API function (voice_ask or ask_wellnuo_ai) in Debug tab
- Update console.log to include API type in transcript log
- Add "API Function" field in Status Card with blue color

Now Debug tab clearly shows which API function is being used, making it easy to verify the Profile settings are working correctly.
2026-01-28 19:53:53 -08:00

519 lines
14 KiB
TypeScript

/**
* Voice Debug Screen
*
* Real-time debugging interface for voice recognition pipeline.
* Shows all events, timers, API calls, and state changes.
*/
import React, { useState, useEffect, useRef, useCallback } from 'react';
import {
View,
Text,
ScrollView,
StyleSheet,
TouchableOpacity,
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Feather } from '@expo/vector-icons';
import { useVoice } from '@/contexts/VoiceContext';
import { useSpeechRecognition } from '@/hooks/useSpeechRecognition';
import { AppColors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
interface LogEntry {
id: string;
timestamp: number;
category: 'stt' | 'api' | 'tts' | 'timer' | 'system';
message: string;
level: 'info' | 'warning' | 'error' | 'success';
data?: any;
}
export default function VoiceDebugScreen() {
const colorScheme = useColorScheme();
const isDark = colorScheme === 'dark';
const insets = useSafeAreaInsets();
const {
isListening,
isSpeaking,
status,
startSession,
stopSession,
voiceApiType,
} = useVoice();
const {
isListening: sttIsListening,
partialTranscript,
recognizedText,
} = useSpeechRecognition({
lang: 'en-US',
continuous: true,
interimResults: true,
});
const [logs, setLogs] = useState<LogEntry[]>([]);
const [silenceTimer, setSilenceTimer] = useState(0);
const scrollViewRef = useRef<ScrollView>(null);
const logIdCounter = useRef(0);
const lastPartialRef = useRef('');
// Add log entry
const addLog = useCallback((
category: LogEntry['category'],
message: string,
level: LogEntry['level'] = 'info',
data?: any
) => {
const entry: LogEntry = {
id: `log-${logIdCounter.current++}`,
timestamp: Date.now(),
category,
message,
level,
data,
};
console.log(`[VoiceDebug:${category}]`, message, data || '');
setLogs(prev => {
const updated = [...prev, entry];
// Keep only last 100 logs
return updated.slice(-100);
});
setTimeout(() => {
scrollViewRef.current?.scrollToEnd({ animated: true });
}, 50);
}, []);
// Clear logs
const clearLogs = useCallback(() => {
setLogs([]);
logIdCounter.current = 0;
addLog('system', 'Logs cleared', 'info');
}, [addLog]);
// Monitor voice session state
useEffect(() => {
if (isListening) {
addLog('system', '🎤 Voice session STARTED', 'success');
} else {
addLog('system', '⏹️ Voice session STOPPED', 'info');
setSilenceTimer(0);
}
}, [isListening, addLog]);
// Monitor STT state
useEffect(() => {
if (sttIsListening) {
addLog('stt', '▶️ STT listening started', 'success');
} else if (isListening) {
addLog('stt', '⏸️ STT stopped (but session active)', 'warning');
}
}, [sttIsListening, isListening, addLog]);
// Monitor status changes
useEffect(() => {
if (status === 'processing') {
addLog('api', '⚙️ Processing transcript → sending to API', 'info');
} else if (status === 'speaking') {
addLog('tts', '🔊 TTS playing (Julia speaking)', 'info');
} else if (status === 'listening') {
addLog('system', '👂 Ready to listen', 'info');
}
}, [status, addLog]);
// Monitor partial transcripts
useEffect(() => {
if (partialTranscript && partialTranscript !== lastPartialRef.current) {
lastPartialRef.current = partialTranscript;
addLog('stt', `📝 Partial: "${partialTranscript.slice(0, 40)}${partialTranscript.length > 40 ? '...' : ''}"`, 'info');
// Reset silence timer
setSilenceTimer(0);
addLog('timer', '🔄 Silence timer RESET', 'warning');
}
}, [partialTranscript, addLog]);
// Monitor final transcripts
useEffect(() => {
if (recognizedText && recognizedText !== lastPartialRef.current) {
addLog('stt', `✅ FINAL: "${recognizedText.slice(0, 40)}${recognizedText.length > 40 ? '...' : ''}"`, 'success', {
length: recognizedText.length,
transcript: recognizedText
});
addLog('api', '📤 Sending to API...', 'info');
}
}, [recognizedText, addLog]);
// Silence timer (only when STT is listening and not processing/speaking)
useEffect(() => {
let interval: NodeJS.Timeout | null = null;
if (sttIsListening && status !== 'processing' && status !== 'speaking') {
interval = setInterval(() => {
setSilenceTimer(prev => {
const next = prev + 100;
// Log milestones
if (next === 1000) {
addLog('timer', '⏱️ Silence: 1.0s', 'info');
} else if (next === 1500) {
addLog('timer', '⏱️ Silence: 1.5s', 'warning');
} else if (next === 2000) {
addLog('timer', '🛑 Silence: 2.0s → AUTO-STOP triggered', 'error');
}
return next;
});
}, 100);
} else {
setSilenceTimer(0);
}
return () => {
if (interval) clearInterval(interval);
};
}, [sttIsListening, status, addLog]);
// Get status indicator
const getStatusDisplay = () => {
if (status === 'speaking' || isSpeaking) {
return { color: '#9333EA', icon: '🔊', text: 'Speaking' };
}
if (status === 'processing') {
return { color: '#F59E0B', icon: '⚙️', text: 'Processing' };
}
if (isListening && sttIsListening) {
return { color: '#10B981', icon: '🟢', text: 'Listening' };
}
if (isListening && !sttIsListening) {
return { color: '#F59E0B', icon: '🟡', text: 'Session Active (STT Off)' };
}
return { color: '#6B7280', icon: '⚪', text: 'Idle' };
};
const statusDisplay = getStatusDisplay();
const silenceProgress = Math.min(silenceTimer / 2000, 1);
const silenceSeconds = (silenceTimer / 1000).toFixed(1);
// Log level colors
const getLogColor = (level: LogEntry['level']) => {
switch (level) {
case 'error': return '#EF4444';
case 'warning': return '#F59E0B';
case 'success': return '#10B981';
default: return isDark ? '#D1D5DB' : '#374151';
}
};
// Category icons
const getCategoryIcon = (category: LogEntry['category']) => {
switch (category) {
case 'stt': return '🎤';
case 'api': return '📡';
case 'tts': return '🔊';
case 'timer': return '⏱️';
case 'system': return '⚙️';
default: return '•';
}
};
return (
<View style={[styles.container, { backgroundColor: isDark ? '#0A0A0A' : '#FFFFFF' }]}>
{/* Header */}
<View style={[styles.header, { paddingTop: insets.top + 16 }]}>
<Text style={[styles.headerTitle, { color: isDark ? '#FFFFFF' : '#000000' }]}>
Voice Debug
</Text>
<TouchableOpacity onPress={clearLogs} style={styles.clearButton}>
<Feather name="trash-2" size={20} color={isDark ? '#9CA3AF' : '#6B7280'} />
</TouchableOpacity>
</View>
{/* Status Card */}
<View style={[styles.statusCard, {
backgroundColor: isDark ? '#1F2937' : '#F3F4F6',
borderColor: statusDisplay.color,
}]}>
<View style={styles.statusRow}>
<Text style={styles.statusIcon}>{statusDisplay.icon}</Text>
<View style={styles.statusTextContainer}>
<Text style={[styles.statusLabel, { color: isDark ? '#9CA3AF' : '#6B7280' }]}>
Status
</Text>
<Text style={[styles.statusText, { color: statusDisplay.color }]}>
{statusDisplay.text}
</Text>
</View>
</View>
{/* Voice API Type */}
<View style={styles.statusRow}>
<Text style={styles.statusIcon}>📡</Text>
<View style={styles.statusTextContainer}>
<Text style={[styles.statusLabel, { color: isDark ? '#9CA3AF' : '#6B7280' }]}>
API Function
</Text>
<Text style={[styles.statusText, { color: '#3B82F6' }]}>
{voiceApiType}
</Text>
</View>
</View>
{/* Silence Timer */}
{sttIsListening && status !== 'processing' && status !== 'speaking' && (
<View style={styles.timerContainer}>
<Text style={[styles.timerLabel, { color: isDark ? '#9CA3AF' : '#6B7280' }]}>
Silence Timer (iOS auto-stop at 2.0s)
</Text>
<View style={styles.timerRow}>
<Text style={[styles.timerText, {
color: silenceTimer >= 2000 ? '#EF4444' : silenceTimer >= 1500 ? '#F59E0B' : isDark ? '#D1D5DB' : '#374151'
}]}>
{silenceSeconds}s / 2.0s
</Text>
</View>
<View style={[styles.progressBarContainer, { backgroundColor: isDark ? '#374151' : '#E5E7EB' }]}>
<View style={[styles.progressBarFill, {
width: `${silenceProgress * 100}%`,
backgroundColor: silenceTimer >= 2000 ? '#EF4444' : silenceTimer >= 1500 ? '#F59E0B' : '#10B981'
}]} />
</View>
</View>
)}
{/* Current Transcripts */}
{partialTranscript && (
<View style={styles.transcriptContainer}>
<Text style={[styles.transcriptLabel, { color: isDark ? '#9CA3AF' : '#6B7280' }]}>
Partial:
</Text>
<Text style={[styles.transcriptText, { color: isDark ? '#F59E0B' : '#D97706' }]}>
"{partialTranscript}"
</Text>
</View>
)}
{recognizedText && (
<View style={styles.transcriptContainer}>
<Text style={[styles.transcriptLabel, { color: isDark ? '#9CA3AF' : '#6B7280' }]}>
Final:
</Text>
<Text style={[styles.transcriptText, { color: isDark ? '#10B981' : '#059669' }]}>
"{recognizedText}"
</Text>
</View>
)}
</View>
{/* Logs */}
<View style={styles.logsContainer}>
<Text style={[styles.logsTitle, { color: isDark ? '#FFFFFF' : '#000000' }]}>
Event Log
</Text>
<ScrollView
ref={scrollViewRef}
style={[styles.logsScrollView, { backgroundColor: isDark ? '#111827' : '#F9FAFB' }]}
contentContainerStyle={styles.logsContent}
>
{logs.length === 0 ? (
<Text style={[styles.emptyText, { color: isDark ? '#6B7280' : '#9CA3AF' }]}>
No events yet. Press FAB to start.
</Text>
) : (
logs.map(log => {
const time = new Date(log.timestamp);
const timeStr = `${String(time.getHours()).padStart(2, '0')}:${String(time.getMinutes()).padStart(2, '0')}:${String(time.getSeconds()).padStart(2, '0')}.${String(time.getMilliseconds()).padStart(3, '0')}`;
return (
<View key={log.id} style={styles.logEntry}>
<Text style={[styles.logTimestamp, { color: isDark ? '#6B7280' : '#9CA3AF' }]}>
{timeStr}
</Text>
<Text style={styles.logIcon}>{getCategoryIcon(log.category)}</Text>
<Text style={[styles.logMessage, { color: getLogColor(log.level) }]}>
{log.message}
</Text>
</View>
);
})
)}
</ScrollView>
</View>
{/* FAB */}
<TouchableOpacity
style={[styles.fab, {
backgroundColor: isListening ? '#EF4444' : AppColors.primary,
bottom: insets.bottom + 80,
}]}
onPress={() => {
if (isListening) {
addLog('system', '🛑 User stopped session', 'warning');
stopSession();
} else {
clearLogs();
addLog('system', '▶️ User started session', 'success');
startSession();
}
}}
>
<Feather
name={isListening ? 'square' : 'mic'}
size={28}
color="#FFFFFF"
/>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingBottom: 16,
},
headerTitle: {
fontSize: 28,
fontWeight: '700',
},
clearButton: {
padding: 8,
},
statusCard: {
marginHorizontal: 20,
marginBottom: 16,
padding: 16,
borderRadius: 12,
borderLeftWidth: 4,
},
statusRow: {
flexDirection: 'row',
alignItems: 'center',
},
statusIcon: {
fontSize: 32,
marginRight: 12,
},
statusTextContainer: {
flex: 1,
},
statusLabel: {
fontSize: 12,
fontWeight: '500',
marginBottom: 2,
},
statusText: {
fontSize: 18,
fontWeight: '700',
},
timerContainer: {
marginTop: 16,
paddingTop: 16,
borderTopWidth: 1,
borderTopColor: 'rgba(156, 163, 175, 0.2)',
},
timerLabel: {
fontSize: 12,
fontWeight: '500',
marginBottom: 8,
},
timerRow: {
marginBottom: 8,
},
timerText: {
fontSize: 24,
fontWeight: '700',
fontVariant: ['tabular-nums'],
},
progressBarContainer: {
height: 8,
borderRadius: 4,
overflow: 'hidden',
},
progressBarFill: {
height: '100%',
borderRadius: 4,
},
transcriptContainer: {
marginTop: 12,
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: 'rgba(156, 163, 175, 0.2)',
},
transcriptLabel: {
fontSize: 12,
fontWeight: '500',
marginBottom: 4,
},
transcriptText: {
fontSize: 14,
fontStyle: 'italic',
},
logsContainer: {
flex: 1,
marginHorizontal: 20,
},
logsTitle: {
fontSize: 16,
fontWeight: '700',
marginBottom: 8,
},
logsScrollView: {
flex: 1,
borderRadius: 8,
},
logsContent: {
padding: 12,
},
emptyText: {
textAlign: 'center',
fontSize: 14,
fontStyle: 'italic',
paddingVertical: 20,
},
logEntry: {
flexDirection: 'row',
marginBottom: 8,
alignItems: 'flex-start',
},
logTimestamp: {
fontSize: 11,
fontVariant: ['tabular-nums'],
marginRight: 8,
width: 80,
},
logIcon: {
fontSize: 14,
marginRight: 6,
},
logMessage: {
fontSize: 13,
flex: 1,
lineHeight: 18,
},
fab: {
position: 'absolute',
right: 20,
width: 64,
height: 64,
borderRadius: 32,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
},
});