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,344 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { LFO } from '../src/engine/lfo';
3
+
4
+ /**
5
+ * Integration tests for the 5 presets from DIGITAKT_II_LFO_PRESETS.md
6
+ */
7
+
8
+ describe('Preset 1: Fade-In One-Shot', () => {
9
+ // RMP, SPD=8, MULT=16, ONE mode, FADE=-32
10
+ // 1 bar cycle (2000ms at 120 BPM), stops after one cycle
11
+
12
+ test('has correct timing (2000ms at 120 BPM)', () => {
13
+ const lfo = new LFO({
14
+ waveform: 'RMP',
15
+ speed: 8,
16
+ multiplier: 16, // 8 * 16 = 128 = 1 bar
17
+ mode: 'ONE',
18
+ fade: -32,
19
+ depth: 63,
20
+ }, 120);
21
+
22
+ const timing = lfo.getTimingInfo();
23
+ expect(timing.cycleTimeMs).toBeCloseTo(2000, 0);
24
+ expect(timing.noteValue).toBe('1 bar');
25
+ expect(timing.product).toBe(128);
26
+ });
27
+
28
+ test('stops after one cycle', () => {
29
+ const lfo = new LFO({
30
+ waveform: 'RMP',
31
+ speed: 8,
32
+ multiplier: 16,
33
+ mode: 'ONE',
34
+ fade: -32,
35
+ depth: 63,
36
+ }, 120);
37
+
38
+ lfo.trigger();
39
+ lfo.update(0);
40
+
41
+ // Run for 2500ms (more than one 2000ms cycle)
42
+ let lastTime = 0;
43
+ for (let t = 0; t < 2500; t += 50) {
44
+ lfo.update(t);
45
+ lastTime = t;
46
+ }
47
+
48
+ expect(lfo.isRunning()).toBe(false);
49
+ });
50
+
51
+ test('fade multiplier increases over time (fade in)', () => {
52
+ const lfo = new LFO({
53
+ waveform: 'RMP',
54
+ speed: 8,
55
+ multiplier: 16,
56
+ mode: 'ONE',
57
+ fade: -32, // Negative = fade IN
58
+ depth: 63,
59
+ }, 120);
60
+
61
+ lfo.trigger();
62
+ lfo.update(0);
63
+
64
+ const state1 = lfo.update(100);
65
+ expect(state1.fadeMultiplier).toBeLessThan(0.5);
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) {
71
+ const state = lfo.update(t);
72
+ midFadeMultiplier = state.fadeMultiplier;
73
+ }
74
+ expect(midFadeMultiplier).toBeGreaterThan(0.3);
75
+ expect(midFadeMultiplier).toBeLessThan(0.8);
76
+ });
77
+ });
78
+
79
+ describe('Preset 2: Ambient Drift', () => {
80
+ // SIN, SPD=1, MULT=1, FRE mode
81
+ // 128 bars cycle (256000ms at 120 BPM)
82
+
83
+ test('has correct timing (256000ms at 120 BPM)', () => {
84
+ const lfo = new LFO({
85
+ waveform: 'SIN',
86
+ speed: 1,
87
+ multiplier: 1, // 1 * 1 = 1 = 128 bars
88
+ mode: 'FRE',
89
+ depth: 24,
90
+ fade: 0,
91
+ }, 120);
92
+
93
+ const timing = lfo.getTimingInfo();
94
+ expect(timing.cycleTimeMs).toBeCloseTo(256000, 0);
95
+ expect(timing.noteValue).toBe('128 bars');
96
+ expect(timing.product).toBe(1);
97
+ });
98
+
99
+ test('continues running despite triggers', () => {
100
+ const lfo = new LFO({
101
+ waveform: 'SIN',
102
+ speed: 1,
103
+ multiplier: 1,
104
+ mode: 'FRE',
105
+ depth: 24,
106
+ }, 120);
107
+
108
+ lfo.update(0);
109
+ const state1 = lfo.update(1000);
110
+ const phase1 = state1.phase;
111
+
112
+ // Trigger should not affect phase in FRE mode
113
+ lfo.trigger();
114
+ const state2 = lfo.update(1100);
115
+
116
+ // Phase should have continued from where it was
117
+ expect(state2.phase).toBeGreaterThan(phase1);
118
+ expect(lfo.isRunning()).toBe(true);
119
+ });
120
+
121
+ test('output is within moderate depth range', () => {
122
+ const lfo = new LFO({
123
+ waveform: 'SIN',
124
+ speed: 1,
125
+ multiplier: 1,
126
+ mode: 'FRE',
127
+ depth: 24, // Moderate depth
128
+ }, 120);
129
+
130
+ lfo.update(0);
131
+ let maxOutput = 0;
132
+
133
+ // Sample some outputs
134
+ for (let t = 0; t < 10000; t += 500) {
135
+ const state = lfo.update(t);
136
+ maxOutput = Math.max(maxOutput, Math.abs(state.output));
137
+ }
138
+
139
+ // Depth 24/63 ≈ 0.38
140
+ expect(maxOutput).toBeLessThanOrEqual(24 / 63 + 0.05);
141
+ });
142
+ });
143
+
144
+ describe('Preset 3: Hi-Hat Humanizer', () => {
145
+ // RND, SPD=32, MULT=64, FRE mode
146
+ // 1/16 note cycle (125ms at 120 BPM)
147
+
148
+ test('has correct timing (125ms at 120 BPM)', () => {
149
+ const lfo = new LFO({
150
+ waveform: 'RND',
151
+ speed: 32,
152
+ multiplier: 64, // 32 * 64 = 2048 = 1/16
153
+ mode: 'FRE',
154
+ depth: 12,
155
+ }, 120);
156
+
157
+ const timing = lfo.getTimingInfo();
158
+ expect(timing.cycleTimeMs).toBeCloseTo(125, 0);
159
+ expect(timing.noteValue).toBe('1/16');
160
+ expect(timing.product).toBe(2048);
161
+ });
162
+
163
+ test('random values change over time', () => {
164
+ const lfo = new LFO({
165
+ waveform: 'RND',
166
+ speed: 32,
167
+ multiplier: 64,
168
+ mode: 'FRE',
169
+ depth: 12,
170
+ }, 120);
171
+
172
+ lfo.update(0);
173
+ const values = new Set<number>();
174
+
175
+ // Collect random values over 500ms (4 cycles)
176
+ for (let t = 0; t < 500; t += 5) {
177
+ const state = lfo.update(t);
178
+ values.add(Math.round(state.rawOutput * 1000) / 1000);
179
+ }
180
+
181
+ // Should have multiple different values
182
+ expect(values.size).toBeGreaterThan(5);
183
+ });
184
+
185
+ test('output within depth range', () => {
186
+ const lfo = new LFO({
187
+ waveform: 'RND',
188
+ speed: 32,
189
+ multiplier: 64,
190
+ mode: 'FRE',
191
+ depth: 12, // Subtle depth
192
+ }, 120);
193
+
194
+ lfo.update(0);
195
+ let maxOutput = 0;
196
+
197
+ for (let t = 0; t < 500; t += 5) {
198
+ const state = lfo.update(t);
199
+ maxOutput = Math.max(maxOutput, Math.abs(state.output));
200
+ }
201
+
202
+ // Depth 12/63 ≈ 0.19
203
+ expect(maxOutput).toBeLessThanOrEqual(12 / 63 + 0.02);
204
+ });
205
+ });
206
+
207
+ describe('Preset 4: Pumping Sidechain', () => {
208
+ // EXP, SPD=32, MULT=4, TRG mode, DEP=-63
209
+ // 1 bar cycle (2000ms at 120 BPM)
210
+
211
+ test('has correct timing (2000ms at 120 BPM)', () => {
212
+ const lfo = new LFO({
213
+ waveform: 'EXP',
214
+ speed: 32,
215
+ multiplier: 4, // 32 * 4 = 128 = 1 bar
216
+ mode: 'TRG',
217
+ depth: -63, // Inverted
218
+ }, 120);
219
+
220
+ const timing = lfo.getTimingInfo();
221
+ expect(timing.cycleTimeMs).toBeCloseTo(2000, 0);
222
+ expect(timing.product).toBe(128);
223
+ });
224
+
225
+ test('restarts on trigger', () => {
226
+ const lfo = new LFO({
227
+ waveform: 'EXP',
228
+ speed: 32,
229
+ multiplier: 4,
230
+ mode: 'TRG',
231
+ depth: -63,
232
+ }, 120);
233
+
234
+ lfo.trigger();
235
+ lfo.update(0);
236
+
237
+ // Let it run partway through
238
+ for (let t = 0; t < 1000; t += 50) {
239
+ lfo.update(t);
240
+ }
241
+
242
+ const stateBefore = lfo.update(1000);
243
+ const phaseBefore = stateBefore.phase;
244
+
245
+ // Trigger again
246
+ lfo.trigger();
247
+ const stateAfter = lfo.update(1050);
248
+
249
+ // Phase should have reset to near 0
250
+ expect(stateAfter.phase).toBeLessThan(phaseBefore);
251
+ expect(stateAfter.phase).toBeLessThan(0.1);
252
+ });
253
+
254
+ test('produces inverted (negative) output', () => {
255
+ const lfo = new LFO({
256
+ waveform: 'EXP',
257
+ speed: 32,
258
+ multiplier: 4,
259
+ mode: 'TRG',
260
+ depth: -63, // Negative depth inverts
261
+ startPhase: 0,
262
+ }, 120);
263
+
264
+ lfo.trigger();
265
+ lfo.update(0);
266
+
267
+ // EXP goes 0 to 1, with -63 depth it should go 0 to -1
268
+ let minOutput = Infinity;
269
+ for (let t = 0; t < 2000; t += 50) {
270
+ const state = lfo.update(t);
271
+ minOutput = Math.min(minOutput, state.output);
272
+ }
273
+
274
+ expect(minOutput).toBeLessThan(-0.8);
275
+ });
276
+ });
277
+
278
+ describe('Preset 5: Wobble Bass', () => {
279
+ // SIN, SPD=16, MULT=8, TRG mode, SPH=32, DEP=+48
280
+ // 1 bar cycle (2000ms at 120 BPM), starts at peak
281
+
282
+ test('has correct timing (2000ms at 120 BPM)', () => {
283
+ const lfo = new LFO({
284
+ waveform: 'SIN',
285
+ speed: 16,
286
+ multiplier: 8, // 16 * 8 = 128 = 1 bar
287
+ mode: 'TRG',
288
+ startPhase: 32, // 90 degrees = peak
289
+ depth: 48,
290
+ }, 120);
291
+
292
+ const timing = lfo.getTimingInfo();
293
+ expect(timing.cycleTimeMs).toBeCloseTo(2000, 0);
294
+ expect(timing.noteValue).toBe('1 bar');
295
+ });
296
+
297
+ test('starts at peak (phase 90 degrees)', () => {
298
+ const lfo = new LFO({
299
+ waveform: 'SIN',
300
+ speed: 16,
301
+ multiplier: 8,
302
+ mode: 'TRG',
303
+ startPhase: 32, // 32/128 = 0.25 = 90 degrees
304
+ depth: 48,
305
+ }, 120);
306
+
307
+ lfo.trigger();
308
+ lfo.update(0);
309
+
310
+ const state = lfo.update(1);
311
+ // At phase 0.25, SIN should be at +1
312
+ expect(state.phase).toBeCloseTo(0.25, 2);
313
+ // Raw output should be near peak
314
+ expect(state.rawOutput).toBeGreaterThan(0.95);
315
+ });
316
+
317
+ test('strong depth produces dramatic sweep', () => {
318
+ const lfo = new LFO({
319
+ waveform: 'SIN',
320
+ speed: 16,
321
+ multiplier: 8,
322
+ mode: 'TRG',
323
+ startPhase: 32,
324
+ depth: 48, // Strong depth
325
+ }, 120);
326
+
327
+ lfo.trigger();
328
+ lfo.update(0);
329
+
330
+ let maxOutput = 0;
331
+ let minOutput = Infinity;
332
+
333
+ for (let t = 0; t < 2500; t += 50) {
334
+ const state = lfo.update(t);
335
+ maxOutput = Math.max(maxOutput, state.output);
336
+ minOutput = Math.min(minOutput, state.output);
337
+ }
338
+
339
+ // Depth 48/63 ≈ 0.76
340
+ const expectedRange = 48 / 63;
341
+ expect(maxOutput).toBeGreaterThan(expectedRange * 0.9);
342
+ expect(minOutput).toBeLessThan(-expectedRange * 0.9);
343
+ });
344
+ });
@@ -0,0 +1,232 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import {
3
+ calculateProduct,
4
+ calculateCycleTimeMs,
5
+ calculateFrequencyHz,
6
+ calculatePhaseIncrement,
7
+ calculateCyclesPerBar,
8
+ calculateNoteValue,
9
+ calculateTimingInfo,
10
+ formatCycleTime,
11
+ formatFrequency,
12
+ } from '../src/engine/timing';
13
+ import type { LFOConfig } from '../src/engine/types';
14
+ import { DEFAULT_CONFIG } from '../src/engine/types';
15
+
16
+ // Helper to create config with defaults
17
+ function createConfig(overrides: Partial<LFOConfig>): LFOConfig {
18
+ return { ...DEFAULT_CONFIG, ...overrides };
19
+ }
20
+
21
+ describe('calculateProduct', () => {
22
+ test('calculates |SPD| × MULT', () => {
23
+ expect(calculateProduct(createConfig({ speed: 16, multiplier: 8 }))).toBe(128);
24
+ expect(calculateProduct(createConfig({ speed: 32, multiplier: 64 }))).toBe(2048);
25
+ expect(calculateProduct(createConfig({ speed: 1, multiplier: 1 }))).toBe(1);
26
+ });
27
+
28
+ test('uses absolute value of speed', () => {
29
+ expect(calculateProduct(createConfig({ speed: -16, multiplier: 8 }))).toBe(128);
30
+ expect(calculateProduct(createConfig({ speed: -32, multiplier: 64 }))).toBe(2048);
31
+ });
32
+
33
+ test('handles speed of 0', () => {
34
+ expect(calculateProduct(createConfig({ speed: 0, multiplier: 8 }))).toBe(0);
35
+ });
36
+ });
37
+
38
+ describe('calculateCycleTimeMs', () => {
39
+ test('calculates correct cycle time for 1 bar at 120 BPM', () => {
40
+ // Product 128 = 1 bar = 2000ms at 120 BPM
41
+ const config = createConfig({ speed: 16, multiplier: 8 }); // 128
42
+ expect(calculateCycleTimeMs(config, 120)).toBeCloseTo(2000, 1);
43
+ });
44
+
45
+ test('calculates correct cycle time for 1/16 note at 120 BPM', () => {
46
+ // Product 2048 = 1/16 note = 125ms at 120 BPM
47
+ const config = createConfig({ speed: 32, multiplier: 64 }); // 2048
48
+ expect(calculateCycleTimeMs(config, 120)).toBeCloseTo(125, 1);
49
+ });
50
+
51
+ test('calculates correct cycle time for 128 bars at 120 BPM', () => {
52
+ // Product 1 = 128 bars = 256000ms at 120 BPM
53
+ const config = createConfig({ speed: 1, multiplier: 1 }); // 1
54
+ expect(calculateCycleTimeMs(config, 120)).toBeCloseTo(256000, 1);
55
+ });
56
+
57
+ test('uses fixed 120 BPM when useFixedBPM is true', () => {
58
+ const config = createConfig({ speed: 16, multiplier: 8, useFixedBPM: true });
59
+ // Should be 2000ms regardless of passed BPM
60
+ expect(calculateCycleTimeMs(config, 90)).toBeCloseTo(2000, 1);
61
+ expect(calculateCycleTimeMs(config, 180)).toBeCloseTo(2000, 1);
62
+ });
63
+
64
+ test('returns Infinity for speed 0', () => {
65
+ const config = createConfig({ speed: 0, multiplier: 8 });
66
+ expect(calculateCycleTimeMs(config, 120)).toBe(Infinity);
67
+ });
68
+
69
+ test('scales with BPM', () => {
70
+ const config = createConfig({ speed: 16, multiplier: 8 }); // 128 = 1 bar
71
+ // At 60 BPM, 1 bar = 4000ms
72
+ expect(calculateCycleTimeMs(config, 60)).toBeCloseTo(4000, 1);
73
+ // At 240 BPM, 1 bar = 1000ms
74
+ expect(calculateCycleTimeMs(config, 240)).toBeCloseTo(1000, 1);
75
+ });
76
+ });
77
+
78
+ describe('calculateFrequencyHz', () => {
79
+ test('calculates correct frequency for 1 bar at 120 BPM', () => {
80
+ // 1 bar at 120 BPM = 2000ms cycle = 0.5 Hz
81
+ const config = createConfig({ speed: 16, multiplier: 8 }); // 128
82
+ expect(calculateFrequencyHz(config, 120)).toBeCloseTo(0.5, 5);
83
+ });
84
+
85
+ test('calculates correct frequency for 1/16 note at 120 BPM', () => {
86
+ // 1/16 at 120 BPM = 125ms cycle = 8 Hz
87
+ const config = createConfig({ speed: 32, multiplier: 64 }); // 2048
88
+ expect(calculateFrequencyHz(config, 120)).toBeCloseTo(8, 5);
89
+ });
90
+
91
+ test('returns 0 for speed 0', () => {
92
+ const config = createConfig({ speed: 0, multiplier: 8 });
93
+ expect(calculateFrequencyHz(config, 120)).toBe(0);
94
+ });
95
+ });
96
+
97
+ describe('calculatePhaseIncrement', () => {
98
+ test('calculates positive increment for positive speed', () => {
99
+ const config = createConfig({ speed: 16, multiplier: 8 }); // 2000ms cycle
100
+ const increment = calculatePhaseIncrement(config, 120);
101
+ expect(increment).toBeGreaterThan(0);
102
+ // 1ms should give 1/2000 phase increment
103
+ expect(increment).toBeCloseTo(1 / 2000, 8);
104
+ });
105
+
106
+ test('calculates negative increment for negative speed', () => {
107
+ const config = createConfig({ speed: -16, multiplier: 8 }); // 2000ms cycle, backward
108
+ const increment = calculatePhaseIncrement(config, 120);
109
+ expect(increment).toBeLessThan(0);
110
+ expect(increment).toBeCloseTo(-1 / 2000, 8);
111
+ });
112
+
113
+ test('returns 0 for speed 0', () => {
114
+ const config = createConfig({ speed: 0, multiplier: 8 });
115
+ expect(calculatePhaseIncrement(config, 120)).toBe(0);
116
+ });
117
+ });
118
+
119
+ describe('calculateCyclesPerBar', () => {
120
+ test('returns 1 for product 128', () => {
121
+ const config = createConfig({ speed: 16, multiplier: 8 }); // 128
122
+ expect(calculateCyclesPerBar(config)).toBe(1);
123
+ });
124
+
125
+ test('returns 16 for product 2048', () => {
126
+ const config = createConfig({ speed: 32, multiplier: 64 }); // 2048
127
+ expect(calculateCyclesPerBar(config)).toBe(16);
128
+ });
129
+
130
+ test('returns 1/128 for product 1', () => {
131
+ const config = createConfig({ speed: 1, multiplier: 1 }); // 1
132
+ expect(calculateCyclesPerBar(config)).toBeCloseTo(1 / 128, 8);
133
+ });
134
+ });
135
+
136
+ describe('calculateNoteValue', () => {
137
+ test('returns correct note values', () => {
138
+ expect(calculateNoteValue(2048)).toBe('1/16');
139
+ expect(calculateNoteValue(1024)).toBe('1/8');
140
+ expect(calculateNoteValue(512)).toBe('1/4');
141
+ expect(calculateNoteValue(256)).toBe('1/2');
142
+ expect(calculateNoteValue(128)).toBe('1 bar');
143
+ });
144
+
145
+ test('returns bar counts for slow cycles', () => {
146
+ expect(calculateNoteValue(64)).toBe('2 bars');
147
+ expect(calculateNoteValue(32)).toBe('4 bars');
148
+ expect(calculateNoteValue(16)).toBe('8 bars');
149
+ expect(calculateNoteValue(1)).toBe('128 bars');
150
+ });
151
+
152
+ test('returns ∞ for product 0', () => {
153
+ expect(calculateNoteValue(0)).toBe('∞');
154
+ });
155
+ });
156
+
157
+ describe('calculateTimingInfo', () => {
158
+ test('returns complete timing info', () => {
159
+ const config = createConfig({ speed: 16, multiplier: 8 });
160
+ const info = calculateTimingInfo(config, 120);
161
+
162
+ expect(info.product).toBe(128);
163
+ expect(info.cycleTimeMs).toBeCloseTo(2000, 1);
164
+ expect(info.frequencyHz).toBeCloseTo(0.5, 5);
165
+ expect(info.cyclesPerBar).toBe(1);
166
+ expect(info.noteValue).toBe('1 bar');
167
+ });
168
+ });
169
+
170
+ describe('formatCycleTime', () => {
171
+ test('formats milliseconds', () => {
172
+ expect(formatCycleTime(125)).toBe('125.0ms');
173
+ expect(formatCycleTime(500)).toBe('500.0ms');
174
+ });
175
+
176
+ test('formats seconds', () => {
177
+ expect(formatCycleTime(2000)).toBe('2.00s');
178
+ expect(formatCycleTime(4500)).toBe('4.50s');
179
+ });
180
+
181
+ test('formats minutes', () => {
182
+ expect(formatCycleTime(120000)).toBe('2.0min');
183
+ expect(formatCycleTime(256000)).toBe('4.3min');
184
+ });
185
+
186
+ test('formats infinity', () => {
187
+ expect(formatCycleTime(Infinity)).toBe('∞');
188
+ });
189
+ });
190
+
191
+ describe('formatFrequency', () => {
192
+ test('formats Hz', () => {
193
+ expect(formatFrequency(8)).toBe('8.00 Hz');
194
+ expect(formatFrequency(0.5)).toBe('0.500 Hz');
195
+ });
196
+
197
+ test('formats mHz for very low frequencies', () => {
198
+ expect(formatFrequency(0.005)).toBe('5.000 mHz');
199
+ });
200
+
201
+ test('formats 0 Hz', () => {
202
+ expect(formatFrequency(0)).toBe('0 Hz');
203
+ });
204
+ });
205
+
206
+ describe('Spec timing examples', () => {
207
+ // Verify against the timing examples from the spec
208
+
209
+ test('SPD=32, MULT=64: 1/16 note, 125ms at 120 BPM', () => {
210
+ const config = createConfig({ speed: 32, multiplier: 64 });
211
+ expect(calculateCycleTimeMs(config, 120)).toBeCloseTo(125, 1);
212
+ expect(calculateNoteValue(32 * 64)).toBe('1/16');
213
+ });
214
+
215
+ test('SPD=16, MULT=8: 1 bar, 2000ms at 120 BPM', () => {
216
+ const config = createConfig({ speed: 16, multiplier: 8 });
217
+ expect(calculateCycleTimeMs(config, 120)).toBeCloseTo(2000, 1);
218
+ expect(calculateNoteValue(16 * 8)).toBe('1 bar');
219
+ });
220
+
221
+ test('SPD=1, MULT=1: 128 bars, 256000ms at 120 BPM', () => {
222
+ const config = createConfig({ speed: 1, multiplier: 1 });
223
+ expect(calculateCycleTimeMs(config, 120)).toBeCloseTo(256000, 1);
224
+ expect(calculateNoteValue(1 * 1)).toBe('128 bars');
225
+ });
226
+
227
+ test('SPD=8, MULT=16: 1 bar, 2000ms at 120 BPM', () => {
228
+ const config = createConfig({ speed: 8, multiplier: 16 });
229
+ expect(calculateCycleTimeMs(config, 120)).toBeCloseTo(2000, 1);
230
+ expect(calculateNoteValue(8 * 16)).toBe('1 bar');
231
+ });
232
+ });