Keyboard fullscreen: - Double-tap keyboard widget to enter fullscreen piano mode - 2-octave touch-friendly piano with labeled keys - Active key highlights cyan, close button to exit Drum Pad module (🥁): - New module type with 4x4 colored pad grid - Each pad triggers a unique frequency (C2-D4 range) - Outputs freq + gate signals (same as keyboard) - Double-tap for fullscreen pad mode with large touch targets - Color-coded pads with hit animation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
280 lines
10 KiB
JavaScript
280 lines
10 KiB
JavaScript
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 (
|
|
<div
|
|
className={`module ${isSelected ? 'selected' : ''}`}
|
|
style={{
|
|
left: mod.x * zoom, top: mod.y * zoom,
|
|
transform: `scale(${zoom})`, transformOrigin: 'top left',
|
|
...(mod.type === 'pianoroll' ? { width: getModuleWidth(mod, 'pianoroll') } : mod.type === 'sequencer' ? { width: getModuleWidth(mod, 'sequencer') } : {}),
|
|
}}
|
|
data-module-id={mod.id}
|
|
onPointerDown={(e) => {
|
|
// Don't deselect when clicking inside a module
|
|
e.stopPropagation();
|
|
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')}
|
|
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')}
|
|
/>
|
|
<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}
|
|
modulated={modulatedParams.has(name)}
|
|
liveValue={liveValues[name]}
|
|
/>
|
|
<span className={`param-value ${liveValues[name] !== undefined ? 'param-value-live' : ''}`}>
|
|
{(() => {
|
|
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}` : ''}
|
|
</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} />}
|
|
|
|
{/* Drum Pad widget */}
|
|
{mod.type === 'drumpad' && <DrumPadWidget moduleId={mod.id} />}
|
|
|
|
{/* Sequencer widget */}
|
|
{mod.type === 'sequencer' && <SequencerWidget moduleId={mod.id} />}
|
|
|
|
{/* Piano Roll widget */}
|
|
{mod.type === 'pianoroll' && <PianoRollWidget 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')}
|
|
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')}
|
|
/>
|
|
<span className="port-label">{port.label}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|