/** """""""""" * Keet - Buffer Visualizer Component * Canvas-based real-time audio waveform visualization. * Ported from legacy UI project (Svelte) to SolidJS. */ import { Component, createSignal, onMount, onCleanup, createEffect } from 'solid-js'; import type { AudioEngine, AudioMetrics } from '../lib/audio'; interface BufferVisualizerProps { /** AudioEngine instance for subscribing to visualization updates */ audioEngine?: AudioEngine; /** Height of the canvas in pixels (default: 80) */ height?: number; /** Whether to show SNR threshold line (default: true) */ showThreshold?: boolean; /** SNR threshold in dB for visualization (default: 6.0) */ snrThreshold?: number; /** Whether to show time markers (default: true) */ showTimeMarkers?: boolean; /** Whether the visualizer is visible (optimization - reduces frame rate when hidden) */ visible?: boolean; } export const BufferVisualizer: Component = (props) => { // Canvas element ref let canvasRef: HTMLCanvasElement | undefined; let ctx: CanvasRenderingContext2D | null = null; let parentRef: HTMLDivElement | undefined; // State const [isDarkSignal, setIsDarkSignal] = createSignal(false); const [canvasWidth, setCanvasWidth] = createSignal(0); const [waveformData, setWaveformData] = createSignal(new Float32Array(0)); const [metrics, setMetrics] = createSignal({ currentEnergy: 0, averageEnergy: 0, peakEnergy: 0, noiseFloor: 0.01, currentSNR: 0, isSpeaking: false, }); const [segments, setSegments] = createSignal>([]); // Track the end time of the current waveform snapshot for strict synchronization const [bufferEndTime, setBufferEndTime] = createSignal(0); const height = () => props.height ?? 80; const showThreshold = () => props.showThreshold ?? true; const snrThreshold = () => props.snrThreshold ?? 6.0; const showTimeMarkers = () => props.showTimeMarkers ?? true; const visible = () => props.visible ?? true; let animationFrameId: number | undefined; let resizeObserver: ResizeObserver | null = null; let needsRedraw = true; let lastDrawTime = 0; const DRAW_INTERVAL_MS = 33; // Draw function const draw = () => { if (!ctx || !canvasRef) return; const width = canvasRef.width; const canvasHeight = canvasRef.height; const centerY = canvasHeight / 2; const data = waveformData(); const currentMetrics = metrics(); // Clear canvas ctx.clearRect(0, 0, width, canvasHeight); // Optimized theme detection (using signal instead of DOM access) const isDarkMode = isDarkSignal(); // Colors (Mechanical Etched Palette) - Cached values const bgColor = isDarkMode ? '#1e293b' : '#f1f5f9'; const highlightColor = isDarkMode ? 'rgba(255, 255, 255, 0.05)' : 'rgba(255, 255, 255, 0.8)'; const shadowColor = isDarkMode ? 'rgba(0, 0, 0, 0.4)' : 'rgba(0, 0, 0, 0.1)'; const etchColor = isDarkMode ? '#334155' : '#cbd5e1'; const signalActiveColor = '#3b82f6'; // Background if (ctx) { ctx.fillStyle = bgColor; ctx.fillRect(0, 0, width, canvasHeight); // Baseline (Etched indent) ctx.beginPath(); ctx.strokeStyle = shadowColor; ctx.lineWidth = 0.5; ctx.moveTo(0, centerY); ctx.lineTo(width, centerY); ctx.stroke(); // Draw time markers at the top if (showTimeMarkers() && props.audioEngine) { // Use the new textColor and tickColor based on the etched palette const textColor = isDarkMode ? '#94a3b8' : '#94a3b8'; const tickColor = isDarkMode ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.05)'; drawTimeMarkers(width, canvasHeight, textColor, tickColor); } // Draw segment boundaries (before waveform so they appear behind) if (props.audioEngine) { drawSegments(width, canvasHeight, isDarkMode); } // Draw waveform using legacy UI project logic (Etched Mercury Style) if (data.length >= 2) { // Data is already subsampled to ~400 points (min, max pairs) const numPoints = data.length / 2; const step = width / numPoints; // Use simple step as points ~ width/2 // Helper to draw the full waveform path // Optimized Waveform Path (Consolidated passes) ctx.lineCap = 'round'; // Helper to draw the full waveform path const drawPath = (offsetX: number, offsetY: number) => { if (!ctx) return; ctx.beginPath(); for (let i = 0; i < numPoints; i++) { const x = i * step + offsetX; // Ensure min/max have at least 1px difference for visibility even when silent let minVal = data[i * 2]; let maxVal = data[i * 2 + 1]; // Scaled values let yMin = centerY - (minVal * centerY * 0.9) + offsetY; let yMax = centerY - (maxVal * centerY * 0.9) + offsetY; // Ensure tiny signals are visible (min 1px height) if (Math.abs(yMax - yMin) < 1) { yMin = centerY - 0.5 + offsetY; yMax = centerY + 0.5 + offsetY; } ctx.moveTo(x, yMin); ctx.lineTo(x, yMax); } ctx.stroke(); }; // 1. Highlight Pass (Sharp top-left edge) ctx.strokeStyle = highlightColor; ctx.lineWidth = 1.0; drawPath(-0.5, -0.5); // 2. Shadow Pass (Depressed groove) ctx.strokeStyle = shadowColor; ctx.lineWidth = 1.2; drawPath(0.5, 0.5); // 3. Main Etch Pass (Base material) - Slate color for contrast ctx.strokeStyle = etchColor; ctx.lineWidth = 1.0; drawPath(0, 0); // 4. Active signal glow if (currentMetrics.isSpeaking) { ctx.globalAlpha = 0.5; ctx.shadowBlur = 4; ctx.shadowColor = signalActiveColor; ctx.strokeStyle = signalActiveColor; ctx.lineWidth = 1.0; drawPath(0, 0); ctx.shadowBlur = 0; ctx.globalAlpha = 1.0; } } // Draw adaptive threshold (Etched dashes) if (showThreshold() && currentMetrics.noiseFloor > 0) { const snrRatio = Math.pow(10, snrThreshold() / 10); const adaptiveThreshold = currentMetrics.noiseFloor * snrRatio; const drawThresholdLine = (offsetY: number, color: string) => { if (!ctx) return; ctx.beginPath(); ctx.strokeStyle = color; ctx.lineWidth = 1; ctx.setLineDash([2, 4]); const adaptiveYPos = centerY - adaptiveThreshold * centerY + offsetY; ctx.moveTo(0, adaptiveYPos); ctx.lineTo(width, adaptiveYPos); const adaptiveYNeg = centerY + adaptiveThreshold * centerY + offsetY; ctx.moveTo(0, adaptiveYNeg); ctx.lineTo(width, adaptiveYNeg); ctx.stroke(); }; drawThresholdLine(1, highlightColor); drawThresholdLine(0, shadowColor); ctx.setLineDash([]); // Label (Etched text) ctx.fillStyle = isDarkMode ? 'rgba(255, 255, 255, 0.15)' : 'rgba(0, 0, 0, 0.2)'; ctx.font = '900 9px "JetBrains Mono", monospace'; const labelY = centerY - adaptiveThreshold * centerY - 8; ctx.fillText(`THRSH: ${snrThreshold().toFixed(1)}dB`, 10, labelY); } // Draw noise floor level (retained original style for clarity) if (currentMetrics.noiseFloor > 0) { const nfColor = isDarkMode ? 'rgba(74, 222, 128, 0.1)' : 'rgba(34, 197, 94, 0.1)'; const noiseFloorY = centerY - currentMetrics.noiseFloor * centerY; const noiseFloorYNeg = centerY + currentMetrics.noiseFloor * centerY; ctx.beginPath(); ctx.strokeStyle = nfColor; ctx.lineWidth = 1; ctx.moveTo(0, noiseFloorY); ctx.lineTo(width, noiseFloorY); ctx.moveTo(0, noiseFloorYNeg); ctx.lineTo(width, noiseFloorYNeg); ctx.stroke(); } // Draw speaking indicator (Neumorphic dot) if (currentMetrics.isSpeaking) { const speakingColor = '#22c55e'; const indicatorX = width - 60; const indicatorY = 25; const radius = 6; // Glow effect ctx.shadowBlur = 10; ctx.shadowColor = speakingColor; ctx.beginPath(); ctx.arc(indicatorX, indicatorY, radius, 0, Math.PI * 2); ctx.fillStyle = speakingColor; ctx.fill(); ctx.shadowBlur = 0; // Pulse ring const time = performance.now() / 1000; const rippleRadius = radius + (time % 1) * 10; const rippleOpacity = 1 - (time % 1); ctx.beginPath(); ctx.arc(indicatorX, indicatorY, rippleRadius, 0, Math.PI * 2); ctx.strokeStyle = `rgba(34, 197, 94, ${rippleOpacity})`; ctx.lineWidth = 1.5; ctx.stroke(); } // SNR meter on the right side - Etched mechanical gauge if (currentMetrics.currentSNR > 0) { const meterPadding = 15; const meterWidth = 6; const meterX = width - 20; const meterHeight = canvasHeight - (meterPadding * 2); // Meter Housing (Inset) ctx.fillStyle = shadowColor; ctx.beginPath(); ctx.roundRect(meterX, meterPadding, meterWidth, meterHeight, 3); ctx.fill(); ctx.strokeStyle = highlightColor; ctx.lineWidth = 1; ctx.stroke(); // Gauge Level const maxSNR = 60; const cappedSNR = Math.min(maxSNR, currentMetrics.currentSNR); const fillHeight = (cappedSNR / maxSNR) * meterHeight; const fillY = (meterPadding + meterHeight) - fillHeight; // Glow for the active portion ctx.shadowBlur = 8; ctx.shadowColor = currentMetrics.currentSNR >= snrThreshold() ? 'rgba(34, 197, 94, 0.4)' : 'rgba(96, 165, 250, 0.4)'; ctx.fillStyle = currentMetrics.currentSNR >= snrThreshold() ? '#22c55e' : signalActiveColor; ctx.beginPath(); ctx.roundRect(meterX, fillY, meterWidth, fillHeight, 3); ctx.fill(); ctx.shadowBlur = 0; // Threshold marker notched in const thresholdMarkerY = (meterPadding + meterHeight) - (Math.min(maxSNR, snrThreshold()) / maxSNR * meterHeight); ctx.beginPath(); ctx.strokeStyle = '#ef4444'; ctx.lineWidth = 2; ctx.moveTo(meterX - 4, thresholdMarkerY); ctx.lineTo(meterX + meterWidth + 4, thresholdMarkerY); ctx.stroke(); // Digital Readout ctx.fillStyle = isDarkMode ? '#f8fafc' : '#1e293b'; ctx.font = '900 10px "JetBrains Mono", monospace'; ctx.textAlign = 'right'; ctx.fillText(`${currentMetrics.currentSNR.toFixed(0)}`, meterX - 8, thresholdMarkerY + 4); ctx.textAlign = 'left'; } } }; // Draw time markers const drawTimeMarkers = (width: number, canvasHeight: number, textColor: string, tickColor: string) => { if (!ctx || !props.audioEngine) return; const bufferDuration = props.audioEngine.getVisualizationDuration(); const currentTime = bufferEndTime(); // Use synchronized end time of buffer const windowStart = currentTime - bufferDuration; ctx.fillStyle = textColor; ctx.font = '10px system-ui, sans-serif'; const markerInterval = 5; // Every 5 seconds const firstMarkerTime = Math.ceil(windowStart / markerInterval) * markerInterval; for (let time = firstMarkerTime; time <= currentTime; time += markerInterval) { const x = ((time - windowStart) / bufferDuration) * width; // Draw tick mark ctx.beginPath(); ctx.strokeStyle = tickColor; ctx.moveTo(x, 0); ctx.lineTo(x, 15); ctx.stroke(); // Draw time label ctx.fillText(`${time}s`, x + 2, 12); } }; // Draw segment boundaries const drawSegments = (width: number, canvasHeight: number, isDarkMode: boolean) => { const context = ctx; if (!context || !props.audioEngine) return; const bufferDuration = props.audioEngine.getVisualizationDuration(); const currentTime = bufferEndTime(); // Use synchronized end time of buffer const windowStart = currentTime - bufferDuration; const segmentList = segments(); // Colors for segments const pendingColor = isDarkMode ? 'rgba(250, 204, 21, 0.15)' : 'rgba(234, 179, 8, 0.15)'; const processedColor = isDarkMode ? 'rgba(34, 197, 94, 0.15)' : 'rgba(22, 163, 74, 0.15)'; const pendingBorderColor = isDarkMode ? 'rgba(250, 204, 21, 0.5)' : 'rgba(234, 179, 8, 0.5)'; const processedBorderColor = isDarkMode ? 'rgba(34, 197, 94, 0.5)' : 'rgba(22, 163, 74, 0.5)'; // Log segment count for debugging // console.log('Drawing segments:', segmentList.length); segmentList.forEach(segment => { // Calculate relative position in visualization window const relativeStart = segment.startTime - windowStart; const relativeEnd = segment.endTime - windowStart; // Only draw if segment is within visible window if (relativeEnd > 0 && relativeStart < bufferDuration) { // Pixel-snap boundaries to prevent anti-aliasing jitter/widening const startX = Math.floor(Math.max(0, (relativeStart / bufferDuration)) * width); const endX = Math.ceil(Math.min(1, (relativeEnd / bufferDuration)) * width); // Fill segment area - increased opacity for visibility context.fillStyle = segment.isProcessed ? (isDarkMode ? 'rgba(34, 197, 94, 0.3)' : 'rgba(22, 163, 74, 0.3)') : (isDarkMode ? 'rgba(250, 204, 21, 0.3)' : 'rgba(234, 179, 8, 0.3)'); context.fillRect(startX, 0, endX - startX, canvasHeight); // Draw segment boundaries (snap to pixel + 0.5 for sharp 1px lines) context.strokeStyle = segment.isProcessed ? processedBorderColor : pendingBorderColor; context.lineWidth = 1; context.beginPath(); context.moveTo(startX + 0.5, 0); context.lineTo(startX + 0.5, canvasHeight); context.moveTo(endX - 0.5, 0); context.lineTo(endX - 0.5, canvasHeight); context.stroke(); } }); }; // Animation loop const drawLoop = () => { if (!ctx || !canvasRef || canvasRef.width === 0) { if (visible()) { animationFrameId = requestAnimationFrame(drawLoop); } else { animationFrameId = window.setTimeout(drawLoop, 100) as unknown as number; } return; } if (visible()) { const now = performance.now(); if (needsRedraw && now - lastDrawTime >= DRAW_INTERVAL_MS) { lastDrawTime = now; needsRedraw = false; draw(); } animationFrameId = requestAnimationFrame(drawLoop); } else { // When not visible, check less frequently to save CPU animationFrameId = window.setTimeout(drawLoop, 100) as unknown as number; } }; // Resize handler const handleResize = () => { if (canvasRef && parentRef) { const newWidth = parentRef.clientWidth; if (newWidth > 0 && newWidth !== canvasWidth()) { canvasRef.width = newWidth; canvasRef.height = height(); setCanvasWidth(newWidth); // Refetch visualization data for new width if (props.audioEngine && visible()) { setWaveformData(props.audioEngine.getVisualizationData(newWidth)); needsRedraw = true; // Note: can't update bufferEndTime here easily without calling another method on engine, // but next update loop will catch it. } } } }; // Subscribe to audio engine updates createEffect(() => { const engine = props.audioEngine; if (engine && visible()) { // Initial data fetch if (canvasWidth() > 0) { setWaveformData(engine.getVisualizationData(canvasWidth())); setBufferEndTime(engine.getCurrentTime()); } // Subscribe to updates const sub = engine.onVisualizationUpdate((data, newMetrics, endTime) => { if (visible()) { setWaveformData(data); setMetrics(newMetrics); setBufferEndTime(endTime); // Fetch segments for visualization setSegments(engine.getSegmentsForVisualization()); needsRedraw = true; } else { // Still update metrics even when not visible setMetrics(newMetrics); } }); onCleanup(() => sub()); } }); // Mark for redraw when visibility toggles createEffect(() => { if (visible()) { needsRedraw = true; } }); onMount(() => { if (canvasRef) { ctx = canvasRef.getContext('2d'); } // Setup dark mode observer setIsDarkSignal(document.documentElement.classList.contains('dark')); const themeObserver = new MutationObserver(() => { setIsDarkSignal(document.documentElement.classList.contains('dark')); }); themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'], }); onCleanup(() => themeObserver.disconnect()); // Setup resize observer handleResize(); resizeObserver = new ResizeObserver(handleResize); if (parentRef) { resizeObserver.observe(parentRef); } // Start animation loop animationFrameId = requestAnimationFrame(drawLoop); }); onCleanup(() => { if (animationFrameId) { cancelAnimationFrame(animationFrameId); clearTimeout(animationFrameId); } if (resizeObserver) { resizeObserver.disconnect(); } }); return (
); }; export default BufferVisualizer;