feat: complete Phase 10 - add phase correlation, LUFS, and audio statistics
Implemented remaining Phase 10 analysis tools: **Phase Correlation Meter (10.3)** - Real-time stereo phase correlation display - Pearson correlation coefficient calculation - Color-coded indicator (-1 to +1 scale) - Visual feedback: Mono-like, Good Stereo, Wide Stereo, Phase Issues **LUFS Loudness Meter (10.3)** - Momentary, Short-term, and Integrated LUFS measurements - Simplified K-weighting approximation - Vertical bar display with -70 to 0 LUFS range - -23 LUFS broadcast standard reference line - Real-time history tracking (10 seconds) **Audio Statistics (10.4)** - Project info: track count, duration, sample rate, channels, bit depth - Level analysis: peak, RMS, dynamic range, headroom - Real-time buffer analysis from all tracks - Color-coded warnings for clipping and low headroom **Integration** - Added 5-button toggle in master column (FFT, SPEC, PHS, LUFS, INFO) - All analyzers share consistent 192px width layout - Theme-aware styling for light/dark modes - Compact button labels for space efficiency 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
167
components/analysis/LUFSMeter.tsx
Normal file
167
components/analysis/LUFSMeter.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user