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:
@@ -16,7 +16,15 @@ export interface TrackLevel {
|
||||
level: number;
|
||||
}
|
||||
|
||||
export function useMultiTrackPlayer(tracks: Track[], masterVolume: number = 1) {
|
||||
export interface AutomationRecordingCallback {
|
||||
(trackId: string, laneId: string, currentTime: number, value: number): void;
|
||||
}
|
||||
|
||||
export function useMultiTrackPlayer(
|
||||
tracks: Track[],
|
||||
masterVolume: number = 1,
|
||||
onRecordAutomation?: AutomationRecordingCallback
|
||||
) {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
@@ -36,12 +44,19 @@ export function useMultiTrackPlayer(tracks: Track[], masterVolume: number = 1) {
|
||||
const automationFrameRef = useRef<number | null>(null);
|
||||
const isMonitoringLevelsRef = useRef<boolean>(false);
|
||||
const tracksRef = useRef<Track[]>(tracks); // Always keep latest tracks
|
||||
const lastRecordedValuesRef = useRef<Map<string, number>>(new Map()); // Track last recorded values to detect changes
|
||||
const onRecordAutomationRef = useRef<AutomationRecordingCallback | undefined>(onRecordAutomation);
|
||||
|
||||
// Keep tracksRef in sync with tracks prop
|
||||
useEffect(() => {
|
||||
tracksRef.current = tracks;
|
||||
}, [tracks]);
|
||||
|
||||
// Keep onRecordAutomationRef in sync
|
||||
useEffect(() => {
|
||||
onRecordAutomationRef.current = onRecordAutomation;
|
||||
}, [onRecordAutomation]);
|
||||
|
||||
// Calculate total duration from all tracks
|
||||
useEffect(() => {
|
||||
let maxDuration = 0;
|
||||
@@ -107,16 +122,32 @@ export function useMultiTrackPlayer(tracks: Track[], masterVolume: number = 1) {
|
||||
|
||||
const currentTime = pausedAtRef.current + (audioContextRef.current.currentTime - startTimeRef.current);
|
||||
|
||||
tracks.forEach((track, index) => {
|
||||
tracksRef.current.forEach((track, index) => {
|
||||
// Apply volume automation
|
||||
const volumeLane = track.automation.lanes.find(lane => lane.parameterId === 'volume');
|
||||
if (volumeLane && volumeLane.points.length > 0) {
|
||||
const automatedValue = evaluateAutomationLinear(volumeLane.points, currentTime);
|
||||
if (automatedValue !== undefined && gainNodesRef.current[index]) {
|
||||
if (volumeLane) {
|
||||
let volumeValue: number | undefined;
|
||||
|
||||
// In write mode, record current track volume (only if value changed)
|
||||
if (volumeLane.mode === 'write' && onRecordAutomationRef.current) {
|
||||
volumeValue = track.volume;
|
||||
const lastValue = lastRecordedValuesRef.current.get(`${track.id}-volume`);
|
||||
|
||||
// Only record if value has changed
|
||||
if (lastValue === undefined || Math.abs(lastValue - volumeValue) > 0.001) {
|
||||
lastRecordedValuesRef.current.set(`${track.id}-volume`, volumeValue);
|
||||
onRecordAutomationRef.current(track.id, volumeLane.id, currentTime, volumeValue);
|
||||
}
|
||||
} else if (volumeLane.points.length > 0) {
|
||||
// Otherwise play back automation
|
||||
volumeValue = evaluateAutomationLinear(volumeLane.points, currentTime);
|
||||
}
|
||||
|
||||
if (volumeValue !== undefined && gainNodesRef.current[index]) {
|
||||
const trackGain = getTrackGain(track, tracks);
|
||||
// Apply both track gain (mute/solo) and automated volume
|
||||
gainNodesRef.current[index].gain.setValueAtTime(
|
||||
trackGain * automatedValue,
|
||||
trackGain * volumeValue,
|
||||
audioContextRef.current!.currentTime
|
||||
);
|
||||
}
|
||||
@@ -124,8 +155,24 @@ export function useMultiTrackPlayer(tracks: Track[], masterVolume: number = 1) {
|
||||
|
||||
// Apply pan automation
|
||||
const panLane = track.automation.lanes.find(lane => lane.parameterId === 'pan');
|
||||
if (panLane && panLane.points.length > 0) {
|
||||
const automatedValue = evaluateAutomationLinear(panLane.points, currentTime);
|
||||
if (panLane) {
|
||||
let automatedValue: number | undefined;
|
||||
|
||||
// In write mode, record current track pan (only if value changed)
|
||||
if (panLane.mode === 'write' && onRecordAutomationRef.current) {
|
||||
automatedValue = (track.pan + 1) / 2; // Convert -1 to 1 -> 0 to 1
|
||||
const lastValue = lastRecordedValuesRef.current.get(`${track.id}-pan`);
|
||||
|
||||
// Only record if value has changed
|
||||
if (lastValue === undefined || Math.abs(lastValue - automatedValue) > 0.001) {
|
||||
lastRecordedValuesRef.current.set(`${track.id}-pan`, automatedValue);
|
||||
onRecordAutomationRef.current(track.id, panLane.id, currentTime, automatedValue);
|
||||
}
|
||||
} else if (panLane.points.length > 0) {
|
||||
// Otherwise play back automation
|
||||
automatedValue = evaluateAutomationLinear(panLane.points, currentTime);
|
||||
}
|
||||
|
||||
if (automatedValue !== undefined && panNodesRef.current[index]) {
|
||||
// Pan automation values are 0-1, but StereoPannerNode expects -1 to 1
|
||||
const panValue = (automatedValue * 2) - 1;
|
||||
@@ -139,7 +186,7 @@ export function useMultiTrackPlayer(tracks: Track[], masterVolume: number = 1) {
|
||||
// Apply effect parameter automation
|
||||
track.automation.lanes.forEach(lane => {
|
||||
// Check if this is an effect parameter (format: effect.{effectId}.{parameterName})
|
||||
if (lane.parameterId.startsWith('effect.') && lane.points.length > 0) {
|
||||
if (lane.parameterId.startsWith('effect.')) {
|
||||
const parts = lane.parameterId.split('.');
|
||||
if (parts.length === 3) {
|
||||
const effectId = parts[1];
|
||||
@@ -147,13 +194,37 @@ export function useMultiTrackPlayer(tracks: Track[], masterVolume: number = 1) {
|
||||
|
||||
// Find the effect in the track's effect chain
|
||||
const effectIndex = track.effectChain.effects.findIndex(e => e.id === effectId);
|
||||
if (effectIndex >= 0 && effectNodesRef.current[index] && effectNodesRef.current[index][effectIndex]) {
|
||||
const automatedValue = evaluateAutomationLinear(lane.points, currentTime);
|
||||
if (automatedValue !== undefined) {
|
||||
const effect = track.effectChain.effects[effectIndex];
|
||||
|
||||
if (effectIndex >= 0 && effect) {
|
||||
let automatedValue: number | undefined;
|
||||
|
||||
// In write mode, record current effect parameter value (only if value changed)
|
||||
if (lane.mode === 'write' && onRecordAutomationRef.current && effect.parameters) {
|
||||
const currentValue = (effect.parameters as any)[paramName];
|
||||
if (currentValue !== undefined) {
|
||||
// Normalize value to 0-1 range
|
||||
const range = lane.valueRange.max - lane.valueRange.min;
|
||||
const normalizedValue = (currentValue - lane.valueRange.min) / range;
|
||||
|
||||
const lastValue = lastRecordedValuesRef.current.get(`${track.id}-effect-${effectId}-${paramName}`);
|
||||
|
||||
// Only record if value has changed
|
||||
if (lastValue === undefined || Math.abs(lastValue - normalizedValue) > 0.001) {
|
||||
lastRecordedValuesRef.current.set(`${track.id}-effect-${effectId}-${paramName}`, normalizedValue);
|
||||
onRecordAutomationRef.current(track.id, lane.id, currentTime, normalizedValue);
|
||||
}
|
||||
}
|
||||
} else if (lane.points.length > 0) {
|
||||
// Otherwise play back automation
|
||||
automatedValue = evaluateAutomationLinear(lane.points, currentTime);
|
||||
}
|
||||
|
||||
// Apply the automated value to the effect
|
||||
if (automatedValue !== undefined && effectNodesRef.current[index] && effectNodesRef.current[index][effectIndex]) {
|
||||
const effectNodeInfo = effectNodesRef.current[index][effectIndex];
|
||||
|
||||
// Convert normalized 0-1 value to actual parameter range
|
||||
const effect = track.effectChain.effects[effectIndex];
|
||||
const actualValue = lane.valueRange.min + (automatedValue * (lane.valueRange.max - lane.valueRange.min));
|
||||
|
||||
// Update the effect parameter
|
||||
@@ -172,7 +243,7 @@ export function useMultiTrackPlayer(tracks: Track[], masterVolume: number = 1) {
|
||||
});
|
||||
|
||||
automationFrameRef.current = requestAnimationFrame(applyAutomation);
|
||||
}, [tracks]);
|
||||
}, []);
|
||||
|
||||
const updatePlaybackPosition = useCallback(() => {
|
||||
if (!audioContextRef.current) return;
|
||||
@@ -356,6 +427,8 @@ export function useMultiTrackPlayer(tracks: Track[], masterVolume: number = 1) {
|
||||
pause();
|
||||
pausedAtRef.current = 0;
|
||||
setCurrentTime(0);
|
||||
// Clear last recorded values when stopping
|
||||
lastRecordedValuesRef.current.clear();
|
||||
}, [pause]);
|
||||
|
||||
const seek = useCallback((time: number) => {
|
||||
|
||||
Reference in New Issue
Block a user