diff --git a/src/components/mixer/EffectChain.tsx b/src/components/mixer/EffectChain.tsx index 3750bfb0..32c4e4d2 100644 --- a/src/components/mixer/EffectChain.tsx +++ b/src/components/mixer/EffectChain.tsx @@ -1,5 +1,4 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { Knob } from '../ui/Knob'; // Inline icon components (no lucide-react dependency) const GripVertical = ({ className }: { className?: string }) => ( @@ -147,19 +146,6 @@ const EFFECT_PRESETS: Record = { ], }; -// ─── Horizontal Slider ─────────────────────────────────────────────────────── - -interface HSliderProps { - value: number; - onChange: (v: number) => void; - min?: number; - max?: number; - label?: string; - displayValue?: string; - color?: string; - width?: number; -} - import { EQ3Card, ParametricEQCard, @@ -175,6 +161,101 @@ import { EFFECT_COLORS, } from './EffectCards'; +// ─── Effect Display Names ──────────────────────────────────────────────────── + +const EFFECT_DISPLAY_NAMES: Record = { + eq3: 'EQ Three', + parametricEq: 'Parametric EQ', + compressor: 'Compressor', + reverb: 'Reverb', + delay: 'Delay', + distortion: 'Distortion', + filter: 'Filter', + chorus: 'Chorus', + flanger: 'Flanger', + phaser: 'Phaser', + convolver: 'Convolver', +}; + +// ─── Categorized Effect Browser ────────────────────────────────────────────── + +/** Build a category entry deriving label from EFFECT_DISPLAY_NAMES to avoid duplication. */ +function categoryEntry(type: TrackEffectType) { + return { type, label: EFFECT_DISPLAY_NAMES[type] ?? type }; +} + +interface EffectCategory { + name: string; + effects: { type: TrackEffectType; label: string }[]; +} + +const EFFECT_CATEGORIES: EffectCategory[] = [ + { name: 'EQ', effects: [categoryEntry('parametricEq'), categoryEntry('eq3')] }, + { name: 'Dynamics', effects: [categoryEntry('compressor')] }, + { name: 'Time', effects: [categoryEntry('reverb'), categoryEntry('delay'), categoryEntry('convolver')] }, + { name: 'Modulation', effects: [categoryEntry('chorus'), categoryEntry('flanger'), categoryEntry('phaser')] }, + { name: 'Distortion', effects: [categoryEntry('distortion')] }, + { name: 'Filter', effects: [categoryEntry('filter')] }, +]; + +// ─── Signal Flow Wire ──────────────────────────────────────────────────────── + +function SignalWire({ bypassed }: { bypassed?: boolean }) { + return ( + + ); +} + +// ─── Signal Terminal (Input / Output) ──────────────────────────────────────── + +function SignalTerminal({ label, side }: { label: string; side: 'input' | 'output' }) { + return ( +
+
+ {label} +
+ ); +} + +// ─── Drop Zone Indicator ───────────────────────────────────────────────────── + +function DropZoneIndicator({ active }: { active: boolean }) { + if (!active) return null; + return ( +
+
+
+ ); +} + +// ─── Effect Device Panel ───────────────────────────────────────────────────── function EffectDevice({ effect, track, index, onDragStart, onDragOver, isDragOver, @@ -198,7 +279,6 @@ function EffectDevice({ updateTrackEffect(track.id, effect.id, { params: preset.params } as Partial); effectsEngine.updateEffectParams(track.id, effect.id, preset.params, effect.type); effectsEngine.rebuildChain(track.id, track.effects ?? [], track.effectsBypassed ?? false); - // Wire Tone.js effect chain into the TrackNode audio graph const engine = getAudioEngine(); const trackNode = engine.getOrCreateTrackNode(track.id); if (trackNode) { @@ -210,128 +290,235 @@ function EffectDevice({ }; return ( -
onDragOver(index)} - > - {/* Title bar */} + <> + {/* Drop zone before this device */} + +
onDragOver(index)} > + {/* Colored accent strip -- Ableton-style top border */}
{ e.stopPropagation(); onDragStart(index); }} - > - -
- - + className="h-[3px] w-full shrink-0" + style={{ backgroundColor: effect.enabled ? color : '#444' }} + /> - - {effect.type} - - - {/* Preset selector */} - + {/* Drag handle */} +
{ e.stopPropagation(); onDragStart(index); }} + title="Drag to reorder" + > + +
+ + {/* Collapse toggle */} + + + {/* Device name */} + + {EFFECT_DISPLAY_NAMES[effect.type] ?? effect.type} + - {/* Enable/bypass toggle */} - + {/* Preset selector */} + + + {/* Power / bypass toggle */} + + + {/* Delete */} + +
- {/* Delete */} - -
+ {/* Device body -- parameter cards */} + {!collapsed && ( +
+ {effect.type === 'eq3' && } + {effect.type === 'parametricEq' && } + {effect.type === 'compressor' && } + {effect.type === 'reverb' && } + {effect.type === 'delay' && } + {effect.type === 'distortion' && } + {effect.type === 'filter' && } + {effect.type === 'chorus' && } + {effect.type === 'flanger' && } + {effect.type === 'phaser' && } + {effect.type === 'convolver' && } +
+ )} - {/* Body */} - {!collapsed && ( -
- {effect.type === 'eq3' && } - {effect.type === 'parametricEq' && } - {effect.type === 'compressor' && } - {effect.type === 'reverb' && } - {effect.type === 'delay' && } - {effect.type === 'distortion' && } - {effect.type === 'filter' && } - {effect.type === 'chorus' && } - {effect.type === 'flanger' && } - {effect.type === 'phaser' && } - {effect.type === 'convolver' && } + {/* Chain position footer */} +
+ + {index + 1} +
- )} -
+
+ ); } -// ─── Add Effect Button ─────────────────────────────────────────────────────── +// ─── Add Effect Button (Categorized + Searchable) ──────────────────────────── function AddEffectButton({ trackId }: { trackId: string }) { const addTrackEffect = useProjectStore((s) => s.addTrackEffect); const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + const dropdownRef = useRef(null); + const searchRef = useRef(null); - const effectTypes: { type: TrackEffectType; label: string; icon: string }[] = [ - { type: 'parametricEq', label: 'Parametric EQ', icon: '🎚️' }, - { type: 'eq3', label: 'EQ Three', icon: '📊' }, - { type: 'compressor', label: 'Compressor', icon: '🔧' }, - { type: 'reverb', label: 'Reverb', icon: '🌊' }, - { type: 'delay', label: 'Delay', icon: '🔁' }, - { type: 'distortion', label: 'Distortion', icon: '⚡' }, - { type: 'filter', label: 'Filter', icon: '🎛' }, - { type: 'chorus', label: 'Chorus', icon: '🎵' }, - { type: 'flanger', label: 'Flanger', icon: '🌀' }, - { type: 'phaser', label: 'Phaser', icon: '🔮' }, - { type: 'convolver', label: 'Convolution Reverb', icon: '🏛️' }, - ]; + // Close dropdown on outside click + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setOpen(false); + setSearch(''); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [open]); + + // Focus search input when dropdown opens + useEffect(() => { + if (open && searchRef.current) { + searchRef.current.focus(); + } + }, [open]); + + const lowerSearch = search.toLowerCase(); + const filteredCategories = EFFECT_CATEGORIES.map((cat) => ({ + ...cat, + effects: cat.effects.filter((e) => + e.label.toLowerCase().includes(lowerSearch) || + cat.name.toLowerCase().includes(lowerSearch) + ), + })).filter((cat) => cat.effects.length > 0); return ( -
+
{open && ( -
- {effectTypes.map(({ type, label, icon }) => ( - - ))} +
+ {/* Search input */} +
+ setSearch(e.target.value)} + className="w-full bg-white/5 border border-white/10 rounded px-2 py-1 text-[11px] text-white/80 placeholder-white/25 outline-none focus:border-violet-500/50" + onKeyDown={(e) => { + if (e.key === 'Escape') { + setOpen(false); + setSearch(''); + } + }} + /> +
+ + {/* Categorized list */} +
+ {filteredCategories.map((cat) => ( +
+
+ {cat.name} +
+ {cat.effects.map(({ type, label }) => { + const c = EFFECT_COLORS[type]; + return ( + + ); + })} +
+ ))} + + {filteredCategories.length === 0 && ( +
+ No effects match "{search}" +
+ )} +
)}
@@ -343,6 +530,7 @@ function AddEffectButton({ trackId }: { trackId: string }) { export function EffectChain() { const project = useProjectStore((s) => s.project); const reorderTrackEffect = useProjectStore((s) => s.reorderTrackEffect); + const toggleTrackEffectsBypass = useProjectStore((s) => s.toggleTrackEffectsBypass); const openTrackId = useUIStore((s) => s.openEffectChainTrackId); const effectChainHeight = useUIStore((s) => s.effectChainHeight); const setEffectChainHeight = useUIStore((s) => s.setEffectChainHeight); @@ -361,7 +549,6 @@ export function EffectChain() { useEffect(() => { if (!track) return; effectsEngine.rebuildChain(track.id, track.effects ?? [], track.effectsBypassed ?? false); - // Wire rebuilt Tone.js chain into TrackNode audio graph const engine = getAudioEngine(); const trackNode = engine.getOrCreateTrackNode(track.id); if (trackNode) { @@ -405,55 +592,134 @@ export function EffectChain() { if (!track) return null; const effects = track.effects ?? []; + const isBypassed = track.effectsBypassed ?? false; return (
setHistoryFocusScope('mixer')} onFocusCapture={() => setHistoryFocusScope('mixer')} > {/* Resize handle */}
- {/* Header */} -
-
- {track.displayName} - - — {effects.length} effect{effects.length !== 1 ? 's' : ''} + {/* Header bar */} +
+ {/* Track color dot */} +
+ + {/* Track name */} + {track.displayName} + + {/* Separator */} +
+ + {/* Chain label */} + + Device Chain - {track.effectsBypassed && ( - + + {/* Effect count */} + + ({effects.length}) + + + {/* Global bypass badge */} + {isBypassed && ( + Bypassed )} + + {/* Spacer */} +
+ + {/* Bypass all toggle */} + + + {/* Close button */}
- {/* Effect devices row */} -
+ {/* Device chain row with signal routing */} +
+ {/* Input terminal */} + + + {/* Wire from input to first device (or output) */} + + + {/* Devices with routing wires between them */} {effects.map((effect, idx) => ( - { setDragOverIdx(i); dragOverIdxRef.current = i; }} - isDragOver={dragOverIdx === idx && dragIdx !== null && dragIdx !== idx} - /> +
+ { setDragOverIdx(i); dragOverIdxRef.current = i; }} + isDragOver={dragOverIdx === idx && dragIdx !== null && dragIdx !== idx} + /> + + {/* Wire after device */} + +
))} + + {/* Add effect button */} + + {/* Wire to output */} + + + {/* Output terminal */} +
+ + {/* Empty state */} + {effects.length === 0 && ( +
+ + No effects -- click + to add a device + +
+ )}
); } diff --git a/src/components/mixer/__tests__/DeviceChainView.test.tsx b/src/components/mixer/__tests__/DeviceChainView.test.tsx new file mode 100644 index 00000000..6c5f6d2c --- /dev/null +++ b/src/components/mixer/__tests__/DeviceChainView.test.tsx @@ -0,0 +1,174 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { useProjectStore } from '../../../store/projectStore'; + +// Mock projectStorage to prevent IndexedDB calls +vi.mock('../../../services/projectStorage', () => ({ + saveProject: vi.fn(), +})); + +// Mock audio engine +vi.mock('../../../hooks/useAudioEngine', () => ({ + getAudioEngine: () => ({ + getOrCreateTrackNode: () => ({ spliceEffects: vi.fn() }), + masterVolume: 1, + }), + useAudioEngine: vi.fn(), +})); + +vi.mock('../../../engine/EffectsEngine', () => ({ + effectsEngine: { + rebuildChain: vi.fn(), + getInputNode: vi.fn(), + getOutputNode: vi.fn(), + updateEffectParams: vi.fn(), + }, +})); + +function createTrack() { + const store = useProjectStore.getState(); + const track = store.addTrack('custom', 'stems'); + return track.id; +} + +describe('Device Chain Store Actions', () => { + beforeEach(() => { + useProjectStore.setState({ project: null }); + useProjectStore.getState().createProject(); + }); + + describe('reorderTrackEffect', () => { + it('exposes reorderTrackEffect as a store action', () => { + const state = useProjectStore.getState(); + expect(typeof state.reorderTrackEffect).toBe('function'); + }); + + it('reorders effects from index 0 to index 2', () => { + const trackId = createTrack(); + const store = useProjectStore.getState(); + + store.addTrackEffect(trackId, 'reverb'); + store.addTrackEffect(trackId, 'delay'); + store.addTrackEffect(trackId, 'compressor'); + + const beforeEffects = useProjectStore.getState().project!.tracks.find(t => t.id === trackId)!.effects!; + expect(beforeEffects).toHaveLength(3); + const [e0, e1, e2] = beforeEffects; + + useProjectStore.getState().reorderTrackEffect(trackId, 0, 2); + + const afterEffects = useProjectStore.getState().project!.tracks.find(t => t.id === trackId)!.effects!; + expect(afterEffects[0].id).toBe(e1.id); + expect(afterEffects[1].id).toBe(e2.id); + expect(afterEffects[2].id).toBe(e0.id); + }); + + it('reorders effects from index 2 to index 0', () => { + const trackId = createTrack(); + const store = useProjectStore.getState(); + + store.addTrackEffect(trackId, 'reverb'); + store.addTrackEffect(trackId, 'delay'); + store.addTrackEffect(trackId, 'compressor'); + + const beforeEffects = useProjectStore.getState().project!.tracks.find(t => t.id === trackId)!.effects!; + const [e0, e1, e2] = beforeEffects; + + useProjectStore.getState().reorderTrackEffect(trackId, 2, 0); + + const afterEffects = useProjectStore.getState().project!.tracks.find(t => t.id === trackId)!.effects!; + expect(afterEffects[0].id).toBe(e2.id); + expect(afterEffects[1].id).toBe(e0.id); + expect(afterEffects[2].id).toBe(e1.id); + }); + + it('does nothing for invalid indices', () => { + const trackId = createTrack(); + const store = useProjectStore.getState(); + store.addTrackEffect(trackId, 'reverb'); + + const beforeEffects = useProjectStore.getState().project!.tracks.find(t => t.id === trackId)!.effects!; + const [e0] = beforeEffects; + + useProjectStore.getState().reorderTrackEffect(trackId, -1, 0); + const afterEffects = useProjectStore.getState().project!.tracks.find(t => t.id === trackId)!.effects!; + expect(afterEffects[0].id).toBe(e0.id); + }); + + it('does nothing when from === to', () => { + const trackId = createTrack(); + const store = useProjectStore.getState(); + store.addTrackEffect(trackId, 'reverb'); + store.addTrackEffect(trackId, 'delay'); + + const beforeEffects = useProjectStore.getState().project!.tracks.find(t => t.id === trackId)!.effects!; + const ids = beforeEffects.map(e => e.id); + + useProjectStore.getState().reorderTrackEffect(trackId, 1, 1); + + const afterEffects = useProjectStore.getState().project!.tracks.find(t => t.id === trackId)!.effects!; + expect(afterEffects.map(e => e.id)).toEqual(ids); + }); + }); + + describe('addTrackEffect', () => { + it('adds an effect to a track', () => { + const trackId = createTrack(); + const effectId = useProjectStore.getState().addTrackEffect(trackId, 'reverb'); + + expect(effectId).toBeDefined(); + const track = useProjectStore.getState().project!.tracks.find(t => t.id === trackId)!; + expect(track.effects).toHaveLength(1); + expect(track.effects![0].type).toBe('reverb'); + expect(track.effects![0].enabled).toBe(true); + }); + + it('adds multiple effects in order', () => { + const trackId = createTrack(); + const store = useProjectStore.getState(); + store.addTrackEffect(trackId, 'reverb'); + store.addTrackEffect(trackId, 'delay'); + store.addTrackEffect(trackId, 'compressor'); + + const track = useProjectStore.getState().project!.tracks.find(t => t.id === trackId)!; + expect(track.effects).toHaveLength(3); + expect(track.effects![0].type).toBe('reverb'); + expect(track.effects![1].type).toBe('delay'); + expect(track.effects![2].type).toBe('compressor'); + }); + }); + + describe('removeTrackEffect', () => { + it('removes a specific effect from a track', () => { + const trackId = createTrack(); + const effectId = useProjectStore.getState().addTrackEffect(trackId, 'reverb')!; + useProjectStore.getState().addTrackEffect(trackId, 'delay'); + + useProjectStore.getState().removeTrackEffect(trackId, effectId); + + const track = useProjectStore.getState().project!.tracks.find(t => t.id === trackId)!; + expect(track.effects).toHaveLength(1); + expect(track.effects![0].type).toBe('delay'); + }); + }); + + describe('updateTrackEffect (bypass toggle)', () => { + it('toggles effect enabled state', () => { + const trackId = createTrack(); + const effectId = useProjectStore.getState().addTrackEffect(trackId, 'reverb')!; + + // Initially enabled + let track = useProjectStore.getState().project!.tracks.find(t => t.id === trackId)!; + expect(track.effects![0].enabled).toBe(true); + + // Bypass (disable) + useProjectStore.getState().updateTrackEffect(trackId, effectId, { enabled: false }); + track = useProjectStore.getState().project!.tracks.find(t => t.id === trackId)!; + expect(track.effects![0].enabled).toBe(false); + + // Re-enable + useProjectStore.getState().updateTrackEffect(trackId, effectId, { enabled: true }); + track = useProjectStore.getState().project!.tracks.find(t => t.id === trackId)!; + expect(track.effects![0].enabled).toBe(true); + }); + }); +});