diff --git a/PLAN.md b/PLAN.md index 490b1a0..60eec8b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -2,7 +2,7 @@ ## Progress Overview -**Current Status**: Phase 6.5 Complete ✓ (Basic Effects + Filters + Dynamics + Time-Based + Advanced Effects) +**Current Status**: Phase 6.6 Complete ✓ (Full Audio Effects Suite + Effect Chain Management) ### Completed Phases - ✅ **Phase 1**: Project Setup & Core Infrastructure (95% complete) @@ -64,17 +64,26 @@ - ✅ Effects accessible via command palette and side panel - ✅ Parameterized effects with real-time visual feedback +**Effect Chain Management:** +- ✅ Effect rack with drag-and-drop reordering +- ✅ Enable/disable individual effects (bypass) +- ✅ Save and load effect chain presets +- ✅ Import/export presets as JSON files +- ✅ Chain tab in side panel +- ✅ localStorage persistence for chains and presets +- ✅ Visual effect rack with status indicators + **Professional UI:** - ✅ Command Palette (Ctrl+K) with searchable actions - ✅ Compact header (Logo + Command Palette + Theme Toggle) -- ✅ Collapsible side panel with tabs (File, History, Info) +- ✅ Collapsible side panel with tabs (File, Chain, Effects, History, Info) - ✅ Full-screen waveform canvas layout - ✅ Integrated playback controls at bottom - ✅ Keyboard-driven workflow ### Next Steps -- **Phase 6**: Audio effects (Section 6.1 ✓ + Section 6.2 filters ✓ + Section 6.3 dynamics ✓ + Section 6.4 time-based ✓ + Section 6.5 advanced ✓) -- **Phase 7**: Multi-track editing +- **Phase 6**: Audio effects ✅ COMPLETE (Basic + Filters + Dynamics + Time-Based + Advanced + Chain Management) +- **Phase 7**: Multi-track editing (NEXT) - **Phase 8**: Recording functionality --- @@ -502,12 +511,16 @@ audio-ui/ - [x] 4 presets per effect type - [x] Undo/redo support for all advanced effects -#### 6.6 Effect Management -- [ ] Effect rack/chain -- [ ] Effect presets -- [ ] Effect bypass toggle -- [ ] Wet/Dry mix control -- [ ] Effect reordering +#### 6.6 Effect Management ✓ +- [x] Effect rack/chain (EffectRack component with drag-and-drop) +- [x] Effect presets (save/load/import/export presets) +- [x] Effect bypass toggle (enable/disable individual effects) +- [x] Effect chain state management (useEffectChain hook) +- [x] Effect reordering (drag-and-drop within chain) +- [x] Chain tab in SidePanel with preset manager +- [x] localStorage persistence for chains and presets +- [ ] Wet/Dry mix control (per-effect) - FUTURE +- [ ] Real-time effect preview - FUTURE ### Phase 7: Multi-Track Support diff --git a/components/editor/AudioEditor.tsx b/components/editor/AudioEditor.tsx index 909635b..d6caff5 100644 --- a/components/editor/AudioEditor.tsx +++ b/components/editor/AudioEditor.tsx @@ -10,6 +10,7 @@ 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 { useToast } from '@/components/ui/Toast'; import { Slider } from '@/components/ui/Slider'; import { cn } from '@/lib/utils/cn'; @@ -127,6 +128,17 @@ export function AudioEditor() { } = 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(); const handleFileSelect = async (file: File) => { @@ -1281,6 +1293,15 @@ export function AudioEditor() { onClear={handleClear} selection={selection} historyState={historyState} + effectChain={effectChain} + effectPresets={effectPresets} + onToggleEffect={toggleEffectEnabled} + onRemoveEffect={removeEffect} + onReorderEffects={reorderEffects} + onSavePreset={savePreset} + onLoadPreset={loadPresetToChain} + onDeletePreset={deletePreset} + onClearChain={clearChain} onNormalize={handleNormalize} onFadeIn={handleFadeIn} onFadeOut={handleFadeOut} diff --git a/components/effects/EffectRack.tsx b/components/effects/EffectRack.tsx new file mode 100644 index 0000000..6d01934 --- /dev/null +++ b/components/effects/EffectRack.tsx @@ -0,0 +1,144 @@ +'use client'; + +import * as React from 'react'; +import { GripVertical, Power, PowerOff, Trash2, Settings } from 'lucide-react'; +import { Button } from '@/components/ui/Button'; +import { cn } from '@/lib/utils/cn'; +import type { ChainEffect, EffectChain } from '@/lib/audio/effects/chain'; +import { EFFECT_NAMES } from '@/lib/audio/effects/chain'; + +export interface EffectRackProps { + chain: EffectChain; + onToggleEffect: (effectId: string) => void; + onRemoveEffect: (effectId: string) => void; + onReorderEffects: (fromIndex: number, toIndex: number) => void; + onEditEffect?: (effect: ChainEffect) => void; + className?: string; +} + +export function EffectRack({ + chain, + onToggleEffect, + onRemoveEffect, + onReorderEffects, + onEditEffect, + className, +}: EffectRackProps) { + const [draggedIndex, setDraggedIndex] = React.useState(null); + const [dragOverIndex, setDragOverIndex] = React.useState(null); + + const handleDragStart = (e: React.DragEvent, index: number) => { + setDraggedIndex(index); + e.dataTransfer.effectAllowed = 'move'; + }; + + const handleDragOver = (e: React.DragEvent, index: number) => { + e.preventDefault(); + if (draggedIndex === null || draggedIndex === index) return; + setDragOverIndex(index); + }; + + const handleDrop = (e: React.DragEvent, index: number) => { + e.preventDefault(); + if (draggedIndex === null || draggedIndex === index) return; + + onReorderEffects(draggedIndex, index); + setDraggedIndex(null); + setDragOverIndex(null); + }; + + const handleDragEnd = () => { + setDraggedIndex(null); + setDragOverIndex(null); + }; + + if (chain.effects.length === 0) { + return ( +
+

+ No effects in chain. Add effects from the side panel to get started. +

+
+ ); + } + + return ( +
+ {chain.effects.map((effect, index) => ( +
handleDragStart(e, index)} + onDragOver={(e) => handleDragOver(e, index)} + onDrop={(e) => handleDrop(e, index)} + onDragEnd={handleDragEnd} + className={cn( + 'flex items-center gap-2 p-3 rounded-lg border transition-all', + effect.enabled + ? 'bg-card border-border' + : 'bg-muted/50 border-border/50 opacity-60', + draggedIndex === index && 'opacity-50', + dragOverIndex === index && 'border-primary' + )} + > + {/* Drag Handle */} +
+ +
+ + {/* Effect Info */} +
+
+ {effect.name} +
+
+ {EFFECT_NAMES[effect.type]} +
+
+ + {/* Actions */} +
+ {/* Edit Button (if edit handler provided) */} + {onEditEffect && ( + + )} + + {/* Toggle Enable/Disable */} + + + {/* Remove */} + +
+
+ ))} +
+ ); +} diff --git a/components/effects/PresetManager.tsx b/components/effects/PresetManager.tsx new file mode 100644 index 0000000..a518178 --- /dev/null +++ b/components/effects/PresetManager.tsx @@ -0,0 +1,250 @@ +'use client'; + +import * as React from 'react'; +import { Save, FolderOpen, Trash2, Download, Upload } from 'lucide-react'; +import { Button } from '@/components/ui/Button'; +import { Modal } from '@/components/ui/Modal'; +import type { EffectChain, EffectPreset } from '@/lib/audio/effects/chain'; +import { createPreset, loadPreset } from '@/lib/audio/effects/chain'; + +export interface PresetManagerProps { + open: boolean; + onClose: () => void; + currentChain: EffectChain; + presets: EffectPreset[]; + onSavePreset: (preset: EffectPreset) => void; + onLoadPreset: (preset: EffectPreset) => void; + onDeletePreset: (presetId: string) => void; + onExportPreset?: (preset: EffectPreset) => void; + onImportPreset?: (preset: EffectPreset) => void; +} + +export function PresetManager({ + open, + onClose, + currentChain, + presets, + onSavePreset, + onLoadPreset, + onDeletePreset, + onExportPreset, + onImportPreset, +}: PresetManagerProps) { + const [presetName, setPresetName] = React.useState(''); + const [presetDescription, setPresetDescription] = React.useState(''); + const [mode, setMode] = React.useState<'list' | 'create'>('list'); + const fileInputRef = React.useRef(null); + + const handleSave = () => { + if (!presetName.trim()) return; + + const preset = createPreset(currentChain, presetName.trim(), presetDescription.trim()); + onSavePreset(preset); + setPresetName(''); + setPresetDescription(''); + setMode('list'); + }; + + const handleLoad = (preset: EffectPreset) => { + onLoadPreset(preset); + onClose(); + }; + + const handleExport = (preset: EffectPreset) => { + if (!onExportPreset) return; + + const data = JSON.stringify(preset, null, 2); + const blob = new Blob([data], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${preset.name.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + onExportPreset(preset); + }; + + const handleImportClick = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file || !onImportPreset) return; + + try { + const text = await file.text(); + const preset: EffectPreset = JSON.parse(text); + + // Validate preset structure + if (!preset.id || !preset.name || !preset.chain) { + throw new Error('Invalid preset format'); + } + + onImportPreset(preset); + } catch (error) { + console.error('Failed to import preset:', error); + alert('Failed to import preset. Please check the file format.'); + } + + // Reset input + e.target.value = ''; + }; + + return ( + +
+ {/* Mode Toggle */} +
+ + + {onImportPreset && ( + <> + + + + )} +
+ + {/* Create Mode */} + {mode === 'create' && ( +
+
+ + setPresetName(e.target.value)} + placeholder="My Awesome Preset" + className="w-full px-3 py-2 bg-background border border-border rounded-md text-sm" + /> +
+
+ +