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',
  }
}