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/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();