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

131
src/engine/state.js Normal file
View File

@@ -0,0 +1,131 @@
/**
* state.js — Centralized reactive state for the modular synth
* Uses a simple pub/sub pattern for React integration
*/
let _listeners = new Set();
let _nextModuleId = 1;
let _nextConnectionId = 1;
export const state = {
modules: [], // { id, type, x, y, params, collapsed }
connections: [], // { id, from: {moduleId, port}, to: {moduleId, port} }
// Interaction
selectedModuleId: null,
dragging: null, // { moduleId, offsetX, offsetY }
connecting: null, // { moduleId, port, portType, direction, x, y } (temp wire)
// Camera
camX: 0, camY: 0, zoom: 1,
panning: false, panStart: null,
// Audio
isRunning: false,
masterVolume: -6,
// UI
showPalette: true,
presetModal: null, // null | 'save' | 'load'
};
export function subscribe(fn) {
_listeners.add(fn);
return () => _listeners.delete(fn);
}
export function emit() {
_listeners.forEach(fn => fn());
}
export function addModule(type, x, y) {
const id = _nextModuleId++;
state.modules.push({ id, type, x, y, params: {}, collapsed: false });
state.selectedModuleId = id;
emit();
return id;
}
export function removeModule(id) {
state.modules = state.modules.filter(m => m.id !== id);
state.connections = state.connections.filter(
c => c.from.moduleId !== id && c.to.moduleId !== id
);
if (state.selectedModuleId === id) state.selectedModuleId = null;
emit();
}
export function updateModulePosition(id, x, y) {
const m = state.modules.find(m => m.id === id);
if (m) { m.x = x; m.y = y; emit(); }
}
export function updateModuleParam(id, paramName, value) {
const m = state.modules.find(m => m.id === id);
if (m) { m.params[paramName] = value; emit(); }
}
export function addConnection(fromModuleId, fromPort, toModuleId, toPort) {
// Prevent duplicates
const exists = state.connections.find(c =>
c.from.moduleId === fromModuleId && c.from.port === fromPort &&
c.to.moduleId === toModuleId && c.to.port === toPort
);
if (exists) return null;
// Prevent connecting to already-connected input
const inputTaken = state.connections.find(c =>
c.to.moduleId === toModuleId && c.to.port === toPort
);
if (inputTaken) {
// Remove old connection to this input
removeConnection(inputTaken.id);
}
const id = _nextConnectionId++;
state.connections.push({ id, from: { moduleId: fromModuleId, port: fromPort }, to: { moduleId: toModuleId, port: toPort } });
emit();
return id;
}
export function removeConnection(id) {
state.connections = state.connections.filter(c => c.id !== id);
emit();
}
export function getModule(id) {
return state.modules.find(m => m.id === id) || null;
}
export function isPortConnected(moduleId, portName, direction) {
return state.connections.some(c =>
direction === 'output'
? (c.from.moduleId === moduleId && c.from.port === portName)
: (c.to.moduleId === moduleId && c.to.port === portName)
);
}
// Serialization
export function serialize() {
return {
modules: state.modules.map(m => ({ id: m.id, type: m.type, x: m.x, y: m.y, params: { ...m.params } })),
connections: state.connections.map(c => ({ ...c })),
camera: { camX: state.camX, camY: state.camY, zoom: state.zoom },
masterVolume: state.masterVolume,
};
}
export function deserialize(data) {
state.modules = data.modules || [];
state.connections = data.connections || [];
if (data.camera) {
state.camX = data.camera.camX || 0;
state.camY = data.camera.camY || 0;
state.zoom = data.camera.zoom || 1;
}
state.masterVolume = data.masterVolume ?? -6;
_nextModuleId = Math.max(1, ...state.modules.map(m => m.id)) + 1;
_nextConnectionId = Math.max(1, ...state.connections.map(c => c.id)) + 1;
state.selectedModuleId = null;
emit();
}