Spaces:
Running
Running
| /** | |
| """""""""" * 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<BufferVisualizerProps> = (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<Float32Array>(new Float32Array(0)); | |
| const [metrics, setMetrics] = createSignal<AudioMetrics>({ | |
| currentEnergy: 0, | |
| averageEnergy: 0, | |
| peakEnergy: 0, | |
| noiseFloor: 0.01, | |
| currentSNR: 0, | |
| isSpeaking: false, | |
| }); | |
| const [segments, setSegments] = createSignal<Array<{ startTime: number; endTime: number; isProcessed: boolean }>>([]); | |
| // 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 ( | |
| <div ref={parentRef} class="w-full relative" style={{ height: `${height()}px` }}> | |
| <canvas | |
| ref={canvasRef} | |
| class="w-full h-full block" | |
| style={{ 'image-rendering': 'auto' }} | |
| aria-label="Audio waveform visualization" | |
| /> | |
| </div> | |
| ); | |
| }; | |
| export default BufferVisualizer; | |