Compare commits
22 Commits
a2cef6cc6e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fa69ac649c | |||
| efd4cfa607 | |||
| 0d8952ca2f | |||
| 1b30931615 | |||
| d9bd8246c9 | |||
| dd8d46795a | |||
| adcc97eb5a | |||
| 1855988b83 | |||
| 477a444c78 | |||
| 119c8c2942 | |||
| 8720c35f23 | |||
| 25ddac349b | |||
| 08b33aacb5 | |||
| 9007522e18 | |||
| a47bf09a32 | |||
| aba26126cc | |||
| 66a515ba79 | |||
| 691f75209d | |||
| 908e6caaf8 | |||
| e09bc1449c | |||
| d03080d3d2 | |||
| 484e3261c5 |
97
PLAN.md
97
PLAN.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Progress Overview
|
## Progress Overview
|
||||||
|
|
||||||
**Current Status**: Phase 13 Complete (Keyboard Shortcuts: Full suite of shortcuts for navigation, editing, and view control) - Ready for Phase 14
|
**Current Status**: Phase 14 Complete (Settings & Preferences: Global settings with localStorage persistence) - Ready for Phase 15
|
||||||
|
|
||||||
### Completed Phases
|
### Completed Phases
|
||||||
- ✅ **Phase 1**: Project Setup & Core Infrastructure (95% complete)
|
- ✅ **Phase 1**: Project Setup & Core Infrastructure (95% complete)
|
||||||
@@ -148,6 +148,16 @@
|
|||||||
- ✅ Dynamic file extension display
|
- ✅ Dynamic file extension display
|
||||||
- ✅ Smart selection detection (disable option when no selection)
|
- ✅ Smart selection detection (disable option when no selection)
|
||||||
|
|
||||||
|
**Settings & Preferences (Phase 14 - Complete):**
|
||||||
|
- ✅ Global settings dialog with 5 tabs (Recording, Audio, Editor, Interface, Performance)
|
||||||
|
- ✅ localStorage persistence with default merging
|
||||||
|
- ✅ Audio settings: buffer size, sample rate (applied to recording), auto-normalize
|
||||||
|
- ✅ UI settings: theme, font size, default track height (applied to new tracks)
|
||||||
|
- ✅ Editor settings: auto-save interval, undo limit, snap-to-grid, grid resolution, default zoom (applied)
|
||||||
|
- ✅ Performance settings: peak quality, waveform quality, spectrogram toggle (applied), max file size
|
||||||
|
- ✅ Category-specific reset buttons
|
||||||
|
- ✅ Real-time application to editor behavior
|
||||||
|
|
||||||
### Next Steps
|
### Next Steps
|
||||||
- **Phase 6**: Audio effects ✅ COMPLETE (Basic + Filters + Dynamics + Time-Based + Advanced + Chain Management)
|
- **Phase 6**: Audio effects ✅ COMPLETE (Basic + Filters + Dynamics + Time-Based + Advanced + Chain Management)
|
||||||
- **Phase 7**: Multi-track editing ✅ COMPLETE (Multi-track playback, effects, selection/editing)
|
- **Phase 7**: Multi-track editing ✅ COMPLETE (Multi-track playback, effects, selection/editing)
|
||||||
@@ -155,6 +165,9 @@
|
|||||||
- **Phase 9**: Automation ✅ COMPLETE (Volume/Pan automation with write/touch/latch modes)
|
- **Phase 9**: Automation ✅ COMPLETE (Volume/Pan automation with write/touch/latch modes)
|
||||||
- **Phase 10**: Analysis Tools ✅ COMPLETE (FFT, Spectrogram, Phase Correlation, LUFS, Audio Statistics)
|
- **Phase 10**: Analysis Tools ✅ COMPLETE (FFT, Spectrogram, Phase Correlation, LUFS, Audio Statistics)
|
||||||
- **Phase 11**: Export & Import ✅ COMPLETE (Full export/import with all formats, settings, scope options & conversions)
|
- **Phase 11**: Export & Import ✅ COMPLETE (Full export/import with all formats, settings, scope options & conversions)
|
||||||
|
- **Phase 12**: Project Management ✅ COMPLETE (IndexedDB storage, auto-save, project export/import)
|
||||||
|
- **Phase 13**: Keyboard Shortcuts ✅ COMPLETE (Full suite of shortcuts for navigation, editing, and view control)
|
||||||
|
- **Phase 14**: Settings & Preferences ✅ COMPLETE (Global settings with localStorage persistence and live application)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -828,57 +841,71 @@ audio-ui/
|
|||||||
- [ ] User-configurable shortcuts (future enhancement)
|
- [ ] User-configurable shortcuts (future enhancement)
|
||||||
- [ ] Shortcut conflict detection (future enhancement)
|
- [ ] Shortcut conflict detection (future enhancement)
|
||||||
|
|
||||||
### Phase 14: Settings & Preferences
|
### Phase 14: Settings & Preferences ✅ COMPLETE
|
||||||
|
|
||||||
#### 14.1 Audio Settings
|
**✅ Accomplished:**
|
||||||
- [ ] Audio output device selection
|
- Global settings system with localStorage persistence
|
||||||
- [ ] Buffer size/latency configuration
|
- Settings dialog with 5 tabs (Recording, Audio, Editor, Interface, Performance)
|
||||||
- [ ] Sample rate preference
|
- Real-time settings application to editor behavior
|
||||||
- [ ] Auto-normalize on import
|
- Category-specific reset buttons
|
||||||
|
- Merge with defaults on load for backward compatibility
|
||||||
|
|
||||||
#### 14.2 UI Settings
|
#### 14.1 Audio Settings ✅
|
||||||
- [ ] Theme selection (dark/light/auto)
|
- [ ] Audio output device selection (future: requires device enumeration API)
|
||||||
- [ ] Color scheme customization
|
- [x] Buffer size/latency configuration
|
||||||
- [ ] Waveform colors
|
- [x] Sample rate preference (applied to recording)
|
||||||
- [ ] Font size
|
- [x] Auto-normalize on import
|
||||||
|
|
||||||
#### 14.3 Editor Settings
|
#### 14.2 UI Settings ✅
|
||||||
- [ ] Auto-save interval
|
- [x] Theme selection (dark/light/auto)
|
||||||
- [ ] Undo history limit
|
- [x] Font size (small/medium/large)
|
||||||
- [ ] Snap-to-grid toggle
|
- [x] Default track height (120-400px, applied to new tracks)
|
||||||
- [ ] Grid resolution
|
- [ ] Color scheme customization (future: advanced theming)
|
||||||
- [ ] Default zoom level
|
|
||||||
|
|
||||||
#### 14.4 Performance Settings
|
#### 14.3 Editor Settings ✅
|
||||||
- [ ] Peak calculation quality
|
- [x] Auto-save interval (0-60 seconds)
|
||||||
- [ ] Waveform rendering quality
|
- [x] Undo history limit (10-200 operations)
|
||||||
- [ ] Enable/disable spectrogram
|
- [x] Snap-to-grid toggle
|
||||||
- [ ] Maximum file size limit
|
- [x] Grid resolution (0.1-10 seconds)
|
||||||
|
- [x] Default zoom level (1-20x, applied to initial state)
|
||||||
|
|
||||||
|
#### 14.4 Performance Settings ✅
|
||||||
|
- [x] Peak calculation quality (low/medium/high)
|
||||||
|
- [x] Waveform rendering quality (low/medium/high)
|
||||||
|
- [x] Enable/disable spectrogram (applied to analyzer visibility)
|
||||||
|
- [x] Maximum file size limit (100-1000 MB)
|
||||||
|
|
||||||
### Phase 15: Polish & Optimization
|
### Phase 15: Polish & Optimization
|
||||||
|
|
||||||
#### 15.1 Performance Optimization
|
#### 15.1 Performance Optimization
|
||||||
- [ ] Web Worker for heavy computations
|
- [ ] Web Worker for heavy computations
|
||||||
- [ ] AudioWorklet for custom processing
|
- [ ] AudioWorklet for custom processing
|
||||||
- [ ] Lazy loading for effects
|
- [x] Lazy loading for dialogs and analysis components (GlobalSettingsDialog, ExportDialog, ProjectsDialog, ImportTrackDialog, FrequencyAnalyzer, Spectrogram, PhaseCorrelationMeter, LUFSMeter, AudioStatistics)
|
||||||
- [ ] Code splitting for route optimization
|
- [ ] Code splitting for route optimization
|
||||||
- [ ] Memory leak prevention
|
- [x] Memory leak prevention (audio-cleanup utilities, proper cleanup in useRecording, animation frame cancellation in visualizations)
|
||||||
|
|
||||||
#### 15.2 Responsive Design
|
#### 15.2 Responsive Design ✅
|
||||||
- [ ] Mobile-friendly layout
|
- [x] Mobile-friendly layout (responsive header, adaptive toolbar with icon-only buttons on small screens)
|
||||||
- [ ] Touch gesture support
|
- [x] Touch gesture support (collapse/expand controls with chevron buttons)
|
||||||
- [ ] Adaptive toolbar (hide on mobile)
|
- [x] Adaptive toolbar (hide less critical buttons on mobile: Export on md, Clear All on lg)
|
||||||
- [ ] Vertical scrolling for track list
|
- [x] Vertical scrolling for track list (sidebar hidden on mobile < lg breakpoint)
|
||||||
|
- [x] Collapsible track controls (two-state mobile: collapsed with minimal controls + horizontal meter, expanded with full height fader + pan control; desktop always expanded with narrow borders)
|
||||||
|
- [x] Collapsible master controls (collapsed view with horizontal level meter, expanded view with full controls; collapse button hidden on desktop)
|
||||||
|
- [x] Track collapse buttons on mobile (left chevron: collapses/expands track in list, right chevron: collapses/expands track controls)
|
||||||
|
- [x] Mobile vertical stacking layout (< lg breakpoint: controls → waveform → automation bars → effects bars per track, master controls and transport controls stacked vertically in bottom bar)
|
||||||
|
- [x] Desktop two-column layout (≥ lg breakpoint: controls left sidebar, waveforms right panel with automation/effects bars, master controls in right sidebar, transport controls centered in bottom bar)
|
||||||
|
- [x] Automation and effects bars on mobile (collapsible with eye/eye-off icons, horizontally scrollable, full functionality: parameter selection, mode cycling, height controls, add effects)
|
||||||
|
- [x] Height synchronization (track controls and waveform container heights match exactly using user-configurable track.height on desktop)
|
||||||
|
|
||||||
#### 15.3 Error Handling
|
#### 15.3 Error Handling
|
||||||
- [ ] Graceful error messages
|
- [x] Graceful error messages (toast notifications for copy/paste/edit operations)
|
||||||
- [ ] File format error handling
|
- [x] File format error handling (UnsupportedFormatDialog with format validation and decode error catching)
|
||||||
- [ ] Memory limit warnings
|
- [x] Memory limit warnings (MemoryWarningDialog with file size checks)
|
||||||
- [ ] Browser compatibility checks
|
- [x] Browser compatibility checks (BrowserCompatDialog with Web Audio API detection)
|
||||||
|
|
||||||
#### 15.4 Documentation
|
#### 15.4 Documentation
|
||||||
- [ ] User guide
|
- [ ] User guide
|
||||||
- [ ] Keyboard shortcuts reference
|
- [x] Keyboard shortcuts reference (KeyboardShortcutsDialog with ? shortcut and command palette integration)
|
||||||
- [ ] Effect descriptions
|
- [ ] Effect descriptions
|
||||||
- [ ] Troubleshooting guide
|
- [ ] Troubleshooting guide
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Eye, EyeOff, ChevronDown, ChevronUp } from 'lucide-react';
|
import { Eye, EyeOff, ChevronDown, ChevronUp, Copy, Clipboard } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import type { AutomationMode } from '@/types/automation';
|
import type { AutomationMode } from '@/types/automation';
|
||||||
@@ -21,6 +21,9 @@ export interface AutomationHeaderProps {
|
|||||||
availableParameters?: Array<{ id: string; name: string }>;
|
availableParameters?: Array<{ id: string; name: string }>;
|
||||||
selectedParameterId?: string;
|
selectedParameterId?: string;
|
||||||
onParameterChange?: (parameterId: string) => void;
|
onParameterChange?: (parameterId: string) => void;
|
||||||
|
// Copy/Paste automation
|
||||||
|
onCopyAutomation?: () => void;
|
||||||
|
onPasteAutomation?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MODE_LABELS: Record<AutomationMode, string> = {
|
const MODE_LABELS: Record<AutomationMode, string> = {
|
||||||
@@ -51,6 +54,8 @@ export function AutomationHeader({
|
|||||||
availableParameters,
|
availableParameters,
|
||||||
selectedParameterId,
|
selectedParameterId,
|
||||||
onParameterChange,
|
onParameterChange,
|
||||||
|
onCopyAutomation,
|
||||||
|
onPasteAutomation,
|
||||||
}: AutomationHeaderProps) {
|
}: AutomationHeaderProps) {
|
||||||
const modes: AutomationMode[] = ['read', 'write', 'touch', 'latch'];
|
const modes: AutomationMode[] = ['read', 'write', 'touch', 'latch'];
|
||||||
const currentModeIndex = modes.indexOf(mode);
|
const currentModeIndex = modes.indexOf(mode);
|
||||||
@@ -145,6 +150,34 @@ export function AutomationHeader({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Copy/Paste automation controls */}
|
||||||
|
{(onCopyAutomation || onPasteAutomation) && (
|
||||||
|
<div className="flex gap-1 flex-shrink-0 ml-auto mr-8">
|
||||||
|
{onCopyAutomation && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onCopyAutomation}
|
||||||
|
title="Copy automation data"
|
||||||
|
className="h-5 w-5"
|
||||||
|
>
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onPasteAutomation && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onPasteAutomation}
|
||||||
|
title="Paste automation data"
|
||||||
|
className="h-5 w-5"
|
||||||
|
>
|
||||||
|
<Clipboard className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Show/hide toggle - Positioned absolutely on the right */}
|
{/* Show/hide toggle - Positioned absolutely on the right */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
import { CircularKnob } from '@/components/ui/CircularKnob';
|
import { CircularKnob } from '@/components/ui/CircularKnob';
|
||||||
import { MasterFader } from './MasterFader';
|
import { MasterFader } from './MasterFader';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
@@ -12,10 +13,12 @@ export interface MasterControlsProps {
|
|||||||
rmsLevel: number;
|
rmsLevel: number;
|
||||||
isClipping: boolean;
|
isClipping: boolean;
|
||||||
isMuted?: boolean;
|
isMuted?: boolean;
|
||||||
|
collapsed?: boolean; // For collapsible on mobile/small screens
|
||||||
onVolumeChange: (volume: number) => void;
|
onVolumeChange: (volume: number) => void;
|
||||||
onPanChange: (pan: number) => void;
|
onPanChange: (pan: number) => void;
|
||||||
onMuteToggle: () => void;
|
onMuteToggle: () => void;
|
||||||
onResetClip?: () => void;
|
onResetClip?: () => void;
|
||||||
|
onToggleCollapse?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,20 +29,81 @@ export function MasterControls({
|
|||||||
rmsLevel,
|
rmsLevel,
|
||||||
isClipping,
|
isClipping,
|
||||||
isMuted = false,
|
isMuted = false,
|
||||||
|
collapsed = false,
|
||||||
onVolumeChange,
|
onVolumeChange,
|
||||||
onPanChange,
|
onPanChange,
|
||||||
onMuteToggle,
|
onMuteToggle,
|
||||||
onResetClip,
|
onResetClip,
|
||||||
|
onToggleCollapse,
|
||||||
className,
|
className,
|
||||||
}: MasterControlsProps) {
|
}: MasterControlsProps) {
|
||||||
|
// Collapsed view - minimal controls
|
||||||
|
if (collapsed) {
|
||||||
|
return (
|
||||||
|
<div className={cn(
|
||||||
|
'flex flex-col items-center gap-2 px-3 py-2 bg-card/50 border border-accent/50 rounded-lg w-full',
|
||||||
|
className
|
||||||
|
)}>
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<div className="text-xs font-bold text-accent uppercase tracking-wider">
|
||||||
|
Master
|
||||||
|
</div>
|
||||||
|
{onToggleCollapse && (
|
||||||
|
<button
|
||||||
|
onClick={onToggleCollapse}
|
||||||
|
className="p-1 hover:bg-accent/20 rounded transition-colors"
|
||||||
|
title="Expand master controls"
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 w-full justify-center">
|
||||||
|
<button
|
||||||
|
onClick={onMuteToggle}
|
||||||
|
className={cn(
|
||||||
|
'h-7 w-7 rounded-md flex items-center justify-center transition-all text-xs font-bold',
|
||||||
|
isMuted
|
||||||
|
? 'bg-blue-500 text-white shadow-md shadow-blue-500/30'
|
||||||
|
: 'bg-card hover:bg-accent text-muted-foreground border border-border/50'
|
||||||
|
)}
|
||||||
|
title={isMuted ? 'Unmute' : 'Mute'}
|
||||||
|
>
|
||||||
|
M
|
||||||
|
</button>
|
||||||
|
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-full transition-all',
|
||||||
|
peakLevel > 0.95 ? 'bg-red-500' : peakLevel > 0.8 ? 'bg-yellow-500' : 'bg-green-500'
|
||||||
|
)}
|
||||||
|
style={{ width: `${peakLevel * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'flex flex-col items-center gap-3 px-4 py-3 bg-card/50 border-2 border-accent/50 rounded-lg',
|
'flex flex-col items-center gap-3 px-4 py-3 bg-card/50 border-2 border-accent/50 rounded-lg',
|
||||||
className
|
className
|
||||||
)}>
|
)}>
|
||||||
{/* Master Label */}
|
{/* Master Label with collapse button */}
|
||||||
<div className="text-[10px] font-bold text-accent uppercase tracking-wider">
|
<div className="flex items-center justify-between w-full">
|
||||||
Master
|
<div className="text-[10px] font-bold text-accent uppercase tracking-wider flex-1 text-center">
|
||||||
|
Master
|
||||||
|
</div>
|
||||||
|
{onToggleCollapse && (
|
||||||
|
<button
|
||||||
|
onClick={onToggleCollapse}
|
||||||
|
className="p-0.5 hover:bg-accent/20 rounded transition-colors flex-shrink-0 lg:hidden"
|
||||||
|
title="Collapse master controls"
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-3 w-3 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pan Control */}
|
{/* Pan Control */}
|
||||||
|
|||||||
130
components/dialogs/BrowserCompatDialog.tsx
Normal file
130
components/dialogs/BrowserCompatDialog.tsx
Normal file
@@ -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 (
|
||||||
|
<Modal open={open} onClose={onClose} title="">
|
||||||
|
<div className="p-6 max-w-md">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{hasErrors ? (
|
||||||
|
<>
|
||||||
|
<XCircle className="h-5 w-5 text-destructive" />
|
||||||
|
<h2 className="text-lg font-semibold">Browser Not Supported</h2>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<AlertTriangle className="h-5 w-5 text-yellow-500" />
|
||||||
|
<h2 className="text-lg font-semibold">Browser Warnings</h2>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{hasErrors ? (
|
||||||
|
<>Your browser is missing required features to run this audio editor.</>
|
||||||
|
) : (
|
||||||
|
<>Some features may not work as expected in your browser.</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Browser Info */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Info className="h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
{browserInfo.name} {browserInfo.version}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Missing Features */}
|
||||||
|
{missingFeatures.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-semibold text-destructive flex items-center gap-2">
|
||||||
|
<XCircle className="h-4 w-4" />
|
||||||
|
Missing Required Features:
|
||||||
|
</h3>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-sm text-muted-foreground">
|
||||||
|
{missingFeatures.map((feature) => (
|
||||||
|
<li key={feature}>{feature}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Warnings */}
|
||||||
|
{warnings.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-semibold text-yellow-600 dark:text-yellow-500 flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
Warnings:
|
||||||
|
</h3>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-sm text-muted-foreground">
|
||||||
|
{warnings.map((warning) => (
|
||||||
|
<li key={warning}>{warning}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Recommendations */}
|
||||||
|
{hasErrors && (
|
||||||
|
<div className="bg-muted/50 border border-border rounded-md p-3 space-y-2">
|
||||||
|
<h3 className="text-sm font-semibold">Recommended Browsers:</h3>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-1">
|
||||||
|
<li>• Chrome 90+ or Edge 90+</li>
|
||||||
|
<li>• Firefox 88+</li>
|
||||||
|
<li>• Safari 14+</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
{hasErrors ? (
|
||||||
|
<Button onClick={onClose} variant="destructive">
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={onClose}>
|
||||||
|
Continue Anyway
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
components/dialogs/KeyboardShortcutsDialog.tsx
Normal file
140
components/dialogs/KeyboardShortcutsDialog.tsx
Normal file
@@ -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 (
|
||||||
|
<kbd className="px-2 py-1 text-xs font-semibold bg-muted border border-border rounded shadow-sm min-w-[2rem] text-center inline-block">
|
||||||
|
{keyName}
|
||||||
|
</kbd>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeyboardShortcutsDialog({ open, onClose }: KeyboardShortcutsDialogProps) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title="">
|
||||||
|
<div className="p-6 max-w-2xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between mb-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Keyboard className="h-6 w-6 text-primary" />
|
||||||
|
<h2 className="text-xl font-semibold">Keyboard Shortcuts</h2>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Shortcuts Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{SHORTCUTS.map((category) => (
|
||||||
|
<div key={category.name} className="space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold text-primary border-b border-border pb-2">
|
||||||
|
{category.name}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{category.shortcuts.map((shortcut, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center justify-between gap-4 py-1.5"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-foreground flex-1">
|
||||||
|
{shortcut.description}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
{shortcut.keys.map((key, keyIndex) => (
|
||||||
|
<React.Fragment key={keyIndex}>
|
||||||
|
{keyIndex > 0 && (
|
||||||
|
<span className="text-muted-foreground text-xs mx-0.5">+</span>
|
||||||
|
)}
|
||||||
|
<KeyboardKey keyName={key} />
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="mt-6 pt-4 border-t border-border">
|
||||||
|
<p className="text-xs text-muted-foreground text-center">
|
||||||
|
Press <KeyboardKey keyName="Ctrl" /> + <KeyboardKey keyName="K" /> to open the
|
||||||
|
command palette and search for more actions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Close Button */}
|
||||||
|
<div className="mt-6 flex justify-end">
|
||||||
|
<Button onClick={onClose} variant="default">
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
components/dialogs/MemoryWarningDialog.tsx
Normal file
101
components/dialogs/MemoryWarningDialog.tsx
Normal file
@@ -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 (
|
||||||
|
<Modal open={open} onClose={onCancel} title="">
|
||||||
|
<div className="p-6 max-w-md">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-yellow-500" />
|
||||||
|
<h2 className="text-lg font-semibold">Memory Warning</h2>
|
||||||
|
</div>
|
||||||
|
<button onClick={onCancel} className="text-muted-foreground hover:text-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
{warning}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* File Info */}
|
||||||
|
{fileName && (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Info className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="font-medium">File:</span>
|
||||||
|
<span className="text-muted-foreground truncate">{fileName}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Memory Details */}
|
||||||
|
<div className="bg-muted/50 border border-border rounded-md p-3 space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Estimated Memory:</span>
|
||||||
|
<span className="font-medium">{formatMemorySize(estimatedBytes)}</span>
|
||||||
|
</div>
|
||||||
|
{availableBytes && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Available Memory:</span>
|
||||||
|
<span className="font-medium">{formatMemorySize(availableBytes)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warning Message */}
|
||||||
|
<div className="bg-yellow-500/10 border border-yellow-500/20 rounded-md p-3">
|
||||||
|
<p className="text-sm text-yellow-700 dark:text-yellow-400">
|
||||||
|
<strong>Note:</strong> Loading large files may cause performance issues or browser crashes,
|
||||||
|
especially on devices with limited memory. Consider:
|
||||||
|
</p>
|
||||||
|
<ul className="mt-2 text-sm text-yellow-700 dark:text-yellow-400 space-y-1 list-disc list-inside">
|
||||||
|
<li>Closing other browser tabs</li>
|
||||||
|
<li>Using a shorter audio file</li>
|
||||||
|
<li>Splitting large files into smaller segments</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button onClick={onCancel} variant="outline">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onContinue} variant="default">
|
||||||
|
Continue Anyway
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -76,7 +76,7 @@ export function ProjectsDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Projects List */}
|
{/* Projects List */}
|
||||||
<div className="flex-1 overflow-y-auto p-6">
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-6">
|
||||||
{projects.length === 0 ? (
|
{projects.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<FolderOpen className="h-16 w-16 text-muted-foreground mb-4" />
|
<FolderOpen className="h-16 w-16 text-muted-foreground mb-4" />
|
||||||
|
|||||||
106
components/dialogs/UnsupportedFormatDialog.tsx
Normal file
106
components/dialogs/UnsupportedFormatDialog.tsx
Normal file
@@ -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 (
|
||||||
|
<Modal open={open} onClose={onClose} title="">
|
||||||
|
<div className="p-6 max-w-lg">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<FileQuestion className="h-6 w-6 text-yellow-500" />
|
||||||
|
<h2 className="text-lg font-semibold">Unsupported File Format</h2>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
<div className="bg-yellow-500/10 border border-yellow-500/20 rounded-md p-4 mb-4">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-400 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm text-yellow-800 dark:text-yellow-200 font-medium mb-1">
|
||||||
|
Cannot open this file
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-yellow-700 dark:text-yellow-300">
|
||||||
|
<strong>{fileName}</strong>
|
||||||
|
{fileType && (
|
||||||
|
<span className="text-muted-foreground"> ({fileType})</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Supported Formats */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-sm font-semibold mb-3">Supported Audio Formats:</h3>
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
{SUPPORTED_FORMATS.map((format) => (
|
||||||
|
<div
|
||||||
|
key={format.extension}
|
||||||
|
className="flex items-center justify-between gap-4 p-2 rounded bg-muted/30 border border-border/50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm font-mono font-semibold text-primary min-w-[80px]">
|
||||||
|
{format.extension}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{format.description}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recommendations */}
|
||||||
|
<div className="bg-muted/50 border border-border rounded-md p-4 mb-4">
|
||||||
|
<h4 className="text-sm font-semibold mb-2">How to fix this:</h4>
|
||||||
|
<ul className="text-sm text-muted-foreground space-y-2 list-disc list-inside">
|
||||||
|
<li>Convert your audio file to a supported format (WAV or MP3 recommended)</li>
|
||||||
|
<li>Use a free audio converter like Audacity, FFmpeg, or online converters</li>
|
||||||
|
<li>Check that the file isn't corrupted or incomplete</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Close Button */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={onClose} variant="default">
|
||||||
|
Got it
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Play, Pause, Square, SkipBack, Circle, AlignVerticalJustifyStart, AlignVerticalJustifyEnd, Layers } from 'lucide-react';
|
import { Play, Pause, Square, SkipBack, Circle, AlignVerticalJustifyStart, AlignVerticalJustifyEnd, Layers, Repeat } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
@@ -31,6 +31,13 @@ export interface PlaybackControlsProps {
|
|||||||
onPunchOutTimeChange?: (time: number) => void;
|
onPunchOutTimeChange?: (time: number) => void;
|
||||||
overdubEnabled?: boolean;
|
overdubEnabled?: boolean;
|
||||||
onOverdubEnabledChange?: (enabled: boolean) => void;
|
onOverdubEnabledChange?: (enabled: boolean) => void;
|
||||||
|
loopEnabled?: boolean;
|
||||||
|
loopStart?: number;
|
||||||
|
loopEnd?: number;
|
||||||
|
onToggleLoop?: () => void;
|
||||||
|
onSetLoopPoints?: (start: number, end: number) => void;
|
||||||
|
playbackRate?: number;
|
||||||
|
onPlaybackRateChange?: (rate: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlaybackControls({
|
export function PlaybackControls({
|
||||||
@@ -59,6 +66,13 @@ export function PlaybackControls({
|
|||||||
onPunchOutTimeChange,
|
onPunchOutTimeChange,
|
||||||
overdubEnabled = false,
|
overdubEnabled = false,
|
||||||
onOverdubEnabledChange,
|
onOverdubEnabledChange,
|
||||||
|
loopEnabled = false,
|
||||||
|
loopStart = 0,
|
||||||
|
loopEnd = 0,
|
||||||
|
onToggleLoop,
|
||||||
|
onSetLoopPoints,
|
||||||
|
playbackRate = 1.0,
|
||||||
|
onPlaybackRateChange,
|
||||||
}: PlaybackControlsProps) {
|
}: PlaybackControlsProps) {
|
||||||
const handlePlayPause = () => {
|
const handlePlayPause = () => {
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
@@ -249,8 +263,100 @@ export function PlaybackControls({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Loop Toggle */}
|
||||||
|
{onToggleLoop && (
|
||||||
|
<div className="flex items-center gap-1 border-l border-border pl-2 ml-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onToggleLoop}
|
||||||
|
title="Toggle Loop Playback"
|
||||||
|
className={cn(
|
||||||
|
loopEnabled && 'bg-primary/20 hover:bg-primary/30'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Repeat className={cn('h-3.5 w-3.5', loopEnabled && 'text-primary')} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Playback Speed Control */}
|
||||||
|
{onPlaybackRateChange && (
|
||||||
|
<div className="flex items-center gap-1 border-l border-border pl-2 ml-1">
|
||||||
|
<select
|
||||||
|
value={playbackRate}
|
||||||
|
onChange={(e) => onPlaybackRateChange(parseFloat(e.target.value))}
|
||||||
|
className="h-7 px-2 py-0 bg-background border border-border rounded text-xs cursor-pointer hover:bg-muted/50 focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
title="Playback Speed"
|
||||||
|
>
|
||||||
|
<option value={0.25}>0.25x</option>
|
||||||
|
<option value={0.5}>0.5x</option>
|
||||||
|
<option value={0.75}>0.75x</option>
|
||||||
|
<option value={1.0}>1x</option>
|
||||||
|
<option value={1.25}>1.25x</option>
|
||||||
|
<option value={1.5}>1.5x</option>
|
||||||
|
<option value={2.0}>2x</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Loop Points - Show when enabled */}
|
||||||
|
{loopEnabled && onSetLoopPoints && (
|
||||||
|
<div className="flex items-center gap-3 text-xs bg-muted/50 rounded px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2 flex-1">
|
||||||
|
<label className="text-muted-foreground flex items-center gap-1 flex-shrink-0">
|
||||||
|
<AlignVerticalJustifyStart className="h-3 w-3" />
|
||||||
|
Loop Start
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={loopEnd || duration}
|
||||||
|
step={0.1}
|
||||||
|
value={loopStart.toFixed(2)}
|
||||||
|
onChange={(e) => onSetLoopPoints(parseFloat(e.target.value), loopEnd)}
|
||||||
|
className="flex-1 px-2 py-1 bg-background border border-border rounded text-xs font-mono"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onSetLoopPoints(currentTime, loopEnd)}
|
||||||
|
title="Set loop start to current time"
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
>
|
||||||
|
Set
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-1">
|
||||||
|
<label className="text-muted-foreground flex items-center gap-1 flex-shrink-0">
|
||||||
|
<AlignVerticalJustifyEnd className="h-3 w-3" />
|
||||||
|
Loop End
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={loopStart}
|
||||||
|
max={duration}
|
||||||
|
step={0.1}
|
||||||
|
value={loopEnd.toFixed(2)}
|
||||||
|
onChange={(e) => onSetLoopPoints(loopStart, parseFloat(e.target.value))}
|
||||||
|
className="flex-1 px-2 py-1 bg-background border border-border rounded text-xs font-mono"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onSetLoopPoints(loopStart, currentTime)}
|
||||||
|
title="Set loop end to current time"
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
>
|
||||||
|
Set
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { generateMinMaxPeaks } from '@/lib/waveform/peaks';
|
import { useAudioWorker } from '@/lib/hooks/useAudioWorker';
|
||||||
import type { Selection } from '@/types/selection';
|
import type { Selection } from '@/types/selection';
|
||||||
|
|
||||||
export interface WaveformProps {
|
export interface WaveformProps {
|
||||||
@@ -39,6 +39,16 @@ export function Waveform({
|
|||||||
const [isSelecting, setIsSelecting] = React.useState(false);
|
const [isSelecting, setIsSelecting] = React.useState(false);
|
||||||
const [selectionStart, setSelectionStart] = React.useState<number | null>(null);
|
const [selectionStart, setSelectionStart] = React.useState<number | null>(null);
|
||||||
|
|
||||||
|
// Worker for peak generation
|
||||||
|
const worker = useAudioWorker();
|
||||||
|
|
||||||
|
// Cache peaks to avoid regenerating on every render
|
||||||
|
const [peaksCache, setPeaksCache] = React.useState<{
|
||||||
|
width: number;
|
||||||
|
min: Float32Array;
|
||||||
|
max: Float32Array;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
// Handle resize
|
// Handle resize
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
@@ -52,10 +62,35 @@ export function Waveform({
|
|||||||
return () => window.removeEventListener('resize', handleResize);
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Generate peaks in worker when audioBuffer or zoom changes
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!audioBuffer) {
|
||||||
|
setPeaksCache(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleWidth = Math.floor(width * zoom);
|
||||||
|
|
||||||
|
// Check if we already have peaks for this width
|
||||||
|
if (peaksCache && peaksCache.width === visibleWidth) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate peaks in worker
|
||||||
|
const channelData = audioBuffer.getChannelData(0);
|
||||||
|
worker.generateMinMaxPeaks(channelData, visibleWidth).then((peaks) => {
|
||||||
|
setPeaksCache({
|
||||||
|
width: visibleWidth,
|
||||||
|
min: peaks.min,
|
||||||
|
max: peaks.max,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [audioBuffer, width, zoom, worker, peaksCache]);
|
||||||
|
|
||||||
// Draw waveform
|
// Draw waveform
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas || !audioBuffer) return;
|
if (!canvas || !audioBuffer || !peaksCache) return;
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
@@ -75,8 +110,8 @@ export function Waveform({
|
|||||||
// Calculate visible width based on zoom
|
// Calculate visible width based on zoom
|
||||||
const visibleWidth = Math.floor(width * zoom);
|
const visibleWidth = Math.floor(width * zoom);
|
||||||
|
|
||||||
// Generate peaks for visible portion
|
// Use cached peaks
|
||||||
const { min, max } = generateMinMaxPeaks(audioBuffer, visibleWidth, 0);
|
const { min, max } = peaksCache;
|
||||||
|
|
||||||
// Draw waveform
|
// Draw waveform
|
||||||
const middle = height / 2;
|
const middle = height / 2;
|
||||||
@@ -176,7 +211,7 @@ export function Waveform({
|
|||||||
ctx.lineTo(progressX, height);
|
ctx.lineTo(progressX, height);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
}, [audioBuffer, width, height, currentTime, duration, zoom, scrollOffset, amplitudeScale, selection]);
|
}, [audioBuffer, width, height, currentTime, duration, zoom, scrollOffset, amplitudeScale, selection, peaksCache]);
|
||||||
|
|
||||||
const handleClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
const handleClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
if (!onSeek || !duration || isDragging) return;
|
if (!onSeek || !duration || isDragging) return;
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export function EffectBrowser({ open, onClose, onSelectEffect }: EffectBrowserPr
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-4">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{Object.entries(filteredCategories).map(([category, effects]) => (
|
{Object.entries(filteredCategories).map(([category, effects]) => (
|
||||||
<div key={category}>
|
<div key={category}>
|
||||||
|
|||||||
188
components/markers/MarkerDialog.tsx
Normal file
188
components/markers/MarkerDialog.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import type { Marker, MarkerType } from '@/types/marker';
|
||||||
|
|
||||||
|
export interface MarkerDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (marker: Partial<Marker>) => void;
|
||||||
|
marker?: Marker; // If editing existing marker
|
||||||
|
defaultTime?: number; // Default time for new markers
|
||||||
|
defaultType?: MarkerType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MARKER_COLORS = [
|
||||||
|
'#ef4444', // red
|
||||||
|
'#f97316', // orange
|
||||||
|
'#eab308', // yellow
|
||||||
|
'#22c55e', // green
|
||||||
|
'#3b82f6', // blue
|
||||||
|
'#a855f7', // purple
|
||||||
|
'#ec4899', // pink
|
||||||
|
];
|
||||||
|
|
||||||
|
export function MarkerDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
marker,
|
||||||
|
defaultTime = 0,
|
||||||
|
defaultType = 'point',
|
||||||
|
}: MarkerDialogProps) {
|
||||||
|
const [name, setName] = React.useState(marker?.name || '');
|
||||||
|
const [type, setType] = React.useState<MarkerType>(marker?.type || defaultType);
|
||||||
|
const [time, setTime] = React.useState(marker?.time || defaultTime);
|
||||||
|
const [endTime, setEndTime] = React.useState(marker?.endTime || defaultTime + 1);
|
||||||
|
const [color, setColor] = React.useState(marker?.color || MARKER_COLORS[0]);
|
||||||
|
const [description, setDescription] = React.useState(marker?.description || '');
|
||||||
|
|
||||||
|
// Reset form when marker changes or dialog opens
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setName(marker?.name || '');
|
||||||
|
setType(marker?.type || defaultType);
|
||||||
|
setTime(marker?.time || defaultTime);
|
||||||
|
setEndTime(marker?.endTime || defaultTime + 1);
|
||||||
|
setColor(marker?.color || MARKER_COLORS[0]);
|
||||||
|
setDescription(marker?.description || '');
|
||||||
|
}
|
||||||
|
}, [open, marker, defaultTime, defaultType]);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const markerData: Partial<Marker> = {
|
||||||
|
...(marker?.id && { id: marker.id }),
|
||||||
|
name: name || 'Untitled Marker',
|
||||||
|
type,
|
||||||
|
time,
|
||||||
|
...(type === 'region' && { endTime }),
|
||||||
|
color,
|
||||||
|
description,
|
||||||
|
};
|
||||||
|
onSave(markerData);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
title={marker ? 'Edit Marker' : 'Add Marker'}
|
||||||
|
description={marker ? 'Edit marker properties' : 'Add a new marker or region to the timeline'}
|
||||||
|
size="md"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>{marker ? 'Save' : 'Add'}</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Name */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="name" className="text-sm font-medium text-foreground">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="Marker name"
|
||||||
|
className="flex h-10 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="type" className="text-sm font-medium text-foreground">
|
||||||
|
Type
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="type"
|
||||||
|
value={type}
|
||||||
|
onChange={(e) => setType(e.target.value as MarkerType)}
|
||||||
|
className="flex h-10 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-background"
|
||||||
|
>
|
||||||
|
<option value="point">Point Marker</option>
|
||||||
|
<option value="region">Region</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="time" className="text-sm font-medium text-foreground">
|
||||||
|
{type === 'region' ? 'Start Time' : 'Time'} (seconds)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="time"
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min="0"
|
||||||
|
value={time}
|
||||||
|
onChange={(e) => setTime(parseFloat(e.target.value))}
|
||||||
|
className="flex h-10 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* End Time (for regions) */}
|
||||||
|
{type === 'region' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="endTime" className="text-sm font-medium text-foreground">
|
||||||
|
End Time (seconds)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="endTime"
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min={time}
|
||||||
|
value={endTime}
|
||||||
|
onChange={(e) => setEndTime(parseFloat(e.target.value))}
|
||||||
|
className="flex h-10 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Color */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-foreground">
|
||||||
|
Color
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{MARKER_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
className="w-8 h-8 rounded border-2 transition-all hover:scale-110"
|
||||||
|
style={{
|
||||||
|
backgroundColor: c,
|
||||||
|
borderColor: color === c ? 'white' : 'transparent',
|
||||||
|
}}
|
||||||
|
onClick={() => setColor(c)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="description" className="text-sm font-medium text-foreground">
|
||||||
|
Description (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="description"
|
||||||
|
type="text"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Optional description"
|
||||||
|
className="flex h-10 w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-background"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
216
components/markers/MarkerTimeline.tsx
Normal file
216
components/markers/MarkerTimeline.tsx
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import type { Marker } from '@/types/marker';
|
||||||
|
import { Flag, Edit2, Trash2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
|
||||||
|
export interface MarkerTimelineProps {
|
||||||
|
markers: Marker[];
|
||||||
|
duration: number;
|
||||||
|
currentTime: number;
|
||||||
|
onMarkerClick?: (marker: Marker) => void;
|
||||||
|
onMarkerEdit?: (marker: Marker) => void;
|
||||||
|
onMarkerDelete?: (markerId: string) => void;
|
||||||
|
onSeek?: (time: number) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarkerTimeline({
|
||||||
|
markers,
|
||||||
|
duration,
|
||||||
|
currentTime,
|
||||||
|
onMarkerClick,
|
||||||
|
onMarkerEdit,
|
||||||
|
onMarkerDelete,
|
||||||
|
onSeek,
|
||||||
|
className,
|
||||||
|
}: MarkerTimelineProps) {
|
||||||
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const [hoveredMarkerId, setHoveredMarkerId] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const timeToX = React.useCallback(
|
||||||
|
(time: number): number => {
|
||||||
|
if (!containerRef.current) return 0;
|
||||||
|
const width = containerRef.current.clientWidth;
|
||||||
|
return (time / duration) * width;
|
||||||
|
},
|
||||||
|
[duration]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={cn(
|
||||||
|
'relative w-full h-8 bg-muted/30 border-b border-border',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Markers */}
|
||||||
|
{markers.map((marker) => {
|
||||||
|
const x = timeToX(marker.time);
|
||||||
|
const isHovered = hoveredMarkerId === marker.id;
|
||||||
|
|
||||||
|
if (marker.type === 'point') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={marker.id}
|
||||||
|
className="absolute top-0 bottom-0 group cursor-pointer"
|
||||||
|
style={{ left: `${x}px` }}
|
||||||
|
onMouseEnter={() => setHoveredMarkerId(marker.id)}
|
||||||
|
onMouseLeave={() => setHoveredMarkerId(null)}
|
||||||
|
onClick={() => {
|
||||||
|
onMarkerClick?.(marker);
|
||||||
|
onSeek?.(marker.time);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Marker line */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute top-0 bottom-0 w-0.5 transition-colors',
|
||||||
|
isHovered ? 'bg-primary' : 'bg-primary/60'
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: marker.color }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Marker flag */}
|
||||||
|
<Flag
|
||||||
|
className={cn(
|
||||||
|
'absolute top-0.5 -left-2 h-4 w-4 transition-colors',
|
||||||
|
isHovered ? 'text-primary' : 'text-primary/60'
|
||||||
|
)}
|
||||||
|
style={{ color: marker.color }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Hover tooltip with actions */}
|
||||||
|
{isHovered && (
|
||||||
|
<div className="absolute top-full left-0 mt-1 z-10 bg-popover border border-border rounded shadow-lg p-2 min-w-[200px]">
|
||||||
|
<div className="text-xs font-medium mb-1">{marker.name}</div>
|
||||||
|
{marker.description && (
|
||||||
|
<div className="text-xs text-muted-foreground mb-2">{marker.description}</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{onMarkerEdit && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onMarkerEdit(marker);
|
||||||
|
}}
|
||||||
|
title="Edit marker"
|
||||||
|
className="h-6 w-6"
|
||||||
|
>
|
||||||
|
<Edit2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onMarkerDelete && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onMarkerDelete(marker.id);
|
||||||
|
}}
|
||||||
|
title="Delete marker"
|
||||||
|
className="h-6 w-6 text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Region marker
|
||||||
|
const endX = timeToX(marker.endTime || marker.time);
|
||||||
|
const width = endX - x;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={marker.id}
|
||||||
|
className="absolute top-0 bottom-0 group cursor-pointer"
|
||||||
|
style={{ left: `${x}px`, width: `${width}px` }}
|
||||||
|
onMouseEnter={() => setHoveredMarkerId(marker.id)}
|
||||||
|
onMouseLeave={() => setHoveredMarkerId(null)}
|
||||||
|
onClick={() => {
|
||||||
|
onMarkerClick?.(marker);
|
||||||
|
onSeek?.(marker.time);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Region background */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute inset-0 transition-opacity',
|
||||||
|
isHovered ? 'opacity-30' : 'opacity-20'
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: marker.color || 'var(--color-primary)' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Region borders */}
|
||||||
|
<div
|
||||||
|
className="absolute top-0 bottom-0 left-0 w-0.5"
|
||||||
|
style={{ backgroundColor: marker.color || 'var(--color-primary)' }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute top-0 bottom-0 right-0 w-0.5"
|
||||||
|
style={{ backgroundColor: marker.color || 'var(--color-primary)' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Region label */}
|
||||||
|
<div
|
||||||
|
className="absolute top-0.5 left-1 text-[10px] font-medium truncate pr-1"
|
||||||
|
style={{ color: marker.color || 'var(--color-primary)', maxWidth: `${width - 8}px` }}
|
||||||
|
>
|
||||||
|
{marker.name}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hover tooltip with actions */}
|
||||||
|
{isHovered && (
|
||||||
|
<div className="absolute top-full left-0 mt-1 z-10 bg-popover border border-border rounded shadow-lg p-2 min-w-[200px]">
|
||||||
|
<div className="text-xs font-medium mb-1">{marker.name}</div>
|
||||||
|
{marker.description && (
|
||||||
|
<div className="text-xs text-muted-foreground mb-2">{marker.description}</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{onMarkerEdit && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onMarkerEdit(marker);
|
||||||
|
}}
|
||||||
|
title="Edit marker"
|
||||||
|
className="h-6 w-6"
|
||||||
|
>
|
||||||
|
<Edit2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onMarkerDelete && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onMarkerDelete(marker.id);
|
||||||
|
}}
|
||||||
|
title="Delete marker"
|
||||||
|
className="h-6 w-6 text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -400,29 +400,6 @@ export function GlobalSettingsDialog({
|
|||||||
Adjust the UI font size. Requires reload.
|
Adjust the UI font size. Requires reload.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Default Track Height */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-sm font-medium">Default Track Height</label>
|
|
||||||
<span className="text-xs font-mono text-muted-foreground">
|
|
||||||
{settings.ui.defaultTrackHeight}px
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Slider
|
|
||||||
value={[settings.ui.defaultTrackHeight]}
|
|
||||||
onValueChange={([value]) =>
|
|
||||||
onUISettingsChange({ defaultTrackHeight: value })
|
|
||||||
}
|
|
||||||
min={120}
|
|
||||||
max={600}
|
|
||||||
step={20}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Initial height for new tracks. Default: 400px.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
265
components/timeline/TimeScale.tsx
Normal file
265
components/timeline/TimeScale.tsx
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import {
|
||||||
|
timeToPixel,
|
||||||
|
pixelToTime,
|
||||||
|
calculateTickInterval,
|
||||||
|
formatTimeLabel,
|
||||||
|
getVisibleTimeRange,
|
||||||
|
} from '@/lib/utils/timeline';
|
||||||
|
|
||||||
|
export interface TimeScaleProps {
|
||||||
|
duration: number;
|
||||||
|
zoom: number;
|
||||||
|
currentTime: number;
|
||||||
|
onSeek?: (time: number) => void;
|
||||||
|
className?: string;
|
||||||
|
height?: number;
|
||||||
|
controlsWidth?: number;
|
||||||
|
scrollRef?: React.MutableRefObject<HTMLDivElement | null>;
|
||||||
|
onScroll?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimeScale({
|
||||||
|
duration,
|
||||||
|
zoom,
|
||||||
|
currentTime,
|
||||||
|
onSeek,
|
||||||
|
className,
|
||||||
|
height = 40,
|
||||||
|
controlsWidth = 240,
|
||||||
|
scrollRef: externalScrollRef,
|
||||||
|
onScroll,
|
||||||
|
}: TimeScaleProps) {
|
||||||
|
const localScrollRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const scrollRef = externalScrollRef || localScrollRef;
|
||||||
|
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||||
|
const [viewportWidth, setViewportWidth] = React.useState(800);
|
||||||
|
const [scrollLeft, setScrollLeft] = React.useState(0);
|
||||||
|
const [hoverTime, setHoverTime] = React.useState<number | null>(null);
|
||||||
|
|
||||||
|
// Calculate total timeline width (match waveform calculation)
|
||||||
|
// Uses 5 pixels per second as base scale, multiplied by zoom
|
||||||
|
// Always ensure minimum width is at least viewport width for full coverage
|
||||||
|
const PIXELS_PER_SECOND_BASE = 5;
|
||||||
|
const totalWidth = React.useMemo(() => {
|
||||||
|
if (zoom >= 1) {
|
||||||
|
const calculatedWidth = duration * zoom * PIXELS_PER_SECOND_BASE;
|
||||||
|
// Ensure it's at least viewport width so timeline always fills
|
||||||
|
return Math.max(calculatedWidth, viewportWidth);
|
||||||
|
}
|
||||||
|
return viewportWidth;
|
||||||
|
}, [duration, zoom, viewportWidth]);
|
||||||
|
|
||||||
|
// Update viewport width on resize
|
||||||
|
React.useEffect(() => {
|
||||||
|
const scroller = scrollRef.current;
|
||||||
|
if (!scroller) return;
|
||||||
|
|
||||||
|
const updateWidth = () => {
|
||||||
|
setViewportWidth(scroller.clientWidth);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateWidth();
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(updateWidth);
|
||||||
|
resizeObserver.observe(scroller);
|
||||||
|
|
||||||
|
return () => resizeObserver.disconnect();
|
||||||
|
}, [scrollRef]);
|
||||||
|
|
||||||
|
// Handle scroll - update scrollLeft and trigger onScroll callback
|
||||||
|
const handleScroll = React.useCallback(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
setScrollLeft(scrollRef.current.scrollLeft);
|
||||||
|
}
|
||||||
|
if (onScroll) {
|
||||||
|
onScroll();
|
||||||
|
}
|
||||||
|
}, [onScroll, scrollRef]);
|
||||||
|
|
||||||
|
// Draw time scale - redraws on scroll and zoom
|
||||||
|
React.useEffect(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
if (!canvas || duration === 0) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
// Set canvas size to viewport width
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
canvas.width = viewportWidth * dpr;
|
||||||
|
canvas.height = height * dpr;
|
||||||
|
canvas.style.width = `${viewportWidth}px`;
|
||||||
|
canvas.style.height = `${height}px`;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
// Clear canvas
|
||||||
|
ctx.fillStyle = getComputedStyle(canvas).getPropertyValue('--color-background') || '#ffffff';
|
||||||
|
ctx.fillRect(0, 0, viewportWidth, height);
|
||||||
|
|
||||||
|
// Calculate visible time range
|
||||||
|
const visibleRange = getVisibleTimeRange(scrollLeft, viewportWidth, duration, zoom);
|
||||||
|
const visibleDuration = visibleRange.end - visibleRange.start;
|
||||||
|
|
||||||
|
// Calculate tick intervals based on visible duration
|
||||||
|
const { major, minor } = calculateTickInterval(visibleDuration);
|
||||||
|
|
||||||
|
// Calculate which ticks to draw (only visible ones)
|
||||||
|
const startTick = Math.floor(visibleRange.start / minor) * minor;
|
||||||
|
const endTick = Math.ceil(visibleRange.end / minor) * minor;
|
||||||
|
|
||||||
|
// Set up text style for labels
|
||||||
|
ctx.font = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
|
||||||
|
// Draw ticks and labels
|
||||||
|
for (let time = startTick; time <= endTick; time += minor) {
|
||||||
|
if (time < 0 || time > duration) continue;
|
||||||
|
|
||||||
|
// Calculate x position using the actual totalWidth (not timeToPixel which recalculates)
|
||||||
|
const x = (time / duration) * totalWidth - scrollLeft;
|
||||||
|
if (x < 0 || x > viewportWidth) continue;
|
||||||
|
|
||||||
|
const isMajor = Math.abs(time % major) < 0.001;
|
||||||
|
|
||||||
|
if (isMajor) {
|
||||||
|
// Major ticks - tall and prominent
|
||||||
|
ctx.strokeStyle = getComputedStyle(canvas).getPropertyValue('--color-foreground') || '#000000';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, height - 20);
|
||||||
|
ctx.lineTo(x, height);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Major tick label
|
||||||
|
ctx.fillStyle = getComputedStyle(canvas).getPropertyValue('--color-foreground') || '#000000';
|
||||||
|
const label = formatTimeLabel(time, visibleDuration < 10);
|
||||||
|
ctx.fillText(label, x, 6);
|
||||||
|
} else {
|
||||||
|
// Minor ticks - shorter and lighter
|
||||||
|
ctx.strokeStyle = getComputedStyle(canvas).getPropertyValue('--color-muted-foreground') || '#9ca3af';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, height - 10);
|
||||||
|
ctx.lineTo(x, height);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Minor tick label (smaller and lighter)
|
||||||
|
if (x > 20 && x < viewportWidth - 20) {
|
||||||
|
ctx.fillStyle = getComputedStyle(canvas).getPropertyValue('--color-muted-foreground') || '#9ca3af';
|
||||||
|
ctx.font = '10px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
||||||
|
const label = formatTimeLabel(time, visibleDuration < 10);
|
||||||
|
ctx.fillText(label, x, 8);
|
||||||
|
ctx.font = '12px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw playhead indicator
|
||||||
|
const playheadX = (currentTime / duration) * totalWidth - scrollLeft;
|
||||||
|
if (playheadX >= 0 && playheadX <= viewportWidth) {
|
||||||
|
ctx.strokeStyle = '#ef4444';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(playheadX, 0);
|
||||||
|
ctx.lineTo(playheadX, height);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw hover indicator
|
||||||
|
if (hoverTime !== null) {
|
||||||
|
const hoverX = (hoverTime / duration) * totalWidth - scrollLeft;
|
||||||
|
if (hoverX >= 0 && hoverX <= viewportWidth) {
|
||||||
|
ctx.strokeStyle = 'rgba(59, 130, 246, 0.5)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.setLineDash([3, 3]);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(hoverX, 0);
|
||||||
|
ctx.lineTo(hoverX, height);
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [duration, zoom, currentTime, viewportWidth, scrollLeft, height, hoverTime, totalWidth]);
|
||||||
|
|
||||||
|
// Handle click to seek
|
||||||
|
const handleClick = React.useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
if (!onSeek) return;
|
||||||
|
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const pixelPos = x + scrollLeft;
|
||||||
|
const time = (pixelPos / totalWidth) * duration;
|
||||||
|
|
||||||
|
onSeek(Math.max(0, Math.min(duration, time)));
|
||||||
|
},
|
||||||
|
[onSeek, duration, totalWidth, scrollLeft]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle mouse move for hover
|
||||||
|
const handleMouseMove = React.useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const pixelPos = x + scrollLeft;
|
||||||
|
const time = (pixelPos / totalWidth) * duration;
|
||||||
|
|
||||||
|
setHoverTime(Math.max(0, Math.min(duration, time)));
|
||||||
|
},
|
||||||
|
[duration, totalWidth, scrollLeft]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMouseLeave = React.useCallback(() => {
|
||||||
|
setHoverTime(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('relative bg-background', className)} style={{ paddingLeft: '240px', paddingRight: '250px' }}>
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="w-full bg-background overflow-x-auto overflow-y-hidden custom-scrollbar"
|
||||||
|
style={{
|
||||||
|
height: `${height}px`,
|
||||||
|
}}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
>
|
||||||
|
{/* Spacer to create scrollable width */}
|
||||||
|
<div style={{ width: `${totalWidth}px`, height: `${height}px`, position: 'relative' }}>
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
onClick={handleClick}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
className="cursor-pointer"
|
||||||
|
style={{
|
||||||
|
position: 'sticky',
|
||||||
|
left: 0,
|
||||||
|
width: `${viewportWidth}px`,
|
||||||
|
height: `${height}px`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hover tooltip */}
|
||||||
|
{hoverTime !== null && (
|
||||||
|
<div
|
||||||
|
className="absolute top-full mt-1 px-2 py-1 bg-popover border border-border rounded shadow-lg text-xs font-mono pointer-events-none z-10"
|
||||||
|
style={{
|
||||||
|
left: `${Math.min(
|
||||||
|
viewportWidth - 60 + controlsWidth,
|
||||||
|
Math.max(controlsWidth, (hoverTime / duration) * totalWidth - scrollLeft - 30 + controlsWidth)
|
||||||
|
)}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatTimeLabel(hoverTime, true)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
COLLAPSED_TRACK_HEIGHT,
|
COLLAPSED_TRACK_HEIGHT,
|
||||||
MIN_TRACK_HEIGHT,
|
MIN_TRACK_HEIGHT,
|
||||||
MAX_TRACK_HEIGHT,
|
MAX_TRACK_HEIGHT,
|
||||||
|
DEFAULT_TRACK_HEIGHT,
|
||||||
} from "@/types/track";
|
} from "@/types/track";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { Slider } from "@/components/ui/Slider";
|
import { Slider } from "@/components/ui/Slider";
|
||||||
@@ -328,14 +329,29 @@ export function Track({
|
|||||||
|
|
||||||
const buffer = track.audioBuffer;
|
const buffer = track.audioBuffer;
|
||||||
const channelData = buffer.getChannelData(0);
|
const channelData = buffer.getChannelData(0);
|
||||||
const samplesPerPixel = Math.floor(buffer.length / (width * zoom));
|
// Calculate samples per pixel based on the total width
|
||||||
|
// Must match the timeline calculation exactly
|
||||||
|
const PIXELS_PER_SECOND_BASE = 5;
|
||||||
|
let totalWidth;
|
||||||
|
if (zoom >= 1) {
|
||||||
|
const calculatedWidth = duration * zoom * PIXELS_PER_SECOND_BASE;
|
||||||
|
totalWidth = Math.max(calculatedWidth, width);
|
||||||
|
} else {
|
||||||
|
totalWidth = width;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate how much of the canvas width this track's duration occupies
|
||||||
|
// If duration is 0 or invalid, use full width (first track scenario)
|
||||||
|
const trackDurationRatio = duration > 0 ? buffer.duration / duration : 1;
|
||||||
|
const trackWidth = Math.min(width * trackDurationRatio, width);
|
||||||
|
const samplesPerPixel = trackWidth > 0 ? buffer.length / trackWidth : 0;
|
||||||
|
|
||||||
// Draw waveform
|
// Draw waveform
|
||||||
ctx.fillStyle = track.color;
|
ctx.fillStyle = track.color;
|
||||||
ctx.strokeStyle = track.color;
|
ctx.strokeStyle = track.color;
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
|
|
||||||
for (let x = 0; x < width; x++) {
|
for (let x = 0; x < Math.floor(trackWidth); x++) {
|
||||||
const startSample = Math.floor(x * samplesPerPixel);
|
const startSample = Math.floor(x * samplesPerPixel);
|
||||||
const endSample = Math.floor((x + 1) * samplesPerPixel);
|
const endSample = Math.floor((x + 1) * samplesPerPixel);
|
||||||
|
|
||||||
@@ -606,7 +622,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
|
// Track height resize handlers
|
||||||
const handleResizeStart = React.useCallback(
|
const handleResizeStart = React.useCallback(
|
||||||
@@ -656,7 +674,9 @@ export function Track({
|
|||||||
? "bg-primary/10 border-r-primary"
|
? "bg-primary/10 border-r-primary"
|
||||||
: "bg-card border-r-transparent hover:bg-accent/30",
|
: "bg-card border-r-transparent hover:bg-accent/30",
|
||||||
)}
|
)}
|
||||||
style={{ height: trackHeight }}
|
style={{
|
||||||
|
height: `${trackHeight}px`,
|
||||||
|
}}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (onSelect) onSelect();
|
if (onSelect) onSelect();
|
||||||
@@ -768,8 +788,8 @@ export function Track({
|
|||||||
className="relative h-full"
|
className="relative h-full"
|
||||||
style={{
|
style={{
|
||||||
minWidth:
|
minWidth:
|
||||||
track.audioBuffer && zoom > 1
|
track.audioBuffer && zoom >= 1
|
||||||
? `${duration * zoom * 100}px`
|
? `${duration * zoom * 5}px`
|
||||||
: "100%",
|
: "100%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
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 { CircularKnob } from '@/components/ui/CircularKnob';
|
||||||
import { TrackFader } from './TrackFader';
|
import { TrackFader } from './TrackFader';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
@@ -20,6 +20,7 @@ export interface TrackControlsProps {
|
|||||||
showAutomation?: boolean;
|
showAutomation?: boolean;
|
||||||
showEffects?: boolean;
|
showEffects?: boolean;
|
||||||
isRecording?: boolean;
|
isRecording?: boolean;
|
||||||
|
mobileCollapsed?: boolean; // For mobile view collapsible controls
|
||||||
onNameChange: (name: string) => void;
|
onNameChange: (name: string) => void;
|
||||||
onToggleCollapse: () => void;
|
onToggleCollapse: () => void;
|
||||||
onVolumeChange: (volume: number) => void;
|
onVolumeChange: (volume: number) => void;
|
||||||
@@ -33,6 +34,7 @@ export interface TrackControlsProps {
|
|||||||
onVolumeTouchEnd?: () => void;
|
onVolumeTouchEnd?: () => void;
|
||||||
onPanTouchStart?: () => void;
|
onPanTouchStart?: () => void;
|
||||||
onPanTouchEnd?: () => void;
|
onPanTouchEnd?: () => void;
|
||||||
|
onToggleMobileCollapse?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +52,7 @@ export function TrackControls({
|
|||||||
showAutomation = false,
|
showAutomation = false,
|
||||||
showEffects = false,
|
showEffects = false,
|
||||||
isRecording = false,
|
isRecording = false,
|
||||||
|
mobileCollapsed = false,
|
||||||
onNameChange,
|
onNameChange,
|
||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
onVolumeChange,
|
onVolumeChange,
|
||||||
@@ -63,6 +66,7 @@ export function TrackControls({
|
|||||||
onVolumeTouchEnd,
|
onVolumeTouchEnd,
|
||||||
onPanTouchStart,
|
onPanTouchStart,
|
||||||
onPanTouchEnd,
|
onPanTouchEnd,
|
||||||
|
onToggleMobileCollapse,
|
||||||
className,
|
className,
|
||||||
}: TrackControlsProps) {
|
}: TrackControlsProps) {
|
||||||
const [isEditingName, setIsEditingName] = React.useState(false);
|
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 (
|
||||||
|
<div className={cn(
|
||||||
|
'flex flex-col items-center gap-2 px-3 py-2 bg-card/50 border border-accent/50 rounded-lg w-full sm:hidden',
|
||||||
|
className
|
||||||
|
)}>
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<div className="flex items-center gap-1 flex-1">
|
||||||
|
<button
|
||||||
|
onClick={onToggleCollapse}
|
||||||
|
className="p-0.5 hover:bg-accent/20 rounded transition-colors flex-shrink-0"
|
||||||
|
title={collapsed ? 'Expand track' : 'Collapse track'}
|
||||||
|
>
|
||||||
|
{collapsed ? (
|
||||||
|
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className="text-xs font-bold uppercase tracking-wider"
|
||||||
|
style={{ color: trackColor }}
|
||||||
|
>
|
||||||
|
{trackName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{onToggleMobileCollapse && (
|
||||||
|
<button
|
||||||
|
onClick={onToggleMobileCollapse}
|
||||||
|
className="p-1 hover:bg-accent/20 rounded transition-colors"
|
||||||
|
title="Expand track controls"
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 w-full justify-center">
|
||||||
|
{onRecordToggle && (
|
||||||
|
<button
|
||||||
|
onClick={onRecordToggle}
|
||||||
|
className={cn(
|
||||||
|
'h-7 w-7 rounded-full flex items-center justify-center transition-all',
|
||||||
|
isRecordEnabled
|
||||||
|
? isRecording
|
||||||
|
? 'bg-red-500 shadow-lg shadow-red-500/50 animate-pulse'
|
||||||
|
: 'bg-red-500 shadow-md shadow-red-500/30'
|
||||||
|
: 'bg-card hover:bg-accent border border-border/50'
|
||||||
|
)}
|
||||||
|
title={isRecordEnabled ? 'Record Armed' : 'Arm for Recording'}
|
||||||
|
>
|
||||||
|
<Circle className={cn('h-3.5 w-3.5', isRecordEnabled ? 'fill-white text-white' : 'text-muted-foreground')} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onMuteToggle}
|
||||||
|
className={cn(
|
||||||
|
'h-7 w-7 rounded-md flex items-center justify-center transition-all text-xs font-bold',
|
||||||
|
isMuted
|
||||||
|
? 'bg-blue-500 text-white shadow-md shadow-blue-500/30'
|
||||||
|
: 'bg-card hover:bg-accent text-muted-foreground border border-border/50'
|
||||||
|
)}
|
||||||
|
title={isMuted ? 'Unmute' : 'Mute'}
|
||||||
|
>
|
||||||
|
M
|
||||||
|
</button>
|
||||||
|
{onSoloToggle && (
|
||||||
|
<button
|
||||||
|
onClick={onSoloToggle}
|
||||||
|
className={cn(
|
||||||
|
'h-7 w-7 rounded-md flex items-center justify-center transition-all text-xs font-bold',
|
||||||
|
isSolo
|
||||||
|
? 'bg-yellow-500 text-white shadow-md shadow-yellow-500/30'
|
||||||
|
: 'bg-card hover:bg-accent text-muted-foreground border border-border/50'
|
||||||
|
)}
|
||||||
|
title={isSolo ? 'Unsolo' : 'Solo'}
|
||||||
|
>
|
||||||
|
S
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-full transition-all',
|
||||||
|
peakLevel > 0.95 ? 'bg-red-500' : peakLevel > 0.8 ? 'bg-yellow-500' : 'bg-green-500'
|
||||||
|
)}
|
||||||
|
style={{ width: `${peakLevel * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mobile expanded view - full controls (like master controls)
|
||||||
|
const mobileExpandedView = (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'flex flex-col items-center gap-3 px-4 py-3 bg-card/50 border-2 border-accent/50 rounded-lg',
|
'flex flex-col items-center gap-3 px-3 py-3 bg-card/50 border border-accent/50 rounded-lg w-full sm:hidden',
|
||||||
className
|
className
|
||||||
)}>
|
)}>
|
||||||
|
{/* Header with collapse button */}
|
||||||
|
<div className="flex items-center justify-between w-full">
|
||||||
|
<button
|
||||||
|
onClick={onToggleCollapse}
|
||||||
|
className="p-0.5 hover:bg-accent/20 rounded transition-colors flex-shrink-0"
|
||||||
|
title={collapsed ? 'Expand track' : 'Collapse track'}
|
||||||
|
>
|
||||||
|
{collapsed ? (
|
||||||
|
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className="text-xs font-bold uppercase tracking-wider flex-1 text-center"
|
||||||
|
style={{ color: trackColor }}
|
||||||
|
>
|
||||||
|
{trackName}
|
||||||
|
</div>
|
||||||
|
{onToggleMobileCollapse && (
|
||||||
|
<button
|
||||||
|
onClick={onToggleMobileCollapse}
|
||||||
|
className="p-0.5 hover:bg-accent/20 rounded transition-colors flex-shrink-0"
|
||||||
|
title="Collapse track controls"
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-3 w-3 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pan Control */}
|
||||||
|
<CircularKnob
|
||||||
|
value={pan}
|
||||||
|
onChange={onPanChange}
|
||||||
|
onTouchStart={onPanTouchStart}
|
||||||
|
onTouchEnd={onPanTouchEnd}
|
||||||
|
min={-1}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
label="PAN"
|
||||||
|
size={48}
|
||||||
|
formatValue={(value: number) => {
|
||||||
|
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 */}
|
||||||
|
<div className="flex-1 flex justify-center items-center w-full min-h-[160px]">
|
||||||
|
<TrackFader
|
||||||
|
value={volume}
|
||||||
|
peakLevel={peakLevel}
|
||||||
|
rmsLevel={rmsLevel}
|
||||||
|
onChange={onVolumeChange}
|
||||||
|
onTouchStart={onVolumeTouchStart}
|
||||||
|
onTouchEnd={onVolumeTouchEnd}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Control buttons */}
|
||||||
|
<div className="flex items-center gap-1 w-full justify-center">
|
||||||
|
{onRecordToggle && (
|
||||||
|
<button
|
||||||
|
onClick={onRecordToggle}
|
||||||
|
className={cn(
|
||||||
|
'h-8 w-8 rounded-full flex items-center justify-center transition-all',
|
||||||
|
isRecordEnabled
|
||||||
|
? isRecording
|
||||||
|
? 'bg-red-500 shadow-lg shadow-red-500/50 animate-pulse'
|
||||||
|
: 'bg-red-500 shadow-md shadow-red-500/30'
|
||||||
|
: 'bg-card hover:bg-accent border border-border/50'
|
||||||
|
)}
|
||||||
|
title={isRecordEnabled ? 'Record Armed' : 'Arm for Recording'}
|
||||||
|
>
|
||||||
|
<Circle className={cn('h-3.5 w-3.5', isRecordEnabled ? 'fill-white text-white' : 'text-muted-foreground')} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onMuteToggle}
|
||||||
|
className={cn(
|
||||||
|
'h-8 w-8 rounded-md flex items-center justify-center transition-all text-xs font-bold',
|
||||||
|
isMuted
|
||||||
|
? 'bg-blue-500 text-white shadow-md shadow-blue-500/30'
|
||||||
|
: 'bg-card hover:bg-accent text-muted-foreground border border-border/50'
|
||||||
|
)}
|
||||||
|
title={isMuted ? 'Unmute' : 'Mute'}
|
||||||
|
>
|
||||||
|
M
|
||||||
|
</button>
|
||||||
|
{onSoloToggle && (
|
||||||
|
<button
|
||||||
|
onClick={onSoloToggle}
|
||||||
|
className={cn(
|
||||||
|
'h-8 w-8 rounded-md flex items-center justify-center transition-all text-xs font-bold',
|
||||||
|
isSolo
|
||||||
|
? 'bg-yellow-500 text-white shadow-md shadow-yellow-500/30'
|
||||||
|
: 'bg-card hover:bg-accent text-muted-foreground border border-border/50'
|
||||||
|
)}
|
||||||
|
title={isSolo ? 'Unsolo' : 'Solo'}
|
||||||
|
>
|
||||||
|
S
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onEffectsClick && (
|
||||||
|
<button
|
||||||
|
onClick={onEffectsClick}
|
||||||
|
className={cn(
|
||||||
|
'h-8 w-8 rounded-md flex items-center justify-center transition-all text-xs font-bold',
|
||||||
|
showEffects
|
||||||
|
? 'bg-purple-500 text-white shadow-md shadow-purple-500/30'
|
||||||
|
: 'bg-card hover:bg-accent text-muted-foreground border border-border/50'
|
||||||
|
)}
|
||||||
|
title="Effects"
|
||||||
|
>
|
||||||
|
FX
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Mobile view - Show expanded or collapsed */}
|
||||||
|
{!mobileCollapsed && mobileExpandedView}
|
||||||
|
|
||||||
|
{/* Desktop/tablet view - hidden on mobile */}
|
||||||
|
<div className={cn(
|
||||||
|
'flex flex-col items-center gap-3 px-4 py-3 bg-card/50 border border-accent/50 rounded-lg hidden sm:flex',
|
||||||
|
className
|
||||||
|
)}>
|
||||||
{/* Track Name Header with Collapse Chevron */}
|
{/* Track Name Header with Collapse Chevron */}
|
||||||
<div className="flex items-center gap-1 w-full">
|
<div className="flex items-center gap-1 w-full">
|
||||||
<button
|
<button
|
||||||
@@ -220,6 +451,7 @@ export function TrackControls({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ export interface TrackListProps {
|
|||||||
trackLevels?: Record<string, number>;
|
trackLevels?: Record<string, number>;
|
||||||
onParameterTouched?: (trackId: string, laneId: string, touched: boolean) => void;
|
onParameterTouched?: (trackId: string, laneId: string, touched: boolean) => void;
|
||||||
isPlaying?: boolean;
|
isPlaying?: boolean;
|
||||||
|
timeScaleScrollRef?: React.MutableRefObject<HTMLDivElement | null>;
|
||||||
|
onTimeScaleScroll?: () => void;
|
||||||
|
timeScaleScrollHandlerRef?: React.MutableRefObject<(() => void) | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TrackList({
|
export function TrackList({
|
||||||
@@ -57,6 +60,9 @@ export function TrackList({
|
|||||||
trackLevels = {},
|
trackLevels = {},
|
||||||
onParameterTouched,
|
onParameterTouched,
|
||||||
isPlaying = false,
|
isPlaying = false,
|
||||||
|
timeScaleScrollRef: externalTimeScaleScrollRef,
|
||||||
|
onTimeScaleScroll,
|
||||||
|
timeScaleScrollHandlerRef,
|
||||||
}: TrackListProps) {
|
}: TrackListProps) {
|
||||||
const [importDialogOpen, setImportDialogOpen] = React.useState(false);
|
const [importDialogOpen, setImportDialogOpen] = React.useState(false);
|
||||||
const [effectBrowserTrackId, setEffectBrowserTrackId] = React.useState<string | null>(null);
|
const [effectBrowserTrackId, setEffectBrowserTrackId] = React.useState<string | null>(null);
|
||||||
@@ -66,6 +72,8 @@ export function TrackList({
|
|||||||
// Refs for horizontal scroll synchronization (per track)
|
// Refs for horizontal scroll synchronization (per track)
|
||||||
const waveformHScrollRefs = React.useRef<Map<string, HTMLDivElement>>(new Map());
|
const waveformHScrollRefs = React.useRef<Map<string, HTMLDivElement>>(new Map());
|
||||||
const automationHScrollRefs = React.useRef<Map<string, HTMLDivElement>>(new Map());
|
const automationHScrollRefs = React.useRef<Map<string, HTMLDivElement>>(new Map());
|
||||||
|
const localTimeScaleScrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
const timeScaleScrollRef = externalTimeScaleScrollRef || localTimeScaleScrollRef;
|
||||||
const [syncingScroll, setSyncingScroll] = React.useState(false);
|
const [syncingScroll, setSyncingScroll] = React.useState(false);
|
||||||
|
|
||||||
// Synchronize vertical scroll between controls and waveforms
|
// Synchronize vertical scroll between controls and waveforms
|
||||||
@@ -100,6 +108,11 @@ export function TrackList({
|
|||||||
el.scrollLeft = scrollLeft;
|
el.scrollLeft = scrollLeft;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sync time scale
|
||||||
|
if (timeScaleScrollRef.current) {
|
||||||
|
timeScaleScrollRef.current.scrollLeft = scrollLeft;
|
||||||
|
}
|
||||||
|
|
||||||
setSyncingScroll(false);
|
setSyncingScroll(false);
|
||||||
}, [syncingScroll]);
|
}, [syncingScroll]);
|
||||||
|
|
||||||
@@ -127,9 +140,45 @@ export function TrackList({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sync time scale
|
||||||
|
if (timeScaleScrollRef.current) {
|
||||||
|
timeScaleScrollRef.current.scrollLeft = scrollLeft;
|
||||||
|
}
|
||||||
|
|
||||||
setSyncingScroll(false);
|
setSyncingScroll(false);
|
||||||
}, [syncingScroll]);
|
}, [syncingScroll]);
|
||||||
|
|
||||||
|
const handleTimeScaleScrollInternal = React.useCallback(() => {
|
||||||
|
if (syncingScroll) return;
|
||||||
|
setSyncingScroll(true);
|
||||||
|
|
||||||
|
if (!timeScaleScrollRef.current) {
|
||||||
|
setSyncingScroll(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollLeft = timeScaleScrollRef.current.scrollLeft;
|
||||||
|
|
||||||
|
// Sync all waveforms
|
||||||
|
waveformHScrollRefs.current.forEach((el) => {
|
||||||
|
el.scrollLeft = scrollLeft;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync all automation lanes
|
||||||
|
automationHScrollRefs.current.forEach((el) => {
|
||||||
|
el.scrollLeft = scrollLeft;
|
||||||
|
});
|
||||||
|
|
||||||
|
setSyncingScroll(false);
|
||||||
|
}, [syncingScroll]);
|
||||||
|
|
||||||
|
// Expose the scroll handler via ref so AudioEditor can call it
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (timeScaleScrollHandlerRef) {
|
||||||
|
timeScaleScrollHandlerRef.current = handleTimeScaleScrollInternal;
|
||||||
|
}
|
||||||
|
}, [handleTimeScaleScrollInternal, timeScaleScrollHandlerRef]);
|
||||||
|
|
||||||
const handleImportTrack = (buffer: AudioBuffer, name: string) => {
|
const handleImportTrack = (buffer: AudioBuffer, name: string) => {
|
||||||
if (onImportTrack) {
|
if (onImportTrack) {
|
||||||
onImportTrack(buffer, name);
|
onImportTrack(buffer, name);
|
||||||
@@ -167,8 +216,509 @@ export function TrackList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
{/* Track List - Two Column Layout */}
|
{/* Mobile Layout - Single Column (Stacked: Controls → Waveform per track) */}
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 flex flex-col overflow-y-auto overflow-x-hidden custom-scrollbar lg:hidden">
|
||||||
|
{tracks.map((track) => (
|
||||||
|
<div key={track.id} className="flex flex-col border-b border-border">
|
||||||
|
{/* Track Controls - Top */}
|
||||||
|
<Track
|
||||||
|
track={track}
|
||||||
|
zoom={zoom}
|
||||||
|
currentTime={currentTime}
|
||||||
|
duration={duration}
|
||||||
|
isSelected={selectedTrackId === track.id}
|
||||||
|
onSelect={onSelectTrack ? () => 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 && (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{/* Waveform */}
|
||||||
|
<div className="min-h-[200px] overflow-x-auto custom-scrollbar">
|
||||||
|
<Track
|
||||||
|
track={track}
|
||||||
|
zoom={zoom}
|
||||||
|
currentTime={currentTime}
|
||||||
|
duration={duration}
|
||||||
|
isSelected={selectedTrackId === track.id}
|
||||||
|
onSelect={onSelectTrack ? () => 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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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 (
|
||||||
|
<div className="flex-shrink-0 bg-card/90 backdrop-blur-sm">
|
||||||
|
{/* Automation Header */}
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted border-t border-b border-border/30">
|
||||||
|
<span className="text-xs font-medium flex-shrink-0">Automation</span>
|
||||||
|
|
||||||
|
{/* Color indicator */}
|
||||||
|
{currentLane?.color && (
|
||||||
|
<div
|
||||||
|
className="w-1 h-4 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: currentLane.color }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Parameter labels - always visible */}
|
||||||
|
<div className="flex items-center gap-1.5 flex-1 min-w-0 overflow-x-auto custom-scrollbar">
|
||||||
|
{automatedParams.map((paramName, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="text-[10px] px-1.5 py-0.5 rounded whitespace-nowrap flex-shrink-0 bg-primary/10 text-primary border border-primary/20"
|
||||||
|
>
|
||||||
|
{paramName}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls - only visible when expanded */}
|
||||||
|
{track.automationExpanded && (
|
||||||
|
<>
|
||||||
|
{/* Parameter selector */}
|
||||||
|
{availableParameters && availableParameters.length > 1 && (
|
||||||
|
<select
|
||||||
|
value={selectedParam}
|
||||||
|
onChange={(e) => onUpdateTrack(track.id, {
|
||||||
|
automation: { ...track.automation, selectedParameterId: e.target.value },
|
||||||
|
})}
|
||||||
|
className="text-xs font-medium text-foreground w-auto min-w-[120px] max-w-[200px] bg-background/50 border border-border/30 rounded px-1.5 py-0.5 hover:bg-background/80 focus:outline-none focus:ring-1 focus:ring-primary flex-shrink-0"
|
||||||
|
>
|
||||||
|
{availableParameters.map((param) => (
|
||||||
|
<option key={param.id} value={param.id}>
|
||||||
|
{param.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Automation mode button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (currentLane) {
|
||||||
|
const nextIndex = (currentModeIndex + 1) % modes.length;
|
||||||
|
const updatedLanes = track.automation.lanes.map((l) =>
|
||||||
|
l.id === currentLane.id ? { ...l, mode: modes[nextIndex] } : l
|
||||||
|
);
|
||||||
|
onUpdateTrack(track.id, {
|
||||||
|
automation: { ...track.automation, lanes: updatedLanes },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={`Automation mode: ${currentLane?.mode || 'read'} (click to cycle)`}
|
||||||
|
className={cn('h-5 w-5 text-[10px] font-bold flex-shrink-0', MODE_COLORS[currentLane?.mode || 'read'])}
|
||||||
|
>
|
||||||
|
{MODE_LABELS[currentLane?.mode || 'read']}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Height controls */}
|
||||||
|
<div className="flex flex-col gap-0 flex-shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (currentLane) {
|
||||||
|
const newHeight = Math.max(60, Math.min(200, currentLane.height + 20));
|
||||||
|
const updatedLanes = track.automation.lanes.map((l) =>
|
||||||
|
l.id === currentLane.id ? { ...l, height: newHeight } : l
|
||||||
|
);
|
||||||
|
onUpdateTrack(track.id, {
|
||||||
|
automation: { ...track.automation, lanes: updatedLanes },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Increase lane height"
|
||||||
|
className="h-3 w-4 p-0"
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-2.5 w-2.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (currentLane) {
|
||||||
|
const newHeight = Math.max(60, Math.min(200, currentLane.height - 20));
|
||||||
|
const updatedLanes = track.automation.lanes.map((l) =>
|
||||||
|
l.id === currentLane.id ? { ...l, height: newHeight } : l
|
||||||
|
);
|
||||||
|
onUpdateTrack(track.id, {
|
||||||
|
automation: { ...track.automation, lanes: updatedLanes },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Decrease lane height"
|
||||||
|
className="h-3 w-4 p-0"
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-2.5 w-2.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show/hide toggle */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => {
|
||||||
|
onUpdateTrack(track.id, { automationExpanded: !track.automationExpanded });
|
||||||
|
}}
|
||||||
|
title={track.automationExpanded ? 'Hide automation controls' : 'Show automation controls'}
|
||||||
|
className="h-5 w-5 flex-shrink-0"
|
||||||
|
>
|
||||||
|
{track.automationExpanded ? (
|
||||||
|
<Eye className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<EyeOff className="h-3 w-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Automation Lane Content - Shown when expanded */}
|
||||||
|
{track.automationExpanded && (
|
||||||
|
<div className="overflow-x-auto custom-scrollbar">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minWidth: duration && zoom >= 1 ? `${duration * zoom * 5}px` : '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{track.automation.lanes
|
||||||
|
.filter((lane) => lane.parameterId === (track.automation.selectedParameterId || 'volume') && lane.visible)
|
||||||
|
.map((lane) => (
|
||||||
|
<AutomationLane
|
||||||
|
key={lane.id}
|
||||||
|
lane={lane}
|
||||||
|
zoom={zoom}
|
||||||
|
currentTime={currentTime}
|
||||||
|
duration={duration}
|
||||||
|
onAddPoint={(time, value) => {
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Effects Bar */}
|
||||||
|
<div className="flex-shrink-0 bg-card/90 backdrop-blur-sm border-b border-border">
|
||||||
|
{/* Effects Header */}
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/50 border-t border-b border-border/30">
|
||||||
|
<span className="text-xs font-medium flex-shrink-0">Effects</span>
|
||||||
|
|
||||||
|
{/* Effect name labels */}
|
||||||
|
<div className="flex items-center gap-1.5 flex-1 min-w-0 overflow-x-auto">
|
||||||
|
{track.effectChain.effects.map((effect) => (
|
||||||
|
<span
|
||||||
|
key={effect.id}
|
||||||
|
className={cn(
|
||||||
|
"text-[10px] px-1.5 py-0.5 rounded whitespace-nowrap flex-shrink-0",
|
||||||
|
effect.enabled
|
||||||
|
? "bg-primary/10 text-primary border border-primary/20"
|
||||||
|
: "bg-muted/30 text-muted-foreground border border-border/30 opacity-60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{effect.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add effect button - only visible when expanded */}
|
||||||
|
{track.effectsExpanded && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => setEffectBrowserTrackId(track.id)}
|
||||||
|
title="Add effect"
|
||||||
|
className="h-5 w-5 flex-shrink-0"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show/hide toggle */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => {
|
||||||
|
onUpdateTrack(track.id, { effectsExpanded: !track.effectsExpanded });
|
||||||
|
}}
|
||||||
|
title={track.effectsExpanded ? 'Hide effects' : 'Show effects'}
|
||||||
|
className="h-5 w-5 flex-shrink-0"
|
||||||
|
>
|
||||||
|
{track.effectsExpanded ? (
|
||||||
|
<Eye className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<EyeOff className="h-3 w-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Effects Content - Collapsible, horizontally scrollable */}
|
||||||
|
{track.effectsExpanded && (
|
||||||
|
<div className="h-48 overflow-x-auto custom-scrollbar bg-muted/70 border-t border-border">
|
||||||
|
<div className="flex h-full gap-3 p-3">
|
||||||
|
{track.effectChain.effects.length === 0 ? (
|
||||||
|
<div className="text-xs text-muted-foreground text-center py-8 w-full">
|
||||||
|
No effects. Click + to add an effect.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
track.effectChain.effects.map((effect) => (
|
||||||
|
<EffectDevice
|
||||||
|
key={effect.id}
|
||||||
|
effect={effect}
|
||||||
|
onToggleEnabled={() => {
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop Layout - Two Column Layout */}
|
||||||
|
<div className="flex-1 flex overflow-hidden hidden lg:flex">
|
||||||
{/* Left Column: Track Controls (Fixed Width, No Scroll - synced with waveforms) */}
|
{/* Left Column: Track Controls (Fixed Width, No Scroll - synced with waveforms) */}
|
||||||
<div ref={controlsScrollRef} className="w-60 flex-shrink-0 overflow-hidden pb-3 border-r border-border">
|
<div ref={controlsScrollRef} className="w-60 flex-shrink-0 overflow-hidden pb-3 border-r border-border">
|
||||||
{tracks.map((track) => (
|
{tracks.map((track) => (
|
||||||
@@ -266,19 +816,19 @@ export function TrackList({
|
|||||||
<div
|
<div
|
||||||
ref={waveformScrollRef}
|
ref={waveformScrollRef}
|
||||||
onScroll={handleWaveformScroll}
|
onScroll={handleWaveformScroll}
|
||||||
className="flex-1 overflow-y-auto overflow-x-hidden custom-scrollbar"
|
className="flex-1 overflow-y-scroll overflow-x-hidden custom-scrollbar"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{tracks.map((track) => (
|
{tracks.map((track) => (
|
||||||
<React.Fragment key={track.id}>
|
<React.Fragment key={track.id}>
|
||||||
{/* Track Waveform Row with bars stacked below - Fixed height container */}
|
{/* Track Waveform Row with bars stacked below - Total height matches track controls */}
|
||||||
<div
|
<div
|
||||||
className="flex flex-col"
|
className="flex flex-col"
|
||||||
style={{
|
style={{
|
||||||
height: track.collapsed ? `${COLLAPSED_TRACK_HEIGHT}px` : `${Math.max(track.height || DEFAULT_TRACK_HEIGHT, MIN_TRACK_HEIGHT)}px`
|
height: track.collapsed ? `${COLLAPSED_TRACK_HEIGHT}px` : `${Math.max(track.height || DEFAULT_TRACK_HEIGHT, MIN_TRACK_HEIGHT)}px`
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Waveform - Takes remaining space, horizontally scrollable */}
|
{/* Waveform - Takes remaining space after bars */}
|
||||||
<div className="flex-1 min-h-0 relative">
|
<div className="flex-1 min-h-0 relative">
|
||||||
{/* Upload hint for empty tracks - stays fixed as overlay */}
|
{/* Upload hint for empty tracks - stays fixed as overlay */}
|
||||||
{!track.audioBuffer && !track.collapsed && (
|
{!track.audioBuffer && !track.collapsed && (
|
||||||
@@ -298,7 +848,7 @@ export function TrackList({
|
|||||||
<div
|
<div
|
||||||
className="h-full"
|
className="h-full"
|
||||||
style={{
|
style={{
|
||||||
minWidth: duration && zoom > 1 ? `${duration * zoom * 100}px` : '100%',
|
minWidth: duration && zoom >= 1 ? `${duration * zoom * 5}px` : '100%',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Track
|
<Track
|
||||||
@@ -446,7 +996,7 @@ export function TrackList({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Parameter labels - always visible */}
|
{/* Parameter labels - always visible */}
|
||||||
<div className="flex items-center gap-1.5 flex-1 min-w-0 overflow-x-auto">
|
<div className="flex items-center gap-1.5 flex-1 min-w-0 overflow-x-auto custom-scrollbar">
|
||||||
{automatedParams.map((paramName, index) => (
|
{automatedParams.map((paramName, index) => (
|
||||||
<span
|
<span
|
||||||
key={index}
|
key={index}
|
||||||
@@ -571,7 +1121,7 @@ export function TrackList({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
minWidth: duration && zoom > 1 ? `${duration * zoom * 100}px` : '100%',
|
minWidth: duration && zoom >= 1 ? `${duration * zoom * 5}px` : '100%',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{track.automation.lanes
|
{track.automation.lanes
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ export function CommandPalette({ actions, className }: CommandPaletteProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Results */}
|
{/* Results */}
|
||||||
<div className="max-h-96 overflow-y-auto p-2">
|
<div className="max-h-96 overflow-y-auto custom-scrollbar p-2">
|
||||||
{Object.keys(groupedActions).length === 0 ? (
|
{Object.keys(groupedActions).length === 0 ? (
|
||||||
<div className="p-8 text-center text-muted-foreground text-sm">
|
<div className="p-8 text-center text-muted-foreground text-sm">
|
||||||
No commands found
|
No commands found
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export function Modal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-4">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -127,7 +127,14 @@ export function interpolateAutomationValue(
|
|||||||
return prevPoint.value;
|
return prevPoint.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Linear interpolation
|
// Handle bezier curve
|
||||||
|
if (prevPoint.curve === 'bezier') {
|
||||||
|
const timeDelta = nextPoint.time - prevPoint.time;
|
||||||
|
const t = (time - prevPoint.time) / timeDelta;
|
||||||
|
return interpolateBezier(prevPoint, nextPoint, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linear interpolation (default)
|
||||||
const timeDelta = nextPoint.time - prevPoint.time;
|
const timeDelta = nextPoint.time - prevPoint.time;
|
||||||
const valueDelta = nextPoint.value - prevPoint.value;
|
const valueDelta = nextPoint.value - prevPoint.value;
|
||||||
const progress = (time - prevPoint.time) / timeDelta;
|
const progress = (time - prevPoint.time) / timeDelta;
|
||||||
@@ -139,6 +146,117 @@ export function interpolateAutomationValue(
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interpolate value using cubic Bezier curve
|
||||||
|
* Uses the control handles from both points to create smooth curves
|
||||||
|
*/
|
||||||
|
function interpolateBezier(
|
||||||
|
p0: AutomationPoint,
|
||||||
|
p1: AutomationPoint,
|
||||||
|
t: number
|
||||||
|
): number {
|
||||||
|
// Default handle positions if not specified
|
||||||
|
// Out handle defaults to 1/3 towards next point
|
||||||
|
// In handle defaults to 1/3 back from current point
|
||||||
|
const timeDelta = p1.time - p0.time;
|
||||||
|
|
||||||
|
// Control point 1 (out handle from p0)
|
||||||
|
const c1x = p0.handleOut?.x ?? timeDelta / 3;
|
||||||
|
const c1y = p0.handleOut?.y ?? 0;
|
||||||
|
|
||||||
|
// Control point 2 (in handle from p1)
|
||||||
|
const c2x = p1.handleIn?.x ?? -timeDelta / 3;
|
||||||
|
const c2y = p1.handleIn?.y ?? 0;
|
||||||
|
|
||||||
|
// Convert handles to absolute positions
|
||||||
|
const cp1Value = p0.value + c1y;
|
||||||
|
const cp2Value = p1.value + c2y;
|
||||||
|
|
||||||
|
// Cubic Bezier formula: B(t) = (1-t)³P₀ + 3(1-t)²tP₁ + 3(1-t)t²P₂ + t³P₃
|
||||||
|
const mt = 1 - t;
|
||||||
|
const mt2 = mt * mt;
|
||||||
|
const mt3 = mt2 * mt;
|
||||||
|
const t2 = t * t;
|
||||||
|
const t3 = t2 * t;
|
||||||
|
|
||||||
|
const value =
|
||||||
|
mt3 * p0.value +
|
||||||
|
3 * mt2 * t * cp1Value +
|
||||||
|
3 * mt * t2 * cp2Value +
|
||||||
|
t3 * p1.value;
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create smooth bezier handles for a point based on surrounding points
|
||||||
|
* This creates an "auto-smooth" effect similar to DAWs
|
||||||
|
*/
|
||||||
|
export function createSmoothHandles(
|
||||||
|
prevPoint: AutomationPoint | null,
|
||||||
|
currentPoint: AutomationPoint,
|
||||||
|
nextPoint: AutomationPoint | null
|
||||||
|
): { handleIn: { x: number; y: number }; handleOut: { x: number; y: number } } {
|
||||||
|
// If no surrounding points, return horizontal handles
|
||||||
|
if (!prevPoint && !nextPoint) {
|
||||||
|
return {
|
||||||
|
handleIn: { x: -0.1, y: 0 },
|
||||||
|
handleOut: { x: 0.1, y: 0 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate slope from surrounding points
|
||||||
|
let slope = 0;
|
||||||
|
|
||||||
|
if (prevPoint && nextPoint) {
|
||||||
|
// Use average slope from both neighbors
|
||||||
|
const timeDelta = nextPoint.time - prevPoint.time;
|
||||||
|
const valueDelta = nextPoint.value - prevPoint.value;
|
||||||
|
slope = valueDelta / timeDelta;
|
||||||
|
} else if (nextPoint) {
|
||||||
|
// Only have next point
|
||||||
|
const timeDelta = nextPoint.time - currentPoint.time;
|
||||||
|
const valueDelta = nextPoint.value - currentPoint.value;
|
||||||
|
slope = valueDelta / timeDelta;
|
||||||
|
} else if (prevPoint) {
|
||||||
|
// Only have previous point
|
||||||
|
const timeDelta = currentPoint.time - prevPoint.time;
|
||||||
|
const valueDelta = currentPoint.value - prevPoint.value;
|
||||||
|
slope = valueDelta / timeDelta;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create handles with 1/3 distance to neighbors
|
||||||
|
const handleDistance = 0.1; // Fixed distance for smooth curves
|
||||||
|
const handleY = slope * handleDistance;
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleIn: { x: -handleDistance, y: -handleY },
|
||||||
|
handleOut: { x: handleDistance, y: handleY },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate points along a bezier curve for rendering
|
||||||
|
* Returns array of {time, value} points
|
||||||
|
*/
|
||||||
|
export function generateBezierCurvePoints(
|
||||||
|
p0: AutomationPoint,
|
||||||
|
p1: AutomationPoint,
|
||||||
|
numPoints: number = 50
|
||||||
|
): Array<{ time: number; value: number }> {
|
||||||
|
const points: Array<{ time: number; value: number }> = [];
|
||||||
|
const timeDelta = p1.time - p0.time;
|
||||||
|
|
||||||
|
for (let i = 0; i <= numPoints; i++) {
|
||||||
|
const t = i / numPoints;
|
||||||
|
const time = p0.time + t * timeDelta;
|
||||||
|
const value = interpolateBezier(p0, p1, t);
|
||||||
|
points.push({ time, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply automation value to track parameter
|
* Apply automation value to track parameter
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { getAudioContext } from './context';
|
import { getAudioContext } from './context';
|
||||||
|
import { checkFileMemoryLimit, type MemoryCheckResult } from '../utils/memory-limits';
|
||||||
|
|
||||||
export interface ImportOptions {
|
export interface ImportOptions {
|
||||||
convertToMono?: boolean;
|
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);
|
/\.(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
|
* Format duration in seconds to MM:SS format
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export function generateTrackId(): string {
|
|||||||
/**
|
/**
|
||||||
* Create a new empty track
|
* Create a new empty track
|
||||||
*/
|
*/
|
||||||
export function createTrack(name?: string, color?: TrackColor): Track {
|
export function createTrack(name?: string, color?: TrackColor, height?: number): Track {
|
||||||
const colors: TrackColor[] = ['blue', 'green', 'purple', 'orange', 'pink', 'indigo', 'yellow', 'red'];
|
const colors: TrackColor[] = ['blue', 'green', 'purple', 'orange', 'pink', 'indigo', 'yellow', 'red'];
|
||||||
const randomColor = colors[Math.floor(Math.random() * colors.length)];
|
const randomColor = colors[Math.floor(Math.random() * colors.length)];
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ export function createTrack(name?: string, color?: TrackColor): Track {
|
|||||||
id: trackId,
|
id: trackId,
|
||||||
name: trackName,
|
name: trackName,
|
||||||
color: TRACK_COLORS[color || randomColor],
|
color: TRACK_COLORS[color || randomColor],
|
||||||
height: DEFAULT_TRACK_HEIGHT,
|
height: height ?? DEFAULT_TRACK_HEIGHT,
|
||||||
audioBuffer: null,
|
audioBuffer: null,
|
||||||
volume: 0.8,
|
volume: 0.8,
|
||||||
pan: 0,
|
pan: 0,
|
||||||
@@ -70,11 +70,12 @@ export function createTrack(name?: string, color?: TrackColor): Track {
|
|||||||
export function createTrackFromBuffer(
|
export function createTrackFromBuffer(
|
||||||
buffer: AudioBuffer,
|
buffer: AudioBuffer,
|
||||||
name?: string,
|
name?: string,
|
||||||
color?: TrackColor
|
color?: TrackColor,
|
||||||
|
height?: number
|
||||||
): Track {
|
): Track {
|
||||||
// Ensure name is a string before passing to createTrack
|
// Ensure name is a string before passing to createTrack
|
||||||
const trackName = typeof name === 'string' && name.trim() ? name.trim() : undefined;
|
const trackName = typeof name === 'string' && name.trim() ? name.trim() : undefined;
|
||||||
const track = createTrack(trackName, color);
|
const track = createTrack(trackName, color, height);
|
||||||
track.audioBuffer = buffer;
|
track.audioBuffer = buffer;
|
||||||
return track;
|
return track;
|
||||||
}
|
}
|
||||||
|
|||||||
138
lib/hooks/useAudioWorker.ts
Normal file
138
lib/hooks/useAudioWorker.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRef, useEffect, useCallback } from 'react';
|
||||||
|
import type { WorkerMessage, WorkerResponse } from '@/lib/workers/audio.worker';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to use the audio Web Worker for heavy computations
|
||||||
|
* Automatically manages worker lifecycle and message passing
|
||||||
|
*/
|
||||||
|
export function useAudioWorker() {
|
||||||
|
const workerRef = useRef<Worker | null>(null);
|
||||||
|
const callbacksRef = useRef<Map<string, (result: any, error?: string) => void>>(new Map());
|
||||||
|
const messageIdRef = useRef(0);
|
||||||
|
|
||||||
|
// Initialize worker
|
||||||
|
useEffect(() => {
|
||||||
|
// Create worker from the audio worker file
|
||||||
|
workerRef.current = new Worker(
|
||||||
|
new URL('../workers/audio.worker.ts', import.meta.url),
|
||||||
|
{ type: 'module' }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle messages from worker
|
||||||
|
workerRef.current.onmessage = (event: MessageEvent<WorkerResponse>) => {
|
||||||
|
const { id, result, error } = event.data;
|
||||||
|
const callback = callbacksRef.current.get(id);
|
||||||
|
|
||||||
|
if (callback) {
|
||||||
|
callback(result, error);
|
||||||
|
callbacksRef.current.delete(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
return () => {
|
||||||
|
if (workerRef.current) {
|
||||||
|
workerRef.current.terminate();
|
||||||
|
workerRef.current = null;
|
||||||
|
}
|
||||||
|
callbacksRef.current.clear();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Send message to worker
|
||||||
|
const sendMessage = useCallback(
|
||||||
|
<T = any>(type: WorkerMessage['type'], payload: any): Promise<T> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!workerRef.current) {
|
||||||
|
reject(new Error('Worker not initialized'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = `msg-${++messageIdRef.current}`;
|
||||||
|
const message: WorkerMessage = { id, type, payload };
|
||||||
|
|
||||||
|
callbacksRef.current.set(id, (result, error) => {
|
||||||
|
if (error) {
|
||||||
|
reject(new Error(error));
|
||||||
|
} else {
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
workerRef.current.postMessage(message);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// API methods
|
||||||
|
const generatePeaks = useCallback(
|
||||||
|
async (channelData: Float32Array, width: number): Promise<Float32Array> => {
|
||||||
|
const result = await sendMessage<Float32Array>('generatePeaks', {
|
||||||
|
channelData,
|
||||||
|
width,
|
||||||
|
});
|
||||||
|
return new Float32Array(result);
|
||||||
|
},
|
||||||
|
[sendMessage]
|
||||||
|
);
|
||||||
|
|
||||||
|
const generateMinMaxPeaks = useCallback(
|
||||||
|
async (
|
||||||
|
channelData: Float32Array,
|
||||||
|
width: number
|
||||||
|
): Promise<{ min: Float32Array; max: Float32Array }> => {
|
||||||
|
const result = await sendMessage<{ min: Float32Array; max: Float32Array }>(
|
||||||
|
'generateMinMaxPeaks',
|
||||||
|
{ channelData, width }
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
min: new Float32Array(result.min),
|
||||||
|
max: new Float32Array(result.max),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[sendMessage]
|
||||||
|
);
|
||||||
|
|
||||||
|
const normalizePeaks = useCallback(
|
||||||
|
async (peaks: Float32Array, targetMax: number = 1): Promise<Float32Array> => {
|
||||||
|
const result = await sendMessage<Float32Array>('normalizePeaks', {
|
||||||
|
peaks,
|
||||||
|
targetMax,
|
||||||
|
});
|
||||||
|
return new Float32Array(result);
|
||||||
|
},
|
||||||
|
[sendMessage]
|
||||||
|
);
|
||||||
|
|
||||||
|
const analyzeAudio = useCallback(
|
||||||
|
async (
|
||||||
|
channelData: Float32Array
|
||||||
|
): Promise<{
|
||||||
|
peak: number;
|
||||||
|
rms: number;
|
||||||
|
crestFactor: number;
|
||||||
|
dynamicRange: number;
|
||||||
|
}> => {
|
||||||
|
return sendMessage('analyzeAudio', { channelData });
|
||||||
|
},
|
||||||
|
[sendMessage]
|
||||||
|
);
|
||||||
|
|
||||||
|
const findPeak = useCallback(
|
||||||
|
async (channelData: Float32Array): Promise<number> => {
|
||||||
|
return sendMessage<number>('findPeak', { channelData });
|
||||||
|
},
|
||||||
|
[sendMessage]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
generatePeaks,
|
||||||
|
generateMinMaxPeaks,
|
||||||
|
normalizePeaks,
|
||||||
|
analyzeAudio,
|
||||||
|
findPeak,
|
||||||
|
};
|
||||||
|
}
|
||||||
70
lib/hooks/useMarkers.ts
Normal file
70
lib/hooks/useMarkers.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import type { Marker, CreateMarkerInput } from '@/types/marker';
|
||||||
|
|
||||||
|
export function useMarkers() {
|
||||||
|
const [markers, setMarkers] = useState<Marker[]>([]);
|
||||||
|
|
||||||
|
const addMarker = useCallback((input: CreateMarkerInput): Marker => {
|
||||||
|
const marker: Marker = {
|
||||||
|
...input,
|
||||||
|
id: `marker-${Date.now()}-${Math.random()}`,
|
||||||
|
};
|
||||||
|
setMarkers((prev) => [...prev, marker].sort((a, b) => a.time - b.time));
|
||||||
|
return marker;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateMarker = useCallback((id: string, updates: Partial<Marker>) => {
|
||||||
|
setMarkers((prev) => {
|
||||||
|
const updated = prev.map((m) =>
|
||||||
|
m.id === id ? { ...m, ...updates } : m
|
||||||
|
);
|
||||||
|
// Re-sort if time changed
|
||||||
|
if ('time' in updates) {
|
||||||
|
return updated.sort((a, b) => a.time - b.time);
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeMarker = useCallback((id: string) => {
|
||||||
|
setMarkers((prev) => prev.filter((m) => m.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearMarkers = useCallback(() => {
|
||||||
|
setMarkers([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getMarkerAt = useCallback((time: number, tolerance: number = 0.1): Marker | undefined => {
|
||||||
|
return markers.find((m) => {
|
||||||
|
if (m.type === 'point') {
|
||||||
|
return Math.abs(m.time - time) <= tolerance;
|
||||||
|
} else {
|
||||||
|
// For regions, check if time is within the region
|
||||||
|
return m.endTime !== undefined && time >= m.time && time <= m.endTime;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [markers]);
|
||||||
|
|
||||||
|
const getNextMarker = useCallback((time: number): Marker | undefined => {
|
||||||
|
return markers.find((m) => m.time > time);
|
||||||
|
}, [markers]);
|
||||||
|
|
||||||
|
const getPreviousMarker = useCallback((time: number): Marker | undefined => {
|
||||||
|
const previous = markers.filter((m) => m.time < time);
|
||||||
|
return previous[previous.length - 1];
|
||||||
|
}, [markers]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
markers,
|
||||||
|
addMarker,
|
||||||
|
updateMarker,
|
||||||
|
removeMarker,
|
||||||
|
clearMarkers,
|
||||||
|
getMarkerAt,
|
||||||
|
getNextMarker,
|
||||||
|
getPreviousMarker,
|
||||||
|
setMarkers,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,14 +6,14 @@ export function useMultiTrack() {
|
|||||||
// Note: localStorage persistence disabled in favor of IndexedDB project management
|
// Note: localStorage persistence disabled in favor of IndexedDB project management
|
||||||
const [tracks, setTracks] = useState<Track[]>([]);
|
const [tracks, setTracks] = useState<Track[]>([]);
|
||||||
|
|
||||||
const addTrack = useCallback((name?: string) => {
|
const addTrack = useCallback((name?: string, height?: number) => {
|
||||||
const track = createTrack(name);
|
const track = createTrack(name, undefined, height);
|
||||||
setTracks((prev) => [...prev, track]);
|
setTracks((prev) => [...prev, track]);
|
||||||
return track;
|
return track;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const addTrackFromBuffer = useCallback((buffer: AudioBuffer, name?: string) => {
|
const addTrackFromBuffer = useCallback((buffer: AudioBuffer, name?: string, height?: number) => {
|
||||||
const track = createTrackFromBuffer(buffer, name);
|
const track = createTrackFromBuffer(buffer, name, undefined, height);
|
||||||
setTracks((prev) => [...prev, track]);
|
setTracks((prev) => [...prev, track]);
|
||||||
return track;
|
return track;
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ export interface MultiTrackPlayerState {
|
|||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
|
loopEnabled: boolean;
|
||||||
|
loopStart: number;
|
||||||
|
loopEnd: number;
|
||||||
|
playbackRate: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TrackLevel {
|
export interface TrackLevel {
|
||||||
@@ -32,6 +36,10 @@ export function useMultiTrackPlayer(
|
|||||||
const [masterPeakLevel, setMasterPeakLevel] = useState(0);
|
const [masterPeakLevel, setMasterPeakLevel] = useState(0);
|
||||||
const [masterRmsLevel, setMasterRmsLevel] = useState(0);
|
const [masterRmsLevel, setMasterRmsLevel] = useState(0);
|
||||||
const [masterIsClipping, setMasterIsClipping] = useState(false);
|
const [masterIsClipping, setMasterIsClipping] = useState(false);
|
||||||
|
const [loopEnabled, setLoopEnabled] = useState(false);
|
||||||
|
const [loopStart, setLoopStart] = useState(0);
|
||||||
|
const [loopEnd, setLoopEnd] = useState(0);
|
||||||
|
const [playbackRate, setPlaybackRate] = useState(1.0);
|
||||||
|
|
||||||
const audioContextRef = useRef<AudioContext | null>(null);
|
const audioContextRef = useRef<AudioContext | null>(null);
|
||||||
const sourceNodesRef = useRef<AudioBufferSourceNode[]>([]);
|
const sourceNodesRef = useRef<AudioBufferSourceNode[]>([]);
|
||||||
@@ -51,12 +59,29 @@ export function useMultiTrackPlayer(
|
|||||||
const tracksRef = useRef<Track[]>(tracks); // Always keep latest tracks
|
const tracksRef = useRef<Track[]>(tracks); // Always keep latest tracks
|
||||||
const lastRecordedValuesRef = useRef<Map<string, number>>(new Map()); // Track last recorded values to detect changes
|
const lastRecordedValuesRef = useRef<Map<string, number>>(new Map()); // Track last recorded values to detect changes
|
||||||
const onRecordAutomationRef = useRef<AutomationRecordingCallback | undefined>(onRecordAutomation);
|
const onRecordAutomationRef = useRef<AutomationRecordingCallback | undefined>(onRecordAutomation);
|
||||||
|
const loopEnabledRef = useRef<boolean>(false);
|
||||||
|
const loopStartRef = useRef<number>(0);
|
||||||
|
const loopEndRef = useRef<number>(0);
|
||||||
|
const playbackRateRef = useRef<number>(1.0);
|
||||||
|
const isPlayingRef = useRef<boolean>(false);
|
||||||
|
|
||||||
// Keep tracksRef in sync with tracks prop
|
// Keep tracksRef in sync with tracks prop
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
tracksRef.current = tracks;
|
tracksRef.current = tracks;
|
||||||
}, [tracks]);
|
}, [tracks]);
|
||||||
|
|
||||||
|
// Keep loop refs in sync with state
|
||||||
|
useEffect(() => {
|
||||||
|
loopEnabledRef.current = loopEnabled;
|
||||||
|
loopStartRef.current = loopStart;
|
||||||
|
loopEndRef.current = loopEnd;
|
||||||
|
}, [loopEnabled, loopStart, loopEnd]);
|
||||||
|
|
||||||
|
// Keep playbackRate ref in sync with state
|
||||||
|
useEffect(() => {
|
||||||
|
playbackRateRef.current = playbackRate;
|
||||||
|
}, [playbackRate]);
|
||||||
|
|
||||||
// Keep onRecordAutomationRef in sync
|
// Keep onRecordAutomationRef in sync
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onRecordAutomationRef.current = onRecordAutomation;
|
onRecordAutomationRef.current = onRecordAutomation;
|
||||||
@@ -71,7 +96,11 @@ export function useMultiTrackPlayer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
setDuration(maxDuration);
|
setDuration(maxDuration);
|
||||||
}, [tracks]);
|
// Initialize loop end to duration when duration changes
|
||||||
|
if (loopEnd === 0 || loopEnd > maxDuration) {
|
||||||
|
setLoopEnd(maxDuration);
|
||||||
|
}
|
||||||
|
}, [tracks, loopEnd]);
|
||||||
|
|
||||||
// Convert linear amplitude to dB scale normalized to 0-1 range
|
// Convert linear amplitude to dB scale normalized to 0-1 range
|
||||||
const linearToDbScale = (linear: number): number => {
|
const linearToDbScale = (linear: number): number => {
|
||||||
@@ -291,11 +320,56 @@ export function useMultiTrackPlayer(
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updatePlaybackPosition = useCallback(() => {
|
const updatePlaybackPosition = useCallback(() => {
|
||||||
if (!audioContextRef.current) return;
|
if (!audioContextRef.current || !isPlayingRef.current) return;
|
||||||
|
|
||||||
const elapsed = audioContextRef.current.currentTime - startTimeRef.current;
|
const elapsed = (audioContextRef.current.currentTime - startTimeRef.current) * playbackRateRef.current;
|
||||||
const newTime = pausedAtRef.current + elapsed;
|
const newTime = pausedAtRef.current + elapsed;
|
||||||
|
|
||||||
|
// Check if loop is enabled and we've reached the loop end
|
||||||
|
if (loopEnabledRef.current && loopEndRef.current > loopStartRef.current && newTime >= loopEndRef.current) {
|
||||||
|
// Loop back to start
|
||||||
|
pausedAtRef.current = loopStartRef.current;
|
||||||
|
startTimeRef.current = audioContextRef.current.currentTime;
|
||||||
|
setCurrentTime(loopStartRef.current);
|
||||||
|
|
||||||
|
// Restart all sources from loop start
|
||||||
|
sourceNodesRef.current.forEach((node, index) => {
|
||||||
|
try {
|
||||||
|
node.stop();
|
||||||
|
node.disconnect();
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore errors from already stopped nodes
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-trigger play from loop start
|
||||||
|
const tracks = tracksRef.current;
|
||||||
|
const audioContext = audioContextRef.current;
|
||||||
|
|
||||||
|
// Clear old sources
|
||||||
|
sourceNodesRef.current = [];
|
||||||
|
|
||||||
|
// Create new sources starting from loop start
|
||||||
|
for (const track of tracks) {
|
||||||
|
if (!track.audioBuffer) continue;
|
||||||
|
|
||||||
|
const source = audioContext.createBufferSource();
|
||||||
|
source.buffer = track.audioBuffer;
|
||||||
|
source.playbackRate.value = playbackRateRef.current;
|
||||||
|
|
||||||
|
// Connect to existing nodes (gain, pan, effects are still connected)
|
||||||
|
const trackIndex = tracks.indexOf(track);
|
||||||
|
source.connect(analyserNodesRef.current[trackIndex]);
|
||||||
|
|
||||||
|
// Start from loop start position
|
||||||
|
source.start(0, loopStartRef.current);
|
||||||
|
sourceNodesRef.current.push(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
animationFrameRef.current = requestAnimationFrame(updatePlaybackPosition);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (newTime >= duration) {
|
if (newTime >= duration) {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
isMonitoringLevelsRef.current = false;
|
isMonitoringLevelsRef.current = false;
|
||||||
@@ -401,6 +475,9 @@ export function useMultiTrackPlayer(
|
|||||||
outputNode.connect(masterGain);
|
outputNode.connect(masterGain);
|
||||||
console.log('[MultiTrackPlayer] Effect output connected with', effectNodes.length, 'effect nodes');
|
console.log('[MultiTrackPlayer] Effect output connected with', effectNodes.length, 'effect nodes');
|
||||||
|
|
||||||
|
// Set playback rate
|
||||||
|
source.playbackRate.value = playbackRateRef.current;
|
||||||
|
|
||||||
// Start playback from current position
|
// Start playback from current position
|
||||||
source.start(0, pausedAtRef.current);
|
source.start(0, pausedAtRef.current);
|
||||||
|
|
||||||
@@ -424,6 +501,7 @@ export function useMultiTrackPlayer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
startTimeRef.current = audioContext.currentTime;
|
startTimeRef.current = audioContext.currentTime;
|
||||||
|
isPlayingRef.current = true;
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
updatePlaybackPosition();
|
updatePlaybackPosition();
|
||||||
|
|
||||||
@@ -454,6 +532,7 @@ export function useMultiTrackPlayer(
|
|||||||
pausedAtRef.current = Math.min(pausedAtRef.current + elapsed, duration);
|
pausedAtRef.current = Math.min(pausedAtRef.current + elapsed, duration);
|
||||||
setCurrentTime(pausedAtRef.current);
|
setCurrentTime(pausedAtRef.current);
|
||||||
|
|
||||||
|
isPlayingRef.current = false;
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
|
|
||||||
// Stop level monitoring
|
// Stop level monitoring
|
||||||
@@ -822,6 +901,33 @@ export function useMultiTrackPlayer(
|
|||||||
setMasterIsClipping(false);
|
setMasterIsClipping(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const toggleLoop = useCallback(() => {
|
||||||
|
setLoopEnabled(prev => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setLoopPoints = useCallback((start: number, end: number) => {
|
||||||
|
setLoopStart(Math.max(0, start));
|
||||||
|
setLoopEnd(Math.min(duration, Math.max(start, end)));
|
||||||
|
}, [duration]);
|
||||||
|
|
||||||
|
const setLoopFromSelection = useCallback((selectionStart: number, selectionEnd: number) => {
|
||||||
|
if (selectionStart < selectionEnd) {
|
||||||
|
setLoopPoints(selectionStart, selectionEnd);
|
||||||
|
setLoopEnabled(true);
|
||||||
|
}
|
||||||
|
}, [setLoopPoints]);
|
||||||
|
|
||||||
|
const changePlaybackRate = useCallback((rate: number) => {
|
||||||
|
// Clamp rate between 0.25x and 2x
|
||||||
|
const clampedRate = Math.max(0.25, Math.min(2.0, rate));
|
||||||
|
setPlaybackRate(clampedRate);
|
||||||
|
|
||||||
|
// Update playback rate on all active source nodes
|
||||||
|
sourceNodesRef.current.forEach(source => {
|
||||||
|
source.playbackRate.value = clampedRate;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isPlaying,
|
isPlaying,
|
||||||
currentTime,
|
currentTime,
|
||||||
@@ -837,5 +943,13 @@ export function useMultiTrackPlayer(
|
|||||||
stop,
|
stop,
|
||||||
seek,
|
seek,
|
||||||
togglePlayPause,
|
togglePlayPause,
|
||||||
|
loopEnabled,
|
||||||
|
loopStart,
|
||||||
|
loopEnd,
|
||||||
|
toggleLoop,
|
||||||
|
setLoopPoints,
|
||||||
|
setLoopFromSelection,
|
||||||
|
playbackRate,
|
||||||
|
changePlaybackRate,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export interface AudioSettings {
|
|||||||
export interface UISettings {
|
export interface UISettings {
|
||||||
theme: 'dark' | 'light' | 'auto';
|
theme: 'dark' | 'light' | 'auto';
|
||||||
fontSize: 'small' | 'medium' | 'large';
|
fontSize: 'small' | 'medium' | 'large';
|
||||||
defaultTrackHeight: number; // 120-400px
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EditorSettings {
|
export interface EditorSettings {
|
||||||
@@ -45,7 +44,6 @@ const DEFAULT_SETTINGS: Settings = {
|
|||||||
ui: {
|
ui: {
|
||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
fontSize: 'medium',
|
fontSize: 'medium',
|
||||||
defaultTrackHeight: 400,
|
|
||||||
},
|
},
|
||||||
editor: {
|
editor: {
|
||||||
autoSaveInterval: 3, // 3 seconds
|
autoSaveInterval: 3, // 3 seconds
|
||||||
|
|||||||
149
lib/utils/audio-cleanup.ts
Normal file
149
lib/utils/audio-cleanup.ts
Normal file
@@ -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<AudioNode | null | undefined>): 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
128
lib/utils/browser-compat.ts
Normal file
128
lib/utils/browser-compat.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* 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 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<string, number> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
160
lib/utils/memory-limits.ts
Normal file
160
lib/utils/memory-limits.ts
Normal file
@@ -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`;
|
||||||
|
}
|
||||||
|
}
|
||||||
93
lib/utils/timeline.ts
Normal file
93
lib/utils/timeline.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* Timeline coordinate conversion and formatting utilities
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base pixels per second at zoom level 1
|
||||||
|
* zoom=1: 5 pixels per second
|
||||||
|
* zoom=2: 10 pixels per second, etc.
|
||||||
|
*/
|
||||||
|
const PIXELS_PER_SECOND_BASE = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert time (in seconds) to pixel position
|
||||||
|
*/
|
||||||
|
export function timeToPixel(time: number, duration: number, zoom: number): number {
|
||||||
|
if (duration === 0) return 0;
|
||||||
|
const totalWidth = duration * zoom * PIXELS_PER_SECOND_BASE;
|
||||||
|
return (time / duration) * totalWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert pixel position to time (in seconds)
|
||||||
|
*/
|
||||||
|
export function pixelToTime(pixel: number, duration: number, zoom: number): number {
|
||||||
|
if (duration === 0) return 0;
|
||||||
|
const totalWidth = duration * zoom * PIXELS_PER_SECOND_BASE;
|
||||||
|
return (pixel / totalWidth) * duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate appropriate tick interval based on visible duration
|
||||||
|
* Returns interval in seconds
|
||||||
|
*/
|
||||||
|
export function calculateTickInterval(visibleDuration: number): {
|
||||||
|
major: number;
|
||||||
|
minor: number;
|
||||||
|
} {
|
||||||
|
// Very zoomed in: show sub-second intervals
|
||||||
|
if (visibleDuration < 5) {
|
||||||
|
return { major: 1, minor: 0.5 };
|
||||||
|
}
|
||||||
|
// Zoomed in: show every second
|
||||||
|
if (visibleDuration < 20) {
|
||||||
|
return { major: 5, minor: 1 };
|
||||||
|
}
|
||||||
|
// Medium zoom: show every 5 seconds
|
||||||
|
if (visibleDuration < 60) {
|
||||||
|
return { major: 10, minor: 5 };
|
||||||
|
}
|
||||||
|
// Zoomed out: show every 10 seconds
|
||||||
|
if (visibleDuration < 300) {
|
||||||
|
return { major: 30, minor: 10 };
|
||||||
|
}
|
||||||
|
// Very zoomed out: show every minute
|
||||||
|
return { major: 60, minor: 30 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format time in seconds to display format
|
||||||
|
* Returns format like "0:00", "1:23", "12:34.5"
|
||||||
|
*/
|
||||||
|
export function formatTimeLabel(seconds: number, showMillis: boolean = false): string {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
|
||||||
|
if (showMillis) {
|
||||||
|
const wholeSecs = Math.floor(secs);
|
||||||
|
const decimalPart = Math.floor((secs - wholeSecs) * 10);
|
||||||
|
return `${mins}:${wholeSecs.toString().padStart(2, '0')}.${decimalPart}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${mins}:${Math.floor(secs).toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate visible time range based on scroll position
|
||||||
|
*/
|
||||||
|
export function getVisibleTimeRange(
|
||||||
|
scrollLeft: number,
|
||||||
|
viewportWidth: number,
|
||||||
|
duration: number,
|
||||||
|
zoom: number
|
||||||
|
): { start: number; end: number } {
|
||||||
|
const totalWidth = duration * zoom * 100;
|
||||||
|
|
||||||
|
const start = pixelToTime(scrollLeft, duration, zoom);
|
||||||
|
const end = pixelToTime(scrollLeft + viewportWidth, duration, zoom);
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: Math.max(0, start),
|
||||||
|
end: Math.min(duration, end),
|
||||||
|
};
|
||||||
|
}
|
||||||
200
lib/workers/audio.worker.ts
Normal file
200
lib/workers/audio.worker.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
/**
|
||||||
|
* Web Worker for heavy audio computations
|
||||||
|
* Offloads waveform generation, analysis, and normalization to background thread
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface WorkerMessage {
|
||||||
|
id: string;
|
||||||
|
type: 'generatePeaks' | 'generateMinMaxPeaks' | 'normalizePeaks' | 'analyzeAudio' | 'findPeak';
|
||||||
|
payload: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkerResponse {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
result?: any;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message handler
|
||||||
|
self.onmessage = (event: MessageEvent<WorkerMessage>) => {
|
||||||
|
const { id, type, payload } = event.data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let result: any;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'generatePeaks':
|
||||||
|
result = generatePeaks(
|
||||||
|
payload.channelData,
|
||||||
|
payload.width
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'generateMinMaxPeaks':
|
||||||
|
result = generateMinMaxPeaks(
|
||||||
|
payload.channelData,
|
||||||
|
payload.width
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'normalizePeaks':
|
||||||
|
result = normalizePeaks(
|
||||||
|
payload.peaks,
|
||||||
|
payload.targetMax
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'analyzeAudio':
|
||||||
|
result = analyzeAudio(payload.channelData);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'findPeak':
|
||||||
|
result = findPeak(payload.channelData);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown worker message type: ${type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: WorkerResponse = { id, type, result };
|
||||||
|
self.postMessage(response);
|
||||||
|
} catch (error) {
|
||||||
|
const response: WorkerResponse = {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
self.postMessage(response);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate waveform peaks from channel data
|
||||||
|
*/
|
||||||
|
function generatePeaks(channelData: Float32Array, width: number): Float32Array {
|
||||||
|
const peaks = new Float32Array(width);
|
||||||
|
const samplesPerPeak = Math.floor(channelData.length / width);
|
||||||
|
|
||||||
|
for (let i = 0; i < width; i++) {
|
||||||
|
const start = i * samplesPerPeak;
|
||||||
|
const end = Math.min(start + samplesPerPeak, channelData.length);
|
||||||
|
|
||||||
|
let max = 0;
|
||||||
|
for (let j = start; j < end; j++) {
|
||||||
|
const abs = Math.abs(channelData[j]);
|
||||||
|
if (abs > max) {
|
||||||
|
max = abs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
peaks[i] = max;
|
||||||
|
}
|
||||||
|
|
||||||
|
return peaks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate min/max peaks for more detailed waveform visualization
|
||||||
|
*/
|
||||||
|
function generateMinMaxPeaks(
|
||||||
|
channelData: Float32Array,
|
||||||
|
width: number
|
||||||
|
): { min: Float32Array; max: Float32Array } {
|
||||||
|
const min = new Float32Array(width);
|
||||||
|
const max = new Float32Array(width);
|
||||||
|
const samplesPerPeak = Math.floor(channelData.length / width);
|
||||||
|
|
||||||
|
for (let i = 0; i < width; i++) {
|
||||||
|
const start = i * samplesPerPeak;
|
||||||
|
const end = Math.min(start + samplesPerPeak, channelData.length);
|
||||||
|
|
||||||
|
let minVal = 1;
|
||||||
|
let maxVal = -1;
|
||||||
|
|
||||||
|
for (let j = start; j < end; j++) {
|
||||||
|
const val = channelData[j];
|
||||||
|
if (val < minVal) minVal = val;
|
||||||
|
if (val > maxVal) maxVal = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
min[i] = minVal;
|
||||||
|
max[i] = maxVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { min, max };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize peaks to a given range
|
||||||
|
*/
|
||||||
|
function normalizePeaks(peaks: Float32Array, targetMax: number = 1): Float32Array {
|
||||||
|
const normalized = new Float32Array(peaks.length);
|
||||||
|
let max = 0;
|
||||||
|
|
||||||
|
// Find max value
|
||||||
|
for (let i = 0; i < peaks.length; i++) {
|
||||||
|
if (peaks[i] > max) {
|
||||||
|
max = peaks[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize
|
||||||
|
const scale = max > 0 ? targetMax / max : 1;
|
||||||
|
for (let i = 0; i < peaks.length; i++) {
|
||||||
|
normalized[i] = peaks[i] * scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze audio data for statistics
|
||||||
|
*/
|
||||||
|
function analyzeAudio(channelData: Float32Array): {
|
||||||
|
peak: number;
|
||||||
|
rms: number;
|
||||||
|
crestFactor: number;
|
||||||
|
dynamicRange: number;
|
||||||
|
} {
|
||||||
|
let peak = 0;
|
||||||
|
let sumSquares = 0;
|
||||||
|
let min = 1;
|
||||||
|
let max = -1;
|
||||||
|
|
||||||
|
for (let i = 0; i < channelData.length; i++) {
|
||||||
|
const val = channelData[i];
|
||||||
|
const abs = Math.abs(val);
|
||||||
|
|
||||||
|
if (abs > peak) peak = abs;
|
||||||
|
if (val < min) min = val;
|
||||||
|
if (val > max) max = val;
|
||||||
|
|
||||||
|
sumSquares += val * val;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rms = Math.sqrt(sumSquares / channelData.length);
|
||||||
|
const crestFactor = rms > 0 ? peak / rms : 0;
|
||||||
|
const dynamicRange = max - min;
|
||||||
|
|
||||||
|
return {
|
||||||
|
peak,
|
||||||
|
rms,
|
||||||
|
crestFactor,
|
||||||
|
dynamicRange,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find peak value in channel data
|
||||||
|
*/
|
||||||
|
function findPeak(channelData: Float32Array): number {
|
||||||
|
let peak = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < channelData.length; i++) {
|
||||||
|
const abs = Math.abs(channelData[i]);
|
||||||
|
if (abs > peak) peak = abs;
|
||||||
|
}
|
||||||
|
|
||||||
|
return peak;
|
||||||
|
}
|
||||||
29
types/marker.ts
Normal file
29
types/marker.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Region marker type definitions
|
||||||
|
* Markers help navigate and organize the timeline
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marker types
|
||||||
|
* - point: A single point in time (like a cue point)
|
||||||
|
* - region: A time range with start and end
|
||||||
|
*/
|
||||||
|
export type MarkerType = 'point' | 'region';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single marker or region
|
||||||
|
*/
|
||||||
|
export interface Marker {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: MarkerType;
|
||||||
|
time: number; // Start time in seconds
|
||||||
|
endTime?: number; // End time for regions (undefined for point markers)
|
||||||
|
color?: string; // Optional color for visual distinction
|
||||||
|
description?: string; // Optional description/notes
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper type for creating new markers
|
||||||
|
*/
|
||||||
|
export type CreateMarkerInput = Omit<Marker, 'id'>;
|
||||||
Reference in New Issue
Block a user