mirror of
https://github.com/JorySeverijnse/ui-fixer-supreme.git
synced 2026-01-29 16:18:38 +00:00
470 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|