From 38dca9402f05ace581633d61d37cb2ad92c304a2 Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Sat, 21 Mar 2026 18:44:28 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20VCA=20closes=20properly=20with=20envelop?= =?UTF-8?q?e=20+=20add=20CV=E2=86=92Gate=20module?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VCA fix: - Add cvMod scaler (like oscillator/filter have) so envelope (0-1) is scaled by the gain param before modulating VCA - Zero base gain when CV is connected (in rebuildGraph) so envelope = 0 produces silence instead of falling back to base gain - updateParam keeps cvMod in sync with gain knob New module: CV→Gate (⚡) in Utility category: - Converts continuous CV signal (e.g. LFO) to gate on/off - Threshold knob (0-1, default 0.5): signal above = gate on - Reads analyser on master clock tick for threshold comparison - Triggers/releases connected envelopes automatically - Use case: LFO → CV→Gate → Envelope → VCA for rhythmic gating Co-Authored-By: Claude Opus 4.6 (1M context) --- src/engine/audioEngine.js | 64 +++++++++++++++++++++++++++++++++--- src/engine/moduleRegistry.js | 17 ++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/src/engine/audioEngine.js b/src/engine/audioEngine.js index 07b6f5e..0ef8391 100644 --- a/src/engine/audioEngine.js +++ b/src/engine/audioEngine.js @@ -126,13 +126,16 @@ function createNode(mod) { }; } case 'vca': { - // Use a Multiply node: in × cv const gain = new Tone.Gain(p.gain); + // CV modulation scaler: envelope (0-1) × gain param → added to gain.gain + const cvMod = new Tone.Gain(p.gain); + cvMod.connect(gain.gain); return { node: gain, - inputs: { in: gain, cv: gain.gain }, + _cvMod: cvMod, + inputs: { in: gain, cv: cvMod }, outputs: { out: gain }, - dispose: () => gain.dispose(), + dispose: () => { cvMod.disconnect(); cvMod.dispose(); gain.dispose(); }, }; } case 'delay': { @@ -187,6 +190,20 @@ function createNode(mod) { dispose: () => analyser.dispose(), }; } + case 'cv2gate': { + // Converts a continuous CV signal to gate on/off based on threshold. + // Uses an analyser to read the CV value and triggers connected envelopes. + const analyser = new Tone.Analyser('waveform', 32); + const gateSig = new Tone.Signal(0); + return { + node: analyser, + _gateSig: gateSig, + _gateState: false, + inputs: { in: analyser }, + outputs: { gate: gateSig }, + dispose: () => { analyser.dispose(); gateSig.dispose(); }, + }; + } case 'output': { // True stereo output: separate left/right channels → merge → master gain → destination const leftGain = new Tone.Gain(1); @@ -372,7 +389,11 @@ export function updateParam(moduleId, paramName, value) { else if (paramName === 'release') entry.node.release = value; break; case 'vca': - if (paramName === 'gain') entry.node.gain.value = value; + if (paramName === 'gain') { + const hasCV = state.connections.some(c => c.to.moduleId === moduleId && c.to.port === 'cv'); + if (!hasCV) entry.node.gain.value = value; + if (entry._cvMod) entry._cvMod.gain.value = value; + } break; case 'delay': if (paramName === 'delayTime') entry.node.delayTime.value = value; @@ -398,6 +419,7 @@ export function updateParam(moduleId, paramName, value) { break; case 'keyboard': case 'drumpad': + case 'cv2gate': case 'sequencer': case 'pianoroll': // All params stored in state, managed by widgets @@ -517,6 +539,15 @@ export function rebuildGraph() { connectWire(conn); } + // Zero base gain on VCAs with active CV connection. + // When envelope controls VCA, base gain must be 0 so silence is possible. + for (const mod of state.modules) { + if (mod.type !== 'vca') continue; + const hasCV = state.connections.some(c => c.to.moduleId === mod.id && c.to.port === 'cv'); + const entry = audioNodes[mod.id]; + if (entry && hasCV) entry.node.gain.value = 0; + } + // Auto-trigger envelopes that have no gate connection (free-running mode). // This allows noise/ambient patches to work without a keyboard/sequencer. for (const mod of state.modules) { @@ -531,6 +562,31 @@ export function rebuildGraph() { } } } + + // Register CV→Gate modules on master clock for threshold detection + for (const mod of state.modules) { + if (mod.type !== 'cv2gate') continue; + const entry = audioNodes[mod.id]; + if (!entry) continue; + subscribeTick(`cv2gate-${mod.id}`, () => { + const data = entry.node.getValue(); + const sample = typeof data === 'number' ? data : (data?.[0] ?? 0); + const threshold = mod.params?.threshold ?? 0.5; + const gateOn = sample > threshold; + if (gateOn !== entry._gateState) { + entry._gateState = gateOn; + entry._gateSig.value = gateOn ? 1 : 0; + // Trigger/release connected envelopes + for (const conn of getConnectionsFrom(mod.id, 'gate')) { + const envEntry = audioNodes[conn.to.moduleId]; + if (envEntry && envEntry.node instanceof Tone.Envelope) { + if (gateOn) envEntry.node.triggerAttack(); + else envEntry.node.triggerRelease(); + } + } + } + }); + } } export function getAnalyserData(moduleId) { diff --git a/src/engine/moduleRegistry.js b/src/engine/moduleRegistry.js index 5b7250e..2a81a2f 100644 --- a/src/engine/moduleRegistry.js +++ b/src/engine/moduleRegistry.js @@ -226,6 +226,23 @@ defineModule('scope', { params: {}, }); +// ==================== CV TO GATE ==================== + +defineModule('cv2gate', { + name: 'CV→Gate', + icon: '⚡', + category: 'Utility', + inputs: [ + { name: 'in', type: PORT_TYPE.CONTROL, label: 'CV In' }, + ], + outputs: [ + { name: 'gate', type: PORT_TYPE.TRIGGER, label: 'Gate' }, + ], + params: { + threshold: { type: 'knob', min: 0, max: 1, default: 0.5, unit: '', label: 'Thresh' }, + }, +}); + // ==================== OUTPUT ==================== defineModule('output', {