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>
597 lines
20 KiB
JavaScript
597 lines
20 KiB
JavaScript
/**
|
||
* audioEngine.js — Bridge between node graph state and Tone.js audio graph
|
||
* Creates, connects, and destroys Tone.js nodes as the user edits the patch
|
||
*/
|
||
import * as Tone from 'tone';
|
||
import { state } from './state.js';
|
||
import { getModuleDef } from './moduleRegistry.js';
|
||
|
||
// Map moduleId → { node: Tone.js node, inputs: {portName: node/param}, outputs: {portName: node} }
|
||
const audioNodes = {};
|
||
|
||
// Active keyboard state
|
||
const keyboardState = { frequency: 440, gate: false };
|
||
|
||
// ==================== Global Master Clock ====================
|
||
// Single clock with integer tick counter. All sequencers/piano rolls
|
||
// derive their step positions from this shared tick count.
|
||
// Using integers avoids floating-point drift entirely.
|
||
export const MASTER_TICK_RATE = 120; // Hz — 6x headroom for 300 BPM sixteenths (20 Hz). Lower = less main thread pressure.
|
||
let _masterClock = null;
|
||
const _tickListeners = new Map(); // id → callback(audioTime, ticks)
|
||
|
||
export function subscribeTick(id, callback) {
|
||
_tickListeners.set(id, callback);
|
||
}
|
||
|
||
export function unsubscribeTick(id) {
|
||
_tickListeners.delete(id);
|
||
}
|
||
|
||
function startMasterClock() {
|
||
if (_masterClock) return;
|
||
let _startTime = 0;
|
||
let _started = false;
|
||
_masterClock = new Tone.Clock((time) => {
|
||
if (!_started) { _startTime = time; _started = true; }
|
||
// Derive ticks from precise AudioContext.currentTime, not a counter.
|
||
// Counters fall behind when callbacks are delayed (GC, UI, tab throttle).
|
||
// The time parameter is always accurate regardless of callback jitter.
|
||
const ticks = Math.round((time - _startTime) * MASTER_TICK_RATE);
|
||
for (const cb of _tickListeners.values()) {
|
||
cb(time, ticks);
|
||
}
|
||
}, MASTER_TICK_RATE);
|
||
_masterClock.start();
|
||
}
|
||
|
||
function stopMasterClock() {
|
||
if (_masterClock) {
|
||
try { _masterClock.stop(); } catch {}
|
||
try { _masterClock.dispose(); } catch {}
|
||
_masterClock = null;
|
||
}
|
||
_tickListeners.clear();
|
||
}
|
||
|
||
// ==================== Node creation ====================
|
||
|
||
function createNode(mod) {
|
||
const def = getModuleDef(mod.type);
|
||
if (!def) return null;
|
||
|
||
const p = { ...Object.fromEntries(Object.entries(def.params).map(([k, v]) => [k, v.default])), ...mod.params };
|
||
|
||
switch (mod.type) {
|
||
case 'oscillator': {
|
||
const osc = new Tone.Oscillator({ type: p.waveform, frequency: p.frequency, detune: p.detune });
|
||
osc.start();
|
||
// Modulation scaler for freq input: LFO (-1..1) × scale → added to osc.frequency
|
||
// Scale = half the current frequency so modulation is musically meaningful
|
||
const freqMod = new Tone.Gain(p.frequency * 0.5);
|
||
freqMod.connect(osc.frequency);
|
||
return {
|
||
node: osc,
|
||
_freqMod: freqMod,
|
||
inputs: { freq: freqMod, detune: osc.detune },
|
||
outputs: { out: osc },
|
||
dispose: () => { osc.stop(); freqMod.disconnect(); freqMod.dispose(); osc.dispose(); },
|
||
};
|
||
}
|
||
case 'lfo': {
|
||
const lfo = new Tone.LFO({ type: p.waveform, frequency: p.frequency, amplitude: p.amplitude, min: -1, max: 1 });
|
||
lfo.start();
|
||
return {
|
||
node: lfo,
|
||
inputs: {},
|
||
outputs: { out: lfo },
|
||
dispose: () => { lfo.stop(); lfo.dispose(); },
|
||
};
|
||
}
|
||
case 'noise': {
|
||
const noise = new Tone.Noise(p.type);
|
||
noise.start();
|
||
return {
|
||
node: noise,
|
||
inputs: {},
|
||
outputs: { out: noise },
|
||
dispose: () => { noise.stop(); noise.dispose(); },
|
||
};
|
||
}
|
||
case 'filter': {
|
||
const filter = new Tone.Filter({ type: p.type, frequency: p.frequency, Q: p.Q });
|
||
// Modulation scaler for cutoff input: LFO (-1..1) × scale → added to filter.frequency
|
||
// Scale = cutoff value so full LFO sweep covers 0 to 2× the cutoff
|
||
const cutoffMod = new Tone.Gain(p.frequency);
|
||
cutoffMod.connect(filter.frequency);
|
||
return {
|
||
node: filter,
|
||
_cutoffMod: cutoffMod,
|
||
inputs: { in: filter, cutoff: cutoffMod },
|
||
outputs: { out: filter },
|
||
dispose: () => { cutoffMod.disconnect(); cutoffMod.dispose(); filter.dispose(); },
|
||
};
|
||
}
|
||
case 'envelope': {
|
||
const env = new Tone.Envelope({ attack: p.attack, decay: p.decay, sustain: p.sustain, release: p.release });
|
||
// Connect env to a signal so it can be used as modulation source
|
||
const sig = new Tone.Signal(0);
|
||
env.connect(sig);
|
||
return {
|
||
node: env,
|
||
_sig: sig,
|
||
inputs: { gate: null }, // Gate is handled via triggerAttack/Release
|
||
outputs: { out: sig },
|
||
dispose: () => { env.dispose(); sig.dispose(); },
|
||
};
|
||
}
|
||
case 'vca': {
|
||
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,
|
||
_cvMod: cvMod,
|
||
inputs: { in: gain, cv: cvMod },
|
||
outputs: { out: gain },
|
||
dispose: () => { cvMod.disconnect(); cvMod.dispose(); gain.dispose(); },
|
||
};
|
||
}
|
||
case 'delay': {
|
||
const delay = new Tone.FeedbackDelay({ delayTime: p.delayTime, feedback: p.feedback, wet: p.wet });
|
||
return {
|
||
node: delay,
|
||
inputs: { in: delay },
|
||
outputs: { out: delay },
|
||
dispose: () => delay.dispose(),
|
||
};
|
||
}
|
||
case 'reverb': {
|
||
const reverb = new Tone.Reverb({ decay: p.decay, wet: p.wet });
|
||
return {
|
||
node: reverb,
|
||
inputs: { in: reverb },
|
||
outputs: { out: reverb },
|
||
dispose: () => reverb.dispose(),
|
||
};
|
||
}
|
||
case 'distortion': {
|
||
const dist = new Tone.Distortion({ distortion: p.distortion, wet: p.wet });
|
||
return {
|
||
node: dist,
|
||
inputs: { in: dist },
|
||
outputs: { out: dist },
|
||
dispose: () => dist.dispose(),
|
||
};
|
||
}
|
||
case 'mixer': {
|
||
const master = new Tone.Gain(1);
|
||
const ch1 = new Tone.Gain(p.gain1);
|
||
const ch2 = new Tone.Gain(p.gain2);
|
||
const ch3 = new Tone.Gain(p.gain3);
|
||
const ch4 = new Tone.Gain(p.gain4);
|
||
ch1.connect(master); ch2.connect(master); ch3.connect(master); ch4.connect(master);
|
||
return {
|
||
node: master,
|
||
_channels: [ch1, ch2, ch3, ch4],
|
||
inputs: { in1: ch1, in2: ch2, in3: ch3, in4: ch4 },
|
||
outputs: { out: master },
|
||
dispose: () => { [ch1, ch2, ch3, ch4, master].forEach(n => n.dispose()); },
|
||
};
|
||
}
|
||
case 'scope': {
|
||
const analyser = new Tone.Analyser('waveform', 2048);
|
||
return {
|
||
node: analyser,
|
||
inputs: { in: analyser },
|
||
outputs: {},
|
||
analyser,
|
||
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);
|
||
const rightGain = new Tone.Gain(1);
|
||
const merge = new Tone.Merge();
|
||
const master = new Tone.Gain(Tone.dbToGain(p.volume));
|
||
leftGain.connect(merge, 0, 0);
|
||
rightGain.connect(merge, 0, 1);
|
||
merge.connect(master);
|
||
master.toDestination();
|
||
return {
|
||
node: master,
|
||
_merge: merge,
|
||
_leftGain: leftGain,
|
||
_rightGain: rightGain,
|
||
inputs: { left: leftGain, right: rightGain },
|
||
outputs: {},
|
||
dispose: () => {
|
||
leftGain.disconnect(); leftGain.dispose();
|
||
rightGain.disconnect(); rightGain.dispose();
|
||
merge.disconnect(); merge.dispose();
|
||
master.disconnect(); master.dispose();
|
||
},
|
||
};
|
||
}
|
||
case 'keyboard':
|
||
case 'drumpad': {
|
||
const freqSig = new Tone.Signal(440);
|
||
const gateSig = new Tone.Signal(0);
|
||
return {
|
||
node: null,
|
||
inputs: {},
|
||
outputs: { freq: freqSig, gate: gateSig },
|
||
_freqSig: freqSig,
|
||
_gateSig: gateSig,
|
||
dispose: () => { freqSig.dispose(); gateSig.dispose(); },
|
||
};
|
||
}
|
||
case 'sequencer': {
|
||
const freqSig = new Tone.Signal(440);
|
||
const gateSig = new Tone.Signal(0);
|
||
// Sequencer loop managed externally by SequencerWidget
|
||
return {
|
||
node: null,
|
||
inputs: {},
|
||
outputs: { freq: freqSig, gate: gateSig },
|
||
_freqSig: freqSig,
|
||
_gateSig: gateSig,
|
||
_seq: null, // Tone.Sequence set by widget
|
||
dispose: () => {
|
||
freqSig.dispose(); gateSig.dispose();
|
||
},
|
||
};
|
||
}
|
||
case 'pianoroll': {
|
||
const freqSig = new Tone.Signal(440);
|
||
const gateSig = new Tone.Signal(0);
|
||
return {
|
||
node: null,
|
||
inputs: {},
|
||
outputs: { freq: freqSig, gate: gateSig },
|
||
_freqSig: freqSig,
|
||
_gateSig: gateSig,
|
||
_part: null, // Tone.Part set by widget
|
||
dispose: () => {
|
||
freqSig.dispose(); gateSig.dispose();
|
||
},
|
||
};
|
||
}
|
||
default:
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ==================== Public API ====================
|
||
|
||
export function ensureNode(moduleId) {
|
||
if (audioNodes[moduleId]) return audioNodes[moduleId];
|
||
const mod = state.modules.find(m => m.id === moduleId);
|
||
if (!mod) return null;
|
||
const node = createNode(mod);
|
||
if (node) audioNodes[moduleId] = node;
|
||
return node;
|
||
}
|
||
|
||
export function getAudioNode(moduleId) {
|
||
return audioNodes[moduleId] || null;
|
||
}
|
||
|
||
export function destroyNode(moduleId) {
|
||
const entry = audioNodes[moduleId];
|
||
if (!entry) return;
|
||
try { entry.dispose(); } catch (e) { console.warn('dispose error', e); }
|
||
delete audioNodes[moduleId];
|
||
}
|
||
|
||
export function connectWire(conn) {
|
||
const fromEntry = ensureNode(conn.from.moduleId);
|
||
const toEntry = ensureNode(conn.to.moduleId);
|
||
if (!fromEntry || !toEntry) return;
|
||
|
||
// Skip audio-graph connection for keyboard/sequencer/pianoroll freq → oscillator freq.
|
||
// These signals carry absolute Hz values that would be mangled by the oscillator's
|
||
// frequency-modulation Gain scaler. Instead, triggerKeyboard / setSequencerSignals
|
||
// set the oscillator frequency directly when notes are played.
|
||
const fromMod = state.modules.find(m => m.id === conn.from.moduleId);
|
||
const toMod = state.modules.find(m => m.id === conn.to.moduleId);
|
||
if (fromMod && ['keyboard', 'drumpad', 'sequencer', 'pianoroll'].includes(fromMod.type) &&
|
||
conn.from.port === 'freq' && toMod?.type === 'oscillator' && conn.to.port === 'freq') {
|
||
return; // handled imperatively in triggerKeyboard / setSequencerSignals
|
||
}
|
||
|
||
const output = fromEntry.outputs[conn.from.port];
|
||
const input = toEntry.inputs[conn.to.port];
|
||
if (!output || input === undefined || input === null) return;
|
||
|
||
try {
|
||
if (typeof output.connect === 'function') {
|
||
output.connect(input);
|
||
}
|
||
} catch (e) {
|
||
console.warn('connect error', e);
|
||
}
|
||
}
|
||
|
||
export function disconnectWire(conn) {
|
||
const fromEntry = audioNodes[conn.from.moduleId];
|
||
const toEntry = audioNodes[conn.to.moduleId];
|
||
if (!fromEntry || !toEntry) return;
|
||
|
||
const output = fromEntry.outputs[conn.from.port];
|
||
const input = toEntry.inputs[conn.to.port];
|
||
if (!output || !input) return;
|
||
|
||
try {
|
||
if (typeof output.disconnect === 'function') {
|
||
output.disconnect(input);
|
||
}
|
||
} catch (e) {
|
||
// Tone.js may throw if not connected
|
||
}
|
||
}
|
||
|
||
export function updateParam(moduleId, paramName, value) {
|
||
const entry = audioNodes[moduleId];
|
||
const mod = state.modules.find(m => m.id === moduleId);
|
||
if (!entry || !mod) return;
|
||
|
||
const def = getModuleDef(mod.type);
|
||
if (!def) return;
|
||
|
||
switch (mod.type) {
|
||
case 'oscillator':
|
||
if (paramName === 'waveform') entry.node.type = value;
|
||
else if (paramName === 'frequency') {
|
||
entry.node.frequency.value = value;
|
||
// Update mod scaler proportionally
|
||
if (entry._freqMod) entry._freqMod.gain.value = value * 0.5;
|
||
}
|
||
else if (paramName === 'detune') entry.node.detune.value = value;
|
||
break;
|
||
case 'lfo':
|
||
if (paramName === 'waveform') entry.node.type = value;
|
||
else if (paramName === 'frequency') entry.node.frequency.value = value;
|
||
else if (paramName === 'amplitude') entry.node.amplitude.value = value;
|
||
break;
|
||
case 'noise':
|
||
if (paramName === 'type') entry.node.type = value;
|
||
break;
|
||
case 'filter':
|
||
if (paramName === 'type') entry.node.type = value;
|
||
else if (paramName === 'frequency') {
|
||
entry.node.frequency.value = value;
|
||
// Update mod scaler proportionally
|
||
if (entry._cutoffMod) entry._cutoffMod.gain.value = value;
|
||
}
|
||
else if (paramName === 'Q') entry.node.Q.value = value;
|
||
break;
|
||
case 'envelope':
|
||
if (paramName === 'attack') entry.node.attack = value;
|
||
else if (paramName === 'decay') entry.node.decay = value;
|
||
else if (paramName === 'sustain') entry.node.sustain = value;
|
||
else if (paramName === 'release') entry.node.release = value;
|
||
break;
|
||
case 'vca':
|
||
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;
|
||
else if (paramName === 'feedback') entry.node.feedback.value = value;
|
||
else if (paramName === 'wet') entry.node.wet.value = value;
|
||
break;
|
||
case 'reverb':
|
||
if (paramName === 'decay') entry.node.decay = value;
|
||
else if (paramName === 'wet') entry.node.wet.value = value;
|
||
break;
|
||
case 'distortion':
|
||
if (paramName === 'distortion') entry.node.distortion = value;
|
||
else if (paramName === 'wet') entry.node.wet.value = value;
|
||
break;
|
||
case 'mixer':
|
||
if (paramName.startsWith('gain')) {
|
||
const idx = parseInt(paramName.replace('gain', '')) - 1;
|
||
if (entry._channels && entry._channels[idx]) entry._channels[idx].gain.value = value;
|
||
}
|
||
break;
|
||
case 'output':
|
||
if (paramName === 'volume') entry.node.gain.value = Tone.dbToGain(value);
|
||
break;
|
||
case 'keyboard':
|
||
case 'drumpad':
|
||
case 'cv2gate':
|
||
case 'sequencer':
|
||
case 'pianoroll':
|
||
// All params stored in state, managed by widgets
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Cache connection lookups for hot-path audio scheduling
|
||
// Rebuilt only when connections actually change (dirty flag, no computation on hit)
|
||
let _connCacheDirty = true;
|
||
const _connByModulePort = new Map(); // "moduleId-portName" → [connections]
|
||
|
||
export function invalidateConnectionCache() {
|
||
_connCacheDirty = true;
|
||
}
|
||
|
||
function getConnectionsFrom(moduleId, portName) {
|
||
if (_connCacheDirty) {
|
||
_connByModulePort.clear();
|
||
for (const conn of state.connections) {
|
||
const key = `${conn.from.moduleId}-${conn.from.port}`;
|
||
if (!_connByModulePort.has(key)) _connByModulePort.set(key, []);
|
||
_connByModulePort.get(key).push(conn);
|
||
}
|
||
_connCacheDirty = false;
|
||
}
|
||
return _connByModulePort.get(`${moduleId}-${portName}`) || [];
|
||
}
|
||
|
||
export function setSequencerSignals(moduleId, freq, gate) {
|
||
const entry = audioNodes[moduleId];
|
||
if (!entry) return;
|
||
if (entry._freqSig) entry._freqSig.value = freq;
|
||
if (entry._gateSig) entry._gateSig.value = gate ? 1 : 0;
|
||
|
||
// Set connected oscillator frequencies directly
|
||
for (const conn of getConnectionsFrom(moduleId, 'freq')) {
|
||
const oscEntry = audioNodes[conn.to.moduleId];
|
||
if (oscEntry?.node?.frequency) {
|
||
oscEntry.node.frequency.value = freq;
|
||
}
|
||
}
|
||
|
||
// Trigger connected envelopes
|
||
for (const conn of getConnectionsFrom(moduleId, 'gate')) {
|
||
const envEntry = audioNodes[conn.to.moduleId];
|
||
if (envEntry && envEntry.node instanceof Tone.Envelope) {
|
||
if (gate) envEntry.node.triggerAttack();
|
||
else envEntry.node.triggerRelease();
|
||
}
|
||
}
|
||
}
|
||
|
||
export function triggerKeyboard(moduleId, freq, gate) {
|
||
const entry = audioNodes[moduleId];
|
||
if (!entry) return;
|
||
if (entry._freqSig) entry._freqSig.value = freq;
|
||
if (entry._gateSig) entry._gateSig.value = gate ? 1 : 0;
|
||
|
||
// Set connected oscillator frequencies directly
|
||
for (const conn of getConnectionsFrom(moduleId, 'freq')) {
|
||
const oscEntry = audioNodes[conn.to.moduleId];
|
||
if (oscEntry?.node?.frequency) {
|
||
oscEntry.node.frequency.value = freq;
|
||
}
|
||
}
|
||
|
||
// Trigger connected envelopes
|
||
for (const conn of getConnectionsFrom(moduleId, 'gate')) {
|
||
const envEntry = audioNodes[conn.to.moduleId];
|
||
if (envEntry && envEntry.node instanceof Tone.Envelope) {
|
||
if (gate) envEntry.node.triggerAttack();
|
||
else envEntry.node.triggerRelease();
|
||
}
|
||
}
|
||
}
|
||
|
||
export async function startAudio() {
|
||
await Tone.start();
|
||
state.isRunning = true;
|
||
startMasterClock();
|
||
|
||
// Rebuild entire audio graph
|
||
rebuildGraph();
|
||
}
|
||
|
||
export function stopAudio() {
|
||
stopMasterClock();
|
||
|
||
// Stop and reset Transport
|
||
try {
|
||
Tone.getTransport().stop();
|
||
Tone.getTransport().cancel();
|
||
Tone.getTransport().position = 0;
|
||
} catch (e) {}
|
||
|
||
// Destroy all nodes
|
||
for (const id of Object.keys(audioNodes)) {
|
||
destroyNode(parseInt(id));
|
||
}
|
||
state.isRunning = false;
|
||
}
|
||
|
||
export function rebuildGraph() {
|
||
// Destroy all existing nodes
|
||
for (const id of Object.keys(audioNodes)) {
|
||
destroyNode(parseInt(id));
|
||
}
|
||
|
||
// Create nodes for all modules
|
||
for (const mod of state.modules) {
|
||
ensureNode(mod.id);
|
||
}
|
||
|
||
// Create all connections
|
||
for (const conn of state.connections) {
|
||
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) {
|
||
if (mod.type !== 'envelope') continue;
|
||
const hasGateInput = state.connections.some(
|
||
c => c.to.moduleId === mod.id && c.to.port === 'gate'
|
||
);
|
||
if (!hasGateInput) {
|
||
const entry = audioNodes[mod.id];
|
||
if (entry && entry.node && typeof entry.node.triggerAttack === 'function') {
|
||
entry.node.triggerAttack();
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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) {
|
||
const entry = audioNodes[moduleId];
|
||
if (!entry || !entry.analyser) return null;
|
||
return entry.analyser.getValue();
|
||
}
|