'use client'; import * as React from 'react'; import { Music, Plus, Upload, Trash2, Settings, Download } from 'lucide-react'; import { PlaybackControls } from './PlaybackControls'; import { ThemeToggle } from '@/components/layout/ThemeToggle'; import { CommandPalette } from '@/components/ui/CommandPalette'; import { GlobalSettingsDialog } from '@/components/settings/GlobalSettingsDialog'; import { ExportDialog, type ExportSettings } from '@/components/dialogs/ExportDialog'; import { Button } from '@/components/ui/Button'; import type { CommandAction } from '@/components/ui/CommandPalette'; import { useMultiTrack } from '@/lib/hooks/useMultiTrack'; 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, downloadArrayBuffer } from '@/lib/audio/export'; export function AudioEditor() { const [importDialogOpen, setImportDialogOpen] = React.useState(false); const [selectedTrackId, setSelectedTrackId] = React.useState(null); const [zoom, setZoom] = React.useState(1); const [masterVolume, setMasterVolume] = React.useState(0.8); const [clipboard, setClipboard] = React.useState(null); const [recordingTrackId, setRecordingTrackId] = React.useState(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 { 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, } = 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>(new Set()); const [latchTriggered, setLatchTriggered] = React.useState>(new Set()); // Track last recorded values to detect changes const lastRecordedValuesRef = React.useRef>(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, 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 { // Get max duration and sample rate const maxDuration = getMaxTrackDuration(tracks); const sampleRate = tracks[0]?.audioBuffer?.sampleRate || 44100; // Mix all tracks into a single buffer const mixedBuffer = mixTracks(tracks, sampleRate, maxDuration); // Convert to WAV const wavBuffer = audioBufferToWav(mixedBuffer, { format: settings.format, bitDepth: settings.bitDepth, normalize: settings.normalize, }); // Download const filename = `${settings.filename}.wav`; downloadArrayBuffer(wavBuffer, filename); addToast({ title: 'Export Complete', description: `Exported ${filename}`, variant: 'success', duration: 3000, }); setExportDialogOpen(false); } catch (error) { console.error('Export failed:', error); addToast({ title: 'Export Failed', description: 'Failed to export audio', variant: 'error', duration: 3000, }); } finally { setIsExporting(false); } }, [tracks, addToast]); // Zoom controls const handleZoomIn = () => { setZoom((prev) => Math.min(20, prev + 1)); }; 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 */}
{/* Left: Logo */}

Audio UI

{/* Track Actions */}
{tracks.length > 0 && ( <> )}
{/* Right: Command Palette + Settings + Theme Toggle */}
{/* Main content area */}
{/* Main canvas area */}
{/* Multi-Track View */}
{/* Transport Controls */}
{/* Import Track Dialog */} setImportDialogOpen(false)} onImportTrack={handleImportTrack} /> {/* Global Settings Dialog */} setSettingsDialogOpen(false)} recordingSettings={recordingSettings} onInputGainChange={setInputGain} onRecordMonoChange={setRecordMono} onSampleRateChange={setSampleRate} /> {/* Export Dialog */} setExportDialogOpen(false)} onExport={handleExport} isExporting={isExporting} /> ); }