feat: improve streaming mic and deepgram token handling
This commit is contained in:
@@ -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 }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
BIN
docs/troubleshooting/screenprint-nieuwe-rapportage-ux-01.png
Normal file
BIN
docs/troubleshooting/screenprint-nieuwe-rapportage-ux-01.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
@@ -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'
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user