diff --git a/components/editor/AudioEditor.tsx b/components/editor/AudioEditor.tsx index 4a22cbb..4494681 100644 --- a/components/editor/AudioEditor.tsx +++ b/components/editor/AudioEditor.tsx @@ -14,6 +14,7 @@ import { useEffectChain } from '@/lib/hooks/useEffectChain'; import { useToast } from '@/components/ui/Toast'; import { TrackList } from '@/components/tracks/TrackList'; import { ImportTrackDialog } from '@/components/tracks/ImportTrackDialog'; +import { EffectsPanel } from '@/components/effects/EffectsPanel'; import { formatDuration } from '@/lib/audio/decoder'; import { useHistory } from '@/lib/hooks/useHistory'; import { useRecording } from '@/lib/hooks/useRecording'; @@ -38,6 +39,8 @@ export function AudioEditor() { const [punchOutTime, setPunchOutTime] = React.useState(0); const [overdubEnabled, setOverdubEnabled] = React.useState(false); const [settingsDialogOpen, setSettingsDialogOpen] = React.useState(false); + const [effectsPanelHeight, setEffectsPanelHeight] = React.useState(300); + const [effectsPanelVisible, setEffectsPanelVisible] = React.useState(false); const { addToast } = useToast(); @@ -61,13 +64,46 @@ export function AudioEditor() { // Multi-track hooks const { tracks, - addTrack, - addTrackFromBuffer, + addTrack: addTrackOriginal, + addTrackFromBuffer: addTrackFromBufferOriginal, removeTrack, updateTrack, clearTracks, } = useMultiTrack(); + // Track whether we should auto-select on next add (when project is empty) + const shouldAutoSelectRef = React.useRef(true); + + React.useEffect(() => { + // Update auto-select flag based on track count + shouldAutoSelectRef.current = tracks.length === 0; + }, [tracks.length]); + + // Wrap addTrack to auto-select first track when adding to empty project + const addTrack = React.useCallback((name?: string) => { + const shouldAutoSelect = shouldAutoSelectRef.current; + const track = addTrackOriginal(name); + if (shouldAutoSelect) { + setSelectedTrackId(track.id); + shouldAutoSelectRef.current = false; // Only auto-select once + } + return track; + }, [addTrackOriginal]); + + // Wrap addTrackFromBuffer to auto-select first track when adding to empty project + const addTrackFromBuffer = React.useCallback((buffer: AudioBuffer, name?: string) => { + console.log(`[AudioEditor] addTrackFromBuffer wrapper called: ${name}, shouldAutoSelect: ${shouldAutoSelectRef.current}`); + const shouldAutoSelect = shouldAutoSelectRef.current; + const track = addTrackFromBufferOriginal(buffer, name); + console.log(`[AudioEditor] Track created: ${track.name} (${track.id})`); + if (shouldAutoSelect) { + console.log(`[AudioEditor] Auto-selecting track: ${track.id}`); + setSelectedTrackId(track.id); + shouldAutoSelectRef.current = false; // Only auto-select once + } + return track; + }, [addTrackFromBufferOriginal]); + // Log tracks to see if they update React.useEffect(() => { console.log('[AudioEditor] Tracks updated:', tracks.map(t => ({ @@ -108,6 +144,7 @@ export function AudioEditor() { }; const handleImportTrack = (buffer: AudioBuffer, name: string) => { + console.log(`[AudioEditor] handleImportTrack called: ${name}`); addTrackFromBuffer(buffer, name); }; @@ -171,6 +208,79 @@ export function AudioEditor() { updateTrack(selectedTrack.id, { effectChain: updatedChain }); }; + // Effects Panel handlers + const handleAddEffect = React.useCallback((effectType: any) => { + if (!selectedTrackId) return; + const track = tracks.find((t) => t.id === selectedTrackId); + if (!track) return; + + // Import createEffect and EFFECT_NAMES dynamically + import('@/lib/audio/effects/chain').then(({ createEffect, EFFECT_NAMES }) => { + const newEffect = createEffect(effectType, EFFECT_NAMES[effectType]); + const updatedChain = { + ...track.effectChain, + effects: [...track.effectChain.effects, newEffect], + }; + updateTrack(selectedTrackId, { effectChain: updatedChain }); + }); + }, [selectedTrackId, tracks, updateTrack]); + + const handleToggleEffect = React.useCallback((effectId: string) => { + if (!selectedTrackId) return; + const track = tracks.find((t) => t.id === selectedTrackId); + if (!track) return; + + const updatedChain = { + ...track.effectChain, + effects: track.effectChain.effects.map((e) => + e.id === effectId ? { ...e, enabled: !e.enabled } : e + ), + }; + updateTrack(selectedTrackId, { effectChain: updatedChain }); + }, [selectedTrackId, tracks, updateTrack]); + + const handleRemoveEffect = React.useCallback((effectId: string) => { + if (!selectedTrackId) return; + const track = tracks.find((t) => t.id === selectedTrackId); + if (!track) return; + + const updatedChain = { + ...track.effectChain, + effects: track.effectChain.effects.filter((e) => e.id !== effectId), + }; + updateTrack(selectedTrackId, { effectChain: updatedChain }); + }, [selectedTrackId, tracks, updateTrack]); + + const handleUpdateEffect = React.useCallback((effectId: string, parameters: any) => { + if (!selectedTrackId) return; + const track = tracks.find((t) => t.id === selectedTrackId); + if (!track) return; + + const updatedChain = { + ...track.effectChain, + effects: track.effectChain.effects.map((e) => + e.id === effectId ? { ...e, parameters } : e + ), + }; + updateTrack(selectedTrackId, { effectChain: updatedChain }); + }, [selectedTrackId, tracks, updateTrack]); + + const handleToggleEffectExpanded = React.useCallback((effectId: string) => { + if (!selectedTrackId) return; + const track = tracks.find((t) => t.id === selectedTrackId); + if (!track) return; + + const updatedChain = { + ...track.effectChain, + effects: track.effectChain.effects.map((e) => + e.id === effectId ? { ...e, expanded: !e.expanded } : e + ), + }; + updateTrack(selectedTrackId, { effectChain: updatedChain }); + }, [selectedTrackId, tracks, updateTrack]); + + // Preserve effects panel state - don't auto-open/close on track selection + // Selection handler const handleSelectionChange = (trackId: string, selection: { start: number; end: number } | null) => { updateTrack(trackId, { selection }); @@ -706,6 +816,23 @@ export function AudioEditor() { /> + {/* Effects Panel - Global Folding State, Collapsed when no track */} + { + if (selectedTrack) { + setEffectsPanelVisible(!effectsPanelVisible); + } + }} + onResizeHeight={setEffectsPanelHeight} + onAddEffect={handleAddEffect} + onToggleEffect={handleToggleEffect} + onRemoveEffect={handleRemoveEffect} + onUpdateEffect={handleUpdateEffect} + onToggleEffectExpanded={handleToggleEffectExpanded} + /> diff --git a/components/effects/EffectBrowser.tsx b/components/effects/EffectBrowser.tsx index c29ad21..2c046e2 100644 --- a/components/effects/EffectBrowser.tsx +++ b/components/effects/EffectBrowser.tsx @@ -21,6 +21,28 @@ const EFFECT_CATEGORIES = { 'Pitch & Time': ['pitch', 'timestretch'] as EffectType[], }; +const EFFECT_DESCRIPTIONS: Record = { + 'compressor': 'Reduce dynamic range and control peaks', + 'limiter': 'Prevent audio from exceeding a maximum level', + 'gate': 'Reduce noise by cutting low-level signals', + 'lowpass': 'Allow frequencies below cutoff to pass', + 'highpass': 'Allow frequencies above cutoff to pass', + 'bandpass': 'Allow frequencies within a range to pass', + 'notch': 'Remove a specific frequency range', + 'lowshelf': 'Boost or cut low frequencies', + 'highshelf': 'Boost or cut high frequencies', + 'peaking': 'Boost or cut a specific frequency band', + 'delay': 'Create echoes and rhythmic repeats', + 'reverb': 'Simulate acoustic space and ambience', + 'chorus': 'Thicken sound with subtle pitch variations', + 'flanger': 'Create sweeping comb filter effects', + 'phaser': 'Create phase-shifted modulation effects', + 'distortion': 'Add harmonic saturation and grit', + 'bitcrusher': 'Reduce bit depth for lo-fi effects', + 'pitch': 'Shift pitch without changing tempo', + 'timestretch': 'Change tempo without affecting pitch', +}; + export function EffectBrowser({ open, onClose, onSelectEffect }: EffectBrowserProps) { const [search, setSearch] = React.useState(''); const [selectedCategory, setSelectedCategory] = React.useState(null); @@ -40,7 +62,8 @@ export function EffectBrowser({ open, onClose, onSelectEffect }: EffectBrowserPr Object.entries(EFFECT_CATEGORIES).forEach(([category, effects]) => { const matchingEffects = effects.filter((effect) => - EFFECT_NAMES[effect].toLowerCase().includes(searchLower) + EFFECT_NAMES[effect].toLowerCase().includes(searchLower) || + EFFECT_DESCRIPTIONS[effect].toLowerCase().includes(searchLower) ); if (matchingEffects.length > 0) { filtered[category] = matchingEffects; @@ -101,7 +124,7 @@ export function EffectBrowser({ open, onClose, onSelectEffect }: EffectBrowserPr )} >
{EFFECT_NAMES[effect]}
-
{effect}
+
{EFFECT_DESCRIPTIONS[effect]}
))} diff --git a/components/effects/EffectDevice.tsx b/components/effects/EffectDevice.tsx index 86ce73d..d6a29b9 100644 --- a/components/effects/EffectDevice.tsx +++ b/components/effects/EffectDevice.tsx @@ -12,6 +12,7 @@ export interface EffectDeviceProps { onToggleEnabled?: () => void; onRemove?: () => void; onUpdateParameters?: (parameters: any) => void; + onToggleExpanded?: () => void; } export function EffectDevice({ @@ -19,16 +20,17 @@ export function EffectDevice({ onToggleEnabled, onRemove, onUpdateParameters, + onToggleExpanded, }: EffectDeviceProps) { - const [isExpanded, setIsExpanded] = React.useState(false); + const isExpanded = effect.expanded || false; return (
@@ -39,7 +41,7 @@ export function EffectDevice({
+ {track && ( +
+ + {track.effectChain.effects.length} device(s) + +
+ )} +
+ ); + } + + return ( +
+ {/* Resize handle */} +
+
+
+ + {/* Header */} +
+ + + {track && ( + <> + + {track.effectChain.effects.length} device(s) + + + + )} +
+ + {/* Device Rack */} +
+ {!track ? ( +
+ Select a track to view its devices +
+ ) : track.effectChain.effects.length === 0 ? ( +
+

No devices on this track

+ +
+ ) : ( +
+ {track.effectChain.effects.map((effect) => ( + onToggleEffect?.(effect.id)} + onRemove={() => onRemoveEffect?.(effect.id)} + onUpdateParameters={(params) => onUpdateEffect?.(effect.id, params)} + onToggleExpanded={() => onToggleEffectExpanded?.(effect.id)} + /> + ))} +
+ )} +
+ + {/* Effect Browser Dialog */} + {track && ( + setEffectBrowserOpen(false)} + onSelectEffect={(effectType) => { + if (onAddEffect) { + onAddEffect(effectType); + } + setEffectBrowserOpen(false); + }} + /> + )} +
+ ); +} diff --git a/components/tracks/ImportTrackDialog.tsx b/components/tracks/ImportTrackDialog.tsx index a0fdbb3..ba3d31c 100644 --- a/components/tracks/ImportTrackDialog.tsx +++ b/components/tracks/ImportTrackDialog.tsx @@ -24,28 +24,42 @@ export function ImportTrackDialog({ const handleFiles = async (files: FileList) => { setIsLoading(true); + // Convert FileList to Array to prevent any weird behavior + const fileArray = Array.from(files); + console.log(`[ImportTrackDialog] Processing ${fileArray.length} files`, fileArray); + try { // Process files sequentially - for (let i = 0; i < files.length; i++) { - const file = files[i]; + for (let i = 0; i < fileArray.length; i++) { + console.log(`[ImportTrackDialog] Loop iteration ${i}, fileArray.length: ${fileArray.length}`); + const file = fileArray[i]; + console.log(`[ImportTrackDialog] Processing file ${i + 1}/${fileArray.length}: ${file.name}, type: ${file.type}`); if (!file.type.startsWith('audio/')) { - console.warn(`Skipping non-audio file: ${file.name}`); + console.warn(`Skipping non-audio file: ${file.name} (type: ${file.type})`); continue; } try { + console.log(`[ImportTrackDialog] Decoding file ${i + 1}/${files.length}: ${file.name}`); const buffer = await decodeAudioFile(file); const trackName = file.name.replace(/\.[^/.]+$/, ''); // Remove extension + console.log(`[ImportTrackDialog] Importing track: ${trackName}`); onImportTrack(buffer, trackName); + console.log(`[ImportTrackDialog] Track imported: ${trackName}`); } catch (error) { console.error(`Failed to import ${file.name}:`, error); } + console.log(`[ImportTrackDialog] Finished processing file ${i + 1}`); } - onClose(); + console.log('[ImportTrackDialog] Loop completed, all files processed'); + } catch (error) { + console.error('[ImportTrackDialog] Error in handleFiles:', error); } finally { setIsLoading(false); + console.log('[ImportTrackDialog] Closing dialog'); + onClose(); } }; diff --git a/components/tracks/Track.tsx b/components/tracks/Track.tsx index 39d1446..6d7c952 100644 --- a/components/tracks/Track.tsx +++ b/components/tracks/Track.tsx @@ -1,15 +1,13 @@ 'use client'; import * as React from 'react'; -import { Volume2, VolumeX, Headphones, Trash2, ChevronDown, ChevronRight, UnfoldHorizontal, Upload, Plus, Mic, Gauge } from 'lucide-react'; +import { Volume2, VolumeX, Headphones, Trash2, ChevronDown, ChevronRight, UnfoldHorizontal, Upload, Mic, Gauge, Circle } from 'lucide-react'; import type { Track as TrackType } from '@/types/track'; import { COLLAPSED_TRACK_HEIGHT, MIN_TRACK_HEIGHT, MAX_TRACK_HEIGHT } from '@/types/track'; 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'; +import type { EffectType } from '@/lib/audio/effects/chain'; import { VerticalFader } from '@/components/ui/VerticalFader'; import { CircularKnob } from '@/components/ui/CircularKnob'; import { AutomationLane } from '@/components/automation/AutomationLane'; @@ -76,8 +74,6 @@ export function Track({ const fileInputRef = React.useRef(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(null); const [isResizing, setIsResizing] = React.useState(false); @@ -441,180 +437,200 @@ export function Track({
{/* Top: Track Row (Control Panel + Waveform) */}
{/* Left: Track Control Panel (Fixed Width) - Ableton Style */}
e.stopPropagation()} - > - {/* Track Name (Full Width) */} -
- - -
- -
- {isEditingName ? ( - setNameInput(e.target.value)} - onBlur={handleNameBlur} - onKeyDown={handleNameKeyDown} - className="w-full px-1 py-0.5 text-xs font-medium bg-background border border-border rounded" - /> - ) : ( -
- {String(track.name || 'Untitled Track')} -
- )} -
-
- - {/* Compact Button Row */} -
- {/* Record Enable Button */} - {onToggleRecordEnable && ( - + className={cn( + "w-48 flex-shrink-0 border-b border-r-4 p-2 flex flex-col gap-2 min-h-0 transition-all duration-200 cursor-pointer border-border", + isSelected + ? "bg-primary/10 border-r-primary" + : "bg-card border-r-transparent hover:bg-accent/30" )} - - {/* Solo Button */} - - - {/* Mute Button */} - - - {/* Remove Button */} - -
- - {/* Track Controls - Only show when not collapsed */} - {!track.collapsed && ( -
- {/* Pan Knob */} -
- + {/* Small triangle indicator */} +
+ {track.collapsed ? ( + + ) : ( + + )}
- {/* Vertical Volume Fader with integrated meter */} -
- + {/* Color stripe (thicker when selected) */} +
+ + {/* Track name (editable) */} +
+ {isEditingName ? ( + setNameInput(e.target.value)} + onBlur={handleNameBlur} + onKeyDown={handleNameKeyDown} + onClick={(e) => e.stopPropagation()} + className="w-full px-1 py-0.5 text-xs font-semibold bg-background border border-border rounded" + /> + ) : ( +
{ + e.stopPropagation(); + handleNameClick(); + }} + className="px-1 py-0.5 text-xs font-semibold text-foreground truncate" + title={String(track.name || 'Untitled Track')} + > + {String(track.name || 'Untitled Track')} +
+ )}
- )} -
+ + {/* Track Controls - Only show when not collapsed */} + {!track.collapsed && ( +
+ {/* Pan Knob */} +
+ +
+ + {/* Vertical Volume Fader with integrated meter */} +
+ +
+ + {/* Inline Button Row - Below fader */} +
+ {/* R/S/M inline row with icons */} +
+ {/* Record Arm */} + {onToggleRecordEnable && ( + + )} + + {/* Solo Button */} + + + {/* Mute Button */} + +
+
+
+ )} +
{/* Right: Waveform Area (Flexible Width) */}
- {track.audioBuffer ? ( -
- {/* Clip Header */} -
- - {track.name} - -
+ {/* Delete Button - Top Right Overlay */} + + {track.audioBuffer ? ( + <> {/* Waveform Canvas */} -
+ ) : ( !track.collapsed && ( <> @@ -648,82 +664,6 @@ export function Track({
- {/* Bottom: Effects Section (Collapsible, Full Width) - Ableton Style */} - {!track.collapsed && ( -
- {/* Effects Header - clickable to toggle */} -
setShowEffects(!showEffects)} - > - {showEffects ? ( - - ) : ( - - )} - - {/* Show mini effect chain when collapsed */} - {!showEffects && track.effectChain.effects.length > 0 ? ( -
- {track.effectChain.effects.map((effect) => ( -
- {effect.name} -
- ))} -
- ) : ( - - Devices ({track.effectChain.effects.length}) - - )} - - -
- - {/* Horizontal scrolling device rack - expanded state */} - {showEffects && ( -
-
- {track.effectChain.effects.length === 0 ? ( -
- No devices. Click + to add an effect. -
- ) : ( - track.effectChain.effects.map((effect) => ( - onToggleEffect?.(effect.id)} - onRemove={() => onRemoveEffect?.(effect.id)} - onUpdateParameters={(params) => onUpdateEffect?.(effect.id, params)} - /> - )) - )} -
-
- )} -
- )} - {/* Automation Lanes */} {!track.collapsed && track.automation?.showAutomation && (
@@ -791,26 +731,15 @@ export function Track({ {!track.collapsed && (
-
+
)} - - {/* Effect Browser Dialog */} - setEffectBrowserOpen(false)} - onSelectEffect={(effectType) => { - if (onAddEffect) { - onAddEffect(effectType); - } - }} - />
); } diff --git a/components/ui/CircularKnob.tsx b/components/ui/CircularKnob.tsx index 33d9f35..d715331 100644 --- a/components/ui/CircularKnob.tsx +++ b/components/ui/CircularKnob.tsx @@ -167,11 +167,6 @@ export function CircularKnob({ {/* Indicator line */}
- - {/* Center dot (for zero position) */} - {value === 0 && ( -
- )}
{/* Value Display */} diff --git a/components/ui/VerticalFader.tsx b/components/ui/VerticalFader.tsx index e68ab5d..87868f3 100644 --- a/components/ui/VerticalFader.tsx +++ b/components/ui/VerticalFader.tsx @@ -107,35 +107,29 @@ export function VerticalFader({
- {/* Level Meter Background (green/yellow/red gradient) */} + {/* Volume Level Overlay - subtle fill up to fader handle */}
- {/* Level Meter (actual level) */} + {/* Level Meter (actual level) - capped at fader handle position */}
- {/* Volume Value Fill */} -
+ {/* Volume Value Fill - Removed to show gradient spectrum */} {/* Fader Handle */}
= { gray: 'rgb(156, 163, 175)', }; -export const DEFAULT_TRACK_HEIGHT = 240; // Increased from 180 to accommodate vertical controls -export const MIN_TRACK_HEIGHT = 120; // Increased from 60 for vertical fader/knob layout -export const MAX_TRACK_HEIGHT = 400; // Increased from 300 for better waveform viewing +export const DEFAULT_TRACK_HEIGHT = 300; // Knob + fader with labels + R/S/M buttons +export const MIN_TRACK_HEIGHT = 220; // Minimum to fit knob + fader with labels + buttons +export const MAX_TRACK_HEIGHT = 500; // Increased for better waveform viewing export const COLLAPSED_TRACK_HEIGHT = 48; // Extracted constant for collapsed state