elektron-lfo 1.0.6 → 1.0.8
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/dist/index.js +20 -4
- package/package.json +2 -2
- package/src/engine/fade.ts +12 -7
- package/src/engine/lfo.ts +37 -1
- package/src/engine/timing.ts +2 -3
- package/tests/depth-fade.test.ts +25 -15
- package/tests/phase.test.ts +24 -16
- package/tests/presets.test.ts +8 -8
- package/tests/timing.test.ts +5 -4
package/dist/index.js
CHANGED
|
@@ -155,8 +155,7 @@ function calculatePhaseIncrement(config, bpm) {
|
|
|
155
155
|
if (cycleTimeMs === Infinity || cycleTimeMs === 0) {
|
|
156
156
|
return 0;
|
|
157
157
|
}
|
|
158
|
-
|
|
159
|
-
return direction / cycleTimeMs;
|
|
158
|
+
return 1 / cycleTimeMs;
|
|
160
159
|
}
|
|
161
160
|
function calculateCyclesPerBar(config) {
|
|
162
161
|
const product = calculateProduct(config);
|
|
@@ -346,7 +345,7 @@ function calculateFadeMultiplier(fadeValue, fadeProgress) {
|
|
|
346
345
|
function calculateFadeCycles(fadeValue) {
|
|
347
346
|
if (fadeValue === 0)
|
|
348
347
|
return 0;
|
|
349
|
-
return Math.abs(fadeValue)
|
|
348
|
+
return 128 / Math.abs(fadeValue);
|
|
350
349
|
}
|
|
351
350
|
function updateFade(config, state, cycleTimeMs, deltaMs) {
|
|
352
351
|
if (config.fade === 0 || config.mode === "FRE") {
|
|
@@ -453,6 +452,9 @@ class LFO {
|
|
|
453
452
|
if (this.config.mode === "HLD" && this.state.triggerCount > 0) {
|
|
454
453
|
effectiveRawOutput = this.state.heldOutput;
|
|
455
454
|
}
|
|
455
|
+
if (this.config.speed < 0) {
|
|
456
|
+
effectiveRawOutput = -effectiveRawOutput;
|
|
457
|
+
}
|
|
456
458
|
const depthScale = this.config.depth / 63;
|
|
457
459
|
let scaledOutput = effectiveRawOutput * depthScale;
|
|
458
460
|
scaledOutput *= this.state.fadeMultiplier;
|
|
@@ -461,7 +463,18 @@ class LFO {
|
|
|
461
463
|
return { ...this.state };
|
|
462
464
|
}
|
|
463
465
|
trigger() {
|
|
464
|
-
|
|
466
|
+
let rawOutputForTrigger = this.state.rawOutput;
|
|
467
|
+
if (this.config.mode === "HLD") {
|
|
468
|
+
const waveformResult = generateWaveform(this.config.waveform, this.state.phase, this.state);
|
|
469
|
+
rawOutputForTrigger = waveformResult.value;
|
|
470
|
+
if (waveformResult.newRandomValue !== undefined) {
|
|
471
|
+
this.state.randomValue = waveformResult.newRandomValue;
|
|
472
|
+
}
|
|
473
|
+
if (waveformResult.newRandomStep !== undefined) {
|
|
474
|
+
this.state.randomStep = waveformResult.newRandomStep;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
this.state = handleTrigger(this.config, this.state, rawOutputForTrigger);
|
|
465
478
|
}
|
|
466
479
|
getState() {
|
|
467
480
|
return { ...this.state };
|
|
@@ -511,6 +524,9 @@ class LFO {
|
|
|
511
524
|
stop() {
|
|
512
525
|
this.state.isRunning = false;
|
|
513
526
|
}
|
|
527
|
+
resetTiming() {
|
|
528
|
+
this.lastUpdateTime = 0;
|
|
529
|
+
}
|
|
514
530
|
}
|
|
515
531
|
export {
|
|
516
532
|
updateFade,
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "elektron-lfo",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.8",
|
|
4
4
|
"description": "Elektron LFO engine simulator implementation with CLI visualization",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "src/index.ts",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
8
8
|
"bin": {
|
|
9
|
-
"elektron-lfo": "
|
|
9
|
+
"elektron-lfo": "src/cli/index.ts"
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
12
12
|
"dev": "bun run src/cli/index.ts",
|
package/src/engine/fade.ts
CHANGED
|
@@ -48,16 +48,21 @@ export function calculateFadeMultiplier(
|
|
|
48
48
|
* Calculate fade cycles - how many LFO cycles for complete fade
|
|
49
49
|
*
|
|
50
50
|
* The FADE parameter (-64 to +63) maps to fade duration in cycles:
|
|
51
|
-
* - |FADE|
|
|
52
|
-
* - At |FADE| = 64, fade takes
|
|
53
|
-
* - At |FADE| = 32, fade takes
|
|
54
|
-
* - At |FADE| =
|
|
51
|
+
* - 128 / |FADE| gives the number of cycles
|
|
52
|
+
* - At |FADE| = 64, fade takes 2 cycles
|
|
53
|
+
* - At |FADE| = 32, fade takes 4 cycles
|
|
54
|
+
* - At |FADE| = 16, fade takes 8 cycles
|
|
55
|
+
* - At |FADE| = 1, fade takes 128 cycles
|
|
56
|
+
*
|
|
57
|
+
* This follows the same "128" convention used in LFO timing calculations,
|
|
58
|
+
* where 128 is the crossover point for bar-length cycles.
|
|
59
|
+
* Higher |FADE| values = faster fade, lower |FADE| values = slower fade.
|
|
55
60
|
*/
|
|
56
61
|
export function calculateFadeCycles(fadeValue: number): number {
|
|
57
62
|
if (fadeValue === 0) return 0;
|
|
58
|
-
// Map |FADE| to cycles: |FADE|
|
|
59
|
-
// Maximum fade (64) =
|
|
60
|
-
return Math.abs(fadeValue)
|
|
63
|
+
// Map |FADE| to cycles: 128 / |FADE| cycles
|
|
64
|
+
// Maximum fade (64) = 2 cycles, minimum (1) = 128 cycles
|
|
65
|
+
return 128 / Math.abs(fadeValue);
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
/**
|
package/src/engine/lfo.ts
CHANGED
|
@@ -134,6 +134,11 @@ export class LFO {
|
|
|
134
134
|
effectiveRawOutput = this.state.heldOutput;
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
// Invert output for negative speed (phase still runs forward)
|
|
138
|
+
if (this.config.speed < 0) {
|
|
139
|
+
effectiveRawOutput = -effectiveRawOutput;
|
|
140
|
+
}
|
|
141
|
+
|
|
137
142
|
// Apply depth
|
|
138
143
|
// Depth scales the output: depth of 63 = 100%, depth of 0 = 0%
|
|
139
144
|
// Negative depth inverts the waveform
|
|
@@ -157,9 +162,32 @@ export class LFO {
|
|
|
157
162
|
|
|
158
163
|
/**
|
|
159
164
|
* Trigger the LFO
|
|
165
|
+
*
|
|
166
|
+
* For HLD mode, this computes the current waveform value at the current phase
|
|
167
|
+
* to ensure the held value is accurate, even if trigger() is called before
|
|
168
|
+
* any update() calls.
|
|
160
169
|
*/
|
|
161
170
|
trigger(): void {
|
|
162
|
-
|
|
171
|
+
// For HLD mode, compute fresh waveform output at current phase.
|
|
172
|
+
// This is necessary because rawOutput may be stale if trigger() is called
|
|
173
|
+
// before update() has been called (e.g., when initializing an HLD mode LFO).
|
|
174
|
+
let rawOutputForTrigger = this.state.rawOutput;
|
|
175
|
+
if (this.config.mode === 'HLD') {
|
|
176
|
+
const waveformResult = generateWaveform(
|
|
177
|
+
this.config.waveform,
|
|
178
|
+
this.state.phase,
|
|
179
|
+
this.state
|
|
180
|
+
);
|
|
181
|
+
rawOutputForTrigger = waveformResult.value;
|
|
182
|
+
// Update random state if needed (for RND waveform)
|
|
183
|
+
if (waveformResult.newRandomValue !== undefined) {
|
|
184
|
+
this.state.randomValue = waveformResult.newRandomValue;
|
|
185
|
+
}
|
|
186
|
+
if (waveformResult.newRandomStep !== undefined) {
|
|
187
|
+
this.state.randomStep = waveformResult.newRandomStep;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
this.state = handleTrigger(this.config, this.state, rawOutputForTrigger);
|
|
163
191
|
}
|
|
164
192
|
|
|
165
193
|
/**
|
|
@@ -266,4 +294,12 @@ export class LFO {
|
|
|
266
294
|
stop(): void {
|
|
267
295
|
this.state.isRunning = false;
|
|
268
296
|
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Reset timing so the next update() call treats it as the first call (deltaMs = 0).
|
|
300
|
+
* Use this when resuming from pause or starting transport to avoid large phase jumps.
|
|
301
|
+
*/
|
|
302
|
+
resetTiming(): void {
|
|
303
|
+
this.lastUpdateTime = 0;
|
|
304
|
+
}
|
|
269
305
|
}
|
package/src/engine/timing.ts
CHANGED
|
@@ -62,9 +62,8 @@ export function calculatePhaseIncrement(config: LFOConfig, bpm: number): number
|
|
|
62
62
|
return 0;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
return direction / cycleTimeMs;
|
|
65
|
+
// Phase always moves forward; negative speed is handled by inverting waveform output
|
|
66
|
+
return 1 / cycleTimeMs;
|
|
68
67
|
}
|
|
69
68
|
|
|
70
69
|
/**
|
package/tests/depth-fade.test.ts
CHANGED
|
@@ -133,11 +133,16 @@ describe('calculateFadeCycles', () => {
|
|
|
133
133
|
expect(calculateFadeCycles(0)).toBe(0);
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
-
test('calculates cycles based on fade value', () => {
|
|
137
|
-
|
|
138
|
-
expect(calculateFadeCycles(
|
|
139
|
-
expect(calculateFadeCycles(
|
|
140
|
-
expect(calculateFadeCycles(
|
|
136
|
+
test('calculates cycles based on fade value (128/|FADE|)', () => {
|
|
137
|
+
// Higher |FADE| = faster fade (fewer cycles)
|
|
138
|
+
expect(calculateFadeCycles(64)).toBe(2); // 128/64 = 2 cycles
|
|
139
|
+
expect(calculateFadeCycles(-64)).toBe(2);
|
|
140
|
+
expect(calculateFadeCycles(32)).toBe(4); // 128/32 = 4 cycles
|
|
141
|
+
expect(calculateFadeCycles(-32)).toBe(4);
|
|
142
|
+
expect(calculateFadeCycles(16)).toBe(8); // 128/16 = 8 cycles
|
|
143
|
+
expect(calculateFadeCycles(-16)).toBe(8);
|
|
144
|
+
expect(calculateFadeCycles(1)).toBe(128); // 128/1 = 128 cycles (slowest)
|
|
145
|
+
expect(calculateFadeCycles(-1)).toBe(128);
|
|
141
146
|
});
|
|
142
147
|
});
|
|
143
148
|
|
|
@@ -161,13 +166,16 @@ describe('updateFade', () => {
|
|
|
161
166
|
});
|
|
162
167
|
|
|
163
168
|
test('progresses fade over time', () => {
|
|
164
|
-
|
|
169
|
+
// fade=-64 takes 128/64 = 2 cycles
|
|
170
|
+
const config = createConfig({ fade: -64, mode: 'TRG' });
|
|
165
171
|
const state = createState({ fadeProgress: 0 });
|
|
172
|
+
const cycleTimeMs = 2000;
|
|
166
173
|
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
expect(result.
|
|
174
|
+
// Fade duration = 2 cycles * 2000ms = 4000ms
|
|
175
|
+
// After 1000ms (1/4 of fade), progress should be ~0.25
|
|
176
|
+
const result = updateFade(config, state, cycleTimeMs, 1000);
|
|
177
|
+
expect(result.fadeProgress).toBeCloseTo(0.25, 1);
|
|
178
|
+
expect(result.fadeMultiplier).toBeCloseTo(0.25, 1);
|
|
171
179
|
});
|
|
172
180
|
});
|
|
173
181
|
|
|
@@ -221,7 +229,7 @@ describe('Fade with LFO integration', () => {
|
|
|
221
229
|
const lfo = new LFO({
|
|
222
230
|
waveform: 'SIN',
|
|
223
231
|
depth: 63,
|
|
224
|
-
fade: -64, // Fade in over
|
|
232
|
+
fade: -64, // Fade in over 2 cycles (128/64)
|
|
225
233
|
mode: 'TRG',
|
|
226
234
|
}, 120);
|
|
227
235
|
|
|
@@ -231,9 +239,10 @@ describe('Fade with LFO integration', () => {
|
|
|
231
239
|
const state1 = lfo.update(100);
|
|
232
240
|
expect(Math.abs(state1.output)).toBeLessThan(0.1); // Start near 0
|
|
233
241
|
|
|
234
|
-
//
|
|
242
|
+
// Fade takes 2 cycles = 4000ms at 2000ms/cycle
|
|
243
|
+
// After more time, output should increase significantly
|
|
235
244
|
let maxOutput = 0;
|
|
236
|
-
for (let t = 100; t <
|
|
245
|
+
for (let t = 100; t < 5000; t += 100) {
|
|
237
246
|
const state = lfo.update(t);
|
|
238
247
|
maxOutput = Math.max(maxOutput, Math.abs(state.output));
|
|
239
248
|
}
|
|
@@ -244,7 +253,7 @@ describe('Fade with LFO integration', () => {
|
|
|
244
253
|
const lfo = new LFO({
|
|
245
254
|
waveform: 'SIN',
|
|
246
255
|
depth: 63,
|
|
247
|
-
fade: 64, // Fade out over
|
|
256
|
+
fade: 64, // Fade out over 2 cycles (128/64)
|
|
248
257
|
mode: 'TRG',
|
|
249
258
|
startPhase: 32, // Start at peak
|
|
250
259
|
}, 120);
|
|
@@ -256,9 +265,10 @@ describe('Fade with LFO integration', () => {
|
|
|
256
265
|
// Should start near full output (at SIN peak)
|
|
257
266
|
expect(Math.abs(state1.output)).toBeGreaterThan(0.8);
|
|
258
267
|
|
|
268
|
+
// Fade takes 2 cycles = 4000ms at 2000ms/cycle
|
|
259
269
|
// After fade completes, output should be near 0
|
|
260
270
|
let lastOutput = 0;
|
|
261
|
-
for (let t = 10; t <
|
|
271
|
+
for (let t = 10; t < 5000; t += 100) {
|
|
262
272
|
const state = lfo.update(t);
|
|
263
273
|
lastOutput = Math.abs(state.output);
|
|
264
274
|
}
|
package/tests/phase.test.ts
CHANGED
|
@@ -23,11 +23,11 @@ describe('Phase wrapping', () => {
|
|
|
23
23
|
expect(wrappedFromOne).toBe(true);
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
-
test('phase wraps from
|
|
27
|
-
const lfo = new LFO({ speed: -32, multiplier: 64 }, 120); // Fast
|
|
26
|
+
test('phase wraps from 1 to 0 (negative speed - phase still runs forward)', () => {
|
|
27
|
+
const lfo = new LFO({ speed: -32, multiplier: 64 }, 120); // Fast cycle, negative speed
|
|
28
28
|
|
|
29
29
|
let lastTime = 0;
|
|
30
|
-
let
|
|
30
|
+
let wrappedFromOne = false;
|
|
31
31
|
let previousPhase = 0;
|
|
32
32
|
|
|
33
33
|
// First update to initialize
|
|
@@ -36,15 +36,16 @@ describe('Phase wrapping', () => {
|
|
|
36
36
|
|
|
37
37
|
for (let i = 0; i < 200; i++) {
|
|
38
38
|
const state = lfo.update(lastTime);
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
// Phase still runs forward even with negative speed
|
|
40
|
+
if (previousPhase > 0.9 && state.phase < 0.1) {
|
|
41
|
+
wrappedFromOne = true;
|
|
41
42
|
break;
|
|
42
43
|
}
|
|
43
44
|
previousPhase = state.phase;
|
|
44
45
|
lastTime += 10;
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
expect(
|
|
48
|
+
expect(wrappedFromOne).toBe(true);
|
|
48
49
|
});
|
|
49
50
|
|
|
50
51
|
test('phase stays within 0-1 range', () => {
|
|
@@ -61,16 +62,23 @@ describe('Phase wrapping', () => {
|
|
|
61
62
|
});
|
|
62
63
|
|
|
63
64
|
describe('Negative speed', () => {
|
|
64
|
-
test('negative speed
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
65
|
+
test('negative speed inverts output (phase still runs forward)', () => {
|
|
66
|
+
// Positive speed LFO
|
|
67
|
+
const lfoPos = new LFO({ speed: 16, multiplier: 8, waveform: 'TRI' }, 120);
|
|
68
|
+
// Negative speed LFO (same magnitude)
|
|
69
|
+
const lfoNeg = new LFO({ speed: -16, multiplier: 8, waveform: 'TRI' }, 120);
|
|
70
|
+
|
|
71
|
+
// Initialize both
|
|
72
|
+
lfoPos.update(0);
|
|
73
|
+
lfoNeg.update(0);
|
|
74
|
+
|
|
75
|
+
const statePos = lfoPos.update(500);
|
|
76
|
+
const stateNeg = lfoNeg.update(500);
|
|
77
|
+
|
|
78
|
+
// Phase should be the same (both run forward)
|
|
79
|
+
expect(stateNeg.phase).toBeCloseTo(statePos.phase, 4);
|
|
80
|
+
// Output should be inverted
|
|
81
|
+
expect(stateNeg.output).toBeCloseTo(-statePos.output, 4);
|
|
74
82
|
});
|
|
75
83
|
|
|
76
84
|
test('positive speed runs phase forwards', () => {
|
package/tests/presets.test.ts
CHANGED
|
@@ -62,17 +62,17 @@ describe('Preset 1: Fade-In One-Shot', () => {
|
|
|
62
62
|
lfo.update(0);
|
|
63
63
|
|
|
64
64
|
const state1 = lfo.update(100);
|
|
65
|
-
expect(state1.fadeMultiplier).toBeLessThan(0.
|
|
65
|
+
expect(state1.fadeMultiplier).toBeLessThan(0.2);
|
|
66
66
|
|
|
67
|
-
// Fade -32 =
|
|
68
|
-
// After ~
|
|
69
|
-
let
|
|
70
|
-
for (let t = 100; t <
|
|
67
|
+
// Fade -32 = 128/32 = 4 cycles = 8000ms at 2000ms cycle
|
|
68
|
+
// After ~2000ms (1 cycle), should be at 25% (2000/8000)
|
|
69
|
+
let laterFadeMultiplier = 0;
|
|
70
|
+
for (let t = 100; t < 2100; t += 50) {
|
|
71
71
|
const state = lfo.update(t);
|
|
72
|
-
|
|
72
|
+
laterFadeMultiplier = state.fadeMultiplier;
|
|
73
73
|
}
|
|
74
|
-
expect(
|
|
75
|
-
expect(
|
|
74
|
+
expect(laterFadeMultiplier).toBeGreaterThan(0.15);
|
|
75
|
+
expect(laterFadeMultiplier).toBeLessThan(0.4);
|
|
76
76
|
});
|
|
77
77
|
});
|
|
78
78
|
|
package/tests/timing.test.ts
CHANGED
|
@@ -103,11 +103,12 @@ describe('calculatePhaseIncrement', () => {
|
|
|
103
103
|
expect(increment).toBeCloseTo(1 / 2000, 8);
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
-
test('calculates
|
|
107
|
-
const config = createConfig({ speed: -16, multiplier: 8 }); // 2000ms cycle
|
|
106
|
+
test('calculates positive increment for negative speed (phase always forward)', () => {
|
|
107
|
+
const config = createConfig({ speed: -16, multiplier: 8 }); // 2000ms cycle
|
|
108
108
|
const increment = calculatePhaseIncrement(config, 120);
|
|
109
|
-
|
|
110
|
-
expect(increment).
|
|
109
|
+
// Phase always moves forward; output inversion is handled in LFO class
|
|
110
|
+
expect(increment).toBeGreaterThan(0);
|
|
111
|
+
expect(increment).toBeCloseTo(1 / 2000, 8);
|
|
111
112
|
});
|
|
112
113
|
|
|
113
114
|
test('returns 0 for speed 0', () => {
|