feat: initial Reaktor modular synth app
React + Tone.js modular synthesizer with visual node editor. Includes: Oscillator, Filter, Envelope, LFO, VCA, Delay, Reverb, Distortion, Mixer, Scope, Output, and Keyboard modules. SVG wire connections, knob controls, preset save/load system. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
343
src/engine/audioEngine.js
Normal file
343
src/engine/audioEngine.js
Normal file
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* 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();
|
||||
return {
|
||||
node: osc,
|
||||
inputs: { freq: osc.frequency, detune: osc.detune },
|
||||
outputs: { out: osc },
|
||||
dispose: () => { osc.stop(); 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 });
|
||||
return {
|
||||
node: filter,
|
||||
inputs: { in: filter, cutoff: filter.frequency },
|
||||
outputs: { out: filter },
|
||||
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', 256);
|
||||
return {
|
||||
node: analyser,
|
||||
inputs: { in: analyser },
|
||||
outputs: {},
|
||||
analyser,
|
||||
dispose: () => analyser.dispose(),
|
||||
};
|
||||
}
|
||||
case 'output': {
|
||||
const gain = new Tone.Gain(Tone.dbToGain(p.volume));
|
||||
gain.toDestination();
|
||||
return {
|
||||
node: gain,
|
||||
inputs: { left: gain, right: gain },
|
||||
outputs: {},
|
||||
dispose: () => { gain.disconnect(); gain.dispose(); },
|
||||
};
|
||||
}
|
||||
case 'keyboard': {
|
||||
// Keyboard outputs frequency as a Signal and gate as a Signal
|
||||
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(); },
|
||||
};
|
||||
}
|
||||
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;
|
||||
|
||||
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;
|
||||
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;
|
||||
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':
|
||||
if (paramName === 'octave') { /* stored in state only */ }
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// 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() {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
export function getAnalyserData(moduleId) {
|
||||
const entry = audioNodes[moduleId];
|
||||
if (!entry || !entry.analyser) return null;
|
||||
return entry.analyser.getValue();
|
||||
}
|
||||
Reference in New Issue
Block a user