import React, { useCallback, useState, useEffect, useRef } from 'react'; import { getModuleDef } from '../engine/moduleRegistry.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'; import KeyboardWidget from './KeyboardWidget.jsx'; import DrumPadWidget from './DrumPadWidget.jsx'; import SequencerWidget from './SequencerWidget.jsx'; import PianoRollWidget from './PianoRollWidget.jsx'; // Dynamic module widths for sequencer/pianoroll based on step/bar count function getModuleWidth(mod, type) { if (type === 'sequencer') { const numSteps = parseInt(mod?.params?.steps || '16'); return Math.max(200, numSteps * 18 + 20); // CELL_W=18 + padding } if (type === 'pianoroll') { const bars = parseInt(mod?.params?.bars || '4'); const totalBeats = bars * 4; return 24 + totalBeats * 30 + 20; // KEY_W + beats*BEAT_PX + padding } return undefined; } // Map input port names → the param name they modulate (for visual feedback) const PORT_TO_PARAM = { filter: { cutoff: 'frequency' }, oscillator: { freq: 'frequency', detune: 'detune' }, vca: { cv: 'gain' }, }; // Compute a simulated LFO waveform value at time t (seconds) function simulateLFO(waveform, phase) { switch (waveform) { case 'sine': return Math.sin(2 * Math.PI * phase); case 'triangle': return 1 - 4 * Math.abs((phase % 1) - 0.5); case 'sawtooth': return 2 * (phase % 1) - 1; case 'square': return (phase % 1) < 0.5 ? 1 : -1; default: return Math.sin(2 * Math.PI * phase); } } 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 }; // Find which params are being modulated (have an incoming connection on their corresponding port) const modulatedParams = new Set(); const portMap = PORT_TO_PARAM[mod.type] || {}; for (const conn of state.connections) { if (conn.to.moduleId === mod.id && portMap[conn.to.port]) { modulatedParams.add(portMap[conn.to.port]); } } // ==================== Live LFO modulation visualization ==================== const [liveValues, setLiveValues] = useState({}); const rafRef = useRef(null); const startTimeRef = useRef(performance.now() / 1000); useEffect(() => { if (modulatedParams.size === 0) { setLiveValues({}); return; } const tick = () => { const t = performance.now() / 1000 - startTimeRef.current; const newValues = {}; for (const conn of state.connections) { if (conn.to.moduleId !== mod.id) continue; const paramName = portMap[conn.to.port]; if (!paramName) continue; const srcMod = state.modules.find(m => m.id === conn.from.moduleId); if (!srcMod || srcMod.type !== 'lfo') continue; // Read LFO params from state const lfoDef = getModuleDef('lfo'); const lfoP = { ...Object.fromEntries(Object.entries(lfoDef.params).map(([k, v]) => [k, v.default])), ...srcMod.params }; const freq = lfoP.frequency; const amp = lfoP.amplitude; const waveform = lfoP.waveform; const phase = (t * freq) % 1; const lfoVal = simulateLFO(waveform, phase) * amp; // Compute modulated value (same scaling as audioEngine) const baseValue = params[paramName]; let scale; if (mod.type === 'oscillator' && paramName === 'frequency') scale = baseValue * 0.5; else if (mod.type === 'filter' && paramName === 'frequency') scale = baseValue; else if (mod.type === 'vca' && paramName === 'gain') scale = 1; else scale = baseValue || 1; newValues[paramName] = baseValue + lfoVal * scale; } setLiveValues(newValues); rafRef.current = requestAnimationFrame(tick); }; rafRef.current = requestAnimationFrame(tick); return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }; }, [mod.id, mod.type, modulatedParams.size]); 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 (
{ // Don't deselect when clicking inside a module e.stopPropagation(); state.selectedModuleId = mod.id; emit(); }} >
{def.icon} {def.name}
{/* Input ports */} {def.inputs.map(port => (
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}
))} {/* 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 (
{paramDef.label} handleParamChange(name, v)} color={color} modulated={modulatedParams.has(name)} liveValue={liveValues[name]} /> {(() => { const v = liveValues[name] !== undefined ? liveValues[name] : params[name]; const s = v >= 1000 ? `${(v / 1000).toFixed(1)}k` : v >= 100 ? Math.round(v) : v >= 1 ? Number(v).toFixed(1) : Number(v).toFixed(3).replace(/0+$/, '').replace(/\.$/, ''); return s; })()} {paramDef.unit ? ` ${paramDef.unit}` : ''}
); } if (paramDef.type === 'select') { return (
{paramDef.label}
); } return null; })} {/* Scope display */} {mod.type === 'scope' && } {/* Keyboard widget */} {mod.type === 'keyboard' && } {/* Drum Pad widget */} {mod.type === 'drumpad' && } {/* Sequencer widget */} {mod.type === 'sequencer' && } {/* Piano Roll widget */} {mod.type === 'pianoroll' && } {/* Output ports */} {def.outputs.map(port => (
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}
))}
); }