Sergei a578ec8081 feat: Pass Debug tab deployment ID to voice calls
- Add debugDeploymentId to BeneficiaryContext for sharing between screens
- Sync Debug tab's deploymentId state with global context
- voice-call.tsx now prioritizes debugDeploymentId when starting calls
- Enables testing voice calls with specific deployment IDs from Debug screen
2026-01-24 00:05:47 -08:00

1404 lines
51 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Debug Screen - Voice Call Testing with Detailed Logs
*
* All-in-one screen for testing Julia AI voice:
* - Start/End call buttons
* - Speaker/Earpiece toggle with logging
* - Real-time logs of all LiveKit events
* - Copy logs button
* - Works on both iOS and Android
*/
import React, { useState, useEffect, useRef, useCallback } from 'react';
import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
Platform,
Share,
AppState,
AppStateStatus,
TextInput,
KeyboardAvoidingView,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import * as Clipboard from 'expo-clipboard';
import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake';
import type { Room as RoomType } from 'livekit-client';
import { AppColors, BorderRadius, FontSizes, Spacing } from '@/constants/theme';
import { getToken, VOICE_NAME } from '@/services/livekitService';
import { api } from '@/services/api';
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
import {
configureAudioForVoiceCall,
stopAudioSession,
} from '@/utils/audioSession';
import {
startVoiceCallService,
stopVoiceCallService,
checkAndPromptBatteryOptimization,
requestNotificationPermission,
} from '@/utils/androidVoiceService';
import Constants from 'expo-constants';
const APP_VERSION = Constants.expoConfig?.version ?? '?.?.?';
type LogEntry = {
id: string;
time: string;
message: string;
type: 'info' | 'success' | 'error' | 'event';
};
type CallState = 'idle' | 'connecting' | 'connected' | 'ending';
export default function DebugScreen() {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [callState, setCallState] = useState<CallState>('idle');
const [callDuration, setCallDuration] = useState(0);
const [agentState, setAgentState] = useState<string>('—'); // listening/thinking/speaking
const [lastUserText, setLastUserText] = useState<string>(''); // Последний распознанный текст пользователя
const [lastAgentText, setLastAgentText] = useState<string>(''); // Последний ответ агента
const [micLevel, setMicLevel] = useState<number>(0); // Уровень микрофона 0-100
const [deploymentId, setDeploymentIdState] = useState<string>(''); // Custom deployment ID
const [loadingBeneficiary, setLoadingBeneficiary] = useState(true);
const [accumulateResponses, setAccumulateResponses] = useState(true); // Накапливать chunks до полного ответа
const flatListRef = useRef<FlatList>(null);
// Refs для накопления chunks
const accumulatedUserTextRef = useRef<string>('');
const accumulatedAgentTextRef = useRef<string>('');
const lastUserSegmentIdRef = useRef<string | null>(null);
const lastAgentSegmentIdRef = useRef<string | null>(null);
const roomRef = useRef<RoomType | null>(null);
const callStartTimeRef = useRef<number | null>(null);
const appStateRef = useRef<AppStateStatus>(AppState.currentState);
const { currentBeneficiary, setDebugDeploymentId } = useBeneficiary();
// Sync deploymentId with context for voice-call.tsx to use
const setDeploymentId = useCallback((id: string) => {
setDeploymentIdState(id);
// Update context so voice-call.tsx can access it
setDebugDeploymentId(id.trim() || null);
}, [setDebugDeploymentId]);
// Load default deployment ID from first beneficiary
useEffect(() => {
const loadDefaultDeploymentId = async () => {
try {
// First check if currentBeneficiary is available
if (currentBeneficiary?.id) {
const id = currentBeneficiary.id.toString();
setDeploymentIdState(id);
setDebugDeploymentId(id); // Also set in context
setLoadingBeneficiary(false);
return;
}
// Otherwise load from API
const response = await api.getAllBeneficiaries();
if (response.ok && response.data && response.data.length > 0) {
const firstBeneficiary = response.data[0];
const id = firstBeneficiary.id.toString();
setDeploymentIdState(id);
setDebugDeploymentId(id); // Also set in context
}
} catch (error) {
console.error('[Debug] Failed to load beneficiary:', error);
} finally {
setLoadingBeneficiary(false);
}
};
loadDefaultDeploymentId();
}, [currentBeneficiary, setDebugDeploymentId]);
// Add log entry
const log = useCallback((message: string, type: LogEntry['type'] = 'info') => {
const time = new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
const ms = String(new Date().getMilliseconds()).padStart(3, '0');
setLogs(prev => [...prev, {
id: `${Date.now()}-${Math.random()}`,
time: `${time}.${ms}`,
message,
type,
}]);
}, []);
// Clear logs
const clearLogs = useCallback(() => {
setLogs([]);
}, []);
// Copy logs to clipboard
const copyLogs = useCallback(async () => {
const text = logs.map(l => `[${l.time}] ${l.message}`).join('\n');
await Clipboard.setStringAsync(text);
log('Logs copied to clipboard!', 'success');
}, [logs, log]);
// Share logs
const shareLogs = useCallback(async () => {
const text = logs.map(l => `[${l.time}] ${l.message}`).join('\n');
try {
await Share.share({ message: text, title: 'Voice Debug Logs' });
} catch (e) {
log(`Share failed: ${e}`, 'error');
}
}, [logs, log]);
// Auto-scroll to bottom
useEffect(() => {
if (logs.length > 0) {
setTimeout(() => flatListRef.current?.scrollToEnd({ animated: true }), 100);
}
}, [logs]);
// Call duration timer
useEffect(() => {
if (callState !== 'connected') return;
const interval = setInterval(() => {
if (callStartTimeRef.current) {
setCallDuration(Math.floor((Date.now() - callStartTimeRef.current) / 1000));
}
}, 1000);
return () => clearInterval(interval);
}, [callState]);
// Handle app background/foreground
useEffect(() => {
const subscription = AppState.addEventListener('change', (nextAppState) => {
if (appStateRef.current.match(/inactive|background/) && nextAppState === 'active') {
log('App returned to foreground', 'event');
} else if (appStateRef.current === 'active' && nextAppState.match(/inactive|background/)) {
log('App went to background - call continues', 'event');
}
appStateRef.current = nextAppState;
});
return () => subscription.remove();
}, [log]);
// Start call
const startCall = useCallback(async () => {
if (callState !== 'idle') return;
clearLogs();
setCallState('connecting');
setCallDuration(0);
callStartTimeRef.current = null;
try {
log('=== STARTING VOICE CALL ===', 'info');
log(`Platform: ${Platform.OS} ${Platform.Version}`, 'info');
// Android: Request notification permission and check battery optimization
if (Platform.OS === 'android') {
log('Android: Requesting notification permission...', 'info');
const notifPermission = await requestNotificationPermission();
log(`Notification permission: ${notifPermission ? 'granted' : 'denied'}`, notifPermission ? 'success' : 'info');
log('Android: Checking battery optimization...', 'info');
const canProceed = await checkAndPromptBatteryOptimization();
if (!canProceed) {
log('User went to battery settings - call postponed', 'info');
setCallState('idle');
return;
}
log('Battery optimization check passed', 'success');
}
// Keep screen awake
await activateKeepAwakeAsync('voiceCall').catch(() => {});
log('Screen keep-awake activated', 'info');
// Step 1: Register WebRTC globals
log('Step 1: Importing @livekit/react-native...', 'info');
const { registerGlobals } = await import('@livekit/react-native');
if (typeof global.RTCPeerConnection === 'undefined') {
log('Registering WebRTC globals...', 'info');
registerGlobals();
log('WebRTC globals registered', 'success');
} else {
log('WebRTC globals already registered', 'info');
}
// Step 2: Import livekit-client
log('Step 2: Importing livekit-client...', 'info');
const { Room, RoomEvent, ConnectionState, Track } = await import('livekit-client');
log('livekit-client imported', 'success');
// Step 3: Configure AudioSession (iOS + Android)
log(`Step 3: Configuring AudioSession for ${Platform.OS}...`, 'info');
try {
await configureAudioForVoiceCall();
log(`AudioSession configured for ${Platform.OS}`, 'success');
} catch (audioErr: any) {
log(`AudioSession config error: ${audioErr?.message || audioErr}`, 'error');
// Continue anyway - might still work
}
// Step 4: Get token from server
log('Step 4: Requesting token from server...', 'info');
log(`Token server: wellnuo.smartlaunchhub.com/julia/token`, 'info');
// Передаём deployment ID если указан
const beneficiaryData = deploymentId.trim() ? {
deploymentId: deploymentId.trim(),
beneficiaryNamesDict: {},
} : undefined;
if (beneficiaryData) {
log(`📋 Using custom Deployment ID: ${deploymentId}`, 'success');
} else {
log(`📋 No Deployment ID specified (default mode)`, 'info');
}
const result = await getToken(`user-${Date.now()}`, beneficiaryData);
if (!result.success || !result.data) {
throw new Error(result.error || 'Failed to get token');
}
const { token, wsUrl, roomName } = result.data;
log(`Token received`, 'success');
log(`Room: ${roomName}`, 'info');
log(`WebSocket URL: ${wsUrl}`, 'info');
// Step 5: Create room and setup listeners
log('Step 5: Creating Room instance...', 'info');
const newRoom = new Room();
roomRef.current = newRoom;
log('Room instance created', 'success');
// Setup ALL event listeners
log('Step 6: Setting up event listeners...', 'info');
newRoom.on(RoomEvent.ConnectionStateChanged, (state: any) => {
log(`EVENT: ConnectionStateChanged → ${state}`, 'event');
if (state === ConnectionState.Connected) {
setCallState('connected');
callStartTimeRef.current = Date.now();
} else if (state === ConnectionState.Disconnected) {
setCallState('idle');
}
});
newRoom.on(RoomEvent.Connected, () => {
log('EVENT: Connected to room', 'success');
});
newRoom.on(RoomEvent.Disconnected, (reason?: any) => {
log(`EVENT: Disconnected. Reason: ${reason || 'unknown'}`, 'event');
});
newRoom.on(RoomEvent.Reconnecting, () => {
log('EVENT: Reconnecting...', 'event');
});
newRoom.on(RoomEvent.Reconnected, () => {
log('EVENT: Reconnected', 'success');
});
newRoom.on(RoomEvent.ParticipantConnected, (participant: any) => {
log(`👋 PARTICIPANT CONNECTED: ${participant.identity}`, 'success');
// Подписаться на события этого участника (для агента Julia)
participant.on('isSpeakingChanged', (speaking: boolean) => {
if (speaking) {
log(`🔊 ${participant.identity} STARTED SPEAKING`, 'success');
setAgentState('speaking');
} else {
log(`🔇 ${participant.identity} stopped speaking`, 'info');
}
});
participant.on('trackMuted', (pub: any) => {
log(`🔇 ${participant.identity} muted ${pub.kind}`, 'event');
});
participant.on('trackUnmuted', (pub: any) => {
log(`🔊 ${participant.identity} unmuted ${pub.kind}`, 'event');
});
participant.on('attributesChanged', (attrs: any) => {
log(`📋 ${participant.identity} ATTRIBUTES:`, 'event');
Object.entries(attrs || {}).forEach(([k, v]) => {
log(` ${k}: ${v}`, 'info');
if (k === 'lk.agent.state') {
setAgentState(String(v));
}
});
});
participant.on('transcriptionReceived', (segments: any[]) => {
log(`🤖 ${participant.identity} TRANSCRIPTION:`, 'success');
segments.forEach((seg: any, i: number) => {
const text = seg.text || seg.final || '';
log(` [${i}] "${text}"`, 'info');
if (text) setLastAgentText(text);
});
});
// Показать текущие атрибуты участника
const attrs = participant.attributes || {};
if (Object.keys(attrs).length > 0) {
log(` Initial attributes: ${JSON.stringify(attrs)}`, 'info');
}
});
newRoom.on(RoomEvent.ParticipantDisconnected, (participant: any) => {
log(`👋 PARTICIPANT DISCONNECTED: ${participant.identity}`, 'event');
});
newRoom.on(RoomEvent.TrackSubscribed, (track: any, publication: any, participant: any) => {
log(`EVENT: Track subscribed: ${track.kind} from ${participant.identity}`, 'event');
if (track.kind === Track.Kind.Audio) {
log('Audio track from Julia AI - should hear voice now', 'success');
}
});
newRoom.on(RoomEvent.TrackUnsubscribed, (track: any, publication: any, participant: any) => {
log(`EVENT: Track unsubscribed: ${track.kind} from ${participant.identity}`, 'event');
});
newRoom.on(RoomEvent.TrackMuted, (publication: any, participant: any) => {
log(`EVENT: Track muted by ${participant.identity}`, 'event');
});
newRoom.on(RoomEvent.TrackUnmuted, (publication: any, participant: any) => {
log(`EVENT: Track unmuted by ${participant.identity}`, 'event');
});
newRoom.on(RoomEvent.ActiveSpeakersChanged, (speakers: any[]) => {
if (speakers.length > 0) {
log(`EVENT: Active speakers: ${speakers.map(s => s.identity).join(', ')}`, 'event');
}
});
newRoom.on(RoomEvent.DataReceived, (payload: any, participant: any, kind: any, topic: any) => {
log(`📩 DATA RECEIVED from ${participant?.identity || 'unknown'}`, 'event');
log(` kind: ${kind}, topic: ${topic || 'none'}`, 'info');
try {
const text = new TextDecoder().decode(payload);
const data = JSON.parse(text);
log(` type: ${data.type || 'unknown'}`, 'info');
// Подробное логирование разных типов сообщений
if (data.type === 'transcript' || data.type === 'transcription') {
log(` 🗣️ TRANSCRIPT: role=${data.role}`, 'success');
const text = data.text || data.content || '';
log(` 📝 TEXT: "${text}"`, 'success');
// Обновить UI
if (data.role === 'user') {
setLastUserText(text);
} else if (data.role === 'assistant' || data.role === 'agent') {
setLastAgentText(text);
}
} else if (data.type === 'state' || data.type === 'agent_state') {
const stateValue = data.state || JSON.stringify(data);
log(` 🤖 AGENT STATE: ${stateValue}`, 'success');
setAgentState(stateValue);
} else if (data.type === 'function_call' || data.type === 'tool_call') {
log(` 🔧 FUNCTION CALL: ${data.name || data.function || JSON.stringify(data)}`, 'event');
} else if (data.type === 'function_result' || data.type === 'tool_result') {
log(` ✅ FUNCTION RESULT: ${JSON.stringify(data.result || data).substring(0, 200)}`, 'event');
} else {
// Показать полный JSON для неизвестных типов
log(` 📦 FULL DATA: ${JSON.stringify(data)}`, 'info');
}
} catch (e) {
// Попробовать показать как текст
try {
const text = new TextDecoder().decode(payload);
log(` 📄 RAW TEXT: "${text.substring(0, 300)}"`, 'info');
} catch {
log(` 📎 BINARY DATA: ${payload.byteLength} bytes`, 'info');
}
}
});
newRoom.on(RoomEvent.AudioPlaybackStatusChanged, () => {
log(`EVENT: AudioPlaybackStatusChanged - canPlay: ${newRoom.canPlaybackAudio}`, 'event');
});
newRoom.on(RoomEvent.MediaDevicesError, (error: any) => {
log(`EVENT: MediaDevicesError: ${error?.message || error}`, 'error');
});
newRoom.on(RoomEvent.RoomMetadataChanged, (metadata: string) => {
log(`EVENT: RoomMetadataChanged: ${metadata}`, 'event');
});
// ===========================================
// TRANSCRIPTION - распознанный текст (STT)
// ===========================================
newRoom.on(RoomEvent.TranscriptionReceived, (segments: any[], participant: any) => {
const isUser = participant?.identity === newRoom.localParticipant.identity;
const who = isUser ? '👤 USER' : '🤖 AGENT';
segments.forEach((segment: any, idx: number) => {
const text = segment.text || segment.final || '';
const segmentId = segment.id || `seg-${Date.now()}`;
const isFinalFlag = segment.final !== undefined;
if (accumulateResponses) {
// === РЕЖИМ НАКОПЛЕНИЯ: Показываем только финальные полные ответы ===
if (isUser) {
// Новый сегмент или продолжение текущего
if (lastUserSegmentIdRef.current !== segmentId) {
// Если был предыдущий финальный - логируем его
if (accumulatedUserTextRef.current && lastUserSegmentIdRef.current) {
log(`👤 USER FINAL: "${accumulatedUserTextRef.current}"`, 'success');
}
accumulatedUserTextRef.current = text;
lastUserSegmentIdRef.current = segmentId;
} else {
// Обновляем текущий сегмент
accumulatedUserTextRef.current = text;
}
// Если финальный - логируем сразу
if (isFinalFlag && text) {
log(`👤 USER: "${text}"`, 'success');
setLastUserText(text);
accumulatedUserTextRef.current = '';
lastUserSegmentIdRef.current = null;
}
} else {
// AGENT
if (lastAgentSegmentIdRef.current !== segmentId) {
if (accumulatedAgentTextRef.current && lastAgentSegmentIdRef.current) {
log(`🤖 AGENT FINAL: "${accumulatedAgentTextRef.current}"`, 'success');
}
accumulatedAgentTextRef.current = text;
lastAgentSegmentIdRef.current = segmentId;
} else {
accumulatedAgentTextRef.current = text;
}
if (isFinalFlag && text) {
log(`🤖 JULIA: "${text}"`, 'success');
setLastAgentText(text);
accumulatedAgentTextRef.current = '';
lastAgentSegmentIdRef.current = null;
}
}
} else {
// === РЕЖИМ ПОЛНОГО ЛОГИРОВАНИЯ: Показываем каждый chunk ===
const finalLabel = isFinalFlag ? '(FINAL)' : '(interim)';
log(`🎤 TRANSCRIPTION from ${who} (${participant?.identity || 'unknown'})`, 'success');
log(` [${idx}] ${finalLabel}: "${text}"`, 'event');
if (segment.id) log(` segment.id: ${segment.id}`, 'info');
if (segment.firstReceivedTime) log(` firstReceivedTime: ${segment.firstReceivedTime}`, 'info');
if (segment.lastReceivedTime) log(` lastReceivedTime: ${segment.lastReceivedTime}`, 'info');
// Обновить UI с последним текстом
if (text && (isFinalFlag || !segment.final)) {
if (isUser) {
setLastUserText(text);
} else {
setLastAgentText(text);
}
}
}
});
});
// ===========================================
// PARTICIPANT ATTRIBUTES - состояние агента
// ===========================================
newRoom.on(RoomEvent.ParticipantAttributesChanged, (changedAttributes: any, participant: any) => {
log(`👤 ATTRIBUTES CHANGED for ${participant?.identity || 'unknown'}`, 'event');
Object.entries(changedAttributes || {}).forEach(([key, value]) => {
log(` ${key}: ${value}`, 'info');
// Особенно важно: lk.agent.state показывает listening/thinking/speaking
if (key === 'lk.agent.state') {
log(` 🤖 AGENT STATE: ${value}`, 'success');
// Обновить UI
setAgentState(String(value));
}
});
// Показать все текущие атрибуты
const attrs = participant?.attributes || {};
if (Object.keys(attrs).length > 0) {
log(` All attributes: ${JSON.stringify(attrs)}`, 'info');
}
});
// ===========================================
// SIGNAL CONNECTED/RECONNECTING
// ===========================================
newRoom.on(RoomEvent.SignalConnected, () => {
log('EVENT: SignalConnected - WebSocket подключен', 'success');
});
newRoom.on(RoomEvent.SignalReconnecting, () => {
log('EVENT: SignalReconnecting - переподключение сигнала...', 'event');
});
// ===========================================
// LOCAL TRACK UNPUBLISHED
// ===========================================
newRoom.on(RoomEvent.LocalTrackUnpublished, (publication: any, participant: any) => {
log(`EVENT: LocalTrackUnpublished - ${publication.trackSid}`, 'event');
});
// ===========================================
// ДОПОЛНИТЕЛЬНЫЕ СОБЫТИЯ ДЛЯ ПОЛНОГО ДЕБАГА
// ===========================================
// Качество соединения
newRoom.on(RoomEvent.ConnectionQualityChanged, (quality: any, participant: any) => {
const qualityEmoji = quality === 'excellent' ? '🟢' : quality === 'good' ? '🟡' : '🔴';
log(`${qualityEmoji} CONNECTION QUALITY: ${participant?.identity || 'local'}${quality}`, 'event');
});
// Изменение устройств (микрофон/камера подключены/отключены)
newRoom.on(RoomEvent.MediaDevicesChanged, () => {
log(`🔌 MEDIA DEVICES CHANGED - устройства обновились`, 'event');
});
// Изменение активного устройства
newRoom.on(RoomEvent.ActiveDeviceChanged, (kind: any, deviceId: any) => {
log(`🎛️ ACTIVE DEVICE CHANGED: ${kind}${deviceId}`, 'event');
});
// Ошибка подписки на трек
newRoom.on(RoomEvent.TrackSubscriptionFailed, (trackSid: any, participant: any, reason: any) => {
log(`❌ TRACK SUBSCRIPTION FAILED: ${trackSid} from ${participant?.identity}`, 'error');
log(` Reason: ${reason}`, 'error');
});
// Публикация трека (когда агент начинает говорить)
newRoom.on(RoomEvent.TrackPublished, (publication: any, participant: any) => {
log(`📢 TRACK PUBLISHED by ${participant?.identity}: ${publication.kind} (${publication.source})`, 'event');
});
// Отмена публикации трека
newRoom.on(RoomEvent.TrackUnpublished, (publication: any, participant: any) => {
log(`📤 TRACK UNPUBLISHED by ${participant?.identity}: ${publication.kind}`, 'event');
});
// Изменение метаданных участника
newRoom.on(RoomEvent.ParticipantMetadataChanged, (metadata: any, participant: any) => {
log(`📋 PARTICIPANT METADATA: ${participant?.identity}`, 'event');
try {
const parsed = JSON.parse(metadata || '{}');
log(` ${JSON.stringify(parsed)}`, 'info');
} catch {
log(` ${metadata}`, 'info');
}
});
// Изменение имени участника
newRoom.on(RoomEvent.ParticipantNameChanged, (name: any, participant: any) => {
log(`👤 PARTICIPANT NAME: ${participant?.identity}${name}`, 'event');
});
// Статус записи (если комната записывается)
newRoom.on(RoomEvent.RecordingStatusChanged, (recording: any) => {
log(`⏺️ RECORDING STATUS: ${recording ? 'RECORDING' : 'NOT RECORDING'}`, recording ? 'success' : 'info');
});
// Изменение статуса потока трека
newRoom.on(RoomEvent.TrackStreamStateChanged, (publication: any, streamState: any, participant: any) => {
log(`📊 TRACK STREAM STATE: ${participant?.identity}/${publication.trackSid}${streamState}`, 'event');
});
// Разрешения на подписку трека
newRoom.on(RoomEvent.TrackSubscriptionPermissionChanged, (publication: any, status: any, participant: any) => {
log(`🔐 TRACK PERMISSION: ${participant?.identity}/${publication.trackSid}${status}`, 'event');
});
// Статус подписки на трек
newRoom.on(RoomEvent.TrackSubscriptionStatusChanged, (publication: any, status: any, participant: any) => {
log(`📶 TRACK SUBSCRIPTION: ${participant?.identity}/${publication.trackSid}${status}`, 'event');
});
// Разрешения участника изменились
newRoom.on(RoomEvent.ParticipantPermissionsChanged, (prevPermissions: any, participant: any) => {
log(`🔑 PARTICIPANT PERMISSIONS CHANGED: ${participant?.identity}`, 'event');
log(` New permissions: ${JSON.stringify(participant?.permissions || {})}`, 'info');
});
// ChatMessage - сообщения в чате комнаты
newRoom.on(RoomEvent.ChatMessage, (message: any, participant: any) => {
log(`💬 CHAT MESSAGE from ${participant?.identity || 'system'}:`, 'success');
log(` ${message.message || JSON.stringify(message)}`, 'info');
});
// SIP DTMF - телефонные сигналы
newRoom.on(RoomEvent.SipDTMFReceived, (dtmf: any, participant: any) => {
log(`📞 SIP DTMF: ${dtmf.code} from ${participant?.identity}`, 'event');
});
// Детекция тишины микрофона
newRoom.on(RoomEvent.LocalAudioSilenceDetected, (publication: any) => {
log(`🔇 LOCAL AUDIO SILENCE DETECTED - микрофон молчит`, 'event');
});
// Изменения буфера DataChannel
newRoom.on(RoomEvent.DCBufferStatusChanged, (isLow: any, kind: any) => {
log(`📦 DC BUFFER: ${kind} buffer is ${isLow ? 'LOW' : 'OK'}`, isLow ? 'event' : 'info');
});
// Метрики производительности
newRoom.on(RoomEvent.MetricsReceived, (metrics: any) => {
log(`📈 METRICS RECEIVED:`, 'info');
if (metrics.audioStats) {
log(` Audio: bitrate=${metrics.audioStats.bitrate}, packetsLost=${metrics.audioStats.packetsLost}`, 'info');
}
if (metrics.videoStats) {
log(` Video: bitrate=${metrics.videoStats.bitrate}, fps=${metrics.videoStats.fps}`, 'info');
}
});
// Статус воспроизведения видео (если есть)
newRoom.on(RoomEvent.VideoPlaybackStatusChanged, () => {
log(`🎬 VIDEO PLAYBACK STATUS CHANGED`, 'event');
});
// Ошибка шифрования
newRoom.on(RoomEvent.EncryptionError, (error: any) => {
log(`🔒 ENCRYPTION ERROR: ${error?.message || error}`, 'error');
});
// Статус шифрования участника
newRoom.on(RoomEvent.ParticipantEncryptionStatusChanged, (encrypted: any, participant: any) => {
log(`🔐 ENCRYPTION STATUS: ${participant?.identity}${encrypted ? 'encrypted' : 'not encrypted'}`, 'event');
});
// Комната перемещена (редко)
newRoom.on(RoomEvent.Moved, (room: any) => {
log(`🚀 ROOM MOVED to new server`, 'event');
});
// Участник стал активным
newRoom.on(RoomEvent.ParticipantActive, (participant: any) => {
log(`✅ PARTICIPANT ACTIVE: ${participant?.identity}`, 'success');
// Проверяем, что это агент Julia (не локальный участник)
const isAgent = participant?.identity?.startsWith('agent-') ||
(participant?.attributes?.['lk.agent_name'] === 'julia-ai');
if (isAgent) {
log(``, 'success');
log(`🟢🟢🟢 AGENT READY 🟢🟢🟢`, 'success');
log(`🔊 Julia will now speak greeting...`, 'success');
log(``, 'success');
}
});
log('Event listeners set up (FULL DEBUG MODE)', 'success');
// Step 7: Connect to room
log('Step 7: Connecting to LiveKit room...', 'info');
await newRoom.connect(wsUrl, token, { autoSubscribe: true });
log('Connected to room', 'success');
// Step 7.5: Start audio playback (required for iOS)
log('Step 7.5: Starting audio playback...', 'info');
await newRoom.startAudio();
log(`Audio playback started, canPlay: ${newRoom.canPlaybackAudio}`, 'success');
// Step 8: Enable microphone
log('Step 8: Enabling microphone...', 'info');
await newRoom.localParticipant.setMicrophoneEnabled(true);
log('Microphone enabled', 'success');
// Step 9: Log local audio track info
log('Step 9: Checking local audio track...', 'info');
const localAudioTracks = newRoom.localParticipant.audioTrackPublications;
log(`Local audio publications: ${localAudioTracks.size}`, 'info');
localAudioTracks.forEach((pub: any) => {
log(`Local audio track: ${pub.trackSid}, muted: ${pub.isMuted}, source: ${pub.source}`, 'info');
if (pub.track) {
log(`Track mediaStreamTrack: ${pub.track.mediaStreamTrack ? 'exists' : 'NULL'}`, 'info');
log(`Track enabled: ${pub.track.mediaStreamTrack?.enabled}`, 'info');
}
});
// ===========================================
// LOCAL PARTICIPANT EVENTS - события моего микрофона
// ===========================================
newRoom.localParticipant.on('localTrackPublished', (pub: any) => {
log(`🎤 MY TRACK PUBLISHED: ${pub.kind} sid=${pub.trackSid}`, 'success');
});
newRoom.localParticipant.on('localTrackUnpublished', (pub: any) => {
log(`🎤 MY TRACK UNPUBLISHED: ${pub.kind} sid=${pub.trackSid}`, 'event');
});
// IsSpeakingChanged - когда я начинаю/перестаю говорить
newRoom.localParticipant.on('isSpeakingChanged', (speaking: boolean) => {
if (speaking) {
log(`🗣️ >>> I STARTED SPEAKING <<<`, 'success');
} else {
log(`🤐 I stopped speaking`, 'info');
}
});
// Мой трек замьютился/размьютился
newRoom.localParticipant.on('trackMuted', (pub: any) => {
log(`🔇 MY TRACK MUTED: ${pub.kind}`, 'event');
});
newRoom.localParticipant.on('trackUnmuted', (pub: any) => {
log(`🔊 MY TRACK UNMUTED: ${pub.kind}`, 'success');
});
// Ошибка медиа устройства на моём участнике
newRoom.localParticipant.on('mediaDevicesError', (error: any) => {
log(`❌ MY MEDIA DEVICE ERROR: ${error?.message || error}`, 'error');
});
// Аудио поток захвачен
newRoom.localParticipant.on('audioStreamAcquired', () => {
log(`🎙️ AUDIO STREAM ACQUIRED - микрофон захвачен!`, 'success');
});
// Транскрипция на моём треке
newRoom.localParticipant.on('transcriptionReceived', (segments: any[]) => {
log(`🎤 MY TRANSCRIPTION (${segments.length} segments):`, 'success');
segments.forEach((seg: any, i: number) => {
log(` [${i}] "${seg.text || seg.final}"`, 'info');
});
});
// Listen when I become an active speaker (means mic is working)
newRoom.on(RoomEvent.ActiveSpeakersChanged, (speakers: any[]) => {
const iAmSpeaking = speakers.some(s => s.identity === newRoom.localParticipant.identity);
if (iAmSpeaking) {
log(`🎙️ *** I AM SPEAKING - MIC WORKS! ***`, 'success');
}
});
log(`Local participant: ${newRoom.localParticipant.identity}`, 'info');
// ===========================================
// AUDIO LEVEL MONITORING - периодическая проверка уровня микрофона
// ===========================================
let audioLevelInterval: ReturnType<typeof setInterval> | null = null;
let lastLoggedLevel = -1;
const startAudioLevelMonitoring = () => {
if (audioLevelInterval) return;
audioLevelInterval = setInterval(() => {
try {
// Найти microphone track среди всех публикаций
const audioTracks = newRoom.localParticipant.audioTrackPublications;
let localAudioTrack: any = null;
audioTracks.forEach((pub: any) => {
if (pub.source === 'microphone' || pub.kind === 'audio') {
localAudioTrack = pub;
}
});
if (localAudioTrack?.track) {
// Получаем audio level через LiveKit API
const audioLevel = (localAudioTrack.track as any).audioLevel;
if (audioLevel !== undefined) {
const roundedLevel = Math.round(audioLevel * 100);
// Обновить UI
setMicLevel(roundedLevel);
// Логируем только когда уровень существенно изменился
if (Math.abs(roundedLevel - lastLoggedLevel) > 5) {
lastLoggedLevel = roundedLevel;
const bars = '▓'.repeat(Math.min(20, Math.round(audioLevel * 20))) + '░'.repeat(Math.max(0, 20 - Math.round(audioLevel * 20)));
log(`🎚️ MIC LEVEL: [${bars}] ${roundedLevel}%`, audioLevel > 0.1 ? 'success' : 'info');
}
}
}
} catch (e) {
// Ignore errors
}
}, 200); // Проверять каждые 200мс для плавного UI
};
// Запустить мониторинг audio level после подключения
newRoom.on(RoomEvent.Connected, () => {
log('Starting audio level monitoring...', 'info');
setTimeout(startAudioLevelMonitoring, 1000);
});
// Остановить при отключении
newRoom.on(RoomEvent.Disconnected, () => {
if (audioLevelInterval) {
clearInterval(audioLevelInterval);
audioLevelInterval = null;
}
});
// Android: Start foreground service to keep call alive in background
if (Platform.OS === 'android') {
log('Android: Starting foreground service...', 'info');
try {
await startVoiceCallService();
log('Foreground service started - call will continue in background', 'success');
} catch (fgErr: any) {
log(`Foreground service error: ${fgErr?.message || fgErr}`, 'error');
// Continue anyway - call will still work, just may be killed in background
}
}
log('=== CALL ACTIVE ===', 'success');
} catch (err: any) {
log(`ERROR: ${err?.message || err}`, 'error');
log(`Stack: ${err?.stack?.substring(0, 200) || 'no stack'}`, 'error');
setCallState('idle');
deactivateKeepAwake('voiceCall');
}
}, [callState, log, clearLogs]);
// End call
const endCall = useCallback(async () => {
if (callState === 'idle') return;
log('=== ENDING CALL ===', 'info');
setCallState('ending');
try {
if (roomRef.current) {
log('Disconnecting from room...', 'info');
await roomRef.current.disconnect();
roomRef.current = null;
log('Disconnected from room', 'success');
}
// Android: Stop foreground service
if (Platform.OS === 'android') {
log('Android: Stopping foreground service...', 'info');
try {
await stopVoiceCallService();
log('Foreground service stopped', 'success');
} catch (fgErr: any) {
log(`Foreground service stop error: ${fgErr?.message || fgErr}`, 'error');
}
}
// Stop AudioSession (iOS + Android)
log(`Stopping AudioSession on ${Platform.OS}...`, 'info');
try {
await stopAudioSession();
log('AudioSession stopped', 'success');
} catch (audioErr: any) {
log(`AudioSession stop error: ${audioErr?.message || audioErr}`, 'error');
}
deactivateKeepAwake('voiceCall');
log('Screen keep-awake deactivated', 'info');
} catch (err: any) {
log(`Error during cleanup: ${err?.message || err}`, 'error');
}
setCallState('idle');
log('=== CALL ENDED ===', 'info');
}, [callState, log]);
// Format duration
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
// Get log color
const getLogColor = (type: LogEntry['type']): string => {
switch (type) {
case 'success': return '#4ade80';
case 'error': return '#f87171';
case 'event': return '#60a5fa';
default: return '#e5e5e5';
}
};
return (
<SafeAreaView style={styles.container} edges={['top']}>
{/* Header */}
<View style={styles.header}>
<View style={styles.headerRow}>
<Text style={styles.title}>Voice Debug</Text>
<Text style={styles.versionBadge}>v{APP_VERSION}</Text>
</View>
<Text style={styles.subtitle}>{VOICE_NAME}</Text>
</View>
{/* Call Status */}
<View style={styles.statusBar}>
<View style={styles.statusLeft}>
<View style={[
styles.statusDot,
{ backgroundColor: callState === 'connected' ? '#4ade80' : callState === 'connecting' ? '#fbbf24' : '#6b7280' }
]} />
<Text style={styles.statusText}>
{callState === 'idle' && 'Ready'}
{callState === 'connecting' && 'Connecting...'}
{callState === 'connected' && `Connected ${formatDuration(callDuration)}`}
{callState === 'ending' && 'Ending...'}
</Text>
</View>
<Text style={styles.logCount}>{logs.length} logs</Text>
</View>
{/* Deployment ID Input */}
{callState === 'idle' && (
<View style={styles.deploymentIdContainer}>
<Text style={styles.deploymentIdLabel}>Deployment ID (optional):</Text>
<TextInput
style={styles.deploymentIdInput}
value={deploymentId}
onChangeText={setDeploymentId}
placeholder="Enter deployment ID..."
placeholderTextColor="#6b7280"
keyboardType="default"
autoCapitalize="none"
autoCorrect={false}
/>
{deploymentId.trim() && (
<TouchableOpacity
style={styles.clearDeploymentId}
onPress={() => setDeploymentId('')}
>
<Ionicons name="close-circle" size={20} color="#6b7280" />
</TouchableOpacity>
)}
</View>
)}
{/* Log Mode Toggle */}
<View style={styles.logModeContainer}>
<Text style={styles.logModeLabel}>Log mode:</Text>
<TouchableOpacity
style={[styles.logModeButton, accumulateResponses && styles.logModeButtonActive]}
onPress={() => setAccumulateResponses(true)}
>
<Text style={[styles.logModeButtonText, accumulateResponses && styles.logModeButtonTextActive]}>
Clean (final only)
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.logModeButton, !accumulateResponses && styles.logModeButtonActive]}
onPress={() => setAccumulateResponses(false)}
>
<Text style={[styles.logModeButtonText, !accumulateResponses && styles.logModeButtonTextActive]}>
Verbose (all chunks)
</Text>
</TouchableOpacity>
</View>
{/* Control Buttons - Row 1: Call controls */}
<View style={styles.controls}>
{callState === 'idle' ? (
<TouchableOpacity style={styles.startButton} onPress={startCall}>
<Ionicons name="call" size={24} color="#fff" />
<Text style={styles.buttonText}>Start Call</Text>
</TouchableOpacity>
) : (
<TouchableOpacity
style={styles.endButton}
onPress={endCall}
disabled={callState === 'ending'}
>
<Ionicons name="call" size={24} color="#fff" style={{ transform: [{ rotate: '135deg' }] }} />
<Text style={styles.buttonText}>End Call</Text>
</TouchableOpacity>
)}
</View>
{/* Control Buttons - Row 2: Log controls */}
<View style={styles.controlsRow2}>
<TouchableOpacity style={styles.copyButton} onPress={copyLogs}>
<Ionicons name="copy" size={20} color="#fff" />
<Text style={styles.smallButtonText}>Copy</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.shareButton} onPress={shareLogs}>
<Ionicons name="share" size={20} color="#fff" />
<Text style={styles.smallButtonText}>Share</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.clearButton} onPress={clearLogs}>
<Ionicons name="trash" size={20} color="#fff" />
<Text style={styles.smallButtonText}>Clear</Text>
</TouchableOpacity>
<View style={styles.platformBadge}>
<Text style={styles.platformText}>{Platform.OS} {Platform.Version}</Text>
</View>
</View>
{/* ========== LIVE STATUS PANEL ========== */}
{callState === 'connected' && (
<View style={styles.liveStatusPanel}>
{/* Agent State */}
<View style={styles.liveStatusRow}>
<Text style={styles.liveStatusLabel}>🤖 Agent:</Text>
<View style={[
styles.agentStateBadge,
agentState === 'speaking' && styles.agentStateSpeaking,
agentState === 'thinking' && styles.agentStateThinking,
agentState === 'listening' && styles.agentStateListening,
]}>
<Text style={styles.agentStateText}>
{agentState === 'speaking' ? '🔊 SPEAKING' :
agentState === 'thinking' ? '🧠 THINKING' :
agentState === 'listening' ? '👂 LISTENING' :
agentState}
</Text>
</View>
</View>
{/* Mic Level */}
<View style={styles.liveStatusRow}>
<Text style={styles.liveStatusLabel}>🎙 Mic:</Text>
<View style={styles.micLevelContainer}>
<View style={[styles.micLevelBar, { width: `${Math.min(100, micLevel)}%` }]} />
</View>
<Text style={styles.micLevelText}>{micLevel}%</Text>
</View>
{/* Last User Text */}
{lastUserText ? (
<View style={styles.liveStatusRow}>
<Text style={styles.liveStatusLabel}>👤 You:</Text>
<Text style={styles.transcriptText} numberOfLines={2}>{lastUserText}</Text>
</View>
) : null}
{/* Last Agent Text */}
{lastAgentText ? (
<View style={styles.liveStatusRow}>
<Text style={styles.liveStatusLabel}>🤖 Julia:</Text>
<Text style={styles.transcriptText} numberOfLines={2}>{lastAgentText}</Text>
</View>
) : null}
</View>
)}
{/* Logs */}
<FlatList
ref={flatListRef}
data={logs}
keyExtractor={(item) => item.id}
style={styles.logsList}
contentContainerStyle={styles.logsContent}
renderItem={({ item }) => (
<Text style={[styles.logEntry, { color: getLogColor(item.type) }]}>
<Text style={styles.logTime}>[{item.time}]</Text> {item.message}
</Text>
)}
ListEmptyComponent={
<View style={styles.emptyContainer}>
<Ionicons name="terminal" size={48} color="#6b7280" />
<Text style={styles.emptyText}>Press "Start Call" to begin</Text>
</View>
}
/>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#0f0f0f',
},
header: {
padding: Spacing.md,
borderBottomWidth: 1,
borderBottomColor: '#333',
},
headerRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
title: {
fontSize: 24,
fontWeight: '700',
color: '#fff',
},
versionBadge: {
fontSize: 14,
fontWeight: '600',
color: '#22c55e',
backgroundColor: 'rgba(34, 197, 94, 0.15)',
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 8,
overflow: 'hidden',
},
subtitle: {
fontSize: 14,
color: '#888',
marginTop: 2,
},
statusBar: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
backgroundColor: '#1a1a1a',
},
statusLeft: {
flexDirection: 'row',
alignItems: 'center',
},
statusDot: {
width: 10,
height: 10,
borderRadius: 5,
marginRight: 8,
},
statusText: {
color: '#fff',
fontSize: 14,
fontWeight: '500',
},
logCount: {
color: '#888',
fontSize: 12,
},
deploymentIdContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.sm,
backgroundColor: '#1f1f1f',
borderBottomWidth: 1,
borderBottomColor: '#333',
},
deploymentIdLabel: {
color: '#9ca3af',
fontSize: 12,
marginRight: 8,
},
deploymentIdInput: {
flex: 1,
backgroundColor: '#2a2a2a',
color: '#fff',
fontSize: 14,
paddingVertical: 8,
paddingHorizontal: 12,
borderRadius: 8,
borderWidth: 1,
borderColor: '#404040',
},
clearDeploymentId: {
marginLeft: 8,
padding: 4,
},
logModeContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: Spacing.md,
paddingVertical: Spacing.xs,
backgroundColor: '#1a1a1a',
gap: 8,
},
logModeLabel: {
color: '#9ca3af',
fontSize: 12,
marginRight: 4,
},
logModeButton: {
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 6,
backgroundColor: '#333',
borderWidth: 1,
borderColor: '#404040',
},
logModeButtonActive: {
backgroundColor: '#3b82f6',
borderColor: '#3b82f6',
},
logModeButtonText: {
color: '#888',
fontSize: 11,
fontWeight: '500',
},
logModeButtonTextActive: {
color: '#fff',
},
controls: {
flexDirection: 'row',
padding: Spacing.md,
paddingBottom: Spacing.sm,
gap: 10,
},
controlsRow2: {
flexDirection: 'row',
paddingHorizontal: Spacing.md,
paddingBottom: Spacing.md,
gap: 10,
borderBottomWidth: 1,
borderBottomColor: '#333',
},
startButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#22c55e',
paddingVertical: 14,
borderRadius: 12,
gap: 8,
},
endButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#ef4444',
paddingVertical: 14,
borderRadius: 12,
gap: 8,
},
buttonText: {
color: '#fff',
fontSize: 16,
fontWeight: '600',
},
copyButton: {
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#3b82f6',
paddingVertical: 10,
paddingHorizontal: 12,
borderRadius: 10,
},
shareButton: {
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#8b5cf6',
paddingVertical: 10,
paddingHorizontal: 12,
borderRadius: 10,
},
clearButton: {
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#6b7280',
paddingVertical: 10,
paddingHorizontal: 12,
borderRadius: 10,
},
platformBadge: {
flex: 1,
alignItems: 'flex-end',
justifyContent: 'center',
},
platformText: {
color: '#888',
fontSize: 11,
fontWeight: '500',
},
smallButtonText: {
color: '#fff',
fontSize: 10,
fontWeight: '500',
marginTop: 2,
},
logsList: {
flex: 1,
},
logsContent: {
padding: Spacing.sm,
paddingBottom: 100,
},
logEntry: {
fontSize: 12,
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
lineHeight: 18,
marginBottom: 2,
},
logTime: {
color: '#888',
},
emptyContainer: {
alignItems: 'center',
justifyContent: 'center',
paddingTop: 100,
},
emptyText: {
color: '#6b7280',
fontSize: 16,
marginTop: 12,
},
// ========== LIVE STATUS PANEL STYLES ==========
liveStatusPanel: {
backgroundColor: '#1a1a1a',
borderBottomWidth: 1,
borderBottomColor: '#333',
padding: Spacing.sm,
gap: 6,
},
liveStatusRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
liveStatusLabel: {
color: '#888',
fontSize: 11,
fontWeight: '600',
width: 55,
},
agentStateBadge: {
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 6,
backgroundColor: '#333',
},
agentStateSpeaking: {
backgroundColor: '#22c55e',
},
agentStateThinking: {
backgroundColor: '#f59e0b',
},
agentStateListening: {
backgroundColor: '#3b82f6',
},
agentStateText: {
color: '#fff',
fontSize: 11,
fontWeight: '700',
},
micLevelContainer: {
flex: 1,
height: 8,
backgroundColor: '#333',
borderRadius: 4,
overflow: 'hidden',
},
micLevelBar: {
height: '100%',
backgroundColor: '#22c55e',
borderRadius: 4,
},
micLevelText: {
color: '#888',
fontSize: 11,
fontWeight: '600',
width: 35,
textAlign: 'right',
},
transcriptText: {
flex: 1,
color: '#e5e5e5',
fontSize: 11,
fontStyle: 'italic',
},
});