fix: VCA closes properly with envelope + add CV→Gate module

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) <noreply@anthropic.com>
This commit is contained in:
Jose Luis
2026-03-21 18:44:28 +01:00
parent 7e6c960b0b
commit 38dca9402f
2 changed files with 77 additions and 4 deletions

View File

@@ -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) {

View File

@@ -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', {