elektron-lfo 1.0.0

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,306 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import {
3
+ calculateFadeMultiplier,
4
+ calculateFadeCycles,
5
+ updateFade,
6
+ resetFade,
7
+ shouldResetFadeOnTrigger,
8
+ applyFade,
9
+ } from '../src/engine/fade';
10
+ import { LFO } from '../src/engine/lfo';
11
+ import type { LFOConfig, LFOState } from '../src/engine/types';
12
+ import { DEFAULT_CONFIG } from '../src/engine/types';
13
+
14
+ function createConfig(overrides: Partial<LFOConfig>): LFOConfig {
15
+ return { ...DEFAULT_CONFIG, ...overrides };
16
+ }
17
+
18
+ function createState(overrides: Partial<LFOState> = {}): LFOState {
19
+ return {
20
+ phase: 0.5,
21
+ output: 0.5,
22
+ rawOutput: 0.5,
23
+ isRunning: true,
24
+ fadeMultiplier: 1,
25
+ fadeProgress: 0,
26
+ randomValue: 0.5,
27
+ previousPhase: 0.4,
28
+ heldOutput: 0,
29
+ startPhaseNormalized: 0,
30
+ cycleCount: 0,
31
+ triggerCount: 0,
32
+ hasTriggered: false,
33
+ randomStep: 8,
34
+ ...overrides,
35
+ };
36
+ }
37
+
38
+ describe('Depth', () => {
39
+ test('depth 0 produces 0 output', () => {
40
+ const lfo = new LFO({ waveform: 'SIN', depth: 0 }, 120);
41
+
42
+ lfo.update(0);
43
+ // Run for a bit to get various waveform positions
44
+ for (let t = 0; t < 500; t += 50) {
45
+ const state = lfo.update(t);
46
+ expect(state.output).toBe(0);
47
+ }
48
+ });
49
+
50
+ test('depth 63 produces full output', () => {
51
+ const lfo = new LFO({ waveform: 'SIN', depth: 63, mode: 'FRE' }, 120);
52
+
53
+ lfo.update(0);
54
+ let maxOutput = 0;
55
+
56
+ // Run through a full cycle
57
+ for (let t = 0; t < 2500; t += 10) {
58
+ const state = lfo.update(t);
59
+ maxOutput = Math.max(maxOutput, Math.abs(state.output));
60
+ }
61
+
62
+ // Should reach near 1.0 at peak
63
+ expect(maxOutput).toBeGreaterThan(0.9);
64
+ });
65
+
66
+ test('depth 32 produces ~half output', () => {
67
+ const lfo = new LFO({ waveform: 'SIN', depth: 32, mode: 'FRE' }, 120);
68
+
69
+ lfo.update(0);
70
+ let maxOutput = 0;
71
+
72
+ for (let t = 0; t < 2500; t += 10) {
73
+ const state = lfo.update(t);
74
+ maxOutput = Math.max(maxOutput, Math.abs(state.output));
75
+ }
76
+
77
+ // Should be approximately 32/63 ≈ 0.5
78
+ expect(maxOutput).toBeCloseTo(32 / 63, 1);
79
+ });
80
+
81
+ test('negative depth inverts output', () => {
82
+ const lfoPos = new LFO({ waveform: 'SIN', depth: 63, startPhase: 32 }, 120);
83
+ const lfoNeg = new LFO({ waveform: 'SIN', depth: -63, startPhase: 32 }, 120);
84
+
85
+ lfoPos.update(0);
86
+ lfoNeg.update(0);
87
+
88
+ const statePos = lfoPos.update(10);
89
+ const stateNeg = lfoNeg.update(10);
90
+
91
+ // Should have opposite signs
92
+ expect(statePos.output * stateNeg.output).toBeLessThan(0);
93
+ });
94
+
95
+ test('negative depth with unipolar waveform (EXP)', () => {
96
+ const lfo = new LFO({ waveform: 'EXP', depth: -63, mode: 'FRE' }, 120);
97
+
98
+ lfo.update(0);
99
+ let minOutput = Infinity;
100
+
101
+ for (let t = 0; t < 2500; t += 10) {
102
+ const state = lfo.update(t);
103
+ minOutput = Math.min(minOutput, state.output);
104
+ }
105
+
106
+ // EXP goes 0 to 1, with negative depth should go 0 to -1
107
+ expect(minOutput).toBeLessThan(-0.8);
108
+ });
109
+ });
110
+
111
+ describe('calculateFadeMultiplier', () => {
112
+ test('returns 1 for fade = 0', () => {
113
+ expect(calculateFadeMultiplier(0, 0)).toBe(1);
114
+ expect(calculateFadeMultiplier(0, 0.5)).toBe(1);
115
+ expect(calculateFadeMultiplier(0, 1)).toBe(1);
116
+ });
117
+
118
+ test('fade in (negative fade) increases from 0 to 1', () => {
119
+ expect(calculateFadeMultiplier(-32, 0)).toBe(0);
120
+ expect(calculateFadeMultiplier(-32, 0.5)).toBe(0.5);
121
+ expect(calculateFadeMultiplier(-32, 1)).toBe(1);
122
+ });
123
+
124
+ test('fade out (positive fade) decreases from 1 to 0', () => {
125
+ expect(calculateFadeMultiplier(32, 0)).toBe(1);
126
+ expect(calculateFadeMultiplier(32, 0.5)).toBe(0.5);
127
+ expect(calculateFadeMultiplier(32, 1)).toBe(0);
128
+ });
129
+ });
130
+
131
+ describe('calculateFadeCycles', () => {
132
+ test('returns 0 for fade = 0', () => {
133
+ expect(calculateFadeCycles(0)).toBe(0);
134
+ });
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);
141
+ });
142
+ });
143
+
144
+ describe('updateFade', () => {
145
+ test('returns full fade for fade = 0', () => {
146
+ const config = createConfig({ fade: 0 });
147
+ const state = createState();
148
+
149
+ const result = updateFade(config, state, 2000, 100);
150
+ expect(result.fadeMultiplier).toBe(1);
151
+ expect(result.fadeProgress).toBe(1);
152
+ });
153
+
154
+ test('does not update fade in FRE mode', () => {
155
+ const config = createConfig({ fade: -32, mode: 'FRE' });
156
+ const state = createState({ fadeProgress: 0.5 });
157
+
158
+ const result = updateFade(config, state, 2000, 100);
159
+ expect(result.fadeMultiplier).toBe(1);
160
+ expect(result.fadeProgress).toBe(1);
161
+ });
162
+
163
+ test('progresses fade over time', () => {
164
+ const config = createConfig({ fade: -64, mode: 'TRG' }); // 1 cycle fade
165
+ const state = createState({ fadeProgress: 0 });
166
+
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);
171
+ });
172
+ });
173
+
174
+ describe('resetFade', () => {
175
+ test('resets fade in to 0', () => {
176
+ const config = createConfig({ fade: -32 });
177
+ const result = resetFade(config);
178
+ expect(result.fadeProgress).toBe(0);
179
+ expect(result.fadeMultiplier).toBe(0);
180
+ });
181
+
182
+ test('resets fade out to 1', () => {
183
+ const config = createConfig({ fade: 32 });
184
+ const result = resetFade(config);
185
+ expect(result.fadeProgress).toBe(0);
186
+ expect(result.fadeMultiplier).toBe(1);
187
+ });
188
+
189
+ test('returns 1 for no fade', () => {
190
+ const config = createConfig({ fade: 0 });
191
+ const result = resetFade(config);
192
+ expect(result.fadeProgress).toBe(1);
193
+ expect(result.fadeMultiplier).toBe(1);
194
+ });
195
+ });
196
+
197
+ describe('shouldResetFadeOnTrigger', () => {
198
+ test('returns true for all modes except FRE', () => {
199
+ expect(shouldResetFadeOnTrigger('TRG')).toBe(true);
200
+ expect(shouldResetFadeOnTrigger('ONE')).toBe(true);
201
+ expect(shouldResetFadeOnTrigger('HLF')).toBe(true);
202
+ expect(shouldResetFadeOnTrigger('HLD')).toBe(true);
203
+ });
204
+
205
+ test('returns false for FRE', () => {
206
+ expect(shouldResetFadeOnTrigger('FRE')).toBe(false);
207
+ });
208
+ });
209
+
210
+ describe('applyFade', () => {
211
+ test('scales output by fade multiplier', () => {
212
+ expect(applyFade(1, 1)).toBe(1);
213
+ expect(applyFade(1, 0.5)).toBe(0.5);
214
+ expect(applyFade(1, 0)).toBe(0);
215
+ expect(applyFade(-1, 0.5)).toBe(-0.5);
216
+ });
217
+ });
218
+
219
+ describe('Fade with LFO integration', () => {
220
+ test('fade in starts at 0 output and increases', () => {
221
+ const lfo = new LFO({
222
+ waveform: 'SIN',
223
+ depth: 63,
224
+ fade: -64, // Fade in over 1 cycle
225
+ mode: 'TRG',
226
+ }, 120);
227
+
228
+ lfo.trigger();
229
+ lfo.update(0);
230
+
231
+ const state1 = lfo.update(100);
232
+ expect(Math.abs(state1.output)).toBeLessThan(0.1); // Start near 0
233
+
234
+ // After more time, output should increase
235
+ let maxOutput = 0;
236
+ for (let t = 100; t < 2500; t += 100) {
237
+ const state = lfo.update(t);
238
+ maxOutput = Math.max(maxOutput, Math.abs(state.output));
239
+ }
240
+ expect(maxOutput).toBeGreaterThan(0.5);
241
+ });
242
+
243
+ test('fade out starts at full output and decreases', () => {
244
+ const lfo = new LFO({
245
+ waveform: 'SIN',
246
+ depth: 63,
247
+ fade: 64, // Fade out over 1 cycle
248
+ mode: 'TRG',
249
+ startPhase: 32, // Start at peak
250
+ }, 120);
251
+
252
+ lfo.trigger();
253
+ lfo.update(0);
254
+
255
+ const state1 = lfo.update(10);
256
+ // Should start near full output (at SIN peak)
257
+ expect(Math.abs(state1.output)).toBeGreaterThan(0.8);
258
+
259
+ // After fade completes, output should be near 0
260
+ let lastOutput = 0;
261
+ for (let t = 10; t < 3000; t += 100) {
262
+ const state = lfo.update(t);
263
+ lastOutput = Math.abs(state.output);
264
+ }
265
+ expect(lastOutput).toBeLessThan(0.2);
266
+ });
267
+
268
+ test('fade resets on trigger for TRG mode', () => {
269
+ const lfo = new LFO({
270
+ waveform: 'SIN',
271
+ fade: -64,
272
+ mode: 'TRG',
273
+ }, 120);
274
+
275
+ lfo.trigger();
276
+ lfo.update(0);
277
+
278
+ // Let fade progress
279
+ for (let t = 0; t < 1500; t += 100) {
280
+ lfo.update(t);
281
+ }
282
+
283
+ // Trigger again
284
+ lfo.trigger();
285
+ const stateAfterTrigger = lfo.update(1600);
286
+
287
+ // Fade should have reset (multiplier back to 0 for fade in)
288
+ expect(stateAfterTrigger.fadeMultiplier).toBeLessThan(0.2);
289
+ });
290
+
291
+ test('fade does not work in FRE mode', () => {
292
+ const lfo = new LFO({
293
+ waveform: 'SIN',
294
+ depth: 63,
295
+ fade: -64, // Would be fade in
296
+ mode: 'FRE',
297
+ startPhase: 32,
298
+ }, 120);
299
+
300
+ lfo.update(0);
301
+ const state = lfo.update(10);
302
+
303
+ // In FRE mode, fade is always 1 (full output immediately)
304
+ expect(state.fadeMultiplier).toBe(1);
305
+ });
306
+ });
@@ -0,0 +1,219 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { LFO } from '../src/engine/lfo';
3
+
4
+ describe('Phase wrapping', () => {
5
+ test('phase wraps from 1 back to 0 (forward)', () => {
6
+ const lfo = new LFO({ speed: 32, multiplier: 64 }, 120); // Fast cycle
7
+
8
+ // Simulate time passing
9
+ let lastTime = 0;
10
+ let wrappedFromOne = false;
11
+ let previousPhase = 0;
12
+
13
+ for (let i = 0; i < 200; i++) {
14
+ const state = lfo.update(lastTime);
15
+ if (previousPhase > 0.9 && state.phase < 0.1) {
16
+ wrappedFromOne = true;
17
+ break;
18
+ }
19
+ previousPhase = state.phase;
20
+ lastTime += 10; // 10ms per step
21
+ }
22
+
23
+ expect(wrappedFromOne).toBe(true);
24
+ });
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
28
+
29
+ let lastTime = 0;
30
+ let wrappedToOne = false;
31
+ let previousPhase = 0;
32
+
33
+ // First update to initialize
34
+ lfo.update(lastTime);
35
+ lastTime += 10;
36
+
37
+ for (let i = 0; i < 200; i++) {
38
+ const state = lfo.update(lastTime);
39
+ if (previousPhase < 0.1 && state.phase > 0.9) {
40
+ wrappedToOne = true;
41
+ break;
42
+ }
43
+ previousPhase = state.phase;
44
+ lastTime += 10;
45
+ }
46
+
47
+ expect(wrappedToOne).toBe(true);
48
+ });
49
+
50
+ test('phase stays within 0-1 range', () => {
51
+ const lfo = new LFO({ speed: 32, multiplier: 64 }, 120);
52
+
53
+ let lastTime = 0;
54
+ for (let i = 0; i < 500; i++) {
55
+ const state = lfo.update(lastTime);
56
+ expect(state.phase).toBeGreaterThanOrEqual(0);
57
+ expect(state.phase).toBeLessThan(1);
58
+ lastTime += 5;
59
+ }
60
+ });
61
+ });
62
+
63
+ 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);
74
+ });
75
+
76
+ test('positive speed runs phase forwards', () => {
77
+ const lfo = new LFO({ speed: 16, multiplier: 8 }, 120);
78
+
79
+ lfo.update(0);
80
+ const state1 = lfo.update(100);
81
+ const state2 = lfo.update(200);
82
+
83
+ // Phase should increase
84
+ expect(state2.phase).toBeGreaterThan(state1.phase);
85
+ });
86
+
87
+ test('negative speed has same cycle time as positive speed', () => {
88
+ const lfoPos = new LFO({ speed: 16, multiplier: 8 }, 120);
89
+ const lfoNeg = new LFO({ speed: -16, multiplier: 8 }, 120);
90
+
91
+ expect(lfoPos.getTimingInfo().cycleTimeMs).toBe(lfoNeg.getTimingInfo().cycleTimeMs);
92
+ });
93
+ });
94
+
95
+ describe('Start phase', () => {
96
+ test('startPhase 0 starts at phase 0', () => {
97
+ const lfo = new LFO({ startPhase: 0 }, 120);
98
+ expect(lfo.getState().phase).toBe(0);
99
+ });
100
+
101
+ test('startPhase 64 starts at phase 0.5', () => {
102
+ const lfo = new LFO({ startPhase: 64 }, 120);
103
+ expect(lfo.getState().phase).toBeCloseTo(0.5, 5);
104
+ });
105
+
106
+ test('startPhase 127 starts at phase ~0.992', () => {
107
+ const lfo = new LFO({ startPhase: 127 }, 120);
108
+ expect(lfo.getState().phase).toBeCloseTo(127 / 128, 3);
109
+ });
110
+
111
+ test('startPhase 32 (90 degrees) affects waveform starting position', () => {
112
+ // SIN at phase 0.25 (90 degrees) should be at peak (+1)
113
+ const lfo = new LFO({ waveform: 'SIN', startPhase: 32 }, 120);
114
+ lfo.update(0);
115
+ const state = lfo.update(1); // Minimal time for initial output
116
+
117
+ // Phase should be at ~0.25, SIN should be near peak
118
+ expect(state.phase).toBeCloseTo(0.25, 2);
119
+ });
120
+ });
121
+
122
+ describe('ONE mode with non-zero startPhase', () => {
123
+ test('ONE mode stops after returning to start phase', () => {
124
+ const lfo = new LFO({
125
+ mode: 'ONE',
126
+ speed: 32,
127
+ multiplier: 64, // Fast 125ms cycle
128
+ startPhase: 32, // Start at 0.25
129
+ }, 120);
130
+
131
+ lfo.trigger();
132
+
133
+ let lastTime = 0;
134
+ let stopped = false;
135
+
136
+ // Run for long enough to complete at least one cycle
137
+ for (let i = 0; i < 100; i++) {
138
+ lfo.update(lastTime);
139
+ lastTime += 10;
140
+ }
141
+
142
+ stopped = !lfo.isRunning();
143
+ expect(stopped).toBe(true);
144
+ });
145
+ });
146
+
147
+ describe('HLF mode with non-zero startPhase', () => {
148
+ test('HLF mode stops at phase 0.5 beyond start', () => {
149
+ const lfo = new LFO({
150
+ mode: 'HLF',
151
+ speed: 32,
152
+ multiplier: 64, // Fast cycle
153
+ startPhase: 32, // Start at 0.25
154
+ }, 120);
155
+
156
+ lfo.trigger();
157
+
158
+ let lastTime = 0;
159
+ let stoppedAtPhase: number | null = null;
160
+
161
+ for (let i = 0; i < 100; i++) {
162
+ const state = lfo.update(lastTime);
163
+ if (!state.isRunning && stoppedAtPhase === null) {
164
+ stoppedAtPhase = state.phase;
165
+ break;
166
+ }
167
+ lastTime += 5;
168
+ }
169
+
170
+ // Should stop at 0.25 + 0.5 = 0.75
171
+ expect(stoppedAtPhase).toBeCloseTo(0.75, 1);
172
+ });
173
+
174
+ test('HLF mode with startPhase 96 stops correctly (wraps through 0)', () => {
175
+ const lfo = new LFO({
176
+ mode: 'HLF',
177
+ speed: 32,
178
+ multiplier: 64,
179
+ startPhase: 96, // Start at 0.75
180
+ }, 120);
181
+
182
+ lfo.trigger();
183
+
184
+ let lastTime = 0;
185
+ let stoppedAtPhase: number | null = null;
186
+
187
+ for (let i = 0; i < 100; i++) {
188
+ const state = lfo.update(lastTime);
189
+ if (!state.isRunning && stoppedAtPhase === null) {
190
+ stoppedAtPhase = state.phase;
191
+ break;
192
+ }
193
+ lastTime += 5;
194
+ }
195
+
196
+ // Should stop at 0.75 + 0.5 = 1.25 -> wraps to 0.25
197
+ expect(stoppedAtPhase).toBeCloseTo(0.25, 1);
198
+ });
199
+ });
200
+
201
+ describe('Cycle counting', () => {
202
+ test('cycle count increments on wrap', () => {
203
+ const lfo = new LFO({ speed: 32, multiplier: 64 }, 120); // 125ms cycle
204
+
205
+ lfo.update(0);
206
+
207
+ let lastTime = 0;
208
+ let maxCycles = 0;
209
+
210
+ // Run for 500ms (should complete ~4 cycles)
211
+ for (let i = 0; i < 100; i++) {
212
+ const state = lfo.update(lastTime);
213
+ maxCycles = Math.max(maxCycles, state.cycleCount);
214
+ lastTime += 5;
215
+ }
216
+
217
+ expect(maxCycles).toBeGreaterThanOrEqual(3);
218
+ });
219
+ });