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>
This commit is contained in:
609
packages/client/src/engine/audioEngine.js
Normal file
609
packages/client/src/engine/audioEngine.js
Normal file
@@ -0,0 +1,609 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
328
packages/client/src/engine/moduleRegistry.js
Normal file
328
packages/client/src/engine/moduleRegistry.js
Normal file
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* moduleRegistry.js — Defines all available module types
|
||||
* Each module type specifies: ports, params, icon, category, and audio factory
|
||||
*/
|
||||
|
||||
export const PORT_TYPE = {
|
||||
AUDIO: 'audio',
|
||||
CONTROL: 'control',
|
||||
TRIGGER: 'trigger',
|
||||
};
|
||||
|
||||
// Module type definitions
|
||||
const registry = {};
|
||||
|
||||
export function defineModule(type, def) {
|
||||
registry[type] = { type, ...def };
|
||||
}
|
||||
|
||||
export function getModuleDef(type) {
|
||||
return registry[type] || null;
|
||||
}
|
||||
|
||||
export function getAllModuleDefs() {
|
||||
return Object.values(registry);
|
||||
}
|
||||
|
||||
export function getModulesByCategory() {
|
||||
const cats = {};
|
||||
for (const def of Object.values(registry)) {
|
||||
if (!cats[def.category]) cats[def.category] = [];
|
||||
cats[def.category].push(def);
|
||||
}
|
||||
return cats;
|
||||
}
|
||||
|
||||
// ==================== SOURCE ====================
|
||||
|
||||
defineModule('oscillator', {
|
||||
name: 'Oscillator',
|
||||
icon: '~',
|
||||
category: 'Source',
|
||||
inputs: [
|
||||
{ name: 'freq', type: PORT_TYPE.AUDIO, label: 'Freq' },
|
||||
{ name: 'detune', type: PORT_TYPE.CONTROL, label: 'Detune' },
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'out', type: PORT_TYPE.AUDIO, label: 'Out' },
|
||||
],
|
||||
params: {
|
||||
waveform: { type: 'select', options: ['sine', 'square', 'sawtooth', 'triangle'], default: 'sawtooth', label: 'Wave' },
|
||||
frequency: { type: 'knob', min: 20, max: 8000, default: 440, unit: 'Hz', label: 'Freq' },
|
||||
detune: { type: 'knob', min: -1200, max: 1200, default: 0, unit: 'ct', label: 'Detune' },
|
||||
},
|
||||
});
|
||||
|
||||
defineModule('lfo', {
|
||||
name: 'LFO',
|
||||
icon: '∿',
|
||||
category: 'Source',
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ name: 'out', type: PORT_TYPE.CONTROL, label: 'Out' },
|
||||
],
|
||||
params: {
|
||||
waveform: { type: 'select', options: ['sine', 'square', 'sawtooth', 'triangle'], default: 'sine', label: 'Wave' },
|
||||
frequency: { type: 'knob', min: 0.01, max: 50, default: 2, unit: 'Hz', label: 'Rate' },
|
||||
amplitude: { type: 'knob', min: 0, max: 1, default: 0.5, unit: '', label: 'Depth' },
|
||||
},
|
||||
});
|
||||
|
||||
defineModule('noise', {
|
||||
name: 'Noise',
|
||||
icon: '⣿',
|
||||
category: 'Source',
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ name: 'out', type: PORT_TYPE.AUDIO, label: 'Out' },
|
||||
],
|
||||
params: {
|
||||
type: { type: 'select', options: ['white', 'pink', 'brown'], default: 'white', label: 'Type' },
|
||||
},
|
||||
});
|
||||
|
||||
// ==================== FILTER ====================
|
||||
|
||||
defineModule('filter', {
|
||||
name: 'Filter',
|
||||
icon: '▽',
|
||||
category: 'Filter',
|
||||
inputs: [
|
||||
{ name: 'in', type: PORT_TYPE.AUDIO, label: 'In' },
|
||||
{ name: 'cutoff', type: PORT_TYPE.CONTROL, label: 'Cutoff' },
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'out', type: PORT_TYPE.AUDIO, label: 'Out' },
|
||||
],
|
||||
params: {
|
||||
type: { type: 'select', options: ['lowpass', 'highpass', 'bandpass', 'notch'], default: 'lowpass', label: 'Type' },
|
||||
frequency: { type: 'knob', min: 20, max: 20000, default: 1000, unit: 'Hz', label: 'Cutoff' },
|
||||
Q: { type: 'knob', min: 0.1, max: 20, default: 1, unit: '', label: 'Reso' },
|
||||
},
|
||||
});
|
||||
|
||||
// ==================== ENVELOPE ====================
|
||||
|
||||
defineModule('envelope', {
|
||||
name: 'Envelope',
|
||||
icon: '⏤╲',
|
||||
category: 'Modulation',
|
||||
inputs: [
|
||||
{ name: 'gate', type: PORT_TYPE.TRIGGER, label: 'Gate' },
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'out', type: PORT_TYPE.CONTROL, label: 'Out' },
|
||||
],
|
||||
params: {
|
||||
attack: { type: 'knob', min: 0.001, max: 4, default: 0.01, unit: 's', label: 'Attack' },
|
||||
decay: { type: 'knob', min: 0.001, max: 4, default: 0.2, unit: 's', label: 'Decay' },
|
||||
sustain: { type: 'knob', min: 0, max: 1, default: 0.5, unit: '', label: 'Sustain' },
|
||||
release: { type: 'knob', min: 0.001, max: 8, default: 0.5, unit: 's', label: 'Release' },
|
||||
},
|
||||
});
|
||||
|
||||
// ==================== AMPLIFIER ====================
|
||||
|
||||
defineModule('vca', {
|
||||
name: 'VCA',
|
||||
icon: '△',
|
||||
category: 'Utility',
|
||||
inputs: [
|
||||
{ name: 'in', type: PORT_TYPE.AUDIO, label: 'In' },
|
||||
{ name: 'cv', type: PORT_TYPE.CONTROL, label: 'CV' },
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'out', type: PORT_TYPE.AUDIO, label: 'Out' },
|
||||
],
|
||||
params: {
|
||||
gain: { type: 'knob', min: 0, max: 1, default: 0.8, unit: '', label: 'Gain' },
|
||||
},
|
||||
});
|
||||
|
||||
// ==================== EFFECTS ====================
|
||||
|
||||
defineModule('delay', {
|
||||
name: 'Delay',
|
||||
icon: '⟫',
|
||||
category: 'Effect',
|
||||
inputs: [
|
||||
{ name: 'in', type: PORT_TYPE.AUDIO, label: 'In' },
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'out', type: PORT_TYPE.AUDIO, label: 'Out' },
|
||||
],
|
||||
params: {
|
||||
delayTime: { type: 'knob', min: 0.01, max: 2, default: 0.3, unit: 's', label: 'Time' },
|
||||
feedback: { type: 'knob', min: 0, max: 0.95, default: 0.4, unit: '', label: 'Feedbk' },
|
||||
wet: { type: 'knob', min: 0, max: 1, default: 0.5, unit: '', label: 'Mix' },
|
||||
},
|
||||
});
|
||||
|
||||
defineModule('reverb', {
|
||||
name: 'Reverb',
|
||||
icon: '◌',
|
||||
category: 'Effect',
|
||||
inputs: [
|
||||
{ name: 'in', type: PORT_TYPE.AUDIO, label: 'In' },
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'out', type: PORT_TYPE.AUDIO, label: 'Out' },
|
||||
],
|
||||
params: {
|
||||
decay: { type: 'knob', min: 0.1, max: 15, default: 3, unit: 's', label: 'Decay' },
|
||||
wet: { type: 'knob', min: 0, max: 1, default: 0.4, unit: '', label: 'Mix' },
|
||||
},
|
||||
});
|
||||
|
||||
defineModule('distortion', {
|
||||
name: 'Distortion',
|
||||
icon: '⚡',
|
||||
category: 'Effect',
|
||||
inputs: [
|
||||
{ name: 'in', type: PORT_TYPE.AUDIO, label: 'In' },
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'out', type: PORT_TYPE.AUDIO, label: 'Out' },
|
||||
],
|
||||
params: {
|
||||
distortion: { type: 'knob', min: 0, max: 1, default: 0.4, unit: '', label: 'Drive' },
|
||||
wet: { type: 'knob', min: 0, max: 1, default: 0.5, unit: '', label: 'Mix' },
|
||||
},
|
||||
});
|
||||
|
||||
// ==================== MIXER ====================
|
||||
|
||||
defineModule('mixer', {
|
||||
name: 'Mixer',
|
||||
icon: '≡',
|
||||
category: 'Utility',
|
||||
inputs: [
|
||||
{ name: 'in1', type: PORT_TYPE.AUDIO, label: 'In 1' },
|
||||
{ name: 'in2', type: PORT_TYPE.AUDIO, label: 'In 2' },
|
||||
{ name: 'in3', type: PORT_TYPE.AUDIO, label: 'In 3' },
|
||||
{ name: 'in4', type: PORT_TYPE.AUDIO, label: 'In 4' },
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'out', type: PORT_TYPE.AUDIO, label: 'Out' },
|
||||
],
|
||||
params: {
|
||||
gain1: { type: 'knob', min: 0, max: 1, default: 0.8, unit: '', label: 'Ch 1' },
|
||||
gain2: { type: 'knob', min: 0, max: 1, default: 0.8, unit: '', label: 'Ch 2' },
|
||||
gain3: { type: 'knob', min: 0, max: 1, default: 0.8, unit: '', label: 'Ch 3' },
|
||||
gain4: { type: 'knob', min: 0, max: 1, default: 0.8, unit: '', label: 'Ch 4' },
|
||||
},
|
||||
});
|
||||
|
||||
// ==================== SCOPE ====================
|
||||
|
||||
defineModule('scope', {
|
||||
name: 'Scope',
|
||||
icon: '📊',
|
||||
category: 'Utility',
|
||||
inputs: [
|
||||
{ name: 'in', type: PORT_TYPE.AUDIO, label: 'In' },
|
||||
],
|
||||
outputs: [],
|
||||
params: {},
|
||||
});
|
||||
|
||||
// ==================== CV TO GATE ====================
|
||||
|
||||
defineModule('cv2gate', {
|
||||
name: 'CV→Gate',
|
||||
icon: '⚡',
|
||||
category: 'Utility',
|
||||
inputs: [
|
||||
{ name: 'in', type: PORT_TYPE.CONTROL, label: 'CV In' },
|
||||
],
|
||||
outputs: [
|
||||
{ name: 'gate', type: PORT_TYPE.TRIGGER, label: 'Gate' },
|
||||
],
|
||||
params: {
|
||||
threshold: { type: 'knob', min: 0, max: 1, default: 0.5, unit: '', label: 'Thresh' },
|
||||
},
|
||||
});
|
||||
|
||||
// ==================== OUTPUT ====================
|
||||
|
||||
defineModule('output', {
|
||||
name: 'Output',
|
||||
icon: '🔊',
|
||||
category: 'Output',
|
||||
inputs: [
|
||||
{ name: 'left', type: PORT_TYPE.AUDIO, label: 'Left' },
|
||||
{ name: 'right', type: PORT_TYPE.AUDIO, label: 'Right' },
|
||||
],
|
||||
outputs: [],
|
||||
params: {
|
||||
volume: { type: 'knob', min: -60, max: 6, default: -6, unit: 'dB', label: 'Volume' },
|
||||
},
|
||||
});
|
||||
|
||||
// ==================== KEYBOARD ====================
|
||||
|
||||
defineModule('keyboard', {
|
||||
name: 'Keyboard',
|
||||
icon: '🎹',
|
||||
category: 'Source',
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ name: 'freq', type: PORT_TYPE.AUDIO, label: 'Freq' },
|
||||
{ name: 'gate', type: PORT_TYPE.TRIGGER, label: 'Gate' },
|
||||
],
|
||||
params: {
|
||||
octave: { type: 'knob', min: 1, max: 8, default: 4, unit: '', label: 'Octave' },
|
||||
},
|
||||
});
|
||||
|
||||
// ==================== DRUM PAD ====================
|
||||
|
||||
defineModule('drumpad', {
|
||||
name: 'Drum Pad',
|
||||
icon: '🥁',
|
||||
category: 'Source',
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ name: 'freq', type: PORT_TYPE.AUDIO, label: 'Freq' },
|
||||
{ name: 'gate', type: PORT_TYPE.TRIGGER, label: 'Gate' },
|
||||
],
|
||||
params: {},
|
||||
});
|
||||
|
||||
// ==================== SEQUENCER ====================
|
||||
|
||||
defineModule('sequencer', {
|
||||
name: 'Sequencer',
|
||||
icon: '▦',
|
||||
category: 'Source',
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ name: 'freq', type: PORT_TYPE.AUDIO, label: 'Freq' },
|
||||
{ name: 'gate', type: PORT_TYPE.TRIGGER, label: 'Gate' },
|
||||
],
|
||||
params: {
|
||||
bpm: { type: 'knob', min: 40, max: 300, default: 140, unit: 'bpm', label: 'BPM' },
|
||||
steps: { type: 'select', options: ['8', '16', '32'], default: '16', label: 'Steps' },
|
||||
swing: { type: 'knob', min: 0, max: 0.5, default: 0, unit: '', label: 'Swing' },
|
||||
},
|
||||
// Custom data: step notes/gates stored in module.params._steps
|
||||
});
|
||||
|
||||
// ==================== PIANO ROLL ====================
|
||||
|
||||
defineModule('pianoroll', {
|
||||
name: 'Piano Roll',
|
||||
icon: '🎼',
|
||||
category: 'Source',
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ name: 'freq', type: PORT_TYPE.AUDIO, label: 'Freq' },
|
||||
{ name: 'gate', type: PORT_TYPE.TRIGGER, label: 'Gate' },
|
||||
],
|
||||
params: {
|
||||
bpm: { type: 'knob', min: 40, max: 300, default: 140, unit: 'bpm', label: 'BPM' },
|
||||
loop: { type: 'select', options: ['on', 'off'], default: 'on', label: 'Loop' },
|
||||
bars: { type: 'select', options: ['1', '2', '4', '8'], default: '4', label: 'Bars' },
|
||||
},
|
||||
// Custom data: notes stored in module.params._notes = [{note, start, duration}, ...]
|
||||
});
|
||||
83
packages/client/src/engine/presets.js
Normal file
83
packages/client/src/engine/presets.js
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* presets.js — Save/load presets to localStorage
|
||||
*/
|
||||
import { serialize, deserialize } from './state.js';
|
||||
import { rebuildGraph } from './audioEngine.js';
|
||||
|
||||
const STORAGE_KEY = 'reaktor_presets';
|
||||
const AUTOSAVE_KEY = 'reaktor_autosave';
|
||||
|
||||
export function getPresets() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]');
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
export function savePreset(name) {
|
||||
const presets = getPresets();
|
||||
const data = serialize();
|
||||
data.name = name;
|
||||
data.savedAt = new Date().toISOString();
|
||||
// Replace if same name exists
|
||||
const idx = presets.findIndex(p => p.name === name);
|
||||
if (idx >= 0) presets[idx] = data;
|
||||
else presets.unshift(data);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
|
||||
}
|
||||
|
||||
export function loadPreset(name) {
|
||||
const presets = getPresets();
|
||||
const preset = presets.find(p => p.name === name);
|
||||
if (!preset) return false;
|
||||
deserialize(preset);
|
||||
rebuildGraph();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function deletePreset(name) {
|
||||
const presets = getPresets().filter(p => p.name !== name);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
|
||||
}
|
||||
|
||||
export function autoSave() {
|
||||
const data = serialize();
|
||||
data.savedAt = new Date().toISOString();
|
||||
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(data));
|
||||
}
|
||||
|
||||
export function autoLoad() {
|
||||
try {
|
||||
const raw = localStorage.getItem(AUTOSAVE_KEY);
|
||||
if (!raw) return false;
|
||||
const data = JSON.parse(raw);
|
||||
deserialize(data);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
export function exportPatch() {
|
||||
const data = serialize();
|
||||
const json = JSON.stringify(data, null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'patch.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function importPatch(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.target.result);
|
||||
deserialize(data);
|
||||
rebuildGraph();
|
||||
resolve(true);
|
||||
} catch (err) { reject(err); }
|
||||
};
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
145
packages/client/src/engine/state.js
Normal file
145
packages/client/src/engine/state.js
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* state.js — Centralized reactive state for the modular synth
|
||||
* Uses a simple pub/sub pattern for React integration
|
||||
*/
|
||||
import { playConnect, playDisconnect, playModuleAdd, playModuleDelete } from './uiSounds.js';
|
||||
import { getModuleDef } from './moduleRegistry.js';
|
||||
import { invalidateConnectionCache } from './audioEngine.js';
|
||||
|
||||
let _listeners = new Set();
|
||||
let _nextModuleId = 1;
|
||||
let _nextConnectionId = 1;
|
||||
|
||||
export const state = {
|
||||
modules: [], // { id, type, x, y, params, collapsed }
|
||||
connections: [], // { id, from: {moduleId, port}, to: {moduleId, port} }
|
||||
|
||||
// Interaction
|
||||
selectedModuleId: null,
|
||||
dragging: null, // { moduleId, offsetX, offsetY }
|
||||
connecting: null, // { moduleId, port, portType, direction, x, y } (temp wire)
|
||||
|
||||
// Camera
|
||||
camX: 0, camY: 0, zoom: 1,
|
||||
panning: false, panStart: null,
|
||||
|
||||
// Audio
|
||||
isRunning: false,
|
||||
masterVolume: -6,
|
||||
|
||||
// UI
|
||||
showPalette: true,
|
||||
presetModal: null, // null | 'save' | 'load'
|
||||
};
|
||||
|
||||
export function subscribe(fn) {
|
||||
_listeners.add(fn);
|
||||
return () => _listeners.delete(fn);
|
||||
}
|
||||
|
||||
export function emit() {
|
||||
_listeners.forEach(fn => fn());
|
||||
}
|
||||
|
||||
export function addModule(type, x, y) {
|
||||
const id = _nextModuleId++;
|
||||
// Populate ALL default params so level checkers can read them immediately
|
||||
const def = getModuleDef(type);
|
||||
const defaults = def
|
||||
? Object.fromEntries(Object.entries(def.params).map(([k, v]) => [k, v.default]))
|
||||
: {};
|
||||
state.modules.push({ id, type, x, y, params: defaults, collapsed: false });
|
||||
state.selectedModuleId = id;
|
||||
emit();
|
||||
playModuleAdd();
|
||||
return id;
|
||||
}
|
||||
|
||||
export function removeModule(id) {
|
||||
state.modules = state.modules.filter(m => m.id !== id);
|
||||
state.connections = state.connections.filter(
|
||||
c => c.from.moduleId !== id && c.to.moduleId !== id
|
||||
);
|
||||
if (state.selectedModuleId === id) state.selectedModuleId = null;
|
||||
emit();
|
||||
playModuleDelete();
|
||||
}
|
||||
|
||||
export function updateModulePosition(id, x, y) {
|
||||
const m = state.modules.find(m => m.id === id);
|
||||
if (m) { m.x = x; m.y = y; emit(); }
|
||||
}
|
||||
|
||||
export function updateModuleParam(id, paramName, value) {
|
||||
const m = state.modules.find(m => m.id === id);
|
||||
if (m) { m.params[paramName] = value; emit(); }
|
||||
}
|
||||
|
||||
export function addConnection(fromModuleId, fromPort, toModuleId, toPort) {
|
||||
// Prevent duplicates
|
||||
const exists = state.connections.find(c =>
|
||||
c.from.moduleId === fromModuleId && c.from.port === fromPort &&
|
||||
c.to.moduleId === toModuleId && c.to.port === toPort
|
||||
);
|
||||
if (exists) return null;
|
||||
|
||||
// Prevent connecting to already-connected input
|
||||
const inputTaken = state.connections.find(c =>
|
||||
c.to.moduleId === toModuleId && c.to.port === toPort
|
||||
);
|
||||
if (inputTaken) {
|
||||
// Remove old connection to this input (silent — connect sound will play)
|
||||
removeConnection(inputTaken.id, true);
|
||||
}
|
||||
|
||||
const id = _nextConnectionId++;
|
||||
state.connections.push({ id, from: { moduleId: fromModuleId, port: fromPort }, to: { moduleId: toModuleId, port: toPort } });
|
||||
invalidateConnectionCache();
|
||||
emit();
|
||||
playConnect();
|
||||
return id;
|
||||
}
|
||||
|
||||
export function removeConnection(id, _silent = false) {
|
||||
state.connections = state.connections.filter(c => c.id !== id);
|
||||
invalidateConnectionCache();
|
||||
emit();
|
||||
if (!_silent) playDisconnect();
|
||||
}
|
||||
|
||||
export function getModule(id) {
|
||||
return state.modules.find(m => m.id === id) || null;
|
||||
}
|
||||
|
||||
export function isPortConnected(moduleId, portName, direction) {
|
||||
return state.connections.some(c =>
|
||||
direction === 'output'
|
||||
? (c.from.moduleId === moduleId && c.from.port === portName)
|
||||
: (c.to.moduleId === moduleId && c.to.port === portName)
|
||||
);
|
||||
}
|
||||
|
||||
// Serialization
|
||||
export function serialize() {
|
||||
return {
|
||||
modules: state.modules.map(m => ({ id: m.id, type: m.type, x: m.x, y: m.y, params: { ...m.params } })),
|
||||
connections: state.connections.map(c => ({ ...c })),
|
||||
camera: { camX: state.camX, camY: state.camY, zoom: state.zoom },
|
||||
masterVolume: state.masterVolume,
|
||||
};
|
||||
}
|
||||
|
||||
export function deserialize(data) {
|
||||
state.modules = data.modules || [];
|
||||
state.connections = data.connections || [];
|
||||
if (data.camera) {
|
||||
state.camX = data.camera.camX || 0;
|
||||
state.camY = data.camera.camY || 0;
|
||||
state.zoom = data.camera.zoom || 1;
|
||||
}
|
||||
state.masterVolume = data.masterVolume ?? -6;
|
||||
_nextModuleId = Math.max(1, ...state.modules.map(m => m.id)) + 1;
|
||||
_nextConnectionId = Math.max(1, ...state.connections.map(c => c.id)) + 1;
|
||||
state.selectedModuleId = null;
|
||||
emit();
|
||||
}
|
||||
223
packages/client/src/engine/uiSounds.js
Normal file
223
packages/client/src/engine/uiSounds.js
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* uiSounds.js — Procedural UI sound effects using Tone.js
|
||||
* All sounds are synthesized on-the-fly — no audio files needed.
|
||||
* Sounds are short, subtle, and "synth-themed" to match the app.
|
||||
*/
|
||||
import * as Tone from 'tone';
|
||||
|
||||
let _enabled = true;
|
||||
let _volume = -18; // dB, subtle
|
||||
let _initialized = false;
|
||||
let _masterGain = null;
|
||||
|
||||
// Lazy init — only create audio nodes after user interaction (Tone.start)
|
||||
function ensureInit() {
|
||||
if (_initialized) return true;
|
||||
if (Tone.context.state !== 'running') return false;
|
||||
_masterGain = new Tone.Gain(Tone.dbToGain(_volume)).toDestination();
|
||||
_initialized = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function setUISoundsEnabled(enabled) { _enabled = enabled; }
|
||||
export function isUISoundsEnabled() { return _enabled; }
|
||||
export function setUIVolume(db) {
|
||||
_volume = db;
|
||||
if (_masterGain) _masterGain.gain.value = Tone.dbToGain(db);
|
||||
}
|
||||
|
||||
// ==================== Sound definitions ====================
|
||||
|
||||
/** Cable connected — short bright "click" with rising pitch */
|
||||
export function playConnect() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.001, decay: 0.08, sustain: 0, release: 0.05 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease('C6', 0.06);
|
||||
setTimeout(() => {
|
||||
const synth2 = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.001, decay: 0.06, sustain: 0, release: 0.04 },
|
||||
}).connect(_masterGain);
|
||||
synth2.triggerAttackRelease('E6', 0.05);
|
||||
setTimeout(() => synth2.dispose(), 200);
|
||||
}, 40);
|
||||
setTimeout(() => synth.dispose(), 300);
|
||||
}
|
||||
|
||||
/** Cable disconnected — short descending blip */
|
||||
export function playDisconnect() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'triangle' },
|
||||
envelope: { attack: 0.001, decay: 0.1, sustain: 0, release: 0.05 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease('E5', 0.06);
|
||||
setTimeout(() => {
|
||||
const synth2 = new Tone.Synth({
|
||||
oscillator: { type: 'triangle' },
|
||||
envelope: { attack: 0.001, decay: 0.08, sustain: 0, release: 0.04 },
|
||||
}).connect(_masterGain);
|
||||
synth2.triggerAttackRelease('C5', 0.05);
|
||||
setTimeout(() => synth2.dispose(), 200);
|
||||
}, 50);
|
||||
setTimeout(() => synth.dispose(), 300);
|
||||
}
|
||||
|
||||
/** Module added — soft metallic "pop" */
|
||||
export function playModuleAdd() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const synth = new Tone.MembraneSynth({
|
||||
pitchDecay: 0.01,
|
||||
octaves: 4,
|
||||
envelope: { attack: 0.001, decay: 0.15, sustain: 0, release: 0.1 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease('C4', 0.08);
|
||||
setTimeout(() => synth.dispose(), 400);
|
||||
}
|
||||
|
||||
/** Module deleted — reverse "zap" */
|
||||
export function playModuleDelete() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'sawtooth' },
|
||||
envelope: { attack: 0.001, decay: 0.12, sustain: 0, release: 0.05 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease('A3', 0.08);
|
||||
setTimeout(() => synth.dispose(), 300);
|
||||
}
|
||||
|
||||
/** Button click — tiny tick */
|
||||
export function playClick() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.001, decay: 0.03, sustain: 0, release: 0.02 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease('A5', 0.02);
|
||||
setTimeout(() => synth.dispose(), 150);
|
||||
}
|
||||
|
||||
/** Star earned — bright ascending arpeggio */
|
||||
export function playStar(starNumber = 1) {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const notes = ['C5', 'E5', 'G5'];
|
||||
const note = notes[Math.min(starNumber - 1, 2)];
|
||||
const delay = (starNumber - 1) * 300;
|
||||
setTimeout(() => {
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.005, decay: 0.3, sustain: 0.1, release: 0.3 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease(note, 0.25);
|
||||
// Shimmer harmonic
|
||||
const shimmer = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.01, decay: 0.2, sustain: 0, release: 0.2 },
|
||||
volume: -6,
|
||||
}).connect(_masterGain);
|
||||
shimmer.triggerAttackRelease(
|
||||
Tone.Frequency(note).transpose(12).toNote(), 0.15
|
||||
);
|
||||
setTimeout(() => { synth.dispose(); shimmer.dispose(); }, 800);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/** Level complete — triumphant chord */
|
||||
export function playLevelComplete() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const chord = ['C4', 'E4', 'G4', 'C5'];
|
||||
chord.forEach((note, i) => {
|
||||
setTimeout(() => {
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'triangle' },
|
||||
envelope: { attack: 0.01, decay: 0.5, sustain: 0.2, release: 0.5 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease(note, 0.4);
|
||||
setTimeout(() => synth.dispose(), 1200);
|
||||
}, i * 60);
|
||||
});
|
||||
}
|
||||
|
||||
/** Level failed / check failed — low "bonk" */
|
||||
export function playFail() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'square' },
|
||||
envelope: { attack: 0.001, decay: 0.15, sustain: 0, release: 0.1 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease('D#3', 0.1);
|
||||
setTimeout(() => {
|
||||
const synth2 = new Tone.Synth({
|
||||
oscillator: { type: 'square' },
|
||||
envelope: { attack: 0.001, decay: 0.2, sustain: 0, release: 0.1 },
|
||||
}).connect(_masterGain);
|
||||
synth2.triggerAttackRelease('C3', 0.12);
|
||||
setTimeout(() => synth2.dispose(), 400);
|
||||
}, 100);
|
||||
setTimeout(() => synth.dispose(), 400);
|
||||
}
|
||||
|
||||
/** Hint revealed — mysterious "whoosh" */
|
||||
export function playHint() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const noise = new Tone.Noise('pink');
|
||||
const filter = new Tone.Filter({ type: 'bandpass', frequency: 2000, Q: 2 });
|
||||
const env = new Tone.AmplitudeEnvelope({ attack: 0.05, decay: 0.2, sustain: 0, release: 0.1 });
|
||||
noise.connect(filter).connect(env).connect(_masterGain);
|
||||
noise.start();
|
||||
env.triggerAttack();
|
||||
setTimeout(() => { env.triggerRelease(); }, 150);
|
||||
setTimeout(() => { noise.stop(); noise.dispose(); filter.dispose(); env.dispose(); }, 600);
|
||||
}
|
||||
|
||||
/** Audio engine start — power-on sweep */
|
||||
export function playEngineStart() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.1, decay: 0.2, sustain: 0, release: 0.1 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease('C4', 0.15);
|
||||
setTimeout(() => {
|
||||
const synth2 = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.05, decay: 0.15, sustain: 0, release: 0.1 },
|
||||
}).connect(_masterGain);
|
||||
synth2.triggerAttackRelease('G4', 0.12);
|
||||
setTimeout(() => synth2.dispose(), 400);
|
||||
}, 100);
|
||||
setTimeout(() => synth.dispose(), 400);
|
||||
}
|
||||
|
||||
/** Audio engine stop — power-down */
|
||||
export function playEngineStop() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.01, decay: 0.2, sustain: 0, release: 0.1 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease('G4', 0.1);
|
||||
setTimeout(() => {
|
||||
const synth2 = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.01, decay: 0.25, sustain: 0, release: 0.15 },
|
||||
}).connect(_masterGain);
|
||||
synth2.triggerAttackRelease('C4', 0.15);
|
||||
setTimeout(() => synth2.dispose(), 500);
|
||||
}, 80);
|
||||
setTimeout(() => synth.dispose(), 500);
|
||||
}
|
||||
|
||||
/** Navigation click (map, back buttons) — soft "tick" */
|
||||
export function playNav() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.001, decay: 0.04, sustain: 0, release: 0.03 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease('E5', 0.03);
|
||||
setTimeout(() => synth.dispose(), 150);
|
||||
}
|
||||
Reference in New Issue
Block a user