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

321
src/App.jsx Normal file
View File

@@ -0,0 +1,321 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { state, subscribe, addModule, emit, addConnection, updateModulePosition } from './engine/state.js';
import { startAudio, stopAudio, connectWire, rebuildGraph, getAudioNode } from './engine/audioEngine.js';
import { getModuleDef, PORT_TYPE } from './engine/moduleRegistry.js';
import { autoSave, autoLoad, exportPatch, importPatch } from './engine/presets.js';
import ModuleNode from './components/ModuleNode.jsx';
import WireLayer from './components/WireLayer.jsx';
import ModulePalette from './components/ModulePalette.jsx';
import PresetModal from './components/PresetModal.jsx';
export default function App() {
const [, forceUpdate] = useState(0);
const containerRef = useRef(null);
const portPositions = useRef({});
const [tempWire, setTempWire] = useState(null);
const connectingRef = useRef(null);
const [presetModal, setPresetModal] = useState(null);
const importRef = useRef(null);
// Subscribe to state changes
useEffect(() => {
const unsub = subscribe(() => forceUpdate(n => n + 1));
return unsub;
}, []);
// Auto-load on mount
useEffect(() => {
const loaded = autoLoad();
if (loaded && state.isRunning) {
startAudio();
}
}, []);
// Auto-save interval
useEffect(() => {
const interval = setInterval(autoSave, 3000);
return () => clearInterval(interval);
}, []);
// Port position reporting
const handlePortPosition = useCallback((moduleId, portName, direction, el) => {
const key = `${moduleId}-${portName}-${direction}`;
portPositions.current[key] = el;
}, []);
// Start connecting a wire
const handleStartConnect = useCallback((info) => {
connectingRef.current = info;
const containerRect = containerRef.current.getBoundingClientRect();
setTempWire({
portType: info.portType,
startX: info.startX - containerRect.left,
startY: info.startY - containerRect.top,
endX: info.startX - containerRect.left,
endY: info.startY - containerRect.top,
});
}, []);
// Canvas pointer events
const handlePointerDown = useCallback((e) => {
if (e.button === 1 || (e.button === 0 && e.altKey)) {
// Middle click or Alt+click: start panning
state.panning = true;
state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
e.preventDefault();
} else if (e.button === 2) {
// Right click: pan
state.panning = true;
state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
e.preventDefault();
} else if (e.button === 0 && !connectingRef.current) {
// Left click on empty space: deselect
state.selectedModuleId = null;
emit();
}
}, []);
const handlePointerMove = useCallback((e) => {
// Panning
if (state.panning && state.panStart) {
state.camX = e.clientX - state.panStart.x;
state.camY = e.clientY - state.panStart.y;
emit();
return;
}
// Module dragging
if (state.dragging) {
const newX = e.clientX / state.zoom - state.dragging.offsetX;
const newY = e.clientY / state.zoom - state.dragging.offsetY;
updateModulePosition(state.dragging.moduleId, newX, newY);
return;
}
// Temp wire
if (connectingRef.current && containerRef.current) {
const containerRect = containerRef.current.getBoundingClientRect();
setTempWire(prev => prev ? {
...prev,
endX: e.clientX - containerRect.left,
endY: e.clientY - containerRect.top,
} : null);
}
}, []);
const handlePointerUp = useCallback((e) => {
if (state.panning) {
state.panning = false;
state.panStart = null;
}
if (state.dragging) {
state.dragging = null;
emit();
}
// End connecting — check if we're over a port
if (connectingRef.current) {
const target = document.elementFromPoint(e.clientX, e.clientY);
if (target && target.classList.contains('port-dot')) {
// Find which module/port this target belongs to
const moduleEl = target.closest('.module');
if (moduleEl) {
finishConnection(target, e);
}
}
connectingRef.current = null;
setTempWire(null);
}
}, []);
const finishConnection = (portEl, e) => {
const from = connectingRef.current;
if (!from) return;
// Find target module and port by inspecting DOM
// Walk up to .module, find moduleId, then find port
const moduleEl = portEl.closest('.module');
if (!moduleEl) return;
// Get all port-row elements to find index
const portRows = moduleEl.querySelectorAll('.port-row');
let targetModuleId = null;
let targetPort = null;
let targetDirection = null;
for (const mod of state.modules) {
const def = getModuleDef(mod.type);
if (!def) continue;
const modX = mod.x * state.zoom;
const modY = mod.y * state.zoom;
const containerRect = containerRef.current.getBoundingClientRect();
const moduleRect = moduleEl.getBoundingClientRect();
// Check if this module element matches
const expectedLeft = modX + state.camX + containerRect.left;
if (Math.abs(moduleRect.left - expectedLeft) > 5) continue;
targetModuleId = mod.id;
// Find which port-dot was clicked
const allDots = moduleEl.querySelectorAll('.port-dot');
const allInputs = def.inputs.map(p => p.name);
const allOutputs = def.outputs.map(p => p.name);
allDots.forEach((dot, idx) => {
if (dot === portEl) {
if (idx < allInputs.length) {
targetPort = allInputs[idx];
targetDirection = 'input';
} else {
targetPort = allOutputs[idx - allInputs.length];
targetDirection = 'output';
}
}
});
break;
}
if (!targetModuleId || !targetPort) return;
if (targetModuleId === from.moduleId) return; // No self-connections
// Determine from/to based on direction
let fromMod, fromPort, toMod, toPort;
if (from.direction === 'output' && targetDirection === 'input') {
fromMod = from.moduleId; fromPort = from.port;
toMod = targetModuleId; toPort = targetPort;
} else if (from.direction === 'input' && targetDirection === 'output') {
fromMod = targetModuleId; fromPort = targetPort;
toMod = from.moduleId; toPort = from.port;
} else {
return; // Invalid: same direction
}
const connId = addConnection(fromMod, fromPort, toMod, toPort);
if (connId && state.isRunning) {
const conn = state.connections.find(c => c.id === connId);
if (conn) connectWire(conn);
}
};
const handleWheel = useCallback((e) => {
e.preventDefault();
const delta = -e.deltaY * 0.001;
const newZoom = Math.max(0.3, Math.min(3, state.zoom + delta));
state.zoom = newZoom;
emit();
}, []);
const handleContextMenu = useCallback((e) => e.preventDefault(), []);
// Toolbar actions
const handleToggleAudio = async () => {
if (state.isRunning) {
stopAudio();
} else {
await startAudio();
}
emit();
};
const handleAddModule = (type) => {
const x = (-state.camX + 300) / state.zoom + Math.random() * 50;
const y = (-state.camY + 200) / state.zoom + Math.random() * 50;
const id = addModule(type, x, y);
if (state.isRunning) {
rebuildGraph();
}
};
const handleImport = async (e) => {
const file = e.target.files[0];
if (!file) return;
await importPatch(file);
emit();
e.target.value = '';
};
return (
<div className="app">
{/* Toolbar */}
<div className="toolbar">
<span className="toolbar-title">Reaktor</span>
<div className="toolbar-sep" />
<button className={`toolbar-btn ${state.isRunning ? 'active' : ''}`} onClick={handleToggleAudio}>
{state.isRunning ? '⏹ Stop' : '▶ Start'}
</button>
<div className="toolbar-sep" />
<div className="toolbar-group">
<button className="toolbar-btn" onClick={() => setPresetModal('save')}>💾 Save</button>
<button className="toolbar-btn" onClick={() => setPresetModal('load')}>📂 Load</button>
<button className="toolbar-btn" onClick={exportPatch}>📤 Export</button>
<button className="toolbar-btn" onClick={() => importRef.current?.click()}>📥 Import</button>
<input ref={importRef} type="file" accept=".json" style={{ display: 'none' }} onChange={handleImport} />
</div>
<div className="toolbar-sep" />
<span className="toolbar-label" style={{ color: state.isRunning ? 'var(--green)' : 'var(--text2)' }}>
{state.isRunning ? '● LIVE' : '○ OFF'}
</span>
<span className="toolbar-label" style={{ marginLeft: 'auto' }}>
{state.modules.length} modules · {state.connections.length} wires
</span>
</div>
{/* Main canvas area */}
<div className="main-area">
<div
ref={containerRef}
className={`node-canvas ${state.panning ? 'grabbing' : ''} ${connectingRef.current ? 'connecting' : ''}`}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onWheel={handleWheel}
onContextMenu={handleContextMenu}
>
{/* Grid background */}
<svg style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', zIndex: 0 }}>
<defs>
<pattern id="grid" width={20 * state.zoom} height={20 * state.zoom}
patternUnits="userSpaceOnUse"
x={state.camX % (20 * state.zoom)} y={state.camY % (20 * state.zoom)}>
<circle cx={1} cy={1} r={0.8} fill="#1a1a30" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)" />
</svg>
{/* Modules container (offset by camera) */}
<div style={{ position: 'absolute', left: state.camX, top: state.camY, zIndex: 2 }}>
{state.modules.map(mod => (
<ModuleNode
key={mod.id}
mod={mod}
zoom={state.zoom}
onStartConnect={handleStartConnect}
onPortPosition={handlePortPosition}
/>
))}
</div>
{/* Wire layer */}
<WireLayer portPositions={portPositions} tempWire={tempWire} containerRef={containerRef} />
</div>
{/* Module palette */}
<ModulePalette onAddModule={handleAddModule} />
</div>
{/* Status bar */}
<div className="status-bar">
<span className="status-accent">Reaktor MontLab Modular Synth</span>
<span>Zoom: {(state.zoom * 100).toFixed(0)}%</span>
<span>Scroll: pan · Wheel: zoom · Click port + drag: wire · Click wire: delete</span>
</div>
{/* Preset modal */}
{presetModal && <PresetModal mode={presetModal} onClose={() => setPresetModal(null)} />}
</div>
);
}