Added comprehensive automation lane UI with Ableton-style design: **Automation Components:** - AutomationLane: Canvas-based rendering with grid lines, curves, and points - AutomationHeader: Parameter name, mode selector, value display - AutomationPoint: Interactive draggable points with hover states **Automation Utilities:** - createAutomationLane/Point: Factory functions - evaluateAutomationLinear: Interpolation between points - formatAutomationValue: Display formatting with unit support - addAutomationPoint/updateAutomationPoint/removeAutomationPoint **Track Integration:** - Added "A" toggle button in track control panel - Automation lanes render below waveform - Default lanes for Volume (orange) and Pan (green) - Support for add/edit/delete automation points - Click to add, drag to move, double-click to delete **Visual Design:** - Dark background with horizontal grid lines - Colored curves with semi-transparent fill (20% opacity) - 4-6px automation points, 8px on hover - Mode indicators (Read/Write/Touch/Latch) with colors - Value labels and current value display Ready for playback integration in next step. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
234 lines
5.1 KiB
TypeScript
234 lines
5.1 KiB
TypeScript
/**
|
|
* Automation utility functions for creating and manipulating automation data
|
|
*/
|
|
|
|
import type {
|
|
AutomationLane,
|
|
AutomationPoint,
|
|
AutomationCurveType,
|
|
AutomationMode,
|
|
CreateAutomationPointInput,
|
|
} from '@/types/automation';
|
|
|
|
/**
|
|
* Generate unique automation point ID
|
|
*/
|
|
export function generateAutomationPointId(): string {
|
|
return `autopoint-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
}
|
|
|
|
/**
|
|
* Generate unique automation lane ID
|
|
*/
|
|
export function generateAutomationLaneId(): string {
|
|
return `autolane-${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: string,
|
|
parameterName: string,
|
|
valueRange: {
|
|
min: number;
|
|
max: number;
|
|
unit?: string;
|
|
formatter?: (value: number) => string;
|
|
}
|
|
): AutomationLane {
|
|
return {
|
|
id: generateAutomationLaneId(),
|
|
trackId,
|
|
parameterId,
|
|
parameterName,
|
|
visible: true,
|
|
height: 80,
|
|
points: [],
|
|
mode: 'read',
|
|
valueRange,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Linear interpolation between two values
|
|
*/
|
|
function lerp(a: number, b: number, t: number): number {
|
|
return a + (b - a) * t;
|
|
}
|
|
|
|
/**
|
|
* Evaluate automation value at a specific time using linear interpolation
|
|
*/
|
|
export function evaluateAutomationLinear(
|
|
points: AutomationPoint[],
|
|
time: number
|
|
): number {
|
|
if (points.length === 0) return 0.5; // Default middle value
|
|
if (points.length === 1) return points[0].value;
|
|
|
|
// Sort points by time (should already be sorted, but ensure it)
|
|
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 p1 = sortedPoints[i];
|
|
const p2 = sortedPoints[i + 1];
|
|
|
|
if (time >= p1.time && time <= p2.time) {
|
|
// Handle step curve
|
|
if (p1.curve === 'step') {
|
|
return p1.value;
|
|
}
|
|
|
|
// Linear interpolation
|
|
const t = (time - p1.time) / (p2.time - p1.time);
|
|
return lerp(p1.value, p2.value, t);
|
|
}
|
|
}
|
|
|
|
return sortedPoints[sortedPoints.length - 1].value;
|
|
}
|
|
|
|
/**
|
|
* Add an automation point to a lane, maintaining time-sorted order
|
|
*/
|
|
export function addAutomationPoint(
|
|
lane: AutomationLane,
|
|
point: CreateAutomationPointInput
|
|
): AutomationLane {
|
|
const newPoint = createAutomationPoint(point);
|
|
const points = [...lane.points, newPoint].sort((a, b) => a.time - b.time);
|
|
|
|
return {
|
|
...lane,
|
|
points,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Remove an automation point by ID
|
|
*/
|
|
export function removeAutomationPoint(
|
|
lane: AutomationLane,
|
|
pointId: string
|
|
): AutomationLane {
|
|
return {
|
|
...lane,
|
|
points: lane.points.filter((p) => p.id !== pointId),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Update an automation point's time and/or value
|
|
*/
|
|
export function updateAutomationPoint(
|
|
lane: AutomationLane,
|
|
pointId: string,
|
|
updates: { time?: number; value?: number; curve?: AutomationCurveType }
|
|
): AutomationLane {
|
|
const points = lane.points.map((p) =>
|
|
p.id === pointId ? { ...p, ...updates } : p
|
|
);
|
|
|
|
// Re-sort by time if time was updated
|
|
if (updates.time !== undefined) {
|
|
points.sort((a, b) => a.time - b.time);
|
|
}
|
|
|
|
return {
|
|
...lane,
|
|
points,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Remove all automation points in a time range
|
|
*/
|
|
export function clearAutomationRange(
|
|
lane: AutomationLane,
|
|
startTime: number,
|
|
endTime: number
|
|
): AutomationLane {
|
|
return {
|
|
...lane,
|
|
points: lane.points.filter((p) => p.time < startTime || p.time > endTime),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Format automation value for display based on lane's value range
|
|
*/
|
|
export function formatAutomationValue(
|
|
lane: AutomationLane,
|
|
normalizedValue: number
|
|
): string {
|
|
const { min, max, unit, formatter } = lane.valueRange;
|
|
|
|
if (formatter) {
|
|
const actualValue = lerp(min, max, normalizedValue);
|
|
return formatter(actualValue);
|
|
}
|
|
|
|
const actualValue = lerp(min, max, normalizedValue);
|
|
|
|
// Format based on unit
|
|
if (unit === 'dB') {
|
|
// Convert to dB scale
|
|
const db = normalizedValue === 0 ? -Infinity : 20 * Math.log10(normalizedValue);
|
|
return db === -Infinity ? '-∞ dB' : `${db.toFixed(1)} dB`;
|
|
}
|
|
|
|
if (unit === '%') {
|
|
return `${(actualValue * 100).toFixed(0)}%`;
|
|
}
|
|
|
|
if (unit === 'ms') {
|
|
return `${actualValue.toFixed(1)} ms`;
|
|
}
|
|
|
|
if (unit === 'Hz') {
|
|
return `${actualValue.toFixed(0)} Hz`;
|
|
}
|
|
|
|
// Default: 2 decimal places with unit
|
|
return unit ? `${actualValue.toFixed(2)} ${unit}` : actualValue.toFixed(2);
|
|
}
|
|
|
|
/**
|
|
* Snap value to grid (useful for user input)
|
|
*/
|
|
export function snapToGrid(value: number, gridSize: number = 0.25): number {
|
|
return Math.round(value / gridSize) * gridSize;
|
|
}
|
|
|
|
/**
|
|
* Clamp value between 0 and 1
|
|
*/
|
|
export function clampNormalized(value: number): number {
|
|
return Math.max(0, Math.min(1, value));
|
|
}
|