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:
2025-11-17 20:27:08 +01:00
parent bc4e75167f
commit 0986896756
7 changed files with 903 additions and 11 deletions

View File

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

View 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>
);
}

View 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>
);
}

View File

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