feat: initial Reaktor modular synth app
React + Tone.js modular synthesizer with visual node editor. Includes: Oscillator, Filter, Envelope, LFO, VCA, Delay, Reverb, Distortion, Mixer, Scope, Output, and Keyboard modules. SVG wire connections, knob controls, preset save/load system. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
82
src/components/Knob.jsx
Normal file
82
src/components/Knob.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React, { useRef, useCallback } from 'react';
|
||||
|
||||
const SIZE = 32;
|
||||
const RADIUS = 12;
|
||||
const STROKE = 3;
|
||||
const START_ANGLE = 225;
|
||||
const END_ANGLE = -45;
|
||||
const RANGE = 270; // degrees
|
||||
|
||||
function polarToCart(cx, cy, r, deg) {
|
||||
const rad = (deg - 90) * Math.PI / 180;
|
||||
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
|
||||
}
|
||||
|
||||
function describeArc(cx, cy, r, startDeg, endDeg) {
|
||||
const start = polarToCart(cx, cy, r, endDeg);
|
||||
const end = polarToCart(cx, cy, r, startDeg);
|
||||
const large = endDeg - startDeg <= 180 ? '0' : '1';
|
||||
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${large} 0 ${end.x} ${end.y}`;
|
||||
}
|
||||
|
||||
export default function Knob({ value, min, max, onChange, color = 'var(--accent)', label, unit, formatValue }) {
|
||||
const ref = useRef(null);
|
||||
const dragRef = useRef(null);
|
||||
|
||||
const norm = Math.max(0, Math.min(1, (value - min) / (max - min)));
|
||||
const angleDeg = START_ANGLE - norm * RANGE;
|
||||
|
||||
const cx = SIZE / 2, cy = SIZE / 2;
|
||||
const trackPath = describeArc(cx, cy, RADIUS, END_ANGLE, START_ANGLE);
|
||||
const fillAngle = START_ANGLE - norm * RANGE;
|
||||
const fillPath = norm > 0.001 ? describeArc(cx, cy, RADIUS, fillAngle, START_ANGLE) : '';
|
||||
|
||||
const dotPos = polarToCart(cx, cy, RADIUS - 4, angleDeg);
|
||||
|
||||
const displayVal = formatValue ? formatValue(value) :
|
||||
value >= 1000 ? `${(value / 1000).toFixed(1)}k` :
|
||||
value >= 100 ? Math.round(value) :
|
||||
value >= 1 ? value.toFixed(1) :
|
||||
value.toFixed(3).replace(/0+$/, '').replace(/\.$/, '');
|
||||
|
||||
const handlePointerDown = useCallback((e) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
dragRef.current = { startY: e.clientY, startValue: value };
|
||||
const handleMove = (me) => {
|
||||
const dy = dragRef.current.startY - me.clientY;
|
||||
const sensitivity = (max - min) / 200;
|
||||
let newVal = dragRef.current.startValue + dy * sensitivity;
|
||||
newVal = Math.max(min, Math.min(max, newVal));
|
||||
// Snap to nice values for integer ranges
|
||||
if (max - min > 10 && Number.isInteger(min) && Number.isInteger(max)) {
|
||||
newVal = Math.round(newVal);
|
||||
}
|
||||
onChange(newVal);
|
||||
};
|
||||
const handleUp = () => {
|
||||
window.removeEventListener('pointermove', handleMove);
|
||||
window.removeEventListener('pointerup', handleUp);
|
||||
dragRef.current = null;
|
||||
};
|
||||
window.addEventListener('pointermove', handleMove);
|
||||
window.addEventListener('pointerup', handleUp);
|
||||
}, [value, min, max, onChange]);
|
||||
|
||||
const handleWheel = useCallback((e) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const step = (max - min) / 100;
|
||||
const newVal = Math.max(min, Math.min(max, value - Math.sign(e.deltaY) * step));
|
||||
onChange(newVal);
|
||||
}, [value, min, max, onChange]);
|
||||
|
||||
return (
|
||||
<div className="knob-container" onWheel={handleWheel}>
|
||||
<svg className="knob-svg" viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||
onPointerDown={handlePointerDown} ref={ref}>
|
||||
<path className="knob-track" d={trackPath} />
|
||||
{fillPath && <path className="knob-fill" d={fillPath} style={{ stroke: color }} />}
|
||||
<circle className="knob-dot" cx={dotPos.x} cy={dotPos.y} r={2} />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user