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)}`}
/>
);
}