Files
reaktor/packages/client/src/engine/audioEngine.js
Jose Luis b058997889 refactor: restructure to monorepo with npm workspaces (Phase 0)
Move frontend to packages/client/, server to packages/server/.
Root package.json uses npm workspaces to orchestrate both.

Structure:
  reaktor/
    packages/client/  (React + Vite + Tone.js frontend)
    packages/server/  (static file server, future API)
    dist/             (built output, shared)
    docker-compose.yml (app + PostgreSQL for future backend)

- npm run dev → runs Vite dev server from client workspace
- npm run build → builds client, outputs to root dist/
- npm run start → runs server.js serving dist/
- Dockerfile updated for multi-stage monorepo build
- docker-compose.yml added with PostgreSQL service (ready for Phase 1)
- All imports and paths preserved, zero functionality change

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

610 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 scaler: always gain=1 so envelope (0-1) passes through fully.
// When CV is connected, base gain is zeroed — envelope controls amplitude entirely.
const cvMod = new Tone.Gain(1);
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);
}
// When CV is connected to VCA, zero the base gain so only envelope controls it
if (toMod?.type === 'vca' && conn.to.port === 'cv') {
toEntry.node.gain.value = 0;
}
}
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
}
// When CV is disconnected from VCA, restore base gain from params
const toMod = state.modules.find(m => m.id === conn.to.moduleId);
if (toMod?.type === 'vca' && conn.to.port === 'cv') {
toEntry.node.gain.value = toMod.params?.gain ?? 0.8;
}
}
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') {
// Only update base gain if no CV is connected (CV zeroes it)
const hasCV = state.connections.some(c => c.to.moduleId === moduleId && c.to.port === 'cv');
if (!hasCV) entry.node.gain.value = value;
// cvMod stays at 1 always — envelope controls full range
}
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();
}