Implemented comprehensive automation recording system for volume, pan, and effect parameters: - Added automation recording modes: - Write: Records continuously during playback when values change - Touch: Records only while control is being touched/moved - Latch: Records from first touch until playback stops - Implemented value change detection (0.001 threshold) to prevent infinite loops - Fixed React setState-in-render errors by: - Using queueMicrotask() to defer state updates - Moving lane creation logic to useEffect - Properly memoizing touch handlers with useMemo - Added proper value ranges for effect parameters: - Frequency: 20-20000 Hz - Q: 0.1-20 - Gain: -40-40 dB - Enhanced automation lane auto-creation with parameter-specific ranges - Added touch callbacks to all parameter controls (volume, pan, effects) - Implemented throttling (100ms) to avoid excessive automation points Technical improvements: - Used tracksRef and onRecordAutomationRef to ensure latest values in animation loops - Added proper cleanup on playback stop - Optimized recording to only trigger when values actually change 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
185 lines
4.9 KiB
TypeScript
185 lines
4.9 KiB
TypeScript
'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;
|
|
onTouchStart?: () => void;
|
|
onTouchEnd?: () => void;
|
|
}
|
|
|
|
export function CircularKnob({
|
|
value,
|
|
onChange,
|
|
min = -1,
|
|
max = 1,
|
|
step = 0.01,
|
|
size = 48,
|
|
className,
|
|
label,
|
|
formatValue,
|
|
onTouchStart,
|
|
onTouchEnd,
|
|
}: CircularKnobProps) {
|
|
const knobRef = React.useRef<HTMLDivElement>(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,
|
|
};
|
|
onTouchStart?.();
|
|
},
|
|
[value, onTouchStart]
|
|
);
|
|
|
|
const handleMouseMove = React.useCallback(
|
|
(e: MouseEvent) => {
|
|
if (isDragging) {
|
|
updateValue(e.clientX, e.clientY);
|
|
}
|
|
},
|
|
[isDragging, updateValue]
|
|
);
|
|
|
|
const handleMouseUp = React.useCallback(() => {
|
|
setIsDragging(false);
|
|
onTouchEnd?.();
|
|
}, [onTouchEnd]);
|
|
|
|
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 (
|
|
<div className={cn('flex flex-col items-center gap-1', className)}>
|
|
{label && (
|
|
<div className="text-[10px] text-muted-foreground uppercase tracking-wide">
|
|
{label}
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
ref={knobRef}
|
|
onMouseDown={handleMouseDown}
|
|
className="relative cursor-pointer select-none"
|
|
style={{ width: size, height: size }}
|
|
>
|
|
{/* Outer ring */}
|
|
<svg
|
|
width={size}
|
|
height={size}
|
|
viewBox={`0 0 ${size} ${size}`}
|
|
className="absolute inset-0"
|
|
>
|
|
{/* Background arc */}
|
|
<circle
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r={size / 2 - 4}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
className="text-muted/30"
|
|
/>
|
|
|
|
{/* Value arc */}
|
|
<circle
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r={size / 2 - 4}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="3"
|
|
strokeLinecap="round"
|
|
className="text-primary"
|
|
strokeDasharray={`${(percentage * 270 * Math.PI * (size / 2 - 4)) / 180} ${(Math.PI * 2 * (size / 2 - 4))}`}
|
|
transform={`rotate(-225 ${size / 2} ${size / 2})`}
|
|
/>
|
|
</svg>
|
|
|
|
{/* Knob body */}
|
|
<div
|
|
className="absolute inset-0 rounded-full bg-card border-2 border-border shadow-sm flex items-center justify-center transition-transform hover:scale-105 active:scale-95"
|
|
style={{
|
|
transform: `rotate(${angle}deg)`,
|
|
margin: '4px',
|
|
}}
|
|
>
|
|
{/* Indicator line */}
|
|
<div className="absolute top-1 left-1/2 w-0.5 h-2 bg-primary rounded-full -translate-x-1/2" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Value Display */}
|
|
<div className="text-[10px] font-medium text-foreground min-w-[32px] text-center">
|
|
{displayValue}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|