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:
150
src/components/ModuleNode.jsx
Normal file
150
src/components/ModuleNode.jsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import { getModuleDef } from '../engine/moduleRegistry.js';
|
||||
import { state, removeModule, updateModuleParam, updateModulePosition, isPortConnected, emit } from '../engine/state.js';
|
||||
import { updateParam } from '../engine/audioEngine.js';
|
||||
import Knob from './Knob.jsx';
|
||||
import ScopeDisplay from './ScopeDisplay.jsx';
|
||||
import KeyboardWidget from './KeyboardWidget.jsx';
|
||||
|
||||
export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }) {
|
||||
const def = getModuleDef(mod.type);
|
||||
if (!def) return null;
|
||||
|
||||
const isSelected = state.selectedModuleId === mod.id;
|
||||
|
||||
// Merge default params
|
||||
const params = { ...Object.fromEntries(Object.entries(def.params).map(([k, v]) => [k, v.default])), ...mod.params };
|
||||
|
||||
const handleParamChange = useCallback((name, value) => {
|
||||
updateModuleParam(mod.id, name, value);
|
||||
updateParam(mod.id, name, value);
|
||||
}, [mod.id]);
|
||||
|
||||
const handleHeaderDown = useCallback((e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.stopPropagation();
|
||||
state.selectedModuleId = mod.id;
|
||||
state.dragging = {
|
||||
moduleId: mod.id,
|
||||
offsetX: e.clientX / zoom - mod.x,
|
||||
offsetY: e.clientY / zoom - mod.y,
|
||||
};
|
||||
emit();
|
||||
}, [mod, zoom]);
|
||||
|
||||
const handleDelete = useCallback((e) => {
|
||||
e.stopPropagation();
|
||||
removeModule(mod.id);
|
||||
}, [mod.id]);
|
||||
|
||||
const handlePortMouseDown = useCallback((e, portName, direction) => {
|
||||
e.stopPropagation(); e.preventDefault();
|
||||
const portDef = direction === 'output'
|
||||
? def.outputs.find(p => p.name === portName)
|
||||
: def.inputs.find(p => p.name === portName);
|
||||
if (!portDef) return;
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
onStartConnect({
|
||||
moduleId: mod.id,
|
||||
port: portName,
|
||||
portType: portDef.type,
|
||||
direction,
|
||||
startX: rect.left + rect.width / 2,
|
||||
startY: rect.top + rect.height / 2,
|
||||
});
|
||||
}, [mod.id, def, onStartConnect]);
|
||||
|
||||
// Report port positions for wire rendering
|
||||
const portRef = useCallback((el, portName, direction) => {
|
||||
if (el) {
|
||||
onPortPosition(mod.id, portName, direction, el);
|
||||
}
|
||||
}, [mod.id, onPortPosition]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`module ${isSelected ? 'selected' : ''}`}
|
||||
style={{ left: mod.x * zoom, top: mod.y * zoom, transform: `scale(${zoom})`, transformOrigin: 'top left' }}
|
||||
onPointerDown={() => { state.selectedModuleId = mod.id; emit(); }}
|
||||
>
|
||||
<div className="module-header" onPointerDown={handleHeaderDown}>
|
||||
<span className="type-icon">{def.icon}</span>
|
||||
<span className="type-name">{def.name}</span>
|
||||
<button className="close-btn" onClick={handleDelete}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="module-body">
|
||||
{/* Input ports */}
|
||||
{def.inputs.map(port => (
|
||||
<div key={port.name} className="port-row input">
|
||||
<div
|
||||
className={`port-dot ${port.type} ${isPortConnected(mod.id, port.name, 'input') ? 'connected' : ''}`}
|
||||
ref={el => portRef(el, port.name, 'input')}
|
||||
onPointerDown={e => handlePortMouseDown(e, port.name, 'input')}
|
||||
/>
|
||||
<span className="port-label">{port.label}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Parameters */}
|
||||
{Object.entries(def.params).map(([name, paramDef]) => {
|
||||
if (paramDef.type === 'knob') {
|
||||
const color = paramDef.unit === 'Hz' ? 'var(--accent)' :
|
||||
paramDef.unit === 'dB' ? 'var(--green)' :
|
||||
paramDef.unit === 's' ? 'var(--purple)' : 'var(--accent)';
|
||||
return (
|
||||
<div key={name} className="param-row">
|
||||
<span className="param-label">{paramDef.label}</span>
|
||||
<Knob
|
||||
value={params[name]}
|
||||
min={paramDef.min}
|
||||
max={paramDef.max}
|
||||
onChange={v => handleParamChange(name, v)}
|
||||
color={color}
|
||||
/>
|
||||
<span className="param-value">
|
||||
{params[name] >= 1000 ? `${(params[name] / 1000).toFixed(1)}k` :
|
||||
params[name] >= 100 ? Math.round(params[name]) :
|
||||
params[name] >= 1 ? Number(params[name]).toFixed(1) :
|
||||
Number(params[name]).toFixed(3).replace(/0+$/, '').replace(/\.$/, '')}
|
||||
{paramDef.unit ? ` ${paramDef.unit}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (paramDef.type === 'select') {
|
||||
return (
|
||||
<div key={name} className="param-row">
|
||||
<span className="param-label">{paramDef.label}</span>
|
||||
<select className="param-select" value={params[name]}
|
||||
onChange={e => handleParamChange(name, e.target.value)}>
|
||||
{paramDef.options.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
|
||||
{/* Scope display */}
|
||||
{mod.type === 'scope' && <ScopeDisplay moduleId={mod.id} />}
|
||||
|
||||
{/* Keyboard widget */}
|
||||
{mod.type === 'keyboard' && <KeyboardWidget moduleId={mod.id} />}
|
||||
|
||||
{/* Output ports */}
|
||||
{def.outputs.map(port => (
|
||||
<div key={port.name} className="port-row output">
|
||||
<div
|
||||
className={`port-dot ${port.type} ${isPortConnected(mod.id, port.name, 'output') ? 'connected' : ''}`}
|
||||
ref={el => portRef(el, port.name, 'output')}
|
||||
onPointerDown={e => handlePortMouseDown(e, port.name, 'output')}
|
||||
/>
|
||||
<span className="port-label">{port.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user