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>
598 lines
19 KiB
TypeScript
598 lines
19 KiB
TypeScript
'use client';
|
|
|
|
import * as React from 'react';
|
|
import {
|
|
FileAudio,
|
|
History,
|
|
Info,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Upload,
|
|
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
|
|
fileName: string | null;
|
|
audioBuffer: AudioBuffer | null;
|
|
onFileSelect: (file: File) => void;
|
|
onClear: () => void;
|
|
|
|
// Selection info
|
|
selection: Selection | null;
|
|
|
|
// 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;
|
|
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;
|
|
|
|
className?: string;
|
|
}
|
|
|
|
export function SidePanel({
|
|
fileName,
|
|
audioBuffer,
|
|
onFileSelect,
|
|
onClear,
|
|
selection,
|
|
historyState,
|
|
effectChain,
|
|
effectPresets,
|
|
onToggleEffect,
|
|
onRemoveEffect,
|
|
onReorderEffects,
|
|
onSavePreset,
|
|
onLoadPreset,
|
|
onDeletePreset,
|
|
onClearChain,
|
|
onNormalize,
|
|
onFadeIn,
|
|
onFadeOut,
|
|
onReverse,
|
|
onLowPassFilter,
|
|
onHighPassFilter,
|
|
onBandPassFilter,
|
|
onCompressor,
|
|
onLimiter,
|
|
onGate,
|
|
onDelay,
|
|
onReverb,
|
|
onChorus,
|
|
onFlanger,
|
|
onPhaser,
|
|
onPitchShift,
|
|
onTimeStretch,
|
|
onDistortion,
|
|
onBitcrusher,
|
|
className,
|
|
}: SidePanelProps) {
|
|
const [isCollapsed, setIsCollapsed] = React.useState(false);
|
|
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 = () => {
|
|
fileInputRef.current?.click();
|
|
};
|
|
|
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) {
|
|
onFileSelect(file);
|
|
}
|
|
};
|
|
|
|
if (isCollapsed) {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'w-12 bg-card border-r border-border flex flex-col items-center py-2',
|
|
className
|
|
)}
|
|
>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={() => setIsCollapsed(false)}
|
|
title="Expand Side Panel"
|
|
>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className={cn('w-64 bg-card border-r border-border flex flex-col', className)}>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-2 border-b border-border">
|
|
<div className="flex items-center gap-1">
|
|
<Button
|
|
variant={activeTab === 'file' ? 'secondary' : 'ghost'}
|
|
size="icon-sm"
|
|
onClick={() => setActiveTab('file')}
|
|
title="File"
|
|
>
|
|
<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"
|
|
onClick={() => setActiveTab('effects')}
|
|
title="Effects"
|
|
>
|
|
<Sparkles className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant={activeTab === 'history' ? 'secondary' : 'ghost'}
|
|
size="icon-sm"
|
|
onClick={() => setActiveTab('history')}
|
|
title="History"
|
|
>
|
|
<History className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant={activeTab === 'info' ? 'secondary' : 'ghost'}
|
|
size="icon-sm"
|
|
onClick={() => setActiveTab('info')}
|
|
title="Info"
|
|
>
|
|
<Info className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
onClick={() => setIsCollapsed(true)}
|
|
title="Collapse Side Panel"
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 overflow-y-auto p-3 space-y-3 custom-scrollbar">
|
|
{activeTab === 'file' && (
|
|
<>
|
|
<div className="space-y-2">
|
|
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
|
|
Audio File
|
|
</h3>
|
|
{audioBuffer ? (
|
|
<div className="space-y-2">
|
|
<div className="p-2 bg-secondary/30 rounded text-xs">
|
|
<div className="font-medium text-foreground truncate" title={fileName || 'Unknown'}>
|
|
{fileName || 'Unknown'}
|
|
</div>
|
|
<div className="text-muted-foreground mt-1">
|
|
Duration: {formatDuration(audioBuffer.duration)}
|
|
</div>
|
|
<div className="text-muted-foreground">
|
|
Channels: {audioBuffer.numberOfChannels}
|
|
</div>
|
|
<div className="text-muted-foreground">
|
|
Sample Rate: {audioBuffer.sampleRate} Hz
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onClear}
|
|
className="w-full"
|
|
>
|
|
<X className="h-3.5 w-3.5 mr-1.5" />
|
|
Clear File
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="audio/*"
|
|
onChange={handleFileChange}
|
|
className="hidden"
|
|
/>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleFileClick}
|
|
className="w-full"
|
|
>
|
|
<Upload className="h-3.5 w-3.5 mr-1.5" />
|
|
Load Audio File
|
|
</Button>
|
|
<div className="text-xs text-muted-foreground">
|
|
Or drag and drop an audio file onto the waveform area.
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{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">
|
|
Edit History
|
|
</h3>
|
|
{historyState.historySize > 0 ? (
|
|
<div className="space-y-1 text-xs">
|
|
<div className="p-2 bg-secondary/30 rounded">
|
|
<div className="text-foreground">
|
|
{historyState.historySize} action{historyState.historySize !== 1 ? 's' : ''}
|
|
</div>
|
|
{historyState.undoDescription && (
|
|
<div className="text-muted-foreground mt-1">
|
|
Next undo: {historyState.undoDescription}
|
|
</div>
|
|
)}
|
|
{historyState.redoDescription && (
|
|
<div className="text-muted-foreground mt-1">
|
|
Next redo: {historyState.redoDescription}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="text-xs text-muted-foreground">
|
|
No history available. Edit operations will appear here.
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'info' && (
|
|
<div className="space-y-2">
|
|
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
|
|
Selection Info
|
|
</h3>
|
|
{selection ? (
|
|
<div className="p-2 bg-secondary/30 rounded text-xs">
|
|
<div className="text-foreground font-medium">Selection Active</div>
|
|
<div className="text-muted-foreground mt-1">
|
|
Duration: {formatDuration(selection.end - selection.start)}
|
|
</div>
|
|
<div className="text-muted-foreground">
|
|
Start: {formatDuration(selection.start)}
|
|
</div>
|
|
<div className="text-muted-foreground">
|
|
End: {formatDuration(selection.end)}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="text-xs text-muted-foreground">
|
|
No selection. Drag on the waveform to select a region.
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'effects' && (
|
|
<div className="space-y-3">
|
|
<div className="space-y-2">
|
|
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
|
|
Basic Effects
|
|
</h3>
|
|
{audioBuffer ? (
|
|
<div className="space-y-1.5">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onNormalize}
|
|
className="w-full justify-start text-xs"
|
|
>
|
|
Normalize
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onFadeIn}
|
|
className="w-full justify-start text-xs"
|
|
>
|
|
Fade In
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onFadeOut}
|
|
className="w-full justify-start text-xs"
|
|
>
|
|
Fade Out
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onReverse}
|
|
className="w-full justify-start text-xs"
|
|
>
|
|
Reverse
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="text-xs text-muted-foreground">
|
|
Load an audio file to apply effects.
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
|
|
Filters
|
|
</h3>
|
|
{audioBuffer ? (
|
|
<div className="space-y-1.5">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onLowPassFilter}
|
|
className="w-full justify-start text-xs"
|
|
>
|
|
Low-Pass Filter
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onHighPassFilter}
|
|
className="w-full justify-start text-xs"
|
|
>
|
|
High-Pass Filter
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onBandPassFilter}
|
|
className="w-full justify-start text-xs"
|
|
>
|
|
Band-Pass Filter
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="text-xs text-muted-foreground">
|
|
Load an audio file to apply filters.
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
|
|
Dynamics Processing
|
|
</h3>
|
|
{audioBuffer ? (
|
|
<div className="space-y-1.5">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onCompressor}
|
|
className="w-full justify-start text-xs"
|
|
>
|
|
Compressor
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onLimiter}
|
|
className="w-full justify-start text-xs"
|
|
>
|
|
Limiter
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onGate}
|
|
className="w-full justify-start text-xs"
|
|
>
|
|
Gate/Expander
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="text-xs text-muted-foreground">
|
|
Load an audio file to apply dynamics processing.
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
|
|
Time-Based Effects
|
|
</h3>
|
|
{audioBuffer ? (
|
|
<div className="space-y-1.5">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onDelay}
|
|
className="w-full justify-start text-xs"
|
|
>
|
|
Delay/Echo
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onReverb}
|
|
className="w-full justify-start text-xs"
|
|
>
|
|
Reverb
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onChorus}
|
|
className="w-full justify-start text-xs"
|
|
>
|
|
Chorus
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onFlanger}
|
|
className="w-full justify-start text-xs"
|
|
>
|
|
Flanger
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onPhaser}
|
|
className="w-full justify-start text-xs"
|
|
>
|
|
Phaser
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="text-xs text-muted-foreground">
|
|
Load an audio file to apply time-based effects.
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
|
|
Advanced Effects
|
|
</h3>
|
|
{audioBuffer ? (
|
|
<div className="space-y-1.5">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onPitchShift}
|
|
className="w-full justify-start text-xs"
|
|
>
|
|
Pitch Shifter
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onTimeStretch}
|
|
className="w-full justify-start text-xs"
|
|
>
|
|
Time Stretch
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onDistortion}
|
|
className="w-full justify-start text-xs"
|
|
>
|
|
Distortion
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onBitcrusher}
|
|
className="w-full justify-start text-xs"
|
|
>
|
|
Bitcrusher
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="text-xs text-muted-foreground">
|
|
Load an audio file to apply advanced effects.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|