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-ui.js ADDED
@@ -0,0 +1,1272 @@
1
+ /**
2
+ * lfo-ui.js — LFO widget, modulation indicators, and drag-to-assign wiring.
3
+ *
4
+ * Provides:
5
+ * LFOWidget — Canvas-based LFO panel with controls and a drag handle.
6
+ * ModIndicator — Floating badge anchored to a connected element showing
7
+ * depth and a remove button. Also draws a range arc on range inputs.
8
+ *
9
+ * No external dependencies. Requires lfo-engine.js.
10
+ */
11
+
12
+ import { engine, SHAPES, smoothRand, seededRand, SHAPE_FN, applySkew } from './lfo-engine.js';
13
+
14
+ // ─── Constants ────────────────────────────────────────────────────────────────
15
+
16
+ export const LFO_COLORS = [
17
+ '#00d4ff', // cyan
18
+ '#ff3aaa', // magenta
19
+ '#39ff14', // neon green
20
+ '#ff9500', // orange
21
+ '#bf80ff', // lavender
22
+ '#ffd700', // gold
23
+ '#00ffcc', // teal
24
+ '#ff4466', // red-pink
25
+ ];
26
+
27
+ let _colorIndex = 0;
28
+ let _labelIndex = 0;
29
+
30
+ const SHAPE_LABELS = {
31
+ sine: 'SIN',
32
+ triangle: 'TRI',
33
+ saw: 'SAW',
34
+ rsaw: 'RSW',
35
+ square: 'SQR',
36
+ random: 'S&H',
37
+ smooth: 'SMO',
38
+ };
39
+
40
+ // ─── CSS injection ────────────────────────────────────────────────────────────
41
+
42
+ const CSS = `
43
+ /* ── Widget container ──────────────────────────────────────────── */
44
+ .lfo-widget {
45
+ display: flex;
46
+ flex-direction: column;
47
+ flex: 1 1 200px;
48
+ background: #0a0a12;
49
+ border: 1px solid #222233;
50
+ border-radius: 8px;
51
+ padding: 10px;
52
+ min-width: 200px;
53
+ font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
54
+ font-size: 11px;
55
+ color: #888;
56
+ user-select: none;
57
+ gap: 7px;
58
+ box-shadow: 0 2px 16px rgba(0,0,0,0.5);
59
+ }
60
+
61
+ .lfo-header {
62
+ display: flex;
63
+ align-items: center;
64
+ justify-content: space-between;
65
+ gap: 6px;
66
+ }
67
+
68
+ .lfo-label {
69
+ font-size: 12px;
70
+ font-weight: 700;
71
+ letter-spacing: 0.08em;
72
+ text-transform: uppercase;
73
+ }
74
+
75
+ .lfo-led {
76
+ width: 7px;
77
+ height: 7px;
78
+ border-radius: 50%;
79
+ background: currentColor;
80
+ flex-shrink: 0;
81
+ transition: opacity 0.05s;
82
+ }
83
+
84
+ /* ── Canvas + shapes row ───────────────────────────────────────── */
85
+ .lfo-canvas-row {
86
+ display: flex;
87
+ gap: 4px;
88
+ align-items: stretch;
89
+ }
90
+
91
+ /* ── Waveform canvas ───────────────────────────────────────────── */
92
+ .lfo-canvas {
93
+ border-radius: 4px;
94
+ background: #050509;
95
+ display: block;
96
+ cursor: crosshair;
97
+ flex: 1;
98
+ min-width: 0;
99
+ width: 140px;
100
+ height: 72px;
101
+ }
102
+
103
+ /* ── Shape buttons — side panel (2 col × 4 row) ────────────────── */
104
+ .lfo-shapes {
105
+ display: grid;
106
+ grid-template-columns: 1fr 1fr;
107
+ grid-template-rows: repeat(4, 1fr);
108
+ gap: 2px;
109
+ width: 52px;
110
+ flex-shrink: 0;
111
+ }
112
+
113
+ .lfo-shape-btn {
114
+ background: #111120;
115
+ border: 1px solid #1e1e30;
116
+ color: #555;
117
+ padding: 1px 0;
118
+ border-radius: 3px;
119
+ cursor: pointer;
120
+ font-size: 9px;
121
+ font-family: inherit;
122
+ text-align: center;
123
+ transition: color 0.1s, border-color 0.1s, background 0.1s;
124
+ line-height: 1;
125
+ display: flex;
126
+ align-items: center;
127
+ justify-content: center;
128
+ }
129
+
130
+ .lfo-shape-btn:hover {
131
+ border-color: #333;
132
+ color: #999;
133
+ }
134
+
135
+ .lfo-shape-btn.active {
136
+ border-color: var(--lfo-color, #00d4ff);
137
+ color: var(--lfo-color, #00d4ff);
138
+ background: #08080f;
139
+ }
140
+
141
+ .lfo-bipolar-btn {
142
+ border-top: 1px solid #2a2a3e;
143
+ }
144
+
145
+ /* ── Param rows ─────────────────────────────────────────────────── */
146
+ .lfo-params {
147
+ display: grid;
148
+ grid-template-columns: 1fr 1fr;
149
+ gap: 5px;
150
+ }
151
+
152
+ .lfo-param-group {
153
+ display: flex;
154
+ flex-direction: column;
155
+ gap: 3px;
156
+ }
157
+
158
+ .lfo-param-group label {
159
+ font-size: 9px;
160
+ color: #444;
161
+ text-transform: uppercase;
162
+ letter-spacing: 0.06em;
163
+ }
164
+
165
+ .lfo-param-row {
166
+ display: flex;
167
+ align-items: center;
168
+ gap: 4px;
169
+ }
170
+
171
+ .lfo-param-group input[type=range] {
172
+ flex: 1;
173
+ min-width: 0;
174
+ appearance: none;
175
+ -webkit-appearance: none;
176
+ height: 3px;
177
+ border-radius: 2px;
178
+ background: #1a1a28;
179
+ outline: none;
180
+ cursor: pointer;
181
+ }
182
+
183
+ .lfo-param-group input[type=range]::-webkit-slider-thumb {
184
+ -webkit-appearance: none;
185
+ width: 11px;
186
+ height: 11px;
187
+ border-radius: 50%;
188
+ background: var(--lfo-color, #00d4ff);
189
+ cursor: pointer;
190
+ box-shadow: 0 0 4px var(--lfo-color, #00d4ff);
191
+ }
192
+
193
+ .lfo-param-group input[type=range]::-moz-range-thumb {
194
+ width: 11px;
195
+ height: 11px;
196
+ border-radius: 50%;
197
+ background: var(--lfo-color, #00d4ff);
198
+ cursor: pointer;
199
+ border: none;
200
+ box-shadow: 0 0 4px var(--lfo-color, #00d4ff);
201
+ }
202
+
203
+ .lfo-param-val {
204
+ font-size: 9px;
205
+ color: var(--lfo-color, #00d4ff);
206
+ width: 44px;
207
+ text-align: right;
208
+ flex-shrink: 0;
209
+ overflow: hidden;
210
+ cursor: text;
211
+ }
212
+
213
+ .lfo-param-edit {
214
+ font-size: 9px;
215
+ font-family: inherit;
216
+ color: var(--lfo-color, #00d4ff);
217
+ background: transparent;
218
+ border: none;
219
+ border-bottom: 1px solid var(--lfo-color, #00d4ff);
220
+ width: 44px;
221
+ flex-shrink: 0;
222
+ text-align: right;
223
+ outline: none;
224
+ padding: 0;
225
+ }
226
+
227
+ /* ── Connect handle ─────────────────────────────────────────────── */
228
+ .lfo-connect-handle {
229
+ background: #0d0d1c;
230
+ border: 1px dashed var(--lfo-color, #00d4ff);
231
+ border-radius: 4px;
232
+ padding: 6px 8px;
233
+ text-align: center;
234
+ color: var(--lfo-color, #00d4ff);
235
+ font-size: 10px;
236
+ cursor: grab;
237
+ transition: opacity 0.15s, background 0.15s;
238
+ opacity: 0.65;
239
+ letter-spacing: 0.04em;
240
+ }
241
+
242
+ .lfo-connect-handle:hover {
243
+ opacity: 1;
244
+ background: #060610;
245
+ }
246
+
247
+ .lfo-connect-handle.dragging {
248
+ cursor: grabbing;
249
+ opacity: 1;
250
+ }
251
+
252
+ .lfo-connect-handle.armed {
253
+ opacity: 1;
254
+ background: #060610;
255
+ border-style: solid;
256
+ }
257
+
258
+ /* ── Drag wire SVG ──────────────────────────────────────────────── */
259
+ #lfo-drag-wire-svg {
260
+ position: fixed;
261
+ top: 0; left: 0;
262
+ width: 100%;
263
+ height: 100%;
264
+ pointer-events: none;
265
+ z-index: calc(var(--lfo-z-base, 9000) + 2);
266
+ overflow: visible;
267
+ }
268
+
269
+ /* ── Target highlight during drag ───────────────────────────────── */
270
+ .lfo-drag-target {
271
+ outline: 2px solid var(--lfo-drag-color, #00d4ff) !important;
272
+ outline-offset: 3px;
273
+ border-radius: 2px;
274
+ }
275
+
276
+ /* ── Mod indicator badge ─────────────────────────────────────────── */
277
+ .lfo-mod-badge {
278
+ position: fixed;
279
+ display: flex;
280
+ align-items: center;
281
+ gap: 5px;
282
+ background: #0a0a14;
283
+ border: 1px solid #222233;
284
+ border-radius: 4px;
285
+ padding: 3px 6px;
286
+ font-family: 'SF Mono', 'Fira Code', monospace;
287
+ font-size: 10px;
288
+ color: #888;
289
+ z-index: calc(var(--lfo-z-base, 9000) + 1);
290
+ pointer-events: auto;
291
+ cursor: default;
292
+ white-space: nowrap;
293
+ box-shadow: 0 1px 8px rgba(0,0,0,0.6);
294
+ transition: opacity 0.1s;
295
+ }
296
+
297
+ .lfo-mod-badge:hover {
298
+ opacity: 1 !important;
299
+ }
300
+
301
+ .lfo-mod-dot {
302
+ width: 6px;
303
+ height: 6px;
304
+ border-radius: 50%;
305
+ flex-shrink: 0;
306
+ }
307
+
308
+ .lfo-mod-depth-label {
309
+ cursor: ew-resize;
310
+ color: var(--badge-color, #00d4ff);
311
+ font-weight: bold;
312
+ min-width: 32px;
313
+ text-align: right;
314
+ user-select: none;
315
+ }
316
+
317
+ .lfo-mod-depth-label:hover::after {
318
+ content: ' ↔';
319
+ opacity: 0.5;
320
+ font-size: 9px;
321
+ }
322
+
323
+ .lfo-mod-remove {
324
+ background: none;
325
+ border: none;
326
+ color: #444;
327
+ cursor: pointer;
328
+ font-size: 12px;
329
+ line-height: 1;
330
+ padding: 0 1px;
331
+ font-family: inherit;
332
+ transition: color 0.1s;
333
+ }
334
+
335
+ .lfo-mod-remove:hover {
336
+ color: #ff4466;
337
+ }
338
+
339
+ /* ── Range arc canvas overlay ────────────────────────────────────── */
340
+ .lfo-range-arc {
341
+ position: fixed;
342
+ pointer-events: none;
343
+ z-index: var(--lfo-z-base, 9000);
344
+ }
345
+ `;
346
+
347
+ let _stylesInjected = false;
348
+
349
+ export function injectStyles() {
350
+ if (_stylesInjected || typeof document === 'undefined') return;
351
+ _stylesInjected = true;
352
+ const style = document.createElement('style');
353
+ style.id = 'lfo-ui-styles';
354
+ style.textContent = CSS;
355
+ document.head.appendChild(style);
356
+ }
357
+
358
+ // ─── Drag wire helper ─────────────────────────────────────────────────────────
359
+
360
+ /**
361
+ * Creates a full-screen SVG overlay showing the drag-assign wire.
362
+ * @param {string} color
363
+ */
364
+ function createDragWire(color) {
365
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
366
+ svg.id = 'lfo-drag-wire-svg';
367
+ document.body.appendChild(svg);
368
+
369
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
370
+ path.setAttribute('fill', 'none');
371
+ path.setAttribute('stroke', color);
372
+ path.setAttribute('stroke-width', '1.5');
373
+ path.setAttribute('stroke-dasharray', '5 4');
374
+ path.setAttribute('opacity', '0.75');
375
+ svg.appendChild(path);
376
+
377
+ const endDot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
378
+ endDot.setAttribute('r', '5');
379
+ endDot.setAttribute('fill', color);
380
+ endDot.setAttribute('opacity', '0.9');
381
+ svg.appendChild(endDot);
382
+
383
+ let sx = 0, sy = 0;
384
+
385
+ return {
386
+ setStart(x, y) { sx = x; sy = y; },
387
+ setEnd(x, y) {
388
+ const midX = (sx + x) / 2;
389
+ path.setAttribute('d',
390
+ `M ${sx} ${sy} C ${midX} ${sy}, ${midX} ${y}, ${x} ${y}`
391
+ );
392
+ endDot.setAttribute('cx', x);
393
+ endDot.setAttribute('cy', y);
394
+ },
395
+ setValid(valid) {
396
+ const c = valid ? color : '#555';
397
+ path.setAttribute('stroke', c);
398
+ endDot.setAttribute('fill', c);
399
+ },
400
+ remove() { svg.remove(); },
401
+ };
402
+ }
403
+
404
+ // ─── Target detection ─────────────────────────────────────────────────────────
405
+
406
+ /** Elements that are valid LFO modulation targets. */
407
+ function isModTarget(el) {
408
+ if (!el || el.tagName === 'BUTTON') return false;
409
+ if (el.tagName === 'INPUT' &&
410
+ (el.type === 'range' || el.type === 'number')) return true;
411
+ if (el.dataset.lfoTarget !== undefined) return true;
412
+ return false;
413
+ }
414
+
415
+ /** Find the best modulation target element under (cx, cy), ignoring skip. */
416
+ function getModTarget(cx, cy, skip) {
417
+ const elems = document.elementsFromPoint(cx, cy);
418
+ for (const el of elems) {
419
+ if (el === skip || el.closest('#lfo-drag-wire-svg')) continue;
420
+ if (isModTarget(el)) return el;
421
+ }
422
+ return null;
423
+ }
424
+
425
+ // ─── ModIndicator ─────────────────────────────────────────────────────────────
426
+
427
+ /**
428
+ * Floating badge anchored to a connected input element.
429
+ * Shows depth, allows drag-to-adjust, and a remove button.
430
+ */
431
+ export class ModIndicator {
432
+ /**
433
+ * @param {HTMLElement} element Connected input.
434
+ * @param {string} routeId
435
+ * @param {string} lfoId
436
+ * @param {string} color
437
+ * @param {string} lfoLabel
438
+ * @param {function} onRemove Called when the user removes this connection.
439
+ */
440
+ constructor(element, routeId, lfoId, color, lfoLabel, onRemove) {
441
+ this._element = element;
442
+ this._routeId = routeId;
443
+ this._lfoId = lfoId;
444
+ this._color = color;
445
+ this._onRemove = onRemove;
446
+ this._badge = null;
447
+ this._arcCanvas = null;
448
+ this._rafId = null;
449
+
450
+ injectStyles();
451
+ this._buildBadge(lfoLabel);
452
+ this._buildArcCanvas();
453
+ this._startPositioning();
454
+ }
455
+
456
+ _buildBadge(label) {
457
+ const badge = this._badge = document.createElement('div');
458
+ badge.className = 'lfo-mod-badge';
459
+ badge.style.setProperty('--badge-color', this._color);
460
+ badge.style.opacity = '0.85';
461
+
462
+ const dot = document.createElement('div');
463
+ dot.className = 'lfo-mod-dot';
464
+ dot.style.background = this._color;
465
+ dot.style.boxShadow = `0 0 4px ${this._color}`;
466
+
467
+ const lbl = document.createElement('span');
468
+ lbl.textContent = label;
469
+ lbl.style.color = '#666';
470
+ lbl.style.fontSize = '9px';
471
+
472
+ const depthLabel = this._depthLabel = document.createElement('span');
473
+ depthLabel.className = 'lfo-mod-depth-label';
474
+ depthLabel.title = 'Drag left/right to adjust modulation depth';
475
+ this._updateDepthLabel();
476
+
477
+ const removeBtn = document.createElement('button');
478
+ removeBtn.className = 'lfo-mod-remove';
479
+ removeBtn.textContent = '×';
480
+ removeBtn.title = 'Remove modulation';
481
+ removeBtn.addEventListener('click', (e) => {
482
+ e.stopPropagation();
483
+ this._onRemove?.(this._routeId);
484
+ });
485
+
486
+ // Drag depth label to adjust depth
487
+ let dragStartX = 0;
488
+ let dragStartDepth = 0;
489
+
490
+ depthLabel.addEventListener('pointerdown', (e) => {
491
+ dragStartX = e.clientX;
492
+ dragStartDepth = engine.getRoute(this._routeId)?.depth ?? 0.5;
493
+ depthLabel.setPointerCapture(e.pointerId);
494
+ e.preventDefault();
495
+ });
496
+
497
+ depthLabel.addEventListener('pointermove', (e) => {
498
+ if (!depthLabel.hasPointerCapture(e.pointerId)) return;
499
+ const delta = (e.clientX - dragStartX) / 120;
500
+ const newDepth = Math.max(0, Math.min(1, dragStartDepth + delta));
501
+ engine.setRouteDepth(this._routeId, newDepth);
502
+ this._updateDepthLabel();
503
+ });
504
+
505
+ badge.appendChild(dot);
506
+ badge.appendChild(lbl);
507
+ badge.appendChild(depthLabel);
508
+ badge.appendChild(removeBtn);
509
+ document.body.appendChild(badge);
510
+ }
511
+
512
+ _buildArcCanvas() {
513
+ // Small arc overlay on range inputs showing the mod sweep range
514
+ if (this._element.type !== 'range') return;
515
+
516
+ const canvas = this._arcCanvas = document.createElement('canvas');
517
+ canvas.className = 'lfo-range-arc';
518
+ canvas.width = 0;
519
+ canvas.height = 5;
520
+ document.body.appendChild(canvas);
521
+ this._arcCtx = canvas.getContext('2d');
522
+ }
523
+
524
+ _updateDepthLabel() {
525
+ const depth = engine.getRoute(this._routeId)?.depth ?? 0;
526
+ if (this._depthLabel) {
527
+ this._depthLabel.textContent = `${Math.round(depth * 100)}%`;
528
+ }
529
+ }
530
+
531
+ _startPositioning() {
532
+ const update = () => {
533
+ this._rafId = requestAnimationFrame(update);
534
+ this._reposition();
535
+ this._updateArc();
536
+ this._updateDepthLabel();
537
+ };
538
+ this._rafId = requestAnimationFrame(update);
539
+ }
540
+
541
+ _reposition() {
542
+ if (!this._badge) return;
543
+ const r = this._element.getBoundingClientRect();
544
+ if (r.width === 0 && r.height === 0) return;
545
+ // Badge dimensions rarely change after first paint — read once and cache.
546
+ if (!this._badgeSize) {
547
+ const bw = this._badge.offsetWidth;
548
+ const bh = this._badge.offsetHeight;
549
+ if (bw && bh) this._badgeSize = { bw, bh };
550
+ else return;
551
+ }
552
+ const { bw, bh } = this._badgeSize;
553
+
554
+ let bx = r.right - bw;
555
+ let by = r.top - bh - 4;
556
+
557
+ // Keep badge on screen
558
+ if (bx < 4) bx = 4;
559
+ if (by < 4) by = r.bottom + 4;
560
+
561
+ this._badge.style.left = `${bx}px`;
562
+ this._badge.style.top = `${by}px`;
563
+ }
564
+
565
+ _updateArc() {
566
+ const canvas = this._arcCanvas;
567
+ if (!canvas) return;
568
+
569
+ const r = this._element.getBoundingClientRect();
570
+ canvas.style.left = `${r.left}px`;
571
+ canvas.style.top = `${r.bottom + 1}px`;
572
+ // Only reset canvas width when it actually changes — resizing clears the
573
+ // canvas and is relatively expensive to do unconditionally at 60fps.
574
+ // Note: reassigning canvas.width does NOT invalidate the 2D context stored
575
+ // in this._arcCtx; the same context reference remains usable after resize.
576
+ const newW = Math.round(r.width);
577
+ if (canvas.width !== newW) {
578
+ canvas.width = newW;
579
+ canvas.style.width = `${r.width}px`;
580
+ }
581
+ if (canvas.height !== 5) {
582
+ canvas.height = 5;
583
+ canvas.style.height = '5px';
584
+ }
585
+
586
+ const route = engine.getRoute(this._routeId);
587
+ if (!route) return;
588
+ const lfoVal = engine.getValue(this._lfoId);
589
+ const depth = route.depth;
590
+
591
+ let centerNorm, swingNorm, curNorm;
592
+
593
+ if (route.targetType === 'lfo') {
594
+ // Chain route — synthesize arc params from target LFO state.
595
+ const tgt = engine.getLFO(route.target);
596
+ if (!tgt) return;
597
+ if (route.targetParam === 'rate') {
598
+ // Rate slider is log-scaled (0.01–10 Hz). Normalise in log space to
599
+ // match slider position so the arc reflects the actual sweep range.
600
+ const min = 0.01, max = 10;
601
+ const logRange = Math.log(max / min);
602
+ const logNorm = v => Math.max(0, Math.min(1, Math.log(Math.max(min, v) / min) / logRange));
603
+ const base = tgt.baseRate;
604
+ centerNorm = logNorm(base);
605
+ // Effective rate = base × (1 + src × depth) — compute high/low in log space.
606
+ swingNorm = (logNorm(base * (1 + depth)) - logNorm(base * (1 - depth))) / 2;
607
+ curNorm = logNorm(base * (1 + lfoVal * depth));
608
+ } else if (route.targetParam === 'depth') {
609
+ const base = tgt.baseDepth;
610
+ centerNorm = base;
611
+ swingNorm = depth * 0.5;
612
+ curNorm = Math.max(0, Math.min(1, base + lfoVal * depth * 0.5));
613
+ } else {
614
+ return;
615
+ }
616
+ } else {
617
+ // Element route — use registered element metadata.
618
+ const meta = engine.getElementMeta(this._element);
619
+ if (!meta) return;
620
+ const range = meta.max - meta.min;
621
+ if (range === 0) return;
622
+ centerNorm = (meta.baseValue - meta.min) / range;
623
+ swingNorm = depth * 0.5;
624
+ curNorm = Math.max(0, Math.min(1, (meta.baseValue + lfoVal * depth * range * 0.5 - meta.min) / range));
625
+ }
626
+
627
+ const w = canvas.width;
628
+ const h = canvas.height;
629
+ const ctx = this._arcCtx;
630
+ ctx.clearRect(0, 0, w, h);
631
+
632
+ // Draw sweep range bar
633
+ const xCenter = centerNorm * w;
634
+ const xMin = Math.max(0, (centerNorm - swingNorm) * w);
635
+ const xMax = Math.min(w, (centerNorm + swingNorm) * w);
636
+
637
+ ctx.fillStyle = `${this._color}30`;
638
+ ctx.fillRect(xMin, 1, xMax - xMin, h - 2);
639
+
640
+ // Draw center tick
641
+ ctx.fillStyle = `${this._color}80`;
642
+ ctx.fillRect(xCenter - 0.5, 0, 1, h);
643
+
644
+ // Draw current position dot
645
+ ctx.fillStyle = this._color;
646
+ ctx.beginPath();
647
+ ctx.arc(curNorm * w, h / 2, 2.5, 0, Math.PI * 2);
648
+ ctx.fill();
649
+ }
650
+
651
+ destroy() {
652
+ if (this._rafId != null) cancelAnimationFrame(this._rafId);
653
+ this._badge?.remove();
654
+ this._arcCanvas?.remove();
655
+ }
656
+
657
+ get routeId() { return this._routeId; }
658
+ }
659
+
660
+ // ─── LFOWidget ────────────────────────────────────────────────────────────────
661
+
662
+ /**
663
+ * Canvas-based LFO panel with waveform display, shape selector, parameter
664
+ * sliders, and a drag handle for wiring to any modulation target.
665
+ */
666
+ export class LFOWidget {
667
+ /**
668
+ * @param {HTMLElement} container Where the widget is appended.
669
+ * @param {string} lfoId Engine LFO id.
670
+ * @param {object} [opts]
671
+ * @param {string} [opts.color] Accent color hex.
672
+ * @param {string} [opts.label] Display label.
673
+ * @param {function} [opts.onConnect] (lfoId, element, routeId) => void
674
+ * @param {function} [opts.onDisconnect] (routeId) => void
675
+ */
676
+ constructor(container, lfoId, opts = {}) {
677
+ injectStyles();
678
+ this._lfoId = lfoId;
679
+ this._color = opts.color ?? LFO_COLORS[_colorIndex++ % LFO_COLORS.length];
680
+ this._label = opts.label ?? `LFO ${++_labelIndex}`;
681
+ this._onConnect = opts.onConnect;
682
+ this._onDisconnect = opts.onDisconnect;
683
+
684
+ /** @type {Map<string, ModIndicator>} routeId → indicator */
685
+ this._indicators = new Map();
686
+
687
+ this._build(container);
688
+ this._latestValue = 0;
689
+ this._unsub = engine.subscribe((id, value) => {
690
+ if (id === this._lfoId) {
691
+ this._recordSample(value);
692
+ this._updateLed(value);
693
+ this._syncChainedSliders();
694
+ this._pruneDeadRoutes();
695
+ }
696
+ });
697
+ this._rafHandle = null;
698
+ this._destroyed = false;
699
+ const rafLoop = () => {
700
+ if (this._destroyed) return;
701
+ this._rafDraw();
702
+ this._rafHandle = requestAnimationFrame(rafLoop);
703
+ };
704
+ this._rafHandle = requestAnimationFrame(rafLoop);
705
+ }
706
+
707
+ // ── Build DOM ────────────────────────────────────────────────────────────
708
+
709
+ _build(container) {
710
+ const root = this._root = document.createElement('div');
711
+ root.className = 'lfo-widget';
712
+ root.style.setProperty('--lfo-color', this._color);
713
+
714
+ // Header
715
+ const header = document.createElement('div');
716
+ header.className = 'lfo-header';
717
+
718
+ const label = document.createElement('span');
719
+ label.className = 'lfo-label';
720
+ label.textContent = this._label;
721
+ label.style.color = this._color;
722
+
723
+ this._led = document.createElement('div');
724
+ this._led.className = 'lfo-led';
725
+ this._led.style.color = this._color;
726
+
727
+ header.appendChild(label);
728
+ header.appendChild(this._led);
729
+ root.appendChild(header);
730
+
731
+ // Canvas + shape buttons row
732
+ const canvasRow = document.createElement('div');
733
+ canvasRow.className = 'lfo-canvas-row';
734
+
735
+ const canvas = this._canvas = document.createElement('canvas');
736
+ canvas.className = 'lfo-canvas';
737
+ const dpr = window.devicePixelRatio || 1;
738
+ canvas.width = 140 * dpr;
739
+ canvas.height = 72 * dpr;
740
+ this._ctx = canvas.getContext('2d');
741
+ canvasRow.appendChild(canvas);
742
+
743
+ // Shape buttons — 2 col × 4 row side panel (7 shapes + bipolar toggle = 8 slots)
744
+ const shapesEl = this._shapesEl = document.createElement('div');
745
+ shapesEl.className = 'lfo-shapes';
746
+ for (const shape of SHAPES) {
747
+ const btn = document.createElement('button');
748
+ btn.className = 'lfo-shape-btn';
749
+ btn.textContent = SHAPE_LABELS[shape] ?? shape.toUpperCase().slice(0, 3);
750
+ btn.title = shape;
751
+ btn.dataset.shape = shape;
752
+ btn.addEventListener('click', () => {
753
+ engine.setParam(this._lfoId, 'shape', shape);
754
+ this._refreshShapeButtons();
755
+ });
756
+ shapesEl.appendChild(btn);
757
+ }
758
+
759
+ // Bipolar toggle occupies the 8th (filler) slot
760
+ const bipolarBtn = this._bipolarBtn = document.createElement('button');
761
+ bipolarBtn.className = 'lfo-shape-btn lfo-bipolar-btn';
762
+ bipolarBtn.title = 'Toggle bipolar (±1) / unipolar (0–1) output';
763
+ bipolarBtn.addEventListener('click', () => {
764
+ const current = engine.getParam(this._lfoId, 'bipolar') ?? true;
765
+ engine.setParam(this._lfoId, 'bipolar', !current);
766
+ this._refreshBipolarBtn();
767
+ });
768
+ shapesEl.appendChild(bipolarBtn);
769
+
770
+ canvasRow.appendChild(shapesEl);
771
+ root.appendChild(canvasRow);
772
+
773
+ // Param sliders — 3-row × 2-col grid (rate, depth, phase, offset, jitter, skew)
774
+ const params = document.createElement('div');
775
+ params.className = 'lfo-params';
776
+
777
+ this._rateInput = this._addParam(params, 'Rate', 0.01, 10, 1, 0.01, 'baseRate', v => `${v.toFixed(2)}Hz`, 'log');
778
+ this._depthInput = this._addParam(params, 'Depth', 0, 1, 1, 0.01, 'baseDepth', v => `${Math.round(v * 100)}%`);
779
+ this._phaseInput = this._addParam(params, 'Phase', 0, 1, 0, 0.01, 'phase', v => `${Math.round(v * 360)}°`);
780
+ this._offsetInput = this._addParam(params, 'Offs.', -1, 1, 0, 0.01, 'offset', v => v.toFixed(2));
781
+ this._jitterInput = this._addParam(params, 'Jitter', 0, 1, 0, 0.01, 'jitter', v => `${Math.round(v * 100)}%`);
782
+ this._skewInput = this._addParam(params, 'Skew', 0, 1, 0.5, 0.01, 'skew', v => `${Math.round((v - 0.5) * 200)}%`);
783
+
784
+ root.appendChild(params);
785
+
786
+ // Connect handle
787
+ const handle = this._handle = document.createElement('div');
788
+ handle.className = 'lfo-connect-handle';
789
+ handle.innerHTML = '⊕&nbsp; drag to assign';
790
+ handle.title = 'Drag onto any slider or number input to modulate it.\nDrag onto another LFO\'s Rate/Depth slider to chain.';
791
+ this._attachDragHandlers(handle);
792
+ root.appendChild(handle);
793
+
794
+ container.appendChild(root);
795
+ this._refreshShapeButtons();
796
+ this._refreshBipolarBtn();
797
+ }
798
+
799
+ _addParam(container, labelText, min, max, defaultVal, step, param, fmt, scale = 'linear') {
800
+ const group = document.createElement('div');
801
+ group.className = 'lfo-param-group';
802
+
803
+ const lbl = document.createElement('label');
804
+ lbl.textContent = labelText;
805
+ group.appendChild(lbl);
806
+
807
+ const row = document.createElement('div');
808
+ row.className = 'lfo-param-row';
809
+
810
+ // Log scale: slider position 0–1000; actual = min × (max/min)^(pos/1000)
811
+ const logScale = scale === 'log';
812
+ const toActual = logScale ? pos => min * Math.pow(max / min, pos / 1000) : v => v;
813
+ const toPos = logScale ? v => Math.log(v / min) / Math.log(max / min) * 1000 : v => v;
814
+
815
+ const input = document.createElement('input');
816
+ input.type = 'range';
817
+ input.min = logScale ? 0 : min;
818
+ input.max = logScale ? 1000 : max;
819
+ input.step = logScale ? 1 : step;
820
+ input.value = toPos(defaultVal);
821
+ // Store converter so _syncChainedSliders can map actual → slider position
822
+ input._toPos = toPos;
823
+ // Mark as LFO param so drag-to-assign auto-promotes to chain route
824
+ input.dataset.lfoId = this._lfoId;
825
+ input.dataset.lfoParam = param;
826
+
827
+ const valEl = document.createElement('span');
828
+ valEl.className = 'lfo-param-val';
829
+ valEl.textContent = fmt(defaultVal);
830
+
831
+ const precision = (step.toString().split('.')[1] ?? '').length;
832
+
833
+ valEl.addEventListener('click', () => {
834
+ const editEl = document.createElement('input');
835
+ editEl.type = 'text';
836
+ editEl.className = 'lfo-param-edit';
837
+ editEl.value = toActual(parseFloat(input.value)).toFixed(precision);
838
+ valEl.replaceWith(editEl);
839
+ editEl.select();
840
+
841
+ let cancelled = false;
842
+ const commit = () => {
843
+ if (cancelled) return;
844
+ const raw = parseFloat(editEl.value);
845
+ if (!isNaN(raw)) {
846
+ const clamped = Math.min(max, Math.max(min, raw));
847
+ input.value = toPos(clamped);
848
+ engine.setParam(this._lfoId, param, clamped);
849
+ valEl.textContent = fmt(clamped);
850
+ }
851
+ editEl.replaceWith(valEl);
852
+ };
853
+
854
+ editEl.addEventListener('blur', commit);
855
+ editEl.addEventListener('keydown', e => {
856
+ if (e.key === 'Enter') { e.preventDefault(); editEl.blur(); }
857
+ if (e.key === 'Escape') { cancelled = true; editEl.replaceWith(valEl); }
858
+ });
859
+ });
860
+
861
+ input.addEventListener('input', () => {
862
+ const v = toActual(parseFloat(input.value));
863
+ engine.setParam(this._lfoId, param, v);
864
+ valEl.textContent = fmt(v);
865
+ });
866
+
867
+ // Also update display when LFO engine writes to this slider (chain modulation)
868
+ input.addEventListener('lfo-update', () => {
869
+ valEl.textContent = fmt(toActual(parseFloat(input.value)));
870
+ });
871
+
872
+ row.appendChild(input);
873
+ row.appendChild(valEl);
874
+ group.appendChild(row);
875
+ container.appendChild(group);
876
+ return input;
877
+ }
878
+
879
+ _refreshShapeButtons() {
880
+ const current = engine.getParam(this._lfoId, 'shape');
881
+ for (const btn of this._shapesEl.querySelectorAll('.lfo-shape-btn')) {
882
+ btn.classList.toggle('active', btn.dataset.shape === current);
883
+ }
884
+ }
885
+
886
+ _refreshBipolarBtn() {
887
+ const bipolar = engine.getParam(this._lfoId, 'bipolar') ?? true;
888
+ this._bipolarBtn.textContent = bipolar ? 'BI' : 'UNI';
889
+ this._bipolarBtn.classList.toggle('active', !bipolar);
890
+ this._bipolarBtn.title = bipolar
891
+ ? 'Bipolar output (±1) — click for unipolar (0–1)'
892
+ : 'Unipolar output (0–1) — click for bipolar (±1)';
893
+ }
894
+
895
+ // ── Waveform drawing ─────────────────────────────────────────────────────
896
+ //
897
+ // _wfHistory: Float32Array(W) of actual LFO output samples — real history,
898
+ // no formula extrapolation. Each frame, intShift new samples are appended
899
+ // (linearly interpolated from _wfPrevValue → currentValue) and the array
900
+ // shifts left. The pixel buffer scrolls in lock-step via getImageData /
901
+ // putImageData, so only intShift columns are repainted. Cursor + dot at
902
+ // the right edge (px = W-1) — always the most-recent sample.
903
+
904
+ /**
905
+ * Reflect effective (post-chain-modulation) rate and depth onto the param
906
+ * sliders so they visually track the modulated position. Only touches the
907
+ * DOM when the effective value actually differs from the displayed value to
908
+ * avoid unnecessary repaints.
909
+ */
910
+ _syncChainedSliders() {
911
+ const lfo = engine.getLFO(this._lfoId);
912
+ if (!lfo) return;
913
+ const pairs = [
914
+ [this._rateInput, lfo.rate],
915
+ [this._depthInput, lfo.depth],
916
+ ];
917
+ for (const [input, eff] of pairs) {
918
+ const pos = input._toPos ? input._toPos(eff) : eff;
919
+ if (Math.abs(parseFloat(input.value) - pos) > 1e-6) {
920
+ input.value = pos;
921
+ input.dispatchEvent(new Event('lfo-update'));
922
+ }
923
+ }
924
+ }
925
+
926
+ /**
927
+ * Remove any indicators whose routes have been deleted externally — e.g. when
928
+ * the target LFO is destroyed and engine.destroyLFO cleans up incoming routes.
929
+ * Called each tick so stale badges are cleared within one frame.
930
+ */
931
+ _pruneDeadRoutes() {
932
+ for (const routeId of [...this._indicators.keys()]) {
933
+ if (!engine.getRoute(routeId)) {
934
+ this.disconnectRoute(routeId);
935
+ }
936
+ }
937
+ }
938
+
939
+ _recordSample(currentValue) {
940
+ this._latestValue = currentValue;
941
+ const canvas = this._canvas;
942
+ const W = canvas.width;
943
+ const H = canvas.height;
944
+ // Canvas shows a fixed 4-second time window regardless of LFO rate.
945
+ const pxPerSec = W / 4;
946
+ const now = performance.now();
947
+
948
+ // ── Init on first call ────────────────────────────────────────────────
949
+ if (!this._wfBuf) {
950
+ const buf = document.createElement('canvas');
951
+ buf.width = W;
952
+ buf.height = H;
953
+ this._wfBuf = buf;
954
+ this._wfBufCtx = buf.getContext('2d', { willReadFrequently: true });
955
+ // W is canvas.width = 140 * devicePixelRatio, so the history array is
956
+ // always sized to match physical pixels — DPR scaling is baked in here.
957
+ this._wfHistory = new Float32Array(W).fill(currentValue);
958
+ this._wfSubpx = 0;
959
+ this._wfLastTime = now;
960
+ this._wfPrevValue = currentValue;
961
+ this._wfNeedsFullRedraw = true;
962
+ }
963
+
964
+ // ── Compute how many new pixels to append ─────────────────────────────
965
+ const dt = (now - this._wfLastTime) / 1000;
966
+ this._wfLastTime = now;
967
+
968
+ const totalPx = dt * pxPerSec + this._wfSubpx;
969
+ const intShift = Math.min(Math.floor(totalPx), W);
970
+ this._wfSubpx = totalPx - intShift;
971
+
972
+ if (intShift > 0) {
973
+ // Shift history left and append interpolated new samples on the right
974
+ this._wfHistory.copyWithin(0, intShift);
975
+ const prev = this._wfPrevValue;
976
+ const cur = currentValue;
977
+ for (let i = 0; i < intShift; i++) {
978
+ const t = (i + 1) / intShift;
979
+ this._wfHistory[W - intShift + i] = prev + (cur - prev) * t;
980
+ }
981
+ }
982
+ this._wfPrevValue = currentValue;
983
+
984
+ // ── Paint offscreen pixel buffer ──────────────────────────────────────
985
+ const bCtx = this._wfBufCtx;
986
+
987
+ if (this._wfNeedsFullRedraw || intShift >= W) {
988
+ this._wfNeedsFullRedraw = false;
989
+ this._wfPaintFull(bCtx, W, H);
990
+ } else if (intShift > 0) {
991
+ this._wfPaintIncremental(bCtx, W, H, intShift);
992
+ }
993
+ }
994
+
995
+ /** Composite _wfBuf → visible canvas + cursor dot. Called from RAF. */
996
+ _rafDraw() {
997
+ if (!this._wfBuf) return;
998
+ const canvas = this._canvas;
999
+ const W = canvas.width;
1000
+ const H = canvas.height;
1001
+ const currentValue = this._latestValue;
1002
+
1003
+ const ctx = this._ctx;
1004
+ ctx.drawImage(this._wfBuf, 0, 0);
1005
+
1006
+ const cursorX = W - 1;
1007
+ const color = this._color;
1008
+ ctx.strokeStyle = `${color}50`;
1009
+ ctx.lineWidth = 1;
1010
+ ctx.setLineDash([2, 3]);
1011
+ ctx.beginPath();
1012
+ ctx.moveTo(cursorX, 0);
1013
+ ctx.lineTo(cursorX, H);
1014
+ ctx.stroke();
1015
+ ctx.setLineDash([]);
1016
+
1017
+ const dotY = (1 - (currentValue + 1) / 2) * (H - 6) + 3;
1018
+ ctx.fillStyle = color;
1019
+ ctx.shadowBlur = 8;
1020
+ ctx.shadowColor = color;
1021
+ ctx.beginPath();
1022
+ ctx.arc(cursorX, dotY, 3.5, 0, Math.PI * 2);
1023
+ ctx.fill();
1024
+ ctx.shadowBlur = 0;
1025
+ }
1026
+
1027
+ /** Full repaint of _wfBuf from _wfHistory. */
1028
+ _wfPaintFull(bCtx, W, H) {
1029
+ bCtx.fillStyle = '#050509';
1030
+ bCtx.fillRect(0, 0, W, H);
1031
+ this._wfDrawGrid(bCtx, W, H, 0, W);
1032
+ this._wfDrawHistoryStroke(bCtx, W, H, 0, W - 1);
1033
+ }
1034
+
1035
+ /**
1036
+ * Scroll _wfBuf left by intShift, fill background+grid in new strip, then
1037
+ * draw only the new right-edge stroke from _wfHistory.
1038
+ */
1039
+ _wfPaintIncremental(bCtx, W, H, intShift) {
1040
+ const newX = W - intShift;
1041
+
1042
+ const imgData = bCtx.getImageData(intShift, 0, newX, H);
1043
+ bCtx.putImageData(imgData, 0, 0);
1044
+
1045
+ bCtx.fillStyle = '#050509';
1046
+ bCtx.fillRect(newX, 0, intShift, H);
1047
+ this._wfDrawGrid(bCtx, W, H, newX, W);
1048
+
1049
+ // Extend stroke 1px into the already-painted region for a seamless join
1050
+ this._wfDrawHistoryStroke(bCtx, W, H, Math.max(0, newX - 1), W - 1);
1051
+ }
1052
+
1053
+ _wfDrawGrid(bCtx, W, H, x0, x1) {
1054
+ bCtx.strokeStyle = '#111120';
1055
+ bCtx.lineWidth = 1;
1056
+ bCtx.setLineDash([]);
1057
+ for (const gy of [H * 0.25, H * 0.5, H * 0.75]) {
1058
+ bCtx.beginPath();
1059
+ bCtx.moveTo(x0, gy);
1060
+ bCtx.lineTo(x1, gy);
1061
+ bCtx.stroke();
1062
+ }
1063
+ }
1064
+
1065
+ /** Stroke _wfHistory[xFrom..xTo] onto bCtx. */
1066
+ _wfDrawHistoryStroke(bCtx, W, H, xFrom, xTo) {
1067
+ const color = this._color;
1068
+ bCtx.beginPath();
1069
+ bCtx.strokeStyle = color;
1070
+ bCtx.lineWidth = 1.5;
1071
+ bCtx.shadowBlur = 4;
1072
+ bCtx.shadowColor = color;
1073
+ for (let px = xFrom; px <= xTo; px++) {
1074
+ const val = this._wfHistory[px];
1075
+ const py = (1 - (val + 1) / 2) * (H - 6) + 3;
1076
+ if (px === xFrom) bCtx.moveTo(px, py);
1077
+ else bCtx.lineTo(px, py);
1078
+ }
1079
+ bCtx.stroke();
1080
+ bCtx.shadowBlur = 0;
1081
+ }
1082
+
1083
+ _updateLed(value) {
1084
+ const brightness = Math.abs(value);
1085
+ this._led.style.opacity = 0.2 + brightness * 0.8;
1086
+ this._led.style.boxShadow = `0 0 ${4 + brightness * 8}px ${this._color}`;
1087
+ }
1088
+
1089
+ // ── Drag-to-assign ───────────────────────────────────────────────────────
1090
+
1091
+ _attachDragHandlers(handle) {
1092
+ let wire = null;
1093
+ let currentHighlight = null;
1094
+ let startRect = null;
1095
+
1096
+ const start = (e) => {
1097
+ handle.setPointerCapture(e.pointerId);
1098
+ handle.classList.add('dragging');
1099
+ startRect = handle.getBoundingClientRect();
1100
+ wire = createDragWire(this._color);
1101
+ wire.setStart(startRect.left + startRect.width / 2, startRect.top + startRect.height / 2);
1102
+ wire.setEnd(e.clientX, e.clientY);
1103
+ e.preventDefault();
1104
+ };
1105
+
1106
+ const move = (e) => {
1107
+ if (!handle.hasPointerCapture(e.pointerId)) return;
1108
+ wire.setEnd(e.clientX, e.clientY);
1109
+
1110
+ const target = getModTarget(e.clientX, e.clientY, handle);
1111
+ if (target !== currentHighlight) {
1112
+ if (currentHighlight) {
1113
+ currentHighlight.classList.remove('lfo-drag-target');
1114
+ currentHighlight.style.removeProperty('--lfo-drag-color');
1115
+ }
1116
+ currentHighlight = target;
1117
+ if (target) {
1118
+ target.classList.add('lfo-drag-target');
1119
+ target.style.setProperty('--lfo-drag-color', this._color);
1120
+ wire.setValid(true);
1121
+ } else {
1122
+ wire.setValid(false);
1123
+ }
1124
+ }
1125
+ };
1126
+
1127
+ // Shared teardown: remove wire + highlight without connecting.
1128
+ // Called on both normal end and pointercancel (where capture is already
1129
+ // revoked by the browser, so hasPointerCapture would return false).
1130
+ const cleanup = () => {
1131
+ handle.classList.remove('dragging');
1132
+ wire?.remove();
1133
+ wire = null;
1134
+ if (currentHighlight) {
1135
+ currentHighlight.classList.remove('lfo-drag-target');
1136
+ currentHighlight.style.removeProperty('--lfo-drag-color');
1137
+ currentHighlight = null;
1138
+ }
1139
+ };
1140
+
1141
+ const end = (e) => {
1142
+ if (!handle.hasPointerCapture(e.pointerId)) return;
1143
+ handle.releasePointerCapture(e.pointerId);
1144
+ const target = currentHighlight;
1145
+ cleanup();
1146
+ if (target) this._connectTo(target);
1147
+ };
1148
+
1149
+ handle.addEventListener('pointerdown', start);
1150
+ handle.addEventListener('pointermove', move);
1151
+ handle.addEventListener('pointerup', end);
1152
+ handle.addEventListener('pointercancel', cleanup);
1153
+ }
1154
+
1155
+ // ── Connection management ────────────────────────────────────────────────
1156
+
1157
+ _connectTo(element) {
1158
+ // Avoid duplicate element routes to the same element.
1159
+ for (const ind of this._indicators.values()) {
1160
+ if (engine.getRoute(ind.routeId)?.target === element) return;
1161
+ }
1162
+
1163
+ // Avoid duplicate chain routes: check if a route from this LFO to the
1164
+ // same target LFO param already exists (element sliders carry data-lfo-id).
1165
+ if (element instanceof Element && element.dataset.lfoId) {
1166
+ const linkedId = element.dataset.lfoId;
1167
+ const rawParam = element.dataset.lfoParam ?? '';
1168
+ const mappedParam = rawParam === 'baseRate' ? 'rate' :
1169
+ rawParam === 'baseDepth' ? 'depth' : rawParam;
1170
+ for (const route of engine.getAllRoutes()) {
1171
+ if (route.sourceId === this._lfoId &&
1172
+ route.targetType === 'lfo' &&
1173
+ route.target === linkedId &&
1174
+ route.targetParam === mappedParam) return;
1175
+ }
1176
+ }
1177
+
1178
+ const routeId = engine.addRoute(this._lfoId, 'element', element, null, { depth: 0.5 });
1179
+ // addRoute returns null for self-modulation rejection.
1180
+ if (!routeId) return;
1181
+
1182
+ // Check if the route was auto-promoted to a chain route.
1183
+ const route = engine.getRoute(routeId);
1184
+ if (!route) return;
1185
+
1186
+ // For both element and chain routes, create a ModIndicator anchored to
1187
+ // the actual DOM element (for chain routes this is the param slider).
1188
+ const indicator = new ModIndicator(
1189
+ element, routeId, this._lfoId, this._color, this._label,
1190
+ (rid) => this.disconnectRoute(rid)
1191
+ );
1192
+ this._indicators.set(routeId, indicator);
1193
+ this._onConnect?.(this._lfoId, element, routeId);
1194
+ }
1195
+
1196
+ /**
1197
+ * Programmatically connect this LFO to an element.
1198
+ * @param {HTMLElement} element
1199
+ * @param {object} [opts]
1200
+ * @param {number} [opts.depth=0.5]
1201
+ * @returns {string|null} routeId, or null if rejected (duplicate, self-mod).
1202
+ */
1203
+ connect(element, opts = {}) {
1204
+ // Avoid duplicate element routes to the same target
1205
+ for (const ind of this._indicators.values()) {
1206
+ if (engine.getRoute(ind.routeId)?.target === element) return null;
1207
+ }
1208
+ // Avoid duplicate chain routes (element carries data-lfo-id)
1209
+ if (element instanceof Element && element.dataset.lfoId) {
1210
+ const linkedId = element.dataset.lfoId;
1211
+ const rawParam = element.dataset.lfoParam ?? '';
1212
+ const mappedParam = rawParam === 'baseRate' ? 'rate' :
1213
+ rawParam === 'baseDepth' ? 'depth' : rawParam;
1214
+ for (const route of engine.getAllRoutes()) {
1215
+ if (route.sourceId === this._lfoId &&
1216
+ route.targetType === 'lfo' &&
1217
+ route.target === linkedId &&
1218
+ route.targetParam === mappedParam) return null;
1219
+ }
1220
+ }
1221
+
1222
+ const routeId = engine.addRoute(this._lfoId, 'element', element, null, opts);
1223
+ if (!routeId) return null;
1224
+ const route = engine.getRoute(routeId);
1225
+ if (!route) return null;
1226
+
1227
+ // Create indicator for both element and auto-promoted chain routes
1228
+ const indicator = new ModIndicator(
1229
+ element, routeId, this._lfoId, this._color, this._label,
1230
+ (rid) => this.disconnectRoute(rid)
1231
+ );
1232
+ this._indicators.set(routeId, indicator);
1233
+ this._onConnect?.(this._lfoId, element, routeId);
1234
+ return routeId;
1235
+ }
1236
+
1237
+ /**
1238
+ * Remove a specific modulation route.
1239
+ * @param {string} routeId
1240
+ */
1241
+ disconnectRoute(routeId) {
1242
+ engine.removeRoute(routeId);
1243
+ const ind = this._indicators.get(routeId);
1244
+ if (ind) {
1245
+ ind.destroy();
1246
+ this._indicators.delete(routeId);
1247
+ }
1248
+ this._onDisconnect?.(routeId);
1249
+ }
1250
+
1251
+ /** Disconnect all routes from this widget. */
1252
+ disconnectAll() {
1253
+ for (const routeId of [...this._indicators.keys()]) {
1254
+ this.disconnectRoute(routeId);
1255
+ }
1256
+ }
1257
+
1258
+ /** Tear down the widget and all its connections. */
1259
+ destroy() {
1260
+ this._destroyed = true;
1261
+ if (this._rafHandle != null) cancelAnimationFrame(this._rafHandle);
1262
+ this._unsub?.();
1263
+ this.disconnectAll();
1264
+ engine.destroyLFO(this._lfoId);
1265
+ this._root.remove();
1266
+ }
1267
+
1268
+ get element() { return this._root; }
1269
+ get lfoId() { return this._lfoId; }
1270
+ get color() { return this._color; }
1271
+ get label() { return this._label; }
1272
+ }