Files
audio-ui/components/editor/AudioEditor.tsx
Sebastian Krüger 832a18dd9c feat: integrate multi-track functionality into main AudioEditor
Added comprehensive multi-track support to the main application:
- Added "Tracks" tab to SidePanel with track management controls
- Integrated useMultiTrack and useMultiTrackPlayer hooks into AudioEditor
- Added view mode switching between waveform and tracks views
- Implemented "Convert to Track" to convert current audio buffer to track
- Added TrackList view with multi-track playback controls
- Wired up ImportTrackDialog for importing multiple audio files

Users can now:
- Click "Tracks" tab in side panel to access multi-track mode
- Convert current audio to a track
- Import multiple audio files as tracks
- View and manage tracks in dedicated TrackList view
- Play multiple tracks simultaneously with individual controls

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 21:57:31 +01:00

1567 lines
44 KiB
TypeScript

'use client';
import * as React from 'react';
import { Music, Loader2 } from 'lucide-react';
import { Waveform } from './Waveform';
import { PlaybackControls } from './PlaybackControls';
import { SidePanel } from '@/components/layout/SidePanel';
import { ThemeToggle } from '@/components/layout/ThemeToggle';
import { CommandPalette } from '@/components/ui/CommandPalette';
import type { CommandAction } from '@/components/ui/CommandPalette';
import { useAudioPlayer } from '@/lib/hooks/useAudioPlayer';
import { useHistory } from '@/lib/hooks/useHistory';
import { useEffectChain } from '@/lib/hooks/useEffectChain';
import { useMultiTrack } from '@/lib/hooks/useMultiTrack';
import { useMultiTrackPlayer } from '@/lib/hooks/useMultiTrackPlayer';
import { useToast } from '@/components/ui/Toast';
import { Slider } from '@/components/ui/Slider';
import { cn } from '@/lib/utils/cn';
import type { Selection, ClipboardData } from '@/types/selection';
import {
extractBufferSegment,
deleteBufferSegment,
insertBufferSegment,
trimBuffer,
} from '@/lib/audio/buffer-utils';
import {
createCutCommand,
createDeleteCommand,
createPasteCommand,
createTrimCommand,
} from '@/lib/history/commands/edit-command';
import {
createGainCommand,
createNormalizePeakCommand,
createNormalizeRMSCommand,
createFadeInCommand,
createFadeOutCommand,
createReverseCommand,
createLowPassFilterCommand,
createHighPassFilterCommand,
createBandPassFilterCommand,
EffectCommand,
} from '@/lib/history/commands/effect-command';
import { applyEffectToSelection, applyAsyncEffectToSelection } from '@/lib/audio/effects/selection';
import { normalizePeak } from '@/lib/audio/effects/normalize';
import { applyFadeIn, applyFadeOut } from '@/lib/audio/effects/fade';
import { reverseAudio } from '@/lib/audio/effects/reverse';
import { applyLowPassFilter, applyHighPassFilter, applyBandPassFilter, applyFilter } from '@/lib/audio/effects/filters';
import type { FilterType } from '@/lib/audio/effects/filters';
import { applyCompressor, applyLimiter, applyGate } from '@/lib/audio/effects/dynamics';
import { applyDelay, applyReverb, applyChorus, applyFlanger, applyPhaser } from '@/lib/audio/effects/time-based';
import { applyPitchShift, applyTimeStretch, applyDistortion, applyBitcrusher } from '@/lib/audio/effects/advanced';
import { EffectParameterDialog, type FilterParameters } from '@/components/effects/EffectParameterDialog';
import { DynamicsParameterDialog, type DynamicsParameters, type DynamicsType } from '@/components/effects/DynamicsParameterDialog';
import { TimeBasedParameterDialog, type TimeBasedParameters, type TimeBasedType } from '@/components/effects/TimeBasedParameterDialog';
import { AdvancedParameterDialog, type AdvancedParameters, type AdvancedType } from '@/components/effects/AdvancedParameterDialog';
import { TrackList } from '@/components/tracks/TrackList';
import { ImportTrackDialog } from '@/components/tracks/ImportTrackDialog';
const EFFECT_LABELS: Record<string, string> = {
lowpass: 'Low-Pass Filter',
highpass: 'High-Pass Filter',
bandpass: 'Band-Pass Filter',
notch: 'Notch Filter',
lowshelf: 'Low Shelf Filter',
highshelf: 'High Shelf Filter',
peaking: 'Peaking EQ',
compressor: 'Compressor',
limiter: 'Limiter',
gate: 'Gate/Expander',
delay: 'Delay/Echo',
reverb: 'Reverb',
chorus: 'Chorus',
flanger: 'Flanger',
phaser: 'Phaser',
pitch: 'Pitch Shifter',
timestretch: 'Time Stretch',
distortion: 'Distortion',
bitcrusher: 'Bitcrusher',
};
export function AudioEditor() {
// View mode state
const [viewMode, setViewMode] = React.useState<'waveform' | 'tracks'>('waveform');
const [importDialogOpen, setImportDialogOpen] = React.useState(false);
// Zoom and scroll state
const [zoom, setZoom] = React.useState(1);
const [scrollOffset, setScrollOffset] = React.useState(0);
const [amplitudeScale, setAmplitudeScale] = React.useState(1);
// Effect dialog state
const [effectDialogOpen, setEffectDialogOpen] = React.useState(false);
const [effectDialogType, setEffectDialogType] = React.useState<'lowpass' | 'highpass' | 'bandpass' | 'notch' | 'lowshelf' | 'highshelf' | 'peaking'>('lowpass');
// Dynamics dialog state
const [dynamicsDialogOpen, setDynamicsDialogOpen] = React.useState(false);
const [dynamicsDialogType, setDynamicsDialogType] = React.useState<DynamicsType>('compressor');
// Time-based dialog state
const [timeBasedDialogOpen, setTimeBasedDialogOpen] = React.useState(false);
const [timeBasedDialogType, setTimeBasedDialogType] = React.useState<TimeBasedType>('delay');
// Advanced dialog state
const [advancedDialogOpen, setAdvancedDialogOpen] = React.useState(false);
const [advancedDialogType, setAdvancedDialogType] = React.useState<AdvancedType>('pitch');
// Drag and drop state
const [isDragging, setIsDragging] = React.useState(false);
const fileInputRef = React.useRef<HTMLInputElement>(null);
// Selection state
const [selection, setSelection] = React.useState<Selection | null>(null);
const [clipboard, setClipboard] = React.useState<ClipboardData | null>(null);
const {
loadFile,
loadBuffer,
clearFile,
play,
pause,
stop,
seek,
setVolume,
isPlaying,
isPaused,
currentTime,
duration,
volume,
audioBuffer,
fileName,
isLoading,
error,
currentTimeFormatted,
durationFormatted,
} = useAudioPlayer();
const { execute, undo, redo, clear: clearHistory, state: historyState } = useHistory(50);
const {
chain: effectChain,
presets: effectPresets,
toggleEffectEnabled,
removeEffect,
reorder: reorderEffects,
clearChain,
savePreset,
loadPresetToChain,
deletePreset,
} = useEffectChain();
const { addToast } = useToast();
// Multi-track hooks
const {
tracks,
addTrack,
addTrackFromBuffer,
removeTrack,
updateTrack,
clearTracks,
} = useMultiTrack();
const {
isPlaying: isMultiTrackPlaying,
currentTime: multiTrackCurrentTime,
duration: multiTrackDuration,
play: playMultiTrack,
pause: pauseMultiTrack,
stop: stopMultiTrack,
seek: seekMultiTrack,
togglePlayPause: toggleMultiTrackPlayPause,
} = useMultiTrackPlayer(tracks);
const handleFileSelect = async (file: File) => {
try {
await loadFile(file);
addToast({
title: 'File loaded',
description: `Successfully loaded ${file.name}`,
variant: 'success',
duration: 3000,
});
} catch (err) {
addToast({
title: 'Error loading file',
description: err instanceof Error ? err.message : 'Unknown error',
variant: 'error',
duration: 5000,
});
}
};
const handleClear = () => {
clearFile();
setZoom(1);
setScrollOffset(0);
setAmplitudeScale(1);
setSelection(null);
setClipboard(null);
clearHistory();
addToast({
title: 'Audio cleared',
description: 'Audio file has been removed',
variant: 'info',
duration: 2000,
});
};
// Multi-track handlers
const handleConvertToTrack = () => {
if (!audioBuffer) return;
const trackName = fileName || 'Audio Track';
addTrackFromBuffer(audioBuffer, trackName);
setViewMode('tracks');
addToast({
title: 'Converted to Track',
description: `"${trackName}" added to tracks`,
variant: 'success',
duration: 2000,
});
};
const handleImportTracks = () => {
setImportDialogOpen(true);
};
const handleImportTrack = (buffer: AudioBuffer, name: string) => {
addTrackFromBuffer(buffer, name);
setViewMode('tracks');
};
const handleClearTracks = () => {
clearTracks();
setViewMode('waveform');
addToast({
title: 'Tracks Cleared',
description: 'All tracks have been removed',
variant: 'info',
duration: 2000,
});
};
// Drag and drop handlers
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Only set to false if we're leaving the drop zone entirely
if (e.currentTarget === e.target) {
setIsDragging(false);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
const audioFile = files.find(file => file.type.startsWith('audio/'));
if (audioFile) {
await handleFileSelect(audioFile);
} else {
addToast({
title: 'Invalid file',
description: 'Please drop an audio file',
variant: 'error',
duration: 3000,
});
}
};
const handleDropZoneClick = () => {
fileInputRef.current?.click();
};
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
handleFileSelect(file);
}
};
// Edit operations
const handleCut = () => {
if (!selection || !audioBuffer) return;
try {
// Copy to clipboard
const clipData = extractBufferSegment(audioBuffer, selection.start, selection.end);
setClipboard({
buffer: clipData,
start: selection.start,
end: selection.end,
duration: selection.end - selection.start,
});
// Create and execute cut command
const command = createCutCommand(audioBuffer, selection, (buffer) => {
loadBuffer(buffer);
});
execute(command);
setSelection(null);
addToast({
title: 'Cut',
description: 'Selection cut to clipboard',
variant: 'success',
duration: 2000,
});
} catch (error) {
addToast({
title: 'Error',
description: 'Failed to cut selection',
variant: 'error',
duration: 3000,
});
}
};
const handleCopy = () => {
if (!selection || !audioBuffer) return;
try {
const clipData = extractBufferSegment(audioBuffer, selection.start, selection.end);
setClipboard({
buffer: clipData,
start: selection.start,
end: selection.end,
duration: selection.end - selection.start,
});
addToast({
title: 'Copied',
description: 'Selection copied to clipboard',
variant: 'success',
duration: 2000,
});
} catch (error) {
addToast({
title: 'Error',
description: 'Failed to copy selection',
variant: 'error',
duration: 3000,
});
}
};
const handlePaste = () => {
if (!clipboard || !audioBuffer) return;
try {
const insertTime = currentTime;
// Create and execute paste command
const command = createPasteCommand(audioBuffer, clipboard.buffer, insertTime, (buffer) => {
loadBuffer(buffer);
});
execute(command);
addToast({
title: 'Pasted',
description: 'Clipboard pasted at current position',
variant: 'success',
duration: 2000,
});
} catch (error) {
addToast({
title: 'Error',
description: 'Failed to paste clipboard',
variant: 'error',
duration: 3000,
});
}
};
const handleDelete = () => {
if (!selection || !audioBuffer) return;
try {
// Create and execute delete command
const command = createDeleteCommand(audioBuffer, selection, (buffer) => {
loadBuffer(buffer);
});
execute(command);
setSelection(null);
addToast({
title: 'Deleted',
description: 'Selection deleted',
variant: 'success',
duration: 2000,
});
} catch (error) {
addToast({
title: 'Error',
description: 'Failed to delete selection',
variant: 'error',
duration: 3000,
});
}
};
const handleTrim = () => {
if (!selection || !audioBuffer) return;
try {
// Create and execute trim command
const command = createTrimCommand(audioBuffer, selection, (buffer) => {
loadBuffer(buffer);
});
execute(command);
setSelection(null);
addToast({
title: 'Trimmed',
description: 'Audio trimmed to selection',
variant: 'success',
duration: 2000,
});
} catch (error) {
addToast({
title: 'Error',
description: 'Failed to trim audio',
variant: 'error',
duration: 3000,
});
}
};
const handleSelectAll = () => {
if (!audioBuffer) return;
setSelection({ start: 0, end: duration });
};
const handleClearSelection = () => {
setSelection(null);
};
// Effect operations
const handleNormalize = () => {
if (!audioBuffer) return;
try {
// Apply to selection or entire buffer
const modifiedBuffer = applyEffectToSelection(
audioBuffer,
selection,
(buf) => normalizePeak(buf, 1.0)
);
const command = new EffectCommand(
audioBuffer,
modifiedBuffer,
(buffer) => loadBuffer(buffer),
selection ? 'Normalize Selection' : 'Normalize'
);
execute(command);
addToast({
title: 'Normalized',
description: selection ? 'Selection normalized to peak' : 'Audio normalized to peak',
variant: 'success',
duration: 2000,
});
} catch (error) {
addToast({
title: 'Error',
description: 'Failed to normalize audio',
variant: 'error',
duration: 3000,
});
}
};
const handleFadeIn = () => {
if (!audioBuffer) return;
if (!selection) {
addToast({
title: 'No Selection',
description: 'Please select a region to apply fade in',
variant: 'info',
duration: 2000,
});
return;
}
try {
const fadeDuration = selection.end - selection.start;
const modifiedBuffer = applyEffectToSelection(
audioBuffer,
selection,
(buf) => applyFadeIn(buf, buf.duration)
);
const command = new EffectCommand(
audioBuffer,
modifiedBuffer,
(buffer) => loadBuffer(buffer),
`Fade In (${fadeDuration.toFixed(2)}s)`
);
execute(command);
addToast({
title: 'Fade In',
description: `Applied fade in (${fadeDuration.toFixed(2)}s)`,
variant: 'success',
duration: 2000,
});
} catch (error) {
addToast({
title: 'Error',
description: 'Failed to apply fade in',
variant: 'error',
duration: 3000,
});
}
};
const handleFadeOut = () => {
if (!audioBuffer) return;
if (!selection) {
addToast({
title: 'No Selection',
description: 'Please select a region to apply fade out',
variant: 'info',
duration: 2000,
});
return;
}
try {
const fadeDuration = selection.end - selection.start;
const modifiedBuffer = applyEffectToSelection(
audioBuffer,
selection,
(buf) => applyFadeOut(buf, buf.duration)
);
const command = new EffectCommand(
audioBuffer,
modifiedBuffer,
(buffer) => loadBuffer(buffer),
`Fade Out (${fadeDuration.toFixed(2)}s)`
);
execute(command);
addToast({
title: 'Fade Out',
description: `Applied fade out (${fadeDuration.toFixed(2)}s)`,
variant: 'success',
duration: 2000,
});
} catch (error) {
addToast({
title: 'Error',
description: 'Failed to apply fade out',
variant: 'error',
duration: 3000,
});
}
};
const handleReverse = () => {
if (!audioBuffer) return;
try {
const modifiedBuffer = applyEffectToSelection(
audioBuffer,
selection,
(buf) => reverseAudio(buf)
);
const command = new EffectCommand(
audioBuffer,
modifiedBuffer,
(buffer) => loadBuffer(buffer),
selection ? 'Reverse Selection' : 'Reverse'
);
execute(command);
addToast({
title: 'Reversed',
description: selection ? 'Selection reversed' : 'Audio reversed',
variant: 'success',
duration: 2000,
});
} catch (error) {
addToast({
title: 'Error',
description: 'Failed to reverse audio',
variant: 'error',
duration: 3000,
});
}
};
const handleLowPassFilter = () => {
setEffectDialogType('lowpass');
setEffectDialogOpen(true);
};
const handleHighPassFilter = () => {
setEffectDialogType('highpass');
setEffectDialogOpen(true);
};
const handleBandPassFilter = () => {
setEffectDialogType('bandpass');
setEffectDialogOpen(true);
};
const handleCompressor = () => {
setDynamicsDialogType('compressor');
setDynamicsDialogOpen(true);
};
const handleLimiter = () => {
setDynamicsDialogType('limiter');
setDynamicsDialogOpen(true);
};
const handleGate = () => {
setDynamicsDialogType('gate');
setDynamicsDialogOpen(true);
};
const handleDelay = () => {
setTimeBasedDialogType('delay');
setTimeBasedDialogOpen(true);
};
const handleReverb = () => {
setTimeBasedDialogType('reverb');
setTimeBasedDialogOpen(true);
};
const handleChorus = () => {
setTimeBasedDialogType('chorus');
setTimeBasedDialogOpen(true);
};
const handleFlanger = () => {
setTimeBasedDialogType('flanger');
setTimeBasedDialogOpen(true);
};
const handlePhaser = () => {
setTimeBasedDialogType('phaser');
setTimeBasedDialogOpen(true);
};
const handlePitchShift = () => {
setAdvancedDialogType('pitch');
setAdvancedDialogOpen(true);
};
const handleTimeStretch = () => {
setAdvancedDialogType('timestretch');
setAdvancedDialogOpen(true);
};
const handleDistortion = () => {
setAdvancedDialogType('distortion');
setAdvancedDialogOpen(true);
};
const handleBitcrusher = () => {
setAdvancedDialogType('bitcrusher');
setAdvancedDialogOpen(true);
};
// Handle effect apply from parameter dialog
const handleEffectApply = async (params: FilterParameters) => {
if (!audioBuffer) return;
try {
const modifiedBuffer = await applyAsyncEffectToSelection(
audioBuffer,
selection,
(buf) => applyFilter(buf, {
type: params.type,
frequency: params.frequency,
Q: params.Q,
gain: params.gain,
})
);
const effectName = EFFECT_LABELS[params.type] || 'Filter';
const command = new EffectCommand(
audioBuffer,
modifiedBuffer,
(buffer) => loadBuffer(buffer),
selection
? `${effectName} Selection (${params.frequency.toFixed(0)}Hz)`
: `${effectName} (${params.frequency.toFixed(0)}Hz)`
);
execute(command);
addToast({
title: effectName,
description: selection
? `Applied ${effectName.toLowerCase()} to selection (${params.frequency.toFixed(0)}Hz)`
: `Applied ${effectName.toLowerCase()} (${params.frequency.toFixed(0)}Hz)`,
variant: 'success',
duration: 2000,
});
} catch (error) {
addToast({
title: 'Error',
description: 'Failed to apply effect',
variant: 'error',
duration: 3000,
});
}
};
// Handle dynamics apply from parameter dialog
const handleDynamicsApply = async (params: DynamicsParameters) => {
if (!audioBuffer) return;
try {
let modifiedBuffer: AudioBuffer;
let effectName: string;
switch (params.type) {
case 'compressor':
modifiedBuffer = await applyAsyncEffectToSelection(
audioBuffer,
selection,
(buf) => applyCompressor(buf, params)
);
effectName = 'Compressor';
break;
case 'limiter':
modifiedBuffer = await applyAsyncEffectToSelection(
audioBuffer,
selection,
(buf) => applyLimiter(buf, params)
);
effectName = 'Limiter';
break;
case 'gate':
modifiedBuffer = await applyAsyncEffectToSelection(
audioBuffer,
selection,
(buf) => applyGate(buf, params)
);
effectName = 'Gate';
break;
}
const command = new EffectCommand(
audioBuffer,
modifiedBuffer,
(buffer) => loadBuffer(buffer),
selection
? `${effectName} Selection (${params.threshold.toFixed(1)}dB)`
: `${effectName} (${params.threshold.toFixed(1)}dB)`
);
execute(command);
addToast({
title: effectName,
description: selection
? `Applied ${effectName.toLowerCase()} to selection`
: `Applied ${effectName.toLowerCase()}`,
variant: 'success',
duration: 2000,
});
} catch (error) {
addToast({
title: 'Error',
description: 'Failed to apply dynamics effect',
variant: 'error',
duration: 3000,
});
}
};
// Handle time-based apply from parameter dialog
const handleTimeBasedApply = async (params: TimeBasedParameters) => {
if (!audioBuffer) return;
try {
let modifiedBuffer: AudioBuffer;
let effectName: string;
switch (params.type) {
case 'delay':
modifiedBuffer = await applyAsyncEffectToSelection(
audioBuffer,
selection,
(buf) => applyDelay(buf, params)
);
effectName = 'Delay';
break;
case 'reverb':
modifiedBuffer = await applyAsyncEffectToSelection(
audioBuffer,
selection,
(buf) => applyReverb(buf, params)
);
effectName = 'Reverb';
break;
case 'chorus':
modifiedBuffer = await applyAsyncEffectToSelection(
audioBuffer,
selection,
(buf) => applyChorus(buf, params)
);
effectName = 'Chorus';
break;
case 'flanger':
modifiedBuffer = await applyAsyncEffectToSelection(
audioBuffer,
selection,
(buf) => applyFlanger(buf, params)
);
effectName = 'Flanger';
break;
case 'phaser':
modifiedBuffer = await applyAsyncEffectToSelection(
audioBuffer,
selection,
(buf) => applyPhaser(buf, params)
);
effectName = 'Phaser';
break;
}
const command = new EffectCommand(
audioBuffer,
modifiedBuffer,
(buffer) => loadBuffer(buffer),
selection
? `${effectName} Selection`
: `${effectName}`
);
execute(command);
addToast({
title: effectName,
description: selection
? `Applied ${effectName.toLowerCase()} to selection`
: `Applied ${effectName.toLowerCase()}`,
variant: 'success',
duration: 2000,
});
} catch (error) {
addToast({
title: 'Error',
description: 'Failed to apply time-based effect',
variant: 'error',
duration: 3000,
});
}
};
const handleAdvancedApply = async (params: AdvancedParameters) => {
if (!audioBuffer) return;
try {
let modifiedBuffer: AudioBuffer;
let effectName: string;
const effectType = params.type;
if (effectType === 'pitch') {
modifiedBuffer = await applyAsyncEffectToSelection(
audioBuffer,
selection,
(buf) => applyPitchShift(buf, params as any)
);
effectName = 'Pitch Shift';
} else if (effectType === 'timestretch') {
modifiedBuffer = await applyAsyncEffectToSelection(
audioBuffer,
selection,
(buf) => applyTimeStretch(buf, params as any)
);
effectName = 'Time Stretch';
} else if (effectType === 'bitcrusher') {
modifiedBuffer = await applyAsyncEffectToSelection(
audioBuffer,
selection,
(buf) => applyBitcrusher(buf, params as any)
);
effectName = 'Bitcrusher';
} else {
modifiedBuffer = await applyAsyncEffectToSelection(
audioBuffer,
selection,
(buf) => applyDistortion(buf, params as any)
);
effectName = 'Distortion';
}
const command = new EffectCommand(
audioBuffer,
modifiedBuffer,
(buffer) => loadBuffer(buffer),
selection
? `${effectName} Selection`
: `${effectName}`
);
execute(command);
addToast({
title: effectName,
description: selection
? `Applied ${effectName.toLowerCase()} to selection`
: `Applied ${effectName.toLowerCase()}`,
variant: 'success',
duration: 2000,
});
} catch (error) {
addToast({
title: 'Error',
description: 'Failed to apply advanced effect',
variant: 'error',
duration: 3000,
});
}
};
// Zoom controls
const handleZoomIn = () => {
setZoom((prev) => Math.min(20, prev + 1));
};
const handleZoomOut = () => {
setZoom((prev) => Math.max(1, prev - 1));
};
const handleFitToView = () => {
setZoom(1);
setScrollOffset(0);
};
// Auto-adjust scroll when zoom changes
React.useEffect(() => {
if (!audioBuffer) return;
// Reset scroll if zoomed out completely
if (zoom === 1) {
setScrollOffset(0);
}
}, [zoom, audioBuffer]);
// 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();
if (isPlaying) {
pause();
} else {
play();
}
return;
}
// Prevent other shortcuts if typing in an input
if (isTyping) {
return;
}
// Ctrl+Z: Undo
if (e.ctrlKey && !e.shiftKey && e.key === 'z') {
e.preventDefault();
if (undo()) {
addToast({
title: 'Undo',
description: 'Last action undone',
variant: 'info',
duration: 1500,
});
}
}
// Ctrl+Y or Ctrl+Shift+Z: Redo
if ((e.ctrlKey && e.key === 'y') || (e.ctrlKey && e.shiftKey && e.key === 'z')) {
e.preventDefault();
if (redo()) {
addToast({
title: 'Redo',
description: 'Last action redone',
variant: 'info',
duration: 1500,
});
}
}
// Ctrl+A: Select all
if (e.ctrlKey && e.key === 'a') {
e.preventDefault();
handleSelectAll();
}
// Ctrl+X: Cut
if (e.ctrlKey && e.key === 'x') {
e.preventDefault();
handleCut();
}
// Ctrl+C: Copy
if (e.ctrlKey && e.key === 'c') {
e.preventDefault();
handleCopy();
}
// Ctrl+V: Paste
if (e.ctrlKey && e.key === 'v') {
e.preventDefault();
handlePaste();
}
// Delete: Delete selection
if (e.key === 'Delete') {
e.preventDefault();
handleDelete();
}
// Escape: Clear selection
if (e.key === 'Escape') {
e.preventDefault();
handleClearSelection();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selection, clipboard, audioBuffer, currentTime, undo, redo, addToast, isPlaying, play, pause]);
// Show error toast
React.useEffect(() => {
if (error) {
addToast({
title: 'Error',
description: error,
variant: 'error',
duration: 5000,
});
}
}, [error, addToast]);
// 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: '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 at current position',
shortcut: 'Ctrl+V',
category: 'edit',
action: handlePaste,
},
{
id: 'delete',
label: 'Delete',
description: 'Delete selection',
shortcut: 'Del',
category: 'edit',
action: handleDelete,
},
{
id: 'trim',
label: 'Trim to Selection',
description: 'Trim audio to selected region',
category: 'edit',
action: handleTrim,
},
{
id: 'select-all',
label: 'Select All',
description: 'Select entire audio',
shortcut: 'Ctrl+A',
category: 'edit',
action: handleSelectAll,
},
{
id: 'clear-selection',
label: 'Clear Selection',
description: 'Clear current selection',
shortcut: 'Esc',
category: 'edit',
action: handleClearSelection,
},
// View
{
id: 'zoom-in',
label: 'Zoom In',
description: 'Zoom in on waveform',
category: 'view',
action: handleZoomIn,
},
{
id: 'zoom-out',
label: 'Zoom Out',
description: 'Zoom out on waveform',
category: 'view',
action: handleZoomOut,
},
{
id: 'fit-to-view',
label: 'Fit to View',
description: 'Reset zoom to fit entire waveform',
category: 'view',
action: handleFitToView,
},
// File
{
id: 'clear',
label: 'Clear Audio',
description: 'Remove loaded audio file',
category: 'file',
action: handleClear,
},
// History
{
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+Y',
category: 'edit',
action: redo,
},
// Effects
{
id: 'normalize',
label: 'Normalize',
description: 'Normalize audio to peak amplitude',
category: 'effects',
action: handleNormalize,
},
{
id: 'fade-in',
label: 'Fade In',
description: 'Apply fade in to selection',
category: 'effects',
action: handleFadeIn,
},
{
id: 'fade-out',
label: 'Fade Out',
description: 'Apply fade out to selection',
category: 'effects',
action: handleFadeOut,
},
{
id: 'reverse',
label: 'Reverse',
description: 'Reverse entire audio',
category: 'effects',
action: handleReverse,
},
{
id: 'lowpass-filter',
label: 'Low-Pass Filter',
description: 'Remove high frequencies (1000Hz cutoff)',
category: 'effects',
action: handleLowPassFilter,
},
{
id: 'highpass-filter',
label: 'High-Pass Filter',
description: 'Remove low frequencies (100Hz cutoff)',
category: 'effects',
action: handleHighPassFilter,
},
{
id: 'bandpass-filter',
label: 'Band-Pass Filter',
description: 'Isolate frequency range (1000Hz center)',
category: 'effects',
action: handleBandPassFilter,
},
{
id: 'compressor',
label: 'Compressor',
description: 'Reduce dynamic range',
category: 'effects',
action: handleCompressor,
},
{
id: 'limiter',
label: 'Limiter',
description: 'Prevent audio from exceeding threshold',
category: 'effects',
action: handleLimiter,
},
{
id: 'gate',
label: 'Gate/Expander',
description: 'Reduce volume of quiet sounds',
category: 'effects',
action: handleGate,
},
{
id: 'delay',
label: 'Delay/Echo',
description: 'Add echo effects with feedback',
category: 'effects',
action: handleDelay,
},
{
id: 'reverb',
label: 'Reverb',
description: 'Add acoustic space and ambience',
category: 'effects',
action: handleReverb,
},
{
id: 'chorus',
label: 'Chorus',
description: 'Thicken sound with modulation',
category: 'effects',
action: handleChorus,
},
{
id: 'flanger',
label: 'Flanger',
description: 'Create sweeping comb-filter effect',
category: 'effects',
action: handleFlanger,
},
{
id: 'phaser',
label: 'Phaser',
description: 'Phase-shifting swoosh effect',
category: 'effects',
action: handlePhaser,
},
{
id: 'pitch',
label: 'Pitch Shifter',
description: 'Change pitch without affecting duration',
category: 'effects',
action: handlePitchShift,
},
{
id: 'timestretch',
label: 'Time Stretch',
description: 'Change duration without affecting pitch',
category: 'effects',
action: handleTimeStretch,
},
{
id: 'distortion',
label: 'Distortion',
description: 'Add overdrive and distortion',
category: 'effects',
action: handleDistortion,
},
{
id: 'bitcrusher',
label: 'Bitcrusher',
description: 'Lo-fi bit depth and sample rate reduction',
category: 'effects',
action: handleBitcrusher,
},
];
return actions;
}, [play, pause, stop, handleCut, handleCopy, handlePaste, handleDelete, handleTrim, handleSelectAll, handleClearSelection, handleZoomIn, handleZoomOut, handleFitToView, handleClear, undo, redo, handleNormalize, handleFadeIn, handleFadeOut, handleReverse, handleLowPassFilter, handleHighPassFilter, handleBandPassFilter, handleCompressor, handleLimiter, handleGate, handleDelay, handleReverb, handleChorus, handleFlanger, handlePhaser, handlePitchShift, handleTimeStretch, handleDistortion, handleBitcrusher]);
return (
<>
{/* Compact Header */}
<header className="flex items-center justify-between px-4 py-2 border-b border-border bg-card flex-shrink-0 gap-4">
{/* Left: Logo */}
<div className="flex items-center gap-2 flex-shrink-0">
<Music className="h-5 w-5 text-primary" />
<h1 className="text-lg font-bold text-foreground">Audio UI</h1>
</div>
{/* Right: Command Palette + Theme Toggle */}
<div className="flex items-center gap-2 flex-shrink-0">
<CommandPalette actions={commandActions} />
<ThemeToggle />
</div>
</header>
{/* Main content area */}
<div className="flex flex-1 overflow-hidden">
{/* Side Panel */}
<SidePanel
fileName={fileName}
audioBuffer={audioBuffer}
onFileSelect={handleFileSelect}
onClear={handleClear}
selection={selection}
historyState={historyState}
effectChain={effectChain}
effectPresets={effectPresets}
onToggleEffect={toggleEffectEnabled}
onRemoveEffect={removeEffect}
onReorderEffects={reorderEffects}
onSavePreset={savePreset}
onLoadPreset={loadPresetToChain}
onDeletePreset={deletePreset}
onClearChain={clearChain}
onNormalize={handleNormalize}
onFadeIn={handleFadeIn}
onFadeOut={handleFadeOut}
onReverse={handleReverse}
onLowPassFilter={handleLowPassFilter}
onHighPassFilter={handleHighPassFilter}
onBandPassFilter={handleBandPassFilter}
onCompressor={handleCompressor}
onLimiter={handleLimiter}
onGate={handleGate}
onDelay={handleDelay}
onReverb={handleReverb}
onChorus={handleChorus}
onFlanger={handleFlanger}
onPhaser={handlePhaser}
onPitchShift={handlePitchShift}
onTimeStretch={handleTimeStretch}
onDistortion={handleDistortion}
onBitcrusher={handleBitcrusher}
tracks={tracks}
onAddTrack={addTrack}
onImportTracks={handleImportTracks}
onConvertToTrack={handleConvertToTrack}
onClearTracks={handleClearTracks}
/>
{/* Main canvas area */}
<main className="flex-1 flex flex-col overflow-hidden bg-background">
{isLoading ? (
<div className="flex-1 flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Loading audio file...</p>
</div>
</div>
) : viewMode === 'tracks' ? (
<>
{/* Multi-Track View */}
<div className="flex-1 flex flex-col overflow-hidden">
<TrackList
tracks={tracks}
zoom={zoom}
currentTime={multiTrackCurrentTime}
duration={multiTrackDuration}
onAddTrack={addTrack}
onImportTrack={handleImportTrack}
onRemoveTrack={removeTrack}
onUpdateTrack={updateTrack}
onSeek={seekMultiTrack}
/>
</div>
{/* Multi-Track Playback Controls */}
<div className="border-t border-border bg-card p-3">
<PlaybackControls
isPlaying={isMultiTrackPlaying}
isPaused={!isMultiTrackPlaying}
currentTime={multiTrackCurrentTime}
duration={multiTrackDuration}
volume={1}
onPlay={playMultiTrack}
onPause={pauseMultiTrack}
onStop={stopMultiTrack}
onSeek={seekMultiTrack}
onVolumeChange={() => {}}
currentTimeFormatted={`${Math.floor(multiTrackCurrentTime / 60)}:${String(Math.floor(multiTrackCurrentTime % 60)).padStart(2, '0')}`}
durationFormatted={`${Math.floor(multiTrackDuration / 60)}:${String(Math.floor(multiTrackDuration % 60)).padStart(2, '0')}`}
/>
</div>
</>
) : audioBuffer ? (
<>
{/* Waveform - takes maximum space */}
<div className="flex-1 flex flex-col p-4 overflow-hidden">
<Waveform
audioBuffer={audioBuffer}
currentTime={currentTime}
duration={duration}
onSeek={seek}
height={200}
zoom={zoom}
scrollOffset={scrollOffset}
amplitudeScale={amplitudeScale}
selection={selection}
onSelectionChange={setSelection}
/>
{/* Horizontal scroll for zoomed waveform */}
{zoom > 1 && (
<div className="mt-4 space-y-2">
<label className="text-xs font-medium text-muted-foreground">
Scroll Position
</label>
<Slider
value={scrollOffset}
onChange={setScrollOffset}
min={0}
max={Math.max(0, (800 * zoom) - 800)}
step={1}
/>
</div>
)}
</div>
{/* Playback Controls - fixed at bottom */}
<div className="border-t border-border bg-card p-3">
<PlaybackControls
isPlaying={isPlaying}
isPaused={isPaused}
currentTime={currentTime}
duration={duration}
volume={volume}
onPlay={play}
onPause={pause}
onStop={stop}
onSeek={seek}
onVolumeChange={setVolume}
currentTimeFormatted={currentTimeFormatted}
durationFormatted={durationFormatted}
/>
</div>
</>
) : (
<div
className={cn(
"flex-1 flex items-center justify-center p-8 transition-colors cursor-pointer",
isDragging && "bg-primary/5 border-2 border-dashed border-primary"
)}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={handleDropZoneClick}
>
<input
ref={fileInputRef}
type="file"
accept="audio/*"
onChange={handleFileInputChange}
className="hidden"
/>
<div className="max-w-md text-center space-y-4">
<p className="text-lg font-medium text-foreground">
{isDragging ? 'Drop audio file here' : 'No audio file loaded'}
</p>
<p className="text-sm text-muted-foreground">
{isDragging
? 'Release to load the file'
: 'Click here or use the side panel to load an audio file, or drag and drop a file onto this area.'}
</p>
</div>
</div>
)}
</main>
</div>
{/* Effect Parameter Dialog */}
<EffectParameterDialog
open={effectDialogOpen}
onClose={() => setEffectDialogOpen(false)}
effectType={effectDialogType}
onApply={handleEffectApply}
sampleRate={audioBuffer?.sampleRate}
/>
{/* Dynamics Parameter Dialog */}
<DynamicsParameterDialog
open={dynamicsDialogOpen}
onClose={() => setDynamicsDialogOpen(false)}
effectType={dynamicsDialogType}
onApply={handleDynamicsApply}
/>
{/* Time-Based Parameter Dialog */}
<TimeBasedParameterDialog
open={timeBasedDialogOpen}
onClose={() => setTimeBasedDialogOpen(false)}
effectType={timeBasedDialogType}
onApply={handleTimeBasedApply}
/>
{/* Advanced Parameter Dialog */}
<AdvancedParameterDialog
open={advancedDialogOpen}
onClose={() => setAdvancedDialogOpen(false)}
effectType={advancedDialogType}
onApply={handleAdvancedApply}
/>
{/* Import Track Dialog */}
<ImportTrackDialog
open={importDialogOpen}
onClose={() => setImportDialogOpen(false)}
onImportTrack={handleImportTrack}
/>
</>
);
}