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:
337
components/automation/AutomationLane.tsx
Normal file
337
components/automation/AutomationLane.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user