diff --git a/components/editor/AudioEditor.tsx b/components/editor/AudioEditor.tsx index 106f6a4..92deed9 100644 --- a/components/editor/AudioEditor.tsx +++ b/components/editor/AudioEditor.tsx @@ -1,152 +1,25 @@ 'use client'; import * as React from 'react'; -import { Music, Loader2 } from 'lucide-react'; -import { Waveform } from './Waveform'; +import { Music } from 'lucide-react'; import { PlaybackControls } from './PlaybackControls'; import { SidePanel } from '@/components/layout/SidePanel'; import { ThemeToggle } from '@/components/layout/ThemeToggle'; import { CommandPalette } from '@/components/ui/CommandPalette'; import type { CommandAction } from '@/components/ui/CommandPalette'; -import { useAudioPlayer } from '@/lib/hooks/useAudioPlayer'; -import { useHistory } from '@/lib/hooks/useHistory'; -import { useEffectChain } from '@/lib/hooks/useEffectChain'; import { useMultiTrack } from '@/lib/hooks/useMultiTrack'; import { useMultiTrackPlayer } from '@/lib/hooks/useMultiTrackPlayer'; +import { useEffectChain } from '@/lib/hooks/useEffectChain'; import { useToast } from '@/components/ui/Toast'; -import { Slider } from '@/components/ui/Slider'; -import { cn } from '@/lib/utils/cn'; -import type { Selection, ClipboardData } from '@/types/selection'; -import { - extractBufferSegment, - deleteBufferSegment, - insertBufferSegment, - trimBuffer, -} from '@/lib/audio/buffer-utils'; -import { - createCutCommand, - createDeleteCommand, - createPasteCommand, - createTrimCommand, -} from '@/lib/history/commands/edit-command'; -import { - createGainCommand, - createNormalizePeakCommand, - createNormalizeRMSCommand, - createFadeInCommand, - createFadeOutCommand, - createReverseCommand, - createLowPassFilterCommand, - createHighPassFilterCommand, - createBandPassFilterCommand, - EffectCommand, -} from '@/lib/history/commands/effect-command'; -import { applyEffectToSelection, applyAsyncEffectToSelection } from '@/lib/audio/effects/selection'; -import { normalizePeak } from '@/lib/audio/effects/normalize'; -import { applyFadeIn, applyFadeOut } from '@/lib/audio/effects/fade'; -import { reverseAudio } from '@/lib/audio/effects/reverse'; -import { applyLowPassFilter, applyHighPassFilter, applyBandPassFilter, applyFilter } from '@/lib/audio/effects/filters'; -import type { FilterType } from '@/lib/audio/effects/filters'; -import { applyCompressor, applyLimiter, applyGate } from '@/lib/audio/effects/dynamics'; -import { applyDelay, applyReverb, applyChorus, applyFlanger, applyPhaser } from '@/lib/audio/effects/time-based'; -import { applyPitchShift, applyTimeStretch, applyDistortion, applyBitcrusher } from '@/lib/audio/effects/advanced'; -import { EffectParameterDialog, type FilterParameters } from '@/components/effects/EffectParameterDialog'; -import { DynamicsParameterDialog, type DynamicsParameters, type DynamicsType } from '@/components/effects/DynamicsParameterDialog'; -import { TimeBasedParameterDialog, type TimeBasedParameters, type TimeBasedType } from '@/components/effects/TimeBasedParameterDialog'; -import { AdvancedParameterDialog, type AdvancedParameters, type AdvancedType } from '@/components/effects/AdvancedParameterDialog'; import { TrackList } from '@/components/tracks/TrackList'; import { ImportTrackDialog } from '@/components/tracks/ImportTrackDialog'; - -const EFFECT_LABELS: Record = { - lowpass: 'Low-Pass Filter', - highpass: 'High-Pass Filter', - bandpass: 'Band-Pass Filter', - notch: 'Notch Filter', - lowshelf: 'Low Shelf Filter', - highshelf: 'High Shelf Filter', - peaking: 'Peaking EQ', - compressor: 'Compressor', - limiter: 'Limiter', - gate: 'Gate/Expander', - delay: 'Delay/Echo', - reverb: 'Reverb', - chorus: 'Chorus', - flanger: 'Flanger', - phaser: 'Phaser', - pitch: 'Pitch Shifter', - timestretch: 'Time Stretch', - distortion: 'Distortion', - bitcrusher: 'Bitcrusher', -}; +import { formatDuration } from '@/lib/audio/decoder'; export function AudioEditor() { - // View mode state - const [viewMode, setViewMode] = React.useState<'waveform' | 'tracks'>('waveform'); const [importDialogOpen, setImportDialogOpen] = React.useState(false); - - // Zoom and scroll state + const [selectedTrackId, setSelectedTrackId] = React.useState(null); const [zoom, setZoom] = React.useState(1); - const [scrollOffset, setScrollOffset] = React.useState(0); - const [amplitudeScale, setAmplitudeScale] = React.useState(1); - // Effect dialog state - const [effectDialogOpen, setEffectDialogOpen] = React.useState(false); - const [effectDialogType, setEffectDialogType] = React.useState<'lowpass' | 'highpass' | 'bandpass' | 'notch' | 'lowshelf' | 'highshelf' | 'peaking'>('lowpass'); - - // Dynamics dialog state - const [dynamicsDialogOpen, setDynamicsDialogOpen] = React.useState(false); - const [dynamicsDialogType, setDynamicsDialogType] = React.useState('compressor'); - - // Time-based dialog state - const [timeBasedDialogOpen, setTimeBasedDialogOpen] = React.useState(false); - const [timeBasedDialogType, setTimeBasedDialogType] = React.useState('delay'); - - // Advanced dialog state - const [advancedDialogOpen, setAdvancedDialogOpen] = React.useState(false); - const [advancedDialogType, setAdvancedDialogType] = React.useState('pitch'); - - // Drag and drop state - const [isDragging, setIsDragging] = React.useState(false); - const fileInputRef = React.useRef(null); - - // Selection state - const [selection, setSelection] = React.useState(null); - const [clipboard, setClipboard] = React.useState(null); - - const { - loadFile, - loadBuffer, - clearFile, - play, - pause, - stop, - seek, - setVolume, - isPlaying, - isPaused, - currentTime, - duration, - volume, - audioBuffer, - fileName, - isLoading, - error, - currentTimeFormatted, - durationFormatted, - } = useAudioPlayer(); - - const { execute, undo, redo, clear: clearHistory, state: historyState } = useHistory(50); - const { - chain: effectChain, - presets: effectPresets, - toggleEffectEnabled, - removeEffect, - reorder: reorderEffects, - clearChain, - savePreset, - loadPresetToChain, - deletePreset, - } = useEffectChain(); const { addToast } = useToast(); // Multi-track hooks @@ -160,78 +33,41 @@ export function AudioEditor() { } = useMultiTrack(); const { - isPlaying: isMultiTrackPlaying, - currentTime: multiTrackCurrentTime, - duration: multiTrackDuration, - play: playMultiTrack, - pause: pauseMultiTrack, - stop: stopMultiTrack, - seek: seekMultiTrack, - togglePlayPause: toggleMultiTrackPlayPause, + isPlaying, + currentTime, + duration, + play, + pause, + stop, + seek, + togglePlayPause, } = useMultiTrackPlayer(tracks); - const handleFileSelect = async (file: File) => { - try { - await loadFile(file); - addToast({ - title: 'File loaded', - description: `Successfully loaded ${file.name}`, - variant: 'success', - duration: 3000, - }); - } catch (err) { - addToast({ - title: 'Error loading file', - description: err instanceof Error ? err.message : 'Unknown error', - variant: 'error', - duration: 5000, - }); - } - }; - - const handleClear = () => { - clearFile(); - setZoom(1); - setScrollOffset(0); - setAmplitudeScale(1); - setSelection(null); - setClipboard(null); - clearHistory(); - addToast({ - title: 'Audio cleared', - description: 'Audio file has been removed', - variant: 'info', - duration: 2000, - }); - }; + // Effect chain (for selected track) + const { + chain: effectChain, + presets: effectPresets, + toggleEffectEnabled, + removeEffect, + reorder: reorderEffects, + clearChain, + savePreset, + loadPresetToChain, + deletePreset, + } = useEffectChain(); // Multi-track handlers - const handleConvertToTrack = () => { - if (!audioBuffer) return; - - const trackName = fileName || 'Audio Track'; - addTrackFromBuffer(audioBuffer, trackName); - setViewMode('tracks'); - addToast({ - title: 'Converted to Track', - description: `"${trackName}" added to tracks`, - variant: 'success', - duration: 2000, - }); - }; - const handleImportTracks = () => { setImportDialogOpen(true); }; const handleImportTrack = (buffer: AudioBuffer, name: string) => { addTrackFromBuffer(buffer, name); - setViewMode('tracks'); }; const handleClearTracks = () => { clearTracks(); - setViewMode('waveform'); + setSelectedTrackId(null); addToast({ title: 'Tracks Cleared', description: 'All tracks have been removed', @@ -240,700 +76,10 @@ export function AudioEditor() { }); }; - // Drag and drop handlers - const handleDragEnter = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragging(true); - }; - - const handleDragLeave = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - // Only set to false if we're leaving the drop zone entirely - if (e.currentTarget === e.target) { - setIsDragging(false); - } - }; - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - }; - - const handleDrop = async (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragging(false); - - const files = Array.from(e.dataTransfer.files); - const audioFile = files.find(file => file.type.startsWith('audio/')); - - if (audioFile) { - await handleFileSelect(audioFile); - } else { - addToast({ - title: 'Invalid file', - description: 'Please drop an audio file', - variant: 'error', - duration: 3000, - }); - } - }; - - const handleDropZoneClick = () => { - fileInputRef.current?.click(); - }; - - const handleFileInputChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) { - handleFileSelect(file); - } - }; - - // Edit operations - const handleCut = () => { - if (!selection || !audioBuffer) return; - - try { - // Copy to clipboard - const clipData = extractBufferSegment(audioBuffer, selection.start, selection.end); - setClipboard({ - buffer: clipData, - start: selection.start, - end: selection.end, - duration: selection.end - selection.start, - }); - - // Create and execute cut command - const command = createCutCommand(audioBuffer, selection, (buffer) => { - loadBuffer(buffer); - }); - execute(command); - - setSelection(null); - addToast({ - title: 'Cut', - description: 'Selection cut to clipboard', - variant: 'success', - duration: 2000, - }); - } catch (error) { - addToast({ - title: 'Error', - description: 'Failed to cut selection', - variant: 'error', - duration: 3000, - }); - } - }; - - const handleCopy = () => { - if (!selection || !audioBuffer) return; - - try { - const clipData = extractBufferSegment(audioBuffer, selection.start, selection.end); - setClipboard({ - buffer: clipData, - start: selection.start, - end: selection.end, - duration: selection.end - selection.start, - }); - - addToast({ - title: 'Copied', - description: 'Selection copied to clipboard', - variant: 'success', - duration: 2000, - }); - } catch (error) { - addToast({ - title: 'Error', - description: 'Failed to copy selection', - variant: 'error', - duration: 3000, - }); - } - }; - - const handlePaste = () => { - if (!clipboard || !audioBuffer) return; - - try { - const insertTime = currentTime; - - // Create and execute paste command - const command = createPasteCommand(audioBuffer, clipboard.buffer, insertTime, (buffer) => { - loadBuffer(buffer); - }); - execute(command); - - addToast({ - title: 'Pasted', - description: 'Clipboard pasted at current position', - variant: 'success', - duration: 2000, - }); - } catch (error) { - addToast({ - title: 'Error', - description: 'Failed to paste clipboard', - variant: 'error', - duration: 3000, - }); - } - }; - - const handleDelete = () => { - if (!selection || !audioBuffer) return; - - try { - // Create and execute delete command - const command = createDeleteCommand(audioBuffer, selection, (buffer) => { - loadBuffer(buffer); - }); - execute(command); - - setSelection(null); - addToast({ - title: 'Deleted', - description: 'Selection deleted', - variant: 'success', - duration: 2000, - }); - } catch (error) { - addToast({ - title: 'Error', - description: 'Failed to delete selection', - variant: 'error', - duration: 3000, - }); - } - }; - - const handleTrim = () => { - if (!selection || !audioBuffer) return; - - try { - // Create and execute trim command - const command = createTrimCommand(audioBuffer, selection, (buffer) => { - loadBuffer(buffer); - }); - execute(command); - - setSelection(null); - addToast({ - title: 'Trimmed', - description: 'Audio trimmed to selection', - variant: 'success', - duration: 2000, - }); - } catch (error) { - addToast({ - title: 'Error', - description: 'Failed to trim audio', - variant: 'error', - duration: 3000, - }); - } - }; - - const handleSelectAll = () => { - if (!audioBuffer) return; - setSelection({ start: 0, end: duration }); - }; - - const handleClearSelection = () => { - setSelection(null); - }; - - // Effect operations - const handleNormalize = () => { - if (!audioBuffer) return; - - try { - // Apply to selection or entire buffer - const modifiedBuffer = applyEffectToSelection( - audioBuffer, - selection, - (buf) => normalizePeak(buf, 1.0) - ); - - const command = new EffectCommand( - audioBuffer, - modifiedBuffer, - (buffer) => loadBuffer(buffer), - selection ? 'Normalize Selection' : 'Normalize' - ); - execute(command); - - addToast({ - title: 'Normalized', - description: selection ? 'Selection normalized to peak' : 'Audio normalized to peak', - variant: 'success', - duration: 2000, - }); - } catch (error) { - addToast({ - title: 'Error', - description: 'Failed to normalize audio', - variant: 'error', - duration: 3000, - }); - } - }; - - const handleFadeIn = () => { - if (!audioBuffer) return; - - if (!selection) { - addToast({ - title: 'No Selection', - description: 'Please select a region to apply fade in', - variant: 'info', - duration: 2000, - }); - return; - } - - try { - const fadeDuration = selection.end - selection.start; - const modifiedBuffer = applyEffectToSelection( - audioBuffer, - selection, - (buf) => applyFadeIn(buf, buf.duration) - ); - - const command = new EffectCommand( - audioBuffer, - modifiedBuffer, - (buffer) => loadBuffer(buffer), - `Fade In (${fadeDuration.toFixed(2)}s)` - ); - execute(command); - - addToast({ - title: 'Fade In', - description: `Applied fade in (${fadeDuration.toFixed(2)}s)`, - variant: 'success', - duration: 2000, - }); - } catch (error) { - addToast({ - title: 'Error', - description: 'Failed to apply fade in', - variant: 'error', - duration: 3000, - }); - } - }; - - const handleFadeOut = () => { - if (!audioBuffer) return; - - if (!selection) { - addToast({ - title: 'No Selection', - description: 'Please select a region to apply fade out', - variant: 'info', - duration: 2000, - }); - return; - } - - try { - const fadeDuration = selection.end - selection.start; - const modifiedBuffer = applyEffectToSelection( - audioBuffer, - selection, - (buf) => applyFadeOut(buf, buf.duration) - ); - - const command = new EffectCommand( - audioBuffer, - modifiedBuffer, - (buffer) => loadBuffer(buffer), - `Fade Out (${fadeDuration.toFixed(2)}s)` - ); - execute(command); - - addToast({ - title: 'Fade Out', - description: `Applied fade out (${fadeDuration.toFixed(2)}s)`, - variant: 'success', - duration: 2000, - }); - } catch (error) { - addToast({ - title: 'Error', - description: 'Failed to apply fade out', - variant: 'error', - duration: 3000, - }); - } - }; - - const handleReverse = () => { - if (!audioBuffer) return; - - try { - const modifiedBuffer = applyEffectToSelection( - audioBuffer, - selection, - (buf) => reverseAudio(buf) - ); - - const command = new EffectCommand( - audioBuffer, - modifiedBuffer, - (buffer) => loadBuffer(buffer), - selection ? 'Reverse Selection' : 'Reverse' - ); - execute(command); - - addToast({ - title: 'Reversed', - description: selection ? 'Selection reversed' : 'Audio reversed', - variant: 'success', - duration: 2000, - }); - } catch (error) { - addToast({ - title: 'Error', - description: 'Failed to reverse audio', - variant: 'error', - duration: 3000, - }); - } - }; - - const handleLowPassFilter = () => { - setEffectDialogType('lowpass'); - setEffectDialogOpen(true); - }; - - const handleHighPassFilter = () => { - setEffectDialogType('highpass'); - setEffectDialogOpen(true); - }; - - const handleBandPassFilter = () => { - setEffectDialogType('bandpass'); - setEffectDialogOpen(true); - }; - - const handleCompressor = () => { - setDynamicsDialogType('compressor'); - setDynamicsDialogOpen(true); - }; - - const handleLimiter = () => { - setDynamicsDialogType('limiter'); - setDynamicsDialogOpen(true); - }; - - const handleGate = () => { - setDynamicsDialogType('gate'); - setDynamicsDialogOpen(true); - }; - - const handleDelay = () => { - setTimeBasedDialogType('delay'); - setTimeBasedDialogOpen(true); - }; - - const handleReverb = () => { - setTimeBasedDialogType('reverb'); - setTimeBasedDialogOpen(true); - }; - - const handleChorus = () => { - setTimeBasedDialogType('chorus'); - setTimeBasedDialogOpen(true); - }; - - const handleFlanger = () => { - setTimeBasedDialogType('flanger'); - setTimeBasedDialogOpen(true); - }; - - const handlePhaser = () => { - setTimeBasedDialogType('phaser'); - setTimeBasedDialogOpen(true); - }; - - const handlePitchShift = () => { - setAdvancedDialogType('pitch'); - setAdvancedDialogOpen(true); - }; - - const handleTimeStretch = () => { - setAdvancedDialogType('timestretch'); - setAdvancedDialogOpen(true); - }; - - const handleDistortion = () => { - setAdvancedDialogType('distortion'); - setAdvancedDialogOpen(true); - }; - - const handleBitcrusher = () => { - setAdvancedDialogType('bitcrusher'); - setAdvancedDialogOpen(true); - }; - - // Handle effect apply from parameter dialog - const handleEffectApply = async (params: FilterParameters) => { - if (!audioBuffer) return; - - try { - const modifiedBuffer = await applyAsyncEffectToSelection( - audioBuffer, - selection, - (buf) => applyFilter(buf, { - type: params.type, - frequency: params.frequency, - Q: params.Q, - gain: params.gain, - }) - ); - - const effectName = EFFECT_LABELS[params.type] || 'Filter'; - const command = new EffectCommand( - audioBuffer, - modifiedBuffer, - (buffer) => loadBuffer(buffer), - selection - ? `${effectName} Selection (${params.frequency.toFixed(0)}Hz)` - : `${effectName} (${params.frequency.toFixed(0)}Hz)` - ); - execute(command); - - addToast({ - title: effectName, - description: selection - ? `Applied ${effectName.toLowerCase()} to selection (${params.frequency.toFixed(0)}Hz)` - : `Applied ${effectName.toLowerCase()} (${params.frequency.toFixed(0)}Hz)`, - variant: 'success', - duration: 2000, - }); - } catch (error) { - addToast({ - title: 'Error', - description: 'Failed to apply effect', - variant: 'error', - duration: 3000, - }); - } - }; - - // Handle dynamics apply from parameter dialog - const handleDynamicsApply = async (params: DynamicsParameters) => { - if (!audioBuffer) return; - - try { - let modifiedBuffer: AudioBuffer; - let effectName: string; - - switch (params.type) { - case 'compressor': - modifiedBuffer = await applyAsyncEffectToSelection( - audioBuffer, - selection, - (buf) => applyCompressor(buf, params) - ); - effectName = 'Compressor'; - break; - case 'limiter': - modifiedBuffer = await applyAsyncEffectToSelection( - audioBuffer, - selection, - (buf) => applyLimiter(buf, params) - ); - effectName = 'Limiter'; - break; - case 'gate': - modifiedBuffer = await applyAsyncEffectToSelection( - audioBuffer, - selection, - (buf) => applyGate(buf, params) - ); - effectName = 'Gate'; - break; - } - - const command = new EffectCommand( - audioBuffer, - modifiedBuffer, - (buffer) => loadBuffer(buffer), - selection - ? `${effectName} Selection (${params.threshold.toFixed(1)}dB)` - : `${effectName} (${params.threshold.toFixed(1)}dB)` - ); - execute(command); - - addToast({ - title: effectName, - description: selection - ? `Applied ${effectName.toLowerCase()} to selection` - : `Applied ${effectName.toLowerCase()}`, - variant: 'success', - duration: 2000, - }); - } catch (error) { - addToast({ - title: 'Error', - description: 'Failed to apply dynamics effect', - variant: 'error', - duration: 3000, - }); - } - }; - - // Handle time-based apply from parameter dialog - const handleTimeBasedApply = async (params: TimeBasedParameters) => { - if (!audioBuffer) return; - - try { - let modifiedBuffer: AudioBuffer; - let effectName: string; - - switch (params.type) { - case 'delay': - modifiedBuffer = await applyAsyncEffectToSelection( - audioBuffer, - selection, - (buf) => applyDelay(buf, params) - ); - effectName = 'Delay'; - break; - case 'reverb': - modifiedBuffer = await applyAsyncEffectToSelection( - audioBuffer, - selection, - (buf) => applyReverb(buf, params) - ); - effectName = 'Reverb'; - break; - case 'chorus': - modifiedBuffer = await applyAsyncEffectToSelection( - audioBuffer, - selection, - (buf) => applyChorus(buf, params) - ); - effectName = 'Chorus'; - break; - case 'flanger': - modifiedBuffer = await applyAsyncEffectToSelection( - audioBuffer, - selection, - (buf) => applyFlanger(buf, params) - ); - effectName = 'Flanger'; - break; - case 'phaser': - modifiedBuffer = await applyAsyncEffectToSelection( - audioBuffer, - selection, - (buf) => applyPhaser(buf, params) - ); - effectName = 'Phaser'; - break; - } - - const command = new EffectCommand( - audioBuffer, - modifiedBuffer, - (buffer) => loadBuffer(buffer), - selection - ? `${effectName} Selection` - : `${effectName}` - ); - execute(command); - - addToast({ - title: effectName, - description: selection - ? `Applied ${effectName.toLowerCase()} to selection` - : `Applied ${effectName.toLowerCase()}`, - variant: 'success', - duration: 2000, - }); - } catch (error) { - addToast({ - title: 'Error', - description: 'Failed to apply time-based effect', - variant: 'error', - duration: 3000, - }); - } - }; - - const handleAdvancedApply = async (params: AdvancedParameters) => { - if (!audioBuffer) return; - - try { - let modifiedBuffer: AudioBuffer; - let effectName: string; - - const effectType = params.type; - - if (effectType === 'pitch') { - modifiedBuffer = await applyAsyncEffectToSelection( - audioBuffer, - selection, - (buf) => applyPitchShift(buf, params as any) - ); - effectName = 'Pitch Shift'; - } else if (effectType === 'timestretch') { - modifiedBuffer = await applyAsyncEffectToSelection( - audioBuffer, - selection, - (buf) => applyTimeStretch(buf, params as any) - ); - effectName = 'Time Stretch'; - } else if (effectType === 'bitcrusher') { - modifiedBuffer = await applyAsyncEffectToSelection( - audioBuffer, - selection, - (buf) => applyBitcrusher(buf, params as any) - ); - effectName = 'Bitcrusher'; - } else { - modifiedBuffer = await applyAsyncEffectToSelection( - audioBuffer, - selection, - (buf) => applyDistortion(buf, params as any) - ); - effectName = 'Distortion'; - } - - const command = new EffectCommand( - audioBuffer, - modifiedBuffer, - (buffer) => loadBuffer(buffer), - selection - ? `${effectName} Selection` - : `${effectName}` - ); - execute(command); - - addToast({ - title: effectName, - description: selection - ? `Applied ${effectName.toLowerCase()} to selection` - : `Applied ${effectName.toLowerCase()}`, - variant: 'success', - duration: 2000, - }); - } catch (error) { - addToast({ - title: 'Error', - description: 'Failed to apply advanced effect', - variant: 'error', - duration: 3000, - }); + const handleRemoveTrack = (trackId: string) => { + removeTrack(trackId); + if (selectedTrackId === trackId) { + setSelectedTrackId(null); } }; @@ -948,119 +94,10 @@ export function AudioEditor() { const handleFitToView = () => { setZoom(1); - setScrollOffset(0); }; - // Auto-adjust scroll when zoom changes - React.useEffect(() => { - if (!audioBuffer) return; - - // Reset scroll if zoomed out completely - if (zoom === 1) { - setScrollOffset(0); - } - }, [zoom, audioBuffer]); - - // Keyboard shortcuts - React.useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - // Prevent shortcuts if typing in an input - const isTyping = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement; - - // Spacebar: Play/Pause (always, unless typing in an input) - if (e.code === 'Space' && !isTyping) { - e.preventDefault(); - if (isPlaying) { - pause(); - } else { - play(); - } - return; - } - - // Prevent other shortcuts if typing in an input - if (isTyping) { - return; - } - - // Ctrl+Z: Undo - if (e.ctrlKey && !e.shiftKey && e.key === 'z') { - e.preventDefault(); - if (undo()) { - addToast({ - title: 'Undo', - description: 'Last action undone', - variant: 'info', - duration: 1500, - }); - } - } - - // Ctrl+Y or Ctrl+Shift+Z: Redo - if ((e.ctrlKey && e.key === 'y') || (e.ctrlKey && e.shiftKey && e.key === 'z')) { - e.preventDefault(); - if (redo()) { - addToast({ - title: 'Redo', - description: 'Last action redone', - variant: 'info', - duration: 1500, - }); - } - } - - // Ctrl+A: Select all - if (e.ctrlKey && e.key === 'a') { - e.preventDefault(); - handleSelectAll(); - } - - // Ctrl+X: Cut - if (e.ctrlKey && e.key === 'x') { - e.preventDefault(); - handleCut(); - } - - // Ctrl+C: Copy - if (e.ctrlKey && e.key === 'c') { - e.preventDefault(); - handleCopy(); - } - - // Ctrl+V: Paste - if (e.ctrlKey && e.key === 'v') { - e.preventDefault(); - handlePaste(); - } - - // Delete: Delete selection - if (e.key === 'Delete') { - e.preventDefault(); - handleDelete(); - } - - // Escape: Clear selection - if (e.key === 'Escape') { - e.preventDefault(); - handleClearSelection(); - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [selection, clipboard, audioBuffer, currentTime, undo, redo, addToast, isPlaying, play, pause]); - - // Show error toast - React.useEffect(() => { - if (error) { - addToast({ - title: 'Error', - description: error, - variant: 'error', - duration: 5000, - }); - } - }, [error, addToast]); + // Find selected track + const selectedTrack = tracks.find((t) => t.id === selectedTrackId); // Command palette actions const commandActions: CommandAction[] = React.useMemo(() => { @@ -1089,246 +126,79 @@ export function AudioEditor() { category: 'playback', action: stop, }, - // Edit - { - id: 'cut', - label: 'Cut', - description: 'Cut selection to clipboard', - shortcut: 'Ctrl+X', - category: 'edit', - action: handleCut, - }, - { - id: 'copy', - label: 'Copy', - description: 'Copy selection to clipboard', - shortcut: 'Ctrl+C', - category: 'edit', - action: handleCopy, - }, - { - id: 'paste', - label: 'Paste', - description: 'Paste clipboard at current position', - shortcut: 'Ctrl+V', - category: 'edit', - action: handlePaste, - }, - { - id: 'delete', - label: 'Delete', - description: 'Delete selection', - shortcut: 'Del', - category: 'edit', - action: handleDelete, - }, - { - id: 'trim', - label: 'Trim to Selection', - description: 'Trim audio to selected region', - category: 'edit', - action: handleTrim, - }, - { - id: 'select-all', - label: 'Select All', - description: 'Select entire audio', - shortcut: 'Ctrl+A', - category: 'edit', - action: handleSelectAll, - }, - { - id: 'clear-selection', - label: 'Clear Selection', - description: 'Clear current selection', - shortcut: 'Esc', - category: 'edit', - action: handleClearSelection, - }, // View { id: 'zoom-in', label: 'Zoom In', - description: 'Zoom in on waveform', + description: 'Zoom in on waveforms', category: 'view', action: handleZoomIn, }, { id: 'zoom-out', label: 'Zoom Out', - description: 'Zoom out on waveform', + description: 'Zoom out on waveforms', category: 'view', action: handleZoomOut, }, { id: 'fit-to-view', label: 'Fit to View', - description: 'Reset zoom to fit entire waveform', + description: 'Reset zoom to fit all tracks', category: 'view', action: handleFitToView, }, - // File + // Tracks { - id: 'clear', - label: 'Clear Audio', - description: 'Remove loaded audio file', - category: 'file', - action: handleClear, - }, - // History - { - id: 'undo', - label: 'Undo', - description: 'Undo last action', - shortcut: 'Ctrl+Z', - category: 'edit', - action: undo, + id: 'add-track', + label: 'Add Empty Track', + description: 'Create a new empty track', + category: 'tracks', + action: () => addTrack(), }, { - id: 'redo', - label: 'Redo', - description: 'Redo last undone action', - shortcut: 'Ctrl+Y', - category: 'edit', - action: redo, - }, - // Effects - { - id: 'normalize', - label: 'Normalize', - description: 'Normalize audio to peak amplitude', - category: 'effects', - action: handleNormalize, + id: 'import-tracks', + label: 'Import Audio Files', + description: 'Import multiple audio files as tracks', + category: 'tracks', + action: handleImportTracks, }, { - id: 'fade-in', - label: 'Fade In', - description: 'Apply fade in to selection', - category: 'effects', - action: handleFadeIn, - }, - { - id: 'fade-out', - label: 'Fade Out', - description: 'Apply fade out to selection', - category: 'effects', - action: handleFadeOut, - }, - { - id: 'reverse', - label: 'Reverse', - description: 'Reverse entire audio', - category: 'effects', - action: handleReverse, - }, - { - id: 'lowpass-filter', - label: 'Low-Pass Filter', - description: 'Remove high frequencies (1000Hz cutoff)', - category: 'effects', - action: handleLowPassFilter, - }, - { - id: 'highpass-filter', - label: 'High-Pass Filter', - description: 'Remove low frequencies (100Hz cutoff)', - category: 'effects', - action: handleHighPassFilter, - }, - { - id: 'bandpass-filter', - label: 'Band-Pass Filter', - description: 'Isolate frequency range (1000Hz center)', - category: 'effects', - action: handleBandPassFilter, - }, - { - id: 'compressor', - label: 'Compressor', - description: 'Reduce dynamic range', - category: 'effects', - action: handleCompressor, - }, - { - id: 'limiter', - label: 'Limiter', - description: 'Prevent audio from exceeding threshold', - category: 'effects', - action: handleLimiter, - }, - { - id: 'gate', - label: 'Gate/Expander', - description: 'Reduce volume of quiet sounds', - category: 'effects', - action: handleGate, - }, - { - id: 'delay', - label: 'Delay/Echo', - description: 'Add echo effects with feedback', - category: 'effects', - action: handleDelay, - }, - { - id: 'reverb', - label: 'Reverb', - description: 'Add acoustic space and ambience', - category: 'effects', - action: handleReverb, - }, - { - id: 'chorus', - label: 'Chorus', - description: 'Thicken sound with modulation', - category: 'effects', - action: handleChorus, - }, - { - id: 'flanger', - label: 'Flanger', - description: 'Create sweeping comb-filter effect', - category: 'effects', - action: handleFlanger, - }, - { - id: 'phaser', - label: 'Phaser', - description: 'Phase-shifting swoosh effect', - category: 'effects', - action: handlePhaser, - }, - { - id: 'pitch', - label: 'Pitch Shifter', - description: 'Change pitch without affecting duration', - category: 'effects', - action: handlePitchShift, - }, - { - id: 'timestretch', - label: 'Time Stretch', - description: 'Change duration without affecting pitch', - category: 'effects', - action: handleTimeStretch, - }, - { - id: 'distortion', - label: 'Distortion', - description: 'Add overdrive and distortion', - category: 'effects', - action: handleDistortion, - }, - { - id: 'bitcrusher', - label: 'Bitcrusher', - description: 'Lo-fi bit depth and sample rate reduction', - category: 'effects', - action: handleBitcrusher, + id: 'clear-tracks', + label: 'Clear All Tracks', + description: 'Remove all tracks', + category: 'tracks', + action: handleClearTracks, }, ]; return actions; - }, [play, pause, stop, handleCut, handleCopy, handlePaste, handleDelete, handleTrim, handleSelectAll, handleClearSelection, handleZoomIn, handleZoomOut, handleFitToView, handleClear, undo, redo, handleNormalize, handleFadeIn, handleFadeOut, handleReverse, handleLowPassFilter, handleHighPassFilter, handleBandPassFilter, handleCompressor, handleLimiter, handleGate, handleDelay, handleReverb, handleChorus, handleFlanger, handlePhaser, handlePitchShift, handleTimeStretch, handleDistortion, handleBitcrusher]); + }, [play, pause, stop, handleZoomIn, handleZoomOut, handleFitToView, handleImportTracks, handleClearTracks, addTrack]); + + // Keyboard shortcuts + React.useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Prevent shortcuts if typing in an input + const isTyping = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement; + + // Spacebar: Play/Pause (always, unless typing in an input) + if (e.code === 'Space' && !isTyping) { + e.preventDefault(); + togglePlayPause(); + return; + } + + if (isTyping) return; + + // Escape: Clear selection + if (e.key === 'Escape') { + e.preventDefault(); + setSelectedTrackId(null); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [togglePlayPause]); return ( <> @@ -1351,12 +221,14 @@ export function AudioEditor() {
{/* Side Panel */} {/* Main canvas area */}
- {isLoading ? ( -
-
- -

Loading audio file...

-
-
- ) : viewMode === 'tracks' ? ( - <> - {/* Multi-Track View */} -
- -
+ {/* Multi-Track View */} +
+ +
- {/* Multi-Track Playback Controls */} -
- {}} - currentTimeFormatted={`${Math.floor(multiTrackCurrentTime / 60)}:${String(Math.floor(multiTrackCurrentTime % 60)).padStart(2, '0')}`} - durationFormatted={`${Math.floor(multiTrackDuration / 60)}:${String(Math.floor(multiTrackDuration % 60)).padStart(2, '0')}`} - /> -
- - ) : audioBuffer ? ( - <> - {/* Waveform - takes maximum space */} -
- - - {/* Horizontal scroll for zoomed waveform */} - {zoom > 1 && ( -
- - -
- )} -
- - {/* Playback Controls - fixed at bottom */} -
- -
- - ) : ( -
- -
-

- {isDragging ? 'Drop audio file here' : 'No audio file loaded'} -

-

- {isDragging - ? 'Release to load the file' - : 'Click here or use the side panel to load an audio file, or drag and drop a file onto this area.'} -

-
-
- )} + {/* Multi-Track Playback Controls */} +
+ {}} + currentTimeFormatted={formatDuration(currentTime)} + durationFormatted={formatDuration(duration)} + /> +
- {/* Effect Parameter Dialog */} - setEffectDialogOpen(false)} - effectType={effectDialogType} - onApply={handleEffectApply} - sampleRate={audioBuffer?.sampleRate} - /> - - {/* Dynamics Parameter Dialog */} - setDynamicsDialogOpen(false)} - effectType={dynamicsDialogType} - onApply={handleDynamicsApply} - /> - - {/* Time-Based Parameter Dialog */} - setTimeBasedDialogOpen(false)} - effectType={timeBasedDialogType} - onApply={handleTimeBasedApply} - /> - - {/* Advanced Parameter Dialog */} - setAdvancedDialogOpen(false)} - effectType={advancedDialogType} - onApply={handleAdvancedApply} - /> - {/* Import Track Dialog */} void; - onClear: () => void; - - // Selection info - selection: Selection | null; - - // History info - historyState: HistoryState; + tracks: Track[]; + selectedTrackId: string | null; + onSelectTrack: (trackId: string | null) => void; + onAddTrack: () => void; + onImportTracks: () => void; + onUpdateTrack: (trackId: string, updates: Partial) => void; + onRemoveTrack: (trackId: string) => void; + onClearTracks: () => void; // Effect chain effectChain: EffectChain; @@ -51,44 +42,18 @@ export interface SidePanelProps { onDeletePreset: (presetId: string) => void; onClearChain: () => void; - // Effects handlers - onNormalize: () => void; - onFadeIn: () => void; - onFadeOut: () => void; - onReverse: () => void; - onLowPassFilter: () => void; - onHighPassFilter: () => void; - onBandPassFilter: () => void; - onCompressor: () => void; - onLimiter: () => void; - onGate: () => void; - onDelay: () => void; - onReverb: () => void; - onChorus: () => void; - onFlanger: () => void; - onPhaser: () => void; - onPitchShift: () => void; - onTimeStretch: () => void; - onDistortion: () => void; - onBitcrusher: () => void; - - // Multi-track - tracks?: Track[]; - onAddTrack?: () => void; - onImportTracks?: () => void; - onConvertToTrack?: () => void; - onClearTracks?: () => void; - className?: string; } export function SidePanel({ - fileName, - audioBuffer, - onFileSelect, - onClear, - selection, - historyState, + tracks, + selectedTrackId, + onSelectTrack, + onAddTrack, + onImportTracks, + onUpdateTrack, + onRemoveTrack, + onClearTracks, effectChain, effectPresets, onToggleEffect, @@ -98,47 +63,13 @@ export function SidePanel({ onLoadPreset, onDeletePreset, onClearChain, - onNormalize, - onFadeIn, - onFadeOut, - onReverse, - onLowPassFilter, - onHighPassFilter, - onBandPassFilter, - onCompressor, - onLimiter, - onGate, - onDelay, - onReverb, - onChorus, - onFlanger, - onPhaser, - onPitchShift, - onTimeStretch, - onDistortion, - onBitcrusher, - tracks, - onAddTrack, - onImportTracks, - onConvertToTrack, - onClearTracks, className, }: SidePanelProps) { const [isCollapsed, setIsCollapsed] = React.useState(false); - const [activeTab, setActiveTab] = React.useState<'file' | 'chain' | 'history' | 'info' | 'effects' | 'tracks'>('file'); + const [activeTab, setActiveTab] = React.useState<'tracks' | 'chain'>('tracks'); const [presetDialogOpen, setPresetDialogOpen] = React.useState(false); - const fileInputRef = React.useRef(null); - const handleFileClick = () => { - fileInputRef.current?.click(); - }; - - const handleFileChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) { - onFileSelect(file); - } - }; + const selectedTrack = tracks.find((t) => t.id === selectedTrackId); if (isCollapsed) { return ( @@ -161,17 +92,17 @@ export function SidePanel({ } return ( -
+
{/* Header */}
- - - -
-
- ) : ( -
- - -
- Or drag and drop an audio file onto the waveform area. -
-
+
+ + +
+ {tracks.length > 0 && ( + )}
+ + {/* Track List */} + {tracks.length > 0 ? ( +
+

+ Tracks ({tracks.length}) +

+
+ {tracks.map((track) => { + const isSelected = selectedTrackId === track.id; + return ( +
onSelectTrack(isSelected ? null : track.id)} + > +
+
+
+ {track.name} +
+ {track.audioBuffer && ( +
+ {formatDuration(track.audioBuffer.duration)} +
+ )} +
+ +
+ + {/* Track Controls - Always visible */} +
+ {/* Volume */} +
+
+ + + {Math.round(track.volume * 100)}% + +
+ onUpdateTrack(track.id, { volume: value })} + min={0} + max={2} + step={0.01} + /> +
+ + {/* Pan */} +
+
+ + + {track.pan === 0 + ? 'C' + : track.pan < 0 + ? `L${Math.round(Math.abs(track.pan) * 100)}` + : `R${Math.round(track.pan * 100)}`} + +
+ onUpdateTrack(track.id, { pan: value })} + min={-1} + max={1} + step={0.01} + /> +
+ + {/* Solo / Mute */} +
+ + +
+
+
+ ); + })} +
+
+ ) : ( +
+ +

+ No tracks yet. Add or import audio files to get started. +

+
+ )} )} @@ -290,6 +299,9 @@ export function SidePanel({

Effect Chain + {selectedTrack && ( + ({selectedTrack.name}) + )}

- - setPresetDialogOpen(false)} - currentChain={effectChain} - presets={effectPresets} - onSavePreset={onSavePreset} - onLoadPreset={onLoadPreset} - onDeletePreset={onDeletePreset} - onExportPreset={() => {}} - onImportPreset={(preset) => onSavePreset(preset)} - /> -
- )} - {activeTab === 'history' && ( -
-

- Edit History -

- {historyState.historySize > 0 ? ( -
-
-
- {historyState.historySize} action{historyState.historySize !== 1 ? 's' : ''} -
- {historyState.undoDescription && ( -
- Next undo: {historyState.undoDescription} -
- )} - {historyState.redoDescription && ( -
- Next redo: {historyState.redoDescription} -
- )} -
+ {!selectedTrack ? ( +
+ +

+ Select a track to apply effects +

) : ( -
- No history available. Edit operations will appear here. -
+ <> + + setPresetDialogOpen(false)} + currentChain={effectChain} + presets={effectPresets} + onSavePreset={onSavePreset} + onLoadPreset={onLoadPreset} + onDeletePreset={onDeletePreset} + onExportPreset={() => {}} + onImportPreset={(preset) => onSavePreset(preset)} + /> + )}
)} - - {activeTab === 'info' && ( -
-

- Selection Info -

- {selection ? ( -
-
Selection Active
-
- Duration: {formatDuration(selection.end - selection.start)} -
-
- Start: {formatDuration(selection.start)} -
-
- End: {formatDuration(selection.end)} -
-
- ) : ( -
- No selection. Drag on the waveform to select a region. -
- )} -
- )} - - {activeTab === 'effects' && ( -
-
-

- Basic Effects -

- {audioBuffer ? ( -
- - - - -
- ) : ( -
- Load an audio file to apply effects. -
- )} -
- -
-

- Filters -

- {audioBuffer ? ( -
- - - -
- ) : ( -
- Load an audio file to apply filters. -
- )} -
- -
-

- Dynamics Processing -

- {audioBuffer ? ( -
- - - -
- ) : ( -
- Load an audio file to apply dynamics processing. -
- )} -
- -
-

- Time-Based Effects -

- {audioBuffer ? ( -
- - - - - -
- ) : ( -
- Load an audio file to apply time-based effects. -
- )} -
- -
-

- Advanced Effects -

- {audioBuffer ? ( -
- - - - -
- ) : ( -
- Load an audio file to apply advanced effects. -
- )} -
-
- )} - - {activeTab === 'tracks' && ( -
-
-

- Multi-Track Mode -

- {tracks && tracks.length > 0 ? ( -
-
-
- {tracks.length} {tracks.length === 1 ? 'track' : 'tracks'} active -
-
- Switch to Tracks view to manage -
-
- {onClearTracks && ( - - )} -
- ) : ( -
-
- Add tracks to work with multiple audio files simultaneously. -
- {audioBuffer && onConvertToTrack && ( - - )} - {onAddTrack && ( - - )} - {onImportTracks && ( - - )} -
- )} -
-
- )}
); diff --git a/components/tracks/Track.tsx b/components/tracks/Track.tsx index cfff829..4071103 100644 --- a/components/tracks/Track.tsx +++ b/components/tracks/Track.tsx @@ -10,6 +10,8 @@ export interface TrackProps { zoom: number; currentTime: number; duration: number; + isSelected?: boolean; + onSelect?: () => void; onToggleMute: () => void; onToggleSolo: () => void; onToggleCollapse: () => void; @@ -25,6 +27,8 @@ export function Track({ zoom, currentTime, duration, + isSelected, + onSelect, onToggleMute, onToggleSolo, onToggleCollapse, @@ -121,7 +125,13 @@ export function Track({ if (track.collapsed) { return ( -
+
void; onAddTrack: () => void; onImportTrack?: (buffer: AudioBuffer, name: string) => void; onRemoveTrack: (trackId: string) => void; @@ -24,6 +26,8 @@ export function TrackList({ zoom, currentTime, duration, + selectedTrackId, + onSelectTrack, onAddTrack, onImportTrack, onRemoveTrack, @@ -78,6 +82,8 @@ export function TrackList({ zoom={zoom} currentTime={currentTime} duration={duration} + isSelected={selectedTrackId === track.id} + onSelect={onSelectTrack ? () => onSelectTrack(track.id) : undefined} onToggleMute={() => onUpdateTrack(track.id, { mute: !track.mute }) }