Compare commits

...

36 Commits

Author SHA1 Message Date
5d9e02fe95 feat: streamline track and master controls layout consistency
- Streamlined track controls and master controls to same width (240px)
- Fixed track controls container to use full width of parent column
- Matched TrackControls card structure with MasterControls (gap-3, no w-full/h-full)
- Updated outer container padding from p-2 to p-4 with gap-4
- Adjusted track controls wrapper to center content instead of stretching
- Added max-width constraint to PlaybackControls to prevent width changes
- Centered transport control buttons in footer

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 16:32:49 +01:00
854e64b4ec fix: add missing cn import to TrackList
- Import cn utility function from @/lib/utils/cn
- Fixes ReferenceError: cn is not defined
- Required for conditional classNames on effect labels

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 13:21:39 +01:00
e7bd262e6f feat: show bypassed effects with gray labels
- Effect labels show in primary color when enabled
- Effect labels show in gray when bypassed/disabled
- Added opacity reduction (60%) for bypassed effects
- Visual feedback matches effect state at a glance

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 13:20:32 +01:00
ba2e138ab9 feat: add effect name labels to effects header
- Display effect names as small chips/badges in effects header
- Shows all effect names at a glance without expanding
- Styled with primary color background and border
- Horizontal scrollable if many effects
- Visible whether effects rack is expanded or collapsed

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 13:19:05 +01:00
7b4a7cc567 fix: remove effects count from effects header
- Remove '(N)' count display from effects bar header
- Effect names are already shown in EffectDevice component:
  - Collapsed: vertical text label
  - Expanded: header with full name
- Cleaner header appearance

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 13:17:47 +01:00
64864cfd34 feat: use eye icon for effects bar folding like automation bar
- Replace ChevronDown/ChevronRight with Eye/EyeOff icons
- Position eye icon absolutely on the right side
- Match AutomationHeader styling with bg-muted/50 and border
- Eye icon shows when expanded, EyeOff when collapsed
- Consistent UX between automation and effects bars

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 13:16:02 +01:00
14a9c6e163 feat: restore automation controls with AutomationHeader component
- Import and use AutomationHeader component in automation bar
- Add parameter selection dropdown (Volume, Pan, Effect parameters)
- Add automation mode controls (Read, Write, Touch, Latch)
- Add lane height controls (increase/decrease buttons)
- Add show/hide toggle button
- Build available parameters dynamically from track effects
- All controls properly wired to update track automation state

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 13:13:12 +01:00
0babc469cc fix: enforce minimum 360px height for all tracks using Math.max
- Track component now uses Math.max to ensure track.height is at least MIN_TRACK_HEIGHT
- TrackList component also uses Math.max for consistent enforcement
- Fixes issue where tracks with old height values (340px, 357px) were smaller
- All tracks now guaranteed to be exactly 360px regardless of stored height

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 13:09:44 +01:00
6658bbbbd4 fix: set minimum and default track height to 360px
- Update DEFAULT_TRACK_HEIGHT from 340px to 360px
- Update MIN_TRACK_HEIGHT from 240px to 360px
- Ensures all tracks have consistent 360px minimum height
- Applies to both control column and waveform column

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 13:07:10 +01:00
9b07f28995 fix: use DEFAULT_TRACK_HEIGHT constant for consistent track heights
- Import DEFAULT_TRACK_HEIGHT and COLLAPSED_TRACK_HEIGHT from types
- Use DEFAULT_TRACK_HEIGHT (340px) instead of hardcoded 240px fallback
- Ensures all tracks have the same height as the first track
- Matches the height used when creating tracks

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 13:02:38 +01:00
d08482a64c fix: restore horizontal scrollbar by making waveform use container height
- Remove fixed height from Track waveformOnly mode
- Use h-full class to fill flex container instead
- Allows parent scroll container to show horizontal scrollbar based on zoom
- Waveform now properly expands horizontally without clipping

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 12:58:47 +01:00
1a66669a77 fix: remove wrapper div to show automation lane controls properly
- Remove h-32 wrapper div around AutomationLane
- AutomationLane now uses its own height (lane.height, defaults to 80px)
- Automation controls (canvas, points) now visible and interactive

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 12:55:42 +01:00
4c794dd293 fix: implement fixed-height track container with flexible waveform
- Track container now has fixed height (240px default or track.height)
- Waveform uses flex-1 to take remaining space
- Automation and effects bars use flex-shrink-0 for fixed height
- When bars expand, waveform shrinks instead of container growing
- Matches DAW behavior where track height stays constant

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 12:54:45 +01:00
29de647b30 refactor: use stacked layout for waveform, automation, and effects bars
- Replace absolute positioning with flex column layout
- Waveform, automation bar, and effects bar now stacked vertically
- Removes gaps between bars naturally with stacked layout
- Both bars remain collapsible with no position dependencies

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 12:50:43 +01:00
83ae2e7ea7 fix: remove border-t from effects bar to eliminate gap with automation bar
- Removed border-t from effects bar container
- Keeps border-b for bottom edge separation
- No gap between automation and effects bars in both folded and unfolded states

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 12:43:00 +01:00
950c0f69a6 fix: automation bar now properly foldable with default parameter
- Default selectedParameterId to 'volume' when undefined
- Fixes issue where clicking automation header did nothing
- Chevron now correctly shows fold/unfold state

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 12:40:45 +01:00
a2542ac87f fix: remove gap between automation and effects bars
- Remove border-t from automation bar to eliminate gap with effects bar

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 12:35:41 +01:00
7aebc1da24 fix: add missing Sparkles import and correct AutomationLane props
- Add Sparkles icon import to TrackExtensions
- Remove invalid props from AutomationLane (trackId, isPlaying, onSeek)
- Fix createAutomationPoint call to use object parameter with curve property

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 12:32:30 +01:00
42b8f61f5f fix: make automation bar collapsible and restore automation controls
Fixed issues:
1. Made automation bar collapsible with chevron indicator
2. Removed border-t from automation lane container (no gap to effects)
3. Restored lane.visible filter so AutomationLane properly renders with controls

The AutomationLane component has all the rich editing UI:
- Click canvas to add automation points
- Drag points to move them
- Double-click to delete points
- Keyboard delete (Del/Backspace) for selected points
- Visual feedback with selected state

All controls are intact and functional when automation bar is expanded.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 12:18:58 +01:00
35a6ee35d0 fix: remove A/E buttons, make automation always expanded, add bottom border
Fixed all three issues:
1. Removed automation (A) and effects (E) toggle buttons from TrackControls
2. Made automation bar always expanded and non-collapsible
3. Added bottom border to effects bar (border-b)

Changes:
- TrackControls.tsx: Removed entire Row 2 (A/E buttons section)
- TrackList.tsx: Removed click handler and chevron from automation header
- TrackList.tsx: Automation lane always visible (no conditional rendering)
- TrackList.tsx: Added border-b to effects bar container
- TrackList.tsx: Added border-b to automation bar for visual consistency

Bars are now permanent, always-visible UI elements at the bottom of tracks.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 12:16:17 +01:00
235fc3913c fix: position bars at bottom, always visible, no inner containers
Major improvements to automation and effects bars:
- Both bars now positioned at bottom of waveform (not top)
- Bars are always visible when track is expanded (no show/hide buttons)
- Effects bar at absolute bottom
- Automation bar above effects, dynamically positioned based on effects state
- Removed inner container from effects - direct rendering with EffectDevice
- Removed close buttons (X icons) - bars are permanent
- Effects render directly with gap-3 padding, no TrackExtensions wrapper
- Automation controls preserved (AutomationLane unchanged)

This creates a cleaner, always-accessible interface where users can quickly
expand/collapse automation or effects without toggling visibility.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 12:12:26 +01:00
0e59870884 refactor: replace overlay cards with integrated collapsible bars
Changes automation and effects from card-based overlays to integrated bars:
- Automation bar positioned at absolute top with no margins/padding
- Effects bar positioned below automation or at top if automation hidden
- Both bars use bg-card/90 backdrop-blur-sm for subtle transparency
- Collapsible with ChevronDown/ChevronRight indicators
- Close button (X icon) on each bar
- Effects bar dynamically positions based on automation state
- Added effectsExpanded property to Track type
- Removed card container styling for cleaner integration

The bars now sit directly on the waveform as requested, making them feel
more integrated into the track view rather than floating overlays.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 12:07:23 +01:00
8c779ccd88 fix: improve overlay visibility with header controls and lighter backdrop
Changes to automation and effects overlays:
- Reduced backdrop opacity from bg-black/60 to bg-black/30 (less dark)
- Added header to automation overlay with parameter name display
- Added close button to automation overlay (ChevronDown icon)
- Wrapped automation lane in rounded card with border and shadow
- Both overlays now have consistent visual styling
- Added ChevronDown import to TrackList

This makes the overlays less obtrusive and adds proper controls for closing
the automation view, matching the effects overlay pattern.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 11:56:59 +01:00
b57ac5912a feat: implement overlay architecture for automation lanes and effects
Automation lanes and effects now render as overlays on top of the waveform
instead of below the track, solving both visibility and layout proportion issues.

Changes:
- Wrapped Track waveforms in relative container for overlay positioning
- Automation lanes render with bg-black/60 backdrop-blur overlay
- Effects render with TrackExtensions in overlay mode (asOverlay prop)
- Added overlay-specific rendering with close button and better empty state
- Both overlays use absolute positioning with z-10 for proper stacking
- Eliminated height mismatch between controls and waveform areas

This approach provides better visual integration and eliminates the need
to match heights between the two-column layout.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 11:53:38 +01:00
d2ed7d6e78 fix: effects expansion, automation lanes, and layout alignment
Fixed multiple issues with the track layout system:

1. Effect cards now expandable/collapsible
   - Added onToggleExpanded callback to EffectDevice
   - Effect expansion state is properly toggled and persisted

2. Removed left column spacers causing misalignment
   - Removed automation lane spacer (h-32)
   - Removed effects section spacer (h-64/h-8)
   - Automation lanes and effects now only in waveform column
   - This eliminates the height mismatch between columns

3. Layout now cleaner
   - Left column stays fixed with only track controls
   - Right column contains waveforms, automation, and effects
   - No artificial spacers needed for alignment

The automation lanes and effects sections now appear properly in the
waveform area without creating alignment issues in the controls column.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 11:45:38 +01:00
cd310ce7e4 fix: ImportDialog not respecting open prop causing unwanted display
Fixed critical bug where ImportDialog was always rendered regardless of
the open prop value. The component interface didn't match the actual
implementation, causing it to appear on page load.

Changes:
- Added `open` prop to ImportDialogProps interface
- Added early return when `open` is false to prevent rendering
- Renamed `onCancel` to `onClose` to match Track component usage
- Made fileName, sampleRate, and channels optional props
- Dialog now only appears when explicitly opened by user action

This fixes the issue where the dialog appeared immediately on page load
when loading a saved project.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 11:39:34 +01:00
594ff7f4c9 fix: improve effects panel styling with padding and gap
Added padding around effects device rack and gap between effect cards
for better visual integration and spacing.

Changes:
- Added p-3 padding to effects rack container
- Added gap-3 between effect device cards
- Improves visual consistency and readability

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 11:37:45 +01:00
ca63d12cbf fix: prevent multiple ImportDialog instances from appearing
Fixed issue where ImportDialog was being rendered for each track in
waveform mode, causing multiple unclosable dialogs to appear on page load.

Changes:
- Moved ImportDialog to renderControlsOnly mode only
- Each track now has exactly one ImportDialog (rendered in controls column)
- Removed duplicate ImportDialog from renderWaveformOnly mode
- Removed ImportDialog from fallback return statement

This ensures only one dialog appears per track, making it properly closable.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 11:30:03 +01:00
7a7d6891cd fix: restore automation lanes and effects sections in two-column layout
Restored the automation lane and effects sections that were removed during
the Track component refactoring. These sections now render as full-width
rows below each track, spanning across both the controls and waveforms columns.

Changes:
- Created TrackExtensions component for effects section rendering
- Added automation lane rendering in TrackList after each track waveform
- Added placeholder spacers in left controls column to maintain alignment
- Effects section shows collapsible device rack with mini preview when collapsed
- Automation lanes render when track.automation.showAutomation is true
- Import dialog moved to waveform-only rendering mode

The automation and effects sections are now properly unfoldable again.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 11:24:38 +01:00
90e66e8bef fix: restore border between track controls and waveforms
Added back the right border on the track controls column to visually
separate the controls from the waveform area.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 11:16:23 +01:00
e0b878daad fix: add bottom padding to track controls to compensate for scrollbar
Added pb-3 (padding-bottom) to the track controls column to account
for the horizontal scrollbar height in the waveforms column, ensuring
track controls stay perfectly aligned with their waveforms.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 11:13:55 +01:00
39ea599f18 fix: synchronize vertical scrolling between track controls and waveforms
Track controls now stay perfectly aligned with their waveforms during
vertical scrolling. The waveform column handles all scrolling (both
horizontal and vertical), and synchronizes its vertical scroll position
to the controls column.

Changes:
- Removed independent vertical scroll from controls column
- Added scroll event handler to waveforms column
- Controls column scrollTop is synced with waveforms column
- Track controls and waveforms now stay aligned at all times

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 11:11:38 +01:00
45d46067ea fix: move useRef before early return to comply with Rules of Hooks
Fixed React Hooks error by moving waveformScrollRef declaration
before the conditional early return. Hooks must always be called
in the same order on every render.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 11:07:15 +01:00
d7dfb8a746 feat: implement synchronized horizontal scrolling for track waveforms
Refactored track layout to use a two-column design where:
- Left column: All track control panels (fixed, vertical scroll)
- Right column: All waveforms (shared horizontal scroll container)

This ensures all track waveforms scroll together horizontally when
zoomed in, providing a more cohesive DAW-like experience.

Changes:
- Added renderControlsOnly and renderWaveformOnly props to Track component
- Track component now supports conditional rendering of just controls or just waveform
- TrackList renders each track twice: once for controls, once for waveforms
- Waveforms share a common scrollable container for synchronized scrolling
- Track controls stay fixed while waveforms scroll horizontally together

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 11:05:27 +01:00
5dadba9c9f fix: waveform fills viewport width when zoom is 1.0
Modified the waveform minWidth calculation to only apply when
zoom > 1, so the waveform fills the available viewport space
at default zoom level instead of creating unnecessary horizontal
scrolling.

Changes:
- minWidth only applied when zoom > 1
- At zoom = 1.0, waveform takes 100% of available space
- Scrollbar only appears when actually zoomed in

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 10:52:14 +01:00
cd311d8145 fix: make only waveform area scrollable, keep track controls fixed
Fixed horizontal scrollbar implementation to only scroll the waveform
content area while keeping track controls fixed in the viewport.

Changes:
- Wrapped waveform content in a scrollable container with overflow-x-auto
- Moved minWidth calculation to inner container
- Track controls now stay fixed at 192px width
- No scrollbar appears on initial track load
- Scrollbar only appears when zooming extends content beyond viewport

Resolves issue where entire track row (including controls) was
scrolling out of view when zoomed in.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 10:50:09 +01:00
11 changed files with 1351 additions and 688 deletions

View File

@@ -69,10 +69,13 @@ export function AutomationHeader({
return (
<div
className={cn(
'relative flex items-center gap-2 px-2 py-1 bg-muted/50 border-b border-border/30 flex-shrink-0',
'flex items-center gap-2 px-3 py-1.5 bg-muted border-t border-b border-border/30 flex-shrink-0',
className
)}
>
{/* Automation label - always visible */}
<span className="text-xs font-medium flex-shrink-0">Automation</span>
{/* Color indicator */}
{color && (
<div

View File

@@ -38,9 +38,9 @@ export function AutomationLane({
(time: number): number => {
if (!containerRef.current) return 0;
const width = containerRef.current.clientWidth;
return (time / duration) * width * zoom;
return (time / duration) * width;
},
[duration, zoom]
[duration]
);
// Convert value (0-1) to Y pixel position (inverted: 0 at bottom, 1 at top)
@@ -58,9 +58,9 @@ export function AutomationLane({
(x: number): number => {
if (!containerRef.current) return 0;
const width = containerRef.current.clientWidth;
return (x / (width * zoom)) * duration;
return (x / width) * duration;
},
[duration, zoom]
[duration]
);
// Convert Y pixel position to value (0-1)
@@ -209,7 +209,7 @@ export function AutomationLane({
const width = rect.width;
// Calculate new time and value
const timePerPixel = duration / (width * zoom);
const timePerPixel = duration / width;
const valuePerPixel = 1 / lane.height;
const newTime = Math.max(0, Math.min(duration, point.time + deltaX * timePerPixel));
@@ -217,7 +217,7 @@ export function AutomationLane({
onUpdatePoint(pointId, { time: newTime, value: newValue });
},
[lane.points, lane.height, duration, zoom, onUpdatePoint]
[lane.points, lane.height, duration, onUpdatePoint]
);
const handlePointDragEnd = React.useCallback(() => {

View File

@@ -4,20 +4,24 @@ import { useState } from 'react';
import { ImportOptions } from '@/lib/audio/decoder';
export interface ImportDialogProps {
open: boolean;
onClose: () => void;
onImport: (options: ImportOptions) => void;
onCancel: () => void;
fileName: string;
originalSampleRate?: number;
originalChannels?: number;
fileName?: string;
sampleRate?: number;
channels?: number;
}
export function ImportDialog({
open,
onClose,
onImport,
onCancel,
fileName,
originalSampleRate,
originalChannels,
sampleRate: originalSampleRate,
channels: originalChannels,
}: ImportDialogProps) {
// Don't render if not open
if (!open) return null;
const [options, setOptions] = useState<ImportOptions>({
convertToMono: false,
targetSampleRate: undefined,
@@ -134,7 +138,7 @@ export function ImportDialog({
<div className="flex justify-end space-x-3 mt-6">
<button
onClick={onCancel}
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors"
>
Cancel

View File

@@ -1569,7 +1569,7 @@ export function AudioEditor() {
</main>
{/* Right Sidebar - Master Controls & Analyzers */}
<aside className="flex-shrink-0 border-l border-border bg-card flex flex-col p-4 gap-4 w-[280px]">
<aside className="flex-shrink-0 border-l border-border bg-card flex flex-col pt-5 px-4 pb-4 gap-4 w-60">
{/* Master Controls */}
<div className="flex items-center justify-center">
<MasterControls
@@ -1655,7 +1655,7 @@ export function AudioEditor() {
{/* Analyzer Display */}
<div className="flex-1 min-h-[360px] flex items-start justify-center">
<div className="w-[192px]">
<div className="w-[178px]">
{analyzerView === 'frequency' && <FrequencyAnalyzer analyserNode={masterAnalyser} />}
{analyzerView === 'spectrogram' && <Spectrogram analyserNode={masterAnalyser} />}
{analyzerView === 'phase' && <PhaseCorrelationMeter analyserNode={masterAnalyser} />}

View File

@@ -71,7 +71,7 @@ export function PlaybackControls({
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
return (
<div className={cn('space-y-4', className)}>
<div className={cn('space-y-4 w-full max-w-2xl', className)}>
{/* Timeline Slider */}
<div className="space-y-2">
<input
@@ -161,7 +161,7 @@ export function PlaybackControls({
)}
{/* Transport Controls */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center justify-center gap-4">
<div className="flex items-center gap-2">
<Button
variant="outline"

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,15 @@
'use client';
import * as React from 'react';
import { Circle, Headphones, MoreHorizontal } from 'lucide-react';
import { Circle, Headphones, MoreHorizontal, ChevronRight, ChevronDown } from 'lucide-react';
import { CircularKnob } from '@/components/ui/CircularKnob';
import { TrackFader } from './TrackFader';
import { cn } from '@/lib/utils/cn';
export interface TrackControlsProps {
trackName: string;
trackColor: string;
collapsed: boolean;
volume: number;
pan: number;
peakLevel: number;
@@ -17,6 +20,8 @@ export interface TrackControlsProps {
showAutomation?: boolean;
showEffects?: boolean;
isRecording?: boolean;
onNameChange: (name: string) => void;
onToggleCollapse: () => void;
onVolumeChange: (volume: number) => void;
onPanChange: (pan: number) => void;
onMuteToggle: () => void;
@@ -32,6 +37,9 @@ export interface TrackControlsProps {
}
export function TrackControls({
trackName,
trackColor,
collapsed,
volume,
pan,
peakLevel,
@@ -42,6 +50,8 @@ export function TrackControls({
showAutomation = false,
showEffects = false,
isRecording = false,
onNameChange,
onToggleCollapse,
onVolumeChange,
onPanChange,
onMuteToggle,
@@ -55,11 +65,77 @@ export function TrackControls({
onPanTouchEnd,
className,
}: TrackControlsProps) {
const [isEditingName, setIsEditingName] = React.useState(false);
const [editName, setEditName] = React.useState(trackName);
const handleNameClick = () => {
setIsEditingName(true);
setEditName(trackName);
};
const handleNameBlur = () => {
setIsEditingName(false);
if (editName.trim() && editName !== trackName) {
onNameChange(editName.trim());
} else {
setEditName(trackName);
}
};
const handleNameKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleNameBlur();
} else if (e.key === 'Escape') {
setIsEditingName(false);
setEditName(trackName);
}
};
return (
<div className={cn(
'flex flex-col items-center justify-between h-full px-3 pt-2 pb-4 bg-card/50 border-2 border-accent/50 rounded-lg',
'flex flex-col items-center gap-3 px-4 py-3 bg-card/50 border-2 border-accent/50 rounded-lg',
className
)}>
{/* Track Name Header with Collapse Chevron */}
<div className="flex items-center gap-1 w-full">
<button
onClick={onToggleCollapse}
className="p-0.5 hover:bg-accent/20 rounded transition-colors flex-shrink-0"
title={collapsed ? 'Expand track' : 'Collapse track'}
>
{collapsed ? (
<ChevronRight className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronDown className="h-3 w-3 text-muted-foreground" />
)}
</button>
<div className="flex-1 flex items-center justify-center min-w-0">
{isEditingName ? (
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onBlur={handleNameBlur}
onKeyDown={handleNameKeyDown}
autoFocus
className="w-24 text-[10px] font-bold uppercase tracking-wider text-center bg-transparent border-b focus:outline-none px-1"
style={{ color: trackColor, borderColor: trackColor }}
/>
) : (
<div
onClick={handleNameClick}
className="w-24 text-[10px] font-bold uppercase tracking-wider text-center cursor-text hover:bg-accent/10 px-1 rounded transition-colors truncate"
style={{ color: trackColor }}
title="Click to edit track name"
>
{trackName}
</div>
)}
</div>
{/* Spacer to balance the chevron and center the label */}
<div className="p-0.5 flex-shrink-0 w-4" />
</div>
{/* Pan Control - Top */}
<div className="flex justify-center w-full">
<CircularKnob
@@ -71,7 +147,7 @@ export function TrackControls({
max={1}
step={0.01}
label="PAN"
size={40}
size={48}
formatValue={(value: number) => {
if (Math.abs(value) < 0.01) return 'C';
if (value < 0) return `${Math.abs(value * 100).toFixed(0)}L`;
@@ -94,14 +170,14 @@ export function TrackControls({
{/* Control Buttons - Bottom */}
<div className="flex flex-col gap-1 w-full">
{/* Control Buttons Row 1: R/S/M */}
<div className="flex items-center gap-0.5 w-full justify-center">
{/* Control Buttons Row 1: R/M/S */}
<div className="flex items-center gap-1 w-full justify-center">
{/* Record Arm */}
{onRecordToggle && (
<button
onClick={onRecordToggle}
className={cn(
'h-5 w-5 rounded-md flex items-center justify-center transition-all text-[9px] font-bold',
'h-8 w-8 rounded-md flex items-center justify-center transition-all text-[11px] font-bold',
isRecordEnabled
? 'bg-red-500 text-white shadow-md shadow-red-500/30'
: 'bg-card hover:bg-accent text-muted-foreground border border-border/50',
@@ -109,23 +185,7 @@ export function TrackControls({
)}
title="Arm track for recording"
>
<Circle className="h-2.5 w-2.5 fill-current" />
</button>
)}
{/* Solo Button */}
{onSoloToggle && (
<button
onClick={onSoloToggle}
className={cn(
'h-5 w-5 rounded-md flex items-center justify-center transition-all text-[9px] font-bold',
isSolo
? 'bg-yellow-500 text-black shadow-md shadow-yellow-500/30'
: 'bg-card hover:bg-accent text-muted-foreground border border-border/50'
)}
title="Solo track"
>
<Headphones className="h-2.5 w-2.5" />
<Circle className="h-3 w-3 fill-current" />
</button>
)}
@@ -133,7 +193,7 @@ export function TrackControls({
<button
onClick={onMuteToggle}
className={cn(
'h-5 w-5 rounded-md flex items-center justify-center transition-all text-[9px] font-bold',
'h-8 w-8 rounded-md flex items-center justify-center transition-all text-[11px] font-bold',
isMuted
? 'bg-blue-500 text-white shadow-md shadow-blue-500/30'
: 'bg-card hover:bg-accent text-muted-foreground border border-border/50'
@@ -142,39 +202,20 @@ export function TrackControls({
>
M
</button>
</div>
{/* Control Buttons Row 2: A/E */}
<div className="flex items-center gap-0.5 w-full justify-center">
{/* Automation Toggle */}
{onAutomationToggle && (
{/* Solo Button */}
{onSoloToggle && (
<button
onClick={onAutomationToggle}
onClick={onSoloToggle}
className={cn(
'h-5 w-5 rounded-md flex items-center justify-center transition-all text-[9px] font-bold',
showAutomation
? 'bg-primary text-primary-foreground shadow-md shadow-primary/30'
'h-8 w-8 rounded-md flex items-center justify-center transition-all text-[11px] font-bold',
isSolo
? 'bg-yellow-500 text-black shadow-md shadow-yellow-500/30'
: 'bg-card hover:bg-accent text-muted-foreground border border-border/50'
)}
title="Toggle automation"
title="Solo track"
>
A
</button>
)}
{/* Effects Button */}
{onEffectsClick && (
<button
onClick={onEffectsClick}
className={cn(
'h-5 w-5 rounded-md flex items-center justify-center transition-all text-[9px] font-bold',
showEffects
? 'bg-primary text-primary-foreground shadow-md shadow-primary/30'
: 'bg-card hover:bg-accent text-muted-foreground border border-border/50'
)}
title={showEffects ? 'Hide effects' : 'Show effects'}
>
E
<Headphones className="h-3 w-3" />
</button>
)}
</div>

View File

@@ -0,0 +1,222 @@
'use client';
import * as React from 'react';
import { Plus, ChevronDown, ChevronRight, Sparkles } from 'lucide-react';
import type { Track as TrackType } from '@/types/track';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/utils/cn';
import { EffectDevice } from '@/components/effects/EffectDevice';
import { EffectBrowser } from '@/components/effects/EffectBrowser';
import type { EffectType } from '@/lib/audio/effects/chain';
export interface TrackExtensionsProps {
track: TrackType;
onUpdateTrack: (trackId: string, updates: Partial<TrackType>) => void;
onToggleEffect?: (effectId: string) => void;
onRemoveEffect?: (effectId: string) => void;
onUpdateEffect?: (effectId: string, parameters: any) => void;
onAddEffect?: (effectType: EffectType) => void;
asOverlay?: boolean; // When true, renders as full overlay without header
}
export function TrackExtensions({
track,
onUpdateTrack,
onToggleEffect,
onRemoveEffect,
onUpdateEffect,
onAddEffect,
asOverlay = false,
}: TrackExtensionsProps) {
const [effectBrowserOpen, setEffectBrowserOpen] = React.useState(false);
// Don't render if track is collapsed (unless it's an overlay, which handles its own visibility)
if (!asOverlay && track.collapsed) {
return null;
}
// Overlay mode: render full-screen effect rack
if (asOverlay) {
return (
<>
<div className="flex flex-col h-full bg-card/95 rounded-lg border border-border shadow-2xl">
{/* Header with close button */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-muted/50">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Effects</span>
<span className="text-xs text-muted-foreground">
({track.effectChain.effects.length})
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon-sm"
onClick={() => setEffectBrowserOpen(true)}
title="Add effect"
>
<Plus className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => onUpdateTrack(track.id, { showEffects: false })}
title="Close effects"
>
<ChevronDown className="h-4 w-4" />
</Button>
</div>
</div>
{/* Effects rack */}
<div className="flex-1 overflow-x-auto custom-scrollbar p-4">
<div className="flex h-full gap-4">
{track.effectChain.effects.length === 0 ? (
<div className="flex flex-col items-center justify-center w-full text-center gap-3">
<Sparkles className="h-12 w-12 text-muted-foreground/30" />
<div>
<p className="text-sm text-muted-foreground mb-1">No effects yet</p>
<p className="text-xs text-muted-foreground/70">
Click + to add an effect
</p>
</div>
</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)}
onToggleExpanded={() => {
const updatedEffects = track.effectChain.effects.map((e) =>
e.id === effect.id ? { ...e, expanded: !e.expanded } : e
);
onUpdateTrack(track.id, {
effectChain: { ...track.effectChain, effects: updatedEffects },
});
}}
/>
))
)}
</div>
</div>
</div>
{/* Effect Browser Dialog */}
<EffectBrowser
open={effectBrowserOpen}
onClose={() => setEffectBrowserOpen(false)}
onSelectEffect={(effectType) => {
if (onAddEffect) {
onAddEffect(effectType);
}
}}
/>
</>
);
}
// Original inline mode
return (
<>
{/* Effects Section (Collapsible, Full Width) */}
<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={() => {
onUpdateTrack(track.id, {
showEffects: !track.showEffects,
});
}}
>
{track.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 */}
{!track.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 */}
{track.showEffects && (
<div className="h-48 overflow-x-auto custom-scrollbar bg-muted/70 p-3">
<div className="flex h-full gap-3">
{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)}
onToggleExpanded={() => {
const updatedEffects = track.effectChain.effects.map((e) =>
e.id === effect.id ? { ...e, expanded: !e.expanded } : e
);
onUpdateTrack(track.id, {
effectChain: { ...track.effectChain, effects: updatedEffects },
});
}}
/>
))
)}
</div>
</div>
)}
</div>
{/* Effect Browser Dialog */}
<EffectBrowser
open={effectBrowserOpen}
onClose={() => setEffectBrowserOpen(false)}
onSelectEffect={(effectType) => {
if (onAddEffect) {
onAddEffect(effectType);
}
}}
/>
</>
);
}

View File

@@ -108,9 +108,9 @@ export function TrackFader({
}, [isDragging, handleMouseMove, handleMouseUp, handleTouchMove, handleTouchEnd]);
return (
<div className={cn('flex gap-2', className)} style={{ marginLeft: '16px' }}>
<div className={cn('flex gap-3', className)} style={{ marginLeft: '16px' }}>
{/* dB Labels (Left) */}
<div className="flex flex-col justify-between text-[9px] font-mono text-muted-foreground py-1">
<div className="flex flex-col justify-between text-[10px] font-mono text-muted-foreground py-1">
<span>0</span>
<span>-12</span>
<span>-24</span>
@@ -120,12 +120,12 @@ export function TrackFader({
{/* Fader Container */}
<div
ref={containerRef}
className="relative w-10 h-32 bg-background/50 rounded-md border border-border/50 cursor-pointer"
className="relative w-12 h-40 bg-background/50 rounded-md border border-border/50 cursor-pointer"
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
>
{/* Peak Meter (Horizontal Bar - Top) */}
<div className="absolute inset-x-1.5 top-1.5 h-2.5 bg-background/80 rounded-sm overflow-hidden border border-border/30">
<div className="absolute inset-x-2 top-2 h-3 bg-background/80 rounded-sm overflow-hidden border border-border/30">
<div
className="absolute left-0 top-0 bottom-0 transition-all duration-75 ease-out"
style={{ width: `${Math.max(0, Math.min(100, peakWidth))}%` }}
@@ -140,7 +140,7 @@ export function TrackFader({
</div>
{/* RMS Meter (Horizontal Bar - Bottom) */}
<div className="absolute inset-x-1.5 bottom-1.5 h-2.5 bg-background/80 rounded-sm overflow-hidden border border-border/30">
<div className="absolute inset-x-2 bottom-2 h-3 bg-background/80 rounded-sm overflow-hidden border border-border/30">
<div
className="absolute left-0 top-0 bottom-0 transition-all duration-150 ease-out"
style={{ width: `${Math.max(0, Math.min(100, rmsWidth))}%` }}
@@ -155,26 +155,26 @@ export function TrackFader({
</div>
{/* Fader Track */}
<div className="absolute top-6 bottom-6 left-1/2 -translate-x-1/2 w-1 bg-muted/50 rounded-full" />
<div className="absolute top-8 bottom-8 left-1/2 -translate-x-1/2 w-1.5 bg-muted/50 rounded-full" />
{/* Fader Handle */}
<div
className="absolute left-1/2 -translate-x-1/2 w-9 h-3.5 bg-primary/80 border-2 border-primary rounded-md shadow-lg cursor-grab active:cursor-grabbing pointer-events-none transition-all"
className="absolute left-1/2 -translate-x-1/2 w-10 h-4 bg-primary/80 border-2 border-primary rounded-md shadow-lg cursor-grab active:cursor-grabbing pointer-events-none transition-all"
style={{
// Inverted: value 1 = top, value 0 = bottom
top: `calc(${(1 - value) * 100}% - 0.4375rem)`,
top: `calc(${(1 - value) * 100}% - 0.5rem)`,
}}
>
{/* Handle grip lines */}
<div className="absolute inset-0 flex items-center justify-center gap-0.5">
<div className="h-1.5 w-px bg-primary-foreground/30" />
<div className="h-1.5 w-px bg-primary-foreground/30" />
<div className="h-1.5 w-px bg-primary-foreground/30" />
<div className="h-2 w-px bg-primary-foreground/30" />
<div className="h-2 w-px bg-primary-foreground/30" />
<div className="h-2 w-px bg-primary-foreground/30" />
</div>
</div>
{/* dB Scale Markers */}
<div className="absolute inset-0 px-1.5 py-6 pointer-events-none">
<div className="absolute inset-0 px-2 py-8 pointer-events-none">
<div className="relative h-full">
{/* -12 dB */}
<div className="absolute left-0 right-0 h-px bg-border/20" style={{ top: '50%' }} />
@@ -186,17 +186,49 @@ export function TrackFader({
</div>
</div>
{/* Value Display (Right) */}
<div className="flex flex-col justify-center items-start text-[9px] font-mono">
{/* Value and Level Display (Right) */}
<div className="flex flex-col justify-between items-start text-[9px] font-mono py-1 w-[36px]">
{/* Current dB Value */}
<div className={cn(
'font-bold text-[10px] w-[32px]',
'font-bold text-[11px]',
valueDb > -3 ? 'text-red-500' :
valueDb > -6 ? 'text-yellow-500' :
'text-green-500'
)}>
{valueDb > -60 ? `${valueDb.toFixed(1)}` : '-∞'}
</div>
{/* Spacer */}
<div className="flex-1" />
{/* Peak Level */}
<div className="flex flex-col items-start">
<span className="text-muted-foreground/60">PK</span>
<span className={cn(
'font-mono text-[10px]',
peakDb > -3 ? 'text-red-500' :
peakDb > -6 ? 'text-yellow-500' :
'text-green-500'
)}>
{peakDb > -60 ? `${peakDb.toFixed(1)}` : '-∞'}
</span>
</div>
{/* RMS Level */}
<div className="flex flex-col items-start">
<span className="text-muted-foreground/60">RM</span>
<span className={cn(
'font-mono text-[10px]',
rmsDb > -3 ? 'text-red-500' :
rmsDb > -6 ? 'text-yellow-500' :
'text-green-500'
)}>
{rmsDb > -60 ? `${rmsDb.toFixed(1)}` : '-∞'}
</span>
</div>
{/* dB Label */}
<span className="text-muted-foreground/60 text-[8px]">dB</span>
</div>
</div>
);

View File

@@ -1,12 +1,20 @@
'use client';
import * as React from 'react';
import { Plus, Upload } from 'lucide-react';
import { Plus, Upload, ChevronDown, ChevronRight, ChevronUp, X, Eye, EyeOff } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/utils/cn';
import { Track } from './Track';
import { TrackExtensions } from './TrackExtensions';
import { ImportTrackDialog } from './ImportTrackDialog';
import type { Track as TrackType } from '@/types/track';
import { DEFAULT_TRACK_HEIGHT, COLLAPSED_TRACK_HEIGHT, MIN_TRACK_HEIGHT } from '@/types/track';
import { createEffect, type EffectType, EFFECT_NAMES } from '@/lib/audio/effects/chain';
import { AutomationLane } from '@/components/automation/AutomationLane';
import { AutomationHeader } from '@/components/automation/AutomationHeader';
import type { AutomationPoint as AutomationPointType } from '@/types/automation';
import { createAutomationPoint } from '@/lib/audio/automation/utils';
import { EffectDevice } from '@/components/effects/EffectDevice';
export interface TrackListProps {
tracks: TrackType[];
@@ -50,6 +58,75 @@ export function TrackList({
isPlaying = false,
}: TrackListProps) {
const [importDialogOpen, setImportDialogOpen] = React.useState(false);
const waveformScrollRef = React.useRef<HTMLDivElement>(null);
const controlsScrollRef = React.useRef<HTMLDivElement>(null);
// Refs for horizontal scroll synchronization (per track)
const waveformHScrollRefs = React.useRef<Map<string, HTMLDivElement>>(new Map());
const automationHScrollRefs = React.useRef<Map<string, HTMLDivElement>>(new Map());
const [syncingScroll, setSyncingScroll] = React.useState(false);
// Synchronize vertical scroll between controls and waveforms
const handleWaveformScroll = React.useCallback(() => {
if (waveformScrollRef.current && controlsScrollRef.current) {
controlsScrollRef.current.scrollTop = waveformScrollRef.current.scrollTop;
}
}, []);
// Synchronize horizontal scroll across all tracks (waveforms and automation lanes)
const handleWaveformHScroll = React.useCallback((trackId: string) => {
if (syncingScroll) return;
setSyncingScroll(true);
const sourceEl = waveformHScrollRefs.current.get(trackId);
if (!sourceEl) {
setSyncingScroll(false);
return;
}
const scrollLeft = sourceEl.scrollLeft;
// Sync all waveforms
waveformHScrollRefs.current.forEach((el, id) => {
if (id !== trackId) {
el.scrollLeft = scrollLeft;
}
});
// Sync all automation lanes
automationHScrollRefs.current.forEach((el) => {
el.scrollLeft = scrollLeft;
});
setSyncingScroll(false);
}, [syncingScroll]);
const handleAutomationHScroll = React.useCallback((trackId: string) => {
if (syncingScroll) return;
setSyncingScroll(true);
const sourceEl = automationHScrollRefs.current.get(trackId);
if (!sourceEl) {
setSyncingScroll(false);
return;
}
const scrollLeft = sourceEl.scrollLeft;
// Sync all waveforms
waveformHScrollRefs.current.forEach((el) => {
el.scrollLeft = scrollLeft;
});
// Sync all automation lanes
automationHScrollRefs.current.forEach((el, id) => {
if (id !== trackId) {
el.scrollLeft = scrollLeft;
}
});
setSyncingScroll(false);
}, [syncingScroll]);
const handleImportTrack = (buffer: AudioBuffer, name: string) => {
if (onImportTrack) {
@@ -88,94 +165,576 @@ export function TrackList({
return (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Track List */}
<div className="flex-1 overflow-auto custom-scrollbar">
{tracks.map((track) => (
<Track
key={track.id}
track={track}
zoom={zoom}
currentTime={currentTime}
duration={duration}
isSelected={selectedTrackId === track.id}
onSelect={onSelectTrack ? () => onSelectTrack(track.id) : undefined}
onToggleMute={() =>
onUpdateTrack(track.id, { mute: !track.mute })
}
onToggleSolo={() =>
onUpdateTrack(track.id, { solo: !track.solo })
}
onToggleCollapse={() =>
onUpdateTrack(track.id, { collapsed: !track.collapsed })
}
onVolumeChange={(volume) =>
onUpdateTrack(track.id, { volume })
}
onPanChange={(pan) =>
onUpdateTrack(track.id, { pan })
}
onRemove={() => onRemoveTrack(track.id)}
onNameChange={(name) =>
onUpdateTrack(track.id, { name })
}
onUpdateTrack={onUpdateTrack}
onSeek={onSeek}
onLoadAudio={(buffer) =>
onUpdateTrack(track.id, { audioBuffer: buffer })
}
onToggleEffect={(effectId) => {
const updatedChain = {
...track.effectChain,
effects: track.effectChain.effects.map((e) =>
e.id === effectId ? { ...e, enabled: !e.enabled } : e
),
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onRemoveEffect={(effectId) => {
const updatedChain = {
...track.effectChain,
effects: track.effectChain.effects.filter((e) => e.id !== effectId),
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onUpdateEffect={(effectId, parameters) => {
const updatedChain = {
...track.effectChain,
effects: track.effectChain.effects.map((e) =>
e.id === effectId ? { ...e, parameters } : e
),
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onAddEffect={(effectType) => {
const newEffect = createEffect(
effectType,
EFFECT_NAMES[effectType]
);
const updatedChain = {
...track.effectChain,
effects: [...track.effectChain.effects, newEffect],
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onSelectionChange={
onSelectionChange
? (selection) => onSelectionChange(track.id, selection)
: undefined
}
onToggleRecordEnable={
onToggleRecordEnable
? () => onToggleRecordEnable(track.id)
: undefined
}
isRecording={recordingTrackId === track.id}
recordingLevel={recordingTrackId === track.id ? recordingLevel : 0}
playbackLevel={trackLevels[track.id] || 0}
onParameterTouched={onParameterTouched}
isPlaying={isPlaying}
/>
))}
{/* Track List - Two Column Layout */}
<div className="flex-1 flex overflow-hidden">
{/* Left Column: Track Controls (Fixed Width, No Scroll - synced with waveforms) */}
<div ref={controlsScrollRef} className="w-60 flex-shrink-0 overflow-hidden pb-3 border-r border-border">
{tracks.map((track) => (
<React.Fragment key={track.id}>
{/* Track Controls */}
<Track
track={track}
zoom={zoom}
currentTime={currentTime}
duration={duration}
isSelected={selectedTrackId === track.id}
onSelect={onSelectTrack ? () => onSelectTrack(track.id) : undefined}
onToggleMute={() =>
onUpdateTrack(track.id, { mute: !track.mute })
}
onToggleSolo={() =>
onUpdateTrack(track.id, { solo: !track.solo })
}
onToggleCollapse={() =>
onUpdateTrack(track.id, { collapsed: !track.collapsed })
}
onVolumeChange={(volume) =>
onUpdateTrack(track.id, { volume })
}
onPanChange={(pan) =>
onUpdateTrack(track.id, { pan })
}
onRemove={() => onRemoveTrack(track.id)}
onNameChange={(name) =>
onUpdateTrack(track.id, { name })
}
onUpdateTrack={onUpdateTrack}
onSeek={onSeek}
onLoadAudio={(buffer) =>
onUpdateTrack(track.id, { audioBuffer: buffer })
}
onToggleEffect={(effectId) => {
const updatedChain = {
...track.effectChain,
effects: track.effectChain.effects.map((e) =>
e.id === effectId ? { ...e, enabled: !e.enabled } : e
),
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onRemoveEffect={(effectId) => {
const updatedChain = {
...track.effectChain,
effects: track.effectChain.effects.filter((e) => e.id !== effectId),
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onUpdateEffect={(effectId, parameters) => {
const updatedChain = {
...track.effectChain,
effects: track.effectChain.effects.map((e) =>
e.id === effectId ? { ...e, parameters } : e
),
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onAddEffect={(effectType) => {
const newEffect = createEffect(
effectType,
EFFECT_NAMES[effectType]
);
const updatedChain = {
...track.effectChain,
effects: [...track.effectChain.effects, newEffect],
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onSelectionChange={
onSelectionChange
? (selection) => onSelectionChange(track.id, selection)
: undefined
}
onToggleRecordEnable={
onToggleRecordEnable
? () => onToggleRecordEnable(track.id)
: undefined
}
isRecording={recordingTrackId === track.id}
recordingLevel={recordingTrackId === track.id ? recordingLevel : 0}
playbackLevel={trackLevels[track.id] || 0}
onParameterTouched={onParameterTouched}
isPlaying={isPlaying}
renderControlsOnly={true}
/>
</React.Fragment>
))}
</div>
{/* Right Column: Waveforms (Flexible Width, Vertical Scroll Only) */}
<div
ref={waveformScrollRef}
onScroll={handleWaveformScroll}
className="flex-1 overflow-y-auto overflow-x-hidden custom-scrollbar"
>
<div className="flex flex-col">
{tracks.map((track) => (
<React.Fragment key={track.id}>
{/* Track Waveform Row with bars stacked below - Fixed height container */}
<div
className="flex flex-col"
style={{
height: track.collapsed ? `${COLLAPSED_TRACK_HEIGHT}px` : `${Math.max(track.height || DEFAULT_TRACK_HEIGHT, MIN_TRACK_HEIGHT)}px`
}}
>
{/* Waveform - Takes remaining space, horizontally scrollable */}
<div className="flex-1 min-h-0 relative">
{/* Upload hint for empty tracks - stays fixed as overlay */}
{!track.audioBuffer && !track.collapsed && (
<div className="absolute inset-0 flex flex-col items-center justify-center text-sm text-muted-foreground pointer-events-none z-10">
<Upload className="h-6 w-6 mb-2 opacity-50" />
<p>Click waveform area to load audio</p>
<p className="text-xs opacity-75 mt-1">or drag & drop</p>
</div>
)}
<div
ref={(el) => {
if (el) waveformHScrollRefs.current.set(track.id, el);
}}
onScroll={() => handleWaveformHScroll(track.id)}
className="w-full h-full overflow-x-auto custom-scrollbar"
>
<div
className="h-full"
style={{
minWidth: duration && zoom > 1 ? `${duration * zoom * 100}px` : '100%',
}}
>
<Track
track={track}
zoom={zoom}
currentTime={currentTime}
duration={duration}
isSelected={selectedTrackId === track.id}
onSelect={onSelectTrack ? () => onSelectTrack(track.id) : undefined}
onToggleMute={() =>
onUpdateTrack(track.id, { mute: !track.mute })
}
onToggleSolo={() =>
onUpdateTrack(track.id, { solo: !track.solo })
}
onToggleCollapse={() =>
onUpdateTrack(track.id, { collapsed: !track.collapsed })
}
onVolumeChange={(volume) =>
onUpdateTrack(track.id, { volume })
}
onPanChange={(pan) =>
onUpdateTrack(track.id, { pan })
}
onRemove={() => onRemoveTrack(track.id)}
onNameChange={(name) =>
onUpdateTrack(track.id, { name })
}
onUpdateTrack={onUpdateTrack}
onSeek={onSeek}
onLoadAudio={(buffer) =>
onUpdateTrack(track.id, { audioBuffer: buffer })
}
onToggleEffect={(effectId) => {
const updatedChain = {
...track.effectChain,
effects: track.effectChain.effects.map((e) =>
e.id === effectId ? { ...e, enabled: !e.enabled } : e
),
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onRemoveEffect={(effectId) => {
const updatedChain = {
...track.effectChain,
effects: track.effectChain.effects.filter((e) => e.id !== effectId),
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onUpdateEffect={(effectId, parameters) => {
const updatedChain = {
...track.effectChain,
effects: track.effectChain.effects.map((e) =>
e.id === effectId ? { ...e, parameters } : e
),
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onAddEffect={(effectType) => {
const newEffect = createEffect(
effectType,
EFFECT_NAMES[effectType]
);
const updatedChain = {
...track.effectChain,
effects: [...track.effectChain.effects, newEffect],
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onSelectionChange={
onSelectionChange
? (selection) => onSelectionChange(track.id, selection)
: undefined
}
onToggleRecordEnable={
onToggleRecordEnable
? () => onToggleRecordEnable(track.id)
: undefined
}
isRecording={recordingTrackId === track.id}
recordingLevel={recordingTrackId === track.id ? recordingLevel : 0}
playbackLevel={trackLevels[track.id] || 0}
onParameterTouched={onParameterTouched}
isPlaying={isPlaying}
renderWaveformOnly={true}
/>
</div>
</div>
</div>
{/* Automation Bar - Collapsible - Fixed height when expanded */}
{!track.collapsed && (() => {
const selectedParam = track.automation.selectedParameterId || 'volume';
const currentLane = track.automation.lanes.find(
l => l.parameterId === selectedParam
);
// Build available parameters list
const availableParameters: Array<{ id: string; name: string }> = [
{ id: 'volume', name: 'Volume' },
{ id: 'pan', name: 'Pan' },
];
// Add effect parameters
track.effectChain.effects.forEach((effect) => {
if (effect.parameters) {
Object.keys(effect.parameters).forEach((paramKey) => {
const parameterId = `effect.${effect.id}.${paramKey}`;
const paramName = `${effect.name} - ${paramKey.charAt(0).toUpperCase() + paramKey.slice(1)}`;
availableParameters.push({ id: parameterId, name: paramName });
});
}
});
// Get parameters that have automation lanes with points
const automatedParams = track.automation.lanes
.filter(lane => lane.points.length > 0)
.map(lane => {
const param = availableParameters.find(p => p.id === lane.parameterId);
return param ? param.name : lane.parameterName;
});
const modes = ['read', 'write', 'touch', 'latch'] as const;
const MODE_LABELS = { read: 'R', write: 'W', touch: 'T', latch: 'L' };
const MODE_COLORS = {
read: 'text-muted-foreground',
write: 'text-red-500',
touch: 'text-yellow-500',
latch: 'text-orange-500',
};
const currentModeIndex = modes.indexOf(currentLane?.mode || 'read');
return (
<div className="flex-shrink-0 bg-card/90 backdrop-blur-sm">
{/* Automation Header - Single Bar */}
<div className="relative flex items-center gap-2 px-3 py-1.5 bg-muted border-t border-b border-border/30">
<span className="text-xs font-medium flex-shrink-0">Automation</span>
{/* Color indicator */}
{currentLane?.color && (
<div
className="w-1 h-4 rounded-full flex-shrink-0"
style={{ backgroundColor: currentLane.color }}
/>
)}
{/* Parameter labels - always visible */}
<div className="flex items-center gap-1.5 flex-1 min-w-0 overflow-x-auto">
{automatedParams.map((paramName, index) => (
<span
key={index}
className="text-[10px] px-1.5 py-0.5 rounded whitespace-nowrap flex-shrink-0 bg-primary/10 text-primary border border-primary/20"
>
{paramName}
</span>
))}
</div>
{/* Controls - only visible when expanded */}
{track.automationExpanded && (
<>
{/* Parameter selector */}
{availableParameters && availableParameters.length > 1 && (
<select
value={selectedParam}
onChange={(e) => onUpdateTrack(track.id, {
automation: { ...track.automation, selectedParameterId: e.target.value },
})}
className="text-xs font-medium text-foreground w-auto min-w-[120px] max-w-[200px] bg-background/50 border border-border/30 rounded px-1.5 py-0.5 hover:bg-background/80 focus:outline-none focus:ring-1 focus:ring-primary flex-shrink-0"
>
{availableParameters.map((param) => (
<option key={param.id} value={param.id}>
{param.name}
</option>
))}
</select>
)}
{/* Automation mode button */}
<Button
variant="ghost"
size="icon-sm"
onClick={() => {
if (currentLane) {
const nextIndex = (currentModeIndex + 1) % modes.length;
const updatedLanes = track.automation.lanes.map((l) =>
l.id === currentLane.id ? { ...l, mode: modes[nextIndex] } : l
);
onUpdateTrack(track.id, {
automation: { ...track.automation, lanes: updatedLanes },
});
}
}}
title={`Automation mode: ${currentLane?.mode || 'read'} (click to cycle)`}
className={cn('h-5 w-5 text-[10px] font-bold flex-shrink-0', MODE_COLORS[currentLane?.mode || 'read'])}
>
{MODE_LABELS[currentLane?.mode || 'read']}
</Button>
{/* Height controls */}
<div className="flex flex-col gap-0 flex-shrink-0">
<Button
variant="ghost"
size="icon-sm"
onClick={() => {
if (currentLane) {
const newHeight = Math.max(60, Math.min(200, currentLane.height + 20));
const updatedLanes = track.automation.lanes.map((l) =>
l.id === currentLane.id ? { ...l, height: newHeight } : l
);
onUpdateTrack(track.id, {
automation: { ...track.automation, lanes: updatedLanes },
});
}
}}
title="Increase lane height"
className="h-3 w-4 p-0"
>
<ChevronUp className="h-2.5 w-2.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onClick={() => {
if (currentLane) {
const newHeight = Math.max(60, Math.min(200, currentLane.height - 20));
const updatedLanes = track.automation.lanes.map((l) =>
l.id === currentLane.id ? { ...l, height: newHeight } : l
);
onUpdateTrack(track.id, {
automation: { ...track.automation, lanes: updatedLanes },
});
}
}}
title="Decrease lane height"
className="h-3 w-4 p-0"
>
<ChevronDown className="h-2.5 w-2.5" />
</Button>
</div>
</>
)}
{/* Show/hide toggle - Positioned absolutely on the right */}
<Button
variant="ghost"
size="icon-sm"
onClick={() => {
onUpdateTrack(track.id, { automationExpanded: !track.automationExpanded });
}}
title={track.automationExpanded ? 'Hide automation controls' : 'Show automation controls'}
className="absolute right-2 h-5 w-5 flex-shrink-0"
>
{track.automationExpanded ? (
<Eye className="h-3 w-3" />
) : (
<EyeOff className="h-3 w-3 text-muted-foreground" />
)}
</Button>
</div>
{/* Automation Lane Content - Shown when expanded */}
{track.automationExpanded && (
<div
ref={(el) => {
if (el) automationHScrollRefs.current.set(track.id, el);
}}
onScroll={() => handleAutomationHScroll(track.id)}
className="overflow-x-auto custom-scrollbar"
>
<div
style={{
minWidth: duration && zoom > 1 ? `${duration * zoom * 100}px` : '100%',
}}
>
{track.automation.lanes
.filter((lane) => lane.parameterId === (track.automation.selectedParameterId || 'volume') && lane.visible)
.map((lane) => (
<AutomationLane
key={lane.id}
lane={lane}
zoom={zoom}
currentTime={currentTime}
duration={duration}
onAddPoint={(time, value) => {
const newPoint = createAutomationPoint({ time, value, curve: 'linear' });
const updatedLanes = track.automation.lanes.map((l) =>
l.id === lane.id
? { ...l, points: [...l.points, newPoint].sort((a, b) => a.time - b.time) }
: l
);
onUpdateTrack(track.id, {
automation: { ...track.automation, lanes: updatedLanes },
});
}}
onUpdatePoint={(pointId, updates) => {
const updatedLanes = track.automation.lanes.map((l) =>
l.id === lane.id
? {
...l,
points: l.points.map((p) =>
p.id === pointId ? { ...p, ...updates } : p
),
}
: l
);
onUpdateTrack(track.id, {
automation: { ...track.automation, lanes: updatedLanes },
});
}}
onRemovePoint={(pointId) => {
const updatedLanes = track.automation.lanes.map((l) =>
l.id === lane.id
? { ...l, points: l.points.filter((p) => p.id !== pointId) }
: l
);
onUpdateTrack(track.id, {
automation: { ...track.automation, lanes: updatedLanes },
});
}}
onUpdateLane={(updates) => {
const updatedLanes = track.automation.lanes.map((l) =>
l.id === lane.id ? { ...l, ...updates } : l
);
onUpdateTrack(track.id, {
automation: { ...track.automation, lanes: updatedLanes },
});
}}
/>
))}
</div>
</div>
)}
</div>
);
})()}
{/* Effects Bar - Collapsible - Fixed height when expanded */}
{!track.collapsed && (
<div className="flex-shrink-0 bg-card/90 backdrop-blur-sm border-b border-border">
{/* Effects Header - Collapsible */}
<div className="relative flex items-center gap-2 px-3 py-1.5 bg-muted/50 border-t border-b border-border/30 overflow-x-auto">
<span className="text-xs font-medium flex-shrink-0">Effects</span>
{/* Effect name labels */}
<div className="flex items-center gap-1.5 flex-1 min-w-0">
{track.effectChain.effects.map((effect) => (
<span
key={effect.id}
className={cn(
"text-[10px] px-1.5 py-0.5 rounded whitespace-nowrap flex-shrink-0",
effect.enabled
? "bg-primary/10 text-primary border border-primary/20"
: "bg-muted/30 text-muted-foreground border border-border/30 opacity-60"
)}
>
{effect.name}
</span>
))}
</div>
{/* Show/hide toggle - Positioned absolutely on the right */}
<Button
variant="ghost"
size="icon-sm"
onClick={() => {
onUpdateTrack(track.id, { effectsExpanded: !track.effectsExpanded });
}}
title={track.effectsExpanded ? 'Hide effects' : 'Show effects'}
className="absolute right-2 h-5 w-5 flex-shrink-0"
>
{track.effectsExpanded ? (
<Eye className="h-3 w-3" />
) : (
<EyeOff className="h-3 w-3 text-muted-foreground" />
)}
</Button>
</div>
{/* Effects Content - Collapsible, horizontally scrollable */}
{track.effectsExpanded && (
<div className="h-48 overflow-x-auto custom-scrollbar bg-muted/70 border-t border-border">
<div className="flex h-full gap-3 p-3">
{track.effectChain.effects.length === 0 ? (
<div className="text-xs text-muted-foreground text-center py-8 w-full">
No effects. Click + to add an effect.
</div>
) : (
track.effectChain.effects.map((effect) => (
<EffectDevice
key={effect.id}
effect={effect}
onToggleEnabled={() => {
const updatedChain = {
...track.effectChain,
effects: track.effectChain.effects.map((e) =>
e.id === effect.id ? { ...e, enabled: !e.enabled } : e
),
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onRemove={() => {
const updatedChain = {
...track.effectChain,
effects: track.effectChain.effects.filter((e) => e.id !== effect.id),
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onUpdateParameters={(params) => {
const updatedChain = {
...track.effectChain,
effects: track.effectChain.effects.map((e) =>
e.id === effect.id ? { ...e, parameters: params } : e
),
};
onUpdateTrack(track.id, { effectChain: updatedChain });
}}
onToggleExpanded={() => {
const updatedEffects = track.effectChain.effects.map((e) =>
e.id === effect.id ? { ...e, expanded: !e.expanded } : e
);
onUpdateTrack(track.id, {
effectChain: { ...track.effectChain, effects: updatedEffects },
});
}}
/>
))
)}
</div>
</div>
)}
</div>
)}
</div>
</React.Fragment>
))}
</div>
</div>
</div>
{/* Import Dialog */}

View File

@@ -34,6 +34,8 @@ export interface Track {
collapsed: boolean;
selected: boolean;
showEffects: boolean; // Show/hide per-track effects panel
effectsExpanded?: boolean; // Whether effects bar is expanded (when showEffects is true)
automationExpanded?: boolean; // Whether automation bar is expanded (shows full controls)
// Selection (for editing operations)
selection: Selection | null;
@@ -68,7 +70,7 @@ export const TRACK_COLORS: Record<TrackColor, string> = {
gray: 'rgb(156, 163, 175)',
};
export const DEFAULT_TRACK_HEIGHT = 340; // Knob + fader with labels + R/S/M/A/E buttons
export const MIN_TRACK_HEIGHT = 240; // Minimum to fit knob + fader with labels + all buttons
export const DEFAULT_TRACK_HEIGHT = 400; // Knob + fader with labels + R/S/M/A/E buttons
export const MIN_TRACK_HEIGHT = 400; // Minimum to fit knob + fader with labels + all buttons
export const MAX_TRACK_HEIGHT = 500; // Increased for better waveform viewing
export const COLLAPSED_TRACK_HEIGHT = 48; // Extracted constant for collapsed state