168 lines
5.8 KiB
TypeScript
168 lines
5.8 KiB
TypeScript
|
|
'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>
|
||
|
|
);
|
||
|
|
}
|