refactor: single automation lane with parameter dropdown and fixed-height effects panel
Phase 9 Automation Improvements: - Replaced multiple automation lanes with single lane + parameter selector - Added dropdown in automation header to switch between parameters: - Track parameters: Volume, Pan - Effect parameters: Dynamically generated from effect chain - Lanes are created on-demand when parameter is selected - Effects panel now has fixed height (280px) with scroll - Panel no longer resizes when effects are expanded - Maintains consistent UI layout Technical changes: - Track.automation.selectedParameterId: Tracks current parameter - AutomationHeader: Added parameter dropdown props - AutomationLane: Passes parameter selection to header - Track.tsx: Single lane rendering with IIFE for parameter list - Effects panel: h-[280px] with flex layout and overflow-y-auto 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,10 @@ export interface AutomationHeaderProps {
|
|||||||
onHeightChange?: (delta: number) => void;
|
onHeightChange?: (delta: number) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
formatter?: (value: number) => string;
|
formatter?: (value: number) => string;
|
||||||
|
// Parameter selection
|
||||||
|
availableParameters?: Array<{ id: string; name: string }>;
|
||||||
|
selectedParameterId?: string;
|
||||||
|
onParameterChange?: (parameterId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MODE_LABELS: Record<AutomationMode, string> = {
|
const MODE_LABELS: Record<AutomationMode, string> = {
|
||||||
@@ -44,6 +48,9 @@ export function AutomationHeader({
|
|||||||
onHeightChange,
|
onHeightChange,
|
||||||
className,
|
className,
|
||||||
formatter,
|
formatter,
|
||||||
|
availableParameters,
|
||||||
|
selectedParameterId,
|
||||||
|
onParameterChange,
|
||||||
}: AutomationHeaderProps) {
|
}: AutomationHeaderProps) {
|
||||||
const modes: AutomationMode[] = ['read', 'write', 'touch', 'latch'];
|
const modes: AutomationMode[] = ['read', 'write', 'touch', 'latch'];
|
||||||
const currentModeIndex = modes.indexOf(mode);
|
const currentModeIndex = modes.indexOf(mode);
|
||||||
@@ -74,10 +81,24 @@ export function AutomationHeader({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Parameter name */}
|
{/* Parameter name / selector */}
|
||||||
<span className="text-xs font-medium text-foreground flex-1 min-w-0 truncate">
|
{availableParameters && availableParameters.length > 1 ? (
|
||||||
{parameterName}
|
<select
|
||||||
</span>
|
value={selectedParameterId}
|
||||||
|
onChange={(e) => onParameterChange?.(e.target.value)}
|
||||||
|
className="text-xs font-medium text-foreground flex-1 min-w-0 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"
|
||||||
|
>
|
||||||
|
{availableParameters.map((param) => (
|
||||||
|
<option key={param.id} value={param.id}>
|
||||||
|
{param.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs font-medium text-foreground flex-1 min-w-0 truncate">
|
||||||
|
{parameterName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Current value display */}
|
{/* Current value display */}
|
||||||
{currentValue !== undefined && (
|
{currentValue !== undefined && (
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ export interface AutomationLaneProps {
|
|||||||
onUpdatePoint?: (pointId: string, updates: Partial<AutomationPointType>) => void;
|
onUpdatePoint?: (pointId: string, updates: Partial<AutomationPointType>) => void;
|
||||||
onRemovePoint?: (pointId: string) => void;
|
onRemovePoint?: (pointId: string) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
// Parameter selection
|
||||||
|
availableParameters?: Array<{ id: string; name: string }>;
|
||||||
|
selectedParameterId?: string;
|
||||||
|
onParameterChange?: (parameterId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AutomationLane({
|
export function AutomationLane({
|
||||||
@@ -28,6 +32,9 @@ export function AutomationLane({
|
|||||||
onUpdatePoint,
|
onUpdatePoint,
|
||||||
onRemovePoint,
|
onRemovePoint,
|
||||||
className,
|
className,
|
||||||
|
availableParameters,
|
||||||
|
selectedParameterId,
|
||||||
|
onParameterChange,
|
||||||
}: AutomationLaneProps) {
|
}: AutomationLaneProps) {
|
||||||
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
const canvasRef = React.useRef<HTMLCanvasElement>(null);
|
||||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||||
@@ -302,6 +309,9 @@ export function AutomationLane({
|
|||||||
onUpdateLane?.({ height: newHeight });
|
onUpdateLane?.({ height: newHeight });
|
||||||
}}
|
}}
|
||||||
formatter={lane.valueRange.formatter}
|
formatter={lane.valueRange.formatter}
|
||||||
|
availableParameters={availableParameters}
|
||||||
|
selectedParameterId={selectedParameterId}
|
||||||
|
onParameterChange={onParameterChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Lane canvas area */}
|
{/* Lane canvas area */}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { CircularKnob } from '@/components/ui/CircularKnob';
|
|||||||
import { AutomationLane } from '@/components/automation/AutomationLane';
|
import { AutomationLane } from '@/components/automation/AutomationLane';
|
||||||
import type { AutomationLane as AutomationLaneType, AutomationPoint as AutomationPointType } from '@/types/automation';
|
import type { AutomationLane as AutomationLaneType, AutomationPoint as AutomationPointType } from '@/types/automation';
|
||||||
import { createAutomationPoint } from '@/lib/audio/automation/utils';
|
import { createAutomationPoint } from '@/lib/audio/automation/utils';
|
||||||
|
import { createAutomationLane } from '@/lib/audio/automation-utils';
|
||||||
import { EffectDevice } from '@/components/effects/EffectDevice';
|
import { EffectDevice } from '@/components/effects/EffectDevice';
|
||||||
import { EffectBrowser } from '@/components/effects/EffectBrowser';
|
import { EffectBrowser } from '@/components/effects/EffectBrowser';
|
||||||
|
|
||||||
@@ -709,19 +710,79 @@ export function Track({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Automation Lanes */}
|
{/* Automation Lane */}
|
||||||
{!track.collapsed && track.automation?.showAutomation && (
|
{!track.collapsed && track.automation?.showAutomation && (() => {
|
||||||
<div className="bg-background/30">
|
// Build list of available parameters from track and effects
|
||||||
{track.automation.lanes.map((lane) => (
|
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 selected parameter ID (default to volume if not set)
|
||||||
|
const selectedParameterId = track.automation.selectedParameterId || 'volume';
|
||||||
|
|
||||||
|
// Find or create lane for selected parameter
|
||||||
|
let selectedLane = track.automation.lanes.find(lane => lane.parameterId === selectedParameterId);
|
||||||
|
|
||||||
|
// If lane doesn't exist yet, create it
|
||||||
|
if (!selectedLane) {
|
||||||
|
const paramInfo = availableParameters.find(p => p.id === selectedParameterId);
|
||||||
|
if (paramInfo) {
|
||||||
|
selectedLane = createAutomationLane(
|
||||||
|
track.id,
|
||||||
|
selectedParameterId,
|
||||||
|
paramInfo.name,
|
||||||
|
{
|
||||||
|
min: 0,
|
||||||
|
max: 1,
|
||||||
|
unit: selectedParameterId === 'volume' ? 'dB' : '',
|
||||||
|
formatter: selectedParameterId === 'pan' ? (value: number) => {
|
||||||
|
if (value === 0.5) return 'C';
|
||||||
|
if (value < 0.5) return `${Math.abs((0.5 - value) * 200).toFixed(0)}L`;
|
||||||
|
return `${((value - 0.5) * 200).toFixed(0)}R`;
|
||||||
|
} : undefined,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// Add the new lane to the track
|
||||||
|
onUpdateTrack(track.id, {
|
||||||
|
automation: {
|
||||||
|
...track.automation,
|
||||||
|
lanes: [...track.automation.lanes, selectedLane],
|
||||||
|
selectedParameterId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedLane ? (
|
||||||
|
<div className="bg-background/30">
|
||||||
<AutomationLane
|
<AutomationLane
|
||||||
key={lane.id}
|
key={selectedLane.id}
|
||||||
lane={lane}
|
lane={selectedLane}
|
||||||
duration={duration}
|
duration={duration}
|
||||||
zoom={zoom}
|
zoom={zoom}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
|
availableParameters={availableParameters}
|
||||||
|
selectedParameterId={selectedParameterId}
|
||||||
|
onParameterChange={(parameterId) => {
|
||||||
|
onUpdateTrack(track.id, {
|
||||||
|
automation: { ...track.automation, selectedParameterId: parameterId },
|
||||||
|
});
|
||||||
|
}}
|
||||||
onUpdateLane={(updates) => {
|
onUpdateLane={(updates) => {
|
||||||
const updatedLanes = track.automation.lanes.map((l) =>
|
const updatedLanes = track.automation.lanes.map((l) =>
|
||||||
l.id === lane.id ? { ...l, ...updates } : l
|
l.id === selectedLane.id ? { ...l, ...updates } : l
|
||||||
);
|
);
|
||||||
onUpdateTrack(track.id, {
|
onUpdateTrack(track.id, {
|
||||||
automation: { ...track.automation, lanes: updatedLanes },
|
automation: { ...track.automation, lanes: updatedLanes },
|
||||||
@@ -734,7 +795,7 @@ export function Track({
|
|||||||
curve: 'linear',
|
curve: 'linear',
|
||||||
});
|
});
|
||||||
const updatedLanes = track.automation.lanes.map((l) =>
|
const updatedLanes = track.automation.lanes.map((l) =>
|
||||||
l.id === lane.id
|
l.id === selectedLane.id
|
||||||
? { ...l, points: [...l.points, newPoint] }
|
? { ...l, points: [...l.points, newPoint] }
|
||||||
: l
|
: l
|
||||||
);
|
);
|
||||||
@@ -744,7 +805,7 @@ export function Track({
|
|||||||
}}
|
}}
|
||||||
onUpdatePoint={(pointId, updates) => {
|
onUpdatePoint={(pointId, updates) => {
|
||||||
const updatedLanes = track.automation.lanes.map((l) =>
|
const updatedLanes = track.automation.lanes.map((l) =>
|
||||||
l.id === lane.id
|
l.id === selectedLane.id
|
||||||
? {
|
? {
|
||||||
...l,
|
...l,
|
||||||
points: l.points.map((p) =>
|
points: l.points.map((p) =>
|
||||||
@@ -759,7 +820,7 @@ export function Track({
|
|||||||
}}
|
}}
|
||||||
onRemovePoint={(pointId) => {
|
onRemovePoint={(pointId) => {
|
||||||
const updatedLanes = track.automation.lanes.map((l) =>
|
const updatedLanes = track.automation.lanes.map((l) =>
|
||||||
l.id === lane.id
|
l.id === selectedLane.id
|
||||||
? { ...l, points: l.points.filter((p) => p.id !== pointId) }
|
? { ...l, points: l.points.filter((p) => p.id !== pointId) }
|
||||||
: l
|
: l
|
||||||
);
|
);
|
||||||
@@ -768,14 +829,14 @@ export function Track({
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
</div>
|
||||||
</div>
|
) : null;
|
||||||
)}
|
})()}
|
||||||
|
|
||||||
{/* Per-Track Effects Panel */}
|
{/* Per-Track Effects Panel */}
|
||||||
{!track.collapsed && track.showEffects && (
|
{!track.collapsed && track.showEffects && (
|
||||||
<div className="bg-background/30 border-t border-border/50 p-3">
|
<div className="bg-background/30 border-t border-border/50 p-3 h-[280px] flex flex-col">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2 flex-shrink-0">
|
||||||
<span className="text-xs font-medium text-foreground">
|
<span className="text-xs font-medium text-foreground">
|
||||||
{track.name} - Effects
|
{track.name} - Effects
|
||||||
</span>
|
</span>
|
||||||
@@ -791,11 +852,11 @@ export function Track({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{track.effectChain.effects.length === 0 ? (
|
{track.effectChain.effects.length === 0 ? (
|
||||||
<div className="text-center py-8 text-sm text-muted-foreground">
|
<div className="text-center py-8 text-sm text-muted-foreground flex-1 flex items-center justify-center">
|
||||||
No effects on this track
|
No effects on this track
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex gap-3 overflow-x-auto custom-scrollbar">
|
<div className="flex gap-3 overflow-x-auto overflow-y-auto custom-scrollbar flex-1">
|
||||||
{track.effectChain.effects.map((effect) => (
|
{track.effectChain.effects.map((effect) => (
|
||||||
<EffectDevice
|
<EffectDevice
|
||||||
key={effect.id}
|
key={effect.id}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export interface Track {
|
|||||||
automation: {
|
automation: {
|
||||||
lanes: AutomationLane[];
|
lanes: AutomationLane[];
|
||||||
showAutomation: boolean; // Master show/hide toggle
|
showAutomation: boolean; // Master show/hide toggle
|
||||||
|
selectedParameterId?: string; // Currently selected parameter to display
|
||||||
};
|
};
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
|
|||||||
Reference in New Issue
Block a user