/** * 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(); }