personal_website/src/components/OscilloscopeDisplay.tsx
gpt-engineer-app[bot] 8ecd8da712 Changes
2025-12-21 18:10:00 +00:00

506 lines
17 KiB
TypeScript
Executable File

import { useEffect, useRef, useCallback } from 'react';
import type { AudioData } from '@/hooks/useAudioAnalyzer';
import type { OscilloscopeMode } from '@/hooks/useOscilloscopeRenderer';
import { useAudioAnalyzer as useSharedAudioAnalyzer } from '@/contexts/AudioAnalyzerContext';
import type { LiveDisplaySettings, VisualizationMode } from './OscilloscopeControls';
interface OscilloscopeDisplayProps {
audioData: AudioData | null;
micAnalyzer: AnalyserNode | null;
mode: OscilloscopeMode;
isPlaying: boolean;
playbackSpeed: number;
isLooping: boolean;
audioElementRef?: React.RefObject<HTMLAudioElement | null>;
onPlaybackEnd?: () => void;
onSeek?: (position: number) => void;
liveSettings?: LiveDisplaySettings;
}
const WIDTH = 800;
const HEIGHT = 600;
const FPS = 60;
// Get computed CSS color from theme
const getThemeColor = (cssVar: string, fallback: string): string => {
if (typeof window === 'undefined') return fallback;
const root = document.documentElement;
const value = getComputedStyle(root).getPropertyValue(cssVar).trim();
if (value) {
return `hsl(${value})`;
}
return fallback;
};
export function OscilloscopeDisplay({
audioData,
micAnalyzer,
mode,
isPlaying,
playbackSpeed,
isLooping,
audioElementRef,
onPlaybackEnd,
onSeek,
liveSettings
}: OscilloscopeDisplayProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const animationRef = useRef<number | null>(null);
const { analyzerNode: sharedAnalyzer } = useSharedAudioAnalyzer();
// Use shared analyzer for live audio (music player, sound effects)
const liveAnalyzer = sharedAnalyzer || micAnalyzer;
// Get settings with defaults
const lineThickness = liveSettings?.lineThickness ?? 2;
const showGrid = liveSettings?.showGrid ?? true;
const glowIntensity = liveSettings?.glowIntensity ?? 1;
const liveDisplayMode = liveSettings?.displayMode ?? 'combined';
const visualizationMode = liveSettings?.visualizationMode ?? 'waveform';
const drawGraticule = useCallback((ctx: CanvasRenderingContext2D) => {
if (!showGrid) return;
const primaryColor = getThemeColor('--primary', '#00ff00');
ctx.strokeStyle = primaryColor;
ctx.globalAlpha = 0.3;
ctx.lineWidth = 1;
// Horizontal center line (X axis)
ctx.beginPath();
ctx.moveTo(0, HEIGHT / 2);
ctx.lineTo(WIDTH, HEIGHT / 2);
ctx.stroke();
// Vertical center line (Y axis)
ctx.beginPath();
ctx.moveTo(WIDTH / 2, 0);
ctx.lineTo(WIDTH / 2, HEIGHT);
ctx.stroke();
ctx.globalAlpha = 1;
}, [showGrid]);
// Draw spectrum bars
const drawSpectrum = useCallback((ctx: CanvasRenderingContext2D, frequencyData: Uint8Array, yOffset: number = 0, heightRatio: number = 1) => {
const primaryColor = getThemeColor('--primary', '#00ff00');
const accentColor = getThemeColor('--accent', '#00ccff');
const barCount = 64;
const barWidth = (WIDTH / barCount) - 2;
const maxBarHeight = (HEIGHT * heightRatio) * 0.8;
// Sample frequency data for the bar count
const step = Math.floor(frequencyData.length / barCount);
for (let i = 0; i < barCount; i++) {
const dataIndex = i * step;
const value = frequencyData[dataIndex] / 255;
const barHeight = value * maxBarHeight;
const x = i * (barWidth + 2);
const y = yOffset + (HEIGHT * heightRatio) - barHeight;
// Create gradient for each bar
const gradient = ctx.createLinearGradient(x, y + barHeight, x, y);
gradient.addColorStop(0, primaryColor);
gradient.addColorStop(1, accentColor);
ctx.fillStyle = gradient;
ctx.fillRect(x, y, barWidth, barHeight);
}
}, []);
const drawFrame = useCallback(() => {
if (!canvasRef.current) return;
// Always allow drawing if we have live analyzer, even without audioData
const hasLiveSource = liveAnalyzer || micAnalyzer;
if (!audioData && !hasLiveSource) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const primaryColor = getThemeColor('--primary', '#00ff00');
const backgroundColor = getThemeColor('--background', '#000000');
let samplesPerFrame: number = 0;
let endSample: number = 0;
let samplesToAdvance: number = 0;
// Priority: micAnalyzer > liveAnalyzer (shared) > audioData (file)
const activeAnalyzer = micAnalyzer || liveAnalyzer;
if (activeAnalyzer && !audioData) {
// Real-time audio data (mic or music player)
const bufferLength = activeAnalyzer.frequencyBinCount;
const timeDomainData = new Uint8Array(bufferLength);
const frequencyData = new Uint8Array(bufferLength);
activeAnalyzer.getByteTimeDomainData(timeDomainData);
activeAnalyzer.getByteFrequencyData(frequencyData);
// Clear to background color
ctx.fillStyle = backgroundColor;
ctx.fillRect(0, 0, WIDTH, HEIGHT);
// Draw graticule first (only for waveform modes)
if (visualizationMode !== 'spectrum') {
drawGraticule(ctx);
}
// Convert to Float32Array-like for consistency
const liveData = new Float32Array(timeDomainData.length);
for (let i = 0; i < timeDomainData.length; i++) {
liveData[i] = (timeDomainData[i] - 128) / 128; // Normalize to -1 to 1
}
// Apply glow effect
if (glowIntensity > 0) {
ctx.shadowColor = primaryColor;
ctx.shadowBlur = glowIntensity * 8;
} else {
ctx.shadowBlur = 0;
}
ctx.strokeStyle = primaryColor;
ctx.lineWidth = lineThickness;
// Draw based on visualization mode
if (visualizationMode === 'spectrum') {
// Spectrum bars only
ctx.shadowBlur = 0;
drawSpectrum(ctx, frequencyData, 0, 1);
} else if (visualizationMode === 'both') {
// Waveform on top half, spectrum on bottom half
// Draw waveform
if (liveDisplayMode === 'all') {
// XY mode in top half
ctx.beginPath();
const centerX = WIDTH / 2;
const centerY = HEIGHT / 4;
const scale = Math.min(WIDTH, HEIGHT / 2) * 0.35;
for (let i = 0; i < liveData.length - 1; i += 2) {
const x = centerX + liveData[i] * scale;
const y = centerY - liveData[i + 1] * scale;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
} else {
// Combined waveform in top half
ctx.beginPath();
const sliceWidth = WIDTH / liveData.length;
let x = 0;
for (let i = 0; i < liveData.length; i++) {
const v = liveData[i];
const y = (v * HEIGHT * 0.4) / 2 + HEIGHT / 4;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
x += sliceWidth;
}
ctx.stroke();
}
// Spectrum in bottom half
ctx.shadowBlur = 0;
drawSpectrum(ctx, frequencyData, HEIGHT / 2, 0.5);
// Divider line
ctx.strokeStyle = 'rgba(255,255,255,0.1)';
ctx.beginPath();
ctx.moveTo(0, HEIGHT / 2);
ctx.lineTo(WIDTH, HEIGHT / 2);
ctx.stroke();
} else {
// Waveform only (default)
if (liveDisplayMode === 'all') {
// XY / Lissajous mode - treat odd/even samples as L/R
ctx.beginPath();
const centerX = WIDTH / 2;
const centerY = HEIGHT / 2;
const scale = Math.min(WIDTH, HEIGHT) * 0.4;
for (let i = 0; i < liveData.length - 1; i += 2) {
const x = centerX + liveData[i] * scale;
const y = centerY - liveData[i + 1] * scale;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
} else {
// Combined waveform mode
ctx.beginPath();
const sliceWidth = WIDTH / liveData.length;
let x = 0;
for (let i = 0; i < liveData.length; i++) {
const v = liveData[i];
const y = (v * HEIGHT) / 2 + HEIGHT / 2;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
x += sliceWidth;
}
ctx.stroke();
}
}
ctx.shadowBlur = 0;
// Request next frame for real-time
animationRef.current = requestAnimationFrame(drawFrame);
return;
}
// File playback mode - need audioData
if (!audioData) return;
// File playback mode - sync with audio element if available
const baseSamplesPerFrame = Math.floor(audioData.sampleRate / FPS);
samplesPerFrame = Math.floor(baseSamplesPerFrame * playbackSpeed);
samplesToAdvance = samplesPerFrame;
// Get current position from audio element (real-time sync at 60fps)
let startSample: number;
if (audioElementRef?.current && !audioElementRef.current.paused) {
const currentTime = audioElementRef.current.currentTime;
startSample = Math.floor((currentTime / audioData.duration) * audioData.leftChannel.length);
} else {
// Fallback: just show first frame when paused
startSample = 0;
}
endSample = Math.min(startSample + samplesPerFrame, audioData.leftChannel.length);
// Clear to background color
ctx.fillStyle = backgroundColor;
ctx.fillRect(0, 0, WIDTH, HEIGHT);
// Draw graticule first
drawGraticule(ctx);
// Apply glow effect
if (glowIntensity > 0) {
ctx.shadowColor = primaryColor;
ctx.shadowBlur = glowIntensity * 8;
} else {
ctx.shadowBlur = 0;
}
ctx.lineWidth = lineThickness;
ctx.lineCap = 'round';
const leftColor = primaryColor;
const rightColor = getThemeColor('--accent', '#00ccff');
const xyColor = getThemeColor('--secondary', '#ff8800');
const dividerColor = 'rgba(255,255,255,0.1)';
if (mode === 'combined') {
// Combined: both channels merged
ctx.strokeStyle = leftColor;
ctx.beginPath();
const samplesPerPixel = samplesPerFrame / WIDTH;
const centerY = HEIGHT / 2;
for (let x = 0; x < WIDTH; x++) {
const sampleIndex = Math.floor(startSample + x * samplesPerPixel);
if (sampleIndex >= audioData.leftChannel.length) break;
const sample = (audioData.leftChannel[sampleIndex] + audioData.rightChannel[sampleIndex]) / 2;
const y = centerY - sample * (HEIGHT * 0.4);
if (x === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
} else if (mode === 'separate') {
// Separate: Left on top, Right on bottom
const halfHeight = HEIGHT / 2;
const samplesPerPixel = samplesPerFrame / WIDTH;
// Left channel (top)
ctx.strokeStyle = leftColor;
ctx.beginPath();
const leftCenterY = halfHeight / 2;
for (let x = 0; x < WIDTH; x++) {
const sampleIndex = Math.floor(startSample + x * samplesPerPixel);
if (sampleIndex >= audioData.leftChannel.length) break;
const sample = audioData.leftChannel[sampleIndex];
const y = leftCenterY - sample * (halfHeight * 0.35);
if (x === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// Right channel (bottom)
ctx.strokeStyle = rightColor;
ctx.beginPath();
const rightCenterY = halfHeight + halfHeight / 2;
for (let x = 0; x < WIDTH; x++) {
const sampleIndex = Math.floor(startSample + x * samplesPerPixel);
if (sampleIndex >= audioData.rightChannel.length) break;
const sample = audioData.rightChannel[sampleIndex];
const y = rightCenterY - sample * (halfHeight * 0.35);
if (x === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// Divider
ctx.strokeStyle = dividerColor;
ctx.beginPath();
ctx.moveTo(0, halfHeight);
ctx.lineTo(WIDTH, halfHeight);
ctx.stroke();
} else if (mode === 'all') {
// All: L/R on top row, XY on bottom
const topHeight = HEIGHT / 2;
const bottomHeight = HEIGHT / 2;
const halfWidth = WIDTH / 2;
const samplesPerPixel = samplesPerFrame / halfWidth;
// Left channel (top-left)
ctx.strokeStyle = leftColor;
ctx.beginPath();
const leftCenterY = topHeight / 2;
for (let x = 0; x < halfWidth; x++) {
const sampleIndex = Math.floor(startSample + x * samplesPerPixel);
if (sampleIndex >= audioData.leftChannel.length) break;
const sample = audioData.leftChannel[sampleIndex];
const y = leftCenterY - sample * (topHeight * 0.35);
if (x === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// Right channel (top-right)
ctx.strokeStyle = rightColor;
ctx.beginPath();
const rightCenterY = topHeight / 2;
for (let x = 0; x < halfWidth; x++) {
const sampleIndex = Math.floor(startSample + x * samplesPerPixel);
if (sampleIndex >= audioData.rightChannel.length) break;
const sample = audioData.rightChannel[sampleIndex];
const y = rightCenterY - sample * (topHeight * 0.35);
if (x === 0) ctx.moveTo(halfWidth + x, y);
else ctx.lineTo(halfWidth + x, y);
}
ctx.stroke();
// XY mode (bottom half)
ctx.strokeStyle = xyColor;
ctx.beginPath();
const xyCenterX = WIDTH / 2;
const xyCenterY = topHeight + bottomHeight / 2;
const xyScale = Math.min(halfWidth, bottomHeight) * 0.35;
for (let i = startSample; i < endSample; i++) {
const x = xyCenterX + audioData.leftChannel[i] * xyScale;
const y = xyCenterY - audioData.rightChannel[i] * xyScale;
if (i === startSample) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// Dividers
ctx.strokeStyle = dividerColor;
ctx.beginPath();
ctx.moveTo(0, topHeight);
ctx.lineTo(WIDTH, topHeight);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(halfWidth, 0);
ctx.lineTo(halfWidth, topHeight);
ctx.stroke();
}
ctx.shadowBlur = 0;
// Check if audio ended (when syncing to audio element)
if (audioElementRef?.current) {
if (audioElementRef.current.ended && !isLooping) {
onPlaybackEnd?.();
return;
}
}
animationRef.current = requestAnimationFrame(drawFrame);
}, [audioData, micAnalyzer, liveAnalyzer, mode, drawGraticule, drawSpectrum, onPlaybackEnd, isPlaying, playbackSpeed, isLooping, lineThickness, glowIntensity, liveDisplayMode, visualizationMode, audioElementRef]);
// Initialize canvas
useEffect(() => {
if (!canvasRef.current) return;
const ctx = canvasRef.current.getContext('2d');
if (ctx) {
ctx.fillStyle = '#000000';
ctx.fillRect(0, 0, WIDTH, HEIGHT);
drawGraticule(ctx);
}
}, [drawGraticule]);
// Handle playback - start animation for file playback or live audio
useEffect(() => {
const hasLiveSource = liveAnalyzer || micAnalyzer;
if (isPlaying && audioData) {
// File playback
animationRef.current = requestAnimationFrame(drawFrame);
} else if (hasLiveSource && !audioData) {
// Live audio visualization (music player, sound effects)
animationRef.current = requestAnimationFrame(drawFrame);
} else {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
}
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, [isPlaying, audioData, liveAnalyzer, micAnalyzer, drawFrame]);
const getModeLabel = () => {
switch (mode) {
case 'combined': return 'L+R';
case 'separate': return 'L / R';
case 'all': return 'ALL';
default: return '';
}
};
return (
<div className="crt-bezel">
<div className="screen-curve relative">
<canvas
ref={canvasRef}
width={WIDTH}
height={HEIGHT}
className="w-full h-auto cursor-pointer"
onClick={(e) => {
if (!audioData) return;
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const x = e.clientX - rect.left;
const clickPosition = x / rect.width;
onSeek?.(Math.max(0, Math.min(1, clickPosition)));
}}
/>
{/* Mode indicator */}
<div className="absolute top-4 left-4 font-crt text-primary/60 text-sm">
{getModeLabel()}
</div>
{/* Idle state - only show if no live audio and no file */}
{!audioData && !liveAnalyzer && !micAnalyzer && (
<div className="absolute inset-0 flex items-center justify-center">
<p className="font-crt text-2xl text-primary/40 text-glow animate-pulse">
NO SIGNAL
</p>
</div>
)}
</div>
</div>
);
}