elektron-lfo 1.0.9 → 1.0.11
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 +21 -3
- package/package.json +1 -1
- package/src/engine/fade.ts +46 -13
- package/src/engine/waveforms.ts +2 -2
- package/tests/depth-fade.test.ts +66 -23
- package/tests/negative-speed.test.ts +316 -0
- package/tests/presets.test.ts +12 -9
- package/tests/waveforms.test.ts +8 -8
package/dist/index.js
CHANGED
|
@@ -66,7 +66,7 @@ function generateSquare(phase) {
|
|
|
66
66
|
return phase < 0.5 ? 1 : -1;
|
|
67
67
|
}
|
|
68
68
|
function generateSawtooth(phase) {
|
|
69
|
-
return phase * 2
|
|
69
|
+
return 1 - phase * 2;
|
|
70
70
|
}
|
|
71
71
|
function generateExponential(phase) {
|
|
72
72
|
const k = 3;
|
|
@@ -345,7 +345,15 @@ 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 >= 48) {
|
|
350
|
+
return Infinity;
|
|
351
|
+
}
|
|
352
|
+
if (absFade <= 16) {
|
|
353
|
+
return Math.max(0.5, absFade / 6);
|
|
354
|
+
}
|
|
355
|
+
const baseAt16 = 16 / 6;
|
|
356
|
+
return baseAt16 * Math.pow(2, (absFade - 16) / 5);
|
|
349
357
|
}
|
|
350
358
|
function updateFade(config, state, cycleTimeMs, deltaMs) {
|
|
351
359
|
if (config.fade === 0 || config.mode === "FRE") {
|
|
@@ -355,8 +363,14 @@ function updateFade(config, state, cycleTimeMs, deltaMs) {
|
|
|
355
363
|
};
|
|
356
364
|
}
|
|
357
365
|
const fadeCycles = calculateFadeCycles(config.fade);
|
|
366
|
+
if (!isFinite(fadeCycles)) {
|
|
367
|
+
return {
|
|
368
|
+
fadeProgress: 0,
|
|
369
|
+
fadeMultiplier: 0
|
|
370
|
+
};
|
|
371
|
+
}
|
|
358
372
|
const fadeDurationMs = fadeCycles * cycleTimeMs;
|
|
359
|
-
if (fadeDurationMs === 0
|
|
373
|
+
if (fadeDurationMs === 0) {
|
|
360
374
|
return {
|
|
361
375
|
fadeProgress: 1,
|
|
362
376
|
fadeMultiplier: config.fade < 0 ? 0 : 1
|
|
@@ -373,6 +387,10 @@ function resetFade(config) {
|
|
|
373
387
|
if (config.fade === 0) {
|
|
374
388
|
return { fadeProgress: 1, fadeMultiplier: 1 };
|
|
375
389
|
}
|
|
390
|
+
const fadeCycles = calculateFadeCycles(config.fade);
|
|
391
|
+
if (!isFinite(fadeCycles)) {
|
|
392
|
+
return { fadeProgress: 0, fadeMultiplier: 0 };
|
|
393
|
+
}
|
|
376
394
|
if (config.fade < 0) {
|
|
377
395
|
return { fadeProgress: 0, fadeMultiplier: 0 };
|
|
378
396
|
} else {
|
package/package.json
CHANGED
package/src/engine/fade.ts
CHANGED
|
@@ -47,22 +47,38 @@ export function calculateFadeMultiplier(
|
|
|
47
47
|
/**
|
|
48
48
|
* Calculate fade cycles - how many LFO cycles for complete fade
|
|
49
49
|
*
|
|
50
|
-
*
|
|
51
|
-
* -
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* -
|
|
50
|
+
* Based on empirical testing against Digitakt II hardware:
|
|
51
|
+
* - |FADE| <= 16: Fast fade, roughly linear (~|FADE|/6 cycles)
|
|
52
|
+
* - |FADE| = 4: ~1 cycle (nearly instant)
|
|
53
|
+
* - |FADE| = 8: ~1.5 cycles
|
|
54
|
+
* - |FADE| = 16: ~2.5 cycles
|
|
55
|
+
* - |FADE| > 16: Exponential slowdown
|
|
56
|
+
* - |FADE| = 32: ~25 cycles
|
|
57
|
+
* - |FADE| >= 48: Effectively disabled (infinitely slow)
|
|
56
58
|
*
|
|
57
|
-
*
|
|
58
|
-
* where 128 is the crossover point for bar-length cycles.
|
|
59
|
-
* Higher |FADE| values = faster fade, lower |FADE| values = slower fade.
|
|
59
|
+
* IMPORTANT: Higher |FADE| = SLOWER fade (opposite of what you might expect)
|
|
60
60
|
*/
|
|
61
61
|
export function calculateFadeCycles(fadeValue: number): number {
|
|
62
62
|
if (fadeValue === 0) return 0;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
|
|
64
|
+
const absFade = Math.abs(fadeValue);
|
|
65
|
+
|
|
66
|
+
// |FADE| >= 48 is effectively disabled (infinitely slow fade)
|
|
67
|
+
if (absFade >= 48) {
|
|
68
|
+
return Infinity;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Fast fade region: roughly linear
|
|
72
|
+
if (absFade <= 16) {
|
|
73
|
+
// |FADE|=4 → ~0.7 cycles, |FADE|=16 → ~2.7 cycles
|
|
74
|
+
return Math.max(0.5, absFade / 6);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Slow fade region: exponential growth
|
|
78
|
+
// Base of ~2.7 cycles at |FADE|=16, doubling every 5 units
|
|
79
|
+
const baseAt16 = 16 / 6; // ~2.67 cycles
|
|
80
|
+
return baseAt16 * Math.pow(2, (absFade - 16) / 5);
|
|
81
|
+
// |FADE|=32: 2.67 * 2^3.2 ≈ 24.5 cycles (matches hardware)
|
|
66
82
|
}
|
|
67
83
|
|
|
68
84
|
/**
|
|
@@ -90,9 +106,19 @@ export function updateFade(
|
|
|
90
106
|
|
|
91
107
|
// Calculate how many cycles the fade takes
|
|
92
108
|
const fadeCycles = calculateFadeCycles(config.fade);
|
|
109
|
+
|
|
110
|
+
// Handle disabled fade (|FADE| >= 48)
|
|
111
|
+
// Both fade-in and fade-out with high values result in no modulation
|
|
112
|
+
if (!isFinite(fadeCycles)) {
|
|
113
|
+
return {
|
|
114
|
+
fadeProgress: 0,
|
|
115
|
+
fadeMultiplier: 0, // No modulation when fade is disabled
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
93
119
|
const fadeDurationMs = fadeCycles * cycleTimeMs;
|
|
94
120
|
|
|
95
|
-
if (fadeDurationMs === 0
|
|
121
|
+
if (fadeDurationMs === 0) {
|
|
96
122
|
return {
|
|
97
123
|
fadeProgress: 1,
|
|
98
124
|
fadeMultiplier: config.fade < 0 ? 0 : 1,
|
|
@@ -117,6 +143,13 @@ export function resetFade(config: LFOConfig): { fadeProgress: number; fadeMultip
|
|
|
117
143
|
return { fadeProgress: 1, fadeMultiplier: 1 };
|
|
118
144
|
}
|
|
119
145
|
|
|
146
|
+
// Check if fade is disabled (|FADE| >= 48)
|
|
147
|
+
const fadeCycles = calculateFadeCycles(config.fade);
|
|
148
|
+
if (!isFinite(fadeCycles)) {
|
|
149
|
+
// Disabled fade = no modulation
|
|
150
|
+
return { fadeProgress: 0, fadeMultiplier: 0 };
|
|
151
|
+
}
|
|
152
|
+
|
|
120
153
|
if (config.fade < 0) {
|
|
121
154
|
// Fade IN: start at 0
|
|
122
155
|
return { fadeProgress: 0, fadeMultiplier: 0 };
|
package/src/engine/waveforms.ts
CHANGED
|
@@ -39,10 +39,10 @@ export function generateSquare(phase: number): number {
|
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
41
|
* Sawtooth waveform - Bipolar
|
|
42
|
-
* Linear
|
|
42
|
+
* Linear fall from +1 to -1 (matches Digitakt II behavior)
|
|
43
43
|
*/
|
|
44
44
|
export function generateSawtooth(phase: number): number {
|
|
45
|
-
return phase * 2
|
|
45
|
+
return 1 - phase * 2;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
/**
|
package/tests/depth-fade.test.ts
CHANGED
|
@@ -133,16 +133,28 @@ 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)', () => {
|
|
137
|
+
// Based on Digitakt II hardware testing:
|
|
138
|
+
// - Higher |FADE| = SLOWER fade (more cycles)
|
|
139
|
+
// - |FADE| >= 48 is disabled (Infinity)
|
|
140
|
+
|
|
141
|
+
// Fast fade region (|FADE| <= 16): ~|FADE|/6 cycles
|
|
142
|
+
expect(calculateFadeCycles(4)).toBeCloseTo(0.67, 1); // ~0.67 cycles
|
|
143
|
+
expect(calculateFadeCycles(-4)).toBeCloseTo(0.67, 1);
|
|
144
|
+
expect(calculateFadeCycles(8)).toBeCloseTo(1.33, 1); // ~1.33 cycles
|
|
145
|
+
expect(calculateFadeCycles(-8)).toBeCloseTo(1.33, 1);
|
|
146
|
+
expect(calculateFadeCycles(16)).toBeCloseTo(2.67, 1); // ~2.67 cycles
|
|
147
|
+
expect(calculateFadeCycles(-16)).toBeCloseTo(2.67, 1);
|
|
148
|
+
|
|
149
|
+
// Slow fade region (16 < |FADE| < 48): exponential
|
|
150
|
+
expect(calculateFadeCycles(32)).toBeCloseTo(24.5, 0); // ~24.5 cycles
|
|
151
|
+
expect(calculateFadeCycles(-32)).toBeCloseTo(24.5, 0);
|
|
152
|
+
|
|
153
|
+
// Disabled region (|FADE| >= 48)
|
|
154
|
+
expect(calculateFadeCycles(48)).toBe(Infinity);
|
|
155
|
+
expect(calculateFadeCycles(-48)).toBe(Infinity);
|
|
156
|
+
expect(calculateFadeCycles(64)).toBe(Infinity);
|
|
157
|
+
expect(calculateFadeCycles(-64)).toBe(Infinity);
|
|
146
158
|
});
|
|
147
159
|
});
|
|
148
160
|
|
|
@@ -166,16 +178,27 @@ describe('updateFade', () => {
|
|
|
166
178
|
});
|
|
167
179
|
|
|
168
180
|
test('progresses fade over time', () => {
|
|
169
|
-
// fade=-
|
|
181
|
+
// fade=-16 takes ~2.67 cycles (based on new formula)
|
|
182
|
+
const config = createConfig({ fade: -16, mode: 'TRG' });
|
|
183
|
+
const state = createState({ fadeProgress: 0 });
|
|
184
|
+
const cycleTimeMs = 1000;
|
|
185
|
+
|
|
186
|
+
// Fade duration = ~2.67 cycles * 1000ms = ~2670ms
|
|
187
|
+
// After 1000ms (~37.5% of fade), progress should be ~0.375
|
|
188
|
+
const result = updateFade(config, state, cycleTimeMs, 1000);
|
|
189
|
+
expect(result.fadeProgress).toBeCloseTo(0.375, 1);
|
|
190
|
+
expect(result.fadeMultiplier).toBeCloseTo(0.375, 1);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('returns 0 multiplier for disabled fade', () => {
|
|
194
|
+
// fade=-64 is disabled (|FADE| >= 48)
|
|
170
195
|
const config = createConfig({ fade: -64, mode: 'TRG' });
|
|
171
196
|
const state = createState({ fadeProgress: 0 });
|
|
172
197
|
const cycleTimeMs = 2000;
|
|
173
198
|
|
|
174
|
-
// Fade duration = 2 cycles * 2000ms = 4000ms
|
|
175
|
-
// After 1000ms (1/4 of fade), progress should be ~0.25
|
|
176
199
|
const result = updateFade(config, state, cycleTimeMs, 1000);
|
|
177
|
-
expect(result.fadeProgress).
|
|
178
|
-
expect(result.fadeMultiplier).
|
|
200
|
+
expect(result.fadeProgress).toBe(0);
|
|
201
|
+
expect(result.fadeMultiplier).toBe(0); // No modulation when disabled
|
|
179
202
|
});
|
|
180
203
|
});
|
|
181
204
|
|
|
@@ -229,7 +252,7 @@ describe('Fade with LFO integration', () => {
|
|
|
229
252
|
const lfo = new LFO({
|
|
230
253
|
waveform: 'SIN',
|
|
231
254
|
depth: 63,
|
|
232
|
-
fade: -
|
|
255
|
+
fade: -16, // Fade in over ~2.67 cycles
|
|
233
256
|
mode: 'TRG',
|
|
234
257
|
}, 120);
|
|
235
258
|
|
|
@@ -237,23 +260,43 @@ describe('Fade with LFO integration', () => {
|
|
|
237
260
|
lfo.update(0);
|
|
238
261
|
|
|
239
262
|
const state1 = lfo.update(100);
|
|
240
|
-
expect(Math.abs(state1.output)).toBeLessThan(0.
|
|
263
|
+
expect(Math.abs(state1.output)).toBeLessThan(0.2); // Start near 0
|
|
241
264
|
|
|
242
|
-
// Fade takes 2 cycles =
|
|
265
|
+
// Fade takes ~2.67 cycles = ~5340ms at 2000ms/cycle
|
|
243
266
|
// After more time, output should increase significantly
|
|
244
267
|
let maxOutput = 0;
|
|
245
|
-
for (let t = 100; t <
|
|
268
|
+
for (let t = 100; t < 6000; t += 100) {
|
|
246
269
|
const state = lfo.update(t);
|
|
247
270
|
maxOutput = Math.max(maxOutput, Math.abs(state.output));
|
|
248
271
|
}
|
|
249
272
|
expect(maxOutput).toBeGreaterThan(0.5);
|
|
250
273
|
});
|
|
251
274
|
|
|
275
|
+
test('disabled fade returns no modulation', () => {
|
|
276
|
+
const lfo = new LFO({
|
|
277
|
+
waveform: 'SIN',
|
|
278
|
+
depth: 63,
|
|
279
|
+
fade: -64, // Disabled (|FADE| >= 48)
|
|
280
|
+
mode: 'TRG',
|
|
281
|
+
}, 120);
|
|
282
|
+
|
|
283
|
+
lfo.trigger();
|
|
284
|
+
lfo.update(0);
|
|
285
|
+
|
|
286
|
+
// With disabled fade, output should stay at 0 (no modulation)
|
|
287
|
+
let maxOutput = 0;
|
|
288
|
+
for (let t = 100; t < 10000; t += 100) {
|
|
289
|
+
const state = lfo.update(t);
|
|
290
|
+
maxOutput = Math.max(maxOutput, Math.abs(state.output));
|
|
291
|
+
}
|
|
292
|
+
expect(maxOutput).toBe(0);
|
|
293
|
+
});
|
|
294
|
+
|
|
252
295
|
test('fade out starts at full output and decreases', () => {
|
|
253
296
|
const lfo = new LFO({
|
|
254
297
|
waveform: 'SIN',
|
|
255
298
|
depth: 63,
|
|
256
|
-
fade:
|
|
299
|
+
fade: 16, // Fade out over ~2.67 cycles
|
|
257
300
|
mode: 'TRG',
|
|
258
301
|
startPhase: 32, // Start at peak
|
|
259
302
|
}, 120);
|
|
@@ -265,10 +308,10 @@ describe('Fade with LFO integration', () => {
|
|
|
265
308
|
// Should start near full output (at SIN peak)
|
|
266
309
|
expect(Math.abs(state1.output)).toBeGreaterThan(0.8);
|
|
267
310
|
|
|
268
|
-
// Fade takes 2 cycles =
|
|
311
|
+
// Fade takes ~2.67 cycles = ~5340ms at 2000ms/cycle
|
|
269
312
|
// After fade completes, output should be near 0
|
|
270
313
|
let lastOutput = 0;
|
|
271
|
-
for (let t = 10; t <
|
|
314
|
+
for (let t = 10; t < 6000; t += 100) {
|
|
272
315
|
const state = lfo.update(t);
|
|
273
316
|
lastOutput = Math.abs(state.output);
|
|
274
317
|
}
|
|
@@ -278,7 +321,7 @@ describe('Fade with LFO integration', () => {
|
|
|
278
321
|
test('fade resets on trigger for TRG mode', () => {
|
|
279
322
|
const lfo = new LFO({
|
|
280
323
|
waveform: 'SIN',
|
|
281
|
-
fade: -
|
|
324
|
+
fade: -16, // Use non-disabled fade value
|
|
282
325
|
mode: 'TRG',
|
|
283
326
|
}, 120);
|
|
284
327
|
|
|
@@ -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
|
|
package/tests/waveforms.test.ts
CHANGED
|
@@ -123,22 +123,22 @@ describe('Square Waveform', () => {
|
|
|
123
123
|
});
|
|
124
124
|
|
|
125
125
|
describe('Sawtooth Waveform', () => {
|
|
126
|
-
test('starts at
|
|
127
|
-
expect(generateSawtooth(0)).toBe(
|
|
126
|
+
test('starts at +1 at phase 0', () => {
|
|
127
|
+
expect(generateSawtooth(0)).toBe(1);
|
|
128
128
|
});
|
|
129
129
|
|
|
130
130
|
test('is 0 at phase 0.5', () => {
|
|
131
131
|
expect(generateSawtooth(0.5)).toBe(0);
|
|
132
132
|
});
|
|
133
133
|
|
|
134
|
-
test('approaches
|
|
135
|
-
expect(generateSawtooth(1)).toBe(1);
|
|
136
|
-
expect(generateSawtooth(0.999)).toBeCloseTo(1, 2);
|
|
134
|
+
test('approaches -1 at phase ~1', () => {
|
|
135
|
+
expect(generateSawtooth(1)).toBe(-1);
|
|
136
|
+
expect(generateSawtooth(0.999)).toBeCloseTo(-1, 2);
|
|
137
137
|
});
|
|
138
138
|
|
|
139
|
-
test('is bipolar and linear (
|
|
140
|
-
expect(generateSawtooth(0.25)).toBeCloseTo(
|
|
141
|
-
expect(generateSawtooth(0.75)).toBeCloseTo(0.5, 5);
|
|
139
|
+
test('is bipolar and linear (falling)', () => {
|
|
140
|
+
expect(generateSawtooth(0.25)).toBeCloseTo(0.5, 5);
|
|
141
|
+
expect(generateSawtooth(0.75)).toBeCloseTo(-0.5, 5);
|
|
142
142
|
});
|
|
143
143
|
});
|
|
144
144
|
|