From 8ecd8da7123396207c4b13ab5b847dd81e1b89df Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 21 Dec 2025 18:10:00 +0000 Subject: [PATCH] Changes --- src/components/Oscilloscope.tsx | 108 ++++++++++------- src/components/OscilloscopeControls.tsx | 54 ++++++++- src/components/OscilloscopeDisplay.tsx | 152 ++++++++++++++++++------ src/hooks/useOfflineVideoExport.ts | 72 ++++++----- 4 files changed, 264 insertions(+), 122 deletions(-) diff --git a/src/components/Oscilloscope.tsx b/src/components/Oscilloscope.tsx index 2833cc2..447864f 100644 --- a/src/components/Oscilloscope.tsx +++ b/src/components/Oscilloscope.tsx @@ -6,7 +6,7 @@ import { useAudioAnalyzer } from '@/hooks/useAudioAnalyzer'; import { useOscilloscopeRenderer } from '@/hooks/useOscilloscopeRenderer'; import { useVideoExporter } from '@/hooks/useVideoExporter'; import { useSettings } from '@/contexts/SettingsContext'; -import { Mic, MicOff, Pause, Play } from 'lucide-react'; +import { Pause, Play } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Slider } from '@/components/ui/slider'; @@ -24,6 +24,7 @@ export function Oscilloscope() { showGrid: true, glowIntensity: 1, displayMode: 'combined', + visualizationMode: 'waveform', }); const [isMicActive, setIsMicActive] = useState(false); const [isPlaying, setIsPlaying] = useState(false); @@ -339,54 +340,21 @@ export function Oscilloscope() { onExportQualityChange={handleExportQualityChange} liveSettings={liveSettings} onLiveSettingsChange={setLiveSettings} + isMicActive={isMicActive} + onToggleMic={toggleMic} /> + + {/* Audio Uploader - below controls on desktop */} +
+ +
- {/* Audio Input Row - Now at bottom */} -
-
- -
- - {/* Microphone Toggle */} -
- - - {isMicActive && ( -
-
- Real-time input active -
- -
- )} -
-
- - {/* Microphone Calibration */} {showMicCalibration && isMicActive && (
@@ -438,6 +406,56 @@ export function Oscilloscope() {
)} + {showMicCalibration && isMicActive && ( +
+

Microphone Calibration

+ +
+ {/* Level Indicator */} +
+
+ Input Level + {Math.round(micLevel * 100)}% +
+
+
+
+
+ Quiet + 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'} + + Loud +
+
+ + {/* Gain Control */} +
+
+ Gain + {micGain.toFixed(1)}x +
+ setMicGain(Number(e.target.value))} + className="w-full" + /> +
+ +

+ Speak into your microphone and adjust gain until the level shows "Good" (green). + The bar should peak around 50-70% when speaking normally. +

+
+
+ )} {/* Status Info */} {(isLoading || isExporting) && ( diff --git a/src/components/OscilloscopeControls.tsx b/src/components/OscilloscopeControls.tsx index 010ca1f..654f45f 100755 --- a/src/components/OscilloscopeControls.tsx +++ b/src/components/OscilloscopeControls.tsx @@ -1,15 +1,18 @@ -import { Play, Download, RotateCcw } from 'lucide-react'; +import { Play, Download, RotateCcw, Mic, MicOff } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { Progress } from '@/components/ui/progress'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import type { OscilloscopeMode } from '@/hooks/useOscilloscopeRenderer'; +export type VisualizationMode = 'waveform' | 'spectrum' | 'both'; + export interface LiveDisplaySettings { lineThickness: number; showGrid: boolean; glowIntensity: number; displayMode: OscilloscopeMode; + visualizationMode: VisualizationMode; } interface ControlPanelProps { @@ -36,6 +39,8 @@ interface ControlPanelProps { onExportQualityChange: (quality: string) => void; liveSettings: LiveDisplaySettings; onLiveSettingsChange: (settings: LiveDisplaySettings) => void; + isMicActive: boolean; + onToggleMic: () => void; } export function OscilloscopeControls({ @@ -62,6 +67,8 @@ export function OscilloscopeControls({ onExportQualityChange, liveSettings, onLiveSettingsChange, + isMicActive, + onToggleMic, }: ControlPanelProps) { return (
@@ -69,6 +76,26 @@ export function OscilloscopeControls({
+ {/* Visualization Mode */} +
+ Visualization +
+ {(['waveform', 'spectrum', 'both'] as VisualizationMode[]).map((vizMode) => ( + + ))} +
+
+ {/* Display Mode */}
+ {/* Audio Input */} +
+ + + {/* Microphone Toggle */} + + + {isMicActive && ( +

+ Real-time input active +

+ )} +
+ {/* Playback Controls */}
diff --git a/src/components/OscilloscopeDisplay.tsx b/src/components/OscilloscopeDisplay.tsx index df1a111..e20bb67 100755 --- a/src/components/OscilloscopeDisplay.tsx +++ b/src/components/OscilloscopeDisplay.tsx @@ -2,7 +2,7 @@ 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 } from './OscilloscopeControls'; +import type { LiveDisplaySettings, VisualizationMode } from './OscilloscopeControls'; interface OscilloscopeDisplayProps { audioData: AudioData | null; @@ -56,6 +56,7 @@ export function OscilloscopeDisplay({ 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; @@ -80,6 +81,36 @@ export function OscilloscopeDisplay({ 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; @@ -104,20 +135,24 @@ export function OscilloscopeDisplay({ if (activeAnalyzer && !audioData) { // Real-time audio data (mic or music player) const bufferLength = activeAnalyzer.frequencyBinCount; - const dataArray = new Uint8Array(bufferLength); - activeAnalyzer.getByteTimeDomainData(dataArray); + 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 - drawGraticule(ctx); + // Draw graticule first (only for waveform modes) + if (visualizationMode !== 'spectrum') { + drawGraticule(ctx); + } // Convert to Float32Array-like for consistency - const liveData = new Float32Array(dataArray.length); - for (let i = 0; i < dataArray.length; i++) { - liveData[i] = (dataArray[i] - 128) / 128; // Normalize to -1 to 1 + 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 @@ -131,44 +166,85 @@ export function OscilloscopeDisplay({ ctx.strokeStyle = primaryColor; ctx.lineWidth = lineThickness; - // Draw based on live display mode - 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; + // 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); + 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 { - // Combined waveform mode (default) - ctx.beginPath(); - const sliceWidth = WIDTH / liveData.length; - let x = 0; + // 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; i++) { - const v = liveData[i]; - const y = (v * HEIGHT) / 2 + HEIGHT / 2; - - if (i === 0) { - ctx.moveTo(x, y); - } else { - ctx.lineTo(x, y); + 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; - x += sliceWidth; + 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.stroke(); } ctx.shadowBlur = 0; @@ -346,7 +422,7 @@ export function OscilloscopeDisplay({ } animationRef.current = requestAnimationFrame(drawFrame); - }, [audioData, micAnalyzer, liveAnalyzer, mode, drawGraticule, onPlaybackEnd, isPlaying, playbackSpeed, isLooping, lineThickness, glowIntensity, liveDisplayMode, audioElementRef]); + }, [audioData, micAnalyzer, liveAnalyzer, mode, drawGraticule, drawSpectrum, onPlaybackEnd, isPlaying, playbackSpeed, isLooping, lineThickness, glowIntensity, liveDisplayMode, visualizationMode, audioElementRef]); // Initialize canvas useEffect(() => { diff --git a/src/hooks/useOfflineVideoExport.ts b/src/hooks/useOfflineVideoExport.ts index f8f3254..5dde309 100644 --- a/src/hooks/useOfflineVideoExport.ts +++ b/src/hooks/useOfflineVideoExport.ts @@ -61,41 +61,29 @@ export const useOfflineVideoExport = () => { setState(prev => ({ ...prev, stage: 'rendering', progress: 5 })); - // Load intro video + // Load intro video - always use webm console.log('📹 Loading intro video...'); const introVideo = document.createElement('video'); introVideo.muted = true; introVideo.playsInline = true; + introVideo.preload = 'auto'; + introVideo.src = '/intro.webm'; - // Try webm first, fallback to mp4 - let introLoaded = false; let introDuration = 0; - try { - await new Promise((resolve, reject) => { - introVideo.onloadedmetadata = () => { - introDuration = introVideo.duration; - console.log(`✅ Intro video loaded: ${introDuration.toFixed(2)}s`); - introLoaded = true; - resolve(); - }; - introVideo.onerror = () => { - console.warn('⚠️ Could not load intro.webm, trying intro.mp4'); - introVideo.src = '/intro.mp4'; - }; - introVideo.src = '/intro.webm'; - - // Timeout after 5 seconds - setTimeout(() => { - if (!introLoaded) { - console.warn('⚠️ Intro video loading timed out'); - resolve(); - } - }, 5000); - }); - } catch (introError) { - console.warn('⚠️ Could not load intro video:', introError); - } + // Wait for video to be fully loaded + await new Promise((resolve) => { + introVideo.onloadeddata = () => { + introDuration = introVideo.duration; + console.log(`✅ Intro video loaded: ${introDuration.toFixed(2)}s, ${introVideo.videoWidth}x${introVideo.videoHeight}`); + resolve(); + }; + introVideo.onerror = (e) => { + console.error('❌ Failed to load intro video:', e); + resolve(); + }; + introVideo.load(); + }); setState(prev => ({ ...prev, progress: 10 })); @@ -215,7 +203,7 @@ export const useOfflineVideoExport = () => { recorder.start(1000); // 1 second chunks // Calculate total frames including intro - const introFrames = introLoaded ? Math.ceil(introDuration * fps) : 0; + const introFrames = introDuration > 0 ? Math.ceil(introDuration * fps) : 0; const mainFrames = Math.ceil(duration * fps); const fadeFrames = Math.ceil(fps * 0.5); // 0.5 second fade const totalFrames = introFrames + mainFrames; @@ -223,22 +211,31 @@ export const useOfflineVideoExport = () => { console.log(`🎬 Total frames: ${totalFrames} (intro: ${introFrames}, main: ${mainFrames}, fade: ${fadeFrames})`); - // Render intro frames first (if loaded) - if (introLoaded && introFrames > 0) { + // Render intro frames first + if (introFrames > 0) { console.log('📹 Rendering intro frames...'); - introVideo.currentTime = 0; - await introVideo.play(); for (let frameIndex = 0; frameIndex < introFrames; frameIndex++) { if (cancelledRef.current) { - introVideo.pause(); recorder.stop(); setState({ isExporting: false, progress: 0, error: 'Cancelled', stage: 'idle', fps: 0 }); return null; } - // Seek to correct time - introVideo.currentTime = frameIndex / fps; + // Seek to correct time and wait for frame + const targetTime = frameIndex / fps; + introVideo.currentTime = targetTime; + + // Wait for the seek to complete + await new Promise((resolve) => { + const onSeeked = () => { + introVideo.removeEventListener('seeked', onSeeked); + resolve(); + }; + introVideo.addEventListener('seeked', onSeeked); + // Fallback timeout + setTimeout(resolve, 50); + }); // Draw intro video frame scaled to canvas ctx.fillStyle = '#0a0f0a'; @@ -268,7 +265,6 @@ export const useOfflineVideoExport = () => { await new Promise(resolve => setTimeout(resolve, 1000 / fps)); } - introVideo.pause(); console.log('✅ Intro frames complete'); } @@ -338,7 +334,7 @@ export const useOfflineVideoExport = () => { } // Apply fade-in effect from intro (first fadeFrames of main content) - if (introLoaded && frameIndex < fadeFrames) { + if (introDuration > 0 && frameIndex < fadeFrames) { const fadeProgress = frameIndex / fadeFrames; // Draw a semi-transparent black overlay that fades out ctx.fillStyle = `rgba(10, 15, 10, ${1 - fadeProgress})`;