{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "bar-visualizer", "files": [ { "path": "components/ui/bar-visualizer.tsx", "content": "\"use client\"\n\nimport {\n forwardRef,\n memo,\n useEffect,\n useMemo,\n useRef,\n useState,\n type HTMLAttributes,\n} from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface AudioAnalyserOptions {\n fftSize?: number\n smoothingTimeConstant?: number\n minDecibels?: number\n maxDecibels?: number\n}\n\nfunction createAudioAnalyser(\n mediaStream: MediaStream,\n options: AudioAnalyserOptions = {}\n) {\n const audioContext = new (window.AudioContext ||\n (window as unknown as { webkitAudioContext: typeof AudioContext })\n .webkitAudioContext)()\n const source = audioContext.createMediaStreamSource(mediaStream)\n const analyser = audioContext.createAnalyser()\n\n if (options.fftSize) analyser.fftSize = options.fftSize\n if (options.smoothingTimeConstant !== undefined) {\n analyser.smoothingTimeConstant = options.smoothingTimeConstant\n }\n if (options.minDecibels !== undefined)\n analyser.minDecibels = options.minDecibels\n if (options.maxDecibels !== undefined)\n analyser.maxDecibels = options.maxDecibels\n\n source.connect(analyser)\n\n const cleanup = () => {\n source.disconnect()\n audioContext.close()\n }\n\n return { analyser, audioContext, cleanup }\n}\n\n/**\n * Hook for tracking the volume of an audio stream using the Web Audio API.\n * @param mediaStream - The MediaStream to analyze\n * @param options - Audio analyser options\n * @returns The current volume level (0-1)\n */\nexport function useAudioVolume(\n mediaStream?: MediaStream | null,\n options: AudioAnalyserOptions = { fftSize: 32, smoothingTimeConstant: 0 }\n) {\n const [volume, setVolume] = useState(0)\n const volumeRef = useRef(0)\n const frameId = useRef(undefined)\n\n // Memoize options to prevent unnecessary re-renders\n const memoizedOptions = useMemo(\n () => options,\n [\n options.fftSize,\n options.smoothingTimeConstant,\n options.minDecibels,\n options.maxDecibels,\n ]\n )\n\n useEffect(() => {\n if (!mediaStream) {\n setVolume(0)\n volumeRef.current = 0\n return\n }\n\n const { analyser, cleanup } = createAudioAnalyser(\n mediaStream,\n memoizedOptions\n )\n\n const bufferLength = analyser.frequencyBinCount\n const dataArray = new Uint8Array(bufferLength)\n let lastUpdate = 0\n const updateInterval = 1000 / 30 // 30 FPS\n\n const updateVolume = (timestamp: number) => {\n if (timestamp - lastUpdate >= updateInterval) {\n analyser.getByteFrequencyData(dataArray)\n let sum = 0\n for (let i = 0; i < dataArray.length; i++) {\n const a = dataArray[i]\n sum += a * a\n }\n const newVolume = Math.sqrt(sum / dataArray.length) / 255\n\n // Only update state if volume changed significantly\n if (Math.abs(newVolume - volumeRef.current) > 0.01) {\n volumeRef.current = newVolume\n setVolume(newVolume)\n }\n lastUpdate = timestamp\n }\n frameId.current = requestAnimationFrame(updateVolume)\n }\n\n frameId.current = requestAnimationFrame(updateVolume)\n\n return () => {\n cleanup()\n if (frameId.current) {\n cancelAnimationFrame(frameId.current)\n }\n }\n }, [mediaStream, memoizedOptions])\n\n return volume\n}\n\nexport interface MultiBandVolumeOptions {\n bands?: number\n loPass?: number // Low frequency cutoff\n hiPass?: number // High frequency cutoff\n updateInterval?: number // Update interval in ms\n analyserOptions?: AudioAnalyserOptions\n}\n\nconst multibandDefaults: MultiBandVolumeOptions = {\n bands: 5,\n loPass: 100,\n hiPass: 600,\n updateInterval: 32,\n analyserOptions: { fftSize: 2048 },\n}\n\n// Memoized normalization function to avoid recreating on each render\nconst normalizeDb = (value: number) => {\n if (value === -Infinity) return 0\n const minDb = -100\n const maxDb = -10\n const db = 1 - (Math.max(minDb, Math.min(maxDb, value)) * -1) / 100\n return Math.sqrt(db)\n}\n\n/**\n * Hook for tracking volume across multiple frequency bands\n * @param mediaStream - The MediaStream to analyze\n * @param options - Multiband options\n * @returns Array of volume levels for each frequency band\n */\nexport function useMultibandVolume(\n mediaStream?: MediaStream | null,\n options: MultiBandVolumeOptions = {}\n) {\n const opts = useMemo(\n () => ({ ...multibandDefaults, ...options }),\n [\n options.bands,\n options.loPass,\n options.hiPass,\n options.updateInterval,\n options.analyserOptions?.fftSize,\n options.analyserOptions?.smoothingTimeConstant,\n options.analyserOptions?.minDecibels,\n options.analyserOptions?.maxDecibels,\n ]\n )\n\n const [frequencyBands, setFrequencyBands] = useState(() =>\n new Array(opts.bands).fill(0)\n )\n const bandsRef = useRef(new Array(opts.bands).fill(0))\n const frameId = useRef(undefined)\n\n useEffect(() => {\n if (!mediaStream) {\n const emptyBands = new Array(opts.bands).fill(0)\n setFrequencyBands(emptyBands)\n bandsRef.current = emptyBands\n return\n }\n\n const { analyser, cleanup } = createAudioAnalyser(\n mediaStream,\n opts.analyserOptions\n )\n\n const bufferLength = analyser.frequencyBinCount\n const dataArray = new Float32Array(bufferLength)\n const sliceStart = opts.loPass!\n const sliceEnd = opts.hiPass!\n const sliceLength = sliceEnd - sliceStart\n const chunkSize = Math.ceil(sliceLength / opts.bands!)\n\n let lastUpdate = 0\n const updateInterval = opts.updateInterval!\n\n const updateVolume = (timestamp: number) => {\n if (timestamp - lastUpdate >= updateInterval) {\n analyser.getFloatFrequencyData(dataArray)\n\n // Process directly without creating intermediate arrays\n const chunks = new Array(opts.bands!)\n\n for (let i = 0; i < opts.bands!; i++) {\n let sum = 0\n let count = 0\n const startIdx = sliceStart + i * chunkSize\n const endIdx = Math.min(sliceStart + (i + 1) * chunkSize, sliceEnd)\n\n for (let j = startIdx; j < endIdx; j++) {\n sum += normalizeDb(dataArray[j])\n count++\n }\n\n chunks[i] = count > 0 ? sum / count : 0\n }\n\n // Only update state if bands changed significantly\n let hasChanged = false\n for (let i = 0; i < chunks.length; i++) {\n if (Math.abs(chunks[i] - bandsRef.current[i]) > 0.01) {\n hasChanged = true\n break\n }\n }\n\n if (hasChanged) {\n bandsRef.current = chunks\n setFrequencyBands(chunks)\n }\n\n lastUpdate = timestamp\n }\n\n frameId.current = requestAnimationFrame(updateVolume)\n }\n\n frameId.current = requestAnimationFrame(updateVolume)\n\n return () => {\n cleanup()\n if (frameId.current) {\n cancelAnimationFrame(frameId.current)\n }\n }\n }, [mediaStream, opts])\n\n return frequencyBands\n}\n\ntype AnimationState =\n | \"connecting\"\n | \"initializing\"\n | \"listening\"\n | \"speaking\"\n | \"thinking\"\n | undefined\n\nexport const useBarAnimator = (\n state: AnimationState,\n columns: number,\n interval: number\n): number[] => {\n const indexRef = useRef(0)\n const [currentFrame, setCurrentFrame] = useState([])\n const animationFrameId = useRef(null)\n\n // Memoize sequence generation\n const sequence = useMemo(() => {\n if (state === \"thinking\" || state === \"listening\") {\n return generateListeningSequenceBar(columns)\n } else if (state === \"connecting\" || state === \"initializing\") {\n return generateConnectingSequenceBar(columns)\n } else if (state === undefined || state === \"speaking\") {\n return [new Array(columns).fill(0).map((_, idx) => idx)]\n } else {\n return [[]]\n }\n }, [state, columns])\n\n useEffect(() => {\n indexRef.current = 0\n setCurrentFrame(sequence[0] || [])\n }, [sequence])\n\n useEffect(() => {\n let startTime = performance.now()\n\n const animate = (time: DOMHighResTimeStamp) => {\n const timeElapsed = time - startTime\n\n if (timeElapsed >= interval) {\n indexRef.current = (indexRef.current + 1) % sequence.length\n setCurrentFrame(sequence[indexRef.current] || [])\n startTime = time\n }\n\n animationFrameId.current = requestAnimationFrame(animate)\n }\n\n animationFrameId.current = requestAnimationFrame(animate)\n\n return () => {\n if (animationFrameId.current !== null) {\n cancelAnimationFrame(animationFrameId.current)\n }\n }\n }, [interval, sequence])\n\n return currentFrame\n}\n\n// Memoize sequence generators\nconst generateConnectingSequenceBar = (columns: number): number[][] => {\n const seq = []\n for (let x = 0; x < columns; x++) {\n seq.push([x, columns - 1 - x])\n }\n return seq\n}\n\nconst generateListeningSequenceBar = (columns: number): number[][] => {\n const center = Math.floor(columns / 2)\n const noIndex = -1\n return [[center], [noIndex]]\n}\n\nexport type AgentState =\n | \"connecting\"\n | \"initializing\"\n | \"listening\"\n | \"speaking\"\n | \"thinking\"\n\nexport interface BarVisualizerProps extends HTMLAttributes {\n /** Voice assistant state */\n state?: AgentState\n /** Number of bars to display */\n barCount?: number\n /** Audio source */\n mediaStream?: MediaStream | null\n /** Min/max height as percentage */\n minHeight?: number\n maxHeight?: number\n /** Enable demo mode with fake audio data */\n demo?: boolean\n /** Align bars from center instead of bottom */\n centerAlign?: boolean\n}\n\nconst BarVisualizerComponent = forwardRef(\n (\n {\n state,\n barCount = 15,\n mediaStream,\n minHeight = 20,\n maxHeight = 100,\n demo = false,\n centerAlign = false,\n className,\n style,\n ...props\n },\n ref\n ) => {\n // Audio processing\n const realVolumeBands = useMultibandVolume(mediaStream, {\n bands: barCount,\n loPass: 100,\n hiPass: 200,\n })\n\n // Generate fake volume data for demo mode using refs to avoid state updates\n const fakeVolumeBandsRef = useRef(new Array(barCount).fill(0.2))\n const [fakeVolumeBands, setFakeVolumeBands] = useState(() =>\n new Array(barCount).fill(0.2)\n )\n const fakeAnimationRef = useRef(undefined)\n\n // Animate fake volume bands for speaking and listening states\n useEffect(() => {\n if (!demo) return\n\n if (state !== \"speaking\" && state !== \"listening\") {\n const bands = new Array(barCount).fill(0.2)\n fakeVolumeBandsRef.current = bands\n setFakeVolumeBands(bands)\n return\n }\n\n let lastUpdate = 0\n const updateInterval = 50\n const startTime = Date.now() / 1000\n\n const updateFakeVolume = (timestamp: number) => {\n if (timestamp - lastUpdate >= updateInterval) {\n const time = Date.now() / 1000 - startTime\n const newBands = new Array(barCount)\n\n for (let i = 0; i < barCount; i++) {\n const waveOffset = i * 0.5\n const baseVolume = Math.sin(time * 2 + waveOffset) * 0.3 + 0.5\n const randomNoise = Math.random() * 0.2\n newBands[i] = Math.max(0.1, Math.min(1, baseVolume + randomNoise))\n }\n\n // Only update if values changed significantly\n let hasChanged = false\n for (let i = 0; i < barCount; i++) {\n if (Math.abs(newBands[i] - fakeVolumeBandsRef.current[i]) > 0.05) {\n hasChanged = true\n break\n }\n }\n\n if (hasChanged) {\n fakeVolumeBandsRef.current = newBands\n setFakeVolumeBands(newBands)\n }\n\n lastUpdate = timestamp\n }\n\n fakeAnimationRef.current = requestAnimationFrame(updateFakeVolume)\n }\n\n fakeAnimationRef.current = requestAnimationFrame(updateFakeVolume)\n\n return () => {\n if (fakeAnimationRef.current) {\n cancelAnimationFrame(fakeAnimationRef.current)\n }\n }\n }, [demo, state, barCount])\n\n // Use fake or real volume data based on demo mode\n const volumeBands = useMemo(\n () => (demo ? fakeVolumeBands : realVolumeBands),\n [demo, fakeVolumeBands, realVolumeBands]\n )\n\n // Animation sequencing\n const highlightedIndices = useBarAnimator(\n state,\n barCount,\n state === \"connecting\"\n ? 2000 / barCount\n : state === \"thinking\"\n ? 150\n : state === \"listening\"\n ? 500\n : 1000\n )\n\n return (\n \n {volumeBands.map((volume, index) => {\n const heightPct = Math.min(\n maxHeight,\n Math.max(minHeight, volume * 100 + 5)\n )\n const isHighlighted = highlightedIndices?.includes(index) ?? false\n\n return (\n \n )\n })}\n \n )\n }\n)\n\n// Memoized Bar component to prevent unnecessary re-renders\nconst Bar = memo<{\n heightPct: number\n isHighlighted: boolean\n state?: AgentState\n}>(({ heightPct, isHighlighted, state }) => (\n \n))\n\nBar.displayName = \"Bar\"\n\n// Wrap the main component with memo for prop comparison optimization\nconst BarVisualizer = memo(BarVisualizerComponent, (prevProps, nextProps) => {\n return (\n prevProps.state === nextProps.state &&\n prevProps.barCount === nextProps.barCount &&\n prevProps.mediaStream === nextProps.mediaStream &&\n prevProps.minHeight === nextProps.minHeight &&\n prevProps.maxHeight === nextProps.maxHeight &&\n prevProps.demo === nextProps.demo &&\n prevProps.centerAlign === nextProps.centerAlign &&\n prevProps.className === nextProps.className &&\n JSON.stringify(prevProps.style) === JSON.stringify(nextProps.style)\n )\n})\n\nBarVisualizerComponent.displayName = \"BarVisualizerComponent\"\nBarVisualizer.displayName = \"BarVisualizer\"\n\nexport { BarVisualizer }\n", "type": "registry:ui" } ], "type": "registry:ui" }