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/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
|
+

|
|
6
|
+
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
[]()
|
|
9
|
+
[]()
|
|
10
|
+
[]()
|
|
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
|
+
}
|