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:
260
lib/audio/effects/chain.ts
Normal file
260
lib/audio/effects/chain.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Effect Chain System
|
||||
* Manages chains of audio effects with bypass, reordering, and preset support
|
||||
*/
|
||||
|
||||
import type {
|
||||
PitchShifterParameters,
|
||||
TimeStretchParameters,
|
||||
DistortionParameters,
|
||||
BitcrusherParameters,
|
||||
} from './advanced';
|
||||
import type {
|
||||
CompressorParameters,
|
||||
LimiterParameters,
|
||||
GateParameters,
|
||||
} from './dynamics';
|
||||
import type {
|
||||
DelayParameters,
|
||||
ReverbParameters,
|
||||
ChorusParameters,
|
||||
FlangerParameters,
|
||||
PhaserParameters,
|
||||
} from './time-based';
|
||||
import type { FilterParameters } from './filters';
|
||||
|
||||
// Effect type identifier
|
||||
export type EffectType =
|
||||
// Basic
|
||||
| 'normalize'
|
||||
| 'fadeIn'
|
||||
| 'fadeOut'
|
||||
| 'reverse'
|
||||
// Filters
|
||||
| 'lowpass'
|
||||
| 'highpass'
|
||||
| 'bandpass'
|
||||
| 'notch'
|
||||
| 'lowshelf'
|
||||
| 'highshelf'
|
||||
| 'peaking'
|
||||
// Dynamics
|
||||
| 'compressor'
|
||||
| 'limiter'
|
||||
| 'gate'
|
||||
// Time-based
|
||||
| 'delay'
|
||||
| 'reverb'
|
||||
| 'chorus'
|
||||
| 'flanger'
|
||||
| 'phaser'
|
||||
// Advanced
|
||||
| 'pitch'
|
||||
| 'timestretch'
|
||||
| 'distortion'
|
||||
| 'bitcrusher';
|
||||
|
||||
// Union of all effect parameter types
|
||||
export type EffectParameters =
|
||||
| FilterParameters
|
||||
| CompressorParameters
|
||||
| LimiterParameters
|
||||
| GateParameters
|
||||
| DelayParameters
|
||||
| ReverbParameters
|
||||
| ChorusParameters
|
||||
| FlangerParameters
|
||||
| PhaserParameters
|
||||
| PitchShifterParameters
|
||||
| TimeStretchParameters
|
||||
| DistortionParameters
|
||||
| BitcrusherParameters
|
||||
| Record<string, never>; // For effects without parameters
|
||||
|
||||
// Effect instance in a chain
|
||||
export interface ChainEffect {
|
||||
id: string;
|
||||
type: EffectType;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
parameters?: EffectParameters;
|
||||
}
|
||||
|
||||
// Effect chain
|
||||
export interface EffectChain {
|
||||
id: string;
|
||||
name: string;
|
||||
effects: ChainEffect[];
|
||||
}
|
||||
|
||||
// Effect preset
|
||||
export interface EffectPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
chain: EffectChain;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique ID for effects/chains
|
||||
*/
|
||||
export function generateId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new effect instance
|
||||
*/
|
||||
export function createEffect(
|
||||
type: EffectType,
|
||||
name: string,
|
||||
parameters?: EffectParameters
|
||||
): ChainEffect {
|
||||
return {
|
||||
id: generateId(),
|
||||
type,
|
||||
name,
|
||||
enabled: true,
|
||||
parameters,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new effect chain
|
||||
*/
|
||||
export function createEffectChain(name: string = 'New Chain'): EffectChain {
|
||||
return {
|
||||
id: generateId(),
|
||||
name,
|
||||
effects: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add effect to chain
|
||||
*/
|
||||
export function addEffectToChain(
|
||||
chain: EffectChain,
|
||||
effect: ChainEffect
|
||||
): EffectChain {
|
||||
return {
|
||||
...chain,
|
||||
effects: [...chain.effects, effect],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove effect from chain
|
||||
*/
|
||||
export function removeEffectFromChain(
|
||||
chain: EffectChain,
|
||||
effectId: string
|
||||
): EffectChain {
|
||||
return {
|
||||
...chain,
|
||||
effects: chain.effects.filter((e) => e.id !== effectId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle effect enabled state
|
||||
*/
|
||||
export function toggleEffect(
|
||||
chain: EffectChain,
|
||||
effectId: string
|
||||
): EffectChain {
|
||||
return {
|
||||
...chain,
|
||||
effects: chain.effects.map((e) =>
|
||||
e.id === effectId ? { ...e, enabled: !e.enabled } : e
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update effect parameters
|
||||
*/
|
||||
export function updateEffectParameters(
|
||||
chain: EffectChain,
|
||||
effectId: string,
|
||||
parameters: EffectParameters
|
||||
): EffectChain {
|
||||
return {
|
||||
...chain,
|
||||
effects: chain.effects.map((e) =>
|
||||
e.id === effectId ? { ...e, parameters } : e
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder effects in chain
|
||||
*/
|
||||
export function reorderEffects(
|
||||
chain: EffectChain,
|
||||
fromIndex: number,
|
||||
toIndex: number
|
||||
): EffectChain {
|
||||
const effects = [...chain.effects];
|
||||
const [removed] = effects.splice(fromIndex, 1);
|
||||
effects.splice(toIndex, 0, removed);
|
||||
|
||||
return {
|
||||
...chain,
|
||||
effects,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a preset from a chain
|
||||
*/
|
||||
export function createPreset(
|
||||
chain: EffectChain,
|
||||
name: string,
|
||||
description?: string
|
||||
): EffectPreset {
|
||||
return {
|
||||
id: generateId(),
|
||||
name,
|
||||
description,
|
||||
chain: JSON.parse(JSON.stringify(chain)), // Deep clone
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Load preset (returns a new chain)
|
||||
*/
|
||||
export function loadPreset(preset: EffectPreset): EffectChain {
|
||||
return JSON.parse(JSON.stringify(preset.chain)); // Deep clone
|
||||
}
|
||||
|
||||
/**
|
||||
* Get effect display name
|
||||
*/
|
||||
export const EFFECT_NAMES: Record<EffectType, string> = {
|
||||
normalize: 'Normalize',
|
||||
fadeIn: 'Fade In',
|
||||
fadeOut: 'Fade Out',
|
||||
reverse: 'Reverse',
|
||||
lowpass: 'Low-Pass Filter',
|
||||
highpass: 'High-Pass Filter',
|
||||
bandpass: 'Band-Pass Filter',
|
||||
notch: 'Notch Filter',
|
||||
lowshelf: 'Low Shelf',
|
||||
highshelf: 'High Shelf',
|
||||
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',
|
||||
};
|
||||
122
lib/hooks/useEffectChain.ts
Normal file
122
lib/hooks/useEffectChain.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import type {
|
||||
EffectChain,
|
||||
EffectPreset,
|
||||
ChainEffect,
|
||||
EffectParameters,
|
||||
} from '@/lib/audio/effects/chain';
|
||||
import {
|
||||
createEffectChain,
|
||||
addEffectToChain,
|
||||
removeEffectFromChain,
|
||||
toggleEffect,
|
||||
updateEffectParameters,
|
||||
reorderEffects,
|
||||
loadPreset,
|
||||
} from '@/lib/audio/effects/chain';
|
||||
|
||||
const STORAGE_KEY_CHAIN = 'audio-ui-effect-chain';
|
||||
const STORAGE_KEY_PRESETS = 'audio-ui-effect-presets';
|
||||
|
||||
export function useEffectChain() {
|
||||
const [chain, setChain] = useState<EffectChain>(() => {
|
||||
if (typeof window === 'undefined') return createEffectChain('Main Chain');
|
||||
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY_CHAIN);
|
||||
return saved ? JSON.parse(saved) : createEffectChain('Main Chain');
|
||||
} catch {
|
||||
return createEffectChain('Main Chain');
|
||||
}
|
||||
});
|
||||
|
||||
const [presets, setPresets] = useState<EffectPreset[]>(() => {
|
||||
if (typeof window === 'undefined') return [];
|
||||
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY_PRESETS);
|
||||
return saved ? JSON.parse(saved) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
// Save chain to localStorage whenever it changes
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_CHAIN, JSON.stringify(chain));
|
||||
} catch (error) {
|
||||
console.error('Failed to save effect chain:', error);
|
||||
}
|
||||
}, [chain]);
|
||||
|
||||
// Save presets to localStorage whenever they change
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_PRESETS, JSON.stringify(presets));
|
||||
} catch (error) {
|
||||
console.error('Failed to save presets:', error);
|
||||
}
|
||||
}, [presets]);
|
||||
|
||||
const addEffect = useCallback((effect: ChainEffect) => {
|
||||
setChain((prev) => addEffectToChain(prev, effect));
|
||||
}, []);
|
||||
|
||||
const removeEffect = useCallback((effectId: string) => {
|
||||
setChain((prev) => removeEffectFromChain(prev, effectId));
|
||||
}, []);
|
||||
|
||||
const toggleEffectEnabled = useCallback((effectId: string) => {
|
||||
setChain((prev) => toggleEffect(prev, effectId));
|
||||
}, []);
|
||||
|
||||
const updateEffect = useCallback(
|
||||
(effectId: string, parameters: EffectParameters) => {
|
||||
setChain((prev) => updateEffectParameters(prev, effectId, parameters));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const reorder = useCallback((fromIndex: number, toIndex: number) => {
|
||||
setChain((prev) => reorderEffects(prev, fromIndex, toIndex));
|
||||
}, []);
|
||||
|
||||
const clearChain = useCallback(() => {
|
||||
setChain((prev) => ({ ...prev, effects: [] }));
|
||||
}, []);
|
||||
|
||||
const savePreset = useCallback((preset: EffectPreset) => {
|
||||
setPresets((prev) => [...prev, preset]);
|
||||
}, []);
|
||||
|
||||
const loadPresetToChain = useCallback((preset: EffectPreset) => {
|
||||
const loadedChain = loadPreset(preset);
|
||||
setChain(loadedChain);
|
||||
}, []);
|
||||
|
||||
const deletePreset = useCallback((presetId: string) => {
|
||||
setPresets((prev) => prev.filter((p) => p.id !== presetId));
|
||||
}, []);
|
||||
|
||||
const importPreset = useCallback((preset: EffectPreset) => {
|
||||
setPresets((prev) => [...prev, preset]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
chain,
|
||||
presets,
|
||||
addEffect,
|
||||
removeEffect,
|
||||
toggleEffectEnabled,
|
||||
updateEffect,
|
||||
reorder,
|
||||
clearChain,
|
||||
savePreset,
|
||||
loadPresetToChain,
|
||||
deletePreset,
|
||||
importPreset,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user