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

343
src/engine/audioEngine.js Normal file
View File

@@ -0,0 +1,343 @@
/**
* audioEngine.js — Bridge between node graph state and Tone.js audio graph
* Creates, connects, and destroys Tone.js nodes as the user edits the patch
*/
import * as Tone from 'tone';
import { state } from './state.js';
import { getModuleDef } from './moduleRegistry.js';
// Map moduleId → { node: Tone.js node, inputs: {portName: node/param}, outputs: {portName: node} }
const audioNodes = {};
// Active keyboard state
const keyboardState = { frequency: 440, gate: false };
// ==================== Node creation ====================
function createNode(mod) {
const def = getModuleDef(mod.type);
if (!def) return null;
const p = { ...Object.fromEntries(Object.entries(def.params).map(([k, v]) => [k, v.default])), ...mod.params };
switch (mod.type) {
case 'oscillator': {
const osc = new Tone.Oscillator({ type: p.waveform, frequency: p.frequency, detune: p.detune });
osc.start();
return {
node: osc,
inputs: { freq: osc.frequency, detune: osc.detune },
outputs: { out: osc },
dispose: () => { osc.stop(); osc.dispose(); },
};
}
case 'lfo': {
const lfo = new Tone.LFO({ type: p.waveform, frequency: p.frequency, amplitude: p.amplitude, min: -1, max: 1 });
lfo.start();
return {
node: lfo,
inputs: {},
outputs: { out: lfo },
dispose: () => { lfo.stop(); lfo.dispose(); },
};
}
case 'noise': {
const noise = new Tone.Noise(p.type);
noise.start();
return {
node: noise,
inputs: {},
outputs: { out: noise },
dispose: () => { noise.stop(); noise.dispose(); },
};
}
case 'filter': {
const filter = new Tone.Filter({ type: p.type, frequency: p.frequency, Q: p.Q });
return {
node: filter,
inputs: { in: filter, cutoff: filter.frequency },
outputs: { out: filter },
dispose: () => filter.dispose(),
};
}
case 'envelope': {
const env = new Tone.Envelope({ attack: p.attack, decay: p.decay, sustain: p.sustain, release: p.release });
// Connect env to a signal so it can be used as modulation source
const sig = new Tone.Signal(0);
env.connect(sig);
return {
node: env,
_sig: sig,
inputs: { gate: null }, // Gate is handled via triggerAttack/Release
outputs: { out: sig },
dispose: () => { env.dispose(); sig.dispose(); },
};
}
case 'vca': {
// Use a Multiply node: in × cv
const gain = new Tone.Gain(p.gain);
return {
node: gain,
inputs: { in: gain, cv: gain.gain },
outputs: { out: gain },
dispose: () => gain.dispose(),
};
}
case 'delay': {
const delay = new Tone.FeedbackDelay({ delayTime: p.delayTime, feedback: p.feedback, wet: p.wet });
return {
node: delay,
inputs: { in: delay },
outputs: { out: delay },
dispose: () => delay.dispose(),
};
}
case 'reverb': {
const reverb = new Tone.Reverb({ decay: p.decay, wet: p.wet });
return {
node: reverb,
inputs: { in: reverb },
outputs: { out: reverb },
dispose: () => reverb.dispose(),
};
}
case 'distortion': {
const dist = new Tone.Distortion({ distortion: p.distortion, wet: p.wet });
return {
node: dist,
inputs: { in: dist },
outputs: { out: dist },
dispose: () => dist.dispose(),
};
}
case 'mixer': {
const master = new Tone.Gain(1);
const ch1 = new Tone.Gain(p.gain1);
const ch2 = new Tone.Gain(p.gain2);
const ch3 = new Tone.Gain(p.gain3);
const ch4 = new Tone.Gain(p.gain4);
ch1.connect(master); ch2.connect(master); ch3.connect(master); ch4.connect(master);
return {
node: master,
_channels: [ch1, ch2, ch3, ch4],
inputs: { in1: ch1, in2: ch2, in3: ch3, in4: ch4 },
outputs: { out: master },
dispose: () => { [ch1, ch2, ch3, ch4, master].forEach(n => n.dispose()); },
};
}
case 'scope': {
const analyser = new Tone.Analyser('waveform', 256);
return {
node: analyser,
inputs: { in: analyser },
outputs: {},
analyser,
dispose: () => analyser.dispose(),
};
}
case 'output': {
const gain = new Tone.Gain(Tone.dbToGain(p.volume));
gain.toDestination();
return {
node: gain,
inputs: { left: gain, right: gain },
outputs: {},
dispose: () => { gain.disconnect(); gain.dispose(); },
};
}
case 'keyboard': {
// Keyboard outputs frequency as a Signal and gate as a Signal
const freqSig = new Tone.Signal(440);
const gateSig = new Tone.Signal(0);
return {
node: null,
inputs: {},
outputs: { freq: freqSig, gate: gateSig },
_freqSig: freqSig,
_gateSig: gateSig,
dispose: () => { freqSig.dispose(); gateSig.dispose(); },
};
}
default:
return null;
}
}
// ==================== Public API ====================
export function ensureNode(moduleId) {
if (audioNodes[moduleId]) return audioNodes[moduleId];
const mod = state.modules.find(m => m.id === moduleId);
if (!mod) return null;
const node = createNode(mod);
if (node) audioNodes[moduleId] = node;
return node;
}
export function getAudioNode(moduleId) {
return audioNodes[moduleId] || null;
}
export function destroyNode(moduleId) {
const entry = audioNodes[moduleId];
if (!entry) return;
try { entry.dispose(); } catch (e) { console.warn('dispose error', e); }
delete audioNodes[moduleId];
}
export function connectWire(conn) {
const fromEntry = ensureNode(conn.from.moduleId);
const toEntry = ensureNode(conn.to.moduleId);
if (!fromEntry || !toEntry) return;
const output = fromEntry.outputs[conn.from.port];
const input = toEntry.inputs[conn.to.port];
if (!output || input === undefined || input === null) return;
try {
if (typeof output.connect === 'function') {
output.connect(input);
}
} catch (e) {
console.warn('connect error', e);
}
}
export function disconnectWire(conn) {
const fromEntry = audioNodes[conn.from.moduleId];
const toEntry = audioNodes[conn.to.moduleId];
if (!fromEntry || !toEntry) return;
const output = fromEntry.outputs[conn.from.port];
const input = toEntry.inputs[conn.to.port];
if (!output || !input) return;
try {
if (typeof output.disconnect === 'function') {
output.disconnect(input);
}
} catch (e) {
// Tone.js may throw if not connected
}
}
export function updateParam(moduleId, paramName, value) {
const entry = audioNodes[moduleId];
const mod = state.modules.find(m => m.id === moduleId);
if (!entry || !mod) return;
const def = getModuleDef(mod.type);
if (!def) return;
switch (mod.type) {
case 'oscillator':
if (paramName === 'waveform') entry.node.type = value;
else if (paramName === 'frequency') entry.node.frequency.value = value;
else if (paramName === 'detune') entry.node.detune.value = value;
break;
case 'lfo':
if (paramName === 'waveform') entry.node.type = value;
else if (paramName === 'frequency') entry.node.frequency.value = value;
else if (paramName === 'amplitude') entry.node.amplitude.value = value;
break;
case 'noise':
if (paramName === 'type') entry.node.type = value;
break;
case 'filter':
if (paramName === 'type') entry.node.type = value;
else if (paramName === 'frequency') entry.node.frequency.value = value;
else if (paramName === 'Q') entry.node.Q.value = value;
break;
case 'envelope':
if (paramName === 'attack') entry.node.attack = value;
else if (paramName === 'decay') entry.node.decay = value;
else if (paramName === 'sustain') entry.node.sustain = value;
else if (paramName === 'release') entry.node.release = value;
break;
case 'vca':
if (paramName === 'gain') entry.node.gain.value = value;
break;
case 'delay':
if (paramName === 'delayTime') entry.node.delayTime.value = value;
else if (paramName === 'feedback') entry.node.feedback.value = value;
else if (paramName === 'wet') entry.node.wet.value = value;
break;
case 'reverb':
if (paramName === 'decay') entry.node.decay = value;
else if (paramName === 'wet') entry.node.wet.value = value;
break;
case 'distortion':
if (paramName === 'distortion') entry.node.distortion = value;
else if (paramName === 'wet') entry.node.wet.value = value;
break;
case 'mixer':
if (paramName.startsWith('gain')) {
const idx = parseInt(paramName.replace('gain', '')) - 1;
if (entry._channels && entry._channels[idx]) entry._channels[idx].gain.value = value;
}
break;
case 'output':
if (paramName === 'volume') entry.node.gain.value = Tone.dbToGain(value);
break;
case 'keyboard':
if (paramName === 'octave') { /* stored in state only */ }
break;
}
}
export function triggerKeyboard(moduleId, freq, gate) {
const entry = audioNodes[moduleId];
if (!entry) return;
if (entry._freqSig) entry._freqSig.value = freq;
if (entry._gateSig) entry._gateSig.value = gate ? 1 : 0;
// Also trigger any connected envelopes
for (const conn of state.connections) {
if (conn.from.moduleId === moduleId && conn.from.port === 'gate') {
const envEntry = audioNodes[conn.to.moduleId];
if (envEntry && envEntry.node instanceof Tone.Envelope) {
if (gate) envEntry.node.triggerAttack();
else envEntry.node.triggerRelease();
}
}
}
}
export async function startAudio() {
await Tone.start();
state.isRunning = true;
// Rebuild entire audio graph
rebuildGraph();
}
export function stopAudio() {
// Destroy all nodes
for (const id of Object.keys(audioNodes)) {
destroyNode(parseInt(id));
}
state.isRunning = false;
}
export function rebuildGraph() {
// Destroy all existing nodes
for (const id of Object.keys(audioNodes)) {
destroyNode(parseInt(id));
}
// Create nodes for all modules
for (const mod of state.modules) {
ensureNode(mod.id);
}
// Create all connections
for (const conn of state.connections) {
connectWire(conn);
}
}
export function getAnalyserData(moduleId) {
const entry = audioNodes[moduleId];
if (!entry || !entry.analyser) return null;
return entry.analyser.getValue();
}

View File

@@ -0,0 +1,259 @@
/**
* moduleRegistry.js — Defines all available module types
* Each module type specifies: ports, params, icon, category, and audio factory
*/
export const PORT_TYPE = {
AUDIO: 'audio',
CONTROL: 'control',
TRIGGER: 'trigger',
};
// Module type definitions
const registry = {};
export function defineModule(type, def) {
registry[type] = { type, ...def };
}
export function getModuleDef(type) {
return registry[type] || null;
}
export function getAllModuleDefs() {
return Object.values(registry);
}
export function getModulesByCategory() {
const cats = {};
for (const def of Object.values(registry)) {
if (!cats[def.category]) cats[def.category] = [];
cats[def.category].push(def);
}
return cats;
}
// ==================== SOURCE ====================
defineModule('oscillator', {
name: 'Oscillator',
icon: '~',
category: 'Source',
inputs: [
{ name: 'freq', type: PORT_TYPE.AUDIO, label: 'Freq' },
{ name: 'detune', type: PORT_TYPE.CONTROL, label: 'Detune' },
],
outputs: [
{ name: 'out', type: PORT_TYPE.AUDIO, label: 'Out' },
],
params: {
waveform: { type: 'select', options: ['sine', 'square', 'sawtooth', 'triangle'], default: 'sawtooth', label: 'Wave' },
frequency: { type: 'knob', min: 20, max: 8000, default: 440, unit: 'Hz', label: 'Freq' },
detune: { type: 'knob', min: -1200, max: 1200, default: 0, unit: 'ct', label: 'Detune' },
},
});
defineModule('lfo', {
name: 'LFO',
icon: '∿',
category: 'Source',
inputs: [],
outputs: [
{ name: 'out', type: PORT_TYPE.CONTROL, label: 'Out' },
],
params: {
waveform: { type: 'select', options: ['sine', 'square', 'sawtooth', 'triangle'], default: 'sine', label: 'Wave' },
frequency: { type: 'knob', min: 0.01, max: 50, default: 2, unit: 'Hz', label: 'Rate' },
amplitude: { type: 'knob', min: 0, max: 1, default: 0.5, unit: '', label: 'Depth' },
},
});
defineModule('noise', {
name: 'Noise',
icon: '⣿',
category: 'Source',
inputs: [],
outputs: [
{ name: 'out', type: PORT_TYPE.AUDIO, label: 'Out' },
],
params: {
type: { type: 'select', options: ['white', 'pink', 'brown'], default: 'white', label: 'Type' },
},
});
// ==================== FILTER ====================
defineModule('filter', {
name: 'Filter',
icon: '▽',
category: 'Filter',
inputs: [
{ name: 'in', type: PORT_TYPE.AUDIO, label: 'In' },
{ name: 'cutoff', type: PORT_TYPE.CONTROL, label: 'Cutoff' },
],
outputs: [
{ name: 'out', type: PORT_TYPE.AUDIO, label: 'Out' },
],
params: {
type: { type: 'select', options: ['lowpass', 'highpass', 'bandpass', 'notch'], default: 'lowpass', label: 'Type' },
frequency: { type: 'knob', min: 20, max: 20000, default: 1000, unit: 'Hz', label: 'Cutoff' },
Q: { type: 'knob', min: 0.1, max: 20, default: 1, unit: '', label: 'Reso' },
},
});
// ==================== ENVELOPE ====================
defineModule('envelope', {
name: 'Envelope',
icon: '⏤╲',
category: 'Modulation',
inputs: [
{ name: 'gate', type: PORT_TYPE.TRIGGER, label: 'Gate' },
],
outputs: [
{ name: 'out', type: PORT_TYPE.CONTROL, label: 'Out' },
],
params: {
attack: { type: 'knob', min: 0.001, max: 4, default: 0.01, unit: 's', label: 'Attack' },
decay: { type: 'knob', min: 0.001, max: 4, default: 0.2, unit: 's', label: 'Decay' },
sustain: { type: 'knob', min: 0, max: 1, default: 0.5, unit: '', label: 'Sustain' },
release: { type: 'knob', min: 0.001, max: 8, default: 0.5, unit: 's', label: 'Release' },
},
});
// ==================== AMPLIFIER ====================
defineModule('vca', {
name: 'VCA',
icon: '△',
category: 'Utility',
inputs: [
{ name: 'in', type: PORT_TYPE.AUDIO, label: 'In' },
{ name: 'cv', type: PORT_TYPE.CONTROL, label: 'CV' },
],
outputs: [
{ name: 'out', type: PORT_TYPE.AUDIO, label: 'Out' },
],
params: {
gain: { type: 'knob', min: 0, max: 1, default: 0.8, unit: '', label: 'Gain' },
},
});
// ==================== EFFECTS ====================
defineModule('delay', {
name: 'Delay',
icon: '⟫',
category: 'Effect',
inputs: [
{ name: 'in', type: PORT_TYPE.AUDIO, label: 'In' },
],
outputs: [
{ name: 'out', type: PORT_TYPE.AUDIO, label: 'Out' },
],
params: {
delayTime: { type: 'knob', min: 0.01, max: 2, default: 0.3, unit: 's', label: 'Time' },
feedback: { type: 'knob', min: 0, max: 0.95, default: 0.4, unit: '', label: 'Feedbk' },
wet: { type: 'knob', min: 0, max: 1, default: 0.5, unit: '', label: 'Mix' },
},
});
defineModule('reverb', {
name: 'Reverb',
icon: '◌',
category: 'Effect',
inputs: [
{ name: 'in', type: PORT_TYPE.AUDIO, label: 'In' },
],
outputs: [
{ name: 'out', type: PORT_TYPE.AUDIO, label: 'Out' },
],
params: {
decay: { type: 'knob', min: 0.1, max: 15, default: 3, unit: 's', label: 'Decay' },
wet: { type: 'knob', min: 0, max: 1, default: 0.4, unit: '', label: 'Mix' },
},
});
defineModule('distortion', {
name: 'Distortion',
icon: '⚡',
category: 'Effect',
inputs: [
{ name: 'in', type: PORT_TYPE.AUDIO, label: 'In' },
],
outputs: [
{ name: 'out', type: PORT_TYPE.AUDIO, label: 'Out' },
],
params: {
distortion: { type: 'knob', min: 0, max: 1, default: 0.4, unit: '', label: 'Drive' },
wet: { type: 'knob', min: 0, max: 1, default: 0.5, unit: '', label: 'Mix' },
},
});
// ==================== MIXER ====================
defineModule('mixer', {
name: 'Mixer',
icon: '≡',
category: 'Utility',
inputs: [
{ name: 'in1', type: PORT_TYPE.AUDIO, label: 'In 1' },
{ name: 'in2', type: PORT_TYPE.AUDIO, label: 'In 2' },
{ name: 'in3', type: PORT_TYPE.AUDIO, label: 'In 3' },
{ name: 'in4', type: PORT_TYPE.AUDIO, label: 'In 4' },
],
outputs: [
{ name: 'out', type: PORT_TYPE.AUDIO, label: 'Out' },
],
params: {
gain1: { type: 'knob', min: 0, max: 1, default: 0.8, unit: '', label: 'Ch 1' },
gain2: { type: 'knob', min: 0, max: 1, default: 0.8, unit: '', label: 'Ch 2' },
gain3: { type: 'knob', min: 0, max: 1, default: 0.8, unit: '', label: 'Ch 3' },
gain4: { type: 'knob', min: 0, max: 1, default: 0.8, unit: '', label: 'Ch 4' },
},
});
// ==================== SCOPE ====================
defineModule('scope', {
name: 'Scope',
icon: '📊',
category: 'Utility',
inputs: [
{ name: 'in', type: PORT_TYPE.AUDIO, label: 'In' },
],
outputs: [],
params: {},
});
// ==================== OUTPUT ====================
defineModule('output', {
name: 'Output',
icon: '🔊',
category: 'Output',
inputs: [
{ name: 'left', type: PORT_TYPE.AUDIO, label: 'Left' },
{ name: 'right', type: PORT_TYPE.AUDIO, label: 'Right' },
],
outputs: [],
params: {
volume: { type: 'knob', min: -60, max: 6, default: -6, unit: 'dB', label: 'Volume' },
},
});
// ==================== KEYBOARD ====================
defineModule('keyboard', {
name: 'Keyboard',
icon: '🎹',
category: 'Source',
inputs: [],
outputs: [
{ name: 'freq', type: PORT_TYPE.AUDIO, label: 'Freq' },
{ name: 'gate', type: PORT_TYPE.TRIGGER, label: 'Gate' },
],
params: {
octave: { type: 'knob', min: 1, max: 8, default: 4, unit: '', label: 'Octave' },
},
});

83
src/engine/presets.js Normal file
View File

@@ -0,0 +1,83 @@
/**
* presets.js — Save/load presets to localStorage
*/
import { serialize, deserialize } from './state.js';
import { rebuildGraph } from './audioEngine.js';
const STORAGE_KEY = 'reaktor_presets';
const AUTOSAVE_KEY = 'reaktor_autosave';
export function getPresets() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
} catch { return []; }
}
export function savePreset(name) {
const presets = getPresets();
const data = serialize();
data.name = name;
data.savedAt = new Date().toISOString();
// Replace if same name exists
const idx = presets.findIndex(p => p.name === name);
if (idx >= 0) presets[idx] = data;
else presets.unshift(data);
localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
}
export function loadPreset(name) {
const presets = getPresets();
const preset = presets.find(p => p.name === name);
if (!preset) return false;
deserialize(preset);
rebuildGraph();
return true;
}
export function deletePreset(name) {
const presets = getPresets().filter(p => p.name !== name);
localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
}
export function autoSave() {
const data = serialize();
data.savedAt = new Date().toISOString();
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(data));
}
export function autoLoad() {
try {
const raw = localStorage.getItem(AUTOSAVE_KEY);
if (!raw) return false;
const data = JSON.parse(raw);
deserialize(data);
return true;
} catch { return false; }
}
export function exportPatch() {
const data = serialize();
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'patch.json';
a.click();
URL.revokeObjectURL(url);
}
export function importPatch(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target.result);
deserialize(data);
rebuildGraph();
resolve(true);
} catch (err) { reject(err); }
};
reader.readAsText(file);
});
}

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();
}