Files
audio-ui/lib/audio/automation/utils.ts
Sebastian Krüger 119c8c2942 feat: implement medium effort features - markers, web workers, and bezier automation
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>
2025-11-20 08:25:33 +01:00

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