elektron-lfo 1.0.4 → 1.0.6
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/cli/args.d.ts +10 -0
- package/dist/cli/display.d.ts +10 -0
- package/dist/cli/index.d.ts +7 -0
- package/dist/cli/keyboard.d.ts +44 -0
- package/dist/engine/lfo.d.ts +7 -0
- package/dist/engine/waveforms.d.ts +2 -2
- package/dist/index.js +3 -2
- package/package.json +1 -1
- package/src/cli/index.ts +0 -0
- package/src/engine/waveforms.ts +8 -3
- package/tests/unipolar-digitakt.test.ts +387 -0
- package/tests/waveforms.test.ts +9 -7
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command-line argument parsing for Elektron LFO CLI
|
|
3
|
+
*/
|
|
4
|
+
import type { LFOConfig } from '../engine/types';
|
|
5
|
+
export interface CLIArgs extends LFOConfig {
|
|
6
|
+
bpm: number;
|
|
7
|
+
help: boolean;
|
|
8
|
+
}
|
|
9
|
+
export declare function printHelp(): void;
|
|
10
|
+
export declare function parseArgs(args: string[]): CLIArgs;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal display for Elektron LFO CLI
|
|
3
|
+
*
|
|
4
|
+
* Renders the LFO state with ASCII waveform visualization
|
|
5
|
+
*/
|
|
6
|
+
import type { LFOConfig, LFOState, TimingInfo } from '../engine/types';
|
|
7
|
+
/**
|
|
8
|
+
* Render the full display
|
|
9
|
+
*/
|
|
10
|
+
export declare function render(config: LFOConfig, state: LFOState, timing: TimingInfo, bpm: number): string;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyboard input handling for Elektron LFO CLI
|
|
3
|
+
*/
|
|
4
|
+
import type { Waveform, TriggerMode, Multiplier } from '../engine/types';
|
|
5
|
+
export type KeyAction = {
|
|
6
|
+
type: 'trigger';
|
|
7
|
+
} | {
|
|
8
|
+
type: 'quit';
|
|
9
|
+
} | {
|
|
10
|
+
type: 'bpm';
|
|
11
|
+
delta: number;
|
|
12
|
+
} | {
|
|
13
|
+
type: 'speed';
|
|
14
|
+
delta: number;
|
|
15
|
+
} | {
|
|
16
|
+
type: 'waveform';
|
|
17
|
+
waveform: Waveform;
|
|
18
|
+
} | {
|
|
19
|
+
type: 'mode';
|
|
20
|
+
mode: TriggerMode;
|
|
21
|
+
} | {
|
|
22
|
+
type: 'multiplier';
|
|
23
|
+
multiplier: Multiplier;
|
|
24
|
+
} | {
|
|
25
|
+
type: 'depth';
|
|
26
|
+
delta: number;
|
|
27
|
+
} | {
|
|
28
|
+
type: 'fade';
|
|
29
|
+
delta: number;
|
|
30
|
+
} | {
|
|
31
|
+
type: 'unknown';
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Parse a key input into an action
|
|
35
|
+
*/
|
|
36
|
+
export declare function parseKey(key: Buffer, currentWaveform: Waveform, currentMode: TriggerMode, currentMultiplier: Multiplier): KeyAction;
|
|
37
|
+
/**
|
|
38
|
+
* Set up raw mode input handling
|
|
39
|
+
*/
|
|
40
|
+
export declare function setupKeyboardInput(onKey: (key: Buffer) => void, onExit: () => void): void;
|
|
41
|
+
/**
|
|
42
|
+
* Clean up keyboard input handling
|
|
43
|
+
*/
|
|
44
|
+
export declare function cleanupKeyboardInput(): void;
|
package/dist/engine/lfo.d.ts
CHANGED
|
@@ -73,4 +73,11 @@ export declare class LFO {
|
|
|
73
73
|
* Stop the LFO
|
|
74
74
|
*/
|
|
75
75
|
stop(): void;
|
|
76
|
+
/**
|
|
77
|
+
* Reset timing so the next update() call has deltaMs = 0
|
|
78
|
+
*
|
|
79
|
+
* Call this after trigger() when restarting from a paused state
|
|
80
|
+
* to prevent the LFO from jumping ahead by the paused duration.
|
|
81
|
+
*/
|
|
82
|
+
resetTiming(): void;
|
|
76
83
|
}
|
|
@@ -23,7 +23,7 @@ export declare function generateSine(phase: number): number;
|
|
|
23
23
|
export declare function generateSquare(phase: number): number;
|
|
24
24
|
/**
|
|
25
25
|
* Sawtooth waveform - Bipolar
|
|
26
|
-
* Linear
|
|
26
|
+
* Linear fall from +1 to -1 (with positive depth)
|
|
27
27
|
*/
|
|
28
28
|
export declare function generateSawtooth(phase: number): number;
|
|
29
29
|
/**
|
|
@@ -33,7 +33,7 @@ export declare function generateSawtooth(phase: number): number;
|
|
|
33
33
|
export declare function generateExponential(phase: number): number;
|
|
34
34
|
/**
|
|
35
35
|
* Ramp waveform - Unipolar (0 to +1)
|
|
36
|
-
* Linear
|
|
36
|
+
* Linear rise from 0 to +1
|
|
37
37
|
*/
|
|
38
38
|
export declare function generateRamp(phase: number): number;
|
|
39
39
|
/**
|
package/dist/index.js
CHANGED
|
@@ -69,8 +69,9 @@ function generateSawtooth(phase) {
|
|
|
69
69
|
return 1 - phase * 2;
|
|
70
70
|
}
|
|
71
71
|
function generateExponential(phase) {
|
|
72
|
-
const k =
|
|
73
|
-
|
|
72
|
+
const k = 3;
|
|
73
|
+
const expK = Math.exp(-k);
|
|
74
|
+
return (Math.exp(-phase * k) - expK) / (1 - expK);
|
|
74
75
|
}
|
|
75
76
|
function generateRamp(phase) {
|
|
76
77
|
return phase;
|
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
File without changes
|
package/src/engine/waveforms.ts
CHANGED
|
@@ -48,11 +48,16 @@ export function generateSawtooth(phase: number): number {
|
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
50
|
* Exponential waveform - Unipolar (0 to +1)
|
|
51
|
-
*
|
|
51
|
+
* Decaying curve from 1 to 0 (matches Digitakt II behavior)
|
|
52
|
+
* Fast initial decay, slowing toward the end (true exponential decay shape)
|
|
52
53
|
*/
|
|
53
54
|
export function generateExponential(phase: number): number {
|
|
54
|
-
const k =
|
|
55
|
-
|
|
55
|
+
const k = 3; // Decay rate - controls steepness of decay
|
|
56
|
+
// True exponential decay: starts at 1, decays rapidly then slows
|
|
57
|
+
// Formula: (exp(-phase * k) - exp(-k)) / (1 - exp(-k))
|
|
58
|
+
// This normalizes to exactly [1, 0] range
|
|
59
|
+
const expK = Math.exp(-k);
|
|
60
|
+
return (Math.exp(-phase * k) - expK) / (1 - expK);
|
|
56
61
|
}
|
|
57
62
|
|
|
58
63
|
/**
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expectation-based tests for EXP and RMP waveforms.
|
|
3
|
+
*
|
|
4
|
+
* Based on Digitakt II hardware verification, these waveforms should behave as:
|
|
5
|
+
*
|
|
6
|
+
* EXP (Exponential):
|
|
7
|
+
* - DECAY curve: starts at 1 (peak), decays toward 0 (center)
|
|
8
|
+
* - Unipolar output range: 0 to 1
|
|
9
|
+
* - With positive depth: CC goes from (64+depth) down to 64
|
|
10
|
+
* - Shape: Fast initial decay, slowing toward the end
|
|
11
|
+
*
|
|
12
|
+
* RMP (Ramp):
|
|
13
|
+
* - RISE curve: starts at 0 (center), rises toward 1 (peak)
|
|
14
|
+
* - Unipolar output range: 0 to 1
|
|
15
|
+
* - With positive depth: CC goes from 64 up to (64+depth)
|
|
16
|
+
* - Shape: Linear rise
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, test, expect } from 'bun:test';
|
|
20
|
+
import { generateExponential, generateRamp } from '../src/engine/waveforms';
|
|
21
|
+
import { LFO } from '../src/engine/lfo';
|
|
22
|
+
|
|
23
|
+
// Helper to convert LFO output to CC value
|
|
24
|
+
const outputToCC = (output: number): number => {
|
|
25
|
+
return Math.max(0, Math.min(127, Math.round(64 + output * 63)));
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
describe('EXP Waveform - Expected Behavior', () => {
|
|
29
|
+
describe('raw waveform generator', () => {
|
|
30
|
+
test('should return 1.0 at phase 0 (starts at peak)', () => {
|
|
31
|
+
expect(generateExponential(0)).toBeCloseTo(1.0, 2);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('should return value < 0.5 at phase 0.5 (decaying)', () => {
|
|
35
|
+
const value = generateExponential(0.5);
|
|
36
|
+
expect(value).toBeLessThan(0.5);
|
|
37
|
+
expect(value).toBeGreaterThan(0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('should return value near 0 at phase 1 (decayed to center)', () => {
|
|
41
|
+
expect(generateExponential(1)).toBeLessThan(0.1);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('should monotonically decrease from phase 0 to 1', () => {
|
|
45
|
+
const phases = [0, 0.2, 0.4, 0.6, 0.8, 1.0];
|
|
46
|
+
const values = phases.map(p => generateExponential(p));
|
|
47
|
+
|
|
48
|
+
for (let i = 1; i < values.length; i++) {
|
|
49
|
+
expect(values[i]).toBeLessThan(values[i - 1]);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('should be unipolar (output range 0 to 1)', () => {
|
|
54
|
+
for (let i = 0; i <= 100; i++) {
|
|
55
|
+
const value = generateExponential(i / 100);
|
|
56
|
+
expect(value).toBeGreaterThanOrEqual(0);
|
|
57
|
+
expect(value).toBeLessThanOrEqual(1);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('full LFO with depth=40', () => {
|
|
63
|
+
const BPM = 120;
|
|
64
|
+
const DEPTH = 40;
|
|
65
|
+
|
|
66
|
+
test('should produce CC near 104 at phase 0 (start at peak)', () => {
|
|
67
|
+
const lfo = new LFO({
|
|
68
|
+
waveform: 'EXP',
|
|
69
|
+
speed: 16,
|
|
70
|
+
multiplier: 4,
|
|
71
|
+
depth: DEPTH,
|
|
72
|
+
startPhase: 0,
|
|
73
|
+
mode: 'TRG',
|
|
74
|
+
}, BPM);
|
|
75
|
+
|
|
76
|
+
lfo.trigger();
|
|
77
|
+
const state = lfo.update(1);
|
|
78
|
+
const cc = outputToCC(state.output);
|
|
79
|
+
|
|
80
|
+
// At phase 0, EXP should be at peak: CC = 64 + 40 = 104
|
|
81
|
+
expect(cc).toBeGreaterThan(100);
|
|
82
|
+
expect(cc).toBeLessThanOrEqual(104);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('should produce CC near 64 at phase 1 (end at center)', () => {
|
|
86
|
+
const lfo = new LFO({
|
|
87
|
+
waveform: 'EXP',
|
|
88
|
+
speed: 16,
|
|
89
|
+
multiplier: 4,
|
|
90
|
+
depth: DEPTH,
|
|
91
|
+
startPhase: 0,
|
|
92
|
+
mode: 'TRG',
|
|
93
|
+
}, BPM);
|
|
94
|
+
|
|
95
|
+
lfo.trigger();
|
|
96
|
+
// First update to set initial time (deltaMs=0 on first call)
|
|
97
|
+
lfo.update(1);
|
|
98
|
+
// Update to near end of cycle
|
|
99
|
+
const cycleMs = 4000;
|
|
100
|
+
const state = lfo.update(1 + cycleMs * 0.99);
|
|
101
|
+
const cc = outputToCC(state.output);
|
|
102
|
+
|
|
103
|
+
// At phase ~1, EXP should be near center: CC ≈ 64
|
|
104
|
+
expect(cc).toBeGreaterThanOrEqual(64);
|
|
105
|
+
expect(cc).toBeLessThan(75);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('CC should decrease over time (decay behavior)', () => {
|
|
109
|
+
const lfo = new LFO({
|
|
110
|
+
waveform: 'EXP',
|
|
111
|
+
speed: 16,
|
|
112
|
+
multiplier: 4,
|
|
113
|
+
depth: DEPTH,
|
|
114
|
+
startPhase: 0,
|
|
115
|
+
mode: 'TRG',
|
|
116
|
+
}, BPM);
|
|
117
|
+
|
|
118
|
+
lfo.trigger();
|
|
119
|
+
const cycleMs = 4000;
|
|
120
|
+
|
|
121
|
+
const ccStart = outputToCC(lfo.update(1).output);
|
|
122
|
+
const ccMid = outputToCC(lfo.update(1 + cycleMs * 0.5).output);
|
|
123
|
+
const ccEnd = outputToCC(lfo.update(1 + cycleMs * 0.99).output);
|
|
124
|
+
|
|
125
|
+
expect(ccStart).toBeGreaterThan(ccMid);
|
|
126
|
+
expect(ccMid).toBeGreaterThan(ccEnd);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('RMP Waveform - Expected Behavior', () => {
|
|
132
|
+
describe('raw waveform generator', () => {
|
|
133
|
+
test('should return 0 at phase 0 (starts at center)', () => {
|
|
134
|
+
expect(generateRamp(0)).toBe(0);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('should return 0.5 at phase 0.5 (linear rise)', () => {
|
|
138
|
+
expect(generateRamp(0.5)).toBe(0.5);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('should return 1.0 at phase 1 (ends at peak)', () => {
|
|
142
|
+
expect(generateRamp(1)).toBe(1);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('should rise linearly from phase 0 to 1', () => {
|
|
146
|
+
for (let i = 0; i <= 100; i++) {
|
|
147
|
+
const phase = i / 100;
|
|
148
|
+
expect(generateRamp(phase)).toBeCloseTo(phase, 5);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('should be unipolar (output range 0 to 1)', () => {
|
|
153
|
+
for (let i = 0; i <= 100; i++) {
|
|
154
|
+
const value = generateRamp(i / 100);
|
|
155
|
+
expect(value).toBeGreaterThanOrEqual(0);
|
|
156
|
+
expect(value).toBeLessThanOrEqual(1);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('full LFO with depth=40', () => {
|
|
162
|
+
const BPM = 120;
|
|
163
|
+
const DEPTH = 40;
|
|
164
|
+
|
|
165
|
+
test('should produce CC near 64 at phase 0 (start at center)', () => {
|
|
166
|
+
const lfo = new LFO({
|
|
167
|
+
waveform: 'RMP',
|
|
168
|
+
speed: 16,
|
|
169
|
+
multiplier: 4,
|
|
170
|
+
depth: DEPTH,
|
|
171
|
+
startPhase: 0,
|
|
172
|
+
mode: 'TRG',
|
|
173
|
+
}, BPM);
|
|
174
|
+
|
|
175
|
+
lfo.trigger();
|
|
176
|
+
const state = lfo.update(1);
|
|
177
|
+
const cc = outputToCC(state.output);
|
|
178
|
+
|
|
179
|
+
// At phase 0, RMP should be at center: CC = 64
|
|
180
|
+
expect(cc).toBe(64);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('should produce CC near 104 at phase 1 (end at peak)', () => {
|
|
184
|
+
const lfo = new LFO({
|
|
185
|
+
waveform: 'RMP',
|
|
186
|
+
speed: 16,
|
|
187
|
+
multiplier: 4,
|
|
188
|
+
depth: DEPTH,
|
|
189
|
+
startPhase: 0,
|
|
190
|
+
mode: 'TRG',
|
|
191
|
+
}, BPM);
|
|
192
|
+
|
|
193
|
+
lfo.trigger();
|
|
194
|
+
// First update to set initial time (deltaMs=0 on first call)
|
|
195
|
+
lfo.update(1);
|
|
196
|
+
const cycleMs = 4000;
|
|
197
|
+
const state = lfo.update(1 + cycleMs * 0.99);
|
|
198
|
+
const cc = outputToCC(state.output);
|
|
199
|
+
|
|
200
|
+
// At phase ~1, RMP should be at peak: CC = 64 + 40 = 104
|
|
201
|
+
expect(cc).toBeGreaterThan(100);
|
|
202
|
+
expect(cc).toBeLessThanOrEqual(104);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('CC should increase over time (rise behavior)', () => {
|
|
206
|
+
const lfo = new LFO({
|
|
207
|
+
waveform: 'RMP',
|
|
208
|
+
speed: 16,
|
|
209
|
+
multiplier: 4,
|
|
210
|
+
depth: DEPTH,
|
|
211
|
+
startPhase: 0,
|
|
212
|
+
mode: 'TRG',
|
|
213
|
+
}, BPM);
|
|
214
|
+
|
|
215
|
+
lfo.trigger();
|
|
216
|
+
const cycleMs = 4000;
|
|
217
|
+
|
|
218
|
+
const ccStart = outputToCC(lfo.update(1).output);
|
|
219
|
+
const ccMid = outputToCC(lfo.update(1 + cycleMs * 0.5).output);
|
|
220
|
+
const ccEnd = outputToCC(lfo.update(1 + cycleMs * 0.99).output);
|
|
221
|
+
|
|
222
|
+
expect(ccStart).toBeLessThan(ccMid);
|
|
223
|
+
expect(ccMid).toBeLessThan(ccEnd);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('should produce CC range [64-104] over full cycle', () => {
|
|
227
|
+
const lfo = new LFO({
|
|
228
|
+
waveform: 'RMP',
|
|
229
|
+
speed: 16,
|
|
230
|
+
multiplier: 4,
|
|
231
|
+
depth: DEPTH,
|
|
232
|
+
startPhase: 0,
|
|
233
|
+
mode: 'TRG',
|
|
234
|
+
}, BPM);
|
|
235
|
+
|
|
236
|
+
lfo.trigger();
|
|
237
|
+
const cycleMs = 4000;
|
|
238
|
+
let minCC = 127, maxCC = 0;
|
|
239
|
+
|
|
240
|
+
for (let i = 0; i <= 100; i++) {
|
|
241
|
+
const state = lfo.update(1 + (i / 100) * cycleMs);
|
|
242
|
+
const cc = outputToCC(state.output);
|
|
243
|
+
minCC = Math.min(minCC, cc);
|
|
244
|
+
maxCC = Math.max(maxCC, cc);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
expect(minCC).toBe(64);
|
|
248
|
+
expect(maxCC).toBeGreaterThanOrEqual(103);
|
|
249
|
+
expect(maxCC).toBeLessThanOrEqual(104);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
describe('Depth Scaling Comparison', () => {
|
|
255
|
+
const BPM = 120;
|
|
256
|
+
const DEPTH = 40;
|
|
257
|
+
|
|
258
|
+
test('TRI (bipolar) should swing ±40 from center [24-104]', () => {
|
|
259
|
+
const lfo = new LFO({
|
|
260
|
+
waveform: 'TRI',
|
|
261
|
+
speed: 16,
|
|
262
|
+
multiplier: 4,
|
|
263
|
+
depth: DEPTH,
|
|
264
|
+
startPhase: 0,
|
|
265
|
+
mode: 'TRG',
|
|
266
|
+
}, BPM);
|
|
267
|
+
|
|
268
|
+
lfo.trigger();
|
|
269
|
+
const cycleMs = 4000;
|
|
270
|
+
let minCC = 127, maxCC = 0;
|
|
271
|
+
|
|
272
|
+
for (let i = 0; i <= 100; i++) {
|
|
273
|
+
const state = lfo.update(1 + (i / 100) * cycleMs);
|
|
274
|
+
const cc = outputToCC(state.output);
|
|
275
|
+
minCC = Math.min(minCC, cc);
|
|
276
|
+
maxCC = Math.max(maxCC, cc);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Bipolar: 64 - 40 = 24, 64 + 40 = 104
|
|
280
|
+
expect(minCC).toBe(24);
|
|
281
|
+
expect(maxCC).toBe(104);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('RMP (unipolar) should swing +40 from center [64-104]', () => {
|
|
285
|
+
const lfo = new LFO({
|
|
286
|
+
waveform: 'RMP',
|
|
287
|
+
speed: 16,
|
|
288
|
+
multiplier: 4,
|
|
289
|
+
depth: DEPTH,
|
|
290
|
+
startPhase: 0,
|
|
291
|
+
mode: 'TRG',
|
|
292
|
+
}, BPM);
|
|
293
|
+
|
|
294
|
+
lfo.trigger();
|
|
295
|
+
const cycleMs = 4000;
|
|
296
|
+
let minCC = 127, maxCC = 0;
|
|
297
|
+
|
|
298
|
+
for (let i = 0; i <= 100; i++) {
|
|
299
|
+
const state = lfo.update(1 + (i / 100) * cycleMs);
|
|
300
|
+
const cc = outputToCC(state.output);
|
|
301
|
+
minCC = Math.min(minCC, cc);
|
|
302
|
+
maxCC = Math.max(maxCC, cc);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Unipolar rising: 64 to 64 + 40 = 104
|
|
306
|
+
expect(minCC).toBe(64);
|
|
307
|
+
expect(maxCC).toBeGreaterThanOrEqual(103);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test('EXP (unipolar) should swing +40 from center [64-104]', () => {
|
|
311
|
+
const lfo = new LFO({
|
|
312
|
+
waveform: 'EXP',
|
|
313
|
+
speed: 16,
|
|
314
|
+
multiplier: 4,
|
|
315
|
+
depth: DEPTH,
|
|
316
|
+
startPhase: 0,
|
|
317
|
+
mode: 'TRG',
|
|
318
|
+
}, BPM);
|
|
319
|
+
|
|
320
|
+
lfo.trigger();
|
|
321
|
+
const cycleMs = 4000;
|
|
322
|
+
let minCC = 127, maxCC = 0;
|
|
323
|
+
|
|
324
|
+
for (let i = 0; i <= 100; i++) {
|
|
325
|
+
const state = lfo.update(1 + (i / 100) * cycleMs);
|
|
326
|
+
const cc = outputToCC(state.output);
|
|
327
|
+
minCC = Math.min(minCC, cc);
|
|
328
|
+
maxCC = Math.max(maxCC, cc);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Unipolar decay: starts at 64 + 40 = 104, decays to 64
|
|
332
|
+
expect(minCC).toBeGreaterThanOrEqual(64);
|
|
333
|
+
expect(minCC).toBeLessThan(70);
|
|
334
|
+
expect(maxCC).toBeGreaterThanOrEqual(103);
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
describe('Negative Depth', () => {
|
|
339
|
+
const BPM = 120;
|
|
340
|
+
const DEPTH = -40;
|
|
341
|
+
|
|
342
|
+
test('EXP with negative depth should produce CC below center', () => {
|
|
343
|
+
const lfo = new LFO({
|
|
344
|
+
waveform: 'EXP',
|
|
345
|
+
speed: 16,
|
|
346
|
+
multiplier: 4,
|
|
347
|
+
depth: DEPTH,
|
|
348
|
+
startPhase: 0,
|
|
349
|
+
mode: 'TRG',
|
|
350
|
+
}, BPM);
|
|
351
|
+
|
|
352
|
+
lfo.trigger();
|
|
353
|
+
const state = lfo.update(1);
|
|
354
|
+
const cc = outputToCC(state.output);
|
|
355
|
+
|
|
356
|
+
// With negative depth, peak should be BELOW center: 64 - 40 = 24
|
|
357
|
+
expect(cc).toBeLessThan(30);
|
|
358
|
+
expect(cc).toBeGreaterThanOrEqual(24);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test('RMP with negative depth should rise toward center', () => {
|
|
362
|
+
const lfo = new LFO({
|
|
363
|
+
waveform: 'RMP',
|
|
364
|
+
speed: 16,
|
|
365
|
+
multiplier: 4,
|
|
366
|
+
depth: DEPTH,
|
|
367
|
+
startPhase: 0,
|
|
368
|
+
mode: 'TRG',
|
|
369
|
+
}, BPM);
|
|
370
|
+
|
|
371
|
+
lfo.trigger();
|
|
372
|
+
const state = lfo.update(1);
|
|
373
|
+
const cc = outputToCC(state.output);
|
|
374
|
+
|
|
375
|
+
// At phase 0, RMP should be at center
|
|
376
|
+
expect(cc).toBe(64);
|
|
377
|
+
|
|
378
|
+
// At end of cycle, should be at negative peak
|
|
379
|
+
const cycleMs = 4000;
|
|
380
|
+
const stateEnd = lfo.update(1 + cycleMs * 0.99);
|
|
381
|
+
const ccEnd = outputToCC(stateEnd.output);
|
|
382
|
+
|
|
383
|
+
// 64 - 40 = 24
|
|
384
|
+
expect(ccEnd).toBeLessThan(30);
|
|
385
|
+
expect(ccEnd).toBeGreaterThanOrEqual(24);
|
|
386
|
+
});
|
|
387
|
+
});
|
package/tests/waveforms.test.ts
CHANGED
|
@@ -143,12 +143,13 @@ describe('Sawtooth Waveform', () => {
|
|
|
143
143
|
});
|
|
144
144
|
|
|
145
145
|
describe('Exponential Waveform', () => {
|
|
146
|
-
test('starts at
|
|
147
|
-
expect(generateExponential(0)).toBeCloseTo(
|
|
146
|
+
test('starts at 1 at phase 0 (peak, matching Digitakt II)', () => {
|
|
147
|
+
expect(generateExponential(0)).toBeCloseTo(1, 5);
|
|
148
148
|
});
|
|
149
149
|
|
|
150
|
-
test('
|
|
151
|
-
|
|
150
|
+
test('decays toward 0 at phase 1', () => {
|
|
151
|
+
// Normalized exponential decay reaches exactly 0 at phase 1
|
|
152
|
+
expect(generateExponential(1)).toBeCloseTo(0, 5);
|
|
152
153
|
});
|
|
153
154
|
|
|
154
155
|
test('is unipolar (0 to +1)', () => {
|
|
@@ -157,9 +158,10 @@ describe('Exponential Waveform', () => {
|
|
|
157
158
|
expect(Math.max(...samples)).toBeLessThanOrEqual(1);
|
|
158
159
|
});
|
|
159
160
|
|
|
160
|
-
test('has
|
|
161
|
-
// At phase 0.5, should be
|
|
161
|
+
test('has decaying (exponential decay) curve shape', () => {
|
|
162
|
+
// At phase 0.5, should be greater than 0.5 (decay curve starts high)
|
|
162
163
|
const midValue = generateExponential(0.5);
|
|
164
|
+
expect(midValue).toBeGreaterThan(0.1);
|
|
163
165
|
expect(midValue).toBeLessThan(0.5);
|
|
164
166
|
});
|
|
165
167
|
});
|
|
@@ -237,7 +239,7 @@ describe('generateWaveform', () => {
|
|
|
237
239
|
expect(generateWaveform('SIN', 0.25, state).value).toBeCloseTo(1, 5);
|
|
238
240
|
expect(generateWaveform('SQR', 0.25, state).value).toBe(1);
|
|
239
241
|
expect(generateWaveform('SAW', 0.5, state).value).toBe(0);
|
|
240
|
-
expect(generateWaveform('EXP',
|
|
242
|
+
expect(generateWaveform('EXP', 0, state).value).toBeCloseTo(1, 5); // EXP starts at peak
|
|
241
243
|
expect(generateWaveform('RMP', 0, state).value).toBe(0);
|
|
242
244
|
});
|
|
243
245
|
});
|