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>
This commit is contained in:
233
lib/audio/automation-utils.ts
Normal file
233
lib/audio/automation-utils.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
import type { Track, TrackColor } from '@/types/track';
|
||||
import { DEFAULT_TRACK_HEIGHT, TRACK_COLORS } from '@/types/track';
|
||||
import { createEffectChain } from '@/lib/audio/effects/chain';
|
||||
import { createAutomationLane } from '@/lib/audio/automation-utils';
|
||||
|
||||
/**
|
||||
* Generate a unique track ID
|
||||
@@ -23,8 +24,10 @@ export function createTrack(name?: string, color?: TrackColor): Track {
|
||||
// Ensure name is always a string, handle cases where event objects might be passed
|
||||
const trackName = typeof name === 'string' && name.trim() ? name.trim() : 'New Track';
|
||||
|
||||
const trackId = generateTrackId();
|
||||
|
||||
return {
|
||||
id: generateTrackId(),
|
||||
id: trackId,
|
||||
name: trackName,
|
||||
color: TRACK_COLORS[color || randomColor],
|
||||
height: DEFAULT_TRACK_HEIGHT,
|
||||
@@ -36,7 +39,22 @@ export function createTrack(name?: string, color?: TrackColor): Track {
|
||||
recordEnabled: false,
|
||||
effectChain: createEffectChain(`${trackName} Effects`),
|
||||
automation: {
|
||||
lanes: [],
|
||||
lanes: [
|
||||
createAutomationLane(trackId, 'volume', 'Volume', {
|
||||
min: 0,
|
||||
max: 1,
|
||||
unit: 'dB',
|
||||
}),
|
||||
createAutomationLane(trackId, 'pan', 'Pan', {
|
||||
min: -1,
|
||||
max: 1,
|
||||
formatter: (value: number) => {
|
||||
if (value === 0) return 'C';
|
||||
if (value < 0) return `${Math.abs(value * 100).toFixed(0)}L`;
|
||||
return `${(value * 100).toFixed(0)}R`;
|
||||
},
|
||||
}),
|
||||
],
|
||||
showAutomation: false,
|
||||
},
|
||||
collapsed: false,
|
||||
|
||||
Reference in New Issue
Block a user