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:
@@ -596,6 +596,27 @@ export function Track({
|
|||||||
>
|
>
|
||||||
{track.mute ? <VolumeX className="h-3 w-3" /> : <Volume2 className="h-3 w-3" />}
|
{track.mute ? <VolumeX className="h-3 w-3" /> : <Volume2 className="h-3 w-3" />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Automation Toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onUpdateTrack(track.id, {
|
||||||
|
automation: {
|
||||||
|
...track.automation,
|
||||||
|
showAutomation: !track.automation?.showAutomation,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'h-6 w-6 rounded flex items-center justify-center transition-all text-[10px] font-bold',
|
||||||
|
track.automation?.showAutomation
|
||||||
|
? 'bg-primary text-primary-foreground shadow-md shadow-primary/30'
|
||||||
|
: 'bg-card hover:bg-accent text-muted-foreground border border-border/50'
|
||||||
|
)}
|
||||||
|
title={track.automation?.showAutomation ? 'Hide automation' : 'Show automation'}
|
||||||
|
>
|
||||||
|
A
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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 type { Track, TrackColor } from '@/types/track';
|
||||||
import { DEFAULT_TRACK_HEIGHT, TRACK_COLORS } from '@/types/track';
|
import { DEFAULT_TRACK_HEIGHT, TRACK_COLORS } from '@/types/track';
|
||||||
import { createEffectChain } from '@/lib/audio/effects/chain';
|
import { createEffectChain } from '@/lib/audio/effects/chain';
|
||||||
|
import { createAutomationLane } from '@/lib/audio/automation-utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a unique track ID
|
* 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
|
// 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 trackName = typeof name === 'string' && name.trim() ? name.trim() : 'New Track';
|
||||||
|
|
||||||
|
const trackId = generateTrackId();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: generateTrackId(),
|
id: trackId,
|
||||||
name: trackName,
|
name: trackName,
|
||||||
color: TRACK_COLORS[color || randomColor],
|
color: TRACK_COLORS[color || randomColor],
|
||||||
height: DEFAULT_TRACK_HEIGHT,
|
height: DEFAULT_TRACK_HEIGHT,
|
||||||
@@ -36,7 +39,22 @@ export function createTrack(name?: string, color?: TrackColor): Track {
|
|||||||
recordEnabled: false,
|
recordEnabled: false,
|
||||||
effectChain: createEffectChain(`${trackName} Effects`),
|
effectChain: createEffectChain(`${trackName} Effects`),
|
||||||
automation: {
|
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,
|
showAutomation: false,
|
||||||
},
|
},
|
||||||
collapsed: false,
|
collapsed: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user