Refactor oscilloscope UI

- Rename ControlPanel to OscilloscopeControls for clarity
- Integrate live audio playback with a progress bar and time display
- Remove MP4 export; WebM only with updated export options
- Align oscilloscope components to use shared audio analyzer context
- Enable immediate oscilloscope initialization and live display modes (Combined/XY)
- Improve styling and responsive sizing of mini/full oscilloscope views

X-Lovable-Edit-ID: edt-28a85a31-f522-4420-8353-3be1a784d5c4
This commit is contained in:
gpt-engineer-app[bot] 2025-12-21 14:55:20 +00:00
commit 8fd0ef4f53
3 changed files with 128 additions and 39 deletions

View File

@ -1,13 +1,21 @@
import { useState, useCallback, useEffect, useRef } from 'react'; import { useState, useCallback, useEffect, useRef } from 'react';
import { AudioUploader } from './AudioUploader'; import { AudioUploader } from './AudioUploader';
import { ControlPanel, LiveDisplaySettings } from './ControlPanel'; import { OscilloscopeControls, LiveDisplaySettings } from './OscilloscopeControls';
import { OscilloscopeDisplay } from './OscilloscopeDisplay'; import { OscilloscopeDisplay } from './OscilloscopeDisplay';
import { useAudioAnalyzer } from '@/hooks/useAudioAnalyzer'; import { useAudioAnalyzer } from '@/hooks/useAudioAnalyzer';
import { useOscilloscopeRenderer } from '@/hooks/useOscilloscopeRenderer'; import { useOscilloscopeRenderer } from '@/hooks/useOscilloscopeRenderer';
import { useVideoExporter } from '@/hooks/useVideoExporter'; import { useVideoExporter } from '@/hooks/useVideoExporter';
import { useSettings } from '@/contexts/SettingsContext'; import { useSettings } from '@/contexts/SettingsContext';
import { Mic, MicOff } from 'lucide-react'; import { Mic, MicOff, Pause, Play } from 'lucide-react';
import { Button } from '@/components/ui/button'; 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() { export function Oscilloscope() {
const [mode, setMode] = useState<'combined' | 'separate' | 'all'>('combined'); const [mode, setMode] = useState<'combined' | 'separate' | 'all'>('combined');
@ -19,13 +27,12 @@ export function Oscilloscope() {
}); });
const [isMicActive, setIsMicActive] = useState(false); const [isMicActive, setIsMicActive] = useState(false);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [currentSample, setCurrentSample] = useState(0); const [currentTime, setCurrentTime] = useState(0);
const [playbackSpeed, setPlaybackSpeed] = useState(1); const [playbackSpeed, setPlaybackSpeed] = useState(1);
const [isLooping, setIsLooping] = useState(false); const [isLooping, setIsLooping] = useState(false);
const [seekPosition, setSeekPosition] = useState(0); const [seekPosition, setSeekPosition] = useState(0);
const [exportResolution, setExportResolution] = useState<'640x480' | '1280x720' | '1920x1080'>('1920x1080'); const [exportResolution, setExportResolution] = useState<'640x480' | '1280x720' | '1920x1080'>('1920x1080');
const [exportFps, setExportFps] = useState<24 | 30 | 60>(60); const [exportFps, setExportFps] = useState<24 | 30 | 60>(60);
const [exportFormat, setExportFormat] = useState<'webm' | 'mp4'>('webm');
const [exportQuality, setExportQuality] = useState<'low' | 'medium' | 'high'>('medium'); const [exportQuality, setExportQuality] = useState<'low' | 'medium' | 'high'>('medium');
const [showMicCalibration, setShowMicCalibration] = useState(false); const [showMicCalibration, setShowMicCalibration] = useState(false);
const [micLevel, setMicLevel] = useState(0); const [micLevel, setMicLevel] = useState(0);
@ -33,6 +40,10 @@ export function Oscilloscope() {
const [micGainNode, setMicGainNode] = useState<GainNode | null>(null); const [micGainNode, setMicGainNode] = useState<GainNode | null>(null);
const isMicActiveRef = useRef(false); 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 { audioData, isLoading, fileName, originalFile, loadAudioFile, reset: resetAudio } = useAudioAnalyzer();
const { isExporting, progress, exportedUrl, exportVideo, reset: resetExport } = useVideoExporter(); const { isExporting, progress, exportedUrl, exportVideo, reset: resetExport } = useVideoExporter();
const { playSound } = useSettings(); const { playSound } = useSettings();
@ -48,6 +59,70 @@ export function Oscilloscope() {
const [micStream, setMicStream] = useState<MediaStream | null>(null); const [micStream, setMicStream] = useState<MediaStream | null>(null);
const [micAnalyzer, setMicAnalyzer] = useState<AnalyserNode | 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) => { const handleFileSelect = useCallback((file: File) => {
loadAudioFile(file); loadAudioFile(file);
if (isMicActive) { if (isMicActive) {
@ -118,9 +193,10 @@ export function Oscilloscope() {
}, [isPlaying, playSound]); }, [isPlaying, playSound]);
const handleSeek = useCallback((position: number) => { const handleSeek = useCallback((position: number) => {
if (audioData && position >= 0) { if (audioData && audioRef.current && position >= 0) {
const newSample = Math.floor(position * audioData.leftChannel.length); const newTime = position * audioData.duration;
setCurrentSample(newSample); audioRef.current.currentTime = newTime;
setCurrentTime(newTime);
setSeekPosition(position); setSeekPosition(position);
} }
}, [audioData]); }, [audioData]);
@ -141,10 +217,6 @@ export function Oscilloscope() {
setExportFps(fps as 24 | 30 | 60); setExportFps(fps as 24 | 30 | 60);
}, []); }, []);
const handleExportFormatChange = useCallback((format: string) => {
setExportFormat(format as 'webm' | 'mp4');
}, []);
const handleExportQualityChange = useCallback((quality: string) => { const handleExportQualityChange = useCallback((quality: string) => {
setExportQuality(quality as 'low' | 'medium' | 'high'); setExportQuality(quality as 'low' | 'medium' | 'high');
}, []); }, []);
@ -160,10 +232,9 @@ export function Oscilloscope() {
fps: exportFps, fps: exportFps,
mode, mode,
audioFile: originalFile, audioFile: originalFile,
format: exportFormat,
quality: exportQuality, quality: exportQuality,
}); });
}, [audioData, originalFile, exportVideo, mode, exportResolution, exportFps, exportFormat, exportQuality]); }, [audioData, originalFile, exportVideo, mode, exportResolution, exportFps, exportQuality]);
const handleReset = useCallback(() => { const handleReset = useCallback(() => {
playSound('click'); playSound('click');
@ -231,7 +302,7 @@ export function Oscilloscope() {
</div> </div>
{/* Right Column: Control Panel */} {/* Right Column: Control Panel */}
<ControlPanel <OscilloscopeControls
mode={mode} mode={mode}
onModeChange={setMode} onModeChange={setMode}
canGenerate={canGenerate} canGenerate={canGenerate}
@ -251,8 +322,6 @@ export function Oscilloscope() {
onExportResolutionChange={handleExportResolutionChange} onExportResolutionChange={handleExportResolutionChange}
exportFps={exportFps} exportFps={exportFps}
onExportFpsChange={handleExportFpsChange} onExportFpsChange={handleExportFpsChange}
exportFormat={exportFormat}
onExportFormatChange={handleExportFormatChange}
exportQuality={exportQuality} exportQuality={exportQuality}
onExportQualityChange={handleExportQualityChange} onExportQualityChange={handleExportQualityChange}
liveSettings={liveSettings} liveSettings={liveSettings}
@ -261,7 +330,7 @@ export function Oscilloscope() {
</div> </div>
{/* Oscilloscope Display */} {/* Oscilloscope Display */}
<div className="flex justify-center"> <div className="flex justify-center flex-col items-center gap-4">
<OscilloscopeDisplay <OscilloscopeDisplay
audioData={audioData} audioData={audioData}
micAnalyzer={micAnalyzer} micAnalyzer={micAnalyzer}
@ -270,10 +339,47 @@ export function Oscilloscope() {
playbackSpeed={playbackSpeed} playbackSpeed={playbackSpeed}
isLooping={isLooping} isLooping={isLooping}
seekPosition={seekPosition} seekPosition={seekPosition}
onPlaybackEnd={() => setIsPlaying(false)} onPlaybackEnd={() => {
setIsPlaying(false);
setCurrentTime(0);
setSeekPosition(0);
}}
onSeek={handleSeek} onSeek={handleSeek}
liveSettings={liveSettings} liveSettings={liveSettings}
/> />
{/* 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> </div>
{/* Microphone Calibration */} {/* Microphone Calibration */}

View File

@ -32,15 +32,13 @@ interface ControlPanelProps {
onExportResolutionChange: (resolution: string) => void; onExportResolutionChange: (resolution: string) => void;
exportFps: number; exportFps: number;
onExportFpsChange: (fps: number) => void; onExportFpsChange: (fps: number) => void;
exportFormat: string;
onExportFormatChange: (format: string) => void;
exportQuality: string; exportQuality: string;
onExportQualityChange: (quality: string) => void; onExportQualityChange: (quality: string) => void;
liveSettings: LiveDisplaySettings; liveSettings: LiveDisplaySettings;
onLiveSettingsChange: (settings: LiveDisplaySettings) => void; onLiveSettingsChange: (settings: LiveDisplaySettings) => void;
} }
export function ControlPanel({ export function OscilloscopeControls({
mode, mode,
onModeChange, onModeChange,
canGenerate, canGenerate,
@ -60,8 +58,6 @@ export function ControlPanel({
onExportResolutionChange, onExportResolutionChange,
exportFps, exportFps,
onExportFpsChange, onExportFpsChange,
exportFormat,
onExportFormatChange,
exportQuality, exportQuality,
onExportQualityChange, onExportQualityChange,
liveSettings, liveSettings,
@ -251,19 +247,6 @@ export function ControlPanel({
</select> </select>
</div> </div>
{/* Format */}
<div className="flex items-center justify-between">
<span className="font-mono-crt text-sm text-foreground/90">Format</span>
<select
value={exportFormat}
onChange={(e) => onExportFormatChange(e.target.value)}
className="bg-background border border-primary/50 text-primary font-mono-crt text-sm px-2 py-1"
>
<option value="webm">WebM</option>
<option value="mp4">MP4</option>
</select>
</div>
{/* Quality */} {/* Quality */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="font-mono-crt text-sm text-foreground/90">Quality</span> <span className="font-mono-crt text-sm text-foreground/90">Quality</span>
@ -338,8 +321,8 @@ export function ControlPanel({
{/* Info */} {/* Info */}
<div className="text-xs text-muted-foreground font-mono-crt space-y-1 pt-4 border-t border-border"> <div className="text-xs text-muted-foreground font-mono-crt space-y-1 pt-4 border-t border-border">
<p>Output: 1920×1080 WebM</p> <p>Output: WebM (VP9)</p>
<p>Frame Rate: 60 FPS</p> <p>Quality affects video bitrate</p>
<p>Supports files up to 6+ hours</p> <p>Supports files up to 6+ hours</p>
</div> </div>
</div> </div>

View File

@ -2,7 +2,7 @@ import { useEffect, useRef, useCallback } from 'react';
import type { AudioData } from '@/hooks/useAudioAnalyzer'; import type { AudioData } from '@/hooks/useAudioAnalyzer';
import type { OscilloscopeMode } from '@/hooks/useOscilloscopeRenderer'; import type { OscilloscopeMode } from '@/hooks/useOscilloscopeRenderer';
import { useAudioAnalyzer as useSharedAudioAnalyzer } from '@/contexts/AudioAnalyzerContext'; import { useAudioAnalyzer as useSharedAudioAnalyzer } from '@/contexts/AudioAnalyzerContext';
import type { LiveDisplaySettings } from './ControlPanel'; import type { LiveDisplaySettings } from './OscilloscopeControls';
interface OscilloscopeDisplayProps { interface OscilloscopeDisplayProps {
audioData: AudioData | null; audioData: AudioData | null;