diff --git a/components/editor/AudioEditor.tsx b/components/editor/AudioEditor.tsx index ea447fe..4a22cbb 100644 --- a/components/editor/AudioEditor.tsx +++ b/components/editor/AudioEditor.tsx @@ -1,10 +1,11 @@ 'use client'; import * as React from 'react'; -import { Music, Plus, Upload, Trash2 } from 'lucide-react'; +import { Music, Plus, Upload, Trash2, Settings } from 'lucide-react'; import { PlaybackControls } from './PlaybackControls'; import { ThemeToggle } from '@/components/layout/ThemeToggle'; import { CommandPalette } from '@/components/ui/CommandPalette'; +import { GlobalSettingsDialog } from '@/components/settings/GlobalSettingsDialog'; import { Button } from '@/components/ui/Button'; import type { CommandAction } from '@/components/ui/CommandPalette'; import { useMultiTrack } from '@/lib/hooks/useMultiTrack'; @@ -36,6 +37,7 @@ export function AudioEditor() { const [punchInTime, setPunchInTime] = React.useState(0); const [punchOutTime, setPunchOutTime] = React.useState(0); const [overdubEnabled, setOverdubEnabled] = React.useState(false); + const [settingsDialogOpen, setSettingsDialogOpen] = React.useState(false); const { addToast } = useToast(); @@ -663,9 +665,17 @@ export function AudioEditor() { - {/* Right: Command Palette + Theme Toggle */} + {/* Right: Command Palette + Settings + Theme Toggle */}
+
@@ -693,10 +703,6 @@ export function AudioEditor() { recordingTrackId={recordingTrackId} recordingLevel={recordingState.inputLevel} trackLevels={trackLevels} - recordingSettings={recordingSettings} - onInputGainChange={setInputGain} - onRecordMonoChange={setRecordMono} - onSampleRateChange={setSampleRate} /> @@ -738,6 +744,16 @@ export function AudioEditor() { onClose={() => setImportDialogOpen(false)} onImportTrack={handleImportTrack} /> + + {/* Global Settings Dialog */} + setSettingsDialogOpen(false)} + recordingSettings={recordingSettings} + onInputGainChange={setInputGain} + onRecordMonoChange={setRecordMono} + onSampleRateChange={setSampleRate} + /> ); } diff --git a/components/settings/GlobalSettingsDialog.tsx b/components/settings/GlobalSettingsDialog.tsx new file mode 100644 index 0000000..479c937 --- /dev/null +++ b/components/settings/GlobalSettingsDialog.tsx @@ -0,0 +1,189 @@ +'use client'; + +import * as React from 'react'; +import { X } from 'lucide-react'; +import { Button } from '@/components/ui/Button'; +import { RecordingSettings } from '@/components/recording/RecordingSettings'; +import { cn } from '@/lib/utils/cn'; +import type { RecordingSettings as RecordingSettingsType } from '@/lib/hooks/useRecording'; + +export interface GlobalSettingsDialogProps { + open: boolean; + onClose: () => void; + recordingSettings: RecordingSettingsType; + onInputGainChange: (gain: number) => void; + onRecordMonoChange: (mono: boolean) => void; + onSampleRateChange: (sampleRate: number) => void; +} + +type TabType = 'recording' | 'playback' | 'interface'; + +export function GlobalSettingsDialog({ + open, + onClose, + recordingSettings, + onInputGainChange, + onRecordMonoChange, + onSampleRateChange, +}: GlobalSettingsDialogProps) { + const [activeTab, setActiveTab] = React.useState('recording'); + + if (!open) return null; + + return ( + <> + {/* Backdrop */} +
+ + {/* Dialog */} +
+
+ {/* Header */} +
+

Settings

+ +
+ + {/* Tabs */} +
+ + + +
+ + {/* Content */} +
+ {activeTab === 'recording' && ( +
+
+

Recording Settings

+ +
+ +
+

Note

+

+ These settings apply globally to all recordings. Arm a track (red button) + to enable recording on that specific track. +

+
+
+ )} + + {activeTab === 'playback' && ( +
+
+

Playback Settings

+

+ Configure audio playback preferences. +

+ +
+
+ Buffer Size + Auto +
+
+ Output Latency + ~20ms +
+

+ Advanced playback settings coming soon... +

+
+
+
+ )} + + {activeTab === 'interface' && ( +
+
+

Interface Settings

+

+ Customize the editor appearance and behavior. +

+ +
+
+ Theme + Use theme toggle in header +
+
+ Default Track Height + 180px +
+

+ More interface options coming soon... +

+
+
+
+ )} +
+ + {/* Footer */} +
+ +
+
+
+ + ); +} diff --git a/components/tracks/Track.tsx b/components/tracks/Track.tsx index 1517c40..775148b 100644 --- a/components/tracks/Track.tsx +++ b/components/tracks/Track.tsx @@ -9,9 +9,8 @@ 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 { InputLevelMeter } from '@/components/recording/InputLevelMeter'; -import { RecordingSettings } from '@/components/recording/RecordingSettings'; -import type { RecordingSettings as RecordingSettingsType } from '@/lib/hooks/useRecording'; +import { VerticalFader } from '@/components/ui/VerticalFader'; +import { CircularKnob } from '@/components/ui/CircularKnob'; export interface TrackProps { track: TrackType; @@ -38,10 +37,6 @@ export interface TrackProps { isRecording?: boolean; recordingLevel?: number; playbackLevel?: number; - recordingSettings?: RecordingSettingsType; - onInputGainChange?: (gain: number) => void; - onRecordMonoChange?: (mono: boolean) => void; - onSampleRateChange?: (sampleRate: number) => void; } export function Track({ @@ -69,10 +64,6 @@ export function Track({ isRecording = false, recordingLevel = 0, playbackLevel = 0, - recordingSettings, - onInputGainChange, - onRecordMonoChange, - onSampleRateChange, }: TrackProps) { const canvasRef = React.useRef(null); const containerRef = React.useRef(null); @@ -411,28 +402,29 @@ export function Track({ > {/* Top: Track Row (Control Panel + Waveform) */}
- {/* Left: Track Control Panel (Fixed Width) */} + {/* Left: Track Control Panel (Fixed Width) - Ableton Style */}
e.stopPropagation()} > - {/* Track Name & Collapse Toggle */} -
+ {/* Track Name (Full Width) */} +
@@ -445,19 +437,22 @@ export function Track({ 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" + 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 && ( )} @@ -480,9 +481,12 @@ export function Track({ size="icon-sm" onClick={onToggleSolo} title="Solo track" - className={cn(track.solo && 'bg-yellow-500/20 hover:bg-yellow-500/30')} + className={cn( + 'h-6 w-6 text-[10px] font-bold', + track.solo && 'bg-yellow-500/20 hover:bg-yellow-500/30 text-yellow-500' + )} > - + S {/* Mute Button */} @@ -491,13 +495,12 @@ export function Track({ size="icon-sm" onClick={onToggleMute} title="Mute track" - className={cn(track.mute && 'bg-red-500/20 hover:bg-red-500/30')} - > - {track.mute ? ( - - ) : ( - + className={cn( + 'h-6 w-6 text-[10px] font-bold', + track.mute && 'bg-red-500/20 hover:bg-red-500/30 text-red-500' )} + > + M {/* Remove Button */} @@ -506,105 +509,37 @@ export function Track({ size="icon-sm" onClick={onRemove} title="Remove track" - className="text-destructive hover:text-destructive hover:bg-destructive/10" + className="h-6 w-6 text-destructive hover:text-destructive hover:bg-destructive/10" > - +
{/* Track Controls - Only show when not collapsed */} {!track.collapsed && ( - <> - {/* Volume */} -
- -
- -
- - {Math.round(track.volume * 100)}% - -
+
+ {/* Pan Knob */} + - {/* Pan */} -
- -
- -
- - {track.pan === 0 - ? 'C' - : track.pan < 0 - ? `L${Math.abs(Math.round(track.pan * 100))}` - : `R${Math.round(track.pan * 100)}`} - -
- - {/* Level Meter (shows input when recording, output level otherwise) */} -
- -
- -
- - {(() => { - const level = track.recordEnabled || isRecording ? recordingLevel : playbackLevel; - // Convert normalized (0-1) back to dB - // normalized = (dB - (-60)) / 60, so dB = (normalized * 60) - 60 - const db = (level * 60) - 60; - return level === 0 ? '-∞' : `${db.toFixed(0)}`; - })()} - -
- - {/* Recording Settings - Show when track is armed */} - {track.recordEnabled && recordingSettings && onInputGainChange && onRecordMonoChange && onSampleRateChange && ( - - )} - + {/* Vertical Volume Fader with integrated meter */} + +
)}
diff --git a/components/tracks/TrackList.tsx b/components/tracks/TrackList.tsx index ab31bd4..6875a34 100644 --- a/components/tracks/TrackList.tsx +++ b/components/tracks/TrackList.tsx @@ -7,7 +7,6 @@ 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'; -import type { RecordingSettings } from '@/lib/hooks/useRecording'; export interface TrackListProps { tracks: TrackType[]; @@ -26,10 +25,6 @@ export interface TrackListProps { recordingTrackId?: string | null; recordingLevel?: number; trackLevels?: Record; - recordingSettings?: RecordingSettings; - onInputGainChange?: (gain: number) => void; - onRecordMonoChange?: (mono: boolean) => void; - onSampleRateChange?: (sampleRate: number) => void; } export function TrackList({ @@ -49,10 +44,6 @@ export function TrackList({ recordingTrackId, recordingLevel = 0, trackLevels = {}, - recordingSettings, - onInputGainChange, - onRecordMonoChange, - onSampleRateChange, }: TrackListProps) { const [importDialogOpen, setImportDialogOpen] = React.useState(false); @@ -176,10 +167,6 @@ export function TrackList({ isRecording={recordingTrackId === track.id} recordingLevel={recordingTrackId === track.id ? recordingLevel : 0} playbackLevel={trackLevels[track.id] || 0} - recordingSettings={recordingSettings} - onInputGainChange={onInputGainChange} - onRecordMonoChange={onRecordMonoChange} - onSampleRateChange={onSampleRateChange} /> ))}
diff --git a/components/ui/CircularKnob.tsx b/components/ui/CircularKnob.tsx new file mode 100644 index 0000000..33d9f35 --- /dev/null +++ b/components/ui/CircularKnob.tsx @@ -0,0 +1,183 @@ +'use client'; + +import * as React from 'react'; +import { cn } from '@/lib/utils/cn'; + +export interface CircularKnobProps { + value: number; // -1.0 to 1.0 for pan + onChange: (value: number) => void; + min?: number; + max?: number; + step?: number; + size?: number; + className?: string; + label?: string; + formatValue?: (value: number) => string; +} + +export function CircularKnob({ + value, + onChange, + min = -1, + max = 1, + step = 0.01, + size = 48, + className, + label, + formatValue, +}: CircularKnobProps) { + const knobRef = React.useRef(null); + const [isDragging, setIsDragging] = React.useState(false); + const dragStartRef = React.useRef({ x: 0, y: 0, value: 0 }); + + const updateValue = React.useCallback( + (clientX: number, clientY: number) => { + if (!knobRef.current) return; + + const rect = knobRef.current.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + + // Calculate vertical drag distance from start + const deltaY = dragStartRef.current.y - clientY; + const sensitivity = 200; // pixels for full range + const range = max - min; + const delta = (deltaY / sensitivity) * range; + + let newValue = dragStartRef.current.value + delta; + + // Snap to step + if (step) { + newValue = Math.round(newValue / step) * step; + } + + // Clamp to range + newValue = Math.max(min, Math.min(max, newValue)); + + onChange(newValue); + }, + [min, max, step, onChange] + ); + + const handleMouseDown = React.useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + setIsDragging(true); + dragStartRef.current = { + x: e.clientX, + y: e.clientY, + value, + }; + }, + [value] + ); + + const handleMouseMove = React.useCallback( + (e: MouseEvent) => { + if (isDragging) { + updateValue(e.clientX, e.clientY); + } + }, + [isDragging, updateValue] + ); + + const handleMouseUp = React.useCallback(() => { + setIsDragging(false); + }, []); + + React.useEffect(() => { + if (isDragging) { + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + } + }, [isDragging, handleMouseMove, handleMouseUp]); + + // Calculate rotation angle (-135deg to 135deg, 270deg range) + const percentage = (value - min) / (max - min); + const angle = -135 + percentage * 270; + + const displayValue = formatValue + ? formatValue(value) + : value === 0 + ? 'C' + : value < 0 + ? `L${Math.abs(Math.round(value * 100))}` + : `R${Math.round(value * 100)}`; + + return ( +
+ {label && ( +
+ {label} +
+ )} + +
+ {/* Outer ring */} + + {/* Background arc */} + + + {/* Value arc */} + + + + {/* Knob body */} +
+ {/* Indicator line */} +
+
+ + {/* Center dot (for zero position) */} + {value === 0 && ( +
+ )} +
+ + {/* Value Display */} +
+ {displayValue} +
+
+ ); +} diff --git a/components/ui/VerticalFader.tsx b/components/ui/VerticalFader.tsx new file mode 100644 index 0000000..46006f3 --- /dev/null +++ b/components/ui/VerticalFader.tsx @@ -0,0 +1,165 @@ +'use client'; + +import * as React from 'react'; +import { cn } from '@/lib/utils/cn'; + +export interface VerticalFaderProps { + value: number; // 0.0 to 1.0 + level?: number; // 0.0 to 1.0 (for level meter display) + onChange: (value: number) => void; + min?: number; + max?: number; + step?: number; + className?: string; + showDb?: boolean; +} + +export function VerticalFader({ + value, + level = 0, + onChange, + min = 0, + max = 1, + step = 0.01, + className, + showDb = true, +}: VerticalFaderProps) { + const trackRef = React.useRef(null); + const [isDragging, setIsDragging] = React.useState(false); + + const updateValue = React.useCallback( + (clientY: number) => { + if (!trackRef.current) return; + + const rect = trackRef.current.getBoundingClientRect(); + const height = rect.height; + const y = Math.max(0, Math.min(height, clientY - rect.top)); + + // Invert Y (top = max, bottom = min) + const percentage = 1 - y / height; + const range = max - min; + let newValue = min + percentage * range; + + // Snap to step + if (step) { + newValue = Math.round(newValue / step) * step; + } + + // Clamp to range + newValue = Math.max(min, Math.min(max, newValue)); + + onChange(newValue); + }, + [min, max, step, onChange] + ); + + const handleMouseDown = React.useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + setIsDragging(true); + updateValue(e.clientY); + }, + [updateValue] + ); + + const handleMouseMove = React.useCallback( + (e: MouseEvent) => { + if (isDragging) { + updateValue(e.clientY); + } + }, + [isDragging, updateValue] + ); + + const handleMouseUp = React.useCallback(() => { + setIsDragging(false); + }, []); + + React.useEffect(() => { + if (isDragging) { + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + } + }, [isDragging, handleMouseMove, handleMouseUp]); + + // Convert value to percentage (0-100) + const valuePercentage = ((value - min) / (max - min)) * 100; + + // Convert level to dB for display + const db = value === 0 ? -Infinity : 20 * Math.log10(value); + const levelDb = level === 0 ? -Infinity : (level * 60) - 60; + + return ( +
+ {/* dB Display */} + {showDb && ( +
+ {db === -Infinity ? '-∞' : `${db.toFixed(1)}`} +
+ )} + + {/* Fader Track */} +
+ {/* Level Meter Background (green/yellow/red gradient) */} +
+ + {/* Level Meter (actual level) */} +
+ + {/* Volume Value Fill */} +
+ + {/* Fader Handle */} +
+ + {/* Scale Marks */} +
+ {[0.25, 0.5, 0.75].map((mark) => ( +
+ ))} +
+
+ + {/* Level dB Display */} + {showDb && ( +
+ {levelDb === -Infinity ? '-∞' : `${levelDb.toFixed(0)}`} +
+ )} +
+ ); +}