File size: 4,574 Bytes
49cd9e0 d7d3273 49cd9e0 3aea50a 49cd9e0 d7d3273 49cd9e0 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 | import { useRef, useCallback, useEffect } from 'react'
import { useCompanionStore } from '../stores/companionStore'
interface Callbacks {
onAudioReceived: (data: ArrayBuffer) => void
onDone?: () => void
}
export function useCompanionWebSocket(callbacks: Callbacks) {
const wsRef = useRef<WebSocket | null>(null)
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const reconnectAttemptRef = useRef(0)
const callbacksRef = useRef(callbacks)
const manualDisconnectRef = useRef(false)
const serverUrl = useCompanionStore((s) => s.serverUrl)
const status = useCompanionStore((s) => s.status)
const setStatus = useCompanionStore((s) => s.setStatus)
const setSpeaking = useCompanionStore((s) => s.setSpeaking)
const setGenerating = useCompanionStore((s) => s.setGenerating)
const appendTranscript = useCompanionStore((s) => s.appendTranscript)
const appendFullTranscript = useCompanionStore((s) => s.appendFullTranscript)
const resetTranscript = useCompanionStore((s) => s.resetTranscript)
const setError = useCompanionStore((s) => s.setError)
// Keep callbacks ref current
callbacksRef.current = callbacks
const cleanup = useCallback(() => {
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current)
reconnectTimerRef.current = null
}
if (wsRef.current) {
wsRef.current.onopen = null
wsRef.current.onclose = null
wsRef.current.onmessage = null
wsRef.current.onerror = null
wsRef.current.close()
wsRef.current = null
}
}, [])
const connect = useCallback(() => {
manualDisconnectRef.current = false
if (wsRef.current && (wsRef.current.readyState === WebSocket.OPEN || wsRef.current.readyState === WebSocket.CONNECTING)) {
return
}
cleanup()
setStatus('connecting')
setError(null)
resetTranscript()
reconnectAttemptRef.current = 0
const ws = new WebSocket(serverUrl)
ws.binaryType = 'arraybuffer'
wsRef.current = ws
ws.onopen = () => {
setStatus('connected')
}
ws.onclose = () => {
// No auto-reconnect — user clicks "call" to retry
setStatus('disconnected')
}
ws.onerror = () => {
setError('WebSocket connection error')
}
ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
// Binary frame = PCM audio
callbacksRef.current.onAudioReceived(event.data)
} else {
// Text frame = JSON control message
try {
const msg = JSON.parse(event.data)
switch (msg.type) {
case 'vad':
setSpeaking(msg.speaking)
break
case 'generating':
setGenerating(true)
resetTranscript()
break
case 'text':
appendTranscript(msg.content)
break
case 'done':
appendFullTranscript(
useCompanionStore.getState().transcript
)
setGenerating(false)
setSpeaking(false)
callbacksRef.current.onDone?.()
break
}
} catch {
// Ignore malformed messages
}
}
}
}, [serverUrl, cleanup, setStatus, setError, resetTranscript, setSpeaking, setGenerating, appendTranscript, appendFullTranscript])
const disconnect = useCallback(() => {
manualDisconnectRef.current = true
cleanup()
setStatus('disconnected')
setError(null)
}, [cleanup, setStatus])
const sendFrame = useCallback((base64Jpeg: string) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'frame', data: base64Jpeg }))
}
}, [])
const sendAudio = useCallback((buffer: ArrayBuffer) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(buffer)
}
}, [])
const sendStop = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'stop' }))
}
}, [])
const sendText = useCallback((text: string) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'text', content: text }))
}
}, [])
// Cleanup on unmount
useEffect(() => {
return () => {
manualDisconnectRef.current = true
cleanup()
}
}, [cleanup])
return {
connect,
disconnect,
sendFrame,
sendAudio,
sendStop,
sendText,
connected: status === 'connected',
}
}
|