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
|
||||
- ✅ Per-track gain and pan 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
|
||||
- **Phase 6**: Audio effects ✅ COMPLETE (Basic + Filters + Dynamics + Time-Based + Advanced + Chain Management)
|
||||
@@ -564,10 +576,14 @@ audio-ui/
|
||||
- [ ] Send/Return effects - FUTURE
|
||||
- [ ] Sidechain support (advanced) - FUTURE
|
||||
|
||||
#### 7.4 Track Effects (Pending - Phase 8+)
|
||||
- [ ] Per-track effect chain
|
||||
- [ ] Effect rack UI
|
||||
- [ ] Effect bypass per track
|
||||
#### 7.4 Track Effects (Complete)
|
||||
- [x] Per-track effect chain
|
||||
- [x] Effect rack UI
|
||||
- [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
|
||||
|
||||
|
||||
124
app/globals.css
124
app/globals.css
@@ -19,97 +19,97 @@
|
||||
/* CSS Variables for theming */
|
||||
@layer base {
|
||||
:root {
|
||||
/* Light mode colors using OKLCH */
|
||||
--background: oklch(100% 0 0);
|
||||
--foreground: oklch(9.8% 0.038 285.8);
|
||||
/* Light mode colors using OKLCH - bright neon palette */
|
||||
--background: oklch(98% 0.03 180);
|
||||
--foreground: oklch(20% 0.12 310);
|
||||
|
||||
--card: oklch(100% 0 0);
|
||||
--card-foreground: oklch(9.8% 0.038 285.8);
|
||||
--card: oklch(99% 0.02 200);
|
||||
--card-foreground: oklch(20% 0.12 310);
|
||||
|
||||
--popover: oklch(100% 0 0);
|
||||
--popover-foreground: oklch(9.8% 0.038 285.8);
|
||||
--popover: oklch(99% 0.02 200);
|
||||
--popover-foreground: oklch(20% 0.12 310);
|
||||
|
||||
--primary: oklch(22.4% 0.053 285.8);
|
||||
--primary-foreground: oklch(98% 0 0);
|
||||
--primary: oklch(58% 0.28 320);
|
||||
--primary-foreground: oklch(99% 0.02 200);
|
||||
|
||||
--secondary: oklch(96.1% 0 0);
|
||||
--secondary-foreground: oklch(13.8% 0.038 285.8);
|
||||
--secondary: oklch(92% 0.08 200);
|
||||
--secondary-foreground: oklch(25% 0.15 300);
|
||||
|
||||
--muted: oklch(96.1% 0 0);
|
||||
--muted-foreground: oklch(45.1% 0.015 285.9);
|
||||
--muted: oklch(94% 0.05 190);
|
||||
--muted-foreground: oklch(40% 0.12 260);
|
||||
|
||||
--accent: oklch(96.1% 0 0);
|
||||
--accent-foreground: oklch(13.8% 0.038 285.8);
|
||||
--accent: oklch(90% 0.12 180);
|
||||
--accent-foreground: oklch(25% 0.18 310);
|
||||
|
||||
--destructive: oklch(60.2% 0.168 29.2);
|
||||
--destructive-foreground: oklch(98% 0 0);
|
||||
--destructive: oklch(60% 0.28 15);
|
||||
--destructive-foreground: oklch(99% 0.02 200);
|
||||
|
||||
--border: oklch(89.8% 0 0);
|
||||
--input: oklch(89.8% 0 0);
|
||||
--ring: oklch(22.4% 0.053 285.8);
|
||||
--border: oklch(85% 0.08 200);
|
||||
--input: oklch(92% 0.06 190);
|
||||
--ring: oklch(58% 0.28 320);
|
||||
|
||||
--radius: 0.5rem;
|
||||
|
||||
--success: oklch(60% 0.15 145);
|
||||
--success-foreground: oklch(98% 0 0);
|
||||
--success: oklch(58% 0.25 160);
|
||||
--success-foreground: oklch(99% 0.02 200);
|
||||
|
||||
--warning: oklch(75% 0.15 85);
|
||||
--warning-foreground: oklch(20% 0 0);
|
||||
--warning: oklch(68% 0.25 85);
|
||||
--warning-foreground: oklch(20% 0.12 310);
|
||||
|
||||
--info: oklch(65% 0.15 240);
|
||||
--info-foreground: oklch(98% 0 0);
|
||||
--info: oklch(62% 0.25 240);
|
||||
--info-foreground: oklch(99% 0.02 200);
|
||||
|
||||
/* Audio-specific colors */
|
||||
--waveform: oklch(50% 0.1 240);
|
||||
--waveform-progress: oklch(60% 0.15 145);
|
||||
--waveform-selection: oklch(65% 0.15 240);
|
||||
--waveform-bg: oklch(98% 0 0);
|
||||
/* Audio-specific colors - neon cyan/magenta */
|
||||
--waveform: oklch(60% 0.26 200);
|
||||
--waveform-progress: oklch(58% 0.28 320);
|
||||
--waveform-selection: oklch(62% 0.26 180);
|
||||
--waveform-bg: oklch(99% 0.015 190);
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Dark mode colors using OKLCH */
|
||||
--background: oklch(9.8% 0.038 285.8);
|
||||
--foreground: oklch(98% 0 0);
|
||||
/* Dark mode colors using OKLCH - vibrant neon palette */
|
||||
--background: oklch(15% 0.015 265);
|
||||
--foreground: oklch(92% 0.02 180);
|
||||
|
||||
--card: oklch(9.8% 0.038 285.8);
|
||||
--card-foreground: oklch(98% 0 0);
|
||||
--card: oklch(18% 0.02 270);
|
||||
--card-foreground: oklch(92% 0.02 180);
|
||||
|
||||
--popover: oklch(9.8% 0.038 285.8);
|
||||
--popover-foreground: oklch(98% 0 0);
|
||||
--popover: oklch(18% 0.02 270);
|
||||
--popover-foreground: oklch(92% 0.02 180);
|
||||
|
||||
--primary: oklch(98% 0 0);
|
||||
--primary-foreground: oklch(13.8% 0.038 285.8);
|
||||
--primary: oklch(75% 0.25 310);
|
||||
--primary-foreground: oklch(18% 0.02 270);
|
||||
|
||||
--secondary: oklch(17.7% 0.038 285.8);
|
||||
--secondary-foreground: oklch(98% 0 0);
|
||||
--secondary: oklch(22% 0.03 280);
|
||||
--secondary-foreground: oklch(85% 0.15 180);
|
||||
|
||||
--muted: oklch(17.7% 0.038 285.8);
|
||||
--muted-foreground: oklch(63.9% 0.012 285.9);
|
||||
--muted: oklch(20% 0.02 270);
|
||||
--muted-foreground: oklch(65% 0.1 200);
|
||||
|
||||
--accent: oklch(17.7% 0.038 285.8);
|
||||
--accent-foreground: oklch(98% 0 0);
|
||||
--accent: oklch(25% 0.03 290);
|
||||
--accent-foreground: oklch(85% 0.2 320);
|
||||
|
||||
--destructive: oklch(50% 0.2 29.2);
|
||||
--destructive-foreground: oklch(98% 0 0);
|
||||
--destructive: oklch(65% 0.25 20);
|
||||
--destructive-foreground: oklch(92% 0.02 180);
|
||||
|
||||
--border: oklch(17.7% 0.038 285.8);
|
||||
--input: oklch(17.7% 0.038 285.8);
|
||||
--ring: oklch(83.1% 0.012 285.9);
|
||||
--border: oklch(30% 0.05 280);
|
||||
--input: oklch(22% 0.03 280);
|
||||
--ring: oklch(75% 0.25 310);
|
||||
|
||||
--success: oklch(55% 0.15 145);
|
||||
--success-foreground: oklch(98% 0 0);
|
||||
--success: oklch(70% 0.22 160);
|
||||
--success-foreground: oklch(18% 0.02 270);
|
||||
|
||||
--warning: oklch(70% 0.15 85);
|
||||
--warning-foreground: oklch(20% 0 0);
|
||||
--warning: oklch(75% 0.22 80);
|
||||
--warning-foreground: oklch(18% 0.02 270);
|
||||
|
||||
--info: oklch(60% 0.15 240);
|
||||
--info-foreground: oklch(98% 0 0);
|
||||
--info: oklch(72% 0.22 240);
|
||||
--info-foreground: oklch(18% 0.02 270);
|
||||
|
||||
/* Audio-specific colors */
|
||||
--waveform: oklch(70% 0.15 240);
|
||||
--waveform-progress: oklch(65% 0.15 145);
|
||||
--waveform-selection: oklch(70% 0.15 240);
|
||||
--waveform-bg: oklch(12% 0.038 285.8);
|
||||
/* Audio-specific colors - neon cyan/magenta */
|
||||
--waveform: oklch(72% 0.25 200);
|
||||
--waveform-progress: oklch(75% 0.25 310);
|
||||
--waveform-selection: oklch(70% 0.25 180);
|
||||
--waveform-bg: oklch(12% 0.02 270);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Music } from 'lucide-react';
|
||||
import { Music, Plus, Upload, Trash2 } from 'lucide-react';
|
||||
import { PlaybackControls } from './PlaybackControls';
|
||||
import { SidePanel } from '@/components/layout/SidePanel';
|
||||
import { ThemeToggle } from '@/components/layout/ThemeToggle';
|
||||
import { CommandPalette } from '@/components/ui/CommandPalette';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import type { CommandAction } from '@/components/ui/CommandPalette';
|
||||
import { useMultiTrack } from '@/lib/hooks/useMultiTrack';
|
||||
import { useMultiTrackPlayer } from '@/lib/hooks/useMultiTrackPlayer';
|
||||
@@ -19,6 +19,7 @@ export function AudioEditor() {
|
||||
const [importDialogOpen, setImportDialogOpen] = React.useState(false);
|
||||
const [selectedTrackId, setSelectedTrackId] = React.useState<string | null>(null);
|
||||
const [zoom, setZoom] = React.useState(1);
|
||||
const [masterVolume, setMasterVolume] = React.useState(0.8);
|
||||
|
||||
const { addToast } = useToast();
|
||||
|
||||
@@ -32,6 +33,15 @@ export function AudioEditor() {
|
||||
clearTracks,
|
||||
} = 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 {
|
||||
isPlaying,
|
||||
currentTime,
|
||||
@@ -41,19 +51,19 @@ export function AudioEditor() {
|
||||
stop,
|
||||
seek,
|
||||
togglePlayPause,
|
||||
} = useMultiTrackPlayer(tracks);
|
||||
} = useMultiTrackPlayer(tracks, masterVolume);
|
||||
|
||||
// Effect chain (for selected track)
|
||||
// Master effect chain
|
||||
const {
|
||||
chain: effectChain,
|
||||
presets: effectPresets,
|
||||
toggleEffectEnabled,
|
||||
removeEffect,
|
||||
reorder: reorderEffects,
|
||||
clearChain,
|
||||
savePreset,
|
||||
loadPresetToChain,
|
||||
deletePreset,
|
||||
chain: masterEffectChain,
|
||||
presets: masterEffectPresets,
|
||||
toggleEffectEnabled: toggleMasterEffect,
|
||||
removeEffect: removeMasterEffect,
|
||||
reorder: reorderMasterEffects,
|
||||
clearChain: clearMasterChain,
|
||||
savePreset: saveMasterPreset,
|
||||
loadPresetToChain: loadMasterPreset,
|
||||
deletePreset: deleteMasterPreset,
|
||||
} = useEffectChain();
|
||||
|
||||
// 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
|
||||
const handleZoomIn = () => {
|
||||
setZoom((prev) => Math.min(20, prev + 1));
|
||||
@@ -96,6 +148,30 @@ export function AudioEditor() {
|
||||
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
|
||||
const selectedTrack = tracks.find((t) => t.id === selectedTrackId);
|
||||
|
||||
@@ -205,9 +281,29 @@ export function AudioEditor() {
|
||||
{/* Compact Header */}
|
||||
<header className="flex items-center justify-between px-4 py-2 border-b border-border bg-card flex-shrink-0 gap-4">
|
||||
{/* Left: Logo */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Music className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-lg font-bold text-foreground">Audio UI</h1>
|
||||
<div className="flex items-center gap-4 flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
|
||||
{/* Right: Command Palette + Theme Toggle */}
|
||||
@@ -219,27 +315,6 @@ export function AudioEditor() {
|
||||
|
||||
{/* Main content area */}
|
||||
<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 className="flex-1 flex flex-col overflow-hidden bg-background">
|
||||
{/* Multi-Track View */}
|
||||
@@ -259,26 +334,27 @@ export function AudioEditor() {
|
||||
/>
|
||||
</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>
|
||||
</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 */}
|
||||
<ImportTrackDialog
|
||||
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,
|
||||
Link2,
|
||||
FolderOpen,
|
||||
Volume2,
|
||||
Music2,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Slider } from '@/components/ui/Slider';
|
||||
import { cn } from '@/lib/utils/cn';
|
||||
import { formatDuration } from '@/lib/audio/decoder';
|
||||
import type { Track } from '@/types/track';
|
||||
import type { EffectChain, EffectPreset } from '@/lib/audio/effects/chain';
|
||||
import { EffectRack } from '@/components/effects/EffectRack';
|
||||
@@ -31,16 +28,23 @@ export interface SidePanelProps {
|
||||
onRemoveTrack: (trackId: string) => void;
|
||||
onClearTracks: () => void;
|
||||
|
||||
// Effect chain
|
||||
effectChain: EffectChain;
|
||||
effectPresets: EffectPreset[];
|
||||
onToggleEffect: (effectId: string) => void;
|
||||
onRemoveEffect: (effectId: string) => void;
|
||||
onReorderEffects: (fromIndex: number, toIndex: number) => void;
|
||||
onSavePreset: (preset: EffectPreset) => void;
|
||||
onLoadPreset: (preset: EffectPreset) => void;
|
||||
onDeletePreset: (presetId: string) => void;
|
||||
onClearChain: () => void;
|
||||
// Track effect chain (for selected track)
|
||||
trackEffectChain: EffectChain | null;
|
||||
onToggleTrackEffect: (effectId: string) => void;
|
||||
onRemoveTrackEffect: (effectId: string) => void;
|
||||
onReorderTrackEffects: (fromIndex: number, toIndex: number) => void;
|
||||
onClearTrackChain: () => void;
|
||||
|
||||
// Master effect chain
|
||||
masterEffectChain: EffectChain;
|
||||
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;
|
||||
}
|
||||
@@ -54,19 +58,24 @@ export function SidePanel({
|
||||
onUpdateTrack,
|
||||
onRemoveTrack,
|
||||
onClearTracks,
|
||||
effectChain,
|
||||
effectPresets,
|
||||
onToggleEffect,
|
||||
onRemoveEffect,
|
||||
onReorderEffects,
|
||||
onSavePreset,
|
||||
onLoadPreset,
|
||||
onDeletePreset,
|
||||
onClearChain,
|
||||
trackEffectChain,
|
||||
onToggleTrackEffect,
|
||||
onRemoveTrackEffect,
|
||||
onReorderTrackEffects,
|
||||
onClearTrackChain,
|
||||
masterEffectChain,
|
||||
masterEffectPresets,
|
||||
onToggleMasterEffect,
|
||||
onRemoveMasterEffect,
|
||||
onReorderMasterEffects,
|
||||
onSaveMasterPreset,
|
||||
onLoadMasterPreset,
|
||||
onDeleteMasterPreset,
|
||||
onClearMasterChain,
|
||||
className,
|
||||
}: SidePanelProps) {
|
||||
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 selectedTrack = tracks.find((t) => t.id === selectedTrackId);
|
||||
@@ -98,19 +107,21 @@ export function SidePanel({
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant={activeTab === 'tracks' ? 'secondary' : 'ghost'}
|
||||
size="icon-sm"
|
||||
size="sm"
|
||||
onClick={() => setActiveTab('tracks')}
|
||||
title="Tracks"
|
||||
>
|
||||
<Music2 className="h-4 w-4" />
|
||||
<Music2 className="h-4 w-4 mr-1.5" />
|
||||
Tracks
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'chain' ? 'secondary' : 'ghost'}
|
||||
size="icon-sm"
|
||||
onClick={() => setActiveTab('chain')}
|
||||
title="Effect Chain"
|
||||
variant={activeTab === 'master' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab('master')}
|
||||
title="Master"
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
<Link2 className="h-4 w-4 mr-1.5 text-primary" />
|
||||
Master
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
@@ -130,7 +141,7 @@ export function SidePanel({
|
||||
{/* Track Actions */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
|
||||
Multi-Track Editor
|
||||
Track Management
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
@@ -165,122 +176,25 @@ export function SidePanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Track List */}
|
||||
{/* Track List Summary */}
|
||||
{tracks.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
|
||||
Tracks ({tracks.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{tracks.map((track) => {
|
||||
const isSelected = selectedTrackId === track.id;
|
||||
return (
|
||||
<div
|
||||
key={track.id}
|
||||
className={cn(
|
||||
'p-3 rounded-lg border transition-colors cursor-pointer',
|
||||
isSelected
|
||||
? 'bg-primary/10 border-primary'
|
||||
: 'bg-secondary/30 border-border hover:border-primary/50'
|
||||
)}
|
||||
onClick={() => onSelectTrack(isSelected ? null : track.id)}
|
||||
>
|
||||
<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 className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
|
||||
Tracks ({tracks.length})
|
||||
</h3>
|
||||
{selectedTrack && (
|
||||
<span className="text-xs text-primary">
|
||||
{String(selectedTrack.name || 'Untitled Track')} selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<p>
|
||||
{selectedTrack
|
||||
? '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.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -291,69 +205,97 @@ export function SidePanel({
|
||||
</p>
|
||||
</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' && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
{activeTab === 'master' && (
|
||||
<>
|
||||
{/* Master Channel Info */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase">
|
||||
Effect Chain
|
||||
{selectedTrack && (
|
||||
<span className="text-primary ml-2">({selectedTrack.name})</span>
|
||||
)}
|
||||
Master Channel
|
||||
</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>
|
||||
{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 className="text-xs text-muted-foreground">
|
||||
<p>
|
||||
Master effects are applied to the final mix of all tracks.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!selectedTrack ? (
|
||||
<div className="text-center py-8">
|
||||
<Link2 className="h-12 w-12 mx-auto text-muted-foreground/50 mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select a track to apply effects
|
||||
</p>
|
||||
{/* Master Effects */}
|
||||
<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">
|
||||
Master Effects
|
||||
</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>
|
||||
) : (
|
||||
<>
|
||||
<EffectRack
|
||||
chain={effectChain}
|
||||
onToggleEffect={onToggleEffect}
|
||||
onRemoveEffect={onRemoveEffect}
|
||||
onReorderEffects={onReorderEffects}
|
||||
/>
|
||||
<PresetManager
|
||||
open={presetDialogOpen}
|
||||
onClose={() => setPresetDialogOpen(false)}
|
||||
currentChain={effectChain}
|
||||
presets={effectPresets}
|
||||
onSavePreset={onSavePreset}
|
||||
onLoadPreset={onLoadPreset}
|
||||
onDeletePreset={onDeletePreset}
|
||||
onExportPreset={() => {}}
|
||||
onImportPreset={(preset) => onSavePreset(preset)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<EffectRack
|
||||
chain={masterEffectChain}
|
||||
onToggleEffect={onToggleMasterEffect}
|
||||
onRemoveEffect={onRemoveMasterEffect}
|
||||
onReorderEffects={onReorderMasterEffects}
|
||||
/>
|
||||
<PresetManager
|
||||
open={presetDialogOpen}
|
||||
onClose={() => setPresetDialogOpen(false)}
|
||||
currentChain={masterEffectChain}
|
||||
presets={masterEffectPresets}
|
||||
onSavePreset={onSaveMasterPreset}
|
||||
onLoadPreset={onLoadMasterPreset}
|
||||
onDeletePreset={onDeleteMasterPreset}
|
||||
onExportPreset={() => {}}
|
||||
onImportPreset={(preset) => onSaveMasterPreset(preset)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
'use client';
|
||||
|
||||
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 { TrackHeader } from './TrackHeader';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Slider } from '@/components/ui/Slider';
|
||||
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 {
|
||||
track: TrackType;
|
||||
@@ -20,6 +25,11 @@ export interface TrackProps {
|
||||
onRemove: () => void;
|
||||
onNameChange: (name: string) => 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({
|
||||
@@ -37,20 +47,82 @@ export function Track({
|
||||
onRemove,
|
||||
onNameChange,
|
||||
onSeek,
|
||||
onLoadAudio,
|
||||
onToggleEffect,
|
||||
onRemoveEffect,
|
||||
onUpdateEffect,
|
||||
onAddEffect,
|
||||
}: TrackProps) {
|
||||
const canvasRef = React.useRef<HTMLCanvasElement>(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
|
||||
React.useEffect(() => {
|
||||
if (!track.audioBuffer || !canvasRef.current || track.collapsed) return;
|
||||
if (!track.audioBuffer || !canvasRef.current) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d');
|
||||
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 rect = canvas.getBoundingClientRect();
|
||||
const rect = parent.getBoundingClientRect();
|
||||
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
@@ -59,8 +131,9 @@ export function Track({
|
||||
const width = rect.width;
|
||||
const height = rect.height;
|
||||
|
||||
// Clear canvas
|
||||
ctx.fillStyle = 'rgb(15, 23, 42)';
|
||||
// Clear canvas with theme color
|
||||
const bgColor = getComputedStyle(canvas).getPropertyValue('--color-waveform-bg') || 'rgb(15, 23, 42)';
|
||||
ctx.fillStyle = bgColor;
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
const buffer = track.audioBuffer;
|
||||
@@ -112,7 +185,7 @@ export function Track({
|
||||
ctx.lineTo(playheadX, height);
|
||||
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>) => {
|
||||
if (!onSeek || !duration) return;
|
||||
@@ -123,61 +196,354 @@ export function Track({
|
||||
onSeek(clickTime);
|
||||
};
|
||||
|
||||
if (track.collapsed) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-b border-border cursor-pointer',
|
||||
isSelected && 'ring-2 ring-primary ring-inset'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<TrackHeader
|
||||
track={track}
|
||||
onToggleMute={onToggleMute}
|
||||
onToggleSolo={onToggleSolo}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
onVolumeChange={onVolumeChange}
|
||||
onPanChange={onPanChange}
|
||||
onRemove={onRemove}
|
||||
onNameChange={onNameChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !onLoadAudio) 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);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
'border-b border-border cursor-pointer',
|
||||
'flex flex-col',
|
||||
isSelected && 'ring-2 ring-primary ring-inset'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<TrackHeader
|
||||
track={track}
|
||||
onToggleMute={onToggleMute}
|
||||
onToggleSolo={onToggleSolo}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
onVolumeChange={onVolumeChange}
|
||||
onPanChange={onPanChange}
|
||||
onRemove={onRemove}
|
||||
onNameChange={onNameChange}
|
||||
/>
|
||||
<div className="relative" style={{ height: track.height }}>
|
||||
{/* Top: Track Row (Control Panel + Waveform) */}
|
||||
<div className="flex" style={{ height: trackHeight }}>
|
||||
{/* Left: Track Control Panel (Fixed Width) */}
|
||||
<div
|
||||
className="w-72 flex-shrink-0 bg-card border-r border-border border-b border-border p-3 flex flex-col gap-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Track Name & Collapse Toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
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 ? (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-full cursor-pointer"
|
||||
className="absolute inset-0 w-full h-full cursor-pointer"
|
||||
onClick={handleCanvasClick}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
|
||||
No audio loaded
|
||||
</div>
|
||||
!track.collapsed && (
|
||||
<>
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,12 +29,12 @@ export function TrackHeader({
|
||||
onNameChange,
|
||||
}: TrackHeaderProps) {
|
||||
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 handleNameClick = () => {
|
||||
setIsEditingName(true);
|
||||
setNameInput(track.name);
|
||||
setNameInput(String(track.name || 'Untitled Track'));
|
||||
};
|
||||
|
||||
const handleNameBlur = () => {
|
||||
@@ -42,7 +42,7 @@ export function TrackHeader({
|
||||
if (nameInput.trim()) {
|
||||
onNameChange(nameInput.trim());
|
||||
} else {
|
||||
setNameInput(track.name);
|
||||
setNameInput(String(track.name || 'Untitled Track'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -50,7 +50,7 @@ export function TrackHeader({
|
||||
if (e.key === 'Enter') {
|
||||
inputRef.current?.blur();
|
||||
} else if (e.key === 'Escape') {
|
||||
setNameInput(track.name);
|
||||
setNameInput(String(track.name || 'Untitled Track'));
|
||||
setIsEditingName(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/Button';
|
||||
import { Track } from './Track';
|
||||
import { ImportTrackDialog } from './ImportTrackDialog';
|
||||
import type { Track as TrackType } from '@/types/track';
|
||||
import { createEffect, type EffectType, EFFECT_NAMES } from '@/lib/audio/effects/chain';
|
||||
|
||||
export interface TrackListProps {
|
||||
tracks: TrackType[];
|
||||
@@ -104,29 +105,49 @@ export function TrackList({
|
||||
onUpdateTrack(track.id, { name })
|
||||
}
|
||||
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>
|
||||
|
||||
{/* 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 */}
|
||||
{onImportTrack && (
|
||||
<ImportTrackDialog
|
||||
|
||||
@@ -25,11 +25,6 @@ import type { FilterOptions } from './filters';
|
||||
|
||||
// Effect type identifier
|
||||
export type EffectType =
|
||||
// Basic
|
||||
| 'normalize'
|
||||
| 'fadeIn'
|
||||
| 'fadeOut'
|
||||
| 'reverse'
|
||||
// Filters
|
||||
| 'lowpass'
|
||||
| 'highpass'
|
||||
@@ -116,7 +111,7 @@ export function createEffect(
|
||||
type,
|
||||
name,
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export const EFFECT_NAMES: Record<EffectType, string> = {
|
||||
normalize: 'Normalize',
|
||||
fadeIn: 'Fade In',
|
||||
fadeOut: 'Fade Out',
|
||||
reverse: 'Reverse',
|
||||
lowpass: 'Low-Pass Filter',
|
||||
highpass: 'High-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 { DEFAULT_TRACK_HEIGHT, TRACK_COLORS } from '@/types/track';
|
||||
import { createEffectChain } from '@/lib/audio/effects/chain';
|
||||
|
||||
/**
|
||||
* 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 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 {
|
||||
id: generateTrackId(),
|
||||
name: name || 'New Track',
|
||||
name: trackName,
|
||||
color: TRACK_COLORS[color || randomColor],
|
||||
height: DEFAULT_TRACK_HEIGHT,
|
||||
audioBuffer: null,
|
||||
@@ -30,6 +34,7 @@ export function createTrack(name?: string, color?: TrackColor): Track {
|
||||
mute: false,
|
||||
solo: false,
|
||||
recordEnabled: false,
|
||||
effectChain: createEffectChain(`${trackName} Effects`),
|
||||
collapsed: false,
|
||||
selected: false,
|
||||
};
|
||||
@@ -43,7 +48,9 @@ export function createTrackFromBuffer(
|
||||
name?: string,
|
||||
color?: TrackColor
|
||||
): 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;
|
||||
return track;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import type { Track } from '@/types/track';
|
||||
import { createTrack, createTrackFromBuffer } from '@/lib/audio/track-utils';
|
||||
import { createEffectChain } from '@/lib/audio/effects/chain';
|
||||
|
||||
const STORAGE_KEY = 'audio-ui-multi-track';
|
||||
|
||||
@@ -12,11 +13,24 @@ export function useMultiTrack() {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (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) => ({
|
||||
...t,
|
||||
name: String(t.name || 'Untitled Track'), // Ensure name is always a string
|
||||
audioBuffer: null, // Will need to be reloaded
|
||||
effectChain: t.effectChain || createEffectChain(`${t.name} Effects`), // Restore effect chain or create new
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -33,7 +47,21 @@ export function useMultiTrack() {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
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));
|
||||
} catch (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 type { Track } from '@/types/track';
|
||||
import { getTrackGain } from '@/lib/audio/track-utils';
|
||||
import { applyEffectChain, updateEffectParameters, toggleEffectBypass, type EffectNodeInfo } from '@/lib/audio/effects/processor';
|
||||
|
||||
export interface MultiTrackPlayerState {
|
||||
isPlaying: boolean;
|
||||
@@ -9,7 +10,7 @@ export interface MultiTrackPlayerState {
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export function useMultiTrackPlayer(tracks: Track[]) {
|
||||
export function useMultiTrackPlayer(tracks: Track[], masterVolume: number = 1) {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
@@ -18,9 +19,17 @@ export function useMultiTrackPlayer(tracks: Track[]) {
|
||||
const sourceNodesRef = useRef<AudioBufferSourceNode[]>([]);
|
||||
const gainNodesRef = useRef<GainNode[]>([]);
|
||||
const panNodesRef = useRef<StereoPannerNode[]>([]);
|
||||
const effectNodesRef = useRef<EffectNodeInfo[][]>([]); // Effect nodes per track
|
||||
const masterGainNodeRef = useRef<GainNode | null>(null);
|
||||
const startTimeRef = useRef<number>(0);
|
||||
const pausedAtRef = useRef<number>(0);
|
||||
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
|
||||
useEffect(() => {
|
||||
@@ -34,7 +43,7 @@ export function useMultiTrackPlayer(tracks: Track[]) {
|
||||
}, [tracks]);
|
||||
|
||||
const updatePlaybackPosition = useCallback(() => {
|
||||
if (!audioContextRef.current || !isPlaying) return;
|
||||
if (!audioContextRef.current) return;
|
||||
|
||||
const elapsed = audioContextRef.current.currentTime - startTimeRef.current;
|
||||
const newTime = pausedAtRef.current + elapsed;
|
||||
@@ -43,12 +52,16 @@ export function useMultiTrackPlayer(tracks: Track[]) {
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0);
|
||||
pausedAtRef.current = 0;
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentTime(newTime);
|
||||
animationFrameRef.current = requestAnimationFrame(updatePlaybackPosition);
|
||||
}, [isPlaying, duration]);
|
||||
}, [duration]);
|
||||
|
||||
const play = useCallback(() => {
|
||||
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());
|
||||
panNodesRef.current.forEach(node => node.disconnect());
|
||||
if (masterGainNodeRef.current) {
|
||||
masterGainNodeRef.current.disconnect();
|
||||
}
|
||||
|
||||
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 tracks) {
|
||||
@@ -89,10 +112,19 @@ export function useMultiTrackPlayer(tracks: Track[]) {
|
||||
// Set pan
|
||||
panNode.pan.setValueAtTime(track.pan, audioContext.currentTime);
|
||||
|
||||
// Connect: source -> gain -> pan -> destination
|
||||
// Connect: source -> gain -> pan -> effects -> master gain -> destination
|
||||
source.connect(gainNode);
|
||||
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
|
||||
source.start(0, pausedAtRef.current);
|
||||
@@ -101,6 +133,7 @@ export function useMultiTrackPlayer(tracks: Track[]) {
|
||||
sourceNodesRef.current.push(source);
|
||||
gainNodesRef.current.push(gainNode);
|
||||
panNodesRef.current.push(panNode);
|
||||
effectNodesRef.current.push(effectNodes);
|
||||
|
||||
// Handle ended event
|
||||
source.onended = () => {
|
||||
@@ -115,7 +148,7 @@ export function useMultiTrackPlayer(tracks: Track[]) {
|
||||
startTimeRef.current = audioContext.currentTime;
|
||||
setIsPlaying(true);
|
||||
updatePlaybackPosition();
|
||||
}, [tracks, duration, updatePlaybackPosition]);
|
||||
}, [tracks, duration, masterVolume, updatePlaybackPosition]);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
if (!audioContextRef.current || !isPlaying) return;
|
||||
@@ -174,7 +207,7 @@ export function useMultiTrackPlayer(tracks: Track[]) {
|
||||
}
|
||||
}, [isPlaying, play, pause]);
|
||||
|
||||
// Update gain/pan when tracks change
|
||||
// Update gain/pan when tracks change (simple updates that don't require graph rebuild)
|
||||
useEffect(() => {
|
||||
if (!isPlaying || !audioContextRef.current) return;
|
||||
|
||||
@@ -196,6 +229,225 @@ export function useMultiTrackPlayer(tracks: Track[]) {
|
||||
});
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -212,6 +464,9 @@ export function useMultiTrackPlayer(tracks: Track[]) {
|
||||
});
|
||||
gainNodesRef.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
|
||||
*/
|
||||
|
||||
import type { EffectChain } from '@/lib/audio/effects/chain';
|
||||
|
||||
export interface Track {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -16,6 +18,9 @@ export interface Track {
|
||||
solo: boolean;
|
||||
recordEnabled: boolean;
|
||||
|
||||
// Effects
|
||||
effectChain: EffectChain;
|
||||
|
||||
// UI state
|
||||
collapsed: boolean;
|
||||
selected: boolean;
|
||||
|
||||
Reference in New Issue
Block a user