feat: add realtime mode drift protection and improve voice UX
```
This commit is contained in:
Mohamed Boudra
2025-10-22 19:06:59 +02:00
parent 015d0f0e27
commit 8b9561b379
6 changed files with 308 additions and 95 deletions
+148 -56
View File
@@ -53,6 +53,7 @@ import {
ArrowUp, ArrowUp,
Square, Square,
AudioLines, AudioLines,
MicOff,
} from "lucide-react-native"; } from "lucide-react-native";
import type { import type {
ActivityLogPayload, ActivityLogPayload,
@@ -231,8 +232,14 @@ export default function VoiceAssistantScreen() {
// Realtime mode state (defined early so we can use it in audioRecorder) // Realtime mode state (defined early so we can use it in audioRecorder)
const [isRealtimeMode, setIsRealtimeMode] = useState(false); const [isRealtimeMode, setIsRealtimeMode] = useState(false);
const isRealtimeModeRef = useRef(isRealtimeMode);
const pulseAnim = useRef(new Animated.Value(1)).current; const pulseAnim = useRef(new Animated.Value(1)).current;
// Keep ref in sync with state
useEffect(() => {
isRealtimeModeRef.current = isRealtimeMode;
}, [isRealtimeMode]);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const audioRecorder = useAudioRecorder(); const audioRecorder = useAudioRecorder();
const audioPlayer = useAudioPlayer(); const audioPlayer = useAudioPlayer();
@@ -293,7 +300,7 @@ export default function VoiceAssistantScreen() {
]); ]);
}, },
volumeThreshold: 0.3, volumeThreshold: 0.3,
silenceDuration: 1000, silenceDuration: 2000,
speechConfirmationDuration: 300, speechConfirmationDuration: 300,
detectionGracePeriod: 200, detectionGracePeriod: 200,
}); });
@@ -452,6 +459,11 @@ export default function VoiceAssistantScreen() {
if (message.type !== "activity_log") return; if (message.type !== "activity_log") return;
const data = message.payload; const data = message.payload;
// Filter out transcription activity logs
if (data.type === "system" && data.content.includes("Transcribing")) {
return;
}
// Handle tool calls // Handle tool calls
if (data.type === "tool_call" && data.metadata) { if (data.type === "tool_call" && data.metadata) {
const { const {
@@ -593,6 +605,42 @@ export default function VoiceAssistantScreen() {
if (message.type !== "audio_output") return; if (message.type !== "audio_output") return;
const data = message.payload; 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 { try {
setIsPlayingAudio(true); setIsPlayingAudio(true);
@@ -669,18 +717,7 @@ export default function VoiceAssistantScreen() {
// Conversation loaded handler // Conversation loaded handler
const unsubConversationLoaded = ws.on("conversation_loaded", (message) => { const unsubConversationLoaded = ws.on("conversation_loaded", (message) => {
if (message.type !== "conversation_loaded") return; if (message.type !== "conversation_loaded") return;
const { conversationId, messageCount } = message.payload; // Don't show message in UI
setMessages((prev) => [
...prev,
{
type: "activity",
id: generateMessageId(),
timestamp: Date.now(),
activityType: "success",
message: `Loaded conversation with ${messageCount} messages`,
},
]);
}); });
// Artifact handler // Artifact handler
@@ -734,32 +771,6 @@ export default function VoiceAssistantScreen() {
}; };
}, [ws, audioPlayer]); }, [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 // Voice button handler
async function handleVoicePress() { async function handleVoicePress() {
@@ -778,16 +789,6 @@ export default function VoiceAssistantScreen() {
); );
setIsProcessingAudio(true); setIsProcessingAudio(true);
setMessages((prev) => [
...prev,
{
type: "activity",
id: generateMessageId(),
timestamp: Date.now(),
activityType: "info",
message: "Sending audio to server...",
},
]);
// Convert to base64 // Convert to base64
const arrayBuffer = await audioBlob.arrayBuffer(); const arrayBuffer = await audioBlob.arrayBuffer();
@@ -974,6 +975,16 @@ export default function VoiceAssistantScreen() {
setIsRealtimeMode(true); setIsRealtimeMode(true);
console.log("[App] Realtime mode enabled"); 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) => [ setMessages((prev) => [
...prev, ...prev,
{ {
@@ -1004,6 +1015,16 @@ export default function VoiceAssistantScreen() {
setIsRealtimeMode(false); setIsRealtimeMode(false);
console.log("[App] Realtime mode disabled"); 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) => [ setMessages((prev) => [
...prev, ...prev,
{ {
@@ -1204,13 +1225,49 @@ export default function VoiceAssistantScreen() {
]} ]}
> >
{isRealtimeMode ? ( {isRealtimeMode ? (
// Realtime mode - show volume meter // Realtime mode - show volume meter and mute button
<Pressable <View style={[styles.inputArea, { minHeight: 200 }]}>
onPress={handleRealtimeToggle} <View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
style={[styles.inputArea, { minHeight: 200, justifyContent: "center" }]} <VolumeMeter
> volume={realtimeAudio.volume}
<VolumeMeter volume={realtimeAudio.volume} /> isMuted={realtimeAudio.isMuted}
</Pressable> isDetecting={realtimeAudio.isDetecting}
isSpeaking={realtimeAudio.isSpeaking}
/>
{/* Debug timer */}
{(realtimeAudio.isDetecting || realtimeAudio.isSpeaking) && (
<Text style={styles.debugTimer}>
{(realtimeAudio.segmentDuration / 1000).toFixed(1)}s
</Text>
)}
</View>
<View style={styles.realtimeModeButtons}>
{/* Mute button */}
<Pressable
onPress={() => realtimeAudio.toggleMute()}
style={[
styles.realtimeMuteButton,
realtimeAudio.isMuted && styles.realtimeMuteButtonActive,
]}
>
<MicOff
size={20}
color={
realtimeAudio.isMuted
? defaultTheme.colors.background
: defaultTheme.colors.foreground
}
/>
</Pressable>
{/* Close button */}
<Pressable
onPress={handleRealtimeToggle}
style={styles.realtimeCloseButton}
>
<Square size={18} color="white" fill="white" />
</Pressable>
</View>
</View>
) : ( ) : (
// Normal mode - show text input and buttons // Normal mode - show text input and buttons
<View style={styles.inputArea}> <View style={styles.inputArea}>
@@ -1420,4 +1477,39 @@ const styles = StyleSheet.create((theme) => ({
buttonDisabled: { buttonDisabled: {
opacity: theme.opacity[50], 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",
},
})); }));
+60 -23
View File
@@ -13,15 +13,18 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
interface VolumeMeterProps { interface VolumeMeterProps {
volume: number; 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(); const { theme } = useUnistyles();
// Base dimensions // Base dimensions
const LINE_SPACING = 8; const LINE_SPACING = 8;
const MAX_HEIGHT = 80; const MAX_HEIGHT = 50;
const MIN_HEIGHT = 4; const MIN_HEIGHT = 20;
// Shared values for each line's height // Shared values for each line's height
const line1Height = useSharedValue(MIN_HEIGHT); const line1Height = useSharedValue(MIN_HEIGHT);
@@ -35,6 +38,14 @@ export function VolumeMeter({ volume }: VolumeMeterProps) {
// Start idle animations with different phases // Start idle animations with different phases
useEffect(() => { 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 // Line 1 - fastest pulse
line1Pulse.value = withRepeat( line1Pulse.value = withRepeat(
withSequence( withSequence(
@@ -66,10 +77,18 @@ export function VolumeMeter({ volume }: VolumeMeterProps) {
-1, -1,
false false
); );
}, []); }, [isMuted]);
// Update heights based on volume with different responsiveness per line // Update heights based on volume with different responsiveness per line
useEffect(() => { 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) { if (volume > 0.01) {
// Active volume - animate heights based on volume // Active volume - animate heights based on volume
// Line 1 - most responsive, follows volume closely // Line 1 - most responsive, follows volume closely
@@ -107,23 +126,41 @@ export function VolumeMeter({ volume }: VolumeMeterProps) {
stiffness: 150, stiffness: 150,
}); });
} }
}, [volume]); }, [volume, isMuted]);
// Animated styles for each line // Animated styles for each line
const line1Style = useAnimatedStyle(() => ({ const line1Style = useAnimatedStyle(() => {
height: line1Height.value * (volume > 0.01 ? 1 : line1Pulse.value), const isActive = isDetecting || isSpeaking;
opacity: 0.8 + (volume * 0.2), 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(() => ({ const line2Style = useAnimatedStyle(() => {
height: line2Height.value * (volume > 0.01 ? 1 : line2Pulse.value), const isActive = isDetecting || isSpeaking;
opacity: 0.7 + (volume * 0.3), 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(() => ({ const line3Style = useAnimatedStyle(() => {
height: line3Height.value * (volume > 0.01 ? 1 : line3Pulse.value), const isActive = isDetecting || isSpeaking;
opacity: 0.6 + (volume * 0.4), 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 ( return (
<View style={styles.container}> <View style={styles.container}>
@@ -131,8 +168,8 @@ export function VolumeMeter({ volume }: VolumeMeterProps) {
style={[ style={[
styles.line, styles.line,
{ {
width: 4, width: lineWidth,
backgroundColor: theme.colors.palette.blue[400], backgroundColor: lineColor,
}, },
line1Style, line1Style,
]} ]}
@@ -142,8 +179,8 @@ export function VolumeMeter({ volume }: VolumeMeterProps) {
style={[ style={[
styles.line, styles.line,
{ {
width: 6, width: lineWidth,
backgroundColor: theme.colors.palette.blue[500], backgroundColor: lineColor,
}, },
line2Style, line2Style,
]} ]}
@@ -153,8 +190,8 @@ export function VolumeMeter({ volume }: VolumeMeterProps) {
style={[ style={[
styles.line, styles.line,
{ {
width: 4, width: lineWidth,
backgroundColor: theme.colors.palette.blue[400], backgroundColor: lineColor,
}, },
line3Style, line3Style,
]} ]}
@@ -22,10 +22,13 @@ export interface SpeechmaticsAudioConfig {
export interface SpeechmaticsAudio { export interface SpeechmaticsAudio {
start: () => Promise<void>; start: () => Promise<void>;
stop: () => Promise<void>; stop: () => Promise<void>;
toggleMute: () => void;
isActive: boolean; isActive: boolean;
isSpeaking: boolean; isSpeaking: boolean;
isDetecting: boolean; isDetecting: boolean;
isMuted: boolean;
volume: number; volume: number;
segmentDuration: number;
} }
/** /**
@@ -131,6 +134,8 @@ export function useSpeechmaticsAudio(
const [isDetecting, setIsDetecting] = useState(false); const [isDetecting, setIsDetecting] = useState(false);
const [audioInitialized, setAudioInitialized] = useState(false); const [audioInitialized, setAudioInitialized] = useState(false);
const [volume, setVolume] = useState(0); const [volume, setVolume] = useState(0);
const [isMuted, setIsMuted] = useState(false);
const [segmentDuration, setSegmentDuration] = useState(0);
const audioBufferRef = useRef<Uint8Array[]>([]); const audioBufferRef = useRef<Uint8Array[]>([]);
const silenceStartRef = useRef<number | null>(null); const silenceStartRef = useRef<number | null>(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 // Listen to microphone data
useExpoTwoWayAudioEventListener( useExpoTwoWayAudioEventListener(
"onMicrophoneData", "onMicrophoneData",
useCallback<MicrophoneDataCallback>( useCallback<MicrophoneDataCallback>(
(event) => { (event) => {
if (!isActive) return; if (!isActive || isMuted) return;
const pcmData: Uint8Array = event.data; const pcmData: Uint8Array = event.data;
@@ -179,7 +200,7 @@ export function useSpeechmaticsAudio(
audioBufferRef.current.push(pcmData); audioBufferRef.current.push(pcmData);
} }
}, },
[isActive] [isActive, isMuted]
) )
); );
@@ -192,6 +213,9 @@ export function useSpeechmaticsAudio(
const volumeLevel: number = event.data; const volumeLevel: number = event.data;
setVolume(volumeLevel); setVolume(volumeLevel);
if (isMuted) return;
const speechDetected = volumeLevel > VOLUME_THRESHOLD; const speechDetected = volumeLevel > VOLUME_THRESHOLD;
// console.log('[SpeechmaticsAudio] Volume:', volumeLevel.toFixed(6), 'Threshold:', 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); setIsSpeaking(false);
setIsDetecting(false); setIsDetecting(false);
setVolume(0); setVolume(0);
setIsMuted(false);
console.log("[SpeechmaticsAudio] Audio capture stopped"); 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 // Cleanup on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -349,9 +395,12 @@ export function useSpeechmaticsAudio(
return { return {
start, start,
stop, stop,
toggleMute,
isActive, isActive,
isSpeaking, isSpeaking,
isDetecting, isDetecting,
isMuted,
volume, volume,
segmentDuration,
}; };
} }
@@ -26,7 +26,8 @@ export class TTSManager {
public async generateAndWaitForPlayback( public async generateAndWaitForPlayback(
text: string, text: string,
emitMessage: (msg: SessionOutboundMessage) => void, emitMessage: (msg: SessionOutboundMessage) => void,
abortSignal: AbortSignal abortSignal: AbortSignal,
isRealtimeMode: boolean
): Promise<void> { ): Promise<void> {
if (abortSignal.aborted) { if (abortSignal.aborted) {
console.log( console.log(
@@ -78,13 +79,14 @@ export class TTSManager {
}); });
} }
// Emit audio output message // Emit audio output message (include mode for drift protection)
emitMessage({ emitMessage({
type: "audio_output", type: "audio_output",
payload: { payload: {
id: audioId, id: audioId,
audio: audio.toString("base64"), audio: audio.toString("base64"),
format, format,
isRealtimeMode,
}, },
}); });
+14
View File
@@ -7,6 +7,7 @@ import { z } from "zod";
export const UserTextMessageSchema = z.object({ export const UserTextMessageSchema = z.object({
type: z.literal("user_text"), type: z.literal("user_text"),
text: z.string(), text: z.string(),
disableTTS: z.boolean().optional(),
}); });
export const AudioChunkMessageSchema = z.object({ export const AudioChunkMessageSchema = z.object({
@@ -39,6 +40,11 @@ export const DeleteConversationRequestMessageSchema = z.object({
conversationId: z.string(), conversationId: z.string(),
}); });
export const SetRealtimeModeMessageSchema = z.object({
type: z.literal("set_realtime_mode"),
enabled: z.boolean(),
});
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
UserTextMessageSchema, UserTextMessageSchema,
AudioChunkMessageSchema, AudioChunkMessageSchema,
@@ -47,6 +53,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
LoadConversationRequestMessageSchema, LoadConversationRequestMessageSchema,
ListConversationsRequestMessageSchema, ListConversationsRequestMessageSchema,
DeleteConversationRequestMessageSchema, DeleteConversationRequestMessageSchema,
SetRealtimeModeMessageSchema,
]); ]);
export type SessionInboundMessage = z.infer<typeof SessionInboundMessageSchema>; export type SessionInboundMessage = z.infer<typeof SessionInboundMessageSchema>;
@@ -88,6 +95,7 @@ export const AudioOutputMessageSchema = z.object({
audio: z.string(), // base64 encoded audio: z.string(), // base64 encoded
format: z.string(), format: z.string(),
id: 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"), type: z.literal("claude"),
sessionId: z.string().optional(), sessionId: z.string().optional(),
error: 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(),
}), }),
}), }),
}); });
+30 -11
View File
@@ -54,6 +54,9 @@ export class Session {
private processingPhase: ProcessingPhase = "idle"; private processingPhase: ProcessingPhase = "idle";
private currentStreamPromise: Promise<void> | null = null; private currentStreamPromise: Promise<void> | null = null;
// Realtime mode state
private isRealtimeMode = false;
// Audio buffering for interruption handling // Audio buffering for interruption handling
private pendingAudioSegments: Array<{ audio: Buffer; format: string }> = []; private pendingAudioSegments: Array<{ audio: Buffer; format: string }> = [];
private bufferTimeout: NodeJS.Timeout | null = null; private bufferTimeout: NodeJS.Timeout | null = null;
@@ -165,12 +168,12 @@ export class Session {
info: { info: {
id: info.id, id: info.id,
status: info.status, status: info.status,
createdAt: info.createdAt.toISOString(), createdAt: info.createdAt,
type: info.type, type: info.type,
sessionId: info.sessionId, sessionId: info.sessionId ?? undefined,
error: info.error, error: info.error ?? undefined,
currentModeId: info.currentModeId, currentModeId: info.currentModeId ?? undefined,
availableModes: info.availableModes, availableModes: info.availableModes ?? undefined,
}, },
}, },
}); });
@@ -300,6 +303,10 @@ export class Session {
case "delete_conversation_request": case "delete_conversation_request":
await this.handleDeleteConversation(msg.conversationId); await this.handleDeleteConversation(msg.conversationId);
break; break;
case "set_realtime_mode":
this.handleSetRealtimeMode(msg.enabled);
break;
} }
} catch (error: any) { } catch (error: any) {
console.error( 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 * Send current session state (live agents and commands) to client
*/ */
@@ -471,8 +488,8 @@ export class Session {
// Add to conversation // Add to conversation
this.messages.push({ role: "user", content: text }); this.messages.push({ role: "user", content: text });
// Process through LLM (TTS enabled for text input) // Process through LLM (TTS enabled in realtime mode for voice conversations)
this.currentStreamPromise = this.processWithLLM(true); this.currentStreamPromise = this.processWithLLM(this.isRealtimeMode);
await this.currentStreamPromise; await this.currentStreamPromise;
} }
@@ -618,9 +635,9 @@ export class Session {
// Add to conversation // Add to conversation
this.messages.push({ role: "user", content: result.text }); 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.setPhase("llm");
this.currentStreamPromise = this.processWithLLM(true); // Enable TTS for voice input this.currentStreamPromise = this.processWithLLM(this.isRealtimeMode);
await this.currentStreamPromise; await this.currentStreamPromise;
this.setPhase("idle"); this.setPhase("idle");
} catch (error: any) { } catch (error: any) {
@@ -648,12 +665,14 @@ export class Session {
const flushTextBuffer = () => { const flushTextBuffer = () => {
if (textBuffer.length > 0) { if (textBuffer.length > 0) {
// TTS handling // TTS handling (capture mode at generation time for drift protection)
if (enableTTS) { if (enableTTS) {
const modeAtGeneration = this.isRealtimeMode;
pendingTTS = this.ttsManager.generateAndWaitForPlayback( pendingTTS = this.ttsManager.generateAndWaitForPlayback(
textBuffer, textBuffer,
(msg) => this.emit(msg), (msg) => this.emit(msg),
this.abortController.signal this.abortController.signal,
modeAtGeneration
); );
} }