hands free videochat

This commit is contained in:
Arjun 2026-07-03 23:20:02 +05:30
parent 6f901095ff
commit ad24699661
5 changed files with 334 additions and 83 deletions

View file

@ -1013,21 +1013,16 @@ function App() {
voiceRef.current = voice
// Video chat mode: while on, the webcam runs and frames are attached to
// every outgoing message so the assistant can see the user.
// every outgoing message so the assistant can see the user. 'chat' keeps
// the normal composer; 'call' is fully hands-free — continuous listening,
// auto-submitted utterances, spoken responses (handler defined below, after
// the voice/submit plumbing it drives).
const video = useVideoMode()
const [videoEnabled, setVideoEnabled] = useState(false)
const videoEnabledRef = useRef(false)
const handleToggleVideo = useCallback(async () => {
if (videoEnabledRef.current) {
video.stop()
videoEnabledRef.current = false
setVideoEnabled(false)
} else {
const ok = await video.start()
videoEnabledRef.current = ok
setVideoEnabled(ok)
}
}, [video])
const [videoChatMode, setVideoChatMode] = useState<'off' | 'chat' | 'call'>('off')
const videoChatModeRef = useRef<'off' | 'chat' | 'call'>('off')
// TTS settings to restore when a hands-free call ends (a call forces
// full-read-aloud TTS for its duration).
const preCallTtsRef = useRef<{ enabled: boolean; mode: 'summary' | 'full' } | null>(null)
const handleToggleMeetingRef = useRef<(() => void) | undefined>(undefined)
const meetingTranscription = useMeetingTranscription(() => {
@ -1087,6 +1082,8 @@ function App() {
}, [])
const handleStartRecording = useCallback(() => {
// Hands-free call mode owns the mic — ignore push-to-talk while it's on.
if (videoChatModeRef.current === 'call') return
setIsRecording(true)
isRecordingRef.current = true
voice.start()
@ -1148,6 +1145,69 @@ function App() {
isRecordingRef.current = false
}, [voice])
// Video chat mode transitions. 'chat' just runs the camera; 'call' also
// listens continuously (auto-submitting each utterance as a voice message)
// and forces full read-aloud TTS so the assistant answers out loud.
const handleVideoModeChange = useCallback(async (mode: 'off' | 'chat' | 'call') => {
const prev = videoChatModeRef.current
if (mode === prev) return
// Leaving a call: release the mic and restore the pre-call TTS settings.
if (prev === 'call') {
voiceRef.current.cancel()
const saved = preCallTtsRef.current
preCallTtsRef.current = null
const restoreEnabled = saved?.enabled ?? false
setTtsEnabled(restoreEnabled)
ttsEnabledRef.current = restoreEnabled
if (saved) {
setTtsMode(saved.mode)
ttsModeRef.current = saved.mode
}
if (!restoreEnabled) ttsRef.current.cancel()
}
if (mode === 'off') {
video.stop()
videoChatModeRef.current = 'off'
setVideoChatMode('off')
return
}
if (prev === 'off') {
const ok = await video.start()
if (!ok) return // camera denied/unavailable — stay off
}
if (mode === 'call') {
// A manual push-to-talk recording can't coexist with the call's mic.
if (isRecordingRef.current) {
voiceRef.current.cancel()
setIsRecording(false)
isRecordingRef.current = false
}
preCallTtsRef.current = { enabled: ttsEnabledRef.current, mode: ttsModeRef.current }
setTtsEnabled(true)
ttsEnabledRef.current = true
setTtsMode('full')
ttsModeRef.current = 'full'
void voiceRef.current.startContinuous((text) => {
pendingVoiceInputRef.current = true
handlePromptSubmitRef.current?.({ text, files: [] })
})
}
videoChatModeRef.current = mode
setVideoChatMode(mode)
}, [video])
// During a call, mute the mic while the assistant is thinking or speaking
// so its own TTS (or a half-turn) never gets transcribed back at it.
useEffect(() => {
if (videoChatMode !== 'call') return
voiceRef.current.setPaused(activeIsProcessing || tts.state !== 'idle')
}, [videoChatMode, activeIsProcessing, tts.state])
// Enter to submit voice input, Escape to cancel
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@ -2629,7 +2689,7 @@ function App() {
// Video chat mode: drain the webcam frames buffered since the last send
// so they ride along with this message as inline image parts.
const videoFrames = videoEnabledRef.current ? video.collectFrames() : []
const videoFrames = videoChatModeRef.current !== 'off' ? video.collectFrames() : []
const userMessageId = `user-${Date.now()}`
const displayAttachments: ChatMessage['attachments'] = hasAttachments || videoFrames.length > 0
@ -2700,7 +2760,7 @@ function App() {
...(ttsEnabledRef.current ? { voiceOutput: ttsModeRef.current } : {}),
...(searchEnabled ? { searchEnabled: true } : {}),
...(codeMode ? { codeMode } : {}),
...(videoEnabledRef.current ? { videoMode: true } : {}),
...(videoChatModeRef.current !== 'off' ? { videoMode: true } : {}),
},
},
},
@ -6387,8 +6447,9 @@ function App() {
onTtsModeChange={isActive ? handleTtsModeChange : undefined}
ttsAvatarEnabled={ttsAvatarEnabled}
onToggleTtsAvatar={isActive ? handleToggleTtsAvatar : undefined}
videoEnabled={videoEnabled}
onToggleVideo={isActive ? handleToggleVideo : undefined}
videoChatMode={videoChatMode}
onVideoModeChange={isActive ? handleVideoModeChange : undefined}
videoCallAvailable={voiceAvailable && ttsAvailable}
/>
</div>
)
@ -6502,14 +6563,28 @@ function App() {
onTtsModeChange={handleTtsModeChange}
ttsAvatarEnabled={ttsAvatarEnabled}
onToggleTtsAvatar={handleToggleTtsAvatar}
videoEnabled={videoEnabled}
onToggleVideo={handleToggleVideo}
videoChatMode={videoChatMode}
onVideoModeChange={handleVideoModeChange}
videoCallAvailable={voiceAvailable && ttsAvailable}
onComposioConnected={handleComposioConnected}
/>
)}
{/* Webcam PiP preview while video chat mode is on */}
{videoEnabled && (
<VideoPreviewOverlay streamRef={video.streamRef} onTurnOff={handleToggleVideo} />
{videoChatMode !== 'off' && (
<VideoPreviewOverlay
streamRef={video.streamRef}
onTurnOff={() => handleVideoModeChange('off')}
callStatus={
videoChatMode === 'call'
? tts.state !== 'idle'
? 'speaking'
: activeIsProcessing
? 'thinking'
: 'listening'
: undefined
}
interimText={videoChatMode === 'call' ? voice.interimText : undefined}
/>
)}
{/* Talking head hovers over the active view while avatar voice mode is
on (hidden during the tour, which shows its own mascot) */}

View file

@ -239,9 +239,12 @@ interface ChatInputInnerProps {
onTtsModeChange?: (mode: 'summary' | 'full') => void
ttsAvatarEnabled?: boolean
onToggleTtsAvatar?: () => void
/** Video chat mode: live webcam frames are attached to each message. */
videoEnabled?: boolean
onToggleVideo?: () => void
/** Video chat mode: 'chat' attaches webcam frames to messages; 'call' is
* fully hands-free continuous listening, spoken responses. */
videoChatMode?: 'off' | 'chat' | 'call'
onVideoModeChange?: (mode: 'off' | 'chat' | 'call') => void
/** Hands-free call needs both voice input (STT) and voice output (TTS). */
videoCallAvailable?: boolean
/** Fired when the user picks a different model in the dropdown (only when no run exists yet). */
onSelectedModelChange?: (model: SelectedModel | null) => void
/** Work directory for this chat (per-chat). Null when none is set. */
@ -282,8 +285,9 @@ function ChatInputInner({
onTtsModeChange,
ttsAvatarEnabled,
onToggleTtsAvatar,
videoEnabled,
onToggleVideo,
videoChatMode = 'off',
onVideoModeChange,
videoCallAvailable,
onSelectedModelChange,
workDir = null,
onWorkDirChange,
@ -1356,32 +1360,61 @@ function ChatInputInner({
</TooltipContent>
</Tooltip>
)}
{onToggleVideo && (
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
<button
type="button"
onClick={onToggleVideo}
className={cn(
'relative flex h-7 w-7 shrink-0 items-center justify-center rounded-full transition-colors',
videoEnabled
? 'text-red-500 hover:bg-muted'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
aria-label={videoEnabled ? 'Turn off video chat' : 'Turn on video chat'}
>
<Video className="h-4 w-4" />
{!videoEnabled && (
<span className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span className="block h-[1.5px] w-5 -rotate-45 rounded-full bg-muted-foreground" />
</span>
)}
</button>
</TooltipTrigger>
<TooltipContent side="top">
{videoEnabled ? 'Video chat on — frames are shared with the assistant' : 'Video chat off'}
</TooltipContent>
</Tooltip>
{onVideoModeChange && (
<div className="flex shrink-0 items-center">
<Tooltip delayDuration={CHAT_INPUT_TOOLTIP_DELAY_MS}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => onVideoModeChange(videoChatMode === 'off' ? 'chat' : 'off')}
className={cn(
'relative flex h-7 w-7 shrink-0 items-center justify-center rounded-full transition-colors',
videoChatMode !== 'off'
? 'text-red-500 hover:bg-muted'
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
)}
aria-label={videoChatMode === 'off' ? 'Turn on video chat' : 'Turn off video chat'}
>
<Video className="h-4 w-4" />
{videoChatMode === 'off' && (
<span className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span className="block h-[1.5px] w-5 -rotate-45 rounded-full bg-muted-foreground" />
</span>
)}
</button>
</TooltipTrigger>
<TooltipContent side="top">
{videoChatMode === 'off'
? 'Video chat off'
: videoChatMode === 'chat'
? 'Video chat on — camera frames are shared with the assistant'
: 'Video call on — speak freely, the assistant answers out loud'}
</TooltipContent>
</Tooltip>
{videoChatMode !== 'off' && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-7 w-4 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
>
<ChevronDown className="h-3 w-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup
value={videoChatMode}
onValueChange={(v) => onVideoModeChange(v as 'chat' | 'call')}
>
<DropdownMenuRadioItem value="chat">Video + chat</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="call" disabled={!videoCallAvailable}>
Video call (hands-free)
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)}
{voiceAvailable && onStartRecording && (
<button
@ -1559,8 +1592,9 @@ export interface ChatInputWithMentionsProps {
onTtsModeChange?: (mode: 'summary' | 'full') => void
ttsAvatarEnabled?: boolean
onToggleTtsAvatar?: () => void
videoEnabled?: boolean
onToggleVideo?: () => void
videoChatMode?: 'off' | 'chat' | 'call'
onVideoModeChange?: (mode: 'off' | 'chat' | 'call') => void
videoCallAvailable?: boolean
onSelectedModelChange?: (model: SelectedModel | null) => void
workDir?: string | null
onWorkDirChange?: (value: string | null) => void
@ -1597,8 +1631,9 @@ export function ChatInputWithMentions({
onTtsModeChange,
ttsAvatarEnabled,
onToggleTtsAvatar,
videoEnabled,
onToggleVideo,
videoChatMode,
onVideoModeChange,
videoCallAvailable,
onSelectedModelChange,
workDir,
onWorkDirChange,
@ -1632,8 +1667,9 @@ export function ChatInputWithMentions({
onTtsModeChange={onTtsModeChange}
ttsAvatarEnabled={ttsAvatarEnabled}
onToggleTtsAvatar={onToggleTtsAvatar}
videoEnabled={videoEnabled}
onToggleVideo={onToggleVideo}
videoChatMode={videoChatMode}
onVideoModeChange={onVideoModeChange}
videoCallAvailable={videoCallAvailable}
onSelectedModelChange={onSelectedModelChange}
workDir={workDir}
onWorkDirChange={onWorkDirChange}

View file

@ -194,8 +194,9 @@ interface ChatSidebarProps {
onTtsModeChange?: (mode: 'summary' | 'full') => void
ttsAvatarEnabled?: boolean
onToggleTtsAvatar?: () => void
videoEnabled?: boolean
onToggleVideo?: () => void
videoChatMode?: 'off' | 'chat' | 'call'
onVideoModeChange?: (mode: 'off' | 'chat' | 'call') => void
videoCallAvailable?: boolean
onComposioConnected?: (toolkitSlug: string) => void
}
@ -262,8 +263,9 @@ export function ChatSidebar({
onTtsModeChange,
ttsAvatarEnabled,
onToggleTtsAvatar,
videoEnabled,
onToggleVideo,
videoChatMode,
onVideoModeChange,
videoCallAvailable,
onComposioConnected,
}: ChatSidebarProps) {
const { state: sidebarState } = useSidebar()
@ -840,8 +842,9 @@ export function ChatSidebar({
onTtsModeChange={isActive ? onTtsModeChange : undefined}
ttsAvatarEnabled={ttsAvatarEnabled}
onToggleTtsAvatar={isActive ? onToggleTtsAvatar : undefined}
videoEnabled={videoEnabled}
onToggleVideo={isActive ? onToggleVideo : undefined}
videoChatMode={videoChatMode}
onVideoModeChange={isActive ? onVideoModeChange : undefined}
videoCallAvailable={videoCallAvailable}
/>
</div>
)

View file

@ -5,6 +5,16 @@ interface VideoPreviewOverlayProps {
/** Live camera stream from useVideoMode — attached to the preview element. */
streamRef: React.MutableRefObject<MediaStream | null>
onTurnOff: () => void
/** Hands-free call mode: current phase of the conversation loop. */
callStatus?: 'listening' | 'thinking' | 'speaking'
/** Hands-free call mode: live transcript of the in-progress utterance. */
interimText?: string
}
const CALL_STATUS_DISPLAY: Record<NonNullable<VideoPreviewOverlayProps['callStatus']>, { label: string; dotClass: string }> = {
listening: { label: 'Listening', dotClass: 'bg-green-500 animate-pulse' },
thinking: { label: 'Thinking…', dotClass: 'bg-amber-400' },
speaking: { label: 'Speaking', dotClass: 'bg-sky-400 animate-pulse' },
}
/**
@ -12,7 +22,7 @@ interface VideoPreviewOverlayProps {
* on. Mirrored like a selfie camera so the user's movements feel natural.
* Sits above the composer dock, mirroring the talking-head overlay's corner.
*/
export function VideoPreviewOverlay({ streamRef, onTurnOff }: VideoPreviewOverlayProps) {
export function VideoPreviewOverlay({ streamRef, onTurnOff, callStatus, interimText }: VideoPreviewOverlayProps) {
const videoRef = useRef<HTMLVideoElement | null>(null)
useEffect(() => {
@ -52,9 +62,25 @@ export function VideoPreviewOverlay({ streamRef, onTurnOff }: VideoPreviewOverla
style={{ transform: 'scaleX(-1)' }}
/>
<span className="pointer-events-none absolute bottom-1.5 left-1.5 flex items-center gap-1 rounded-full bg-black/60 px-1.5 py-0.5 text-[10px] font-medium text-white">
<span className="block h-1.5 w-1.5 animate-pulse rounded-full bg-red-500" />
Video on
{callStatus ? (
<>
<span className={`block h-1.5 w-1.5 rounded-full ${CALL_STATUS_DISPLAY[callStatus].dotClass}`} />
{CALL_STATUS_DISPLAY[callStatus].label}
</>
) : (
<>
<span className="block h-1.5 w-1.5 animate-pulse rounded-full bg-red-500" />
Video on
</>
)}
</span>
{callStatus && interimText && (
<div className="pointer-events-none absolute inset-x-0 -bottom-1 translate-y-full pt-1">
<div className="max-h-16 overflow-hidden rounded-lg bg-black/60 px-2 py-1 text-[11px] leading-snug text-white/90">
{interimText}
</div>
</div>
)}
</div>
)
}

View file

@ -19,7 +19,25 @@ const DEEPGRAM_PARAMS = new URLSearchParams({
endpointing: '100',
no_delay: 'true',
});
const DEEPGRAM_LISTEN_URL = `wss://api.deepgram.com/v1/listen?${DEEPGRAM_PARAMS.toString()}`;
// Hands-free (continuous) mode uses a much longer endpoint than push-to-talk:
// speech_final fires only after this much silence, so a thinking pause doesn't
// cut the user off mid-sentence during a call.
const CONTINUOUS_ENDPOINTING_MS = 1800;
// While the mic is paused (assistant speaking), keep the idle Deepgram socket
// alive — it closes after ~10s without audio otherwise.
const KEEPALIVE_INTERVAL_MS = 5000;
function deepgramParams(continuous: boolean): URLSearchParams {
if (!continuous) return DEEPGRAM_PARAMS;
const params = new URLSearchParams(DEEPGRAM_PARAMS);
params.set('endpointing', String(CONTINUOUS_ENDPOINTING_MS));
// Second end-of-speech signal: speech_final can be missed (it often rides
// on a result with an empty transcript, or never fires when background
// noise keeps the endpointer engaged). UtteranceEnd is word-timing based
// and arrives as its own message type, so we listen for both.
params.set('utterance_end_ms', '2000');
return params;
}
// Cap on retained per-frame amplitude samples (~64ms/frame ⇒ ~5 min of history).
// The waveform only ever displays the most recent window, so older samples are dropped.
@ -53,6 +71,11 @@ export function useVoiceMode() {
const audioLevelsRef = useRef<number[]>([]);
// Running peak amplitude for the waveform auto-gain (see PEAK_DECAY/MIN_PEAK).
const audioPeakRef = useRef(0);
// Hands-free mode: invoked with each completed utterance (speech_final).
const continuousCbRef = useRef<((text: string) => void) | null>(null);
// While true (assistant is speaking), mic audio is dropped instead of streamed.
const pausedRef = useRef(false);
const keepAliveTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Refresh cached auth details (called on warmup, not on mic click)
const refreshAuth = useCallback(async () => {
@ -71,9 +94,22 @@ export function useVoiceMode() {
}
}, [refreshRowboatAccount]);
// Hands-free mode: flush the accumulated utterance to the callback.
// Called on either end-of-speech signal (speech_final or UtteranceEnd);
// both may fire for the same utterance — the second finds an empty
// buffer and is a no-op.
const fireContinuousUtterance = useCallback(() => {
if (!continuousCbRef.current || pausedRef.current) return;
const utterance = transcriptBufferRef.current.trim();
transcriptBufferRef.current = '';
interimRef.current = '';
setInterimText('');
if (utterance) continuousCbRef.current(utterance);
}, []);
// Create and connect a Deepgram WebSocket using cached auth.
// Starts the connection and returns immediately (does not wait for open).
const connectWs = useCallback(async () => {
const connectWs = useCallback(async (continuous = false) => {
if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) return;
// Refresh auth if we don't have it cached yet
@ -82,12 +118,13 @@ export function useVoiceMode() {
}
if (!cachedAuth) return;
const params = deepgramParams(continuous);
let ws: WebSocket;
if (cachedAuth.type === 'rowboat') {
const listenUrl = buildDeepgramListenUrl(cachedAuth.url, DEEPGRAM_PARAMS);
const listenUrl = buildDeepgramListenUrl(cachedAuth.url, params);
ws = new WebSocket(listenUrl, ['bearer', cachedAuth.token]);
} else {
ws = new WebSocket(DEEPGRAM_LISTEN_URL, ['token', cachedAuth.apiKey]);
ws = new WebSocket(`wss://api.deepgram.com/v1/listen?${params.toString()}`, ['token', cachedAuth.apiKey]);
}
wsRef.current = ws;
@ -103,16 +140,35 @@ export function useVoiceMode() {
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (!data.channel?.alternatives?.[0]) return;
// Hands-free mode: word-timing based end-of-speech marker.
if (data.type === 'UtteranceEnd') {
fireContinuousUtterance();
return;
}
if (!data.channel?.alternatives?.[0]) return;
const transcript = data.channel.alternatives[0].transcript;
if (!transcript) return;
if (data.is_final) {
transcriptBufferRef.current += (transcriptBufferRef.current ? ' ' : '') + transcript;
interimRef.current = '';
setInterimText(transcriptBufferRef.current);
} else {
// NOTE: the endpoint marker (speech_final) usually arrives on a
// result whose transcript is EMPTY — the silence after the user
// stops talking. Empty finals must still reach the speech_final
// check below or hands-free utterances never complete.
if (transcript) {
transcriptBufferRef.current += (transcriptBufferRef.current ? ' ' : '') + transcript;
interimRef.current = '';
}
// Hands-free mode: an endpoint completes the utterance — hand
// it off and reset for the next one.
if (continuousCbRef.current && data.speech_final) {
fireContinuousUtterance();
return;
}
if (transcript) {
setInterimText(transcriptBufferRef.current);
}
} else if (transcript) {
interimRef.current = transcript;
setInterimText(transcriptBufferRef.current + (transcriptBufferRef.current ? ' ' : '') + transcript);
}
@ -127,8 +183,17 @@ export function useVoiceMode() {
ws.onclose = () => {
console.log('[voice] WebSocket closed');
wsRef.current = null;
// A hands-free call is long-lived — if the socket drops while the
// call is still on, reconnect instead of silently going deaf.
if (continuousCbRef.current) {
setTimeout(() => {
if (continuousCbRef.current && !wsRef.current) {
void connectWs(true);
}
}, 1000);
}
};
}, [refreshAuth]);
}, [refreshAuth, fireContinuousUtterance]);
const waitForWsOpen = useCallback(async (timeoutMs = 1500): Promise<boolean> => {
const ws = wsRef.current;
@ -191,6 +256,12 @@ export function useVoiceMode() {
wsRef.current.close();
wsRef.current = null;
}
continuousCbRef.current = null;
pausedRef.current = false;
if (keepAliveTimerRef.current) {
clearInterval(keepAliveTimerRef.current);
keepAliveTimerRef.current = null;
}
audioBufferRef.current = [];
audioLevelsRef.current = [];
audioPeakRef.current = 0;
@ -200,7 +271,7 @@ export function useVoiceMode() {
setState('idle');
}, [stopInputCapture]);
const start = useCallback(async () => {
const start = useCallback(async (continuous = false) => {
if (state !== 'idle') return;
transcriptBufferRef.current = '';
@ -235,7 +306,7 @@ export function useVoiceMode() {
console.error('Microphone access denied:', err);
return null;
}),
connectWs(),
connectWs(continuous),
]);
if (!stream) {
@ -259,6 +330,9 @@ export function useVoiceMode() {
processorRef.current = processor;
processor.onaudioprocess = (e) => {
// Paused (assistant is speaking in a call): drop mic audio so the
// assistant's own TTS never gets transcribed back at it.
if (pausedRef.current) return;
const float32 = e.inputBuffer.getChannelData(0);
const int16 = new Int16Array(float32.length);
let sumSquares = 0;
@ -283,8 +357,13 @@ export function useVoiceMode() {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(buffer);
} else {
// WebSocket still connecting — buffer the audio
// WebSocket still connecting (or reconnecting mid-call) —
// buffer the audio, bounded so an unreachable server during a
// long call can't grow it without limit (~30s at 64ms/chunk).
audioBufferRef.current.push(buffer);
if (audioBufferRef.current.length > 500) {
audioBufferRef.current.shift();
}
}
};
@ -318,10 +397,42 @@ export function useVoiceMode() {
stopAudioCapture();
}, [stopAudioCapture]);
/**
* Hands-free (call) mode: listen continuously and invoke `onUtterance`
* with each completed utterance. Runs until cancel()/stop.
*/
const startContinuous = useCallback(async (onUtterance: (text: string) => void) => {
continuousCbRef.current = onUtterance;
await start(true);
}, [start]);
/**
* Mute/unmute the continuous stream (used while the assistant is
* thinking/speaking). Keeps the Deepgram socket alive with KeepAlives and
* discards any half-heard utterance from before the pause.
*/
const setPaused = useCallback((paused: boolean) => {
if (pausedRef.current === paused) return;
pausedRef.current = paused;
if (paused) {
transcriptBufferRef.current = '';
interimRef.current = '';
setInterimText('');
keepAliveTimerRef.current = setInterval(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'KeepAlive' }));
}
}, KEEPALIVE_INTERVAL_MS);
} else if (keepAliveTimerRef.current) {
clearInterval(keepAliveTimerRef.current);
keepAliveTimerRef.current = null;
}
}, []);
/** Pre-cache auth details so mic click skips IPC round-trips */
const warmup = useCallback(() => {
refreshAuth().catch(() => {});
}, [refreshAuth]);
return { state, interimText, audioLevelsRef, start, submit, cancel, warmup };
return { state, interimText, audioLevelsRef, start, submit, cancel, warmup, startContinuous, setPaused };
}