feat: implement Phase 10.1 & 10.2 - frequency analyzer and spectrogram
Added real-time audio analysis visualizations to master column: - FrequencyAnalyzer component with FFT bar display - Canvas-based rendering with requestAnimationFrame - Color gradient from cyan to green based on frequency - Frequency axis labels (20Hz, 1kHz, 20kHz) - Spectrogram component with time-frequency waterfall display - Scrolling visualization with ImageData pixel manipulation - Color mapping: black → blue → cyan → green → yellow → red - Vertical frequency axis with labels - Master column redesign - Fixed width layout (280px) - Toggle buttons to switch between FFT and Spectrum views - Integrated above master controls with 300px minimum height - Exposed masterAnalyser from useMultiTrackPlayer hook - Analyser node now accessible to visualization components 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
86
components/analysis/FrequencyAnalyzer.tsx
Normal file
86
components/analysis/FrequencyAnalyzer.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
export interface FrequencyAnalyzerProps {
|
||||
analyserNode: AnalyserNode | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FrequencyAnalyzer({ analyserNode, className }: FrequencyAnalyzerProps) {
|
||||
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||
const animationFrameRef = 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);
|
||||
|
||||
// Clear canvas
|
||||
ctx.fillStyle = 'rgb(15, 15, 15)';
|
||||
ctx.fillRect(0, 0, rect.width, rect.height);
|
||||
|
||||
const barWidth = rect.width / bufferLength;
|
||||
let x = 0;
|
||||
|
||||
for (let i = 0; i < bufferLength; i++) {
|
||||
const barHeight = (dataArray[i] / 255) * rect.height;
|
||||
|
||||
// Color gradient based on frequency
|
||||
const hue = (i / bufferLength) * 120; // 0 (red) to 120 (green)
|
||||
ctx.fillStyle = `hsl(${180 + hue}, 70%, 50%)`;
|
||||
|
||||
ctx.fillRect(x, rect.height - barHeight, barWidth, barHeight);
|
||||
x += barWidth;
|
||||
}
|
||||
|
||||
// Draw frequency labels
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
|
||||
ctx.font = '10px monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('20Hz', 5, rect.height - 5);
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('1kHz', rect.width / 2, rect.height - 5);
|
||||
ctx.textAlign = 'right';
|
||||
ctx.fillText('20kHz', rect.width - 5, rect.height - 5);
|
||||
};
|
||||
|
||||
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">
|
||||
Frequency Analyzer
|
||||
</div>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-[calc(100%-24px)] rounded"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
components/analysis/Spectrogram.tsx
Normal file
127
components/analysis/Spectrogram.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
|
||||
export interface SpectrogramProps {
|
||||
analyserNode: AnalyserNode | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Spectrogram({ analyserNode, className }: SpectrogramProps) {
|
||||
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||
const animationFrameRef = React.useRef<number>();
|
||||
const spectrogramDataRef = React.useRef<ImageData | null>(null);
|
||||
|
||||
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);
|
||||
|
||||
// Initialize spectrogram data
|
||||
spectrogramDataRef.current = ctx.createImageData(rect.width, rect.height);
|
||||
|
||||
const draw = () => {
|
||||
animationFrameRef.current = requestAnimationFrame(draw);
|
||||
|
||||
analyserNode.getByteFrequencyData(dataArray);
|
||||
|
||||
if (!spectrogramDataRef.current) return;
|
||||
|
||||
const imageData = spectrogramDataRef.current;
|
||||
|
||||
// Shift existing data to the left by 1 pixel
|
||||
for (let y = 0; y < rect.height; y++) {
|
||||
for (let x = 0; x < rect.width - 1; x++) {
|
||||
const sourceIndex = ((y * rect.width) + x + 1) * 4;
|
||||
const targetIndex = ((y * rect.width) + x) * 4;
|
||||
imageData.data[targetIndex] = imageData.data[sourceIndex];
|
||||
imageData.data[targetIndex + 1] = imageData.data[sourceIndex + 1];
|
||||
imageData.data[targetIndex + 2] = imageData.data[sourceIndex + 2];
|
||||
imageData.data[targetIndex + 3] = 255;
|
||||
}
|
||||
}
|
||||
|
||||
// Add new column on the right
|
||||
const x = rect.width - 1;
|
||||
for (let y = 0; y < rect.height; y++) {
|
||||
// Map frequency bins to canvas height (inverted)
|
||||
const freqIndex = Math.floor((1 - y / rect.height) * bufferLength);
|
||||
const value = dataArray[freqIndex];
|
||||
|
||||
// Color mapping: black (0) -> blue -> cyan -> green -> yellow -> red (255)
|
||||
let r, g, b;
|
||||
if (value < 64) {
|
||||
// Black to blue
|
||||
r = 0;
|
||||
g = 0;
|
||||
b = value * 4;
|
||||
} else if (value < 128) {
|
||||
// Blue to cyan
|
||||
r = 0;
|
||||
g = (value - 64) * 4;
|
||||
b = 255;
|
||||
} else if (value < 192) {
|
||||
// Cyan to green
|
||||
r = 0;
|
||||
g = 255;
|
||||
b = 255 - (value - 128) * 4;
|
||||
} else {
|
||||
// Green to yellow to red
|
||||
r = (value - 192) * 4;
|
||||
g = 255;
|
||||
b = 0;
|
||||
}
|
||||
|
||||
const index = ((y * rect.width) + x) * 4;
|
||||
imageData.data[index] = r;
|
||||
imageData.data[index + 1] = g;
|
||||
imageData.data[index + 2] = b;
|
||||
imageData.data[index + 3] = 255;
|
||||
}
|
||||
|
||||
// Draw the spectrogram
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
// Draw frequency labels
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
|
||||
ctx.font = '10px monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('20kHz', 5, 12);
|
||||
ctx.fillText('1kHz', 5, rect.height / 2);
|
||||
ctx.fillText('20Hz', 5, rect.height - 5);
|
||||
};
|
||||
|
||||
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">
|
||||
Spectrogram
|
||||
</div>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-[calc(100%-24px)] rounded"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import * as React from 'react';
|
||||
import { Music, Plus, Upload, Trash2, Settings, Download } from 'lucide-react';
|
||||
import { PlaybackControls } from './PlaybackControls';
|
||||
import { MasterControls } from '@/components/controls/MasterControls';
|
||||
import { FrequencyAnalyzer } from '@/components/analysis/FrequencyAnalyzer';
|
||||
import { Spectrogram } from '@/components/analysis/Spectrogram';
|
||||
import { ThemeToggle } from '@/components/layout/ThemeToggle';
|
||||
import { CommandPalette } from '@/components/ui/CommandPalette';
|
||||
import { GlobalSettingsDialog } from '@/components/settings/GlobalSettingsDialog';
|
||||
@@ -47,6 +49,7 @@ export function AudioEditor() {
|
||||
const [settingsDialogOpen, setSettingsDialogOpen] = React.useState(false);
|
||||
const [exportDialogOpen, setExportDialogOpen] = React.useState(false);
|
||||
const [isExporting, setIsExporting] = React.useState(false);
|
||||
const [analyzerView, setAnalyzerView] = React.useState<'frequency' | 'spectrogram'>('frequency');
|
||||
|
||||
const { addToast } = useToast();
|
||||
|
||||
@@ -225,6 +228,7 @@ export function AudioEditor() {
|
||||
masterPeakLevel,
|
||||
masterRmsLevel,
|
||||
masterIsClipping,
|
||||
masterAnalyser,
|
||||
resetClipIndicator,
|
||||
play,
|
||||
pause,
|
||||
@@ -1036,28 +1040,64 @@ export function AudioEditor() {
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Right Sidebar - Master Controls */}
|
||||
<aside className="flex-shrink-0 border-l border-border bg-card flex items-center justify-center p-4">
|
||||
<MasterControls
|
||||
volume={masterVolume}
|
||||
pan={masterPan}
|
||||
peakLevel={masterPeakLevel}
|
||||
rmsLevel={masterRmsLevel}
|
||||
isClipping={masterIsClipping}
|
||||
isMuted={isMasterMuted}
|
||||
onVolumeChange={setMasterVolume}
|
||||
onPanChange={setMasterPan}
|
||||
onMuteToggle={() => {
|
||||
if (isMasterMuted) {
|
||||
setMasterVolume(0.8);
|
||||
setIsMasterMuted(false);
|
||||
} else {
|
||||
setMasterVolume(0);
|
||||
setIsMasterMuted(true);
|
||||
}
|
||||
}}
|
||||
onResetClip={resetClipIndicator}
|
||||
/>
|
||||
{/* Right Sidebar - Master Controls & Analyzers */}
|
||||
<aside className="flex-shrink-0 border-l border-border bg-card flex flex-col p-4 gap-4 w-[280px]">
|
||||
{/* Master Controls */}
|
||||
<div className="flex items-center justify-center">
|
||||
<MasterControls
|
||||
volume={masterVolume}
|
||||
pan={masterPan}
|
||||
peakLevel={masterPeakLevel}
|
||||
rmsLevel={masterRmsLevel}
|
||||
isClipping={masterIsClipping}
|
||||
isMuted={isMasterMuted}
|
||||
onVolumeChange={setMasterVolume}
|
||||
onPanChange={setMasterPan}
|
||||
onMuteToggle={() => {
|
||||
if (isMasterMuted) {
|
||||
setMasterVolume(0.8);
|
||||
setIsMasterMuted(false);
|
||||
} else {
|
||||
setMasterVolume(0);
|
||||
setIsMasterMuted(true);
|
||||
}
|
||||
}}
|
||||
onResetClip={resetClipIndicator}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Analyzer Toggle */}
|
||||
<div className="flex gap-1 bg-muted/20 border border-border/50 rounded-md p-1">
|
||||
<button
|
||||
onClick={() => setAnalyzerView('frequency')}
|
||||
className={`flex-1 px-2 py-1 rounded text-[10px] font-bold uppercase tracking-wider transition-all ${
|
||||
analyzerView === 'frequency'
|
||||
? 'bg-accent text-accent-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
FFT
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAnalyzerView('spectrogram')}
|
||||
className={`flex-1 px-2 py-1 rounded text-[10px] font-bold uppercase tracking-wider transition-all ${
|
||||
analyzerView === 'spectrogram'
|
||||
? 'bg-accent text-accent-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
Spectrum
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Analyzer Display */}
|
||||
<div className="flex-1 min-h-[300px]">
|
||||
{analyzerView === 'frequency' ? (
|
||||
<FrequencyAnalyzer analyserNode={masterAnalyser} />
|
||||
) : (
|
||||
<Spectrogram analyserNode={masterAnalyser} />
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -830,6 +830,7 @@ export function useMultiTrackPlayer(
|
||||
masterPeakLevel,
|
||||
masterRmsLevel,
|
||||
masterIsClipping,
|
||||
masterAnalyser: masterAnalyserRef.current,
|
||||
resetClipIndicator,
|
||||
play,
|
||||
pause,
|
||||
|
||||
Reference in New Issue
Block a user