diff --git a/components/tracks/Track.tsx b/components/tracks/Track.tsx index eff7d6c..78694f5 100644 --- a/components/tracks/Track.tsx +++ b/components/tracks/Track.tsx @@ -596,6 +596,27 @@ export function Track({ > {track.mute ? : } + + {/* Automation Toggle */} + diff --git a/lib/audio/automation-utils.ts b/lib/audio/automation-utils.ts new file mode 100644 index 0000000..9595708 --- /dev/null +++ b/lib/audio/automation-utils.ts @@ -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)); +} diff --git a/lib/audio/track-utils.ts b/lib/audio/track-utils.ts index 0fc144f..7665242 100644 --- a/lib/audio/track-utils.ts +++ b/lib/audio/track-utils.ts @@ -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,