import { useEffect, useRef, useState } from 'react'; import api from '@/services/api'; interface MelSpectrogramVisualizerProps { isActive: boolean; spectrogramData?: number[][]; audioFilename?: string; className?: string; } export default function MelSpectrogramVisualizer({ isActive, spectrogramData, audioFilename, className = "" }: MelSpectrogramVisualizerProps) { const canvasRef = useRef(null); const animationRef = useRef(); const [melData, setMelData] = useState([]); const [isLoading, setIsLoading] = useState(false); // Keep internal melData in sync with spectrogramData from parent useEffect(() => { if (spectrogramData && spectrogramData.length > 0) { setMelData(spectrogramData); } }, [spectrogramData]); // Fetch real mel-spectrogram data from backend periodically useEffect(() => { if (!isActive || !audioFilename) return; const fetchMelData = async () => { try { const result = await api.getSpectrogram(audioFilename); setMelData(result.spectrogram || []); } catch (err) { // Silently fail - backend may not have file yet } }; // Fetch immediately and then poll every 3 seconds during synthesis fetchMelData(); const interval = setInterval(fetchMelData, 3000); return () => clearInterval(interval); }, [isActive, audioFilename]); // Draw mel-spectrogram on canvas useEffect(() => { if (!canvasRef.current) return; const draw = () => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const width = canvas.width; const height = canvas.height; // Clear canvas ctx.fillStyle = '#0f0f0f'; ctx.fillRect(0, 0, width, height); if (melData.length > 0) { drawRealSpectrogram(ctx, width, height, melData); } else if (isActive) { drawLoadingPlaceholder(ctx, width, height); } if (isActive) { animationRef.current = requestAnimationFrame(draw); } }; if (isActive) { draw(); } return () => { if (animationRef.current) { cancelAnimationFrame(animationRef.current); } }; }, [isActive, melData]); const drawRealSpectrogram = ( ctx: CanvasRenderingContext2D, width: number, height: number, data: number[][] ) => { if (data.length === 0) return; const timeSteps = data.length; const freqBins = data[0]?.length || 80; const pixelsPerTime = Math.max(1, width / timeSteps); const pixelsPerFreq = height / freqBins; // Draw each time-frequency bin with correct orientation // Time flows left-to-right, frequency flows bottom-to-top for (let t = 0; t < timeSteps; t++) { for (let f = 0; f < freqBins; f++) { const value = data[t]?.[f] ?? 0; // Normalize to 0-1 range (backend returns 0-255) const normalizedValue = Math.min(1, Math.max(0, value / 255)); const color = getSpectrogramColor(normalizedValue); ctx.fillStyle = color; // Draw pixel at time t, frequency f // Frequency 0 at bottom, increasing upward const xPos = t * pixelsPerTime; const yPos = height - (f + 1) * pixelsPerFreq; ctx.fillRect( xPos, yPos, Math.ceil(pixelsPerTime), Math.ceil(pixelsPerFreq) ); } } // Add horizontal frequency guide lines (optional) ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)'; ctx.lineWidth = 0.5; const numGuides = 4; for (let i = 1; i < numGuides; i++) { const y = height - (i / numGuides) * height; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(width, y); ctx.stroke(); } }; const drawLoadingPlaceholder = ( ctx: CanvasRenderingContext2D, width: number, height: number ) => { const time = Date.now() / 1000; const freqBins = 80; const binHeight = height / freqBins; // Animated pattern while waiting for real data for (let f = 0; f < freqBins; f++) { const intensity = Math.sin(f / 10 + time * 2) * 0.3 + Math.sin(time * 4) * 0.2 + 0.3; const color = getSpectrogramColor(Math.min(1, intensity)); ctx.fillStyle = color; ctx.fillRect(0, f * binHeight, width, binHeight + 1); } // Scanning line effect const scanX = (time * 100) % width; ctx.strokeStyle = 'rgba(100, 255, 100, 0.4)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(scanX, 0); ctx.lineTo(scanX, height); ctx.stroke(); }; const getSpectrogramColor = (intensity: number): string => { // Viridis-like colormap optimized for mel-spectrograms // Matches reference images: dark purple/blue → cyan → green → yellow → bright white if (intensity < 0.1) { // Almost black to dark purple const t = intensity / 0.1; const r = Math.floor(10 + t * 40); const g = Math.floor(5 + t * 20); const b = Math.floor(30 + t * 80); return `rgb(${r}, ${g}, ${b})`; } else if (intensity < 0.2) { // Dark purple to deep blue const t = (intensity - 0.1) / 0.1; const r = Math.floor(50 + t * 10); const g = Math.floor(25 - t * 10); const b = Math.floor(110 + t * 80); return `rgb(${r}, ${g}, ${b})`; } else if (intensity < 0.3) { // Deep blue to cyan const t = (intensity - 0.2) / 0.1; const r = Math.floor(60 - t * 30); const g = Math.floor(15 + t * 180); const b = Math.floor(190 + t * 65); return `rgb(${r}, ${g}, ${b})`; } else if (intensity < 0.4) { // Cyan to bright green const t = (intensity - 0.3) / 0.1; const r = Math.floor(30 - t * 30); const g = Math.floor(195 + t * 60); const b = Math.floor(255 - t * 255); return `rgb(${r}, ${g}, ${b})`; } else if (intensity < 0.55) { // Bright green to yellow-green const t = (intensity - 0.4) / 0.15; const r = Math.floor(0 + t * 200); const g = Math.floor(255 - t * 50); const b = Math.floor(0); return `rgb(${r}, ${g}, ${b})`; } else if (intensity < 0.7) { // Yellow-green to bright yellow const t = (intensity - 0.55) / 0.15; const r = Math.floor(200 + t * 55); const g = Math.floor(205 + t * 50); const b = Math.floor(0); return `rgb(${r}, ${g}, ${b})`; } else if (intensity < 0.85) { // Bright yellow to orange-red const t = (intensity - 0.7) / 0.15; const r = Math.floor(255); const g = Math.floor(255 - t * 100); const b = Math.floor(0); return `rgb(${r}, ${g}, ${b})`; } else { // Orange-red to bright white (peaks) const t = (intensity - 0.85) / 0.15; const r = Math.floor(255); const g = Math.floor(155 + t * 100); const b = Math.floor(0 + t * 255); return `rgb(${r}, ${g}, ${b})`; } }; return (
{isActive && ( ● Live Feed )}
Time (→) Frequency (↑) Energy (intensity)
); }