Files
audio-ui/lib/audio/automation-utils.ts
Sebastian Krüger 03a7e2e485 feat: implement Phase 9.1 - Automation lanes UI
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>
2025-11-18 18:34:35 +01:00

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