mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
feat: implement realtime audio with VAD for Android
- Install react-native-vad and expo-audio-studio for voice detection - Create use-realtime-audio hook for continuous recording + VAD segmentation - Add Android FOREGROUND_SERVICE permissions for background mic access - Update UI components with Lucide icons and markdown rendering - Add npm overrides to resolve peer dependency conflicts with Expo SDK 54 - Integrate realtime mode toggle with pulse animation for speech detection Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
Binary file not shown.
@@ -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<string | null>(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<MessageEntry[]>([]);
|
||||
const [currentAssistantMessage, setCurrentAssistantMessage] = useState('');
|
||||
const [isProcessingAudio, setIsProcessingAudio] = useState(false);
|
||||
@@ -117,8 +174,33 @@ export default function VoiceAssistantScreen() {
|
||||
|
||||
const scrollViewRef = useRef<ScrollView>(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() {
|
||||
<KeyboardAvoidingView
|
||||
behavior="padding"
|
||||
className="flex-1 bg-black"
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? insets.top : 0}
|
||||
>
|
||||
<View className="flex-1">
|
||||
{/* Connection status header with buttons */}
|
||||
<View style={{ paddingTop: insets.top + 16 }}>
|
||||
<View className="flex-row items-center justify-between px-6 pb-4">
|
||||
<View className="flex-1">
|
||||
<ConnectionStatus isConnected={ws.isConnected} />
|
||||
</View>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<ConversationSelector
|
||||
currentConversationId={conversationId}
|
||||
onSelectConversation={handleSelectConversation}
|
||||
websocket={ws}
|
||||
/>
|
||||
<Pressable
|
||||
onPress={() => router.push('/settings')}
|
||||
className="bg-zinc-800 p-3 rounded-lg"
|
||||
>
|
||||
<MaterialIcons name="settings" size={20} color="white" />
|
||||
</Pressable>
|
||||
</View>
|
||||
{/* Connection status header with buttons */}
|
||||
<View style={{ paddingTop: insets.top + 16 }}>
|
||||
<View className="flex-row items-center justify-between px-6 pb-4">
|
||||
<View className="flex-1">
|
||||
<ConnectionStatus isConnected={ws.isConnected} />
|
||||
</View>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<ConversationSelector
|
||||
currentConversationId={conversationId}
|
||||
onSelectConversation={handleSelectConversation}
|
||||
websocket={ws}
|
||||
/>
|
||||
<Pressable
|
||||
onPress={() => router.push('/settings')}
|
||||
className="bg-zinc-800 p-3 rounded-lg"
|
||||
>
|
||||
<Settings size={20} color="white" />
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Active processes bar */}
|
||||
<ActiveProcesses
|
||||
agents={Array.from(agents.values())}
|
||||
commands={Array.from(commands.values())}
|
||||
activeProcessId={activeAgentId}
|
||||
activeProcessType={activeAgentId ? 'agent' : null}
|
||||
onSelectAgent={handleSelectAgent}
|
||||
onBackToOrchestrator={handleBackToOrchestrator}
|
||||
/>
|
||||
{/* Active processes bar */}
|
||||
<ActiveProcesses
|
||||
agents={Array.from(agents.values())}
|
||||
commands={Array.from(commands.values())}
|
||||
activeProcessId={activeAgentId}
|
||||
activeProcessType={activeAgentId ? 'agent' : null}
|
||||
onSelectAgent={handleSelectAgent}
|
||||
onBackToOrchestrator={handleBackToOrchestrator}
|
||||
/>
|
||||
|
||||
{/* Messages */}
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
className="flex-1"
|
||||
contentContainerClassName="pb-4"
|
||||
>
|
||||
{/* Messages */}
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
className="flex-1"
|
||||
contentContainerClassName="pb-4"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="on-drag"
|
||||
>
|
||||
{messages.map((msg) => {
|
||||
if (msg.type === 'user') {
|
||||
return (
|
||||
@@ -865,10 +1006,15 @@ export default function VoiceAssistantScreen() {
|
||||
isStreaming={true}
|
||||
/>
|
||||
)}
|
||||
</ScrollView>
|
||||
</ScrollView>
|
||||
|
||||
{/* Input area */}
|
||||
<View className="pt-4 px-6 border-t border-zinc-800" style={{ paddingBottom: Math.max(insets.bottom, 32) }}>
|
||||
{/* Input area */}
|
||||
<View
|
||||
className="pt-4 px-6 border-t border-zinc-800 bg-black"
|
||||
style={{
|
||||
paddingBottom: Math.max(insets.bottom, 32)
|
||||
}}
|
||||
>
|
||||
{/* Text input */}
|
||||
<TextInput
|
||||
value={userInput}
|
||||
@@ -881,23 +1027,25 @@ export default function VoiceAssistantScreen() {
|
||||
/>
|
||||
|
||||
{/* Buttons */}
|
||||
<View className="flex-row items-center justify-center gap-4">
|
||||
{/* Realtime mode button (placeholder) */}
|
||||
<View className="flex-row items-center justify-end gap-3">
|
||||
{/* Realtime mode button */}
|
||||
<Pressable
|
||||
disabled={true}
|
||||
className="w-16 h-16 rounded-full bg-zinc-800 items-center justify-center opacity-50"
|
||||
onPress={handleRealtimeToggle}
|
||||
disabled={!ws.isConnected}
|
||||
className={`w-12 h-12 rounded-full items-center justify-center ${
|
||||
!ws.isConnected ? 'opacity-50' : 'opacity-100'
|
||||
} ${isRealtimeMode ? 'bg-blue-600' : 'bg-zinc-800'}`}
|
||||
>
|
||||
<View className="w-8 h-8 items-center justify-center">
|
||||
<View className="w-6 h-6 rounded-full border-2 border-white" />
|
||||
<View className="absolute w-3 h-3 rounded-full border-2 border-white" />
|
||||
</View>
|
||||
<Animated.View style={{ transform: [{ scale: pulseAnim }] }}>
|
||||
<AudioLines size={20} color="white" />
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
|
||||
{/* Main action button */}
|
||||
<Pressable
|
||||
onPress={handleButtonClick}
|
||||
disabled={!ws.isConnected}
|
||||
className={`w-20 h-20 rounded-full items-center justify-center ${
|
||||
className={`w-12 h-12 rounded-full items-center justify-center ${
|
||||
!ws.isConnected ? 'opacity-50' : 'opacity-100'
|
||||
} ${
|
||||
isRecording
|
||||
@@ -910,30 +1058,23 @@ export default function VoiceAssistantScreen() {
|
||||
}`}
|
||||
>
|
||||
{isInProgress ? (
|
||||
<View className="relative w-6 h-6">
|
||||
<View className="absolute w-6 h-0.5 bg-white rotate-45" style={{top: 11}} />
|
||||
<View className="absolute w-6 h-0.5 bg-white -rotate-45" style={{top: 11}} />
|
||||
</View>
|
||||
<Square size={18} color="white" fill="white" />
|
||||
) : isRecording ? (
|
||||
<View className="w-4 h-4 bg-white rounded-full" />
|
||||
<Square size={14} color="white" fill="white" />
|
||||
) : userInput.trim() ? (
|
||||
<Text className="text-white text-xl">▶</Text>
|
||||
<ArrowUp size={20} color="white" />
|
||||
) : (
|
||||
<View className="w-6 h-8 relative">
|
||||
<View className="absolute bottom-0 left-1/2 -ml-2 w-4 h-6 bg-white rounded-t-full" />
|
||||
<View className="absolute bottom-0 left-1/2 -ml-3 w-6 h-1.5 bg-white rounded-full" />
|
||||
</View>
|
||||
<Mic size={20} color="white" />
|
||||
)}
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Artifact drawer */}
|
||||
<ArtifactDrawer
|
||||
artifact={currentArtifact}
|
||||
onClose={handleCloseArtifact}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Artifact drawer */}
|
||||
<ArtifactDrawer
|
||||
artifact={currentArtifact}
|
||||
onClose={handleCloseArtifact}
|
||||
/>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<View className="flex-1 bg-black">
|
||||
<SafeAreaView edges={['top', 'bottom']} className="flex-1 bg-black">
|
||||
{/* Header */}
|
||||
<View className="pt-16 pb-4 px-4 border-b border-zinc-800">
|
||||
<View className="pb-4 px-4 border-b border-zinc-800">
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-1 mr-4">
|
||||
<Text className="text-white text-xl font-bold" numberOfLines={2}>
|
||||
@@ -120,7 +121,7 @@ export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<MaterialIcons name="chat-bubble-outline" size={20} color="white" />
|
||||
<MessageSquare size={20} color="white" />
|
||||
</Pressable>
|
||||
|
||||
<Modal
|
||||
@@ -186,17 +188,20 @@ export function ConversationSelector({
|
||||
transparent={true}
|
||||
onRequestClose={() => setIsOpen(false)}
|
||||
>
|
||||
<View className="flex-1 bg-black/50">
|
||||
<View style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||||
<Pressable
|
||||
className="flex-1"
|
||||
style={{ flex: 1 }}
|
||||
onPress={() => setIsOpen(false)}
|
||||
/>
|
||||
<View className="bg-zinc-900 rounded-t-3xl max-h-[80%]">
|
||||
{/* Header */}
|
||||
<View className="flex-row items-center justify-between p-6 border-b border-zinc-800">
|
||||
<Text className="text-white text-lg font-semibold">Conversations</Text>
|
||||
<View style={{ backgroundColor: '#18181b', borderTopLeftRadius: 24, borderTopRightRadius: 24, height: '80%' }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
{/* Header */}
|
||||
<View className="flex-row items-center justify-between p-6 border-b border-zinc-800">
|
||||
<Text className="text-white text-lg font-semibold">
|
||||
Conversations {conversations.length > 0 && `(${conversations.length})`}
|
||||
</Text>
|
||||
<Pressable onPress={() => setIsOpen(false)}>
|
||||
<MaterialIcons name="close" size={24} color="white" />
|
||||
<X size={24} color="white" />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<MaterialIcons name="add" size={20} color="white" />
|
||||
<Plus size={20} color="white" />
|
||||
<Text className="text-white font-semibold">New Conversation</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Conversations List */}
|
||||
<ScrollView className="flex-1">
|
||||
{isLoading ? (
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{ paddingBottom: 20 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="on-drag"
|
||||
>
|
||||
{isLoading && (
|
||||
<View className="p-8 items-center">
|
||||
<Text className="text-zinc-400">Loading...</Text>
|
||||
</View>
|
||||
) : conversations.length > 0 ? (
|
||||
<>
|
||||
{conversations.map((conversation) => (
|
||||
<Pressable
|
||||
key={conversation.id}
|
||||
onPress={() => handleSelectConversation(conversation.id)}
|
||||
className={`flex-row items-center justify-between p-4 border-b border-zinc-800 ${
|
||||
conversation.id === currentConversationId ? 'bg-zinc-800' : ''
|
||||
}`}
|
||||
>
|
||||
<View className="flex-1">
|
||||
<Text className="text-white font-semibold">
|
||||
{conversation.messageCount} messages
|
||||
</Text>
|
||||
<Text className="text-zinc-400 text-sm mt-1">
|
||||
{formatDate(conversation.lastUpdated)}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={() => handleDeleteConversation(conversation.id)}
|
||||
className="p-2"
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<MaterialIcons name="delete-outline" size={20} color="#ef4444" />
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
))}
|
||||
)}
|
||||
|
||||
{/* Clear All Button */}
|
||||
<View className="p-4">
|
||||
<Pressable
|
||||
onPress={handleClearAll}
|
||||
className="flex-row items-center justify-center gap-2 bg-red-900/20 py-3 rounded-lg border border-red-900"
|
||||
>
|
||||
<MaterialIcons name="delete-outline" size={18} color="#ef4444" />
|
||||
<Text className="text-red-500 font-semibold">Clear All</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
{!isLoading && conversations.length === 0 && (
|
||||
<View className="p-8 items-center">
|
||||
<Text className="text-zinc-400">No saved conversations</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!isLoading && conversations.map((conversation) => (
|
||||
<View
|
||||
key={conversation.id}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#27272a',
|
||||
backgroundColor: conversation.id === currentConversationId ? '#27272a' : 'transparent'
|
||||
}}
|
||||
>
|
||||
<Pressable
|
||||
style={{ flex: 1 }}
|
||||
onPress={() => handleSelectConversation(conversation.id)}
|
||||
>
|
||||
<Text style={{ color: 'white', fontWeight: '600', fontSize: 16 }}>
|
||||
{conversation.messageCount} messages
|
||||
</Text>
|
||||
<Text style={{ color: '#a1a1aa', fontSize: 14, marginTop: 4 }}>
|
||||
{formatDate(conversation.lastUpdated)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => handleDeleteConversation(conversation.id)}
|
||||
style={{ padding: 8 }}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Trash2 size={20} color="#ef4444" />
|
||||
</Pressable>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{!isLoading && conversations.length > 0 && (
|
||||
<View className="p-4">
|
||||
<Pressable
|
||||
onPress={handleClearAll}
|
||||
className="flex-row items-center justify-center gap-2 bg-red-900/20 py-3 rounded-lg border border-red-900"
|
||||
>
|
||||
<Trash2 size={18} color="#ef4444" />
|
||||
<Text className="text-red-500 font-semibold">Clear All</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
|
||||
@@ -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 (
|
||||
<View className="flex-row justify-end mb-3 px-4">
|
||||
<View className="bg-blue-600 rounded-2xl rounded-tr-sm px-4 py-3 max-w-[80%]">
|
||||
<Text className="text-white text-base leading-5">{message}</Text>
|
||||
<Text className="text-blue-200 text-xs mt-1">{time}</Text>
|
||||
<Text className="text-white text-base leading-6">{message}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -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 (
|
||||
<View className="flex-row justify-start mb-3 px-4">
|
||||
<View className="bg-teal-800 rounded-2xl rounded-tl-sm px-4 py-3 max-w-[80%]">
|
||||
<Text className="text-teal-50 text-base leading-5">{message}</Text>
|
||||
<View className="flex-row items-center justify-between mt-1">
|
||||
<Text className="text-teal-200 text-xs">{time}</Text>
|
||||
{isStreaming && (
|
||||
<Animated.View style={{ opacity: fadeAnim }}>
|
||||
<Text className="text-teal-200 text-xs ml-2 font-bold">...</Text>
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<View className="mb-3 px-4 py-3">
|
||||
<Markdown style={markdownStyles}>{message}</Markdown>
|
||||
{isStreaming && (
|
||||
<Animated.View style={{ opacity: fadeAnim }} className="mt-1">
|
||||
<Text className="text-teal-200 text-xs font-bold">...</Text>
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
>
|
||||
<View className="px-3 py-2.5">
|
||||
<View className="flex-row items-start gap-2">
|
||||
<Text className={`${config.text} text-sm leading-5`}>
|
||||
{config.icon}
|
||||
</Text>
|
||||
<IconComponent size={16} color={config.color} />
|
||||
<View className="flex-1">
|
||||
<Text className={`${config.text} text-sm leading-5`}>
|
||||
<Text style={{ color: config.color }} className="text-sm leading-5">
|
||||
{displayMessage}
|
||||
</Text>
|
||||
{metadata && (
|
||||
<View className="flex-row items-center mt-1">
|
||||
<Text className="text-zinc-500 text-xs">Details</Text>
|
||||
<Text className="text-zinc-500 text-xs ml-1">
|
||||
{isExpanded ? '▼' : '▶'}
|
||||
</Text>
|
||||
<Text className="text-zinc-500 text-xs mr-1">Details</Text>
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={12} color="#71717a" />
|
||||
) : (
|
||||
<ChevronRight size={12} color="#71717a" />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -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
|
||||
>
|
||||
<View className="p-3">
|
||||
<View className="flex-row items-center">
|
||||
<Text className="text-zinc-400 text-sm mr-2">
|
||||
{isExpanded ? '▼' : '▶'}
|
||||
</Text>
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={16} color="#9ca3af" className="mr-2" />
|
||||
) : (
|
||||
<ChevronRight size={16} color="#9ca3af" className="mr-2" />
|
||||
)}
|
||||
<Text className="text-slate-200 font-mono font-medium text-sm flex-1">
|
||||
{toolName}
|
||||
</Text>
|
||||
<View className={`flex-row items-center gap-1.5 px-2 py-1 rounded ${config.bg}`}>
|
||||
{status === 'executing' ? (
|
||||
<Animated.Text
|
||||
style={{ transform: [{ rotate: spin }] }}
|
||||
className={`${config.text} text-xs`}
|
||||
>
|
||||
{config.icon}
|
||||
</Animated.Text>
|
||||
<Animated.View style={{ transform: [{ rotate: spin }] }}>
|
||||
<RefreshCw size={14} color={config.color} />
|
||||
</Animated.View>
|
||||
) : status === 'completed' ? (
|
||||
<CheckCircle size={14} color={config.color} />
|
||||
) : (
|
||||
<Text className={`${config.text} text-xs`}>
|
||||
{config.icon}
|
||||
</Text>
|
||||
<XCircle size={14} color={config.color} />
|
||||
)}
|
||||
<Text className={`${config.text} text-xs font-medium uppercase`}>
|
||||
<Text style={{ color: config.color }} className="text-xs font-medium uppercase">
|
||||
{config.label}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -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<void> {
|
||||
if (isRecording) {
|
||||
throw new Error('Already recording');
|
||||
|
||||
269
packages/voice-mobile/hooks/use-realtime-audio.ts
Normal file
269
packages/voice-mobile/hooks/use-realtime-audio.ts
Normal file
@@ -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<void>;
|
||||
stop: () => Promise<void>;
|
||||
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<any>(null);
|
||||
const recordingRef = useRef<AudioRecording | null>(null);
|
||||
const audioBufferRef = useRef<string[]>([]);
|
||||
const vadPollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isSpeakingRef = useRef(false);
|
||||
const silenceStartRef = useRef<number | null>(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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
331
packages/voice-mobile/package-lock.json
generated
331
packages/voice-mobile/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user