Files

168 lines
5.8 KiB
TypeScript
Raw Permalink Normal View History

'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<HTMLCanvasElement>(null);
const animationFrameRef = React.useRef<number | undefined>(undefined);
const [lufs, setLufs] = React.useState({ integrated: -23, shortTerm: -23, momentary: -23 });
const lufsHistoryRef = React.useRef<number[]>([]);
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 (
<div className={cn('w-full h-full bg-card/50 border-2 border-accent/50 rounded-lg p-2', className)}>
<div className="text-[10px] font-bold text-accent uppercase tracking-wider mb-2">
LUFS Loudness
</div>
<div className="w-full h-[calc(100%-24px)] rounded bg-muted/30 flex flex-col">
<canvas
ref={canvasRef}
className="w-full flex-1 rounded"
/>
<div className="grid grid-cols-3 gap-1 mt-2 text-[9px] font-mono text-center">
<div>
<div className="text-muted-foreground">Momentary</div>
<div className={cn('font-bold', lufs.momentary > -9 ? 'text-red-500' : 'text-foreground')}>
{lufs.momentary > -70 ? lufs.momentary.toFixed(1) : '-∞'}
</div>
</div>
<div>
<div className="text-muted-foreground">Short-term</div>
<div className={cn('font-bold', lufs.shortTerm > -16 ? 'text-orange-500' : 'text-foreground')}>
{lufs.shortTerm > -70 ? lufs.shortTerm.toFixed(1) : '-∞'}
</div>
</div>
<div>
<div className="text-muted-foreground">Integrated</div>
<div className={cn('font-bold', Math.abs(lufs.integrated + 23) < 2 ? 'text-green-500' : 'text-foreground')}>
{lufs.integrated > -70 ? lufs.integrated.toFixed(1) : '-∞'}
</div>
</div>
</div>
</div>
</div>
);
}