diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 246399693..a5060b83f 100644 Binary files a/.claude/CLAUDE.md and b/.claude/CLAUDE.md differ diff --git a/packages/voice-mobile/app/index.tsx b/packages/voice-mobile/app/index.tsx index a26f8c753..87b3762d3 100644 --- a/packages/voice-mobile/app/index.tsx +++ b/packages/voice-mobile/app/index.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, useRef } from 'react'; -import { View, ScrollView, KeyboardAvoidingView, Platform, Pressable, Text, TextInput, Keyboard } from 'react-native'; +import { View, Pressable, Text, TextInput, Platform, KeyboardAvoidingView, ScrollView, Animated } from 'react-native'; import { router } from 'expo-router'; import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -12,6 +12,7 @@ function generateMessageId(): string { import { useWebSocket } from '@/hooks/use-websocket'; import { useAudioRecorder } from '@/hooks/use-audio-recorder'; import { useAudioPlayer } from '@/hooks/use-audio-player'; +import { useRealtimeAudio } from '@/hooks/use-realtime-audio'; import { useSettings } from '@/hooks/use-settings'; import { ConnectionStatus } from '@/components/connection-status'; import { UserMessage, AssistantMessage, ActivityLog, ToolCall } from '@/components/message'; @@ -19,7 +20,7 @@ import { ArtifactDrawer, type Artifact } from '@/components/artifact-drawer'; import { ActiveProcesses } from '@/components/active-processes'; import { AgentStreamView } from '@/components/agent-stream-view'; import { ConversationSelector } from '@/components/conversation-selector'; -import { MaterialIcons } from '@expo/vector-icons'; +import { Settings, Mic, ArrowUp, Square, AudioLines } from 'lucide-react-native'; import type { ActivityLogPayload, SessionInboundMessage, @@ -91,10 +92,66 @@ export default function VoiceAssistantScreen() { const { settings, isLoading: settingsLoading } = useSettings(); const [conversationId, setConversationId] = useState(null); const ws = useWebSocket(settings.serverUrl, conversationId); + + // Realtime mode state (defined early so we can use it in audioRecorder) + const [isRealtimeMode, setIsRealtimeMode] = useState(false); + const [isVADActive, setIsVADActive] = useState(false); + const pulseAnim = useRef(new Animated.Value(1)).current; + const audioRecorder = useAudioRecorder(); const audioPlayer = useAudioPlayer({ useSpeaker: settings.useSpeaker }); const insets = useSafeAreaInsets(); + // Realtime audio with VAD + const realtimeAudio = useRealtimeAudio({ + onSpeechStart: () => { + console.log('[App] Realtime speech started'); + setIsVADActive(true); + // Pause audio playback if playing + if (isPlayingAudio) { + audioPlayer.pause(); + } + }, + onSpeechEnd: () => { + console.log('[App] Realtime speech ended'); + setIsVADActive(false); + }, + onAudioSegment: async (audioData: string, format: string) => { + console.log('[App] Received audio segment, length:', audioData.length); + + // Send audio segment to server + try { + ws.send({ + type: 'session', + message: { + type: 'audio_chunk', + audio: audioData, + format: format, + isLast: true, + }, + }); + console.log('[App] Sent audio segment to server'); + } catch (error) { + console.error('[App] Failed to send audio segment:', error); + } + }, + onError: (error) => { + console.error('[App] Realtime audio error:', error); + setMessages((prev) => [ + ...prev, + { + type: 'activity', + id: generateMessageId(), + timestamp: Date.now(), + activityType: 'error', + message: `Realtime audio error: ${error.message}`, + }, + ]); + }, + speechThreshold: 0.5, + silenceDuration: 1000, + }); + const [messages, setMessages] = useState([]); const [currentAssistantMessage, setCurrentAssistantMessage] = useState(''); const [isProcessingAudio, setIsProcessingAudio] = useState(false); @@ -117,8 +174,33 @@ export default function VoiceAssistantScreen() { const scrollViewRef = useRef(null); - // Keep screen awake if setting is enabled + // Pulse animation for VAD indicator useEffect(() => { + if (isVADActive) { + Animated.loop( + Animated.sequence([ + Animated.timing(pulseAnim, { + toValue: 1.3, + duration: 400, + useNativeDriver: true, + }), + Animated.timing(pulseAnim, { + toValue: 1, + duration: 400, + useNativeDriver: true, + }), + ]) + ).start(); + } else { + pulseAnim.stopAnimation(); + pulseAnim.setValue(1); + } + }, [isVADActive, pulseAnim]); + + // Keep screen awake if setting is enabled (mobile only) + useEffect(() => { + if (Platform.OS === 'web') return; + if (settings.keepScreenOn) { activateKeepAwakeAsync('voice-assistant'); } else { @@ -653,7 +735,6 @@ export default function VoiceAssistantScreen() { // Clear input and reset streaming state setUserInput(''); setCurrentAssistantMessage(''); - Keyboard.dismiss(); } function handleCancel() { @@ -710,6 +791,64 @@ export default function VoiceAssistantScreen() { } } + // Realtime mode toggle handler + async function handleRealtimeToggle() { + const newRealtimeMode = !isRealtimeMode; + + if (newRealtimeMode) { + // Start realtime mode + try { + await realtimeAudio.start(); + setIsRealtimeMode(true); + console.log('[App] Realtime mode enabled'); + + setMessages((prev) => [ + ...prev, + { + type: 'activity', + id: generateMessageId(), + timestamp: Date.now(), + activityType: 'success', + message: 'Realtime mode started - speak anytime!', + }, + ]); + } catch (error: any) { + console.error('[App] Failed to start realtime mode:', error); + setMessages((prev) => [ + ...prev, + { + type: 'activity', + id: generateMessageId(), + timestamp: Date.now(), + activityType: 'error', + message: `Failed to start realtime mode: ${error.message}`, + }, + ]); + } + } else { + // Stop realtime mode + try { + await realtimeAudio.stop(); + setIsRealtimeMode(false); + setIsVADActive(false); + console.log('[App] Realtime mode disabled'); + + setMessages((prev) => [ + ...prev, + { + type: 'activity', + id: generateMessageId(), + timestamp: Date.now(), + activityType: 'info', + message: 'Realtime mode stopped', + }, + ]); + } catch (error: any) { + console.error('[App] Failed to stop realtime mode:', error); + } + } + } + // Conversation selection handler function handleSelectConversation(newConversationId: string | null) { // Clear all state @@ -767,46 +906,48 @@ export default function VoiceAssistantScreen() { - - {/* Connection status header with buttons */} - - - - - - - - router.push('/settings')} - className="bg-zinc-800 p-3 rounded-lg" - > - - - + {/* Connection status header with buttons */} + + + + + + + + router.push('/settings')} + className="bg-zinc-800 p-3 rounded-lg" + > + + + - {/* Active processes bar */} - + {/* Active processes bar */} + - {/* Messages */} - + {/* Messages */} + {messages.map((msg) => { if (msg.type === 'user') { return ( @@ -865,10 +1006,15 @@ export default function VoiceAssistantScreen() { isStreaming={true} /> )} - + - {/* Input area */} - + {/* Input area */} + {/* Text input */} {/* Buttons */} - - {/* Realtime mode button (placeholder) */} + + {/* Realtime mode button */} - - - - + + + {/* Main action button */} {isInProgress ? ( - - - - + ) : isRecording ? ( - + ) : userInput.trim() ? ( - ▶ + ) : ( - - - - + )} - - - {/* Artifact drawer */} - + + {/* Artifact drawer */} + ); } diff --git a/packages/voice-mobile/components/artifact-drawer.tsx b/packages/voice-mobile/components/artifact-drawer.tsx index 26ac84a25..2328167f8 100644 --- a/packages/voice-mobile/components/artifact-drawer.tsx +++ b/packages/voice-mobile/components/artifact-drawer.tsx @@ -1,4 +1,5 @@ import { View, Text, ScrollView, Pressable, Modal } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; import { useEffect } from 'react'; export interface Artifact { @@ -42,9 +43,9 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) { presentationStyle="pageSheet" onRequestClose={onClose} > - + {/* Header */} - + @@ -120,7 +121,7 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) { - + ); } diff --git a/packages/voice-mobile/components/conversation-selector.tsx b/packages/voice-mobile/components/conversation-selector.tsx index 9cc7f9d2d..4d0d2bfc2 100644 --- a/packages/voice-mobile/components/conversation-selector.tsx +++ b/packages/voice-mobile/components/conversation-selector.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react'; -import { Modal, View, Text, Pressable, ScrollView, Alert } from 'react-native'; -import { MaterialIcons } from '@expo/vector-icons'; +import { Modal, View, Text, Pressable, ScrollView, Alert, KeyboardAvoidingView, Platform } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { MessageSquare, X, Plus, Trash2 } from 'lucide-react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; import type { UseWebSocketReturn } from '../hooks/use-websocket'; @@ -30,7 +31,7 @@ export function ConversationSelector({ // Listen for conversation list responses useEffect(() => { const unsubscribe = websocket.on('list_conversations_response', (message) => { - console.log('[ConversationSelector] Received conversations:', message.payload.conversations.length); + if (message.type !== 'list_conversations_response') return; setConversations(message.payload.conversations); setIsLoading(false); }); @@ -41,6 +42,7 @@ export function ConversationSelector({ // Listen for delete conversation responses useEffect(() => { const unsubscribe = websocket.on('delete_conversation_response', (message) => { + if (message.type !== 'delete_conversation_response') return; console.log('[ConversationSelector] Delete response:', message.payload); if (message.payload.success) { // Refresh conversations list @@ -177,7 +179,7 @@ export function ConversationSelector({ onPress={() => setIsOpen(true)} className="bg-zinc-800 px-4 py-2 rounded-lg" > - + setIsOpen(false)} > - + setIsOpen(false)} /> - - {/* Header */} - - Conversations + + + {/* Header */} + + + Conversations {conversations.length > 0 && `(${conversations.length})`} + setIsOpen(false)}> - + @@ -206,62 +211,77 @@ export function ConversationSelector({ onPress={handleNewConversation} className="flex-row items-center justify-center gap-2 bg-zinc-800 py-3 rounded-lg" > - + New Conversation {/* Conversations List */} - - {isLoading ? ( + + {isLoading && ( Loading... - ) : conversations.length > 0 ? ( - <> - {conversations.map((conversation) => ( - handleSelectConversation(conversation.id)} - className={`flex-row items-center justify-between p-4 border-b border-zinc-800 ${ - conversation.id === currentConversationId ? 'bg-zinc-800' : '' - }`} - > - - - {conversation.messageCount} messages - - - {formatDate(conversation.lastUpdated)} - - - handleDeleteConversation(conversation.id)} - className="p-2" - hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} - > - - - - ))} + )} - {/* Clear All Button */} - - - - Clear All - - - - ) : ( + {!isLoading && conversations.length === 0 && ( No saved conversations )} + + {!isLoading && conversations.map((conversation) => ( + + handleSelectConversation(conversation.id)} + > + + {conversation.messageCount} messages + + + {formatDate(conversation.lastUpdated)} + + + handleDeleteConversation(conversation.id)} + style={{ padding: 8 }} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + > + + + + ))} + + {!isLoading && conversations.length > 0 && ( + + + + Clear All + + + )} + diff --git a/packages/voice-mobile/components/message.tsx b/packages/voice-mobile/components/message.tsx index cae7a7514..e4345223a 100644 --- a/packages/voice-mobile/components/message.tsx +++ b/packages/voice-mobile/components/message.tsx @@ -1,5 +1,7 @@ import { View, Text, Pressable, Animated } from 'react-native'; import { useState, useEffect, useRef } from 'react'; +import Markdown from 'react-native-markdown-display'; +import { Circle, Info, CheckCircle, XCircle, FileText, ChevronRight, ChevronDown, RefreshCw } from 'lucide-react-native'; interface UserMessageProps { message: string; @@ -7,13 +9,10 @@ interface UserMessageProps { } export function UserMessage({ message, timestamp }: UserMessageProps) { - const time = new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - return ( - {message} - {time} + {message} ); @@ -26,7 +25,6 @@ interface AssistantMessageProps { } export function AssistantMessage({ message, timestamp, isStreaming = false }: AssistantMessageProps) { - const time = new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); const fadeAnim = useRef(new Animated.Value(0.3)).current; useEffect(() => { @@ -51,19 +49,69 @@ export function AssistantMessage({ message, timestamp, isStreaming = false }: As } }, [isStreaming, fadeAnim]); + const markdownStyles = { + body: { + color: '#f0fdfa', + fontSize: 16, + lineHeight: 24, + }, + paragraph: { + marginTop: 0, + marginBottom: 8, + }, + strong: { + fontWeight: '700', + }, + em: { + fontStyle: 'italic', + }, + code_inline: { + backgroundColor: '#134e4a', + color: '#ccfbf1', + paddingHorizontal: 4, + paddingVertical: 2, + borderRadius: 3, + fontFamily: 'monospace', + }, + code_block: { + backgroundColor: '#134e4a', + color: '#ccfbf1', + padding: 12, + borderRadius: 6, + fontFamily: 'monospace', + fontSize: 14, + }, + fence: { + backgroundColor: '#134e4a', + color: '#ccfbf1', + padding: 12, + borderRadius: 6, + fontFamily: 'monospace', + fontSize: 14, + }, + link: { + color: '#5eead4', + textDecorationLine: 'underline', + }, + bullet_list: { + marginBottom: 8, + }, + ordered_list: { + marginBottom: 8, + }, + list_item: { + marginBottom: 4, + }, + }; + return ( - - - {message} - - {time} - {isStreaming && ( - - ... - - )} - - + + {message} + {isStreaming && ( + + ... + + )} ); } @@ -92,14 +140,15 @@ export function ActivityLog({ const [isExpanded, setIsExpanded] = useState(false); const typeConfig = { - system: { bg: 'bg-zinc-800/50', text: 'text-zinc-400', icon: '●' }, - info: { bg: 'bg-blue-900/30', text: 'text-blue-400', icon: 'i' }, - success: { bg: 'bg-green-900/30', text: 'text-green-400', icon: '✓' }, - error: { bg: 'bg-red-900/30', text: 'text-red-400', icon: '✗' }, - artifact: { bg: 'bg-blue-900/40', text: 'text-blue-300', icon: '📋' }, + system: { bg: 'bg-zinc-800/50', color: '#a1a1aa', Icon: Circle }, + info: { bg: 'bg-blue-900/30', color: '#60a5fa', Icon: Info }, + success: { bg: 'bg-green-900/30', color: '#4ade80', Icon: CheckCircle }, + error: { bg: 'bg-red-900/30', color: '#f87171', Icon: XCircle }, + artifact: { bg: 'bg-blue-900/40', color: '#93c5fd', Icon: FileText }, }; const config = typeConfig[type]; + const IconComponent = config.Icon; const handlePress = () => { if (type === 'artifact' && artifactId && onArtifactClick) { @@ -123,19 +172,19 @@ export function ActivityLog({ > - - {config.icon} - + - + {displayMessage} {metadata && ( - Details - - {isExpanded ? '▼' : '▶'} - + Details + {isExpanded ? ( + + ) : ( + + )} )} @@ -188,22 +237,19 @@ export function ToolCall({ toolName, args, result, error, status }: ToolCallProp executing: { border: 'border-amber-500', bg: 'bg-amber-500/20', - text: 'text-amber-300', - icon: '⟳', + color: '#fcd34d', label: 'executing', }, completed: { border: 'border-green-500', bg: 'bg-green-500/20', - text: 'text-green-300', - icon: '✓', + color: '#86efac', label: 'completed', }, failed: { border: 'border-red-500', bg: 'bg-red-500/20', - text: 'text-red-300', - icon: '✗', + color: '#fca5a5', label: 'failed', }, }; @@ -217,26 +263,25 @@ export function ToolCall({ toolName, args, result, error, status }: ToolCallProp > - - {isExpanded ? '▼' : '▶'} - + {isExpanded ? ( + + ) : ( + + )} {toolName} {status === 'executing' ? ( - - {config.icon} - + + + + ) : status === 'completed' ? ( + ) : ( - - {config.icon} - + )} - + {config.label} diff --git a/packages/voice-mobile/hooks/use-audio-recorder.ts b/packages/voice-mobile/hooks/use-audio-recorder.ts index cd591d8b4..f3bebf5a3 100644 --- a/packages/voice-mobile/hooks/use-audio-recorder.ts +++ b/packages/voice-mobile/hooks/use-audio-recorder.ts @@ -1,11 +1,14 @@ import { useAudioRecorder as useExpoAudioRecorder, RecordingOptions, RecordingPresets, requestRecordingPermissionsAsync, setAudioModeAsync } from 'expo-audio'; import { Paths, File, Directory, FileInfo } from 'expo-file-system'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; export interface AudioCaptureConfig { sampleRate?: number; numberOfChannels?: number; bitRate?: number; + onAudioLevel?: (level: number) => void; + onSpeechSegment?: (audioBlob: Blob) => void; + enableContinuousRecording?: boolean; } /** @@ -133,6 +136,7 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { numberOfChannels: config?.numberOfChannels || 1, bitRate: config?.bitRate || 128000, extension: '.m4a', + isMeteringEnabled: !!config?.onAudioLevel, // Enable metering if callback provided android: { extension: '.m4a', outputFormat: 'mpeg4', @@ -156,6 +160,24 @@ export function useAudioRecorder(config?: AudioCaptureConfig) { const audioRecorder = useExpoAudioRecorder(recordingOptions); + // Monitor audio levels if metering is enabled + useEffect(() => { + if (!config?.onAudioLevel || !isRecording) return; + + const interval = setInterval(() => { + const metering = audioRecorder.state?.metering; + if (metering !== undefined && metering !== null) { + // Normalize metering value (typically ranges from -160 to 0 dB) + // Convert to 0-1 range where 0 is silence and 1 is loud + // We'll use -40 dB as the threshold for "loud" + const normalized = Math.max(0, Math.min(1, (metering + 40) / 40)); + config.onAudioLevel(normalized); + } + }, 100); // Check every 100ms + + return () => clearInterval(interval); + }, [isRecording, config, audioRecorder.state?.metering]); + async function start(): Promise { if (isRecording) { throw new Error('Already recording'); diff --git a/packages/voice-mobile/hooks/use-realtime-audio.ts b/packages/voice-mobile/hooks/use-realtime-audio.ts new file mode 100644 index 000000000..7ea310c5a --- /dev/null +++ b/packages/voice-mobile/hooks/use-realtime-audio.ts @@ -0,0 +1,269 @@ +import { useState, useEffect, useRef } from 'react'; +import { Platform } from 'react-native'; +import { createVADRNBridgeInstance } from 'react-native-vad'; +import { AudioRecording } from '@siteed/expo-audio-studio'; +import { check, request, PERMISSIONS, RESULTS } from 'react-native-permissions'; + +export interface RealtimeAudioConfig { + onSpeechStart?: () => void; + onSpeechEnd?: () => void; + onAudioSegment?: (audioData: string, format: string) => void; + onError?: (error: Error) => void; + speechThreshold?: number; + silenceDuration?: number; +} + +export interface RealtimeAudio { + start: () => Promise; + stop: () => Promise; + isActive: boolean; + isSpeechDetected: boolean; +} + +/** + * Hook for realtime audio with VAD-based segmentation + * Uses react-native-vad for speech detection and expo-audio-studio for recording + */ +export function useRealtimeAudio(config: RealtimeAudioConfig): RealtimeAudio { + const [isActive, setIsActive] = useState(false); + const [isSpeechDetected, setIsSpeechDetected] = useState(false); + + const vadInstanceRef = useRef(null); + const recordingRef = useRef(null); + const audioBufferRef = useRef([]); + const vadPollingIntervalRef = useRef(null); + const isSpeakingRef = useRef(false); + const silenceStartRef = useRef(null); + + const SPEECH_THRESHOLD = config.speechThreshold ?? 0.5; + const SILENCE_DURATION_MS = config.silenceDuration ?? 1000; // 1 second of silence to end segment + + async function requestMicrophonePermission(): Promise { + try { + const permission = Platform.OS === 'ios' + ? PERMISSIONS.IOS.MICROPHONE + : PERMISSIONS.ANDROID.RECORD_AUDIO; + + const status = await check(permission); + if (status === RESULTS.GRANTED) { + return true; + } + + const result = await request(permission); + return result === RESULTS.GRANTED; + } catch (error) { + console.error('[RealtimeAudio] Permission error:', error); + return false; + } + } + + async function initializeVAD(): Promise { + try { + console.log('[RealtimeAudio] Initializing VAD...'); + const instance = await createVADRNBridgeInstance('realtime-vad', false); + if (!instance) { + throw new Error('Failed to create VAD instance'); + } + + // Create VAD instance with sensitivity + instance.createInstance(0.1, 10); + + // Set license (using the example license from the demo) + await instance.setVADDetectionLicense( + "MTczOTU3MDQwMDAwMA==-+2/cH2HBQz3/SsDidS6qvIgc8KxGH5cbvSVM/6qmk3Q=" + ); + + // Start VAD detection + await instance.startVADDetection(); + + vadInstanceRef.current = instance; + console.log('[RealtimeAudio] VAD initialized successfully'); + } catch (error) { + console.error('[RealtimeAudio] VAD initialization error:', error); + throw error; + } + } + + async function startAudioRecording(): Promise { + try { + console.log('[RealtimeAudio] Starting audio recording...'); + + const recording = new AudioRecording({ + sampleRate: 16000, + channels: 1, + encoding: 'pcm_16bit', + interval: 500, // Get audio chunks every 500ms + }); + + // Handle audio stream chunks + recording.onAudioStream = (event: any) => { + // Only buffer chunks while speech is detected + if (isSpeakingRef.current && event.data) { + audioBufferRef.current.push(event.data); + console.log('[RealtimeAudio] Buffered chunk, total chunks:', audioBufferRef.current.length); + } + }; + + await recording.start(); + recordingRef.current = recording; + console.log('[RealtimeAudio] Audio recording started'); + } catch (error) { + console.error('[RealtimeAudio] Recording start error:', error); + throw error; + } + } + + function startVADPolling(): void { + console.log('[RealtimeAudio] Starting VAD polling...'); + + vadPollingIntervalRef.current = setInterval(async () => { + if (!vadInstanceRef.current) return; + + try { + const voiceProps = await vadInstanceRef.current.getVoiceProps(); + const probability = voiceProps.voiceProbability; + const speechDetected = probability > SPEECH_THRESHOLD; + + // Update UI state + setIsSpeechDetected(speechDetected); + + // Handle speech start + if (speechDetected && !isSpeakingRef.current) { + console.log('[RealtimeAudio] Speech START detected (probability:', probability, ')'); + isSpeakingRef.current = true; + silenceStartRef.current = null; + audioBufferRef.current = []; // Start new buffer + config.onSpeechStart?.(); + } + // Handle potential speech end (silence detected) + else if (!speechDetected && isSpeakingRef.current) { + if (silenceStartRef.current === null) { + // First silence detection - start timer + silenceStartRef.current = Date.now(); + } else { + // Check if silence duration threshold reached + const silenceDuration = Date.now() - silenceStartRef.current; + if (silenceDuration >= SILENCE_DURATION_MS) { + console.log('[RealtimeAudio] Speech END detected (silence for', silenceDuration, 'ms)'); + isSpeakingRef.current = false; + silenceStartRef.current = null; + config.onSpeechEnd?.(); + + // Send buffered audio segment + if (audioBufferRef.current.length > 0) { + const combinedAudio = combineAudioChunks(audioBufferRef.current); + config.onAudioSegment?.(combinedAudio, 'audio/wav'); + audioBufferRef.current = []; + } + } + } + } + // Reset silence timer if speech detected again + else if (speechDetected && isSpeakingRef.current && silenceStartRef.current !== null) { + silenceStartRef.current = null; + } + } catch (error) { + console.error('[RealtimeAudio] VAD polling error:', error); + } + }, 200); // Poll every 200ms + } + + function combineAudioChunks(chunks: string[]): string { + // Combine base64 PCM chunks into a single audio segment + // For now, just concatenate - you may need proper WAV header + console.log('[RealtimeAudio] Combining', chunks.length, 'audio chunks'); + return chunks.join(''); + } + + async function start(): Promise { + if (isActive) { + console.log('[RealtimeAudio] Already active'); + return; + } + + try { + console.log('[RealtimeAudio] Starting realtime audio...'); + + // Request permissions + const hasPermission = await requestMicrophonePermission(); + if (!hasPermission) { + throw new Error('Microphone permission denied'); + } + + // Initialize VAD + await initializeVAD(); + + // Start audio recording + await startAudioRecording(); + + // Start VAD polling + startVADPolling(); + + setIsActive(true); + console.log('[RealtimeAudio] Realtime audio started successfully'); + } catch (error) { + console.error('[RealtimeAudio] Start error:', error); + const err = error instanceof Error ? error : new Error(String(error)); + config.onError?.(err); + + // Cleanup on error + await stop(); + throw error; + } + } + + async function stop(): Promise { + console.log('[RealtimeAudio] Stopping realtime audio...'); + + // Stop VAD polling + if (vadPollingIntervalRef.current) { + clearInterval(vadPollingIntervalRef.current); + vadPollingIntervalRef.current = null; + } + + // Stop recording + if (recordingRef.current) { + try { + await recordingRef.current.stop(); + recordingRef.current = null; + } catch (error) { + console.error('[RealtimeAudio] Error stopping recording:', error); + } + } + + // Stop VAD + if (vadInstanceRef.current) { + try { + await vadInstanceRef.current.stopVADDetection(); + vadInstanceRef.current = null; + } catch (error) { + console.error('[RealtimeAudio] Error stopping VAD:', error); + } + } + + // Reset state + audioBufferRef.current = []; + isSpeakingRef.current = false; + silenceStartRef.current = null; + setIsActive(false); + setIsSpeechDetected(false); + + console.log('[RealtimeAudio] Realtime audio stopped'); + } + + // Cleanup on unmount + useEffect(() => { + return () => { + if (isActive) { + stop(); + } + }; + }, []); + + return { + start, + stop, + isActive, + isSpeechDetected, + }; +} diff --git a/packages/voice-mobile/package-lock.json b/packages/voice-mobile/package-lock.json index c9caf3292..382d2e4d5 100644 --- a/packages/voice-mobile/package-lock.json +++ b/packages/voice-mobile/package-lock.json @@ -13,6 +13,7 @@ "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", + "@siteed/expo-audio-studio": "^2.18.1", "expo": "~54.0.15", "expo-audio": "~1.0.13", "expo-av": "^16.0.7", @@ -31,15 +32,19 @@ "expo-system-ui": "~6.0.7", "expo-updates": "~29.0.12", "expo-web-browser": "~15.0.8", + "lucide-react-native": "^0.546.0", "nativewind": "^5.0.0-preview.2", "react": "19.1.0", "react-dom": "19.1.0", "react-native": "0.81.4", "react-native-css": "^3.0.1", "react-native-gesture-handler": "~2.28.0", + "react-native-markdown-display": "^7.0.2", + "react-native-permissions": "^5.4.2", "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", + "react-native-vad": "^1.0.44", "react-native-web": "~0.21.0", "react-native-worklets": "0.5.1" }, @@ -4523,6 +4528,18 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@siteed/expo-audio-studio": { + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/@siteed/expo-audio-studio/-/expo-audio-studio-2.18.1.tgz", + "integrity": "sha512-gyi02iaj/ZEpMOnFM7jlgXWhy4I6pvu7dK7f95nQgFkuSDogmM/pA/n+lS31ui5HzDbVRle426EpQ1hShVmqDA==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "expo-modules-core": "~2.4.0", + "react": "*", + "react-native": "*" + } + }, "node_modules/@tailwindcss/node": { "version": "4.1.15", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.15.tgz", @@ -6351,6 +6368,13 @@ "node": ">=0.6" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC", + "peer": true + }, "node_modules/bplist-creator": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", @@ -6572,6 +6596,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/camelize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", + "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001751", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz", @@ -7116,6 +7149,15 @@ "node": ">=8" } }, + "node_modules/css-color-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", + "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", + "license": "ISC", + "engines": { + "node": ">=4" + } + }, "node_modules/css-in-js-utils": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz", @@ -7125,6 +7167,71 @@ "hyphenate-style-name": "^1.0.3" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-to-react-native": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", + "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", + "license": "MIT", + "dependencies": { + "camelize": "^1.0.0", + "css-color-keywords": "^1.0.0", + "postcss-value-parser": "^4.0.2" + } + }, + "node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/css-tree/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -7384,6 +7491,63 @@ "node": ">=0.10.0" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "peer": true, + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/dom-serializer/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause", + "peer": true + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, "node_modules/domino": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/domino/-/domino-2.1.6.tgz", @@ -7391,6 +7555,21 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "16.4.7", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", @@ -8714,6 +8893,12 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.0.3.tgz", + "integrity": "sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ==", + "license": "BSD-2-Clause" + }, "node_modules/env-editor": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz", @@ -12811,6 +12996,15 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "node_modules/linkify-it": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.2.0.tgz", + "integrity": "sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw==", + "license": "MIT", + "dependencies": { + "uc.micro": "^1.0.1" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -12971,6 +13165,17 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react-native": { + "version": "0.546.0", + "resolved": "https://registry.npmjs.org/lucide-react-native/-/lucide-react-native-0.546.0.tgz", + "integrity": "sha512-v/qe5JnOHKIhccqfE7Ln78wd0mn5YgmCBvkTX2ca8sWeoym3ZYZcY/mapFsxssarutO4bMI/bRtTqiusGbpOUw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-native": "*", + "react-native-svg": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.19", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", @@ -12997,6 +13202,31 @@ "tmpl": "1.0.5" } }, + "node_modules/markdown-it": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-10.0.0.tgz", + "integrity": "sha512-YWOP1j7UbDNz+TumYP1kpwnP0aEa711cJjrAQrzd0UXlbJfc5aAq0F/PZHjiioqDC1NKgvIMX+o+9Bk7yuM2dg==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "entities": "~2.0.0", + "linkify-it": "^2.0.0", + "mdurl": "^1.0.1", + "uc.micro": "^1.0.5" + }, + "bin": { + "markdown-it": "bin/markdown-it.js" + } + }, + "node_modules/markdown-it/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/marky": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", @@ -13025,6 +13255,19 @@ "is-buffer": "~1.1.6" } }, + "node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "license": "CC0-1.0", + "peer": true + }, + "node_modules/mdurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==", + "license": "MIT" + }, "node_modules/memoize-one": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", @@ -13807,6 +14050,19 @@ "node": ">=10" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/nullthrows": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", @@ -14612,7 +14868,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -14624,7 +14879,6 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, "node_modules/punycode": { @@ -14869,6 +15123,15 @@ "@babel/types": "^7.26.0" } }, + "node_modules/react-native-fit-image": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/react-native-fit-image/-/react-native-fit-image-1.5.5.tgz", + "integrity": "sha512-Wl3Vq2DQzxgsWKuW4USfck9zS7YzhvLNPpkwUUCF90bL32e1a0zOVQ3WsJILJOwzmPdHfzZmWasiiAUNBkhNkg==", + "license": "Beerware", + "dependencies": { + "prop-types": "^15.5.10" + } + }, "node_modules/react-native-gesture-handler": { "version": "2.28.0", "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz", @@ -14894,6 +15157,38 @@ "react-native": "*" } }, + "node_modules/react-native-markdown-display": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/react-native-markdown-display/-/react-native-markdown-display-7.0.2.tgz", + "integrity": "sha512-Mn4wotMvMfLAwbX/huMLt202W5DsdpMO/kblk+6eUs55S57VVNni1gzZCh5qpznYLjIQELNh50VIozEfY6fvaQ==", + "license": "MIT", + "dependencies": { + "css-to-react-native": "^3.0.0", + "markdown-it": "^10.0.0", + "prop-types": "^15.7.2", + "react-native-fit-image": "^1.5.5" + }, + "peerDependencies": { + "react": ">=16.2.0", + "react-native": ">=0.50.4" + } + }, + "node_modules/react-native-permissions": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/react-native-permissions/-/react-native-permissions-5.4.2.tgz", + "integrity": "sha512-XNMoG1fxrB9q73MLn/ZfTaP7pS8qPu0KWypbeFKVTvoR+JJ3O7uedMOTH/mts9bTG+GKhShOoZ+k0CR63q9jwA==", + "license": "MIT", + "peerDependencies": { + "react": ">=18.1.0", + "react-native": ">=0.70.0", + "react-native-windows": ">=0.70.0" + }, + "peerDependenciesMeta": { + "react-native-windows": { + "optional": true + } + } + }, "node_modules/react-native-reanimated": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.1.3.tgz", @@ -14947,6 +15242,32 @@ "react-native": "*" } }, + "node_modules/react-native-svg": { + "version": "15.14.0", + "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.14.0.tgz", + "integrity": "sha512-B3gYc7WztcOT4N54AtUutbe0Nuqqh/nkresY0fAXzUHYLsWuIu/yGiCCD3DKfAs6GLv5LFtWTu7N333Q+e3bkg==", + "license": "MIT", + "peer": true, + "dependencies": { + "css-select": "^5.1.0", + "css-tree": "^1.1.3", + "warn-once": "0.1.1" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-vad": { + "version": "1.0.44", + "resolved": "https://registry.npmjs.org/react-native-vad/-/react-native-vad-1.0.44.tgz", + "integrity": "sha512-n8Ufijqp63EIpfwjiVhaYEGdeFYN0scHvGGe1AgeTYg6ivUam3N5HlbQg1Wwj4OX5BjXkZT558NFckhHMddFzg==", + "license": "MIT", + "peerDependencies": { + "react": "^18.3.1", + "react-native": ">=0.70.1" + } + }, "node_modules/react-native-web": { "version": "0.21.2", "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz", @@ -17087,6 +17408,12 @@ "node": "*" } }, + "node_modules/uc.micro": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", + "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==", + "license": "MIT" + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", diff --git a/packages/voice-mobile/package.json b/packages/voice-mobile/package.json index b170f2ff8..fbc040b76 100644 --- a/packages/voice-mobile/package.json +++ b/packages/voice-mobile/package.json @@ -16,6 +16,7 @@ "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", "@react-navigation/native": "^7.1.8", + "@siteed/expo-audio-studio": "^2.18.1", "expo": "~54.0.15", "expo-audio": "~1.0.13", "expo-av": "^16.0.7", @@ -34,15 +35,19 @@ "expo-system-ui": "~6.0.7", "expo-updates": "~29.0.12", "expo-web-browser": "~15.0.8", + "lucide-react-native": "^0.546.0", "nativewind": "^5.0.0-preview.2", "react": "19.1.0", "react-dom": "19.1.0", "react-native": "0.81.4", "react-native-css": "^3.0.1", "react-native-gesture-handler": "~2.28.0", + "react-native-markdown-display": "^7.0.2", + "react-native-permissions": "^5.4.2", "react-native-reanimated": "~4.1.1", "react-native-safe-area-context": "~5.6.0", "react-native-screens": "~4.16.0", + "react-native-vad": "^1.0.44", "react-native-web": "~0.21.0", "react-native-worklets": "0.5.1" }, @@ -57,7 +62,13 @@ "typescript": "~5.9.2" }, "overrides": { - "lightningcss": "1.30.1" + "lightningcss": "1.30.1", + "react-native-vad": { + "react": "19.1.0" + }, + "@siteed/expo-audio-studio": { + "expo-modules-core": "3.0.22" + } }, "private": true }