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:
2025-11-18 12:08:33 +01:00
parent cbcd38b1ed
commit beb7085c89
11 changed files with 2180 additions and 100 deletions

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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>
);

View 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>
);
}

View File

@@ -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)}
/>
))
)}

View File

@@ -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,