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 CHANGED
@@ -155,8 +155,7 @@ function calculatePhaseIncrement(config, bpm) {
155
155
  if (cycleTimeMs === Infinity || cycleTimeMs === 0) {
156
156
  return 0;
157
157
  }
158
- const direction = config.speed >= 0 ? 1 : -1;
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) / 64;
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
- this.state = handleTrigger(this.config, this.state, this.state.rawOutput);
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.6",
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": "./src/cli/index.ts"
9
+ "elektron-lfo": "src/cli/index.ts"
10
10
  },
11
11
  "scripts": {
12
12
  "dev": "bun run src/cli/index.ts",
@@ -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| / 64 gives the number of cycles (approximately)
52
- * - At |FADE| = 64, fade takes 1 cycle
53
- * - At |FADE| = 32, fade takes 0.5 cycles
54
- * - At |FADE| = 1, fade takes ~1/64 of a cycle
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| / 64 cycles
59
- // Maximum fade (64) = 1 cycle, minimum (1) = 1/64 cycle
60
- return Math.abs(fadeValue) / 64;
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
- this.state = handleTrigger(this.config, this.state, this.state.rawOutput);
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
  }
@@ -62,9 +62,8 @@ export function calculatePhaseIncrement(config: LFOConfig, bpm: number): number
62
62
  return 0;
63
63
  }
64
64
 
65
- // Negative speed runs phase backwards
66
- const direction = config.speed >= 0 ? 1 : -1;
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
  /**
@@ -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
- expect(calculateFadeCycles(64)).toBe(1); // Full fade = 1 cycle
138
- expect(calculateFadeCycles(-64)).toBe(1);
139
- expect(calculateFadeCycles(32)).toBe(0.5); // Half fade = 0.5 cycle
140
- expect(calculateFadeCycles(-32)).toBe(0.5);
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
- const config = createConfig({ fade: -64, mode: 'TRG' }); // 1 cycle fade
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
- // After half the cycle time, progress should be ~0.5
168
- const result = updateFade(config, state, 2000, 1000);
169
- expect(result.fadeProgress).toBeCloseTo(0.5, 1);
170
- expect(result.fadeMultiplier).toBeCloseTo(0.5, 1);
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 1 cycle
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
- // After more time, output should increase
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 < 2500; t += 100) {
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 1 cycle
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 < 3000; t += 100) {
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
  }
@@ -23,11 +23,11 @@ describe('Phase wrapping', () => {
23
23
  expect(wrappedFromOne).toBe(true);
24
24
  });
25
25
 
26
- test('phase wraps from 0 to 1 (backward/negative speed)', () => {
27
- const lfo = new LFO({ speed: -32, multiplier: 64 }, 120); // Fast backward cycle
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 wrappedToOne = false;
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
- if (previousPhase < 0.1 && state.phase > 0.9) {
40
- wrappedToOne = true;
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(wrappedToOne).toBe(true);
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 runs phase backwards', () => {
65
- const lfo = new LFO({ speed: -16, multiplier: 8, startPhase: 64 }, 120);
66
-
67
- // Initialize
68
- lfo.update(0);
69
- const state1 = lfo.update(100);
70
- const state2 = lfo.update(200);
71
-
72
- // Phase should decrease
73
- expect(state2.phase).toBeLessThan(state1.phase);
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', () => {
@@ -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.5);
65
+ expect(state1.fadeMultiplier).toBeLessThan(0.2);
66
66
 
67
- // Fade -32 = 0.5 cycles = 1000ms at 2000ms cycle
68
- // After ~500ms, should be about halfway
69
- let midFadeMultiplier = 0;
70
- for (let t = 100; t < 600; t += 50) {
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
- midFadeMultiplier = state.fadeMultiplier;
72
+ laterFadeMultiplier = state.fadeMultiplier;
73
73
  }
74
- expect(midFadeMultiplier).toBeGreaterThan(0.3);
75
- expect(midFadeMultiplier).toBeLessThan(0.8);
74
+ expect(laterFadeMultiplier).toBeGreaterThan(0.15);
75
+ expect(laterFadeMultiplier).toBeLessThan(0.4);
76
76
  });
77
77
  });
78
78
 
@@ -103,11 +103,12 @@ describe('calculatePhaseIncrement', () => {
103
103
  expect(increment).toBeCloseTo(1 / 2000, 8);
104
104
  });
105
105
 
106
- test('calculates negative increment for negative speed', () => {
107
- const config = createConfig({ speed: -16, multiplier: 8 }); // 2000ms cycle, backward
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
- expect(increment).toBeLessThan(0);
110
- expect(increment).toBeCloseTo(-1 / 2000, 8);
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', () => {