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

260
lib/audio/effects/chain.ts Normal file
View 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
View 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,
};
}