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>
This commit is contained in:
@@ -33,6 +33,15 @@ export function AudioEditor() {
|
||||
clearTracks,
|
||||
} = useMultiTrack();
|
||||
|
||||
// Log tracks to see if they update
|
||||
React.useEffect(() => {
|
||||
console.log('[AudioEditor] Tracks updated:', tracks.map(t => ({
|
||||
name: t.name,
|
||||
effectCount: t.effectChain.effects.length,
|
||||
effects: t.effectChain.effects.map(e => e.name)
|
||||
})));
|
||||
}, [tracks]);
|
||||
|
||||
const {
|
||||
isPlaying,
|
||||
currentTime,
|
||||
@@ -280,7 +289,7 @@ export function AudioEditor() {
|
||||
|
||||
{/* Track Actions */}
|
||||
<div className="flex items-center gap-2 border-l border-border pl-4">
|
||||
<Button variant="outline" size="sm" onClick={addTrack}>
|
||||
<Button variant="outline" size="sm" onClick={() => addTrack()}>
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
Add Track
|
||||
</Button>
|
||||
|
||||
@@ -19,7 +19,6 @@ const EFFECT_CATEGORIES = {
|
||||
'Time-Based': ['delay', 'reverb', 'chorus', 'flanger', 'phaser'] as EffectType[],
|
||||
'Distortion': ['distortion', 'bitcrusher'] as EffectType[],
|
||||
'Pitch & Time': ['pitch', 'timestretch'] as EffectType[],
|
||||
'Utility': ['normalize', 'fadeIn', 'fadeOut', 'reverse'] as EffectType[],
|
||||
};
|
||||
|
||||
export function EffectBrowser({ open, onClose, onSelectEffect }: EffectBrowserProps) {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { ChevronDown, ChevronUp, Power, X } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, Power, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import type { ChainEffect } from '@/lib/audio/effects/chain';
|
||||
import { EffectParameters } from './EffectParameters';
|
||||
|
||||
export interface EffectDeviceProps {
|
||||
effect: ChainEffect;
|
||||
@@ -24,84 +25,80 @@ export function EffectDevice({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex-shrink-0 w-48 border border-border rounded-md overflow-hidden',
|
||||
effect.enabled ? 'bg-accent/30' : 'bg-muted/30'
|
||||
'flex-shrink-0 flex flex-col h-full border-l border-border transition-all duration-200',
|
||||
effect.enabled ? 'bg-accent/20' : 'bg-muted/20',
|
||||
isExpanded ? 'min-w-96' : 'w-10'
|
||||
)}
|
||||
>
|
||||
{/* Device Header */}
|
||||
<div className="flex items-center justify-between px-2 py-1.5 border-b border-border bg-card/50">
|
||||
{!isExpanded ? (
|
||||
/* Collapsed State - No Header */
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex items-center gap-1 flex-1 min-w-0 text-left hover:text-primary transition-colors"
|
||||
onClick={() => setIsExpanded(true)}
|
||||
className="w-full h-full flex flex-col items-center justify-between py-1 hover:bg-primary/10 transition-colors group"
|
||||
title={`Expand ${effect.name}`}
|
||||
>
|
||||
<span className="text-xs font-medium truncate">{effect.name}</span>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-3 w-3 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3 flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
<div className="flex items-center gap-0.5 ml-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onToggleEnabled}
|
||||
title={effect.enabled ? 'Disable effect' : 'Enable effect'}
|
||||
className="h-5 w-5"
|
||||
<ChevronRight className="h-3 w-3 flex-shrink-0 text-muted-foreground group-hover:text-primary transition-colors" />
|
||||
<span
|
||||
className="flex-1 text-xs font-medium whitespace-nowrap text-muted-foreground group-hover:text-primary transition-colors"
|
||||
style={{
|
||||
writingMode: 'vertical-rl',
|
||||
textOrientation: 'mixed',
|
||||
}}
|
||||
>
|
||||
<Power
|
||||
className={cn(
|
||||
'h-3 w-3',
|
||||
effect.enabled ? 'text-primary' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onRemove}
|
||||
title="Remove effect"
|
||||
className="h-5 w-5"
|
||||
>
|
||||
<X className="h-3 w-3 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Device Parameters */}
|
||||
{isExpanded && (
|
||||
<div className="p-2 space-y-2 bg-card/30">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div className="mb-1">
|
||||
<span className="font-medium">Type:</span> {effect.type}
|
||||
</div>
|
||||
{effect.parameters && Object.keys(effect.parameters).length > 0 && (
|
||||
<div>
|
||||
<span className="font-medium">Parameters:</span>
|
||||
<div className="mt-1 space-y-1 pl-2">
|
||||
{Object.entries(effect.parameters).map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between">
|
||||
<span className="text-muted-foreground/70">{key}:</span>
|
||||
<span>{String(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground/70 italic">
|
||||
Parameter controls coming soon
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapsed State Indicator */}
|
||||
{!isExpanded && (
|
||||
<div className="px-2 py-1 text-center">
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-wider">
|
||||
{effect.type}
|
||||
{effect.name}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'w-1 h-1 rounded-full flex-shrink-0 mb-1',
|
||||
effect.enabled ? 'bg-primary' : 'bg-muted-foreground/30'
|
||||
)}
|
||||
title={effect.enabled ? 'Enabled' : 'Disabled'}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{/* Full-Width Header Row */}
|
||||
<div className="flex items-center gap-1 px-2 py-1 border-b border-border bg-card/30 flex-shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setIsExpanded(false)}
|
||||
title="Collapse device"
|
||||
className="h-5 w-5 flex-shrink-0"
|
||||
>
|
||||
<ChevronLeft className="h-3 w-3" />
|
||||
</Button>
|
||||
<span className="text-xs font-semibold flex-1 min-w-0 truncate">{effect.name}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onToggleEnabled}
|
||||
title={effect.enabled ? 'Disable effect' : 'Enable effect'}
|
||||
className="h-5 w-5 flex-shrink-0"
|
||||
>
|
||||
<Power
|
||||
className={cn(
|
||||
'h-3 w-3',
|
||||
effect.enabled ? 'text-primary' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onRemove}
|
||||
title="Remove effect"
|
||||
className="h-5 w-5 flex-shrink-0"
|
||||
>
|
||||
<X className="h-3 w-3 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Device Body */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar p-2">
|
||||
<EffectParameters effect={effect} onUpdateParameters={onUpdateParameters} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
722
components/effects/EffectParameters.tsx
Normal file
722
components/effects/EffectParameters.tsx
Normal file
@@ -0,0 +1,722 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Slider } from '@/components/ui/Slider';
|
||||
import type { ChainEffect, EffectType } from '@/lib/audio/effects/chain';
|
||||
import type {
|
||||
PitchShifterParameters,
|
||||
TimeStretchParameters,
|
||||
DistortionParameters,
|
||||
BitcrusherParameters,
|
||||
} from '@/lib/audio/effects/advanced';
|
||||
import type {
|
||||
CompressorParameters,
|
||||
LimiterParameters,
|
||||
GateParameters,
|
||||
} from '@/lib/audio/effects/dynamics';
|
||||
import type {
|
||||
DelayParameters,
|
||||
ReverbParameters,
|
||||
ChorusParameters,
|
||||
FlangerParameters,
|
||||
PhaserParameters,
|
||||
} from '@/lib/audio/effects/time-based';
|
||||
import type { FilterOptions } from '@/lib/audio/effects/filters';
|
||||
|
||||
export interface EffectParametersProps {
|
||||
effect: ChainEffect;
|
||||
onUpdateParameters?: (parameters: any) => void;
|
||||
}
|
||||
|
||||
export function EffectParameters({ effect, onUpdateParameters }: EffectParametersProps) {
|
||||
const params = effect.parameters || {};
|
||||
|
||||
const updateParam = (key: string, value: any) => {
|
||||
if (onUpdateParameters) {
|
||||
onUpdateParameters({ ...params, [key]: value });
|
||||
}
|
||||
};
|
||||
|
||||
// Filter effects
|
||||
if (['lowpass', 'highpass', 'bandpass', 'notch', 'lowshelf', 'highshelf', 'peaking'].includes(effect.type)) {
|
||||
const filterParams = params as FilterOptions;
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Frequency: {Math.round(filterParams.frequency || 1000)} Hz
|
||||
</label>
|
||||
<Slider
|
||||
value={[filterParams.frequency || 1000]}
|
||||
onValueChange={([value]) => updateParam('frequency', value)}
|
||||
min={20}
|
||||
max={20000}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Q: {(filterParams.Q || 1).toFixed(2)}
|
||||
</label>
|
||||
<Slider
|
||||
value={[filterParams.Q || 1]}
|
||||
onValueChange={([value]) => updateParam('Q', value)}
|
||||
min={0.1}
|
||||
max={20}
|
||||
step={0.1}
|
||||
/>
|
||||
</div>
|
||||
{['lowshelf', 'highshelf', 'peaking'].includes(effect.type) && (
|
||||
<div className="space-y-1 col-span-2">
|
||||
<label className="text-xs font-medium">
|
||||
Gain: {(filterParams.gain || 0).toFixed(1)} dB
|
||||
</label>
|
||||
<Slider
|
||||
value={[filterParams.gain || 0]}
|
||||
onValueChange={([value]) => updateParam('gain', value)}
|
||||
min={-40}
|
||||
max={40}
|
||||
step={0.5}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Compressor
|
||||
if (effect.type === 'compressor') {
|
||||
const compParams = params as CompressorParameters;
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-x-4 gap-y-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Threshold: {(compParams.threshold || -24).toFixed(1)} dB
|
||||
</label>
|
||||
<Slider
|
||||
value={[compParams.threshold || -24]}
|
||||
onValueChange={([value]) => updateParam('threshold', value)}
|
||||
min={-60}
|
||||
max={0}
|
||||
step={0.5}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Ratio: {(compParams.ratio || 4).toFixed(1)}:1
|
||||
</label>
|
||||
<Slider
|
||||
value={[compParams.ratio || 4]}
|
||||
onValueChange={([value]) => updateParam('ratio', value)}
|
||||
min={1}
|
||||
max={20}
|
||||
step={0.5}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Knee: {(compParams.knee || 30).toFixed(1)} dB
|
||||
</label>
|
||||
<Slider
|
||||
value={[compParams.knee || 30]}
|
||||
onValueChange={([value]) => updateParam('knee', value)}
|
||||
min={0}
|
||||
max={40}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Attack: {(compParams.attack || 0.003).toFixed(3)} s
|
||||
</label>
|
||||
<Slider
|
||||
value={[compParams.attack || 0.003]}
|
||||
onValueChange={([value]) => updateParam('attack', value)}
|
||||
min={0.001}
|
||||
max={1}
|
||||
step={0.001}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Release: {(compParams.release || 0.25).toFixed(3)} s
|
||||
</label>
|
||||
<Slider
|
||||
value={[compParams.release || 0.25]}
|
||||
onValueChange={([value]) => updateParam('release', value)}
|
||||
min={0.01}
|
||||
max={3}
|
||||
step={0.01}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Limiter
|
||||
if (effect.type === 'limiter') {
|
||||
const limParams = params as LimiterParameters;
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-x-4 gap-y-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Threshold: {(limParams.threshold || -3).toFixed(1)} dB
|
||||
</label>
|
||||
<Slider
|
||||
value={[limParams.threshold || -3]}
|
||||
onValueChange={([value]) => updateParam('threshold', value)}
|
||||
min={-30}
|
||||
max={0}
|
||||
step={0.5}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Release: {(limParams.release || 0.05).toFixed(3)} s
|
||||
</label>
|
||||
<Slider
|
||||
value={[limParams.release || 0.05]}
|
||||
onValueChange={([value]) => updateParam('release', value)}
|
||||
min={0.01}
|
||||
max={1}
|
||||
step={0.01}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Makeup: {(limParams.makeupGain || 0).toFixed(1)} dB
|
||||
</label>
|
||||
<Slider
|
||||
value={[limParams.makeupGain || 0]}
|
||||
onValueChange={([value]) => updateParam('makeupGain', value)}
|
||||
min={0}
|
||||
max={20}
|
||||
step={0.5}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Gate
|
||||
if (effect.type === 'gate') {
|
||||
const gateParams = params as GateParameters;
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Threshold: {(gateParams.threshold || -40).toFixed(1)} dB
|
||||
</label>
|
||||
<Slider
|
||||
value={[gateParams.threshold || -40]}
|
||||
onValueChange={([value]) => updateParam('threshold', value)}
|
||||
min={-80}
|
||||
max={0}
|
||||
step={0.5}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Ratio: {(gateParams.ratio || 10).toFixed(1)}:1
|
||||
</label>
|
||||
<Slider
|
||||
value={[gateParams.ratio || 10]}
|
||||
onValueChange={([value]) => updateParam('ratio', value)}
|
||||
min={1}
|
||||
max={20}
|
||||
step={0.5}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Attack: {(gateParams.attack || 0.001).toFixed(3)} s
|
||||
</label>
|
||||
<Slider
|
||||
value={[gateParams.attack || 0.001]}
|
||||
onValueChange={([value]) => updateParam('attack', value)}
|
||||
min={0.0001}
|
||||
max={0.5}
|
||||
step={0.0001}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Release: {(gateParams.release || 0.1).toFixed(3)} s
|
||||
</label>
|
||||
<Slider
|
||||
value={[gateParams.release || 0.1]}
|
||||
onValueChange={([value]) => updateParam('release', value)}
|
||||
min={0.01}
|
||||
max={3}
|
||||
step={0.01}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Delay
|
||||
if (effect.type === 'delay') {
|
||||
const delayParams = params as DelayParameters;
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-x-4 gap-y-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Time: {(delayParams.time || 0.5).toFixed(3)} s
|
||||
</label>
|
||||
<Slider
|
||||
value={[delayParams.time || 0.5]}
|
||||
onValueChange={([value]) => updateParam('time', value)}
|
||||
min={0.001}
|
||||
max={2}
|
||||
step={0.001}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Feedback: {((delayParams.feedback || 0.3) * 100).toFixed(0)}%
|
||||
</label>
|
||||
<Slider
|
||||
value={[delayParams.feedback || 0.3]}
|
||||
onValueChange={([value]) => updateParam('feedback', value)}
|
||||
min={0}
|
||||
max={0.9}
|
||||
step={0.01}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Mix: {((delayParams.mix || 0.5) * 100).toFixed(0)}%
|
||||
</label>
|
||||
<Slider
|
||||
value={[delayParams.mix || 0.5]}
|
||||
onValueChange={([value]) => updateParam('mix', value)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Reverb
|
||||
if (effect.type === 'reverb') {
|
||||
const reverbParams = params as ReverbParameters;
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-x-4 gap-y-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Room Size: {((reverbParams.roomSize || 0.5) * 100).toFixed(0)}%
|
||||
</label>
|
||||
<Slider
|
||||
value={[reverbParams.roomSize || 0.5]}
|
||||
onValueChange={([value]) => updateParam('roomSize', value)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Damping: {((reverbParams.damping || 0.5) * 100).toFixed(0)}%
|
||||
</label>
|
||||
<Slider
|
||||
value={[reverbParams.damping || 0.5]}
|
||||
onValueChange={([value]) => updateParam('damping', value)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Mix: {((reverbParams.mix || 0.3) * 100).toFixed(0)}%
|
||||
</label>
|
||||
<Slider
|
||||
value={[reverbParams.mix || 0.3]}
|
||||
onValueChange={([value]) => updateParam('mix', value)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Chorus
|
||||
if (effect.type === 'chorus') {
|
||||
const chorusParams = params as ChorusParameters;
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-x-4 gap-y-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Rate: {(chorusParams.rate || 1.5).toFixed(2)} Hz
|
||||
</label>
|
||||
<Slider
|
||||
value={[chorusParams.rate || 1.5]}
|
||||
onValueChange={([value]) => updateParam('rate', value)}
|
||||
min={0.1}
|
||||
max={10}
|
||||
step={0.1}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Depth: {((chorusParams.depth || 0.002) * 1000).toFixed(2)} ms
|
||||
</label>
|
||||
<Slider
|
||||
value={[chorusParams.depth || 0.002]}
|
||||
onValueChange={([value]) => updateParam('depth', value)}
|
||||
min={0.0001}
|
||||
max={0.01}
|
||||
step={0.0001}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Mix: {((chorusParams.mix || 0.5) * 100).toFixed(0)}%
|
||||
</label>
|
||||
<Slider
|
||||
value={[chorusParams.mix || 0.5]}
|
||||
onValueChange={([value]) => updateParam('mix', value)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Flanger
|
||||
if (effect.type === 'flanger') {
|
||||
const flangerParams = params as FlangerParameters;
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-x-4 gap-y-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Rate: {(flangerParams.rate || 0.5).toFixed(2)} Hz
|
||||
</label>
|
||||
<Slider
|
||||
value={[flangerParams.rate || 0.5]}
|
||||
onValueChange={([value]) => updateParam('rate', value)}
|
||||
min={0.1}
|
||||
max={10}
|
||||
step={0.1}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Depth: {((flangerParams.depth || 0.002) * 1000).toFixed(2)} ms
|
||||
</label>
|
||||
<Slider
|
||||
value={[flangerParams.depth || 0.002]}
|
||||
onValueChange={([value]) => updateParam('depth', value)}
|
||||
min={0.0001}
|
||||
max={0.01}
|
||||
step={0.0001}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Feedback: {((flangerParams.feedback || 0.5) * 100).toFixed(0)}%
|
||||
</label>
|
||||
<Slider
|
||||
value={[flangerParams.feedback || 0.5]}
|
||||
onValueChange={([value]) => updateParam('feedback', value)}
|
||||
min={0}
|
||||
max={0.95}
|
||||
step={0.01}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Mix: {((flangerParams.mix || 0.5) * 100).toFixed(0)}%
|
||||
</label>
|
||||
<Slider
|
||||
value={[flangerParams.mix || 0.5]}
|
||||
onValueChange={([value]) => updateParam('mix', value)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Phaser
|
||||
if (effect.type === 'phaser') {
|
||||
const phaserParams = params as PhaserParameters;
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-x-4 gap-y-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Rate: {(phaserParams.rate || 0.5).toFixed(2)} Hz
|
||||
</label>
|
||||
<Slider
|
||||
value={[phaserParams.rate || 0.5]}
|
||||
onValueChange={([value]) => updateParam('rate', value)}
|
||||
min={0.1}
|
||||
max={10}
|
||||
step={0.1}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Depth: {((phaserParams.depth || 0.5) * 100).toFixed(0)}%
|
||||
</label>
|
||||
<Slider
|
||||
value={[phaserParams.depth || 0.5]}
|
||||
onValueChange={([value]) => updateParam('depth', value)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Stages: {phaserParams.stages || 4}
|
||||
</label>
|
||||
<Slider
|
||||
value={[phaserParams.stages || 4]}
|
||||
onValueChange={([value]) => updateParam('stages', Math.round(value))}
|
||||
min={2}
|
||||
max={12}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Mix: {((phaserParams.mix || 0.5) * 100).toFixed(0)}%
|
||||
</label>
|
||||
<Slider
|
||||
value={[phaserParams.mix || 0.5]}
|
||||
onValueChange={([value]) => updateParam('mix', value)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Pitch Shifter
|
||||
if (effect.type === 'pitch') {
|
||||
const pitchParams = params as PitchShifterParameters;
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-x-4 gap-y-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Semitones: {pitchParams.semitones || 0}
|
||||
</label>
|
||||
<Slider
|
||||
value={[pitchParams.semitones || 0]}
|
||||
onValueChange={([value]) => updateParam('semitones', Math.round(value))}
|
||||
min={-12}
|
||||
max={12}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Cents: {pitchParams.cents || 0}
|
||||
</label>
|
||||
<Slider
|
||||
value={[pitchParams.cents || 0]}
|
||||
onValueChange={([value]) => updateParam('cents', Math.round(value))}
|
||||
min={-100}
|
||||
max={100}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Mix: {((pitchParams.mix || 1) * 100).toFixed(0)}%
|
||||
</label>
|
||||
<Slider
|
||||
value={[pitchParams.mix || 1]}
|
||||
onValueChange={([value]) => updateParam('mix', value)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Time Stretch
|
||||
if (effect.type === 'timestretch') {
|
||||
const stretchParams = params as TimeStretchParameters;
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-x-4 gap-y-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Rate: {(stretchParams.rate || 1).toFixed(2)}x
|
||||
</label>
|
||||
<Slider
|
||||
value={[stretchParams.rate || 1]}
|
||||
onValueChange={([value]) => updateParam('rate', value)}
|
||||
min={0.5}
|
||||
max={2}
|
||||
step={0.01}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 py-1 px-2 border-b border-border/30">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`preserve-pitch-${effect.id}`}
|
||||
checked={stretchParams.preservePitch ?? true}
|
||||
onChange={(e) => updateParam('preservePitch', e.target.checked)}
|
||||
className="h-3 w-3 rounded border-border"
|
||||
/>
|
||||
<label htmlFor={`preserve-pitch-${effect.id}`} className="text-xs">
|
||||
Preserve Pitch
|
||||
</label>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Mix: {((stretchParams.mix || 1) * 100).toFixed(0)}%
|
||||
</label>
|
||||
<Slider
|
||||
value={[stretchParams.mix || 1]}
|
||||
onValueChange={([value]) => updateParam('mix', value)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Distortion
|
||||
if (effect.type === 'distortion') {
|
||||
const distParams = params as DistortionParameters;
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-x-4 gap-y-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">Type</label>
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{(['soft', 'hard', 'tube'] as const).map((type) => (
|
||||
<Button
|
||||
key={type}
|
||||
variant={(distParams.type || 'soft') === type ? 'secondary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => updateParam('type', type)}
|
||||
className="text-xs py-1 h-auto"
|
||||
>
|
||||
{type}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Drive: {((distParams.drive || 0.5) * 100).toFixed(0)}%
|
||||
</label>
|
||||
<Slider
|
||||
value={[distParams.drive || 0.5]}
|
||||
onValueChange={([value]) => updateParam('drive', value)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Tone: {((distParams.tone || 0.5) * 100).toFixed(0)}%
|
||||
</label>
|
||||
<Slider
|
||||
value={[distParams.tone || 0.5]}
|
||||
onValueChange={([value]) => updateParam('tone', value)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Output: {((distParams.output || 0.7) * 100).toFixed(0)}%
|
||||
</label>
|
||||
<Slider
|
||||
value={[distParams.output || 0.7]}
|
||||
onValueChange={([value]) => updateParam('output', value)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Mix: {((distParams.mix || 1) * 100).toFixed(0)}%
|
||||
</label>
|
||||
<Slider
|
||||
value={[distParams.mix || 1]}
|
||||
onValueChange={([value]) => updateParam('mix', value)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Bitcrusher
|
||||
if (effect.type === 'bitcrusher') {
|
||||
const crushParams = params as BitcrusherParameters;
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-x-4 gap-y-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Bit Depth: {crushParams.bitDepth || 8} bits
|
||||
</label>
|
||||
<Slider
|
||||
value={[crushParams.bitDepth || 8]}
|
||||
onValueChange={([value]) => updateParam('bitDepth', Math.round(value))}
|
||||
min={1}
|
||||
max={16}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Sample Rate: {crushParams.sampleRate || 8000} Hz
|
||||
</label>
|
||||
<Slider
|
||||
value={[crushParams.sampleRate || 8000]}
|
||||
onValueChange={([value]) => updateParam('sampleRate', Math.round(value))}
|
||||
min={100}
|
||||
max={48000}
|
||||
step={100}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">
|
||||
Mix: {((crushParams.mix || 1) * 100).toFixed(0)}%
|
||||
</label>
|
||||
<Slider
|
||||
value={[crushParams.mix || 1]}
|
||||
onValueChange={([value]) => updateParam('mix', value)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback for unknown effects
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground/70 italic text-center py-4">
|
||||
No parameters available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export interface TrackProps {
|
||||
onLoadAudio?: (buffer: AudioBuffer) => void;
|
||||
onToggleEffect?: (effectId: string) => void;
|
||||
onRemoveEffect?: (effectId: string) => void;
|
||||
onUpdateEffect?: (effectId: string, parameters: any) => void;
|
||||
onAddEffect?: (effectType: EffectType) => void;
|
||||
}
|
||||
|
||||
@@ -49,6 +50,7 @@ export function Track({
|
||||
onLoadAudio,
|
||||
onToggleEffect,
|
||||
onRemoveEffect,
|
||||
onUpdateEffect,
|
||||
onAddEffect,
|
||||
}: TrackProps) {
|
||||
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||
@@ -509,8 +511,8 @@ export function Track({
|
||||
|
||||
{/* Horizontal scrolling device rack - expanded state */}
|
||||
{showEffects && (
|
||||
<div className="overflow-x-auto custom-scrollbar px-3 py-3 bg-muted/70">
|
||||
<div className="flex gap-2">
|
||||
<div className="h-48 overflow-x-auto custom-scrollbar bg-muted/70">
|
||||
<div className="flex h-full">
|
||||
{track.effectChain.effects.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground text-center py-8 w-full">
|
||||
No devices. Click + to add an effect.
|
||||
@@ -522,6 +524,7 @@ export function Track({
|
||||
effect={effect}
|
||||
onToggleEnabled={() => onToggleEffect?.(effect.id)}
|
||||
onRemove={() => onRemoveEffect?.(effect.id)}
|
||||
onUpdateParameters={(params) => onUpdateEffect?.(effect.id, params)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -124,6 +124,15 @@ export function TrackList({
|
||||
};
|
||||
onUpdateTrack(track.id, { effectChain: updatedChain });
|
||||
}}
|
||||
onUpdateEffect={(effectId, parameters) => {
|
||||
const updatedChain = {
|
||||
...track.effectChain,
|
||||
effects: track.effectChain.effects.map((e) =>
|
||||
e.id === effectId ? { ...e, parameters } : e
|
||||
),
|
||||
};
|
||||
onUpdateTrack(track.id, { effectChain: updatedChain });
|
||||
}}
|
||||
onAddEffect={(effectType) => {
|
||||
const newEffect = createEffect(
|
||||
effectType,
|
||||
|
||||
Reference in New Issue
Block a user