Files
triqura-ecd/hooks/use-deepgram-streaming.ts
colinislit dce3943963 feat: rapportage UI refactor, speech streaming, docs & seed data
Rapportage:
- Refactor workspace into modular components (quick-actions, timeline-card, timeline-sidebar)
- Add updateReport action for inline editing
- Improve report timeline with better UX

Speech:
- Add Deepgram token API endpoint
- Add use-deepgram-streaming hook
- Add confidence-text component

Docs:
- Add architecture documentation
- Add performance optimization plan
- Add speech specs and seed data docs

Scripts & Data:
- Add seed-reports script and migration
- Update AGENTS.md guidelines

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 14:07:08 +01:00

423 lines
14 KiB
TypeScript

'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<void>
/** 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
}
// ─────────────────────────────────────────────────────────────────────────────
// 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
// ─────────────────────────────────────────────────────────────────────────────
// Hook Implementation
// ─────────────────────────────────────────────────────────────────────────────
export function useDeepgramStreaming({
onTranscript,
onError,
onStatusChange,
language = 'nl',
model = 'nova-2',
endpointingMs = 3000,
}: UseDeepgramStreamingProps): UseDeepgramStreamingReturn {
// State
const [status, setStatus] = useState<ConnectionStatus>('disconnected')
const [isRecording, setIsRecording] = useState(false)
const [isPaused, setIsPaused] = useState(false)
const [error, setError] = useState<string | null>(null)
const [analyserNode, setAnalyserNode] = useState<AnalyserNode | null>(null)
const [isBrowserSupported, setIsBrowserSupported] = useState(true)
// Check browser support on mount
useEffect(() => {
setIsBrowserSupported(checkBrowserSupport())
}, [])
// Refs
const liveClientRef = useRef<LiveClient | null>(null)
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
const mediaStreamRef = useRef<MediaStream | null>(null)
const audioContextRef = useRef<AudioContext | null>(null)
const reconnectAttemptsRef = useRef(0)
const shouldReconnectRef = useRef(false)
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])
// Status update helper
const updateStatus = useCallback((newStatus: ConnectionStatus) => {
setStatus(newStatus)
onStatusChangeRef.current?.(newStatus)
}, [])
// Fetch token van onze proxy endpoint
const fetchToken = useCallback(async (): Promise<string> => {
const response = await fetch('/api/deepgram/token', { method: 'POST' })
if (!response.ok) {
const data = await response.json().catch(() => ({}))
if (response.status === 429) {
throw new Error('Rate limit bereikt. Probeer over een uur opnieuw.')
}
if (response.status === 401) {
throw new Error('Niet ingelogd. Log opnieuw in.')
}
throw new Error(data.error || 'Kon geen token ophalen')
}
const data = await response.json()
return data.token
}, [])
// Setup WebSocket verbinding met Deepgram
const connect = useCallback(async () => {
try {
updateStatus('connecting')
setError(null)
const token = await fetchToken()
const deepgram = createClient(token)
const connection = deepgram.listen.live({
model,
language,
smart_format: true,
interim_results: true,
endpointing: endpointingMs,
punctuate: true,
utterances: true,
})
// Event handlers
connection.on(LiveTranscriptionEvents.Open, () => {
updateStatus('connected')
reconnectAttemptsRef.current = 0
setError(null)
})
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)
setError(err.message || 'WebSocket fout')
updateStatus('error')
onErrorRef.current?.(err)
// Probeer te reconnecten als we nog aan het opnemen waren
if (shouldReconnectRef.current) {
handleReconnect()
}
})
connection.on(LiveTranscriptionEvents.Close, () => {
if (shouldReconnectRef.current && reconnectAttemptsRef.current < MAX_RECONNECT_ATTEMPTS) {
handleReconnect()
} else {
updateStatus('disconnected')
}
})
liveClientRef.current = connection
} catch (err) {
console.error('Connection error:', err)
const errorMessage = err instanceof Error ? err.message : 'Verbindingsfout'
setError(errorMessage)
updateStatus('error')
onErrorRef.current?.(err instanceof Error ? err : new Error(errorMessage))
if (shouldReconnectRef.current) {
handleReconnect()
}
}
}, [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
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) {
await connect()
}
}, [connect, updateStatus])
// Setup audio stream en analyser
const setupAudio = useCallback(async () => {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
mediaStreamRef.current = stream
// Setup Web Audio API voor waveform
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
// 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)
}
}
mediaRecorder.start(AUDIO_CHUNK_INTERVAL_MS)
mediaRecorderRef.current = mediaRecorder
}, [])
// Start opname
const startRecording = useCallback(async () => {
try {
setError(null)
shouldReconnectRef.current = true
// Eerst WebSocket verbinding opzetten
await connect()
// Dan audio stream starten
await setupAudio()
setIsRecording(true)
setIsPaused(false)
} catch (err) {
console.error('Start recording 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)
}
updateStatus('error')
onErrorRef.current?.(err instanceof Error ? err : new Error(errorMessage))
}
}, [connect, setupAudio, updateStatus])
// Stop opname
const stopRecording = useCallback(() => {
shouldReconnectRef.current = false
// Stop MediaRecorder
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop()
}
mediaRecorderRef.current = null
// Stop alle audio tracks
if (mediaStreamRef.current) {
mediaStreamRef.current.getTracks().forEach((track) => track.stop())
}
mediaStreamRef.current = null
// Close AudioContext
if (audioContextRef.current) {
audioContextRef.current.close()
}
audioContextRef.current = null
setAnalyserNode(null)
// Close WebSocket
if (liveClientRef.current) {
liveClientRef.current.requestClose()
}
liveClientRef.current = null
setIsRecording(false)
setIsPaused(false)
updateStatus('disconnected')
}, [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,
}
}