/** * 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 }; // ==================== 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': { // Use a Multiply node: in × cv const gain = new Tone.Gain(p.gain); return { node: gain, inputs: { in: gain, cv: gain.gain }, outputs: { out: gain }, 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 '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') entry.node.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 'sequencer': case 'pianoroll': // All params stored in state, managed by widgets break; } } 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; // Directly set connected oscillator frequencies (bypasses the modulation Gain) for (const conn of state.connections) { if (conn.from.moduleId === moduleId && conn.from.port === 'freq') { const oscEntry = audioNodes[conn.to.moduleId]; const oscMod = state.modules.find(m => m.id === conn.to.moduleId); if (oscEntry?.node && oscMod?.type === 'oscillator') { oscEntry.node.frequency.value = freq; } } } // Trigger connected envelopes for (const conn of state.connections) { if (conn.from.moduleId === moduleId && conn.from.port === '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; // Directly set connected oscillator frequencies (bypasses the modulation Gain) for (const conn of state.connections) { if (conn.from.moduleId === moduleId && conn.from.port === 'freq') { const oscEntry = audioNodes[conn.to.moduleId]; const oscMod = state.modules.find(m => m.id === conn.to.moduleId); if (oscEntry?.node && oscMod?.type === 'oscillator') { oscEntry.node.frequency.value = freq; } } } // Also trigger any connected envelopes for (const conn of state.connections) { if (conn.from.moduleId === moduleId && conn.from.port === '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; // Rebuild entire audio graph rebuildGraph(); } export function stopAudio() { // Stop and reset Transport so pianoroll/sequencer Parts don't get stranded try { Tone.getTransport().stop(); Tone.getTransport().cancel(); // Remove all scheduled events Tone.getTransport().position = 0; } catch (e) { /* ignore if Transport not started */ } // 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); } // 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(); } } } } export function getAnalyserData(moduleId) { const entry = audioNodes[moduleId]; if (!entry || !entry.analyser) return null; return entry.analyser.getValue(); }