Files
audio-ui/components/editor/AudioEditor.tsx
Sebastian Krüger 8720c35f23 fix: add missing 'S' keyboard shortcut for split at cursor
Added keyboard handler for the 'S' key to trigger split at cursor.
The shortcut was defined in the command palette but missing from
the keyboard event handler.

Note: Ctrl+A for Select All was already working correctly.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 07:56:02 +01:00

2225 lines
73 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 { ThemeToggle } from '@/components/layout/ThemeToggle';
import { CommandPalette } from '@/components/ui/CommandPalette';
import { BrowserCompatDialog } from '@/components/dialogs/BrowserCompatDialog';
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 type { ExportSettings } from '@/components/dialogs/ExportDialog';
// Lazy load dialogs for better performance
const GlobalSettingsDialog = React.lazy(() => import('@/components/settings/GlobalSettingsDialog').then(m => ({ default: m.GlobalSettingsDialog })));
const ExportDialog = React.lazy(() => import('@/components/dialogs/ExportDialog').then(m => ({ default: m.ExportDialog })));
const ProjectsDialog = React.lazy(() => import('@/components/dialogs/ProjectsDialog').then(m => ({ default: m.ProjectsDialog })));
const ImportTrackDialog = React.lazy(() => import('@/components/tracks/ImportTrackDialog').then(m => ({ default: m.ImportTrackDialog })));
const KeyboardShortcutsDialog = React.lazy(() => import('@/components/dialogs/KeyboardShortcutsDialog').then(m => ({ default: m.KeyboardShortcutsDialog })));
// Lazy load analysis components (shown conditionally based on analyzerView)
const FrequencyAnalyzer = React.lazy(() => import('@/components/analysis/FrequencyAnalyzer').then(m => ({ default: m.FrequencyAnalyzer })));
const Spectrogram = React.lazy(() => import('@/components/analysis/Spectrogram').then(m => ({ default: m.Spectrogram })));
const PhaseCorrelationMeter = React.lazy(() => import('@/components/analysis/PhaseCorrelationMeter').then(m => ({ default: m.PhaseCorrelationMeter })));
const LUFSMeter = React.lazy(() => import('@/components/analysis/LUFSMeter').then(m => ({ default: m.LUFSMeter })));
const AudioStatistics = React.lazy(() => import('@/components/analysis/AudioStatistics').then(m => ({ default: m.AudioStatistics })));
import { formatDuration } from '@/lib/audio/decoder';
import { useHistory } from '@/lib/hooks/useHistory';
import { useRecording } from '@/lib/hooks/useRecording';
import { useSettings } from '@/lib/hooks/useSettings';
import { DEFAULT_TRACK_HEIGHT } from '@/types/track';
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,
exportProjectAsJSON,
importProjectFromJSON,
type ProjectMetadata,
} from '@/lib/storage/projects';
import { getAudioContext } from '@/lib/audio/context';
import { checkBrowserCompatibility } from '@/lib/utils/browser-compat';
export function AudioEditor() {
// Settings hook
const {
settings,
updateAudioSettings,
updateUISettings,
updateEditorSettings,
updatePerformanceSettings,
resetCategory,
} = useSettings();
const [importDialogOpen, setImportDialogOpen] = React.useState(false);
const [selectedTrackId, setSelectedTrackId] = React.useState<string | null>(null);
const [zoom, setZoom] = React.useState(settings.editor.defaultZoom);
const [masterVolume, setMasterVolume] = React.useState(0.8);
const [masterPan, setMasterPan] = React.useState(0);
const [isMasterMuted, setIsMasterMuted] = React.useState(false);
const [masterControlsCollapsed, setMasterControlsCollapsed] = React.useState(false);
const [clipboard, setClipboard] = React.useState<AudioBuffer | null>(null);
const [automationClipboard, setAutomationClipboard] = React.useState<{ points: Array<{ time: number; value: number; curve: string }> } | 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');
// Switch away from spectrogram if it gets disabled
React.useEffect(() => {
if (analyzerView === 'spectrogram' && !settings.performance.enableSpectrogram) {
setAnalyzerView('frequency');
}
}, [analyzerView, settings.performance.enableSpectrogram]);
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 [browserCompatDialogOpen, setBrowserCompatDialogOpen] = React.useState(false);
const [shortcutsDialogOpen, setShortcutsDialogOpen] = React.useState(false);
const [browserCompatInfo, setBrowserCompatInfo] = React.useState<{
missingFeatures: string[];
warnings: string[];
}>({ missingFeatures: [], warnings: [] });
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();
// Sync recording sample rate with global settings
React.useEffect(() => {
setSampleRate(settings.audio.sampleRate);
}, [settings.audio.sampleRate, setSampleRate]);
// 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, DEFAULT_TRACK_HEIGHT);
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, DEFAULT_TRACK_HEIGHT);
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,
loopEnabled,
loopStart,
loopEnd,
toggleLoop,
setLoopPoints,
setLoopFromSelection,
playbackRate,
changePlaybackRate,
} = 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);
// Ensure the track is selected so paste works
setSelectedTrackId(track.id);
// 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);
// Ensure the track is selected so paste works
setSelectedTrackId(track.id);
// 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) {
addToast({
title: 'Nothing to Paste',
description: 'Clipboard is empty. Copy or cut a selection first.',
variant: 'error',
duration: 2000,
});
return;
}
if (!selectedTrackId) {
addToast({
title: 'No Track Selected',
description: 'Select a track to paste into.',
variant: 'error',
duration: 2000,
});
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]);
const handleSplitAtCursor = React.useCallback(() => {
if (!selectedTrackId) {
addToast({
title: 'No Track Selected',
description: 'Select a track to split',
variant: 'error',
duration: 2000,
});
return;
}
const track = tracks.find((t) => t.id === selectedTrackId);
if (!track || !track.audioBuffer) {
addToast({
title: 'No Audio',
description: 'Selected track has no audio to split',
variant: 'error',
duration: 2000,
});
return;
}
const splitTime = currentTime;
const duration = track.audioBuffer.duration;
// Can't split at the very beginning or end
if (splitTime <= 0 || splitTime >= duration) {
addToast({
title: 'Invalid Split Position',
description: 'Cannot split at the beginning or end of the track',
variant: 'error',
duration: 2000,
});
return;
}
// Extract two segments
const firstSegment = extractBufferSegment(track.audioBuffer, 0, splitTime);
const secondSegment = extractBufferSegment(track.audioBuffer, splitTime, duration);
// Create two new tracks
const trackIndex = tracks.indexOf(track);
const firstName = `${track.name} (1)`;
const secondName = `${track.name} (2)`;
// Add the two new tracks
addTrackFromBuffer(firstSegment, firstName);
addTrackFromBuffer(secondSegment, secondName);
// Remove the original track
removeTrack(track.id);
addToast({
title: 'Track Split',
description: `Split track at ${formatDuration(splitTime)}`,
variant: 'success',
duration: 2000,
});
}, [selectedTrackId, tracks, currentTime, addTrackFromBuffer, removeTrack, addToast]);
const handleCopyAutomation = React.useCallback((trackId: string, laneId: string) => {
const track = tracks.find((t) => t.id === trackId);
if (!track) return;
const lane = track.automation.lanes.find((l) => l.id === laneId);
if (!lane || lane.points.length === 0) {
addToast({
title: 'No Automation Data',
description: 'Selected automation lane has no points',
variant: 'error',
duration: 2000,
});
return;
}
// Copy the automation points
setAutomationClipboard({
points: lane.points.map(p => ({
time: p.time,
value: p.value,
curve: p.curve,
})),
});
addToast({
title: 'Automation Copied',
description: `Copied ${lane.points.length} automation point(s)`,
variant: 'success',
duration: 2000,
});
}, [tracks, addToast]);
const handlePasteAutomation = React.useCallback((trackId: string, laneId: string) => {
if (!automationClipboard) {
addToast({
title: 'Nothing to Paste',
description: 'No automation data in clipboard',
variant: 'error',
duration: 2000,
});
return;
}
const track = tracks.find((t) => t.id === trackId);
if (!track) return;
const lane = track.automation.lanes.find((l) => l.id === laneId);
if (!lane) return;
// Generate new IDs for pasted points and shift them to current time
const timeOffset = currentTime;
const newPoints = automationClipboard.points.map((p) => ({
id: `point-${Date.now()}-${Math.random()}`,
time: p.time + timeOffset,
value: p.value,
curve: p.curve as 'linear' | 'bezier' | 'step',
}));
// Merge with existing points and sort by time
const updatedLanes = track.automation.lanes.map((l) => {
if (l.id === laneId) {
return {
...l,
points: [...l.points, ...newPoints].sort((a, b) => a.time - b.time),
};
}
return l;
});
updateTrack(trackId, {
automation: {
...track.automation,
lanes: updatedLanes,
},
});
addToast({
title: 'Automation Pasted',
description: `Pasted ${newPoints.length} automation point(s) at ${formatDuration(currentTime)}`,
variant: 'success',
duration: 2000,
});
}, [automationClipboard, tracks, currentTime, 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 (showToast = false) => {
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);
// Only show toast for manual saves
if (showToast) {
addToast({
title: 'Project Saved',
description: `"${currentProjectName}" saved successfully`,
variant: 'success',
duration: 2000,
});
}
} catch (error) {
console.error('Failed to save project:', error);
// Always show error toasts
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]);
// Check browser compatibility on mount
React.useEffect(() => {
const { isSupported, missingFeatures, warnings } = checkBrowserCompatibility();
if (!isSupported || warnings.length > 0) {
setBrowserCompatInfo({ missingFeatures, warnings });
setBrowserCompatDialogOpen(true);
}
}, []);
// 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]);
// Export project
const handleExportProject = React.useCallback(async (projectId: string) => {
try {
const project = projects.find(p => p.id === projectId);
if (!project) return;
await exportProjectAsJSON(projectId);
addToast({
title: 'Project Exported',
description: `"${project.name}" exported successfully`,
variant: 'success',
duration: 2000,
});
} catch (error) {
console.error('Failed to export project:', error);
addToast({
title: 'Export Failed',
description: 'Could not export project',
variant: 'error',
duration: 3000,
});
}
}, [projects, addToast]);
// Import project
const handleImportProject = React.useCallback(async () => {
try {
// Create file input
const input = document.createElement('input');
input.type = 'file';
input.accept = '.zip';
input.onchange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
try {
const projectId = await importProjectFromJSON(file);
await loadProjectsList();
addToast({
title: 'Project Imported',
description: 'Project imported successfully',
variant: 'success',
duration: 2000,
});
} catch (error) {
console.error('Failed to import project:', error);
addToast({
title: 'Import Failed',
description: 'Could not import project file',
variant: 'error',
duration: 3000,
});
}
};
input.click();
} catch (error) {
console.error('Failed to open file picker:', error);
}
}, [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,
},
// Edit
{
id: 'undo',
label: 'Undo',
description: 'Undo last action',
shortcut: 'Ctrl+Z',
category: 'edit',
action: undo,
},
{
id: 'redo',
label: 'Redo',
description: 'Redo last undone action',
shortcut: 'Ctrl+Shift+Z',
category: 'edit',
action: redo,
},
{
id: 'cut',
label: 'Cut',
description: 'Cut selection to clipboard',
shortcut: 'Ctrl+X',
category: 'edit',
action: handleCut,
},
{
id: 'copy',
label: 'Copy',
description: 'Copy selection to clipboard',
shortcut: 'Ctrl+C',
category: 'edit',
action: handleCopy,
},
{
id: 'paste',
label: 'Paste',
description: 'Paste clipboard content',
shortcut: 'Ctrl+V',
category: 'edit',
action: handlePaste,
},
{
id: 'delete',
label: 'Delete',
description: 'Delete selection',
shortcut: 'Delete',
category: 'edit',
action: handleDelete,
},
{
id: 'duplicate',
label: 'Duplicate',
description: 'Duplicate selection',
shortcut: 'Ctrl+D',
category: 'edit',
action: handleDuplicate,
},
{
id: 'split-at-cursor',
label: 'Split at Cursor',
description: 'Split selected track at current playback position',
shortcut: 'S',
category: 'edit',
action: handleSplitAtCursor,
},
{
id: 'select-all',
label: 'Select All',
description: 'Select all content on current track',
shortcut: 'Ctrl+A',
category: 'edit',
action: () => {
if (selectedTrackId) {
const track = tracks.find(t => t.id === selectedTrackId);
if (track?.audioBuffer) {
updateTrack(selectedTrackId, {
selection: { start: 0, end: track.audioBuffer.duration }
});
}
}
},
},
// Project
{
id: 'save-project',
label: 'Save Project',
description: 'Save current project',
shortcut: 'Ctrl+S',
category: 'file',
action: () => handleSaveProject(true),
},
{
id: 'open-projects',
label: 'Open Projects',
description: 'Open projects dialog',
category: 'file',
action: handleOpenProjectsDialog,
},
// View
{
id: 'zoom-in',
label: 'Zoom In',
description: 'Zoom in on waveforms',
shortcut: 'Ctrl++',
category: 'view',
action: handleZoomIn,
},
{
id: 'zoom-out',
label: 'Zoom Out',
description: 'Zoom out on waveforms',
shortcut: 'Ctrl+-',
category: 'view',
action: handleZoomOut,
},
{
id: 'fit-to-view',
label: 'Fit to View',
description: 'Reset zoom to fit all tracks',
shortcut: 'Ctrl+0',
category: 'view',
action: handleFitToView,
},
// Navigation
{
id: 'seek-start',
label: 'Go to Start',
description: 'Seek to beginning',
shortcut: 'Home',
category: 'playback',
action: () => seek(0),
},
{
id: 'seek-end',
label: 'Go to End',
description: 'Seek to end',
shortcut: 'End',
category: 'playback',
action: () => seek(duration),
},
{
id: 'seek-backward',
label: 'Seek Backward',
description: 'Seek backward 1 second',
shortcut: '←',
category: 'playback',
action: () => seek(Math.max(0, currentTime - 1)),
},
{
id: 'seek-forward',
label: 'Seek Forward',
description: 'Seek forward 1 second',
shortcut: '→',
category: 'playback',
action: () => seek(Math.min(duration, currentTime + 1)),
},
{
id: 'seek-backward-5s',
label: 'Seek Backward 5s',
description: 'Seek backward 5 seconds',
shortcut: 'Ctrl+←',
category: 'playback',
action: () => seek(Math.max(0, currentTime - 5)),
},
{
id: 'seek-forward-5s',
label: 'Seek Forward 5s',
description: 'Seek forward 5 seconds',
shortcut: 'Ctrl+→',
category: 'playback',
action: () => seek(Math.min(duration, currentTime + 5)),
},
// 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,
},
// Help
{
id: 'keyboard-shortcuts',
label: 'Keyboard Shortcuts',
description: 'View all keyboard shortcuts',
shortcut: '?',
category: 'view',
action: () => setShortcutsDialogOpen(true),
},
];
return actions;
}, [play, pause, stop, undo, redo, handleCut, handleCopy, handlePaste, handleDelete, handleDuplicate, handleSaveProject, handleOpenProjectsDialog, handleZoomIn, handleZoomOut, handleFitToView, handleImportTracks, handleClearTracks, addTrack, seek, duration, currentTime, selectedTrackId, tracks, updateTrack, setShortcutsDialogOpen]);
// 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+S: Save project (manual save with toast)
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
handleSaveProject(true); // Show toast for manual saves
return;
}
// Ctrl/Cmd+D: Duplicate
if ((e.ctrlKey || e.metaKey) && e.key === 'd') {
e.preventDefault();
handleDuplicate();
return;
}
// Ctrl/Cmd++: Zoom In (also accepts Ctrl+=)
if ((e.ctrlKey || e.metaKey) && (e.key === '=' || e.key === '+')) {
e.preventDefault();
handleZoomIn();
return;
}
// Ctrl/Cmd+-: Zoom Out
if ((e.ctrlKey || e.metaKey) && e.key === '-') {
e.preventDefault();
handleZoomOut();
return;
}
// Ctrl/Cmd+0: Fit to View
if ((e.ctrlKey || e.metaKey) && e.key === '0') {
e.preventDefault();
handleFitToView();
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);
return;
}
// Home: Go to start
if (e.key === 'Home') {
e.preventDefault();
seek(0);
return;
}
// End: Go to end
if (e.key === 'End') {
e.preventDefault();
seek(duration);
return;
}
// Left Arrow: Seek backward 1 second
if (e.key === 'ArrowLeft' && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
seek(Math.max(0, currentTime - 1));
return;
}
// Right Arrow: Seek forward 1 second
if (e.key === 'ArrowRight' && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
seek(Math.min(duration, currentTime + 1));
return;
}
// Ctrl+Left Arrow: Seek backward 5 seconds
if (e.key === 'ArrowLeft' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
seek(Math.max(0, currentTime - 5));
return;
}
// Ctrl+Right Arrow: Seek forward 5 seconds
if (e.key === 'ArrowRight' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
seek(Math.min(duration, currentTime + 5));
return;
}
// Ctrl+A: Select All (select all content on current track)
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
e.preventDefault();
if (!selectedTrackId) {
// If no track selected, try to select the first track with audio
const trackWithAudio = tracks.find(t => t.audioBuffer);
if (trackWithAudio && trackWithAudio.audioBuffer) {
setSelectedTrackId(trackWithAudio.id);
updateTrack(trackWithAudio.id, {
selection: { start: 0, end: trackWithAudio.audioBuffer.duration }
});
}
} else {
const track = tracks.find(t => t.id === selectedTrackId);
if (track?.audioBuffer) {
updateTrack(selectedTrackId, {
selection: { start: 0, end: track.audioBuffer.duration }
});
}
}
return;
}
// S: Split at cursor
if (e.key === 's' && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
handleSplitAtCursor();
return;
}
// ?: Open keyboard shortcuts help
if (e.key === '?' && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
setShortcutsDialogOpen(true);
return;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [togglePlayPause, canUndo, canRedo, undo, redo, handleCut, handleCopy, handlePaste, handleDelete, handleDuplicate, handleSaveProject, seek, duration, currentTime, selectedTrackId, tracks, updateTrack, handleZoomIn, handleZoomOut, handleFitToView, setSelectedTrackId, setShortcutsDialogOpen]);
return (
<>
{/* Compact Header */}
<header className="flex items-center justify-between px-2 sm:px-4 py-2 border-b border-border bg-card flex-shrink-0 gap-2 sm:gap-4">
{/* Left: Logo */}
<div className="flex items-center gap-2 sm:gap-4 flex-shrink-0">
<div className="flex items-center gap-2">
<Music className="h-5 w-5 text-primary" />
<h1 className="text-base sm:text-lg font-bold text-foreground hidden sm:block">Audio UI</h1>
</div>
{/* Project Name */}
<div className="hidden md: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 - Compact on mobile */}
<div className="flex items-center gap-1 sm:gap-2 border-l border-border pl-2 sm:pl-4">
<Button variant="outline" size="sm" onClick={handleOpenProjectsDialog} className="hidden sm:flex">
<FolderOpen className="h-4 w-4 mr-1.5" />
Projects
</Button>
<Button variant="outline" size="sm" onClick={handleOpenProjectsDialog} className="sm:hidden px-2">
<FolderOpen className="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" onClick={() => addTrack()}>
<Plus className="h-4 w-4 sm:mr-1.5" />
<span className="hidden sm:inline">Add Track</span>
</Button>
<Button variant="outline" size="sm" onClick={handleImportTracks}>
<Upload className="h-4 w-4 sm:mr-1.5" />
<span className="hidden sm:inline">Import</span>
</Button>
{tracks.length > 0 && (
<>
<Button variant="outline" size="sm" onClick={() => setExportDialogOpen(true)} className="hidden md:flex">
<Download className="h-4 w-4 mr-1.5" />
Export
</Button>
<Button variant="outline" size="sm" onClick={handleClearTracks} className="hidden lg:flex">
<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 - Hidden on mobile */}
<aside className="hidden lg:flex flex-shrink-0 border-l border-border bg-card flex-col pt-5 px-4 pb-4 gap-4 w-60">
{/* Master Controls */}
<div className="flex items-center justify-center">
<MasterControls
volume={masterVolume}
pan={masterPan}
peakLevel={masterPeakLevel}
rmsLevel={masterRmsLevel}
isClipping={masterIsClipping}
isMuted={isMasterMuted}
collapsed={masterControlsCollapsed}
onVolumeChange={setMasterVolume}
onPanChange={setMasterPan}
onMuteToggle={() => {
if (isMasterMuted) {
setMasterVolume(0.8);
setIsMasterMuted(false);
} else {
setMasterVolume(0);
setIsMasterMuted(true);
}
}}
onResetClip={resetClipIndicator}
onToggleCollapse={() => setMasterControlsCollapsed(!masterControlsCollapsed)}
/>
</div>
{/* Analyzer Toggle */}
<div className={`grid ${settings.performance.enableSpectrogram ? 'grid-cols-5' : 'grid-cols-4'} 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>
{settings.performance.enableSpectrogram && (
<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-[178px]">
{analyzerView === 'frequency' && (
<React.Suspense fallback={<div className="h-[300px] flex items-center justify-center text-muted-foreground">Loading...</div>}>
<FrequencyAnalyzer analyserNode={masterAnalyser} />
</React.Suspense>
)}
{analyzerView === 'spectrogram' && settings.performance.enableSpectrogram && (
<React.Suspense fallback={<div className="h-[300px] flex items-center justify-center text-muted-foreground">Loading...</div>}>
<Spectrogram analyserNode={masterAnalyser} />
</React.Suspense>
)}
{analyzerView === 'phase' && (
<React.Suspense fallback={<div className="h-[300px] flex items-center justify-center text-muted-foreground">Loading...</div>}>
<PhaseCorrelationMeter analyserNode={masterAnalyser} />
</React.Suspense>
)}
{analyzerView === 'lufs' && (
<React.Suspense fallback={<div className="h-[300px] flex items-center justify-center text-muted-foreground">Loading...</div>}>
<LUFSMeter analyserNode={masterAnalyser} />
</React.Suspense>
)}
{analyzerView === 'stats' && (
<React.Suspense fallback={<div className="h-[300px] flex items-center justify-center text-muted-foreground">Loading...</div>}>
<AudioStatistics tracks={tracks} />
</React.Suspense>
)}
</div>
</div>
</aside>
</div>
{/* Bottom Bar - Stacked on mobile (Master then Transport), Side-by-side on desktop */}
<div className="border-t border-border bg-card p-3 flex flex-col lg:flex-row gap-3">
{/* Master Controls - Mobile only (hidden on desktop where sidebar shows master) */}
<div className="lg:hidden flex justify-center border-b border-border pb-3 lg:border-b-0 lg:pb-0">
<MasterControls
volume={masterVolume}
pan={masterPan}
peakLevel={masterPeakLevel}
rmsLevel={masterRmsLevel}
isClipping={masterIsClipping}
isMuted={isMasterMuted}
collapsed={masterControlsCollapsed}
onVolumeChange={setMasterVolume}
onPanChange={setMasterPan}
onMuteToggle={() => {
if (isMasterMuted) {
setMasterVolume(0.8);
setIsMasterMuted(false);
} else {
setMasterVolume(0);
setIsMasterMuted(true);
}
}}
onResetClip={resetClipIndicator}
onToggleCollapse={() => setMasterControlsCollapsed(!masterControlsCollapsed)}
/>
</div>
{/* Playback Controls - Bottom on mobile, centered on desktop */}
<div className="flex-1 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}
loopEnabled={loopEnabled}
loopStart={loopStart}
loopEnd={loopEnd}
onToggleLoop={toggleLoop}
onSetLoopPoints={setLoopPoints}
playbackRate={playbackRate}
onPlaybackRateChange={changePlaybackRate}
/>
</div>
</div>
{/* Import Track Dialog */}
<React.Suspense fallback={null}>
<ImportTrackDialog
open={importDialogOpen}
onClose={() => setImportDialogOpen(false)}
onImportTrack={handleImportTrack}
/>
</React.Suspense>
{/* Global Settings Dialog */}
<React.Suspense fallback={null}>
<GlobalSettingsDialog
open={settingsDialogOpen}
onClose={() => setSettingsDialogOpen(false)}
recordingSettings={recordingSettings}
onInputGainChange={setInputGain}
onRecordMonoChange={setRecordMono}
onSampleRateChange={setSampleRate}
settings={settings}
onAudioSettingsChange={updateAudioSettings}
onUISettingsChange={updateUISettings}
onEditorSettingsChange={updateEditorSettings}
onPerformanceSettingsChange={updatePerformanceSettings}
onResetCategory={resetCategory}
/>
</React.Suspense>
{/* Export Dialog */}
<React.Suspense fallback={null}>
<ExportDialog
open={exportDialogOpen}
onClose={() => setExportDialogOpen(false)}
onExport={handleExport}
isExporting={isExporting}
hasSelection={tracks.some(t => t.selection !== null)}
/>
</React.Suspense>
{/* Projects Dialog */}
<React.Suspense fallback={null}>
<ProjectsDialog
open={projectsDialogOpen}
onClose={() => setProjectsDialogOpen(false)}
projects={projects}
onNewProject={handleNewProject}
onLoadProject={handleLoadProject}
onDeleteProject={handleDeleteProject}
onDuplicateProject={handleDuplicateProject}
onExportProject={handleExportProject}
onImportProject={handleImportProject}
/>
</React.Suspense>
{/* Browser Compatibility Dialog */}
<BrowserCompatDialog
open={browserCompatDialogOpen}
missingFeatures={browserCompatInfo.missingFeatures}
warnings={browserCompatInfo.warnings}
onClose={() => setBrowserCompatDialogOpen(false)}
/>
{/* Keyboard Shortcuts Dialog */}
<React.Suspense fallback={null}>
<KeyboardShortcutsDialog
open={shortcutsDialogOpen}
onClose={() => setShortcutsDialogOpen(false)}
/>
</React.Suspense>
</>
);
}