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:
Jose Luis
2026-03-21 01:02:41 +01:00
commit 95054a70df
23 changed files with 3770 additions and 0 deletions

82
src/components/Knob.jsx Normal file
View 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>
);
}