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:
2025-11-18 18:49:44 +01:00
parent d2709b8fc2
commit eb445bfa3a
4 changed files with 114 additions and 21 deletions

View File

@@ -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 && (

View File

@@ -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 */}

View File

@@ -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}

View File

@@ -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