elektron-lfo 1.0.9 → 1.0.11

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
@@ -66,7 +66,7 @@ function generateSquare(phase) {
66
66
  return phase < 0.5 ? 1 : -1;
67
67
  }
68
68
  function generateSawtooth(phase) {
69
- return phase * 2 - 1;
69
+ return 1 - phase * 2;
70
70
  }
71
71
  function generateExponential(phase) {
72
72
  const k = 3;
@@ -345,7 +345,15 @@ 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 >= 48) {
350
+ return Infinity;
351
+ }
352
+ if (absFade <= 16) {
353
+ return Math.max(0.5, absFade / 6);
354
+ }
355
+ const baseAt16 = 16 / 6;
356
+ return baseAt16 * Math.pow(2, (absFade - 16) / 5);
349
357
  }
350
358
  function updateFade(config, state, cycleTimeMs, deltaMs) {
351
359
  if (config.fade === 0 || config.mode === "FRE") {
@@ -355,8 +363,14 @@ function updateFade(config, state, cycleTimeMs, deltaMs) {
355
363
  };
356
364
  }
357
365
  const fadeCycles = calculateFadeCycles(config.fade);
366
+ if (!isFinite(fadeCycles)) {
367
+ return {
368
+ fadeProgress: 0,
369
+ fadeMultiplier: 0
370
+ };
371
+ }
358
372
  const fadeDurationMs = fadeCycles * cycleTimeMs;
359
- if (fadeDurationMs === 0 || fadeDurationMs === Infinity) {
373
+ if (fadeDurationMs === 0) {
360
374
  return {
361
375
  fadeProgress: 1,
362
376
  fadeMultiplier: config.fade < 0 ? 0 : 1
@@ -373,6 +387,10 @@ function resetFade(config) {
373
387
  if (config.fade === 0) {
374
388
  return { fadeProgress: 1, fadeMultiplier: 1 };
375
389
  }
390
+ const fadeCycles = calculateFadeCycles(config.fade);
391
+ if (!isFinite(fadeCycles)) {
392
+ return { fadeProgress: 0, fadeMultiplier: 0 };
393
+ }
376
394
  if (config.fade < 0) {
377
395
  return { fadeProgress: 0, fadeMultiplier: 0 };
378
396
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "elektron-lfo",
3
- "version": "1.0.9",
3
+ "version": "1.0.11",
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,38 @@ 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:
51
+ * - |FADE| <= 16: Fast fade, roughly linear (~|FADE|/6 cycles)
52
+ * - |FADE| = 4: ~1 cycle (nearly instant)
53
+ * - |FADE| = 8: ~1.5 cycles
54
+ * - |FADE| = 16: ~2.5 cycles
55
+ * - |FADE| > 16: Exponential slowdown
56
+ * - |FADE| = 32: ~25 cycles
57
+ * - |FADE| >= 48: Effectively disabled (infinitely slow)
56
58
  *
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.
59
+ * IMPORTANT: Higher |FADE| = SLOWER fade (opposite of what you might expect)
60
60
  */
61
61
  export function calculateFadeCycles(fadeValue: number): number {
62
62
  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);
63
+
64
+ const absFade = Math.abs(fadeValue);
65
+
66
+ // |FADE| >= 48 is effectively disabled (infinitely slow fade)
67
+ if (absFade >= 48) {
68
+ return Infinity;
69
+ }
70
+
71
+ // Fast fade region: roughly linear
72
+ if (absFade <= 16) {
73
+ // |FADE|=4 → ~0.7 cycles, |FADE|=16 → ~2.7 cycles
74
+ return Math.max(0.5, absFade / 6);
75
+ }
76
+
77
+ // Slow fade region: exponential growth
78
+ // Base of ~2.7 cycles at |FADE|=16, doubling every 5 units
79
+ const baseAt16 = 16 / 6; // ~2.67 cycles
80
+ return baseAt16 * Math.pow(2, (absFade - 16) / 5);
81
+ // |FADE|=32: 2.67 * 2^3.2 ≈ 24.5 cycles (matches hardware)
66
82
  }
67
83
 
68
84
  /**
@@ -90,9 +106,19 @@ export function updateFade(
90
106
 
91
107
  // Calculate how many cycles the fade takes
92
108
  const fadeCycles = calculateFadeCycles(config.fade);
109
+
110
+ // Handle disabled fade (|FADE| >= 48)
111
+ // Both fade-in and fade-out with high values result in no modulation
112
+ if (!isFinite(fadeCycles)) {
113
+ return {
114
+ fadeProgress: 0,
115
+ fadeMultiplier: 0, // No modulation when fade is disabled
116
+ };
117
+ }
118
+
93
119
  const fadeDurationMs = fadeCycles * cycleTimeMs;
94
120
 
95
- if (fadeDurationMs === 0 || fadeDurationMs === Infinity) {
121
+ if (fadeDurationMs === 0) {
96
122
  return {
97
123
  fadeProgress: 1,
98
124
  fadeMultiplier: config.fade < 0 ? 0 : 1,
@@ -117,6 +143,13 @@ export function resetFade(config: LFOConfig): { fadeProgress: number; fadeMultip
117
143
  return { fadeProgress: 1, fadeMultiplier: 1 };
118
144
  }
119
145
 
146
+ // Check if fade is disabled (|FADE| >= 48)
147
+ const fadeCycles = calculateFadeCycles(config.fade);
148
+ if (!isFinite(fadeCycles)) {
149
+ // Disabled fade = no modulation
150
+ return { fadeProgress: 0, fadeMultiplier: 0 };
151
+ }
152
+
120
153
  if (config.fade < 0) {
121
154
  // Fade IN: start at 0
122
155
  return { fadeProgress: 0, fadeMultiplier: 0 };
@@ -39,10 +39,10 @@ export function generateSquare(phase: number): number {
39
39
 
40
40
  /**
41
41
  * Sawtooth waveform - Bipolar
42
- * Linear rise from -1 to +1 (matches Digitakt II behavior)
42
+ * Linear fall from +1 to -1 (matches Digitakt II behavior)
43
43
  */
44
44
  export function generateSawtooth(phase: number): number {
45
- return phase * 2 - 1;
45
+ return 1 - phase * 2;
46
46
  }
47
47
 
48
48
  /**
@@ -133,16 +133,28 @@ 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)', () => {
137
+ // Based on Digitakt II hardware testing:
138
+ // - Higher |FADE| = SLOWER fade (more cycles)
139
+ // - |FADE| >= 48 is disabled (Infinity)
140
+
141
+ // Fast fade region (|FADE| <= 16): ~|FADE|/6 cycles
142
+ expect(calculateFadeCycles(4)).toBeCloseTo(0.67, 1); // ~0.67 cycles
143
+ expect(calculateFadeCycles(-4)).toBeCloseTo(0.67, 1);
144
+ expect(calculateFadeCycles(8)).toBeCloseTo(1.33, 1); // ~1.33 cycles
145
+ expect(calculateFadeCycles(-8)).toBeCloseTo(1.33, 1);
146
+ expect(calculateFadeCycles(16)).toBeCloseTo(2.67, 1); // ~2.67 cycles
147
+ expect(calculateFadeCycles(-16)).toBeCloseTo(2.67, 1);
148
+
149
+ // Slow fade region (16 < |FADE| < 48): exponential
150
+ expect(calculateFadeCycles(32)).toBeCloseTo(24.5, 0); // ~24.5 cycles
151
+ expect(calculateFadeCycles(-32)).toBeCloseTo(24.5, 0);
152
+
153
+ // Disabled region (|FADE| >= 48)
154
+ expect(calculateFadeCycles(48)).toBe(Infinity);
155
+ expect(calculateFadeCycles(-48)).toBe(Infinity);
156
+ expect(calculateFadeCycles(64)).toBe(Infinity);
157
+ expect(calculateFadeCycles(-64)).toBe(Infinity);
146
158
  });
147
159
  });
148
160
 
@@ -166,16 +178,27 @@ describe('updateFade', () => {
166
178
  });
167
179
 
168
180
  test('progresses fade over time', () => {
169
- // fade=-64 takes 128/64 = 2 cycles
181
+ // fade=-16 takes ~2.67 cycles (based on new formula)
182
+ const config = createConfig({ fade: -16, mode: 'TRG' });
183
+ const state = createState({ fadeProgress: 0 });
184
+ const cycleTimeMs = 1000;
185
+
186
+ // Fade duration = ~2.67 cycles * 1000ms = ~2670ms
187
+ // After 1000ms (~37.5% of fade), progress should be ~0.375
188
+ const result = updateFade(config, state, cycleTimeMs, 1000);
189
+ expect(result.fadeProgress).toBeCloseTo(0.375, 1);
190
+ expect(result.fadeMultiplier).toBeCloseTo(0.375, 1);
191
+ });
192
+
193
+ test('returns 0 multiplier for disabled fade', () => {
194
+ // fade=-64 is disabled (|FADE| >= 48)
170
195
  const config = createConfig({ fade: -64, mode: 'TRG' });
171
196
  const state = createState({ fadeProgress: 0 });
172
197
  const cycleTimeMs = 2000;
173
198
 
174
- // Fade duration = 2 cycles * 2000ms = 4000ms
175
- // After 1000ms (1/4 of fade), progress should be ~0.25
176
199
  const result = updateFade(config, state, cycleTimeMs, 1000);
177
- expect(result.fadeProgress).toBeCloseTo(0.25, 1);
178
- expect(result.fadeMultiplier).toBeCloseTo(0.25, 1);
200
+ expect(result.fadeProgress).toBe(0);
201
+ expect(result.fadeMultiplier).toBe(0); // No modulation when disabled
179
202
  });
180
203
  });
181
204
 
@@ -229,7 +252,7 @@ describe('Fade with LFO integration', () => {
229
252
  const lfo = new LFO({
230
253
  waveform: 'SIN',
231
254
  depth: 63,
232
- fade: -64, // Fade in over 2 cycles (128/64)
255
+ fade: -16, // Fade in over ~2.67 cycles
233
256
  mode: 'TRG',
234
257
  }, 120);
235
258
 
@@ -237,23 +260,43 @@ describe('Fade with LFO integration', () => {
237
260
  lfo.update(0);
238
261
 
239
262
  const state1 = lfo.update(100);
240
- expect(Math.abs(state1.output)).toBeLessThan(0.1); // Start near 0
263
+ expect(Math.abs(state1.output)).toBeLessThan(0.2); // Start near 0
241
264
 
242
- // Fade takes 2 cycles = 4000ms at 2000ms/cycle
265
+ // Fade takes ~2.67 cycles = ~5340ms at 2000ms/cycle
243
266
  // After more time, output should increase significantly
244
267
  let maxOutput = 0;
245
- for (let t = 100; t < 5000; t += 100) {
268
+ for (let t = 100; t < 6000; t += 100) {
246
269
  const state = lfo.update(t);
247
270
  maxOutput = Math.max(maxOutput, Math.abs(state.output));
248
271
  }
249
272
  expect(maxOutput).toBeGreaterThan(0.5);
250
273
  });
251
274
 
275
+ test('disabled fade returns no modulation', () => {
276
+ const lfo = new LFO({
277
+ waveform: 'SIN',
278
+ depth: 63,
279
+ fade: -64, // Disabled (|FADE| >= 48)
280
+ mode: 'TRG',
281
+ }, 120);
282
+
283
+ lfo.trigger();
284
+ lfo.update(0);
285
+
286
+ // With disabled fade, output should stay at 0 (no modulation)
287
+ let maxOutput = 0;
288
+ for (let t = 100; t < 10000; t += 100) {
289
+ const state = lfo.update(t);
290
+ maxOutput = Math.max(maxOutput, Math.abs(state.output));
291
+ }
292
+ expect(maxOutput).toBe(0);
293
+ });
294
+
252
295
  test('fade out starts at full output and decreases', () => {
253
296
  const lfo = new LFO({
254
297
  waveform: 'SIN',
255
298
  depth: 63,
256
- fade: 64, // Fade out over 2 cycles (128/64)
299
+ fade: 16, // Fade out over ~2.67 cycles
257
300
  mode: 'TRG',
258
301
  startPhase: 32, // Start at peak
259
302
  }, 120);
@@ -265,10 +308,10 @@ describe('Fade with LFO integration', () => {
265
308
  // Should start near full output (at SIN peak)
266
309
  expect(Math.abs(state1.output)).toBeGreaterThan(0.8);
267
310
 
268
- // Fade takes 2 cycles = 4000ms at 2000ms/cycle
311
+ // Fade takes ~2.67 cycles = ~5340ms at 2000ms/cycle
269
312
  // After fade completes, output should be near 0
270
313
  let lastOutput = 0;
271
- for (let t = 10; t < 5000; t += 100) {
314
+ for (let t = 10; t < 6000; t += 100) {
272
315
  const state = lfo.update(t);
273
316
  lastOutput = Math.abs(state.output);
274
317
  }
@@ -278,7 +321,7 @@ describe('Fade with LFO integration', () => {
278
321
  test('fade resets on trigger for TRG mode', () => {
279
322
  const lfo = new LFO({
280
323
  waveform: 'SIN',
281
- fade: -64,
324
+ fade: -16, // Use non-disabled fade value
282
325
  mode: 'TRG',
283
326
  }, 120);
284
327
 
@@ -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
 
@@ -123,22 +123,22 @@ describe('Square Waveform', () => {
123
123
  });
124
124
 
125
125
  describe('Sawtooth Waveform', () => {
126
- test('starts at -1 at phase 0', () => {
127
- expect(generateSawtooth(0)).toBe(-1);
126
+ test('starts at +1 at phase 0', () => {
127
+ expect(generateSawtooth(0)).toBe(1);
128
128
  });
129
129
 
130
130
  test('is 0 at phase 0.5', () => {
131
131
  expect(generateSawtooth(0.5)).toBe(0);
132
132
  });
133
133
 
134
- test('approaches +1 at phase ~1', () => {
135
- expect(generateSawtooth(1)).toBe(1);
136
- expect(generateSawtooth(0.999)).toBeCloseTo(1, 2);
134
+ test('approaches -1 at phase ~1', () => {
135
+ expect(generateSawtooth(1)).toBe(-1);
136
+ expect(generateSawtooth(0.999)).toBeCloseTo(-1, 2);
137
137
  });
138
138
 
139
- test('is bipolar and linear (rising)', () => {
140
- expect(generateSawtooth(0.25)).toBeCloseTo(-0.5, 5);
141
- expect(generateSawtooth(0.75)).toBeCloseTo(0.5, 5);
139
+ test('is bipolar and linear (falling)', () => {
140
+ expect(generateSawtooth(0.25)).toBeCloseTo(0.5, 5);
141
+ expect(generateSawtooth(0.75)).toBeCloseTo(-0.5, 5);
142
142
  });
143
143
  });
144
144