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:
2025-11-18 23:29:18 +01:00
parent a1f230a6e6
commit c54d5089c5
13 changed files with 1040 additions and 70 deletions

View File

@@ -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>

View File

@@ -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>