Compare commits
44 Commits
fe519dab01
...
beb7085c89
| Author | SHA1 | Date | |
|---|---|---|---|
| beb7085c89 | |||
| cbcd38b1ed | |||
| ecb5152d21 | |||
| bc7158dbe0 | |||
| ee24f04d76 | |||
| 2a0d6cd673 | |||
| 2948557e94 | |||
| 0dfbdca00c | |||
| f74c8abbf9 | |||
| 46ce75f3a5 | |||
| 439c14db87 | |||
| 25be7027f3 | |||
| b9ffbf28ef | |||
| a3fb8a564e | |||
| 6bfc8d3cfe | |||
| fa3588d619 | |||
| cb396ddfd6 | |||
| 25e843236d | |||
| 4547446ced | |||
| f3f5b65e1e | |||
| a8f2391400 | |||
| f640f2f9d4 | |||
| 2f8718626c | |||
| 5817598c48 | |||
| c6b9cb9af6 | |||
| a034ca7e85 | |||
| e3582b7b9a | |||
| 1404228239 | |||
| 889b2b91ae | |||
| 8ffa8e8b81 | |||
| d3ef961d31 | |||
| da5045e4f8 | |||
| 8023369239 | |||
| b8d2cb9585 | |||
| 401bab8886 | |||
| 8bd326a21b | |||
| 4381057f3f | |||
| e376f3b0b4 | |||
| f42e5d4556 | |||
| 64902fa707 | |||
| cf1358e051 | |||
| 7c19c069bf | |||
| 53d436a174 | |||
| 6b540ef8fb |
24
PLAN.md
24
PLAN.md
@@ -94,6 +94,18 @@
|
|||||||
- ✅ Synchronized playback across all tracks
|
- ✅ Synchronized playback across all tracks
|
||||||
- ✅ Per-track gain and pan during playback
|
- ✅ Per-track gain and pan during playback
|
||||||
- ✅ Solo/Mute handling during playback
|
- ✅ Solo/Mute handling during playback
|
||||||
|
- ✅ Per-track effect chains with device rack
|
||||||
|
- ✅ Collapsible effects section below each track (192px height)
|
||||||
|
- ✅ Effect browser with categorized effects
|
||||||
|
- ✅ Horizontal scrolling device rack (Ableton-style)
|
||||||
|
- ✅ Individual effect cards with side-folding design (40px collapsed, 384px+ expanded)
|
||||||
|
- ✅ Real-time parameter controls for all effects (filters, dynamics, time-based, advanced)
|
||||||
|
- ✅ Inline parameter editing with sliders and controls (multi-column grid layout)
|
||||||
|
- ✅ Real-time effect processing during playback with Web Audio API nodes
|
||||||
|
- ✅ Effect bypass functionality (disable/enable effects in real-time)
|
||||||
|
- ✅ Supported real-time effects: All filters, compressor, limiter, gate, delay
|
||||||
|
- 🔲 Advanced real-time effects: Reverb, chorus, flanger, phaser, distortion (TODO: Complex node graphs)
|
||||||
|
- 🔲 Master channel effects (TODO: Implement master effect chain UI similar to per-track effects)
|
||||||
|
|
||||||
### Next Steps
|
### Next Steps
|
||||||
- **Phase 6**: Audio effects ✅ COMPLETE (Basic + Filters + Dynamics + Time-Based + Advanced + Chain Management)
|
- **Phase 6**: Audio effects ✅ COMPLETE (Basic + Filters + Dynamics + Time-Based + Advanced + Chain Management)
|
||||||
@@ -564,10 +576,14 @@ audio-ui/
|
|||||||
- [ ] Send/Return effects - FUTURE
|
- [ ] Send/Return effects - FUTURE
|
||||||
- [ ] Sidechain support (advanced) - FUTURE
|
- [ ] Sidechain support (advanced) - FUTURE
|
||||||
|
|
||||||
#### 7.4 Track Effects (Pending - Phase 8+)
|
#### 7.4 Track Effects (Complete)
|
||||||
- [ ] Per-track effect chain
|
- [x] Per-track effect chain
|
||||||
- [ ] Effect rack UI
|
- [x] Effect rack UI
|
||||||
- [ ] Effect bypass per track
|
- [x] Effect bypass per track
|
||||||
|
- [x] Real-time effect processing during playback
|
||||||
|
- [x] Add/remove effects during playback
|
||||||
|
- [x] Real-time parameter updates
|
||||||
|
- [x] Effect chain persistence (localStorage)
|
||||||
|
|
||||||
### Phase 8: Recording
|
### Phase 8: Recording
|
||||||
|
|
||||||
|
|||||||
124
app/globals.css
124
app/globals.css
@@ -19,97 +19,97 @@
|
|||||||
/* CSS Variables for theming */
|
/* CSS Variables for theming */
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
/* Light mode colors using OKLCH */
|
/* Light mode colors using OKLCH - bright neon palette */
|
||||||
--background: oklch(100% 0 0);
|
--background: oklch(98% 0.03 180);
|
||||||
--foreground: oklch(9.8% 0.038 285.8);
|
--foreground: oklch(20% 0.12 310);
|
||||||
|
|
||||||
--card: oklch(100% 0 0);
|
--card: oklch(99% 0.02 200);
|
||||||
--card-foreground: oklch(9.8% 0.038 285.8);
|
--card-foreground: oklch(20% 0.12 310);
|
||||||
|
|
||||||
--popover: oklch(100% 0 0);
|
--popover: oklch(99% 0.02 200);
|
||||||
--popover-foreground: oklch(9.8% 0.038 285.8);
|
--popover-foreground: oklch(20% 0.12 310);
|
||||||
|
|
||||||
--primary: oklch(22.4% 0.053 285.8);
|
--primary: oklch(58% 0.28 320);
|
||||||
--primary-foreground: oklch(98% 0 0);
|
--primary-foreground: oklch(99% 0.02 200);
|
||||||
|
|
||||||
--secondary: oklch(96.1% 0 0);
|
--secondary: oklch(92% 0.08 200);
|
||||||
--secondary-foreground: oklch(13.8% 0.038 285.8);
|
--secondary-foreground: oklch(25% 0.15 300);
|
||||||
|
|
||||||
--muted: oklch(96.1% 0 0);
|
--muted: oklch(94% 0.05 190);
|
||||||
--muted-foreground: oklch(45.1% 0.015 285.9);
|
--muted-foreground: oklch(40% 0.12 260);
|
||||||
|
|
||||||
--accent: oklch(96.1% 0 0);
|
--accent: oklch(90% 0.12 180);
|
||||||
--accent-foreground: oklch(13.8% 0.038 285.8);
|
--accent-foreground: oklch(25% 0.18 310);
|
||||||
|
|
||||||
--destructive: oklch(60.2% 0.168 29.2);
|
--destructive: oklch(60% 0.28 15);
|
||||||
--destructive-foreground: oklch(98% 0 0);
|
--destructive-foreground: oklch(99% 0.02 200);
|
||||||
|
|
||||||
--border: oklch(89.8% 0 0);
|
--border: oklch(85% 0.08 200);
|
||||||
--input: oklch(89.8% 0 0);
|
--input: oklch(92% 0.06 190);
|
||||||
--ring: oklch(22.4% 0.053 285.8);
|
--ring: oklch(58% 0.28 320);
|
||||||
|
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
|
|
||||||
--success: oklch(60% 0.15 145);
|
--success: oklch(58% 0.25 160);
|
||||||
--success-foreground: oklch(98% 0 0);
|
--success-foreground: oklch(99% 0.02 200);
|
||||||
|
|
||||||
--warning: oklch(75% 0.15 85);
|
--warning: oklch(68% 0.25 85);
|
||||||
--warning-foreground: oklch(20% 0 0);
|
--warning-foreground: oklch(20% 0.12 310);
|
||||||
|
|
||||||
--info: oklch(65% 0.15 240);
|
--info: oklch(62% 0.25 240);
|
||||||
--info-foreground: oklch(98% 0 0);
|
--info-foreground: oklch(99% 0.02 200);
|
||||||
|
|
||||||
/* Audio-specific colors */
|
/* Audio-specific colors - neon cyan/magenta */
|
||||||
--waveform: oklch(50% 0.1 240);
|
--waveform: oklch(60% 0.26 200);
|
||||||
--waveform-progress: oklch(60% 0.15 145);
|
--waveform-progress: oklch(58% 0.28 320);
|
||||||
--waveform-selection: oklch(65% 0.15 240);
|
--waveform-selection: oklch(62% 0.26 180);
|
||||||
--waveform-bg: oklch(98% 0 0);
|
--waveform-bg: oklch(99% 0.015 190);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
/* Dark mode colors using OKLCH */
|
/* Dark mode colors using OKLCH - vibrant neon palette */
|
||||||
--background: oklch(9.8% 0.038 285.8);
|
--background: oklch(15% 0.015 265);
|
||||||
--foreground: oklch(98% 0 0);
|
--foreground: oklch(92% 0.02 180);
|
||||||
|
|
||||||
--card: oklch(9.8% 0.038 285.8);
|
--card: oklch(18% 0.02 270);
|
||||||
--card-foreground: oklch(98% 0 0);
|
--card-foreground: oklch(92% 0.02 180);
|
||||||
|
|
||||||
--popover: oklch(9.8% 0.038 285.8);
|
--popover: oklch(18% 0.02 270);
|
||||||
--popover-foreground: oklch(98% 0 0);
|
--popover-foreground: oklch(92% 0.02 180);
|
||||||
|
|
||||||
--primary: oklch(98% 0 0);
|
--primary: oklch(75% 0.25 310);
|
||||||
--primary-foreground: oklch(13.8% 0.038 285.8);
|
--primary-foreground: oklch(18% 0.02 270);
|
||||||
|
|
||||||
--secondary: oklch(17.7% 0.038 285.8);
|
--secondary: oklch(22% 0.03 280);
|
||||||
--secondary-foreground: oklch(98% 0 0);
|
--secondary-foreground: oklch(85% 0.15 180);
|
||||||
|
|
||||||
--muted: oklch(17.7% 0.038 285.8);
|
--muted: oklch(20% 0.02 270);
|
||||||
--muted-foreground: oklch(63.9% 0.012 285.9);
|
--muted-foreground: oklch(65% 0.1 200);
|
||||||
|
|
||||||
--accent: oklch(17.7% 0.038 285.8);
|
--accent: oklch(25% 0.03 290);
|
||||||
--accent-foreground: oklch(98% 0 0);
|
--accent-foreground: oklch(85% 0.2 320);
|
||||||
|
|
||||||
--destructive: oklch(50% 0.2 29.2);
|
--destructive: oklch(65% 0.25 20);
|
||||||
--destructive-foreground: oklch(98% 0 0);
|
--destructive-foreground: oklch(92% 0.02 180);
|
||||||
|
|
||||||
--border: oklch(17.7% 0.038 285.8);
|
--border: oklch(30% 0.05 280);
|
||||||
--input: oklch(17.7% 0.038 285.8);
|
--input: oklch(22% 0.03 280);
|
||||||
--ring: oklch(83.1% 0.012 285.9);
|
--ring: oklch(75% 0.25 310);
|
||||||
|
|
||||||
--success: oklch(55% 0.15 145);
|
--success: oklch(70% 0.22 160);
|
||||||
--success-foreground: oklch(98% 0 0);
|
--success-foreground: oklch(18% 0.02 270);
|
||||||
|
|
||||||
--warning: oklch(70% 0.15 85);
|
--warning: oklch(75% 0.22 80);
|
||||||
--warning-foreground: oklch(20% 0 0);
|
--warning-foreground: oklch(18% 0.02 270);
|
||||||
|
|
||||||
--info: oklch(60% 0.15 240);
|
--info: oklch(72% 0.22 240);
|
||||||
--info-foreground: oklch(98% 0 0);
|
--info-foreground: oklch(18% 0.02 270);
|
||||||
|
|
||||||
/* Audio-specific colors */
|
/* Audio-specific colors - neon cyan/magenta */
|
||||||
--waveform: oklch(70% 0.15 240);
|
--waveform: oklch(72% 0.25 200);
|
||||||
--waveform-progress: oklch(65% 0.15 145);
|
--waveform-progress: oklch(75% 0.25 310);
|
||||||
--waveform-selection: oklch(70% 0.15 240);
|
--waveform-selection: oklch(70% 0.25 180);
|
||||||
--waveform-bg: oklch(12% 0.038 285.8);
|
--waveform-bg: oklch(12% 0.02 270);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Music } from 'lucide-react';
|
import { Music, Plus, Upload, Trash2 } from 'lucide-react';
|
||||||
import { PlaybackControls } from './PlaybackControls';
|
import { PlaybackControls } from './PlaybackControls';
|
||||||
import { SidePanel } from '@/components/layout/SidePanel';
|
|
||||||
import { ThemeToggle } from '@/components/layout/ThemeToggle';
|
import { ThemeToggle } from '@/components/layout/ThemeToggle';
|
||||||
import { CommandPalette } from '@/components/ui/CommandPalette';
|
import { CommandPalette } from '@/components/ui/CommandPalette';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
import type { CommandAction } from '@/components/ui/CommandPalette';
|
import type { CommandAction } from '@/components/ui/CommandPalette';
|
||||||
import { useMultiTrack } from '@/lib/hooks/useMultiTrack';
|
import { useMultiTrack } from '@/lib/hooks/useMultiTrack';
|
||||||
import { useMultiTrackPlayer } from '@/lib/hooks/useMultiTrackPlayer';
|
import { useMultiTrackPlayer } from '@/lib/hooks/useMultiTrackPlayer';
|
||||||
@@ -19,6 +19,7 @@ export function AudioEditor() {
|
|||||||
const [importDialogOpen, setImportDialogOpen] = React.useState(false);
|
const [importDialogOpen, setImportDialogOpen] = React.useState(false);
|
||||||
const [selectedTrackId, setSelectedTrackId] = React.useState<string | null>(null);
|
const [selectedTrackId, setSelectedTrackId] = React.useState<string | null>(null);
|
||||||
const [zoom, setZoom] = React.useState(1);
|
const [zoom, setZoom] = React.useState(1);
|
||||||
|
const [masterVolume, setMasterVolume] = React.useState(0.8);
|
||||||
|
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
|
|
||||||
@@ -32,6 +33,15 @@ export function AudioEditor() {
|
|||||||
clearTracks,
|
clearTracks,
|
||||||
} = useMultiTrack();
|
} = useMultiTrack();
|
||||||
|
|
||||||
|
// Log tracks to see if they update
|
||||||
|
React.useEffect(() => {
|
||||||
|
console.log('[AudioEditor] Tracks updated:', tracks.map(t => ({
|
||||||
|
name: t.name,
|
||||||
|
effectCount: t.effectChain.effects.length,
|
||||||
|
effects: t.effectChain.effects.map(e => e.name)
|
||||||
|
})));
|
||||||
|
}, [tracks]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isPlaying,
|
isPlaying,
|
||||||
currentTime,
|
currentTime,
|
||||||
@@ -41,19 +51,19 @@ export function AudioEditor() {
|
|||||||
stop,
|
stop,
|
||||||
seek,
|
seek,
|
||||||
togglePlayPause,
|
togglePlayPause,
|
||||||
} = useMultiTrackPlayer(tracks);
|
} = useMultiTrackPlayer(tracks, masterVolume);
|
||||||
|
|
||||||
// Effect chain (for selected track)
|
// Master effect chain
|
||||||
const {
|
const {
|
||||||
chain: effectChain,
|
chain: masterEffectChain,
|
||||||
presets: effectPresets,
|
presets: masterEffectPresets,
|
||||||
toggleEffectEnabled,
|
toggleEffectEnabled: toggleMasterEffect,
|
||||||
removeEffect,
|
removeEffect: removeMasterEffect,
|
||||||
reorder: reorderEffects,
|
reorder: reorderMasterEffects,
|
||||||
clearChain,
|
clearChain: clearMasterChain,
|
||||||
savePreset,
|
savePreset: saveMasterPreset,
|
||||||
loadPresetToChain,
|
loadPresetToChain: loadMasterPreset,
|
||||||
deletePreset,
|
deletePreset: deleteMasterPreset,
|
||||||
} = useEffectChain();
|
} = useEffectChain();
|
||||||
|
|
||||||
// Multi-track handlers
|
// Multi-track handlers
|
||||||
@@ -83,6 +93,48 @@ export function AudioEditor() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Per-track effect chain handlers
|
||||||
|
const handleToggleTrackEffect = (effectId: string) => {
|
||||||
|
if (!selectedTrack) return;
|
||||||
|
const updatedChain = {
|
||||||
|
...selectedTrack.effectChain,
|
||||||
|
effects: selectedTrack.effectChain.effects.map((e) =>
|
||||||
|
e.id === effectId ? { ...e, enabled: !e.enabled } : e
|
||||||
|
),
|
||||||
|
};
|
||||||
|
updateTrack(selectedTrack.id, { effectChain: updatedChain });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveTrackEffect = (effectId: string) => {
|
||||||
|
if (!selectedTrack) return;
|
||||||
|
const updatedChain = {
|
||||||
|
...selectedTrack.effectChain,
|
||||||
|
effects: selectedTrack.effectChain.effects.filter((e) => e.id !== effectId),
|
||||||
|
};
|
||||||
|
updateTrack(selectedTrack.id, { effectChain: updatedChain });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReorderTrackEffects = (fromIndex: number, toIndex: number) => {
|
||||||
|
if (!selectedTrack) return;
|
||||||
|
const effects = [...selectedTrack.effectChain.effects];
|
||||||
|
const [removed] = effects.splice(fromIndex, 1);
|
||||||
|
effects.splice(toIndex, 0, removed);
|
||||||
|
const updatedChain = {
|
||||||
|
...selectedTrack.effectChain,
|
||||||
|
effects,
|
||||||
|
};
|
||||||
|
updateTrack(selectedTrack.id, { effectChain: updatedChain });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearTrackChain = () => {
|
||||||
|
if (!selectedTrack) return;
|
||||||
|
const updatedChain = {
|
||||||
|
...selectedTrack.effectChain,
|
||||||
|
effects: [],
|
||||||
|
};
|
||||||
|
updateTrack(selectedTrack.id, { effectChain: updatedChain });
|
||||||
|
};
|
||||||
|
|
||||||
// Zoom controls
|
// Zoom controls
|
||||||
const handleZoomIn = () => {
|
const handleZoomIn = () => {
|
||||||
setZoom((prev) => Math.min(20, prev + 1));
|
setZoom((prev) => Math.min(20, prev + 1));
|
||||||
@@ -96,6 +148,30 @@ export function AudioEditor() {
|
|||||||
setZoom(1);
|
setZoom(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
// Spacebar for play/pause - only if not interacting with form elements
|
||||||
|
if (e.code === 'Space') {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
// Don't trigger if user is typing or interacting with buttons/form elements
|
||||||
|
if (
|
||||||
|
target instanceof HTMLInputElement ||
|
||||||
|
target instanceof HTMLTextAreaElement ||
|
||||||
|
target instanceof HTMLButtonElement ||
|
||||||
|
target.getAttribute('role') === 'button'
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
togglePlayPause();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [togglePlayPause]);
|
||||||
|
|
||||||
// Find selected track
|
// Find selected track
|
||||||
const selectedTrack = tracks.find((t) => t.id === selectedTrackId);
|
const selectedTrack = tracks.find((t) => t.id === selectedTrackId);
|
||||||
|
|
||||||
@@ -205,9 +281,29 @@ export function AudioEditor() {
|
|||||||
{/* Compact Header */}
|
{/* Compact Header */}
|
||||||
<header className="flex items-center justify-between px-4 py-2 border-b border-border bg-card flex-shrink-0 gap-4">
|
<header className="flex items-center justify-between px-4 py-2 border-b border-border bg-card flex-shrink-0 gap-4">
|
||||||
{/* Left: Logo */}
|
{/* Left: Logo */}
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-4 flex-shrink-0">
|
||||||
<Music className="h-5 w-5 text-primary" />
|
<div className="flex items-center gap-2">
|
||||||
<h1 className="text-lg font-bold text-foreground">Audio UI</h1>
|
<Music className="h-5 w-5 text-primary" />
|
||||||
|
<h1 className="text-lg font-bold text-foreground">Audio UI</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Track Actions */}
|
||||||
|
<div className="flex items-center gap-2 border-l border-border pl-4">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => addTrack()}>
|
||||||
|
<Plus className="h-4 w-4 mr-1.5" />
|
||||||
|
Add Track
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleImportTracks}>
|
||||||
|
<Upload className="h-4 w-4 mr-1.5" />
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
{tracks.length > 0 && (
|
||||||
|
<Button variant="outline" size="sm" onClick={handleClearTracks}>
|
||||||
|
<Trash2 className="h-4 w-4 mr-1.5 text-destructive" />
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Command Palette + Theme Toggle */}
|
{/* Right: Command Palette + Theme Toggle */}
|
||||||
@@ -219,27 +315,6 @@ export function AudioEditor() {
|
|||||||
|
|
||||||
{/* Main content area */}
|
{/* Main content area */}
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* Side Panel */}
|
|
||||||
<SidePanel
|
|
||||||
tracks={tracks}
|
|
||||||
selectedTrackId={selectedTrackId}
|
|
||||||
onSelectTrack={setSelectedTrackId}
|
|
||||||
onAddTrack={addTrack}
|
|
||||||
onImportTracks={handleImportTracks}
|
|
||||||
onUpdateTrack={updateTrack}
|
|
||||||
onRemoveTrack={handleRemoveTrack}
|
|
||||||
onClearTracks={handleClearTracks}
|
|
||||||
effectChain={effectChain}
|
|
||||||
effectPresets={effectPresets}
|
|
||||||
onToggleEffect={toggleEffectEnabled}
|
|
||||||
onRemoveEffect={removeEffect}
|
|
||||||
onReorderEffects={reorderEffects}
|
|
||||||
onSavePreset={savePreset}
|
|
||||||
onLoadPreset={loadPresetToChain}
|
|
||||||
onDeletePreset={deletePreset}
|
|
||||||
onClearChain={clearChain}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Main canvas area */}
|
{/* Main canvas area */}
|
||||||
<main className="flex-1 flex flex-col overflow-hidden bg-background">
|
<main className="flex-1 flex flex-col overflow-hidden bg-background">
|
||||||
{/* Multi-Track View */}
|
{/* Multi-Track View */}
|
||||||
@@ -259,26 +334,27 @@ export function AudioEditor() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Multi-Track Playback Controls */}
|
|
||||||
<div className="border-t border-border bg-card p-3">
|
|
||||||
<PlaybackControls
|
|
||||||
isPlaying={isPlaying}
|
|
||||||
isPaused={!isPlaying}
|
|
||||||
currentTime={currentTime}
|
|
||||||
duration={duration}
|
|
||||||
volume={1}
|
|
||||||
onPlay={play}
|
|
||||||
onPause={pause}
|
|
||||||
onStop={stop}
|
|
||||||
onSeek={seek}
|
|
||||||
onVolumeChange={() => {}}
|
|
||||||
currentTimeFormatted={formatDuration(currentTime)}
|
|
||||||
durationFormatted={formatDuration(duration)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Transport Controls */}
|
||||||
|
<div className="border-t border-border bg-card p-3 flex justify-center">
|
||||||
|
<PlaybackControls
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
isPaused={!isPlaying}
|
||||||
|
currentTime={currentTime}
|
||||||
|
duration={duration}
|
||||||
|
volume={masterVolume}
|
||||||
|
onPlay={play}
|
||||||
|
onPause={pause}
|
||||||
|
onStop={stop}
|
||||||
|
onSeek={seek}
|
||||||
|
onVolumeChange={setMasterVolume}
|
||||||
|
currentTimeFormatted={formatDuration(currentTime)}
|
||||||
|
durationFormatted={formatDuration(duration)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Import Track Dialog */}
|
{/* Import Track Dialog */}
|
||||||
<ImportTrackDialog
|
<ImportTrackDialog
|
||||||
open={importDialogOpen}
|
open={importDialogOpen}
|
||||||
|
|||||||
121
components/effects/EffectBrowser.tsx
Normal file
121
components/effects/EffectBrowser.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { X, Search } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import type { EffectType } from '@/lib/audio/effects/chain';
|
||||||
|
import { EFFECT_NAMES } from '@/lib/audio/effects/chain';
|
||||||
|
|
||||||
|
export interface EffectBrowserProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSelectEffect: (effectType: EffectType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EFFECT_CATEGORIES = {
|
||||||
|
'Dynamics': ['compressor', 'limiter', 'gate'] as EffectType[],
|
||||||
|
'Filters': ['lowpass', 'highpass', 'bandpass', 'notch', 'lowshelf', 'highshelf', 'peaking'] as EffectType[],
|
||||||
|
'Time-Based': ['delay', 'reverb', 'chorus', 'flanger', 'phaser'] as EffectType[],
|
||||||
|
'Distortion': ['distortion', 'bitcrusher'] as EffectType[],
|
||||||
|
'Pitch & Time': ['pitch', 'timestretch'] as EffectType[],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EffectBrowser({ open, onClose, onSelectEffect }: EffectBrowserProps) {
|
||||||
|
const [search, setSearch] = React.useState('');
|
||||||
|
const [selectedCategory, setSelectedCategory] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSelectEffect = (effectType: EffectType) => {
|
||||||
|
onSelectEffect(effectType);
|
||||||
|
onClose();
|
||||||
|
setSearch('');
|
||||||
|
setSelectedCategory(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredCategories = React.useMemo(() => {
|
||||||
|
if (!search) return EFFECT_CATEGORIES;
|
||||||
|
|
||||||
|
const searchLower = search.toLowerCase();
|
||||||
|
const filtered: Record<string, EffectType[]> = {};
|
||||||
|
|
||||||
|
Object.entries(EFFECT_CATEGORIES).forEach(([category, effects]) => {
|
||||||
|
const matchingEffects = effects.filter((effect) =>
|
||||||
|
EFFECT_NAMES[effect].toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
if (matchingEffects.length > 0) {
|
||||||
|
filtered[category] = matchingEffects;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="bg-card border border-border rounded-lg shadow-lg w-full max-w-2xl max-h-[80vh] flex flex-col"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">Add Effect</h2>
|
||||||
|
<Button variant="ghost" size="icon-sm" onClick={onClose}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="p-4 border-b border-border">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search effects..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 bg-background border border-border rounded-md text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{Object.entries(filteredCategories).map(([category, effects]) => (
|
||||||
|
<div key={category}>
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground uppercase mb-2">
|
||||||
|
{category}
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{effects.map((effect) => (
|
||||||
|
<button
|
||||||
|
key={effect}
|
||||||
|
onClick={() => handleSelectEffect(effect)}
|
||||||
|
className={cn(
|
||||||
|
'px-4 py-3 text-left rounded-md border transition-colors',
|
||||||
|
'hover:bg-accent hover:border-primary',
|
||||||
|
'border-border bg-card text-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="font-medium text-sm">{EFFECT_NAMES[effect]}</div>
|
||||||
|
<div className="text-xs text-muted-foreground capitalize">{effect}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Object.keys(filteredCategories).length === 0 && (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
No effects found matching "{search}"
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
components/effects/EffectDevice.tsx
Normal file
105
components/effects/EffectDevice.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { ChevronLeft, ChevronRight, Power, X } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import type { ChainEffect } from '@/lib/audio/effects/chain';
|
||||||
|
import { EffectParameters } from './EffectParameters';
|
||||||
|
|
||||||
|
export interface EffectDeviceProps {
|
||||||
|
effect: ChainEffect;
|
||||||
|
onToggleEnabled?: () => void;
|
||||||
|
onRemove?: () => void;
|
||||||
|
onUpdateParameters?: (parameters: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EffectDevice({
|
||||||
|
effect,
|
||||||
|
onToggleEnabled,
|
||||||
|
onRemove,
|
||||||
|
onUpdateParameters,
|
||||||
|
}: EffectDeviceProps) {
|
||||||
|
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex-shrink-0 flex flex-col h-full border-l border-border transition-all duration-200',
|
||||||
|
effect.enabled ? 'bg-accent/20' : 'bg-muted/20',
|
||||||
|
isExpanded ? 'min-w-96' : 'w-10'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!isExpanded ? (
|
||||||
|
/* Collapsed State - No Header */
|
||||||
|
<button
|
||||||
|
onClick={() => setIsExpanded(true)}
|
||||||
|
className="w-full h-full flex flex-col items-center justify-between py-1 hover:bg-primary/10 transition-colors group"
|
||||||
|
title={`Expand ${effect.name}`}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-3 w-3 flex-shrink-0 text-muted-foreground group-hover:text-primary transition-colors" />
|
||||||
|
<span
|
||||||
|
className="flex-1 text-xs font-medium whitespace-nowrap text-muted-foreground group-hover:text-primary transition-colors"
|
||||||
|
style={{
|
||||||
|
writingMode: 'vertical-rl',
|
||||||
|
textOrientation: 'mixed',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{effect.name}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-1 h-1 rounded-full flex-shrink-0 mb-1',
|
||||||
|
effect.enabled ? 'bg-primary' : 'bg-muted-foreground/30'
|
||||||
|
)}
|
||||||
|
title={effect.enabled ? 'Enabled' : 'Disabled'}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Full-Width Header Row */}
|
||||||
|
<div className="flex items-center gap-1 px-2 py-1 border-b border-border bg-card/30 flex-shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => setIsExpanded(false)}
|
||||||
|
title="Collapse device"
|
||||||
|
className="h-5 w-5 flex-shrink-0"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs font-semibold flex-1 min-w-0 truncate">{effect.name}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onToggleEnabled}
|
||||||
|
title={effect.enabled ? 'Disable effect' : 'Enable effect'}
|
||||||
|
className="h-5 w-5 flex-shrink-0"
|
||||||
|
>
|
||||||
|
<Power
|
||||||
|
className={cn(
|
||||||
|
'h-3 w-3',
|
||||||
|
effect.enabled ? 'text-primary' : 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onRemove}
|
||||||
|
title="Remove effect"
|
||||||
|
className="h-5 w-5 flex-shrink-0"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Device Body */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar p-2">
|
||||||
|
<EffectParameters effect={effect} onUpdateParameters={onUpdateParameters} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
722
components/effects/EffectParameters.tsx
Normal file
722
components/effects/EffectParameters.tsx
Normal file
@@ -0,0 +1,722 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Slider } from '@/components/ui/Slider';
|
||||||
|
import type { ChainEffect, EffectType } from '@/lib/audio/effects/chain';
|
||||||
|
import type {
|
||||||
|
PitchShifterParameters,
|
||||||
|
TimeStretchParameters,
|
||||||
|
DistortionParameters,
|
||||||
|
BitcrusherParameters,
|
||||||
|
} from '@/lib/audio/effects/advanced';
|
||||||
|
import type {
|
||||||
|
CompressorParameters,
|
||||||
|
LimiterParameters,
|
||||||
|
GateParameters,
|
||||||
|
} from '@/lib/audio/effects/dynamics';
|
||||||
|
import type {
|
||||||
|
DelayParameters,
|
||||||
|
ReverbParameters,
|
||||||
|
ChorusParameters,
|
||||||
|
FlangerParameters,
|
||||||
|
PhaserParameters,
|
||||||
|
} from '@/lib/audio/effects/time-based';
|
||||||
|
import type { FilterOptions } from '@/lib/audio/effects/filters';
|
||||||
|
|
||||||
|
export interface EffectParametersProps {
|
||||||
|
effect: ChainEffect;
|
||||||
|
onUpdateParameters?: (parameters: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EffectParameters({ effect, onUpdateParameters }: EffectParametersProps) {
|
||||||
|
const params = effect.parameters || {};
|
||||||
|
|
||||||
|
const updateParam = (key: string, value: any) => {
|
||||||
|
if (onUpdateParameters) {
|
||||||
|
onUpdateParameters({ ...params, [key]: value });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter effects
|
||||||
|
if (['lowpass', 'highpass', 'bandpass', 'notch', 'lowshelf', 'highshelf', 'peaking'].includes(effect.type)) {
|
||||||
|
const filterParams = params as FilterOptions;
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Frequency: {Math.round(filterParams.frequency || 1000)} Hz
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[filterParams.frequency || 1000]}
|
||||||
|
onValueChange={([value]) => updateParam('frequency', value)}
|
||||||
|
min={20}
|
||||||
|
max={20000}
|
||||||
|
step={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Q: {(filterParams.Q || 1).toFixed(2)}
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[filterParams.Q || 1]}
|
||||||
|
onValueChange={([value]) => updateParam('Q', value)}
|
||||||
|
min={0.1}
|
||||||
|
max={20}
|
||||||
|
step={0.1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{['lowshelf', 'highshelf', 'peaking'].includes(effect.type) && (
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Gain: {(filterParams.gain || 0).toFixed(1)} dB
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[filterParams.gain || 0]}
|
||||||
|
onValueChange={([value]) => updateParam('gain', value)}
|
||||||
|
min={-40}
|
||||||
|
max={40}
|
||||||
|
step={0.5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compressor
|
||||||
|
if (effect.type === 'compressor') {
|
||||||
|
const compParams = params as CompressorParameters;
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-x-4 gap-y-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Threshold: {(compParams.threshold || -24).toFixed(1)} dB
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[compParams.threshold || -24]}
|
||||||
|
onValueChange={([value]) => updateParam('threshold', value)}
|
||||||
|
min={-60}
|
||||||
|
max={0}
|
||||||
|
step={0.5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Ratio: {(compParams.ratio || 4).toFixed(1)}:1
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[compParams.ratio || 4]}
|
||||||
|
onValueChange={([value]) => updateParam('ratio', value)}
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
step={0.5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Knee: {(compParams.knee || 30).toFixed(1)} dB
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[compParams.knee || 30]}
|
||||||
|
onValueChange={([value]) => updateParam('knee', value)}
|
||||||
|
min={0}
|
||||||
|
max={40}
|
||||||
|
step={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Attack: {(compParams.attack || 0.003).toFixed(3)} s
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[compParams.attack || 0.003]}
|
||||||
|
onValueChange={([value]) => updateParam('attack', value)}
|
||||||
|
min={0.001}
|
||||||
|
max={1}
|
||||||
|
step={0.001}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Release: {(compParams.release || 0.25).toFixed(3)} s
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[compParams.release || 0.25]}
|
||||||
|
onValueChange={([value]) => updateParam('release', value)}
|
||||||
|
min={0.01}
|
||||||
|
max={3}
|
||||||
|
step={0.01}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limiter
|
||||||
|
if (effect.type === 'limiter') {
|
||||||
|
const limParams = params as LimiterParameters;
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-x-4 gap-y-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Threshold: {(limParams.threshold || -3).toFixed(1)} dB
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[limParams.threshold || -3]}
|
||||||
|
onValueChange={([value]) => updateParam('threshold', value)}
|
||||||
|
min={-30}
|
||||||
|
max={0}
|
||||||
|
step={0.5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Release: {(limParams.release || 0.05).toFixed(3)} s
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[limParams.release || 0.05]}
|
||||||
|
onValueChange={([value]) => updateParam('release', value)}
|
||||||
|
min={0.01}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Makeup: {(limParams.makeupGain || 0).toFixed(1)} dB
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[limParams.makeupGain || 0]}
|
||||||
|
onValueChange={([value]) => updateParam('makeupGain', value)}
|
||||||
|
min={0}
|
||||||
|
max={20}
|
||||||
|
step={0.5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gate
|
||||||
|
if (effect.type === 'gate') {
|
||||||
|
const gateParams = params as GateParameters;
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Threshold: {(gateParams.threshold || -40).toFixed(1)} dB
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[gateParams.threshold || -40]}
|
||||||
|
onValueChange={([value]) => updateParam('threshold', value)}
|
||||||
|
min={-80}
|
||||||
|
max={0}
|
||||||
|
step={0.5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Ratio: {(gateParams.ratio || 10).toFixed(1)}:1
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[gateParams.ratio || 10]}
|
||||||
|
onValueChange={([value]) => updateParam('ratio', value)}
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
step={0.5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Attack: {(gateParams.attack || 0.001).toFixed(3)} s
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[gateParams.attack || 0.001]}
|
||||||
|
onValueChange={([value]) => updateParam('attack', value)}
|
||||||
|
min={0.0001}
|
||||||
|
max={0.5}
|
||||||
|
step={0.0001}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Release: {(gateParams.release || 0.1).toFixed(3)} s
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[gateParams.release || 0.1]}
|
||||||
|
onValueChange={([value]) => updateParam('release', value)}
|
||||||
|
min={0.01}
|
||||||
|
max={3}
|
||||||
|
step={0.01}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delay
|
||||||
|
if (effect.type === 'delay') {
|
||||||
|
const delayParams = params as DelayParameters;
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-x-4 gap-y-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Time: {(delayParams.time || 0.5).toFixed(3)} s
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[delayParams.time || 0.5]}
|
||||||
|
onValueChange={([value]) => updateParam('time', value)}
|
||||||
|
min={0.001}
|
||||||
|
max={2}
|
||||||
|
step={0.001}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Feedback: {((delayParams.feedback || 0.3) * 100).toFixed(0)}%
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[delayParams.feedback || 0.3]}
|
||||||
|
onValueChange={([value]) => updateParam('feedback', value)}
|
||||||
|
min={0}
|
||||||
|
max={0.9}
|
||||||
|
step={0.01}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Mix: {((delayParams.mix || 0.5) * 100).toFixed(0)}%
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[delayParams.mix || 0.5]}
|
||||||
|
onValueChange={([value]) => updateParam('mix', value)}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverb
|
||||||
|
if (effect.type === 'reverb') {
|
||||||
|
const reverbParams = params as ReverbParameters;
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-x-4 gap-y-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Room Size: {((reverbParams.roomSize || 0.5) * 100).toFixed(0)}%
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[reverbParams.roomSize || 0.5]}
|
||||||
|
onValueChange={([value]) => updateParam('roomSize', value)}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Damping: {((reverbParams.damping || 0.5) * 100).toFixed(0)}%
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[reverbParams.damping || 0.5]}
|
||||||
|
onValueChange={([value]) => updateParam('damping', value)}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Mix: {((reverbParams.mix || 0.3) * 100).toFixed(0)}%
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[reverbParams.mix || 0.3]}
|
||||||
|
onValueChange={([value]) => updateParam('mix', value)}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chorus
|
||||||
|
if (effect.type === 'chorus') {
|
||||||
|
const chorusParams = params as ChorusParameters;
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-x-4 gap-y-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Rate: {(chorusParams.rate || 1.5).toFixed(2)} Hz
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[chorusParams.rate || 1.5]}
|
||||||
|
onValueChange={([value]) => updateParam('rate', value)}
|
||||||
|
min={0.1}
|
||||||
|
max={10}
|
||||||
|
step={0.1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Depth: {((chorusParams.depth || 0.002) * 1000).toFixed(2)} ms
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[chorusParams.depth || 0.002]}
|
||||||
|
onValueChange={([value]) => updateParam('depth', value)}
|
||||||
|
min={0.0001}
|
||||||
|
max={0.01}
|
||||||
|
step={0.0001}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Mix: {((chorusParams.mix || 0.5) * 100).toFixed(0)}%
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[chorusParams.mix || 0.5]}
|
||||||
|
onValueChange={([value]) => updateParam('mix', value)}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flanger
|
||||||
|
if (effect.type === 'flanger') {
|
||||||
|
const flangerParams = params as FlangerParameters;
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-x-4 gap-y-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Rate: {(flangerParams.rate || 0.5).toFixed(2)} Hz
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[flangerParams.rate || 0.5]}
|
||||||
|
onValueChange={([value]) => updateParam('rate', value)}
|
||||||
|
min={0.1}
|
||||||
|
max={10}
|
||||||
|
step={0.1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Depth: {((flangerParams.depth || 0.002) * 1000).toFixed(2)} ms
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[flangerParams.depth || 0.002]}
|
||||||
|
onValueChange={([value]) => updateParam('depth', value)}
|
||||||
|
min={0.0001}
|
||||||
|
max={0.01}
|
||||||
|
step={0.0001}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Feedback: {((flangerParams.feedback || 0.5) * 100).toFixed(0)}%
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[flangerParams.feedback || 0.5]}
|
||||||
|
onValueChange={([value]) => updateParam('feedback', value)}
|
||||||
|
min={0}
|
||||||
|
max={0.95}
|
||||||
|
step={0.01}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Mix: {((flangerParams.mix || 0.5) * 100).toFixed(0)}%
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[flangerParams.mix || 0.5]}
|
||||||
|
onValueChange={([value]) => updateParam('mix', value)}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phaser
|
||||||
|
if (effect.type === 'phaser') {
|
||||||
|
const phaserParams = params as PhaserParameters;
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-x-4 gap-y-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Rate: {(phaserParams.rate || 0.5).toFixed(2)} Hz
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[phaserParams.rate || 0.5]}
|
||||||
|
onValueChange={([value]) => updateParam('rate', value)}
|
||||||
|
min={0.1}
|
||||||
|
max={10}
|
||||||
|
step={0.1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Depth: {((phaserParams.depth || 0.5) * 100).toFixed(0)}%
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[phaserParams.depth || 0.5]}
|
||||||
|
onValueChange={([value]) => updateParam('depth', value)}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Stages: {phaserParams.stages || 4}
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[phaserParams.stages || 4]}
|
||||||
|
onValueChange={([value]) => updateParam('stages', Math.round(value))}
|
||||||
|
min={2}
|
||||||
|
max={12}
|
||||||
|
step={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Mix: {((phaserParams.mix || 0.5) * 100).toFixed(0)}%
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[phaserParams.mix || 0.5]}
|
||||||
|
onValueChange={([value]) => updateParam('mix', value)}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pitch Shifter
|
||||||
|
if (effect.type === 'pitch') {
|
||||||
|
const pitchParams = params as PitchShifterParameters;
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-x-4 gap-y-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Semitones: {pitchParams.semitones || 0}
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[pitchParams.semitones || 0]}
|
||||||
|
onValueChange={([value]) => updateParam('semitones', Math.round(value))}
|
||||||
|
min={-12}
|
||||||
|
max={12}
|
||||||
|
step={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Cents: {pitchParams.cents || 0}
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[pitchParams.cents || 0]}
|
||||||
|
onValueChange={([value]) => updateParam('cents', Math.round(value))}
|
||||||
|
min={-100}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Mix: {((pitchParams.mix || 1) * 100).toFixed(0)}%
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[pitchParams.mix || 1]}
|
||||||
|
onValueChange={([value]) => updateParam('mix', value)}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time Stretch
|
||||||
|
if (effect.type === 'timestretch') {
|
||||||
|
const stretchParams = params as TimeStretchParameters;
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-x-4 gap-y-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Rate: {(stretchParams.rate || 1).toFixed(2)}x
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[stretchParams.rate || 1]}
|
||||||
|
onValueChange={([value]) => updateParam('rate', value)}
|
||||||
|
min={0.5}
|
||||||
|
max={2}
|
||||||
|
step={0.01}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 py-1 px-2 border-b border-border/30">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={`preserve-pitch-${effect.id}`}
|
||||||
|
checked={stretchParams.preservePitch ?? true}
|
||||||
|
onChange={(e) => updateParam('preservePitch', e.target.checked)}
|
||||||
|
className="h-3 w-3 rounded border-border"
|
||||||
|
/>
|
||||||
|
<label htmlFor={`preserve-pitch-${effect.id}`} className="text-xs">
|
||||||
|
Preserve Pitch
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Mix: {((stretchParams.mix || 1) * 100).toFixed(0)}%
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[stretchParams.mix || 1]}
|
||||||
|
onValueChange={([value]) => updateParam('mix', value)}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distortion
|
||||||
|
if (effect.type === 'distortion') {
|
||||||
|
const distParams = params as DistortionParameters;
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-x-4 gap-y-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">Type</label>
|
||||||
|
<div className="grid grid-cols-3 gap-1">
|
||||||
|
{(['soft', 'hard', 'tube'] as const).map((type) => (
|
||||||
|
<Button
|
||||||
|
key={type}
|
||||||
|
variant={(distParams.type || 'soft') === type ? 'secondary' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => updateParam('type', type)}
|
||||||
|
className="text-xs py-1 h-auto"
|
||||||
|
>
|
||||||
|
{type}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Drive: {((distParams.drive || 0.5) * 100).toFixed(0)}%
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[distParams.drive || 0.5]}
|
||||||
|
onValueChange={([value]) => updateParam('drive', value)}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Tone: {((distParams.tone || 0.5) * 100).toFixed(0)}%
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[distParams.tone || 0.5]}
|
||||||
|
onValueChange={([value]) => updateParam('tone', value)}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Output: {((distParams.output || 0.7) * 100).toFixed(0)}%
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[distParams.output || 0.7]}
|
||||||
|
onValueChange={([value]) => updateParam('output', value)}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Mix: {((distParams.mix || 1) * 100).toFixed(0)}%
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[distParams.mix || 1]}
|
||||||
|
onValueChange={([value]) => updateParam('mix', value)}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bitcrusher
|
||||||
|
if (effect.type === 'bitcrusher') {
|
||||||
|
const crushParams = params as BitcrusherParameters;
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-3 gap-x-4 gap-y-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Bit Depth: {crushParams.bitDepth || 8} bits
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[crushParams.bitDepth || 8]}
|
||||||
|
onValueChange={([value]) => updateParam('bitDepth', Math.round(value))}
|
||||||
|
min={1}
|
||||||
|
max={16}
|
||||||
|
step={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Sample Rate: {crushParams.sampleRate || 8000} Hz
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[crushParams.sampleRate || 8000]}
|
||||||
|
onValueChange={([value]) => updateParam('sampleRate', Math.round(value))}
|
||||||
|
min={100}
|
||||||
|
max={48000}
|
||||||
|
step={100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="text-xs font-medium">
|
||||||
|
Mix: {((crushParams.mix || 1) * 100).toFixed(0)}%
|
||||||
|
</label>
|
||||||
|
<Slider
|
||||||
|
value={[crushParams.mix || 1]}
|
||||||
|
onValueChange={([value]) => updateParam('mix', value)}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback for unknown effects
|
||||||
|
return (
|
||||||
|
<div className="text-xs text-muted-foreground/70 italic text-center py-4">
|
||||||
|
No parameters available
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,13 +9,10 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Link2,
|
Link2,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Volume2,
|
|
||||||
Music2,
|
Music2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Slider } from '@/components/ui/Slider';
|
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { formatDuration } from '@/lib/audio/decoder';
|
|
||||||
import type { Track } from '@/types/track';
|
import type { Track } from '@/types/track';
|
||||||
import type { EffectChain, EffectPreset } from '@/lib/audio/effects/chain';
|
import type { EffectChain, EffectPreset } from '@/lib/audio/effects/chain';
|
||||||
import { EffectRack } from '@/components/effects/EffectRack';
|
import { EffectRack } from '@/components/effects/EffectRack';
|
||||||
@@ -31,16 +28,23 @@ export interface SidePanelProps {
|
|||||||
onRemoveTrack: (trackId: string) => void;
|
onRemoveTrack: (trackId: string) => void;
|
||||||
onClearTracks: () => void;
|
onClearTracks: () => void;
|
||||||
|
|
||||||
// Effect chain
|
// Track effect chain (for selected track)
|
||||||
effectChain: EffectChain;
|
trackEffectChain: EffectChain | null;
|
||||||
effectPresets: EffectPreset[];
|
onToggleTrackEffect: (effectId: string) => void;
|
||||||
onToggleEffect: (effectId: string) => void;
|
onRemoveTrackEffect: (effectId: string) => void;
|
||||||
onRemoveEffect: (effectId: string) => void;
|
onReorderTrackEffects: (fromIndex: number, toIndex: number) => void;
|
||||||
onReorderEffects: (fromIndex: number, toIndex: number) => void;
|
onClearTrackChain: () => void;
|
||||||
onSavePreset: (preset: EffectPreset) => void;
|
|
||||||
onLoadPreset: (preset: EffectPreset) => void;
|
// Master effect chain
|
||||||
onDeletePreset: (presetId: string) => void;
|
masterEffectChain: EffectChain;
|
||||||
onClearChain: () => void;
|
masterEffectPresets: EffectPreset[];
|
||||||
|
onToggleMasterEffect: (effectId: string) => void;
|
||||||
|
onRemoveMasterEffect: (effectId: string) => void;
|
||||||
|
onReorderMasterEffects: (fromIndex: number, toIndex: number) => void;
|
||||||
|
onSaveMasterPreset: (preset: EffectPreset) => void;
|
||||||
|
onLoadMasterPreset: (preset: EffectPreset) => void;
|
||||||
|
onDeleteMasterPreset: (presetId: string) => void;
|
||||||
|
onClearMasterChain: () => void;
|
||||||
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
@@ -54,19 +58,24 @@ export function SidePanel({
|
|||||||
onUpdateTrack,
|
onUpdateTrack,
|
||||||
onRemoveTrack,
|
onRemoveTrack,
|
||||||
onClearTracks,
|
onClearTracks,
|
||||||
effectChain,
|
trackEffectChain,
|
||||||
effectPresets,
|
onToggleTrackEffect,
|
||||||
onToggleEffect,
|
onRemoveTrackEffect,
|
||||||
onRemoveEffect,
|
onReorderTrackEffects,
|
||||||
onReorderEffects,
|
onClearTrackChain,
|
||||||
onSavePreset,
|
masterEffectChain,
|
||||||
onLoadPreset,
|
masterEffectPresets,
|
||||||
onDeletePreset,
|
onToggleMasterEffect,
|
||||||
onClearChain,
|
onRemoveMasterEffect,
|
||||||
|
onReorderMasterEffects,
|
||||||
|
onSaveMasterPreset,
|
||||||
|
onLoadMasterPreset,
|
||||||
|
onDeleteMasterPreset,
|
||||||
|
onClearMasterChain,
|
||||||
className,
|
className,
|
||||||
}: SidePanelProps) {
|
}: SidePanelProps) {
|
||||||
const [isCollapsed, setIsCollapsed] = React.useState(false);
|
const [isCollapsed, setIsCollapsed] = React.useState(false);
|
||||||
const [activeTab, setActiveTab] = React.useState<'tracks' | 'chain'>('tracks');
|
const [activeTab, setActiveTab] = React.useState<'tracks' | 'master'>('tracks');
|
||||||
const [presetDialogOpen, setPresetDialogOpen] = React.useState(false);
|
const [presetDialogOpen, setPresetDialogOpen] = React.useState(false);
|
||||||
|
|
||||||
const selectedTrack = tracks.find((t) => t.id === selectedTrackId);
|
const selectedTrack = tracks.find((t) => t.id === selectedTrackId);
|
||||||
@@ -98,19 +107,21 @@ export function SidePanel({
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
variant={activeTab === 'tracks' ? 'secondary' : 'ghost'}
|
variant={activeTab === 'tracks' ? 'secondary' : 'ghost'}
|
||||||
size="icon-sm"
|
size="sm"
|
||||||
onClick={() => setActiveTab('tracks')}
|
onClick={() => setActiveTab('tracks')}
|
||||||
title="Tracks"
|
title="Tracks"
|
||||||
>
|
>
|
||||||
<Music2 className="h-4 w-4" />
|
<Music2 className="h-4 w-4 mr-1.5" />
|
||||||
|
Tracks
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={activeTab === 'chain' ? 'secondary' : 'ghost'}
|
variant={activeTab === 'master' ? 'secondary' : 'ghost'}
|
||||||
size="icon-sm"
|
size="sm"
|
||||||
onClick={() => setActiveTab('chain')}
|
onClick={() => setActiveTab('master')}
|
||||||
title="Effect Chain"
|
title="Master"
|
||||||
>
|
>
|
||||||
<Link2 className="h-4 w-4" />
|
<Link2 className="h-4 w-4 mr-1.5 text-primary" />
|
||||||
|
Master
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -130,7 +141,7 @@ export function SidePanel({
|
|||||||
{/* Track Actions */}
|
{/* Track Actions */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
|
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
|
||||||
Multi-Track Editor
|
Track Management
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -165,122 +176,25 @@ export function SidePanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Track List */}
|
{/* Track List Summary */}
|
||||||
{tracks.length > 0 ? (
|
{tracks.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
|
<div className="flex items-center justify-between">
|
||||||
Tracks ({tracks.length})
|
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
|
||||||
</h3>
|
Tracks ({tracks.length})
|
||||||
<div className="space-y-2">
|
</h3>
|
||||||
{tracks.map((track) => {
|
{selectedTrack && (
|
||||||
const isSelected = selectedTrackId === track.id;
|
<span className="text-xs text-primary">
|
||||||
return (
|
{String(selectedTrack.name || 'Untitled Track')} selected
|
||||||
<div
|
</span>
|
||||||
key={track.id}
|
)}
|
||||||
className={cn(
|
</div>
|
||||||
'p-3 rounded-lg border transition-colors cursor-pointer',
|
<div className="text-xs text-muted-foreground">
|
||||||
isSelected
|
<p>
|
||||||
? 'bg-primary/10 border-primary'
|
{selectedTrack
|
||||||
: 'bg-secondary/30 border-border hover:border-primary/50'
|
? 'Track controls are on the left of each track. Effects for the selected track are shown below.'
|
||||||
)}
|
: 'Click a track\'s waveform to select it and edit its effects below.'}
|
||||||
onClick={() => onSelectTrack(isSelected ? null : track.id)}
|
</p>
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-sm font-medium text-foreground truncate">
|
|
||||||
{String(track.name || 'Untitled Track')}
|
|
||||||
</div>
|
|
||||||
{track.audioBuffer && (
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{formatDuration(track.audioBuffer.duration)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onRemoveTrack(track.id);
|
|
||||||
}}
|
|
||||||
title="Remove track"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Track Controls - Always visible */}
|
|
||||||
<div className="space-y-2" onClick={(e) => e.stopPropagation()}>
|
|
||||||
{/* Volume */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-xs text-muted-foreground flex items-center gap-1">
|
|
||||||
<Volume2 className="h-3 w-3" />
|
|
||||||
Volume
|
|
||||||
</label>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{Math.round(track.volume * 100)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Slider
|
|
||||||
value={track.volume}
|
|
||||||
onChange={(value) => onUpdateTrack(track.id, { volume: value })}
|
|
||||||
min={0}
|
|
||||||
max={2}
|
|
||||||
step={0.01}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pan */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-xs text-muted-foreground">Pan</label>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{track.pan === 0
|
|
||||||
? 'C'
|
|
||||||
: track.pan < 0
|
|
||||||
? `L${Math.round(Math.abs(track.pan) * 100)}`
|
|
||||||
: `R${Math.round(track.pan * 100)}`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Slider
|
|
||||||
value={track.pan}
|
|
||||||
onChange={(value) => onUpdateTrack(track.id, { pan: value })}
|
|
||||||
min={-1}
|
|
||||||
max={1}
|
|
||||||
step={0.01}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Solo / Mute */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant={track.solo ? 'default' : 'outline'}
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onUpdateTrack(track.id, { solo: !track.solo });
|
|
||||||
}}
|
|
||||||
className="flex-1 text-xs"
|
|
||||||
>
|
|
||||||
Solo
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={track.mute ? 'default' : 'outline'}
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onUpdateTrack(track.id, { mute: !track.mute });
|
|
||||||
}}
|
|
||||||
className="flex-1 text-xs"
|
|
||||||
>
|
|
||||||
Mute
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -291,69 +205,97 @@ export function SidePanel({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Selected Track Effects */}
|
||||||
|
{selectedTrack && (
|
||||||
|
<div className="space-y-2 pt-3 border-t border-border">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
|
||||||
|
Track Effects
|
||||||
|
</h3>
|
||||||
|
{trackEffectChain && trackEffectChain.effects.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onClearTrackChain}
|
||||||
|
title="Clear all effects"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<EffectRack
|
||||||
|
chain={trackEffectChain!}
|
||||||
|
onToggleEffect={onToggleTrackEffect}
|
||||||
|
onRemoveEffect={onRemoveTrackEffect}
|
||||||
|
onReorderEffects={onReorderTrackEffects}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'chain' && (
|
{activeTab === 'master' && (
|
||||||
<div className="space-y-2">
|
<>
|
||||||
<div className="flex items-center justify-between">
|
{/* Master Channel Info */}
|
||||||
|
<div className="space-y-2">
|
||||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
|
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
|
||||||
Effect Chain
|
Master Channel
|
||||||
{selectedTrack && (
|
|
||||||
<span className="text-primary ml-2">({selectedTrack.name})</span>
|
|
||||||
)}
|
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex gap-1">
|
<div className="text-xs text-muted-foreground">
|
||||||
<Button
|
<p>
|
||||||
variant="ghost"
|
Master effects are applied to the final mix of all tracks.
|
||||||
size="icon-sm"
|
</p>
|
||||||
onClick={() => setPresetDialogOpen(true)}
|
|
||||||
title="Manage presets"
|
|
||||||
>
|
|
||||||
<FolderOpen className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
{effectChain.effects.length > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-sm"
|
|
||||||
onClick={onClearChain}
|
|
||||||
title="Clear all effects"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!selectedTrack ? (
|
{/* Master Effects */}
|
||||||
<div className="text-center py-8">
|
<div className="space-y-2 pt-3 border-t border-border">
|
||||||
<Link2 className="h-12 w-12 mx-auto text-muted-foreground/50 mb-2" />
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-sm text-muted-foreground">
|
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
|
||||||
Select a track to apply effects
|
Master Effects
|
||||||
</p>
|
</h3>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={() => setPresetDialogOpen(true)}
|
||||||
|
title="Manage presets"
|
||||||
|
>
|
||||||
|
<FolderOpen className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
{masterEffectChain.effects.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onClearMasterChain}
|
||||||
|
title="Clear all effects"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<>
|
<EffectRack
|
||||||
<EffectRack
|
chain={masterEffectChain}
|
||||||
chain={effectChain}
|
onToggleEffect={onToggleMasterEffect}
|
||||||
onToggleEffect={onToggleEffect}
|
onRemoveEffect={onRemoveMasterEffect}
|
||||||
onRemoveEffect={onRemoveEffect}
|
onReorderEffects={onReorderMasterEffects}
|
||||||
onReorderEffects={onReorderEffects}
|
/>
|
||||||
/>
|
<PresetManager
|
||||||
<PresetManager
|
open={presetDialogOpen}
|
||||||
open={presetDialogOpen}
|
onClose={() => setPresetDialogOpen(false)}
|
||||||
onClose={() => setPresetDialogOpen(false)}
|
currentChain={masterEffectChain}
|
||||||
currentChain={effectChain}
|
presets={masterEffectPresets}
|
||||||
presets={effectPresets}
|
onSavePreset={onSaveMasterPreset}
|
||||||
onSavePreset={onSavePreset}
|
onLoadPreset={onLoadMasterPreset}
|
||||||
onLoadPreset={onLoadPreset}
|
onDeletePreset={onDeleteMasterPreset}
|
||||||
onDeletePreset={onDeletePreset}
|
onExportPreset={() => {}}
|
||||||
onExportPreset={() => {}}
|
onImportPreset={(preset) => onSaveMasterPreset(preset)}
|
||||||
onImportPreset={(preset) => onSavePreset(preset)}
|
/>
|
||||||
/>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { Volume2, VolumeX, Headphones, Trash2, ChevronDown, ChevronRight, CircleArrowOutUpRight, Upload, Plus } from 'lucide-react';
|
||||||
import type { Track as TrackType } from '@/types/track';
|
import type { Track as TrackType } from '@/types/track';
|
||||||
import { TrackHeader } from './TrackHeader';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Slider } from '@/components/ui/Slider';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
import { EffectBrowser } from '@/components/effects/EffectBrowser';
|
||||||
|
import { EffectDevice } from '@/components/effects/EffectDevice';
|
||||||
|
import { createEffect, type EffectType } from '@/lib/audio/effects/chain';
|
||||||
|
|
||||||
export interface TrackProps {
|
export interface TrackProps {
|
||||||
track: TrackType;
|
track: TrackType;
|
||||||
@@ -20,6 +25,11 @@ export interface TrackProps {
|
|||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
onNameChange: (name: string) => void;
|
onNameChange: (name: string) => void;
|
||||||
onSeek?: (time: number) => void;
|
onSeek?: (time: number) => void;
|
||||||
|
onLoadAudio?: (buffer: AudioBuffer) => void;
|
||||||
|
onToggleEffect?: (effectId: string) => void;
|
||||||
|
onRemoveEffect?: (effectId: string) => void;
|
||||||
|
onUpdateEffect?: (effectId: string, parameters: any) => void;
|
||||||
|
onAddEffect?: (effectType: EffectType) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Track({
|
export function Track({
|
||||||
@@ -37,20 +47,82 @@ export function Track({
|
|||||||
onRemove,
|
onRemove,
|
||||||
onNameChange,
|
onNameChange,
|
||||||
onSeek,
|
onSeek,
|
||||||
|
onLoadAudio,
|
||||||
|
onToggleEffect,
|
||||||
|
onRemoveEffect,
|
||||||
|
onUpdateEffect,
|
||||||
|
onAddEffect,
|
||||||
}: TrackProps) {
|
}: TrackProps) {
|
||||||
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
const [isEditingName, setIsEditingName] = React.useState(false);
|
||||||
|
const [nameInput, setNameInput] = React.useState(String(track.name || 'Untitled Track'));
|
||||||
|
const [effectBrowserOpen, setEffectBrowserOpen] = React.useState(false);
|
||||||
|
const [showEffects, setShowEffects] = React.useState(false);
|
||||||
|
const [themeKey, setThemeKey] = React.useState(0);
|
||||||
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleNameClick = () => {
|
||||||
|
setIsEditingName(true);
|
||||||
|
setNameInput(String(track.name || 'Untitled Track'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNameBlur = () => {
|
||||||
|
setIsEditingName(false);
|
||||||
|
if (nameInput.trim()) {
|
||||||
|
onNameChange(nameInput.trim());
|
||||||
|
} else {
|
||||||
|
setNameInput(String(track.name || 'Untitled Track'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNameKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
inputRef.current?.blur();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setNameInput(String(track.name || 'Untitled Track'));
|
||||||
|
setIsEditingName(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isEditingName && inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
inputRef.current.select();
|
||||||
|
}
|
||||||
|
}, [isEditingName]);
|
||||||
|
|
||||||
|
// Listen for theme changes
|
||||||
|
React.useEffect(() => {
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
// Increment key to force waveform redraw
|
||||||
|
setThemeKey((prev) => prev + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Watch for class changes on document element (dark mode toggle)
|
||||||
|
observer.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['class'],
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Draw waveform
|
// Draw waveform
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!track.audioBuffer || !canvasRef.current || track.collapsed) return;
|
if (!track.audioBuffer || !canvasRef.current) return;
|
||||||
|
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
|
// Use parent container's size since canvas is absolute positioned
|
||||||
|
const parent = canvas.parentElement;
|
||||||
|
if (!parent) return;
|
||||||
|
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = parent.getBoundingClientRect();
|
||||||
|
|
||||||
canvas.width = rect.width * dpr;
|
canvas.width = rect.width * dpr;
|
||||||
canvas.height = rect.height * dpr;
|
canvas.height = rect.height * dpr;
|
||||||
@@ -59,8 +131,9 @@ export function Track({
|
|||||||
const width = rect.width;
|
const width = rect.width;
|
||||||
const height = rect.height;
|
const height = rect.height;
|
||||||
|
|
||||||
// Clear canvas
|
// Clear canvas with theme color
|
||||||
ctx.fillStyle = 'rgb(15, 23, 42)';
|
const bgColor = getComputedStyle(canvas).getPropertyValue('--color-waveform-bg') || 'rgb(15, 23, 42)';
|
||||||
|
ctx.fillStyle = bgColor;
|
||||||
ctx.fillRect(0, 0, width, height);
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
const buffer = track.audioBuffer;
|
const buffer = track.audioBuffer;
|
||||||
@@ -112,7 +185,7 @@ export function Track({
|
|||||||
ctx.lineTo(playheadX, height);
|
ctx.lineTo(playheadX, height);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
}, [track.audioBuffer, track.color, track.collapsed, zoom, currentTime, duration]);
|
}, [track.audioBuffer, track.color, track.collapsed, track.height, zoom, currentTime, duration, themeKey]);
|
||||||
|
|
||||||
const handleCanvasClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
const handleCanvasClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||||
if (!onSeek || !duration) return;
|
if (!onSeek || !duration) return;
|
||||||
@@ -123,61 +196,354 @@ export function Track({
|
|||||||
onSeek(clickTime);
|
onSeek(clickTime);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (track.collapsed) {
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
return (
|
const file = e.target.files?.[0];
|
||||||
<div
|
if (!file || !onLoadAudio) return;
|
||||||
className={cn(
|
|
||||||
'border-b border-border cursor-pointer',
|
try {
|
||||||
isSelected && 'ring-2 ring-primary ring-inset'
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
)}
|
const audioContext = new AudioContext();
|
||||||
onClick={onSelect}
|
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||||
>
|
onLoadAudio(audioBuffer);
|
||||||
<TrackHeader
|
|
||||||
track={track}
|
// Update track name to filename if it's still default
|
||||||
onToggleMute={onToggleMute}
|
if (track.name === 'New Track' || track.name === 'Untitled Track') {
|
||||||
onToggleSolo={onToggleSolo}
|
const fileName = file.name.replace(/\.[^/.]+$/, '');
|
||||||
onToggleCollapse={onToggleCollapse}
|
onNameChange(fileName);
|
||||||
onVolumeChange={onVolumeChange}
|
}
|
||||||
onPanChange={onPanChange}
|
} catch (error) {
|
||||||
onRemove={onRemove}
|
console.error('Failed to load audio file:', error);
|
||||||
onNameChange={onNameChange}
|
}
|
||||||
/>
|
|
||||||
</div>
|
// Reset input
|
||||||
);
|
e.target.value = '';
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const handleLoadAudioClick = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const [isDragging, setIsDragging] = React.useState(false);
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = async (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
|
||||||
|
const file = e.dataTransfer.files?.[0];
|
||||||
|
if (!file || !onLoadAudio) return;
|
||||||
|
|
||||||
|
// Check if it's an audio file
|
||||||
|
if (!file.type.startsWith('audio/')) {
|
||||||
|
console.warn('Dropped file is not an audio file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const audioContext = new AudioContext();
|
||||||
|
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||||
|
onLoadAudio(audioBuffer);
|
||||||
|
|
||||||
|
// Update track name to filename if it's still default
|
||||||
|
if (track.name === 'New Track' || track.name === 'Untitled Track') {
|
||||||
|
const fileName = file.name.replace(/\.[^/.]+$/, '');
|
||||||
|
onNameChange(fileName);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load audio file:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const trackHeight = track.collapsed ? 48 : track.height;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
'border-b border-border cursor-pointer',
|
'flex flex-col',
|
||||||
isSelected && 'ring-2 ring-primary ring-inset'
|
isSelected && 'ring-2 ring-primary ring-inset'
|
||||||
)}
|
)}
|
||||||
onClick={onSelect}
|
|
||||||
>
|
>
|
||||||
<TrackHeader
|
{/* Top: Track Row (Control Panel + Waveform) */}
|
||||||
track={track}
|
<div className="flex" style={{ height: trackHeight }}>
|
||||||
onToggleMute={onToggleMute}
|
{/* Left: Track Control Panel (Fixed Width) */}
|
||||||
onToggleSolo={onToggleSolo}
|
<div
|
||||||
onToggleCollapse={onToggleCollapse}
|
className="w-72 flex-shrink-0 bg-card border-r border-border border-b border-border p-3 flex flex-col gap-2"
|
||||||
onVolumeChange={onVolumeChange}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onPanChange={onPanChange}
|
>
|
||||||
onRemove={onRemove}
|
{/* Track Name & Collapse Toggle */}
|
||||||
onNameChange={onNameChange}
|
<div className="flex items-center gap-2">
|
||||||
/>
|
<Button
|
||||||
<div className="relative" style={{ height: track.height }}>
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onToggleCollapse}
|
||||||
|
title={track.collapsed ? 'Expand track' : 'Collapse track'}
|
||||||
|
>
|
||||||
|
{track.collapsed ? (
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="w-1 h-8 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: track.color }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{isEditingName ? (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={nameInput}
|
||||||
|
onChange={(e) => setNameInput(e.target.value)}
|
||||||
|
onBlur={handleNameBlur}
|
||||||
|
onKeyDown={handleNameKeyDown}
|
||||||
|
className="w-full px-2 py-1 text-sm font-medium bg-background border border-border rounded"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
onClick={handleNameClick}
|
||||||
|
className="px-2 py-1 text-sm font-medium text-foreground truncate cursor-pointer hover:bg-accent rounded"
|
||||||
|
title={String(track.name || 'Untitled Track')}
|
||||||
|
>
|
||||||
|
{String(track.name || 'Untitled Track')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Solo Button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onToggleSolo}
|
||||||
|
title="Solo track"
|
||||||
|
className={cn(track.solo && 'bg-yellow-500/20 hover:bg-yellow-500/30')}
|
||||||
|
>
|
||||||
|
<Headphones className={cn('h-4 w-4', track.solo && 'text-yellow-500')} />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Mute Button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onToggleMute}
|
||||||
|
title="Mute track"
|
||||||
|
className={cn(track.mute && 'bg-red-500/20 hover:bg-red-500/30')}
|
||||||
|
>
|
||||||
|
{track.mute ? (
|
||||||
|
<VolumeX className="h-4 w-4 text-red-500" />
|
||||||
|
) : (
|
||||||
|
<Volume2 className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Remove Button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={onRemove}
|
||||||
|
title="Remove track"
|
||||||
|
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Track Controls - Only show when not collapsed */}
|
||||||
|
{!track.collapsed && (
|
||||||
|
<>
|
||||||
|
{/* Volume */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-xs text-muted-foreground flex items-center gap-1 w-16 flex-shrink-0">
|
||||||
|
<Volume2 className="h-3.5 w-3.5" />
|
||||||
|
Volume
|
||||||
|
</label>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Slider
|
||||||
|
value={track.volume}
|
||||||
|
onChange={onVolumeChange}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground w-10 text-right flex-shrink-0">
|
||||||
|
{Math.round(track.volume * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pan */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-xs text-muted-foreground flex items-center gap-1 w-16 flex-shrink-0">
|
||||||
|
<CircleArrowOutUpRight className="h-3 w-3" />
|
||||||
|
Pan
|
||||||
|
</label>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Slider
|
||||||
|
value={track.pan}
|
||||||
|
onChange={onPanChange}
|
||||||
|
min={-1}
|
||||||
|
max={1}
|
||||||
|
step={0.01}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground w-10 text-right flex-shrink-0">
|
||||||
|
{track.pan === 0
|
||||||
|
? 'C'
|
||||||
|
: track.pan < 0
|
||||||
|
? `L${Math.abs(Math.round(track.pan * 100))}`
|
||||||
|
: `R${Math.round(track.pan * 100)}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Waveform Area (Flexible Width) */}
|
||||||
|
<div
|
||||||
|
className="flex-1 relative bg-waveform-bg border-b border-border cursor-pointer"
|
||||||
|
onClick={onSelect}
|
||||||
|
>
|
||||||
{track.audioBuffer ? (
|
{track.audioBuffer ? (
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
className="w-full h-full cursor-pointer"
|
className="absolute inset-0 w-full h-full cursor-pointer"
|
||||||
onClick={handleCanvasClick}
|
onClick={handleCanvasClick}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
|
!track.collapsed && (
|
||||||
No audio loaded
|
<>
|
||||||
</div>
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 flex flex-col items-center justify-center text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer group",
|
||||||
|
isDragging ? "bg-primary/20 text-primary border-2 border-primary border-dashed" : "hover:bg-accent/50"
|
||||||
|
)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleLoadAudioClick();
|
||||||
|
}}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
<Upload className="h-6 w-6 mb-2 opacity-50 group-hover:opacity-100" />
|
||||||
|
<p>{isDragging ? 'Drop audio file here' : 'Click to load audio file'}</p>
|
||||||
|
<p className="text-xs opacity-75 mt-1">or drag & drop</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="audio/*"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom: Effects Section (Collapsible, Full Width) */}
|
||||||
|
{!track.collapsed && (
|
||||||
|
<div className="bg-muted/50 border-b border-border/50">
|
||||||
|
{/* Effects Header - clickable to toggle */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 cursor-pointer hover:bg-accent/30 transition-colors"
|
||||||
|
onClick={() => setShowEffects(!showEffects)}
|
||||||
|
>
|
||||||
|
{showEffects ? (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show mini effect chain when collapsed */}
|
||||||
|
{!showEffects && track.effectChain.effects.length > 0 ? (
|
||||||
|
<div className="flex-1 flex items-center gap-1 overflow-x-auto custom-scrollbar">
|
||||||
|
{track.effectChain.effects.map((effect) => (
|
||||||
|
<div
|
||||||
|
key={effect.id}
|
||||||
|
className={cn(
|
||||||
|
'px-2 py-0.5 rounded text-[10px] font-medium flex-shrink-0',
|
||||||
|
effect.enabled
|
||||||
|
? 'bg-primary/20 text-primary border border-primary/30'
|
||||||
|
: 'bg-muted/30 text-muted-foreground border border-border'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{effect.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
Devices ({track.effectChain.effects.length})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setEffectBrowserOpen(true);
|
||||||
|
}}
|
||||||
|
title="Add effect"
|
||||||
|
className="h-5 w-5 flex-shrink-0"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Horizontal scrolling device rack - expanded state */}
|
||||||
|
{showEffects && (
|
||||||
|
<div className="h-48 overflow-x-auto custom-scrollbar bg-muted/70">
|
||||||
|
<div className="flex h-full">
|
||||||
|
{track.effectChain.effects.length === 0 ? (
|
||||||
|
<div className="text-xs text-muted-foreground text-center py-8 w-full">
|
||||||
|
No devices. Click + to add an effect.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
track.effectChain.effects.map((effect) => (
|
||||||
|
<EffectDevice
|
||||||
|
key={effect.id}
|
||||||
|
effect={effect}
|
||||||
|
onToggleEnabled={() => onToggleEffect?.(effect.id)}
|
||||||
|
onRemove={() => onRemoveEffect?.(effect.id)}
|
||||||
|
onUpdateParameters={(params) => onUpdateEffect?.(effect.id, params)}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Effect Browser Dialog */}
|
||||||
|
<EffectBrowser
|
||||||
|
open={effectBrowserOpen}
|
||||||
|
onClose={() => setEffectBrowserOpen(false)}
|
||||||
|
onSelectEffect={(effectType) => {
|
||||||
|
if (onAddEffect) {
|
||||||
|
onAddEffect(effectType);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,12 +29,12 @@ export function TrackHeader({
|
|||||||
onNameChange,
|
onNameChange,
|
||||||
}: TrackHeaderProps) {
|
}: TrackHeaderProps) {
|
||||||
const [isEditingName, setIsEditingName] = React.useState(false);
|
const [isEditingName, setIsEditingName] = React.useState(false);
|
||||||
const [nameInput, setNameInput] = React.useState(track.name);
|
const [nameInput, setNameInput] = React.useState(String(track.name || 'Untitled Track'));
|
||||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const handleNameClick = () => {
|
const handleNameClick = () => {
|
||||||
setIsEditingName(true);
|
setIsEditingName(true);
|
||||||
setNameInput(track.name);
|
setNameInput(String(track.name || 'Untitled Track'));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNameBlur = () => {
|
const handleNameBlur = () => {
|
||||||
@@ -42,7 +42,7 @@ export function TrackHeader({
|
|||||||
if (nameInput.trim()) {
|
if (nameInput.trim()) {
|
||||||
onNameChange(nameInput.trim());
|
onNameChange(nameInput.trim());
|
||||||
} else {
|
} else {
|
||||||
setNameInput(track.name);
|
setNameInput(String(track.name || 'Untitled Track'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ export function TrackHeader({
|
|||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
inputRef.current?.blur();
|
inputRef.current?.blur();
|
||||||
} else if (e.key === 'Escape') {
|
} else if (e.key === 'Escape') {
|
||||||
setNameInput(track.name);
|
setNameInput(String(track.name || 'Untitled Track'));
|
||||||
setIsEditingName(false);
|
setIsEditingName(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/Button';
|
|||||||
import { Track } from './Track';
|
import { Track } from './Track';
|
||||||
import { ImportTrackDialog } from './ImportTrackDialog';
|
import { ImportTrackDialog } from './ImportTrackDialog';
|
||||||
import type { Track as TrackType } from '@/types/track';
|
import type { Track as TrackType } from '@/types/track';
|
||||||
|
import { createEffect, type EffectType, EFFECT_NAMES } from '@/lib/audio/effects/chain';
|
||||||
|
|
||||||
export interface TrackListProps {
|
export interface TrackListProps {
|
||||||
tracks: TrackType[];
|
tracks: TrackType[];
|
||||||
@@ -104,29 +105,49 @@ export function TrackList({
|
|||||||
onUpdateTrack(track.id, { name })
|
onUpdateTrack(track.id, { name })
|
||||||
}
|
}
|
||||||
onSeek={onSeek}
|
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 });
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add Track Buttons */}
|
|
||||||
<div className="p-2 border-t border-border bg-card space-y-2">
|
|
||||||
<Button onClick={onAddTrack} variant="outline" size="sm" className="w-full">
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
Add Empty Track
|
|
||||||
</Button>
|
|
||||||
{onImportTrack && (
|
|
||||||
<Button
|
|
||||||
onClick={() => setImportDialogOpen(true)}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<Upload className="h-4 w-4 mr-2" />
|
|
||||||
Import Audio Files
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Import Dialog */}
|
{/* Import Dialog */}
|
||||||
{onImportTrack && (
|
{onImportTrack && (
|
||||||
<ImportTrackDialog
|
<ImportTrackDialog
|
||||||
|
|||||||
@@ -25,11 +25,6 @@ import type { FilterOptions } from './filters';
|
|||||||
|
|
||||||
// Effect type identifier
|
// Effect type identifier
|
||||||
export type EffectType =
|
export type EffectType =
|
||||||
// Basic
|
|
||||||
| 'normalize'
|
|
||||||
| 'fadeIn'
|
|
||||||
| 'fadeOut'
|
|
||||||
| 'reverse'
|
|
||||||
// Filters
|
// Filters
|
||||||
| 'lowpass'
|
| 'lowpass'
|
||||||
| 'highpass'
|
| 'highpass'
|
||||||
@@ -116,7 +111,7 @@ export function createEffect(
|
|||||||
type,
|
type,
|
||||||
name,
|
name,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
parameters,
|
parameters: parameters || getDefaultParameters(type),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,14 +225,63 @@ export function loadPreset(preset: EffectPreset): EffectChain {
|
|||||||
return JSON.parse(JSON.stringify(preset.chain)); // Deep clone
|
return JSON.parse(JSON.stringify(preset.chain)); // Deep clone
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default parameters for an effect type
|
||||||
|
*/
|
||||||
|
export function getDefaultParameters(type: EffectType): EffectParameters {
|
||||||
|
switch (type) {
|
||||||
|
// Filters
|
||||||
|
case 'lowpass':
|
||||||
|
case 'highpass':
|
||||||
|
return { frequency: 1000, Q: 1 } as FilterOptions;
|
||||||
|
case 'bandpass':
|
||||||
|
case 'notch':
|
||||||
|
return { frequency: 1000, Q: 1 } as FilterOptions;
|
||||||
|
case 'lowshelf':
|
||||||
|
case 'highshelf':
|
||||||
|
return { frequency: 1000, Q: 1, gain: 0 } as FilterOptions;
|
||||||
|
case 'peaking':
|
||||||
|
return { frequency: 1000, Q: 1, gain: 0 } as FilterOptions;
|
||||||
|
|
||||||
|
// Dynamics
|
||||||
|
case 'compressor':
|
||||||
|
return { threshold: -24, ratio: 4, attack: 0.003, release: 0.25, knee: 30, makeupGain: 0 } as CompressorParameters;
|
||||||
|
case 'limiter':
|
||||||
|
return { threshold: -3, attack: 0.001, release: 0.05, makeupGain: 0 } as LimiterParameters;
|
||||||
|
case 'gate':
|
||||||
|
return { threshold: -40, ratio: 10, attack: 0.001, release: 0.1, knee: 0 } as GateParameters;
|
||||||
|
|
||||||
|
// Time-based
|
||||||
|
case 'delay':
|
||||||
|
return { time: 0.5, feedback: 0.3, mix: 0.5 } as DelayParameters;
|
||||||
|
case 'reverb':
|
||||||
|
return { roomSize: 0.5, damping: 0.5, mix: 0.3 } as ReverbParameters;
|
||||||
|
case 'chorus':
|
||||||
|
return { rate: 1.5, depth: 0.002, mix: 0.5 } as ChorusParameters;
|
||||||
|
case 'flanger':
|
||||||
|
return { rate: 0.5, depth: 0.002, feedback: 0.5, mix: 0.5 } as FlangerParameters;
|
||||||
|
case 'phaser':
|
||||||
|
return { rate: 0.5, depth: 0.5, stages: 4, mix: 0.5 } as PhaserParameters;
|
||||||
|
|
||||||
|
// Advanced
|
||||||
|
case 'distortion':
|
||||||
|
return { drive: 0.5, type: 'soft', output: 0.7, mix: 1 } as DistortionParameters;
|
||||||
|
case 'pitch':
|
||||||
|
return { semitones: 0, cents: 0, mix: 1 } as PitchShifterParameters;
|
||||||
|
case 'timestretch':
|
||||||
|
return { rate: 1.0, preservePitch: false, mix: 1 } as TimeStretchParameters;
|
||||||
|
case 'bitcrusher':
|
||||||
|
return { bitDepth: 8, sampleRate: 8000, mix: 1 } as BitcrusherParameters;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get effect display name
|
* Get effect display name
|
||||||
*/
|
*/
|
||||||
export const EFFECT_NAMES: Record<EffectType, string> = {
|
export const EFFECT_NAMES: Record<EffectType, string> = {
|
||||||
normalize: 'Normalize',
|
|
||||||
fadeIn: 'Fade In',
|
|
||||||
fadeOut: 'Fade Out',
|
|
||||||
reverse: 'Reverse',
|
|
||||||
lowpass: 'Low-Pass Filter',
|
lowpass: 'Low-Pass Filter',
|
||||||
highpass: 'High-Pass Filter',
|
highpass: 'High-Pass Filter',
|
||||||
bandpass: 'Band-Pass Filter',
|
bandpass: 'Band-Pass Filter',
|
||||||
|
|||||||
1059
lib/audio/effects/processor.ts
Normal file
1059
lib/audio/effects/processor.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import type { Track, TrackColor } from '@/types/track';
|
import type { Track, TrackColor } from '@/types/track';
|
||||||
import { DEFAULT_TRACK_HEIGHT, TRACK_COLORS } from '@/types/track';
|
import { DEFAULT_TRACK_HEIGHT, TRACK_COLORS } from '@/types/track';
|
||||||
|
import { createEffectChain } from '@/lib/audio/effects/chain';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a unique track ID
|
* Generate a unique track ID
|
||||||
@@ -19,9 +20,12 @@ export function createTrack(name?: string, color?: TrackColor): Track {
|
|||||||
const colors: TrackColor[] = ['blue', 'green', 'purple', 'orange', 'pink', 'indigo', 'yellow', 'red'];
|
const colors: TrackColor[] = ['blue', 'green', 'purple', 'orange', 'pink', 'indigo', 'yellow', 'red'];
|
||||||
const randomColor = colors[Math.floor(Math.random() * colors.length)];
|
const randomColor = colors[Math.floor(Math.random() * colors.length)];
|
||||||
|
|
||||||
|
// Ensure name is always a string, handle cases where event objects might be passed
|
||||||
|
const trackName = typeof name === 'string' && name.trim() ? name.trim() : 'New Track';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: generateTrackId(),
|
id: generateTrackId(),
|
||||||
name: name || 'New Track',
|
name: trackName,
|
||||||
color: TRACK_COLORS[color || randomColor],
|
color: TRACK_COLORS[color || randomColor],
|
||||||
height: DEFAULT_TRACK_HEIGHT,
|
height: DEFAULT_TRACK_HEIGHT,
|
||||||
audioBuffer: null,
|
audioBuffer: null,
|
||||||
@@ -30,6 +34,7 @@ export function createTrack(name?: string, color?: TrackColor): Track {
|
|||||||
mute: false,
|
mute: false,
|
||||||
solo: false,
|
solo: false,
|
||||||
recordEnabled: false,
|
recordEnabled: false,
|
||||||
|
effectChain: createEffectChain(`${trackName} Effects`),
|
||||||
collapsed: false,
|
collapsed: false,
|
||||||
selected: false,
|
selected: false,
|
||||||
};
|
};
|
||||||
@@ -43,7 +48,9 @@ export function createTrackFromBuffer(
|
|||||||
name?: string,
|
name?: string,
|
||||||
color?: TrackColor
|
color?: TrackColor
|
||||||
): Track {
|
): Track {
|
||||||
const track = createTrack(name, color);
|
// Ensure name is a string before passing to createTrack
|
||||||
|
const trackName = typeof name === 'string' && name.trim() ? name.trim() : undefined;
|
||||||
|
const track = createTrack(trackName, color);
|
||||||
track.audioBuffer = buffer;
|
track.audioBuffer = buffer;
|
||||||
return track;
|
return track;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import type { Track } from '@/types/track';
|
import type { Track } from '@/types/track';
|
||||||
import { createTrack, createTrackFromBuffer } from '@/lib/audio/track-utils';
|
import { createTrack, createTrackFromBuffer } from '@/lib/audio/track-utils';
|
||||||
|
import { createEffectChain } from '@/lib/audio/effects/chain';
|
||||||
|
|
||||||
const STORAGE_KEY = 'audio-ui-multi-track';
|
const STORAGE_KEY = 'audio-ui-multi-track';
|
||||||
|
|
||||||
@@ -12,11 +13,24 @@ export function useMultiTrack() {
|
|||||||
const saved = localStorage.getItem(STORAGE_KEY);
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
if (saved) {
|
if (saved) {
|
||||||
const parsed = JSON.parse(saved);
|
const parsed = JSON.parse(saved);
|
||||||
// Note: AudioBuffers can't be serialized, so we only restore track metadata
|
|
||||||
|
// Clear corrupted data immediately if we detect issues
|
||||||
|
const hasInvalidData = parsed.some((t: any) =>
|
||||||
|
typeof t.name !== 'string' || t.name === '[object Object]'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasInvalidData) {
|
||||||
|
console.warn('Detected corrupted track data in localStorage, clearing...');
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: AudioBuffers can't be serialized, but EffectChains can
|
||||||
return parsed.map((t: any) => ({
|
return parsed.map((t: any) => ({
|
||||||
...t,
|
...t,
|
||||||
name: String(t.name || 'Untitled Track'), // Ensure name is always a string
|
name: String(t.name || 'Untitled Track'), // Ensure name is always a string
|
||||||
audioBuffer: null, // Will need to be reloaded
|
audioBuffer: null, // Will need to be reloaded
|
||||||
|
effectChain: t.effectChain || createEffectChain(`${t.name} Effects`), // Restore effect chain or create new
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -33,7 +47,21 @@ export function useMultiTrack() {
|
|||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const trackData = tracks.map(({ audioBuffer, ...track }) => track);
|
// Only save serializable fields, excluding audioBuffer and any DOM references
|
||||||
|
const trackData = tracks.map((track) => ({
|
||||||
|
id: track.id,
|
||||||
|
name: String(track.name || 'Untitled Track'),
|
||||||
|
color: track.color,
|
||||||
|
height: track.height,
|
||||||
|
volume: track.volume,
|
||||||
|
pan: track.pan,
|
||||||
|
mute: track.mute,
|
||||||
|
solo: track.solo,
|
||||||
|
recordEnabled: track.recordEnabled,
|
||||||
|
collapsed: track.collapsed,
|
||||||
|
selected: track.selected,
|
||||||
|
effectChain: track.effectChain, // Save effect chain
|
||||||
|
}));
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(trackData));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(trackData));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save tracks to localStorage:', error);
|
console.error('Failed to save tracks to localStorage:', error);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useCallback, useRef, useEffect } from 'react';
|
|||||||
import { getAudioContext } from '@/lib/audio/context';
|
import { getAudioContext } from '@/lib/audio/context';
|
||||||
import type { Track } from '@/types/track';
|
import type { Track } from '@/types/track';
|
||||||
import { getTrackGain } from '@/lib/audio/track-utils';
|
import { getTrackGain } from '@/lib/audio/track-utils';
|
||||||
|
import { applyEffectChain, updateEffectParameters, toggleEffectBypass, type EffectNodeInfo } from '@/lib/audio/effects/processor';
|
||||||
|
|
||||||
export interface MultiTrackPlayerState {
|
export interface MultiTrackPlayerState {
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
@@ -9,7 +10,7 @@ export interface MultiTrackPlayerState {
|
|||||||
duration: number;
|
duration: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMultiTrackPlayer(tracks: Track[]) {
|
export function useMultiTrackPlayer(tracks: Track[], masterVolume: number = 1) {
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [duration, setDuration] = useState(0);
|
const [duration, setDuration] = useState(0);
|
||||||
@@ -18,9 +19,17 @@ export function useMultiTrackPlayer(tracks: Track[]) {
|
|||||||
const sourceNodesRef = useRef<AudioBufferSourceNode[]>([]);
|
const sourceNodesRef = useRef<AudioBufferSourceNode[]>([]);
|
||||||
const gainNodesRef = useRef<GainNode[]>([]);
|
const gainNodesRef = useRef<GainNode[]>([]);
|
||||||
const panNodesRef = useRef<StereoPannerNode[]>([]);
|
const panNodesRef = useRef<StereoPannerNode[]>([]);
|
||||||
|
const effectNodesRef = useRef<EffectNodeInfo[][]>([]); // Effect nodes per track
|
||||||
|
const masterGainNodeRef = useRef<GainNode | null>(null);
|
||||||
const startTimeRef = useRef<number>(0);
|
const startTimeRef = useRef<number>(0);
|
||||||
const pausedAtRef = useRef<number>(0);
|
const pausedAtRef = useRef<number>(0);
|
||||||
const animationFrameRef = useRef<number | null>(null);
|
const animationFrameRef = useRef<number | null>(null);
|
||||||
|
const tracksRef = useRef<Track[]>(tracks); // Always keep latest tracks
|
||||||
|
|
||||||
|
// Keep tracksRef in sync with tracks prop
|
||||||
|
useEffect(() => {
|
||||||
|
tracksRef.current = tracks;
|
||||||
|
}, [tracks]);
|
||||||
|
|
||||||
// Calculate total duration from all tracks
|
// Calculate total duration from all tracks
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -34,7 +43,7 @@ export function useMultiTrackPlayer(tracks: Track[]) {
|
|||||||
}, [tracks]);
|
}, [tracks]);
|
||||||
|
|
||||||
const updatePlaybackPosition = useCallback(() => {
|
const updatePlaybackPosition = useCallback(() => {
|
||||||
if (!audioContextRef.current || !isPlaying) return;
|
if (!audioContextRef.current) return;
|
||||||
|
|
||||||
const elapsed = audioContextRef.current.currentTime - startTimeRef.current;
|
const elapsed = audioContextRef.current.currentTime - startTimeRef.current;
|
||||||
const newTime = pausedAtRef.current + elapsed;
|
const newTime = pausedAtRef.current + elapsed;
|
||||||
@@ -43,12 +52,16 @@ export function useMultiTrackPlayer(tracks: Track[]) {
|
|||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
setCurrentTime(0);
|
setCurrentTime(0);
|
||||||
pausedAtRef.current = 0;
|
pausedAtRef.current = 0;
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
animationFrameRef.current = null;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentTime(newTime);
|
setCurrentTime(newTime);
|
||||||
animationFrameRef.current = requestAnimationFrame(updatePlaybackPosition);
|
animationFrameRef.current = requestAnimationFrame(updatePlaybackPosition);
|
||||||
}, [isPlaying, duration]);
|
}, [duration]);
|
||||||
|
|
||||||
const play = useCallback(() => {
|
const play = useCallback(() => {
|
||||||
if (tracks.length === 0 || tracks.every(t => !t.audioBuffer)) return;
|
if (tracks.length === 0 || tracks.every(t => !t.audioBuffer)) return;
|
||||||
@@ -67,10 +80,20 @@ export function useMultiTrackPlayer(tracks: Track[]) {
|
|||||||
});
|
});
|
||||||
gainNodesRef.current.forEach(node => node.disconnect());
|
gainNodesRef.current.forEach(node => node.disconnect());
|
||||||
panNodesRef.current.forEach(node => node.disconnect());
|
panNodesRef.current.forEach(node => node.disconnect());
|
||||||
|
if (masterGainNodeRef.current) {
|
||||||
|
masterGainNodeRef.current.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
sourceNodesRef.current = [];
|
sourceNodesRef.current = [];
|
||||||
gainNodesRef.current = [];
|
gainNodesRef.current = [];
|
||||||
panNodesRef.current = [];
|
panNodesRef.current = [];
|
||||||
|
effectNodesRef.current = [];
|
||||||
|
|
||||||
|
// Create master gain node
|
||||||
|
const masterGain = audioContext.createGain();
|
||||||
|
masterGain.gain.setValueAtTime(masterVolume, audioContext.currentTime);
|
||||||
|
masterGain.connect(audioContext.destination);
|
||||||
|
masterGainNodeRef.current = masterGain;
|
||||||
|
|
||||||
// Create audio graph for each track
|
// Create audio graph for each track
|
||||||
for (const track of tracks) {
|
for (const track of tracks) {
|
||||||
@@ -89,10 +112,19 @@ export function useMultiTrackPlayer(tracks: Track[]) {
|
|||||||
// Set pan
|
// Set pan
|
||||||
panNode.pan.setValueAtTime(track.pan, audioContext.currentTime);
|
panNode.pan.setValueAtTime(track.pan, audioContext.currentTime);
|
||||||
|
|
||||||
// Connect: source -> gain -> pan -> destination
|
// Connect: source -> gain -> pan -> effects -> master gain -> destination
|
||||||
source.connect(gainNode);
|
source.connect(gainNode);
|
||||||
gainNode.connect(panNode);
|
gainNode.connect(panNode);
|
||||||
panNode.connect(audioContext.destination);
|
|
||||||
|
// Apply effect chain
|
||||||
|
console.log('[MultiTrackPlayer] Applying effect chain for track:', track.name);
|
||||||
|
console.log('[MultiTrackPlayer] Effect chain ID:', track.effectChain.id);
|
||||||
|
console.log('[MultiTrackPlayer] Effect chain name:', track.effectChain.name);
|
||||||
|
console.log('[MultiTrackPlayer] Number of effects:', track.effectChain.effects.length);
|
||||||
|
console.log('[MultiTrackPlayer] Effects:', track.effectChain.effects);
|
||||||
|
const { outputNode, effectNodes } = applyEffectChain(audioContext, panNode, track.effectChain);
|
||||||
|
outputNode.connect(masterGain);
|
||||||
|
console.log('[MultiTrackPlayer] Effect output connected with', effectNodes.length, 'effect nodes');
|
||||||
|
|
||||||
// Start playback from current position
|
// Start playback from current position
|
||||||
source.start(0, pausedAtRef.current);
|
source.start(0, pausedAtRef.current);
|
||||||
@@ -101,6 +133,7 @@ export function useMultiTrackPlayer(tracks: Track[]) {
|
|||||||
sourceNodesRef.current.push(source);
|
sourceNodesRef.current.push(source);
|
||||||
gainNodesRef.current.push(gainNode);
|
gainNodesRef.current.push(gainNode);
|
||||||
panNodesRef.current.push(panNode);
|
panNodesRef.current.push(panNode);
|
||||||
|
effectNodesRef.current.push(effectNodes);
|
||||||
|
|
||||||
// Handle ended event
|
// Handle ended event
|
||||||
source.onended = () => {
|
source.onended = () => {
|
||||||
@@ -115,7 +148,7 @@ export function useMultiTrackPlayer(tracks: Track[]) {
|
|||||||
startTimeRef.current = audioContext.currentTime;
|
startTimeRef.current = audioContext.currentTime;
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
updatePlaybackPosition();
|
updatePlaybackPosition();
|
||||||
}, [tracks, duration, updatePlaybackPosition]);
|
}, [tracks, duration, masterVolume, updatePlaybackPosition]);
|
||||||
|
|
||||||
const pause = useCallback(() => {
|
const pause = useCallback(() => {
|
||||||
if (!audioContextRef.current || !isPlaying) return;
|
if (!audioContextRef.current || !isPlaying) return;
|
||||||
@@ -174,7 +207,7 @@ export function useMultiTrackPlayer(tracks: Track[]) {
|
|||||||
}
|
}
|
||||||
}, [isPlaying, play, pause]);
|
}, [isPlaying, play, pause]);
|
||||||
|
|
||||||
// Update gain/pan when tracks change
|
// Update gain/pan when tracks change (simple updates that don't require graph rebuild)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPlaying || !audioContextRef.current) return;
|
if (!isPlaying || !audioContextRef.current) return;
|
||||||
|
|
||||||
@@ -196,6 +229,225 @@ export function useMultiTrackPlayer(tracks: Track[]) {
|
|||||||
});
|
});
|
||||||
}, [tracks, isPlaying]);
|
}, [tracks, isPlaying]);
|
||||||
|
|
||||||
|
// Track effect chain structure to detect add/remove operations
|
||||||
|
const previousEffectStructureRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// Detect effect chain structure changes (add/remove/reorder) and restart
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlaying || !audioContextRef.current) return;
|
||||||
|
|
||||||
|
// Create a signature of the current effect structure (IDs and count)
|
||||||
|
const currentStructure = tracks.map(track =>
|
||||||
|
track.effectChain.effects.map(e => e.id).join(',')
|
||||||
|
).join('|');
|
||||||
|
|
||||||
|
// If structure changed (effects added/removed/reordered) while playing, restart
|
||||||
|
// Don't restart if tracks is empty (intermediate state during updates)
|
||||||
|
if (previousEffectStructureRef.current !== null &&
|
||||||
|
previousEffectStructureRef.current !== currentStructure &&
|
||||||
|
tracks.length > 0) {
|
||||||
|
console.log('[useMultiTrackPlayer] Effect chain structure changed, restarting...');
|
||||||
|
|
||||||
|
// Update the reference immediately to prevent re-triggering
|
||||||
|
previousEffectStructureRef.current = currentStructure;
|
||||||
|
|
||||||
|
// Update tracksRef with current tracks BEFORE setTimeout
|
||||||
|
tracksRef.current = tracks;
|
||||||
|
|
||||||
|
// Save current position
|
||||||
|
const elapsed = audioContextRef.current.currentTime - startTimeRef.current;
|
||||||
|
const currentPos = pausedAtRef.current + elapsed;
|
||||||
|
|
||||||
|
// Stop all source nodes
|
||||||
|
sourceNodesRef.current.forEach(node => {
|
||||||
|
try {
|
||||||
|
node.onended = null;
|
||||||
|
node.stop();
|
||||||
|
node.disconnect();
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update position
|
||||||
|
pausedAtRef.current = currentPos;
|
||||||
|
setCurrentTime(currentPos);
|
||||||
|
setIsPlaying(false);
|
||||||
|
|
||||||
|
// Clear animation frame
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
animationFrameRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart after a brief delay
|
||||||
|
setTimeout(() => {
|
||||||
|
// Use tracksRef.current to get the latest tracks, not the stale closure
|
||||||
|
const latestTracks = tracksRef.current;
|
||||||
|
|
||||||
|
if (latestTracks.length === 0 || latestTracks.every(t => !t.audioBuffer)) return;
|
||||||
|
|
||||||
|
const audioContext = getAudioContext();
|
||||||
|
audioContextRef.current = audioContext;
|
||||||
|
|
||||||
|
// Disconnect old nodes
|
||||||
|
gainNodesRef.current.forEach(node => node.disconnect());
|
||||||
|
panNodesRef.current.forEach(node => node.disconnect());
|
||||||
|
effectNodesRef.current.forEach(trackEffects => {
|
||||||
|
trackEffects.forEach(effectNodeInfo => {
|
||||||
|
if (effectNodeInfo.node) {
|
||||||
|
try {
|
||||||
|
effectNodeInfo.node.disconnect();
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (effectNodeInfo.dryGain) effectNodeInfo.dryGain.disconnect();
|
||||||
|
if (effectNodeInfo.wetGain) effectNodeInfo.wetGain.disconnect();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (masterGainNodeRef.current) {
|
||||||
|
masterGainNodeRef.current.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset refs
|
||||||
|
sourceNodesRef.current = [];
|
||||||
|
gainNodesRef.current = [];
|
||||||
|
panNodesRef.current = [];
|
||||||
|
effectNodesRef.current = [];
|
||||||
|
|
||||||
|
// Create master gain node
|
||||||
|
const masterGain = audioContext.createGain();
|
||||||
|
masterGain.gain.setValueAtTime(masterVolume, audioContext.currentTime);
|
||||||
|
masterGain.connect(audioContext.destination);
|
||||||
|
masterGainNodeRef.current = masterGain;
|
||||||
|
|
||||||
|
// Create audio graph for each track
|
||||||
|
for (const track of latestTracks) {
|
||||||
|
if (!track.audioBuffer) continue;
|
||||||
|
|
||||||
|
const source = audioContext.createBufferSource();
|
||||||
|
source.buffer = track.audioBuffer;
|
||||||
|
|
||||||
|
const gainNode = audioContext.createGain();
|
||||||
|
const panNode = audioContext.createStereoPanner();
|
||||||
|
|
||||||
|
// Set gain based on track volume and solo/mute state
|
||||||
|
const trackGain = getTrackGain(track, latestTracks);
|
||||||
|
gainNode.gain.setValueAtTime(trackGain, audioContext.currentTime);
|
||||||
|
|
||||||
|
// Set pan
|
||||||
|
panNode.pan.setValueAtTime(track.pan, audioContext.currentTime);
|
||||||
|
|
||||||
|
// Connect: source -> gain -> pan -> effects -> master gain -> destination
|
||||||
|
source.connect(gainNode);
|
||||||
|
gainNode.connect(panNode);
|
||||||
|
|
||||||
|
// Apply effect chain
|
||||||
|
const { outputNode, effectNodes } = applyEffectChain(audioContext, panNode, track.effectChain);
|
||||||
|
outputNode.connect(masterGain);
|
||||||
|
|
||||||
|
// Start playback from current position
|
||||||
|
source.start(0, pausedAtRef.current);
|
||||||
|
|
||||||
|
// Store references
|
||||||
|
sourceNodesRef.current.push(source);
|
||||||
|
gainNodesRef.current.push(gainNode);
|
||||||
|
panNodesRef.current.push(panNode);
|
||||||
|
effectNodesRef.current.push(effectNodes);
|
||||||
|
|
||||||
|
// Handle ended event
|
||||||
|
source.onended = () => {
|
||||||
|
if (pausedAtRef.current + (audioContext.currentTime - startTimeRef.current) >= duration) {
|
||||||
|
setIsPlaying(false);
|
||||||
|
setCurrentTime(0);
|
||||||
|
pausedAtRef.current = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
startTimeRef.current = audioContext.currentTime;
|
||||||
|
setIsPlaying(true);
|
||||||
|
|
||||||
|
// Start animation frame for position updates
|
||||||
|
const updatePosition = () => {
|
||||||
|
if (!audioContextRef.current) return;
|
||||||
|
|
||||||
|
const elapsed = audioContextRef.current.currentTime - startTimeRef.current;
|
||||||
|
const newTime = pausedAtRef.current + elapsed;
|
||||||
|
|
||||||
|
if (newTime >= duration) {
|
||||||
|
setIsPlaying(false);
|
||||||
|
setCurrentTime(0);
|
||||||
|
pausedAtRef.current = 0;
|
||||||
|
if (animationFrameRef.current) {
|
||||||
|
cancelAnimationFrame(animationFrameRef.current);
|
||||||
|
animationFrameRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentTime(newTime);
|
||||||
|
animationFrameRef.current = requestAnimationFrame(updatePosition);
|
||||||
|
};
|
||||||
|
updatePosition();
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
previousEffectStructureRef.current = currentStructure;
|
||||||
|
}, [tracks, isPlaying, duration, masterVolume]);
|
||||||
|
|
||||||
|
// Stop playback when all tracks are deleted
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlaying) return;
|
||||||
|
|
||||||
|
// If tracks become empty or all tracks have no audio buffer, stop playback
|
||||||
|
if (tracks.length === 0 || tracks.every(t => !t.audioBuffer)) {
|
||||||
|
console.log('[useMultiTrackPlayer] All tracks deleted, stopping playback');
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
}, [tracks, isPlaying, stop]);
|
||||||
|
|
||||||
|
// Update effect parameters and bypass state in real-time
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlaying || !audioContextRef.current) return;
|
||||||
|
|
||||||
|
tracks.forEach((track, trackIndex) => {
|
||||||
|
const effectNodes = effectNodesRef.current[trackIndex];
|
||||||
|
if (!effectNodes) return;
|
||||||
|
|
||||||
|
// Only update if we have the same number of effects (no add/remove)
|
||||||
|
if (effectNodes.length !== track.effectChain.effects.length) return;
|
||||||
|
|
||||||
|
track.effectChain.effects.forEach((effect, effectIndex) => {
|
||||||
|
const effectNodeInfo = effectNodes[effectIndex];
|
||||||
|
if (!effectNodeInfo) return;
|
||||||
|
|
||||||
|
// Update bypass state
|
||||||
|
if (effect.enabled !== effectNodeInfo.effect.enabled) {
|
||||||
|
toggleEffectBypass(audioContextRef.current!, effectNodeInfo, effect.enabled);
|
||||||
|
effectNodeInfo.effect.enabled = effect.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update parameters (only works for certain effect types)
|
||||||
|
if (JSON.stringify(effect.parameters) !== JSON.stringify(effectNodeInfo.effect.parameters)) {
|
||||||
|
updateEffectParameters(audioContextRef.current!, effectNodeInfo, effect);
|
||||||
|
effectNodeInfo.effect.parameters = effect.parameters;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [tracks, isPlaying]);
|
||||||
|
|
||||||
|
// Update master volume when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPlaying || !audioContextRef.current || !masterGainNodeRef.current) return;
|
||||||
|
|
||||||
|
masterGainNodeRef.current.gain.setValueAtTime(
|
||||||
|
masterVolume,
|
||||||
|
audioContextRef.current.currentTime
|
||||||
|
);
|
||||||
|
}, [masterVolume, isPlaying]);
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -212,6 +464,9 @@ export function useMultiTrackPlayer(tracks: Track[]) {
|
|||||||
});
|
});
|
||||||
gainNodesRef.current.forEach(node => node.disconnect());
|
gainNodesRef.current.forEach(node => node.disconnect());
|
||||||
panNodesRef.current.forEach(node => node.disconnect());
|
panNodesRef.current.forEach(node => node.disconnect());
|
||||||
|
if (masterGainNodeRef.current) {
|
||||||
|
masterGainNodeRef.current.disconnect();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* Multi-track types and interfaces
|
* Multi-track types and interfaces
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { EffectChain } from '@/lib/audio/effects/chain';
|
||||||
|
|
||||||
export interface Track {
|
export interface Track {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -16,6 +18,9 @@ export interface Track {
|
|||||||
solo: boolean;
|
solo: boolean;
|
||||||
recordEnabled: boolean;
|
recordEnabled: boolean;
|
||||||
|
|
||||||
|
// Effects
|
||||||
|
effectChain: EffectChain;
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user