feat: improve streaming mic and deepgram token handling

This commit is contained in:
colinislit
2025-11-25 22:53:04 +01:00
parent 779ab85e5d
commit 56b66ae6ff
7 changed files with 430 additions and 90 deletions

View File

@@ -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<string, RateLimitEntry>()
// Per-user rate limiting
const userRateLimitStore = new Map<string, RateLimitEntry>()
// 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 }

View File

@@ -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)

View File

@@ -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: () => <EditorSkeleton /> }
);
const SpeechRecorderStreaming = dynamic(
() => import('@/components/speech-recorder-streaming').then((m) => m.SpeechRecorderStreaming),
{ ssr: false, loading: () => <RecorderSkeleton /> }
);
function EditorSkeleton() {
return (
<div className="rounded-lg border border-slate-200 animate-pulse">
@@ -31,15 +26,6 @@ function EditorSkeleton() {
);
}
function RecorderSkeleton() {
return (
<div className="rounded-lg border border-slate-200 bg-white p-4 animate-pulse">
<div className="h-4 w-1/2 rounded bg-slate-100 mb-2" />
<div className="h-10 rounded bg-slate-100" />
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Types
// ─────────────────────────────────────────────────────────────────────────────
@@ -81,8 +67,8 @@ export function ReportComposer({
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [lastAutosave, setLastAutosave] = useState<Date | null>(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 = (
<button
type="button"
onClick={() => setShowRecorder((prev) => !prev)}
className={cn(
'inline-flex h-7 items-center gap-1 px-2 rounded-md text-xs font-medium transition-colors',
showRecorder || isStreaming
? 'bg-emerald-100 text-emerald-700'
: 'text-slate-600 hover:bg-white hover:text-slate-900'
)}
title="Spraakopname"
>
<Mic className={cn('h-4 w-4', isStreaming && 'text-emerald-600 animate-pulse')} />
{isStreaming && <span className="text-emerald-600"></span>}
</button>
<ToolbarMicButton
onTranscript={handleTranscript}
onInterimTranscript={handleInterimTranscript}
onRecordingStart={handleRecordingStart}
onRecordingStop={handleRecordingStop}
disabled={isSaving || isAnalyzing}
patientId={patientId}
/>
);
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 && (
<div className="text-sm text-slate-500 italic bg-orange-50 border border-orange-200 rounded-lg px-3 py-2 -mt-2 animate-pulse">
{interimText}
</div>
)}
{/* Character count */}
<div className="flex justify-between text-xs text-slate-500 -mt-2">
<span>{characterCount} / 5000 karakters</span>
{characterCount > 0 && characterCount < 20 && <span className="text-amber-600">Minimaal 20 karakters</span>}
</div>
{/* Speech recorder (collapsible) */}
{showRecorder && (
<div className="animate-in slide-in-from-top-2 duration-200">
<SpeechRecorderStreaming
disabled={isSaving || isAnalyzing}
onTranscript={handleTranscript}
onRecordingStart={handleRecordingStart}
onRecordingStop={handleRecordingStop}
telemetryContext={{ context: 'report_composer', patientId }}
/>
</div>
)}
{/* AI Classification result (inline) */}
{classification && (
<div className="flex items-center gap-2 text-sm text-slate-600 bg-slate-50 rounded-lg px-3 py-2">

View File

@@ -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<HTMLCanvasElement>(null);
const animationRef = useRef<number | null>(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 (
<canvas
ref={canvasRef}
width={32}
height={20}
className="inline-block"
/>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Main Component
// ─────────────────────────────────────────────────────────────────────────────
export function ToolbarMicButton({
onTranscript,
onInterimTranscript,
onRecordingStart,
onRecordingStop,
disabled = false,
patientId,
}: ToolbarMicButtonProps) {
const [interimText, setInterimText] = useState('');
// Completed utterances (after speechFinal)
const completedUtterancesRef = useRef<string[]>([]);
// Current utterance being built (before speechFinal)
const currentUtteranceRef = useRef<string>('');
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 (
<button
type="button"
disabled
className="inline-flex h-7 items-center gap-1 px-2 rounded-md text-xs font-medium bg-slate-200 text-slate-400 cursor-not-allowed"
title="Spraakopname niet ondersteund in deze browser"
>
<Mic className="h-4 w-4" />
</button>
);
}
return (
<button
type="button"
onClick={handleClick}
disabled={disabled || isConnecting}
className={cn(
'inline-flex h-7 items-center gap-1.5 px-2 rounded-md text-xs font-medium transition-all duration-200',
getButtonClasses(),
disabled && 'opacity-50 cursor-not-allowed'
)}
title={isRecording ? 'Stop opname' : 'Start opname'}
>
{isConnecting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isActive ? (
<>
<MiniWaveform analyserNode={analyserNode} />
<Square className="h-3 w-3" />
</>
) : (
<Mic className="h-4 w-4" />
)}
</button>
);
}

View File

@@ -80,7 +80,7 @@ export function RichTextEditor({
<div className={cn(
'rounded-lg border overflow-hidden transition-all duration-200',
isStreaming
? 'border-emerald-500 border-2 shadow-emerald-500/20 shadow-md'
? 'border-orange-500 border-2 shadow-orange-500/20 shadow-md'
: 'border-slate-200'
)}>
<div className="flex items-center justify-between border-b border-slate-200 bg-slate-50 px-2 py-1.5">

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -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<string> => {
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'