elektron-lfo 1.0.10 → 1.0.12
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 +7 -2
- package/package.json +1 -1
- package/src/engine/fade.ts +39 -13
- package/tests/depth-fade.test.ts +97 -24
- package/tests/negative-speed.test.ts +316 -0
- package/tests/presets.test.ts +12 -9
package/dist/index.js
CHANGED
|
@@ -345,7 +345,12 @@ function calculateFadeMultiplier(fadeValue, fadeProgress) {
|
|
|
345
345
|
function calculateFadeCycles(fadeValue) {
|
|
346
346
|
if (fadeValue === 0)
|
|
347
347
|
return 0;
|
|
348
|
-
|
|
348
|
+
const absFade = Math.abs(fadeValue);
|
|
349
|
+
if (absFade <= 16) {
|
|
350
|
+
return Math.max(0.5, 0.1 * absFade + 0.6);
|
|
351
|
+
}
|
|
352
|
+
const baseAt16 = 2.2;
|
|
353
|
+
return baseAt16 * Math.pow(2, (absFade - 16) / 4.5);
|
|
349
354
|
}
|
|
350
355
|
function updateFade(config, state, cycleTimeMs, deltaMs) {
|
|
351
356
|
if (config.fade === 0 || config.mode === "FRE") {
|
|
@@ -356,7 +361,7 @@ function updateFade(config, state, cycleTimeMs, deltaMs) {
|
|
|
356
361
|
}
|
|
357
362
|
const fadeCycles = calculateFadeCycles(config.fade);
|
|
358
363
|
const fadeDurationMs = fadeCycles * cycleTimeMs;
|
|
359
|
-
if (fadeDurationMs === 0
|
|
364
|
+
if (fadeDurationMs === 0) {
|
|
360
365
|
return {
|
|
361
366
|
fadeProgress: 1,
|
|
362
367
|
fadeMultiplier: config.fade < 0 ? 0 : 1
|
package/package.json
CHANGED
package/src/engine/fade.ts
CHANGED
|
@@ -47,22 +47,41 @@ export function calculateFadeMultiplier(
|
|
|
47
47
|
/**
|
|
48
48
|
* Calculate fade cycles - how many LFO cycles for complete fade
|
|
49
49
|
*
|
|
50
|
-
*
|
|
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 (January 2025):
|
|
56
51
|
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
52
|
+
* Key observations:
|
|
53
|
+
* - |FADE| <= 16: Linear region, ~1 cycle at FADE=4, ~2.2 cycles at FADE=16
|
|
54
|
+
* - |FADE| > 16: Exponential slowdown, doubling every ~4.7 units
|
|
55
|
+
* - NO "disabled" threshold - even |FADE|=63 continues fading, just very slowly
|
|
56
|
+
*
|
|
57
|
+
* Measured values:
|
|
58
|
+
* |FADE| = 4: ~1 cycle
|
|
59
|
+
* |FADE| = 8: ~1.6 cycles
|
|
60
|
+
* |FADE| = 16: ~2.2 cycles
|
|
61
|
+
* |FADE| = 24: ~7 cycles
|
|
62
|
+
* |FADE| = 32: ~26 cycles
|
|
63
|
+
* |FADE| = 40: ~90 cycles
|
|
64
|
+
* |FADE| = 48: ~320 cycles
|
|
65
|
+
* |FADE| = 56: ~1100 cycles
|
|
66
|
+
* |FADE| = 63: ~3300 cycles
|
|
67
|
+
*
|
|
68
|
+
* IMPORTANT: Higher |FADE| = SLOWER fade (opposite of what you might expect)
|
|
60
69
|
*/
|
|
61
70
|
export function calculateFadeCycles(fadeValue: number): number {
|
|
62
71
|
if (fadeValue === 0) return 0;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
72
|
+
|
|
73
|
+
const absFade = Math.abs(fadeValue);
|
|
74
|
+
|
|
75
|
+
// Linear region (|FADE| <= 16): ~1 cycle at FADE=4, ~2.2 cycles at FADE=16
|
|
76
|
+
// Formula: 0.1 * |FADE| + 0.6, with minimum of 0.5
|
|
77
|
+
if (absFade <= 16) {
|
|
78
|
+
return Math.max(0.5, 0.1 * absFade + 0.6);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Exponential region (|FADE| > 16): starts at 2.2 cycles at FADE=16
|
|
82
|
+
// Doubles every ~4.5 units of |FADE|
|
|
83
|
+
const baseAt16 = 2.2;
|
|
84
|
+
return baseAt16 * Math.pow(2, (absFade - 16) / 4.5);
|
|
66
85
|
}
|
|
67
86
|
|
|
68
87
|
/**
|
|
@@ -90,9 +109,13 @@ export function updateFade(
|
|
|
90
109
|
|
|
91
110
|
// Calculate how many cycles the fade takes
|
|
92
111
|
const fadeCycles = calculateFadeCycles(config.fade);
|
|
112
|
+
|
|
113
|
+
// Note: There is no "disabled" threshold - even extreme fade values
|
|
114
|
+
// just result in very slow fades (thousands of cycles for |FADE|=63)
|
|
115
|
+
|
|
93
116
|
const fadeDurationMs = fadeCycles * cycleTimeMs;
|
|
94
117
|
|
|
95
|
-
if (fadeDurationMs === 0
|
|
118
|
+
if (fadeDurationMs === 0) {
|
|
96
119
|
return {
|
|
97
120
|
fadeProgress: 1,
|
|
98
121
|
fadeMultiplier: config.fade < 0 ? 0 : 1,
|
|
@@ -111,6 +134,9 @@ export function updateFade(
|
|
|
111
134
|
|
|
112
135
|
/**
|
|
113
136
|
* Reset fade state (called on trigger for modes that reset fade)
|
|
137
|
+
*
|
|
138
|
+
* Note: There is no "disabled" threshold - even extreme fade values
|
|
139
|
+
* will eventually complete, just over thousands of cycles.
|
|
114
140
|
*/
|
|
115
141
|
export function resetFade(config: LFOConfig): { fadeProgress: number; fadeMultiplier: number } {
|
|
116
142
|
if (config.fade === 0) {
|
package/tests/depth-fade.test.ts
CHANGED
|
@@ -133,16 +133,53 @@ describe('calculateFadeCycles', () => {
|
|
|
133
133
|
expect(calculateFadeCycles(0)).toBe(0);
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
-
test('calculates cycles based on fade value (
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
expect(calculateFadeCycles(
|
|
143
|
-
expect(calculateFadeCycles(-
|
|
144
|
-
expect(calculateFadeCycles(
|
|
145
|
-
expect(calculateFadeCycles(-
|
|
136
|
+
test('calculates cycles based on fade value (empirical formula from Digitakt II)', () => {
|
|
137
|
+
// Based on Digitakt II hardware testing (January 2025):
|
|
138
|
+
// - Higher |FADE| = SLOWER fade (more cycles)
|
|
139
|
+
// - NO disabled threshold - even extreme values fade, just slowly
|
|
140
|
+
|
|
141
|
+
// Linear region (|FADE| <= 16): 0.1 * |FADE| + 0.6
|
|
142
|
+
expect(calculateFadeCycles(4)).toBeCloseTo(1.0, 1); // ~1 cycle
|
|
143
|
+
expect(calculateFadeCycles(-4)).toBeCloseTo(1.0, 1);
|
|
144
|
+
expect(calculateFadeCycles(8)).toBeCloseTo(1.4, 1); // ~1.4 cycles
|
|
145
|
+
expect(calculateFadeCycles(-8)).toBeCloseTo(1.4, 1);
|
|
146
|
+
expect(calculateFadeCycles(16)).toBeCloseTo(2.2, 1); // ~2.2 cycles
|
|
147
|
+
expect(calculateFadeCycles(-16)).toBeCloseTo(2.2, 1);
|
|
148
|
+
|
|
149
|
+
// Exponential region (|FADE| > 16): 2.2 * 2^((|FADE| - 16) / 4.5)
|
|
150
|
+
expect(calculateFadeCycles(24)).toBeCloseTo(7.5, 0); // ~7-8 cycles
|
|
151
|
+
expect(calculateFadeCycles(-24)).toBeCloseTo(7.5, 0);
|
|
152
|
+
expect(calculateFadeCycles(32)).toBeCloseTo(26, 0); // ~26 cycles
|
|
153
|
+
expect(calculateFadeCycles(-32)).toBeCloseTo(26, 0);
|
|
154
|
+
|
|
155
|
+
// Extreme values - NOT disabled, just very slow
|
|
156
|
+
// Using loose precision since exact values are less critical at extremes
|
|
157
|
+
const fade40 = calculateFadeCycles(40);
|
|
158
|
+
const fade48 = calculateFadeCycles(48);
|
|
159
|
+
const fade56 = calculateFadeCycles(56);
|
|
160
|
+
const fade63 = calculateFadeCycles(63);
|
|
161
|
+
|
|
162
|
+
expect(fade40).toBeGreaterThan(70);
|
|
163
|
+
expect(fade40).toBeLessThan(120); // ~90 cycles
|
|
164
|
+
expect(fade48).toBeGreaterThan(250);
|
|
165
|
+
expect(fade48).toBeLessThan(400); // ~300 cycles
|
|
166
|
+
expect(fade56).toBeGreaterThan(800);
|
|
167
|
+
expect(fade56).toBeLessThan(1400); // ~1000 cycles
|
|
168
|
+
expect(fade63).toBeGreaterThan(2500);
|
|
169
|
+
expect(fade63).toBeLessThan(4000); // ~3000 cycles
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('is symmetric for positive and negative fade values', () => {
|
|
173
|
+
expect(calculateFadeCycles(-16)).toBe(calculateFadeCycles(16));
|
|
174
|
+
expect(calculateFadeCycles(-32)).toBe(calculateFadeCycles(32));
|
|
175
|
+
expect(calculateFadeCycles(-48)).toBe(calculateFadeCycles(48));
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('extreme fade values return finite (large) cycle counts', () => {
|
|
179
|
+
// Verify no Infinity values - Digitakt continues fading even at extremes
|
|
180
|
+
expect(isFinite(calculateFadeCycles(48))).toBe(true);
|
|
181
|
+
expect(isFinite(calculateFadeCycles(63))).toBe(true);
|
|
182
|
+
expect(isFinite(calculateFadeCycles(-64))).toBe(true);
|
|
146
183
|
});
|
|
147
184
|
});
|
|
148
185
|
|
|
@@ -166,16 +203,29 @@ describe('updateFade', () => {
|
|
|
166
203
|
});
|
|
167
204
|
|
|
168
205
|
test('progresses fade over time', () => {
|
|
169
|
-
// fade=-
|
|
206
|
+
// fade=-16 takes ~2.2 cycles (based on new empirical formula)
|
|
207
|
+
const config = createConfig({ fade: -16, mode: 'TRG' });
|
|
208
|
+
const state = createState({ fadeProgress: 0 });
|
|
209
|
+
const cycleTimeMs = 1000;
|
|
210
|
+
|
|
211
|
+
// Fade duration = ~2.2 cycles * 1000ms = ~2200ms
|
|
212
|
+
// After 1000ms (~45% of fade), progress should be ~0.45
|
|
213
|
+
const result = updateFade(config, state, cycleTimeMs, 1000);
|
|
214
|
+
expect(result.fadeProgress).toBeCloseTo(0.45, 1);
|
|
215
|
+
expect(result.fadeMultiplier).toBeCloseTo(0.45, 1);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('extreme fade values still progress, just very slowly', () => {
|
|
219
|
+
// fade=-64 takes ~3500+ cycles - NOT disabled, just very slow
|
|
170
220
|
const config = createConfig({ fade: -64, mode: 'TRG' });
|
|
171
221
|
const state = createState({ fadeProgress: 0 });
|
|
172
|
-
const cycleTimeMs =
|
|
222
|
+
const cycleTimeMs = 1000;
|
|
173
223
|
|
|
174
|
-
//
|
|
175
|
-
// After 1000ms (1/4 of fade), progress should be ~0.25
|
|
224
|
+
// After 1000ms (1 cycle), progress should be 1/3500 = ~0.0003
|
|
176
225
|
const result = updateFade(config, state, cycleTimeMs, 1000);
|
|
177
|
-
expect(result.fadeProgress).
|
|
178
|
-
expect(result.
|
|
226
|
+
expect(result.fadeProgress).toBeGreaterThan(0);
|
|
227
|
+
expect(result.fadeProgress).toBeLessThan(0.01);
|
|
228
|
+
expect(result.fadeMultiplier).toBeGreaterThan(0);
|
|
179
229
|
});
|
|
180
230
|
});
|
|
181
231
|
|
|
@@ -229,7 +279,7 @@ describe('Fade with LFO integration', () => {
|
|
|
229
279
|
const lfo = new LFO({
|
|
230
280
|
waveform: 'SIN',
|
|
231
281
|
depth: 63,
|
|
232
|
-
fade: -
|
|
282
|
+
fade: -16, // Fade in over ~2.2 cycles
|
|
233
283
|
mode: 'TRG',
|
|
234
284
|
}, 120);
|
|
235
285
|
|
|
@@ -237,23 +287,46 @@ describe('Fade with LFO integration', () => {
|
|
|
237
287
|
lfo.update(0);
|
|
238
288
|
|
|
239
289
|
const state1 = lfo.update(100);
|
|
240
|
-
expect(Math.abs(state1.output)).toBeLessThan(0.
|
|
290
|
+
expect(Math.abs(state1.output)).toBeLessThan(0.2); // Start near 0
|
|
241
291
|
|
|
242
|
-
// Fade takes 2 cycles =
|
|
292
|
+
// Fade takes ~2.2 cycles = ~4400ms at 2000ms/cycle
|
|
243
293
|
// After more time, output should increase significantly
|
|
244
294
|
let maxOutput = 0;
|
|
245
|
-
for (let t = 100; t <
|
|
295
|
+
for (let t = 100; t < 6000; t += 100) {
|
|
246
296
|
const state = lfo.update(t);
|
|
247
297
|
maxOutput = Math.max(maxOutput, Math.abs(state.output));
|
|
248
298
|
}
|
|
249
299
|
expect(maxOutput).toBeGreaterThan(0.5);
|
|
250
300
|
});
|
|
251
301
|
|
|
302
|
+
test('extreme fade values progress very slowly but are not disabled', () => {
|
|
303
|
+
const lfo = new LFO({
|
|
304
|
+
waveform: 'SIN',
|
|
305
|
+
depth: 63,
|
|
306
|
+
fade: -64, // Very slow fade (~3500 cycles), NOT disabled
|
|
307
|
+
mode: 'TRG',
|
|
308
|
+
}, 120);
|
|
309
|
+
|
|
310
|
+
lfo.trigger();
|
|
311
|
+
lfo.update(0);
|
|
312
|
+
|
|
313
|
+
// With extreme fade, output starts near 0 and increases VERY slowly
|
|
314
|
+
// After 10 seconds (~5 cycles), fade is only 5/3500 = 0.14% complete
|
|
315
|
+
let maxOutput = 0;
|
|
316
|
+
for (let t = 100; t < 10000; t += 100) {
|
|
317
|
+
const state = lfo.update(t);
|
|
318
|
+
maxOutput = Math.max(maxOutput, Math.abs(state.output));
|
|
319
|
+
}
|
|
320
|
+
// Should have some tiny output (not zero) since fade is progressing
|
|
321
|
+
expect(maxOutput).toBeGreaterThan(0);
|
|
322
|
+
expect(maxOutput).toBeLessThan(0.05); // But still very small
|
|
323
|
+
});
|
|
324
|
+
|
|
252
325
|
test('fade out starts at full output and decreases', () => {
|
|
253
326
|
const lfo = new LFO({
|
|
254
327
|
waveform: 'SIN',
|
|
255
328
|
depth: 63,
|
|
256
|
-
fade:
|
|
329
|
+
fade: 16, // Fade out over ~2.2 cycles
|
|
257
330
|
mode: 'TRG',
|
|
258
331
|
startPhase: 32, // Start at peak
|
|
259
332
|
}, 120);
|
|
@@ -265,10 +338,10 @@ describe('Fade with LFO integration', () => {
|
|
|
265
338
|
// Should start near full output (at SIN peak)
|
|
266
339
|
expect(Math.abs(state1.output)).toBeGreaterThan(0.8);
|
|
267
340
|
|
|
268
|
-
// Fade takes 2 cycles =
|
|
341
|
+
// Fade takes ~2.2 cycles = ~4400ms at 2000ms/cycle
|
|
269
342
|
// After fade completes, output should be near 0
|
|
270
343
|
let lastOutput = 0;
|
|
271
|
-
for (let t = 10; t <
|
|
344
|
+
for (let t = 10; t < 6000; t += 100) {
|
|
272
345
|
const state = lfo.update(t);
|
|
273
346
|
lastOutput = Math.abs(state.output);
|
|
274
347
|
}
|
|
@@ -278,7 +351,7 @@ describe('Fade with LFO integration', () => {
|
|
|
278
351
|
test('fade resets on trigger for TRG mode', () => {
|
|
279
352
|
const lfo = new LFO({
|
|
280
353
|
waveform: 'SIN',
|
|
281
|
-
fade: -
|
|
354
|
+
fade: -16, // Use non-disabled fade value
|
|
282
355
|
mode: 'TRG',
|
|
283
356
|
}, 120);
|
|
284
357
|
|
|
@@ -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
|
+
});
|
package/tests/presets.test.ts
CHANGED
|
@@ -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:
|
|
56
|
-
mode: '
|
|
57
|
-
fade: -
|
|
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.
|
|
66
|
+
expect(state1.fadeMultiplier).toBeLessThan(0.1);
|
|
66
67
|
|
|
67
|
-
// Fade -
|
|
68
|
-
//
|
|
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 <
|
|
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.
|
|
75
|
-
expect(laterFadeMultiplier).toBeLessThan(0.
|
|
77
|
+
expect(laterFadeMultiplier).toBeGreaterThan(0.6);
|
|
78
|
+
expect(laterFadeMultiplier).toBeLessThan(0.9);
|
|
76
79
|
});
|
|
77
80
|
});
|
|
78
81
|
|