Files
reaktor/src/engine/audioEngine.js
Jose Luis 38dca9402f 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>
2026-03-21 18:44:28 +01:00

597 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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();
}