elektron-lfo 1.0.4 → 1.0.7

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.
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Command-line argument parsing for Elektron LFO CLI
3
+ */
4
+ import type { LFOConfig } from '../engine/types';
5
+ export interface CLIArgs extends LFOConfig {
6
+ bpm: number;
7
+ help: boolean;
8
+ }
9
+ export declare function printHelp(): void;
10
+ export declare function parseArgs(args: string[]): CLIArgs;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Terminal display for Elektron LFO CLI
3
+ *
4
+ * Renders the LFO state with ASCII waveform visualization
5
+ */
6
+ import type { LFOConfig, LFOState, TimingInfo } from '../engine/types';
7
+ /**
8
+ * Render the full display
9
+ */
10
+ export declare function render(config: LFOConfig, state: LFOState, timing: TimingInfo, bpm: number): string;
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Elektron LFO CLI - Main Entry Point
4
+ *
5
+ * Real-time terminal visualization of the Digitakt II LFO engine
6
+ */
7
+ export {};
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Keyboard input handling for Elektron LFO CLI
3
+ */
4
+ import type { Waveform, TriggerMode, Multiplier } from '../engine/types';
5
+ export type KeyAction = {
6
+ type: 'trigger';
7
+ } | {
8
+ type: 'quit';
9
+ } | {
10
+ type: 'bpm';
11
+ delta: number;
12
+ } | {
13
+ type: 'speed';
14
+ delta: number;
15
+ } | {
16
+ type: 'waveform';
17
+ waveform: Waveform;
18
+ } | {
19
+ type: 'mode';
20
+ mode: TriggerMode;
21
+ } | {
22
+ type: 'multiplier';
23
+ multiplier: Multiplier;
24
+ } | {
25
+ type: 'depth';
26
+ delta: number;
27
+ } | {
28
+ type: 'fade';
29
+ delta: number;
30
+ } | {
31
+ type: 'unknown';
32
+ };
33
+ /**
34
+ * Parse a key input into an action
35
+ */
36
+ export declare function parseKey(key: Buffer, currentWaveform: Waveform, currentMode: TriggerMode, currentMultiplier: Multiplier): KeyAction;
37
+ /**
38
+ * Set up raw mode input handling
39
+ */
40
+ export declare function setupKeyboardInput(onKey: (key: Buffer) => void, onExit: () => void): void;
41
+ /**
42
+ * Clean up keyboard input handling
43
+ */
44
+ export declare function cleanupKeyboardInput(): void;
@@ -73,4 +73,11 @@ export declare class LFO {
73
73
  * Stop the LFO
74
74
  */
75
75
  stop(): void;
76
+ /**
77
+ * Reset timing so the next update() call has deltaMs = 0
78
+ *
79
+ * Call this after trigger() when restarting from a paused state
80
+ * to prevent the LFO from jumping ahead by the paused duration.
81
+ */
82
+ resetTiming(): void;
76
83
  }
@@ -23,7 +23,7 @@ export declare function generateSine(phase: number): number;
23
23
  export declare function generateSquare(phase: number): number;
24
24
  /**
25
25
  * Sawtooth waveform - Bipolar
26
- * Linear rise from -1 to +1
26
+ * Linear fall from +1 to -1 (with positive depth)
27
27
  */
28
28
  export declare function generateSawtooth(phase: number): number;
29
29
  /**
@@ -33,7 +33,7 @@ export declare function generateSawtooth(phase: number): number;
33
33
  export declare function generateExponential(phase: number): number;
34
34
  /**
35
35
  * Ramp waveform - Unipolar (0 to +1)
36
- * Linear fall from +1 to 0
36
+ * Linear rise from 0 to +1
37
37
  */
38
38
  export declare function generateRamp(phase: number): number;
39
39
  /**
package/dist/index.js CHANGED
@@ -69,8 +69,9 @@ function generateSawtooth(phase) {
69
69
  return 1 - phase * 2;
70
70
  }
71
71
  function generateExponential(phase) {
72
- const k = 4;
73
- return (Math.exp(phase * k) - 1) / (Math.exp(k) - 1);
72
+ const k = 3;
73
+ const expK = Math.exp(-k);
74
+ return (Math.exp(-phase * k) - expK) / (1 - expK);
74
75
  }
75
76
  function generateRamp(phase) {
76
77
  return phase;
@@ -154,8 +155,7 @@ function calculatePhaseIncrement(config, bpm) {
154
155
  if (cycleTimeMs === Infinity || cycleTimeMs === 0) {
155
156
  return 0;
156
157
  }
157
- const direction = config.speed >= 0 ? 1 : -1;
158
- return direction / cycleTimeMs;
158
+ return 1 / cycleTimeMs;
159
159
  }
160
160
  function calculateCyclesPerBar(config) {
161
161
  const product = calculateProduct(config);
@@ -345,7 +345,7 @@ function calculateFadeMultiplier(fadeValue, fadeProgress) {
345
345
  function calculateFadeCycles(fadeValue) {
346
346
  if (fadeValue === 0)
347
347
  return 0;
348
- return Math.abs(fadeValue) / 64;
348
+ return 128 / Math.abs(fadeValue);
349
349
  }
350
350
  function updateFade(config, state, cycleTimeMs, deltaMs) {
351
351
  if (config.fade === 0 || config.mode === "FRE") {
@@ -452,6 +452,9 @@ class LFO {
452
452
  if (this.config.mode === "HLD" && this.state.triggerCount > 0) {
453
453
  effectiveRawOutput = this.state.heldOutput;
454
454
  }
455
+ if (this.config.speed < 0) {
456
+ effectiveRawOutput = -effectiveRawOutput;
457
+ }
455
458
  const depthScale = this.config.depth / 63;
456
459
  let scaledOutput = effectiveRawOutput * depthScale;
457
460
  scaledOutput *= this.state.fadeMultiplier;
@@ -460,7 +463,18 @@ class LFO {
460
463
  return { ...this.state };
461
464
  }
462
465
  trigger() {
463
- 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);
464
478
  }
465
479
  getState() {
466
480
  return { ...this.state };
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "elektron-lfo",
3
- "version": "1.0.4",
3
+ "version": "1.0.7",
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",
package/src/cli/index.ts CHANGED
File without changes
@@ -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
  /**
@@ -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
  /**
@@ -48,11 +48,16 @@ export function generateSawtooth(phase: number): number {
48
48
 
49
49
  /**
50
50
  * Exponential waveform - Unipolar (0 to +1)
51
- * Accelerating curve from 0 to 1
51
+ * Decaying curve from 1 to 0 (matches Digitakt II behavior)
52
+ * Fast initial decay, slowing toward the end (true exponential decay shape)
52
53
  */
53
54
  export function generateExponential(phase: number): number {
54
- const k = 4; // Steepness factor
55
- return (Math.exp(phase * k) - 1) / (Math.exp(k) - 1);
55
+ const k = 3; // Decay rate - controls steepness of decay
56
+ // True exponential decay: starts at 1, decays rapidly then slows
57
+ // Formula: (exp(-phase * k) - exp(-k)) / (1 - exp(-k))
58
+ // This normalizes to exactly [1, 0] range
59
+ const expK = Math.exp(-k);
60
+ return (Math.exp(-phase * k) - expK) / (1 - expK);
56
61
  }
57
62
 
58
63
  /**
@@ -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', () => {
@@ -0,0 +1,387 @@
1
+ /**
2
+ * Expectation-based tests for EXP and RMP waveforms.
3
+ *
4
+ * Based on Digitakt II hardware verification, these waveforms should behave as:
5
+ *
6
+ * EXP (Exponential):
7
+ * - DECAY curve: starts at 1 (peak), decays toward 0 (center)
8
+ * - Unipolar output range: 0 to 1
9
+ * - With positive depth: CC goes from (64+depth) down to 64
10
+ * - Shape: Fast initial decay, slowing toward the end
11
+ *
12
+ * RMP (Ramp):
13
+ * - RISE curve: starts at 0 (center), rises toward 1 (peak)
14
+ * - Unipolar output range: 0 to 1
15
+ * - With positive depth: CC goes from 64 up to (64+depth)
16
+ * - Shape: Linear rise
17
+ */
18
+
19
+ import { describe, test, expect } from 'bun:test';
20
+ import { generateExponential, generateRamp } from '../src/engine/waveforms';
21
+ import { LFO } from '../src/engine/lfo';
22
+
23
+ // Helper to convert LFO output to CC value
24
+ const outputToCC = (output: number): number => {
25
+ return Math.max(0, Math.min(127, Math.round(64 + output * 63)));
26
+ };
27
+
28
+ describe('EXP Waveform - Expected Behavior', () => {
29
+ describe('raw waveform generator', () => {
30
+ test('should return 1.0 at phase 0 (starts at peak)', () => {
31
+ expect(generateExponential(0)).toBeCloseTo(1.0, 2);
32
+ });
33
+
34
+ test('should return value < 0.5 at phase 0.5 (decaying)', () => {
35
+ const value = generateExponential(0.5);
36
+ expect(value).toBeLessThan(0.5);
37
+ expect(value).toBeGreaterThan(0);
38
+ });
39
+
40
+ test('should return value near 0 at phase 1 (decayed to center)', () => {
41
+ expect(generateExponential(1)).toBeLessThan(0.1);
42
+ });
43
+
44
+ test('should monotonically decrease from phase 0 to 1', () => {
45
+ const phases = [0, 0.2, 0.4, 0.6, 0.8, 1.0];
46
+ const values = phases.map(p => generateExponential(p));
47
+
48
+ for (let i = 1; i < values.length; i++) {
49
+ expect(values[i]).toBeLessThan(values[i - 1]);
50
+ }
51
+ });
52
+
53
+ test('should be unipolar (output range 0 to 1)', () => {
54
+ for (let i = 0; i <= 100; i++) {
55
+ const value = generateExponential(i / 100);
56
+ expect(value).toBeGreaterThanOrEqual(0);
57
+ expect(value).toBeLessThanOrEqual(1);
58
+ }
59
+ });
60
+ });
61
+
62
+ describe('full LFO with depth=40', () => {
63
+ const BPM = 120;
64
+ const DEPTH = 40;
65
+
66
+ test('should produce CC near 104 at phase 0 (start at peak)', () => {
67
+ const lfo = new LFO({
68
+ waveform: 'EXP',
69
+ speed: 16,
70
+ multiplier: 4,
71
+ depth: DEPTH,
72
+ startPhase: 0,
73
+ mode: 'TRG',
74
+ }, BPM);
75
+
76
+ lfo.trigger();
77
+ const state = lfo.update(1);
78
+ const cc = outputToCC(state.output);
79
+
80
+ // At phase 0, EXP should be at peak: CC = 64 + 40 = 104
81
+ expect(cc).toBeGreaterThan(100);
82
+ expect(cc).toBeLessThanOrEqual(104);
83
+ });
84
+
85
+ test('should produce CC near 64 at phase 1 (end at center)', () => {
86
+ const lfo = new LFO({
87
+ waveform: 'EXP',
88
+ speed: 16,
89
+ multiplier: 4,
90
+ depth: DEPTH,
91
+ startPhase: 0,
92
+ mode: 'TRG',
93
+ }, BPM);
94
+
95
+ lfo.trigger();
96
+ // First update to set initial time (deltaMs=0 on first call)
97
+ lfo.update(1);
98
+ // Update to near end of cycle
99
+ const cycleMs = 4000;
100
+ const state = lfo.update(1 + cycleMs * 0.99);
101
+ const cc = outputToCC(state.output);
102
+
103
+ // At phase ~1, EXP should be near center: CC ≈ 64
104
+ expect(cc).toBeGreaterThanOrEqual(64);
105
+ expect(cc).toBeLessThan(75);
106
+ });
107
+
108
+ test('CC should decrease over time (decay behavior)', () => {
109
+ const lfo = new LFO({
110
+ waveform: 'EXP',
111
+ speed: 16,
112
+ multiplier: 4,
113
+ depth: DEPTH,
114
+ startPhase: 0,
115
+ mode: 'TRG',
116
+ }, BPM);
117
+
118
+ lfo.trigger();
119
+ const cycleMs = 4000;
120
+
121
+ const ccStart = outputToCC(lfo.update(1).output);
122
+ const ccMid = outputToCC(lfo.update(1 + cycleMs * 0.5).output);
123
+ const ccEnd = outputToCC(lfo.update(1 + cycleMs * 0.99).output);
124
+
125
+ expect(ccStart).toBeGreaterThan(ccMid);
126
+ expect(ccMid).toBeGreaterThan(ccEnd);
127
+ });
128
+ });
129
+ });
130
+
131
+ describe('RMP Waveform - Expected Behavior', () => {
132
+ describe('raw waveform generator', () => {
133
+ test('should return 0 at phase 0 (starts at center)', () => {
134
+ expect(generateRamp(0)).toBe(0);
135
+ });
136
+
137
+ test('should return 0.5 at phase 0.5 (linear rise)', () => {
138
+ expect(generateRamp(0.5)).toBe(0.5);
139
+ });
140
+
141
+ test('should return 1.0 at phase 1 (ends at peak)', () => {
142
+ expect(generateRamp(1)).toBe(1);
143
+ });
144
+
145
+ test('should rise linearly from phase 0 to 1', () => {
146
+ for (let i = 0; i <= 100; i++) {
147
+ const phase = i / 100;
148
+ expect(generateRamp(phase)).toBeCloseTo(phase, 5);
149
+ }
150
+ });
151
+
152
+ test('should be unipolar (output range 0 to 1)', () => {
153
+ for (let i = 0; i <= 100; i++) {
154
+ const value = generateRamp(i / 100);
155
+ expect(value).toBeGreaterThanOrEqual(0);
156
+ expect(value).toBeLessThanOrEqual(1);
157
+ }
158
+ });
159
+ });
160
+
161
+ describe('full LFO with depth=40', () => {
162
+ const BPM = 120;
163
+ const DEPTH = 40;
164
+
165
+ test('should produce CC near 64 at phase 0 (start at center)', () => {
166
+ const lfo = new LFO({
167
+ waveform: 'RMP',
168
+ speed: 16,
169
+ multiplier: 4,
170
+ depth: DEPTH,
171
+ startPhase: 0,
172
+ mode: 'TRG',
173
+ }, BPM);
174
+
175
+ lfo.trigger();
176
+ const state = lfo.update(1);
177
+ const cc = outputToCC(state.output);
178
+
179
+ // At phase 0, RMP should be at center: CC = 64
180
+ expect(cc).toBe(64);
181
+ });
182
+
183
+ test('should produce CC near 104 at phase 1 (end at peak)', () => {
184
+ const lfo = new LFO({
185
+ waveform: 'RMP',
186
+ speed: 16,
187
+ multiplier: 4,
188
+ depth: DEPTH,
189
+ startPhase: 0,
190
+ mode: 'TRG',
191
+ }, BPM);
192
+
193
+ lfo.trigger();
194
+ // First update to set initial time (deltaMs=0 on first call)
195
+ lfo.update(1);
196
+ const cycleMs = 4000;
197
+ const state = lfo.update(1 + cycleMs * 0.99);
198
+ const cc = outputToCC(state.output);
199
+
200
+ // At phase ~1, RMP should be at peak: CC = 64 + 40 = 104
201
+ expect(cc).toBeGreaterThan(100);
202
+ expect(cc).toBeLessThanOrEqual(104);
203
+ });
204
+
205
+ test('CC should increase over time (rise behavior)', () => {
206
+ const lfo = new LFO({
207
+ waveform: 'RMP',
208
+ speed: 16,
209
+ multiplier: 4,
210
+ depth: DEPTH,
211
+ startPhase: 0,
212
+ mode: 'TRG',
213
+ }, BPM);
214
+
215
+ lfo.trigger();
216
+ const cycleMs = 4000;
217
+
218
+ const ccStart = outputToCC(lfo.update(1).output);
219
+ const ccMid = outputToCC(lfo.update(1 + cycleMs * 0.5).output);
220
+ const ccEnd = outputToCC(lfo.update(1 + cycleMs * 0.99).output);
221
+
222
+ expect(ccStart).toBeLessThan(ccMid);
223
+ expect(ccMid).toBeLessThan(ccEnd);
224
+ });
225
+
226
+ test('should produce CC range [64-104] over full cycle', () => {
227
+ const lfo = new LFO({
228
+ waveform: 'RMP',
229
+ speed: 16,
230
+ multiplier: 4,
231
+ depth: DEPTH,
232
+ startPhase: 0,
233
+ mode: 'TRG',
234
+ }, BPM);
235
+
236
+ lfo.trigger();
237
+ const cycleMs = 4000;
238
+ let minCC = 127, maxCC = 0;
239
+
240
+ for (let i = 0; i <= 100; i++) {
241
+ const state = lfo.update(1 + (i / 100) * cycleMs);
242
+ const cc = outputToCC(state.output);
243
+ minCC = Math.min(minCC, cc);
244
+ maxCC = Math.max(maxCC, cc);
245
+ }
246
+
247
+ expect(minCC).toBe(64);
248
+ expect(maxCC).toBeGreaterThanOrEqual(103);
249
+ expect(maxCC).toBeLessThanOrEqual(104);
250
+ });
251
+ });
252
+ });
253
+
254
+ describe('Depth Scaling Comparison', () => {
255
+ const BPM = 120;
256
+ const DEPTH = 40;
257
+
258
+ test('TRI (bipolar) should swing ±40 from center [24-104]', () => {
259
+ const lfo = new LFO({
260
+ waveform: 'TRI',
261
+ speed: 16,
262
+ multiplier: 4,
263
+ depth: DEPTH,
264
+ startPhase: 0,
265
+ mode: 'TRG',
266
+ }, BPM);
267
+
268
+ lfo.trigger();
269
+ const cycleMs = 4000;
270
+ let minCC = 127, maxCC = 0;
271
+
272
+ for (let i = 0; i <= 100; i++) {
273
+ const state = lfo.update(1 + (i / 100) * cycleMs);
274
+ const cc = outputToCC(state.output);
275
+ minCC = Math.min(minCC, cc);
276
+ maxCC = Math.max(maxCC, cc);
277
+ }
278
+
279
+ // Bipolar: 64 - 40 = 24, 64 + 40 = 104
280
+ expect(minCC).toBe(24);
281
+ expect(maxCC).toBe(104);
282
+ });
283
+
284
+ test('RMP (unipolar) should swing +40 from center [64-104]', () => {
285
+ const lfo = new LFO({
286
+ waveform: 'RMP',
287
+ speed: 16,
288
+ multiplier: 4,
289
+ depth: DEPTH,
290
+ startPhase: 0,
291
+ mode: 'TRG',
292
+ }, BPM);
293
+
294
+ lfo.trigger();
295
+ const cycleMs = 4000;
296
+ let minCC = 127, maxCC = 0;
297
+
298
+ for (let i = 0; i <= 100; i++) {
299
+ const state = lfo.update(1 + (i / 100) * cycleMs);
300
+ const cc = outputToCC(state.output);
301
+ minCC = Math.min(minCC, cc);
302
+ maxCC = Math.max(maxCC, cc);
303
+ }
304
+
305
+ // Unipolar rising: 64 to 64 + 40 = 104
306
+ expect(minCC).toBe(64);
307
+ expect(maxCC).toBeGreaterThanOrEqual(103);
308
+ });
309
+
310
+ test('EXP (unipolar) should swing +40 from center [64-104]', () => {
311
+ const lfo = new LFO({
312
+ waveform: 'EXP',
313
+ speed: 16,
314
+ multiplier: 4,
315
+ depth: DEPTH,
316
+ startPhase: 0,
317
+ mode: 'TRG',
318
+ }, BPM);
319
+
320
+ lfo.trigger();
321
+ const cycleMs = 4000;
322
+ let minCC = 127, maxCC = 0;
323
+
324
+ for (let i = 0; i <= 100; i++) {
325
+ const state = lfo.update(1 + (i / 100) * cycleMs);
326
+ const cc = outputToCC(state.output);
327
+ minCC = Math.min(minCC, cc);
328
+ maxCC = Math.max(maxCC, cc);
329
+ }
330
+
331
+ // Unipolar decay: starts at 64 + 40 = 104, decays to 64
332
+ expect(minCC).toBeGreaterThanOrEqual(64);
333
+ expect(minCC).toBeLessThan(70);
334
+ expect(maxCC).toBeGreaterThanOrEqual(103);
335
+ });
336
+ });
337
+
338
+ describe('Negative Depth', () => {
339
+ const BPM = 120;
340
+ const DEPTH = -40;
341
+
342
+ test('EXP with negative depth should produce CC below center', () => {
343
+ const lfo = new LFO({
344
+ waveform: 'EXP',
345
+ speed: 16,
346
+ multiplier: 4,
347
+ depth: DEPTH,
348
+ startPhase: 0,
349
+ mode: 'TRG',
350
+ }, BPM);
351
+
352
+ lfo.trigger();
353
+ const state = lfo.update(1);
354
+ const cc = outputToCC(state.output);
355
+
356
+ // With negative depth, peak should be BELOW center: 64 - 40 = 24
357
+ expect(cc).toBeLessThan(30);
358
+ expect(cc).toBeGreaterThanOrEqual(24);
359
+ });
360
+
361
+ test('RMP with negative depth should rise toward center', () => {
362
+ const lfo = new LFO({
363
+ waveform: 'RMP',
364
+ speed: 16,
365
+ multiplier: 4,
366
+ depth: DEPTH,
367
+ startPhase: 0,
368
+ mode: 'TRG',
369
+ }, BPM);
370
+
371
+ lfo.trigger();
372
+ const state = lfo.update(1);
373
+ const cc = outputToCC(state.output);
374
+
375
+ // At phase 0, RMP should be at center
376
+ expect(cc).toBe(64);
377
+
378
+ // At end of cycle, should be at negative peak
379
+ const cycleMs = 4000;
380
+ const stateEnd = lfo.update(1 + cycleMs * 0.99);
381
+ const ccEnd = outputToCC(stateEnd.output);
382
+
383
+ // 64 - 40 = 24
384
+ expect(ccEnd).toBeLessThan(30);
385
+ expect(ccEnd).toBeGreaterThanOrEqual(24);
386
+ });
387
+ });
@@ -143,12 +143,13 @@ describe('Sawtooth Waveform', () => {
143
143
  });
144
144
 
145
145
  describe('Exponential Waveform', () => {
146
- test('starts at 0 at phase 0', () => {
147
- expect(generateExponential(0)).toBeCloseTo(0, 5);
146
+ test('starts at 1 at phase 0 (peak, matching Digitakt II)', () => {
147
+ expect(generateExponential(0)).toBeCloseTo(1, 5);
148
148
  });
149
149
 
150
- test('ends at 1 at phase 1', () => {
151
- expect(generateExponential(1)).toBeCloseTo(1, 5);
150
+ test('decays toward 0 at phase 1', () => {
151
+ // Normalized exponential decay reaches exactly 0 at phase 1
152
+ expect(generateExponential(1)).toBeCloseTo(0, 5);
152
153
  });
153
154
 
154
155
  test('is unipolar (0 to +1)', () => {
@@ -157,9 +158,10 @@ describe('Exponential Waveform', () => {
157
158
  expect(Math.max(...samples)).toBeLessThanOrEqual(1);
158
159
  });
159
160
 
160
- test('has accelerating (exponential) curve shape', () => {
161
- // At phase 0.5, should be less than 0.5 (curve bends up)
161
+ test('has decaying (exponential decay) curve shape', () => {
162
+ // At phase 0.5, should be greater than 0.5 (decay curve starts high)
162
163
  const midValue = generateExponential(0.5);
164
+ expect(midValue).toBeGreaterThan(0.1);
163
165
  expect(midValue).toBeLessThan(0.5);
164
166
  });
165
167
  });
@@ -237,7 +239,7 @@ describe('generateWaveform', () => {
237
239
  expect(generateWaveform('SIN', 0.25, state).value).toBeCloseTo(1, 5);
238
240
  expect(generateWaveform('SQR', 0.25, state).value).toBe(1);
239
241
  expect(generateWaveform('SAW', 0.5, state).value).toBe(0);
240
- expect(generateWaveform('EXP', 1, state).value).toBeCloseTo(1, 5);
242
+ expect(generateWaveform('EXP', 0, state).value).toBeCloseTo(1, 5); // EXP starts at peak
241
243
  expect(generateWaveform('RMP', 0, state).value).toBe(0);
242
244
  });
243
245
  });