diff --git a/components/automation/AutomationHeader.tsx b/components/automation/AutomationHeader.tsx new file mode 100644 index 0000000..7df3a9b --- /dev/null +++ b/components/automation/AutomationHeader.tsx @@ -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 = { + read: 'R', + write: 'W', + touch: 'T', + latch: 'L', +}; + +const MODE_COLORS: Record = { + 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 ( +
+ {/* Color indicator */} + {color && ( +
+ )} + + {/* Parameter name */} + + {parameterName} + + + {/* Current value display */} + {currentValue !== undefined && ( + + {formatValue(currentValue)} + + )} + + {/* Automation mode button */} + + + {/* Height controls */} + {onHeightChange && ( +
+ + +
+ )} + + {/* Show/hide toggle */} + +
+ ); +} diff --git a/components/automation/AutomationLane.tsx b/components/automation/AutomationLane.tsx new file mode 100644 index 0000000..1a05226 --- /dev/null +++ b/components/automation/AutomationLane.tsx @@ -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) => void; + onAddPoint?: (time: number, value: number) => void; + onUpdatePoint?: (pointId: string, updates: Partial) => 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(null); + const containerRef = React.useRef(null); + const [selectedPointId, setSelectedPointId] = React.useState(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) => { + 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 ( +
+ {/* Header */} + 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 */} +
+ + + {/* Automation points */} + {lane.points.map((point) => ( + + ))} +
+
+ ); +} diff --git a/components/automation/AutomationPoint.tsx b/components/automation/AutomationPoint.tsx new file mode 100644 index 0000000..fa972ab --- /dev/null +++ b/components/automation/AutomationPoint.tsx @@ -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 ( +
+ ); +} diff --git a/components/effects/EffectDevice.tsx b/components/effects/EffectDevice.tsx index 769dacb..86ce73d 100644 --- a/components/effects/EffectDevice.tsx +++ b/components/effects/EffectDevice.tsx @@ -25,40 +25,50 @@ export function EffectDevice({ return (
{!isExpanded ? ( - /* Collapsed State - No Header */ - + + + {effect.name} + +
+ + ) : ( <> + {/* Colored top indicator */} +
+ {/* Full-Width Header Row */} -
+
- {/* Bottom: Effects Section (Collapsible, Full Width) */} + {/* Bottom: Effects Section (Collapsible, Full Width) - Ableton Style */} {!track.collapsed && ( -
+
{/* Effects Header - clickable to toggle */}
setShowEffects(!showEffects)} > {showEffects ? ( @@ -642,8 +645,8 @@ export function Track({ {/* Horizontal scrolling device rack - expanded state */} {showEffects && ( -
-
+
+
{track.effectChain.effects.length === 0 ? (
No devices. Click + to add an effect. @@ -665,6 +668,69 @@ export function Track({
)} + {/* Automation Lanes */} + {!track.collapsed && track.automation?.showAutomation && ( +
+ {track.automation.lanes.map((lane) => ( + { + 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 }, + }); + }} + /> + ))} +
+ )} + {/* Effect Browser Dialog */} { + const automationValues = getAutomationValuesAtTime(track, time); + + if (automationValues.length === 0) { + return track; + } + + return applyAutomationValues(track, automationValues); + }); +} + +/** + * Record automation point during playback + */ +export function recordAutomationPoint( + lane: AutomationLane, + time: number, + value: number +): AutomationLane { + // In write mode, replace all existing points in the recorded region + // For simplicity, just add the point for now + // TODO: Implement proper write mode that clears existing points + + const newPoint = { + id: `point-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + time, + value, + curve: 'linear' as const, + }; + + return { + ...lane, + points: [...lane.points, newPoint], + }; +} + +/** + * Automation playback scheduler + * Schedules automation updates at regular intervals during playback + */ +export class AutomationPlaybackScheduler { + private intervalId: number | null = null; + private updateInterval: number = 50; // Update every 50ms (20 Hz) + private onUpdate: ((time: number) => void) | null = null; + + /** + * Start the automation scheduler + */ + start(onUpdate: (time: number) => void): void { + if (this.intervalId !== null) { + this.stop(); + } + + this.onUpdate = onUpdate; + this.intervalId = window.setInterval(() => { + // Get current playback time from your audio engine + // This is a placeholder - you'll need to integrate with your actual playback system + if (this.onUpdate) { + // Call update callback with current time + // The callback should get the time from your actual playback system + this.onUpdate(0); // Placeholder + } + }, this.updateInterval); + } + + /** + * Stop the automation scheduler + */ + stop(): void { + if (this.intervalId !== null) { + window.clearInterval(this.intervalId); + this.intervalId = null; + } + this.onUpdate = null; + } + + /** + * Set update interval (in milliseconds) + */ + setUpdateInterval(interval: number): void { + this.updateInterval = Math.max(10, Math.min(1000, interval)); + + // Restart if already running + if (this.intervalId !== null && this.onUpdate) { + const callback = this.onUpdate; + this.stop(); + this.start(callback); + } + } + + /** + * Check if scheduler is running + */ + isRunning(): boolean { + return this.intervalId !== null; + } +} diff --git a/lib/audio/automation/utils.ts b/lib/audio/automation/utils.ts new file mode 100644 index 0000000..5d2ff51 --- /dev/null +++ b/lib/audio/automation/utils.ts @@ -0,0 +1,185 @@ +/** + * Automation utility functions + */ + +import type { + AutomationLane, + AutomationPoint, + CreateAutomationLaneInput, + CreateAutomationPointInput, + AutomationParameterId, +} from '@/types/automation'; + +/** + * Generate a unique automation point ID + */ +export function generateAutomationPointId(): string { + return `point-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; +} + +/** + * Generate a unique automation lane ID + */ +export function generateAutomationLaneId(): string { + return `lane-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; +} + +/** + * Create a new automation point + */ +export function createAutomationPoint( + input: CreateAutomationPointInput +): AutomationPoint { + return { + id: generateAutomationPointId(), + ...input, + }; +} + +/** + * Create a new automation lane + */ +export function createAutomationLane( + trackId: string, + parameterId: AutomationParameterId, + parameterName: string, + input?: Partial +): AutomationLane { + return { + id: generateAutomationLaneId(), + trackId, + parameterId, + parameterName, + visible: input?.visible ?? true, + height: input?.height ?? 80, + points: input?.points ?? [], + mode: input?.mode ?? 'read', + color: input?.color, + valueRange: input?.valueRange ?? { + min: 0, + max: 1, + }, + }; +} + +/** + * Create a volume automation lane + */ +export function createVolumeAutomationLane(trackId: string): AutomationLane { + return createAutomationLane(trackId, 'volume', 'Volume', { + valueRange: { + min: 0, + max: 1, + formatter: (value) => `${(value * 100).toFixed(0)}%`, + }, + color: 'rgb(34, 197, 94)', // green + }); +} + +/** + * Create a pan automation lane + */ +export function createPanAutomationLane(trackId: string): AutomationLane { + return createAutomationLane(trackId, 'pan', 'Pan', { + valueRange: { + min: -1, + max: 1, + formatter: (value) => { + const normalized = value * 2 - 1; // Convert 0-1 to -1-1 + if (normalized === 0) return 'C'; + if (normalized < 0) return `L${Math.abs(Math.round(normalized * 100))}`; + return `R${Math.round(normalized * 100)}`; + }, + }, + color: 'rgb(59, 130, 246)', // blue + }); +} + +/** + * Interpolate automation value at a specific time + */ +export function interpolateAutomationValue( + points: AutomationPoint[], + time: number +): number { + if (points.length === 0) return 0; + + const sortedPoints = [...points].sort((a, b) => a.time - b.time); + + // Before first point + if (time <= sortedPoints[0].time) { + return sortedPoints[0].value; + } + + // After last point + if (time >= sortedPoints[sortedPoints.length - 1].time) { + return sortedPoints[sortedPoints.length - 1].value; + } + + // Find surrounding points + for (let i = 0; i < sortedPoints.length - 1; i++) { + const prevPoint = sortedPoints[i]; + const nextPoint = sortedPoints[i + 1]; + + if (time >= prevPoint.time && time <= nextPoint.time) { + // Handle step curve + if (prevPoint.curve === 'step') { + return prevPoint.value; + } + + // Linear interpolation + const timeDelta = nextPoint.time - prevPoint.time; + const valueDelta = nextPoint.value - prevPoint.value; + const progress = (time - prevPoint.time) / timeDelta; + + return prevPoint.value + valueDelta * progress; + } + } + + return 0; +} + +/** + * Apply automation value to track parameter + */ +export function applyAutomationToTrack( + track: any, + parameterId: AutomationParameterId, + value: number +): any { + if (parameterId === 'volume') { + return { ...track, volume: value }; + } + + if (parameterId === 'pan') { + // Convert 0-1 to -1-1 + return { ...track, pan: value * 2 - 1 }; + } + + // Effect parameters (format: "effect.{effectId}.{paramName}") + if (parameterId.startsWith('effect.')) { + const parts = parameterId.split('.'); + if (parts.length === 3) { + const [, effectId, paramName] = parts; + return { + ...track, + effectChain: { + ...track.effectChain, + effects: track.effectChain.effects.map((effect: any) => + effect.id === effectId + ? { + ...effect, + parameters: { + ...effect.parameters, + [paramName]: value, + }, + } + : effect + ), + }, + }; + } + } + + return track; +} diff --git a/lib/audio/track-utils.ts b/lib/audio/track-utils.ts index ab5e951..0fc144f 100644 --- a/lib/audio/track-utils.ts +++ b/lib/audio/track-utils.ts @@ -35,6 +35,10 @@ export function createTrack(name?: string, color?: TrackColor): Track { solo: false, recordEnabled: false, effectChain: createEffectChain(`${trackName} Effects`), + automation: { + lanes: [], + showAutomation: false, + }, collapsed: false, selected: false, selection: null, diff --git a/lib/hooks/useMultiTrack.ts b/lib/hooks/useMultiTrack.ts index 094aa62..48ee418 100644 --- a/lib/hooks/useMultiTrack.ts +++ b/lib/hooks/useMultiTrack.ts @@ -26,13 +26,14 @@ export function useMultiTrack() { return []; } - // Note: AudioBuffers can't be serialized, but EffectChains can + // Note: AudioBuffers can't be serialized, but EffectChains and Automation can return parsed.map((t: any) => ({ ...t, name: String(t.name || 'Untitled Track'), // Ensure name is always a string height: t.height && t.height >= DEFAULT_TRACK_HEIGHT ? t.height : DEFAULT_TRACK_HEIGHT, // Migrate old heights audioBuffer: null, // Will need to be reloaded effectChain: t.effectChain || createEffectChain(`${t.name} Effects`), // Restore effect chain or create new + automation: t.automation || { lanes: [], showAutomation: false }, // Restore automation or create new selection: t.selection || null, // Initialize selection })); } @@ -64,6 +65,7 @@ export function useMultiTrack() { collapsed: track.collapsed, selected: track.selected, effectChain: track.effectChain, // Save effect chain + automation: track.automation, // Save automation data })); localStorage.setItem(STORAGE_KEY, JSON.stringify(trackData)); } catch (error) { diff --git a/types/automation.ts b/types/automation.ts new file mode 100644 index 0000000..83cce53 --- /dev/null +++ b/types/automation.ts @@ -0,0 +1,105 @@ +/** + * Automation system type definitions + * Based on Ableton Live's automation model + */ + +/** + * Automation curve types + * - linear: Straight line between points + * - bezier: Curved line with control handles + * - step: Horizontal lines with vertical transitions (for discrete values) + */ +export type AutomationCurveType = 'linear' | 'bezier' | 'step'; + +/** + * Automation recording/playback modes + * - read: Only playback automation + * - write: Record automation (replaces existing) + * - touch: Record while touching control, then return to read mode + * - latch: Record from first touch until stop, then return to read mode + */ +export type AutomationMode = 'read' | 'write' | 'touch' | 'latch'; + +/** + * Single automation breakpoint + */ +export interface AutomationPoint { + id: string; + time: number; // Position in seconds from track start + value: number; // Parameter value (normalized 0-1) + curve: AutomationCurveType; + // Bezier control handles (only used when curve is 'bezier') + handleIn?: { x: number; y: number }; // Relative to point position + handleOut?: { x: number; y: number }; // Relative to point position +} + +/** + * Parameter identifier for automation + * Examples: + * - 'volume' - Track volume + * - 'pan' - Track pan + * - 'mute' - Track mute (step curve) + * - 'effect.compressor-1.threshold' - Effect parameter + * - 'effect.delay-2.time' - Effect parameter + */ +export type AutomationParameterId = string; + +/** + * Single automation lane for a specific parameter + */ +export interface AutomationLane { + id: string; + trackId: string; + parameterId: AutomationParameterId; + parameterName: string; // Display name (e.g., "Volume", "Compressor Threshold") + visible: boolean; // Show/hide lane + height: number; // Lane height in pixels (user-adjustable, 60-120px) + points: AutomationPoint[]; + mode: AutomationMode; + color?: string; // Optional color override (defaults to parameter type color) + // Value range for display (actual values are normalized 0-1) + valueRange: { + min: number; // Display minimum (e.g., 0 for volume) + max: number; // Display maximum (e.g., 1 for volume) + unit?: string; // Display unit (e.g., 'dB', '%', 'ms', 'Hz') + formatter?: (value: number) => string; // Custom value formatter + }; +} + +/** + * All automation lanes for a single track + */ +export interface TrackAutomation { + trackId: string; + lanes: AutomationLane[]; + showAutomation: boolean; // Master show/hide toggle for all lanes +} + +/** + * Complete automation data for entire project + */ +export interface ProjectAutomation { + tracks: Record; +} + +/** + * Automation parameter value at a specific time + * Used for real-time playback + */ +export interface AutomationValue { + parameterId: AutomationParameterId; + value: number; + time: number; +} + +/** + * Helper type for creating new automation points + */ +export type CreateAutomationPointInput = Omit; + +/** + * Helper type for creating new automation lanes + */ +export type CreateAutomationLaneInput = Omit & { + points?: AutomationPoint[]; +}; diff --git a/types/track.ts b/types/track.ts index a5ad9e4..fc43e34 100644 --- a/types/track.ts +++ b/types/track.ts @@ -4,6 +4,7 @@ import type { EffectChain } from '@/lib/audio/effects/chain'; import type { Selection } from './selection'; +import type { AutomationLane } from './automation'; export interface Track { id: string; @@ -22,6 +23,12 @@ export interface Track { // Effects effectChain: EffectChain; + // Automation + automation: { + lanes: AutomationLane[]; + showAutomation: boolean; // Master show/hide toggle + }; + // UI state collapsed: boolean; selected: boolean;