elektron-lfo 1.0.4 → 1.0.7
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 +20 -6
- package/package.json +2 -2
- package/src/cli/index.ts +0 -0
- package/src/engine/fade.ts +12 -7
- package/src/engine/lfo.ts +29 -1
- package/src/engine/timing.ts +2 -3
- package/src/engine/waveforms.ts +8 -3
- package/tests/depth-fade.test.ts +25 -15
- package/tests/phase.test.ts +24 -16
- package/tests/presets.test.ts +8 -8
- package/tests/timing.test.ts +5 -4
- 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;
|
|
@@ -154,8 +155,7 @@ function calculatePhaseIncrement(config, bpm) {
|
|
|
154
155
|
if (cycleTimeMs === Infinity || cycleTimeMs === 0) {
|
|
155
156
|
return 0;
|
|
156
157
|
}
|
|
157
|
-
|
|
158
|
-
return direction / cycleTimeMs;
|
|
158
|
+
return 1 / cycleTimeMs;
|
|
159
159
|
}
|
|
160
160
|
function calculateCyclesPerBar(config) {
|
|
161
161
|
const product = calculateProduct(config);
|
|
@@ -345,7 +345,7 @@ function calculateFadeMultiplier(fadeValue, fadeProgress) {
|
|
|
345
345
|
function calculateFadeCycles(fadeValue) {
|
|
346
346
|
if (fadeValue === 0)
|
|
347
347
|
return 0;
|
|
348
|
-
return Math.abs(fadeValue)
|
|
348
|
+
return 128 / Math.abs(fadeValue);
|
|
349
349
|
}
|
|
350
350
|
function updateFade(config, state, cycleTimeMs, deltaMs) {
|
|
351
351
|
if (config.fade === 0 || config.mode === "FRE") {
|
|
@@ -452,6 +452,9 @@ class LFO {
|
|
|
452
452
|
if (this.config.mode === "HLD" && this.state.triggerCount > 0) {
|
|
453
453
|
effectiveRawOutput = this.state.heldOutput;
|
|
454
454
|
}
|
|
455
|
+
if (this.config.speed < 0) {
|
|
456
|
+
effectiveRawOutput = -effectiveRawOutput;
|
|
457
|
+
}
|
|
455
458
|
const depthScale = this.config.depth / 63;
|
|
456
459
|
let scaledOutput = effectiveRawOutput * depthScale;
|
|
457
460
|
scaledOutput *= this.state.fadeMultiplier;
|
|
@@ -460,7 +463,18 @@ class LFO {
|
|
|
460
463
|
return { ...this.state };
|
|
461
464
|
}
|
|
462
465
|
trigger() {
|
|
463
|
-
|
|
466
|
+
let rawOutputForTrigger = this.state.rawOutput;
|
|
467
|
+
if (this.config.mode === "HLD") {
|
|
468
|
+
const waveformResult = generateWaveform(this.config.waveform, this.state.phase, this.state);
|
|
469
|
+
rawOutputForTrigger = waveformResult.value;
|
|
470
|
+
if (waveformResult.newRandomValue !== undefined) {
|
|
471
|
+
this.state.randomValue = waveformResult.newRandomValue;
|
|
472
|
+
}
|
|
473
|
+
if (waveformResult.newRandomStep !== undefined) {
|
|
474
|
+
this.state.randomStep = waveformResult.newRandomStep;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
this.state = handleTrigger(this.config, this.state, rawOutputForTrigger);
|
|
464
478
|
}
|
|
465
479
|
getState() {
|
|
466
480
|
return { ...this.state };
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "elektron-lfo",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "Elektron LFO engine simulator implementation with CLI visualization",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "src/index.ts",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
8
8
|
"bin": {
|
|
9
|
-
"elektron-lfo": "
|
|
9
|
+
"elektron-lfo": "src/cli/index.ts"
|
|
10
10
|
},
|
|
11
11
|
"scripts": {
|
|
12
12
|
"dev": "bun run src/cli/index.ts",
|
package/src/cli/index.ts
CHANGED
|
File without changes
|
package/src/engine/fade.ts
CHANGED
|
@@ -48,16 +48,21 @@ export function calculateFadeMultiplier(
|
|
|
48
48
|
* Calculate fade cycles - how many LFO cycles for complete fade
|
|
49
49
|
*
|
|
50
50
|
* The FADE parameter (-64 to +63) maps to fade duration in cycles:
|
|
51
|
-
* - |FADE|
|
|
52
|
-
* - At |FADE| = 64, fade takes
|
|
53
|
-
* - At |FADE| = 32, fade takes
|
|
54
|
-
* - At |FADE| =
|
|
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
|
|
56
|
+
*
|
|
57
|
+
* This follows the same "128" convention used in LFO timing calculations,
|
|
58
|
+
* where 128 is the crossover point for bar-length cycles.
|
|
59
|
+
* Higher |FADE| values = faster fade, lower |FADE| values = slower fade.
|
|
55
60
|
*/
|
|
56
61
|
export function calculateFadeCycles(fadeValue: number): number {
|
|
57
62
|
if (fadeValue === 0) return 0;
|
|
58
|
-
// Map |FADE| to cycles: |FADE|
|
|
59
|
-
// Maximum fade (64) =
|
|
60
|
-
return Math.abs(fadeValue)
|
|
63
|
+
// Map |FADE| to cycles: 128 / |FADE| cycles
|
|
64
|
+
// Maximum fade (64) = 2 cycles, minimum (1) = 128 cycles
|
|
65
|
+
return 128 / Math.abs(fadeValue);
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
/**
|
package/src/engine/lfo.ts
CHANGED
|
@@ -134,6 +134,11 @@ export class LFO {
|
|
|
134
134
|
effectiveRawOutput = this.state.heldOutput;
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
// Invert output for negative speed (phase still runs forward)
|
|
138
|
+
if (this.config.speed < 0) {
|
|
139
|
+
effectiveRawOutput = -effectiveRawOutput;
|
|
140
|
+
}
|
|
141
|
+
|
|
137
142
|
// Apply depth
|
|
138
143
|
// Depth scales the output: depth of 63 = 100%, depth of 0 = 0%
|
|
139
144
|
// Negative depth inverts the waveform
|
|
@@ -157,9 +162,32 @@ export class LFO {
|
|
|
157
162
|
|
|
158
163
|
/**
|
|
159
164
|
* Trigger the LFO
|
|
165
|
+
*
|
|
166
|
+
* For HLD mode, this computes the current waveform value at the current phase
|
|
167
|
+
* to ensure the held value is accurate, even if trigger() is called before
|
|
168
|
+
* any update() calls.
|
|
160
169
|
*/
|
|
161
170
|
trigger(): void {
|
|
162
|
-
|
|
171
|
+
// For HLD mode, compute fresh waveform output at current phase.
|
|
172
|
+
// This is necessary because rawOutput may be stale if trigger() is called
|
|
173
|
+
// before update() has been called (e.g., when initializing an HLD mode LFO).
|
|
174
|
+
let rawOutputForTrigger = this.state.rawOutput;
|
|
175
|
+
if (this.config.mode === 'HLD') {
|
|
176
|
+
const waveformResult = generateWaveform(
|
|
177
|
+
this.config.waveform,
|
|
178
|
+
this.state.phase,
|
|
179
|
+
this.state
|
|
180
|
+
);
|
|
181
|
+
rawOutputForTrigger = waveformResult.value;
|
|
182
|
+
// Update random state if needed (for RND waveform)
|
|
183
|
+
if (waveformResult.newRandomValue !== undefined) {
|
|
184
|
+
this.state.randomValue = waveformResult.newRandomValue;
|
|
185
|
+
}
|
|
186
|
+
if (waveformResult.newRandomStep !== undefined) {
|
|
187
|
+
this.state.randomStep = waveformResult.newRandomStep;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
this.state = handleTrigger(this.config, this.state, rawOutputForTrigger);
|
|
163
191
|
}
|
|
164
192
|
|
|
165
193
|
/**
|
package/src/engine/timing.ts
CHANGED
|
@@ -62,9 +62,8 @@ export function calculatePhaseIncrement(config: LFOConfig, bpm: number): number
|
|
|
62
62
|
return 0;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
return direction / cycleTimeMs;
|
|
65
|
+
// Phase always moves forward; negative speed is handled by inverting waveform output
|
|
66
|
+
return 1 / cycleTimeMs;
|
|
68
67
|
}
|
|
69
68
|
|
|
70
69
|
/**
|
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
|
/**
|
package/tests/depth-fade.test.ts
CHANGED
|
@@ -133,11 +133,16 @@ describe('calculateFadeCycles', () => {
|
|
|
133
133
|
expect(calculateFadeCycles(0)).toBe(0);
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
-
test('calculates cycles based on fade value', () => {
|
|
137
|
-
|
|
138
|
-
expect(calculateFadeCycles(
|
|
139
|
-
expect(calculateFadeCycles(
|
|
140
|
-
expect(calculateFadeCycles(
|
|
136
|
+
test('calculates cycles based on fade value (128/|FADE|)', () => {
|
|
137
|
+
// Higher |FADE| = faster fade (fewer cycles)
|
|
138
|
+
expect(calculateFadeCycles(64)).toBe(2); // 128/64 = 2 cycles
|
|
139
|
+
expect(calculateFadeCycles(-64)).toBe(2);
|
|
140
|
+
expect(calculateFadeCycles(32)).toBe(4); // 128/32 = 4 cycles
|
|
141
|
+
expect(calculateFadeCycles(-32)).toBe(4);
|
|
142
|
+
expect(calculateFadeCycles(16)).toBe(8); // 128/16 = 8 cycles
|
|
143
|
+
expect(calculateFadeCycles(-16)).toBe(8);
|
|
144
|
+
expect(calculateFadeCycles(1)).toBe(128); // 128/1 = 128 cycles (slowest)
|
|
145
|
+
expect(calculateFadeCycles(-1)).toBe(128);
|
|
141
146
|
});
|
|
142
147
|
});
|
|
143
148
|
|
|
@@ -161,13 +166,16 @@ describe('updateFade', () => {
|
|
|
161
166
|
});
|
|
162
167
|
|
|
163
168
|
test('progresses fade over time', () => {
|
|
164
|
-
|
|
169
|
+
// fade=-64 takes 128/64 = 2 cycles
|
|
170
|
+
const config = createConfig({ fade: -64, mode: 'TRG' });
|
|
165
171
|
const state = createState({ fadeProgress: 0 });
|
|
172
|
+
const cycleTimeMs = 2000;
|
|
166
173
|
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
expect(result.
|
|
174
|
+
// Fade duration = 2 cycles * 2000ms = 4000ms
|
|
175
|
+
// After 1000ms (1/4 of fade), progress should be ~0.25
|
|
176
|
+
const result = updateFade(config, state, cycleTimeMs, 1000);
|
|
177
|
+
expect(result.fadeProgress).toBeCloseTo(0.25, 1);
|
|
178
|
+
expect(result.fadeMultiplier).toBeCloseTo(0.25, 1);
|
|
171
179
|
});
|
|
172
180
|
});
|
|
173
181
|
|
|
@@ -221,7 +229,7 @@ describe('Fade with LFO integration', () => {
|
|
|
221
229
|
const lfo = new LFO({
|
|
222
230
|
waveform: 'SIN',
|
|
223
231
|
depth: 63,
|
|
224
|
-
fade: -64, // Fade in over
|
|
232
|
+
fade: -64, // Fade in over 2 cycles (128/64)
|
|
225
233
|
mode: 'TRG',
|
|
226
234
|
}, 120);
|
|
227
235
|
|
|
@@ -231,9 +239,10 @@ describe('Fade with LFO integration', () => {
|
|
|
231
239
|
const state1 = lfo.update(100);
|
|
232
240
|
expect(Math.abs(state1.output)).toBeLessThan(0.1); // Start near 0
|
|
233
241
|
|
|
234
|
-
//
|
|
242
|
+
// Fade takes 2 cycles = 4000ms at 2000ms/cycle
|
|
243
|
+
// After more time, output should increase significantly
|
|
235
244
|
let maxOutput = 0;
|
|
236
|
-
for (let t = 100; t <
|
|
245
|
+
for (let t = 100; t < 5000; t += 100) {
|
|
237
246
|
const state = lfo.update(t);
|
|
238
247
|
maxOutput = Math.max(maxOutput, Math.abs(state.output));
|
|
239
248
|
}
|
|
@@ -244,7 +253,7 @@ describe('Fade with LFO integration', () => {
|
|
|
244
253
|
const lfo = new LFO({
|
|
245
254
|
waveform: 'SIN',
|
|
246
255
|
depth: 63,
|
|
247
|
-
fade: 64, // Fade out over
|
|
256
|
+
fade: 64, // Fade out over 2 cycles (128/64)
|
|
248
257
|
mode: 'TRG',
|
|
249
258
|
startPhase: 32, // Start at peak
|
|
250
259
|
}, 120);
|
|
@@ -256,9 +265,10 @@ describe('Fade with LFO integration', () => {
|
|
|
256
265
|
// Should start near full output (at SIN peak)
|
|
257
266
|
expect(Math.abs(state1.output)).toBeGreaterThan(0.8);
|
|
258
267
|
|
|
268
|
+
// Fade takes 2 cycles = 4000ms at 2000ms/cycle
|
|
259
269
|
// After fade completes, output should be near 0
|
|
260
270
|
let lastOutput = 0;
|
|
261
|
-
for (let t = 10; t <
|
|
271
|
+
for (let t = 10; t < 5000; t += 100) {
|
|
262
272
|
const state = lfo.update(t);
|
|
263
273
|
lastOutput = Math.abs(state.output);
|
|
264
274
|
}
|
package/tests/phase.test.ts
CHANGED
|
@@ -23,11 +23,11 @@ describe('Phase wrapping', () => {
|
|
|
23
23
|
expect(wrappedFromOne).toBe(true);
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
-
test('phase wraps from
|
|
27
|
-
const lfo = new LFO({ speed: -32, multiplier: 64 }, 120); // Fast
|
|
26
|
+
test('phase wraps from 1 to 0 (negative speed - phase still runs forward)', () => {
|
|
27
|
+
const lfo = new LFO({ speed: -32, multiplier: 64 }, 120); // Fast cycle, negative speed
|
|
28
28
|
|
|
29
29
|
let lastTime = 0;
|
|
30
|
-
let
|
|
30
|
+
let wrappedFromOne = false;
|
|
31
31
|
let previousPhase = 0;
|
|
32
32
|
|
|
33
33
|
// First update to initialize
|
|
@@ -36,15 +36,16 @@ describe('Phase wrapping', () => {
|
|
|
36
36
|
|
|
37
37
|
for (let i = 0; i < 200; i++) {
|
|
38
38
|
const state = lfo.update(lastTime);
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
// Phase still runs forward even with negative speed
|
|
40
|
+
if (previousPhase > 0.9 && state.phase < 0.1) {
|
|
41
|
+
wrappedFromOne = true;
|
|
41
42
|
break;
|
|
42
43
|
}
|
|
43
44
|
previousPhase = state.phase;
|
|
44
45
|
lastTime += 10;
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
expect(
|
|
48
|
+
expect(wrappedFromOne).toBe(true);
|
|
48
49
|
});
|
|
49
50
|
|
|
50
51
|
test('phase stays within 0-1 range', () => {
|
|
@@ -61,16 +62,23 @@ describe('Phase wrapping', () => {
|
|
|
61
62
|
});
|
|
62
63
|
|
|
63
64
|
describe('Negative speed', () => {
|
|
64
|
-
test('negative speed
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
65
|
+
test('negative speed inverts output (phase still runs forward)', () => {
|
|
66
|
+
// Positive speed LFO
|
|
67
|
+
const lfoPos = new LFO({ speed: 16, multiplier: 8, waveform: 'TRI' }, 120);
|
|
68
|
+
// Negative speed LFO (same magnitude)
|
|
69
|
+
const lfoNeg = new LFO({ speed: -16, multiplier: 8, waveform: 'TRI' }, 120);
|
|
70
|
+
|
|
71
|
+
// Initialize both
|
|
72
|
+
lfoPos.update(0);
|
|
73
|
+
lfoNeg.update(0);
|
|
74
|
+
|
|
75
|
+
const statePos = lfoPos.update(500);
|
|
76
|
+
const stateNeg = lfoNeg.update(500);
|
|
77
|
+
|
|
78
|
+
// Phase should be the same (both run forward)
|
|
79
|
+
expect(stateNeg.phase).toBeCloseTo(statePos.phase, 4);
|
|
80
|
+
// Output should be inverted
|
|
81
|
+
expect(stateNeg.output).toBeCloseTo(-statePos.output, 4);
|
|
74
82
|
});
|
|
75
83
|
|
|
76
84
|
test('positive speed runs phase forwards', () => {
|
package/tests/presets.test.ts
CHANGED
|
@@ -62,17 +62,17 @@ describe('Preset 1: Fade-In One-Shot', () => {
|
|
|
62
62
|
lfo.update(0);
|
|
63
63
|
|
|
64
64
|
const state1 = lfo.update(100);
|
|
65
|
-
expect(state1.fadeMultiplier).toBeLessThan(0.
|
|
65
|
+
expect(state1.fadeMultiplier).toBeLessThan(0.2);
|
|
66
66
|
|
|
67
|
-
// Fade -32 =
|
|
68
|
-
// After ~
|
|
69
|
-
let
|
|
70
|
-
for (let t = 100; t <
|
|
67
|
+
// Fade -32 = 128/32 = 4 cycles = 8000ms at 2000ms cycle
|
|
68
|
+
// After ~2000ms (1 cycle), should be at 25% (2000/8000)
|
|
69
|
+
let laterFadeMultiplier = 0;
|
|
70
|
+
for (let t = 100; t < 2100; t += 50) {
|
|
71
71
|
const state = lfo.update(t);
|
|
72
|
-
|
|
72
|
+
laterFadeMultiplier = state.fadeMultiplier;
|
|
73
73
|
}
|
|
74
|
-
expect(
|
|
75
|
-
expect(
|
|
74
|
+
expect(laterFadeMultiplier).toBeGreaterThan(0.15);
|
|
75
|
+
expect(laterFadeMultiplier).toBeLessThan(0.4);
|
|
76
76
|
});
|
|
77
77
|
});
|
|
78
78
|
|
package/tests/timing.test.ts
CHANGED
|
@@ -103,11 +103,12 @@ describe('calculatePhaseIncrement', () => {
|
|
|
103
103
|
expect(increment).toBeCloseTo(1 / 2000, 8);
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
-
test('calculates
|
|
107
|
-
const config = createConfig({ speed: -16, multiplier: 8 }); // 2000ms cycle
|
|
106
|
+
test('calculates positive increment for negative speed (phase always forward)', () => {
|
|
107
|
+
const config = createConfig({ speed: -16, multiplier: 8 }); // 2000ms cycle
|
|
108
108
|
const increment = calculatePhaseIncrement(config, 120);
|
|
109
|
-
|
|
110
|
-
expect(increment).
|
|
109
|
+
// Phase always moves forward; output inversion is handled in LFO class
|
|
110
|
+
expect(increment).toBeGreaterThan(0);
|
|
111
|
+
expect(increment).toBeCloseTo(1 / 2000, 8);
|
|
111
112
|
});
|
|
112
113
|
|
|
113
114
|
test('returns 0 for speed 0', () => {
|
|
@@ -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
|
});
|