'use client' import { useCallback, useEffect, useRef, useState } from 'react' import { createClient, LiveClient, LiveTranscriptionEvents, type LiveTranscriptionEvent, } from '@deepgram/sdk' // ───────────────────────────────────────────────────────────────────────────── // Types // ───────────────────────────────────────────────────────────────────────────── export type ConnectionStatus = | 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error' export interface TranscriptWord { word: string confidence: number start: number end: number } export interface TranscriptResult { transcript: string isFinal: boolean confidence: number words: TranscriptWord[] speechFinal: boolean } export interface UseDeepgramStreamingProps { /** Callback voor transcript updates (zowel interim als final) */ onTranscript: (result: TranscriptResult) => void /** Callback voor errors */ onError?: (error: Error) => void /** Callback wanneer verbinding status verandert */ onStatusChange?: (status: ConnectionStatus) => void /** Taal voor transcriptie (default: 'nl') */ language?: string /** Deepgram model (default: 'nova-2') */ model?: string /** Endpointing timeout in ms (default: 3000) */ endpointingMs?: number } export interface UseDeepgramStreamingReturn { /** Huidige verbindingsstatus */ status: ConnectionStatus /** Of er actief wordt opgenomen */ isRecording: boolean /** Start opname en streaming */ startRecording: () => Promise /** Stop opname en streaming */ stopRecording: () => void /** Pauzeer opname (behoud verbinding) */ pauseRecording: () => void /** Hervat opname na pause */ resumeRecording: () => void /** Of opname gepauzeerd is */ isPaused: boolean /** Huidige error message (indien aanwezig) */ error: string | null /** Audio analyser node voor waveform visualisatie */ analyserNode: AnalyserNode | null /** Of de browser alle vereiste APIs ondersteunt */ isBrowserSupported: boolean } type DeepgramAuthMode = 'accessToken' | 'apiKey' interface DeepgramTokenResponse { token: string expiresIn: number authMode?: DeepgramAuthMode } function createTokenError(message: string) { const error = new Error(message) error.name = 'DeepgramTokenError' return error } // ───────────────────────────────────────────────────────────────────────────── // Browser Support Check // ───────────────────────────────────────────────────────────────────────────── function checkBrowserSupport(): boolean { if (typeof window === 'undefined') return false const hasMediaDevices = !!(navigator.mediaDevices?.getUserMedia) const hasMediaRecorder = typeof MediaRecorder !== 'undefined' const hasAudioContext = typeof AudioContext !== 'undefined' || typeof (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext !== 'undefined' const hasWebSocket = typeof WebSocket !== 'undefined' return hasMediaDevices && hasMediaRecorder && hasAudioContext && hasWebSocket } // ───────────────────────────────────────────────────────────────────────────── // Constants // ───────────────────────────────────────────────────────────────────────────── const MAX_RECONNECT_ATTEMPTS = 3 const RECONNECT_BASE_DELAY_MS = 1000 const AUDIO_CHUNK_INTERVAL_MS = 250 const CONNECTION_TIMEOUT_MS = 5000 // ───────────────────────────────────────────────────────────────────────────── // Hook Implementation // ───────────────────────────────────────────────────────────────────────────── export function useDeepgramStreaming({ onTranscript, onError, onStatusChange, language = 'nl', model = 'nova-2', endpointingMs = 3000, }: UseDeepgramStreamingProps): UseDeepgramStreamingReturn { // State const [status, setStatus] = useState('disconnected') const [isRecording, setIsRecording] = useState(false) const [isPaused, setIsPaused] = useState(false) const [error, setError] = useState(null) const [analyserNode, setAnalyserNode] = useState(null) const [isBrowserSupported, setIsBrowserSupported] = useState(true) // Check browser support on mount useEffect(() => { setIsBrowserSupported(checkBrowserSupport()) }, []) // Refs const liveClientRef = useRef(null) const mediaRecorderRef = useRef(null) const mediaStreamRef = useRef(null) const audioContextRef = useRef(null) const reconnectAttemptsRef = useRef(0) const shouldReconnectRef = useRef(false) const handleReconnectRef = useRef<(() => Promise) | null>(null) const isPausedRef = useRef(isPaused) // Callback refs voor stabiele referenties const onTranscriptRef = useRef(onTranscript) const onErrorRef = useRef(onError) const onStatusChangeRef = useRef(onStatusChange) useEffect(() => { onTranscriptRef.current = onTranscript onErrorRef.current = onError onStatusChangeRef.current = onStatusChange }, [onTranscript, onError, onStatusChange]) useEffect(() => { isPausedRef.current = isPaused }, [isPaused]) const cleanupRecordingResources = useCallback(() => { if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') { mediaRecorderRef.current.stop() } mediaRecorderRef.current = null if (mediaStreamRef.current) { mediaStreamRef.current.getTracks().forEach((track) => track.stop()) } mediaStreamRef.current = null if (audioContextRef.current) { audioContextRef.current.close() } audioContextRef.current = null setAnalyserNode(null) if (liveClientRef.current) { liveClientRef.current.requestClose() } liveClientRef.current = null }, []) // Status update helper const updateStatus = useCallback((newStatus: ConnectionStatus) => { setStatus(newStatus) onStatusChangeRef.current?.(newStatus) }, []) // Fetch token van onze proxy endpoint const fetchToken = useCallback(async (): Promise => { console.log('[Deepgram] Fetching token from /api/deepgram/token...') const response = await fetch('/api/deepgram/token', { method: 'POST' }) console.log('[Deepgram] Token response status:', response.status) if (!response.ok) { const data = await response.json().catch(() => ({})) console.error('[Deepgram] Token fetch failed:', response.status, data) if (response.status === 429) { throw createTokenError('Rate limit bereikt. Probeer over een uur opnieuw.') } if (response.status === 401) { throw createTokenError('Niet ingelogd. Log opnieuw in.') } throw createTokenError(data.error || 'Kon geen token ophalen') } const data = (await response.json()) as DeepgramTokenResponse console.log('[Deepgram] Token received, expiresIn:', data.expiresIn, 'authMode:', data.authMode || 'accessToken') return data }, []) // Setup WebSocket verbinding met Deepgram const connect = useCallback(async () => { console.log('[Deepgram] connect() called') try { updateStatus('connecting') setError(null) const tokenResponse = await fetchToken() const authMode = tokenResponse.authMode || 'accessToken' console.log('[Deepgram] Creating Deepgram client with authMode:', authMode) const deepgram = authMode === 'apiKey' ? createClient(tokenResponse.token) : createClient({ accessToken: tokenResponse.token }) console.log('[Deepgram] Creating live connection with options:', { model, language, endpointingMs }) const connection = deepgram.listen.live({ model, language, smart_format: true, interim_results: true, endpointing: endpointingMs, punctuate: true, utterances: true, }) console.log('[Deepgram] Live connection created, setting up event handlers...') liveClientRef.current = connection await new Promise((resolve, reject) => { let opened = false const timeout = window.setTimeout(() => { if (liveClientRef.current === connection) { liveClientRef.current = null } connection.requestClose() reject(new Error('Timeout bij verbinden met Deepgram')) }, CONNECTION_TIMEOUT_MS) // Event handlers connection.on(LiveTranscriptionEvents.Open, () => { console.log('[Deepgram] WebSocket OPEN') opened = true window.clearTimeout(timeout) updateStatus('connected') setError(null) resolve() }) connection.on( LiveTranscriptionEvents.Transcript, (data: LiveTranscriptionEvent) => { const alternative = data.channel?.alternatives?.[0] if (!alternative) return const transcript = alternative.transcript || '' if (!transcript.trim()) return const words: TranscriptWord[] = (alternative.words || []).map( (w: { word: string; confidence: number; start: number; end: number }) => ({ word: w.word, confidence: w.confidence, start: w.start, end: w.end, }) ) const result: TranscriptResult = { transcript, isFinal: data.is_final ?? false, confidence: alternative.confidence ?? 1, words, speechFinal: data.speech_final ?? false, } onTranscriptRef.current(result) } ) connection.on(LiveTranscriptionEvents.Error, (err: Error) => { console.error('[Deepgram] WebSocket ERROR:', err) window.clearTimeout(timeout) setError(err.message || 'WebSocket fout') updateStatus('error') onErrorRef.current?.(err) if (!opened) { reject(err) return } // Probeer te reconnecten als we nog aan het opnemen waren if (shouldReconnectRef.current) { handleReconnectRef.current?.() } }) connection.on(LiveTranscriptionEvents.Close, () => { console.log('[Deepgram] WebSocket CLOSE') window.clearTimeout(timeout) if (!opened) { reject(new Error('Deepgram verbinding sloot voordat deze klaar was')) return } if (shouldReconnectRef.current && reconnectAttemptsRef.current < MAX_RECONNECT_ATTEMPTS) { handleReconnectRef.current?.() } else { shouldReconnectRef.current = false cleanupRecordingResources() setIsRecording(false) setIsPaused(false) updateStatus('disconnected') } }) }) console.log('[Deepgram] Connection setup complete') } catch (err) { console.error('[Deepgram] Connection error:', err) const errorMessage = err instanceof Error ? err.message : 'Verbindingsfout' setError(errorMessage) updateStatus('error') onErrorRef.current?.(err instanceof Error ? err : new Error(errorMessage)) shouldReconnectRef.current = false cleanupRecordingResources() setIsRecording(false) setIsPaused(false) throw err } }, [cleanupRecordingResources, fetchToken, model, language, endpointingMs, updateStatus]) // Reconnect met exponential backoff const handleReconnect = useCallback(async () => { if (reconnectAttemptsRef.current >= MAX_RECONNECT_ATTEMPTS) { setError('Kon niet herverbinden na 3 pogingen') updateStatus('error') shouldReconnectRef.current = false cleanupRecordingResources() setIsRecording(false) setIsPaused(false) return } reconnectAttemptsRef.current++ updateStatus('reconnecting') const delay = RECONNECT_BASE_DELAY_MS * Math.pow(2, reconnectAttemptsRef.current - 1) await new Promise((resolve) => setTimeout(resolve, delay)) if (shouldReconnectRef.current) { try { await connect() } catch (err) { console.error('[Deepgram] Reconnect failed:', err) shouldReconnectRef.current = false cleanupRecordingResources() setIsRecording(false) setIsPaused(false) } } }, [cleanupRecordingResources, connect, updateStatus]) handleReconnectRef.current = handleReconnect // Setup audio stream en analyser const setupAudio = useCallback(async () => { console.log('[Deepgram] setupAudio() called') console.log('[Deepgram] Requesting microphone access...') const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) console.log('[Deepgram] Microphone access granted') mediaStreamRef.current = stream // Setup Web Audio API voor waveform console.log('[Deepgram] Setting up AudioContext...') const audioContext = new AudioContext() const source = audioContext.createMediaStreamSource(stream) const analyser = audioContext.createAnalyser() analyser.fftSize = 256 source.connect(analyser) audioContextRef.current = audioContext setAnalyserNode(analyser) // Bepaal beste mimeType (Safari ondersteunt geen audio/webm) const mimeType = MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : MediaRecorder.isTypeSupported('audio/mp4') ? 'audio/mp4' : undefined // Browser default console.log('[Deepgram] Using mimeType:', mimeType || 'browser default') // Setup MediaRecorder voor streaming naar Deepgram const mediaRecorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream) mediaRecorder.ondataavailable = (event) => { if (event.data.size > 0 && liveClientRef.current && !isPausedRef.current) { liveClientRef.current.send(event.data) } } console.log('[Deepgram] Starting MediaRecorder with interval:', AUDIO_CHUNK_INTERVAL_MS) mediaRecorder.start(AUDIO_CHUNK_INTERVAL_MS) mediaRecorderRef.current = mediaRecorder console.log('[Deepgram] Audio setup complete') }, []) // Start opname const startRecording = useCallback(async () => { console.log('[Deepgram] startRecording() called') try { setError(null) shouldReconnectRef.current = true reconnectAttemptsRef.current = 0 // Eerst verbinden en wachten op WebSocket OPEN. Daarna pas de MediaRecorder starten, // zodat het eerste chunk (met WebM/EBML-header) bij Deepgram aankomt. console.log('[Deepgram] Step 1: Connecting to Deepgram...') await connect() console.log('[Deepgram] Step 2: Setting up audio...') await setupAudio() console.log('[Deepgram] Recording started successfully') setIsRecording(true) setIsPaused(false) } catch (err) { console.error('[Deepgram] startRecording error:', err) const errorMessage = err instanceof Error ? err.message : 'Kon opname niet starten' if ( err instanceof Error && (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') ) { setError('Microfoontoegang geweigerd. Sta toegang toe in je browser.') } else { setError(errorMessage) } shouldReconnectRef.current = false cleanupRecordingResources() setIsRecording(false) setIsPaused(false) updateStatus('error') onErrorRef.current?.(err instanceof Error ? err : new Error(errorMessage)) } }, [cleanupRecordingResources, connect, setupAudio, updateStatus]) // Stop opname const stopRecording = useCallback(() => { shouldReconnectRef.current = false cleanupRecordingResources() setIsRecording(false) setIsPaused(false) updateStatus('disconnected') }, [cleanupRecordingResources, updateStatus]) // Pauzeer opname const pauseRecording = useCallback(() => { if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') { mediaRecorderRef.current.pause() setIsPaused(true) } }, []) // Hervat opname const resumeRecording = useCallback(() => { if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'paused') { mediaRecorderRef.current.resume() setIsPaused(false) } }, []) // Cleanup bij unmount useEffect(() => { return () => { shouldReconnectRef.current = false stopRecording() } }, [stopRecording]) return { status, isRecording, startRecording, stopRecording, pauseRecording, resumeRecording, isPaused, error, analyserNode, isBrowserSupported, } }