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 ADDED
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
+
6
+ ## [Unreleased]
7
+
8
+ ## [0.1.0] - 2026-03-28
9
+
10
+ ### Added
11
+ - 7 waveform shapes: sine, triangle, saw, rsaw, square, S&H, smooth random
12
+ - Drag-to-assign handle with pointer capture and cross-element detection
13
+ - LFO chaining — drag onto another LFO's Rate or Depth slider; rate modulation is multiplicative so it never reaches zero
14
+ - Per-cycle jitter and waveform skew parameters
15
+ - ModIndicator badges with drag-adjustable depth and range arc overlay
16
+ - Modulation matrix with live depth sliders and one-click delete
17
+ - Bipolar / unipolar toggle (BI/UNI button)
18
+ - Click-to-type on any param value readout; Enter commits, Escape cancels
19
+ - Float32Array ring buffer oscilloscope with fixed wall-clock time axis
20
+ - Log-scaled rate slider (0.01 Hz – 10 Hz)
21
+ - Zero runtime dependencies
22
+
23
+ [Unreleased]: https://github.com/KUSH42/lfocomp/compare/v0.1.0...HEAD
24
+ [0.1.0]: https://github.com/KUSH42/lfocomp/releases/tag/v0.1.0
package/README.md ADDED
@@ -0,0 +1,179 @@
1
+ # lfocomp
2
+
3
+ **Drop an LFO onto any HTML input. Done.**
4
+
5
+ ![lfocomp demo](docs/demo.gif)
6
+
7
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
8
+ [![zero dependencies](https://img.shields.io/badge/dependencies-0-brightgreen.svg)]()
9
+ [![ES modules](https://img.shields.io/badge/module-ESM-f7df1e.svg)]()
10
+ [![npm](https://img.shields.io/badge/npm-not%20yet%20published-lightgrey.svg)]()
11
+
12
+ **[Live demo](https://kush42.github.io/lfo.html)**
13
+
14
+ ---
15
+
16
+ ## What it is
17
+
18
+ A zero-dependency ES module that adds Ableton/Bitwig-style LFO modulation to any `<input type=range>` or `<input type=number>`. Drag the assign handle onto a slider. It modulates. That's it.
19
+
20
+ No npm install at runtime. No bundler. No framework. Serve the files, open the page, wire things up.
21
+
22
+ ---
23
+
24
+ ## Why this exists
25
+
26
+ Every existing approach is either:
27
+
28
+ - A full synthesizer library pulling in 400 KB of irrelevant code
29
+ - A half-finished CodePen someone wrote at 2am
30
+ - Something that requires a bundler, a framework, and three config files to get "hello sine wave"
31
+
32
+ lfocomp is one import, three files, and you're live.
33
+
34
+ ---
35
+
36
+ ## Install
37
+
38
+ ### npm *(coming soon)*
39
+ ```bash
40
+ npm install lfocomp
41
+ ```
42
+ ```js
43
+ import { createLFO } from 'lfocomp';
44
+ ```
45
+
46
+ ### CDN (no install)
47
+ ```html
48
+ <script type="module">
49
+ import { createLFO } from 'https://cdn.jsdelivr.net/npm/lfocomp/lfo-comp.js';
50
+ </script>
51
+ ```
52
+
53
+ ### Self-hosted
54
+ Download `lfo-comp.js`, `lfo-engine.js`, and `lfo-ui.js` into the same directory.
55
+ No build step required.
56
+
57
+ ---
58
+
59
+ ## Quick start
60
+
61
+ ```bash
62
+ git clone https://github.com/KUSH42/lfocomp
63
+ cd lfocomp
64
+ npm run serve # python3 -m http.server 8080
65
+ # open http://localhost:8080/demo.html
66
+ ```
67
+
68
+ ```js
69
+ import { createLFO } from './lfo-comp.js';
70
+
71
+ const { widget } = createLFO(document.querySelector('#lfo-panel'));
72
+
73
+ // Drag the ⊕ handle onto any slider — or wire programmatically:
74
+ widget.connect(document.querySelector('#my-slider'), { depth: 0.7 });
75
+ ```
76
+
77
+ ---
78
+
79
+ ## Features
80
+
81
+ - **7 waveforms** — Sine, Triangle, Saw, Reverse Saw, Square, Sample & Hold, Smooth Random
82
+ - **Drag-to-assign** — pointer-captured drag wire, highlights valid targets on hover
83
+ - **LFO chaining** — drag LFO B's handle onto LFO A's Rate or Depth slider to chain them. Rate modulation is multiplicative (`rate × (1 + src × depth)`), so it never goes to zero
84
+ - **Per-cycle jitter** — randomises each cycle's duration independently; visible as variable-width cycles in the waveform view
85
+ - **Waveform skew** — warps the phase midpoint, turning a sine into something between a shark fin and a reverse ramp
86
+ - **Modulation matrix** — live table of all active routes with adjustable depth sliders and one-click delete
87
+ - **ModIndicator badges** — floating badges anchored to each connected input with drag-to-adjust depth and a range arc showing the sweep zone
88
+ - **Bipolar / unipolar toggle** — BI/UNI button on the widget switches output between ±1 and 0–1
89
+ - **Click-to-type param values** — click any value readout to type an exact number; Enter commits, Escape cancels
90
+
91
+ ---
92
+
93
+ ## Technical deep dive: the oscilloscope buffer
94
+
95
+ Most LFO visualisers recompute the waveform from the current phase on every frame. That approach has two problems: it can't show jitter (because jitter is stochastic per-cycle, not a formula), and it makes speed changes look instant instead of gradual.
96
+
97
+ lfocomp records the *actual output value* on every engine tick into a `Float32Array` ring buffer:
98
+
99
+ ```js
100
+ // Engine tick → subscribe callback → _recordSample(currentValue)
101
+ this._wfHistory.copyWithin(0, intShift); // shift left, O(1) typed-array copy
102
+ for (let i = 0; i < intShift; i++) {
103
+ const t = (i + 1) / intShift;
104
+ this._wfHistory[W - intShift + i] = prev + (cur - prev) * t; // linear interpolation
105
+ }
106
+ ```
107
+
108
+ The cursor advances at a fixed wall-clock rate (`W / 4` px/sec — always a 4-second window) regardless of LFO rate, so a 0.1 Hz LFO and a 10 Hz LFO both scroll at the same speed. Only the cycle density changes.
109
+
110
+ The visible canvas is composited from an offscreen `willReadFrequently` buffer. Only `intShift` new columns are painted per frame via `getImageData`/`putImageData` — no full redraws at 60 fps.
111
+
112
+ ---
113
+
114
+ ## API
115
+
116
+ ```js
117
+ // Create
118
+ const { lfoId, widget } = createLFO(containerEl, {
119
+ shape: 'sine', // 'sine'|'triangle'|'saw'|'rsaw'|'square'|'random'|'smooth'
120
+ rate: 1.0, // Hz (0.01–10)
121
+ depth: 1.0, // 0–1
122
+ phase: 0.0, // 0–1 (0.5 = 180° offset)
123
+ offset: 0.0, // DC offset, -1–1
124
+ bipolar: true, // true = ±1 output, false = 0–1
125
+ color: '#00d4ff',
126
+ label: 'LFO 1',
127
+ });
128
+
129
+ // Connect
130
+ const routeId = widget.connect(element, { depth: 0.5 });
131
+
132
+ // Disconnect
133
+ disconnect(routeId);
134
+
135
+ // Inspect
136
+ getRoutes(); // → RouteState[]
137
+ engine.getLFO(lfoId); // → full LFO state object
138
+ engine.getAllRoutes();
139
+ ```
140
+
141
+ ### LFO chain routes (programmatic)
142
+
143
+ ```js
144
+ // LFO 2 modulates LFO 1's rate at 40% depth
145
+ engine.addRoute(lfo2Id, 'lfo', lfo1Id, 'rate', { depth: 0.4 });
146
+ engine.addRoute(lfo2Id, 'lfo', lfo1Id, 'depth', { depth: 0.3 });
147
+ ```
148
+
149
+ ---
150
+
151
+ ## File structure
152
+
153
+ ```
154
+ lfocomp/
155
+ ├── lfo-engine.js Core: LFO state machine, tick loop, route graph
156
+ ├── lfo-ui.js DOM: canvas widget, ModIndicator badge, drag wire
157
+ ├── lfo-comp.js Public API: createLFO(), connect(), disconnect()
158
+ ├── demo.html Interactive demo — open after npm run serve
159
+ ├── tests/ Unit tests (vitest, jsdom)
160
+ ```
161
+
162
+ The three files are fully independent of each other except in one direction: `lfo-ui.js` imports from `lfo-engine.js`, and `lfo-comp.js` imports from both. Nothing imports from the outside.
163
+
164
+ ---
165
+
166
+ ## Running tests
167
+
168
+ ```bash
169
+ npm test # vitest run
170
+ npm run test:watch # watch mode
171
+ ```
172
+
173
+ 91 tests across three files: `engine.test.js` (math, tick loop, route graph), `widget.test.js` (LFOWidget lifecycle, drag cancel, ModIndicator, stale-route pruning), and `comp.test.js` (public API factory, color cycling, connect/disconnect helpers).
174
+
175
+ ---
176
+
177
+ ## License
178
+
179
+ MIT
package/lfo-comp.js ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * lfo-comp.js — Public API for the LFO modulation component.
3
+ *
4
+ * Quick start:
5
+ *
6
+ * import { createLFO } from './lfo-comp.js';
7
+ *
8
+ * // 1. Create an LFO panel in a container element:
9
+ * const { widget } = createLFO(document.querySelector('#lfo-panel'));
10
+ *
11
+ * // 2. Drag the "⊕ drag to assign" handle onto any <input type=range>
12
+ * // or <input type=number> to wire up modulation.
13
+ *
14
+ * // 3. Programmatic connection (optional):
15
+ * widget.connect(document.querySelector('#my-slider'), { depth: 0.7 });
16
+ *
17
+ * // 4. Chain LFOs — drag LFO 2's handle onto LFO 1's Rate slider,
18
+ * // or call:
19
+ * engine.addRoute(lfo2Id, 'lfo', lfo1Id, 'rate', { depth: 0.4 });
20
+ *
21
+ * Re-exports the engine singleton and UI classes for advanced use.
22
+ */
23
+
24
+ import { engine, SHAPES, seededRand, smoothRand } from './lfo-engine.js';
25
+ import { LFOWidget, ModIndicator, injectStyles, LFO_COLORS } from './lfo-ui.js';
26
+
27
+ export { engine, SHAPES, seededRand, smoothRand };
28
+ export { LFOWidget, ModIndicator, injectStyles, LFO_COLORS };
29
+
30
+ // Registry so disconnect() can clean up the ModIndicator badge.
31
+ // Maps routeId → LFOWidget that owns it.
32
+ const _widgetByRoute = new Map();
33
+
34
+ let _count = 0;
35
+ let _colorCount = 0; // separate counter so color cycling advances on every createLFO call
36
+
37
+ /**
38
+ * Create a fully wired LFO widget.
39
+ *
40
+ * @param {HTMLElement|object} containerOrOpts
41
+ * If an HTMLElement, the widget is appended to it and opts is the second arg.
42
+ * If an object, treated as opts — caller is responsible for appending widget.element.
43
+ * @param {object} [opts]
44
+ * @param {string} [opts.shape='sine'] Initial waveform shape.
45
+ * @param {number} [opts.rate=1] Initial rate in Hz.
46
+ * @param {number} [opts.depth=1] Initial depth 0–1.
47
+ * @param {number} [opts.phase=0] Initial phase offset 0–1.
48
+ * @param {number} [opts.offset=0] DC offset -1–1.
49
+ * @param {string} [opts.color] Accent color. Auto-assigned if omitted.
50
+ * @param {string} [opts.label] Widget label. Auto-assigned if omitted.
51
+ * @param {function} [opts.onConnect] Called when a connection is made.
52
+ * @param {function} [opts.onDisconnect] Called when a connection is removed.
53
+ *
54
+ * @returns {{ lfoId: string, widget: LFOWidget }}
55
+ */
56
+ export function createLFO(containerOrOpts = {}, opts = {}) {
57
+ let container, engineOpts, uiOpts;
58
+
59
+ if (containerOrOpts instanceof Element) {
60
+ container = containerOrOpts;
61
+ engineOpts = opts;
62
+ uiOpts = opts;
63
+ } else {
64
+ container = null;
65
+ engineOpts = containerOrOpts;
66
+ uiOpts = containerOrOpts;
67
+ }
68
+
69
+ const colorIdx = _colorCount++;
70
+ if (!uiOpts.label) {
71
+ uiOpts = { ...uiOpts, label: `LFO ${++_count}` };
72
+ }
73
+ if (!uiOpts.color) {
74
+ uiOpts = { ...uiOpts, color: LFO_COLORS[colorIdx % LFO_COLORS.length] };
75
+ }
76
+
77
+ // Wrap caller callbacks to maintain _widgetByRoute registry.
78
+ const callerOnConnect = uiOpts.onConnect;
79
+ const callerOnDisconnect = uiOpts.onDisconnect;
80
+ // Forward-declare so the onConnect closure can reference it without a lint warning.
81
+ let widget;
82
+
83
+ uiOpts = {
84
+ ...uiOpts,
85
+ onConnect: (lfoId, el, routeId) => {
86
+ _widgetByRoute.set(routeId, widget);
87
+ callerOnConnect?.(lfoId, el, routeId);
88
+ },
89
+ onDisconnect: (routeId) => {
90
+ _widgetByRoute.delete(routeId);
91
+ callerOnDisconnect?.(routeId);
92
+ },
93
+ };
94
+
95
+ const lfoId = engine.createLFO(engineOpts);
96
+ const div = container ?? document.createElement('div');
97
+ widget = new LFOWidget(div, lfoId, uiOpts);
98
+
99
+ return { lfoId, widget };
100
+ }
101
+
102
+ /**
103
+ * Programmatically connect an LFO to an HTML element.
104
+ * Equivalent to calling widget.connect(element, opts).
105
+ *
106
+ * @param {LFOWidget} widget
107
+ * @param {HTMLElement} element
108
+ * @param {object} [opts]
109
+ * @param {number} [opts.depth=0.5]
110
+ * @returns {string|null} routeId, or null if connection was rejected.
111
+ */
112
+ export function connect(widget, element, opts = {}) {
113
+ return widget.connect(element, opts);
114
+ }
115
+
116
+ /**
117
+ * Remove a modulation route and its UI indicator.
118
+ * Prefer calling widget.disconnectRoute(routeId) directly when you have the widget.
119
+ * This function handles programmatic routes created via connect() or drag-to-assign.
120
+ *
121
+ * @param {string} routeId
122
+ */
123
+ export function disconnect(routeId) {
124
+ const widget = _widgetByRoute.get(routeId);
125
+ if (widget) {
126
+ // Goes through the widget so the ModIndicator badge is also removed.
127
+ widget.disconnectRoute(routeId);
128
+ } else {
129
+ // Fallback for engine-only routes (e.g. direct engine.addRoute calls).
130
+ engine.removeRoute(routeId);
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Get all currently active modulation routes.
136
+ * @returns {object[]}
137
+ */
138
+ export function getRoutes() {
139
+ return engine.getAllRoutes();
140
+ }