elektron-lfo 1.0.10 → 1.0.12

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
@@ -345,7 +345,12 @@ function calculateFadeMultiplier(fadeValue, fadeProgress) {
345
345
  function calculateFadeCycles(fadeValue) {
346
346
  if (fadeValue === 0)
347
347
  return 0;
348
- return 128 / Math.abs(fadeValue);
348
+ const absFade = Math.abs(fadeValue);
349
+ if (absFade <= 16) {
350
+ return Math.max(0.5, 0.1 * absFade + 0.6);
351
+ }
352
+ const baseAt16 = 2.2;
353
+ return baseAt16 * Math.pow(2, (absFade - 16) / 4.5);
349
354
  }
350
355
  function updateFade(config, state, cycleTimeMs, deltaMs) {
351
356
  if (config.fade === 0 || config.mode === "FRE") {
@@ -356,7 +361,7 @@ function updateFade(config, state, cycleTimeMs, deltaMs) {
356
361
  }
357
362
  const fadeCycles = calculateFadeCycles(config.fade);
358
363
  const fadeDurationMs = fadeCycles * cycleTimeMs;
359
- if (fadeDurationMs === 0 || fadeDurationMs === Infinity) {
364
+ if (fadeDurationMs === 0) {
360
365
  return {
361
366
  fadeProgress: 1,
362
367
  fadeMultiplier: config.fade < 0 ? 0 : 1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "elektron-lfo",
3
- "version": "1.0.10",
3
+ "version": "1.0.12",
4
4
  "description": "Elektron LFO engine simulator implementation with CLI visualization",
5
5
  "main": "dist/index.js",
6
6
  "module": "src/index.ts",
@@ -47,22 +47,41 @@ export function calculateFadeMultiplier(
47
47
  /**
48
48
  * Calculate fade cycles - how many LFO cycles for complete fade
49
49
  *
50
- * The FADE parameter (-64 to +63) maps to fade duration in cycles:
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
50
+ * Based on empirical testing against Digitakt II hardware (January 2025):
56
51
  *
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.
52
+ * Key observations:
53
+ * - |FADE| <= 16: Linear region, ~1 cycle at FADE=4, ~2.2 cycles at FADE=16
54
+ * - |FADE| > 16: Exponential slowdown, doubling every ~4.7 units
55
+ * - NO "disabled" threshold - even |FADE|=63 continues fading, just very slowly
56
+ *
57
+ * Measured values:
58
+ * |FADE| = 4: ~1 cycle
59
+ * |FADE| = 8: ~1.6 cycles
60
+ * |FADE| = 16: ~2.2 cycles
61
+ * |FADE| = 24: ~7 cycles
62
+ * |FADE| = 32: ~26 cycles
63
+ * |FADE| = 40: ~90 cycles
64
+ * |FADE| = 48: ~320 cycles
65
+ * |FADE| = 56: ~1100 cycles
66
+ * |FADE| = 63: ~3300 cycles
67
+ *
68
+ * IMPORTANT: Higher |FADE| = SLOWER fade (opposite of what you might expect)
60
69
  */
61
70
  export function calculateFadeCycles(fadeValue: number): number {
62
71
  if (fadeValue === 0) return 0;
63
- // Map |FADE| to cycles: 128 / |FADE| cycles
64
- // Maximum fade (64) = 2 cycles, minimum (1) = 128 cycles
65
- return 128 / Math.abs(fadeValue);
72
+
73
+ const absFade = Math.abs(fadeValue);
74
+
75
+ // Linear region (|FADE| <= 16): ~1 cycle at FADE=4, ~2.2 cycles at FADE=16
76
+ // Formula: 0.1 * |FADE| + 0.6, with minimum of 0.5
77
+ if (absFade <= 16) {
78
+ return Math.max(0.5, 0.1 * absFade + 0.6);
79
+ }
80
+
81
+ // Exponential region (|FADE| > 16): starts at 2.2 cycles at FADE=16
82
+ // Doubles every ~4.5 units of |FADE|
83
+ const baseAt16 = 2.2;
84
+ return baseAt16 * Math.pow(2, (absFade - 16) / 4.5);
66
85
  }
67
86
 
68
87
  /**
@@ -90,9 +109,13 @@ export function updateFade(
90
109
 
91
110
  // Calculate how many cycles the fade takes
92
111
  const fadeCycles = calculateFadeCycles(config.fade);
112
+
113
+ // Note: There is no "disabled" threshold - even extreme fade values
114
+ // just result in very slow fades (thousands of cycles for |FADE|=63)
115
+
93
116
  const fadeDurationMs = fadeCycles * cycleTimeMs;
94
117
 
95
- if (fadeDurationMs === 0 || fadeDurationMs === Infinity) {
118
+ if (fadeDurationMs === 0) {
96
119
  return {
97
120
  fadeProgress: 1,
98
121
  fadeMultiplier: config.fade < 0 ? 0 : 1,
@@ -111,6 +134,9 @@ export function updateFade(
111
134
 
112
135
  /**
113
136
  * Reset fade state (called on trigger for modes that reset fade)
137
+ *
138
+ * Note: There is no "disabled" threshold - even extreme fade values
139
+ * will eventually complete, just over thousands of cycles.
114
140
  */
115
141
  export function resetFade(config: LFOConfig): { fadeProgress: number; fadeMultiplier: number } {
116
142
  if (config.fade === 0) {
@@ -133,16 +133,53 @@ describe('calculateFadeCycles', () => {
133
133
  expect(calculateFadeCycles(0)).toBe(0);
134
134
  });
135
135
 
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);
136
+ test('calculates cycles based on fade value (empirical formula from Digitakt II)', () => {
137
+ // Based on Digitakt II hardware testing (January 2025):
138
+ // - Higher |FADE| = SLOWER fade (more cycles)
139
+ // - NO disabled threshold - even extreme values fade, just slowly
140
+
141
+ // Linear region (|FADE| <= 16): 0.1 * |FADE| + 0.6
142
+ expect(calculateFadeCycles(4)).toBeCloseTo(1.0, 1); // ~1 cycle
143
+ expect(calculateFadeCycles(-4)).toBeCloseTo(1.0, 1);
144
+ expect(calculateFadeCycles(8)).toBeCloseTo(1.4, 1); // ~1.4 cycles
145
+ expect(calculateFadeCycles(-8)).toBeCloseTo(1.4, 1);
146
+ expect(calculateFadeCycles(16)).toBeCloseTo(2.2, 1); // ~2.2 cycles
147
+ expect(calculateFadeCycles(-16)).toBeCloseTo(2.2, 1);
148
+
149
+ // Exponential region (|FADE| > 16): 2.2 * 2^((|FADE| - 16) / 4.5)
150
+ expect(calculateFadeCycles(24)).toBeCloseTo(7.5, 0); // ~7-8 cycles
151
+ expect(calculateFadeCycles(-24)).toBeCloseTo(7.5, 0);
152
+ expect(calculateFadeCycles(32)).toBeCloseTo(26, 0); // ~26 cycles
153
+ expect(calculateFadeCycles(-32)).toBeCloseTo(26, 0);
154
+
155
+ // Extreme values - NOT disabled, just very slow
156
+ // Using loose precision since exact values are less critical at extremes
157
+ const fade40 = calculateFadeCycles(40);
158
+ const fade48 = calculateFadeCycles(48);
159
+ const fade56 = calculateFadeCycles(56);
160
+ const fade63 = calculateFadeCycles(63);
161
+
162
+ expect(fade40).toBeGreaterThan(70);
163
+ expect(fade40).toBeLessThan(120); // ~90 cycles
164
+ expect(fade48).toBeGreaterThan(250);
165
+ expect(fade48).toBeLessThan(400); // ~300 cycles
166
+ expect(fade56).toBeGreaterThan(800);
167
+ expect(fade56).toBeLessThan(1400); // ~1000 cycles
168
+ expect(fade63).toBeGreaterThan(2500);
169
+ expect(fade63).toBeLessThan(4000); // ~3000 cycles
170
+ });
171
+
172
+ test('is symmetric for positive and negative fade values', () => {
173
+ expect(calculateFadeCycles(-16)).toBe(calculateFadeCycles(16));
174
+ expect(calculateFadeCycles(-32)).toBe(calculateFadeCycles(32));
175
+ expect(calculateFadeCycles(-48)).toBe(calculateFadeCycles(48));
176
+ });
177
+
178
+ test('extreme fade values return finite (large) cycle counts', () => {
179
+ // Verify no Infinity values - Digitakt continues fading even at extremes
180
+ expect(isFinite(calculateFadeCycles(48))).toBe(true);
181
+ expect(isFinite(calculateFadeCycles(63))).toBe(true);
182
+ expect(isFinite(calculateFadeCycles(-64))).toBe(true);
146
183
  });
147
184
  });
148
185
 
@@ -166,16 +203,29 @@ describe('updateFade', () => {
166
203
  });
167
204
 
168
205
  test('progresses fade over time', () => {
169
- // fade=-64 takes 128/64 = 2 cycles
206
+ // fade=-16 takes ~2.2 cycles (based on new empirical formula)
207
+ const config = createConfig({ fade: -16, mode: 'TRG' });
208
+ const state = createState({ fadeProgress: 0 });
209
+ const cycleTimeMs = 1000;
210
+
211
+ // Fade duration = ~2.2 cycles * 1000ms = ~2200ms
212
+ // After 1000ms (~45% of fade), progress should be ~0.45
213
+ const result = updateFade(config, state, cycleTimeMs, 1000);
214
+ expect(result.fadeProgress).toBeCloseTo(0.45, 1);
215
+ expect(result.fadeMultiplier).toBeCloseTo(0.45, 1);
216
+ });
217
+
218
+ test('extreme fade values still progress, just very slowly', () => {
219
+ // fade=-64 takes ~3500+ cycles - NOT disabled, just very slow
170
220
  const config = createConfig({ fade: -64, mode: 'TRG' });
171
221
  const state = createState({ fadeProgress: 0 });
172
- const cycleTimeMs = 2000;
222
+ const cycleTimeMs = 1000;
173
223
 
174
- // Fade duration = 2 cycles * 2000ms = 4000ms
175
- // After 1000ms (1/4 of fade), progress should be ~0.25
224
+ // After 1000ms (1 cycle), progress should be 1/3500 = ~0.0003
176
225
  const result = updateFade(config, state, cycleTimeMs, 1000);
177
- expect(result.fadeProgress).toBeCloseTo(0.25, 1);
178
- expect(result.fadeMultiplier).toBeCloseTo(0.25, 1);
226
+ expect(result.fadeProgress).toBeGreaterThan(0);
227
+ expect(result.fadeProgress).toBeLessThan(0.01);
228
+ expect(result.fadeMultiplier).toBeGreaterThan(0);
179
229
  });
180
230
  });
181
231
 
@@ -229,7 +279,7 @@ describe('Fade with LFO integration', () => {
229
279
  const lfo = new LFO({
230
280
  waveform: 'SIN',
231
281
  depth: 63,
232
- fade: -64, // Fade in over 2 cycles (128/64)
282
+ fade: -16, // Fade in over ~2.2 cycles
233
283
  mode: 'TRG',
234
284
  }, 120);
235
285
 
@@ -237,23 +287,46 @@ describe('Fade with LFO integration', () => {
237
287
  lfo.update(0);
238
288
 
239
289
  const state1 = lfo.update(100);
240
- expect(Math.abs(state1.output)).toBeLessThan(0.1); // Start near 0
290
+ expect(Math.abs(state1.output)).toBeLessThan(0.2); // Start near 0
241
291
 
242
- // Fade takes 2 cycles = 4000ms at 2000ms/cycle
292
+ // Fade takes ~2.2 cycles = ~4400ms at 2000ms/cycle
243
293
  // After more time, output should increase significantly
244
294
  let maxOutput = 0;
245
- for (let t = 100; t < 5000; t += 100) {
295
+ for (let t = 100; t < 6000; t += 100) {
246
296
  const state = lfo.update(t);
247
297
  maxOutput = Math.max(maxOutput, Math.abs(state.output));
248
298
  }
249
299
  expect(maxOutput).toBeGreaterThan(0.5);
250
300
  });
251
301
 
302
+ test('extreme fade values progress very slowly but are not disabled', () => {
303
+ const lfo = new LFO({
304
+ waveform: 'SIN',
305
+ depth: 63,
306
+ fade: -64, // Very slow fade (~3500 cycles), NOT disabled
307
+ mode: 'TRG',
308
+ }, 120);
309
+
310
+ lfo.trigger();
311
+ lfo.update(0);
312
+
313
+ // With extreme fade, output starts near 0 and increases VERY slowly
314
+ // After 10 seconds (~5 cycles), fade is only 5/3500 = 0.14% complete
315
+ let maxOutput = 0;
316
+ for (let t = 100; t < 10000; t += 100) {
317
+ const state = lfo.update(t);
318
+ maxOutput = Math.max(maxOutput, Math.abs(state.output));
319
+ }
320
+ // Should have some tiny output (not zero) since fade is progressing
321
+ expect(maxOutput).toBeGreaterThan(0);
322
+ expect(maxOutput).toBeLessThan(0.05); // But still very small
323
+ });
324
+
252
325
  test('fade out starts at full output and decreases', () => {
253
326
  const lfo = new LFO({
254
327
  waveform: 'SIN',
255
328
  depth: 63,
256
- fade: 64, // Fade out over 2 cycles (128/64)
329
+ fade: 16, // Fade out over ~2.2 cycles
257
330
  mode: 'TRG',
258
331
  startPhase: 32, // Start at peak
259
332
  }, 120);
@@ -265,10 +338,10 @@ describe('Fade with LFO integration', () => {
265
338
  // Should start near full output (at SIN peak)
266
339
  expect(Math.abs(state1.output)).toBeGreaterThan(0.8);
267
340
 
268
- // Fade takes 2 cycles = 4000ms at 2000ms/cycle
341
+ // Fade takes ~2.2 cycles = ~4400ms at 2000ms/cycle
269
342
  // After fade completes, output should be near 0
270
343
  let lastOutput = 0;
271
- for (let t = 10; t < 5000; t += 100) {
344
+ for (let t = 10; t < 6000; t += 100) {
272
345
  const state = lfo.update(t);
273
346
  lastOutput = Math.abs(state.output);
274
347
  }
@@ -278,7 +351,7 @@ describe('Fade with LFO integration', () => {
278
351
  test('fade resets on trigger for TRG mode', () => {
279
352
  const lfo = new LFO({
280
353
  waveform: 'SIN',
281
- fade: -64,
354
+ fade: -16, // Use non-disabled fade value
282
355
  mode: 'TRG',
283
356
  }, 120);
284
357
 
@@ -0,0 +1,316 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { LFO } from '../src/engine/lfo';
3
+
4
+ /**
5
+ * Negative Speed Behavior Tests
6
+ *
7
+ * Current implementation: Negative speed INVERTS the output while phase still runs forward
8
+ * - SAW at +speed: outputs +1 → -1 (falling)
9
+ * - SAW at -speed: outputs -1 → +1 (rising, because output is negated)
10
+ *
11
+ * NOTE: Digitakt II behavior may differ. These tests document our current model.
12
+ * If Digitakt tests show different behavior, we may need to update the implementation.
13
+ */
14
+
15
+ describe('Negative Speed - SAW waveform', () => {
16
+ test('positive speed SAW starts high and falls', () => {
17
+ const lfo = new LFO({
18
+ waveform: 'SAW',
19
+ speed: 16,
20
+ multiplier: 4,
21
+ depth: 63,
22
+ }, 120);
23
+
24
+ lfo.trigger();
25
+ lfo.update(0);
26
+ const stateStart = lfo.update(1);
27
+
28
+ // SAW at phase 0 should be +1, mapped to output ~+1 with depth 63
29
+ expect(stateStart.rawOutput).toBeCloseTo(1, 1);
30
+ expect(stateStart.output).toBeGreaterThan(0.9);
31
+
32
+ // After some time, SAW should fall
33
+ const stateLater = lfo.update(2000);
34
+ expect(stateLater.rawOutput).toBeLessThan(stateStart.rawOutput);
35
+ });
36
+
37
+ test('negative speed SAW starts low and rises (inverted)', () => {
38
+ const lfo = new LFO({
39
+ waveform: 'SAW',
40
+ speed: -16,
41
+ multiplier: 4,
42
+ depth: 63,
43
+ }, 120);
44
+
45
+ lfo.trigger();
46
+ lfo.update(0);
47
+ const stateStart = lfo.update(1);
48
+
49
+ // With negative speed, output is inverted
50
+ // rawOutput is still +1 (SAW at phase 0), but output is negated to -1
51
+ expect(stateStart.rawOutput).toBeCloseTo(1, 1);
52
+ expect(stateStart.output).toBeLessThan(-0.9); // Inverted!
53
+ });
54
+
55
+ test('SAW positive vs negative speed have inverted outputs at same time', () => {
56
+ const lfoPos = new LFO({
57
+ waveform: 'SAW',
58
+ speed: 16,
59
+ multiplier: 4,
60
+ depth: 63,
61
+ }, 120);
62
+
63
+ const lfoNeg = new LFO({
64
+ waveform: 'SAW',
65
+ speed: -16,
66
+ multiplier: 4,
67
+ depth: 63,
68
+ }, 120);
69
+
70
+ lfoPos.trigger();
71
+ lfoNeg.trigger();
72
+ lfoPos.update(0);
73
+ lfoNeg.update(0);
74
+
75
+ // Check at several time points
76
+ for (const time of [100, 500, 1000, 2000, 3000]) {
77
+ const statePos = lfoPos.update(time);
78
+ const stateNeg = lfoNeg.update(time);
79
+
80
+ // Phase should be the same
81
+ expect(stateNeg.phase).toBeCloseTo(statePos.phase, 3);
82
+
83
+ // Output should be inverted (opposite sign)
84
+ expect(stateNeg.output).toBeCloseTo(-statePos.output, 3);
85
+ }
86
+ });
87
+ });
88
+
89
+ describe('Negative Speed - TRI waveform', () => {
90
+ test('positive speed TRI starts at 0, rises to peak, then falls', () => {
91
+ const lfo = new LFO({
92
+ waveform: 'TRI',
93
+ speed: 16,
94
+ multiplier: 4,
95
+ depth: 63,
96
+ }, 120);
97
+
98
+ lfo.trigger();
99
+ lfo.update(0);
100
+ const stateStart = lfo.update(1);
101
+
102
+ // TRI at phase 0 should be 0
103
+ expect(stateStart.rawOutput).toBeCloseTo(0, 1);
104
+ });
105
+
106
+ test('negative speed TRI has inverted output', () => {
107
+ const lfoPos = new LFO({
108
+ waveform: 'TRI',
109
+ speed: 16,
110
+ multiplier: 4,
111
+ depth: 63,
112
+ }, 120);
113
+
114
+ const lfoNeg = new LFO({
115
+ waveform: 'TRI',
116
+ speed: -16,
117
+ multiplier: 4,
118
+ depth: 63,
119
+ }, 120);
120
+
121
+ lfoPos.trigger();
122
+ lfoNeg.trigger();
123
+
124
+ // Initialize with time 0, then advance to 25% of cycle
125
+ const cycleMs = lfoPos.getTimingInfo().cycleTimeMs;
126
+ const timeAt25Percent = cycleMs * 0.25;
127
+
128
+ // Start from time 1 to avoid sentinel value issue
129
+ lfoPos.update(1);
130
+ lfoNeg.update(1);
131
+
132
+ const statePos = lfoPos.update(1 + timeAt25Percent);
133
+ const stateNeg = lfoNeg.update(1 + timeAt25Percent);
134
+
135
+ // At phase 0.25, TRI should be at +1, so output ~= +1 * depth/63
136
+ // Positive should be positive, negative should be negative (inverted)
137
+ expect(statePos.output).toBeGreaterThan(0.5);
138
+ expect(stateNeg.output).toBeLessThan(-0.5);
139
+ });
140
+ });
141
+
142
+ describe('Negative Speed - RMP waveform (unipolar)', () => {
143
+ test('positive speed RMP rises from 0 to 1', () => {
144
+ const lfo = new LFO({
145
+ waveform: 'RMP',
146
+ speed: 16,
147
+ multiplier: 4,
148
+ depth: 63,
149
+ }, 120);
150
+
151
+ lfo.trigger();
152
+ lfo.update(0);
153
+ const stateStart = lfo.update(1);
154
+
155
+ // RMP at phase 0 should be 0
156
+ expect(stateStart.rawOutput).toBeCloseTo(0, 1);
157
+
158
+ // After half cycle, should be at 0.5
159
+ const cycleMs = lfo.getTimingInfo().cycleTimeMs;
160
+ const stateMid = lfo.update(cycleMs * 0.5);
161
+ expect(stateMid.rawOutput).toBeCloseTo(0.5, 1);
162
+ });
163
+
164
+ test('negative speed RMP has inverted output (0 to -1)', () => {
165
+ const lfo = new LFO({
166
+ waveform: 'RMP',
167
+ speed: -16,
168
+ multiplier: 4,
169
+ depth: 63,
170
+ }, 120);
171
+
172
+ lfo.trigger();
173
+ lfo.update(0);
174
+ const stateStart = lfo.update(1);
175
+
176
+ // rawOutput is still 0 (RMP at phase 0), output is negated to 0
177
+ expect(stateStart.rawOutput).toBeCloseTo(0, 1);
178
+ expect(stateStart.output).toBeCloseTo(0, 1); // -0 = 0
179
+
180
+ // After half cycle, rawOutput is 0.5, output is -0.5 * depth
181
+ const cycleMs = lfo.getTimingInfo().cycleTimeMs;
182
+ const stateMid = lfo.update(cycleMs * 0.5);
183
+ expect(stateMid.rawOutput).toBeCloseTo(0.5, 1);
184
+ expect(stateMid.output).toBeLessThan(0); // Negated
185
+ });
186
+ });
187
+
188
+ describe('Negative Speed - EXP waveform (unipolar)', () => {
189
+ test('positive speed EXP decays from 1 to 0', () => {
190
+ const lfo = new LFO({
191
+ waveform: 'EXP',
192
+ speed: 16,
193
+ multiplier: 4,
194
+ depth: 63,
195
+ }, 120);
196
+
197
+ lfo.trigger();
198
+ lfo.update(0);
199
+ const stateStart = lfo.update(1);
200
+
201
+ // EXP at phase 0 should be 1
202
+ expect(stateStart.rawOutput).toBeCloseTo(1, 1);
203
+ expect(stateStart.output).toBeGreaterThan(0.9);
204
+
205
+ // Near end of cycle, should be close to 0
206
+ const cycleMs = lfo.getTimingInfo().cycleTimeMs;
207
+ const stateEnd = lfo.update(cycleMs * 0.95);
208
+ expect(stateEnd.rawOutput).toBeLessThan(0.2);
209
+ });
210
+
211
+ test('negative speed EXP has inverted output (-1 to 0)', () => {
212
+ const lfo = new LFO({
213
+ waveform: 'EXP',
214
+ speed: -16,
215
+ multiplier: 4,
216
+ depth: 63,
217
+ }, 120);
218
+
219
+ lfo.trigger();
220
+ lfo.update(0);
221
+ const stateStart = lfo.update(1);
222
+
223
+ // rawOutput is still 1 (EXP at phase 0), output is negated to -1
224
+ expect(stateStart.rawOutput).toBeCloseTo(1, 1);
225
+ expect(stateStart.output).toBeLessThan(-0.9); // Inverted!
226
+ });
227
+ });
228
+
229
+ describe('Negative Speed - CC value mapping', () => {
230
+ test('SAW positive speed maps to CC 103 → 24 (high to low)', () => {
231
+ const lfo = new LFO({
232
+ waveform: 'SAW',
233
+ speed: 16,
234
+ multiplier: 4,
235
+ depth: 40, // CC range: 64-40=24 to 64+40=104
236
+ }, 120);
237
+
238
+ lfo.trigger();
239
+ lfo.update(0);
240
+ const stateStart = lfo.update(1);
241
+
242
+ // Convert output to CC: center (64) + output * 63
243
+ const ccStart = Math.round(64 + stateStart.output * 63);
244
+ expect(ccStart).toBeGreaterThan(95); // Should start near max (~103)
245
+
246
+ // At end of cycle
247
+ const cycleMs = lfo.getTimingInfo().cycleTimeMs;
248
+ const stateEnd = lfo.update(cycleMs * 0.95);
249
+ const ccEnd = Math.round(64 + stateEnd.output * 63);
250
+ expect(ccEnd).toBeLessThan(35); // Should end near min (~24)
251
+ });
252
+
253
+ test('SAW negative speed maps to CC 24 → 103 (low to high)', () => {
254
+ const lfo = new LFO({
255
+ waveform: 'SAW',
256
+ speed: -16,
257
+ multiplier: 4,
258
+ depth: 40,
259
+ }, 120);
260
+
261
+ lfo.trigger();
262
+ lfo.update(0);
263
+ const stateStart = lfo.update(1);
264
+
265
+ // With negative speed, output is inverted
266
+ const ccStart = Math.round(64 + stateStart.output * 63);
267
+ expect(ccStart).toBeLessThan(35); // Should start near min (~24)
268
+
269
+ // At end of cycle
270
+ const cycleMs = lfo.getTimingInfo().cycleTimeMs;
271
+ const stateEnd = lfo.update(cycleMs * 0.95);
272
+ const ccEnd = Math.round(64 + stateEnd.output * 63);
273
+ expect(ccEnd).toBeGreaterThan(95); // Should end near max (~103)
274
+ });
275
+ });
276
+
277
+ describe('Negative Speed - Timing', () => {
278
+ test('cycle time is same for positive and negative speed', () => {
279
+ const lfoPos = new LFO({ speed: 16, multiplier: 4 }, 120);
280
+ const lfoNeg = new LFO({ speed: -16, multiplier: 4 }, 120);
281
+
282
+ expect(lfoPos.getTimingInfo().cycleTimeMs).toBe(lfoNeg.getTimingInfo().cycleTimeMs);
283
+ });
284
+
285
+ test('phase progression is forward for both positive and negative speed', () => {
286
+ const lfoPos = new LFO({ speed: 16, multiplier: 4 }, 120);
287
+ const lfoNeg = new LFO({ speed: -16, multiplier: 4 }, 120);
288
+
289
+ lfoPos.trigger();
290
+ lfoNeg.trigger();
291
+ lfoPos.update(0);
292
+ lfoNeg.update(0);
293
+
294
+ let prevPhasePos = 0;
295
+ let prevPhaseNeg = 0;
296
+
297
+ // Check that phase always increases (modulo wrap)
298
+ for (let time = 100; time < 2000; time += 100) {
299
+ const statePos = lfoPos.update(time);
300
+ const stateNeg = lfoNeg.update(time);
301
+
302
+ // Both should have same phase
303
+ expect(stateNeg.phase).toBeCloseTo(statePos.phase, 3);
304
+
305
+ // Phase should increase or wrap
306
+ if (statePos.phase < 0.1 && prevPhasePos > 0.9) {
307
+ // Wrapped
308
+ } else {
309
+ expect(statePos.phase).toBeGreaterThanOrEqual(prevPhasePos - 0.01);
310
+ }
311
+
312
+ prevPhasePos = statePos.phase;
313
+ prevPhaseNeg = stateNeg.phase;
314
+ }
315
+ });
316
+ });
@@ -49,12 +49,13 @@ describe('Preset 1: Fade-In One-Shot', () => {
49
49
  });
50
50
 
51
51
  test('fade multiplier increases over time (fade in)', () => {
52
+ // Use TRG mode instead of ONE so LFO keeps running for fade to progress
52
53
  const lfo = new LFO({
53
54
  waveform: 'RMP',
54
55
  speed: 8,
55
- multiplier: 16,
56
- mode: 'ONE',
57
- fade: -32, // Negative = fade IN
56
+ multiplier: 8, // product=64, cycle=4000ms
57
+ mode: 'TRG',
58
+ fade: -16, // Fast fade-in (~2.67 cycles)
58
59
  depth: 63,
59
60
  }, 120);
60
61
 
@@ -62,17 +63,19 @@ describe('Preset 1: Fade-In One-Shot', () => {
62
63
  lfo.update(0);
63
64
 
64
65
  const state1 = lfo.update(100);
65
- expect(state1.fadeMultiplier).toBeLessThan(0.2);
66
+ expect(state1.fadeMultiplier).toBeLessThan(0.1);
66
67
 
67
- // Fade -32 = 128/32 = 4 cycles = 8000ms at 2000ms cycle
68
- // After ~2000ms (1 cycle), should be at 25% (2000/8000)
68
+ // Fade -16 = ~2.67 cycles (new formula)
69
+ // With speed=8, mult=8, product=64, cycle time = 4000ms at 120 BPM
70
+ // Total fade duration = ~2.67 * 4000ms = ~10680ms
71
+ // After ~8000ms (2 cycles), should be at ~75%
69
72
  let laterFadeMultiplier = 0;
70
- for (let t = 100; t < 2100; t += 50) {
73
+ for (let t = 100; t < 8100; t += 100) {
71
74
  const state = lfo.update(t);
72
75
  laterFadeMultiplier = state.fadeMultiplier;
73
76
  }
74
- expect(laterFadeMultiplier).toBeGreaterThan(0.15);
75
- expect(laterFadeMultiplier).toBeLessThan(0.4);
77
+ expect(laterFadeMultiplier).toBeGreaterThan(0.6);
78
+ expect(laterFadeMultiplier).toBeLessThan(0.9);
76
79
  });
77
80
  });
78
81