'use client'; import { useState, useCallback, useRef } from 'react'; import type { RealtimeServerEvent, AgentStatus } from '../types/realtime-events'; interface UseWebRTCVoiceReturn { isConnected: boolean; isConnecting: boolean; error: string | null; stream: MediaStream | null; agentStatus: AgentStatus; connect: (deviceId?: string) => Promise; disconnect: () => void; } interface UseWebRTCVoiceOptions { onEvent?: (event: RealtimeServerEvent) => void; onStatusChange?: (status: AgentStatus) => void; } export function useWebRTCVoice(options: UseWebRTCVoiceOptions = {}): UseWebRTCVoiceReturn { const { onEvent, onStatusChange } = options; const [isConnected, setIsConnected] = useState(false); const [isConnecting, setIsConnecting] = useState(false); const [error, setError] = useState(null); const [stream, setStream] = useState(null); const [agentStatus, setAgentStatus] = useState('disconnected'); const peerConnectionRef = useRef(null); const dataChannelRef = useRef(null); const audioElementRef = useRef(null); const streamRef = useRef(null); const connect = useCallback(async (deviceId?: string) => { if (isConnecting || isConnected) return; setIsConnecting(true); setError(null); try { if (!window.isSecureContext) { throw new Error('HTTPS required. Please access this app over HTTPS or localhost.'); } if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { throw new Error('Media devices not supported in this browser.'); } const audioConstraints: MediaTrackConstraints = { echoCancellation: true, noiseSuppression: true, autoGainControl: true, }; if (deviceId) { audioConstraints.deviceId = { exact: deviceId }; } const micStream = await navigator.mediaDevices.getUserMedia({ audio: audioConstraints }); streamRef.current = micStream; setStream(micStream); const basePath = process.env.NEXT_PUBLIC_BASE_PATH || ''; const response = await fetch(`${basePath}/api/session`, { method: 'POST' }); if (!response.ok) { throw new Error('Failed to get session token'); } const { client_secret } = await response.json(); const pc = new RTCPeerConnection(); peerConnectionRef.current = pc; micStream.getTracks().forEach(track => pc.addTrack(track, micStream)); const dc = pc.createDataChannel('oai-events'); dataChannelRef.current = dc; // Listen for data channel messages (realtime events from server) dc.onmessage = (event) => { try { const data = JSON.parse(event.data) as RealtimeServerEvent; onEvent?.(data); } catch (err) { console.error('Failed to parse data channel message:', err); } }; dc.onopen = () => { console.log('Data channel opened'); }; dc.onclose = () => { console.log('Data channel closed'); }; pc.ontrack = (event) => { const audioElement = new Audio(); audioElement.autoplay = true; audioElement.srcObject = event.streams[0]; audioElementRef.current = audioElement; audioElement.play().catch(err => { console.error('Error playing audio:', err); }); }; pc.onconnectionstatechange = () => { if (pc.connectionState === 'connected') { setIsConnected(true); setIsConnecting(false); setAgentStatus('connected'); onStatusChange?.('connected'); } else if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') { setError('Connection failed'); setIsConnected(false); setIsConnecting(false); setAgentStatus('disconnected'); onStatusChange?.('disconnected'); } }; const offer = await pc.createOffer(); await pc.setLocalDescription(offer); const sdpResponse = await fetch( `https://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview-2024-12-17`, { method: 'POST', headers: { 'Authorization': `Bearer ${client_secret.value}`, 'Content-Type': 'application/sdp', }, body: offer.sdp, } ); if (!sdpResponse.ok) { throw new Error('Failed to connect to OpenAI'); } const answerSdp = await sdpResponse.text(); const answer: RTCSessionDescriptionInit = { type: 'answer', sdp: answerSdp, }; await pc.setRemoteDescription(answer); } catch (err) { console.error('Connection error:', err); setError(err instanceof Error ? err.message : 'Failed to connect'); setIsConnecting(false); setIsConnected(false); if (streamRef.current) { streamRef.current.getTracks().forEach(track => track.stop()); streamRef.current = null; setStream(null); } } }, [isConnecting, isConnected]); const disconnect = useCallback(() => { if (dataChannelRef.current) { dataChannelRef.current.close(); dataChannelRef.current = null; } if (peerConnectionRef.current) { peerConnectionRef.current.close(); peerConnectionRef.current = null; } if (streamRef.current) { streamRef.current.getTracks().forEach(track => track.stop()); streamRef.current = null; setStream(null); } if (audioElementRef.current) { audioElementRef.current.pause(); audioElementRef.current.srcObject = null; audioElementRef.current = null; } setIsConnected(false); setIsConnecting(false); setError(null); setAgentStatus('disconnected'); }, []); return { isConnected, isConnecting, error, stream, agentStatus, connect, disconnect, }; }