'use client'; import * as React from 'react'; import { cn } from '@/lib/utils/cn'; export interface LUFSMeterProps { analyserNode: AnalyserNode | null; className?: string; } export function LUFSMeter({ analyserNode, className }: LUFSMeterProps) { const canvasRef = React.useRef(null); const animationFrameRef = React.useRef(undefined); const [lufs, setLufs] = React.useState({ integrated: -23, shortTerm: -23, momentary: -23 }); const lufsHistoryRef = React.useRef([]); React.useEffect(() => { if (!analyserNode || !canvasRef.current) return; const canvas = canvasRef.current; const ctx = canvas.getContext('2d'); if (!ctx) return; // Set canvas size const dpr = window.devicePixelRatio || 1; const rect = canvas.getBoundingClientRect(); canvas.width = rect.width * dpr; canvas.height = rect.height * dpr; ctx.scale(dpr, dpr); const bufferLength = analyserNode.frequencyBinCount; const dataArray = new Uint8Array(bufferLength); const draw = () => { animationFrameRef.current = requestAnimationFrame(draw); analyserNode.getByteFrequencyData(dataArray); // Calculate RMS from frequency data let sum = 0; for (let i = 0; i < bufferLength; i++) { const normalized = dataArray[i] / 255; sum += normalized * normalized; } const rms = Math.sqrt(sum / bufferLength); // Convert to LUFS approximation (simplified K-weighting) // Real LUFS requires proper K-weighting filter, this is an approximation let lufsValue = -23; // Silence baseline if (rms > 0.0001) { lufsValue = 20 * Math.log10(rms) - 0.691; // Simplified LUFS estimation lufsValue = Math.max(-70, Math.min(0, lufsValue)); } // Store history for integrated measurement lufsHistoryRef.current.push(lufsValue); if (lufsHistoryRef.current.length > 300) { // Keep last 10 seconds at 30fps lufsHistoryRef.current.shift(); } // Calculate measurements const momentary = lufsValue; // Current value const shortTerm = lufsHistoryRef.current.slice(-90).reduce((a, b) => a + b, 0) / Math.min(90, lufsHistoryRef.current.length); // Last 3 seconds const integrated = lufsHistoryRef.current.reduce((a, b) => a + b, 0) / lufsHistoryRef.current.length; // All time setLufs({ integrated, shortTerm, momentary }); // Clear canvas const bgColor = getComputedStyle(canvas.parentElement!).backgroundColor; ctx.fillStyle = bgColor; ctx.fillRect(0, 0, rect.width, rect.height); // Draw LUFS scale (-70 to 0) const lufsToY = (lufs: number) => { return ((0 - lufs) / 70) * rect.height; }; // Draw reference lines ctx.strokeStyle = 'rgba(128, 128, 128, 0.2)'; ctx.lineWidth = 1; [-23, -16, -9, -3].forEach(db => { const y = lufsToY(db); ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(rect.width, y); ctx.stroke(); // Labels ctx.fillStyle = 'rgba(255, 255, 255, 0.4)'; ctx.font = '9px monospace'; ctx.textAlign = 'right'; ctx.fillText(`${db}`, rect.width - 2, y - 2); }); // Draw -23 LUFS broadcast standard line ctx.strokeStyle = 'rgba(59, 130, 246, 0.5)'; ctx.lineWidth = 2; const standardY = lufsToY(-23); ctx.beginPath(); ctx.moveTo(0, standardY); ctx.lineTo(rect.width, standardY); ctx.stroke(); // Draw bars const barWidth = rect.width / 4; const drawBar = (value: number, x: number, color: string, label: string) => { const y = lufsToY(value); const height = rect.height - y; ctx.fillStyle = color; ctx.fillRect(x, y, barWidth - 4, height); // Label ctx.fillStyle = 'rgba(255, 255, 255, 0.7)'; ctx.font = 'bold 9px monospace'; ctx.textAlign = 'center'; ctx.fillText(label, x + barWidth / 2 - 2, rect.height - 2); }; drawBar(momentary, 0, 'rgba(239, 68, 68, 0.7)', 'M'); drawBar(shortTerm, barWidth, 'rgba(251, 146, 60, 0.7)', 'S'); drawBar(integrated, barWidth * 2, 'rgba(34, 197, 94, 0.7)', 'I'); }; draw(); return () => { if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); } }; }, [analyserNode]); return (
LUFS Loudness
Momentary
-9 ? 'text-red-500' : 'text-foreground')}> {lufs.momentary > -70 ? lufs.momentary.toFixed(1) : '-∞'}
Short-term
-16 ? 'text-orange-500' : 'text-foreground')}> {lufs.shortTerm > -70 ? lufs.shortTerm.toFixed(1) : '-∞'}
Integrated
{lufs.integrated > -70 ? lufs.integrated.toFixed(1) : '-∞'}
); }