import { useRef, useEffect } from "react"; interface VoiceWaveformProps { stream: MediaStream | null; active: boolean; // controls animation loop } export function VoiceWaveform({ stream, active }: VoiceWaveformProps) { const canvasRef = useRef(null); const audioCtxRef = useRef(null); const analyserRef = useRef(null); const sourceRef = useRef(null); const animFrameRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; if (!canvas || !stream || !active) return; // Create or resume AudioContext (lazily — reused across start/stop cycles) if (!audioCtxRef.current) { audioCtxRef.current = new AudioContext(); } const audioCtx = audioCtxRef.current; if (audioCtx.state === "suspended") { audioCtx.resume(); } // Set up analyser const analyser = audioCtx.createAnalyser(); analyser.fftSize = 64; // 32 frequency bins analyserRef.current = analyser; const source = audioCtx.createMediaStreamSource(stream); sourceRef.current = source; source.connect(analyser); const dataArray = new Uint8Array(analyser.frequencyBinCount); // 32 bins const canvasWidth = canvas.width; const canvasHeight = canvas.height; const ctx2d = canvas.getContext("2d"); // Get primary color from CSS variable; fall back to volt (the dark-mode brand accent). const primaryColor = getComputedStyle(document.documentElement).getPropertyValue("--primary").trim() || "#faff69"; const draw = () => { analyser.getByteFrequencyData(dataArray); if (ctx2d) { ctx2d.clearRect(0, 0, canvasWidth, canvasHeight); ctx2d.fillStyle = primaryColor; // Draw 20 bars, skipping every other bin (using bins 0, 2, 4, ... 38) const barCount = 20; const barWidth = 2; const barGap = 2; const totalWidth = barCount * barWidth + (barCount - 1) * barGap; const startX = Math.floor((canvasWidth - totalWidth) / 2); for (let i = 0; i < barCount; i++) { const binValue = dataArray[i * 2] ?? 0; const barHeight = Math.max(2, (binValue / 255) * canvasHeight); const x = startX + i * (barWidth + barGap); const y = canvasHeight - barHeight; ctx2d.fillRect(x, y, barWidth, barHeight); } } animFrameRef.current = requestAnimationFrame(draw); }; animFrameRef.current = requestAnimationFrame(draw); return () => { // Cleanup on unmount or when active becomes false if (animFrameRef.current !== null) { cancelAnimationFrame(animFrameRef.current); animFrameRef.current = null; } source.disconnect(); sourceRef.current = null; analyserRef.current = null; // Do NOT close AudioContext — reuse across start/stop cycles }; }, [stream, active]); return (