Files
audio-ui/components/effects/EffectParameters.tsx
Sebastian Krüger c54d5089c5 feat: complete Phase 9.3 - automation recording with write/touch/latch modes
Implemented comprehensive automation recording system for volume, pan, and effect parameters:

- Added automation recording modes:
  - Write: Records continuously during playback when values change
  - Touch: Records only while control is being touched/moved
  - Latch: Records from first touch until playback stops

- Implemented value change detection (0.001 threshold) to prevent infinite loops
- Fixed React setState-in-render errors by:
  - Using queueMicrotask() to defer state updates
  - Moving lane creation logic to useEffect
  - Properly memoizing touch handlers with useMemo

- Added proper value ranges for effect parameters:
  - Frequency: 20-20000 Hz
  - Q: 0.1-20
  - Gain: -40-40 dB

- Enhanced automation lane auto-creation with parameter-specific ranges
- Added touch callbacks to all parameter controls (volume, pan, effects)
- Implemented throttling (100ms) to avoid excessive automation points

Technical improvements:
- Used tracksRef and onRecordAutomationRef to ensure latest values in animation loops
- Added proper cleanup on playback stop
- Optimized recording to only trigger when values actually change

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 23:29:18 +01:00

778 lines
24 KiB
TypeScript

'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;
trackId?: string;
isPlaying?: boolean;
onParameterTouched?: (trackId: string, laneId: string, touched: boolean) => void;
automationLanes?: Array<{ id: string; parameterId: string; mode: string }>;
}
export function EffectParameters({
effect,
onUpdateParameters,
trackId,
isPlaying,
onParameterTouched,
automationLanes = []
}: EffectParametersProps) {
const params = effect.parameters || {};
const updateParam = (key: string, value: any) => {
if (onUpdateParameters) {
onUpdateParameters({ ...params, [key]: value });
}
};
// Memoize touch handlers for all parameters
const touchHandlers = React.useMemo(() => {
if (!trackId || !isPlaying || !onParameterTouched || !automationLanes) {
return {};
}
const handlers: Record<string, { onTouchStart: () => void; onTouchEnd: () => void }> = {};
automationLanes.forEach(lane => {
if (!lane.parameterId.startsWith(`effect.${effect.id}.`)) {
return;
}
// For effect parameters, write mode works like touch mode
if (lane.mode !== 'touch' && lane.mode !== 'latch' && lane.mode !== 'write') {
return;
}
// Extract parameter name from parameterId (effect.{effectId}.{paramName})
const parts = lane.parameterId.split('.');
if (parts.length !== 3) return;
const paramName = parts[2];
handlers[paramName] = {
onTouchStart: () => {
queueMicrotask(() => onParameterTouched(trackId, lane.id, true));
},
onTouchEnd: () => {
queueMicrotask(() => onParameterTouched(trackId, lane.id, false));
},
};
});
return handlers;
}, [trackId, isPlaying, onParameterTouched, effect.id, automationLanes]);
// Helper to get touch handlers for a parameter
const getTouchHandlers = (paramName: string) => {
return touchHandlers[paramName] || {};
};
// 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}
{...getTouchHandlers('frequency')}
/>
</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}
{...getTouchHandlers('Q')}
/>
</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}
{...getTouchHandlers('gain')}
/>
</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>
);
}