'use client' import { useCallback, useEffect, useRef, useState } from 'react' import { Loader2, Mic, Pause, Play, Square, Settings, Clock } from 'lucide-react' import { cn } from '@/lib/utils' import { useDeepgramStreaming, type ConnectionStatus, type TranscriptResult, type TranscriptWord, } from '@/hooks/use-deepgram-streaming' import { ConfidencePreview } from '@/components/confidence-text' import { logSpeechUsage, type SpeechTelemetryOptions } from '@/lib/telemetry/speech' // ───────────────────────────────────────────────────────────────────────────── // Types // ───────────────────────────────────────────────────────────────────────────── export interface SpeechRecorderStreamingProps { /** Callback wanneer er een final transcript is */ onTranscript: (transcript: string) => void /** Callback voor interim transcripts (optioneel, voor live preview) */ onInterimTranscript?: (interim: string) => void /** Callback voor woorden met confidence scores */ onWordsUpdate?: (words: TranscriptWord[]) => void /** Disabled state */ disabled?: boolean /** Extra CSS classes */ className?: string /** Callback wanneer opname start (voor cursor positioning) */ onRecordingStart?: () => void /** Callback wanneer opname stopt */ onRecordingStop?: () => void /** Optionele context voor telemetrie */ telemetryContext?: SpeechTelemetryOptions } // ───────────────────────────────────────────────────────────────────────────── // Status Display Component // ───────────────────────────────────────────────────────────────────────────── function StatusIndicator({ status }: { status: ConnectionStatus }) { const config: Record< ConnectionStatus, { icon: string; color: string; label: string } > = { disconnected: { icon: '◯', color: 'text-slate-400', label: 'Niet verbonden', }, connecting: { icon: '◐', color: 'text-amber-500', label: 'Verbinden...', }, connected: { icon: '●', color: 'text-emerald-500', label: 'Verbonden & streaming', }, reconnecting: { icon: '⚠', color: 'text-orange-500', label: 'Herverbinden...', }, error: { icon: '✕', color: 'text-red-500', label: 'Fout', }, } const { icon, color, label } = config[status] return ( {icon} {label} ) } // ───────────────────────────────────────────────────────────────────────────── // Waveform Visualizer Component // ───────────────────────────────────────────────────────────────────────────── function WaveformVisualizer({ analyserNode, isActive, }: { analyserNode: AnalyserNode | null isActive: boolean }) { const canvasRef = useRef(null) const animationRef = useRef(null) useEffect(() => { if (!analyserNode || !canvasRef.current || !isActive) { if (animationRef.current) { cancelAnimationFrame(animationRef.current) animationRef.current = null } return } const canvas = canvasRef.current const ctx = canvas.getContext('2d') if (!ctx) return const bufferLength = analyserNode.frequencyBinCount const dataArray = new Uint8Array(bufferLength) const draw = () => { animationRef.current = requestAnimationFrame(draw) analyserNode.getByteFrequencyData(dataArray) // Clear canvas ctx.fillStyle = '#f8fafc' // slate-50 ctx.fillRect(0, 0, canvas.width, canvas.height) const barCount = 32 const barWidth = (canvas.width - (barCount - 1) * 2) / barCount const maxBarHeight = canvas.height - 8 for (let i = 0; i < barCount; i++) { // Sample from frequency data const dataIndex = Math.floor((i / barCount) * bufferLength) const value = dataArray[dataIndex] / 255 const barHeight = Math.max(4, value * maxBarHeight) const x = i * (barWidth + 2) const y = (canvas.height - barHeight) / 2 // Gradient from slate to emerald based on value const intensity = Math.floor(value * 255) ctx.fillStyle = value > 0.3 ? `rgb(${16 + (1 - value) * 50}, ${185 - (1 - value) * 100}, ${129 - (1 - value) * 50})` : '#64748b' // slate-500 // Rounded bars ctx.beginPath() ctx.roundRect(x, y, barWidth, barHeight, 2) ctx.fill() } } draw() return () => { if (animationRef.current) { cancelAnimationFrame(animationRef.current) } } }, [analyserNode, isActive]) if (!isActive) return null return ( ) } // ───────────────────────────────────────────────────────────────────────────── // Main Component // ───────────────────────────────────────────────────────────────────────────── export function SpeechRecorderStreaming({ onTranscript, onInterimTranscript, onWordsUpdate, disabled = false, className, onRecordingStart, onRecordingStop, telemetryContext, }: SpeechRecorderStreamingProps) { const [interimText, setInterimText] = useState('') const [allWords, setAllWords] = useState([]) const [localError, setLocalError] = useState(null) const [isAutoPaused, setIsAutoPaused] = useState(false) // Accumulate final transcript parts const finalPartsRef = useRef([]) const autoPauseTriggeredRef = useRef(false) const telemetryRef = useRef(telemetryContext) useEffect(() => { telemetryRef.current = telemetryContext }, [telemetryContext]) const trackSpeechUsage = useCallback( (action: 'start' | 'stop' | 'final', metadata?: Record) => { if (!telemetryRef.current) return logSpeechUsage({ ...telemetryRef.current, action, metadata }) }, [] ) const handleTranscript = useCallback( (result: TranscriptResult) => { if (result.isFinal) { // Final transcript - accumulate en stuur naar parent finalPartsRef.current.push(result.transcript) setInterimText('') // Update words met confidence setAllWords((prev) => { const next = [...prev, ...result.words] onWordsUpdate?.(next) return next }) // Stuur volledige tekst naar parent onTranscript(finalPartsRef.current.join(' ')) trackSpeechUsage('final', { chunkLength: result.transcript.length, totalLength: finalPartsRef.current.join(' ').length, }) // Check voor auto-pause na speech_final (3 sec stilte gedetecteerd door Deepgram) if (result.speechFinal && !autoPauseTriggeredRef.current) { autoPauseTriggeredRef.current = true setIsAutoPaused(true) } } else { // Interim transcript - alleen preview setInterimText(result.transcript) onInterimTranscript?.(result.transcript) // Reset auto-pause state bij nieuwe interim speech if (isAutoPaused) { setIsAutoPaused(false) autoPauseTriggeredRef.current = false } } }, [onTranscript, onInterimTranscript, onWordsUpdate, isAutoPaused, trackSpeechUsage] ) const handleError = useCallback((error: Error) => { setLocalError(error.message) }, []) const { status, isRecording, startRecording, stopRecording, pauseRecording, resumeRecording, isPaused, error: hookError, analyserNode, isBrowserSupported, } = useDeepgramStreaming({ onTranscript: handleTranscript, onError: handleError, }) const error = localError || hookError const handleStart = async () => { setLocalError(null) finalPartsRef.current = [] setAllWords([]) setInterimText('') setIsAutoPaused(false) autoPauseTriggeredRef.current = false onRecordingStart?.() await startRecording() trackSpeechUsage('start') } const handleStop = () => { stopRecording() setIsAutoPaused(false) autoPauseTriggeredRef.current = false onRecordingStop?.() trackSpeechUsage('stop', { totalLength: finalPartsRef.current.join(' ').length, }) } const handlePauseResume = () => { if (isPaused || isAutoPaused) { setIsAutoPaused(false) autoPauseTriggeredRef.current = false resumeRecording() } else { pauseRecording() } } const isConnecting = status === 'connecting' const isReconnecting = status === 'reconnecting' const showWaveform = isRecording && !isPaused && !isAutoPaused && status === 'connected' const effectivelyPaused = isPaused || isAutoPaused useEffect(() => { if (isAutoPaused && isRecording && !isPaused) { pauseRecording() } }, [isAutoPaused, isRecording, isPaused, pauseRecording]) return (
{/* Header */}
Opname
{/* Browser not supported message */} {!isBrowserSupported && (
⚠ Spraakopname wordt niet ondersteund in deze browser. Gebruik Chrome, Firefox of Edge voor de beste ervaring.
)} {/* Error message */} {error && (
⚠ {error}
)} {/* Reconnecting message */} {isReconnecting && (
Herverbinden... Transcript blijft behouden.
)} {/* Interim text preview */} {interimText && (
{interimText}
)} {/* Waveform */} {/* Confidence preview */} {allWords.length > 0 && ( )} {/* Auto-pause message (3 sec stilte) */} {isAutoPaused && isRecording && (
Automatisch gepauzeerd (3 seconden stilte)
)} {/* Manual pause message */} {isPaused && !isAutoPaused && isRecording && (
Gepauzeerd
)} {/* Controls */}
{!isRecording ? ( ) : ( <> )}
) }