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

@@ -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}
/>
</>
);
}