From 56b66ae6ff151f9e0d75abe5f1a5700a711c8384 Mon Sep 17 00:00:00 2001 From: colinislit Date: Tue, 25 Nov 2025 22:53:04 +0100 Subject: [PATCH] feat: improve streaming mic and deepgram token handling --- app/api/deepgram/token/route.ts | 131 ++++++++-- app/epd/components/epd-sidebar.tsx | 30 ++- .../rapportage/components/report-composer.tsx | 78 +++--- .../components/toolbar-mic-button.tsx | 241 ++++++++++++++++++ components/rich-text-editor.tsx | 2 +- .../screenprint-nieuwe-rapportage-ux-01.png | Bin 0 -> 36866 bytes hooks/use-deepgram-streaming.ts | 38 ++- 7 files changed, 430 insertions(+), 90 deletions(-) create mode 100644 app/epd/patients/[id]/rapportage/components/toolbar-mic-button.tsx create mode 100644 docs/troubleshooting/screenprint-nieuwe-rapportage-ux-01.png diff --git a/app/api/deepgram/token/route.ts b/app/api/deepgram/token/route.ts index befacf4..a55c26b 100644 --- a/app/api/deepgram/token/route.ts +++ b/app/api/deepgram/token/route.ts @@ -2,9 +2,13 @@ import { NextResponse } from 'next/server' import { createClient as createSupabaseClient } from '@/lib/auth/server' import { createClient as createDeepgramClient } from '@deepgram/sdk' -const MAX_TOKENS_PER_HOUR = 10 -const TOKEN_TTL_SECONDS = 3600 +// Rate limiting configuratie +const MAX_TOKENS_PER_USER_PER_HOUR = 5 // Per gebruiker +const MAX_TOKENS_GLOBAL_PER_HOUR = 50 // Totaal voor hele app +const MAX_RECORDING_MINUTES_PER_DAY = 30 // Geschatte minuten per dag (globaal) +const TOKEN_TTL_SECONDS = 600 // Token geldig voor 10 min (korter = veiliger) const HOUR_IN_MS = 60 * 60 * 1000 +const DAY_IN_MS = 24 * 60 * 60 * 1000 type RateLimitEntry = { count: number @@ -15,61 +19,109 @@ type RateLimitResult = { allowed: boolean remaining: number resetAt: number + reason?: string } -const rateLimitStore = new Map() +// Per-user rate limiting +const userRateLimitStore = new Map() + +// Global rate limiting (beschermt tegen misbruik door meerdere users) +let globalHourlyCount = 0 +let globalHourlyResetAt = Date.now() + HOUR_IN_MS +let globalDailyMinutes = 0 +let globalDailyResetAt = Date.now() + DAY_IN_MS function consumeRateLimit(userId: string): RateLimitResult { const now = Date.now() - const entry = rateLimitStore.get(userId) + + // Reset global counters if needed + if (now >= globalHourlyResetAt) { + globalHourlyCount = 0 + globalHourlyResetAt = now + HOUR_IN_MS + } + if (now >= globalDailyResetAt) { + globalDailyMinutes = 0 + globalDailyResetAt = now + DAY_IN_MS + } + + // Check global hourly limit + if (globalHourlyCount >= MAX_TOKENS_GLOBAL_PER_HOUR) { + console.log('[RateLimit] Global hourly limit reached:', globalHourlyCount) + return { + allowed: false, + remaining: 0, + resetAt: globalHourlyResetAt, + reason: 'Globale limiet bereikt. Probeer later opnieuw.', + } + } + + // Check global daily minutes (rough estimate: 1 token ≈ 10 min recording) + const estimatedMinutes = globalHourlyCount * 10 + if (estimatedMinutes >= MAX_RECORDING_MINUTES_PER_DAY) { + console.log('[RateLimit] Daily recording limit reached:', estimatedMinutes, 'min') + return { + allowed: false, + remaining: 0, + resetAt: globalDailyResetAt, + reason: 'Dagelijkse opnamelimiet bereikt.', + } + } + + // Check per-user limit + const entry = userRateLimitStore.get(userId) if (!entry || now >= entry.resetAt) { const resetAt = now + HOUR_IN_MS - rateLimitStore.set(userId, { count: 1, resetAt }) + userRateLimitStore.set(userId, { count: 1, resetAt }) + globalHourlyCount++ return { allowed: true, - remaining: MAX_TOKENS_PER_HOUR - 1, + remaining: MAX_TOKENS_PER_USER_PER_HOUR - 1, resetAt, } } - if (entry.count >= MAX_TOKENS_PER_HOUR) { + if (entry.count >= MAX_TOKENS_PER_USER_PER_HOUR) { return { allowed: false, remaining: 0, resetAt: entry.resetAt, + reason: 'Je hebt de limiet van 5 opnames per uur bereikt.', } } entry.count += 1 + globalHourlyCount++ return { allowed: true, - remaining: MAX_TOKENS_PER_HOUR - entry.count, + remaining: MAX_TOKENS_PER_USER_PER_HOUR - entry.count, resetAt: entry.resetAt, } } function buildRateLimitHeaders(result: RateLimitResult) { return { - 'X-RateLimit-Limit': `${MAX_TOKENS_PER_HOUR}`, + 'X-RateLimit-Limit': `${MAX_TOKENS_PER_USER_PER_HOUR}`, 'X-RateLimit-Remaining': `${Math.max(result.remaining, 0)}`, 'X-RateLimit-Reset': `${Math.floor(result.resetAt / 1000)}`, } } export async function POST() { + console.log('[API /deepgram/token] POST request received') try { const skipAuthCheck = process.env.SKIP_AUTH_CHECK === 'true' let userId = 'demo-user' // Auth check (skip in development/demo mode) if (!skipAuthCheck) { + console.log('[API /deepgram/token] Checking auth...') const supabase = await createSupabaseClient() const { data: authData, error: authError } = await supabase.auth.getUser() if (authError) { - console.error('Deepgram token: auth error', authError) + console.error('[API /deepgram/token] Auth error:', authError) return NextResponse.json( { error: 'Kon sessie niet ophalen' }, { status: 500 } @@ -77,17 +129,23 @@ export async function POST() { } if (!authData?.user) { + console.log('[API /deepgram/token] No user found - returning 401') return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 }) } userId = authData.user.id + console.log('[API /deepgram/token] Auth OK, userId:', userId.slice(0, 8) + '...') } else { - console.log('🔓 Deepgram token: Auth check overgeslagen (demo mode)') + console.log('[API /deepgram/token] Auth check skipped (SKIP_AUTH_CHECK=true)') } - const apiKey = process.env.DEEPGRAM_API_KEY + // Prefer admin key (can generate tokens), fallback to regular key + const apiKey = process.env.DEEPGRAM_ADMIN_API_KEY || process.env.DEEPGRAM_API_KEY + const keyType = process.env.DEEPGRAM_ADMIN_API_KEY ? 'ADMIN' : 'REGULAR' + console.log('[API /deepgram/token] Using', keyType, 'API key, present:', !!apiKey) if (!apiKey) { + console.error('[API /deepgram/token] No Deepgram API key configured!') return NextResponse.json( { error: 'Deepgram API key ontbreekt in server configuratie' }, { status: 500 } @@ -96,14 +154,16 @@ export async function POST() { const rateLimit = consumeRateLimit(userId) const headers = buildRateLimitHeaders(rateLimit) + console.log('[API /deepgram/token] Rate limit check:', { allowed: rateLimit.allowed, remaining: rateLimit.remaining }) if (!rateLimit.allowed) { const retryAfterSeconds = Math.max( 0, Math.ceil((rateLimit.resetAt - Date.now()) / 1000) ) + console.log('[API /deepgram/token] Rate limit exceeded:', rateLimit.reason, 'retry after:', retryAfterSeconds) return NextResponse.json( - { error: 'Rate limit overschreden. Max 10 tokens per uur.' }, + { error: rateLimit.reason || 'Rate limit bereikt.' }, { status: 429, headers: { @@ -114,19 +174,48 @@ export async function POST() { ) } - const deepgram = createDeepgramClient(apiKey) - const tokenResponse = await deepgram.auth.grantToken({ - ttl_seconds: TOKEN_TTL_SECONDS, - }) + // Check if we should use direct API key (for development when grantToken doesn't work) + const useDirectKey = process.env.DEEPGRAM_USE_DIRECT_KEY === 'true' - if (!tokenResponse.result) { - console.error('Deepgram token: API error', tokenResponse.error) + if (useDirectKey) { + console.log('[API /deepgram/token] Using direct API key (DEEPGRAM_USE_DIRECT_KEY=true)') return NextResponse.json( - { error: 'Genereren van tijdelijk token mislukt' }, + { + token: apiKey, + expiresIn: TOKEN_TTL_SECONDS, + }, + { headers } + ) + } + + console.log('[API /deepgram/token] Requesting token from Deepgram...') + console.log('[API /deepgram/token] API key length:', apiKey.length, 'starts with:', apiKey.slice(0, 8)) + const deepgram = createDeepgramClient(apiKey) + + let tokenResponse + try { + tokenResponse = await deepgram.auth.grantToken({ + ttl_seconds: TOKEN_TTL_SECONDS, + }) + console.log('[API /deepgram/token] Deepgram response:', JSON.stringify(tokenResponse, null, 2)) + } catch (deepgramError) { + console.error('[API /deepgram/token] Deepgram SDK threw error:', deepgramError) + return NextResponse.json( + { error: 'Deepgram SDK error: ' + (deepgramError instanceof Error ? deepgramError.message : String(deepgramError)) }, { status: 502, headers } ) } + if (!tokenResponse.result) { + console.error('[API /deepgram/token] Deepgram API error - no result. Full response:', tokenResponse) + console.log('[API /deepgram/token] TIP: Set DEEPGRAM_USE_DIRECT_KEY=true in .env.local to use API key directly') + return NextResponse.json( + { error: 'Genereren van tijdelijk token mislukt', details: tokenResponse.error }, + { status: 502, headers } + ) + } + + console.log('[API /deepgram/token] Token generated successfully, expiresIn:', tokenResponse.result.expires_in) return NextResponse.json( { token: tokenResponse.result.access_token, @@ -135,7 +224,7 @@ export async function POST() { { headers } ) } catch (error) { - console.error('Deepgram token: onverwachte fout', error) + console.error('[API /deepgram/token] Unexpected error:', error) return NextResponse.json( { error: 'Onverwachte fout bij genereren token' }, { status: 500 } diff --git a/app/epd/components/epd-sidebar.tsx b/app/epd/components/epd-sidebar.tsx index c60e2d6..fa2d279 100644 --- a/app/epd/components/epd-sidebar.tsx +++ b/app/epd/components/epd-sidebar.tsx @@ -127,8 +127,9 @@ const SidebarItem = memo(function SidebarItem({ item, isActive, isCollapsed, onC export function EPDSidebar({ className = "", userEmail, userName }: EPDSidebarProps) { const pathname = usePathname(); - const [isOpen, setIsOpen] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false); + const [isMobile, setIsMobile] = useState(false); + const [isOpen, setIsOpen] = useState(false); // Context detection: Level 2 if URL contains /patients/[id] const isPatientContext = pathname?.match(/\/epd\/patients\/[^\/]+/); @@ -145,20 +146,19 @@ export function EPDSidebar({ className = "", userEmail, userName }: EPDSidebarPr return level1NavigationItems; }, [isPatientContext, patientId]); - // Stabilize event handlers with useCallback - const toggleSidebar = useCallback(() => { - setIsOpen(prev => !prev); - }, []); - const toggleCollapse = useCallback(() => { setIsCollapsed(prev => !prev); }, []); + const toggleSidebar = useCallback(() => { + setIsOpen(prev => !prev); + }, []); + const handleItemClick = useCallback(() => { - if (window.innerWidth < 768) { + if (isMobile) { setIsOpen(false); } - }, []); + }, [isMobile]); // Memoize isActive check function const getIsActive = useCallback((item: NavigationItem): boolean => { @@ -168,13 +168,14 @@ export function EPDSidebar({ className = "", userEmail, userName }: EPDSidebarPr return pathname === item.href || Boolean(item.href && pathname?.startsWith(item.href + '/')); }, [pathname]); - // Auto-open sidebar on desktop + // Track mobile state and auto-collapse on mobile useEffect(() => { const handleResize = () => { - if (window.innerWidth >= 768) { - setIsOpen(true); - } else { - setIsOpen(false); + const mobile = window.innerWidth < 768; + setIsMobile(mobile); + // Auto-collapse on mobile, remember preference on desktop + if (mobile) { + setIsCollapsed(true); } }; @@ -183,6 +184,9 @@ export function EPDSidebar({ className = "", userEmail, userName }: EPDSidebarPr return () => window.removeEventListener('resize', handleResize); }, []); + // Effective collapsed state: always collapsed on mobile + const effectiveCollapsed = isMobile || isCollapsed; + // Get user initials const userInitials = userName ? userName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) diff --git a/app/epd/patients/[id]/rapportage/components/report-composer.tsx b/app/epd/patients/[id]/rapportage/components/report-composer.tsx index a60b971..3c36467 100644 --- a/app/epd/patients/[id]/rapportage/components/report-composer.tsx +++ b/app/epd/patients/[id]/rapportage/components/report-composer.tsx @@ -3,13 +3,13 @@ import { useMemo, useState, useEffect, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import dynamic from 'next/dynamic'; -import { Mic, Sparkles } from 'lucide-react'; +import { Sparkles } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { toast } from '@/hooks/use-toast'; import type { ClassificationResult, Report, ReportType } from '@/lib/types/report'; import { createReport } from '../actions'; -import { cn } from '@/lib/utils'; import type { Editor } from '@/components/rich-text-editor'; +import { ToolbarMicButton } from './toolbar-mic-button'; // Dynamic imports const RichTextEditor = dynamic( @@ -17,11 +17,6 @@ const RichTextEditor = dynamic( { ssr: false, loading: () => } ); -const SpeechRecorderStreaming = dynamic( - () => import('@/components/speech-recorder-streaming').then((m) => m.SpeechRecorderStreaming), - { ssr: false, loading: () => } -); - function EditorSkeleton() { return (
@@ -31,15 +26,6 @@ function EditorSkeleton() { ); } -function RecorderSkeleton() { - return ( -
-
-
-
- ); -} - // ───────────────────────────────────────────────────────────────────────────── // Types // ───────────────────────────────────────────────────────────────────────────── @@ -81,8 +67,8 @@ export function ReportComposer({ const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); const [lastAutosave, setLastAutosave] = useState(null); - const [isStreaming, setIsStreaming] = useState(false); - const [showRecorder, setShowRecorder] = useState(false); + const [isRecording, setIsRecording] = useState(false); + const [interimText, setInterimText] = useState(''); const draftStorageKey = useMemo(() => `rapportage-draft-${patientId}`, [patientId]); // Calculate plain text length from HTML content @@ -155,14 +141,16 @@ export function ReportComposer({ // Recording handlers const handleRecordingStart = useCallback(() => { - setIsStreaming(true); + setIsRecording(true); + setInterimText(''); if (editorRef) { editorRef.chain().focus().run(); } }, [editorRef]); const handleRecordingStop = useCallback(() => { - setIsStreaming(false); + setIsRecording(false); + setInterimText(''); }, []); const handleTranscript = useCallback((text: string) => { @@ -176,6 +164,10 @@ export function ReportComposer({ }); }, []); + const handleInterimTranscript = useCallback((text: string) => { + setInterimText(text); + }, []); + // Reference snippet for context const referenceSnippet = useMemo(() => { if (!selectedReport) return null; @@ -257,22 +249,16 @@ export function ReportComposer({ setContent((prev) => (prev ? `${prev}${block}` : block)); }; - // Mic button for toolbar + // Mic button for toolbar - directly starts/stops recording const MicToolbarButton = ( - + ); return ( @@ -301,29 +287,23 @@ export function ReportComposer({ placeholder="Begin met typen of gebruik spraakopname..." toolbarExtra={MicToolbarButton} onEditorReady={setEditorRef} - isStreaming={isStreaming} + isStreaming={isRecording} minHeight="180px" /> + {/* Interim transcript preview during recording */} + {interimText && ( +
+ {interimText} +
+ )} + {/* Character count */}
{characterCount} / 5000 karakters {characterCount > 0 && characterCount < 20 && Minimaal 20 karakters}
- {/* Speech recorder (collapsible) */} - {showRecorder && ( -
- -
- )} - {/* AI Classification result (inline) */} {classification && (
diff --git a/app/epd/patients/[id]/rapportage/components/toolbar-mic-button.tsx b/app/epd/patients/[id]/rapportage/components/toolbar-mic-button.tsx new file mode 100644 index 0000000..aac7f40 --- /dev/null +++ b/app/epd/patients/[id]/rapportage/components/toolbar-mic-button.tsx @@ -0,0 +1,241 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Mic, Square, Loader2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { + useDeepgramStreaming, + type TranscriptResult, +} from '@/hooks/use-deepgram-streaming'; + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + +interface ToolbarMicButtonProps { + /** Callback voor final transcripts */ + onTranscript: (text: string) => void; + /** Callback voor interim transcripts (live preview) */ + onInterimTranscript?: (text: string) => void; + /** Callback wanneer recording start */ + onRecordingStart?: () => void; + /** Callback wanneer recording stopt */ + onRecordingStop?: () => void; + /** Disabled state */ + disabled?: boolean; + /** Patient ID voor telemetry */ + patientId?: string; +} + +// ───────────────────────────────────────────────────────────────────────────── +// Mini Waveform Component +// ───────────────────────────────────────────────────────────────────────────── + +function MiniWaveform({ analyserNode }: { analyserNode: AnalyserNode | null }) { + const canvasRef = useRef(null); + const animationRef = useRef(null); + + useEffect(() => { + if (!analyserNode || !canvasRef.current) { + 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.clearRect(0, 0, canvas.width, canvas.height); + + // Draw bars + const barCount = 5; + const barWidth = 3; + const gap = 2; + const maxHeight = canvas.height - 4; + const startX = (canvas.width - (barCount * barWidth + (barCount - 1) * gap)) / 2; + + for (let i = 0; i < barCount; i++) { + // Sample from different parts of frequency spectrum + const dataIndex = Math.floor((i / barCount) * bufferLength * 0.5); + const value = dataArray[dataIndex] / 255; + const barHeight = Math.max(4, value * maxHeight); + + const x = startX + i * (barWidth + gap); + const y = (canvas.height - barHeight) / 2; + + ctx.fillStyle = '#fff'; + ctx.beginPath(); + ctx.roundRect(x, y, barWidth, barHeight, 1); + ctx.fill(); + } + }; + + draw(); + + return () => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + } + }; + }, [analyserNode]); + + return ( + + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Main Component +// ───────────────────────────────────────────────────────────────────────────── + +export function ToolbarMicButton({ + onTranscript, + onInterimTranscript, + onRecordingStart, + onRecordingStop, + disabled = false, + patientId, +}: ToolbarMicButtonProps) { + const [interimText, setInterimText] = useState(''); + // Completed utterances (after speechFinal) + const completedUtterancesRef = useRef([]); + // Current utterance being built (before speechFinal) + const currentUtteranceRef = useRef(''); + + const handleTranscript = useCallback( + (result: TranscriptResult) => { + if (result.isFinal) { + // Update current utterance with latest final text + currentUtteranceRef.current = result.transcript; + setInterimText(''); + + if (result.speechFinal) { + // Utterance complete - commit to completed list + if (currentUtteranceRef.current.trim()) { + completedUtterancesRef.current.push(currentUtteranceRef.current); + } + currentUtteranceRef.current = ''; + } + + // Build full text: completed utterances + current utterance + const parts = [...completedUtterancesRef.current]; + if (currentUtteranceRef.current.trim()) { + parts.push(currentUtteranceRef.current); + } + onTranscript(parts.join(' ')); + } else { + // Interim result - show as preview + setInterimText(result.transcript); + onInterimTranscript?.(result.transcript); + } + }, + [onTranscript, onInterimTranscript] + ); + + const { + status, + isRecording, + startRecording, + stopRecording, + analyserNode, + isBrowserSupported, + } = useDeepgramStreaming({ + onTranscript: handleTranscript, + }); + + const handleClick = async () => { + console.log('[ToolbarMicButton] Click - isRecording:', isRecording, 'status:', status); + + if (isRecording) { + console.log('[ToolbarMicButton] Stopping recording...'); + stopRecording(); + onRecordingStop?.(); + completedUtterancesRef.current = []; + currentUtteranceRef.current = ''; + setInterimText(''); + } else { + console.log('[ToolbarMicButton] Starting recording...'); + completedUtterancesRef.current = []; + currentUtteranceRef.current = ''; + setInterimText(''); + onRecordingStart?.(); + try { + await startRecording(); + console.log('[ToolbarMicButton] startRecording() completed'); + } catch (err) { + console.error('[ToolbarMicButton] startRecording() failed:', err); + } + } + }; + + const isConnecting = status === 'connecting'; + const isActive = isRecording && status === 'connected'; + + // Determine button state and colors + const getButtonClasses = () => { + if (isActive) { + // Recording - orange background + return 'bg-orange-500 text-white hover:bg-orange-600 shadow-sm'; + } + if (isConnecting) { + // Connecting - subtle loading state + return 'bg-amber-100 text-amber-700'; + } + // Default - green background + return 'bg-emerald-500 text-white hover:bg-emerald-600 shadow-sm'; + }; + + if (!isBrowserSupported) { + return ( + + ); + } + + return ( + + ); +} diff --git a/components/rich-text-editor.tsx b/components/rich-text-editor.tsx index 2faaa55..ff15dfd 100644 --- a/components/rich-text-editor.tsx +++ b/components/rich-text-editor.tsx @@ -80,7 +80,7 @@ export function RichTextEditor({
diff --git a/docs/troubleshooting/screenprint-nieuwe-rapportage-ux-01.png b/docs/troubleshooting/screenprint-nieuwe-rapportage-ux-01.png new file mode 100644 index 0000000000000000000000000000000000000000..19e1073cedd65605aab3169a6584e6b6fdb973b9 GIT binary patch literal 36866 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 $8(c@c8VF&!2oXaT1{2xP@DJb}C2Z_5(%-MYm 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 A$N&HU literal 0 HcmV?d00001 diff --git a/hooks/use-deepgram-streaming.ts b/hooks/use-deepgram-streaming.ts index cf0d4e9..04e8eab 100644 --- a/hooks/use-deepgram-streaming.ts +++ b/hooks/use-deepgram-streaming.ts @@ -78,12 +78,12 @@ export interface UseDeepgramStreamingReturn { 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 } @@ -152,10 +152,13 @@ export function useDeepgramStreaming({ // 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 new Error('Rate limit bereikt. Probeer over een uur opnieuw.') } @@ -166,18 +169,26 @@ export function useDeepgramStreaming({ } const data = await response.json() + console.log('[Deepgram] Token received, expiresIn:', data.expiresIn) return data.token }, []) // Setup WebSocket verbinding met Deepgram const connect = useCallback(async () => { + console.log('[Deepgram] connect() called') try { updateStatus('connecting') setError(null) const token = await fetchToken() - const deepgram = createClient(token) + // IMPORTANT: Use accessToken option for JWT tokens from grantToken() + // This uses Bearer scheme authentication instead of Token scheme + // See: https://github.com/deepgram/deepgram-js-sdk + console.log('[Deepgram] Creating Deepgram client with accessToken...') + const deepgram = createClient({ accessToken: token }) + + console.log('[Deepgram] Creating live connection with options:', { model, language, endpointingMs }) const connection = deepgram.listen.live({ model, language, @@ -187,9 +198,11 @@ export function useDeepgramStreaming({ punctuate: true, utterances: true, }) + console.log('[Deepgram] Live connection created, setting up event handlers...') // Event handlers connection.on(LiveTranscriptionEvents.Open, () => { + console.log('[Deepgram] WebSocket OPEN') updateStatus('connected') reconnectAttemptsRef.current = 0 setError(null) @@ -226,7 +239,7 @@ export function useDeepgramStreaming({ ) connection.on(LiveTranscriptionEvents.Error, (err: Error) => { - console.error('Deepgram WebSocket error:', err) + console.error('[Deepgram] WebSocket ERROR:', err) setError(err.message || 'WebSocket fout') updateStatus('error') onErrorRef.current?.(err) @@ -238,6 +251,7 @@ export function useDeepgramStreaming({ }) connection.on(LiveTranscriptionEvents.Close, () => { + console.log('[Deepgram] WebSocket CLOSE') if (shouldReconnectRef.current && reconnectAttemptsRef.current < MAX_RECONNECT_ATTEMPTS) { handleReconnect() } else { @@ -246,8 +260,9 @@ export function useDeepgramStreaming({ }) liveClientRef.current = connection + console.log('[Deepgram] Connection setup complete') } catch (err) { - console.error('Connection error:', err) + console.error('[Deepgram] Connection error:', err) const errorMessage = err instanceof Error ? err.message : 'Verbindingsfout' setError(errorMessage) updateStatus('error') @@ -281,10 +296,14 @@ export function useDeepgramStreaming({ // 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() @@ -300,6 +319,7 @@ export function useDeepgramStreaming({ : 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 @@ -312,26 +332,32 @@ export function useDeepgramStreaming({ } } + 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 // Eerst WebSocket verbinding opzetten + console.log('[Deepgram] Step 1: Connecting to Deepgram...') await connect() // Dan audio stream starten + console.log('[Deepgram] Step 2: Setting up audio...') await setupAudio() + console.log('[Deepgram] Recording started successfully') setIsRecording(true) setIsPaused(false) } catch (err) { - console.error('Start recording error:', err) + console.error('[Deepgram] startRecording error:', err) const errorMessage = err instanceof Error ? err.message : 'Kon opname niet starten'