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:
@@ -1,11 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Music, Plus, Upload, Trash2, Settings } from 'lucide-react';
|
||||
import { Music, Plus, Upload, Trash2, Settings, Download } from 'lucide-react';
|
||||
import { PlaybackControls } from './PlaybackControls';
|
||||
import { ThemeToggle } from '@/components/layout/ThemeToggle';
|
||||
import { CommandPalette } from '@/components/ui/CommandPalette';
|
||||
import { GlobalSettingsDialog } from '@/components/settings/GlobalSettingsDialog';
|
||||
import { ExportDialog, type ExportSettings } from '@/components/dialogs/ExportDialog';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import type { CommandAction } from '@/components/ui/CommandPalette';
|
||||
import { useMultiTrack } from '@/lib/hooks/useMultiTrack';
|
||||
@@ -26,6 +27,8 @@ import {
|
||||
createMultiTrackDuplicateCommand,
|
||||
} from '@/lib/history/commands/multi-track-edit-command';
|
||||
import { extractBufferSegment } from '@/lib/audio/buffer-utils';
|
||||
import { mixTracks, getMaxTrackDuration } from '@/lib/audio/track-utils';
|
||||
import { audioBufferToWav, downloadArrayBuffer } from '@/lib/audio/export';
|
||||
|
||||
export function AudioEditor() {
|
||||
const [importDialogOpen, setImportDialogOpen] = React.useState(false);
|
||||
@@ -39,6 +42,8 @@ export function AudioEditor() {
|
||||
const [punchOutTime, setPunchOutTime] = React.useState(0);
|
||||
const [overdubEnabled, setOverdubEnabled] = React.useState(false);
|
||||
const [settingsDialogOpen, setSettingsDialogOpen] = React.useState(false);
|
||||
const [exportDialogOpen, setExportDialogOpen] = React.useState(false);
|
||||
const [isExporting, setIsExporting] = React.useState(false);
|
||||
|
||||
const { addToast } = useToast();
|
||||
|
||||
@@ -102,14 +107,112 @@ export function AudioEditor() {
|
||||
return track;
|
||||
}, [addTrackFromBufferOriginal]);
|
||||
|
||||
// Log tracks to see if they update
|
||||
React.useEffect(() => {
|
||||
console.log('[AudioEditor] Tracks updated:', tracks.map(t => ({
|
||||
name: t.name,
|
||||
effectCount: t.effectChain.effects.length,
|
||||
effects: t.effectChain.effects.map(e => e.name)
|
||||
})));
|
||||
}, [tracks]);
|
||||
// Track which parameters are being touched (for touch/latch modes)
|
||||
const [touchedParameters, setTouchedParameters] = React.useState<Set<string>>(new Set());
|
||||
const [latchTriggered, setLatchTriggered] = React.useState<Set<string>>(new Set());
|
||||
|
||||
// Track last recorded values to detect changes
|
||||
const lastRecordedValuesRef = React.useRef<Map<string, { value: number; time: number }>>(new Map());
|
||||
|
||||
// Automation recording callback
|
||||
const handleAutomationRecording = React.useCallback((
|
||||
trackId: string,
|
||||
laneId: string,
|
||||
currentTime: number,
|
||||
value: number
|
||||
) => {
|
||||
const track = tracks.find(t => t.id === trackId);
|
||||
if (!track) return;
|
||||
|
||||
const lane = track.automation.lanes.find(l => l.id === laneId);
|
||||
if (!lane) return;
|
||||
|
||||
const paramKey = `${trackId}-${laneId}`;
|
||||
let shouldRecord = false;
|
||||
|
||||
// Determine if we should record based on mode
|
||||
switch (lane.mode) {
|
||||
case 'write':
|
||||
// Always record in write mode
|
||||
shouldRecord = true;
|
||||
break;
|
||||
|
||||
case 'touch':
|
||||
// Only record when parameter is being touched
|
||||
shouldRecord = touchedParameters.has(paramKey);
|
||||
break;
|
||||
|
||||
case 'latch':
|
||||
// Record from first touch until stop
|
||||
if (touchedParameters.has(paramKey)) {
|
||||
setLatchTriggered(prev => new Set(prev).add(paramKey));
|
||||
}
|
||||
shouldRecord = latchTriggered.has(paramKey);
|
||||
break;
|
||||
|
||||
default:
|
||||
shouldRecord = false;
|
||||
}
|
||||
|
||||
if (!shouldRecord) return;
|
||||
|
||||
// Throttle recording to avoid creating too many automation points
|
||||
// This doesn't prevent recording, just limits frequency
|
||||
const lastRecorded = lastRecordedValuesRef.current.get(paramKey);
|
||||
|
||||
if (lastRecorded && currentTime - lastRecorded.time < 0.1) {
|
||||
// Check if value has changed significantly
|
||||
const valueChanged = Math.abs(lastRecorded.value - value) > 0.001;
|
||||
if (!valueChanged) {
|
||||
// Skip if value hasn't changed and we recorded recently
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Update last recorded value
|
||||
lastRecordedValuesRef.current.set(paramKey, { value, time: currentTime });
|
||||
|
||||
// Create new automation point
|
||||
const newPoint = {
|
||||
id: `point-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
time: currentTime,
|
||||
value,
|
||||
curve: 'linear' as const,
|
||||
};
|
||||
|
||||
// In write mode, remove existing points near this time (overwrites)
|
||||
const updatedPoints = lane.mode === 'write'
|
||||
? [...lane.points.filter(p => Math.abs(p.time - currentTime) > 0.05), newPoint]
|
||||
: [...lane.points, newPoint];
|
||||
|
||||
updatedPoints.sort((a, b) => a.time - b.time);
|
||||
|
||||
// Update the lane with new points
|
||||
const updatedLanes = track.automation.lanes.map(l =>
|
||||
l.id === laneId ? { ...l, points: updatedPoints } : l
|
||||
);
|
||||
|
||||
updateTrack(trackId, {
|
||||
automation: {
|
||||
...track.automation,
|
||||
lanes: updatedLanes,
|
||||
},
|
||||
});
|
||||
}, [tracks, updateTrack, touchedParameters, latchTriggered]);
|
||||
|
||||
// Helper to mark parameter as touched (for touch/latch modes)
|
||||
const setParameterTouched = React.useCallback((trackId: string, laneId: string, touched: boolean) => {
|
||||
const paramKey = `${trackId}-${laneId}`;
|
||||
setTouchedParameters(prev => {
|
||||
const next = new Set(prev);
|
||||
if (touched) {
|
||||
next.add(paramKey);
|
||||
} else {
|
||||
next.delete(paramKey);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const {
|
||||
isPlaying,
|
||||
@@ -121,7 +224,56 @@ export function AudioEditor() {
|
||||
stop,
|
||||
seek,
|
||||
togglePlayPause,
|
||||
} = useMultiTrackPlayer(tracks, masterVolume);
|
||||
} = useMultiTrackPlayer(tracks, masterVolume, handleAutomationRecording);
|
||||
|
||||
// Reset latch triggered state when playback stops
|
||||
React.useEffect(() => {
|
||||
if (!isPlaying) {
|
||||
setLatchTriggered(new Set());
|
||||
lastRecordedValuesRef.current.clear();
|
||||
}
|
||||
}, [isPlaying]);
|
||||
|
||||
// Record effect parameter values while touched
|
||||
React.useEffect(() => {
|
||||
if (!isPlaying) return;
|
||||
|
||||
const recordEffectParams = () => {
|
||||
const time = currentTime;
|
||||
|
||||
touchedParameters.forEach(paramKey => {
|
||||
const [trackId, laneId] = paramKey.split('-');
|
||||
const track = tracks.find(t => t.id === trackId);
|
||||
if (!track) return;
|
||||
|
||||
const lane = track.automation.lanes.find(l => l.id === laneId);
|
||||
if (!lane || !lane.parameterId.startsWith('effect.')) return;
|
||||
|
||||
// Parse effect parameter ID: effect.{effectId}.{paramName}
|
||||
const parts = lane.parameterId.split('.');
|
||||
if (parts.length !== 3) return;
|
||||
|
||||
const effectId = parts[1];
|
||||
const paramName = parts[2];
|
||||
|
||||
const effect = track.effectChain.effects.find(e => e.id === effectId);
|
||||
if (!effect || !effect.parameters) return;
|
||||
|
||||
const currentValue = (effect.parameters as any)[paramName];
|
||||
if (currentValue === undefined) return;
|
||||
|
||||
// Normalize value to 0-1 range
|
||||
const range = lane.valueRange.max - lane.valueRange.min;
|
||||
const normalizedValue = (currentValue - lane.valueRange.min) / range;
|
||||
|
||||
// Record the automation
|
||||
handleAutomationRecording(trackId, laneId, time, normalizedValue);
|
||||
});
|
||||
};
|
||||
|
||||
const interval = setInterval(recordEffectParams, 50); // Record every 50ms while touched
|
||||
return () => clearInterval(interval);
|
||||
}, [isPlaying, currentTime, touchedParameters, tracks, handleAutomationRecording]);
|
||||
|
||||
// Master effect chain
|
||||
const {
|
||||
@@ -549,6 +701,60 @@ export function AudioEditor() {
|
||||
});
|
||||
}, [tracks, executeCommand, updateTrack, addToast]);
|
||||
|
||||
// Export handler
|
||||
const handleExport = React.useCallback(async (settings: ExportSettings) => {
|
||||
if (tracks.length === 0) {
|
||||
addToast({
|
||||
title: 'No Tracks',
|
||||
description: 'Add some tracks before exporting',
|
||||
variant: 'warning',
|
||||
duration: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsExporting(true);
|
||||
|
||||
try {
|
||||
// Get max duration and sample rate
|
||||
const maxDuration = getMaxTrackDuration(tracks);
|
||||
const sampleRate = tracks[0]?.audioBuffer?.sampleRate || 44100;
|
||||
|
||||
// Mix all tracks into a single buffer
|
||||
const mixedBuffer = mixTracks(tracks, sampleRate, maxDuration);
|
||||
|
||||
// Convert to WAV
|
||||
const wavBuffer = audioBufferToWav(mixedBuffer, {
|
||||
format: settings.format,
|
||||
bitDepth: settings.bitDepth,
|
||||
normalize: settings.normalize,
|
||||
});
|
||||
|
||||
// Download
|
||||
const filename = `${settings.filename}.wav`;
|
||||
downloadArrayBuffer(wavBuffer, filename);
|
||||
|
||||
addToast({
|
||||
title: 'Export Complete',
|
||||
description: `Exported ${filename}`,
|
||||
variant: 'success',
|
||||
duration: 3000,
|
||||
});
|
||||
|
||||
setExportDialogOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error);
|
||||
addToast({
|
||||
title: 'Export Failed',
|
||||
description: 'Failed to export audio',
|
||||
variant: 'error',
|
||||
duration: 3000,
|
||||
});
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
}, [tracks, addToast]);
|
||||
|
||||
// Zoom controls
|
||||
const handleZoomIn = () => {
|
||||
setZoom((prev) => Math.min(20, prev + 1));
|
||||
@@ -765,10 +971,16 @@ export function AudioEditor() {
|
||||
Import
|
||||
</Button>
|
||||
{tracks.length > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={handleClearTracks}>
|
||||
<Trash2 className="h-4 w-4 mr-1.5 text-destructive" />
|
||||
Clear All
|
||||
</Button>
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setExportDialogOpen(true)}>
|
||||
<Download className="h-4 w-4 mr-1.5" />
|
||||
Export
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleClearTracks}>
|
||||
<Trash2 className="h-4 w-4 mr-1.5 text-destructive" />
|
||||
Clear All
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -811,6 +1023,8 @@ export function AudioEditor() {
|
||||
recordingTrackId={recordingTrackId}
|
||||
recordingLevel={recordingState.inputLevel}
|
||||
trackLevels={trackLevels}
|
||||
onParameterTouched={setParameterTouched}
|
||||
isPlaying={isPlaying}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
@@ -861,6 +1075,14 @@ export function AudioEditor() {
|
||||
onRecordMonoChange={setRecordMono}
|
||||
onSampleRateChange={setSampleRate}
|
||||
/>
|
||||
|
||||
{/* Export Dialog */}
|
||||
<ExportDialog
|
||||
open={exportDialogOpen}
|
||||
onClose={() => setExportDialogOpen(false)}
|
||||
onExport={handleExport}
|
||||
isExporting={isExporting}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user