Before attempting to integrate osciloscope properely. STILL BROKEN NOW

This commit is contained in:
2025-12-20 15:34:07 +01:00
parent 6fa754a1eb
commit 26584ea848
5 changed files with 1420 additions and 0 deletions
+308
View File
@@ -0,0 +1,308 @@
import { useRef, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import { Mic, Radio, Move, Upload, Play, Pause, Square, Music, Video, Download, X } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Progress } from '@/components/ui/progress';
import type { ExportStage } from '@/hooks/useOfflineVideoExport';
interface ControlPanelProps {
mode: 'normal' | 'xy';
onModeChange: (mode: 'normal' | 'xy') => void;
isActive: boolean;
isPlaying: boolean;
source: 'microphone' | 'file' | null;
fileName: string | null;
onStartMicrophone: () => void;
onLoadAudioFile: (file: File) => void;
onTogglePlayPause: () => void;
onStop: () => void;
onGainChange: (value: number) => void;
error: string | null;
isExporting: boolean;
exportProgress: number;
exportStage: ExportStage;
exportFps: number;
onExportVideo: (format: 'webm' | 'mp4') => void;
onCancelExport: () => void;
}
export const ControlPanel = ({
mode,
onModeChange,
isActive,
isPlaying,
source,
fileName,
onStartMicrophone,
onLoadAudioFile,
onTogglePlayPause,
onStop,
onGainChange,
error,
isExporting,
exportProgress,
exportStage,
exportFps,
onExportVideo,
onCancelExport,
}: ControlPanelProps) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [showExportDialog, setShowExportDialog] = useState(false);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
onLoadAudioFile(file);
}
};
const handleExportClick = () => {
if (isExporting) {
onCancelExport();
} else {
setShowExportDialog(true);
}
};
const handleFormatSelect = (format: 'webm' | 'mp4') => {
setShowExportDialog(false);
onExportVideo(format);
};
return (
<>
<div className="flex flex-col gap-5 p-5 bg-bezel rounded-lg border border-border">
{/* Status indicator */}
<div className="flex items-center gap-3">
<div
className={`w-3 h-3 rounded-full transition-all duration-300 ${
isActive
? 'bg-primary shadow-[0_0_10px_hsl(var(--primary))]'
: 'bg-muted-foreground'
}`}
/>
<span className="text-sm text-muted-foreground uppercase tracking-wider">
{isExporting ? 'Exporting' : isActive ? (source === 'microphone' ? 'Mic Active' : 'Playing') : 'Standby'}
</span>
{isExporting && (
<div className="w-2 h-2 rounded-full bg-destructive animate-pulse" />
)}
</div>
{/* Input Source */}
<div className="space-y-2">
<label className="text-xs text-muted-foreground uppercase tracking-wider">
Input Source
</label>
<div className="flex flex-col gap-2">
<Button
variant="oscilloscope"
className={`w-full justify-start ${source === 'microphone' ? 'border-primary shadow-[0_0_15px_hsl(var(--primary)/0.4)]' : ''}`}
onClick={onStartMicrophone}
disabled={isExporting}
>
<Mic className="w-4 h-4" />
Microphone
</Button>
<input
ref={fileInputRef}
type="file"
accept="audio/*"
onChange={handleFileChange}
className="hidden"
/>
<Button
variant="oscilloscope"
className={`w-full justify-start ${source === 'file' ? 'border-primary shadow-[0_0_15px_hsl(var(--primary)/0.4)]' : ''}`}
onClick={() => fileInputRef.current?.click()}
disabled={isExporting}
>
<Upload className="w-4 h-4" />
Load File
</Button>
</div>
</div>
{/* File name display */}
{fileName && (
<div className="flex items-center gap-2 p-2 bg-secondary/50 rounded border border-border/50">
<Music className="w-4 h-4 text-primary shrink-0" />
<span className="text-xs text-foreground truncate">{fileName}</span>
</div>
)}
{/* Playback controls */}
{isActive && !isExporting && (
<div className="flex gap-2">
{source === 'file' && (
<Button
variant="oscilloscope"
size="icon"
onClick={onTogglePlayPause}
>
{isPlaying ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
</Button>
)}
<Button
variant="oscilloscope"
className="flex-1"
onClick={onStop}
>
<Square className="w-4 h-4" />
Stop
</Button>
</div>
)}
{/* Video Export */}
{source === 'file' && (
<div className="space-y-2">
<label className="text-xs text-muted-foreground uppercase tracking-wider">
Video Export
</label>
<Button
variant="oscilloscope"
className={`w-full justify-start ${isExporting ? 'border-destructive shadow-[0_0_15px_hsl(var(--destructive)/0.4)]' : ''}`}
onClick={handleExportClick}
>
{isExporting ? (
<>
<X className="w-4 h-4" />
Cancel Export
</>
) : (
<>
<Video className="w-4 h-4" />
Export Video
</>
)}
</Button>
{isExporting && (
<div className="space-y-2">
<Progress value={exportProgress} className="h-2" />
<p className="text-xs text-muted-foreground/60 text-center">
{exportStage === 'preparing' && 'Preparing audio...'}
{exportStage === 'rendering' && `Rendering: ${exportProgress}% ${exportFps > 0 ? `(${exportFps} fps)` : ''}`}
{exportStage === 'encoding' && 'Encoding final video...'}
{exportStage === 'complete' && 'Finalizing...'}
</p>
</div>
)}
{!isExporting && (
<p className="text-xs text-muted-foreground/60">
Generates video from the entire audio file offline.
</p>
)}
</div>
)}
{/* Sensitivity / Gain control */}
<div className="space-y-3">
<label className="text-xs text-muted-foreground uppercase tracking-wider">
Sensitivity
</label>
<Slider
defaultValue={[3]}
min={0.5}
max={10}
step={0.5}
onValueChange={(value) => onGainChange(value[0])}
className="w-full"
disabled={isExporting}
/>
<p className="text-xs text-muted-foreground/60">
Increase for quiet audio sources
</p>
</div>
{/* Mode selector */}
<div className="space-y-2">
<label className="text-xs text-muted-foreground uppercase tracking-wider">
Display Mode
</label>
<div className="flex gap-2">
<Button
variant="oscilloscope"
className={`flex-1 ${mode === 'normal' ? 'border-primary shadow-[0_0_15px_hsl(var(--primary)/0.4)]' : ''}`}
onClick={() => onModeChange('normal')}
disabled={isExporting}
>
<Radio className="w-4 h-4" />
Normal
</Button>
<Button
variant="oscilloscope"
className={`flex-1 ${mode === 'xy' ? 'border-primary shadow-[0_0_15px_hsl(var(--primary)/0.4)]' : ''}`}
onClick={() => onModeChange('xy')}
disabled={isExporting}
>
<Move className="w-4 h-4" />
X-Y
</Button>
</div>
</div>
{/* Mode description */}
<div className="p-3 bg-secondary/50 rounded border border-border/50">
<p className="text-xs text-muted-foreground leading-relaxed">
{mode === 'normal'
? 'Time-domain waveform display. Shows amplitude over time.'
: 'Lissajous (X-Y) mode. Left channel controls X, Right controls Y. Creates patterns from stereo audio.'}
</p>
</div>
{/* Error display */}
{error && (
<div className="p-3 bg-destructive/10 border border-destructive/50 rounded">
<p className="text-xs text-destructive">{error}</p>
</div>
)}
{/* Info */}
<div className="mt-auto pt-4 border-t border-border/50">
<p className="text-xs text-muted-foreground/60 text-center">
Audio Oscilloscope v1.3
</p>
</div>
</div>
{/* Export Format Dialog */}
<Dialog open={showExportDialog} onOpenChange={setShowExportDialog}>
<DialogContent className="bg-bezel border-border">
<DialogHeader>
<DialogTitle className="text-foreground">Choose Export Format</DialogTitle>
<DialogDescription className="text-muted-foreground">
The video will be generated from the entire audio file. This works offline and supports large files.
</DialogDescription>
</DialogHeader>
<div className="flex gap-3 mt-4">
<Button
variant="oscilloscope"
className="flex-1"
onClick={() => handleFormatSelect('webm')}
>
<Download className="w-4 h-4 mr-2" />
WebM (recommended)
</Button>
<Button
variant="oscilloscope"
className="flex-1"
onClick={() => handleFormatSelect('mp4')}
>
<Download className="w-4 h-4 mr-2" />
MP4
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
};
+119
View File
@@ -0,0 +1,119 @@
import { useState, useRef, useCallback } from 'react';
import { OscilloscopeScreen, OscilloscopeScreenHandle } from './OscilloscopeScreen';
import { ControlPanel } from './ControlPanel';
import { useAudioAnalyzer } from '@/hooks/useAudioAnalyzer';
import { useOfflineVideoExport } from '@/hooks/useOfflineVideoExport';
import { toast } from 'sonner';
export const Oscilloscope = () => {
const [mode, setMode] = useState<'normal' | 'xy'>('normal');
const screenRef = useRef<OscilloscopeScreenHandle>(null);
const audioFileRef = useRef<File | null>(null);
const {
isActive,
isPlaying,
source,
fileName,
error,
startMicrophone,
loadAudioFile,
togglePlayPause,
stop,
setGain,
getTimeDomainData,
getStereoData,
} = useAudioAnalyzer();
const {
isExporting,
progress,
stage,
fps: exportFps,
generateVideoWithAudio,
cancelExport,
downloadBlob,
} = useOfflineVideoExport();
const handleLoadAudioFile = useCallback((file: File) => {
audioFileRef.current = file;
loadAudioFile(file);
}, [loadAudioFile]);
const handleExportVideo = useCallback(async (format: 'webm' | 'mp4') => {
if (!audioFileRef.current) {
toast.error('Please load an audio file first');
return;
}
const drawFrame = screenRef.current?.drawFrameWithData;
if (!drawFrame) {
toast.error('Canvas not ready');
return;
}
toast.info('Starting video export... This may take a while for large files.');
const blob = await generateVideoWithAudio(
audioFileRef.current,
drawFrame,
{
fps: 60,
format,
width: 1920,
height: 1080,
}
);
if (blob) {
const baseName = fileName?.replace(/\.[^/.]+$/, '') || 'oscilloscope';
const extension = format === 'mp4' ? 'mp4' : 'webm';
downloadBlob(blob, `${baseName}.${extension}`);
toast.success('Video exported successfully!');
}
}, [fileName, generateVideoWithAudio, downloadBlob]);
return (
<div className="flex flex-col lg:flex-row gap-6 w-full max-w-7xl mx-auto p-4 lg:p-8">
{/* Main oscilloscope display */}
<div className="flex-1 min-h-[400px] lg:min-h-[600px]">
<div className="h-full bg-bezel p-4 lg:p-6 rounded-xl border border-border box-glow">
{/* Screen bezel */}
<div className="h-full rounded-lg overflow-hidden border-4 border-secondary">
<OscilloscopeScreen
ref={screenRef}
mode={mode}
getTimeDomainData={getTimeDomainData}
getStereoData={getStereoData}
isActive={isActive}
/>
</div>
</div>
</div>
{/* Control panel */}
<div className="w-full lg:w-72 shrink-0">
<ControlPanel
mode={mode}
onModeChange={setMode}
isActive={isActive}
isPlaying={isPlaying}
source={source}
fileName={fileName}
onStartMicrophone={startMicrophone}
onLoadAudioFile={handleLoadAudioFile}
onTogglePlayPause={togglePlayPause}
onStop={stop}
onGainChange={setGain}
error={error}
isExporting={isExporting}
exportProgress={progress}
exportStage={stage}
exportFps={exportFps}
onExportVideo={handleExportVideo}
onCancelExport={cancelExport}
/>
</div>
</div>
);
};
+295
View File
@@ -0,0 +1,295 @@
import { useRef, useEffect, useCallback, forwardRef, useImperativeHandle } from 'react';
interface OscilloscopeScreenProps {
mode: 'normal' | 'xy';
getTimeDomainData: () => Uint8Array | null;
getStereoData: () => { left: Uint8Array; right: Uint8Array } | null;
isActive: boolean;
}
export interface OscilloscopeScreenHandle {
getCanvas: () => HTMLCanvasElement | null;
drawFrameWithData: (ctx: CanvasRenderingContext2D, width: number, height: number, leftData: Uint8Array, rightData: Uint8Array) => void;
}
export const OscilloscopeScreen = forwardRef<OscilloscopeScreenHandle, OscilloscopeScreenProps>(({
mode,
getTimeDomainData,
getStereoData,
isActive,
}, ref) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const animationRef = useRef<number>();
const lastTimeRef = useRef<number>(0);
const targetFPS = 120;
const frameInterval = 1000 / targetFPS;
const drawGrid = useCallback((ctx: CanvasRenderingContext2D, width: number, height: number) => {
ctx.strokeStyle = '#1a3a1a';
ctx.lineWidth = 1;
const vDivisions = 10;
for (let i = 0; i <= vDivisions; i++) {
const x = Math.round((width / vDivisions) * i) + 0.5;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
const hDivisions = 8;
for (let i = 0; i <= hDivisions; i++) {
const y = Math.round((height / hDivisions) * i) + 0.5;
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
ctx.strokeStyle = '#2a5a2a';
ctx.lineWidth = 1;
const centerX = Math.round(width / 2) + 0.5;
const centerY = Math.round(height / 2) + 0.5;
const tickLength = 6;
const tickSpacing = width / 50;
ctx.beginPath();
ctx.moveTo(centerX, 0);
ctx.lineTo(centerX, height);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, centerY);
ctx.lineTo(width, centerY);
ctx.stroke();
ctx.strokeStyle = '#2a5a2a';
for (let i = 0; i < 50; i++) {
const x = Math.round(i * tickSpacing) + 0.5;
const y = Math.round(i * tickSpacing * (height / width)) + 0.5;
ctx.beginPath();
ctx.moveTo(x, centerY - tickLength / 2);
ctx.lineTo(x, centerY + tickLength / 2);
ctx.stroke();
if (y < height) {
ctx.beginPath();
ctx.moveTo(centerX - tickLength / 2, y);
ctx.lineTo(centerX + tickLength / 2, y);
ctx.stroke();
}
}
}, []);
const drawNormalMode = useCallback((ctx: CanvasRenderingContext2D, width: number, height: number, data: Uint8Array) => {
const centerY = height / 2;
const points: { x: number; y: number }[] = [];
const step = Math.max(1, Math.floor(data.length / (width * 2)));
for (let i = 0; i < data.length; i += step) {
const x = (i / data.length) * width;
const normalizedValue = (data[i] - 128) / 128;
const y = centerY - (normalizedValue * (height / 2) * 0.85);
points.push({ x, y });
}
if (points.length < 2) return;
ctx.strokeStyle = 'rgba(0, 255, 0, 0.15)';
ctx.lineWidth = 6;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length - 1; i++) {
const xc = (points[i].x + points[i + 1].x) / 2;
const yc = (points[i].y + points[i + 1].y) / 2;
ctx.quadraticCurveTo(points[i].x, points[i].y, xc, yc);
}
ctx.lineTo(points[points.length - 1].x, points[points.length - 1].y);
ctx.stroke();
ctx.strokeStyle = 'rgba(0, 255, 0, 0.3)';
ctx.lineWidth = 3;
ctx.stroke();
ctx.strokeStyle = '#00ff00';
ctx.lineWidth = 1.5;
ctx.stroke();
}, []);
const drawXYMode = useCallback((ctx: CanvasRenderingContext2D, width: number, height: number, leftData: Uint8Array, rightData: Uint8Array) => {
const centerX = width / 2;
const centerY = height / 2;
const scale = Math.min(width, height) / 2 * 0.85;
const points: { x: number; y: number }[] = [];
const step = Math.max(1, Math.floor(leftData.length / 2048));
for (let i = 0; i < leftData.length; i += step) {
const xNorm = (leftData[i] - 128) / 128;
const yNorm = (rightData[i] - 128) / 128;
const x = centerX + xNorm * scale;
const y = centerY - yNorm * scale;
points.push({ x, y });
}
if (points.length < 2) return;
ctx.strokeStyle = 'rgba(0, 255, 0, 0.15)';
ctx.lineWidth = 6;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length - 1; i++) {
const xc = (points[i].x + points[i + 1].x) / 2;
const yc = (points[i].y + points[i + 1].y) / 2;
ctx.quadraticCurveTo(points[i].x, points[i].y, xc, yc);
}
ctx.lineTo(points[points.length - 1].x, points[points.length - 1].y);
ctx.stroke();
ctx.strokeStyle = 'rgba(0, 255, 0, 0.3)';
ctx.lineWidth = 3;
ctx.stroke();
ctx.strokeStyle = '#00ff00';
ctx.lineWidth = 1.5;
ctx.stroke();
}, []);
const drawIdleWave = useCallback((ctx: CanvasRenderingContext2D, width: number, height: number) => {
const centerY = height / 2;
ctx.strokeStyle = 'rgba(0, 255, 0, 0.15)';
ctx.lineWidth = 6;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(0, centerY);
ctx.lineTo(width, centerY);
ctx.stroke();
ctx.strokeStyle = 'rgba(0, 255, 0, 0.3)';
ctx.lineWidth = 3;
ctx.stroke();
ctx.strokeStyle = '#00ff00';
ctx.lineWidth = 1.5;
ctx.stroke();
}, []);
useImperativeHandle(ref, () => ({
getCanvas: () => canvasRef.current,
drawFrameWithData: (ctx: CanvasRenderingContext2D, width: number, height: number, leftData: Uint8Array, rightData: Uint8Array) => {
ctx.fillStyle = '#0a0f0a';
ctx.fillRect(0, 0, width, height);
drawGrid(ctx, width, height);
if (mode === 'normal') {
drawNormalMode(ctx, width, height, leftData);
} else {
drawXYMode(ctx, width, height, leftData, rightData);
}
},
}), [mode, drawGrid, drawNormalMode, drawXYMode]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d', { alpha: false });
if (!ctx) return;
const render = (currentTime: number) => {
const deltaTime = currentTime - lastTimeRef.current;
if (deltaTime >= frameInterval) {
lastTimeRef.current = currentTime - (deltaTime % frameInterval);
const dpr = window.devicePixelRatio || 1;
const width = canvas.width / dpr;
const height = canvas.height / dpr;
ctx.fillStyle = '#0a0f0a';
ctx.fillRect(0, 0, width, height);
drawGrid(ctx, width, height);
if (isActive) {
if (mode === 'normal') {
const data = getTimeDomainData();
if (data) {
drawNormalMode(ctx, width, height, data);
}
} else {
const stereoData = getStereoData();
if (stereoData) {
drawXYMode(ctx, width, height, stereoData.left, stereoData.right);
}
}
} else {
drawIdleWave(ctx, width, height);
}
}
animationRef.current = requestAnimationFrame(render);
};
animationRef.current = requestAnimationFrame(render);
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, [mode, isActive, getTimeDomainData, getStereoData, drawGrid, drawNormalMode, drawXYMode, drawIdleWave, frameInterval]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const resizeCanvas = () => {
const container = canvas.parentElement;
if (!container) return;
const rect = container.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.scale(dpr, dpr);
}
canvas.style.width = `${rect.width}px`;
canvas.style.height = `${rect.height}px`;
};
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
return () => {
window.removeEventListener('resize', resizeCanvas);
};
}, []);
return (
<div className="relative w-full h-full overflow-hidden rounded-lg" style={{ backgroundColor: '#0a0f0a' }}>
<canvas
ref={canvasRef}
className="w-full h-full"
/>
</div>
);
});