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:
JorySeverijnse 2025-12-21 14:29:17 +01:00
parent cde5f34858
commit 614811167f
4 changed files with 337 additions and 16 deletions

View File

@ -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}

View File

@ -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">

View File

@ -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 */}

View File

@ -8,6 +8,8 @@ interface ExportOptions {
fps: number;
mode: OscilloscopeMode;
audioFile: File;
format?: 'webm' | 'mp4';
quality?: 'low' | 'medium' | 'high';
}
// WebGL shaders