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.
- package/bun.lock +23 -0
- package/bunfig.toml +3 -0
- package/docs/PLAN.md +446 -0
- package/docs/TEST_QUESTIONS.md +550 -0
- package/package.json +26 -0
- package/src/cli/args.ts +182 -0
- package/src/cli/display.ts +237 -0
- package/src/cli/index.ts +129 -0
- package/src/cli/keyboard.ts +143 -0
- package/src/engine/fade.ts +137 -0
- package/src/engine/index.ts +72 -0
- package/src/engine/lfo.ts +269 -0
- package/src/engine/timing.ts +157 -0
- package/src/engine/triggers.ts +179 -0
- package/src/engine/types.ts +126 -0
- package/src/engine/waveforms.ts +152 -0
- package/src/index.ts +31 -0
- package/tests/depth-fade.test.ts +306 -0
- package/tests/phase.test.ts +219 -0
- package/tests/presets.test.ts +344 -0
- package/tests/timing.test.ts +232 -0
- package/tests/triggers.test.ts +345 -0
- package/tests/waveforms.test.ts +273 -0
- package/tsconfig.json +13 -0
|
@@ -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
|
+
});
|