import { useEffect, useRef } from 'react'; import { useAudio } from '../context/AudioContext'; interface AudioVisualizerProps { height?: number; } const NUM_POINTS = 80; const BASE_AMPLITUDE = 0.12; const MAX_AMPLITUDE = 0.42; export default function AudioVisualizer({ height = 80 }: AudioVisualizerProps) { const { isPlaying, currentTime, duration, getFrequencyData } = useAudio(); const canvasRef = useRef(null); const rafRef = useRef(0); const heightsRef = useRef(Array(NUM_POINTS).fill(BASE_AMPLITUDE)); const playingRef = useRef(isPlaying); const getDataRef = useRef(getFrequencyData); const timeRef = useRef({ currentTime, duration }); useEffect(() => { playingRef.current = isPlaying; }, [isPlaying]); useEffect(() => { getDataRef.current = getFrequencyData; }, [getFrequencyData]); useEffect(() => { timeRef.current = { currentTime, duration }; }, [currentTime, duration]); useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const dpr = window.devicePixelRatio || 1; const parentW = canvas.parentElement?.clientWidth || 300; const maxW = Math.min(parentW, 800); const displayW = maxW; const displayH = height; canvas.width = displayW * dpr; canvas.height = displayH * dpr; canvas.style.width = `${displayW}px`; canvas.style.height = `${displayH}px`; ctx.scale(dpr, dpr); const midY = displayH / 2; let frame = 0; const animate = () => { if (!ctx || !canvas) return; frame++; const raw = getDataRef.current?.(); const hts = heightsRef.current; const step = displayW / (NUM_POINTS - 1); const { currentTime: ct, duration: dur } = timeRef.current; const progress = dur > 0 ? ct / dur : 0; // Update heights from frequency data if (raw && playingRef.current && frame % 3 === 0) { for (let i = 0; i < NUM_POINTS; i++) { const bucketSize = raw.length / NUM_POINTS; const startIdx = Math.floor(i * bucketSize); const endIdx = Math.floor((i + 1) * bucketSize); let sum = 0; let count = 0; for (let j = startIdx; j < endIdx && j < raw.length; j++) { sum += raw[j]; count++; } const avg = count > 0 ? sum / count : 0; const target = BASE_AMPLITUDE + (avg / 255) * (MAX_AMPLITUDE - BASE_AMPLITUDE); const rate = target > hts[i] ? 0.3 : 0.12; hts[i] += (target - hts[i]) * rate; } } else if (!playingRef.current) { for (let i = 0; i < NUM_POINTS; i++) { hts[i] += (BASE_AMPLITUDE - hts[i]) * 0.06; } } // Compute path points const points: { x: number; y: number }[] = []; for (let i = 0; i < NUM_POINTS; i++) { const x = i * step; const direction = i % 2 === 0 ? -1 : 1; const y = midY + direction * hts[i] * midY; points.push({ x, y }); } ctx.clearRect(0, 0, displayW, displayH); // ── Background muted waveform ── drawSmoothPath(ctx, points, 'rgba(100, 116, 139, 0.25)', 2, displayW, midY); // ── Active glowing waveform ── // Outer glow ctx.save(); ctx.shadowColor = 'rgba(34, 211, 238, 0.6)'; ctx.shadowBlur = 14; const grad = ctx.createLinearGradient(0, 0, displayW, 0); grad.addColorStop(0, '#22d3ee'); grad.addColorStop(0.5, '#818cf8'); grad.addColorStop(1, '#c084fc'); drawSmoothPath(ctx, points, grad, 3, displayW, midY); ctx.restore(); // ── Progress indicator dot ── const dotX = progress * displayW; const dotIdx = Math.floor(progress * (NUM_POINTS - 1)); const clampedIdx = Math.min(dotIdx, NUM_POINTS - 1); const dotY = points[clampedIdx]?.y ?? midY; // Outer glow const dotGlow = ctx.createRadialGradient(dotX, dotY, 0, dotX, dotY, 10); dotGlow.addColorStop(0, 'rgba(255, 255, 255, 0.9)'); dotGlow.addColorStop(0.3, 'rgba(255, 255, 255, 0.5)'); dotGlow.addColorStop(1, 'rgba(255, 255, 255, 0)'); ctx.fillStyle = dotGlow; ctx.beginPath(); ctx.arc(dotX, dotY, 10, 0, Math.PI * 2); ctx.fill(); // Core dot ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.arc(dotX, dotY, 4, 0, Math.PI * 2); ctx.fill(); rafRef.current = requestAnimationFrame(animate); }; rafRef.current = requestAnimationFrame(animate); return () => cancelAnimationFrame(rafRef.current); }, [height]); return (