Files
reaktor/src/engine/audioEngine.js
Jose Luis 816e7270ed feat: fullscreen keyboard + new Drum Pad module
Keyboard fullscreen:
- Double-tap keyboard widget to enter fullscreen piano mode
- 2-octave touch-friendly piano with labeled keys
- Active key highlights cyan, close button to exit

Drum Pad module (🥁):
- New module type with 4x4 colored pad grid
- Each pad triggers a unique frequency (C2-D4 range)
- Outputs freq + gate signals (same as keyboard)
- Double-tap for fullscreen pad mode with large touch targets
- Color-coded pads with hit animation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 16:02:22 +01:00

484 lines
16 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 };
// ==================== 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();
}