susurro-audio
Version:
🎙️ Real-time conversational audio with AI transcription. Build ChatGPT-style voice interfaces in minutes with <300ms latency
1 lines • 102 kB
Source Map (JSON)
{"version":3,"sources":["../src/lib/dynamic-loaders.ts","../src/hooks/use-susurro.ts","../src/lib/chunk-middleware.ts","../src/hooks/use-latency-monitor.ts","../src/lib/latency-monitor.ts","../src/lib/ui-interfaces.ts","../src/hooks/use-model-cache.ts","../src/lib/audio-constants.ts","../src/lib/error-utils.ts"],"sourcesContent":["// Dynamic loaders for bundle size optimization\n// This file handles lazy loading of heavy dependencies\n\n// Cache for loaded modules to prevent multiple loads\nconst MODULE_CACHE = {\n transformers: null as any,\n murmubaraProcessing: null as any,\n};\n\n/**\n * Dynamically loads Transformers.js for Whisper processing\n * Reduces initial bundle size by ~45MB\n */\nexport const loadTransformers = async () => {\n if (MODULE_CACHE.transformers) {\n console.log('[loadTransformers] Using cached module');\n return MODULE_CACHE.transformers;\n }\n\n const transformers = await import(\n /* webpackChunkName: \"transformers-core\" */\n /* webpackPreload: true */\n '@huggingface/transformers'\n );\n\n MODULE_CACHE.transformers = transformers;\n return transformers;\n};\n\n// Note: useMurmubaraEngine is now imported directly in use-susurro.ts\n// to avoid conditional hook calls which violate React's rules of hooks\n// Keeping this commented for reference\n// export const loadMurmubaraEngine = async () => {\n// const { useMurmubaraEngine } = await import('murmuraba');\n// return useMurmubaraEngine;\n// };\n\n/**\n * Dynamically loads Murmuraba processing functions\n * Separates heavy processing logic from core engine\n */\nexport const loadMurmubaraProcessing = async () => {\n if (MODULE_CACHE.murmubaraProcessing) {\n console.log('[loadMurmubaraProcessing] Using cached module');\n return MODULE_CACHE.murmubaraProcessing;\n }\n\n const module = await import(\n /* webpackChunkName: \"murmuraba-processing\" */\n /* webpackPreload: true */\n 'murmuraba'\n );\n\n console.log('[loadMurmubaraProcessing] Module loaded for first time, keys:', Object.keys(module));\n console.log('[loadMurmubaraProcessing] murmubaraVAD type:', typeof module.murmubaraVAD);\n\n const processedModule = {\n processFileWithMetrics: module.processFileWithMetrics || module.processFile, // Use processFileWithMetrics first, fallback to processFile\n murmubaraVAD: module.murmubaraVAD, // No fallback - murmubaraVAD is a required export\n extractAudioMetadata:\n module.extractAudioMetadata || (() => ({ duration: 1.0, sampleRate: 44100, channels: 2 })), // Fallback metadata\n // Add engine status check to ensure initialization\n getEngineStatus: module.getEngineStatus,\n initializeAudioEngine: module.initializeAudioEngine,\n };\n\n MODULE_CACHE.murmubaraProcessing = processedModule;\n return processedModule;\n};\n\n/**\n * Preloads critical dependencies in the background\n * Call this after initial page load for better UX\n */\nexport const preloadCriticalDependencies = () => {\n // Preload transformers.js in background\n setTimeout(() => {\n import(\n /* webpackChunkName: \"transformers-core\" */\n /* webpackPrefetch: true */\n '@huggingface/transformers'\n ).catch(() => {\n // Ignore preload errors - will load when needed\n });\n }, 2000);\n\n // Preload murmuraba in background\n setTimeout(() => {\n import(\n /* webpackChunkName: \"murmuraba-engine\" */\n /* webpackPrefetch: true */\n 'murmuraba'\n ).catch(() => {\n // Ignore preload errors - will load when needed\n });\n }, 3000);\n};\n","// useSusurro.ts — lean & mean: MediaRecorder 100% en murmuraba\n\nimport { useCallback, useEffect, useRef, useState } from 'react';\nimport { useMurmubaraEngine } from 'murmuraba';\nimport { ChunkMiddlewarePipeline } from '../lib/chunk-middleware';\nimport { useLatencyMonitor } from './use-latency-monitor';\n\nimport type {\n AudioChunk,\n ProcessingStatus,\n TranscriptionResult,\n SusurroChunk,\n UseSusurroOptions as BaseUseSusurroOptions,\n CompleteAudioResult,\n StreamingSusurroChunk,\n RecordingConfig,\n AudioMetadata,\n} from '../lib/types';\n\n// —— Whisper thin wrapper (runtime download, 16k resample) ——\nconst WHISPER_ENV = {\n useBrowserCache: true,\n logLevel: 'error' as const,\n} as const;\n\n// Singleton cache for ASR pipelines to prevent multiple loads\nconst ASR_PIPELINE_CACHE = new Map<string, CallableFunction>();\n\nasync function ensureASR(model: string, quantized: boolean, onProgress: (p: number) => void) {\n try {\n // Create cache key based on model and quantization\n const cacheKey = `${model}_${quantized ? 'q8' : 'fp32'}`;\n\n // Check if pipeline already exists in cache\n const cachedPipeline = ASR_PIPELINE_CACHE.get(cacheKey);\n if (cachedPipeline) {\n console.log(`[ensureASR] Using cached pipeline for ${cacheKey}`);\n onProgress(100); // Immediately complete since it's cached\n return cachedPipeline;\n }\n\n console.log(`[ensureASR] Creating new pipeline for ${cacheKey}`);\n\n // Import @huggingface/transformers v3\n const transformersModule = await import('@huggingface/transformers');\n\n // Extract what we need\n const { pipeline, env } = transformersModule;\n\n // Configure transformers v3 environment\n if (env) {\n env.useBrowserCache = WHISPER_ENV.useBrowserCache;\n // v3 uses allowRemoteModels (default true)\n env.allowRemoteModels = true;\n }\n\n // Use Xenova ONNX models that work with v3\n // Remove .en suffix to ensure multilingual support (needed for Spanish)\n const modelName = `Xenova/${model.replace('.en', '')}`;\n console.log(`[ensureASR] Loading model: ${modelName}`);\n\n // Create pipeline with v3 API\n const asr = await pipeline('automatic-speech-recognition', modelName, {\n // v3 uses dtype instead of quantized\n dtype: quantized ? 'q8' : 'fp32',\n // Optional: use WebGPU if available (requires COEP/COOP headers)\n // device: 'webgpu',\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n progress_callback: (p: any) => {\n // v3 has different progress info structure\n if (p?.progress !== undefined) {\n const percent = p.progress <= 1 ? Math.round(p.progress * 100) : Math.round(p.progress);\n onProgress(Math.min(100, Math.max(0, percent)));\n } else if (p?.status) {\n // Log status updates\n console.log(`[ensureASR] Status: ${p.status}`);\n }\n },\n });\n\n // Store in cache for future use\n ASR_PIPELINE_CACHE.set(cacheKey, asr);\n console.log(`[ensureASR] Pipeline cached for ${cacheKey}`);\n\n return asr;\n } catch (error) {\n console.error(`[ensureASR] Failed to load model:`, error);\n throw error;\n }\n}\n\nasync function resampleTo16k(buffer: AudioBuffer): Promise<Float32Array> {\n if (buffer.sampleRate === 16000) return buffer.getChannelData(0).slice();\n const length = Math.ceil(buffer.duration * 16000);\n const offline = new OfflineAudioContext(1, length, 16000);\n const mono = offline.createBuffer(1, buffer.length, buffer.sampleRate);\n mono.copyToChannel(buffer.getChannelData(0), 0);\n const src = offline.createBufferSource();\n src.buffer = mono;\n src.connect(offline.destination);\n src.start(0);\n const rendered = await offline.startRendering();\n return rendered.getChannelData(0).slice();\n}\n\nasync function transcribeBlobWith(asr: CallableFunction, blob: Blob, language: string) {\n const ab = await blob.arrayBuffer();\n const ctx = new AudioContext();\n const decoded = await ctx.decodeAudioData(ab);\n const audioData = await resampleTo16k(decoded);\n ctx.close();\n\n // Ensure we have a Float32Array\n const audioArray = audioData instanceof Float32Array ? audioData : new Float32Array(audioData);\n\n console.log(\n '[transcribeBlobWith] Audio array type:',\n audioArray.constructor.name,\n 'Length:',\n audioArray.length\n );\n\n // Whisper expects the audio directly as the first parameter\n // Build options based on what the model supports\n const options: any = {\n return_timestamps: true,\n chunk_length_s: 30,\n stride_length_s: 5,\n };\n\n // Try with language/task first, if it fails, retry without them\n try {\n // First attempt with language and task (for multilingual models)\n const out = await asr(audioArray, {\n ...options,\n language: language || 'es', // Default to Spanish\n task: 'transcribe',\n });\n console.log('[transcribeBlobWith] Transcription successful with language:', language || 'es');\n return processTranscriptionResult(out);\n } catch (error: any) {\n console.warn('[transcribeBlobWith] First attempt failed:', error?.message);\n\n // If error mentions English-only model, retry without language/task\n if (error?.message?.includes('English-only') || error?.message?.includes('Cannot specify')) {\n console.log('[transcribeBlobWith] Retrying for English-only model...');\n const out = await asr(audioArray, options);\n return processTranscriptionResult(out);\n }\n\n // If it's a different error, throw it\n throw error;\n }\n}\n\nfunction processTranscriptionResult(out: any): TranscriptionResult {\n const result: TranscriptionResult = {\n text: out?.text ?? '',\n chunkIndex: 0,\n timestamp: Date.now(),\n segments:\n out?.chunks?.map((c: { timestamp?: [number, number]; text?: string }, index: number) => ({\n id: index,\n seek: c.timestamp?.[0] ?? 0,\n start: c.timestamp?.[0] ?? 0,\n end: c.timestamp?.[1] ?? 0,\n text: c.text ?? '',\n tokens: [],\n temperature: 0,\n avg_logprob: 0,\n compression_ratio: 0,\n no_speech_prob: 0,\n })) ?? [],\n };\n return result;\n}\n\nasync function urlToBlob(url?: string): Promise<Blob> {\n if (!url) return new Blob();\n const r = await fetch(url);\n return r.blob();\n}\n\n// —— Public API ——\nexport interface UseSusurroOptions extends BaseUseSusurroOptions {\n onWhisperProgressLog?: (message: string, type?: 'info' | 'warning' | 'error' | 'success') => void;\n initialModel?: 'tiny' | 'base' | 'small' | 'medium' | 'large';\n engineConfig?: {\n bufferSize?: number;\n denoiseStrength?: number;\n enableMetrics?: boolean;\n noiseReductionLevel?: 'low' | 'medium' | 'high';\n algorithm?: 'rnnoise';\n };\n}\n\nexport interface UseSusurroReturn {\n isRecording: boolean;\n isProcessing: boolean;\n transcriptions: TranscriptionResult[];\n audioChunks: AudioChunk[];\n processingStatus: ProcessingStatus;\n averageVad: number;\n startRecording: (config?: RecordingConfig) => Promise<void>;\n stopRecording: () => void;\n pauseRecording: () => void;\n resumeRecording: () => void;\n clearTranscriptions: () => void;\n\n whisperReady: boolean;\n whisperProgress: number;\n whisperError: Error | string | null;\n transcribeWithWhisper: (blob: Blob) => Promise<TranscriptionResult | null>;\n\n exportChunkAsWav: (chunkId: string) => Promise<Blob>;\n\n conversationalChunks: SusurroChunk[];\n clearConversationalChunks: () => void;\n\n middlewarePipeline: ChunkMiddlewarePipeline;\n\n latencyReport: ReturnType<typeof useLatencyMonitor>['latencyReport'];\n latencyStatus: ReturnType<typeof useLatencyMonitor>['latencyStatus'];\n\n initializeAudioEngine: () => Promise<void>;\n resetAudioEngine: () => Promise<void>;\n isEngineInitialized: boolean;\n engineError: string | null;\n isInitializingEngine: boolean;\n\n processAndTranscribeFile: (file: File) => Promise<CompleteAudioResult>;\n\n startStreamingRecording: (\n onChunk: (chunk: StreamingSusurroChunk) => void,\n config?: RecordingConfig\n ) => Promise<void>;\n stopStreamingRecording: () => Promise<StreamingSusurroChunk[]>;\n\n analyzeVAD: (buffer: ArrayBuffer) => Promise<{\n averageVad: number;\n vadScores: number[];\n metrics: unknown[];\n voiceSegments: Array<{\n startTime: number;\n endTime: number;\n vadScore: number;\n confidence: number;\n }>;\n }>;\n convertBlobToBuffer: (blob: Blob) => Promise<ArrayBuffer>;\n\n currentStream: MediaStream | null; // exposed from murmuraba\n}\n\n// —— Hook ——\nexport function useSusurro(options: UseSusurroOptions = {}): UseSusurroReturn {\n const {\n chunkDurationMs = 8000,\n whisperConfig = {},\n conversational,\n onWhisperProgressLog,\n } = options;\n\n // — Murmuraba Engine: Direct integration with official hook —\n const murmubaraConfig = {\n bufferSize: (options.engineConfig?.bufferSize ?? 1024) as 256 | 512 | 1024 | 2048 | 4096,\n denoiseStrength: options.engineConfig?.denoiseStrength ?? 0.5,\n enableMetrics: options.engineConfig?.enableMetrics ?? true,\n noiseReductionLevel: options.engineConfig?.noiseReductionLevel ?? 'medium',\n algorithm: options.engineConfig?.algorithm ?? 'rnnoise',\n chunkDurationMs: chunkDurationMs,\n autoCleanup: true,\n useAudioWorklet: true,\n logLevel: 'error' as const, // Changed from 'info' to 'error' to reduce logs\n enableDebugLogs: false, // Explicitly disable debug logs\n };\n\n const {\n isInitialized: engineReady,\n error: engineError,\n recordingState,\n currentStream: engineStream,\n startRecording: murmubaraStartRecording,\n stopRecording: murmubaraStopRecording,\n pauseRecording: murmurbaraPauseRecording,\n resumeRecording: murmubaraResumeRecording,\n exportChunkAsWav: murmubaraExportChunkAsWav,\n initialize: murmubaraInitializeEngine,\n destroy: murmubaraDestroyEngine,\n } = useMurmubaraEngine(murmubaraConfig);\n\n // Derive isInitializing from engine state\n const [engineInitializing, setEngineInitializing] = useState(false);\n\n // — Whisper state —\n const [whisperReady, setWhisperReady] = useState(false);\n const [whisperProgress, setWhisperProgress] = useState(0);\n const [whisperError, setWhisperError] = useState<Error | string | null>(null);\n const asrRef = useRef<CallableFunction | null>(null);\n\n // Use Xenova ONNX multilingual models for Spanish support\n // Removed .en suffix to support multiple languages including Spanish\n const modelMap: Record<string, string> = {\n tiny: 'whisper-tiny',\n base: 'whisper-base',\n medium: 'whisper-medium',\n small: 'whisper-small',\n large: 'whisper-large-v3',\n };\n const whisperModel = modelMap[options.initialModel || 'tiny'] || 'whisper-tiny';\n const whisperLanguage = whisperConfig?.language || 'es'; // Default to Spanish\n const whisperQuantized = true;\n\n useEffect(() => {\n let cancelled = false;\n (async () => {\n try {\n // Log initial state\n\n const asr = await ensureASR(whisperModel, whisperQuantized, (p: number) => {\n setWhisperProgress(p);\n if (onWhisperProgressLog) {\n if (p === 100) {\n onWhisperProgressLog(\n `âś… Modelo Whisper ${whisperModel} cargado correctamente`,\n 'success'\n );\n onWhisperProgressLog('🎙️ Sistema de transcripciĂłn listo para usar', 'success');\n } else if (p === 0) {\n onWhisperProgressLog(`📥 Iniciando descarga del modelo ${whisperModel}...`, 'info');\n } else if (p > 0 && p < 25) {\n onWhisperProgressLog(`📥 Descargando modelo Whisper... ${p}%`, 'info');\n } else if (p >= 25 && p < 50) {\n onWhisperProgressLog(`⚙️ Procesando modelo de IA... ${p}%`, 'info');\n } else if (p >= 50 && p < 75) {\n onWhisperProgressLog(`đź”§ Configurando neural network... ${p}%`, 'info');\n } else if (p >= 75 && p < 100) {\n onWhisperProgressLog(`🚀 Finalizando inicializaciĂłn... ${p}%`, 'info');\n }\n }\n });\n if (!cancelled) {\n asrRef.current = asr;\n setWhisperReady(true);\n }\n } catch (e) {\n if (!cancelled) {\n const errorMessage = (e as Error)?.message ?? 'Failed to load Whisper';\n\n setWhisperError(errorMessage);\n onWhisperProgressLog?.(`❌ Error al cargar Whisper: ${errorMessage}`, 'error');\n }\n }\n })();\n return () => {\n cancelled = true;\n asrRef.current = null;\n setWhisperReady(false);\n setWhisperProgress(0);\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [whisperModel]);\n\n // — App state —\n const [audioChunks, setAudioChunks] = useState<AudioChunk[]>([]);\n const [transcriptions, setTranscriptions] = useState<TranscriptionResult[]>([]);\n const [processingStatus, setProcessingStatus] = useState<ProcessingStatus>({\n isProcessing: false,\n currentChunk: 0,\n totalChunks: 0,\n stage: 'idle',\n });\n const [averageVad, setAverageVad] = useState(0);\n\n const [conversationalChunks, setConversationalChunks] = useState<SusurroChunk[]>([]);\n const processedAudioUrls = useRef(new Map<string, string>());\n const chunkTranscriptions = useRef(new Map<string, string>());\n const chunkProcessingTimes = useRef(new Map<string, number>());\n\n // Engine state is now managed by AudioEngineManager - no local state needed\n const [isStreamingRecording, setIsStreamingRecording] = useState(false);\n const streamingCallbackRef = useRef<((c: StreamingSusurroChunk) => void) | null>(null);\n const streamingSessionRef = useRef<{ stop: () => Promise<void> } | null>(null);\n const lastProcessedChunkIndexRef = useRef(0);\n\n const { latencyReport, latencyStatus, recordMetrics } = useLatencyMonitor(300);\n\n const [middlewarePipeline] = useState(() => new ChunkMiddlewarePipeline());\n\n // — Engine init/reset — Now using Murmuraba directly\n const initializeAudioEngine = useCallback(async () => {\n if (engineReady || engineInitializing) return;\n setEngineInitializing(true);\n try {\n await murmubaraInitializeEngine();\n } finally {\n setEngineInitializing(false);\n }\n }, [engineReady, engineInitializing, murmubaraInitializeEngine]);\n\n // Move clearConversationalChunks declaration before resetAudioEngine\n const clearConversationalChunks = useCallback(() => {\n setConversationalChunks([]);\n processedAudioUrls.current.clear();\n chunkTranscriptions.current.clear();\n chunkProcessingTimes.current.clear();\n }, []);\n\n const resetAudioEngine = useCallback(async () => {\n // Stop any ongoing recordings first\n if (recordingState?.isRecording) {\n murmubaraStopRecording();\n }\n\n if (streamingSessionRef.current) {\n await streamingSessionRef.current.stop();\n streamingSessionRef.current = null;\n }\n setIsStreamingRecording(false);\n streamingCallbackRef.current = null;\n\n setAudioChunks([]);\n setTranscriptions([]);\n clearConversationalChunks();\n\n // Proper engine reset through Murmuraba\n await murmubaraDestroyEngine();\n setEngineInitializing(true);\n try {\n await murmubaraInitializeEngine();\n } finally {\n setEngineInitializing(false);\n }\n }, [\n recordingState,\n murmubaraStopRecording,\n murmubaraDestroyEngine,\n murmubaraInitializeEngine,\n clearConversationalChunks,\n ]);\n\n // Engine state synchronization is now handled by AudioEngineManager - no manual sync needed\n\n // — Recording controls (using Murmuraba directly) —\n const startRecording = useCallback(\n async (config?: RecordingConfig) => {\n // Ensure engine is ready\n if (!engineReady) {\n await initializeAudioEngine();\n }\n\n const seconds = (config?.chunkDuration ?? chunkDurationMs / 1000) | 0;\n await murmubaraStartRecording(seconds);\n },\n [engineReady, initializeAudioEngine, murmubaraStartRecording, chunkDurationMs]\n );\n\n const stopRecording = useCallback(() => {\n murmubaraStopRecording();\n }, [murmubaraStopRecording]);\n\n const pauseRecording = useCallback(() => {\n murmurbaraPauseRecording();\n }, [murmurbaraPauseRecording]);\n\n const resumeRecording = useCallback(() => {\n murmubaraResumeRecording();\n }, [murmubaraResumeRecording]);\n\n const clearTranscriptions = useCallback(() => {\n setTranscriptions([]);\n setAudioChunks([]);\n chunkTranscriptions.current.clear();\n processedAudioUrls.current.clear();\n chunkProcessingTimes.current.clear();\n setConversationalChunks([]);\n }, []);\n\n // — Whisper call —\n const transcribeWithWhisper = useCallback(\n async (blob: Blob): Promise<TranscriptionResult | null> => {\n if (!asrRef.current || !whisperReady) return null;\n const t0 = performance.now();\n const out = await transcribeBlobWith(asrRef.current, blob, whisperLanguage);\n // latency metrics (best-effort)\n recordMetrics({\n chunkId: 'file',\n audioToEmitLatency: performance.now() - t0,\n audioProcessingLatency: 0,\n transcriptionLatency: performance.now() - t0,\n middlewareLatency: 0,\n vadScore: 0,\n audioSize: blob.size,\n });\n return out;\n },\n [whisperReady, whisperLanguage, recordMetrics]\n );\n\n // — VAD / metadata vĂa murmuraba processing helpers —\n const analyzeVAD = useCallback(async (buffer: ArrayBuffer) => {\n try {\n const { loadMurmubaraProcessing } = await import('../lib/dynamic-loaders');\n const { murmubaraVAD } = await loadMurmubaraProcessing();\n\n if (!murmubaraVAD) {\n console.warn('murmubaraVAD function not available in murmuraba module');\n return { averageVad: 0, vadScores: [], metrics: [], voiceSegments: [] };\n }\n\n // Log for debugging\n console.log('[analyzeVAD] Buffer type:', buffer.constructor.name, 'Size:', buffer.byteLength);\n console.log('[analyzeVAD] murmubaraVAD type:', typeof murmubaraVAD);\n\n const r = await murmubaraVAD(buffer);\n\n console.log('[analyzeVAD] Result type:', typeof r, 'Keys:', r ? Object.keys(r) : 'null');\n\n return {\n averageVad: r.average || 0,\n vadScores: r.scores || [],\n metrics: r.metrics || [],\n voiceSegments: (r.voiceSegments || []).map(\n (s: {\n startTime?: number;\n endTime?: number;\n vadScore?: number;\n confidence?: number;\n }) => ({\n startTime: s.startTime || 0,\n endTime: s.endTime || 0,\n vadScore: s.vadScore || 0,\n confidence: s.confidence || 0,\n })\n ),\n };\n } catch (error) {\n console.error('VAD analysis failed:', error);\n console.error('Error stack:', error instanceof Error ? error.stack : 'No stack');\n return { averageVad: 0, vadScores: [], metrics: [], voiceSegments: [] };\n }\n }, []);\n\n const convertBlobToBuffer = useCallback((blob: Blob) => blob.arrayBuffer(), []);\n\n const calculateDuration = useCallback(async (buffer: ArrayBuffer): Promise<number> => {\n try {\n const { loadMurmubaraProcessing } = await import('../lib/dynamic-loaders');\n const { extractAudioMetadata } = await loadMurmubaraProcessing();\n const metadata = extractAudioMetadata(buffer);\n return metadata.duration;\n } catch {\n const bytes = buffer.byteLength;\n return Math.max(0.1, bytes / (44100 * 2 * 2));\n }\n }, []);\n\n // — Streaming API (callback por chunk) —\n const startStreamingRecording = useCallback(\n async (onChunk: (chunk: StreamingSusurroChunk) => void, config?: RecordingConfig) => {\n if (isStreamingRecording) throw new Error('Already recording. Stop first.');\n\n // Ensure engine is ready\n if (!engineReady) {\n await initializeAudioEngine();\n }\n\n setIsStreamingRecording(true);\n streamingCallbackRef.current = onChunk;\n lastProcessedChunkIndexRef.current = recordingState?.chunks?.length ?? 0;\n\n // Clear previous streaming chunks\n streamingChunksRef.current = [];\n\n const seconds = (config?.chunkDuration ?? chunkDurationMs / 1000) | 0;\n\n // Start recording with Murmuraba\n await murmubaraStartRecording(seconds);\n\n // Set up cleanup session\n streamingSessionRef.current = {\n stop: async () => {\n murmubaraStopRecording();\n setIsStreamingRecording(false);\n streamingCallbackRef.current = null;\n },\n };\n },\n [\n isStreamingRecording,\n engineReady,\n initializeAudioEngine,\n murmubaraStartRecording,\n murmubaraStopRecording,\n chunkDurationMs,\n recordingState?.chunks?.length,\n ]\n );\n\n // Track streaming chunks separately\n const streamingChunksRef = useRef<StreamingSusurroChunk[]>([]);\n\n const stopStreamingRecording = useCallback(async (): Promise<StreamingSusurroChunk[]> => {\n console.log('[stopStreamingRecording] Stopping recording...');\n\n if (streamingSessionRef.current) {\n await streamingSessionRef.current.stop();\n streamingSessionRef.current = null;\n }\n setIsStreamingRecording(false);\n streamingCallbackRef.current = null;\n\n // Return the chunks that were processed during streaming\n const chunks = [...streamingChunksRef.current];\n console.log('[stopStreamingRecording] Returning', chunks.length, 'chunks');\n\n // Clear for next recording session\n streamingChunksRef.current = [];\n lastProcessedChunkIndexRef.current = 0;\n\n return chunks;\n }, []);\n\n // — Observa y consume chunks de murmuraba (REACTIVO Y LIMPIO) —\n useEffect(() => {\n if (!engineReady || !recordingState?.chunks) {\n return;\n }\n\n const chunks = recordingState.chunks;\n\n // Process new chunks for regular recording\n const newOnes: AudioChunk[] = [];\n for (let i = audioChunks.length; i < chunks.length; i++) {\n const src = chunks[i];\n\n const id = src.id || `chunk-${Date.now()}-${i}`;\n const startTime = src.startTime ?? i * chunkDurationMs;\n const endTime = src.endTime ?? (i + 1) * chunkDurationMs;\n const vadScore = src.averageVad ?? 0;\n\n newOnes.push({\n id,\n blob: undefined as unknown as Blob, // lo traemos on-demand al transcribir\n startTime,\n endTime,\n vadScore,\n duration: src.duration ?? chunkDurationMs,\n });\n\n if (src.processedAudioUrl) {\n processedAudioUrls.current.set(id, src.processedAudioUrl);\n }\n }\n\n if (newOnes.length) {\n setAudioChunks((prev) => [...prev, ...newOnes]);\n }\n\n // promedio de VAD del Ăşltimo\n const last = chunks[chunks.length - 1];\n if (last?.averageVad != null) setAverageVad(last.averageVad);\n }, [engineReady, recordingState?.chunks, audioChunks.length, chunkDurationMs]);\n\n // — Streaming Transcription: Monitoreo reactivo separado —\n useEffect(() => {\n if (!isStreamingRecording || !streamingCallbackRef.current || !recordingState?.chunks) {\n return;\n }\n\n const chunks = recordingState.chunks;\n const newChunks = chunks.slice(lastProcessedChunkIndexRef.current);\n\n if (newChunks.length === 0) return;\n\n // Process new chunks for streaming\n newChunks.forEach(async (chunk, relativeIndex) => {\n const absoluteIndex = lastProcessedChunkIndexRef.current + relativeIndex;\n\n try {\n const audioBlob = await urlToBlob(chunk.processedAudioUrl);\n const vadScore = chunk.averageVad ?? 0;\n const isVoiceActive = vadScore > 0.3;\n\n let transcriptionText = '';\n if (whisperReady && isVoiceActive && audioBlob.size > 0) {\n try {\n const r = await transcribeWithWhisper(audioBlob);\n transcriptionText = r?.text ?? '';\n } catch (error) {\n console.error('[STREAMING] Transcription error:', error);\n }\n }\n\n const streamingChunk: StreamingSusurroChunk = {\n id: chunk.id || `chunk-${Date.now()}-${absoluteIndex}`,\n audioBlob,\n vadScore,\n timestamp: Date.now(),\n transcriptionText,\n duration: chunk.duration ?? chunkDurationMs,\n isVoiceActive,\n };\n\n streamingChunksRef.current.push(streamingChunk);\n streamingCallbackRef.current?.(streamingChunk);\n } catch (error) {\n console.error('[STREAMING] Error processing chunk:', error);\n }\n });\n\n lastProcessedChunkIndexRef.current = chunks.length;\n }, [\n recordingState?.chunks,\n isStreamingRecording,\n whisperReady,\n transcribeWithWhisper,\n chunkDurationMs,\n ]);\n\n // — Conversational emit: cuando audio + transcripciĂłn están listos —\n const tryEmitChunk = useCallback(\n async (chunk: AudioChunk, forceEmit = false) => {\n if (!conversational?.onChunk) return;\n\n const audioUrl = processedAudioUrls.current.get(chunk.id);\n const transcript = chunkTranscriptions.current.get(chunk.id);\n const t0 = chunkProcessingTimes.current.get(chunk.id);\n\n if (audioUrl && (transcript || forceEmit)) {\n let emitted: SusurroChunk = {\n id: chunk.id,\n audioUrl,\n transcript: transcript ?? '',\n startTime: chunk.startTime,\n endTime: chunk.endTime,\n vadScore: chunk.vadScore ?? 0,\n isComplete: Boolean(transcript),\n processingLatency: t0 ? Date.now() - t0 : undefined,\n };\n\n const t1 = performance.now();\n try {\n emitted = await middlewarePipeline.process(emitted);\n } catch {\n /* ignore middleware errors */\n }\n const middlewareLatency = performance.now() - t1;\n\n if (emitted.processingLatency != null) {\n recordMetrics({\n chunkId: chunk.id,\n audioToEmitLatency: emitted.processingLatency,\n audioProcessingLatency: Math.max(0, emitted.processingLatency - middlewareLatency),\n transcriptionLatency: 0,\n middlewareLatency,\n vadScore: chunk.vadScore,\n audioSize: 0,\n });\n }\n\n setConversationalChunks((prev) => [...prev, emitted]);\n conversational.onChunk(emitted);\n chunkProcessingTimes.current.delete(chunk.id);\n }\n },\n [conversational, middlewarePipeline, recordMetrics]\n );\n\n // — Auto-procesa al terminar la grabaciĂłn (batch) —\n const processChunks = useCallback(\n async (chunks: AudioChunk[]) => {\n if (!chunks.length) return;\n setProcessingStatus({\n isProcessing: true,\n currentChunk: 0,\n totalChunks: chunks.length,\n stage: 'processing',\n });\n\n for (let i = 0; i < chunks.length; i++) {\n setProcessingStatus((p: any) => ({ ...p, currentChunk: i + 1 }));\n const id = chunks[i].id;\n const processedUrl = processedAudioUrls.current.get(id);\n if (!processedUrl) continue;\n try {\n const blob = await urlToBlob(processedUrl);\n const r = await transcribeWithWhisper(blob);\n if (r) {\n setTranscriptions((prev: any[]) => [\n ...prev,\n { ...r, chunkIndex: i, timestamp: Date.now() },\n ]);\n chunkTranscriptions.current.set(id, r.text);\n await tryEmitChunk(chunks[i]);\n }\n } catch {\n /* ignore chunk fail */\n }\n }\n\n setProcessingStatus({\n isProcessing: false,\n currentChunk: 0,\n totalChunks: 0,\n stage: 'complete',\n });\n },\n [transcribeWithWhisper, tryEmitChunk]\n );\n\n useEffect(() => {\n // si hay chunks y no estamos grabando → procesar batch\n if (audioChunks.length > 0 && engineReady && whisperReady) {\n const isRecording = recordingState?.isRecording ?? false;\n\n if (!isRecording) {\n setTimeout(() => {\n if (!conversational?.onChunk || conversational.enableInstantTranscription) {\n processChunks(audioChunks);\n }\n }, 50);\n }\n }\n }, [\n audioChunks,\n engineReady,\n whisperReady,\n recordingState?.isRecording,\n conversational,\n processChunks,\n ]);\n\n // — Limpieza — (already moved before resetAudioEngine)\n\n useEffect(() => {\n return () => {\n clearConversationalChunks();\n if (streamingSessionRef.current) {\n streamingSessionRef.current.stop().catch(() => {});\n }\n };\n }, [clearConversationalChunks]);\n\n // — Export chunk as WAV (using Murmuraba's export) —\n const exportChunkAsWav = useCallback(\n async (chunkId: string) => {\n if (!murmubaraExportChunkAsWav) {\n console.warn('Export chunk feature not available');\n return new Blob();\n }\n return murmubaraExportChunkAsWav(chunkId, 'processed');\n },\n [murmubaraExportChunkAsWav]\n );\n\n // — File pipeline end-to-end —\n const processAndTranscribeFile = useCallback(\n async (file: File): Promise<CompleteAudioResult> => {\n const t0 = performance.now();\n if (!whisperReady) throw new Error('Whisper model not ready');\n\n await initializeAudioEngine();\n\n const originalBuffer = await file.arrayBuffer();\n const originalAudioUrl = URL.createObjectURL(file);\n\n const { loadMurmubaraProcessing } = await import('../lib/dynamic-loaders');\n const {\n processFileWithMetrics,\n getEngineStatus,\n initializeAudioEngine: initProc,\n } = await loadMurmubaraProcessing();\n\n try {\n const status = getEngineStatus?.() ?? 'uninitialized';\n if (status === 'uninitialized' && initProc) {\n await initProc({\n noiseReductionLevel: 'medium',\n bufferSize: 1024,\n algorithm: 'rnnoise',\n logLevel: 'info',\n autoCleanup: true,\n useAudioWorklet: true,\n });\n }\n } catch {\n /* ignore */\n }\n\n const processed = await processFileWithMetrics(originalBuffer, () => {});\n const processedBlob = new Blob([processed.processedBuffer], { type: 'audio/wav' });\n const processedAudioUrl = URL.createObjectURL(processedBlob);\n\n const vadAnalysis = await analyzeVAD(originalBuffer);\n const tr = await transcribeWithWhisper(processedBlob);\n if (!tr) throw new Error('Transcription failed');\n\n const metadata: AudioMetadata = {\n duration: await calculateDuration(originalBuffer),\n sampleRate: 44100,\n channels: 2,\n fileSize: file.size,\n processedSize: processed.processedBuffer.byteLength,\n };\n\n return {\n originalAudioUrl,\n processedAudioUrl,\n transcriptionText: tr.text,\n transcriptionSegments: tr.segments,\n vadAnalysis,\n metadata,\n processingTime: performance.now() - t0,\n };\n },\n [whisperReady, initializeAudioEngine, analyzeVAD, transcribeWithWhisper, calculateDuration]\n );\n\n return {\n // Recording (managed by Murmuraba directly)\n isRecording: recordingState?.isRecording ?? false,\n isProcessing: processingStatus.isProcessing,\n transcriptions,\n audioChunks,\n processingStatus,\n averageVad,\n\n startRecording,\n stopRecording,\n pauseRecording,\n resumeRecording,\n clearTranscriptions,\n\n exportChunkAsWav,\n\n whisperReady,\n whisperProgress,\n whisperError,\n transcribeWithWhisper,\n\n conversationalChunks,\n clearConversationalChunks,\n\n middlewarePipeline,\n\n latencyReport,\n latencyStatus,\n\n initializeAudioEngine,\n resetAudioEngine,\n isEngineInitialized: engineReady,\n engineError: engineError ? String(engineError) : null,\n isInitializingEngine: engineInitializing,\n\n processAndTranscribeFile,\n\n startStreamingRecording,\n stopStreamingRecording,\n\n analyzeVAD,\n convertBlobToBuffer,\n\n currentStream: engineStream,\n };\n}\n","// Chunk Middleware Pipeline - Extensible processing for SusurroChunks\n// Part of Murmuraba v3 Conversational Evolution\n\nimport type { SusurroChunk } from './types';\n\nexport interface ChunkMiddleware {\n name: string;\n process: (chunk: SusurroChunk) => Promise<SusurroChunk>;\n enabled: boolean;\n priority: number; // Lower number = higher priority\n}\n\nexport interface MiddlewareContext {\n startTime: number;\n processingStage: 'pre-emit' | 'post-emit';\n metadata: Record<string, unknown>;\n}\n\n// Translation Middleware\nexport const translationMiddleware: ChunkMiddleware = {\n name: 'translation',\n enabled: false,\n priority: 1,\n async process(chunk: SusurroChunk): Promise<SusurroChunk> {\n // Future: Integrate with translation API\n const translatedText = chunk.transcript; // Placeholder\n\n return {\n ...chunk,\n metadata: {\n ...chunk.metadata,\n originalLanguage: 'en',\n translatedText,\n translationConfidence: 0.95,\n },\n };\n },\n};\n\n// Sentiment Analysis Middleware\nexport const sentimentMiddleware: ChunkMiddleware = {\n name: 'sentiment',\n enabled: false,\n priority: 2,\n async process(chunk: SusurroChunk): Promise<SusurroChunk> {\n // Future: Integrate with sentiment analysis\n const sentiment = analyzeSentiment(chunk.transcript);\n\n return {\n ...chunk,\n metadata: {\n ...chunk.metadata,\n sentiment: sentiment.label,\n sentimentScore: sentiment.score,\n emotion: sentiment.emotion,\n },\n };\n },\n};\n\n// Intent Detection Middleware\nexport const intentMiddleware: ChunkMiddleware = {\n name: 'intent',\n enabled: false,\n priority: 3,\n async process(chunk: SusurroChunk): Promise<SusurroChunk> {\n // Future: Integrate with intent detection\n const intent = detectIntent(chunk.transcript);\n\n return {\n ...chunk,\n metadata: {\n ...chunk.metadata,\n intent: intent.name,\n intentConfidence: intent.confidence,\n entities: intent.entities,\n },\n };\n },\n};\n\n// Quality Enhancement Middleware\nexport const qualityMiddleware: ChunkMiddleware = {\n name: 'quality',\n enabled: true,\n priority: 0, // Highest priority\n async process(chunk: SusurroChunk): Promise<SusurroChunk> {\n // Audio quality validation and enhancement\n const qualityMetrics = analyzeAudioQuality(chunk);\n\n return {\n ...chunk,\n metadata: {\n ...chunk.metadata,\n audioQuality: qualityMetrics.score,\n noiseLevel: qualityMetrics.noiseLevel,\n clarity: qualityMetrics.clarity,\n enhancement: qualityMetrics.applied,\n },\n };\n },\n};\n\n// Middleware Pipeline Manager\nexport class ChunkMiddlewarePipeline {\n private middlewares: ChunkMiddleware[] = [];\n // private context: MiddlewareContext; // Context stored for future middleware extensions\n\n constructor() {\n // Ready for middleware registration\n\n // Register default middlewares\n this.register(qualityMiddleware);\n this.register(translationMiddleware);\n this.register(sentimentMiddleware);\n this.register(intentMiddleware);\n }\n\n register(middleware: ChunkMiddleware): void {\n this.middlewares.push(middleware);\n this.middlewares.sort((a, b) => a.priority - b.priority);\n }\n\n unregister(name: string): void {\n this.middlewares = this.middlewares.filter((m) => m.name !== name);\n }\n\n enable(name: string): void {\n const middleware = this.middlewares.find((m) => m.name === name);\n if (middleware) {\n middleware.enabled = true;\n }\n }\n\n disable(name: string): void {\n const middleware = this.middlewares.find((m) => m.name === name);\n if (middleware) {\n middleware.enabled = false;\n }\n }\n\n async process(chunk: SusurroChunk): Promise<SusurroChunk> {\n let processedChunk = { ...chunk };\n const processingLatencies: Record<string, number> = {};\n\n for (const middleware of this.middlewares) {\n if (!middleware.enabled) continue;\n\n const startTime = performance.now();\n\n try {\n processedChunk = await middleware.process(processedChunk);\n\n const latency = performance.now() - startTime;\n processingLatencies[middleware.name] = latency;\n } catch (error) {\n // Continue processing with other middlewares\n }\n }\n\n // Add processing metadata\n return {\n ...processedChunk,\n metadata: {\n ...processedChunk.metadata,\n middlewareLatencies: processingLatencies,\n totalMiddlewareTime: Object.values(processingLatencies).reduce((a, b) => a + b, 0),\n },\n };\n }\n\n getStatus(): { name: string; enabled: boolean; priority: number }[] {\n return this.middlewares.map((m) => ({\n name: m.name,\n enabled: m.enabled,\n priority: m.priority,\n }));\n }\n}\n\n// Helper functions for middleware implementations\nfunction analyzeSentiment(text: string): { label: string; score: number; emotion: string } {\n // Placeholder implementation - Future: integrate with sentiment API\n const positiveWords = ['good', 'great', 'awesome', 'excellent', 'amazing'];\n const negativeWords = ['bad', 'terrible', 'awful', 'horrible', 'worst'];\n\n const words = text.toLowerCase().split(' ');\n const positiveCount = words.filter((w) => positiveWords.includes(w)).length;\n const negativeCount = words.filter((w) => negativeWords.includes(w)).length;\n\n if (positiveCount > negativeCount) {\n return { label: 'positive', score: 0.7, emotion: 'happy' };\n } else if (negativeCount > positiveCount) {\n return { label: 'negative', score: 0.7, emotion: 'sad' };\n }\n\n return { label: 'neutral', score: 0.5, emotion: 'neutral' };\n}\n\nfunction detectIntent(text: string): { name: string; confidence: number; entities: string[] } {\n // Placeholder implementation - Future: integrate with NLU API\n const questionWords = ['what', 'how', 'when', 'where', 'why', 'who'];\n const commandWords = ['play', 'stop', 'start', 'open', 'close', 'send'];\n\n const words = text.toLowerCase().split(' ');\n\n if (words.some((w) => questionWords.includes(w))) {\n return { name: 'question', confidence: 0.8, entities: [] };\n } else if (words.some((w) => commandWords.includes(w))) {\n return { name: 'command', confidence: 0.8, entities: [] };\n }\n\n return { name: 'statement', confidence: 0.6, entities: [] };\n}\n\nfunction analyzeAudioQuality(chunk: SusurroChunk): {\n score: number;\n noiseLevel: number;\n clarity: number;\n applied: string[];\n} {\n // Placeholder implementation - Future: integrate with audio analysis\n return {\n score: chunk.vadScore || 0.8,\n noiseLevel: 0.1, // Low noise thanks to neural processing\n clarity: 0.9,\n applied: ['neural_denoising', 'voice_enhancement'],\n };\n}\n","import { useState, useCallback, useEffect, useRef } from 'react';\nimport { LatencyMonitor, type LatencyMetrics, type LatencyReport } from '../lib/latency-monitor';\n\ninterface UseLatencyMonitorReturn {\n latencyReport: LatencyReport;\n latencyStatus: {\n isHealthy: boolean;\n currentLatency: number;\n trend: 'improving' | 'degrading' | 'stable';\n lastOptimization?: string;\n };\n recordMetrics: (metrics: Omit<LatencyMetrics, 'timestamp'>) => void;\n exportMetrics: (format?: 'json' | 'csv') => string;\n clear: () => void;\n getMetricsCount: () => number;\n onOptimization: (listener: (data: unknown) => void) => void;\n offOptimization: (listener: (data: unknown) => void) => void;\n}\n\n/**\n * Hook-based latency monitor\n * Replaces the singleton latencyMonitor with modern React patterns\n */\nexport function useLatencyMonitor(targetLatency = 300): UseLatencyMonitorReturn {\n const monitorRef = useRef<LatencyMonitor | null>(null);\n const [latencyReport, setLatencyReport] = useState<LatencyReport>(() => {\n if (!monitorRef.current) {\n monitorRef.current = new LatencyMonitor(targetLatency);\n }\n return monitorRef.current.generateReport();\n });\n const [latencyStatus, setLatencyStatus] = useState(() => {\n if (!monitorRef.current) {\n monitorRef.current = new LatencyMonitor(targetLatency);\n }\n return monitorRef.current.getRealtimeStatus();\n });\n\n // Initialize monitor if not already done\n useEffect(() => {\n if (!monitorRef.current) {\n monitorRef.current = new LatencyMonitor(targetLatency);\n }\n }, [targetLatency]);\n\n // Record metrics\n const recordMetrics = useCallback((metrics: Omit<LatencyMetrics, 'timestamp'>) => {\n if (monitorRef.current) {\n monitorRef.current.recordMetrics(metrics);\n // Update status immediately after recording\n setLatencyStatus(monitorRef.current.getRealtimeStatus());\n }\n }, []);\n\n // Export metrics\n const exportMetrics = useCallback((format: 'json' | 'csv' = 'json'): string => {\n if (monitorRef.current) {\n return monitorRef.current.exportMetrics(format);\n }\n return format === 'json' ? '[]' : '';\n }, []);\n\n // Clear metrics\n const clear = useCallback(() => {\n if (monitorRef.current) {\n monitorRef.current.clear();\n setLatencyReport(monitorRef.current.generateReport());\n setLatencyStatus(monitorRef.current.getRealtimeStatus());\n }\n }, []);\n\n // Get metrics count\n const getMetricsCount = useCallback((): number => {\n if (monitorRef.current) {\n return monitorRef.current.getMetricsCount();\n }\n return 0;\n }, []);\n\n // Optimization event listeners\n const onOptimization = useCallback((listener: (data: unknown) => void) => {\n if (monitorRef.current) {\n monitorRef.current.on('optimization-trigger', listener);\n }\n }, []);\n\n const offOptimization = useCallback((listener: (data: unknown) => void) => {\n if (monitorRef.current) {\n monitorRef.current.off('optimization-trigger', listener);\n }\n }, []);\n\n // Periodic report updates\n useEffect(() => {\n const updateLatencyReport = () => {\n if (monitorRef.current) {\n setLatencyReport(monitorRef.current.generateReport());\n setLatencyStatus(monitorRef.current.getRealtimeStatus());\n }\n };\n\n // Update every 10 seconds for real-time monitoring\n const interval = setInterval(updateLatencyReport, 10000);\n\n return () => clearInterval(interval);\n }, []);\n\n return {\n latencyReport,\n latencyStatus,\n recordMetrics,\n exportMetrics,\n clear,\n getMetricsCount,\n onOptimization,\n offOptimization,\n };\n}\n","// Latency Monitor - High-precision latency measurement and optimization\n// Part of Murmuraba v3 Conversational Evolution - Phase 3\n\nexport interface LatencyMetrics {\n audioToEmitLatency: number; // Total audio-to-emit latency\n audioProcessingLatency: number; // Murmuraba processing time\n transcriptionLatency: number; // Whisper transcription time\n middlewareLatency: number; // Middleware processing time\n chunkId: string;\n timestamp: number;\n vadScore?: number;\n audioSize?: number; // Blob size in bytes\n}\n\nexport interface LatencyReport {\n averageLatency: number;\n medianLatency: number;\n p95Latency: number;\n p99Latency: number;\n minLatency: number;\n maxLatency: number;\n targetMet: boolean; // <300ms achieved?\n sampleCount: number;\n timeRange: { start: number; end: number };\n breakdown: {\n audioProcessing: number;\n transcription: number;\n middleware: number;\n };\n}\n\nexport interface PerformanceOptimization {\n name: string;\n description: string;\n expectedLatencyReduction: number; // Expected reduction in ms\n condition: (metrics: LatencyMetrics) => boolean;\n apply: () => Promise<void>;\n}\n\nexport class LatencyMonitor {\n private metrics: LatencyMetrics[] = [];\n private maxMetrics = 1000; // Keep last 1000 measurements\n private target = 300; // Target latency in ms\n private optimizations: PerformanceOptimization[] = [];\n\n constructor(targetLatency = 300) {\n this.target = targetLatency;\n this.setupOptimizations();\n }\n\n recordMetrics(metrics: Omit<LatencyMetrics, 'timestamp'>): void {\n const fullMetrics: LatencyMetrics = {\n ...metrics,\n timestamp: performance.now(),\n };\n\n this.metrics.push(fullMetrics);\n\n // Keep only recent metrics to prevent memory bloat\n if (this.metrics.length > this.maxMetrics) {\n this.metrics = this.metrics.slice(-this.maxMetrics);\n }\n\n // Real-time optimization triggers\n this.checkForOptimizations(fullMetrics);\n }\n\n generateReport(lastNMinutes = 5): LatencyReport {\n const cutoffTime = performance.now() - lastNMinutes * 60 * 1000;\n const recentMetrics = this.metrics.filter((m) => m.timestamp > cutoffTime);\n\n if (recentMetrics.length === 0) {\n return this.getEmptyReport();\n }\n\n const latencies = recentMetrics.map((m) => m.audioToEmitLatency).sort((a, b) => a - b);\n const avgLatency = latencies.reduce((a, b) => a + b, 0) / latencies.length;\n\n return {\n averageLatency: avgLatency,\n medianLatency: this.getPercentile(latencies, 50),\n p95Latency: this.getPercentile(latencies, 95),\n p99Latency: this.getPercentile(latencies, 99),\n minLatency: latencies[0],\n maxLatency: latencies[latencies.length - 1],\n targetMet: avgLatency < this.target,\n sampleCount: recentMetrics.length,\n timeRange: {\n start: recentMetrics[0].timestamp,\n end: recentMetrics[recentMetrics.length - 1].timestamp,\n },\n breakdown: {\n audioProcessing: this.calculateAverageBreakdown(recentMetrics, 'audioProcessingLatency'),\n transcriptio