personal_website/src/components/Oscilloscope.tsx

470 lines
16 KiB
TypeScript

import { useState, useCallback, useEffect, useRef } from 'react';
import { AudioUploader } from './AudioUploader';
import { OscilloscopeControls, LiveDisplaySettings } from './OscilloscopeControls';
import { OscilloscopeDisplay } from './OscilloscopeDisplay';
import { useAudioAnalyzer } from '@/hooks/useAudioAnalyzer';
import { useOscilloscopeRenderer } from '@/hooks/useOscilloscopeRenderer';
import { useVideoExporter } from '@/hooks/useVideoExporter';
import { useSettings } from '@/contexts/SettingsContext';
import { Pause, Play } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
// Format time in mm:ss format
const formatTime = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
export function Oscilloscope() {
const [mode, setMode] = useState<'combined' | 'separate' | 'all'>('combined');
const [liveSettings, setLiveSettings] = useState<LiveDisplaySettings>({
lineThickness: 2,
showGrid: true,
glowIntensity: 1,
displayMode: 'combined',
visualizationMode: 'waveform',
});
const [isMicActive, setIsMicActive] = useState(false);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [playbackSpeed, setPlaybackSpeed] = useState(1);
const [isLooping, setIsLooping] = useState(false);
const [seekPosition, setSeekPosition] = useState(0);
const [exportResolution, setExportResolution] = useState<'640x480' | '1280x720' | '1920x1080'>('1920x1080');
const [exportFps, setExportFps] = useState<24 | 30 | 60>(60);
const [exportQuality, setExportQuality] = useState<'low' | 'medium' | 'high'>('medium');
const [showMicCalibration, setShowMicCalibration] = useState(false);
const [micLevel, setMicLevel] = useState(0);
const [micGain, setMicGain] = useState(1);
const [micGainNode, setMicGainNode] = useState<GainNode | null>(null);
const isMicActiveRef = useRef(false);
// Audio playback refs
const audioRef = useRef<HTMLAudioElement | null>(null);
const audioUrlRef = useRef<string | null>(null);
const { audioData, isLoading, fileName, originalFile, loadAudioFile, reset: resetAudio } = useAudioAnalyzer();
const { isExporting, progress, exportedUrl, exportVideo, reset: resetExport } = useVideoExporter();
const { playSound } = useSettings();
// Update mic gain when it changes
useEffect(() => {
if (micGainNode) {
micGainNode.gain.value = micGain;
}
}, [micGain, micGainNode]);
// Real-time microphone input
const [micStream, setMicStream] = useState<MediaStream | null>(null);
const [micAnalyzer, setMicAnalyzer] = useState<AnalyserNode | null>(null);
// Create audio element when file is loaded
useEffect(() => {
if (originalFile) {
// Clean up previous audio URL
if (audioUrlRef.current) {
URL.revokeObjectURL(audioUrlRef.current);
}
// Create new audio element
const url = URL.createObjectURL(originalFile);
audioUrlRef.current = url;
const audio = new Audio(url);
audio.loop = isLooping;
audio.playbackRate = playbackSpeed;
audio.addEventListener('timeupdate', () => {
setCurrentTime(audio.currentTime);
if (audioData) {
const position = audio.currentTime / audioData.duration;
setSeekPosition(position);
}
});
audio.addEventListener('ended', () => {
if (!isLooping) {
setIsPlaying(false);
setCurrentTime(0);
setSeekPosition(0);
}
});
audioRef.current = audio;
return () => {
audio.pause();
audio.src = '';
if (audioUrlRef.current) {
URL.revokeObjectURL(audioUrlRef.current);
audioUrlRef.current = null;
}
};
}
}, [originalFile, audioData]);
// Update audio playback state
useEffect(() => {
if (audioRef.current) {
audioRef.current.loop = isLooping;
audioRef.current.playbackRate = playbackSpeed;
}
}, [isLooping, playbackSpeed]);
// Handle play/pause
useEffect(() => {
if (audioRef.current) {
if (isPlaying) {
audioRef.current.play().catch(console.error);
} else {
audioRef.current.pause();
}
}
}, [isPlaying]);
const handleFileSelect = useCallback((file: File) => {
loadAudioFile(file);
if (isMicActive) {
setIsMicActive(false);
}
}, [loadAudioFile, isMicActive]);
const toggleMic = useCallback(async () => {
playSound('click');
if (isMicActive) {
// Stop microphone
isMicActiveRef.current = false;
if (micStream) {
micStream.getTracks().forEach(track => track.stop());
setMicStream(null);
}
setMicAnalyzer(null);
setMicGainNode(null);
setIsMicActive(false);
setMicLevel(0);
setShowMicCalibration(false);
resetAudio();
} else {
// Start microphone
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
setMicStream(stream);
const audioContext = new AudioContext();
const analyser = audioContext.createAnalyser();
analyser.fftSize = 2048;
analyser.smoothingTimeConstant = 0.8;
const gainNode = audioContext.createGain();
gainNode.gain.value = micGain;
setMicGainNode(gainNode);
const source = audioContext.createMediaStreamSource(stream);
source.connect(gainNode);
gainNode.connect(analyser);
// Start monitoring mic levels
isMicActiveRef.current = true;
const dataArray = new Uint8Array(analyser.frequencyBinCount);
const monitorLevels = () => {
if (isMicActiveRef.current && analyser) {
analyser.getByteFrequencyData(dataArray);
const average = dataArray.reduce((a, b) => a + b) / dataArray.length;
setMicLevel(average / 255); // Normalize to 0-1
requestAnimationFrame(monitorLevels);
}
};
monitorLevels();
setMicAnalyzer(analyser);
setIsMicActive(true);
resetAudio(); // Clear any loaded file
} catch (error) {
console.error('Error accessing microphone:', error);
alert('Could not access microphone. Please check permissions.');
}
}
}, [isMicActive, micStream, playSound, resetAudio, micGain]);
const handlePreview = useCallback(() => {
playSound('click');
setIsPlaying(!isPlaying);
}, [isPlaying, playSound]);
const handleSeek = useCallback((position: number) => {
if (audioData && audioRef.current && position >= 0) {
const newTime = position * audioData.duration;
audioRef.current.currentTime = newTime;
setCurrentTime(newTime);
setSeekPosition(position);
}
}, [audioData]);
const handlePlaybackSpeedChange = useCallback((speed: number) => {
setPlaybackSpeed(speed);
}, []);
const handleLoopingChange = useCallback((looping: boolean) => {
setIsLooping(looping);
}, []);
const handleExportResolutionChange = useCallback((resolution: string) => {
setExportResolution(resolution as '640x480' | '1280x720' | '1920x1080');
}, []);
const handleExportFpsChange = useCallback((fps: number) => {
setExportFps(fps as 24 | 30 | 60);
}, []);
const handleExportQualityChange = useCallback((quality: string) => {
setExportQuality(quality as 'low' | 'medium' | 'high');
}, []);
const handleGenerate = useCallback(async () => {
if (!audioData || !originalFile) return;
const [width, height] = exportResolution.split('x').map(Number);
await exportVideo(audioData, originalFile, {
width,
height,
fps: exportFps,
mode,
audioFile: originalFile,
quality: exportQuality,
});
}, [audioData, originalFile, exportVideo, mode, exportResolution, exportFps, exportQuality]);
const handleReset = useCallback(() => {
playSound('click');
setIsPlaying(false);
resetAudio();
resetExport();
}, [playSound, resetAudio, resetExport]);
const canPreview = (audioData !== null || isMicActive) && !isExporting;
const canGenerate = audioData !== null && originalFile !== null && !isExporting && !isLoading;
return (
<div className="space-y-6 max-w-7xl mx-auto">
{/* Header */}
<div className="text-center space-y-4">
<h2 className="font-minecraft text-2xl md:text-3xl text-primary text-glow-strong">
Audio Oscilloscope
</h2>
<p className="font-pixel text-foreground/80">
Visualize audio waveforms in real-time
</p>
</div>
{/* Main Content: Display + Controls Side by Side */}
<div className="grid grid-cols-1 xl:grid-cols-[1fr_320px] gap-6">
{/* Oscilloscope Display */}
<div className="flex flex-col items-center gap-4 order-2 xl:order-1">
<OscilloscopeDisplay
audioData={audioData}
micAnalyzer={micAnalyzer}
mode={liveSettings.displayMode}
isPlaying={isPlaying}
playbackSpeed={playbackSpeed}
isLooping={isLooping}
audioElementRef={audioRef}
onPlaybackEnd={() => {
setIsPlaying(false);
setCurrentTime(0);
setSeekPosition(0);
}}
onSeek={handleSeek}
liveSettings={liveSettings}
/>
{/* Audio Uploader - below controls on desktop */}
<div className="w-full mt-4">
<AudioUploader
onFileSelect={handleFileSelect}
isLoading={isLoading}
fileName={fileName}
/>
</div>
{/* Audio Playback Controls */}
{audioData && originalFile && (
<div className="w-full max-w-3xl space-y-2 px-4">
{/* Play/Pause and Time Display */}
<div className="flex items-center gap-4">
<Button
onClick={handlePreview}
variant="outline"
size="sm"
className="font-crt border-primary/50 hover:bg-primary/10"
disabled={isExporting}
>
{isPlaying ? <Pause size={16} /> : <Play size={16} />}
</Button>
<span className="font-mono-crt text-sm text-foreground/80 min-w-[80px]">
{formatTime(currentTime)} / {formatTime(audioData.duration)}
</span>
{/* Progress Bar */}
<div className="flex-1">
<Slider
value={[seekPosition * 100]}
onValueChange={(value) => handleSeek(value[0] / 100)}
max={100}
step={0.1}
className="cursor-pointer"
/>
</div>
</div>
</div>
)}
</div>
{/* Control Panel */}
<div className="order-1 xl:order-2">
<OscilloscopeControls
mode={mode}
onModeChange={setMode}
canGenerate={canGenerate}
isGenerating={isExporting}
progress={progress}
exportedUrl={exportedUrl}
onGenerate={handleGenerate}
onReset={handleReset}
isPlaying={isPlaying}
onPreview={handlePreview}
canPreview={canPreview}
playbackSpeed={playbackSpeed}
onPlaybackSpeedChange={handlePlaybackSpeedChange}
isLooping={isLooping}
onLoopingChange={handleLoopingChange}
exportResolution={exportResolution}
onExportResolutionChange={handleExportResolutionChange}
exportFps={exportFps}
onExportFpsChange={handleExportFpsChange}
exportQuality={exportQuality}
onExportQualityChange={handleExportQualityChange}
liveSettings={liveSettings}
onLiveSettingsChange={setLiveSettings}
isMicActive={isMicActive}
onToggleMic={toggleMic}
/>
</div>
</div>
{/* Microphone Calibration */}
{showMicCalibration && isMicActive && (
<div className="bg-card border border-border rounded-lg p-4 max-w-md mx-auto">
<h3 className="font-crt text-lg text-primary text-glow mb-4">Microphone Calibration</h3>
<div className="space-y-4">
{/* Level Indicator */}
<div>
<div className="flex justify-between text-sm font-mono-crt mb-2">
<span>Input Level</span>
<span>{Math.round(micLevel * 100)}%</span>
</div>
<div className="w-full bg-secondary rounded-full h-3">
<div
className="bg-primary h-3 rounded-full transition-all duration-100"
style={{ width: `${micLevel * 100}%` }}
/>
</div>
<div className="flex justify-between text-xs text-muted-foreground mt-1">
<span>Quiet</span>
<span className={micLevel > 0.7 ? 'text-red-400' : micLevel > 0.5 ? 'text-yellow-400' : 'text-green-400'}>
{micLevel > 0.7 ? 'Too Loud' : micLevel > 0.5 ? 'Good' : 'Too Quiet'}
</span>
<span>Loud</span>
</div>
</div>
{/* Gain Control */}
<div>
<div className="flex justify-between text-sm font-mono-crt mb-2">
<span>Gain</span>
<span>{micGain.toFixed(1)}x</span>
</div>
<input
type="range"
min="0.1"
max="3"
step="0.1"
value={micGain}
onChange={(e) => setMicGain(Number(e.target.value))}
className="w-full"
/>
</div>
<p className="text-xs text-muted-foreground font-mono-crt">
Speak into your microphone and adjust gain until the level shows "Good" (green).
The bar should peak around 50-70% when speaking normally.
</p>
</div>
</div>
)}
{showMicCalibration && isMicActive && (
<div className="bg-card border border-border rounded-lg p-4 max-w-md mx-auto">
<h3 className="font-crt text-lg text-primary text-glow mb-4">Microphone Calibration</h3>
<div className="space-y-4">
{/* Level Indicator */}
<div>
<div className="flex justify-between text-sm font-mono-crt mb-2">
<span>Input Level</span>
<span>{Math.round(micLevel * 100)}%</span>
</div>
<div className="w-full bg-secondary rounded-full h-3">
<div
className="bg-primary h-3 rounded-full transition-all duration-100"
style={{ width: `${micLevel * 100}%` }}
/>
</div>
<div className="flex justify-between text-xs text-muted-foreground mt-1">
<span>Quiet</span>
<span className={micLevel > 0.7 ? 'text-red-400' : micLevel > 0.5 ? 'text-yellow-400' : 'text-green-400'}>
{micLevel > 0.7 ? 'Too Loud' : micLevel > 0.5 ? 'Good' : 'Too Quiet'}
</span>
<span>Loud</span>
</div>
</div>
{/* Gain Control */}
<div>
<div className="flex justify-between text-sm font-mono-crt mb-2">
<span>Gain</span>
<span>{micGain.toFixed(1)}x</span>
</div>
<input
type="range"
min="0.1"
max="3"
step="0.1"
value={micGain}
onChange={(e) => setMicGain(Number(e.target.value))}
className="w-full"
/>
</div>
<p className="text-xs text-muted-foreground font-mono-crt">
Speak into your microphone and adjust gain until the level shows "Good" (green).
The bar should peak around 50-70% when speaking normally.
</p>
</div>
</div>
)}
{/* Status Info */}
{(isLoading || isExporting) && (
<div className="text-center text-muted-foreground font-mono-crt text-sm">
{isLoading && "Loading audio..."}
{isExporting && `Exporting video... ${progress}%`}
</div>
)}
</div>
);
}