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 */}
+
@@ -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})`;