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:
@@ -126,13 +126,16 @@ function createNode(mod) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'vca': {
|
case 'vca': {
|
||||||
// Use a Multiply node: in × cv
|
|
||||||
const gain = new Tone.Gain(p.gain);
|
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 {
|
return {
|
||||||
node: gain,
|
node: gain,
|
||||||
inputs: { in: gain, cv: gain.gain },
|
_cvMod: cvMod,
|
||||||
|
inputs: { in: gain, cv: cvMod },
|
||||||
outputs: { out: gain },
|
outputs: { out: gain },
|
||||||
dispose: () => gain.dispose(),
|
dispose: () => { cvMod.disconnect(); cvMod.dispose(); gain.dispose(); },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'delay': {
|
case 'delay': {
|
||||||
@@ -187,6 +190,20 @@ function createNode(mod) {
|
|||||||
dispose: () => analyser.dispose(),
|
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': {
|
case 'output': {
|
||||||
// True stereo output: separate left/right channels → merge → master gain → destination
|
// True stereo output: separate left/right channels → merge → master gain → destination
|
||||||
const leftGain = new Tone.Gain(1);
|
const leftGain = new Tone.Gain(1);
|
||||||
@@ -372,7 +389,11 @@ export function updateParam(moduleId, paramName, value) {
|
|||||||
else if (paramName === 'release') entry.node.release = value;
|
else if (paramName === 'release') entry.node.release = value;
|
||||||
break;
|
break;
|
||||||
case 'vca':
|
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;
|
break;
|
||||||
case 'delay':
|
case 'delay':
|
||||||
if (paramName === 'delayTime') entry.node.delayTime.value = value;
|
if (paramName === 'delayTime') entry.node.delayTime.value = value;
|
||||||
@@ -398,6 +419,7 @@ export function updateParam(moduleId, paramName, value) {
|
|||||||
break;
|
break;
|
||||||
case 'keyboard':
|
case 'keyboard':
|
||||||
case 'drumpad':
|
case 'drumpad':
|
||||||
|
case 'cv2gate':
|
||||||
case 'sequencer':
|
case 'sequencer':
|
||||||
case 'pianoroll':
|
case 'pianoroll':
|
||||||
// All params stored in state, managed by widgets
|
// All params stored in state, managed by widgets
|
||||||
@@ -517,6 +539,15 @@ export function rebuildGraph() {
|
|||||||
connectWire(conn);
|
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).
|
// Auto-trigger envelopes that have no gate connection (free-running mode).
|
||||||
// This allows noise/ambient patches to work without a keyboard/sequencer.
|
// This allows noise/ambient patches to work without a keyboard/sequencer.
|
||||||
for (const mod of state.modules) {
|
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) {
|
export function getAnalyserData(moduleId) {
|
||||||
|
|||||||
@@ -226,6 +226,23 @@ defineModule('scope', {
|
|||||||
params: {},
|
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 ====================
|
// ==================== OUTPUT ====================
|
||||||
|
|
||||||
defineModule('output', {
|
defineModule('output', {
|
||||||
|
|||||||
Reference in New Issue
Block a user