Implemented three major medium effort features to enhance the audio editor: **1. Region Markers System** - Add marker type definitions supporting point markers and regions - Create useMarkers hook for marker state management - Build MarkerTimeline component for visual marker display - Create MarkerDialog component for adding/editing markers - Add keyboard shortcuts: M (add marker), Shift+M (next), Shift+Ctrl+M (previous) - Support marker navigation, editing, and deletion **2. Web Worker for Computations** - Create audio worker for offloading heavy computations - Implement worker functions: generatePeaks, generateMinMaxPeaks, normalizePeaks, analyzeAudio, findPeak - Build useAudioWorker hook for easy worker integration - Integrate worker into Waveform component with peak caching - Significantly improve UI responsiveness during waveform generation **3. Bezier Curve Automation** - Enhance interpolateAutomationValue to support Bezier curves - Implement cubic Bezier interpolation with control handles - Add createSmoothHandles for auto-smooth curve generation - Add generateBezierCurvePoints for smooth curve rendering - Support bezier alongside existing linear and step curves All features are type-safe and integrate seamlessly with the existing codebase. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
304 lines
7.7 KiB
TypeScript
304 lines
7.7 KiB
TypeScript
/**
|
|
* Automation utility functions
|
|
*/
|
|
|
|
import type {
|
|
AutomationLane,
|
|
AutomationPoint,
|
|
CreateAutomationLaneInput,
|
|
CreateAutomationPointInput,
|
|
AutomationParameterId,
|
|
} from '@/types/automation';
|
|
|
|
/**
|
|
* Generate a unique automation point ID
|
|
*/
|
|
export function generateAutomationPointId(): string {
|
|
return `point-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
}
|
|
|
|
/**
|
|
* Generate a unique automation lane ID
|
|
*/
|
|
export function generateAutomationLaneId(): string {
|
|
return `lane-${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: AutomationParameterId,
|
|
parameterName: string,
|
|
input?: Partial<CreateAutomationLaneInput>
|
|
): AutomationLane {
|
|
return {
|
|
id: generateAutomationLaneId(),
|
|
trackId,
|
|
parameterId,
|
|
parameterName,
|
|
visible: input?.visible ?? true,
|
|
height: input?.height ?? 80,
|
|
points: input?.points ?? [],
|
|
mode: input?.mode ?? 'read',
|
|
color: input?.color,
|
|
valueRange: input?.valueRange ?? {
|
|
min: 0,
|
|
max: 1,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create a volume automation lane
|
|
*/
|
|
export function createVolumeAutomationLane(trackId: string): AutomationLane {
|
|
return createAutomationLane(trackId, 'volume', 'Volume', {
|
|
valueRange: {
|
|
min: 0,
|
|
max: 1,
|
|
formatter: (value) => `${(value * 100).toFixed(0)}%`,
|
|
},
|
|
color: 'rgb(34, 197, 94)', // green
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create a pan automation lane
|
|
*/
|
|
export function createPanAutomationLane(trackId: string): AutomationLane {
|
|
return createAutomationLane(trackId, 'pan', 'Pan', {
|
|
valueRange: {
|
|
min: -1,
|
|
max: 1,
|
|
formatter: (value) => {
|
|
const normalized = value * 2 - 1; // Convert 0-1 to -1-1
|
|
if (normalized === 0) return 'C';
|
|
if (normalized < 0) return `L${Math.abs(Math.round(normalized * 100))}`;
|
|
return `R${Math.round(normalized * 100)}`;
|
|
},
|
|
},
|
|
color: 'rgb(59, 130, 246)', // blue
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Interpolate automation value at a specific time
|
|
*/
|
|
export function interpolateAutomationValue(
|
|
points: AutomationPoint[],
|
|
time: number
|
|
): number {
|
|
if (points.length === 0) return 0;
|
|
|
|
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 prevPoint = sortedPoints[i];
|
|
const nextPoint = sortedPoints[i + 1];
|
|
|
|
if (time >= prevPoint.time && time <= nextPoint.time) {
|
|
// Handle step curve
|
|
if (prevPoint.curve === 'step') {
|
|
return prevPoint.value;
|
|
}
|
|
|
|
// Handle bezier curve
|
|
if (prevPoint.curve === 'bezier') {
|
|
const timeDelta = nextPoint.time - prevPoint.time;
|
|
const t = (time - prevPoint.time) / timeDelta;
|
|
return interpolateBezier(prevPoint, nextPoint, t);
|
|
}
|
|
|
|
// Linear interpolation (default)
|
|
const timeDelta = nextPoint.time - prevPoint.time;
|
|
const valueDelta = nextPoint.value - prevPoint.value;
|
|
const progress = (time - prevPoint.time) / timeDelta;
|
|
|
|
return prevPoint.value + valueDelta * progress;
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Interpolate value using cubic Bezier curve
|
|
* Uses the control handles from both points to create smooth curves
|
|
*/
|
|
function interpolateBezier(
|
|
p0: AutomationPoint,
|
|
p1: AutomationPoint,
|
|
t: number
|
|
): number {
|
|
// Default handle positions if not specified
|
|
// Out handle defaults to 1/3 towards next point
|
|
// In handle defaults to 1/3 back from current point
|
|
const timeDelta = p1.time - p0.time;
|
|
|
|
// Control point 1 (out handle from p0)
|
|
const c1x = p0.handleOut?.x ?? timeDelta / 3;
|
|
const c1y = p0.handleOut?.y ?? 0;
|
|
|
|
// Control point 2 (in handle from p1)
|
|
const c2x = p1.handleIn?.x ?? -timeDelta / 3;
|
|
const c2y = p1.handleIn?.y ?? 0;
|
|
|
|
// Convert handles to absolute positions
|
|
const cp1Value = p0.value + c1y;
|
|
const cp2Value = p1.value + c2y;
|
|
|
|
// Cubic Bezier formula: B(t) = (1-t)³P₀ + 3(1-t)²tP₁ + 3(1-t)t²P₂ + t³P₃
|
|
const mt = 1 - t;
|
|
const mt2 = mt * mt;
|
|
const mt3 = mt2 * mt;
|
|
const t2 = t * t;
|
|
const t3 = t2 * t;
|
|
|
|
const value =
|
|
mt3 * p0.value +
|
|
3 * mt2 * t * cp1Value +
|
|
3 * mt * t2 * cp2Value +
|
|
t3 * p1.value;
|
|
|
|
return value;
|
|
}
|
|
|
|
/**
|
|
* Create smooth bezier handles for a point based on surrounding points
|
|
* This creates an "auto-smooth" effect similar to DAWs
|
|
*/
|
|
export function createSmoothHandles(
|
|
prevPoint: AutomationPoint | null,
|
|
currentPoint: AutomationPoint,
|
|
nextPoint: AutomationPoint | null
|
|
): { handleIn: { x: number; y: number }; handleOut: { x: number; y: number } } {
|
|
// If no surrounding points, return horizontal handles
|
|
if (!prevPoint && !nextPoint) {
|
|
return {
|
|
handleIn: { x: -0.1, y: 0 },
|
|
handleOut: { x: 0.1, y: 0 },
|
|
};
|
|
}
|
|
|
|
// Calculate slope from surrounding points
|
|
let slope = 0;
|
|
|
|
if (prevPoint && nextPoint) {
|
|
// Use average slope from both neighbors
|
|
const timeDelta = nextPoint.time - prevPoint.time;
|
|
const valueDelta = nextPoint.value - prevPoint.value;
|
|
slope = valueDelta / timeDelta;
|
|
} else if (nextPoint) {
|
|
// Only have next point
|
|
const timeDelta = nextPoint.time - currentPoint.time;
|
|
const valueDelta = nextPoint.value - currentPoint.value;
|
|
slope = valueDelta / timeDelta;
|
|
} else if (prevPoint) {
|
|
// Only have previous point
|
|
const timeDelta = currentPoint.time - prevPoint.time;
|
|
const valueDelta = currentPoint.value - prevPoint.value;
|
|
slope = valueDelta / timeDelta;
|
|
}
|
|
|
|
// Create handles with 1/3 distance to neighbors
|
|
const handleDistance = 0.1; // Fixed distance for smooth curves
|
|
const handleY = slope * handleDistance;
|
|
|
|
return {
|
|
handleIn: { x: -handleDistance, y: -handleY },
|
|
handleOut: { x: handleDistance, y: handleY },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Generate points along a bezier curve for rendering
|
|
* Returns array of {time, value} points
|
|
*/
|
|
export function generateBezierCurvePoints(
|
|
p0: AutomationPoint,
|
|
p1: AutomationPoint,
|
|
numPoints: number = 50
|
|
): Array<{ time: number; value: number }> {
|
|
const points: Array<{ time: number; value: number }> = [];
|
|
const timeDelta = p1.time - p0.time;
|
|
|
|
for (let i = 0; i <= numPoints; i++) {
|
|
const t = i / numPoints;
|
|
const time = p0.time + t * timeDelta;
|
|
const value = interpolateBezier(p0, p1, t);
|
|
points.push({ time, value });
|
|
}
|
|
|
|
return points;
|
|
}
|
|
|
|
/**
|
|
* Apply automation value to track parameter
|
|
*/
|
|
export function applyAutomationToTrack(
|
|
track: any,
|
|
parameterId: AutomationParameterId,
|
|
value: number
|
|
): any {
|
|
if (parameterId === 'volume') {
|
|
return { ...track, volume: value };
|
|
}
|
|
|
|
if (parameterId === 'pan') {
|
|
// Convert 0-1 to -1-1
|
|
return { ...track, pan: value * 2 - 1 };
|
|
}
|
|
|
|
// Effect parameters (format: "effect.{effectId}.{paramName}")
|
|
if (parameterId.startsWith('effect.')) {
|
|
const parts = parameterId.split('.');
|
|
if (parts.length === 3) {
|
|
const [, effectId, paramName] = parts;
|
|
return {
|
|
...track,
|
|
effectChain: {
|
|
...track.effectChain,
|
|
effects: track.effectChain.effects.map((effect: any) =>
|
|
effect.id === effectId
|
|
? {
|
|
...effect,
|
|
parameters: {
|
|
...effect.parameters,
|
|
[paramName]: value,
|
|
},
|
|
}
|
|
: effect
|
|
),
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
return track;
|
|
}
|