The periodic audio glitches were caused by main thread starvation: ~840 events/sec during playback starved the audio buffer. Changes: - Master clock 480→120 Hz (still 6x headroom for 300 BPM sixteenths) - Connection cache: replace O(n) reduce hash with dirty flag (zero work on cache hit, flag set only when connections actually change) - Tone.js lookAhead: 100ms→50ms for tighter scheduling - ModuleNode LFO visualization RAF: 60fps→15fps (every 4th frame) - ScopeDisplay RAF: 60fps→30fps (every 2nd frame) Net effect: ~840 events/sec → ~200 events/sec during playback. Audio processing gets 4x more main thread headroom. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
146 lines
4.3 KiB
JavaScript
146 lines
4.3 KiB
JavaScript
/**
|
|
* 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();
|
|
}
|