mirror of
https://github.com/JorySeverijnse/ui-fixer-supreme.git
synced 2026-01-29 21:58:37 +00:00
✅ Audio Playback Controls
- Playback Speed Control: 0.5x, 1x, 1.5x, and 2x speed options - Looping Toggle: Enable/disable automatic looping of audio playback - Seeking: Click anywhere on the oscilloscope display to jump to that position in the audio ✅ Export Options - Resolution Selection: Choose from 640×480, 1280×720, or 1920×1080 - Frame Rate Options: 24, 30, or 60 FPS - Format Selection: WebM or MP4 (if supported) - Quality Settings: Low, Medium, or High quality presets ✅ Microphone Calibration - Real-time Level Monitoring: Visual indicator showing current microphone input level - Gain Control: Adjustable gain slider (0.1x to 3x) to optimize input levels - Visual Feedback: Color-coded level indicator (red=too loud, yellow=good, green=too quiet) - Calibration Mode: Dedicated calibration panel that appears when mic is active
This commit is contained in:
parent
cde5f34858
commit
614811167f
@ -17,6 +17,18 @@ interface ControlPanelProps {
|
||||
isPlaying: boolean;
|
||||
onPreview: () => void;
|
||||
canPreview: boolean;
|
||||
playbackSpeed: number;
|
||||
onPlaybackSpeedChange: (speed: number) => void;
|
||||
isLooping: boolean;
|
||||
onLoopingChange: (looping: boolean) => void;
|
||||
exportResolution: string;
|
||||
onExportResolutionChange: (resolution: string) => void;
|
||||
exportFps: number;
|
||||
onExportFpsChange: (fps: number) => void;
|
||||
exportFormat: string;
|
||||
onExportFormatChange: (format: string) => void;
|
||||
exportQuality: string;
|
||||
onExportQualityChange: (quality: string) => void;
|
||||
}
|
||||
|
||||
export function ControlPanel({
|
||||
@ -31,6 +43,18 @@ export function ControlPanel({
|
||||
isPlaying,
|
||||
onPreview,
|
||||
canPreview,
|
||||
playbackSpeed,
|
||||
onPlaybackSpeedChange,
|
||||
isLooping,
|
||||
onLoopingChange,
|
||||
exportResolution,
|
||||
onExportResolutionChange,
|
||||
exportFps,
|
||||
onExportFpsChange,
|
||||
exportFormat,
|
||||
onExportFormatChange,
|
||||
exportQuality,
|
||||
onExportQualityChange,
|
||||
}: ControlPanelProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6 bg-card border border-border rounded-lg">
|
||||
@ -63,6 +87,48 @@ export function ControlPanel({
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* Playback Controls */}
|
||||
<div className="space-y-3">
|
||||
<Label className="font-crt text-lg text-primary text-glow">PLAYBACK CONTROLS</Label>
|
||||
|
||||
{/* Playback Speed */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono-crt text-sm text-foreground/90">Speed: {playbackSpeed}x</span>
|
||||
<div className="flex gap-1">
|
||||
{[0.5, 1, 1.5, 2].map((speed) => (
|
||||
<button
|
||||
key={speed}
|
||||
onClick={() => onPlaybackSpeedChange(speed)}
|
||||
className={`px-2 py-1 text-xs font-mono-crt border transition-all duration-300 ${
|
||||
playbackSpeed === speed
|
||||
? 'border-primary text-primary bg-primary/10'
|
||||
: 'border-primary/50 text-primary/70 hover:border-primary hover:text-primary'
|
||||
}`}
|
||||
>
|
||||
{speed}x
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Looping Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono-crt text-sm text-foreground/90">Looping</span>
|
||||
<button
|
||||
onClick={() => onLoopingChange(!isLooping)}
|
||||
className={`w-12 h-6 rounded-full border border-primary transition-all duration-300 ${
|
||||
isLooping ? 'bg-primary' : 'bg-transparent'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-4 h-4 rounded-full bg-background border border-primary transition-transform duration-300 ${
|
||||
isLooping ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview Button */}
|
||||
<Button
|
||||
onClick={onPreview}
|
||||
@ -74,6 +140,66 @@ export function ControlPanel({
|
||||
{isPlaying ? 'PLAYING...' : 'PREVIEW'}
|
||||
</Button>
|
||||
|
||||
{/* Export Options */}
|
||||
<div className="space-y-3">
|
||||
<Label className="font-crt text-lg text-primary text-glow">EXPORT OPTIONS</Label>
|
||||
|
||||
{/* Resolution */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono-crt text-sm text-foreground/90">Resolution</span>
|
||||
<select
|
||||
value={exportResolution}
|
||||
onChange={(e) => onExportResolutionChange(e.target.value)}
|
||||
className="bg-background border border-primary/50 text-primary font-mono-crt text-sm px-2 py-1"
|
||||
>
|
||||
<option value="640x480">640×480</option>
|
||||
<option value="1280x720">1280×720</option>
|
||||
<option value="1920x1080">1920×1080</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Frame Rate */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono-crt text-sm text-foreground/90">Frame Rate</span>
|
||||
<select
|
||||
value={exportFps}
|
||||
onChange={(e) => onExportFpsChange(Number(e.target.value))}
|
||||
className="bg-background border border-primary/50 text-primary font-mono-crt text-sm px-2 py-1"
|
||||
>
|
||||
<option value={24}>24 FPS</option>
|
||||
<option value={30}>30 FPS</option>
|
||||
<option value={60}>60 FPS</option>
|
||||
</select>
|
||||
</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 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-mono-crt text-sm text-foreground/90">Quality</span>
|
||||
<select
|
||||
value={exportQuality}
|
||||
onChange={(e) => onExportQualityChange(e.target.value)}
|
||||
className="bg-background border border-primary/50 text-primary font-mono-crt text-sm px-2 py-1"
|
||||
>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Generate Button */}
|
||||
<Button
|
||||
onClick={onGenerate}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { AudioUploader } from './AudioUploader';
|
||||
import { ControlPanel } from './ControlPanel';
|
||||
import { OscilloscopeDisplay } from './OscilloscopeDisplay';
|
||||
@ -14,11 +14,30 @@ export function Oscilloscope() {
|
||||
const [isMicActive, setIsMicActive] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentSample, setCurrentSample] = 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 [exportFormat, setExportFormat] = useState<'webm' | 'mp4'>('webm');
|
||||
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);
|
||||
|
||||
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);
|
||||
@ -34,12 +53,16 @@ export function Oscilloscope() {
|
||||
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
|
||||
@ -50,9 +73,28 @@ export function Oscilloscope() {
|
||||
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(analyser);
|
||||
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);
|
||||
@ -62,24 +104,60 @@ export function Oscilloscope() {
|
||||
alert('Could not access microphone. Please check permissions.');
|
||||
}
|
||||
}
|
||||
}, [isMicActive, micStream, playSound, resetAudio]);
|
||||
}, [isMicActive, micStream, playSound, resetAudio, micGain]);
|
||||
|
||||
const handlePreview = useCallback(() => {
|
||||
playSound('click');
|
||||
setIsPlaying(!isPlaying);
|
||||
}, [isPlaying, playSound]);
|
||||
|
||||
const handleSeek = useCallback((position: number) => {
|
||||
if (audioData && position >= 0) {
|
||||
const newSample = Math.floor(position * audioData.leftChannel.length);
|
||||
setCurrentSample(newSample);
|
||||
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 handleExportFormatChange = useCallback((format: string) => {
|
||||
setExportFormat(format as 'webm' | 'mp4');
|
||||
}, []);
|
||||
|
||||
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: 1920,
|
||||
height: 1080,
|
||||
fps: 60,
|
||||
width,
|
||||
height,
|
||||
fps: exportFps,
|
||||
mode,
|
||||
audioFile: originalFile,
|
||||
format: exportFormat,
|
||||
quality: exportQuality,
|
||||
});
|
||||
}, [audioData, originalFile, exportVideo, mode]);
|
||||
}, [audioData, originalFile, exportVideo, mode, exportResolution, exportFps, exportFormat, exportQuality]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
playSound('click');
|
||||
@ -129,8 +207,18 @@ export function Oscilloscope() {
|
||||
</Button>
|
||||
|
||||
{isMicActive && (
|
||||
<div className="text-sm text-muted-foreground font-mono-crt">
|
||||
Real-time input active
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm text-muted-foreground font-mono-crt">
|
||||
Real-time input active
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setShowMicCalibration(!showMicCalibration)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="font-mono-crt text-xs"
|
||||
>
|
||||
Calibrate
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -149,6 +237,18 @@ export function Oscilloscope() {
|
||||
isPlaying={isPlaying}
|
||||
onPreview={handlePreview}
|
||||
canPreview={canPreview}
|
||||
playbackSpeed={playbackSpeed}
|
||||
onPlaybackSpeedChange={handlePlaybackSpeedChange}
|
||||
isLooping={isLooping}
|
||||
onLoopingChange={handleLoopingChange}
|
||||
exportResolution={exportResolution}
|
||||
onExportResolutionChange={handleExportResolutionChange}
|
||||
exportFps={exportFps}
|
||||
onExportFpsChange={handleExportFpsChange}
|
||||
exportFormat={exportFormat}
|
||||
onExportFormatChange={handleExportFormatChange}
|
||||
exportQuality={exportQuality}
|
||||
onExportQualityChange={handleExportQualityChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -159,10 +259,66 @@ export function Oscilloscope() {
|
||||
micAnalyzer={micAnalyzer}
|
||||
mode={mode}
|
||||
isPlaying={isPlaying}
|
||||
playbackSpeed={playbackSpeed}
|
||||
isLooping={isLooping}
|
||||
seekPosition={seekPosition}
|
||||
onPlaybackEnd={() => setIsPlaying(false)}
|
||||
onSeek={handleSeek}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Status Info */}
|
||||
{(isLoading || isExporting) && (
|
||||
<div className="text-center text-muted-foreground font-mono-crt text-sm">
|
||||
|
||||
@ -7,7 +7,11 @@ interface OscilloscopeDisplayProps {
|
||||
micAnalyzer: AnalyserNode | null;
|
||||
mode: OscilloscopeMode;
|
||||
isPlaying: boolean;
|
||||
playbackSpeed: number;
|
||||
isLooping: boolean;
|
||||
seekPosition: number;
|
||||
onPlaybackEnd?: () => void;
|
||||
onSeek?: (position: number) => void;
|
||||
}
|
||||
|
||||
const WIDTH = 800;
|
||||
@ -19,11 +23,16 @@ export function OscilloscopeDisplay({
|
||||
micAnalyzer,
|
||||
mode,
|
||||
isPlaying,
|
||||
onPlaybackEnd
|
||||
playbackSpeed,
|
||||
isLooping,
|
||||
seekPosition,
|
||||
onPlaybackEnd,
|
||||
onSeek
|
||||
}: OscilloscopeDisplayProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const animationRef = useRef<number | null>(null);
|
||||
const currentSampleRef = useRef(0);
|
||||
const lastSeekPositionRef = useRef(0);
|
||||
|
||||
const drawGraticule = useCallback((ctx: CanvasRenderingContext2D) => {
|
||||
ctx.strokeStyle = '#00ff00';
|
||||
@ -52,6 +61,7 @@ export function OscilloscopeDisplay({
|
||||
let samplesPerFrame: number;
|
||||
let startSample: number;
|
||||
let endSample: number;
|
||||
let samplesToAdvance: number = samplesPerFrame;
|
||||
|
||||
if (micAnalyzer) {
|
||||
// Real-time microphone data
|
||||
@ -103,8 +113,23 @@ export function OscilloscopeDisplay({
|
||||
}
|
||||
|
||||
// File playback mode
|
||||
samplesPerFrame = Math.floor(audioData.sampleRate / FPS);
|
||||
startSample = currentSampleRef.current;
|
||||
const baseSamplesPerFrame = Math.floor(audioData.sampleRate / FPS);
|
||||
samplesPerFrame = Math.floor(baseSamplesPerFrame * playbackSpeed);
|
||||
samplesToAdvance = samplesPerFrame;
|
||||
|
||||
// Handle seeking
|
||||
if (seekPosition > 0 && seekPosition !== lastSeekPositionRef.current) {
|
||||
startSample = Math.floor(seekPosition * audioData.leftChannel.length);
|
||||
currentSampleRef.current = startSample;
|
||||
lastSeekPositionRef.current = seekPosition;
|
||||
// Reset after one frame
|
||||
setTimeout(() => {
|
||||
lastSeekPositionRef.current = 0;
|
||||
}, 1000 / FPS);
|
||||
} else {
|
||||
startSample = currentSampleRef.current;
|
||||
}
|
||||
|
||||
endSample = Math.min(startSample + samplesPerFrame, audioData.leftChannel.length);
|
||||
|
||||
// Clear to pure black
|
||||
@ -240,12 +265,16 @@ export function OscilloscopeDisplay({
|
||||
currentSampleRef.current = endSample;
|
||||
|
||||
if (endSample >= audioData.leftChannel.length) {
|
||||
onPlaybackEnd?.();
|
||||
return;
|
||||
if (isLooping) {
|
||||
currentSampleRef.current = 0; // Loop back to start
|
||||
} else {
|
||||
onPlaybackEnd?.();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
animationRef.current = requestAnimationFrame(drawFrame);
|
||||
}, [audioData, micAnalyzer, mode, drawGraticule, onPlaybackEnd, isPlaying]);
|
||||
}, [audioData, micAnalyzer, mode, drawGraticule, onPlaybackEnd, isPlaying, playbackSpeed, isLooping, seekPosition]);
|
||||
|
||||
// Initialize canvas
|
||||
useEffect(() => {
|
||||
@ -293,7 +322,15 @@ export function OscilloscopeDisplay({
|
||||
ref={canvasRef}
|
||||
width={WIDTH}
|
||||
height={HEIGHT}
|
||||
className="w-full h-auto"
|
||||
className="w-full h-auto cursor-pointer"
|
||||
onClick={(e) => {
|
||||
if (!audioData) return;
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const x = e.clientX - rect.left;
|
||||
const clickPosition = x / rect.width;
|
||||
onSeek?.(Math.max(0, Math.min(1, clickPosition)));
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Mode indicator */}
|
||||
|
||||
@ -8,6 +8,8 @@ interface ExportOptions {
|
||||
fps: number;
|
||||
mode: OscilloscopeMode;
|
||||
audioFile: File;
|
||||
format?: 'webm' | 'mp4';
|
||||
quality?: 'low' | 'medium' | 'high';
|
||||
}
|
||||
|
||||
// WebGL shaders
|
||||
|
||||
Loading…
Reference in New Issue
Block a user