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 createSupabaseClient } from '@/lib/auth/server'
import { createClient as createDeepgramClient } from '@deepgram/sdk' import { createClient as createDeepgramClient } from '@deepgram/sdk'
const MAX_TOKENS_PER_HOUR = 10 // Rate limiting configuratie
const TOKEN_TTL_SECONDS = 3600 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 HOUR_IN_MS = 60 * 60 * 1000
const DAY_IN_MS = 24 * 60 * 60 * 1000
type RateLimitEntry = { type RateLimitEntry = {
count: number count: number
@@ -15,61 +19,109 @@ type RateLimitResult = {
allowed: boolean allowed: boolean
remaining: number remaining: number
resetAt: 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 { function consumeRateLimit(userId: string): RateLimitResult {
const now = Date.now() 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) { if (!entry || now >= entry.resetAt) {
const resetAt = now + HOUR_IN_MS const resetAt = now + HOUR_IN_MS
rateLimitStore.set(userId, { count: 1, resetAt }) userRateLimitStore.set(userId, { count: 1, resetAt })
globalHourlyCount++
return { return {
allowed: true, allowed: true,
remaining: MAX_TOKENS_PER_HOUR - 1, remaining: MAX_TOKENS_PER_USER_PER_HOUR - 1,
resetAt, resetAt,
} }
} }
if (entry.count >= MAX_TOKENS_PER_HOUR) { if (entry.count >= MAX_TOKENS_PER_USER_PER_HOUR) {
return { return {
allowed: false, allowed: false,
remaining: 0, remaining: 0,
resetAt: entry.resetAt, resetAt: entry.resetAt,
reason: 'Je hebt de limiet van 5 opnames per uur bereikt.',
} }
} }
entry.count += 1 entry.count += 1
globalHourlyCount++
return { return {
allowed: true, allowed: true,
remaining: MAX_TOKENS_PER_HOUR - entry.count, remaining: MAX_TOKENS_PER_USER_PER_HOUR - entry.count,
resetAt: entry.resetAt, resetAt: entry.resetAt,
} }
} }
function buildRateLimitHeaders(result: RateLimitResult) { function buildRateLimitHeaders(result: RateLimitResult) {
return { 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-Remaining': `${Math.max(result.remaining, 0)}`,
'X-RateLimit-Reset': `${Math.floor(result.resetAt / 1000)}`, 'X-RateLimit-Reset': `${Math.floor(result.resetAt / 1000)}`,
} }
} }
export async function POST() { export async function POST() {
console.log('[API /deepgram/token] POST request received')
try { try {
const skipAuthCheck = process.env.SKIP_AUTH_CHECK === 'true' const skipAuthCheck = process.env.SKIP_AUTH_CHECK === 'true'
let userId = 'demo-user' let userId = 'demo-user'
// Auth check (skip in development/demo mode) // Auth check (skip in development/demo mode)
if (!skipAuthCheck) { if (!skipAuthCheck) {
console.log('[API /deepgram/token] Checking auth...')
const supabase = await createSupabaseClient() const supabase = await createSupabaseClient()
const { data: authData, error: authError } = await supabase.auth.getUser() const { data: authData, error: authError } = await supabase.auth.getUser()
if (authError) { if (authError) {
console.error('Deepgram token: auth error', authError) console.error('[API /deepgram/token] Auth error:', authError)
return NextResponse.json( return NextResponse.json(
{ error: 'Kon sessie niet ophalen' }, { error: 'Kon sessie niet ophalen' },
{ status: 500 } { status: 500 }
@@ -77,17 +129,23 @@ export async function POST() {
} }
if (!authData?.user) { if (!authData?.user) {
console.log('[API /deepgram/token] No user found - returning 401')
return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 }) return NextResponse.json({ error: 'Niet geautoriseerd' }, { status: 401 })
} }
userId = authData.user.id userId = authData.user.id
console.log('[API /deepgram/token] Auth OK, userId:', userId.slice(0, 8) + '...')
} else { } 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) { if (!apiKey) {
console.error('[API /deepgram/token] No Deepgram API key configured!')
return NextResponse.json( return NextResponse.json(
{ error: 'Deepgram API key ontbreekt in server configuratie' }, { error: 'Deepgram API key ontbreekt in server configuratie' },
{ status: 500 } { status: 500 }
@@ -96,14 +154,16 @@ export async function POST() {
const rateLimit = consumeRateLimit(userId) const rateLimit = consumeRateLimit(userId)
const headers = buildRateLimitHeaders(rateLimit) const headers = buildRateLimitHeaders(rateLimit)
console.log('[API /deepgram/token] Rate limit check:', { allowed: rateLimit.allowed, remaining: rateLimit.remaining })
if (!rateLimit.allowed) { if (!rateLimit.allowed) {
const retryAfterSeconds = Math.max( const retryAfterSeconds = Math.max(
0, 0,
Math.ceil((rateLimit.resetAt - Date.now()) / 1000) Math.ceil((rateLimit.resetAt - Date.now()) / 1000)
) )
console.log('[API /deepgram/token] Rate limit exceeded:', rateLimit.reason, 'retry after:', retryAfterSeconds)
return NextResponse.json( return NextResponse.json(
{ error: 'Rate limit overschreden. Max 10 tokens per uur.' }, { error: rateLimit.reason || 'Rate limit bereikt.' },
{ {
status: 429, status: 429,
headers: { headers: {
@@ -114,19 +174,48 @@ export async function POST() {
) )
} }
const deepgram = createDeepgramClient(apiKey) // Check if we should use direct API key (for development when grantToken doesn't work)
const tokenResponse = await deepgram.auth.grantToken({ const useDirectKey = process.env.DEEPGRAM_USE_DIRECT_KEY === 'true'
ttl_seconds: TOKEN_TTL_SECONDS,
})
if (!tokenResponse.result) { if (useDirectKey) {
console.error('Deepgram token: API error', tokenResponse.error) console.log('[API /deepgram/token] Using direct API key (DEEPGRAM_USE_DIRECT_KEY=true)')
return NextResponse.json( 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 } { 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( return NextResponse.json(
{ {
token: tokenResponse.result.access_token, token: tokenResponse.result.access_token,
@@ -135,7 +224,7 @@ export async function POST() {
{ headers } { headers }
) )
} catch (error) { } catch (error) {
console.error('Deepgram token: onverwachte fout', error) console.error('[API /deepgram/token] Unexpected error:', error)
return NextResponse.json( return NextResponse.json(
{ error: 'Onverwachte fout bij genereren token' }, { error: 'Onverwachte fout bij genereren token' },
{ status: 500 } { status: 500 }

View File

@@ -127,8 +127,9 @@ const SidebarItem = memo(function SidebarItem({ item, isActive, isCollapsed, onC
export function EPDSidebar({ className = "", userEmail, userName }: EPDSidebarProps) { export function EPDSidebar({ className = "", userEmail, userName }: EPDSidebarProps) {
const pathname = usePathname(); const pathname = usePathname();
const [isOpen, setIsOpen] = useState(false);
const [isCollapsed, setIsCollapsed] = 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] // Context detection: Level 2 if URL contains /patients/[id]
const isPatientContext = pathname?.match(/\/epd\/patients\/[^\/]+/); const isPatientContext = pathname?.match(/\/epd\/patients\/[^\/]+/);
@@ -145,20 +146,19 @@ export function EPDSidebar({ className = "", userEmail, userName }: EPDSidebarPr
return level1NavigationItems; return level1NavigationItems;
}, [isPatientContext, patientId]); }, [isPatientContext, patientId]);
// Stabilize event handlers with useCallback
const toggleSidebar = useCallback(() => {
setIsOpen(prev => !prev);
}, []);
const toggleCollapse = useCallback(() => { const toggleCollapse = useCallback(() => {
setIsCollapsed(prev => !prev); setIsCollapsed(prev => !prev);
}, []); }, []);
const toggleSidebar = useCallback(() => {
setIsOpen(prev => !prev);
}, []);
const handleItemClick = useCallback(() => { const handleItemClick = useCallback(() => {
if (window.innerWidth < 768) { if (isMobile) {
setIsOpen(false); setIsOpen(false);
} }
}, []); }, [isMobile]);
// Memoize isActive check function // Memoize isActive check function
const getIsActive = useCallback((item: NavigationItem): boolean => { 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 + '/')); return pathname === item.href || Boolean(item.href && pathname?.startsWith(item.href + '/'));
}, [pathname]); }, [pathname]);
// Auto-open sidebar on desktop // Track mobile state and auto-collapse on mobile
useEffect(() => { useEffect(() => {
const handleResize = () => { const handleResize = () => {
if (window.innerWidth >= 768) { const mobile = window.innerWidth < 768;
setIsOpen(true); setIsMobile(mobile);
} else { // Auto-collapse on mobile, remember preference on desktop
setIsOpen(false); if (mobile) {
setIsCollapsed(true);
} }
}; };
@@ -183,6 +184,9 @@ export function EPDSidebar({ className = "", userEmail, userName }: EPDSidebarPr
return () => window.removeEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize);
}, []); }, []);
// Effective collapsed state: always collapsed on mobile
const effectiveCollapsed = isMobile || isCollapsed;
// Get user initials // Get user initials
const userInitials = userName const userInitials = userName
? userName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) ? 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 { useMemo, useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { Mic, Sparkles } from 'lucide-react'; import { Sparkles } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
import type { ClassificationResult, Report, ReportType } from '@/lib/types/report'; import type { ClassificationResult, Report, ReportType } from '@/lib/types/report';
import { createReport } from '../actions'; import { createReport } from '../actions';
import { cn } from '@/lib/utils';
import type { Editor } from '@/components/rich-text-editor'; import type { Editor } from '@/components/rich-text-editor';
import { ToolbarMicButton } from './toolbar-mic-button';
// Dynamic imports // Dynamic imports
const RichTextEditor = dynamic( const RichTextEditor = dynamic(
@@ -17,11 +17,6 @@ const RichTextEditor = dynamic(
{ ssr: false, loading: () => <EditorSkeleton /> } { ssr: false, loading: () => <EditorSkeleton /> }
); );
const SpeechRecorderStreaming = dynamic(
() => import('@/components/speech-recorder-streaming').then((m) => m.SpeechRecorderStreaming),
{ ssr: false, loading: () => <RecorderSkeleton /> }
);
function EditorSkeleton() { function EditorSkeleton() {
return ( return (
<div className="rounded-lg border border-slate-200 animate-pulse"> <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 // Types
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@@ -81,8 +67,8 @@ export function ReportComposer({
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [lastAutosave, setLastAutosave] = useState<Date | null>(null); const [lastAutosave, setLastAutosave] = useState<Date | null>(null);
const [isStreaming, setIsStreaming] = useState(false); const [isRecording, setIsRecording] = useState(false);
const [showRecorder, setShowRecorder] = useState(false); const [interimText, setInterimText] = useState('');
const draftStorageKey = useMemo(() => `rapportage-draft-${patientId}`, [patientId]); const draftStorageKey = useMemo(() => `rapportage-draft-${patientId}`, [patientId]);
// Calculate plain text length from HTML content // Calculate plain text length from HTML content
@@ -155,14 +141,16 @@ export function ReportComposer({
// Recording handlers // Recording handlers
const handleRecordingStart = useCallback(() => { const handleRecordingStart = useCallback(() => {
setIsStreaming(true); setIsRecording(true);
setInterimText('');
if (editorRef) { if (editorRef) {
editorRef.chain().focus().run(); editorRef.chain().focus().run();
} }
}, [editorRef]); }, [editorRef]);
const handleRecordingStop = useCallback(() => { const handleRecordingStop = useCallback(() => {
setIsStreaming(false); setIsRecording(false);
setInterimText('');
}, []); }, []);
const handleTranscript = useCallback((text: string) => { const handleTranscript = useCallback((text: string) => {
@@ -176,6 +164,10 @@ export function ReportComposer({
}); });
}, []); }, []);
const handleInterimTranscript = useCallback((text: string) => {
setInterimText(text);
}, []);
// Reference snippet for context // Reference snippet for context
const referenceSnippet = useMemo(() => { const referenceSnippet = useMemo(() => {
if (!selectedReport) return null; if (!selectedReport) return null;
@@ -257,22 +249,16 @@ export function ReportComposer({
setContent((prev) => (prev ? `${prev}${block}` : block)); setContent((prev) => (prev ? `${prev}${block}` : block));
}; };
// Mic button for toolbar // Mic button for toolbar - directly starts/stops recording
const MicToolbarButton = ( const MicToolbarButton = (
<button <ToolbarMicButton
type="button" onTranscript={handleTranscript}
onClick={() => setShowRecorder((prev) => !prev)} onInterimTranscript={handleInterimTranscript}
className={cn( onRecordingStart={handleRecordingStart}
'inline-flex h-7 items-center gap-1 px-2 rounded-md text-xs font-medium transition-colors', onRecordingStop={handleRecordingStop}
showRecorder || isStreaming disabled={isSaving || isAnalyzing}
? 'bg-emerald-100 text-emerald-700' patientId={patientId}
: '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>
); );
return ( return (
@@ -301,29 +287,23 @@ export function ReportComposer({
placeholder="Begin met typen of gebruik spraakopname..." placeholder="Begin met typen of gebruik spraakopname..."
toolbarExtra={MicToolbarButton} toolbarExtra={MicToolbarButton}
onEditorReady={setEditorRef} onEditorReady={setEditorRef}
isStreaming={isStreaming} isStreaming={isRecording}
minHeight="180px" 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 */} {/* Character count */}
<div className="flex justify-between text-xs text-slate-500 -mt-2"> <div className="flex justify-between text-xs text-slate-500 -mt-2">
<span>{characterCount} / 5000 karakters</span> <span>{characterCount} / 5000 karakters</span>
{characterCount > 0 && characterCount < 20 && <span className="text-amber-600">Minimaal 20 karakters</span>} {characterCount > 0 && characterCount < 20 && <span className="text-amber-600">Minimaal 20 karakters</span>}
</div> </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) */} {/* AI Classification result (inline) */}
{classification && ( {classification && (
<div className="flex items-center gap-2 text-sm text-slate-600 bg-slate-50 rounded-lg px-3 py-2"> <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( <div className={cn(
'rounded-lg border overflow-hidden transition-all duration-200', 'rounded-lg border overflow-hidden transition-all duration-200',
isStreaming 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' : 'border-slate-200'
)}> )}>
<div className="flex items-center justify-between border-b border-slate-200 bg-slate-50 px-2 py-1.5"> <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 { function checkBrowserSupport(): boolean {
if (typeof window === 'undefined') return false if (typeof window === 'undefined') return false
const hasMediaDevices = !!(navigator.mediaDevices?.getUserMedia) const hasMediaDevices = !!(navigator.mediaDevices?.getUserMedia)
const hasMediaRecorder = typeof MediaRecorder !== 'undefined' const hasMediaRecorder = typeof MediaRecorder !== 'undefined'
const hasAudioContext = typeof AudioContext !== 'undefined' || typeof (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext !== 'undefined' const hasAudioContext = typeof AudioContext !== 'undefined' || typeof (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext !== 'undefined'
const hasWebSocket = typeof WebSocket !== 'undefined' const hasWebSocket = typeof WebSocket !== 'undefined'
return hasMediaDevices && hasMediaRecorder && hasAudioContext && hasWebSocket return hasMediaDevices && hasMediaRecorder && hasAudioContext && hasWebSocket
} }
@@ -152,10 +152,13 @@ export function useDeepgramStreaming({
// Fetch token van onze proxy endpoint // Fetch token van onze proxy endpoint
const fetchToken = useCallback(async (): Promise<string> => { const fetchToken = useCallback(async (): Promise<string> => {
console.log('[Deepgram] Fetching token from /api/deepgram/token...')
const response = await fetch('/api/deepgram/token', { method: 'POST' }) const response = await fetch('/api/deepgram/token', { method: 'POST' })
console.log('[Deepgram] Token response status:', response.status)
if (!response.ok) { if (!response.ok) {
const data = await response.json().catch(() => ({})) const data = await response.json().catch(() => ({}))
console.error('[Deepgram] Token fetch failed:', response.status, data)
if (response.status === 429) { if (response.status === 429) {
throw new Error('Rate limit bereikt. Probeer over een uur opnieuw.') throw new Error('Rate limit bereikt. Probeer over een uur opnieuw.')
} }
@@ -166,18 +169,26 @@ export function useDeepgramStreaming({
} }
const data = await response.json() const data = await response.json()
console.log('[Deepgram] Token received, expiresIn:', data.expiresIn)
return data.token return data.token
}, []) }, [])
// Setup WebSocket verbinding met Deepgram // Setup WebSocket verbinding met Deepgram
const connect = useCallback(async () => { const connect = useCallback(async () => {
console.log('[Deepgram] connect() called')
try { try {
updateStatus('connecting') updateStatus('connecting')
setError(null) setError(null)
const token = await fetchToken() 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({ const connection = deepgram.listen.live({
model, model,
language, language,
@@ -187,9 +198,11 @@ export function useDeepgramStreaming({
punctuate: true, punctuate: true,
utterances: true, utterances: true,
}) })
console.log('[Deepgram] Live connection created, setting up event handlers...')
// Event handlers // Event handlers
connection.on(LiveTranscriptionEvents.Open, () => { connection.on(LiveTranscriptionEvents.Open, () => {
console.log('[Deepgram] WebSocket OPEN')
updateStatus('connected') updateStatus('connected')
reconnectAttemptsRef.current = 0 reconnectAttemptsRef.current = 0
setError(null) setError(null)
@@ -226,7 +239,7 @@ export function useDeepgramStreaming({
) )
connection.on(LiveTranscriptionEvents.Error, (err: Error) => { connection.on(LiveTranscriptionEvents.Error, (err: Error) => {
console.error('Deepgram WebSocket error:', err) console.error('[Deepgram] WebSocket ERROR:', err)
setError(err.message || 'WebSocket fout') setError(err.message || 'WebSocket fout')
updateStatus('error') updateStatus('error')
onErrorRef.current?.(err) onErrorRef.current?.(err)
@@ -238,6 +251,7 @@ export function useDeepgramStreaming({
}) })
connection.on(LiveTranscriptionEvents.Close, () => { connection.on(LiveTranscriptionEvents.Close, () => {
console.log('[Deepgram] WebSocket CLOSE')
if (shouldReconnectRef.current && reconnectAttemptsRef.current < MAX_RECONNECT_ATTEMPTS) { if (shouldReconnectRef.current && reconnectAttemptsRef.current < MAX_RECONNECT_ATTEMPTS) {
handleReconnect() handleReconnect()
} else { } else {
@@ -246,8 +260,9 @@ export function useDeepgramStreaming({
}) })
liveClientRef.current = connection liveClientRef.current = connection
console.log('[Deepgram] Connection setup complete')
} catch (err) { } catch (err) {
console.error('Connection error:', err) console.error('[Deepgram] Connection error:', err)
const errorMessage = err instanceof Error ? err.message : 'Verbindingsfout' const errorMessage = err instanceof Error ? err.message : 'Verbindingsfout'
setError(errorMessage) setError(errorMessage)
updateStatus('error') updateStatus('error')
@@ -281,10 +296,14 @@ export function useDeepgramStreaming({
// Setup audio stream en analyser // Setup audio stream en analyser
const setupAudio = useCallback(async () => { const setupAudio = useCallback(async () => {
console.log('[Deepgram] setupAudio() called')
console.log('[Deepgram] Requesting microphone access...')
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
console.log('[Deepgram] Microphone access granted')
mediaStreamRef.current = stream mediaStreamRef.current = stream
// Setup Web Audio API voor waveform // Setup Web Audio API voor waveform
console.log('[Deepgram] Setting up AudioContext...')
const audioContext = new AudioContext() const audioContext = new AudioContext()
const source = audioContext.createMediaStreamSource(stream) const source = audioContext.createMediaStreamSource(stream)
const analyser = audioContext.createAnalyser() const analyser = audioContext.createAnalyser()
@@ -300,6 +319,7 @@ export function useDeepgramStreaming({
: MediaRecorder.isTypeSupported('audio/mp4') : MediaRecorder.isTypeSupported('audio/mp4')
? 'audio/mp4' ? 'audio/mp4'
: undefined // Browser default : undefined // Browser default
console.log('[Deepgram] Using mimeType:', mimeType || 'browser default')
// Setup MediaRecorder voor streaming naar Deepgram // Setup MediaRecorder voor streaming naar Deepgram
const mediaRecorder = mimeType 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) mediaRecorder.start(AUDIO_CHUNK_INTERVAL_MS)
mediaRecorderRef.current = mediaRecorder mediaRecorderRef.current = mediaRecorder
console.log('[Deepgram] Audio setup complete')
}, []) }, [])
// Start opname // Start opname
const startRecording = useCallback(async () => { const startRecording = useCallback(async () => {
console.log('[Deepgram] startRecording() called')
try { try {
setError(null) setError(null)
shouldReconnectRef.current = true shouldReconnectRef.current = true
// Eerst WebSocket verbinding opzetten // Eerst WebSocket verbinding opzetten
console.log('[Deepgram] Step 1: Connecting to Deepgram...')
await connect() await connect()
// Dan audio stream starten // Dan audio stream starten
console.log('[Deepgram] Step 2: Setting up audio...')
await setupAudio() await setupAudio()
console.log('[Deepgram] Recording started successfully')
setIsRecording(true) setIsRecording(true)
setIsPaused(false) setIsPaused(false)
} catch (err) { } catch (err) {
console.error('Start recording error:', err) console.error('[Deepgram] startRecording error:', err)
const errorMessage = const errorMessage =
err instanceof Error ? err.message : 'Kon opname niet starten' err instanceof Error ? err.message : 'Kon opname niet starten'