fix: VCA zeroes on live CV connect + visual feedback for envelope control

VCA fix:
- connectWire now zeros VCA base gain immediately when CV is connected
  (previously only rebuildGraph did this, missing live-connect case)
- disconnectWire restores base gain from params when CV is removed

Visual modulation feedback:
- ModuleNode RAF loop now handles envelope sources (not just LFO)
- Reads actual Tone.js gain node value for real-time display
- VCA gain knob shows live envelope value during playback
- LFO visualization unchanged (simulated waveform as before)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jose Luis
2026-03-21 18:50:28 +01:00
parent 38dca9402f
commit 2a2b3b3341
2 changed files with 38 additions and 20 deletions

View File

@@ -1,7 +1,7 @@
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 { updateParam, getAudioNode } from '../engine/audioEngine.js';
import Knob from './Knob.jsx';
import ScopeDisplay from './ScopeDisplay.jsx';
import KeyboardWidget from './KeyboardWidget.jsx';
@@ -60,7 +60,7 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
}
}
// ==================== Live LFO modulation visualization ====================
// ==================== Live modulation visualization (LFO + Envelope + any CV) ====================
const [liveValues, setLiveValues] = useState({});
const rafRef = useRef(null);
const startTimeRef = useRef(performance.now() / 1000);
@@ -75,7 +75,6 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
const tick = () => {
frameCount++;
rafRef.current = requestAnimationFrame(tick);
// Throttle to ~15fps (every 4th frame) to reduce main thread pressure
if (frameCount % 4 !== 0) return;
const t = performance.now() / 1000 - startTimeRef.current;
@@ -87,26 +86,34 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
if (!paramName) continue;
const srcMod = state.modules.find(m => m.id === conn.from.moduleId);
if (!srcMod || srcMod.type !== 'lfo') continue;
if (!srcMod) 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;
if (srcMod.type === 'lfo') {
// LFO: simulate waveform for smooth visual
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;
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;
newValues[paramName] = baseValue + lfoVal * scale;
} else if (srcMod.type === 'envelope') {
// Envelope: read the actual audio node gain value for real-time display
const audioEntry = getAudioNode(mod.id);
if (audioEntry?.node?.gain) {
const currentGain = audioEntry.node.gain.value;
newValues[paramName] = currentGain;
}
}
}
setLiveValues(newValues);

View File

@@ -327,6 +327,11 @@ export function connectWire(conn) {
} catch (e) {
console.warn('connect error', e);
}
// When CV is connected to VCA, zero the base gain so only envelope controls it
if (toMod?.type === 'vca' && conn.to.port === 'cv') {
toEntry.node.gain.value = 0;
}
}
export function disconnectWire(conn) {
@@ -345,6 +350,12 @@ export function disconnectWire(conn) {
} catch (e) {
// Tone.js may throw if not connected
}
// When CV is disconnected from VCA, restore base gain from params
const toMod = state.modules.find(m => m.id === conn.to.moduleId);
if (toMod?.type === 'vca' && conn.to.port === 'cv') {
toEntry.node.gain.value = toMod.params?.gain ?? 0.8;
}
}
export function updateParam(moduleId, paramName, value) {