From 8b9561b379ad41df611406bd97861abdfcca8fba Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 22 Oct 2025 19:06:59 +0200 Subject: [PATCH] ``` feat: add realtime mode drift protection and improve voice UX ``` --- packages/app/src/app/index.tsx | 204 +++++++++++++----- packages/app/src/components/volume-meter.tsx | 83 +++++-- .../app/src/hooks/use-speechmatics-audio.ts | 55 ++++- .../server/src/server/agent/tts-manager.ts | 6 +- packages/server/src/server/messages.ts | 14 ++ packages/server/src/server/session.ts | 41 +++- 6 files changed, 308 insertions(+), 95 deletions(-) diff --git a/packages/app/src/app/index.tsx b/packages/app/src/app/index.tsx index 023cecef2..82f4e4e1f 100644 --- a/packages/app/src/app/index.tsx +++ b/packages/app/src/app/index.tsx @@ -53,6 +53,7 @@ import { ArrowUp, Square, AudioLines, + MicOff, } from "lucide-react-native"; import type { ActivityLogPayload, @@ -231,8 +232,14 @@ export default function VoiceAssistantScreen() { // Realtime mode state (defined early so we can use it in audioRecorder) const [isRealtimeMode, setIsRealtimeMode] = useState(false); + const isRealtimeModeRef = useRef(isRealtimeMode); const pulseAnim = useRef(new Animated.Value(1)).current; + // Keep ref in sync with state + useEffect(() => { + isRealtimeModeRef.current = isRealtimeMode; + }, [isRealtimeMode]); + const insets = useSafeAreaInsets(); const audioRecorder = useAudioRecorder(); const audioPlayer = useAudioPlayer(); @@ -293,7 +300,7 @@ export default function VoiceAssistantScreen() { ]); }, volumeThreshold: 0.3, - silenceDuration: 1000, + silenceDuration: 2000, speechConfirmationDuration: 300, detectionGracePeriod: 200, }); @@ -452,6 +459,11 @@ export default function VoiceAssistantScreen() { if (message.type !== "activity_log") return; const data = message.payload; + // Filter out transcription activity logs + if (data.type === "system" && data.content.includes("Transcribing")) { + return; + } + // Handle tool calls if (data.type === "tool_call" && data.metadata) { const { @@ -593,6 +605,42 @@ export default function VoiceAssistantScreen() { if (message.type !== "audio_output") return; const data = message.payload; + const currentIsRealtimeMode = isRealtimeModeRef.current; + + // Drift protection: Don't play audio generated in different mode + if (data.isRealtimeMode !== currentIsRealtimeMode) { + console.log( + `[App] Skipping audio playback due to mode drift (generated in ${data.isRealtimeMode ? "realtime" : "normal"} mode, currently in ${currentIsRealtimeMode ? "realtime" : "normal"} mode)` + ); + + // Still send confirmation to prevent server from waiting + const confirmMessage: WSInboundMessage = { + type: "session", + message: { + type: "audio_played", + id: data.id, + }, + }; + ws.send(confirmMessage); + return; + } + + // Additional check: Don't play if NOT in realtime mode (shouldn't happen with server-side fix, but defense in depth) + if (!currentIsRealtimeMode) { + console.log("[App] Skipping audio playback - not in realtime mode"); + + // Still send confirmation + const confirmMessage: WSInboundMessage = { + type: "session", + message: { + type: "audio_played", + id: data.id, + }, + }; + ws.send(confirmMessage); + return; + } + try { setIsPlayingAudio(true); @@ -669,18 +717,7 @@ export default function VoiceAssistantScreen() { // Conversation loaded handler const unsubConversationLoaded = ws.on("conversation_loaded", (message) => { if (message.type !== "conversation_loaded") return; - const { conversationId, messageCount } = message.payload; - - setMessages((prev) => [ - ...prev, - { - type: "activity", - id: generateMessageId(), - timestamp: Date.now(), - activityType: "success", - message: `Loaded conversation with ${messageCount} messages`, - }, - ]); + // Don't show message in UI }); // Artifact handler @@ -734,32 +771,6 @@ export default function VoiceAssistantScreen() { }; }, [ws, audioPlayer]); - // Connection status handler - useEffect(() => { - if (ws.isConnected) { - setMessages((prev) => [ - ...prev, - { - type: "activity", - id: generateMessageId(), - timestamp: Date.now(), - activityType: "success", - message: "WebSocket connected", - }, - ]); - } else if (messages.length > 0) { - setMessages((prev) => [ - ...prev, - { - type: "activity", - id: generateMessageId(), - timestamp: Date.now(), - activityType: "error", - message: "WebSocket disconnected", - }, - ]); - } - }, [ws.isConnected]); // Voice button handler async function handleVoicePress() { @@ -778,16 +789,6 @@ export default function VoiceAssistantScreen() { ); setIsProcessingAudio(true); - setMessages((prev) => [ - ...prev, - { - type: "activity", - id: generateMessageId(), - timestamp: Date.now(), - activityType: "info", - message: "Sending audio to server...", - }, - ]); // Convert to base64 const arrayBuffer = await audioBlob.arrayBuffer(); @@ -974,6 +975,16 @@ export default function VoiceAssistantScreen() { setIsRealtimeMode(true); console.log("[App] Realtime mode enabled"); + // Notify server of mode change + const modeMessage: WSInboundMessage = { + type: "session", + message: { + type: "set_realtime_mode", + enabled: true, + }, + }; + ws.send(modeMessage); + setMessages((prev) => [ ...prev, { @@ -1004,6 +1015,16 @@ export default function VoiceAssistantScreen() { setIsRealtimeMode(false); console.log("[App] Realtime mode disabled"); + // Notify server of mode change + const modeMessage: WSInboundMessage = { + type: "session", + message: { + type: "set_realtime_mode", + enabled: false, + }, + }; + ws.send(modeMessage); + setMessages((prev) => [ ...prev, { @@ -1204,13 +1225,49 @@ export default function VoiceAssistantScreen() { ]} > {isRealtimeMode ? ( - // Realtime mode - show volume meter - - - + // Realtime mode - show volume meter and mute button + + + + {/* Debug timer */} + {(realtimeAudio.isDetecting || realtimeAudio.isSpeaking) && ( + + {(realtimeAudio.segmentDuration / 1000).toFixed(1)}s + + )} + + + {/* Mute button */} + realtimeAudio.toggleMute()} + style={[ + styles.realtimeMuteButton, + realtimeAudio.isMuted && styles.realtimeMuteButtonActive, + ]} + > + + + {/* Close button */} + + + + + ) : ( // Normal mode - show text input and buttons @@ -1420,4 +1477,39 @@ const styles = StyleSheet.create((theme) => ({ buttonDisabled: { opacity: theme.opacity[50], }, + realtimeModeButtons: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: theme.spacing[3], + paddingTop: theme.spacing[4], + }, + realtimeMuteButton: { + width: 48, + height: 48, + borderRadius: theme.borderRadius.full, + alignItems: "center", + justifyContent: "center", + backgroundColor: theme.colors.muted, + borderWidth: theme.borderWidth[2], + borderColor: theme.colors.border, + }, + realtimeMuteButtonActive: { + backgroundColor: theme.colors.palette.red[500], + borderColor: theme.colors.palette.red[600], + }, + realtimeCloseButton: { + width: 48, + height: 48, + borderRadius: theme.borderRadius.full, + alignItems: "center", + justifyContent: "center", + backgroundColor: theme.colors.palette.red[600], + }, + debugTimer: { + marginTop: theme.spacing[2], + color: theme.colors.mutedForeground, + fontSize: theme.fontSize.sm, + fontFamily: "monospace", + }, })); diff --git a/packages/app/src/components/volume-meter.tsx b/packages/app/src/components/volume-meter.tsx index 19edae17f..b2481f580 100644 --- a/packages/app/src/components/volume-meter.tsx +++ b/packages/app/src/components/volume-meter.tsx @@ -13,15 +13,18 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles"; interface VolumeMeterProps { volume: number; + isMuted?: boolean; + isDetecting?: boolean; + isSpeaking?: boolean; } -export function VolumeMeter({ volume }: VolumeMeterProps) { +export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSpeaking = false }: VolumeMeterProps) { const { theme } = useUnistyles(); // Base dimensions const LINE_SPACING = 8; - const MAX_HEIGHT = 80; - const MIN_HEIGHT = 4; + const MAX_HEIGHT = 50; + const MIN_HEIGHT = 20; // Shared values for each line's height const line1Height = useSharedValue(MIN_HEIGHT); @@ -35,6 +38,14 @@ export function VolumeMeter({ volume }: VolumeMeterProps) { // Start idle animations with different phases useEffect(() => { + if (isMuted) { + // When muted, set pulse to 1 (no animation) + line1Pulse.value = 1; + line2Pulse.value = 1; + line3Pulse.value = 1; + return; + } + // Line 1 - fastest pulse line1Pulse.value = withRepeat( withSequence( @@ -66,10 +77,18 @@ export function VolumeMeter({ volume }: VolumeMeterProps) { -1, false ); - }, []); + }, [isMuted]); // Update heights based on volume with different responsiveness per line useEffect(() => { + if (isMuted) { + // When muted, keep lines at minimum height without animation + line1Height.value = MIN_HEIGHT; + line2Height.value = MIN_HEIGHT; + line3Height.value = MIN_HEIGHT; + return; + } + if (volume > 0.01) { // Active volume - animate heights based on volume // Line 1 - most responsive, follows volume closely @@ -107,23 +126,41 @@ export function VolumeMeter({ volume }: VolumeMeterProps) { stiffness: 150, }); } - }, [volume]); + }, [volume, isMuted]); // Animated styles for each line - const line1Style = useAnimatedStyle(() => ({ - height: line1Height.value * (volume > 0.01 ? 1 : line1Pulse.value), - opacity: 0.8 + (volume * 0.2), - })); + const line1Style = useAnimatedStyle(() => { + const isActive = isDetecting || isSpeaking; + const baseOpacity = isMuted ? 0.3 : isActive ? 0.9 : 0.5; + const volumeBoost = isMuted ? 0 : volume * 0.3; + return { + height: line1Height.value * (isMuted || volume > 0.01 ? 1 : line1Pulse.value), + opacity: baseOpacity + volumeBoost, + }; + }); - const line2Style = useAnimatedStyle(() => ({ - height: line2Height.value * (volume > 0.01 ? 1 : line2Pulse.value), - opacity: 0.7 + (volume * 0.3), - })); + const line2Style = useAnimatedStyle(() => { + const isActive = isDetecting || isSpeaking; + const baseOpacity = isMuted ? 0.3 : isActive ? 0.9 : 0.5; + const volumeBoost = isMuted ? 0 : volume * 0.3; + return { + height: line2Height.value * (isMuted || volume > 0.01 ? 1 : line2Pulse.value), + opacity: baseOpacity + volumeBoost, + }; + }); - const line3Style = useAnimatedStyle(() => ({ - height: line3Height.value * (volume > 0.01 ? 1 : line3Pulse.value), - opacity: 0.6 + (volume * 0.4), - })); + const line3Style = useAnimatedStyle(() => { + const isActive = isDetecting || isSpeaking; + const baseOpacity = isMuted ? 0.3 : isActive ? 0.9 : 0.5; + const volumeBoost = isMuted ? 0 : volume * 0.3; + return { + height: line3Height.value * (isMuted || volume > 0.01 ? 1 : line3Pulse.value), + opacity: baseOpacity + volumeBoost, + }; + }); + + const lineColor = "#FFFFFF"; + const lineWidth = 8; return ( @@ -131,8 +168,8 @@ export function VolumeMeter({ volume }: VolumeMeterProps) { style={[ styles.line, { - width: 4, - backgroundColor: theme.colors.palette.blue[400], + width: lineWidth, + backgroundColor: lineColor, }, line1Style, ]} @@ -142,8 +179,8 @@ export function VolumeMeter({ volume }: VolumeMeterProps) { style={[ styles.line, { - width: 6, - backgroundColor: theme.colors.palette.blue[500], + width: lineWidth, + backgroundColor: lineColor, }, line2Style, ]} @@ -153,8 +190,8 @@ export function VolumeMeter({ volume }: VolumeMeterProps) { style={[ styles.line, { - width: 4, - backgroundColor: theme.colors.palette.blue[400], + width: lineWidth, + backgroundColor: lineColor, }, line3Style, ]} diff --git a/packages/app/src/hooks/use-speechmatics-audio.ts b/packages/app/src/hooks/use-speechmatics-audio.ts index f92b24e02..52037b919 100644 --- a/packages/app/src/hooks/use-speechmatics-audio.ts +++ b/packages/app/src/hooks/use-speechmatics-audio.ts @@ -22,10 +22,13 @@ export interface SpeechmaticsAudioConfig { export interface SpeechmaticsAudio { start: () => Promise; stop: () => Promise; + toggleMute: () => void; isActive: boolean; isSpeaking: boolean; isDetecting: boolean; + isMuted: boolean; volume: number; + segmentDuration: number; } /** @@ -131,6 +134,8 @@ export function useSpeechmaticsAudio( const [isDetecting, setIsDetecting] = useState(false); const [audioInitialized, setAudioInitialized] = useState(false); const [volume, setVolume] = useState(0); + const [isMuted, setIsMuted] = useState(false); + const [segmentDuration, setSegmentDuration] = useState(0); const audioBufferRef = useRef([]); const silenceStartRef = useRef(null); @@ -164,12 +169,28 @@ export function useSpeechmaticsAudio( }; }, []); + // Update segment duration timer + useEffect(() => { + if (!isDetecting && !isSpeaking) { + setSegmentDuration(0); + return; + } + + const startTime = speechDetectionStartRef.current || Date.now(); + const interval = setInterval(() => { + const elapsed = Date.now() - startTime; + setSegmentDuration(elapsed); + }, 100); + + return () => clearInterval(interval); + }, [isDetecting, isSpeaking]); + // Listen to microphone data useExpoTwoWayAudioEventListener( "onMicrophoneData", useCallback( (event) => { - if (!isActive) return; + if (!isActive || isMuted) return; const pcmData: Uint8Array = event.data; @@ -179,7 +200,7 @@ export function useSpeechmaticsAudio( audioBufferRef.current.push(pcmData); } }, - [isActive] + [isActive, isMuted] ) ); @@ -192,6 +213,9 @@ export function useSpeechmaticsAudio( const volumeLevel: number = event.data; setVolume(volumeLevel); + + if (isMuted) return; + const speechDetected = volumeLevel > VOLUME_THRESHOLD; // console.log('[SpeechmaticsAudio] Volume:', volumeLevel.toFixed(6), 'Threshold:', VOLUME_THRESHOLD); @@ -285,7 +309,7 @@ export function useSpeechmaticsAudio( } } }, - [isActive, VOLUME_THRESHOLD, SILENCE_DURATION_MS, SPEECH_CONFIRMATION_MS, DETECTION_GRACE_PERIOD_MS, config] + [isActive, isMuted, VOLUME_THRESHOLD, SILENCE_DURATION_MS, SPEECH_CONFIRMATION_MS, DETECTION_GRACE_PERIOD_MS, config] ) ); @@ -333,10 +357,32 @@ export function useSpeechmaticsAudio( setIsSpeaking(false); setIsDetecting(false); setVolume(0); + setIsMuted(false); console.log("[SpeechmaticsAudio] Audio capture stopped"); } + function toggleMute(): void { + setIsMuted((prev) => { + const newMuted = !prev; + console.log("[SpeechmaticsAudio] Mute toggled:", newMuted); + + if (newMuted) { + // Clear any ongoing speech detection/speaking state + audioBufferRef.current = []; + isSpeakingRef.current = false; + speechConfirmedRef.current = false; + speechDetectionStartRef.current = null; + detectionSilenceStartRef.current = null; + silenceStartRef.current = null; + setIsSpeaking(false); + setIsDetecting(false); + } + + return newMuted; + }); + } + // Cleanup on unmount useEffect(() => { return () => { @@ -349,9 +395,12 @@ export function useSpeechmaticsAudio( return { start, stop, + toggleMute, isActive, isSpeaking, isDetecting, + isMuted, volume, + segmentDuration, }; } diff --git a/packages/server/src/server/agent/tts-manager.ts b/packages/server/src/server/agent/tts-manager.ts index 5de37d25b..e186691e8 100644 --- a/packages/server/src/server/agent/tts-manager.ts +++ b/packages/server/src/server/agent/tts-manager.ts @@ -26,7 +26,8 @@ export class TTSManager { public async generateAndWaitForPlayback( text: string, emitMessage: (msg: SessionOutboundMessage) => void, - abortSignal: AbortSignal + abortSignal: AbortSignal, + isRealtimeMode: boolean ): Promise { if (abortSignal.aborted) { console.log( @@ -78,13 +79,14 @@ export class TTSManager { }); } - // Emit audio output message + // Emit audio output message (include mode for drift protection) emitMessage({ type: "audio_output", payload: { id: audioId, audio: audio.toString("base64"), format, + isRealtimeMode, }, }); diff --git a/packages/server/src/server/messages.ts b/packages/server/src/server/messages.ts index 45315db96..43b062a4b 100644 --- a/packages/server/src/server/messages.ts +++ b/packages/server/src/server/messages.ts @@ -7,6 +7,7 @@ import { z } from "zod"; export const UserTextMessageSchema = z.object({ type: z.literal("user_text"), text: z.string(), + disableTTS: z.boolean().optional(), }); export const AudioChunkMessageSchema = z.object({ @@ -39,6 +40,11 @@ export const DeleteConversationRequestMessageSchema = z.object({ conversationId: z.string(), }); +export const SetRealtimeModeMessageSchema = z.object({ + type: z.literal("set_realtime_mode"), + enabled: z.boolean(), +}); + export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ UserTextMessageSchema, AudioChunkMessageSchema, @@ -47,6 +53,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ LoadConversationRequestMessageSchema, ListConversationsRequestMessageSchema, DeleteConversationRequestMessageSchema, + SetRealtimeModeMessageSchema, ]); export type SessionInboundMessage = z.infer; @@ -88,6 +95,7 @@ export const AudioOutputMessageSchema = z.object({ audio: z.string(), // base64 encoded format: z.string(), id: z.string(), + isRealtimeMode: z.boolean(), // Mode when audio was generated (for drift protection) }), }); @@ -164,6 +172,12 @@ export const AgentStatusMessageSchema = z.object({ type: z.literal("claude"), sessionId: z.string().optional(), error: z.string().optional(), + currentModeId: z.string().optional(), + availableModes: z.array(z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable().optional(), + })).optional(), }), }), }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 881169fcf..e74b3796f 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -54,6 +54,9 @@ export class Session { private processingPhase: ProcessingPhase = "idle"; private currentStreamPromise: Promise | null = null; + // Realtime mode state + private isRealtimeMode = false; + // Audio buffering for interruption handling private pendingAudioSegments: Array<{ audio: Buffer; format: string }> = []; private bufferTimeout: NodeJS.Timeout | null = null; @@ -165,12 +168,12 @@ export class Session { info: { id: info.id, status: info.status, - createdAt: info.createdAt.toISOString(), + createdAt: info.createdAt, type: info.type, - sessionId: info.sessionId, - error: info.error, - currentModeId: info.currentModeId, - availableModes: info.availableModes, + sessionId: info.sessionId ?? undefined, + error: info.error ?? undefined, + currentModeId: info.currentModeId ?? undefined, + availableModes: info.availableModes ?? undefined, }, }, }); @@ -300,6 +303,10 @@ export class Session { case "delete_conversation_request": await this.handleDeleteConversation(msg.conversationId); break; + + case "set_realtime_mode": + this.handleSetRealtimeMode(msg.enabled); + break; } } catch (error: any) { console.error( @@ -400,6 +407,16 @@ export class Session { } } + /** + * Handle realtime mode toggle + */ + private handleSetRealtimeMode(enabled: boolean): void { + this.isRealtimeMode = enabled; + console.log( + `[Session ${this.clientId}] Realtime mode ${enabled ? "enabled" : "disabled"}` + ); + } + /** * Send current session state (live agents and commands) to client */ @@ -471,8 +488,8 @@ export class Session { // Add to conversation this.messages.push({ role: "user", content: text }); - // Process through LLM (TTS enabled for text input) - this.currentStreamPromise = this.processWithLLM(true); + // Process through LLM (TTS enabled in realtime mode for voice conversations) + this.currentStreamPromise = this.processWithLLM(this.isRealtimeMode); await this.currentStreamPromise; } @@ -618,9 +635,9 @@ export class Session { // Add to conversation this.messages.push({ role: "user", content: result.text }); - // Set phase to LLM and process + // Set phase to LLM and process (TTS enabled in realtime mode for voice conversations) this.setPhase("llm"); - this.currentStreamPromise = this.processWithLLM(true); // Enable TTS for voice input + this.currentStreamPromise = this.processWithLLM(this.isRealtimeMode); await this.currentStreamPromise; this.setPhase("idle"); } catch (error: any) { @@ -648,12 +665,14 @@ export class Session { const flushTextBuffer = () => { if (textBuffer.length > 0) { - // TTS handling + // TTS handling (capture mode at generation time for drift protection) if (enableTTS) { + const modeAtGeneration = this.isRealtimeMode; pendingTTS = this.ttsManager.generateAndWaitForPlayback( textBuffer, (msg) => this.emit(msg), - this.abortController.signal + this.abortController.signal, + modeAtGeneration ); }