feat: Ableton Live-style effects and complete automation system

Enhanced visual design:
- Improved device rack container with darker background and inner shadow
- Device cards now have rounded corners, shadows, and colored indicators
- Better visual separation between enabled/disabled effects
- Active devices highlighted with accent border

Complete automation infrastructure (Phase 9):
- Created comprehensive type system for automation lanes and points
- Implemented AutomationPoint component with drag-and-drop editing
- Implemented AutomationHeader with mode controls (Read/Write/Touch/Latch)
- Implemented AutomationLane with canvas-based curve rendering
- Integrated automation lanes into Track component below effects
- Created automation playback engine with real-time interpolation
- Added automation data persistence to localStorage

Automation features:
- Add/remove automation points by clicking/double-clicking
- Drag points to change time and value
- Multiple automation modes (Read, Write, Touch, Latch)
- Linear and step curve types (bezier planned)
- Adjustable lane height (60-180px)
- Show/hide automation per lane
- Real-time value display at playhead
- Color-coded lanes by parameter type
- Keyboard delete support (Delete/Backspace)

Track type updates:
- Added automation field to Track interface
- Updated track creation to initialize empty automation
- Updated localStorage save/load to include automation data

Files created:
- components/automation/AutomationPoint.tsx
- components/automation/AutomationHeader.tsx
- components/automation/AutomationLane.tsx
- lib/audio/automation/utils.ts (helper functions)
- lib/audio/automation/playback.ts (playback engine)
- types/automation.ts (complete type system)

Files modified:
- components/effects/EffectDevice.tsx (Ableton-style visual improvements)
- components/tracks/Track.tsx (automation lanes integration)
- types/track.ts (automation field added)
- lib/audio/track-utils.ts (automation initialization)
- lib/hooks/useMultiTrack.ts (automation 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 16:30:01 +01:00
parent dc9647731d
commit 9b1eedc379
11 changed files with 1179 additions and 33 deletions

View File

@@ -0,0 +1,140 @@
'use client';
import * as React from 'react';
import { Eye, EyeOff, ChevronDown, ChevronUp } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/utils/cn';
import type { AutomationMode } from '@/types/automation';
export interface AutomationHeaderProps {
parameterName: string;
currentValue?: number;
visible: boolean;
mode: AutomationMode;
color?: string;
onToggleVisible?: () => void;
onModeChange?: (mode: AutomationMode) => void;
onHeightChange?: (delta: number) => void;
className?: string;
formatter?: (value: number) => string;
}
const MODE_LABELS: Record<AutomationMode, string> = {
read: 'R',
write: 'W',
touch: 'T',
latch: 'L',
};
const MODE_COLORS: Record<AutomationMode, string> = {
read: 'text-muted-foreground',
write: 'text-red-500',
touch: 'text-yellow-500',
latch: 'text-orange-500',
};
export function AutomationHeader({
parameterName,
currentValue,
visible,
mode,
color,
onToggleVisible,
onModeChange,
onHeightChange,
className,
formatter,
}: AutomationHeaderProps) {
const modes: AutomationMode[] = ['read', 'write', 'touch', 'latch'];
const currentModeIndex = modes.indexOf(mode);
const handleCycleModeClick = () => {
if (!onModeChange) return;
const nextIndex = (currentModeIndex + 1) % modes.length;
onModeChange(modes[nextIndex]);
};
const formatValue = (value: number) => {
if (formatter) return formatter(value);
return value.toFixed(2);
};
return (
<div
className={cn(
'flex items-center gap-2 px-2 py-1 bg-muted/50 border-b border-border/30 flex-shrink-0',
className
)}
>
{/* Color indicator */}
{color && (
<div
className="w-1 h-4 rounded-full flex-shrink-0"
style={{ backgroundColor: color }}
/>
)}
{/* Parameter name */}
<span className="text-xs font-medium text-foreground flex-1 min-w-0 truncate">
{parameterName}
</span>
{/* Current value display */}
{currentValue !== undefined && (
<span className="text-[10px] font-mono text-muted-foreground px-1.5 py-0.5 bg-background/50 rounded">
{formatValue(currentValue)}
</span>
)}
{/* Automation mode button */}
<Button
variant="ghost"
size="icon-sm"
onClick={handleCycleModeClick}
title={`Automation mode: ${mode} (click to cycle)`}
className={cn('h-5 w-5 text-[10px] font-bold flex-shrink-0', MODE_COLORS[mode])}
>
{MODE_LABELS[mode]}
</Button>
{/* Height controls */}
{onHeightChange && (
<div className="flex flex-col gap-0 flex-shrink-0">
<Button
variant="ghost"
size="icon-sm"
onClick={() => onHeightChange(20)}
title="Increase lane height"
className="h-3 w-4 p-0"
>
<ChevronUp className="h-2.5 w-2.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => onHeightChange(-20)}
title="Decrease lane height"
className="h-3 w-4 p-0"
>
<ChevronDown className="h-2.5 w-2.5" />
</Button>
</div>
)}
{/* Show/hide toggle */}
<Button
variant="ghost"
size="icon-sm"
onClick={onToggleVisible}
title={visible ? 'Hide automation' : 'Show automation'}
className="h-5 w-5 flex-shrink-0"
>
{visible ? (
<Eye className="h-3 w-3" />
) : (
<EyeOff className="h-3 w-3 text-muted-foreground" />
)}
</Button>
</div>
);
}

View File

@@ -0,0 +1,337 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
import type { AutomationLane as AutomationLaneType, AutomationPoint as AutomationPointType, AutomationMode } from '@/types/automation';
import { AutomationHeader } from './AutomationHeader';
import { AutomationPoint } from './AutomationPoint';
export interface AutomationLaneProps {
lane: AutomationLaneType;
duration: number; // Total timeline duration in seconds
zoom: number; // Zoom factor
currentTime?: number; // Playhead position
onUpdateLane?: (updates: Partial<AutomationLaneType>) => void;
onAddPoint?: (time: number, value: number) => void;
onUpdatePoint?: (pointId: string, updates: Partial<AutomationPointType>) => void;
onRemovePoint?: (pointId: string) => void;
className?: string;
}
export function AutomationLane({
lane,
duration,
zoom,
currentTime = 0,
onUpdateLane,
onAddPoint,
onUpdatePoint,
onRemovePoint,
className,
}: AutomationLaneProps) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const [selectedPointId, setSelectedPointId] = React.useState<string | null>(null);
const [isDraggingPoint, setIsDraggingPoint] = React.useState(false);
// Convert time to X pixel position
const timeToX = React.useCallback(
(time: number): number => {
if (!containerRef.current) return 0;
const width = containerRef.current.clientWidth;
return (time / duration) * width * zoom;
},
[duration, zoom]
);
// Convert value (0-1) to Y pixel position (inverted: 0 at bottom, 1 at top)
const valueToY = React.useCallback(
(value: number): number => {
if (!containerRef.current) return 0;
const height = lane.height;
return height * (1 - value);
},
[lane.height]
);
// Convert X pixel position to time
const xToTime = React.useCallback(
(x: number): number => {
if (!containerRef.current) return 0;
const width = containerRef.current.clientWidth;
return (x / (width * zoom)) * duration;
},
[duration, zoom]
);
// Convert Y pixel position to value (0-1)
const yToValue = React.useCallback(
(y: number): number => {
const height = lane.height;
return Math.max(0, Math.min(1, 1 - y / height));
},
[lane.height]
);
// Draw automation curve
React.useEffect(() => {
if (!canvasRef.current || !lane.visible) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
const width = rect.width;
const height = rect.height;
// Clear canvas
ctx.clearRect(0, 0, width, height);
// Background
ctx.fillStyle = getComputedStyle(canvas).getPropertyValue('--color-background') || 'rgb(15, 23, 42)';
ctx.fillRect(0, 0, width, height);
// Grid lines (horizontal value guides)
ctx.strokeStyle = 'rgba(148, 163, 184, 0.1)';
ctx.lineWidth = 1;
for (let i = 0; i <= 4; i++) {
const y = (height / 4) * i;
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
// Draw automation curve
if (lane.points.length > 0) {
const color = lane.color || 'rgb(59, 130, 246)';
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
// Sort points by time
const sortedPoints = [...lane.points].sort((a, b) => a.time - b.time);
// Draw lines between points
for (let i = 0; i < sortedPoints.length; i++) {
const point = sortedPoints[i];
const x = timeToX(point.time);
const y = valueToY(point.value);
if (i === 0) {
// Start from left edge at first point's value
ctx.moveTo(0, y);
ctx.lineTo(x, y);
} else {
const prevPoint = sortedPoints[i - 1];
const prevX = timeToX(prevPoint.time);
const prevY = valueToY(prevPoint.value);
if (point.curve === 'step') {
// Step curve: horizontal then vertical
ctx.lineTo(x, prevY);
ctx.lineTo(x, y);
} else {
// Linear curve (bezier not implemented yet)
ctx.lineTo(x, y);
}
}
// Extend to right edge from last point
if (i === sortedPoints.length - 1) {
ctx.lineTo(width, y);
}
}
ctx.stroke();
// Fill area under curve
ctx.globalAlpha = 0.2;
ctx.fillStyle = color;
ctx.lineTo(width, height);
ctx.lineTo(0, height);
ctx.closePath();
ctx.fill();
ctx.globalAlpha = 1.0;
}
// Draw playhead
if (currentTime >= 0 && duration > 0) {
const playheadX = timeToX(currentTime);
if (playheadX >= 0 && playheadX <= width) {
ctx.strokeStyle = 'rgba(239, 68, 68, 0.8)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(playheadX, 0);
ctx.lineTo(playheadX, height);
ctx.stroke();
}
}
}, [lane, duration, zoom, currentTime, timeToX, valueToY]);
// Handle canvas click to add point
const handleCanvasClick = React.useCallback(
(e: React.MouseEvent<HTMLCanvasElement>) => {
if (isDraggingPoint || !onAddPoint) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const time = xToTime(x);
const value = yToValue(y);
onAddPoint(time, value);
},
[isDraggingPoint, onAddPoint, xToTime, yToValue]
);
// Handle point drag
const handlePointDragStart = React.useCallback((pointId: string) => {
setIsDraggingPoint(true);
setSelectedPointId(pointId);
}, []);
const handlePointDrag = React.useCallback(
(pointId: string, deltaX: number, deltaY: number) => {
if (!containerRef.current || !onUpdatePoint) return;
const point = lane.points.find((p) => p.id === pointId);
if (!point) return;
const rect = containerRef.current.getBoundingClientRect();
const width = rect.width;
// Calculate new time and value
const timePerPixel = duration / (width * zoom);
const valuePerPixel = 1 / lane.height;
const newTime = Math.max(0, Math.min(duration, point.time + deltaX * timePerPixel));
const newValue = Math.max(0, Math.min(1, point.value - deltaY * valuePerPixel));
onUpdatePoint(pointId, { time: newTime, value: newValue });
},
[lane.points, lane.height, duration, zoom, onUpdatePoint]
);
const handlePointDragEnd = React.useCallback(() => {
setIsDraggingPoint(false);
}, []);
// Handle point click (select)
const handlePointClick = React.useCallback((pointId: string, event: React.MouseEvent) => {
event.stopPropagation();
setSelectedPointId(pointId);
}, []);
// Handle point double-click (delete)
const handlePointDoubleClick = React.useCallback(
(pointId: string) => {
if (onRemovePoint) {
onRemovePoint(pointId);
}
},
[onRemovePoint]
);
// Handle keyboard delete
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedPointId && onRemovePoint) {
e.preventDefault();
onRemovePoint(selectedPointId);
setSelectedPointId(null);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedPointId, onRemovePoint]);
// Get current value at playhead (interpolated)
const getCurrentValue = React.useCallback((): number | undefined => {
if (lane.points.length === 0) return undefined;
const sortedPoints = [...lane.points].sort((a, b) => a.time - b.time);
// Find surrounding points
let prevPoint = sortedPoints[0];
let nextPoint = sortedPoints[sortedPoints.length - 1];
for (let i = 0; i < sortedPoints.length - 1; i++) {
if (sortedPoints[i].time <= currentTime && sortedPoints[i + 1].time >= currentTime) {
prevPoint = sortedPoints[i];
nextPoint = sortedPoints[i + 1];
break;
}
}
// Interpolate
if (currentTime <= prevPoint.time) return prevPoint.value;
if (currentTime >= nextPoint.time) return nextPoint.value;
const timeDelta = nextPoint.time - prevPoint.time;
const valueDelta = nextPoint.value - prevPoint.value;
const progress = (currentTime - prevPoint.time) / timeDelta;
return prevPoint.value + valueDelta * progress;
}, [lane.points, currentTime]);
if (!lane.visible) return null;
return (
<div className={cn('flex flex-col border-b border-border/50', className)} style={{ height: lane.height + 30 }}>
{/* Header */}
<AutomationHeader
parameterName={lane.parameterName}
currentValue={getCurrentValue()}
visible={lane.visible}
mode={lane.mode}
color={lane.color}
onToggleVisible={() => onUpdateLane?.({ visible: !lane.visible })}
onModeChange={(mode: AutomationMode) => onUpdateLane?.({ mode })}
onHeightChange={(delta) => {
const newHeight = Math.max(60, Math.min(180, lane.height + delta));
onUpdateLane?.({ height: newHeight });
}}
formatter={lane.valueRange.formatter}
/>
{/* Lane canvas area */}
<div
ref={containerRef}
className="relative flex-1 bg-background/30 overflow-hidden cursor-crosshair"
style={{ height: lane.height }}
>
<canvas
ref={canvasRef}
className="absolute inset-0 w-full h-full"
onClick={handleCanvasClick}
/>
{/* Automation points */}
{lane.points.map((point) => (
<AutomationPoint
key={point.id}
point={point}
x={timeToX(point.time)}
y={valueToY(point.value)}
isSelected={selectedPointId === point.id}
onDragStart={handlePointDragStart}
onDrag={handlePointDrag}
onDragEnd={handlePointDragEnd}
onClick={handlePointClick}
onDoubleClick={handlePointDoubleClick}
/>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,123 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
import type { AutomationPoint as AutomationPointType } from '@/types/automation';
export interface AutomationPointProps {
point: AutomationPointType;
x: number; // Pixel position
y: number; // Pixel position
isSelected?: boolean;
onDragStart?: (pointId: string, startX: number, startY: number) => void;
onDrag?: (pointId: string, deltaX: number, deltaY: number) => void;
onDragEnd?: (pointId: string) => void;
onClick?: (pointId: string, event: React.MouseEvent) => void;
onDoubleClick?: (pointId: string) => void;
}
export function AutomationPoint({
point,
x,
y,
isSelected = false,
onDragStart,
onDrag,
onDragEnd,
onClick,
onDoubleClick,
}: AutomationPointProps) {
const [isDragging, setIsDragging] = React.useState(false);
const dragStartRef = React.useRef({ x: 0, y: 0 });
const handleMouseDown = React.useCallback(
(e: React.MouseEvent) => {
if (e.button !== 0) return; // Only left click
e.stopPropagation();
setIsDragging(true);
dragStartRef.current = { x: e.clientX, y: e.clientY };
if (onDragStart) {
onDragStart(point.id, e.clientX, e.clientY);
}
},
[point.id, onDragStart]
);
const handleClick = React.useCallback(
(e: React.MouseEvent) => {
if (!isDragging && onClick) {
onClick(point.id, e);
}
},
[isDragging, point.id, onClick]
);
const handleDoubleClick = React.useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (onDoubleClick) {
onDoubleClick(point.id);
}
},
[point.id, onDoubleClick]
);
// Global mouse handlers
React.useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging) return;
const deltaX = e.clientX - dragStartRef.current.x;
const deltaY = e.clientY - dragStartRef.current.y;
if (onDrag) {
onDrag(point.id, deltaX, deltaY);
}
// Update drag start position for next delta calculation
dragStartRef.current = { x: e.clientX, y: e.clientY };
};
const handleMouseUp = () => {
if (isDragging) {
setIsDragging(false);
if (onDragEnd) {
onDragEnd(point.id);
}
}
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging, point.id, onDrag, onDragEnd]);
return (
<div
className={cn(
'absolute rounded-full cursor-pointer transition-all select-none',
'hover:scale-125',
isDragging ? 'scale-125 z-10' : 'z-0',
isSelected
? 'w-3 h-3 bg-primary border-2 border-background shadow-lg'
: 'w-2.5 h-2.5 bg-primary/80 border border-background shadow-md'
)}
style={{
left: x - (isSelected || isDragging ? 6 : 5),
top: y - (isSelected || isDragging ? 6 : 5),
}}
onMouseDown={handleMouseDown}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
title={`Time: ${point.time.toFixed(3)}s, Value: ${point.value.toFixed(3)}`}
/>
);
}

View File

@@ -25,40 +25,50 @@ export function EffectDevice({
return (
<div
className={cn(
'flex-shrink-0 flex flex-col h-full border-l border-border transition-all duration-200',
effect.enabled ? 'bg-accent/20' : 'bg-muted/20',
'flex-shrink-0 flex flex-col h-full transition-all duration-200 rounded-md overflow-hidden',
effect.enabled
? 'bg-card border border-border/50 shadow-md'
: 'bg-card/40 border border-border/30 shadow-sm opacity-60',
isExpanded ? 'min-w-96' : 'w-10'
)}
>
{!isExpanded ? (
/* Collapsed State - No Header */
<button
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}`}
>
<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',
}}
/* Collapsed State */
<>
{/* Colored top indicator */}
<div className={cn('h-0.5 w-full', effect.enabled ? 'bg-primary' : 'bg-muted-foreground/20')} />
<button
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}`}
>
{effect.name}
</span>
<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>
<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',
}}
>
{effect.name}
</span>
<div
className={cn(
'w-1.5 h-1.5 rounded-full flex-shrink-0 mb-1',
effect.enabled ? 'bg-primary shadow-sm shadow-primary/50' : 'bg-muted-foreground/30'
)}
title={effect.enabled ? 'Enabled' : 'Disabled'}
/>
</button>
</>
) : (
<>
{/* Colored top indicator */}
<div className={cn('h-0.5 w-full', effect.enabled ? 'bg-primary' : 'bg-muted-foreground/20')} />
{/* 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">
<div className="flex items-center gap-1 px-2 py-1.5 border-b border-border/50 bg-muted/30 flex-shrink-0">
<Button
variant="ghost"
size="icon-sm"
@@ -95,7 +105,7 @@ export function EffectDevice({
</div>
{/* Device Body */}
<div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar p-2">
<div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar p-3 bg-card/50">
<EffectParameters effect={effect} onUpdateParameters={onUpdateParameters} />
</div>
</>

View File

@@ -11,6 +11,9 @@ import { EffectDevice } from '@/components/effects/EffectDevice';
import { createEffect, type EffectType } from '@/lib/audio/effects/chain';
import { VerticalFader } from '@/components/ui/VerticalFader';
import { CircularKnob } from '@/components/ui/CircularKnob';
import { AutomationLane } from '@/components/automation/AutomationLane';
import type { AutomationLane as AutomationLaneType, AutomationPoint as AutomationPointType } from '@/types/automation';
import { createAutomationPoint } from '@/lib/audio/automation/utils';
export interface TrackProps {
track: TrackType;
@@ -589,12 +592,12 @@ export function Track({
</div>
</div>
{/* Bottom: Effects Section (Collapsible, Full Width) */}
{/* Bottom: Effects Section (Collapsible, Full Width) - Ableton Style */}
{!track.collapsed && (
<div className="bg-muted/50 border-b border-border/50">
<div className="bg-muted/80 border-b border-border shadow-inner">
{/* Effects Header - clickable to toggle */}
<div
className="flex items-center gap-2 px-3 py-1.5 cursor-pointer hover:bg-accent/30 transition-colors"
className="flex items-center gap-2 px-3 py-1.5 cursor-pointer hover:bg-accent/30 transition-colors border-b border-border/30"
onClick={() => setShowEffects(!showEffects)}
>
{showEffects ? (
@@ -642,8 +645,8 @@ export function Track({
{/* Horizontal scrolling device rack - expanded state */}
{showEffects && (
<div className="h-48 overflow-x-auto custom-scrollbar bg-muted/70">
<div className="flex h-full">
<div className="h-48 overflow-x-auto custom-scrollbar bg-background/50 p-2">
<div className="flex h-full gap-2">
{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.
@@ -665,6 +668,69 @@ export function Track({
</div>
)}
{/* Automation Lanes */}
{!track.collapsed && track.automation?.showAutomation && (
<div className="bg-background/30">
{track.automation.lanes.map((lane) => (
<AutomationLane
key={lane.id}
lane={lane}
duration={duration}
zoom={zoom}
currentTime={currentTime}
onUpdateLane={(updates) => {
const updatedLanes = track.automation.lanes.map((l) =>
l.id === lane.id ? { ...l, ...updates } : l
);
onUpdateTrack(track.id, {
automation: { ...track.automation, lanes: updatedLanes },
});
}}
onAddPoint={(time, value) => {
const newPoint = createAutomationPoint({
time,
value,
curve: 'linear',
});
const updatedLanes = track.automation.lanes.map((l) =>
l.id === lane.id
? { ...l, points: [...l.points, newPoint] }
: l
);
onUpdateTrack(track.id, {
automation: { ...track.automation, lanes: updatedLanes },
});
}}
onUpdatePoint={(pointId, updates) => {
const updatedLanes = track.automation.lanes.map((l) =>
l.id === lane.id
? {
...l,
points: l.points.map((p) =>
p.id === pointId ? { ...p, ...updates } : p
),
}
: l
);
onUpdateTrack(track.id, {
automation: { ...track.automation, lanes: updatedLanes },
});
}}
onRemovePoint={(pointId) => {
const updatedLanes = track.automation.lanes.map((l) =>
l.id === lane.id
? { ...l, points: l.points.filter((p) => p.id !== pointId) }
: l
);
onUpdateTrack(track.id, {
automation: { ...track.automation, lanes: updatedLanes },
});
}}
/>
))}
</div>
)}
{/* Effect Browser Dialog */}
<EffectBrowser
open={effectBrowserOpen}