Files
audio-ui/components/analysis/PhaseCorrelationMeter.tsx
Sebastian Krüger 355bade08f 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>
2025-11-19 02:00:41 +01:00

182 lines
5.9 KiB
TypeScript

'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>
);
}