Files
audio-ui/components/editor/AudioEditor.tsx
Sebastian Krüger 9007522e18 feat: add playback speed control (0.25x - 2x)
Implemented variable playback speed functionality:
- Added playbackRate state and ref to useMultiTrackPlayer (0.25x - 2x range)
- Applied playback rate to AudioBufferSourceNode.playbackRate
- Updated timing calculations to account for playback rate
- Real-time playback speed adjustment for active playback
- Dropdown UI control in PlaybackControls with preset speeds
- Integrated changePlaybackRate function through AudioEditor

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

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

2065 lines
68 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 [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]);
// 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 */}
<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>
</>
);
}