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:
181
components/analysis/PhaseCorrelationMeter.tsx
Normal file
181
components/analysis/PhaseCorrelationMeter.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
export interface PhaseCorrelationMeterProps {
|
||||
analyserNode: AnalyserNode | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PhaseCorrelationMeter({ analyserNode, className }: PhaseCorrelationMeterProps) {
|
||||
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||
const animationFrameRef = React.useRef<number | undefined>(undefined);
|
||||
const [correlation, setCorrelation] = React.useState(0);
|
||||
|
||||
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 audioContext = analyserNode.context as AudioContext;
|
||||
const bufferLength = analyserNode.fftSize;
|
||||
const dataArrayL = new Float32Array(bufferLength);
|
||||
const dataArrayR = new Float32Array(bufferLength);
|
||||
|
||||
// Create a splitter to get L/R channels
|
||||
const splitter = audioContext.createChannelSplitter(2);
|
||||
const analyserL = audioContext.createAnalyser();
|
||||
const analyserR = audioContext.createAnalyser();
|
||||
|
||||
analyserL.fftSize = bufferLength;
|
||||
analyserR.fftSize = bufferLength;
|
||||
|
||||
// Try to connect to the analyser node's source
|
||||
// Note: This is a simplified approach - ideally we'd get the source node
|
||||
try {
|
||||
analyserNode.connect(splitter);
|
||||
splitter.connect(analyserL, 0);
|
||||
splitter.connect(analyserR, 1);
|
||||
} catch (e) {
|
||||
// If connection fails, just show static display
|
||||
}
|
||||
|
||||
const draw = () => {
|
||||
animationFrameRef.current = requestAnimationFrame(draw);
|
||||
|
||||
try {
|
||||
analyserL.getFloatTimeDomainData(dataArrayL);
|
||||
analyserR.getFloatTimeDomainData(dataArrayR);
|
||||
|
||||
// Calculate phase correlation (Pearson correlation coefficient)
|
||||
let sumL = 0, sumR = 0, sumLR = 0, sumL2 = 0, sumR2 = 0;
|
||||
const n = bufferLength;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
sumL += dataArrayL[i];
|
||||
sumR += dataArrayR[i];
|
||||
sumLR += dataArrayL[i] * dataArrayR[i];
|
||||
sumL2 += dataArrayL[i] * dataArrayL[i];
|
||||
sumR2 += dataArrayR[i] * dataArrayR[i];
|
||||
}
|
||||
|
||||
const meanL = sumL / n;
|
||||
const meanR = sumR / n;
|
||||
const covLR = (sumLR / n) - (meanL * meanR);
|
||||
const varL = (sumL2 / n) - (meanL * meanL);
|
||||
const varR = (sumR2 / n) - (meanR * meanR);
|
||||
|
||||
let r = 0;
|
||||
if (varL > 0 && varR > 0) {
|
||||
r = covLR / Math.sqrt(varL * varR);
|
||||
r = Math.max(-1, Math.min(1, r)); // Clamp to [-1, 1]
|
||||
}
|
||||
|
||||
setCorrelation(r);
|
||||
|
||||
// Clear canvas
|
||||
const bgColor = getComputedStyle(canvas.parentElement!).backgroundColor;
|
||||
ctx.fillStyle = bgColor;
|
||||
ctx.fillRect(0, 0, rect.width, rect.height);
|
||||
|
||||
// Draw scale background
|
||||
const centerY = rect.height / 2;
|
||||
const barHeight = 20;
|
||||
|
||||
// Draw scale markers
|
||||
ctx.fillStyle = 'rgba(128, 128, 128, 0.2)';
|
||||
ctx.fillRect(0, centerY - barHeight / 2, rect.width, barHeight);
|
||||
|
||||
// Draw center line (0)
|
||||
ctx.strokeStyle = 'rgba(128, 128, 128, 0.5)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(rect.width / 2, centerY - barHeight / 2 - 5);
|
||||
ctx.lineTo(rect.width / 2, centerY + barHeight / 2 + 5);
|
||||
ctx.stroke();
|
||||
|
||||
// Draw correlation indicator
|
||||
const x = ((r + 1) / 2) * rect.width;
|
||||
|
||||
// Color based on correlation value
|
||||
let color;
|
||||
if (r > 0.9) {
|
||||
color = '#10b981'; // Green - good correlation (mono-ish)
|
||||
} else if (r > 0.5) {
|
||||
color = '#84cc16'; // Lime - moderate correlation
|
||||
} else if (r > -0.5) {
|
||||
color = '#eab308'; // Yellow - decorrelated (good stereo)
|
||||
} else if (r > -0.9) {
|
||||
color = '#f97316'; // Orange - negative correlation
|
||||
} else {
|
||||
color = '#ef4444'; // Red - phase issues
|
||||
}
|
||||
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(x - 2, centerY - barHeight / 2, 4, barHeight);
|
||||
|
||||
// Draw labels
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.7)';
|
||||
ctx.font = '9px monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('-1', 2, centerY - barHeight / 2 - 8);
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('0', rect.width / 2, centerY - barHeight / 2 - 8);
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText('+1', rect.width - 2, centerY - barHeight / 2 - 8);
|
||||
|
||||
// Draw correlation value
|
||||
ctx.textAlign = 'center';
|
||||
ctx.font = 'bold 11px monospace';
|
||||
ctx.fillText(r.toFixed(3), rect.width / 2, centerY + barHeight / 2 + 15);
|
||||
} catch (e) {
|
||||
// Silently handle errors
|
||||
}
|
||||
};
|
||||
|
||||
draw();
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
try {
|
||||
splitter.disconnect();
|
||||
analyserL.disconnect();
|
||||
analyserR.disconnect();
|
||||
} catch (e) {
|
||||
// Ignore disconnection errors
|
||||
}
|
||||
};
|
||||
}, [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">
|
||||
Phase Correlation
|
||||
</div>
|
||||
<div className="w-full h-[calc(100%-24px)] rounded bg-muted/30 flex flex-col items-center justify-center">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-16 rounded"
|
||||
/>
|
||||
<div className="text-[9px] text-muted-foreground mt-2 text-center px-2">
|
||||
{correlation > 0.9 ? 'Mono-like' :
|
||||
correlation > 0.5 ? 'Good Stereo' :
|
||||
correlation > -0.5 ? 'Wide Stereo' :
|
||||
'Phase Issues'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user