From 908e6caaf85df94802107fcee4f30c8bf34cc480 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Wed, 19 Nov 2025 20:50:44 +0100 Subject: [PATCH] feat: enhance mobile responsiveness with collapsible controls and automation/effects bars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added comprehensive mobile support for Phase 15 (Polish & Optimization): **Mobile Layout Enhancements:** - Track controls now collapsible on mobile with two states: - Collapsed: minimal controls with expand chevron, R/M/S buttons, horizontal level meter - Expanded: full height fader, pan control, all buttons - Track collapse buttons added to mobile view (left chevron for track collapse, right chevron for control collapse) - Master controls collapse button hidden on desktop (lg:hidden) - Automation and effects bars now available on mobile layout - Both bars collapsible with eye/eye-off icons, horizontally scrollable when zoomed - Mobile vertical stacking: controls → waveform → automation → effects per track **Bug Fixes:** - Fixed track controls and waveform container height matching on desktop - Fixed Modal component prop: isOpen → open in all dialog components - Fixed TypeScript null check for audioBuffer.duration - Fixed keyboard shortcut category: 'help' → 'view' **Technical Improvements:** - Consistent height calculation using trackHeight variable - Proper responsive breakpoints with Tailwind (sm:640px, lg:1024px) - Progressive disclosure pattern for mobile controls 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- components/controls/MasterControls.tsx | 70 ++- components/dialogs/BrowserCompatDialog.tsx | 130 +++++ .../dialogs/KeyboardShortcutsDialog.tsx | 140 +++++ components/dialogs/MemoryWarningDialog.tsx | 101 ++++ .../dialogs/UnsupportedFormatDialog.tsx | 106 ++++ components/editor/AudioEditor.tsx | 505 +++++++++++++---- components/tracks/Track.tsx | 9 +- components/tracks/TrackControls.tsx | 240 ++++++++- components/tracks/TrackList.tsx | 509 +++++++++++++++++- lib/audio/decoder.ts | 10 + lib/utils/audio-cleanup.ts | 149 +++++ lib/utils/browser-compat.ts | 133 +++++ lib/utils/memory-limits.ts | 160 ++++++ 13 files changed, 2136 insertions(+), 126 deletions(-) create mode 100644 components/dialogs/BrowserCompatDialog.tsx create mode 100644 components/dialogs/KeyboardShortcutsDialog.tsx create mode 100644 components/dialogs/MemoryWarningDialog.tsx create mode 100644 components/dialogs/UnsupportedFormatDialog.tsx create mode 100644 lib/utils/audio-cleanup.ts create mode 100644 lib/utils/browser-compat.ts create mode 100644 lib/utils/memory-limits.ts diff --git a/components/controls/MasterControls.tsx b/components/controls/MasterControls.tsx index 6b8459d..8d40456 100644 --- a/components/controls/MasterControls.tsx +++ b/components/controls/MasterControls.tsx @@ -1,6 +1,7 @@ 'use client'; import * as React from 'react'; +import { ChevronDown, ChevronUp } from 'lucide-react'; import { CircularKnob } from '@/components/ui/CircularKnob'; import { MasterFader } from './MasterFader'; import { cn } from '@/lib/utils/cn'; @@ -12,10 +13,12 @@ export interface MasterControlsProps { rmsLevel: number; isClipping: boolean; isMuted?: boolean; + collapsed?: boolean; // For collapsible on mobile/small screens onVolumeChange: (volume: number) => void; onPanChange: (pan: number) => void; onMuteToggle: () => void; onResetClip?: () => void; + onToggleCollapse?: () => void; className?: string; } @@ -26,20 +29,81 @@ export function MasterControls({ rmsLevel, isClipping, isMuted = false, + collapsed = false, onVolumeChange, onPanChange, onMuteToggle, onResetClip, + onToggleCollapse, className, }: MasterControlsProps) { + // Collapsed view - minimal controls + if (collapsed) { + return ( +
+
+
+ Master +
+ {onToggleCollapse && ( + + )} +
+
+ +
+
0.95 ? 'bg-red-500' : peakLevel > 0.8 ? 'bg-yellow-500' : 'bg-green-500' + )} + style={{ width: `${peakLevel * 100}%` }} + /> +
+
+
+ ); + } + return (
- {/* Master Label */} -
- Master + {/* Master Label with collapse button */} +
+
+ Master +
+ {onToggleCollapse && ( + + )}
{/* Pan Control */} diff --git a/components/dialogs/BrowserCompatDialog.tsx b/components/dialogs/BrowserCompatDialog.tsx new file mode 100644 index 0000000..c3268d3 --- /dev/null +++ b/components/dialogs/BrowserCompatDialog.tsx @@ -0,0 +1,130 @@ +'use client'; + +import * as React from 'react'; +import { AlertTriangle, XCircle, Info, X } from 'lucide-react'; +import { Modal } from '@/components/ui/Modal'; +import { Button } from '@/components/ui/Button'; +import { getBrowserInfo } from '@/lib/utils/browser-compat'; + +interface BrowserCompatDialogProps { + open: boolean; + missingFeatures: string[]; + warnings: string[]; + onClose: () => void; +} + +export function BrowserCompatDialog({ + open, + missingFeatures, + warnings, + onClose, +}: BrowserCompatDialogProps) { + const [browserInfo, setBrowserInfo] = React.useState({ name: 'Unknown', version: 'Unknown' }); + const hasErrors = missingFeatures.length > 0; + + // Get browser info only on client side + React.useEffect(() => { + setBrowserInfo(getBrowserInfo()); + }, []); + + if (!open) return null; + + return ( + +
+ {/* Header */} +
+
+ {hasErrors ? ( + <> + +

Browser Not Supported

+ + ) : ( + <> + +

Browser Warnings

+ + )} +
+ +
+ +

+ {hasErrors ? ( + <>Your browser is missing required features to run this audio editor. + ) : ( + <>Some features may not work as expected in your browser. + )} +

+ +
+ {/* Browser Info */} +
+ + + {browserInfo.name} {browserInfo.version} + +
+ + {/* Missing Features */} + {missingFeatures.length > 0 && ( +
+

+ + Missing Required Features: +

+
    + {missingFeatures.map((feature) => ( +
  • {feature}
  • + ))} +
+
+ )} + + {/* Warnings */} + {warnings.length > 0 && ( +
+

+ + Warnings: +

+
    + {warnings.map((warning) => ( +
  • {warning}
  • + ))} +
+
+ )} + + {/* Recommendations */} + {hasErrors && ( +
+

Recommended Browsers:

+
    +
  • • Chrome 90+ or Edge 90+
  • +
  • • Firefox 88+
  • +
  • • Safari 14+
  • +
+
+ )} + + {/* Actions */} +
+ {hasErrors ? ( + + ) : ( + + )} +
+
+
+
+ ); +} diff --git a/components/dialogs/KeyboardShortcutsDialog.tsx b/components/dialogs/KeyboardShortcutsDialog.tsx new file mode 100644 index 0000000..9335aa1 --- /dev/null +++ b/components/dialogs/KeyboardShortcutsDialog.tsx @@ -0,0 +1,140 @@ +'use client'; + +import * as React from 'react'; +import { Keyboard, X } from 'lucide-react'; +import { Modal } from '@/components/ui/Modal'; +import { Button } from '@/components/ui/Button'; +import { cn } from '@/lib/utils/cn'; + +export interface KeyboardShortcutsDialogProps { + open: boolean; + onClose: () => void; +} + +interface ShortcutCategory { + name: string; + shortcuts: Array<{ + keys: string[]; + description: string; + }>; +} + +const SHORTCUTS: ShortcutCategory[] = [ + { + name: 'Playback', + shortcuts: [ + { keys: ['Space'], description: 'Play / Pause' }, + { keys: ['Home'], description: 'Go to Start' }, + { keys: ['End'], description: 'Go to End' }, + { keys: ['←'], description: 'Seek Backward' }, + { keys: ['→'], description: 'Seek Forward' }, + { keys: ['Ctrl', '←'], description: 'Seek Backward 5s' }, + { keys: ['Ctrl', '→'], description: 'Seek Forward 5s' }, + ], + }, + { + name: 'Edit', + shortcuts: [ + { keys: ['Ctrl', 'Z'], description: 'Undo' }, + { keys: ['Ctrl', 'Shift', 'Z'], description: 'Redo' }, + { keys: ['Ctrl', 'X'], description: 'Cut' }, + { keys: ['Ctrl', 'C'], description: 'Copy' }, + { keys: ['Ctrl', 'V'], description: 'Paste' }, + { keys: ['Delete'], description: 'Delete Selection' }, + { keys: ['Ctrl', 'D'], description: 'Duplicate' }, + { keys: ['Ctrl', 'A'], description: 'Select All' }, + ], + }, + { + name: 'View', + shortcuts: [ + { keys: ['Ctrl', '+'], description: 'Zoom In' }, + { keys: ['Ctrl', '-'], description: 'Zoom Out' }, + { keys: ['Ctrl', '0'], description: 'Fit to View' }, + ], + }, + { + name: 'File', + shortcuts: [ + { keys: ['Ctrl', 'S'], description: 'Save Project' }, + { keys: ['Ctrl', 'K'], description: 'Open Command Palette' }, + ], + }, +]; + +function KeyboardKey({ keyName }: { keyName: string }) { + return ( + + {keyName} + + ); +} + +export function KeyboardShortcutsDialog({ open, onClose }: KeyboardShortcutsDialogProps) { + if (!open) return null; + + return ( + +
+ {/* Header */} +
+
+ +

Keyboard Shortcuts

+
+ +
+ + {/* Shortcuts Grid */} +
+ {SHORTCUTS.map((category) => ( +
+

+ {category.name} +

+
+ {category.shortcuts.map((shortcut, index) => ( +
+ + {shortcut.description} + +
+ {shortcut.keys.map((key, keyIndex) => ( + + {keyIndex > 0 && ( + + + )} + + + ))} +
+
+ ))} +
+
+ ))} +
+ + {/* Footer */} +
+

+ Press + to open the + command palette and search for more actions +

+
+ + {/* Close Button */} +
+ +
+
+
+ ); +} diff --git a/components/dialogs/MemoryWarningDialog.tsx b/components/dialogs/MemoryWarningDialog.tsx new file mode 100644 index 0000000..56eba8d --- /dev/null +++ b/components/dialogs/MemoryWarningDialog.tsx @@ -0,0 +1,101 @@ +'use client'; + +import * as React from 'react'; +import { AlertTriangle, Info, X } from 'lucide-react'; +import { Modal } from '@/components/ui/Modal'; +import { Button } from '@/components/ui/Button'; +import { formatMemorySize } from '@/lib/utils/memory-limits'; + +interface MemoryWarningDialogProps { + open: boolean; + estimatedMemoryMB: number; + availableMemoryMB?: number; + warning: string; + fileName?: string; + onContinue: () => void; + onCancel: () => void; +} + +export function MemoryWarningDialog({ + open, + estimatedMemoryMB, + availableMemoryMB, + warning, + fileName, + onContinue, + onCancel, +}: MemoryWarningDialogProps) { + if (!open) return null; + + const estimatedBytes = estimatedMemoryMB * 1024 * 1024; + const availableBytes = availableMemoryMB ? availableMemoryMB * 1024 * 1024 : undefined; + + return ( + +
+ {/* Header */} +
+
+ +

Memory Warning

+
+ +
+ +

+ {warning} +

+ +
+ {/* File Info */} + {fileName && ( +
+ + File: + {fileName} +
+ )} + + {/* Memory Details */} +
+
+ Estimated Memory: + {formatMemorySize(estimatedBytes)} +
+ {availableBytes && ( +
+ Available Memory: + {formatMemorySize(availableBytes)} +
+ )} +
+ + {/* Warning Message */} +
+

+ Note: Loading large files may cause performance issues or browser crashes, + especially on devices with limited memory. Consider: +

+
    +
  • Closing other browser tabs
  • +
  • Using a shorter audio file
  • +
  • Splitting large files into smaller segments
  • +
+
+ + {/* Actions */} +
+ + +
+
+
+
+ ); +} diff --git a/components/dialogs/UnsupportedFormatDialog.tsx b/components/dialogs/UnsupportedFormatDialog.tsx new file mode 100644 index 0000000..8eaaf0f --- /dev/null +++ b/components/dialogs/UnsupportedFormatDialog.tsx @@ -0,0 +1,106 @@ +'use client'; + +import * as React from 'react'; +import { AlertCircle, FileQuestion, X } from 'lucide-react'; +import { Modal } from '@/components/ui/Modal'; +import { Button } from '@/components/ui/Button'; + +export interface UnsupportedFormatDialogProps { + open: boolean; + fileName: string; + fileType: string; + onClose: () => void; +} + +const SUPPORTED_FORMATS = [ + { extension: 'WAV', mimeType: 'audio/wav', description: 'Lossless, widely supported' }, + { extension: 'MP3', mimeType: 'audio/mpeg', description: 'Compressed, universal support' }, + { extension: 'OGG', mimeType: 'audio/ogg', description: 'Free, open format' }, + { extension: 'FLAC', mimeType: 'audio/flac', description: 'Lossless compression' }, + { extension: 'M4A/AAC', mimeType: 'audio/aac', description: 'Apple audio format' }, + { extension: 'AIFF', mimeType: 'audio/aiff', description: 'Apple lossless format' }, + { extension: 'WebM', mimeType: 'audio/webm', description: 'Modern web format' }, +]; + +export function UnsupportedFormatDialog({ + open, + fileName, + fileType, + onClose, +}: UnsupportedFormatDialogProps) { + if (!open) return null; + + return ( + +
+ {/* Header */} +
+
+ +

Unsupported File Format

+
+ +
+ + {/* Error Message */} +
+
+ +
+

+ Cannot open this file +

+

+ {fileName} + {fileType && ( + ({fileType}) + )} +

+
+
+
+ + {/* Supported Formats */} +
+

Supported Audio Formats:

+
+ {SUPPORTED_FORMATS.map((format) => ( +
+
+ + {format.extension} + + + {format.description} + +
+
+ ))} +
+
+ + {/* Recommendations */} +
+

How to fix this:

+
    +
  • Convert your audio file to a supported format (WAV or MP3 recommended)
  • +
  • Use a free audio converter like Audacity, FFmpeg, or online converters
  • +
  • Check that the file isn't corrupted or incomplete
  • +
+
+ + {/* Close Button */} +
+ +
+
+
+ ); +} diff --git a/components/editor/AudioEditor.tsx b/components/editor/AudioEditor.tsx index 60dd86d..729b21b 100644 --- a/components/editor/AudioEditor.tsx +++ b/components/editor/AudioEditor.tsx @@ -4,16 +4,9 @@ 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 { FrequencyAnalyzer } from '@/components/analysis/FrequencyAnalyzer'; -import { Spectrogram } from '@/components/analysis/Spectrogram'; -import { PhaseCorrelationMeter } from '@/components/analysis/PhaseCorrelationMeter'; -import { LUFSMeter } from '@/components/analysis/LUFSMeter'; -import { AudioStatistics } from '@/components/analysis/AudioStatistics'; import { ThemeToggle } from '@/components/layout/ThemeToggle'; import { CommandPalette } from '@/components/ui/CommandPalette'; -import { GlobalSettingsDialog } from '@/components/settings/GlobalSettingsDialog'; -import { ExportDialog, type ExportSettings } from '@/components/dialogs/ExportDialog'; -import { ProjectsDialog } from '@/components/dialogs/ProjectsDialog'; +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'; @@ -21,7 +14,21 @@ import { useMultiTrackPlayer } from '@/lib/hooks/useMultiTrackPlayer'; import { useEffectChain } from '@/lib/hooks/useEffectChain'; import { useToast } from '@/components/ui/Toast'; import { TrackList } from '@/components/tracks/TrackList'; -import { ImportTrackDialog } from '@/components/tracks/ImportTrackDialog'; +import 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'; @@ -48,6 +55,7 @@ import { type ProjectMetadata, } from '@/lib/storage/projects'; import { getAudioContext } from '@/lib/audio/context'; +import { checkBrowserCompatibility } from '@/lib/utils/browser-compat'; export function AudioEditor() { // Settings hook @@ -66,6 +74,7 @@ export function AudioEditor() { const [masterVolume, setMasterVolume] = React.useState(0.8); const [masterPan, setMasterPan] = React.useState(0); const [isMasterMuted, setIsMasterMuted] = React.useState(false); + const [masterControlsCollapsed, setMasterControlsCollapsed] = React.useState(false); const [clipboard, setClipboard] = React.useState(null); const [recordingTrackId, setRecordingTrackId] = React.useState(null); const [punchInEnabled, setPunchInEnabled] = React.useState(false); @@ -87,6 +96,12 @@ export function AudioEditor() { const [projects, setProjects] = React.useState([]); const [currentProjectId, setCurrentProjectId] = React.useState(null); const [currentProjectName, setCurrentProjectName] = React.useState('Untitled Project'); + const [browserCompatDialogOpen, setBrowserCompatDialogOpen] = React.useState(false); + const [shortcutsDialogOpen, setShortcutsDialogOpen] = React.useState(false); + const [browserCompatInfo, setBrowserCompatInfo] = React.useState<{ + missingFeatures: string[]; + warnings: string[]; + }>({ missingFeatures: [], warnings: [] }); const { addToast } = useToast(); @@ -633,6 +648,9 @@ export function AudioEditor() { ); setClipboard(extracted); + // Ensure the track is selected so paste works + setSelectedTrackId(track.id); + // Execute cut command const command = createMultiTrackCutCommand( track.id, @@ -664,6 +682,9 @@ export function AudioEditor() { ); 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, @@ -684,7 +705,25 @@ export function AudioEditor() { }, [tracks, executeCommand, updateTrack, addToast]); const handlePaste = React.useCallback(() => { - if (!clipboard || !selectedTrackId) return; + 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; @@ -1056,6 +1095,16 @@ export function AudioEditor() { } }, [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(() => { @@ -1266,6 +1315,80 @@ export function AudioEditor() { 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', @@ -1287,6 +1410,7 @@ export function AudioEditor() { id: 'zoom-in', label: 'Zoom In', description: 'Zoom in on waveforms', + shortcut: 'Ctrl++', category: 'view', action: handleZoomIn, }, @@ -1294,6 +1418,7 @@ export function AudioEditor() { id: 'zoom-out', label: 'Zoom Out', description: 'Zoom out on waveforms', + shortcut: 'Ctrl+-', category: 'view', action: handleZoomOut, }, @@ -1301,9 +1426,59 @@ export function AudioEditor() { 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', @@ -1326,9 +1501,18 @@ export function AudioEditor() { 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, handleSaveProject, handleOpenProjectsDialog, handleZoomIn, handleZoomOut, handleFitToView, handleImportTracks, handleClearTracks, addTrack]); + }, [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(() => { @@ -1398,6 +1582,27 @@ export function AudioEditor() { 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(); @@ -1457,7 +1662,16 @@ export function AudioEditor() { // Ctrl+A: Select All (select all content on current track) if ((e.ctrlKey || e.metaKey) && e.key === 'a') { e.preventDefault(); - if (selectedTrackId) { + 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, { @@ -1468,45 +1682,31 @@ export function AudioEditor() { return; } - // Ctrl+Plus/Equals: Zoom in - if ((e.ctrlKey || e.metaKey) && (e.key === '+' || e.key === '=')) { + // ?: Open keyboard shortcuts help + if (e.key === '?' && !e.ctrlKey && !e.metaKey) { e.preventDefault(); - handleZoomIn(); - return; - } - - // Ctrl+Minus: Zoom out - if ((e.ctrlKey || e.metaKey) && e.key === '-') { - e.preventDefault(); - handleZoomOut(); - return; - } - - // Ctrl+0: Fit to view - if ((e.ctrlKey || e.metaKey) && e.key === '0') { - e.preventDefault(); - handleFitToView(); + 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]); + }, [togglePlayPause, canUndo, canRedo, undo, redo, handleCut, handleCopy, handlePaste, handleDelete, handleDuplicate, handleSaveProject, seek, duration, currentTime, selectedTrackId, tracks, updateTrack, handleZoomIn, handleZoomOut, handleFitToView, setSelectedTrackId, setShortcutsDialogOpen]); return ( <> {/* Compact Header */} -
+
{/* Left: Logo */} -
+
-

Audio UI

+

Audio UI

{/* Project Name */} -
+
- {/* Track Actions */} -
- + + {tracks.length > 0 && ( <> - - @@ -1591,8 +1795,8 @@ export function AudioEditor() {
- {/* Right Sidebar - Master Controls & Analyzers */} -
}> + + + )} + {analyzerView === 'lufs' && ( + Loading...
}> + + + )} + {analyzerView === 'stats' && ( + Loading...
}> + + + )}
- {/* Transport Controls */} -
- + {/* Bottom Bar - Stacked on mobile (Master then Transport), Side-by-side on desktop */} +
+ {/* Master Controls - Mobile only (hidden on desktop where sidebar shows master) */} +
+ { + if (isMasterMuted) { + setMasterVolume(0.8); + setIsMasterMuted(false); + } else { + setMasterVolume(0); + setIsMasterMuted(true); + } + }} + onResetClip={resetClipIndicator} + onToggleCollapse={() => setMasterControlsCollapsed(!masterControlsCollapsed)} + /> +
+ + {/* Playback Controls - Bottom on mobile, centered on desktop */} +
+ +
{/* Import Track Dialog */} - setImportDialogOpen(false)} - onImportTrack={handleImportTrack} - /> + + setImportDialogOpen(false)} + onImportTrack={handleImportTrack} + /> + {/* Global Settings Dialog */} - setSettingsDialogOpen(false)} - recordingSettings={recordingSettings} - onInputGainChange={setInputGain} - onRecordMonoChange={setRecordMono} - onSampleRateChange={setSampleRate} - settings={settings} - onAudioSettingsChange={updateAudioSettings} - onUISettingsChange={updateUISettings} - onEditorSettingsChange={updateEditorSettings} - onPerformanceSettingsChange={updatePerformanceSettings} - onResetCategory={resetCategory} - /> + + setSettingsDialogOpen(false)} + recordingSettings={recordingSettings} + onInputGainChange={setInputGain} + onRecordMonoChange={setRecordMono} + onSampleRateChange={setSampleRate} + settings={settings} + onAudioSettingsChange={updateAudioSettings} + onUISettingsChange={updateUISettings} + onEditorSettingsChange={updateEditorSettings} + onPerformanceSettingsChange={updatePerformanceSettings} + onResetCategory={resetCategory} + /> + {/* Export Dialog */} - setExportDialogOpen(false)} - onExport={handleExport} - isExporting={isExporting} - hasSelection={tracks.some(t => t.selection !== null)} - /> + + setExportDialogOpen(false)} + onExport={handleExport} + isExporting={isExporting} + hasSelection={tracks.some(t => t.selection !== null)} + /> + {/* Projects Dialog */} - setProjectsDialogOpen(false)} - projects={projects} - onNewProject={handleNewProject} - onLoadProject={handleLoadProject} - onDeleteProject={handleDeleteProject} - onDuplicateProject={handleDuplicateProject} - onExportProject={handleExportProject} - onImportProject={handleImportProject} + + setProjectsDialogOpen(false)} + projects={projects} + onNewProject={handleNewProject} + onLoadProject={handleLoadProject} + onDeleteProject={handleDeleteProject} + onDuplicateProject={handleDuplicateProject} + onExportProject={handleExportProject} + onImportProject={handleImportProject} + /> + + + {/* Browser Compatibility Dialog */} + setBrowserCompatDialogOpen(false)} /> + + {/* Keyboard Shortcuts Dialog */} + + setShortcutsDialogOpen(false)} + /> + ); } diff --git a/components/tracks/Track.tsx b/components/tracks/Track.tsx index 21f049d..8eaceca 100644 --- a/components/tracks/Track.tsx +++ b/components/tracks/Track.tsx @@ -21,6 +21,7 @@ import { COLLAPSED_TRACK_HEIGHT, MIN_TRACK_HEIGHT, MAX_TRACK_HEIGHT, + DEFAULT_TRACK_HEIGHT, } from "@/types/track"; import { Button } from "@/components/ui/Button"; import { Slider } from "@/components/ui/Slider"; @@ -606,7 +607,9 @@ export function Track({ } }; - const trackHeight = track.collapsed ? COLLAPSED_TRACK_HEIGHT : Math.max(track.height || MIN_TRACK_HEIGHT, MIN_TRACK_HEIGHT); + const trackHeight = track.collapsed + ? COLLAPSED_TRACK_HEIGHT + : Math.max(track.height || DEFAULT_TRACK_HEIGHT, MIN_TRACK_HEIGHT); // Track height resize handlers const handleResizeStart = React.useCallback( @@ -656,7 +659,9 @@ export function Track({ ? "bg-primary/10 border-r-primary" : "bg-card border-r-transparent hover:bg-accent/30", )} - style={{ height: trackHeight }} + style={{ + height: `${trackHeight}px`, + }} onClick={(e) => { e.stopPropagation(); if (onSelect) onSelect(); diff --git a/components/tracks/TrackControls.tsx b/components/tracks/TrackControls.tsx index b532539..31deba5 100644 --- a/components/tracks/TrackControls.tsx +++ b/components/tracks/TrackControls.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; -import { Circle, Headphones, MoreHorizontal, ChevronRight, ChevronDown } from 'lucide-react'; +import { Circle, Headphones, MoreHorizontal, ChevronRight, ChevronDown, ChevronUp } from 'lucide-react'; import { CircularKnob } from '@/components/ui/CircularKnob'; import { TrackFader } from './TrackFader'; import { cn } from '@/lib/utils/cn'; @@ -20,6 +20,7 @@ export interface TrackControlsProps { showAutomation?: boolean; showEffects?: boolean; isRecording?: boolean; + mobileCollapsed?: boolean; // For mobile view collapsible controls onNameChange: (name: string) => void; onToggleCollapse: () => void; onVolumeChange: (volume: number) => void; @@ -33,6 +34,7 @@ export interface TrackControlsProps { onVolumeTouchEnd?: () => void; onPanTouchStart?: () => void; onPanTouchEnd?: () => void; + onToggleMobileCollapse?: () => void; className?: string; } @@ -50,6 +52,7 @@ export function TrackControls({ showAutomation = false, showEffects = false, isRecording = false, + mobileCollapsed = false, onNameChange, onToggleCollapse, onVolumeChange, @@ -63,6 +66,7 @@ export function TrackControls({ onVolumeTouchEnd, onPanTouchStart, onPanTouchEnd, + onToggleMobileCollapse, className, }: TrackControlsProps) { const [isEditingName, setIsEditingName] = React.useState(false); @@ -91,11 +95,238 @@ export function TrackControls({ } }; - return ( + // Mobile collapsed view - minimal controls (like master controls) + if (mobileCollapsed) { + return ( +
+
+
+ +
+ {trackName} +
+
+ {onToggleMobileCollapse && ( + + )} +
+
+ {onRecordToggle && ( + + )} + + {onSoloToggle && ( + + )} +
+
0.95 ? 'bg-red-500' : peakLevel > 0.8 ? 'bg-yellow-500' : 'bg-green-500' + )} + style={{ width: `${peakLevel * 100}%` }} + /> +
+
+
+ ); + } + + // Mobile expanded view - full controls (like master controls) + const mobileExpandedView = (
+ {/* Header with collapse button */} +
+ +
+ {trackName} +
+ {onToggleMobileCollapse && ( + + )} +
+ + {/* Pan Control */} + { + if (Math.abs(value) < 0.01) return 'C'; + if (value < 0) return `${Math.abs(value * 100).toFixed(0)}L`; + return `${(value * 100).toFixed(0)}R`; + }} + /> + + {/* Volume Fader - Full height, not compressed */} +
+ +
+ + {/* Control buttons */} +
+ {onRecordToggle && ( + + )} + + {onSoloToggle && ( + + )} + {onEffectsClick && ( + + )} +
+
+ ); + + return ( + <> + {/* Mobile view - Show expanded or collapsed */} + {!mobileCollapsed && mobileExpandedView} + + {/* Desktop/tablet view - hidden on mobile */} + +
+ ); } diff --git a/components/tracks/TrackList.tsx b/components/tracks/TrackList.tsx index 4393a05..9e34d8e 100644 --- a/components/tracks/TrackList.tsx +++ b/components/tracks/TrackList.tsx @@ -167,8 +167,509 @@ export function TrackList({ return (
- {/* Track List - Two Column Layout */} -
+ {/* Mobile Layout - Single Column (Stacked: Controls → Waveform per track) */} +
+ {tracks.map((track) => ( +
+ {/* Track Controls - Top */} + onSelectTrack(track.id) : undefined} + onToggleMute={() => onUpdateTrack(track.id, { mute: !track.mute })} + onToggleSolo={() => onUpdateTrack(track.id, { solo: !track.solo })} + onToggleCollapse={() => onUpdateTrack(track.id, { collapsed: !track.collapsed })} + onVolumeChange={(volume) => onUpdateTrack(track.id, { volume })} + onPanChange={(pan) => onUpdateTrack(track.id, { pan })} + onRemove={() => onRemoveTrack(track.id)} + onNameChange={(name) => onUpdateTrack(track.id, { name })} + onUpdateTrack={onUpdateTrack} + onSeek={onSeek} + onLoadAudio={(buffer) => onUpdateTrack(track.id, { audioBuffer: buffer })} + onToggleEffect={(effectId) => { + const updatedChain = { + ...track.effectChain, + effects: track.effectChain.effects.map((e) => + e.id === effectId ? { ...e, enabled: !e.enabled } : e + ), + }; + onUpdateTrack(track.id, { effectChain: updatedChain }); + }} + onRemoveEffect={(effectId) => { + const updatedChain = { + ...track.effectChain, + effects: track.effectChain.effects.filter((e) => e.id !== effectId), + }; + onUpdateTrack(track.id, { effectChain: updatedChain }); + }} + onUpdateEffect={(effectId, parameters) => { + const updatedChain = { + ...track.effectChain, + effects: track.effectChain.effects.map((e) => + e.id === effectId ? { ...e, parameters } : e + ), + }; + onUpdateTrack(track.id, { effectChain: updatedChain }); + }} + onAddEffect={(effectType) => { + const newEffect = createEffect(effectType, EFFECT_NAMES[effectType]); + const updatedChain = { + ...track.effectChain, + effects: [...track.effectChain.effects, newEffect], + }; + onUpdateTrack(track.id, { effectChain: updatedChain }); + }} + onSelectionChange={ + onSelectionChange + ? (selection) => onSelectionChange(track.id, selection) + : undefined + } + onToggleRecordEnable={ + onToggleRecordEnable + ? () => onToggleRecordEnable(track.id) + : undefined + } + isRecording={recordingTrackId === track.id} + recordingLevel={recordingTrackId === track.id ? recordingLevel : 0} + playbackLevel={trackLevels[track.id] || 0} + onParameterTouched={onParameterTouched} + isPlaying={isPlaying} + renderControlsOnly={true} + /> + + {/* Track Waveform with Automation and Effects - Bottom */} + {!track.collapsed && ( +
+ {/* Waveform */} +
+ onSelectTrack(track.id) : undefined} + onToggleMute={() => onUpdateTrack(track.id, { mute: !track.mute })} + onToggleSolo={() => onUpdateTrack(track.id, { solo: !track.solo })} + onToggleCollapse={() => onUpdateTrack(track.id, { collapsed: !track.collapsed })} + onVolumeChange={(volume) => onUpdateTrack(track.id, { volume })} + onPanChange={(pan) => onUpdateTrack(track.id, { pan })} + onRemove={() => onRemoveTrack(track.id)} + onNameChange={(name) => onUpdateTrack(track.id, { name })} + onUpdateTrack={onUpdateTrack} + onSeek={onSeek} + onLoadAudio={(buffer) => onUpdateTrack(track.id, { audioBuffer: buffer })} + onToggleEffect={(effectId) => { + const updatedChain = { + ...track.effectChain, + effects: track.effectChain.effects.map((e) => + e.id === effectId ? { ...e, enabled: !e.enabled } : e + ), + }; + onUpdateTrack(track.id, { effectChain: updatedChain }); + }} + onRemoveEffect={(effectId) => { + const updatedChain = { + ...track.effectChain, + effects: track.effectChain.effects.filter((e) => e.id !== effectId), + }; + onUpdateTrack(track.id, { effectChain: updatedChain }); + }} + onUpdateEffect={(effectId, parameters) => { + const updatedChain = { + ...track.effectChain, + effects: track.effectChain.effects.map((e) => + e.id === effectId ? { ...e, parameters } : e + ), + }; + onUpdateTrack(track.id, { effectChain: updatedChain }); + }} + onAddEffect={(effectType) => { + const newEffect = createEffect(effectType, EFFECT_NAMES[effectType]); + const updatedChain = { + ...track.effectChain, + effects: [...track.effectChain.effects, newEffect], + }; + onUpdateTrack(track.id, { effectChain: updatedChain }); + }} + onSelectionChange={ + onSelectionChange + ? (selection) => onSelectionChange(track.id, selection) + : undefined + } + onToggleRecordEnable={ + onToggleRecordEnable + ? () => onToggleRecordEnable(track.id) + : undefined + } + isRecording={recordingTrackId === track.id} + recordingLevel={recordingTrackId === track.id ? recordingLevel : 0} + playbackLevel={trackLevels[track.id] || 0} + onParameterTouched={onParameterTouched} + isPlaying={isPlaying} + renderWaveformOnly={true} + /> +
+ + {/* Automation Bar */} + {(() => { + const selectedParam = track.automation.selectedParameterId || 'volume'; + const currentLane = track.automation.lanes.find( + l => l.parameterId === selectedParam + ); + + // Build available parameters list + const availableParameters: Array<{ id: string; name: string }> = [ + { id: 'volume', name: 'Volume' }, + { id: 'pan', name: 'Pan' }, + ]; + + // Add effect parameters + track.effectChain.effects.forEach((effect) => { + if (effect.parameters) { + Object.keys(effect.parameters).forEach((paramKey) => { + const parameterId = `effect.${effect.id}.${paramKey}`; + const paramName = `${effect.name} - ${paramKey.charAt(0).toUpperCase() + paramKey.slice(1)}`; + availableParameters.push({ id: parameterId, name: paramName }); + }); + } + }); + + // Get parameters that have automation lanes with points + const automatedParams = track.automation.lanes + .filter(lane => lane.points.length > 0) + .map(lane => { + const param = availableParameters.find(p => p.id === lane.parameterId); + return param ? param.name : lane.parameterName; + }); + + const modes = ['read', 'write', 'touch', 'latch'] as const; + const MODE_LABELS = { read: 'R', write: 'W', touch: 'T', latch: 'L' }; + const MODE_COLORS = { + read: 'text-muted-foreground', + write: 'text-red-500', + touch: 'text-yellow-500', + latch: 'text-orange-500', + }; + const currentModeIndex = modes.indexOf(currentLane?.mode || 'read'); + + return ( +
+ {/* Automation Header */} +
+ Automation + + {/* Color indicator */} + {currentLane?.color && ( +
+ )} + + {/* Parameter labels - always visible */} +
+ {automatedParams.map((paramName, index) => ( + + {paramName} + + ))} +
+ + {/* Controls - only visible when expanded */} + {track.automationExpanded && ( + <> + {/* Parameter selector */} + {availableParameters && availableParameters.length > 1 && ( + + )} + + {/* Automation mode button */} + + + {/* Height controls */} +
+ + +
+ + )} + + {/* Show/hide toggle */} + +
+ + {/* Automation Lane Content - Shown when expanded */} + {track.automationExpanded && ( +
+
1 ? `${duration * zoom * 100}px` : '100%', + }} + > + {track.automation.lanes + .filter((lane) => lane.parameterId === (track.automation.selectedParameterId || 'volume') && lane.visible) + .map((lane) => ( + { + const newPoint = createAutomationPoint({ time, value, curve: 'linear' }); + const updatedLanes = track.automation.lanes.map((l) => + l.id === lane.id + ? { ...l, points: [...l.points, newPoint].sort((a, b) => a.time - b.time) } + : l + ); + onUpdateTrack(track.id, { + automation: { ...track.automation, lanes: updatedLanes }, + }); + }} + onUpdatePoint={(pointId, updates) => { + const updatedLanes = track.automation.lanes.map((l) => + l.id === lane.id + ? { + ...l, + points: l.points.map((p) => + p.id === pointId ? { ...p, ...updates } : p + ), + } + : l + ); + onUpdateTrack(track.id, { + automation: { ...track.automation, lanes: updatedLanes }, + }); + }} + onRemovePoint={(pointId) => { + const updatedLanes = track.automation.lanes.map((l) => + l.id === lane.id + ? { ...l, points: l.points.filter((p) => p.id !== pointId) } + : l + ); + onUpdateTrack(track.id, { + automation: { ...track.automation, lanes: updatedLanes }, + }); + }} + onUpdateLane={(updates) => { + const updatedLanes = track.automation.lanes.map((l) => + l.id === lane.id ? { ...l, ...updates } : l + ); + onUpdateTrack(track.id, { + automation: { ...track.automation, lanes: updatedLanes }, + }); + }} + /> + ))} +
+
+ )} +
+ ); + })()} + + {/* Effects Bar */} +
+ {/* Effects Header */} +
+ Effects + + {/* Effect name labels */} +
+ {track.effectChain.effects.map((effect) => ( + + {effect.name} + + ))} +
+ + {/* Add effect button - only visible when expanded */} + {track.effectsExpanded && ( + + )} + + {/* Show/hide toggle */} + +
+ + {/* Effects Content - Collapsible, horizontally scrollable */} + {track.effectsExpanded && ( +
+
+ {track.effectChain.effects.length === 0 ? ( +
+ No effects. Click + to add an effect. +
+ ) : ( + track.effectChain.effects.map((effect) => ( + { + const updatedChain = { + ...track.effectChain, + effects: track.effectChain.effects.map((e) => + e.id === effect.id ? { ...e, enabled: !e.enabled } : e + ), + }; + onUpdateTrack(track.id, { effectChain: updatedChain }); + }} + onRemove={() => { + const updatedChain = { + ...track.effectChain, + effects: track.effectChain.effects.filter((e) => e.id !== effect.id), + }; + onUpdateTrack(track.id, { effectChain: updatedChain }); + }} + onUpdateParameters={(params) => { + const updatedChain = { + ...track.effectChain, + effects: track.effectChain.effects.map((e) => + e.id === effect.id ? { ...e, parameters: params } : e + ), + }; + onUpdateTrack(track.id, { effectChain: updatedChain }); + }} + onToggleExpanded={() => { + const updatedEffects = track.effectChain.effects.map((e) => + e.id === effect.id ? { ...e, expanded: !e.expanded } : e + ); + onUpdateTrack(track.id, { + effectChain: { ...track.effectChain, effects: updatedEffects }, + }); + }} + /> + )) + )} +
+
+ )} +
+
+ )} +
+ ))} +
+ + {/* Desktop Layout - Two Column Layout */} +
{/* Left Column: Track Controls (Fixed Width, No Scroll - synced with waveforms) */}
{tracks.map((track) => ( @@ -271,14 +772,14 @@ export function TrackList({
{tracks.map((track) => ( - {/* Track Waveform Row with bars stacked below - Fixed height container */} + {/* Track Waveform Row with bars stacked below - Total height matches track controls */}
- {/* Waveform - Takes remaining space, horizontally scrollable */} + {/* Waveform - Takes remaining space after bars */}
{/* Upload hint for empty tracks - stays fixed as overlay */} {!track.audioBuffer && !track.collapsed && ( diff --git a/lib/audio/decoder.ts b/lib/audio/decoder.ts index 504f5af..2ccd4fa 100644 --- a/lib/audio/decoder.ts +++ b/lib/audio/decoder.ts @@ -3,6 +3,7 @@ */ import { getAudioContext } from './context'; +import { checkFileMemoryLimit, type MemoryCheckResult } from '../utils/memory-limits'; export interface ImportOptions { convertToMono?: boolean; @@ -248,6 +249,15 @@ export function isSupportedAudioFormat(file: File): boolean { /\.(wav|mp3|ogg|webm|flac|aac|m4a|aiff|aif)$/i.test(file.name); } +/** + * Check memory requirements for an audio file before decoding + * @param file File to check + * @returns Memory check result with warning if file is large + */ +export function checkAudioFileMemory(file: File): MemoryCheckResult { + return checkFileMemoryLimit(file.size); +} + /** * Format duration in seconds to MM:SS format */ diff --git a/lib/utils/audio-cleanup.ts b/lib/utils/audio-cleanup.ts new file mode 100644 index 0000000..1a19832 --- /dev/null +++ b/lib/utils/audio-cleanup.ts @@ -0,0 +1,149 @@ +/** + * Audio cleanup utilities to prevent memory leaks + */ + +/** + * Safely disconnect and cleanup an AudioNode + */ +export function cleanupAudioNode(node: AudioNode | null | undefined): void { + if (!node) return; + + try { + node.disconnect(); + } catch (error) { + // Node may already be disconnected, ignore error + console.debug('AudioNode cleanup error (expected):', error); + } +} + +/** + * Cleanup multiple audio nodes + */ +export function cleanupAudioNodes(nodes: Array): void { + nodes.forEach(cleanupAudioNode); +} + +/** + * Safely stop and cleanup an AudioBufferSourceNode + */ +export function cleanupAudioSource(source: AudioBufferSourceNode | null | undefined): void { + if (!source) return; + + try { + source.stop(); + } catch (error) { + // Source may already be stopped, ignore error + console.debug('AudioSource stop error (expected):', error); + } + + cleanupAudioNode(source); +} + +/** + * Cleanup canvas and release resources + */ +export function cleanupCanvas(canvas: HTMLCanvasElement | null | undefined): void { + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (ctx) { + // Clear the canvas + ctx.clearRect(0, 0, canvas.width, canvas.height); + // Reset transform + ctx.setTransform(1, 0, 0, 1, 0, 0); + } + + // Release context (helps with memory) + canvas.width = 0; + canvas.height = 0; +} + +/** + * Cancel animation frame safely + */ +export function cleanupAnimationFrame(frameId: number | null | undefined): void { + if (frameId !== null && frameId !== undefined) { + cancelAnimationFrame(frameId); + } +} + +/** + * Cleanup media stream tracks + */ +export function cleanupMediaStream(stream: MediaStream | null | undefined): void { + if (!stream) return; + + stream.getTracks().forEach(track => { + track.stop(); + }); +} + +/** + * Create a cleanup registry for managing multiple cleanup tasks + */ +export class CleanupRegistry { + private cleanupTasks: Array<() => void> = []; + + /** + * Register a cleanup task + */ + register(cleanup: () => void): void { + this.cleanupTasks.push(cleanup); + } + + /** + * Register an audio node for cleanup + */ + registerAudioNode(node: AudioNode): void { + this.register(() => cleanupAudioNode(node)); + } + + /** + * Register an audio source for cleanup + */ + registerAudioSource(source: AudioBufferSourceNode): void { + this.register(() => cleanupAudioSource(source)); + } + + /** + * Register a canvas for cleanup + */ + registerCanvas(canvas: HTMLCanvasElement): void { + this.register(() => cleanupCanvas(canvas)); + } + + /** + * Register an animation frame for cleanup + */ + registerAnimationFrame(frameId: number): void { + this.register(() => cleanupAnimationFrame(frameId)); + } + + /** + * Register a media stream for cleanup + */ + registerMediaStream(stream: MediaStream): void { + this.register(() => cleanupMediaStream(stream)); + } + + /** + * Execute all cleanup tasks and clear the registry + */ + cleanup(): void { + this.cleanupTasks.forEach(task => { + try { + task(); + } catch (error) { + console.error('Cleanup task failed:', error); + } + }); + this.cleanupTasks = []; + } + + /** + * Get the number of registered cleanup tasks + */ + get size(): number { + return this.cleanupTasks.length; + } +} diff --git a/lib/utils/browser-compat.ts b/lib/utils/browser-compat.ts new file mode 100644 index 0000000..5d6aeb8 --- /dev/null +++ b/lib/utils/browser-compat.ts @@ -0,0 +1,133 @@ +/** + * Browser compatibility checking utilities + */ + +export interface BrowserCompatibility { + isSupported: boolean; + missingFeatures: string[]; + warnings: string[]; +} + +/** + * Check if all required browser features are supported + */ +export function checkBrowserCompatibility(): BrowserCompatibility { + const missingFeatures: string[] = []; + const warnings: string[] = []; + + // Check if running in browser + if (typeof window === 'undefined') { + return { + isSupported: true, + missingFeatures: [], + warnings: [], + }; + } + + // Check Web Audio API + if (!window.AudioContext && !(window as any).webkitAudioContext) { + missingFeatures.push('Web Audio API'); + } + + // Check IndexedDB + if (!window.indexedDB) { + missingFeatures.push('IndexedDB'); + } + + // Check localStorage + try { + localStorage.setItem('test', 'test'); + localStorage.removeItem('test'); + } catch (e) { + missingFeatures.push('LocalStorage'); + } + + // Check Canvas API + const canvas = document.createElement('canvas'); + if (!canvas.getContext || !canvas.getContext('2d')) { + missingFeatures.push('Canvas API'); + } + + // Check MediaDevices API (for recording) + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + warnings.push('Microphone recording not supported (requires HTTPS or localhost)'); + } + + // Check File API + if (!window.File || !window.FileReader || !window.FileList || !window.Blob) { + missingFeatures.push('File API'); + } + + // Check AudioWorklet support (optional) + if (window.AudioContext && !AudioContext.prototype.hasOwnProperty('audioWorklet')) { + warnings.push('AudioWorklet not supported (some features may have higher latency)'); + } + + // Check OfflineAudioContext + if (!window.OfflineAudioContext && !(window as any).webkitOfflineAudioContext) { + missingFeatures.push('OfflineAudioContext (required for audio processing)'); + } + + return { + isSupported: missingFeatures.length === 0, + missingFeatures, + warnings, + }; +} + +/** + * Get user-friendly browser name + */ +export function getBrowserInfo(): { name: string; version: string } { + // Check if running in browser + if (typeof window === 'undefined' || typeof navigator === 'undefined') { + return { name: 'Unknown', version: 'Unknown' }; + } + + const userAgent = navigator.userAgent; + let name = 'Unknown'; + let version = 'Unknown'; + + if (userAgent.indexOf('Chrome') > -1 && userAgent.indexOf('Edg') === -1) { + name = 'Chrome'; + const match = userAgent.match(/Chrome\/(\d+)/); + version = match ? match[1] : 'Unknown'; + } else if (userAgent.indexOf('Edg') > -1) { + name = 'Edge'; + const match = userAgent.match(/Edg\/(\d+)/); + version = match ? match[1] : 'Unknown'; + } else if (userAgent.indexOf('Firefox') > -1) { + name = 'Firefox'; + const match = userAgent.match(/Firefox\/(\d+)/); + version = match ? match[1] : 'Unknown'; + } else if (userAgent.indexOf('Safari') > -1 && userAgent.indexOf('Chrome') === -1) { + name = 'Safari'; + const match = userAgent.match(/Version\/(\d+)/); + version = match ? match[1] : 'Unknown'; + } + + return { name, version }; +} + +/** + * Check if browser version meets minimum requirements + */ +export function checkMinimumVersion(): boolean { + const { name, version } = getBrowserInfo(); + const versionNum = parseInt(version, 10); + + const minimumVersions: Record = { + Chrome: 90, + Edge: 90, + Firefox: 88, + Safari: 14, + }; + + const minVersion = minimumVersions[name]; + if (!minVersion) { + // Unknown browser, assume it's ok + return true; + } + + return versionNum >= minVersion; +} diff --git a/lib/utils/memory-limits.ts b/lib/utils/memory-limits.ts new file mode 100644 index 0000000..20cfc74 --- /dev/null +++ b/lib/utils/memory-limits.ts @@ -0,0 +1,160 @@ +/** + * Memory limit checking utilities for audio file handling + */ + +export interface MemoryCheckResult { + allowed: boolean; + warning?: string; + estimatedMemoryMB: number; + availableMemoryMB?: number; +} + +/** + * Estimate memory required for an audio buffer + * @param duration Duration in seconds + * @param sampleRate Sample rate (default: 48000 Hz) + * @param channels Number of channels (default: 2 for stereo) + * @returns Estimated memory in MB + */ +export function estimateAudioMemory( + duration: number, + sampleRate: number = 48000, + channels: number = 2 +): number { + // Each sample is a 32-bit float (4 bytes) + const bytesPerSample = 4; + const totalSamples = duration * sampleRate * channels; + const bytes = totalSamples * bytesPerSample; + + // Convert to MB + return bytes / (1024 * 1024); +} + +/** + * Get available device memory if supported + * @returns Available memory in MB, or undefined if not supported + */ +export function getAvailableMemory(): number | undefined { + if (typeof navigator === 'undefined') return undefined; + + // @ts-ignore - deviceMemory is not in TypeScript types yet + const deviceMemory = navigator.deviceMemory; + if (typeof deviceMemory === 'number') { + // deviceMemory is in GB, convert to MB + return deviceMemory * 1024; + } + + return undefined; +} + +/** + * Check if a file size is within safe memory limits + * @param fileSizeBytes File size in bytes + * @returns Memory check result + */ +export function checkFileMemoryLimit(fileSizeBytes: number): MemoryCheckResult { + // Estimate memory usage (audio files decompress to ~10x their size) + const estimatedMemoryMB = (fileSizeBytes / (1024 * 1024)) * 10; + const availableMemoryMB = getAvailableMemory(); + + // Conservative limits + const WARN_THRESHOLD_MB = 100; // Warn if file will use > 100MB + const MAX_RECOMMENDED_MB = 500; // Don't recommend files > 500MB + + if (estimatedMemoryMB > MAX_RECOMMENDED_MB) { + return { + allowed: false, + warning: `This file may require ${Math.round(estimatedMemoryMB)}MB of memory. ` + + `Files larger than ${MAX_RECOMMENDED_MB}MB are not recommended as they may cause performance issues or crashes.`, + estimatedMemoryMB, + availableMemoryMB, + }; + } + + if (estimatedMemoryMB > WARN_THRESHOLD_MB) { + const warning = availableMemoryMB + ? `This file will require approximately ${Math.round(estimatedMemoryMB)}MB of memory. ` + + `Your device has ${Math.round(availableMemoryMB)}MB available.` + : `This file will require approximately ${Math.round(estimatedMemoryMB)}MB of memory. ` + + `Large files may cause performance issues on devices with limited memory.`; + + return { + allowed: true, + warning, + estimatedMemoryMB, + availableMemoryMB, + }; + } + + return { + allowed: true, + estimatedMemoryMB, + availableMemoryMB, + }; +} + +/** + * Check if an audio buffer is within safe memory limits + * @param duration Duration in seconds + * @param sampleRate Sample rate + * @param channels Number of channels + * @returns Memory check result + */ +export function checkAudioBufferMemoryLimit( + duration: number, + sampleRate: number = 48000, + channels: number = 2 +): MemoryCheckResult { + const estimatedMemoryMB = estimateAudioMemory(duration, sampleRate, channels); + const availableMemoryMB = getAvailableMemory(); + + const WARN_THRESHOLD_MB = 100; + const MAX_RECOMMENDED_MB = 500; + + if (estimatedMemoryMB > MAX_RECOMMENDED_MB) { + return { + allowed: false, + warning: `This audio (${Math.round(duration / 60)} minutes) will require ${Math.round(estimatedMemoryMB)}MB of memory. ` + + `Audio longer than ${Math.round((MAX_RECOMMENDED_MB / sampleRate / channels / 4) / 60)} minutes may cause performance issues.`, + estimatedMemoryMB, + availableMemoryMB, + }; + } + + if (estimatedMemoryMB > WARN_THRESHOLD_MB) { + const warning = availableMemoryMB + ? `This audio will require approximately ${Math.round(estimatedMemoryMB)}MB of memory. ` + + `Your device has ${Math.round(availableMemoryMB)}MB available.` + : `This audio will require approximately ${Math.round(estimatedMemoryMB)}MB of memory.`; + + return { + allowed: true, + warning, + estimatedMemoryMB, + availableMemoryMB, + }; + } + + return { + allowed: true, + estimatedMemoryMB, + availableMemoryMB, + }; +} + +/** + * Format memory size in human-readable format + * @param bytes Size in bytes + * @returns Formatted string (e.g., "1.5 MB", "250 KB") + */ +export function formatMemorySize(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } else if (bytes < 1024 * 1024) { + return `${(bytes / 1024).toFixed(1)} KB`; + } else if (bytes < 1024 * 1024 * 1024) { + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + } else { + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; + } +}