feat: complete Phase 9.3 - automation recording with write/touch/latch modes
Implemented comprehensive automation recording system for volume, pan, and effect parameters: - Added automation recording modes: - Write: Records continuously during playback when values change - Touch: Records only while control is being touched/moved - Latch: Records from first touch until playback stops - Implemented value change detection (0.001 threshold) to prevent infinite loops - Fixed React setState-in-render errors by: - Using queueMicrotask() to defer state updates - Moving lane creation logic to useEffect - Properly memoizing touch handlers with useMemo - Added proper value ranges for effect parameters: - Frequency: 20-20000 Hz - Q: 0.1-20 - Gain: -40-40 dB - Enhanced automation lane auto-creation with parameter-specific ranges - Added touch callbacks to all parameter controls (volume, pan, effects) - Implemented throttling (100ms) to avoid excessive automation points Technical improvements: - Used tracksRef and onRecordAutomationRef to ensure latest values in animation loops - Added proper cleanup on playback stop - Optimized recording to only trigger when values actually change 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
173
lib/hooks/useAutomationRecording.ts
Normal file
173
lib/hooks/useAutomationRecording.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Hook for recording automation data during playback
|
||||
* Supports write, touch, and latch modes
|
||||
*/
|
||||
|
||||
import { useCallback, useRef } from 'react';
|
||||
import type { Track } from '@/types/track';
|
||||
import type { AutomationPoint, AutomationMode } from '@/types/automation';
|
||||
|
||||
export interface AutomationRecordingState {
|
||||
isRecording: boolean;
|
||||
recordingLaneId: string | null;
|
||||
touchActive: boolean; // For touch mode - tracks if control is being touched
|
||||
latchTriggered: boolean; // For latch mode - tracks if recording has started
|
||||
}
|
||||
|
||||
export function useAutomationRecording(
|
||||
track: Track,
|
||||
onUpdateTrack: (trackId: string, updates: Partial<Track>) => void
|
||||
) {
|
||||
const recordingStateRef = useRef<Map<string, AutomationRecordingState>>(new Map());
|
||||
const recordingIntervalRef = useRef<Map<string, number>>(new Map());
|
||||
const lastRecordedValueRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
/**
|
||||
* Start recording automation for a specific lane
|
||||
*/
|
||||
const startRecording = useCallback((laneId: string, mode: AutomationMode) => {
|
||||
const state: AutomationRecordingState = {
|
||||
isRecording: mode === 'write',
|
||||
recordingLaneId: laneId,
|
||||
touchActive: false,
|
||||
latchTriggered: false,
|
||||
};
|
||||
recordingStateRef.current.set(laneId, state);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Stop recording automation for a specific lane
|
||||
*/
|
||||
const stopRecording = useCallback((laneId: string) => {
|
||||
recordingStateRef.current.delete(laneId);
|
||||
const intervalId = recordingIntervalRef.current.get(laneId);
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
recordingIntervalRef.current.delete(laneId);
|
||||
}
|
||||
lastRecordedValueRef.current.delete(laneId);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Record a single automation point
|
||||
*/
|
||||
const recordPoint = useCallback((
|
||||
laneId: string,
|
||||
currentTime: number,
|
||||
value: number,
|
||||
mode: AutomationMode
|
||||
) => {
|
||||
const lane = track.automation.lanes.find(l => l.id === laneId);
|
||||
if (!lane) return;
|
||||
|
||||
const state = recordingStateRef.current.get(laneId);
|
||||
if (!state) return;
|
||||
|
||||
// Check if we should record based on mode
|
||||
let shouldRecord = false;
|
||||
|
||||
switch (mode) {
|
||||
case 'write':
|
||||
// Always record in write mode
|
||||
shouldRecord = true;
|
||||
break;
|
||||
|
||||
case 'touch':
|
||||
// Only record when control is being touched
|
||||
shouldRecord = state.touchActive;
|
||||
break;
|
||||
|
||||
case 'latch':
|
||||
// Record from first touch until stop
|
||||
if (state.touchActive && !state.latchTriggered) {
|
||||
state.latchTriggered = true;
|
||||
}
|
||||
shouldRecord = state.latchTriggered;
|
||||
break;
|
||||
|
||||
default:
|
||||
shouldRecord = false;
|
||||
}
|
||||
|
||||
if (!shouldRecord) return;
|
||||
|
||||
// Check if value has changed significantly (avoid redundant points)
|
||||
const lastValue = lastRecordedValueRef.current.get(laneId);
|
||||
if (lastValue !== undefined && Math.abs(lastValue - value) < 0.001) {
|
||||
return; // Skip if value hasn't changed
|
||||
}
|
||||
|
||||
lastRecordedValueRef.current.set(laneId, value);
|
||||
|
||||
// In write mode, clear existing points in the time range
|
||||
let updatedPoints = [...lane.points];
|
||||
if (mode === 'write') {
|
||||
// Remove points that are within a small time window of current time
|
||||
updatedPoints = updatedPoints.filter(p =>
|
||||
Math.abs(p.time - currentTime) > 0.05 // 50ms threshold
|
||||
);
|
||||
}
|
||||
|
||||
// Add new point
|
||||
const newPoint: AutomationPoint = {
|
||||
id: `point-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
time: currentTime,
|
||||
value,
|
||||
curve: 'linear',
|
||||
};
|
||||
|
||||
updatedPoints.push(newPoint);
|
||||
|
||||
// Sort points by time
|
||||
updatedPoints.sort((a, b) => a.time - b.time);
|
||||
|
||||
// Update track with new automation points
|
||||
const updatedLanes = track.automation.lanes.map(l =>
|
||||
l.id === laneId ? { ...l, points: updatedPoints } : l
|
||||
);
|
||||
|
||||
onUpdateTrack(track.id, {
|
||||
automation: {
|
||||
...track.automation,
|
||||
lanes: updatedLanes,
|
||||
},
|
||||
});
|
||||
}, [track, onUpdateTrack]);
|
||||
|
||||
/**
|
||||
* Set touch state for touch mode
|
||||
*/
|
||||
const setTouchActive = useCallback((laneId: string, active: boolean) => {
|
||||
const state = recordingStateRef.current.get(laneId);
|
||||
if (state) {
|
||||
state.touchActive = active;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Check if a lane is currently recording
|
||||
*/
|
||||
const isRecordingLane = useCallback((laneId: string): boolean => {
|
||||
const state = recordingStateRef.current.get(laneId);
|
||||
return state?.isRecording ?? false;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Cleanup - stop all recording
|
||||
*/
|
||||
const cleanup = useCallback(() => {
|
||||
recordingStateRef.current.forEach((_, laneId) => {
|
||||
stopRecording(laneId);
|
||||
});
|
||||
recordingStateRef.current.clear();
|
||||
}, [stopRecording]);
|
||||
|
||||
return {
|
||||
startRecording,
|
||||
stopRecording,
|
||||
recordPoint,
|
||||
setTouchActive,
|
||||
isRecordingLane,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user