lfocomp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +179 -0
- package/lfo-comp.js +140 -0
- package/lfo-engine.js +468 -0
- package/lfo-ui.js +1272 -0
- package/lfo.js +1878 -0
- package/package.json +74 -0
- package/types/lfo-comp.d.ts +68 -0
- package/types/lfo-engine.d.ts +150 -0
- package/types/lfo-ui.d.ts +147 -0
- package/types/lfo.d.ts +350 -0
package/lfo-engine.js
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lfo-engine.js — Core LFO math engine, no DOM dependencies.
|
|
3
|
+
*
|
|
4
|
+
* Manages LFO instances, a global RAF tick loop, and a modulation routing
|
|
5
|
+
* graph. Routes can target HTML elements (range/number inputs) or other
|
|
6
|
+
* LFO parameters (for chaining).
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* import { engine, SHAPES } from './lfo-engine.js';
|
|
10
|
+
* const id = engine.createLFO({ shape: 'sine', rate: 2 });
|
|
11
|
+
* const routeId = engine.addRoute(id, 'element', sliderEl, null, { depth: 0.5 });
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const SHAPES = ['sine', 'triangle', 'saw', 'rsaw', 'square', 'random', 'smooth'];
|
|
15
|
+
|
|
16
|
+
// ─── Waveform math ───────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/** Deterministic pseudo-random in [-1, 1] for a given integer seed. */
|
|
19
|
+
export function seededRand(seed) {
|
|
20
|
+
const x = Math.sin(seed * 127.1 + 311.7) * 43758.5453;
|
|
21
|
+
return (x - Math.floor(x)) * 2 - 1;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Smoothly interpolated random (cubic Hermite) for non-integer phase. */
|
|
25
|
+
export function smoothRand(seed, phase) {
|
|
26
|
+
const i0 = Math.floor(phase);
|
|
27
|
+
const t = phase - i0;
|
|
28
|
+
const v0 = seededRand(seed + i0);
|
|
29
|
+
const v1 = seededRand(seed + i0 + 1);
|
|
30
|
+
const st = t * t * (3 - 2 * t); // smoothstep
|
|
31
|
+
return v0 + (v1 - v0) * st;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Piecewise-linear phase skew — repositions the midpoint of a 0–1 fractional phase.
|
|
36
|
+
* skew = 0.5 → symmetric (no change).
|
|
37
|
+
* skew < 0.5 → ascending flank compressed (peak shifts left).
|
|
38
|
+
* skew > 0.5 → descending flank compressed (peak shifts right).
|
|
39
|
+
* @param {number} frac Fractional phase in [0, 1).
|
|
40
|
+
* @param {number} skew Skew amount in [0, 1].
|
|
41
|
+
* @returns {number} Skewed fractional phase in [0, 1).
|
|
42
|
+
*/
|
|
43
|
+
export function applySkew(frac, skew) {
|
|
44
|
+
if (skew === 0.5) return frac;
|
|
45
|
+
const s = Math.max(0.001, Math.min(0.999, skew));
|
|
46
|
+
return frac < s ? frac / s * 0.5 : 0.5 + (frac - s) / (1 - s) * 0.5;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const SHAPE_FN = {
|
|
50
|
+
sine: p => Math.sin(p * Math.PI * 2),
|
|
51
|
+
triangle: p => 1 - 4 * Math.abs(((p + 0.25) % 1 + 1) % 1 - 0.5),
|
|
52
|
+
saw: p => 2 * ((p % 1 + 1) % 1) - 1,
|
|
53
|
+
rsaw: p => 1 - 2 * ((p % 1 + 1) % 1),
|
|
54
|
+
square: p => (Math.sin(p * Math.PI * 2) >= 0 ? 1 : -1),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// ─── LFOEngine ───────────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
let _nextId = 0;
|
|
60
|
+
|
|
61
|
+
export class LFOEngine {
|
|
62
|
+
constructor() {
|
|
63
|
+
/** @type {Map<string, LFOState>} */
|
|
64
|
+
this._lfos = new Map();
|
|
65
|
+
/** @type {Map<string, RouteState>} */
|
|
66
|
+
this._routes = new Map();
|
|
67
|
+
/** @type {Map<HTMLElement, ElementState>} */
|
|
68
|
+
this._elementStates = new Map();
|
|
69
|
+
/** @type {Set<function>} */
|
|
70
|
+
this._subscribers = new Set();
|
|
71
|
+
|
|
72
|
+
this._lastTs = null;
|
|
73
|
+
this._rafId = null;
|
|
74
|
+
this._running = false;
|
|
75
|
+
this._tick = this._tick.bind(this);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
start() {
|
|
81
|
+
if (this._running) return;
|
|
82
|
+
this._running = true;
|
|
83
|
+
this._lastTs = null;
|
|
84
|
+
this._rafId = requestAnimationFrame(this._tick);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
stop() {
|
|
88
|
+
this._running = false;
|
|
89
|
+
if (this._rafId != null) cancelAnimationFrame(this._rafId);
|
|
90
|
+
this._rafId = null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── LFO management ────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Create a new LFO instance.
|
|
97
|
+
* @param {object} opts
|
|
98
|
+
* @param {string} [opts.shape='sine'] Waveform shape. One of SHAPES.
|
|
99
|
+
* @param {number} [opts.rate=1] Frequency in Hz.
|
|
100
|
+
* @param {number} [opts.depth=1] Modulation depth multiplier 0–1.
|
|
101
|
+
* @param {number} [opts.phase=0] Initial phase offset 0–1.
|
|
102
|
+
* @param {number} [opts.offset=0] DC offset added to output, -1–1.
|
|
103
|
+
* @param {boolean} [opts.bipolar=true] If false, output is remapped 0–1.
|
|
104
|
+
* @returns {string} LFO id.
|
|
105
|
+
*/
|
|
106
|
+
createLFO(opts = {}) {
|
|
107
|
+
const id = `lfo_${_nextId++}`;
|
|
108
|
+
/** @type {LFOState} */
|
|
109
|
+
const state = {
|
|
110
|
+
id,
|
|
111
|
+
shape: opts.shape ?? 'sine',
|
|
112
|
+
baseRate: opts.rate ?? 1.0,
|
|
113
|
+
baseDepth: opts.depth ?? 1.0,
|
|
114
|
+
phase: opts.phase ?? 0.0,
|
|
115
|
+
offset: opts.offset ?? 0.0,
|
|
116
|
+
bipolar: opts.bipolar ?? true,
|
|
117
|
+
jitter: opts.jitter ?? 0.0, // 0=steady, 1=max timing randomness
|
|
118
|
+
skew: opts.skew ?? 0.5, // 0.5=symmetric; 0–1 shifts the waveform midpoint
|
|
119
|
+
// Effective params (may differ when being modulated by another LFO)
|
|
120
|
+
rate: opts.rate ?? 1.0,
|
|
121
|
+
depth: opts.depth ?? 1.0,
|
|
122
|
+
// Running state
|
|
123
|
+
currentPhase: opts.phase ?? 0.0,
|
|
124
|
+
currentValue: 0,
|
|
125
|
+
// Random shape state
|
|
126
|
+
seed: Math.floor(Math.random() * 9999),
|
|
127
|
+
shValue: 0,
|
|
128
|
+
shLastCycle: -1,
|
|
129
|
+
// Jitter state: per-cycle rate multiplier for timing randomness
|
|
130
|
+
jitterRateMult: 1.0,
|
|
131
|
+
jitterLastCycle: -1,
|
|
132
|
+
};
|
|
133
|
+
this._lfos.set(id, state);
|
|
134
|
+
this.start();
|
|
135
|
+
return id;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Remove an LFO and all its routes.
|
|
140
|
+
* @param {string} id
|
|
141
|
+
*/
|
|
142
|
+
destroyLFO(id) {
|
|
143
|
+
// Collect first — mutating _routes while iterating it skips entries.
|
|
144
|
+
const toRemove = [];
|
|
145
|
+
for (const [routeId, route] of this._routes) {
|
|
146
|
+
if (route.sourceId === id) toRemove.push(routeId);
|
|
147
|
+
// Also remove incoming chain routes (this LFO is the target) so they
|
|
148
|
+
// don't linger as orphans leaking memory and leaving source widgets stale.
|
|
149
|
+
if (route.targetType === 'lfo' && route.target === id) toRemove.push(routeId);
|
|
150
|
+
}
|
|
151
|
+
for (const routeId of toRemove) this.removeRoute(routeId);
|
|
152
|
+
this._lfos.delete(id);
|
|
153
|
+
if (this._lfos.size === 0) this.stop();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Route management ──────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Add a modulation route.
|
|
160
|
+
*
|
|
161
|
+
* For element routes: LFO modulates an HTML input element.
|
|
162
|
+
* engine.addRoute(lfoId, 'element', inputEl, null, { depth: 0.5 });
|
|
163
|
+
*
|
|
164
|
+
* For LFO-chain routes: LFO modulates another LFO's rate or depth.
|
|
165
|
+
* engine.addRoute(lfoId, 'lfo', targetLfoId, 'rate', { depth: 0.3 });
|
|
166
|
+
*
|
|
167
|
+
* @param {string} sourceId
|
|
168
|
+
* @param {'element'|'lfo'} targetType
|
|
169
|
+
* @param {HTMLElement|string} target Element or target LFO id.
|
|
170
|
+
* @param {string|null} targetParam 'rate'|'depth' for lfo, null for element.
|
|
171
|
+
* @param {object} opts
|
|
172
|
+
* @param {number} [opts.depth=0.5] Modulation depth 0–1.
|
|
173
|
+
* @param {number} [opts.min] Override element min.
|
|
174
|
+
* @param {number} [opts.max] Override element max.
|
|
175
|
+
* @param {number} [opts.step] Override element step.
|
|
176
|
+
* @returns {string|null} Route id, or null if the route was rejected (e.g. self-modulation).
|
|
177
|
+
*/
|
|
178
|
+
addRoute(sourceId, targetType, target, targetParam, opts = {}) {
|
|
179
|
+
// If the element has data-lfo-id/data-lfo-param, auto-promote to lfo route
|
|
180
|
+
if (targetType === 'element' && target instanceof Element) {
|
|
181
|
+
const linkedLfoId = target.dataset.lfoId;
|
|
182
|
+
const linkedParam = target.dataset.lfoParam;
|
|
183
|
+
if (linkedLfoId && linkedParam && this._lfos.has(linkedLfoId)) {
|
|
184
|
+
targetType = 'lfo';
|
|
185
|
+
target = linkedLfoId;
|
|
186
|
+
targetParam = linkedParam === 'baseRate' ? 'rate' :
|
|
187
|
+
linkedParam === 'baseDepth' ? 'depth' : linkedParam;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Prevent self-modulation (LFO modulating its own rate/depth).
|
|
192
|
+
if (targetType === 'lfo' && target === sourceId) return null;
|
|
193
|
+
|
|
194
|
+
const routeId = `route_${_nextId++}`;
|
|
195
|
+
|
|
196
|
+
/** @type {RouteState} */
|
|
197
|
+
const route = {
|
|
198
|
+
id: routeId,
|
|
199
|
+
sourceId,
|
|
200
|
+
targetType,
|
|
201
|
+
target,
|
|
202
|
+
targetParam,
|
|
203
|
+
depth: opts.depth ?? 0.5,
|
|
204
|
+
enabled: true,
|
|
205
|
+
};
|
|
206
|
+
this._routes.set(routeId, route);
|
|
207
|
+
|
|
208
|
+
if (targetType === 'element') {
|
|
209
|
+
this._initElementState(target, opts);
|
|
210
|
+
this._elementStates.get(target).routeIds.add(routeId);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return routeId;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Remove a modulation route.
|
|
218
|
+
* @param {string} routeId
|
|
219
|
+
*/
|
|
220
|
+
removeRoute(routeId) {
|
|
221
|
+
const route = this._routes.get(routeId);
|
|
222
|
+
if (!route) return;
|
|
223
|
+
if (route.targetType === 'element') {
|
|
224
|
+
const state = this._elementStates.get(route.target);
|
|
225
|
+
if (state) {
|
|
226
|
+
state.routeIds.delete(routeId);
|
|
227
|
+
if (state.routeIds.size === 0) {
|
|
228
|
+
this._cleanupElementState(route.target);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
this._routes.delete(routeId);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Set the depth of an existing route.
|
|
237
|
+
* @param {string} routeId
|
|
238
|
+
* @param {number} depth 0–1
|
|
239
|
+
*/
|
|
240
|
+
setRouteDepth(routeId, depth) {
|
|
241
|
+
const route = this._routes.get(routeId);
|
|
242
|
+
if (route) route.depth = Math.max(0, Math.min(1, depth));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Enable or disable a route without removing it.
|
|
247
|
+
* @param {string} routeId
|
|
248
|
+
* @param {boolean} enabled
|
|
249
|
+
*/
|
|
250
|
+
setRouteEnabled(routeId, enabled) {
|
|
251
|
+
const route = this._routes.get(routeId);
|
|
252
|
+
if (route) route.enabled = enabled;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ── LFO parameter access ──────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Set a parameter on an LFO.
|
|
259
|
+
* @param {string} lfoId
|
|
260
|
+
* @param {string} param 'shape'|'rate'|'depth'|'phase'|'offset'|'bipolar'|'baseRate'|'baseDepth'|'jitter'|'skew'
|
|
261
|
+
* @param {*} value
|
|
262
|
+
*/
|
|
263
|
+
setParam(lfoId, param, value) {
|
|
264
|
+
const lfo = this._lfos.get(lfoId);
|
|
265
|
+
if (!lfo) return;
|
|
266
|
+
lfo[param] = value;
|
|
267
|
+
// Keep base and effective params in sync when either side is set directly
|
|
268
|
+
if (param === 'rate') lfo.baseRate = value;
|
|
269
|
+
if (param === 'depth') lfo.baseDepth = value;
|
|
270
|
+
if (param === 'baseRate') lfo.rate = value;
|
|
271
|
+
if (param === 'baseDepth') lfo.depth = value;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Get a parameter from an LFO.
|
|
276
|
+
* @param {string} lfoId
|
|
277
|
+
* @param {string} param
|
|
278
|
+
* @returns {*}
|
|
279
|
+
*/
|
|
280
|
+
getParam(lfoId, param) {
|
|
281
|
+
return this._lfos.get(lfoId)?.[param];
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Get the current output value of an LFO. @param {string} lfoId @returns {number} */
|
|
285
|
+
getValue(lfoId) {
|
|
286
|
+
return this._lfos.get(lfoId)?.currentValue ?? 0;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ── Observation ───────────────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Subscribe to tick notifications. Called each frame for every LFO.
|
|
293
|
+
* @param {function(lfoId: string, value: number): void} fn
|
|
294
|
+
* @returns {function} Unsubscribe callback.
|
|
295
|
+
*/
|
|
296
|
+
subscribe(fn) {
|
|
297
|
+
this._subscribers.add(fn);
|
|
298
|
+
return () => this._subscribers.delete(fn);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── Inspection ────────────────────────────────────────────────────────────
|
|
302
|
+
|
|
303
|
+
getLFOs() { return [...this._lfos.values()]; }
|
|
304
|
+
getLFO(id) { return this._lfos.get(id); }
|
|
305
|
+
getAllRoutes() { return [...this._routes.values()]; }
|
|
306
|
+
getRoute(id) { return this._routes.get(id); }
|
|
307
|
+
|
|
308
|
+
getRoutesByElement(element) {
|
|
309
|
+
return [...(this._elementStates.get(element)?.routeIds ?? [])]
|
|
310
|
+
.map(id => this._routes.get(id))
|
|
311
|
+
.filter(Boolean);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
getElementMeta(element) {
|
|
315
|
+
return this._elementStates.get(element);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ── Internal ──────────────────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
_initElementState(element, opts) {
|
|
321
|
+
if (this._elementStates.has(element)) return;
|
|
322
|
+
const min = opts.min ?? parseFloat(element.getAttribute('min') ?? '0');
|
|
323
|
+
const max = opts.max ?? parseFloat(element.getAttribute('max') ?? '1');
|
|
324
|
+
const step = opts.step ?? parseFloat(element.getAttribute('step') ?? '0');
|
|
325
|
+
const parsed = parseFloat(element.value);
|
|
326
|
+
const baseValue = isNaN(parsed) ? (min + max) / 2 : parsed;
|
|
327
|
+
|
|
328
|
+
const onUserInput = () => {
|
|
329
|
+
const state = this._elementStates.get(element);
|
|
330
|
+
if (!state) return;
|
|
331
|
+
const v = parseFloat(element.value);
|
|
332
|
+
// Keep previous baseValue if the input contains invalid text (NaN).
|
|
333
|
+
if (!isNaN(v)) state.baseValue = v;
|
|
334
|
+
};
|
|
335
|
+
element.addEventListener('input', onUserInput);
|
|
336
|
+
element.addEventListener('change', onUserInput);
|
|
337
|
+
|
|
338
|
+
this._elementStates.set(element, {
|
|
339
|
+
min, max, step,
|
|
340
|
+
baseValue,
|
|
341
|
+
onUserInput,
|
|
342
|
+
routeIds: new Set(),
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
_cleanupElementState(element) {
|
|
347
|
+
const state = this._elementStates.get(element);
|
|
348
|
+
if (!state) return;
|
|
349
|
+
element.removeEventListener('input', state.onUserInput);
|
|
350
|
+
element.removeEventListener('change', state.onUserInput);
|
|
351
|
+
this._elementStates.delete(element);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
_computeValue(lfo, dt) {
|
|
355
|
+
const rate = Math.max(0.001, lfo.rate);
|
|
356
|
+
// Jitter: at each new cycle, pick a random rate multiplier so different
|
|
357
|
+
// cycles visibly run faster or slower (per-cycle timing randomness).
|
|
358
|
+
if (lfo.jitter > 0) {
|
|
359
|
+
const evalCycle = Math.floor(((lfo.currentPhase + lfo.phase) % 1e7 + 1e7) % 1e7) & 0xFFFF;
|
|
360
|
+
if (evalCycle !== lfo.jitterLastCycle) {
|
|
361
|
+
lfo.jitterLastCycle = evalCycle;
|
|
362
|
+
lfo.jitterRateMult = Math.max(0.2, 1 + seededRand(lfo.seed + evalCycle) * lfo.jitter * 0.8);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
const jitterMult = lfo.jitter > 0 ? lfo.jitterRateMult : 1;
|
|
366
|
+
lfo.currentPhase += rate * dt * jitterMult;
|
|
367
|
+
// Prevent float accumulation over long sessions
|
|
368
|
+
if (lfo.currentPhase > 1e7) lfo.currentPhase -= 1e7;
|
|
369
|
+
|
|
370
|
+
const phaseOff = ((lfo.currentPhase + lfo.phase) % 1e7 + 1e7) % 1e7;
|
|
371
|
+
// Skew: warp fractional phase before evaluating the shape.
|
|
372
|
+
const intPart = Math.floor(phaseOff);
|
|
373
|
+
const skewedFrac = applySkew(phaseOff - intPart, lfo.skew);
|
|
374
|
+
const p = intPart + skewedFrac;
|
|
375
|
+
let raw;
|
|
376
|
+
|
|
377
|
+
if (lfo.shape === 'random') {
|
|
378
|
+
// Reduce cycle index to 16-bit range so seededRand never receives an
|
|
379
|
+
// argument large enough to lose sin() precision (>~1e8 degrades quality).
|
|
380
|
+
const cycle = intPart & 0xFFFF;
|
|
381
|
+
if (cycle !== lfo.shLastCycle) {
|
|
382
|
+
lfo.shValue = seededRand(lfo.seed + cycle);
|
|
383
|
+
lfo.shLastCycle = cycle;
|
|
384
|
+
}
|
|
385
|
+
raw = lfo.shValue;
|
|
386
|
+
} else if (lfo.shape === 'smooth') {
|
|
387
|
+
raw = smoothRand(lfo.seed, (intPart & 0xFFFF) + skewedFrac);
|
|
388
|
+
} else {
|
|
389
|
+
raw = (SHAPE_FN[lfo.shape] ?? SHAPE_FN.sine)(p);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
let value = lfo.offset + lfo.depth * raw;
|
|
393
|
+
// Remap bipolar [-1, 1] to nominal unipolar [0, 1]. Note: depth > 1 or a
|
|
394
|
+
// non-zero offset can push the result outside [0, 1] — intentional overdrive.
|
|
395
|
+
// Element route application (Pass 3) clamps to [meta.min, meta.max].
|
|
396
|
+
if (!lfo.bipolar) value = (value + 1) * 0.5;
|
|
397
|
+
|
|
398
|
+
lfo.currentValue = value;
|
|
399
|
+
return value;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
_tick(ts) {
|
|
403
|
+
if (!this._running) return;
|
|
404
|
+
this._rafId = requestAnimationFrame(this._tick);
|
|
405
|
+
|
|
406
|
+
const dt = this._lastTs === null ? 0 : Math.min((ts - this._lastTs) / 1000, 0.1);
|
|
407
|
+
this._lastTs = ts;
|
|
408
|
+
|
|
409
|
+
// Reset effective params before chain modulation so chains compose cleanly
|
|
410
|
+
for (const lfo of this._lfos.values()) {
|
|
411
|
+
lfo.rate = lfo.baseRate;
|
|
412
|
+
lfo.depth = lfo.baseDepth;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Pass 1: apply LFO→LFO chain routes using previous frame values
|
|
416
|
+
for (const route of this._routes.values()) {
|
|
417
|
+
if (!route.enabled || route.targetType !== 'lfo') continue;
|
|
418
|
+
const src = this._lfos.get(route.sourceId);
|
|
419
|
+
const tgt = this._lfos.get(route.target);
|
|
420
|
+
if (!src || !tgt) continue;
|
|
421
|
+
if (route.targetParam === 'rate') {
|
|
422
|
+
tgt.rate = Math.max(0.001, tgt.baseRate * (1 + src.currentValue * route.depth));
|
|
423
|
+
} else if (route.targetParam === 'depth') {
|
|
424
|
+
tgt.depth = Math.max(0, Math.min(1, tgt.baseDepth + src.currentValue * route.depth * 0.5));
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Pass 2: advance all LFOs and compute new values
|
|
429
|
+
if (dt > 0) {
|
|
430
|
+
for (const lfo of this._lfos.values()) {
|
|
431
|
+
this._computeValue(lfo, dt);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Notify UI subscribers
|
|
436
|
+
for (const fn of this._subscribers) {
|
|
437
|
+
for (const lfo of this._lfos.values()) fn(lfo.id, lfo.currentValue);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Pass 3: apply element routes
|
|
441
|
+
for (const route of this._routes.values()) {
|
|
442
|
+
if (!route.enabled || route.targetType !== 'element') continue;
|
|
443
|
+
const src = this._lfos.get(route.sourceId);
|
|
444
|
+
const el = route.target;
|
|
445
|
+
const meta = this._elementStates.get(el);
|
|
446
|
+
if (!src || !meta) continue;
|
|
447
|
+
|
|
448
|
+
const range = meta.max - meta.min;
|
|
449
|
+
const modAmount = src.currentValue * route.depth * range * 0.5;
|
|
450
|
+
let newVal = meta.baseValue + modAmount;
|
|
451
|
+
newVal = Math.max(meta.min, Math.min(meta.max, newVal));
|
|
452
|
+
if (meta.step > 0) {
|
|
453
|
+
newVal = Math.round(newVal / meta.step) * meta.step;
|
|
454
|
+
// Re-clamp: rounding can push value fractionally outside [min, max].
|
|
455
|
+
newVal = Math.max(meta.min, Math.min(meta.max, newVal));
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Only update if value actually changed (avoid unnecessary repaints)
|
|
459
|
+
if (parseFloat(el.value) !== newVal) {
|
|
460
|
+
el.value = newVal;
|
|
461
|
+
el.dispatchEvent(new Event('lfo-update', { bubbles: false }));
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Global engine singleton — import and use directly. */
|
|
468
|
+
export const engine = new LFOEngine();
|