'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(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(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 [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([]); const [currentProjectId, setCurrentProjectId] = React.useState(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>(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, 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]); // 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: '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; } // ?: 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 */}
{/* Left: Logo */}

Audio UI

{/* Project Name */}
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` }} />
{/* Track Actions - Compact on mobile */}
{tracks.length > 0 && ( <> )}
{/* Right: Command Palette + Settings + Theme Toggle */}
{/* Main content area */}
{/* Main canvas area */}
{/* Multi-Track View */}
{/* Right Sidebar - Master Controls & Analyzers - Hidden on mobile */}
}> )} {analyzerView === 'lufs' && ( Loading...}> )} {analyzerView === 'stats' && ( Loading...}> )} {/* Bottom Bar - Stacked on mobile (Master then Transport), Side-by-side on desktop */}
{/* Master Controls - Mobile only (hidden on desktop where sidebar shows master) */}
{ if (isMasterMuted) { setMasterVolume(0.8); setIsMasterMuted(false); } else { setMasterVolume(0); setIsMasterMuted(true); } }} onResetClip={resetClipIndicator} onToggleCollapse={() => setMasterControlsCollapsed(!masterControlsCollapsed)} />
{/* Playback Controls - Bottom on mobile, centered on desktop */}
{/* Import Track Dialog */} setImportDialogOpen(false)} onImportTrack={handleImportTrack} /> {/* Global Settings Dialog */} setSettingsDialogOpen(false)} recordingSettings={recordingSettings} onInputGainChange={setInputGain} onRecordMonoChange={setRecordMono} onSampleRateChange={setSampleRate} settings={settings} onAudioSettingsChange={updateAudioSettings} onUISettingsChange={updateUISettings} onEditorSettingsChange={updateEditorSettings} onPerformanceSettingsChange={updatePerformanceSettings} onResetCategory={resetCategory} /> {/* Export Dialog */} setExportDialogOpen(false)} onExport={handleExport} isExporting={isExporting} hasSelection={tracks.some(t => t.selection !== null)} /> {/* Projects Dialog */} setProjectsDialogOpen(false)} projects={projects} onNewProject={handleNewProject} onLoadProject={handleLoadProject} onDeleteProject={handleDeleteProject} onDuplicateProject={handleDuplicateProject} onExportProject={handleExportProject} onImportProject={handleImportProject} /> {/* Browser Compatibility Dialog */} setBrowserCompatDialogOpen(false)} /> {/* Keyboard Shortcuts Dialog */} setShortcutsDialogOpen(false)} /> ); }