From 7dc0780bd2f7b97716bdf41fa35cf7e9d92d7333 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Wed, 19 Nov 2025 01:40:04 +0100 Subject: [PATCH] feat: implement Phase 10.1 & 10.2 - frequency analyzer and spectrogram MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- components/analysis/FrequencyAnalyzer.tsx | 86 +++++++++++++++ components/analysis/Spectrogram.tsx | 127 ++++++++++++++++++++++ components/editor/AudioEditor.tsx | 84 ++++++++++---- lib/hooks/useMultiTrackPlayer.ts | 1 + 4 files changed, 276 insertions(+), 22 deletions(-) create mode 100644 components/analysis/FrequencyAnalyzer.tsx create mode 100644 components/analysis/Spectrogram.tsx diff --git a/components/analysis/FrequencyAnalyzer.tsx b/components/analysis/FrequencyAnalyzer.tsx new file mode 100644 index 0000000..fb5f710 --- /dev/null +++ b/components/analysis/FrequencyAnalyzer.tsx @@ -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(null); + const animationFrameRef = React.useRef(); + + 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 ( +
+
+ Frequency Analyzer +
+ +
+ ); +} diff --git a/components/analysis/Spectrogram.tsx b/components/analysis/Spectrogram.tsx new file mode 100644 index 0000000..ba2107a --- /dev/null +++ b/components/analysis/Spectrogram.tsx @@ -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(null); + const animationFrameRef = React.useRef(); + const spectrogramDataRef = React.useRef(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 ( +
+
+ Spectrogram +
+ +
+ ); +} diff --git a/components/editor/AudioEditor.tsx b/components/editor/AudioEditor.tsx index 8bb5423..39c4240 100644 --- a/components/editor/AudioEditor.tsx +++ b/components/editor/AudioEditor.tsx @@ -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() { - {/* Right Sidebar - Master Controls */} -