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:
@@ -43,6 +43,8 @@ export interface TrackProps {
|
||||
isRecording?: boolean;
|
||||
recordingLevel?: number;
|
||||
playbackLevel?: number;
|
||||
onParameterTouched?: (trackId: string, laneId: string, touched: boolean) => void;
|
||||
isPlaying?: boolean;
|
||||
}
|
||||
|
||||
export function Track({
|
||||
@@ -71,6 +73,8 @@ export function Track({
|
||||
isRecording = false,
|
||||
recordingLevel = 0,
|
||||
playbackLevel = 0,
|
||||
onParameterTouched,
|
||||
isPlaying = false,
|
||||
}: TrackProps) {
|
||||
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -89,6 +93,123 @@ export function Track({
|
||||
const [isSelectingByDrag, setIsSelectingByDrag] = React.useState(false);
|
||||
const [dragStartPos, setDragStartPos] = React.useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
// Touch callbacks for automation recording
|
||||
const handlePanTouchStart = React.useCallback(() => {
|
||||
if (isPlaying && onParameterTouched) {
|
||||
const panLane = track.automation.lanes.find(l => l.parameterId === 'pan');
|
||||
if (panLane && (panLane.mode === 'touch' || panLane.mode === 'latch')) {
|
||||
queueMicrotask(() => onParameterTouched(track.id, panLane.id, true));
|
||||
}
|
||||
}
|
||||
}, [isPlaying, onParameterTouched, track.id, track.automation.lanes]);
|
||||
|
||||
const handlePanTouchEnd = React.useCallback(() => {
|
||||
if (isPlaying && onParameterTouched) {
|
||||
const panLane = track.automation.lanes.find(l => l.parameterId === 'pan');
|
||||
if (panLane && (panLane.mode === 'touch' || panLane.mode === 'latch')) {
|
||||
queueMicrotask(() => onParameterTouched(track.id, panLane.id, false));
|
||||
}
|
||||
}
|
||||
}, [isPlaying, onParameterTouched, track.id, track.automation.lanes]);
|
||||
|
||||
const handleVolumeTouchStart = React.useCallback(() => {
|
||||
if (isPlaying && onParameterTouched) {
|
||||
const volumeLane = track.automation.lanes.find(l => l.parameterId === 'volume');
|
||||
if (volumeLane && (volumeLane.mode === 'touch' || volumeLane.mode === 'latch')) {
|
||||
queueMicrotask(() => onParameterTouched(track.id, volumeLane.id, true));
|
||||
}
|
||||
}
|
||||
}, [isPlaying, onParameterTouched, track.id, track.automation.lanes]);
|
||||
|
||||
const handleVolumeTouchEnd = React.useCallback(() => {
|
||||
if (isPlaying && onParameterTouched) {
|
||||
const volumeLane = track.automation.lanes.find(l => l.parameterId === 'volume');
|
||||
if (volumeLane && (volumeLane.mode === 'touch' || volumeLane.mode === 'latch')) {
|
||||
queueMicrotask(() => onParameterTouched(track.id, volumeLane.id, false));
|
||||
}
|
||||
}
|
||||
}, [isPlaying, onParameterTouched, track.id, track.automation.lanes]);
|
||||
|
||||
// Auto-create automation lane for selected parameter if it doesn't exist
|
||||
React.useEffect(() => {
|
||||
if (!track.automation?.showAutomation) return;
|
||||
|
||||
const selectedParameterId = track.automation.selectedParameterId || 'volume';
|
||||
const laneExists = track.automation.lanes.some(lane => lane.parameterId === selectedParameterId);
|
||||
|
||||
if (!laneExists) {
|
||||
// Build list of available parameters
|
||||
const availableParameters: Array<{ id: string; name: string }> = [
|
||||
{ id: 'volume', name: 'Volume' },
|
||||
{ id: 'pan', name: 'Pan' },
|
||||
];
|
||||
|
||||
track.effectChain.effects.forEach((effect) => {
|
||||
if (effect.parameters) {
|
||||
Object.keys(effect.parameters).forEach((paramKey) => {
|
||||
const parameterId = `effect.${effect.id}.${paramKey}`;
|
||||
const paramName = `${effect.name} - ${paramKey.charAt(0).toUpperCase() + paramKey.slice(1)}`;
|
||||
availableParameters.push({ id: parameterId, name: paramName });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const paramInfo = availableParameters.find(p => p.id === selectedParameterId);
|
||||
if (paramInfo) {
|
||||
// Determine value range based on parameter type
|
||||
let valueRange = { min: 0, max: 1 };
|
||||
let unit = '';
|
||||
let formatter: ((value: number) => string) | undefined;
|
||||
|
||||
if (selectedParameterId === 'volume') {
|
||||
unit = 'dB';
|
||||
} else if (selectedParameterId === 'pan') {
|
||||
formatter = (value: number) => {
|
||||
if (value === 0.5) return 'C';
|
||||
if (value < 0.5) return `${Math.abs((0.5 - value) * 200).toFixed(0)}L`;
|
||||
return `${((value - 0.5) * 200).toFixed(0)}R`;
|
||||
};
|
||||
} else if (selectedParameterId.startsWith('effect.')) {
|
||||
// Parse effect parameter: effect.{effectId}.{paramName}
|
||||
const parts = selectedParameterId.split('.');
|
||||
if (parts.length === 3) {
|
||||
const paramName = parts[2];
|
||||
// Set ranges based on parameter name
|
||||
if (paramName === 'frequency') {
|
||||
valueRange = { min: 20, max: 20000 };
|
||||
unit = 'Hz';
|
||||
} else if (paramName === 'Q') {
|
||||
valueRange = { min: 0.1, max: 20 };
|
||||
} else if (paramName === 'gain') {
|
||||
valueRange = { min: -40, max: 40 };
|
||||
unit = 'dB';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newLane = createAutomationLane(
|
||||
track.id,
|
||||
selectedParameterId,
|
||||
paramInfo.name,
|
||||
{
|
||||
min: valueRange.min,
|
||||
max: valueRange.max,
|
||||
unit,
|
||||
formatter,
|
||||
}
|
||||
);
|
||||
|
||||
onUpdateTrack(track.id, {
|
||||
automation: {
|
||||
...track.automation,
|
||||
lanes: [...track.automation.lanes, newLane],
|
||||
selectedParameterId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [track.automation?.showAutomation, track.automation?.selectedParameterId, track.automation?.lanes, track.effectChain.effects, track.id, onUpdateTrack]);
|
||||
|
||||
const handleNameClick = () => {
|
||||
setIsEditingName(true);
|
||||
setNameInput(String(track.name || 'Untitled Track'));
|
||||
@@ -536,6 +657,8 @@ export function Track({
|
||||
step={0.01}
|
||||
size={48}
|
||||
label="PAN"
|
||||
onTouchStart={handlePanTouchStart}
|
||||
onTouchEnd={handlePanTouchEnd}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -549,6 +672,8 @@ export function Track({
|
||||
max={1}
|
||||
step={0.01}
|
||||
showDb={true}
|
||||
onTouchStart={handleVolumeTouchStart}
|
||||
onTouchEnd={handleVolumeTouchEnd}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -735,35 +860,8 @@ export function Track({
|
||||
// Find or create lane for selected parameter
|
||||
let selectedLane = track.automation.lanes.find(lane => lane.parameterId === selectedParameterId);
|
||||
|
||||
// If lane doesn't exist yet, create it
|
||||
if (!selectedLane) {
|
||||
const paramInfo = availableParameters.find(p => p.id === selectedParameterId);
|
||||
if (paramInfo) {
|
||||
selectedLane = createAutomationLane(
|
||||
track.id,
|
||||
selectedParameterId,
|
||||
paramInfo.name,
|
||||
{
|
||||
min: 0,
|
||||
max: 1,
|
||||
unit: selectedParameterId === 'volume' ? 'dB' : '',
|
||||
formatter: selectedParameterId === 'pan' ? (value: number) => {
|
||||
if (value === 0.5) return 'C';
|
||||
if (value < 0.5) return `${Math.abs((0.5 - value) * 200).toFixed(0)}L`;
|
||||
return `${((value - 0.5) * 200).toFixed(0)}R`;
|
||||
} : undefined,
|
||||
}
|
||||
);
|
||||
// Add the new lane to the track
|
||||
onUpdateTrack(track.id, {
|
||||
automation: {
|
||||
...track.automation,
|
||||
lanes: [...track.automation.lanes, selectedLane],
|
||||
selectedParameterId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
// If lane doesn't exist yet, we need to create it (but not during render)
|
||||
// This will be handled by a useEffect instead
|
||||
|
||||
const modes: Array<{ value: string; label: string; color: string }> = [
|
||||
{ value: 'read', label: 'R', color: 'text-muted-foreground' },
|
||||
@@ -957,6 +1055,10 @@ export function Track({
|
||||
effectChain: { ...track.effectChain, effects: updatedEffects },
|
||||
});
|
||||
}}
|
||||
trackId={track.id}
|
||||
isPlaying={isPlaying}
|
||||
onParameterTouched={onParameterTouched}
|
||||
automationLanes={track.automation.lanes}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,8 @@ export interface TrackListProps {
|
||||
recordingTrackId?: string | null;
|
||||
recordingLevel?: number;
|
||||
trackLevels?: Record<string, number>;
|
||||
onParameterTouched?: (trackId: string, laneId: string, touched: boolean) => void;
|
||||
isPlaying?: boolean;
|
||||
}
|
||||
|
||||
export function TrackList({
|
||||
@@ -44,6 +46,8 @@ export function TrackList({
|
||||
recordingTrackId,
|
||||
recordingLevel = 0,
|
||||
trackLevels = {},
|
||||
onParameterTouched,
|
||||
isPlaying = false,
|
||||
}: TrackListProps) {
|
||||
const [importDialogOpen, setImportDialogOpen] = React.useState(false);
|
||||
|
||||
@@ -168,6 +172,8 @@ export function TrackList({
|
||||
isRecording={recordingTrackId === track.id}
|
||||
recordingLevel={recordingTrackId === track.id ? recordingLevel : 0}
|
||||
playbackLevel={trackLevels[track.id] || 0}
|
||||
onParameterTouched={onParameterTouched}
|
||||
isPlaying={isPlaying}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user