Major improvements: - Fixed multi-file import (FileList to Array conversion) - Auto-select first track when adding to empty project - Global effects panel folding state (independent of track selection) - Effects panel collapsed/disabled when no track selected - Effect device expansion state persisted per-device - Effect browser with searchable descriptions Visual refinements: - Removed center dot from pan knob for cleaner look - Simplified fader: removed volume fill overlay, dynamic level meter visible through semi-transparent handle - Level meter capped at fader position (realistic mixer behavior) - Solid background instead of gradient for fader track - Subtle volume overlay up to fader handle - Fixed track control width flickering (consistent 4px border) - Effect devices: removed shadows/rounded corners for flatter DAW-style look, added consistent border-radius - Added border between track control and waveform area 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
145 lines
5.6 KiB
TypeScript
145 lines
5.6 KiB
TypeScript
'use client';
|
|
|
|
import * as React from 'react';
|
|
import { X, Search } from 'lucide-react';
|
|
import { Button } from '@/components/ui/Button';
|
|
import { cn } from '@/lib/utils/cn';
|
|
import type { EffectType } from '@/lib/audio/effects/chain';
|
|
import { EFFECT_NAMES } from '@/lib/audio/effects/chain';
|
|
|
|
export interface EffectBrowserProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
onSelectEffect: (effectType: EffectType) => void;
|
|
}
|
|
|
|
const EFFECT_CATEGORIES = {
|
|
'Dynamics': ['compressor', 'limiter', 'gate'] as EffectType[],
|
|
'Filters': ['lowpass', 'highpass', 'bandpass', 'notch', 'lowshelf', 'highshelf', 'peaking'] as EffectType[],
|
|
'Time-Based': ['delay', 'reverb', 'chorus', 'flanger', 'phaser'] as EffectType[],
|
|
'Distortion': ['distortion', 'bitcrusher'] as EffectType[],
|
|
'Pitch & Time': ['pitch', 'timestretch'] as EffectType[],
|
|
};
|
|
|
|
const EFFECT_DESCRIPTIONS: Record<EffectType, string> = {
|
|
'compressor': 'Reduce dynamic range and control peaks',
|
|
'limiter': 'Prevent audio from exceeding a maximum level',
|
|
'gate': 'Reduce noise by cutting low-level signals',
|
|
'lowpass': 'Allow frequencies below cutoff to pass',
|
|
'highpass': 'Allow frequencies above cutoff to pass',
|
|
'bandpass': 'Allow frequencies within a range to pass',
|
|
'notch': 'Remove a specific frequency range',
|
|
'lowshelf': 'Boost or cut low frequencies',
|
|
'highshelf': 'Boost or cut high frequencies',
|
|
'peaking': 'Boost or cut a specific frequency band',
|
|
'delay': 'Create echoes and rhythmic repeats',
|
|
'reverb': 'Simulate acoustic space and ambience',
|
|
'chorus': 'Thicken sound with subtle pitch variations',
|
|
'flanger': 'Create sweeping comb filter effects',
|
|
'phaser': 'Create phase-shifted modulation effects',
|
|
'distortion': 'Add harmonic saturation and grit',
|
|
'bitcrusher': 'Reduce bit depth for lo-fi effects',
|
|
'pitch': 'Shift pitch without changing tempo',
|
|
'timestretch': 'Change tempo without affecting pitch',
|
|
};
|
|
|
|
export function EffectBrowser({ open, onClose, onSelectEffect }: EffectBrowserProps) {
|
|
const [search, setSearch] = React.useState('');
|
|
const [selectedCategory, setSelectedCategory] = React.useState<string | null>(null);
|
|
|
|
const handleSelectEffect = (effectType: EffectType) => {
|
|
onSelectEffect(effectType);
|
|
onClose();
|
|
setSearch('');
|
|
setSelectedCategory(null);
|
|
};
|
|
|
|
const filteredCategories = React.useMemo(() => {
|
|
if (!search) return EFFECT_CATEGORIES;
|
|
|
|
const searchLower = search.toLowerCase();
|
|
const filtered: Record<string, EffectType[]> = {};
|
|
|
|
Object.entries(EFFECT_CATEGORIES).forEach(([category, effects]) => {
|
|
const matchingEffects = effects.filter((effect) =>
|
|
EFFECT_NAMES[effect].toLowerCase().includes(searchLower) ||
|
|
EFFECT_DESCRIPTIONS[effect].toLowerCase().includes(searchLower)
|
|
);
|
|
if (matchingEffects.length > 0) {
|
|
filtered[category] = matchingEffects;
|
|
}
|
|
});
|
|
|
|
return filtered;
|
|
}, [search]);
|
|
|
|
if (!open) return null;
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
|
<div
|
|
className="bg-card border border-border rounded-lg shadow-lg w-full max-w-2xl max-h-[80vh] flex flex-col"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-4 border-b border-border">
|
|
<h2 className="text-lg font-semibold text-foreground">Add Effect</h2>
|
|
<Button variant="ghost" size="icon-sm" onClick={onClose}>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Search */}
|
|
<div className="p-4 border-b border-border">
|
|
<div className="relative">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search effects..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
className="w-full pl-10 pr-4 py-2 bg-background border border-border rounded-md text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 overflow-y-auto p-4">
|
|
<div className="space-y-6">
|
|
{Object.entries(filteredCategories).map(([category, effects]) => (
|
|
<div key={category}>
|
|
<h3 className="text-sm font-semibold text-muted-foreground uppercase mb-2">
|
|
{category}
|
|
</h3>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{effects.map((effect) => (
|
|
<button
|
|
key={effect}
|
|
onClick={() => handleSelectEffect(effect)}
|
|
className={cn(
|
|
'px-4 py-3 text-left rounded-md border transition-colors',
|
|
'hover:bg-accent hover:border-primary',
|
|
'border-border bg-card text-foreground'
|
|
)}
|
|
>
|
|
<div className="font-medium text-sm">{EFFECT_NAMES[effect]}</div>
|
|
<div className="text-xs text-muted-foreground">{EFFECT_DESCRIPTIONS[effect]}</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{Object.keys(filteredCategories).length === 0 && (
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
No effects found matching "{search}"
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|