feat: add Phase 6.6 Effect Chain Management system
Effect Chain System: - Create comprehensive effect chain types and state management - Implement EffectRack component with drag-and-drop reordering - Add enable/disable toggle for individual effects - Build PresetManager for save/load/import/export functionality - Create useEffectChain hook with localStorage persistence UI Integration: - Add Chain tab to SidePanel with effect rack - Integrate preset manager dialog - Add chain management controls (clear, presets) - Update SidePanel with chain props and handlers Features: - Drag-and-drop effect reordering with visual feedback - Effect bypass/enable toggle with power icons - Save effect chains as presets with descriptions - Import/export presets as JSON files - localStorage persistence for chains and presets - Visual status indicators for enabled/disabled effects - Preset timestamp and effect count display Components Created: - /lib/audio/effects/chain.ts - Effect chain types and utilities - /components/effects/EffectRack.tsx - Visual effect chain component - /components/effects/PresetManager.tsx - Preset management dialog - /lib/hooks/useEffectChain.ts - Effect chain state hook Updated PLAN.md: - Mark Phase 6.6 as complete - Update current status to Phase 6.6 Complete - Add effect chain features to working features list - Update Next Steps to show Phase 6 complete 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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}
|
||||
|
||||
144
components/effects/EffectRack.tsx
Normal file
144
components/effects/EffectRack.tsx
Normal file
@@ -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<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = React.useState<number | null>(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 (
|
||||
<div className={cn('p-4 text-center', className)}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No effects in chain. Add effects from the side panel to get started.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
{chain.effects.map((effect, index) => (
|
||||
<div
|
||||
key={effect.id}
|
||||
draggable
|
||||
onDragStart={(e) => 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 */}
|
||||
<div
|
||||
className="cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground"
|
||||
title="Drag to reorder"
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</div>
|
||||
|
||||
{/* Effect Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-foreground truncate">
|
||||
{effect.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{EFFECT_NAMES[effect.type]}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Edit Button (if edit handler provided) */}
|
||||
{onEditEffect && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => onEditEffect(effect)}
|
||||
title="Edit parameters"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Toggle Enable/Disable */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => onToggleEffect(effect.id)}
|
||||
title={effect.enabled ? 'Disable effect' : 'Enable effect'}
|
||||
>
|
||||
{effect.enabled ? (
|
||||
<Power className="h-4 w-4 text-success" />
|
||||
) : (
|
||||
<PowerOff className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Remove */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => onRemoveEffect(effect.id)}
|
||||
title="Remove effect"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
250
components/effects/PresetManager.tsx
Normal file
250
components/effects/PresetManager.tsx
Normal file
@@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title="Effect Presets"
|
||||
className="max-w-2xl"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Mode Toggle */}
|
||||
<div className="flex items-center gap-2 border-b border-border pb-3">
|
||||
<Button
|
||||
variant={mode === 'list' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setMode('list')}
|
||||
>
|
||||
<FolderOpen className="h-4 w-4 mr-2" />
|
||||
Load Preset
|
||||
</Button>
|
||||
<Button
|
||||
variant={mode === 'create' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setMode('create')}
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Save Current
|
||||
</Button>
|
||||
{onImportPreset && (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={handleImportClick}>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Import
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Mode */}
|
||||
{mode === 'create' && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
Preset Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={presetName}
|
||||
onChange={(e) => setPresetName(e.target.value)}
|
||||
placeholder="My Awesome Preset"
|
||||
className="w-full px-3 py-2 bg-background border border-border rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
Description (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={presetDescription}
|
||||
onChange={(e) => setPresetDescription(e.target.value)}
|
||||
placeholder="What does this preset do?"
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 bg-background border border-border rounded-md text-sm resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Current chain has {currentChain.effects.length} effect
|
||||
{currentChain.effects.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => setMode('list')}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!presetName.trim()}
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Save Preset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* List Mode */}
|
||||
{mode === 'list' && (
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto custom-scrollbar">
|
||||
{presets.length === 0 ? (
|
||||
<div className="text-center py-8 text-sm text-muted-foreground">
|
||||
No presets saved yet. Create one to get started!
|
||||
</div>
|
||||
) : (
|
||||
presets.map((preset) => (
|
||||
<div
|
||||
key={preset.id}
|
||||
className="flex items-start gap-3 p-3 rounded-lg border border-border bg-card hover:bg-accent/50 transition-colors"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{preset.name}
|
||||
</div>
|
||||
{preset.description && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{preset.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{preset.chain.effects.length} effect
|
||||
{preset.chain.effects.length !== 1 ? 's' : ''} •{' '}
|
||||
{new Date(preset.createdAt).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => handleLoad(preset)}
|
||||
title="Load preset"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
{onExportPreset && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => handleExport(preset)}
|
||||
title="Export preset"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => onDeletePreset(preset.id)}
|
||||
title="Delete preset"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -11,12 +11,18 @@ import {
|
||||
Download,
|
||||
X,
|
||||
Sparkles,
|
||||
Link2,
|
||||
FolderOpen,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { formatDuration } from '@/lib/audio/decoder';
|
||||
import type { Selection } from '@/types/selection';
|
||||
import type { HistoryState } from '@/lib/history/history-manager';
|
||||
import type { EffectChain, ChainEffect, EffectPreset } from '@/lib/audio/effects/chain';
|
||||
import { EffectRack } from '@/components/effects/EffectRack';
|
||||
import { PresetManager } from '@/components/effects/PresetManager';
|
||||
|
||||
export interface SidePanelProps {
|
||||
// File info
|
||||
@@ -31,6 +37,17 @@ export interface SidePanelProps {
|
||||
// History info
|
||||
historyState: HistoryState;
|
||||
|
||||
// Effect chain
|
||||
effectChain: EffectChain;
|
||||
effectPresets: EffectPreset[];
|
||||
onToggleEffect: (effectId: string) => void;
|
||||
onRemoveEffect: (effectId: string) => void;
|
||||
onReorderEffects: (fromIndex: number, toIndex: number) => void;
|
||||
onSavePreset: (preset: EffectPreset) => void;
|
||||
onLoadPreset: (preset: EffectPreset) => void;
|
||||
onDeletePreset: (presetId: string) => void;
|
||||
onClearChain: () => void;
|
||||
|
||||
// Effects handlers
|
||||
onNormalize: () => void;
|
||||
onFadeIn: () => void;
|
||||
@@ -62,6 +79,15 @@ export function SidePanel({
|
||||
onClear,
|
||||
selection,
|
||||
historyState,
|
||||
effectChain,
|
||||
effectPresets,
|
||||
onToggleEffect,
|
||||
onRemoveEffect,
|
||||
onReorderEffects,
|
||||
onSavePreset,
|
||||
onLoadPreset,
|
||||
onDeletePreset,
|
||||
onClearChain,
|
||||
onNormalize,
|
||||
onFadeIn,
|
||||
onFadeOut,
|
||||
@@ -84,7 +110,8 @@ export function SidePanel({
|
||||
className,
|
||||
}: SidePanelProps) {
|
||||
const [isCollapsed, setIsCollapsed] = React.useState(false);
|
||||
const [activeTab, setActiveTab] = React.useState<'file' | 'history' | 'info' | 'effects'>('file');
|
||||
const [activeTab, setActiveTab] = React.useState<'file' | 'chain' | 'history' | 'info' | 'effects'>('file');
|
||||
const [presetDialogOpen, setPresetDialogOpen] = React.useState(false);
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileClick = () => {
|
||||
@@ -131,6 +158,14 @@ export function SidePanel({
|
||||
>
|
||||
<FileAudio className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'chain' ? 'secondary' : 'ghost'}
|
||||
size="icon-sm"
|
||||
onClick={() => setActiveTab('chain')}
|
||||
title="Effect Chain"
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'effects' ? 'secondary' : 'ghost'}
|
||||
size="icon-sm"
|
||||
@@ -227,6 +262,53 @@ export function SidePanel({
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'chain' && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
|
||||
Effect Chain
|
||||
</h3>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setPresetDialogOpen(true)}
|
||||
title="Manage presets"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
{effectChain.effects.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onClearChain}
|
||||
title="Clear all effects"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<EffectRack
|
||||
chain={effectChain}
|
||||
onToggleEffect={onToggleEffect}
|
||||
onRemoveEffect={onRemoveEffect}
|
||||
onReorderEffects={onReorderEffects}
|
||||
/>
|
||||
<PresetManager
|
||||
open={presetDialogOpen}
|
||||
onClose={() => setPresetDialogOpen(false)}
|
||||
currentChain={effectChain}
|
||||
presets={effectPresets}
|
||||
onSavePreset={onSavePreset}
|
||||
onLoadPreset={onLoadPreset}
|
||||
onDeletePreset={onDeletePreset}
|
||||
onExportPreset={() => {}}
|
||||
onImportPreset={(preset) => onSavePreset(preset)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'history' && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
|
||||
|
||||
Reference in New Issue
Block a user