feat: universal modulation animation for all source types

- Read params fresh each tick instead of stale closure
- Add oscillator FM, noise, and generic fallback animations
- Any modulation source now shows visual feedback on target knob

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jose Luis
2026-03-22 18:08:44 +01:00
parent a0a3b58b49
commit 925043e055

View File

@@ -60,7 +60,7 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
} }
} }
// ==================== Live modulation visualization (LFO + Envelope + any CV) ==================== // ==================== Live modulation visualization (any source any param) ====================
const [liveValues, setLiveValues] = useState({}); const [liveValues, setLiveValues] = useState({});
const rafRef = useRef(null); const rafRef = useRef(null);
const startTimeRef = useRef(performance.now() / 1000); const startTimeRef = useRef(performance.now() / 1000);
@@ -80,6 +80,13 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
const t = performance.now() / 1000 - startTimeRef.current; const t = performance.now() / 1000 - startTimeRef.current;
const newValues = {}; const newValues = {};
// Read current params fresh from state each tick (avoid stale closure)
const curMod = state.modules.find(m => m.id === mod.id);
if (!curMod) return;
const curDef = getModuleDef(curMod.type);
if (!curDef) return;
const curParams = { ...Object.fromEntries(Object.entries(curDef.params).map(([k, v]) => [k, v.default])), ...curMod.params };
for (const conn of state.connections) { for (const conn of state.connections) {
if (conn.to.moduleId !== mod.id) continue; if (conn.to.moduleId !== mod.id) continue;
const paramName = portMap[conn.to.port]; const paramName = portMap[conn.to.port];
@@ -88,40 +95,55 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
const srcMod = state.modules.find(m => m.id === conn.from.moduleId); const srcMod = state.modules.find(m => m.id === conn.from.moduleId);
if (!srcMod) continue; if (!srcMod) continue;
const baseValue = curParams[paramName];
// Modulation scale based on target parameter
const getScale = () => {
if (curMod.type === 'oscillator' && paramName === 'frequency') return baseValue * 0.5;
if (curMod.type === 'filter' && paramName === 'frequency') return baseValue;
if (curMod.type === 'vca' && paramName === 'gain') return 1;
return baseValue || 1;
};
if (srcMod.type === 'lfo') { if (srcMod.type === 'lfo') {
// LFO: simulate waveform for smooth visual // LFO: simulate waveform for smooth visual
const lfoDef = getModuleDef('lfo'); const lfoDef = getModuleDef('lfo');
const lfoP = { ...Object.fromEntries(Object.entries(lfoDef.params).map(([k, v]) => [k, v.default])), ...srcMod.params }; const lfoP = { ...Object.fromEntries(Object.entries(lfoDef.params).map(([k, v]) => [k, v.default])), ...srcMod.params };
const freq = lfoP.frequency; const phase = (t * lfoP.frequency) % 1;
const amp = lfoP.amplitude; const lfoVal = simulateLFO(lfoP.waveform, phase) * lfoP.amplitude;
const waveform = lfoP.waveform; newValues[paramName] = baseValue + lfoVal * getScale();
const phase = (t * freq) % 1;
const lfoVal = simulateLFO(waveform, phase) * amp;
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;
} else if (srcMod.type === 'envelope') { } else if (srcMod.type === 'envelope') {
// Envelope: read the envelope's current level (0-1) from the source module // Envelope: read current level (0-1) from the source envelope node
const envEntry = getAudioNode(srcMod.id); const envEntry = getAudioNode(srcMod.id);
if (envEntry?.node) { if (envEntry?.node) {
const envValue = typeof envEntry.node.value === 'number' ? envEntry.node.value : 0; const envValue = typeof envEntry.node.value === 'number' ? envEntry.node.value : 0;
const baseValue = params[paramName]; if (curMod.type === 'vca' && paramName === 'gain') {
if (mod.type === 'vca' && paramName === 'gain') {
newValues[paramName] = envValue; // Envelope directly drives gain (0→1) newValues[paramName] = envValue; // Envelope directly drives gain (0→1)
} else { } else {
let scale; newValues[paramName] = baseValue + envValue * getScale();
if (mod.type === 'oscillator' && paramName === 'frequency') scale = baseValue * 0.5;
else if (mod.type === 'filter' && paramName === 'frequency') scale = baseValue;
else scale = baseValue || 1;
newValues[paramName] = baseValue + envValue * scale;
} }
} }
} else if (srcMod.type === 'oscillator') {
// Oscillator FM: simulate modulating oscillator waveform
const srcDef = getModuleDef('oscillator');
const srcP = { ...Object.fromEntries(Object.entries(srcDef.params).map(([k, v]) => [k, v.default])), ...srcMod.params };
// Clamp visual frequency to avoid aliasing — show a slow representation
const visFreq = Math.min(srcP.frequency, 8);
const phase = (t * visFreq) % 1;
const modVal = simulateLFO(srcP.waveform, phase) * 0.5;
newValues[paramName] = baseValue + modVal * getScale();
} else if (srcMod.type === 'noise') {
// Noise: random jitter
const noiseVal = (Math.random() * 2 - 1) * 0.3;
newValues[paramName] = baseValue + noiseVal * getScale();
} else {
// Generic fallback: subtle visual pulse so user sees modulation is active
const pulseVal = Math.sin(2 * Math.PI * t) * 0.2;
newValues[paramName] = baseValue + pulseVal * getScale();
} }
} }