refactor: optimize audio initialization and fix agent interactions

- Lazy initialize audio (on-demand instead of mount) in both player and Speechmatics hooks
- Add audio session teardown on stop for proper cleanup
- Implement agent audio recording with sendAgentAudio in AgentInputArea
- Fix Android navigation race condition in CreateAgentModal (navigate before closing)
- Add animations to global footer and agent screen (FadeIn/FadeOut)
- Auto-cancel stuck agents when sending new messages
- Fix permission request ID propagation from AgentManager to Session
- Apply theme background color via AppContainer wrapper

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Mohamed Boudra
2025-10-23 13:15:18 +02:00
co-authored by Claude
parent 10189514dd
commit 74fffcd8a3
11 changed files with 139 additions and 76 deletions
+13 -2
View File
@@ -8,6 +8,17 @@ import { RealtimeProvider } from "@/contexts/realtime-context";
import { useSettings } from "@/hooks/use-settings"; import { useSettings } from "@/hooks/use-settings";
import { View, ActivityIndicator } from "react-native"; import { View, ActivityIndicator } from "react-native";
import { GlobalFooter } from "@/components/global-footer"; import { GlobalFooter } from "@/components/global-footer";
import { useUnistyles } from "react-native-unistyles";
function AppContainer({ children }: { children: React.ReactNode }) {
const { theme } = useUnistyles();
return (
<View style={{ flex: 1, backgroundColor: theme.colors.background }}>
{children}
</View>
);
}
function ProvidersWrapper({ children }: { children: React.ReactNode }) { function ProvidersWrapper({ children }: { children: React.ReactNode }) {
const { settings, isLoading } = useSettings(); const { settings, isLoading } = useSettings();
@@ -41,7 +52,7 @@ export default function RootLayout() {
<KeyboardProvider> <KeyboardProvider>
<BottomSheetModalProvider> <BottomSheetModalProvider>
<ProvidersWrapper> <ProvidersWrapper>
<View style={{ flex: 1 }}> <AppContainer>
<Stack <Stack
screenOptions={{ screenOptions={{
headerShown: false, headerShown: false,
@@ -60,7 +71,7 @@ export default function RootLayout() {
<Stack.Screen name="audio-test" /> <Stack.Screen name="audio-test" />
</Stack> </Stack>
<GlobalFooter /> <GlobalFooter />
</View> </AppContainer>
</ProvidersWrapper> </ProvidersWrapper>
</BottomSheetModalProvider> </BottomSheetModalProvider>
</KeyboardProvider> </KeyboardProvider>
+6 -3
View File
@@ -2,7 +2,7 @@ import { View, Text } from "react-native";
import { useLocalSearchParams } from "expo-router"; import { useLocalSearchParams } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller"; import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import ReanimatedAnimated, { useAnimatedStyle } from "react-native-reanimated"; import ReanimatedAnimated, { useAnimatedStyle, FadeIn } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { BackHeader } from "@/components/headers/back-header"; import { BackHeader } from "@/components/headers/back-header";
import { AgentStreamView } from "@/components/agent-stream-view"; import { AgentStreamView } from "@/components/agent-stream-view";
@@ -61,9 +61,12 @@ export default function AgentScreen() {
/> />
{/* Agent Input - will hide itself when realtime is active */} {/* Agent Input - will hide itself when realtime is active */}
<View style={[styles.inputContainer, { paddingBottom: insets.bottom }]}> <ReanimatedAnimated.View
entering={FadeIn.duration(250).delay(100)}
style={[styles.inputContainer, { paddingBottom: insets.bottom }]}
>
<AgentInputArea agentId={id!} /> <AgentInputArea agentId={id!} />
</View> </ReanimatedAnimated.View>
</ReanimatedAnimated.View> </ReanimatedAnimated.View>
</View> </View>
); );
@@ -13,7 +13,7 @@ interface AgentInputAreaProps {
export function AgentInputArea({ agentId }: AgentInputAreaProps) { export function AgentInputArea({ agentId }: AgentInputAreaProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { agents, ws, sendAgentMessage, setAgentMode } = useSession(); const { agents, ws, sendAgentMessage, sendAgentAudio, setAgentMode } = useSession();
const { isRealtimeMode, startRealtime } = useRealtime(); const { isRealtimeMode, startRealtime } = useRealtime();
const audioRecorder = useAudioRecorder(); const audioRecorder = useAudioRecorder();
@@ -54,10 +54,17 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) {
if (audioData) { if (audioData) {
setIsProcessing(true); setIsProcessing(true);
// TODO: Send audio to agent
// For now, just log it
console.log("[AgentInput] Audio recorded:", audioData.size, "bytes"); console.log("[AgentInput] Audio recorded:", audioData.size, "bytes");
setIsProcessing(false);
try {
// Send audio to agent for transcription and processing
await sendAgentAudio(agentId, audioData);
console.log("[AgentInput] Audio sent to agent");
} catch (error) {
console.error("[AgentInput] Failed to send audio:", error);
} finally {
setIsProcessing(false);
}
} }
} catch (error) { } catch (error) {
console.error("[AgentInput] Failed to stop recording:", error); console.error("[AgentInput] Failed to stop recording:", error);
@@ -131,10 +131,14 @@ export function CreateAgentModal({
console.log("[CreateAgentModal] Agent created:", agentId); console.log("[CreateAgentModal] Agent created:", agentId);
setIsLoading(false); setIsLoading(false);
setPendingRequestId(null); setPendingRequestId(null);
handleClose();
// Navigate to the agent page // Navigate to the agent page BEFORE closing modal
// This prevents race condition on Android where router.push() happens
// while the modal is unmounting, causing NullPointerException
router.push(`/agent/${agentId}`); router.push(`/agent/${agentId}`);
// Close modal after navigation starts
handleClose();
} }
}); });
+25 -12
View File
@@ -1,42 +1,55 @@
import { View, Pressable } from "react-native"; import { View, Pressable } from "react-native";
import { usePathname, useLocalSearchParams } from "expo-router"; import { usePathname } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { AudioLines } from "lucide-react-native"; import { AudioLines } from "lucide-react-native";
import { useRealtime } from "@/contexts/realtime-context"; import { useRealtime } from "@/contexts/realtime-context";
import { useSession } from "@/contexts/session-context"; import { useSession } from "@/contexts/session-context";
import { RealtimeControls } from "./realtime-controls"; import { RealtimeControls } from "./realtime-controls";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
export function GlobalFooter() { export function GlobalFooter() {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const pathname = usePathname(); const pathname = usePathname();
const params = useLocalSearchParams();
const { isRealtimeMode, startRealtime } = useRealtime(); const { isRealtimeMode, startRealtime } = useRealtime();
const { ws } = useSession(); const { ws } = useSession();
// Determine current screen type // Determine current screen type
const isAgentScreen = pathname?.startsWith("/agent/"); const isAgentScreen = pathname?.startsWith("/agent/");
const isOrchestratorScreen = pathname === "/orchestrator";
const isHomeScreen = pathname === "/";
// If realtime is active, always show realtime controls // Determine if footer should be visible
// Hidden when: on agent screen AND realtime is off
const shouldHide = isAgentScreen && !isRealtimeMode;
// If realtime is active, show realtime controls
if (isRealtimeMode) { if (isRealtimeMode) {
return ( return (
<View style={[styles.container, { paddingBottom: insets.bottom }]}> <Animated.View
entering={FadeIn.duration(400)}
exiting={FadeOut.duration(250)}
style={[styles.container, { paddingBottom: insets.bottom }]}
>
<RealtimeControls /> <RealtimeControls />
</View> </Animated.View>
); );
} }
// If on agent screen and realtime is off, hide footer (agent has its own controls) // For home and orchestrator screens, show centered realtime button
if (isAgentScreen) { // On agent screens, don't render at all
if (shouldHide) {
return null; return null;
} }
// For home and orchestrator screens, show centered realtime button
return ( return (
<View style={[styles.container, { paddingBottom: insets.bottom }]}> <Animated.View
entering={FadeIn.duration(250)}
exiting={FadeOut.duration(250)}
style={[
styles.container,
{ paddingBottom: insets.bottom }
]}
>
<View style={styles.centeredButtonContainer}> <View style={styles.centeredButtonContainer}>
<Pressable <Pressable
onPress={startRealtime} onPress={startRealtime}
@@ -49,7 +62,7 @@ export function GlobalFooter() {
<AudioLines size={24} color="white" /> <AudioLines size={24} color="white" />
</Pressable> </Pressable>
</View> </View>
</View> </Animated.View>
); );
} }
@@ -120,6 +120,7 @@ interface SessionContextValue {
// Helpers // Helpers
sendAgentMessage: (agentId: string, message: string) => void; sendAgentMessage: (agentId: string, message: string) => void;
sendAgentAudio: (agentId: string, audioBlob: Blob) => Promise<void>;
createAgent: (options: { cwd: string; initialMode?: string; requestId?: string }) => void; createAgent: (options: { cwd: string; initialMode?: string; requestId?: string }) => void;
setAgentMode: (agentId: string, modeId: string) => void; setAgentMode: (agentId: string, modeId: string) => void;
respondToPermission: (requestId: string, agentId: string, sessionId: string, selectedOptionIds: string[]) => void; respondToPermission: (requestId: string, agentId: string, sessionId: string, selectedOptionIds: string[]) => void;
@@ -516,6 +517,40 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
ws.send(msg); ws.send(msg);
}, [ws]); }, [ws]);
const sendAgentAudio = useCallback(async (agentId: string, audioBlob: Blob) => {
try {
// Convert blob to base64
const arrayBuffer = await audioBlob.arrayBuffer();
const bytes = new Uint8Array(arrayBuffer);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
const base64Audio = btoa(binary);
// Determine format from MIME type
const format = audioBlob.type.split('/')[1] || 'm4a';
// Send audio message
const msg: WSInboundMessage = {
type: "session",
message: {
type: "send_agent_audio",
agentId,
audio: base64Audio,
format,
isLast: true,
},
};
ws.send(msg);
console.log("[Session] Sent audio to agent:", agentId, format, audioBlob.size, "bytes");
} catch (error) {
console.error("[Session] Failed to send audio:", error);
throw error;
}
}, [ws]);
const createAgent = useCallback((options: { cwd: string; initialMode?: string; requestId?: string }) => { const createAgent = useCallback((options: { cwd: string; initialMode?: string; requestId?: string }) => {
const msg: WSInboundMessage = { const msg: WSInboundMessage = {
type: "session", type: "session",
@@ -584,6 +619,7 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) {
pendingPermissions, pendingPermissions,
setPendingPermissions, setPendingPermissions,
sendAgentMessage, sendAgentMessage,
sendAgentAudio,
createAgent, createAgent,
setAgentMode, setAgentMode,
respondToPermission, respondToPermission,
+6 -16
View File
@@ -1,4 +1,4 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef } from 'react';
import { initialize, playPCMData } from '@speechmatics/expo-two-way-audio'; import { initialize, playPCMData } from '@speechmatics/expo-two-way-audio';
interface QueuedAudio { interface QueuedAudio {
@@ -45,20 +45,6 @@ export function useAudioPlayer() {
const queueRef = useRef<QueuedAudio[]>([]); const queueRef = useRef<QueuedAudio[]>([]);
const isProcessingQueueRef = useRef(false); const isProcessingQueueRef = useRef(false);
// Initialize Speechmatics audio on mount
useEffect(() => {
const initializeAudio = async () => {
try {
await initialize();
setAudioInitialized(true);
console.log('[AudioPlayer] ✅ Initialized (Speechmatics two-way audio)');
} catch (error) {
console.error('[AudioPlayer] Failed to initialize audio:', error);
}
};
initializeAudio();
}, []);
async function play(audioData: Blob): Promise<number> { async function play(audioData: Blob): Promise<number> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// Add to queue with its promise handlers // Add to queue with its promise handlers
@@ -112,8 +98,12 @@ export function useAudioPlayer() {
`[AudioPlayer] Playing audio (${audioData.size} bytes, type: ${audioData.type})` `[AudioPlayer] Playing audio (${audioData.size} bytes, type: ${audioData.type})`
); );
// Initialize audio if not already initialized
if (!audioInitialized) { if (!audioInitialized) {
throw new Error('Audio not initialized'); console.log('[AudioPlayer] Initializing audio...');
await initialize();
setAudioInitialized(true);
console.log('[AudioPlayer] ✅ Initialized (Speechmatics two-way audio)');
} }
// Get PCM data from blob (server now sends PCM format) // Get PCM data from blob (server now sends PCM format)
@@ -149,26 +149,6 @@ export function useSpeechmaticsAudio(
const SPEECH_CONFIRMATION_MS = config.speechConfirmationDuration; const SPEECH_CONFIRMATION_MS = config.speechConfirmationDuration;
const DETECTION_GRACE_PERIOD_MS = config.detectionGracePeriod; const DETECTION_GRACE_PERIOD_MS = config.detectionGracePeriod;
// Initialize audio on mount
useEffect(() => {
const initializeAudio = async () => {
try {
await initialize();
setAudioInitialized(true);
console.log("[SpeechmaticsAudio] Audio initialized");
} catch (error) {
console.error("[SpeechmaticsAudio] Initialization error:", error);
const err = error instanceof Error ? error : new Error(String(error));
config.onError?.(err);
}
};
initializeAudio();
return () => {
tearDown();
};
}, []);
// Update segment duration timer // Update segment duration timer
useEffect(() => { useEffect(() => {
if (!isDetecting && !isSpeaking) { if (!isDetecting && !isSpeaking) {
@@ -314,16 +294,20 @@ export function useSpeechmaticsAudio(
); );
async function start(): Promise<void> { async function start(): Promise<void> {
if (!audioInitialized) {
throw new Error("Audio not initialized");
}
if (isActive) { if (isActive) {
console.log("[SpeechmaticsAudio] Already active"); console.log("[SpeechmaticsAudio] Already active");
return; return;
} }
try { try {
// Initialize audio if not already initialized
if (!audioInitialized) {
console.log("[SpeechmaticsAudio] Initializing audio...");
await initialize();
setAudioInitialized(true);
console.log("[SpeechmaticsAudio] Audio initialized");
}
console.log("[SpeechmaticsAudio] Starting audio capture..."); console.log("[SpeechmaticsAudio] Starting audio capture...");
// Start recording // Start recording
@@ -344,7 +328,16 @@ export function useSpeechmaticsAudio(
console.log("[SpeechmaticsAudio] Stopping audio capture..."); console.log("[SpeechmaticsAudio] Stopping audio capture...");
// Stop recording // Stop recording
toggleRecording(false); if (isActive) {
toggleRecording(false);
}
// Tear down audio session
if (audioInitialized) {
tearDown();
setAudioInitialized(false);
console.log("[SpeechmaticsAudio] Audio torn down");
}
// Reset state // Reset state
audioBufferRef.current = []; audioBufferRef.current = [];
@@ -383,15 +376,6 @@ export function useSpeechmaticsAudio(
}); });
} }
// Cleanup on unmount
useEffect(() => {
return () => {
if (isActive) {
stop();
}
};
}, [isActive]);
return { return {
start, start,
stop, stop,
@@ -268,6 +268,20 @@ export class AgentManager {
throw new Error(`Agent ${agentId} is ${agent.status}`); throw new Error(`Agent ${agentId} is ${agent.status}`);
} }
// Auto-cancel if agent is currently processing
// This allows users to interrupt stuck agents by sending a new message
if (agent.status === "processing") {
console.log(`[Agent ${agentId}] Auto-cancelling current task before new prompt`);
try {
await this.cancelAgent(agentId);
// Small delay to allow cancellation to propagate
await new Promise(resolve => setTimeout(resolve, 100));
} catch (error) {
console.warn(`[Agent ${agentId}] Cancel failed, continuing with new prompt:`, error);
// Continue anyway - the new prompt might still work
}
}
// Reset message IDs for new turn - ensures each prompt gets fresh responses // Reset message IDs for new turn - ensures each prompt gets fresh responses
agent.currentAssistantMessageId = null; agent.currentAssistantMessageId = null;
agent.currentThoughtId = null; agent.currentThoughtId = null;
@@ -784,6 +798,7 @@ export class AgentManager {
timestamp: new Date(), timestamp: new Date(),
notification: { notification: {
type: 'permission', type: 'permission',
requestId, // Pass the requestId that we stored in pendingPermissions
request: params, request: params,
}, },
}; };
+1 -1
View File
@@ -23,7 +23,7 @@ export interface EnrichedSessionNotification extends Omit<SessionNotification, '
*/ */
export type AgentNotification = export type AgentNotification =
| { type: 'session'; notification: EnrichedSessionNotification } | { type: 'session'; notification: EnrichedSessionNotification }
| { type: 'permission'; request: RequestPermissionRequest } | { type: 'permission'; requestId: string; request: RequestPermissionRequest }
| { type: 'status'; status: AgentStatus; error?: string }; | { type: 'status'; status: AgentStatus; error?: string };
/** /**
+2 -2
View File
@@ -178,14 +178,14 @@ export class Session {
type: "agent_permission_request", type: "agent_permission_request",
payload: { payload: {
agentId, agentId,
requestId: uuidv4(), // Generate request ID requestId: notification.requestId, // Use the requestId from AgentManager
sessionId: permissionRequest.sessionId, sessionId: permissionRequest.sessionId,
toolCall: permissionRequest.toolCall, toolCall: permissionRequest.toolCall,
options: permissionRequest.options, options: permissionRequest.options,
}, },
}); });
console.log( console.log(
`[Session ${this.clientId}] Forwarded permission request for agent ${agentId}` `[Session ${this.clientId}] Forwarded permission request for agent ${agentId} with requestId ${notification.requestId}`
); );
return; return;
} }