diff --git a/src/App.jsx b/src/App.jsx index a7beaaa..0f884eb 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,12 +1,13 @@ 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 { state, subscribe, addModule, emit, addConnection, updateModulePosition, deserialize } from './engine/state.js'; +import { startAudio, stopAudio, connectWire, rebuildGraph } from './engine/audioEngine.js'; +import { getModuleDef } 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'; +import { CHIPTUNE_PRESET } from './presets/chiptune.js'; export default function App() { const [, forceUpdate] = useState(0); @@ -23,11 +24,12 @@ export default function App() { return unsub; }, []); - // Auto-load on mount + // Auto-load on mount, or load chiptune demo if empty useEffect(() => { const loaded = autoLoad(); - if (loaded && state.isRunning) { - startAudio(); + if (!loaded || state.modules.length === 0) { + // Load chiptune demo preset + deserialize(CHIPTUNE_PRESET); } }, []); @@ -56,43 +58,48 @@ export default function App() { }); }, []); + // Find port-dot element at pointer position (including nearby) + const findPortAtPoint = (x, y) => { + // First try exact hit + const el = document.elementFromPoint(x, y); + if (el && el.classList.contains('port-dot') && el.dataset.moduleId) { + return el; + } + // Try a small radius around the point + for (const [dx, dy] of [[0,0],[-8,0],[8,0],[0,-8],[0,8],[-6,-6],[6,-6],[-6,6],[6,6]]) { + const hit = document.elementFromPoint(x + dx, y + dy); + if (hit && hit.classList.contains('port-dot') && hit.dataset.moduleId) { + return hit; + } + } + return null; + }; + // 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 + if (e.button === 1 || (e.button === 0 && e.altKey) || e.button === 2) { 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 ? { @@ -108,80 +115,35 @@ export default function App() { state.panning = false; state.panStart = null; } - if (state.dragging) { state.dragging = null; emit(); } - // End connecting โ€” check if we're over a port + // End connecting 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); - } + const portEl = findPortAtPoint(e.clientX, e.clientY); + if (portEl) { + finishConnection(portEl); } connectingRef.current = null; setTempWire(null); } }, []); - const finishConnection = (portEl, e) => { + const finishConnection = (portEl) => { 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; + // Read data attributes directly โ€” clean and reliable + const targetModuleId = parseInt(portEl.dataset.moduleId); + const targetPort = portEl.dataset.portName; + const targetDirection = portEl.dataset.portDirection; - // Get all port-row elements to find index - const portRows = moduleEl.querySelectorAll('.port-row'); - let targetModuleId = null; - let targetPort = null; - let targetDirection = null; + if (!targetModuleId || !targetPort || !targetDirection) return; + if (targetModuleId === from.moduleId && targetPort === from.port) return; - 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 + // Determine from/to let fromMod, fromPort, toMod, toPort; if (from.direction === 'output' && targetDirection === 'input') { fromMod = from.moduleId; fromPort = from.port; @@ -190,7 +152,7 @@ export default function App() { fromMod = targetModuleId; fromPort = targetPort; toMod = from.moduleId; toPort = from.port; } else { - return; // Invalid: same direction + return; // same direction โ€” invalid } const connId = addConnection(fromMod, fromPort, toMod, toPort); @@ -203,14 +165,12 @@ export default function App() { 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; + state.zoom = Math.max(0.3, Math.min(3, state.zoom + delta)); emit(); }, []); const handleContextMenu = useCallback((e) => e.preventDefault(), []); - // Toolbar actions const handleToggleAudio = async () => { if (state.isRunning) { stopAudio(); @@ -223,10 +183,8 @@ export default function App() { 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(); - } + addModule(type, x, y); + if (state.isRunning) rebuildGraph(); }; const handleImport = async (e) => { @@ -237,6 +195,12 @@ export default function App() { e.target.value = ''; }; + const handleLoadDemo = () => { + deserialize(CHIPTUNE_PRESET); + if (state.isRunning) rebuildGraph(); + emit(); + }; + return (
{/* Toolbar */} @@ -255,6 +219,10 @@ export default function App() {
+ +
{state.isRunning ? 'โ— LIVE' : 'โ—‹ OFF'} @@ -286,6 +254,9 @@ export default function App() { + {/* Wire layer (behind modules, uses getBoundingClientRect) */} + + {/* Modules container (offset by camera) */}
{state.modules.map(mod => ( @@ -298,9 +269,6 @@ export default function App() { /> ))}
- - {/* Wire layer */} -
{/* Module palette */} @@ -311,10 +279,9 @@ export default function App() {
Reaktor โ€” MontLab Modular Synth Zoom: {(state.zoom * 100).toFixed(0)}% - Scroll: pan ยท Wheel: zoom ยท Click port + drag: wire ยท Click wire: delete + RClick: pan ยท Wheel: zoom ยท Drag port: wire ยท Click wire: delete
- {/* Preset modal */} {presetModal && setPresetModal(null)} />}
); diff --git a/src/components/ModuleNode.jsx b/src/components/ModuleNode.jsx index d450f6d..23e123f 100644 --- a/src/components/ModuleNode.jsx +++ b/src/components/ModuleNode.jsx @@ -1,6 +1,6 @@ -import React, { useCallback, useRef } from 'react'; +import React, { useCallback } from 'react'; import { getModuleDef } from '../engine/moduleRegistry.js'; -import { state, removeModule, updateModuleParam, updateModulePosition, isPortConnected, emit } from '../engine/state.js'; +import { state, removeModule, updateModuleParam, isPortConnected, emit } from '../engine/state.js'; import { updateParam } from '../engine/audioEngine.js'; import Knob from './Knob.jsx'; import ScopeDisplay from './ScopeDisplay.jsx'; @@ -66,7 +66,12 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
{ state.selectedModuleId = mod.id; emit(); }} + data-module-id={mod.id} + onPointerDown={(e) => { + // Don't deselect when clicking inside a module + e.stopPropagation(); + state.selectedModuleId = mod.id; emit(); + }} >
{def.icon} @@ -81,6 +86,10 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
portRef(el, port.name, 'input')} + data-module-id={mod.id} + data-port-name={port.name} + data-port-direction="input" + data-port-type={port.type} onPointerDown={e => handlePortMouseDown(e, port.name, 'input')} /> {port.label} @@ -139,6 +148,10 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
portRef(el, port.name, 'output')} + data-module-id={mod.id} + data-port-name={port.name} + data-port-direction="output" + data-port-type={port.type} onPointerDown={e => handlePortMouseDown(e, port.name, 'output')} /> {port.label} diff --git a/src/index.css b/src/index.css index 84a7c39..990e539 100644 --- a/src/index.css +++ b/src/index.css @@ -73,16 +73,19 @@ html, body, #root { .node-canvas.connecting { cursor: crosshair; } .wires-svg { - position: absolute; inset: 0; pointer-events: none; z-index: 1; + position: absolute; inset: 0; pointer-events: none; z-index: 3; + overflow: visible; } .wires-svg path { fill: none; stroke-width: 2.5; stroke-linecap: round; + pointer-events: stroke; cursor: pointer; + filter: drop-shadow(0 0 3px rgba(0,229,255,0.3)); } -.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; } +.wires-svg path.audio { stroke: var(--wire-audio); opacity: 0.8; } +.wires-svg path.control { stroke: var(--wire-control); opacity: 0.8; } +.wires-svg path.trigger { stroke: var(--wire-trigger); opacity: 0.8; } +.wires-svg path.temp { stroke-dasharray: 6 4; opacity: 0.5; filter: none; } +.wires-svg path:hover { stroke-width: 4; opacity: 1; filter: drop-shadow(0 0 6px rgba(0,229,255,0.6)); } /* ===== Modules ===== */ .module { @@ -128,7 +131,7 @@ html, body, #root { 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; + position: relative; z-index: 5; } .port-dot.audio { border-color: var(--wire-audio); } .port-dot.control { border-color: var(--wire-control); } diff --git a/src/presets/chiptune.js b/src/presets/chiptune.js new file mode 100644 index 0000000..4e9128a --- /dev/null +++ b/src/presets/chiptune.js @@ -0,0 +1,74 @@ +/** + * Chiptune Demo Preset + * + * Signal flow: + * Keyboard โ†’ [freq] Osc1 (square, melody) โ†’ VCA1 โ† Envelope1 (short plucky) + * Keyboard โ†’ [freq] Osc2 (square, -12 oct sub bass) โ†’ VCA2 โ† Envelope2 (bass sustain) + * LFO (vibrato) โ†’ Osc1 detune + * VCA1 + VCA2 โ†’ Mixer โ†’ Filter (lowpass, slight resonance) โ†’ Delay โ†’ Distortion (light) โ†’ Output + * Mixer โ†’ Scope (visualization) + * + * Layout: left-to-right signal flow, neatly arranged + */ + +export const CHIPTUNE_PRESET = { + modules: [ + // Row 1: Keyboard & Sources + { id: 1, type: 'keyboard', x: 40, y: 40, params: { octave: 4 } }, + { id: 2, type: 'oscillator', x: 280, y: 20, params: { waveform: 'square', frequency: 440, detune: 0 } }, + { id: 3, type: 'oscillator', x: 280, y: 260, params: { waveform: 'square', frequency: 220, detune: 0 } }, + { id: 4, type: 'lfo', x: 40, y: 280, params: { waveform: 'sine', frequency: 5.5, amplitude: 0.3 } }, + + // Row 2: Envelopes & VCAs + { id: 5, type: 'envelope', x: 500, y: 20, params: { attack: 0.005, decay: 0.15, sustain: 0.2, release: 0.1 } }, + { id: 6, type: 'envelope', x: 500, y: 240, params: { attack: 0.005, decay: 0.3, sustain: 0.6, release: 0.2 } }, + { id: 7, type: 'vca', x: 700, y: 20, params: { gain: 0.7 } }, + { id: 8, type: 'vca', x: 700, y: 220, params: { gain: 0.5 } }, + + // Row 3: Mixer, processing, output + { id: 9, type: 'mixer', x: 900, y: 60, params: { gain1: 0.8, gain2: 0.6, gain3: 0.0, gain4: 0.0 } }, + { id: 10, type: 'filter', x: 1100, y: 40, params: { type: 'lowpass', frequency: 3500, Q: 2.5 } }, + { id: 11, type: 'delay', x: 1300, y: 40, params: { delayTime: 0.18, feedback: 0.35, wet: 0.25 } }, + { id: 12, type: 'distortion', x: 1300, y: 280, params: { distortion: 0.15, wet: 0.3 } }, + { id: 13, type: 'output', x: 1520, y: 120, params: { volume: -8 } }, + + // Scope + { id: 14, type: 'scope', x: 900, y: 320, params: {} }, + ], + connections: [ + // Keyboard โ†’ Oscillators (freq) + { id: 1, from: { moduleId: 1, port: 'freq' }, to: { moduleId: 2, port: 'freq' } }, + { id: 2, from: { moduleId: 1, port: 'freq' }, to: { moduleId: 3, port: 'freq' } }, + + // Keyboard โ†’ Envelopes (gate) + { id: 3, from: { moduleId: 1, port: 'gate' }, to: { moduleId: 5, port: 'gate' } }, + { id: 4, from: { moduleId: 1, port: 'gate' }, to: { moduleId: 6, port: 'gate' } }, + + // LFO โ†’ Osc1 detune (vibrato) + { id: 5, from: { moduleId: 4, port: 'out' }, to: { moduleId: 2, port: 'detune' } }, + + // Osc1 โ†’ VCA1, Envelope1 โ†’ VCA1 CV + { id: 6, from: { moduleId: 2, port: 'out' }, to: { moduleId: 7, port: 'in' } }, + { id: 7, from: { moduleId: 5, port: 'out' }, to: { moduleId: 7, port: 'cv' } }, + + // Osc2 โ†’ VCA2, Envelope2 โ†’ VCA2 CV + { id: 8, from: { moduleId: 3, port: 'out' }, to: { moduleId: 8, port: 'in' } }, + { id: 9, from: { moduleId: 6, port: 'out' }, to: { moduleId: 8, port: 'cv' } }, + + // VCA1 + VCA2 โ†’ Mixer + { id: 10, from: { moduleId: 7, port: 'out' }, to: { moduleId: 9, port: 'in1' } }, + { id: 11, from: { moduleId: 8, port: 'out' }, to: { moduleId: 9, port: 'in2' } }, + + // Mixer โ†’ Filter โ†’ Delay โ†’ Distortion โ†’ Output + { id: 12, from: { moduleId: 9, port: 'out' }, to: { moduleId: 10, port: 'in' } }, + { id: 13, from: { moduleId: 10, port: 'out' }, to: { moduleId: 11, port: 'in' } }, + { id: 14, from: { moduleId: 11, port: 'out' }, to: { moduleId: 12, port: 'in' } }, + { id: 15, from: { moduleId: 12, port: 'out' }, to: { moduleId: 13, port: 'left' } }, + { id: 16, from: { moduleId: 12, port: 'out' }, to: { moduleId: 13, port: 'right' } }, + + // Mixer โ†’ Scope + { id: 17, from: { moduleId: 9, port: 'out' }, to: { moduleId: 14, port: 'in' } }, + ], + camera: { camX: 0, camY: 0, zoom: 1 }, + masterVolume: -8, +};