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

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules
dist
.vite

13
Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY server.js .
EXPOSE 80
CMD ["node", "server.js"]

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Reaktor — MontLab Modular Synth</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

1727
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "reaktor-montlab",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"start": "node server.js"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tone": "^14.8.49"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.0",
"vite": "^5.4.0"
}
}

8
public/favicon.svg Normal file
View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="4" fill="#0a0a14"/>
<circle cx="10" cy="16" r="3" fill="#00e5ff" opacity="0.9"/>
<circle cx="22" cy="10" r="3" fill="#ff6644" opacity="0.9"/>
<circle cx="22" cy="22" r="3" fill="#44ff88" opacity="0.9"/>
<line x1="13" y1="16" x2="19" y2="10" stroke="#00e5ff" stroke-width="1.5" opacity="0.6"/>
<line x1="13" y1="16" x2="19" y2="22" stroke="#00e5ff" stroke-width="1.5" opacity="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 496 B

47
server.js Normal file
View File

@@ -0,0 +1,47 @@
// Lightweight static file server for Reaktor modular synth
const http = require('http');
const fs = require('fs');
const path = require('path');
const PORT = process.env.PORT || 80;
const STATIC_DIR = path.join(__dirname, 'dist');
const MIME = {
'.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',
'.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg',
'.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.woff2': 'font/woff2',
'.wasm': 'application/wasm',
};
const server = http.createServer((req, res) => {
let filePath = path.join(STATIC_DIR, req.url === '/' ? 'index.html' : req.url);
// SPA fallback: if file doesn't exist, serve index.html
if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
filePath = path.join(STATIC_DIR, 'index.html');
}
if (!filePath.startsWith(STATIC_DIR)) {
res.writeHead(403); res.end('Forbidden'); return;
}
const ext = path.extname(filePath).toLowerCase();
const contentType = MIME[ext] || 'application/octet-stream';
fs.readFile(filePath, (err, data) => {
if (err) {
res.writeHead(err.code === 'ENOENT' ? 404 : 500);
res.end(err.code === 'ENOENT' ? 'Not found' : 'Server error');
return;
}
if (['.png', '.jpg', '.woff2', '.js', '.css'].includes(ext)) {
res.setHeader('Cache-Control', 'public, max-age=604800');
}
res.writeHead(200, { 'Content-Type': contentType });
res.end(data);
});
});
server.listen(PORT, () => {
console.log(`[reaktor] Running on port ${PORT}`);
});

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

View File

@@ -0,0 +1,91 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { triggerKeyboard } from '../engine/audioEngine.js';
import { state } from '../engine/state.js';
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
// Computer keyboard to semitone offset mapping (2 octaves)
const KEY_MAP = {
'z': 0, 's': 1, 'x': 2, 'd': 3, 'c': 4, 'v': 5, 'g': 6,
'b': 7, 'h': 8, 'n': 9, 'j': 10, 'm': 11, ',': 12,
'q': 12, '2': 13, 'w': 14, '3': 15, 'e': 16, 'r': 17,
'5': 18, 't': 19, '6': 20, 'y': 21, '7': 22, 'u': 23, 'i': 24,
};
function midiToFreq(midi) {
return 440 * Math.pow(2, (midi - 69) / 12);
}
export default function KeyboardWidget({ moduleId }) {
const mod = state.modules.find(m => m.id === moduleId);
const octave = mod?.params?.octave ?? 4;
const activeKeys = useRef(new Set());
const playNote = useCallback((semitone) => {
const midi = (octave + 1) * 12 + semitone;
const freq = midiToFreq(midi);
triggerKeyboard(moduleId, freq, true);
}, [moduleId, octave]);
const stopNote = useCallback(() => {
triggerKeyboard(moduleId, 440, false);
}, [moduleId]);
useEffect(() => {
const handleDown = (e) => {
if (e.repeat) return;
const key = e.key.toLowerCase();
if (KEY_MAP[key] !== undefined && !activeKeys.current.has(key)) {
activeKeys.current.add(key);
playNote(KEY_MAP[key]);
}
};
const handleUp = (e) => {
const key = e.key.toLowerCase();
if (KEY_MAP[key] !== undefined) {
activeKeys.current.delete(key);
if (activeKeys.current.size === 0) stopNote();
}
};
window.addEventListener('keydown', handleDown);
window.addEventListener('keyup', handleUp);
return () => {
window.removeEventListener('keydown', handleDown);
window.removeEventListener('keyup', handleUp);
};
}, [playNote, stopNote]);
// Draw mini keyboard (1 octave)
const whites = [0, 2, 4, 5, 7, 9, 11];
const blacks = [1, 3, -1, 6, 8, 10];
return (
<div style={{ padding: '2px 0' }}>
<svg viewBox="0 0 140 30" style={{ width: '100%', height: 30 }}>
{whites.map((note, i) => (
<rect key={`w${i}`} x={i * 20} y={0} width={19} height={28}
rx={1} fill="#222" stroke="#444" strokeWidth={0.5}
style={{ cursor: 'pointer' }}
onPointerDown={() => playNote(note)}
onPointerUp={stopNote}
/>
))}
{blacks.filter(n => n >= 0).map((note, i) => {
const pos = [1, 2, 4, 5, 6][i];
return (
<rect key={`b${i}`} x={pos * 20 - 6} y={0} width={12} height={18}
rx={1} fill="#111" stroke="#333" strokeWidth={0.5}
style={{ cursor: 'pointer' }}
onPointerDown={() => playNote(note)}
onPointerUp={stopNote}
/>
);
})}
</svg>
<div style={{ fontSize: 9, color: 'var(--text2)', textAlign: 'center', marginTop: 2 }}>
Z-M / Q-I keys · Oct {octave}
</div>
</div>
);
}

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

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

View File

@@ -0,0 +1,24 @@
import React from 'react';
import { getModulesByCategory } from '../engine/moduleRegistry.js';
export default function ModulePalette({ onAddModule }) {
const categories = getModulesByCategory();
return (
<div className="palette">
<div className="palette-title">Modules</div>
{Object.entries(categories).map(([cat, modules]) => (
<React.Fragment key={cat}>
<div className="palette-title" style={{ marginTop: 6, color: 'var(--text2)', fontSize: 8 }}>{cat}</div>
{modules.map(def => (
<div key={def.type} className="palette-item"
onClick={() => onAddModule(def.type)}>
<span className="p-icon">{def.icon}</span>
<span className="p-name">{def.name}</span>
</div>
))}
</React.Fragment>
))}
</div>
);
}

View File

@@ -0,0 +1,72 @@
import React, { useState } from 'react';
import { getPresets, savePreset, loadPreset, deletePreset } from '../engine/presets.js';
export default function PresetModal({ mode, onClose }) {
const [name, setName] = useState('');
const presets = getPresets();
const handleSave = () => {
if (!name.trim()) return;
savePreset(name.trim());
onClose();
};
const handleLoad = (presetName) => {
loadPreset(presetName);
onClose();
};
const handleDelete = (e, presetName) => {
e.stopPropagation();
deletePreset(presetName);
// Force re-render
setName(n => n + '');
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal" onClick={e => e.stopPropagation()}>
<h2>{mode === 'save' ? 'Save Preset' : 'Load Preset'}</h2>
{mode === 'save' && (
<>
<input
autoFocus
placeholder="Preset name..."
value={name}
onChange={e => setName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSave()}
/>
<div className="modal-actions">
<button onClick={onClose}>Cancel</button>
<button className="primary" onClick={handleSave}>Save</button>
</div>
</>
)}
{mode === 'load' && (
<>
<div className="preset-list">
{presets.length === 0 && (
<div style={{ color: 'var(--text2)', padding: 12, textAlign: 'center' }}>No presets saved yet</div>
)}
{presets.map(p => (
<div key={p.name} className="preset-item" onClick={() => handleLoad(p.name)}>
<span>{p.name}</span>
<span className="preset-date">{p.modules?.length || 0} modules</span>
<button
style={{ background: 'none', border: 'none', color: 'var(--red)', cursor: 'pointer', marginLeft: 8, fontSize: 12 }}
onClick={e => handleDelete(e, p.name)}
></button>
</div>
))}
</div>
<div className="modal-actions">
<button onClick={onClose}>Close</button>
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,50 @@
import React, { useRef, useEffect } from 'react';
import { getAnalyserData } from '../engine/audioEngine.js';
export default function ScopeDisplay({ moduleId }) {
const canvasRef = useRef(null);
const rafRef = useRef(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = canvas.width = 160;
const h = canvas.height = 60;
const draw = () => {
ctx.fillStyle = '#050510';
ctx.fillRect(0, 0, w, h);
// Grid lines
ctx.strokeStyle = '#151530';
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(0, h / 2); ctx.lineTo(w, h / 2);
ctx.moveTo(0, h / 4); ctx.lineTo(w, h / 4);
ctx.moveTo(0, h * 3 / 4); ctx.lineTo(w, h * 3 / 4);
ctx.stroke();
const data = getAnalyserData(moduleId);
if (data && data.length > 0) {
ctx.strokeStyle = '#00e5ff';
ctx.lineWidth = 1.5;
ctx.beginPath();
const step = w / data.length;
for (let i = 0; i < data.length; i++) {
const y = h / 2 + data[i] * h / 2 * -1;
if (i === 0) ctx.moveTo(0, y);
else ctx.lineTo(i * step, y);
}
ctx.stroke();
}
rafRef.current = requestAnimationFrame(draw);
};
draw();
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
}, [moduleId]);
return <canvas ref={canvasRef} className="scope-canvas" />;
}

View File

@@ -0,0 +1,65 @@
import React from 'react';
import { wirePath } from '../utils/bezier.js';
import { state, removeConnection } from '../engine/state.js';
import { disconnectWire } from '../engine/audioEngine.js';
import { getModuleDef, PORT_TYPE } from '../engine/moduleRegistry.js';
export default function WireLayer({ portPositions, tempWire, containerRef }) {
const getPortPos = (moduleId, portName, direction) => {
const key = `${moduleId}-${portName}-${direction}`;
const el = portPositions.current[key];
if (!el || !containerRef.current) return null;
const rect = el.getBoundingClientRect();
const containerRect = containerRef.current.getBoundingClientRect();
return {
x: rect.left + rect.width / 2 - containerRect.left,
y: rect.top + rect.height / 2 - containerRect.top,
};
};
const getPortType = (moduleId, portName, direction) => {
const mod = state.modules.find(m => m.id === moduleId);
if (!mod) return PORT_TYPE.AUDIO;
const def = getModuleDef(mod.type);
if (!def) return PORT_TYPE.AUDIO;
const ports = direction === 'output' ? def.outputs : def.inputs;
const port = ports.find(p => p.name === portName);
return port?.type || PORT_TYPE.AUDIO;
};
const handleWireClick = (conn) => {
disconnectWire(conn);
removeConnection(conn.id);
};
return (
<svg className="wires-svg">
{/* Existing connections */}
{state.connections.map(conn => {
const from = getPortPos(conn.from.moduleId, conn.from.port, 'output');
const to = getPortPos(conn.to.moduleId, conn.to.port, 'input');
if (!from || !to) return null;
const portType = getPortType(conn.from.moduleId, conn.from.port, 'output');
return (
<path
key={conn.id}
className={portType}
d={wirePath(from.x, from.y, to.x, to.y)}
style={{ pointerEvents: 'stroke', cursor: 'pointer' }}
onClick={() => handleWireClick(conn)}
/>
);
})}
{/* Temp wire while connecting */}
{tempWire && (
<path
className={`${tempWire.portType} temp`}
d={wirePath(tempWire.startX, tempWire.startY, tempWire.endX, tempWire.endY)}
/>
)}
</svg>
);
}

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

245
src/index.css Normal file
View File

@@ -0,0 +1,245 @@
/* ===== Reset & Base ===== */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #08080f;
--panel: #0e0e1a;
--surface: #14142a;
--surface2: #1a1a35;
--border: #252545;
--text: #c8cce0;
--text2: #6668a0;
--accent: #00e5ff;
--accent2: #ff6644;
--green: #44ff88;
--yellow: #ffcc00;
--purple: #aa55ff;
--red: #ff4466;
--wire-audio: #00e5ff;
--wire-control: #ff6644;
--wire-trigger: #ffcc00;
--knob-track: #333;
--knob-fill: #00e5ff;
--module-w: 180;
--port-r: 6;
}
html, body, #root {
width: 100%; height: 100%; overflow: hidden;
background: var(--bg); color: var(--text);
font-family: 'Inter', 'SF Pro', -apple-system, system-ui, sans-serif;
font-size: 12px;
-webkit-font-smoothing: antialiased;
}
/* ===== Layout ===== */
.app { display: flex; flex-direction: column; height: 100vh; }
.toolbar {
height: 40px; background: var(--panel); border-bottom: 1px solid var(--border);
display: flex; align-items: center; padding: 0 12px; gap: 8px; flex-shrink: 0;
z-index: 10;
}
.toolbar-title {
font-weight: 700; font-size: 14px; color: var(--accent);
letter-spacing: 1px; text-transform: uppercase; margin-right: 16px;
}
.toolbar-btn {
padding: 4px 10px; border: 1px solid var(--border); border-radius: 4px;
background: var(--surface); color: var(--text2); cursor: pointer;
font-size: 11px; font-weight: 500; transition: all 0.15s;
font-family: inherit;
}
.toolbar-btn:hover { border-color: var(--accent); color: var(--text); }
.toolbar-btn.active { background: var(--accent); color: #000; border-color: var(--accent); }
.toolbar-btn.danger { border-color: var(--red); color: var(--red); }
.toolbar-btn.danger:hover { background: var(--red); color: #000; }
.toolbar-sep { width: 1px; height: 20px; background: var(--border); margin: 0 4px; }
.toolbar-group { display: flex; gap: 4px; align-items: center; }
.toolbar-label { color: var(--text2); font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; }
.main-area { flex: 1; position: relative; overflow: hidden; }
/* ===== Node Canvas ===== */
.node-canvas {
position: absolute; inset: 0; cursor: grab;
}
.node-canvas.grabbing { cursor: grabbing; }
.node-canvas.connecting { cursor: crosshair; }
.wires-svg {
position: absolute; inset: 0; pointer-events: none; z-index: 1;
}
.wires-svg path {
fill: none; stroke-width: 2.5; stroke-linecap: round;
}
.wires-svg path.audio { stroke: var(--wire-audio); opacity: 0.75; }
.wires-svg path.control { stroke: var(--wire-control); opacity: 0.75; }
.wires-svg path.trigger { stroke: var(--wire-trigger); opacity: 0.75; }
.wires-svg path.temp { stroke-dasharray: 6 4; opacity: 0.5; }
.wires-svg path:hover { stroke-width: 4; opacity: 1; }
/* ===== Modules ===== */
.module {
position: absolute; width: 180px;
background: var(--surface); border: 1px solid var(--border);
border-radius: 8px; user-select: none; z-index: 2;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
transition: box-shadow 0.15s;
}
.module.selected { border-color: var(--accent); box-shadow: 0 0 20px rgba(0,229,255,0.15); }
.module:hover { box-shadow: 0 6px 24px rgba(0,0,0,0.5); }
.module-header {
display: flex; align-items: center; gap: 6px;
padding: 6px 10px; border-bottom: 1px solid var(--border);
cursor: grab; border-radius: 8px 8px 0 0;
background: var(--surface2);
}
.module-header .type-icon { font-size: 14px; }
.module-header .type-name {
font-size: 11px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.5px; color: var(--text);
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.module-header .close-btn {
width: 18px; height: 18px; border: none; background: transparent;
color: var(--text2); cursor: pointer; font-size: 12px; border-radius: 3px;
display: flex; align-items: center; justify-content: center;
}
.module-header .close-btn:hover { background: var(--red); color: #fff; }
.module-body { padding: 8px 10px; display: flex; flex-direction: column; gap: 6px; }
/* Ports */
.port-row {
display: flex; align-items: center; gap: 6px;
position: relative; height: 20px;
}
.port-row.input { flex-direction: row; }
.port-row.output { flex-direction: row-reverse; }
.port-dot {
width: 12px; height: 12px; border-radius: 50%;
border: 2px solid var(--border); background: var(--surface);
cursor: pointer; flex-shrink: 0; transition: all 0.15s;
position: relative;
}
.port-dot.audio { border-color: var(--wire-audio); }
.port-dot.control { border-color: var(--wire-control); }
.port-dot.trigger { border-color: var(--wire-trigger); }
.port-dot:hover { transform: scale(1.3); }
.port-dot.connected { background: currentColor; }
.port-dot.audio.connected { background: var(--wire-audio); }
.port-dot.control.connected { background: var(--wire-control); }
.port-dot.trigger.connected { background: var(--wire-trigger); }
.port-dot.compatible { animation: pulse-port 0.6s infinite alternate; }
@keyframes pulse-port {
from { box-shadow: 0 0 2px currentColor; }
to { box-shadow: 0 0 8px currentColor; }
}
.port-label {
font-size: 10px; color: var(--text2); text-transform: uppercase;
letter-spacing: 0.3px; white-space: nowrap;
}
/* Knobs */
.param-row {
display: flex; align-items: center; gap: 6px;
}
.param-label {
font-size: 10px; color: var(--text2); width: 48px;
text-transform: uppercase; letter-spacing: 0.3px; flex-shrink: 0;
}
.knob-container { position: relative; width: 32px; height: 32px; flex-shrink: 0; }
.knob-svg { width: 32px; height: 32px; cursor: pointer; }
.knob-track { fill: none; stroke: var(--knob-track); stroke-width: 3; stroke-linecap: round; }
.knob-fill { fill: none; stroke-width: 3; stroke-linecap: round; }
.knob-dot { fill: var(--text); }
.param-value {
font-size: 10px; color: var(--accent); font-family: 'JetBrains Mono', monospace;
min-width: 40px; text-align: right;
}
/* Select param */
.param-select {
flex: 1; background: var(--bg); border: 1px solid var(--border);
border-radius: 3px; padding: 2px 4px; color: var(--text);
font-size: 10px; font-family: inherit; cursor: pointer;
}
.param-select:focus { outline: none; border-color: var(--accent); }
/* Scope canvas */
.scope-canvas {
width: 100%; height: 60px; border-radius: 4px;
background: #050510; border: 1px solid var(--border);
}
/* ===== Module Palette (sidebar) ===== */
.palette {
position: absolute; left: 8px; top: 8px; z-index: 20;
background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
padding: 8px; display: flex; flex-direction: column; gap: 4px;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
max-height: calc(100% - 16px); overflow-y: auto;
}
.palette-title {
font-size: 9px; font-weight: 700; color: var(--text2);
text-transform: uppercase; letter-spacing: 1px; padding: 2px 4px;
}
.palette-item {
display: flex; align-items: center; gap: 6px;
padding: 5px 8px; border-radius: 4px; cursor: pointer;
font-size: 11px; color: var(--text); transition: all 0.1s;
}
.palette-item:hover { background: var(--surface2); }
.palette-item .p-icon { font-size: 14px; width: 20px; text-align: center; }
.palette-item .p-name { font-weight: 500; }
.palette-item .p-cat { font-size: 9px; color: var(--text2); margin-left: auto; }
/* ===== Status Bar ===== */
.status-bar {
height: 24px; background: var(--panel); border-top: 1px solid var(--border);
display: flex; align-items: center; padding: 0 12px; gap: 16px;
font-size: 10px; color: var(--text2); flex-shrink: 0; z-index: 10;
}
.status-bar .status-accent { color: var(--accent); }
/* ===== Preset Modal ===== */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.7);
display: flex; align-items: center; justify-content: center; z-index: 100;
}
.modal {
background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
padding: 20px; min-width: 360px; max-width: 500px;
box-shadow: 0 24px 64px rgba(0,0,0,0.6);
}
.modal h2 { font-size: 15px; color: var(--accent); margin-bottom: 12px; }
.modal input {
width: 100%; padding: 8px 10px; background: var(--bg);
border: 1px solid var(--border); border-radius: 4px;
color: var(--text); font-size: 13px; font-family: inherit;
}
.modal input:focus { outline: none; border-color: var(--accent); }
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 12px; }
.modal-actions button { padding: 6px 14px; border-radius: 4px; cursor: pointer; font-size: 12px; font-weight: 600; border: 1px solid var(--border); background: var(--surface); color: var(--text); font-family: inherit; }
.modal-actions .primary { background: var(--accent); color: #000; border-color: var(--accent); }
.preset-list { max-height: 200px; overflow-y: auto; margin: 8px 0; }
.preset-item {
padding: 6px 10px; cursor: pointer; border-radius: 4px;
display: flex; align-items: center; justify-content: space-between;
font-size: 12px;
}
.preset-item:hover { background: var(--surface2); }
.preset-item .preset-date { color: var(--text2); font-size: 10px; }

6
src/main.jsx Normal file
View File

@@ -0,0 +1,6 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
createRoot(document.getElementById('root')).render(<App />);

8
src/utils/bezier.js Normal file
View File

@@ -0,0 +1,8 @@
/**
* Generate SVG bezier path between two points (for wires)
*/
export function wirePath(x1, y1, x2, y2) {
const dx = Math.abs(x2 - x1);
const cp = Math.max(50, dx * 0.5);
return `M ${x1} ${y1} C ${x1 + cp} ${y1}, ${x2 - cp} ${y2}, ${x2} ${y2}`;
}

8
vite.config.js Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: { port: 3000 },
build: { outDir: 'dist' }
});