Compare commits

...

22 Commits

Author SHA1 Message Date
fa69ac649c fix: prevent playhead marker from continuing after pause
Fixed race condition where animation frames could continue after pause() by
adding an isPlayingRef that's checked at the start of updatePlaybackPosition.
This ensures any queued animation frames will exit early if pause was called,
preventing the playhead from continuing to move when audio has stopped.

This fixes the issue where the playhead marker would occasionally keep moving
after hitting spacebar to pause, even though no audio was playing.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 12:53:13 +01:00
efd4cfa607 fix: adjust timeline right padding to match scrollbar width
Changed timeline right padding from 240px to 250px to account for the
vertical scrollbar width in the waveforms area, ensuring perfect alignment
between timeline and waveform playhead markers.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 12:49:40 +01:00
0d8952ca2f fix: ensure consistent timeline-waveform alignment with always-visible scrollbar
Changed waveforms container from overflow-y-auto to overflow-y-scroll to ensure
the vertical scrollbar is always visible. This maintains consistent width alignment
between the timeline and waveform areas, preventing playhead marker misalignment
that occurred when scrollbar appeared/disappeared based on track count.

Fixes the issue where timeline was slightly wider than waveforms when vertical
scrollbar was present, causing ~15px offset in playhead position.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 12:45:13 +01:00
1b30931615 style: add custom-scrollbar styling to all dialog scrollable areas
Added custom-scrollbar class to scrollable elements in:
- EffectBrowser dialog content area
- ProjectsDialog projects list
- CommandPalette results list
- Modal content area
- TrackList automation parameter label containers

This ensures consistent scrollbar styling across all dialogs and UI elements.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 12:37:16 +01:00
d9bd8246c9 fix: prevent infinite loop in waveform rendering
- Add safety check for duration === 0 (first track scenario)
- Ensure trackWidth doesn't exceed canvas width
- Use Math.floor for integer loop iterations
- Prevent division by zero in samplesPerPixel calculation

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 11:24:31 +01:00
dd8d46795a fix: waveform rendering to respect track duration vs project duration
- Calculate trackWidth based on track duration ratio to project duration
- Shorter tracks now render proportionally instead of stretching
- Longer tracks automatically update project duration
- All existing tracks re-render correctly when duration changes

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 11:17:28 +01:00
adcc97eb5a fix: timeline tooltip positioning to account for left padding
- Add controlsWidth offset to tooltip left position
- Tooltip now appears correctly at mouse cursor position

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 10:36:40 +01:00
1855988b83 fix: timeline zoom and waveform rendering improvements
- Fix timeline width calculation to always fill viewport at minimum
- Fix waveform sampling to match timeline width calculation exactly
- Fix infinite scroll loop by removing circular callback
- Ensure scrollbars appear correctly when zooming in
- Use consistent PIXELS_PER_SECOND_BASE = 5 across all components

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 10:34:31 +01:00
477a444c78 feat: implement TimeScale component with proper zoom calculation
- Add TimeScale component with canvas-based rendering
- Use 5 pixels per second base scale (duration * zoom * 5)
- Implement viewport-based rendering for performance
- Add scroll synchronization with waveforms
- Add 240px padding for alignment with track controls and master area
- Apply custom scrollbar styling
- Update all waveform width calculations to match timeline

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 10:12:13 +01:00
119c8c2942 feat: implement medium effort features - markers, web workers, and bezier automation
Implemented three major medium effort features to enhance the audio editor:

**1. Region Markers System**
- Add marker type definitions supporting point markers and regions
- Create useMarkers hook for marker state management
- Build MarkerTimeline component for visual marker display
- Create MarkerDialog component for adding/editing markers
- Add keyboard shortcuts: M (add marker), Shift+M (next), Shift+Ctrl+M (previous)
- Support marker navigation, editing, and deletion

**2. Web Worker for Computations**
- Create audio worker for offloading heavy computations
- Implement worker functions: generatePeaks, generateMinMaxPeaks, normalizePeaks, analyzeAudio, findPeak
- Build useAudioWorker hook for easy worker integration
- Integrate worker into Waveform component with peak caching
- Significantly improve UI responsiveness during waveform generation

**3. Bezier Curve Automation**
- Enhance interpolateAutomationValue to support Bezier curves
- Implement cubic Bezier interpolation with control handles
- Add createSmoothHandles for auto-smooth curve generation
- Add generateBezierCurvePoints for smooth curve rendering
- Support bezier alongside existing linear and step curves

All features are type-safe and integrate seamlessly with the existing codebase.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 08:25:33 +01:00
8720c35f23 fix: add missing 'S' keyboard shortcut for split at cursor
Added keyboard handler for the 'S' key to trigger split at cursor.
The shortcut was defined in the command palette but missing from
the keyboard event handler.

Note: Ctrl+A for Select All was already working correctly.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 07:56:02 +01:00
25ddac349b feat: add copy/paste automation data functionality
Implemented automation clipboard system:
- Added separate automationClipboard state for automation points
- Created handleCopyAutomation function to copy automation lane points
- Created handlePasteAutomation function to paste at current time with time offset
- Added Copy and Clipboard icon buttons to AutomationHeader component
- Automation points preserve curve type and value when copied/pasted
- Points are sorted by time after pasting
- Toast notifications for user feedback
- Ready for integration when automation lanes are actively used

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 07:51:24 +01:00
08b33aacb5 feat: add split at cursor functionality
Implemented track splitting at playback cursor:
- Added handleSplitAtCursor function to split selected track at current time
- Extracts two audio segments: before and after cursor position
- Creates two new tracks from the segments with numbered names
- Removes original track after split
- Added 'Split at Cursor' command to command palette (keyboard shortcut: S)
- Validation for edge cases (no track selected, no audio, invalid position)
- User feedback via toast notifications

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 07:38:07 +01:00
9007522e18 feat: add playback speed control (0.25x - 2x)
Implemented variable playback speed functionality:
- Added playbackRate state and ref to useMultiTrackPlayer (0.25x - 2x range)
- Applied playback rate to AudioBufferSourceNode.playbackRate
- Updated timing calculations to account for playback rate
- Real-time playback speed adjustment for active playback
- Dropdown UI control in PlaybackControls with preset speeds
- Integrated changePlaybackRate function through AudioEditor

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 07:35:39 +01:00
a47bf09a32 feat: add loop playback functionality
Added complete loop functionality with UI controls:
- Loop state management in useMultiTrackPlayer (loopEnabled, loopStart, loopEnd)
- Automatic restart from loop start when reaching loop end during playback
- Loop toggle button in PlaybackControls with Repeat icon
- Loop points UI showing when loop is enabled (similar to punch in/out)
- Manual loop point adjustment with number inputs
- Quick set buttons to set loop points to current time
- Wired loop functionality through AudioEditor component

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 07:31:53 +01:00
aba26126cc refactor: remove configurable track height setting
Removed the defaultTrackHeight setting from UI preferences as it doesn't need
to be user-configurable. All tracks now use DEFAULT_TRACK_HEIGHT constant (400px).

Changes:
- Removed defaultTrackHeight from UISettings interface
- Removed track height slider from GlobalSettingsDialog
- Updated AudioEditor to use DEFAULT_TRACK_HEIGHT constant directly
- Simplified dependency arrays in addTrack callbacks

This simplifies the settings interface while maintaining the same visual behavior.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 20:55:45 +01:00
66a515ba79 fix: remove AudioWorklet browser warning
Removed the AudioWorklet compatibility check warning since it's an optional
feature that doesn't affect core functionality. The app works fine without
AudioWorklet support, using standard Web Audio API nodes instead.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 20:52:28 +01:00
691f75209d docs: mark Phase 15.2 Responsive Design as complete
Updated PLAN.md to reflect completion of mobile responsiveness enhancements:
- Touch gesture support via collapse/expand chevron buttons
- Collapsible track and master controls with detailed state descriptions
- Track collapse buttons on mobile (dual chevron system)
- Mobile vertical stacking with automation and effects bars
- Height synchronization between track controls and waveform containers

Phase 15.2 now fully complete with comprehensive mobile support.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 20:51:15 +01:00
908e6caaf8 feat: enhance mobile responsiveness with collapsible controls and automation/effects bars
Added comprehensive mobile support for Phase 15 (Polish & Optimization):

**Mobile Layout Enhancements:**
- Track controls now collapsible on mobile with two states:
  - Collapsed: minimal controls with expand chevron, R/M/S buttons, horizontal level meter
  - Expanded: full height fader, pan control, all buttons
- Track collapse buttons added to mobile view (left chevron for track collapse, right chevron for control collapse)
- Master controls collapse button hidden on desktop (lg:hidden)
- Automation and effects bars now available on mobile layout
- Both bars collapsible with eye/eye-off icons, horizontally scrollable when zoomed
- Mobile vertical stacking: controls → waveform → automation → effects per track

**Bug Fixes:**
- Fixed track controls and waveform container height matching on desktop
- Fixed Modal component prop: isOpen → open in all dialog components
- Fixed TypeScript null check for audioBuffer.duration
- Fixed keyboard shortcut category: 'help' → 'view'

**Technical Improvements:**
- Consistent height calculation using trackHeight variable
- Proper responsive breakpoints with Tailwind (sm:640px, lg:1024px)
- Progressive disclosure pattern for mobile controls

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 20:50:44 +01:00
e09bc1449c docs: mark Phase 14 (Settings & Preferences) as complete
Updated PLAN.md to reflect completion of Phase 14:
- Global settings system with localStorage persistence
- 5 settings tabs: Recording, Audio, Editor, Interface, Performance
- Real-time application to editor behavior
- All major settings implemented and functional

Applied settings:
- defaultTrackHeight → new track creation
- defaultZoom → initial zoom state
- enableSpectrogram → analyzer visibility
- sampleRate → recording configuration

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 18:32:19 +01:00
d03080d3d2 feat: apply enableSpectrogram and sampleRate settings to editor
Applied performance and audio settings to actual editor behavior:
- enableSpectrogram: conditionally show/hide spectrogram analyzer option
- sampleRate: sync recording sample rate with global audio settings

Changes:
- Switch analyzer view away from spectrogram when setting is disabled
- Adjust analyzer toggle grid columns based on spectrogram visibility
- Sync recording hook's sampleRate with global settings via useEffect

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 18:29:05 +01:00
484e3261c5 feat: apply defaultTrackHeight and defaultZoom settings
- Modified createTrack and createTrackFromBuffer to accept height parameter
- Updated useMultiTrack to pass height when creating tracks
- Applied settings.ui.defaultTrackHeight when adding new tracks
- Applied settings.editor.defaultZoom for initial zoom state
- Removed duplicate useSettings hook declaration in AudioEditor
- New tracks now use configured default height from settings

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 18:23:41 +01:00
35 changed files with 4265 additions and 242 deletions

97
PLAN.md
View File

@@ -2,7 +2,7 @@
## 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
-**Phase 1**: Project Setup & Core Infrastructure (95% complete)
@@ -148,6 +148,16 @@
- ✅ Dynamic file extension display
- ✅ 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
- **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)
@@ -155,6 +165,9 @@
- **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 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)
- [ ] Shortcut conflict detection (future enhancement)
### Phase 14: Settings & Preferences
### Phase 14: Settings & Preferences ✅ COMPLETE
#### 14.1 Audio Settings
- [ ] Audio output device selection
- [ ] Buffer size/latency configuration
- [ ] Sample rate preference
- [ ] Auto-normalize on import
**✅ Accomplished:**
- Global settings system with localStorage persistence
- Settings dialog with 5 tabs (Recording, Audio, Editor, Interface, Performance)
- Real-time settings application to editor behavior
- Category-specific reset buttons
- Merge with defaults on load for backward compatibility
#### 14.2 UI Settings
- [ ] Theme selection (dark/light/auto)
- [ ] Color scheme customization
- [ ] Waveform colors
- [ ] Font size
#### 14.1 Audio Settings
- [ ] Audio output device selection (future: requires device enumeration API)
- [x] Buffer size/latency configuration
- [x] Sample rate preference (applied to recording)
- [x] Auto-normalize on import
#### 14.3 Editor Settings
- [ ] Auto-save interval
- [ ] Undo history limit
- [ ] Snap-to-grid toggle
- [ ] Grid resolution
- [ ] Default zoom level
#### 14.2 UI Settings
- [x] Theme selection (dark/light/auto)
- [x] Font size (small/medium/large)
- [x] Default track height (120-400px, applied to new tracks)
- [ ] Color scheme customization (future: advanced theming)
#### 14.4 Performance Settings
- [ ] Peak calculation quality
- [ ] Waveform rendering quality
- [ ] Enable/disable spectrogram
- [ ] Maximum file size limit
#### 14.3 Editor Settings
- [x] Auto-save interval (0-60 seconds)
- [x] Undo history limit (10-200 operations)
- [x] Snap-to-grid toggle
- [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
#### 15.1 Performance Optimization
- [ ] Web Worker for heavy computations
- [ ] 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
- [ ] Memory leak prevention
- [x] Memory leak prevention (audio-cleanup utilities, proper cleanup in useRecording, animation frame cancellation in visualizations)
#### 15.2 Responsive Design
- [ ] Mobile-friendly layout
- [ ] Touch gesture support
- [ ] Adaptive toolbar (hide on mobile)
- [ ] Vertical scrolling for track list
#### 15.2 Responsive Design
- [x] Mobile-friendly layout (responsive header, adaptive toolbar with icon-only buttons on small screens)
- [x] Touch gesture support (collapse/expand controls with chevron buttons)
- [x] Adaptive toolbar (hide less critical buttons on mobile: Export on md, Clear All on lg)
- [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
- [ ] Graceful error messages
- [ ] File format error handling
- [ ] Memory limit warnings
- [ ] Browser compatibility checks
- [x] Graceful error messages (toast notifications for copy/paste/edit operations)
- [x] File format error handling (UnsupportedFormatDialog with format validation and decode error catching)
- [x] Memory limit warnings (MemoryWarningDialog with file size checks)
- [x] Browser compatibility checks (BrowserCompatDialog with Web Audio API detection)
#### 15.4 Documentation
- [ ] User guide
- [ ] Keyboard shortcuts reference
- [x] Keyboard shortcuts reference (KeyboardShortcutsDialog with ? shortcut and command palette integration)
- [ ] Effect descriptions
- [ ] Troubleshooting guide

View File

@@ -1,7 +1,7 @@
'use client';
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 { cn } from '@/lib/utils/cn';
import type { AutomationMode } from '@/types/automation';
@@ -21,6 +21,9 @@ export interface AutomationHeaderProps {
availableParameters?: Array<{ id: string; name: string }>;
selectedParameterId?: string;
onParameterChange?: (parameterId: string) => void;
// Copy/Paste automation
onCopyAutomation?: () => void;
onPasteAutomation?: () => void;
}
const MODE_LABELS: Record<AutomationMode, string> = {
@@ -51,6 +54,8 @@ export function AutomationHeader({
availableParameters,
selectedParameterId,
onParameterChange,
onCopyAutomation,
onPasteAutomation,
}: AutomationHeaderProps) {
const modes: AutomationMode[] = ['read', 'write', 'touch', 'latch'];
const currentModeIndex = modes.indexOf(mode);
@@ -145,6 +150,34 @@ export function AutomationHeader({
</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 */}
<Button
variant="ghost"

View File

@@ -1,6 +1,7 @@
'use client';
import * as React from 'react';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { CircularKnob } from '@/components/ui/CircularKnob';
import { MasterFader } from './MasterFader';
import { cn } from '@/lib/utils/cn';
@@ -12,10 +13,12 @@ export interface MasterControlsProps {
rmsLevel: number;
isClipping: boolean;
isMuted?: boolean;
collapsed?: boolean; // For collapsible on mobile/small screens
onVolumeChange: (volume: number) => void;
onPanChange: (pan: number) => void;
onMuteToggle: () => void;
onResetClip?: () => void;
onToggleCollapse?: () => void;
className?: string;
}
@@ -26,20 +29,81 @@ export function MasterControls({
rmsLevel,
isClipping,
isMuted = false,
collapsed = false,
onVolumeChange,
onPanChange,
onMuteToggle,
onResetClip,
onToggleCollapse,
className,
}: MasterControlsProps) {
// Collapsed view - minimal controls
if (collapsed) {
return (
<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 (
<div className={cn(
'flex flex-col items-center gap-3 px-4 py-3 bg-card/50 border-2 border-accent/50 rounded-lg',
className
)}>
{/* Master Label */}
<div className="text-[10px] font-bold text-accent uppercase tracking-wider">
Master
{/* Master Label with collapse button */}
<div className="flex items-center justify-between w-full">
<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>
{/* Pan Control */}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -76,7 +76,7 @@ export function ProjectsDialog({
</div>
{/* 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 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<FolderOpen className="h-16 w-16 text-muted-foreground mb-4" />

View 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

View File

@@ -1,7 +1,7 @@
'use client';
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 { cn } from '@/lib/utils/cn';
@@ -31,6 +31,13 @@ export interface PlaybackControlsProps {
onPunchOutTimeChange?: (time: number) => void;
overdubEnabled?: boolean;
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({
@@ -59,6 +66,13 @@ export function PlaybackControls({
onPunchOutTimeChange,
overdubEnabled = false,
onOverdubEnabledChange,
loopEnabled = false,
loopStart = 0,
loopEnd = 0,
onToggleLoop,
onSetLoopPoints,
playbackRate = 1.0,
onPlaybackRateChange,
}: PlaybackControlsProps) {
const handlePlayPause = () => {
if (isPlaying) {
@@ -249,8 +263,100 @@ export function PlaybackControls({
</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>
{/* 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>
);
}

View File

@@ -2,7 +2,7 @@
import * as React from 'react';
import { cn } from '@/lib/utils/cn';
import { generateMinMaxPeaks } from '@/lib/waveform/peaks';
import { useAudioWorker } from '@/lib/hooks/useAudioWorker';
import type { Selection } from '@/types/selection';
export interface WaveformProps {
@@ -39,6 +39,16 @@ export function Waveform({
const [isSelecting, setIsSelecting] = React.useState(false);
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
React.useEffect(() => {
const handleResize = () => {
@@ -52,10 +62,35 @@ export function Waveform({
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
React.useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !audioBuffer) return;
if (!canvas || !audioBuffer || !peaksCache) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
@@ -75,8 +110,8 @@ export function Waveform({
// Calculate visible width based on zoom
const visibleWidth = Math.floor(width * zoom);
// Generate peaks for visible portion
const { min, max } = generateMinMaxPeaks(audioBuffer, visibleWidth, 0);
// Use cached peaks
const { min, max } = peaksCache;
// Draw waveform
const middle = height / 2;
@@ -176,7 +211,7 @@ export function Waveform({
ctx.lineTo(progressX, height);
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>) => {
if (!onSeek || !duration || isDragging) return;

View File

@@ -105,7 +105,7 @@ export function EffectBrowser({ open, onClose, onSelectEffect }: EffectBrowserPr
</div>
{/* 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">
{Object.entries(filteredCategories).map(([category, effects]) => (
<div key={category}>

View 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>
);
}

View 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>
);
}

View File

@@ -400,29 +400,6 @@ export function GlobalSettingsDialog({
Adjust the UI font size. Requires reload.
</p>
</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>
)}

View 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>
);
}

View File

@@ -21,6 +21,7 @@ import {
COLLAPSED_TRACK_HEIGHT,
MIN_TRACK_HEIGHT,
MAX_TRACK_HEIGHT,
DEFAULT_TRACK_HEIGHT,
} from "@/types/track";
import { Button } from "@/components/ui/Button";
import { Slider } from "@/components/ui/Slider";
@@ -328,14 +329,29 @@ export function Track({
const buffer = track.audioBuffer;
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
ctx.fillStyle = track.color;
ctx.strokeStyle = track.color;
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 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
const handleResizeStart = React.useCallback(
@@ -656,7 +674,9 @@ export function Track({
? "bg-primary/10 border-r-primary"
: "bg-card border-r-transparent hover:bg-accent/30",
)}
style={{ height: trackHeight }}
style={{
height: `${trackHeight}px`,
}}
onClick={(e) => {
e.stopPropagation();
if (onSelect) onSelect();
@@ -768,8 +788,8 @@ export function Track({
className="relative h-full"
style={{
minWidth:
track.audioBuffer && zoom > 1
? `${duration * zoom * 100}px`
track.audioBuffer && zoom >= 1
? `${duration * zoom * 5}px`
: "100%",
}}
>

View File

@@ -1,7 +1,7 @@
'use client';
import * as React from 'react';
import { Circle, Headphones, MoreHorizontal, ChevronRight, ChevronDown } from 'lucide-react';
import { Circle, Headphones, MoreHorizontal, ChevronRight, ChevronDown, ChevronUp } from 'lucide-react';
import { CircularKnob } from '@/components/ui/CircularKnob';
import { TrackFader } from './TrackFader';
import { cn } from '@/lib/utils/cn';
@@ -20,6 +20,7 @@ export interface TrackControlsProps {
showAutomation?: boolean;
showEffects?: boolean;
isRecording?: boolean;
mobileCollapsed?: boolean; // For mobile view collapsible controls
onNameChange: (name: string) => void;
onToggleCollapse: () => void;
onVolumeChange: (volume: number) => void;
@@ -33,6 +34,7 @@ export interface TrackControlsProps {
onVolumeTouchEnd?: () => void;
onPanTouchStart?: () => void;
onPanTouchEnd?: () => void;
onToggleMobileCollapse?: () => void;
className?: string;
}
@@ -50,6 +52,7 @@ export function TrackControls({
showAutomation = false,
showEffects = false,
isRecording = false,
mobileCollapsed = false,
onNameChange,
onToggleCollapse,
onVolumeChange,
@@ -63,6 +66,7 @@ export function TrackControls({
onVolumeTouchEnd,
onPanTouchStart,
onPanTouchEnd,
onToggleMobileCollapse,
className,
}: TrackControlsProps) {
const [isEditingName, setIsEditingName] = React.useState(false);
@@ -91,11 +95,238 @@ export function TrackControls({
}
};
return (
// Mobile collapsed view - minimal controls (like master controls)
if (mobileCollapsed) {
return (
<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(
'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
)}>
{/* 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 */}
<div className="flex items-center gap-1 w-full">
<button
@@ -220,6 +451,7 @@ export function TrackControls({
)}
</div>
</div>
</div>
</div>
</>
);
}

View File

@@ -36,6 +36,9 @@ export interface TrackListProps {
trackLevels?: Record<string, number>;
onParameterTouched?: (trackId: string, laneId: string, touched: boolean) => void;
isPlaying?: boolean;
timeScaleScrollRef?: React.MutableRefObject<HTMLDivElement | null>;
onTimeScaleScroll?: () => void;
timeScaleScrollHandlerRef?: React.MutableRefObject<(() => void) | null>;
}
export function TrackList({
@@ -57,6 +60,9 @@ export function TrackList({
trackLevels = {},
onParameterTouched,
isPlaying = false,
timeScaleScrollRef: externalTimeScaleScrollRef,
onTimeScaleScroll,
timeScaleScrollHandlerRef,
}: TrackListProps) {
const [importDialogOpen, setImportDialogOpen] = React.useState(false);
const [effectBrowserTrackId, setEffectBrowserTrackId] = React.useState<string | null>(null);
@@ -66,6 +72,8 @@ export function TrackList({
// Refs for horizontal scroll synchronization (per track)
const waveformHScrollRefs = 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);
// Synchronize vertical scroll between controls and waveforms
@@ -100,6 +108,11 @@ export function TrackList({
el.scrollLeft = scrollLeft;
});
// Sync time scale
if (timeScaleScrollRef.current) {
timeScaleScrollRef.current.scrollLeft = scrollLeft;
}
setSyncingScroll(false);
}, [syncingScroll]);
@@ -127,9 +140,45 @@ export function TrackList({
}
});
// Sync time scale
if (timeScaleScrollRef.current) {
timeScaleScrollRef.current.scrollLeft = scrollLeft;
}
setSyncingScroll(false);
}, [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) => {
if (onImportTrack) {
onImportTrack(buffer, name);
@@ -167,8 +216,509 @@ export function TrackList({
return (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Track List - Two Column Layout */}
<div className="flex-1 flex overflow-hidden">
{/* Mobile Layout - Single Column (Stacked: Controls → Waveform per track) */}
<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) */}
<div ref={controlsScrollRef} className="w-60 flex-shrink-0 overflow-hidden pb-3 border-r border-border">
{tracks.map((track) => (
@@ -266,19 +816,19 @@ export function TrackList({
<div
ref={waveformScrollRef}
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">
{tracks.map((track) => (
<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
className="flex flex-col"
style={{
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">
{/* Upload hint for empty tracks - stays fixed as overlay */}
{!track.audioBuffer && !track.collapsed && (
@@ -298,7 +848,7 @@ export function TrackList({
<div
className="h-full"
style={{
minWidth: duration && zoom > 1 ? `${duration * zoom * 100}px` : '100%',
minWidth: duration && zoom >= 1 ? `${duration * zoom * 5}px` : '100%',
}}
>
<Track
@@ -446,7 +996,7 @@ export function TrackList({
)}
{/* 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) => (
<span
key={index}
@@ -571,7 +1121,7 @@ export function TrackList({
>
<div
style={{
minWidth: duration && zoom > 1 ? `${duration * zoom * 100}px` : '100%',
minWidth: duration && zoom >= 1 ? `${duration * zoom * 5}px` : '100%',
}}
>
{track.automation.lanes

View File

@@ -144,7 +144,7 @@ export function CommandPalette({ actions, className }: CommandPaletteProps) {
</div>
{/* 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 ? (
<div className="p-8 text-center text-muted-foreground text-sm">
No commands found

View File

@@ -102,7 +102,7 @@ export function Modal({
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
<div className="flex-1 overflow-y-auto custom-scrollbar p-4">
{children}
</div>

View File

@@ -127,7 +127,14 @@ export function interpolateAutomationValue(
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 valueDelta = nextPoint.value - prevPoint.value;
const progress = (time - prevPoint.time) / timeDelta;
@@ -139,6 +146,117 @@ export function interpolateAutomationValue(
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
*/

View File

@@ -3,6 +3,7 @@
*/
import { getAudioContext } from './context';
import { checkFileMemoryLimit, type MemoryCheckResult } from '../utils/memory-limits';
export interface ImportOptions {
convertToMono?: boolean;
@@ -248,6 +249,15 @@ export function isSupportedAudioFormat(file: File): boolean {
/\.(wav|mp3|ogg|webm|flac|aac|m4a|aiff|aif)$/i.test(file.name);
}
/**
* Check memory requirements for an audio file before decoding
* @param file File to check
* @returns Memory check result with warning if file is large
*/
export function checkAudioFileMemory(file: File): MemoryCheckResult {
return checkFileMemoryLimit(file.size);
}
/**
* Format duration in seconds to MM:SS format
*/

View File

@@ -17,7 +17,7 @@ export function generateTrackId(): string {
/**
* 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 randomColor = colors[Math.floor(Math.random() * colors.length)];
@@ -30,7 +30,7 @@ export function createTrack(name?: string, color?: TrackColor): Track {
id: trackId,
name: trackName,
color: TRACK_COLORS[color || randomColor],
height: DEFAULT_TRACK_HEIGHT,
height: height ?? DEFAULT_TRACK_HEIGHT,
audioBuffer: null,
volume: 0.8,
pan: 0,
@@ -70,11 +70,12 @@ export function createTrack(name?: string, color?: TrackColor): Track {
export function createTrackFromBuffer(
buffer: AudioBuffer,
name?: string,
color?: TrackColor
color?: TrackColor,
height?: number
): Track {
// Ensure name is a string before passing to createTrack
const trackName = typeof name === 'string' && name.trim() ? name.trim() : undefined;
const track = createTrack(trackName, color);
const track = createTrack(trackName, color, height);
track.audioBuffer = buffer;
return track;
}

138
lib/hooks/useAudioWorker.ts Normal file
View 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
View 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,
};
}

View File

@@ -6,14 +6,14 @@ export function useMultiTrack() {
// Note: localStorage persistence disabled in favor of IndexedDB project management
const [tracks, setTracks] = useState<Track[]>([]);
const addTrack = useCallback((name?: string) => {
const track = createTrack(name);
const addTrack = useCallback((name?: string, height?: number) => {
const track = createTrack(name, undefined, height);
setTracks((prev) => [...prev, track]);
return track;
}, []);
const addTrackFromBuffer = useCallback((buffer: AudioBuffer, name?: string) => {
const track = createTrackFromBuffer(buffer, name);
const addTrackFromBuffer = useCallback((buffer: AudioBuffer, name?: string, height?: number) => {
const track = createTrackFromBuffer(buffer, name, undefined, height);
setTracks((prev) => [...prev, track]);
return track;
}, []);

View File

@@ -9,6 +9,10 @@ export interface MultiTrackPlayerState {
isPlaying: boolean;
currentTime: number;
duration: number;
loopEnabled: boolean;
loopStart: number;
loopEnd: number;
playbackRate: number;
}
export interface TrackLevel {
@@ -32,6 +36,10 @@ export function useMultiTrackPlayer(
const [masterPeakLevel, setMasterPeakLevel] = useState(0);
const [masterRmsLevel, setMasterRmsLevel] = useState(0);
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 sourceNodesRef = useRef<AudioBufferSourceNode[]>([]);
@@ -51,12 +59,29 @@ export function useMultiTrackPlayer(
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 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
useEffect(() => {
tracksRef.current = 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
useEffect(() => {
onRecordAutomationRef.current = onRecordAutomation;
@@ -71,7 +96,11 @@ export function useMultiTrackPlayer(
}
}
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
const linearToDbScale = (linear: number): number => {
@@ -291,11 +320,56 @@ export function useMultiTrackPlayer(
}, []);
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;
// 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) {
setIsPlaying(false);
isMonitoringLevelsRef.current = false;
@@ -401,6 +475,9 @@ export function useMultiTrackPlayer(
outputNode.connect(masterGain);
console.log('[MultiTrackPlayer] Effect output connected with', effectNodes.length, 'effect nodes');
// Set playback rate
source.playbackRate.value = playbackRateRef.current;
// Start playback from current position
source.start(0, pausedAtRef.current);
@@ -424,6 +501,7 @@ export function useMultiTrackPlayer(
}
startTimeRef.current = audioContext.currentTime;
isPlayingRef.current = true;
setIsPlaying(true);
updatePlaybackPosition();
@@ -454,6 +532,7 @@ export function useMultiTrackPlayer(
pausedAtRef.current = Math.min(pausedAtRef.current + elapsed, duration);
setCurrentTime(pausedAtRef.current);
isPlayingRef.current = false;
setIsPlaying(false);
// Stop level monitoring
@@ -822,6 +901,33 @@ export function useMultiTrackPlayer(
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 {
isPlaying,
currentTime,
@@ -837,5 +943,13 @@ export function useMultiTrackPlayer(
stop,
seek,
togglePlayPause,
loopEnabled,
loopStart,
loopEnd,
toggleLoop,
setLoopPoints,
setLoopFromSelection,
playbackRate,
changePlaybackRate,
};
}

View File

@@ -11,7 +11,6 @@ export interface AudioSettings {
export interface UISettings {
theme: 'dark' | 'light' | 'auto';
fontSize: 'small' | 'medium' | 'large';
defaultTrackHeight: number; // 120-400px
}
export interface EditorSettings {
@@ -45,7 +44,6 @@ const DEFAULT_SETTINGS: Settings = {
ui: {
theme: 'dark',
fontSize: 'medium',
defaultTrackHeight: 400,
},
editor: {
autoSaveInterval: 3, // 3 seconds

149
lib/utils/audio-cleanup.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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'>;