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.js ADDED
@@ -0,0 +1,1878 @@
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
+ 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
+ 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
+ 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
+ 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
+ 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
+ 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
+ const engine = new LFOEngine();
469
+
470
+ /**
471
+ * lfo-ui.js — LFO widget, modulation indicators, and drag-to-assign wiring.
472
+ *
473
+ * Provides:
474
+ * LFOWidget — Canvas-based LFO panel with controls and a drag handle.
475
+ * ModIndicator — Floating badge anchored to a connected element showing
476
+ * depth and a remove button. Also draws a range arc on range inputs.
477
+ *
478
+ * No external dependencies. Requires lfo-engine.js.
479
+ */
480
+
481
+
482
+ // ─── Constants ────────────────────────────────────────────────────────────────
483
+
484
+ const LFO_COLORS = [
485
+ '#00d4ff', // cyan
486
+ '#ff3aaa', // magenta
487
+ '#39ff14', // neon green
488
+ '#ff9500', // orange
489
+ '#bf80ff', // lavender
490
+ '#ffd700', // gold
491
+ '#00ffcc', // teal
492
+ '#ff4466', // red-pink
493
+ ];
494
+
495
+ let _colorIndex = 0;
496
+ let _labelIndex = 0;
497
+
498
+ const SHAPE_LABELS = {
499
+ sine: 'SIN',
500
+ triangle: 'TRI',
501
+ saw: 'SAW',
502
+ rsaw: 'RSW',
503
+ square: 'SQR',
504
+ random: 'S&H',
505
+ smooth: 'SMO',
506
+ };
507
+
508
+ // ─── CSS injection ────────────────────────────────────────────────────────────
509
+
510
+ const CSS = `
511
+ /* ── Widget container ──────────────────────────────────────────── */
512
+ .lfo-widget {
513
+ display: flex;
514
+ flex-direction: column;
515
+ flex: 1 1 200px;
516
+ background: #0a0a12;
517
+ border: 1px solid #222233;
518
+ border-radius: 8px;
519
+ padding: 10px;
520
+ min-width: 200px;
521
+ font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
522
+ font-size: 11px;
523
+ color: #888;
524
+ user-select: none;
525
+ gap: 7px;
526
+ box-shadow: 0 2px 16px rgba(0,0,0,0.5);
527
+ }
528
+
529
+ .lfo-header {
530
+ display: flex;
531
+ align-items: center;
532
+ justify-content: space-between;
533
+ gap: 6px;
534
+ }
535
+
536
+ .lfo-label {
537
+ font-size: 12px;
538
+ font-weight: 700;
539
+ letter-spacing: 0.08em;
540
+ text-transform: uppercase;
541
+ }
542
+
543
+ .lfo-led {
544
+ width: 7px;
545
+ height: 7px;
546
+ border-radius: 50%;
547
+ background: currentColor;
548
+ flex-shrink: 0;
549
+ transition: opacity 0.05s;
550
+ }
551
+
552
+ /* ── Canvas + shapes row ───────────────────────────────────────── */
553
+ .lfo-canvas-row {
554
+ display: flex;
555
+ gap: 4px;
556
+ align-items: stretch;
557
+ }
558
+
559
+ /* ── Waveform canvas ───────────────────────────────────────────── */
560
+ .lfo-canvas {
561
+ border-radius: 4px;
562
+ background: #050509;
563
+ display: block;
564
+ cursor: crosshair;
565
+ flex: 1;
566
+ min-width: 0;
567
+ width: 140px;
568
+ height: 72px;
569
+ }
570
+
571
+ /* ── Shape buttons — side panel (2 col × 4 row) ────────────────── */
572
+ .lfo-shapes {
573
+ display: grid;
574
+ grid-template-columns: 1fr 1fr;
575
+ grid-template-rows: repeat(4, 1fr);
576
+ gap: 2px;
577
+ width: 52px;
578
+ flex-shrink: 0;
579
+ }
580
+
581
+ .lfo-shape-btn {
582
+ background: #111120;
583
+ border: 1px solid #1e1e30;
584
+ color: #555;
585
+ padding: 1px 0;
586
+ border-radius: 3px;
587
+ cursor: pointer;
588
+ font-size: 9px;
589
+ font-family: inherit;
590
+ text-align: center;
591
+ transition: color 0.1s, border-color 0.1s, background 0.1s;
592
+ line-height: 1;
593
+ display: flex;
594
+ align-items: center;
595
+ justify-content: center;
596
+ }
597
+
598
+ .lfo-shape-btn:hover {
599
+ border-color: #333;
600
+ color: #999;
601
+ }
602
+
603
+ .lfo-shape-btn.active {
604
+ border-color: var(--lfo-color, #00d4ff);
605
+ color: var(--lfo-color, #00d4ff);
606
+ background: #08080f;
607
+ }
608
+
609
+ .lfo-bipolar-btn {
610
+ border-top: 1px solid #2a2a3e;
611
+ }
612
+
613
+ /* ── Param rows ─────────────────────────────────────────────────── */
614
+ .lfo-params {
615
+ display: grid;
616
+ grid-template-columns: 1fr 1fr;
617
+ gap: 5px;
618
+ }
619
+
620
+ .lfo-param-group {
621
+ display: flex;
622
+ flex-direction: column;
623
+ gap: 3px;
624
+ }
625
+
626
+ .lfo-param-group label {
627
+ font-size: 9px;
628
+ color: #444;
629
+ text-transform: uppercase;
630
+ letter-spacing: 0.06em;
631
+ }
632
+
633
+ .lfo-param-row {
634
+ display: flex;
635
+ align-items: center;
636
+ gap: 4px;
637
+ }
638
+
639
+ .lfo-param-group input[type=range] {
640
+ flex: 1;
641
+ min-width: 0;
642
+ appearance: none;
643
+ -webkit-appearance: none;
644
+ height: 3px;
645
+ border-radius: 2px;
646
+ background: #1a1a28;
647
+ outline: none;
648
+ cursor: pointer;
649
+ }
650
+
651
+ .lfo-param-group input[type=range]::-webkit-slider-thumb {
652
+ -webkit-appearance: none;
653
+ width: 11px;
654
+ height: 11px;
655
+ border-radius: 50%;
656
+ background: var(--lfo-color, #00d4ff);
657
+ cursor: pointer;
658
+ box-shadow: 0 0 4px var(--lfo-color, #00d4ff);
659
+ }
660
+
661
+ .lfo-param-group input[type=range]::-moz-range-thumb {
662
+ width: 11px;
663
+ height: 11px;
664
+ border-radius: 50%;
665
+ background: var(--lfo-color, #00d4ff);
666
+ cursor: pointer;
667
+ border: none;
668
+ box-shadow: 0 0 4px var(--lfo-color, #00d4ff);
669
+ }
670
+
671
+ .lfo-param-val {
672
+ font-size: 9px;
673
+ color: var(--lfo-color, #00d4ff);
674
+ width: 44px;
675
+ text-align: right;
676
+ flex-shrink: 0;
677
+ overflow: hidden;
678
+ cursor: text;
679
+ }
680
+
681
+ .lfo-param-edit {
682
+ font-size: 9px;
683
+ font-family: inherit;
684
+ color: var(--lfo-color, #00d4ff);
685
+ background: transparent;
686
+ border: none;
687
+ border-bottom: 1px solid var(--lfo-color, #00d4ff);
688
+ width: 44px;
689
+ flex-shrink: 0;
690
+ text-align: right;
691
+ outline: none;
692
+ padding: 0;
693
+ }
694
+
695
+ /* ── Connect handle ─────────────────────────────────────────────── */
696
+ .lfo-connect-handle {
697
+ background: #0d0d1c;
698
+ border: 1px dashed var(--lfo-color, #00d4ff);
699
+ border-radius: 4px;
700
+ padding: 6px 8px;
701
+ text-align: center;
702
+ color: var(--lfo-color, #00d4ff);
703
+ font-size: 10px;
704
+ cursor: grab;
705
+ transition: opacity 0.15s, background 0.15s;
706
+ opacity: 0.65;
707
+ letter-spacing: 0.04em;
708
+ }
709
+
710
+ .lfo-connect-handle:hover {
711
+ opacity: 1;
712
+ background: #060610;
713
+ }
714
+
715
+ .lfo-connect-handle.dragging {
716
+ cursor: grabbing;
717
+ opacity: 1;
718
+ }
719
+
720
+ .lfo-connect-handle.armed {
721
+ opacity: 1;
722
+ background: #060610;
723
+ border-style: solid;
724
+ }
725
+
726
+ /* ── Drag wire SVG ──────────────────────────────────────────────── */
727
+ #lfo-drag-wire-svg {
728
+ position: fixed;
729
+ top: 0; left: 0;
730
+ width: 100%;
731
+ height: 100%;
732
+ pointer-events: none;
733
+ z-index: calc(var(--lfo-z-base, 9000) + 2);
734
+ overflow: visible;
735
+ }
736
+
737
+ /* ── Target highlight during drag ───────────────────────────────── */
738
+ .lfo-drag-target {
739
+ outline: 2px solid var(--lfo-drag-color, #00d4ff) !important;
740
+ outline-offset: 3px;
741
+ border-radius: 2px;
742
+ }
743
+
744
+ /* ── Mod indicator badge ─────────────────────────────────────────── */
745
+ .lfo-mod-badge {
746
+ position: fixed;
747
+ display: flex;
748
+ align-items: center;
749
+ gap: 5px;
750
+ background: #0a0a14;
751
+ border: 1px solid #222233;
752
+ border-radius: 4px;
753
+ padding: 3px 6px;
754
+ font-family: 'SF Mono', 'Fira Code', monospace;
755
+ font-size: 10px;
756
+ color: #888;
757
+ z-index: calc(var(--lfo-z-base, 9000) + 1);
758
+ pointer-events: auto;
759
+ cursor: default;
760
+ white-space: nowrap;
761
+ box-shadow: 0 1px 8px rgba(0,0,0,0.6);
762
+ transition: opacity 0.1s;
763
+ }
764
+
765
+ .lfo-mod-badge:hover {
766
+ opacity: 1 !important;
767
+ }
768
+
769
+ .lfo-mod-dot {
770
+ width: 6px;
771
+ height: 6px;
772
+ border-radius: 50%;
773
+ flex-shrink: 0;
774
+ }
775
+
776
+ .lfo-mod-depth-label {
777
+ cursor: ew-resize;
778
+ color: var(--badge-color, #00d4ff);
779
+ font-weight: bold;
780
+ min-width: 32px;
781
+ text-align: right;
782
+ user-select: none;
783
+ }
784
+
785
+ .lfo-mod-depth-label:hover::after {
786
+ content: ' ↔';
787
+ opacity: 0.5;
788
+ font-size: 9px;
789
+ }
790
+
791
+ .lfo-mod-remove {
792
+ background: none;
793
+ border: none;
794
+ color: #444;
795
+ cursor: pointer;
796
+ font-size: 12px;
797
+ line-height: 1;
798
+ padding: 0 1px;
799
+ font-family: inherit;
800
+ transition: color 0.1s;
801
+ }
802
+
803
+ .lfo-mod-remove:hover {
804
+ color: #ff4466;
805
+ }
806
+
807
+ /* ── Range arc canvas overlay ────────────────────────────────────── */
808
+ .lfo-range-arc {
809
+ position: fixed;
810
+ pointer-events: none;
811
+ z-index: var(--lfo-z-base, 9000);
812
+ }
813
+ `;
814
+
815
+ let _stylesInjected = false;
816
+
817
+ function injectStyles() {
818
+ if (_stylesInjected || typeof document === 'undefined') return;
819
+ _stylesInjected = true;
820
+ const style = document.createElement('style');
821
+ style.id = 'lfo-ui-styles';
822
+ style.textContent = CSS;
823
+ document.head.appendChild(style);
824
+ }
825
+
826
+ // ─── Drag wire helper ─────────────────────────────────────────────────────────
827
+
828
+ /**
829
+ * Creates a full-screen SVG overlay showing the drag-assign wire.
830
+ * @param {string} color
831
+ */
832
+ function createDragWire(color) {
833
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
834
+ svg.id = 'lfo-drag-wire-svg';
835
+ document.body.appendChild(svg);
836
+
837
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
838
+ path.setAttribute('fill', 'none');
839
+ path.setAttribute('stroke', color);
840
+ path.setAttribute('stroke-width', '1.5');
841
+ path.setAttribute('stroke-dasharray', '5 4');
842
+ path.setAttribute('opacity', '0.75');
843
+ svg.appendChild(path);
844
+
845
+ const endDot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
846
+ endDot.setAttribute('r', '5');
847
+ endDot.setAttribute('fill', color);
848
+ endDot.setAttribute('opacity', '0.9');
849
+ svg.appendChild(endDot);
850
+
851
+ let sx = 0, sy = 0;
852
+
853
+ return {
854
+ setStart(x, y) { sx = x; sy = y; },
855
+ setEnd(x, y) {
856
+ const midX = (sx + x) / 2;
857
+ path.setAttribute('d',
858
+ `M ${sx} ${sy} C ${midX} ${sy}, ${midX} ${y}, ${x} ${y}`
859
+ );
860
+ endDot.setAttribute('cx', x);
861
+ endDot.setAttribute('cy', y);
862
+ },
863
+ setValid(valid) {
864
+ const c = valid ? color : '#555';
865
+ path.setAttribute('stroke', c);
866
+ endDot.setAttribute('fill', c);
867
+ },
868
+ remove() { svg.remove(); },
869
+ };
870
+ }
871
+
872
+ // ─── Target detection ─────────────────────────────────────────────────────────
873
+
874
+ /** Elements that are valid LFO modulation targets. */
875
+ function isModTarget(el) {
876
+ if (!el || el.tagName === 'BUTTON') return false;
877
+ if (el.tagName === 'INPUT' &&
878
+ (el.type === 'range' || el.type === 'number')) return true;
879
+ if (el.dataset.lfoTarget !== undefined) return true;
880
+ return false;
881
+ }
882
+
883
+ /** Find the best modulation target element under (cx, cy), ignoring skip. */
884
+ function getModTarget(cx, cy, skip) {
885
+ const elems = document.elementsFromPoint(cx, cy);
886
+ for (const el of elems) {
887
+ if (el === skip || el.closest('#lfo-drag-wire-svg')) continue;
888
+ if (isModTarget(el)) return el;
889
+ }
890
+ return null;
891
+ }
892
+
893
+ // ─── ModIndicator ─────────────────────────────────────────────────────────────
894
+
895
+ /**
896
+ * Floating badge anchored to a connected input element.
897
+ * Shows depth, allows drag-to-adjust, and a remove button.
898
+ */
899
+ class ModIndicator {
900
+ /**
901
+ * @param {HTMLElement} element Connected input.
902
+ * @param {string} routeId
903
+ * @param {string} lfoId
904
+ * @param {string} color
905
+ * @param {string} lfoLabel
906
+ * @param {function} onRemove Called when the user removes this connection.
907
+ */
908
+ constructor(element, routeId, lfoId, color, lfoLabel, onRemove) {
909
+ this._element = element;
910
+ this._routeId = routeId;
911
+ this._lfoId = lfoId;
912
+ this._color = color;
913
+ this._onRemove = onRemove;
914
+ this._badge = null;
915
+ this._arcCanvas = null;
916
+ this._rafId = null;
917
+
918
+ injectStyles();
919
+ this._buildBadge(lfoLabel);
920
+ this._buildArcCanvas();
921
+ this._startPositioning();
922
+ }
923
+
924
+ _buildBadge(label) {
925
+ const badge = this._badge = document.createElement('div');
926
+ badge.className = 'lfo-mod-badge';
927
+ badge.style.setProperty('--badge-color', this._color);
928
+ badge.style.opacity = '0.85';
929
+
930
+ const dot = document.createElement('div');
931
+ dot.className = 'lfo-mod-dot';
932
+ dot.style.background = this._color;
933
+ dot.style.boxShadow = `0 0 4px ${this._color}`;
934
+
935
+ const lbl = document.createElement('span');
936
+ lbl.textContent = label;
937
+ lbl.style.color = '#666';
938
+ lbl.style.fontSize = '9px';
939
+
940
+ const depthLabel = this._depthLabel = document.createElement('span');
941
+ depthLabel.className = 'lfo-mod-depth-label';
942
+ depthLabel.title = 'Drag left/right to adjust modulation depth';
943
+ this._updateDepthLabel();
944
+
945
+ const removeBtn = document.createElement('button');
946
+ removeBtn.className = 'lfo-mod-remove';
947
+ removeBtn.textContent = '×';
948
+ removeBtn.title = 'Remove modulation';
949
+ removeBtn.addEventListener('click', (e) => {
950
+ e.stopPropagation();
951
+ this._onRemove?.(this._routeId);
952
+ });
953
+
954
+ // Drag depth label to adjust depth
955
+ let dragStartX = 0;
956
+ let dragStartDepth = 0;
957
+
958
+ depthLabel.addEventListener('pointerdown', (e) => {
959
+ dragStartX = e.clientX;
960
+ dragStartDepth = engine.getRoute(this._routeId)?.depth ?? 0.5;
961
+ depthLabel.setPointerCapture(e.pointerId);
962
+ e.preventDefault();
963
+ });
964
+
965
+ depthLabel.addEventListener('pointermove', (e) => {
966
+ if (!depthLabel.hasPointerCapture(e.pointerId)) return;
967
+ const delta = (e.clientX - dragStartX) / 120;
968
+ const newDepth = Math.max(0, Math.min(1, dragStartDepth + delta));
969
+ engine.setRouteDepth(this._routeId, newDepth);
970
+ this._updateDepthLabel();
971
+ });
972
+
973
+ badge.appendChild(dot);
974
+ badge.appendChild(lbl);
975
+ badge.appendChild(depthLabel);
976
+ badge.appendChild(removeBtn);
977
+ document.body.appendChild(badge);
978
+ }
979
+
980
+ _buildArcCanvas() {
981
+ // Small arc overlay on range inputs showing the mod sweep range
982
+ if (this._element.type !== 'range') return;
983
+
984
+ const canvas = this._arcCanvas = document.createElement('canvas');
985
+ canvas.className = 'lfo-range-arc';
986
+ canvas.width = 0;
987
+ canvas.height = 5;
988
+ document.body.appendChild(canvas);
989
+ this._arcCtx = canvas.getContext('2d');
990
+ }
991
+
992
+ _updateDepthLabel() {
993
+ const depth = engine.getRoute(this._routeId)?.depth ?? 0;
994
+ if (this._depthLabel) {
995
+ this._depthLabel.textContent = `${Math.round(depth * 100)}%`;
996
+ }
997
+ }
998
+
999
+ _startPositioning() {
1000
+ const update = () => {
1001
+ this._rafId = requestAnimationFrame(update);
1002
+ this._reposition();
1003
+ this._updateArc();
1004
+ this._updateDepthLabel();
1005
+ };
1006
+ this._rafId = requestAnimationFrame(update);
1007
+ }
1008
+
1009
+ _reposition() {
1010
+ if (!this._badge) return;
1011
+ const r = this._element.getBoundingClientRect();
1012
+ if (r.width === 0 && r.height === 0) return;
1013
+ // Badge dimensions rarely change after first paint — read once and cache.
1014
+ if (!this._badgeSize) {
1015
+ const bw = this._badge.offsetWidth;
1016
+ const bh = this._badge.offsetHeight;
1017
+ if (bw && bh) this._badgeSize = { bw, bh };
1018
+ else return;
1019
+ }
1020
+ const { bw, bh } = this._badgeSize;
1021
+
1022
+ let bx = r.right - bw;
1023
+ let by = r.top - bh - 4;
1024
+
1025
+ // Keep badge on screen
1026
+ if (bx < 4) bx = 4;
1027
+ if (by < 4) by = r.bottom + 4;
1028
+
1029
+ this._badge.style.left = `${bx}px`;
1030
+ this._badge.style.top = `${by}px`;
1031
+ }
1032
+
1033
+ _updateArc() {
1034
+ const canvas = this._arcCanvas;
1035
+ if (!canvas) return;
1036
+
1037
+ const r = this._element.getBoundingClientRect();
1038
+ canvas.style.left = `${r.left}px`;
1039
+ canvas.style.top = `${r.bottom + 1}px`;
1040
+ // Only reset canvas width when it actually changes — resizing clears the
1041
+ // canvas and is relatively expensive to do unconditionally at 60fps.
1042
+ // Note: reassigning canvas.width does NOT invalidate the 2D context stored
1043
+ // in this._arcCtx; the same context reference remains usable after resize.
1044
+ const newW = Math.round(r.width);
1045
+ if (canvas.width !== newW) {
1046
+ canvas.width = newW;
1047
+ canvas.style.width = `${r.width}px`;
1048
+ }
1049
+ if (canvas.height !== 5) {
1050
+ canvas.height = 5;
1051
+ canvas.style.height = '5px';
1052
+ }
1053
+
1054
+ const route = engine.getRoute(this._routeId);
1055
+ if (!route) return;
1056
+ const lfoVal = engine.getValue(this._lfoId);
1057
+ const depth = route.depth;
1058
+
1059
+ let centerNorm, swingNorm, curNorm;
1060
+
1061
+ if (route.targetType === 'lfo') {
1062
+ // Chain route — synthesize arc params from target LFO state.
1063
+ const tgt = engine.getLFO(route.target);
1064
+ if (!tgt) return;
1065
+ if (route.targetParam === 'rate') {
1066
+ // Rate slider is log-scaled (0.01–10 Hz). Normalise in log space to
1067
+ // match slider position so the arc reflects the actual sweep range.
1068
+ const min = 0.01, max = 10;
1069
+ const logRange = Math.log(max / min);
1070
+ const logNorm = v => Math.max(0, Math.min(1, Math.log(Math.max(min, v) / min) / logRange));
1071
+ const base = tgt.baseRate;
1072
+ centerNorm = logNorm(base);
1073
+ // Effective rate = base × (1 + src × depth) — compute high/low in log space.
1074
+ swingNorm = (logNorm(base * (1 + depth)) - logNorm(base * (1 - depth))) / 2;
1075
+ curNorm = logNorm(base * (1 + lfoVal * depth));
1076
+ } else if (route.targetParam === 'depth') {
1077
+ const base = tgt.baseDepth;
1078
+ centerNorm = base;
1079
+ swingNorm = depth * 0.5;
1080
+ curNorm = Math.max(0, Math.min(1, base + lfoVal * depth * 0.5));
1081
+ } else {
1082
+ return;
1083
+ }
1084
+ } else {
1085
+ // Element route — use registered element metadata.
1086
+ const meta = engine.getElementMeta(this._element);
1087
+ if (!meta) return;
1088
+ const range = meta.max - meta.min;
1089
+ if (range === 0) return;
1090
+ centerNorm = (meta.baseValue - meta.min) / range;
1091
+ swingNorm = depth * 0.5;
1092
+ curNorm = Math.max(0, Math.min(1, (meta.baseValue + lfoVal * depth * range * 0.5 - meta.min) / range));
1093
+ }
1094
+
1095
+ const w = canvas.width;
1096
+ const h = canvas.height;
1097
+ const ctx = this._arcCtx;
1098
+ ctx.clearRect(0, 0, w, h);
1099
+
1100
+ // Draw sweep range bar
1101
+ const xCenter = centerNorm * w;
1102
+ const xMin = Math.max(0, (centerNorm - swingNorm) * w);
1103
+ const xMax = Math.min(w, (centerNorm + swingNorm) * w);
1104
+
1105
+ ctx.fillStyle = `${this._color}30`;
1106
+ ctx.fillRect(xMin, 1, xMax - xMin, h - 2);
1107
+
1108
+ // Draw center tick
1109
+ ctx.fillStyle = `${this._color}80`;
1110
+ ctx.fillRect(xCenter - 0.5, 0, 1, h);
1111
+
1112
+ // Draw current position dot
1113
+ ctx.fillStyle = this._color;
1114
+ ctx.beginPath();
1115
+ ctx.arc(curNorm * w, h / 2, 2.5, 0, Math.PI * 2);
1116
+ ctx.fill();
1117
+ }
1118
+
1119
+ destroy() {
1120
+ if (this._rafId != null) cancelAnimationFrame(this._rafId);
1121
+ this._badge?.remove();
1122
+ this._arcCanvas?.remove();
1123
+ }
1124
+
1125
+ get routeId() { return this._routeId; }
1126
+ }
1127
+
1128
+ // ─── LFOWidget ────────────────────────────────────────────────────────────────
1129
+
1130
+ /**
1131
+ * Canvas-based LFO panel with waveform display, shape selector, parameter
1132
+ * sliders, and a drag handle for wiring to any modulation target.
1133
+ */
1134
+ class LFOWidget {
1135
+ /**
1136
+ * @param {HTMLElement} container Where the widget is appended.
1137
+ * @param {string} lfoId Engine LFO id.
1138
+ * @param {object} [opts]
1139
+ * @param {string} [opts.color] Accent color hex.
1140
+ * @param {string} [opts.label] Display label.
1141
+ * @param {function} [opts.onConnect] (lfoId, element, routeId) => void
1142
+ * @param {function} [opts.onDisconnect] (routeId) => void
1143
+ */
1144
+ constructor(container, lfoId, opts = {}) {
1145
+ injectStyles();
1146
+ this._lfoId = lfoId;
1147
+ this._color = opts.color ?? LFO_COLORS[_colorIndex++ % LFO_COLORS.length];
1148
+ this._label = opts.label ?? `LFO ${++_labelIndex}`;
1149
+ this._onConnect = opts.onConnect;
1150
+ this._onDisconnect = opts.onDisconnect;
1151
+
1152
+ /** @type {Map<string, ModIndicator>} routeId → indicator */
1153
+ this._indicators = new Map();
1154
+
1155
+ this._build(container);
1156
+ this._latestValue = 0;
1157
+ this._unsub = engine.subscribe((id, value) => {
1158
+ if (id === this._lfoId) {
1159
+ this._recordSample(value);
1160
+ this._updateLed(value);
1161
+ this._syncChainedSliders();
1162
+ this._pruneDeadRoutes();
1163
+ }
1164
+ });
1165
+ this._rafHandle = null;
1166
+ this._destroyed = false;
1167
+ const rafLoop = () => {
1168
+ if (this._destroyed) return;
1169
+ this._rafDraw();
1170
+ this._rafHandle = requestAnimationFrame(rafLoop);
1171
+ };
1172
+ this._rafHandle = requestAnimationFrame(rafLoop);
1173
+ }
1174
+
1175
+ // ── Build DOM ────────────────────────────────────────────────────────────
1176
+
1177
+ _build(container) {
1178
+ const root = this._root = document.createElement('div');
1179
+ root.className = 'lfo-widget';
1180
+ root.style.setProperty('--lfo-color', this._color);
1181
+
1182
+ // Header
1183
+ const header = document.createElement('div');
1184
+ header.className = 'lfo-header';
1185
+
1186
+ const label = document.createElement('span');
1187
+ label.className = 'lfo-label';
1188
+ label.textContent = this._label;
1189
+ label.style.color = this._color;
1190
+
1191
+ this._led = document.createElement('div');
1192
+ this._led.className = 'lfo-led';
1193
+ this._led.style.color = this._color;
1194
+
1195
+ header.appendChild(label);
1196
+ header.appendChild(this._led);
1197
+ root.appendChild(header);
1198
+
1199
+ // Canvas + shape buttons row
1200
+ const canvasRow = document.createElement('div');
1201
+ canvasRow.className = 'lfo-canvas-row';
1202
+
1203
+ const canvas = this._canvas = document.createElement('canvas');
1204
+ canvas.className = 'lfo-canvas';
1205
+ const dpr = window.devicePixelRatio || 1;
1206
+ canvas.width = 140 * dpr;
1207
+ canvas.height = 72 * dpr;
1208
+ this._ctx = canvas.getContext('2d');
1209
+ canvasRow.appendChild(canvas);
1210
+
1211
+ // Shape buttons — 2 col × 4 row side panel (7 shapes + bipolar toggle = 8 slots)
1212
+ const shapesEl = this._shapesEl = document.createElement('div');
1213
+ shapesEl.className = 'lfo-shapes';
1214
+ for (const shape of SHAPES) {
1215
+ const btn = document.createElement('button');
1216
+ btn.className = 'lfo-shape-btn';
1217
+ btn.textContent = SHAPE_LABELS[shape] ?? shape.toUpperCase().slice(0, 3);
1218
+ btn.title = shape;
1219
+ btn.dataset.shape = shape;
1220
+ btn.addEventListener('click', () => {
1221
+ engine.setParam(this._lfoId, 'shape', shape);
1222
+ this._refreshShapeButtons();
1223
+ });
1224
+ shapesEl.appendChild(btn);
1225
+ }
1226
+
1227
+ // Bipolar toggle occupies the 8th (filler) slot
1228
+ const bipolarBtn = this._bipolarBtn = document.createElement('button');
1229
+ bipolarBtn.className = 'lfo-shape-btn lfo-bipolar-btn';
1230
+ bipolarBtn.title = 'Toggle bipolar (±1) / unipolar (0–1) output';
1231
+ bipolarBtn.addEventListener('click', () => {
1232
+ const current = engine.getParam(this._lfoId, 'bipolar') ?? true;
1233
+ engine.setParam(this._lfoId, 'bipolar', !current);
1234
+ this._refreshBipolarBtn();
1235
+ });
1236
+ shapesEl.appendChild(bipolarBtn);
1237
+
1238
+ canvasRow.appendChild(shapesEl);
1239
+ root.appendChild(canvasRow);
1240
+
1241
+ // Param sliders — 3-row × 2-col grid (rate, depth, phase, offset, jitter, skew)
1242
+ const params = document.createElement('div');
1243
+ params.className = 'lfo-params';
1244
+
1245
+ this._rateInput = this._addParam(params, 'Rate', 0.01, 10, 1, 0.01, 'baseRate', v => `${v.toFixed(2)}Hz`, 'log');
1246
+ this._depthInput = this._addParam(params, 'Depth', 0, 1, 1, 0.01, 'baseDepth', v => `${Math.round(v * 100)}%`);
1247
+ this._phaseInput = this._addParam(params, 'Phase', 0, 1, 0, 0.01, 'phase', v => `${Math.round(v * 360)}°`);
1248
+ this._offsetInput = this._addParam(params, 'Offs.', -1, 1, 0, 0.01, 'offset', v => v.toFixed(2));
1249
+ this._jitterInput = this._addParam(params, 'Jitter', 0, 1, 0, 0.01, 'jitter', v => `${Math.round(v * 100)}%`);
1250
+ this._skewInput = this._addParam(params, 'Skew', 0, 1, 0.5, 0.01, 'skew', v => `${Math.round((v - 0.5) * 200)}%`);
1251
+
1252
+ root.appendChild(params);
1253
+
1254
+ // Connect handle
1255
+ const handle = this._handle = document.createElement('div');
1256
+ handle.className = 'lfo-connect-handle';
1257
+ handle.innerHTML = '⊕&nbsp; drag to assign';
1258
+ handle.title = 'Drag onto any slider or number input to modulate it.\nDrag onto another LFO\'s Rate/Depth slider to chain.';
1259
+ this._attachDragHandlers(handle);
1260
+ root.appendChild(handle);
1261
+
1262
+ container.appendChild(root);
1263
+ this._refreshShapeButtons();
1264
+ this._refreshBipolarBtn();
1265
+ }
1266
+
1267
+ _addParam(container, labelText, min, max, defaultVal, step, param, fmt, scale = 'linear') {
1268
+ const group = document.createElement('div');
1269
+ group.className = 'lfo-param-group';
1270
+
1271
+ const lbl = document.createElement('label');
1272
+ lbl.textContent = labelText;
1273
+ group.appendChild(lbl);
1274
+
1275
+ const row = document.createElement('div');
1276
+ row.className = 'lfo-param-row';
1277
+
1278
+ // Log scale: slider position 0–1000; actual = min × (max/min)^(pos/1000)
1279
+ const logScale = scale === 'log';
1280
+ const toActual = logScale ? pos => min * Math.pow(max / min, pos / 1000) : v => v;
1281
+ const toPos = logScale ? v => Math.log(v / min) / Math.log(max / min) * 1000 : v => v;
1282
+
1283
+ const input = document.createElement('input');
1284
+ input.type = 'range';
1285
+ input.min = logScale ? 0 : min;
1286
+ input.max = logScale ? 1000 : max;
1287
+ input.step = logScale ? 1 : step;
1288
+ input.value = toPos(defaultVal);
1289
+ // Store converter so _syncChainedSliders can map actual → slider position
1290
+ input._toPos = toPos;
1291
+ // Mark as LFO param so drag-to-assign auto-promotes to chain route
1292
+ input.dataset.lfoId = this._lfoId;
1293
+ input.dataset.lfoParam = param;
1294
+
1295
+ const valEl = document.createElement('span');
1296
+ valEl.className = 'lfo-param-val';
1297
+ valEl.textContent = fmt(defaultVal);
1298
+
1299
+ const precision = (step.toString().split('.')[1] ?? '').length;
1300
+
1301
+ valEl.addEventListener('click', () => {
1302
+ const editEl = document.createElement('input');
1303
+ editEl.type = 'text';
1304
+ editEl.className = 'lfo-param-edit';
1305
+ editEl.value = toActual(parseFloat(input.value)).toFixed(precision);
1306
+ valEl.replaceWith(editEl);
1307
+ editEl.select();
1308
+
1309
+ let cancelled = false;
1310
+ const commit = () => {
1311
+ if (cancelled) return;
1312
+ const raw = parseFloat(editEl.value);
1313
+ if (!isNaN(raw)) {
1314
+ const clamped = Math.min(max, Math.max(min, raw));
1315
+ input.value = toPos(clamped);
1316
+ engine.setParam(this._lfoId, param, clamped);
1317
+ valEl.textContent = fmt(clamped);
1318
+ }
1319
+ editEl.replaceWith(valEl);
1320
+ };
1321
+
1322
+ editEl.addEventListener('blur', commit);
1323
+ editEl.addEventListener('keydown', e => {
1324
+ if (e.key === 'Enter') { e.preventDefault(); editEl.blur(); }
1325
+ if (e.key === 'Escape') { cancelled = true; editEl.replaceWith(valEl); }
1326
+ });
1327
+ });
1328
+
1329
+ input.addEventListener('input', () => {
1330
+ const v = toActual(parseFloat(input.value));
1331
+ engine.setParam(this._lfoId, param, v);
1332
+ valEl.textContent = fmt(v);
1333
+ });
1334
+
1335
+ // Also update display when LFO engine writes to this slider (chain modulation)
1336
+ input.addEventListener('lfo-update', () => {
1337
+ valEl.textContent = fmt(toActual(parseFloat(input.value)));
1338
+ });
1339
+
1340
+ row.appendChild(input);
1341
+ row.appendChild(valEl);
1342
+ group.appendChild(row);
1343
+ container.appendChild(group);
1344
+ return input;
1345
+ }
1346
+
1347
+ _refreshShapeButtons() {
1348
+ const current = engine.getParam(this._lfoId, 'shape');
1349
+ for (const btn of this._shapesEl.querySelectorAll('.lfo-shape-btn')) {
1350
+ btn.classList.toggle('active', btn.dataset.shape === current);
1351
+ }
1352
+ }
1353
+
1354
+ _refreshBipolarBtn() {
1355
+ const bipolar = engine.getParam(this._lfoId, 'bipolar') ?? true;
1356
+ this._bipolarBtn.textContent = bipolar ? 'BI' : 'UNI';
1357
+ this._bipolarBtn.classList.toggle('active', !bipolar);
1358
+ this._bipolarBtn.title = bipolar
1359
+ ? 'Bipolar output (±1) — click for unipolar (0–1)'
1360
+ : 'Unipolar output (0–1) — click for bipolar (±1)';
1361
+ }
1362
+
1363
+ // ── Waveform drawing ─────────────────────────────────────────────────────
1364
+ //
1365
+ // _wfHistory: Float32Array(W) of actual LFO output samples — real history,
1366
+ // no formula extrapolation. Each frame, intShift new samples are appended
1367
+ // (linearly interpolated from _wfPrevValue → currentValue) and the array
1368
+ // shifts left. The pixel buffer scrolls in lock-step via getImageData /
1369
+ // putImageData, so only intShift columns are repainted. Cursor + dot at
1370
+ // the right edge (px = W-1) — always the most-recent sample.
1371
+
1372
+ /**
1373
+ * Reflect effective (post-chain-modulation) rate and depth onto the param
1374
+ * sliders so they visually track the modulated position. Only touches the
1375
+ * DOM when the effective value actually differs from the displayed value to
1376
+ * avoid unnecessary repaints.
1377
+ */
1378
+ _syncChainedSliders() {
1379
+ const lfo = engine.getLFO(this._lfoId);
1380
+ if (!lfo) return;
1381
+ const pairs = [
1382
+ [this._rateInput, lfo.rate],
1383
+ [this._depthInput, lfo.depth],
1384
+ ];
1385
+ for (const [input, eff] of pairs) {
1386
+ const pos = input._toPos ? input._toPos(eff) : eff;
1387
+ if (Math.abs(parseFloat(input.value) - pos) > 1e-6) {
1388
+ input.value = pos;
1389
+ input.dispatchEvent(new Event('lfo-update'));
1390
+ }
1391
+ }
1392
+ }
1393
+
1394
+ /**
1395
+ * Remove any indicators whose routes have been deleted externally — e.g. when
1396
+ * the target LFO is destroyed and engine.destroyLFO cleans up incoming routes.
1397
+ * Called each tick so stale badges are cleared within one frame.
1398
+ */
1399
+ _pruneDeadRoutes() {
1400
+ for (const routeId of [...this._indicators.keys()]) {
1401
+ if (!engine.getRoute(routeId)) {
1402
+ this.disconnectRoute(routeId);
1403
+ }
1404
+ }
1405
+ }
1406
+
1407
+ _recordSample(currentValue) {
1408
+ this._latestValue = currentValue;
1409
+ const canvas = this._canvas;
1410
+ const W = canvas.width;
1411
+ const H = canvas.height;
1412
+ // Canvas shows a fixed 4-second time window regardless of LFO rate.
1413
+ const pxPerSec = W / 4;
1414
+ const now = performance.now();
1415
+
1416
+ // ── Init on first call ────────────────────────────────────────────────
1417
+ if (!this._wfBuf) {
1418
+ const buf = document.createElement('canvas');
1419
+ buf.width = W;
1420
+ buf.height = H;
1421
+ this._wfBuf = buf;
1422
+ this._wfBufCtx = buf.getContext('2d', { willReadFrequently: true });
1423
+ // W is canvas.width = 140 * devicePixelRatio, so the history array is
1424
+ // always sized to match physical pixels — DPR scaling is baked in here.
1425
+ this._wfHistory = new Float32Array(W).fill(currentValue);
1426
+ this._wfSubpx = 0;
1427
+ this._wfLastTime = now;
1428
+ this._wfPrevValue = currentValue;
1429
+ this._wfNeedsFullRedraw = true;
1430
+ }
1431
+
1432
+ // ── Compute how many new pixels to append ─────────────────────────────
1433
+ const dt = (now - this._wfLastTime) / 1000;
1434
+ this._wfLastTime = now;
1435
+
1436
+ const totalPx = dt * pxPerSec + this._wfSubpx;
1437
+ const intShift = Math.min(Math.floor(totalPx), W);
1438
+ this._wfSubpx = totalPx - intShift;
1439
+
1440
+ if (intShift > 0) {
1441
+ // Shift history left and append interpolated new samples on the right
1442
+ this._wfHistory.copyWithin(0, intShift);
1443
+ const prev = this._wfPrevValue;
1444
+ const cur = currentValue;
1445
+ for (let i = 0; i < intShift; i++) {
1446
+ const t = (i + 1) / intShift;
1447
+ this._wfHistory[W - intShift + i] = prev + (cur - prev) * t;
1448
+ }
1449
+ }
1450
+ this._wfPrevValue = currentValue;
1451
+
1452
+ // ── Paint offscreen pixel buffer ──────────────────────────────────────
1453
+ const bCtx = this._wfBufCtx;
1454
+
1455
+ if (this._wfNeedsFullRedraw || intShift >= W) {
1456
+ this._wfNeedsFullRedraw = false;
1457
+ this._wfPaintFull(bCtx, W, H);
1458
+ } else if (intShift > 0) {
1459
+ this._wfPaintIncremental(bCtx, W, H, intShift);
1460
+ }
1461
+ }
1462
+
1463
+ /** Composite _wfBuf → visible canvas + cursor dot. Called from RAF. */
1464
+ _rafDraw() {
1465
+ if (!this._wfBuf) return;
1466
+ const canvas = this._canvas;
1467
+ const W = canvas.width;
1468
+ const H = canvas.height;
1469
+ const currentValue = this._latestValue;
1470
+
1471
+ const ctx = this._ctx;
1472
+ ctx.drawImage(this._wfBuf, 0, 0);
1473
+
1474
+ const cursorX = W - 1;
1475
+ const color = this._color;
1476
+ ctx.strokeStyle = `${color}50`;
1477
+ ctx.lineWidth = 1;
1478
+ ctx.setLineDash([2, 3]);
1479
+ ctx.beginPath();
1480
+ ctx.moveTo(cursorX, 0);
1481
+ ctx.lineTo(cursorX, H);
1482
+ ctx.stroke();
1483
+ ctx.setLineDash([]);
1484
+
1485
+ const dotY = (1 - (currentValue + 1) / 2) * (H - 6) + 3;
1486
+ ctx.fillStyle = color;
1487
+ ctx.shadowBlur = 8;
1488
+ ctx.shadowColor = color;
1489
+ ctx.beginPath();
1490
+ ctx.arc(cursorX, dotY, 3.5, 0, Math.PI * 2);
1491
+ ctx.fill();
1492
+ ctx.shadowBlur = 0;
1493
+ }
1494
+
1495
+ /** Full repaint of _wfBuf from _wfHistory. */
1496
+ _wfPaintFull(bCtx, W, H) {
1497
+ bCtx.fillStyle = '#050509';
1498
+ bCtx.fillRect(0, 0, W, H);
1499
+ this._wfDrawGrid(bCtx, W, H, 0, W);
1500
+ this._wfDrawHistoryStroke(bCtx, W, H, 0, W - 1);
1501
+ }
1502
+
1503
+ /**
1504
+ * Scroll _wfBuf left by intShift, fill background+grid in new strip, then
1505
+ * draw only the new right-edge stroke from _wfHistory.
1506
+ */
1507
+ _wfPaintIncremental(bCtx, W, H, intShift) {
1508
+ const newX = W - intShift;
1509
+
1510
+ const imgData = bCtx.getImageData(intShift, 0, newX, H);
1511
+ bCtx.putImageData(imgData, 0, 0);
1512
+
1513
+ bCtx.fillStyle = '#050509';
1514
+ bCtx.fillRect(newX, 0, intShift, H);
1515
+ this._wfDrawGrid(bCtx, W, H, newX, W);
1516
+
1517
+ // Extend stroke 1px into the already-painted region for a seamless join
1518
+ this._wfDrawHistoryStroke(bCtx, W, H, Math.max(0, newX - 1), W - 1);
1519
+ }
1520
+
1521
+ _wfDrawGrid(bCtx, W, H, x0, x1) {
1522
+ bCtx.strokeStyle = '#111120';
1523
+ bCtx.lineWidth = 1;
1524
+ bCtx.setLineDash([]);
1525
+ for (const gy of [H * 0.25, H * 0.5, H * 0.75]) {
1526
+ bCtx.beginPath();
1527
+ bCtx.moveTo(x0, gy);
1528
+ bCtx.lineTo(x1, gy);
1529
+ bCtx.stroke();
1530
+ }
1531
+ }
1532
+
1533
+ /** Stroke _wfHistory[xFrom..xTo] onto bCtx. */
1534
+ _wfDrawHistoryStroke(bCtx, W, H, xFrom, xTo) {
1535
+ const color = this._color;
1536
+ bCtx.beginPath();
1537
+ bCtx.strokeStyle = color;
1538
+ bCtx.lineWidth = 1.5;
1539
+ bCtx.shadowBlur = 4;
1540
+ bCtx.shadowColor = color;
1541
+ for (let px = xFrom; px <= xTo; px++) {
1542
+ const val = this._wfHistory[px];
1543
+ const py = (1 - (val + 1) / 2) * (H - 6) + 3;
1544
+ if (px === xFrom) bCtx.moveTo(px, py);
1545
+ else bCtx.lineTo(px, py);
1546
+ }
1547
+ bCtx.stroke();
1548
+ bCtx.shadowBlur = 0;
1549
+ }
1550
+
1551
+ _updateLed(value) {
1552
+ const brightness = Math.abs(value);
1553
+ this._led.style.opacity = 0.2 + brightness * 0.8;
1554
+ this._led.style.boxShadow = `0 0 ${4 + brightness * 8}px ${this._color}`;
1555
+ }
1556
+
1557
+ // ── Drag-to-assign ───────────────────────────────────────────────────────
1558
+
1559
+ _attachDragHandlers(handle) {
1560
+ let wire = null;
1561
+ let currentHighlight = null;
1562
+ let startRect = null;
1563
+
1564
+ const start = (e) => {
1565
+ handle.setPointerCapture(e.pointerId);
1566
+ handle.classList.add('dragging');
1567
+ startRect = handle.getBoundingClientRect();
1568
+ wire = createDragWire(this._color);
1569
+ wire.setStart(startRect.left + startRect.width / 2, startRect.top + startRect.height / 2);
1570
+ wire.setEnd(e.clientX, e.clientY);
1571
+ e.preventDefault();
1572
+ };
1573
+
1574
+ const move = (e) => {
1575
+ if (!handle.hasPointerCapture(e.pointerId)) return;
1576
+ wire.setEnd(e.clientX, e.clientY);
1577
+
1578
+ const target = getModTarget(e.clientX, e.clientY, handle);
1579
+ if (target !== currentHighlight) {
1580
+ if (currentHighlight) {
1581
+ currentHighlight.classList.remove('lfo-drag-target');
1582
+ currentHighlight.style.removeProperty('--lfo-drag-color');
1583
+ }
1584
+ currentHighlight = target;
1585
+ if (target) {
1586
+ target.classList.add('lfo-drag-target');
1587
+ target.style.setProperty('--lfo-drag-color', this._color);
1588
+ wire.setValid(true);
1589
+ } else {
1590
+ wire.setValid(false);
1591
+ }
1592
+ }
1593
+ };
1594
+
1595
+ // Shared teardown: remove wire + highlight without connecting.
1596
+ // Called on both normal end and pointercancel (where capture is already
1597
+ // revoked by the browser, so hasPointerCapture would return false).
1598
+ const cleanup = () => {
1599
+ handle.classList.remove('dragging');
1600
+ wire?.remove();
1601
+ wire = null;
1602
+ if (currentHighlight) {
1603
+ currentHighlight.classList.remove('lfo-drag-target');
1604
+ currentHighlight.style.removeProperty('--lfo-drag-color');
1605
+ currentHighlight = null;
1606
+ }
1607
+ };
1608
+
1609
+ const end = (e) => {
1610
+ if (!handle.hasPointerCapture(e.pointerId)) return;
1611
+ handle.releasePointerCapture(e.pointerId);
1612
+ const target = currentHighlight;
1613
+ cleanup();
1614
+ if (target) this._connectTo(target);
1615
+ };
1616
+
1617
+ handle.addEventListener('pointerdown', start);
1618
+ handle.addEventListener('pointermove', move);
1619
+ handle.addEventListener('pointerup', end);
1620
+ handle.addEventListener('pointercancel', cleanup);
1621
+ }
1622
+
1623
+ // ── Connection management ────────────────────────────────────────────────
1624
+
1625
+ _connectTo(element) {
1626
+ // Avoid duplicate element routes to the same element.
1627
+ for (const ind of this._indicators.values()) {
1628
+ if (engine.getRoute(ind.routeId)?.target === element) return;
1629
+ }
1630
+
1631
+ // Avoid duplicate chain routes: check if a route from this LFO to the
1632
+ // same target LFO param already exists (element sliders carry data-lfo-id).
1633
+ if (element instanceof Element && element.dataset.lfoId) {
1634
+ const linkedId = element.dataset.lfoId;
1635
+ const rawParam = element.dataset.lfoParam ?? '';
1636
+ const mappedParam = rawParam === 'baseRate' ? 'rate' :
1637
+ rawParam === 'baseDepth' ? 'depth' : rawParam;
1638
+ for (const route of engine.getAllRoutes()) {
1639
+ if (route.sourceId === this._lfoId &&
1640
+ route.targetType === 'lfo' &&
1641
+ route.target === linkedId &&
1642
+ route.targetParam === mappedParam) return;
1643
+ }
1644
+ }
1645
+
1646
+ const routeId = engine.addRoute(this._lfoId, 'element', element, null, { depth: 0.5 });
1647
+ // addRoute returns null for self-modulation rejection.
1648
+ if (!routeId) return;
1649
+
1650
+ // Check if the route was auto-promoted to a chain route.
1651
+ const route = engine.getRoute(routeId);
1652
+ if (!route) return;
1653
+
1654
+ // For both element and chain routes, create a ModIndicator anchored to
1655
+ // the actual DOM element (for chain routes this is the param slider).
1656
+ const indicator = new ModIndicator(
1657
+ element, routeId, this._lfoId, this._color, this._label,
1658
+ (rid) => this.disconnectRoute(rid)
1659
+ );
1660
+ this._indicators.set(routeId, indicator);
1661
+ this._onConnect?.(this._lfoId, element, routeId);
1662
+ }
1663
+
1664
+ /**
1665
+ * Programmatically connect this LFO to an element.
1666
+ * @param {HTMLElement} element
1667
+ * @param {object} [opts]
1668
+ * @param {number} [opts.depth=0.5]
1669
+ * @returns {string|null} routeId, or null if rejected (duplicate, self-mod).
1670
+ */
1671
+ connect(element, opts = {}) {
1672
+ // Avoid duplicate element routes to the same target
1673
+ for (const ind of this._indicators.values()) {
1674
+ if (engine.getRoute(ind.routeId)?.target === element) return null;
1675
+ }
1676
+ // Avoid duplicate chain routes (element carries data-lfo-id)
1677
+ if (element instanceof Element && element.dataset.lfoId) {
1678
+ const linkedId = element.dataset.lfoId;
1679
+ const rawParam = element.dataset.lfoParam ?? '';
1680
+ const mappedParam = rawParam === 'baseRate' ? 'rate' :
1681
+ rawParam === 'baseDepth' ? 'depth' : rawParam;
1682
+ for (const route of engine.getAllRoutes()) {
1683
+ if (route.sourceId === this._lfoId &&
1684
+ route.targetType === 'lfo' &&
1685
+ route.target === linkedId &&
1686
+ route.targetParam === mappedParam) return null;
1687
+ }
1688
+ }
1689
+
1690
+ const routeId = engine.addRoute(this._lfoId, 'element', element, null, opts);
1691
+ if (!routeId) return null;
1692
+ const route = engine.getRoute(routeId);
1693
+ if (!route) return null;
1694
+
1695
+ // Create indicator for both element and auto-promoted chain routes
1696
+ const indicator = new ModIndicator(
1697
+ element, routeId, this._lfoId, this._color, this._label,
1698
+ (rid) => this.disconnectRoute(rid)
1699
+ );
1700
+ this._indicators.set(routeId, indicator);
1701
+ this._onConnect?.(this._lfoId, element, routeId);
1702
+ return routeId;
1703
+ }
1704
+
1705
+ /**
1706
+ * Remove a specific modulation route.
1707
+ * @param {string} routeId
1708
+ */
1709
+ disconnectRoute(routeId) {
1710
+ engine.removeRoute(routeId);
1711
+ const ind = this._indicators.get(routeId);
1712
+ if (ind) {
1713
+ ind.destroy();
1714
+ this._indicators.delete(routeId);
1715
+ }
1716
+ this._onDisconnect?.(routeId);
1717
+ }
1718
+
1719
+ /** Disconnect all routes from this widget. */
1720
+ disconnectAll() {
1721
+ for (const routeId of [...this._indicators.keys()]) {
1722
+ this.disconnectRoute(routeId);
1723
+ }
1724
+ }
1725
+
1726
+ /** Tear down the widget and all its connections. */
1727
+ destroy() {
1728
+ this._destroyed = true;
1729
+ if (this._rafHandle != null) cancelAnimationFrame(this._rafHandle);
1730
+ this._unsub?.();
1731
+ this.disconnectAll();
1732
+ engine.destroyLFO(this._lfoId);
1733
+ this._root.remove();
1734
+ }
1735
+
1736
+ get element() { return this._root; }
1737
+ get lfoId() { return this._lfoId; }
1738
+ get color() { return this._color; }
1739
+ get label() { return this._label; }
1740
+ }
1741
+
1742
+ /**
1743
+ * lfo-comp.js — Public API for the LFO modulation component.
1744
+ *
1745
+ * Quick start:
1746
+ *
1747
+ * import { createLFO } from './lfo-comp.js';
1748
+ *
1749
+ * // 1. Create an LFO panel in a container element:
1750
+ * const { widget } = createLFO(document.querySelector('#lfo-panel'));
1751
+ *
1752
+ * // 2. Drag the "⊕ drag to assign" handle onto any <input type=range>
1753
+ * // or <input type=number> to wire up modulation.
1754
+ *
1755
+ * // 3. Programmatic connection (optional):
1756
+ * widget.connect(document.querySelector('#my-slider'), { depth: 0.7 });
1757
+ *
1758
+ * // 4. Chain LFOs — drag LFO 2's handle onto LFO 1's Rate slider,
1759
+ * // or call:
1760
+ * engine.addRoute(lfo2Id, 'lfo', lfo1Id, 'rate', { depth: 0.4 });
1761
+ *
1762
+ * Re-exports the engine singleton and UI classes for advanced use.
1763
+ */
1764
+
1765
+
1766
+ // Registry so disconnect() can clean up the ModIndicator badge.
1767
+ // Maps routeId → LFOWidget that owns it.
1768
+ const _widgetByRoute = new Map();
1769
+
1770
+ let _count = 0;
1771
+ let _colorCount = 0; // separate counter so color cycling advances on every createLFO call
1772
+
1773
+ /**
1774
+ * Create a fully wired LFO widget.
1775
+ *
1776
+ * @param {HTMLElement|object} containerOrOpts
1777
+ * If an HTMLElement, the widget is appended to it and opts is the second arg.
1778
+ * If an object, treated as opts — caller is responsible for appending widget.element.
1779
+ * @param {object} [opts]
1780
+ * @param {string} [opts.shape='sine'] Initial waveform shape.
1781
+ * @param {number} [opts.rate=1] Initial rate in Hz.
1782
+ * @param {number} [opts.depth=1] Initial depth 0–1.
1783
+ * @param {number} [opts.phase=0] Initial phase offset 0–1.
1784
+ * @param {number} [opts.offset=0] DC offset -1–1.
1785
+ * @param {string} [opts.color] Accent color. Auto-assigned if omitted.
1786
+ * @param {string} [opts.label] Widget label. Auto-assigned if omitted.
1787
+ * @param {function} [opts.onConnect] Called when a connection is made.
1788
+ * @param {function} [opts.onDisconnect] Called when a connection is removed.
1789
+ *
1790
+ * @returns {{ lfoId: string, widget: LFOWidget }}
1791
+ */
1792
+ function createLFO(containerOrOpts = {}, opts = {}) {
1793
+ let container, engineOpts, uiOpts;
1794
+
1795
+ if (containerOrOpts instanceof Element) {
1796
+ container = containerOrOpts;
1797
+ engineOpts = opts;
1798
+ uiOpts = opts;
1799
+ } else {
1800
+ container = null;
1801
+ engineOpts = containerOrOpts;
1802
+ uiOpts = containerOrOpts;
1803
+ }
1804
+
1805
+ const colorIdx = _colorCount++;
1806
+ if (!uiOpts.label) {
1807
+ uiOpts = { ...uiOpts, label: `LFO ${++_count}` };
1808
+ }
1809
+ if (!uiOpts.color) {
1810
+ uiOpts = { ...uiOpts, color: LFO_COLORS[colorIdx % LFO_COLORS.length] };
1811
+ }
1812
+
1813
+ // Wrap caller callbacks to maintain _widgetByRoute registry.
1814
+ const callerOnConnect = uiOpts.onConnect;
1815
+ const callerOnDisconnect = uiOpts.onDisconnect;
1816
+ // Forward-declare so the onConnect closure can reference it without a lint warning.
1817
+ let widget;
1818
+
1819
+ uiOpts = {
1820
+ ...uiOpts,
1821
+ onConnect: (lfoId, el, routeId) => {
1822
+ _widgetByRoute.set(routeId, widget);
1823
+ callerOnConnect?.(lfoId, el, routeId);
1824
+ },
1825
+ onDisconnect: (routeId) => {
1826
+ _widgetByRoute.delete(routeId);
1827
+ callerOnDisconnect?.(routeId);
1828
+ },
1829
+ };
1830
+
1831
+ const lfoId = engine.createLFO(engineOpts);
1832
+ const div = container ?? document.createElement('div');
1833
+ widget = new LFOWidget(div, lfoId, uiOpts);
1834
+
1835
+ return { lfoId, widget };
1836
+ }
1837
+
1838
+ /**
1839
+ * Programmatically connect an LFO to an HTML element.
1840
+ * Equivalent to calling widget.connect(element, opts).
1841
+ *
1842
+ * @param {LFOWidget} widget
1843
+ * @param {HTMLElement} element
1844
+ * @param {object} [opts]
1845
+ * @param {number} [opts.depth=0.5]
1846
+ * @returns {string|null} routeId, or null if connection was rejected.
1847
+ */
1848
+ function connect(widget, element, opts = {}) {
1849
+ return widget.connect(element, opts);
1850
+ }
1851
+
1852
+ /**
1853
+ * Remove a modulation route and its UI indicator.
1854
+ * Prefer calling widget.disconnectRoute(routeId) directly when you have the widget.
1855
+ * This function handles programmatic routes created via connect() or drag-to-assign.
1856
+ *
1857
+ * @param {string} routeId
1858
+ */
1859
+ function disconnect(routeId) {
1860
+ const widget = _widgetByRoute.get(routeId);
1861
+ if (widget) {
1862
+ // Goes through the widget so the ModIndicator badge is also removed.
1863
+ widget.disconnectRoute(routeId);
1864
+ } else {
1865
+ // Fallback for engine-only routes (e.g. direct engine.addRoute calls).
1866
+ engine.removeRoute(routeId);
1867
+ }
1868
+ }
1869
+
1870
+ /**
1871
+ * Get all currently active modulation routes.
1872
+ * @returns {object[]}
1873
+ */
1874
+ function getRoutes() {
1875
+ return engine.getAllRoutes();
1876
+ }
1877
+
1878
+ export { LFOWidget, LFO_COLORS, ModIndicator, SHAPES, connect, createLFO, disconnect, engine, getRoutes, injectStyles, seededRand, smoothRand };