lfocomp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +179 -0
- package/lfo-comp.js +140 -0
- package/lfo-engine.js +468 -0
- package/lfo-ui.js +1272 -0
- package/lfo.js +1878 -0
- package/package.json +74 -0
- package/types/lfo-comp.d.ts +68 -0
- package/types/lfo-engine.d.ts +150 -0
- package/types/lfo-ui.d.ts +147 -0
- package/types/lfo.d.ts +350 -0
package/lfo-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 = '⊕ 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
|
+
}
|