Files
audio-ui/components/effects/EffectBrowser.tsx
Sebastian Krüger beb7085c89 feat: complete Phase 7.4 - real-time track effects system
Implemented comprehensive real-time effect processing for multi-track audio:

Core Features:
- Per-track effect chains with drag-and-drop reordering
- Effect bypass/enable toggle per effect
- Real-time parameter updates (filters, dynamics, time-based, distortion, bitcrusher, pitch, timestretch)
- Add/remove effects during playback without interruption
- Effect chain persistence via localStorage
- Automatic playback stop when tracks are deleted

Technical Implementation:
- Effect processor with dry/wet routing for bypass functionality
- Real-time effect parameter updates using AudioParam setValueAtTime
- Structure change detection for add/remove/reorder operations
- Stale closure fix using refs for latest track state
- ScriptProcessorNode for bitcrusher, pitch shifter, and time stretch
- Dual-tap delay line for pitch shifting
- Overlap-add synthesis for time stretching

UI Components:
- EffectBrowser dialog with categorized effects
- EffectDevice component with parameter controls
- EffectParameters for all 19 real-time effect types
- Device rack with horizontal scrolling (Ableton-style)

Removed offline-only effects (normalize, fadeIn, fadeOut, reverse) as they don't fit the real-time processing model.

Completed all items in Phase 7.4:
- [x] Per-track effect chain
- [x] Effect rack UI
- [x] Effect bypass per track
- [x] Real-time effect processing during playback
- [x] Add/remove effects during playback
- [x] Real-time parameter updates
- [x] Effect chain persistence

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 12:08:33 +01:00

122 lines
4.5 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[],
};
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)
);
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 capitalize">{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>
);
}