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:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.vite
|
||||
13
Dockerfile
Normal file
13
Dockerfile
Normal 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
13
index.html
Normal 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
1727
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
package.json
Normal file
21
package.json
Normal 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
8
public/favicon.svg
Normal 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
47
server.js
Normal 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
321
src/App.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
src/components/KeyboardWidget.jsx
Normal file
91
src/components/KeyboardWidget.jsx
Normal 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
82
src/components/Knob.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
150
src/components/ModuleNode.jsx
Normal file
150
src/components/ModuleNode.jsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React, { useCallback, useRef } from 'react';
|
||||
import { getModuleDef } from '../engine/moduleRegistry.js';
|
||||
import { state, removeModule, updateModuleParam, updateModulePosition, isPortConnected, emit } from '../engine/state.js';
|
||||
import { updateParam } from '../engine/audioEngine.js';
|
||||
import Knob from './Knob.jsx';
|
||||
import ScopeDisplay from './ScopeDisplay.jsx';
|
||||
import KeyboardWidget from './KeyboardWidget.jsx';
|
||||
|
||||
export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }) {
|
||||
const def = getModuleDef(mod.type);
|
||||
if (!def) return null;
|
||||
|
||||
const isSelected = state.selectedModuleId === mod.id;
|
||||
|
||||
// Merge default params
|
||||
const params = { ...Object.fromEntries(Object.entries(def.params).map(([k, v]) => [k, v.default])), ...mod.params };
|
||||
|
||||
const handleParamChange = useCallback((name, value) => {
|
||||
updateModuleParam(mod.id, name, value);
|
||||
updateParam(mod.id, name, value);
|
||||
}, [mod.id]);
|
||||
|
||||
const handleHeaderDown = useCallback((e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.stopPropagation();
|
||||
state.selectedModuleId = mod.id;
|
||||
state.dragging = {
|
||||
moduleId: mod.id,
|
||||
offsetX: e.clientX / zoom - mod.x,
|
||||
offsetY: e.clientY / zoom - mod.y,
|
||||
};
|
||||
emit();
|
||||
}, [mod, zoom]);
|
||||
|
||||
const handleDelete = useCallback((e) => {
|
||||
e.stopPropagation();
|
||||
removeModule(mod.id);
|
||||
}, [mod.id]);
|
||||
|
||||
const handlePortMouseDown = useCallback((e, portName, direction) => {
|
||||
e.stopPropagation(); e.preventDefault();
|
||||
const portDef = direction === 'output'
|
||||
? def.outputs.find(p => p.name === portName)
|
||||
: def.inputs.find(p => p.name === portName);
|
||||
if (!portDef) return;
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
onStartConnect({
|
||||
moduleId: mod.id,
|
||||
port: portName,
|
||||
portType: portDef.type,
|
||||
direction,
|
||||
startX: rect.left + rect.width / 2,
|
||||
startY: rect.top + rect.height / 2,
|
||||
});
|
||||
}, [mod.id, def, onStartConnect]);
|
||||
|
||||
// Report port positions for wire rendering
|
||||
const portRef = useCallback((el, portName, direction) => {
|
||||
if (el) {
|
||||
onPortPosition(mod.id, portName, direction, el);
|
||||
}
|
||||
}, [mod.id, onPortPosition]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`module ${isSelected ? 'selected' : ''}`}
|
||||
style={{ left: mod.x * zoom, top: mod.y * zoom, transform: `scale(${zoom})`, transformOrigin: 'top left' }}
|
||||
onPointerDown={() => { state.selectedModuleId = mod.id; emit(); }}
|
||||
>
|
||||
<div className="module-header" onPointerDown={handleHeaderDown}>
|
||||
<span className="type-icon">{def.icon}</span>
|
||||
<span className="type-name">{def.name}</span>
|
||||
<button className="close-btn" onClick={handleDelete}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="module-body">
|
||||
{/* Input ports */}
|
||||
{def.inputs.map(port => (
|
||||
<div key={port.name} className="port-row input">
|
||||
<div
|
||||
className={`port-dot ${port.type} ${isPortConnected(mod.id, port.name, 'input') ? 'connected' : ''}`}
|
||||
ref={el => portRef(el, port.name, 'input')}
|
||||
onPointerDown={e => handlePortMouseDown(e, port.name, 'input')}
|
||||
/>
|
||||
<span className="port-label">{port.label}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Parameters */}
|
||||
{Object.entries(def.params).map(([name, paramDef]) => {
|
||||
if (paramDef.type === 'knob') {
|
||||
const color = paramDef.unit === 'Hz' ? 'var(--accent)' :
|
||||
paramDef.unit === 'dB' ? 'var(--green)' :
|
||||
paramDef.unit === 's' ? 'var(--purple)' : 'var(--accent)';
|
||||
return (
|
||||
<div key={name} className="param-row">
|
||||
<span className="param-label">{paramDef.label}</span>
|
||||
<Knob
|
||||
value={params[name]}
|
||||
min={paramDef.min}
|
||||
max={paramDef.max}
|
||||
onChange={v => handleParamChange(name, v)}
|
||||
color={color}
|
||||
/>
|
||||
<span className="param-value">
|
||||
{params[name] >= 1000 ? `${(params[name] / 1000).toFixed(1)}k` :
|
||||
params[name] >= 100 ? Math.round(params[name]) :
|
||||
params[name] >= 1 ? Number(params[name]).toFixed(1) :
|
||||
Number(params[name]).toFixed(3).replace(/0+$/, '').replace(/\.$/, '')}
|
||||
{paramDef.unit ? ` ${paramDef.unit}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (paramDef.type === 'select') {
|
||||
return (
|
||||
<div key={name} className="param-row">
|
||||
<span className="param-label">{paramDef.label}</span>
|
||||
<select className="param-select" value={params[name]}
|
||||
onChange={e => handleParamChange(name, e.target.value)}>
|
||||
{paramDef.options.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
|
||||
{/* Scope display */}
|
||||
{mod.type === 'scope' && <ScopeDisplay moduleId={mod.id} />}
|
||||
|
||||
{/* Keyboard widget */}
|
||||
{mod.type === 'keyboard' && <KeyboardWidget moduleId={mod.id} />}
|
||||
|
||||
{/* Output ports */}
|
||||
{def.outputs.map(port => (
|
||||
<div key={port.name} className="port-row output">
|
||||
<div
|
||||
className={`port-dot ${port.type} ${isPortConnected(mod.id, port.name, 'output') ? 'connected' : ''}`}
|
||||
ref={el => portRef(el, port.name, 'output')}
|
||||
onPointerDown={e => handlePortMouseDown(e, port.name, 'output')}
|
||||
/>
|
||||
<span className="port-label">{port.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
src/components/ModulePalette.jsx
Normal file
24
src/components/ModulePalette.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
src/components/PresetModal.jsx
Normal file
72
src/components/PresetModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
src/components/ScopeDisplay.jsx
Normal file
50
src/components/ScopeDisplay.jsx
Normal 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" />;
|
||||
}
|
||||
65
src/components/WireLayer.jsx
Normal file
65
src/components/WireLayer.jsx
Normal 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
343
src/engine/audioEngine.js
Normal 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();
|
||||
}
|
||||
259
src/engine/moduleRegistry.js
Normal file
259
src/engine/moduleRegistry.js
Normal 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
83
src/engine/presets.js
Normal 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
131
src/engine/state.js
Normal 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
245
src/index.css
Normal 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
6
src/main.jsx
Normal 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
8
src/utils/bezier.js
Normal 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
8
vite.config.js
Normal 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' }
|
||||
});
|
||||
Reference in New Issue
Block a user