Files
audio-ui/components/editor/AudioEditor.tsx
Sebastian Krüger 31af08e9f7 fix: stabilize auto-save by using ref for currentTime
The handleSaveProject callback had currentTime in its dependencies, which
caused the callback to be recreated on every playback frame update. This
made the auto-save effect reset its timer constantly, preventing auto-save
from ever triggering.

Solution: Use a ref to capture the latest currentTime value without
including it in the callback dependencies. This keeps the callback stable
while still saving the correct currentTime.

Added debug logging to track auto-save scheduling and triggering.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 10:05:21 +01:00

1566 lines
52 KiB
TypeScript

'use client';
import * as React from 'react';
import { Music, Plus, Upload, Trash2, Settings, Download, FolderOpen } from 'lucide-react';
import { PlaybackControls } from './PlaybackControls';
import { MasterControls } from '@/components/controls/MasterControls';
import { FrequencyAnalyzer } from '@/components/analysis/FrequencyAnalyzer';
import { Spectrogram } from '@/components/analysis/Spectrogram';
import { PhaseCorrelationMeter } from '@/components/analysis/PhaseCorrelationMeter';
import { LUFSMeter } from '@/components/analysis/LUFSMeter';
import { AudioStatistics } from '@/components/analysis/AudioStatistics';
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 { ProjectsDialog } from '@/components/dialogs/ProjectsDialog';
import { Button } from '@/components/ui/Button';
import type { CommandAction } from '@/components/ui/CommandPalette';
import { useMultiTrack } from '@/lib/hooks/useMultiTrack';
import { useMultiTrackPlayer } from '@/lib/hooks/useMultiTrackPlayer';
import { useEffectChain } from '@/lib/hooks/useEffectChain';
import { useToast } from '@/components/ui/Toast';
import { TrackList } from '@/components/tracks/TrackList';
import { ImportTrackDialog } from '@/components/tracks/ImportTrackDialog';
import { formatDuration } from '@/lib/audio/decoder';
import { useHistory } from '@/lib/hooks/useHistory';
import { useRecording } from '@/lib/hooks/useRecording';
import type { EffectType } from '@/lib/audio/effects/chain';
import {
createMultiTrackCutCommand,
createMultiTrackCopyCommand,
createMultiTrackDeleteCommand,
createMultiTrackPasteCommand,
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, audioBufferToMp3, downloadArrayBuffer } from '@/lib/audio/export';
import {
saveCurrentProject,
loadProjectById,
listProjects,
removeProject,
duplicateProject,
type ProjectMetadata,
} from '@/lib/storage/projects';
import { getAudioContext } from '@/lib/audio/context';
export function AudioEditor() {
const [importDialogOpen, setImportDialogOpen] = React.useState(false);
const [selectedTrackId, setSelectedTrackId] = React.useState<string | null>(null);
const [zoom, setZoom] = React.useState(1);
const [masterVolume, setMasterVolume] = React.useState(0.8);
const [masterPan, setMasterPan] = React.useState(0);
const [isMasterMuted, setIsMasterMuted] = React.useState(false);
const [clipboard, setClipboard] = React.useState<AudioBuffer | null>(null);
const [recordingTrackId, setRecordingTrackId] = React.useState<string | null>(null);
const [punchInEnabled, setPunchInEnabled] = React.useState(false);
const [punchInTime, setPunchInTime] = React.useState(0);
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 [analyzerView, setAnalyzerView] = React.useState<'frequency' | 'spectrogram' | 'phase' | 'lufs' | 'stats'>('frequency');
const [projectsDialogOpen, setProjectsDialogOpen] = React.useState(false);
const [projects, setProjects] = React.useState<ProjectMetadata[]>([]);
const [currentProjectId, setCurrentProjectId] = React.useState<string | null>(null);
const [currentProjectName, setCurrentProjectName] = React.useState('Untitled Project');
const { addToast } = useToast();
// Command history for undo/redo
const { execute: executeCommand, undo, redo, state: historyState } = useHistory();
const canUndo = historyState.canUndo;
const canRedo = historyState.canRedo;
// Recording hook
const {
state: recordingState,
settings: recordingSettings,
startRecording,
stopRecording,
requestPermission,
setInputGain,
setRecordMono,
setSampleRate,
} = useRecording();
// Multi-track hooks
const {
tracks,
addTrack: addTrackOriginal,
addTrackFromBuffer: addTrackFromBufferOriginal,
removeTrack,
updateTrack,
clearTracks,
loadTracks,
} = useMultiTrack();
// Track whether we should auto-select on next add (when project is empty)
const shouldAutoSelectRef = React.useRef(true);
React.useEffect(() => {
// Update auto-select flag based on track count
shouldAutoSelectRef.current = tracks.length === 0;
}, [tracks.length]);
// Wrap addTrack to auto-select first track when adding to empty project
const addTrack = React.useCallback((name?: string) => {
const shouldAutoSelect = shouldAutoSelectRef.current;
const track = addTrackOriginal(name);
if (shouldAutoSelect) {
setSelectedTrackId(track.id);
shouldAutoSelectRef.current = false; // Only auto-select once
}
return track;
}, [addTrackOriginal]);
// Wrap addTrackFromBuffer to auto-select first track when adding to empty project
const addTrackFromBuffer = React.useCallback((buffer: AudioBuffer, name?: string) => {
console.log(`[AudioEditor] addTrackFromBuffer wrapper called: ${name}, shouldAutoSelect: ${shouldAutoSelectRef.current}`);
const shouldAutoSelect = shouldAutoSelectRef.current;
const track = addTrackFromBufferOriginal(buffer, name);
console.log(`[AudioEditor] Track created: ${track.name} (${track.id})`);
if (shouldAutoSelect) {
console.log(`[AudioEditor] Auto-selecting track: ${track.id}`);
setSelectedTrackId(track.id);
shouldAutoSelectRef.current = false; // Only auto-select once
}
return track;
}, [addTrackFromBufferOriginal]);
// 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,
currentTime,
duration,
trackLevels,
masterPeakLevel,
masterRmsLevel,
masterIsClipping,
masterAnalyser,
resetClipIndicator,
play,
pause,
stop,
seek,
togglePlayPause,
} = 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 {
chain: masterEffectChain,
presets: masterEffectPresets,
toggleEffectEnabled: toggleMasterEffect,
removeEffect: removeMasterEffect,
reorder: reorderMasterEffects,
clearChain: clearMasterChain,
savePreset: saveMasterPreset,
loadPresetToChain: loadMasterPreset,
deletePreset: deleteMasterPreset,
} = useEffectChain();
// Multi-track handlers
const handleImportTracks = () => {
setImportDialogOpen(true);
};
const handleImportTrack = (buffer: AudioBuffer, name: string) => {
console.log(`[AudioEditor] handleImportTrack called: ${name}`);
addTrackFromBuffer(buffer, name);
};
const handleClearTracks = () => {
clearTracks();
setSelectedTrackId(null);
addToast({
title: 'Tracks Cleared',
description: 'All tracks have been removed',
variant: 'info',
duration: 2000,
});
};
const handleRemoveTrack = (trackId: string) => {
removeTrack(trackId);
if (selectedTrackId === trackId) {
setSelectedTrackId(null);
}
};
// Per-track effect chain handlers
const handleToggleTrackEffect = (effectId: string) => {
if (!selectedTrack) return;
const updatedChain = {
...selectedTrack.effectChain,
effects: selectedTrack.effectChain.effects.map((e) =>
e.id === effectId ? { ...e, enabled: !e.enabled } : e
),
};
updateTrack(selectedTrack.id, { effectChain: updatedChain });
};
const handleRemoveTrackEffect = (effectId: string) => {
if (!selectedTrack) return;
const updatedChain = {
...selectedTrack.effectChain,
effects: selectedTrack.effectChain.effects.filter((e) => e.id !== effectId),
};
updateTrack(selectedTrack.id, { effectChain: updatedChain });
};
const handleReorderTrackEffects = (fromIndex: number, toIndex: number) => {
if (!selectedTrack) return;
const effects = [...selectedTrack.effectChain.effects];
const [removed] = effects.splice(fromIndex, 1);
effects.splice(toIndex, 0, removed);
const updatedChain = {
...selectedTrack.effectChain,
effects,
};
updateTrack(selectedTrack.id, { effectChain: updatedChain });
};
const handleClearTrackChain = () => {
if (!selectedTrack) return;
const updatedChain = {
...selectedTrack.effectChain,
effects: [],
};
updateTrack(selectedTrack.id, { effectChain: updatedChain });
};
// Effects Panel handlers
const handleAddEffect = React.useCallback((effectType: EffectType) => {
if (!selectedTrackId) return;
const track = tracks.find((t) => t.id === selectedTrackId);
if (!track) return;
// Import createEffect and EFFECT_NAMES dynamically
import('@/lib/audio/effects/chain').then(({ createEffect, EFFECT_NAMES }) => {
const newEffect = createEffect(effectType, EFFECT_NAMES[effectType]);
const updatedChain = {
...track.effectChain,
effects: [...track.effectChain.effects, newEffect],
};
updateTrack(selectedTrackId, { effectChain: updatedChain });
});
}, [selectedTrackId, tracks, updateTrack]);
const handleToggleEffect = React.useCallback((effectId: string) => {
if (!selectedTrackId) return;
const track = tracks.find((t) => t.id === selectedTrackId);
if (!track) return;
const updatedChain = {
...track.effectChain,
effects: track.effectChain.effects.map((e) =>
e.id === effectId ? { ...e, enabled: !e.enabled } : e
),
};
updateTrack(selectedTrackId, { effectChain: updatedChain });
}, [selectedTrackId, tracks, updateTrack]);
const handleRemoveEffect = React.useCallback((effectId: string) => {
if (!selectedTrackId) return;
const track = tracks.find((t) => t.id === selectedTrackId);
if (!track) return;
const updatedChain = {
...track.effectChain,
effects: track.effectChain.effects.filter((e) => e.id !== effectId),
};
updateTrack(selectedTrackId, { effectChain: updatedChain });
}, [selectedTrackId, tracks, updateTrack]);
const handleUpdateEffect = React.useCallback((effectId: string, parameters: any) => {
if (!selectedTrackId) return;
const track = tracks.find((t) => t.id === selectedTrackId);
if (!track) return;
const updatedChain = {
...track.effectChain,
effects: track.effectChain.effects.map((e) =>
e.id === effectId ? { ...e, parameters } : e
),
};
updateTrack(selectedTrackId, { effectChain: updatedChain });
}, [selectedTrackId, tracks, updateTrack]);
const handleToggleEffectExpanded = React.useCallback((effectId: string) => {
if (!selectedTrackId) return;
const track = tracks.find((t) => t.id === selectedTrackId);
if (!track) return;
const updatedChain = {
...track.effectChain,
effects: track.effectChain.effects.map((e) =>
e.id === effectId ? { ...e, expanded: !e.expanded } : e
),
};
updateTrack(selectedTrackId, { effectChain: updatedChain });
}, [selectedTrackId, tracks, updateTrack]);
// Preserve effects panel state - don't auto-open/close on track selection
// Selection handler
const handleSelectionChange = (trackId: string, selection: { start: number; end: number } | null) => {
updateTrack(trackId, { selection });
};
// Recording handlers
const handleToggleRecordEnable = React.useCallback((trackId: string) => {
const track = tracks.find((t) => t.id === trackId);
if (!track) return;
// Toggle record enable
updateTrack(trackId, { recordEnabled: !track.recordEnabled });
}, [tracks, updateTrack]);
const handleStartRecording = React.useCallback(async () => {
// Find first armed track
const armedTrack = tracks.find((t) => t.recordEnabled);
if (!armedTrack) {
addToast({
title: 'No Track Armed',
description: 'Please arm a track for recording first',
variant: 'warning',
duration: 3000,
});
return;
}
// Request permission if needed
const hasPermission = await requestPermission();
if (!hasPermission) {
addToast({
title: 'Microphone Access Denied',
description: 'Please allow microphone access to record',
variant: 'error',
duration: 3000,
});
return;
}
try {
await startRecording();
setRecordingTrackId(armedTrack.id);
addToast({
title: 'Recording Started',
description: `Recording to ${armedTrack.name}`,
variant: 'success',
duration: 2000,
});
} catch (error) {
console.error('Failed to start recording:', error);
addToast({
title: 'Recording Failed',
description: 'Failed to start recording',
variant: 'error',
duration: 3000,
});
}
}, [tracks, startRecording, requestPermission, addToast]);
const handleStopRecording = React.useCallback(async () => {
if (!recordingTrackId) return;
try {
const recordedBuffer = await stopRecording();
if (recordedBuffer) {
const track = tracks.find((t) => t.id === recordingTrackId);
// Check if overdub mode is enabled and track has existing audio
if (overdubEnabled && track?.audioBuffer) {
// Mix recorded audio with existing audio
const audioContext = new AudioContext();
const existingBuffer = track.audioBuffer;
// Create a new buffer that's long enough for both
const maxDuration = Math.max(existingBuffer.duration, recordedBuffer.duration);
const maxChannels = Math.max(existingBuffer.numberOfChannels, recordedBuffer.numberOfChannels);
const mixedBuffer = audioContext.createBuffer(
maxChannels,
Math.floor(maxDuration * existingBuffer.sampleRate),
existingBuffer.sampleRate
);
// Mix each channel
for (let channel = 0; channel < maxChannels; channel++) {
const mixedData = mixedBuffer.getChannelData(channel);
const existingData = channel < existingBuffer.numberOfChannels
? existingBuffer.getChannelData(channel)
: new Float32Array(mixedData.length);
const recordedData = channel < recordedBuffer.numberOfChannels
? recordedBuffer.getChannelData(channel)
: new Float32Array(mixedData.length);
// Mix the samples (average them to avoid clipping)
for (let i = 0; i < mixedData.length; i++) {
const existingSample = i < existingData.length ? existingData[i] : 0;
const recordedSample = i < recordedData.length ? recordedData[i] : 0;
mixedData[i] = (existingSample + recordedSample) / 2;
}
}
updateTrack(recordingTrackId, { audioBuffer: mixedBuffer });
addToast({
title: 'Recording Complete (Overdub)',
description: `Mixed ${recordedBuffer.duration.toFixed(2)}s with existing audio`,
variant: 'success',
duration: 3000,
});
} else {
// Normal mode - replace existing audio
updateTrack(recordingTrackId, { audioBuffer: recordedBuffer });
addToast({
title: 'Recording Complete',
description: `Recorded ${recordedBuffer.duration.toFixed(2)}s of audio`,
variant: 'success',
duration: 3000,
});
}
}
setRecordingTrackId(null);
} catch (error) {
console.error('Failed to stop recording:', error);
addToast({
title: 'Recording Error',
description: 'Failed to save recording',
variant: 'error',
duration: 3000,
});
setRecordingTrackId(null);
}
}, [recordingTrackId, stopRecording, updateTrack, addToast, overdubEnabled, tracks]);
// Edit handlers
const handleCut = React.useCallback(() => {
const track = tracks.find((t) => t.selection);
if (!track || !track.audioBuffer || !track.selection) return;
// Extract to clipboard
const extracted = extractBufferSegment(
track.audioBuffer,
track.selection.start,
track.selection.end
);
setClipboard(extracted);
// Execute cut command
const command = createMultiTrackCutCommand(
track.id,
track.audioBuffer,
track.selection,
(trackId, buffer, selection) => {
updateTrack(trackId, { audioBuffer: buffer, selection });
}
);
executeCommand(command);
addToast({
title: 'Cut',
description: 'Selection cut to clipboard',
variant: 'success',
duration: 2000,
});
}, [tracks, executeCommand, updateTrack, addToast]);
const handleCopy = React.useCallback(() => {
const track = tracks.find((t) => t.selection);
if (!track || !track.audioBuffer || !track.selection) return;
// Extract to clipboard
const extracted = extractBufferSegment(
track.audioBuffer,
track.selection.start,
track.selection.end
);
setClipboard(extracted);
// Execute copy command (doesn't modify buffer, just for undo history)
const command = createMultiTrackCopyCommand(
track.id,
track.audioBuffer,
track.selection,
(trackId, buffer, selection) => {
updateTrack(trackId, { audioBuffer: buffer, selection });
}
);
executeCommand(command);
addToast({
title: 'Copy',
description: 'Selection copied to clipboard',
variant: 'success',
duration: 2000,
});
}, [tracks, executeCommand, updateTrack, addToast]);
const handlePaste = React.useCallback(() => {
if (!clipboard || !selectedTrackId) return;
const track = tracks.find((t) => t.id === selectedTrackId);
if (!track) return;
// Paste at current time or at end of buffer
const pastePosition = currentTime || track.audioBuffer?.duration || 0;
const command = createMultiTrackPasteCommand(
track.id,
track.audioBuffer,
clipboard,
pastePosition,
(trackId, buffer, selection) => {
updateTrack(trackId, { audioBuffer: buffer, selection });
}
);
executeCommand(command);
addToast({
title: 'Paste',
description: 'Clipboard content pasted',
variant: 'success',
duration: 2000,
});
}, [clipboard, selectedTrackId, tracks, currentTime, executeCommand, updateTrack, addToast]);
const handleDelete = React.useCallback(() => {
const track = tracks.find((t) => t.selection);
if (!track || !track.audioBuffer || !track.selection) return;
const command = createMultiTrackDeleteCommand(
track.id,
track.audioBuffer,
track.selection,
(trackId, buffer, selection) => {
updateTrack(trackId, { audioBuffer: buffer, selection });
}
);
executeCommand(command);
addToast({
title: 'Delete',
description: 'Selection deleted',
variant: 'success',
duration: 2000,
});
}, [tracks, executeCommand, updateTrack, addToast]);
const handleDuplicate = React.useCallback(() => {
const track = tracks.find((t) => t.selection);
if (!track || !track.audioBuffer || !track.selection) return;
const command = createMultiTrackDuplicateCommand(
track.id,
track.audioBuffer,
track.selection,
(trackId, buffer, selection) => {
updateTrack(trackId, { audioBuffer: buffer, selection });
}
);
executeCommand(command);
addToast({
title: 'Duplicate',
description: 'Selection duplicated',
variant: 'success',
duration: 2000,
});
}, [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 {
const sampleRate = tracks[0]?.audioBuffer?.sampleRate || 44100;
// Helper function to convert and download a buffer
const convertAndDownload = async (buffer: AudioBuffer, filename: string) => {
let exportedBuffer: ArrayBuffer;
let mimeType: string;
let fileExtension: string;
if (settings.format === 'mp3') {
exportedBuffer = await audioBufferToMp3(buffer, {
format: 'mp3',
bitrate: settings.bitrate,
normalize: settings.normalize,
});
mimeType = 'audio/mpeg';
fileExtension = 'mp3';
} else {
// WAV export
exportedBuffer = audioBufferToWav(buffer, {
format: 'wav',
bitDepth: settings.bitDepth,
normalize: settings.normalize,
});
mimeType = 'audio/wav';
fileExtension = 'wav';
}
const fullFilename = `${filename}.${fileExtension}`;
downloadArrayBuffer(exportedBuffer, fullFilename, mimeType);
return fullFilename;
};
if (settings.scope === 'tracks') {
// Export each track individually
let exportedCount = 0;
for (const track of tracks) {
if (!track.audioBuffer) continue;
const trackFilename = `${settings.filename}_${track.name.replace(/[^a-z0-9]/gi, '_')}`;
await convertAndDownload(track.audioBuffer, trackFilename);
exportedCount++;
}
addToast({
title: 'Export Complete',
description: `Exported ${exportedCount} track${exportedCount !== 1 ? 's' : ''}`,
variant: 'success',
duration: 3000,
});
} else if (settings.scope === 'selection') {
// Export selected region
const selectedTrack = tracks.find(t => t.selection);
if (!selectedTrack || !selectedTrack.selection) {
addToast({
title: 'No Selection',
description: 'No region selected for export',
variant: 'warning',
duration: 3000,
});
setIsExporting(false);
return;
}
// Extract selection from all tracks and mix
const selectionStart = selectedTrack.selection.start;
const selectionEnd = selectedTrack.selection.end;
const selectionDuration = selectionEnd - selectionStart;
// Create tracks with only the selected region
const selectedTracks = tracks.map(track => ({
...track,
audioBuffer: track.audioBuffer
? extractBufferSegment(track.audioBuffer, selectionStart, selectionEnd)
: null,
}));
const mixedBuffer = mixTracks(selectedTracks, sampleRate, selectionDuration);
const filename = await convertAndDownload(mixedBuffer, settings.filename);
addToast({
title: 'Export Complete',
description: `Exported ${filename}`,
variant: 'success',
duration: 3000,
});
} else {
// Export entire project (mix all tracks)
const maxDuration = getMaxTrackDuration(tracks);
const mixedBuffer = mixTracks(tracks, sampleRate, maxDuration);
const filename = await convertAndDownload(mixedBuffer, settings.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]);
// Load projects list when dialog opens
const loadProjectsList = React.useCallback(async () => {
try {
const projectsList = await listProjects();
setProjects(projectsList);
} catch (error) {
console.error('Failed to load projects:', error);
addToast({
title: 'Failed to Load Projects',
description: 'Could not retrieve project list',
variant: 'error',
duration: 3000,
});
}
}, [addToast]);
// Open projects dialog
const handleOpenProjectsDialog = React.useCallback(async () => {
await loadProjectsList();
setProjectsDialogOpen(true);
}, [loadProjectsList]);
// Save current project
// Use ref to capture latest currentTime without triggering callback recreation
const currentTimeRef = React.useRef(currentTime);
React.useEffect(() => {
currentTimeRef.current = currentTime;
}, [currentTime]);
const handleSaveProject = React.useCallback(async () => {
if (tracks.length === 0) return;
try {
const audioContext = getAudioContext();
console.log('[Project Save] Saving project:', {
id: currentProjectId,
name: currentProjectName,
trackCount: tracks.length,
tracks: tracks.map(t => ({
name: t.name,
effectsCount: t.effectChain?.effects?.length || 0,
automationLanes: t.automation?.lanes?.length || 0,
}))
});
const projectId = await saveCurrentProject(
currentProjectId,
currentProjectName,
tracks,
{
zoom,
currentTime: currentTimeRef.current, // Use ref value
sampleRate: audioContext.sampleRate,
}
);
setCurrentProjectId(projectId);
// Save last project ID to localStorage for auto-load on next visit
localStorage.setItem('audio-ui-last-project', projectId);
addToast({
title: 'Project Saved',
description: `"${currentProjectName}" saved successfully`,
variant: 'success',
duration: 2000,
});
} catch (error) {
console.error('Failed to save project:', error);
addToast({
title: 'Save Failed',
description: 'Could not save project',
variant: 'error',
duration: 3000,
});
}
}, [tracks, currentProjectId, currentProjectName, zoom, addToast]); // Removed currentTime from deps
// Auto-save effect (saves on track or name changes after 3 seconds of no changes)
React.useEffect(() => {
if (tracks.length === 0) return;
console.log('[Auto-save] Scheduling auto-save in 3 seconds...', {
trackCount: tracks.length,
projectName: currentProjectName,
});
const autoSaveTimer = setTimeout(() => {
console.log('[Auto-save] Triggering auto-save now');
handleSaveProject();
}, 3000); // Auto-save after 3 seconds of no changes
return () => {
console.log('[Auto-save] Clearing auto-save timer');
clearTimeout(autoSaveTimer);
};
}, [tracks, currentProjectName, handleSaveProject]);
// Create new project
const handleNewProject = React.useCallback(() => {
if (tracks.length > 0) {
if (!confirm('Create new project? Unsaved changes will be lost.')) {
return;
}
}
clearTracks();
setCurrentProjectId(null);
setCurrentProjectName('Untitled Project');
setProjectsDialogOpen(false);
addToast({
title: 'New Project',
description: 'Started new project',
variant: 'success',
duration: 2000,
});
}, [tracks, clearTracks, addToast]);
// Load project
const handleLoadProject = React.useCallback(async (projectId: string) => {
try {
const projectData = await loadProjectById(projectId);
if (!projectData) {
throw new Error('Project not found');
}
console.log('[Project Load] Loading project:', {
id: projectData.metadata.id,
name: projectData.metadata.name,
trackCount: projectData.tracks.length,
tracks: projectData.tracks.map(t => ({
name: t.name,
hasAudioBuffer: !!t.audioBuffer,
effectsCount: t.effectChain?.effects?.length || 0,
automationLanes: t.automation?.lanes?.length || 0,
}))
});
// Load tracks with all their properties restored
loadTracks(projectData.tracks);
// Restore settings
setZoom(projectData.settings.zoom);
// Note: currentTime is managed by player, will start at 0
// Set project metadata
setCurrentProjectId(projectData.metadata.id);
setCurrentProjectName(projectData.metadata.name);
setProjectsDialogOpen(false);
addToast({
title: 'Project Loaded',
description: `"${projectData.metadata.name}" loaded successfully (${projectData.tracks.length} track${projectData.tracks.length !== 1 ? 's' : ''})`,
variant: 'success',
duration: 2000,
});
} catch (error) {
console.error('Failed to load project:', error);
addToast({
title: 'Load Failed',
description: 'Could not load project',
variant: 'error',
duration: 3000,
});
}
}, [loadTracks, addToast]);
// Auto-load last project on mount
const [hasAutoLoaded, setHasAutoLoaded] = React.useState(false);
React.useEffect(() => {
if (hasAutoLoaded) return; // Only run once
const loadLastProject = async () => {
const lastProjectId = localStorage.getItem('audio-ui-last-project');
if (lastProjectId) {
try {
console.log('[Auto-load] Loading last project:', lastProjectId);
await handleLoadProject(lastProjectId);
} catch (error) {
console.error('[Auto-load] Failed to load last project:', error);
// Clear invalid project ID
localStorage.removeItem('audio-ui-last-project');
}
}
setHasAutoLoaded(true);
};
loadLastProject();
}, [hasAutoLoaded, handleLoadProject]);
// Delete project
const handleDeleteProject = React.useCallback(async (projectId: string) => {
try {
await removeProject(projectId);
await loadProjectsList();
// If deleted current project, reset
if (projectId === currentProjectId) {
setCurrentProjectId(null);
setCurrentProjectName('Untitled Project');
}
addToast({
title: 'Project Deleted',
description: 'Project deleted successfully',
variant: 'success',
duration: 2000,
});
} catch (error) {
console.error('Failed to delete project:', error);
addToast({
title: 'Delete Failed',
description: 'Could not delete project',
variant: 'error',
duration: 3000,
});
}
}, [currentProjectId, loadProjectsList, addToast]);
// Duplicate project
const handleDuplicateProject = React.useCallback(async (projectId: string) => {
try {
const sourceProject = projects.find(p => p.id === projectId);
if (!sourceProject) return;
const newName = `${sourceProject.name} (Copy)`;
await duplicateProject(projectId, newName);
await loadProjectsList();
addToast({
title: 'Project Duplicated',
description: `"${newName}" created successfully`,
variant: 'success',
duration: 2000,
});
} catch (error) {
console.error('Failed to duplicate project:', error);
addToast({
title: 'Duplicate Failed',
description: 'Could not duplicate project',
variant: 'error',
duration: 3000,
});
}
}, [projects, loadProjectsList, addToast]);
// Zoom controls
const handleZoomIn = () => {
setZoom((prev) => Math.min(20, prev + 1));
};
const handleZoomOut = () => {
setZoom((prev) => Math.max(1, prev - 1));
};
const handleFitToView = () => {
setZoom(1);
};
// Keyboard shortcuts
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Spacebar for play/pause - only if not interacting with form elements
if (e.code === 'Space') {
const target = e.target as HTMLElement;
// Don't trigger if user is typing or interacting with buttons/form elements
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLButtonElement ||
target.getAttribute('role') === 'button'
) {
return;
}
e.preventDefault();
togglePlayPause();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [togglePlayPause]);
// Find selected track
const selectedTrack = tracks.find((t) => t.id === selectedTrackId);
// Command palette actions
const commandActions: CommandAction[] = React.useMemo(() => {
const actions: CommandAction[] = [
// Playback
{
id: 'play',
label: 'Play',
description: 'Start playback',
shortcut: 'Space',
category: 'playback',
action: play,
},
{
id: 'pause',
label: 'Pause',
description: 'Pause playback',
shortcut: 'Space',
category: 'playback',
action: pause,
},
{
id: 'stop',
label: 'Stop',
description: 'Stop playback',
category: 'playback',
action: stop,
},
// View
{
id: 'zoom-in',
label: 'Zoom In',
description: 'Zoom in on waveforms',
category: 'view',
action: handleZoomIn,
},
{
id: 'zoom-out',
label: 'Zoom Out',
description: 'Zoom out on waveforms',
category: 'view',
action: handleZoomOut,
},
{
id: 'fit-to-view',
label: 'Fit to View',
description: 'Reset zoom to fit all tracks',
category: 'view',
action: handleFitToView,
},
// Tracks
{
id: 'add-track',
label: 'Add Empty Track',
description: 'Create a new empty track',
category: 'tracks',
action: () => addTrack(),
},
{
id: 'import-tracks',
label: 'Import Audio Files',
description: 'Import multiple audio files as tracks',
category: 'tracks',
action: handleImportTracks,
},
{
id: 'clear-tracks',
label: 'Clear All Tracks',
description: 'Remove all tracks',
category: 'tracks',
action: handleClearTracks,
},
];
return actions;
}, [play, pause, stop, handleZoomIn, handleZoomOut, handleFitToView, handleImportTracks, handleClearTracks, addTrack]);
// Keyboard shortcuts
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Prevent shortcuts if typing in an input
const isTyping = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement;
// Spacebar: Play/Pause (always, unless typing in an input)
if (e.code === 'Space' && !isTyping) {
e.preventDefault();
togglePlayPause();
return;
}
if (isTyping) return;
// Ctrl/Cmd+Z: Undo
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
if (canUndo) {
undo();
}
return;
}
// Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y: Redo
if (((e.ctrlKey || e.metaKey) && e.key === 'z' && e.shiftKey) || ((e.ctrlKey || e.metaKey) && e.key === 'y')) {
e.preventDefault();
if (canRedo) {
redo();
}
return;
}
// Ctrl/Cmd+X: Cut
if ((e.ctrlKey || e.metaKey) && e.key === 'x') {
e.preventDefault();
handleCut();
return;
}
// Ctrl/Cmd+C: Copy
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
e.preventDefault();
handleCopy();
return;
}
// Ctrl/Cmd+V: Paste
if ((e.ctrlKey || e.metaKey) && e.key === 'v') {
e.preventDefault();
handlePaste();
return;
}
// Ctrl/Cmd+D: Duplicate
if ((e.ctrlKey || e.metaKey) && e.key === 'd') {
e.preventDefault();
handleDuplicate();
return;
}
// Delete or Backspace: Delete selection
if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault();
handleDelete();
return;
}
// Escape: Clear selection
if (e.key === 'Escape') {
e.preventDefault();
setSelectedTrackId(null);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [togglePlayPause, canUndo, canRedo, undo, redo, handleCut, handleCopy, handlePaste, handleDelete, handleDuplicate]);
return (
<>
{/* Compact Header */}
<header className="flex items-center justify-between px-4 py-2 border-b border-border bg-card flex-shrink-0 gap-4">
{/* Left: Logo */}
<div className="flex items-center gap-4 flex-shrink-0">
<div className="flex items-center gap-2">
<Music className="h-5 w-5 text-primary" />
<h1 className="text-lg font-bold text-foreground">Audio UI</h1>
</div>
{/* Project Name */}
<div className="flex items-center gap-2 border-l border-border pl-4">
<input
type="text"
value={currentProjectName}
onChange={(e) => setCurrentProjectName(e.target.value)}
className="bg-transparent border-none outline-none text-sm font-medium text-muted-foreground hover:text-foreground focus:text-foreground transition-colors px-2 py-1 rounded hover:bg-accent/50 focus:bg-accent"
placeholder="Untitled Project"
title="Click to edit project name"
style={{ width: `${Math.max(12, currentProjectName.length)}ch` }}
/>
</div>
{/* Track Actions */}
<div className="flex items-center gap-2 border-l border-border pl-4">
<Button variant="outline" size="sm" onClick={handleOpenProjectsDialog}>
<FolderOpen className="h-4 w-4 mr-1.5" />
Projects
</Button>
<Button variant="outline" size="sm" onClick={() => addTrack()}>
<Plus className="h-4 w-4 mr-1.5" />
Add Track
</Button>
<Button variant="outline" size="sm" onClick={handleImportTracks}>
<Upload className="h-4 w-4 mr-1.5" />
Import
</Button>
{tracks.length > 0 && (
<>
<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>
{/* Right: Command Palette + Settings + Theme Toggle */}
<div className="flex items-center gap-2 flex-shrink-0">
<CommandPalette actions={commandActions} />
<Button
variant="ghost"
size="icon"
onClick={() => setSettingsDialogOpen(true)}
title="Settings"
>
<Settings className="h-5 w-5" />
</Button>
<ThemeToggle />
</div>
</header>
{/* Main content area */}
<div className="flex flex-1 overflow-hidden">
{/* Main canvas area */}
<main className="flex-1 flex flex-col overflow-hidden bg-background">
{/* Multi-Track View */}
<div className="flex-1 flex flex-col overflow-hidden">
<TrackList
tracks={tracks}
zoom={zoom}
currentTime={currentTime}
duration={duration}
selectedTrackId={selectedTrackId}
onSelectTrack={setSelectedTrackId}
onAddTrack={addTrack}
onImportTrack={handleImportTrack}
onRemoveTrack={handleRemoveTrack}
onUpdateTrack={updateTrack}
onSeek={seek}
onSelectionChange={handleSelectionChange}
onToggleRecordEnable={handleToggleRecordEnable}
recordingTrackId={recordingTrackId}
recordingLevel={recordingState.inputLevel}
trackLevels={trackLevels}
onParameterTouched={setParameterTouched}
isPlaying={isPlaying}
/>
</div>
</main>
{/* Right Sidebar - Master Controls & Analyzers */}
<aside className="flex-shrink-0 border-l border-border bg-card flex flex-col p-4 gap-4 w-[280px]">
{/* Master Controls */}
<div className="flex items-center justify-center">
<MasterControls
volume={masterVolume}
pan={masterPan}
peakLevel={masterPeakLevel}
rmsLevel={masterRmsLevel}
isClipping={masterIsClipping}
isMuted={isMasterMuted}
onVolumeChange={setMasterVolume}
onPanChange={setMasterPan}
onMuteToggle={() => {
if (isMasterMuted) {
setMasterVolume(0.8);
setIsMasterMuted(false);
} else {
setMasterVolume(0);
setIsMasterMuted(true);
}
}}
onResetClip={resetClipIndicator}
/>
</div>
{/* Analyzer Toggle */}
<div className="grid grid-cols-5 gap-0.5 bg-muted/20 border border-border/50 rounded-md p-0.5 max-w-[192px] mx-auto">
<button
onClick={() => setAnalyzerView('frequency')}
className={`px-1 py-1 rounded text-[9px] font-bold uppercase tracking-wider transition-all ${
analyzerView === 'frequency'
? 'bg-accent text-accent-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
title="Frequency Analyzer"
>
FFT
</button>
<button
onClick={() => setAnalyzerView('spectrogram')}
className={`px-1 py-1 rounded text-[9px] font-bold uppercase tracking-wider transition-all ${
analyzerView === 'spectrogram'
? 'bg-accent text-accent-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
title="Spectrogram"
>
SPEC
</button>
<button
onClick={() => setAnalyzerView('phase')}
className={`px-1 py-1 rounded text-[9px] font-bold uppercase tracking-wider transition-all ${
analyzerView === 'phase'
? 'bg-accent text-accent-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
title="Phase Correlation"
>
PHS
</button>
<button
onClick={() => setAnalyzerView('lufs')}
className={`px-1 py-1 rounded text-[9px] font-bold uppercase tracking-wider transition-all ${
analyzerView === 'lufs'
? 'bg-accent text-accent-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
title="LUFS Loudness"
>
LUFS
</button>
<button
onClick={() => setAnalyzerView('stats')}
className={`px-1 py-1 rounded text-[9px] font-bold uppercase tracking-wider transition-all ${
analyzerView === 'stats'
? 'bg-accent text-accent-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
title="Audio Statistics"
>
INFO
</button>
</div>
{/* Analyzer Display */}
<div className="flex-1 min-h-[360px] flex items-start justify-center">
<div className="w-[192px]">
{analyzerView === 'frequency' && <FrequencyAnalyzer analyserNode={masterAnalyser} />}
{analyzerView === 'spectrogram' && <Spectrogram analyserNode={masterAnalyser} />}
{analyzerView === 'phase' && <PhaseCorrelationMeter analyserNode={masterAnalyser} />}
{analyzerView === 'lufs' && <LUFSMeter analyserNode={masterAnalyser} />}
{analyzerView === 'stats' && <AudioStatistics tracks={tracks} />}
</div>
</div>
</aside>
</div>
{/* Transport Controls */}
<div className="border-t border-border bg-card p-3 flex justify-center">
<PlaybackControls
isPlaying={isPlaying}
isPaused={!isPlaying}
currentTime={currentTime}
duration={duration}
volume={masterVolume}
onPlay={play}
onPause={pause}
onStop={stop}
onSeek={seek}
onVolumeChange={setMasterVolume}
currentTimeFormatted={formatDuration(currentTime)}
durationFormatted={formatDuration(duration)}
isRecording={recordingState.isRecording}
onStartRecording={handleStartRecording}
onStopRecording={handleStopRecording}
punchInEnabled={punchInEnabled}
punchInTime={punchInTime}
punchOutTime={punchOutTime}
onPunchInEnabledChange={setPunchInEnabled}
onPunchInTimeChange={setPunchInTime}
onPunchOutTimeChange={setPunchOutTime}
overdubEnabled={overdubEnabled}
onOverdubEnabledChange={setOverdubEnabled}
/>
</div>
{/* Import Track Dialog */}
<ImportTrackDialog
open={importDialogOpen}
onClose={() => setImportDialogOpen(false)}
onImportTrack={handleImportTrack}
/>
{/* Global Settings Dialog */}
<GlobalSettingsDialog
open={settingsDialogOpen}
onClose={() => setSettingsDialogOpen(false)}
recordingSettings={recordingSettings}
onInputGainChange={setInputGain}
onRecordMonoChange={setRecordMono}
onSampleRateChange={setSampleRate}
/>
{/* Export Dialog */}
<ExportDialog
open={exportDialogOpen}
onClose={() => setExportDialogOpen(false)}
onExport={handleExport}
isExporting={isExporting}
hasSelection={tracks.some(t => t.selection !== null)}
/>
{/* Projects Dialog */}
<ProjectsDialog
open={projectsDialogOpen}
onClose={() => setProjectsDialogOpen(false)}
projects={projects}
onNewProject={handleNewProject}
onLoadProject={handleLoadProject}
onDeleteProject={handleDeleteProject}
onDuplicateProject={handleDuplicateProject}
/>
</>
);
}