elektron-lfo 1.0.13 → 1.0.16
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 +19 -6
- package/docs/PLAN.md +4 -4
- package/docs/TEST_QUESTIONS.md +73 -0
- package/package.json +1 -1
- package/src/engine/lfo.ts +33 -4
- package/src/engine/timing.ts +5 -4
- package/tests/negative-speed.test.ts +19 -8
package/dist/index.js
CHANGED
|
@@ -177,10 +177,10 @@ function calculateNoteValue(product) {
|
|
|
177
177
|
if (product >= 128)
|
|
178
178
|
return "1 bar";
|
|
179
179
|
const bars = 128 / product;
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
return `${
|
|
180
|
+
const rounded = Math.round(bars * 10) / 10;
|
|
181
|
+
const isWhole = rounded === Math.floor(rounded);
|
|
182
|
+
const barsStr = isWhole ? String(Math.floor(rounded)) : rounded.toFixed(1);
|
|
183
|
+
return `${barsStr} bars`;
|
|
184
184
|
}
|
|
185
185
|
function calculateTimingInfo(config, bpm) {
|
|
186
186
|
const product = calculateProduct(config);
|
|
@@ -411,20 +411,29 @@ class LFO {
|
|
|
411
411
|
if (shouldUpdatePhase) {
|
|
412
412
|
const previousPhase = this.state.phase;
|
|
413
413
|
let newPhase = this.state.phase + phaseIncrement * deltaMs;
|
|
414
|
+
let cycleCompleted = false;
|
|
414
415
|
if (newPhase >= 1) {
|
|
415
416
|
newPhase = newPhase % 1;
|
|
416
417
|
this.state.cycleCount++;
|
|
418
|
+
cycleCompleted = true;
|
|
417
419
|
} else if (newPhase < 0) {
|
|
418
420
|
newPhase = 1 + newPhase % 1;
|
|
419
421
|
if (newPhase === 1)
|
|
420
422
|
newPhase = 0;
|
|
421
423
|
this.state.cycleCount++;
|
|
424
|
+
cycleCompleted = true;
|
|
425
|
+
}
|
|
426
|
+
if (cycleCompleted && this.config.waveform === "RND") {
|
|
427
|
+
if (this.config.mode === "FRE" || this.config.mode === "TRG") {
|
|
428
|
+
this.state.randomValue = Math.random() * 2 - 1;
|
|
429
|
+
this.state.randomStep = Math.floor(newPhase * 16);
|
|
430
|
+
}
|
|
422
431
|
}
|
|
423
432
|
const stopCheck = checkModeStop(this.config, this.state, previousPhase, newPhase);
|
|
424
433
|
if (stopCheck.shouldStop) {
|
|
425
434
|
this.state.isRunning = false;
|
|
426
435
|
if (this.config.mode === "ONE") {
|
|
427
|
-
newPhase =
|
|
436
|
+
newPhase = 1;
|
|
428
437
|
} else if (this.config.mode === "HLF") {
|
|
429
438
|
newPhase = (this.state.startPhaseNormalized + 0.5) % 1;
|
|
430
439
|
}
|
|
@@ -450,7 +459,11 @@ class LFO {
|
|
|
450
459
|
effectiveRawOutput = this.state.heldOutput;
|
|
451
460
|
}
|
|
452
461
|
if (this.config.speed < 0) {
|
|
453
|
-
|
|
462
|
+
if (isUnipolar(this.config.waveform)) {
|
|
463
|
+
effectiveRawOutput = 1 - effectiveRawOutput;
|
|
464
|
+
} else {
|
|
465
|
+
effectiveRawOutput = -effectiveRawOutput;
|
|
466
|
+
}
|
|
454
467
|
}
|
|
455
468
|
const depthScale = this.config.depth / 63;
|
|
456
469
|
let scaledOutput = effectiveRawOutput * depthScale;
|
package/docs/PLAN.md
CHANGED
|
@@ -421,15 +421,15 @@ types.ts
|
|
|
421
421
|
|
|
422
422
|
2. **Random waveform state** needs special handling - must track previous phase to detect step changes.
|
|
423
423
|
|
|
424
|
-
3. **ONE mode stopping**
|
|
424
|
+
3. **ONE mode stopping** occurs immediately when phase wraps (cycleCount >= 1), NOT when returning to start phase. This was verified against Digitakt II hardware - non-zero startPhase results in partial amplitude coverage.
|
|
425
425
|
|
|
426
426
|
4. **HLF mode** stops at the phase 0.5 beyond start phase (wrapping), not absolute 0.5.
|
|
427
427
|
|
|
428
|
-
5. **HLD mode** captures the current output when triggered and holds it until the next trigger
|
|
428
|
+
5. **HLD mode** captures the current output when triggered and holds it until the next trigger. The LFO continues running in background. Note: Digitakt hardware only sends MIDI CC when the held value CHANGES between triggers - if multiple triggers capture the same value, only one CC is sent.
|
|
429
429
|
|
|
430
|
-
6. **Fade timing** is relative to LFO cycles, not absolute time.
|
|
430
|
+
6. **Fade timing** is relative to LFO cycles, not absolute time. Hardware-verified formula: Linear region (|FADE| ≤ 16): `cycles = 0.1 * |FADE| + 0.6`; Exponential region (|FADE| > 16): `cycles = 2.2 * 2^((|FADE| - 16) / 4.5)`. Higher |FADE| = slower fade.
|
|
431
431
|
|
|
432
|
-
7. **Fade resets** on trigger for TRG, ONE, and HLF modes. FRE mode does not reset fade.
|
|
432
|
+
7. **Fade resets** on trigger for TRG, ONE, and HLF modes. FRE mode does not reset fade, and importantly, fade does NOT work in FRE mode at all (requires a trigger to initiate).
|
|
433
433
|
|
|
434
434
|
8. **60fps update rate** is sufficient for visualization but may need adjustment for audio-rate LFO applications.
|
|
435
435
|
|
package/docs/TEST_QUESTIONS.md
CHANGED
|
@@ -541,6 +541,79 @@ Use this checklist to track test completion:
|
|
|
541
541
|
|
|
542
542
|
---
|
|
543
543
|
|
|
544
|
+
## Hardware-Verified Findings (January 2026)
|
|
545
|
+
|
|
546
|
+
The following behaviors were verified against real Digitakt II hardware via MIDI CC monitoring.
|
|
547
|
+
|
|
548
|
+
### ONE Mode Stop Behavior
|
|
549
|
+
|
|
550
|
+
**Finding:** ONE mode stops immediately when phase wraps (cycleCount >= 1), NOT when phase returns to startPhase.
|
|
551
|
+
|
|
552
|
+
**Implications:**
|
|
553
|
+
- Non-zero startPhase results in partial amplitude coverage before wrap
|
|
554
|
+
- Phase=0: full waveform traversed before wrap
|
|
555
|
+
- Phase=32 (90°): full amplitude range (peak to trough)
|
|
556
|
+
- Phase=64 (180°): half amplitude range (starts at middle)
|
|
557
|
+
- Phase=96 (270°): half amplitude range (starts at trough)
|
|
558
|
+
|
|
559
|
+
**Test Updates:** TEST 2 and TEST 17 expected results should be revised - the LFO stops on phase wrap, not when returning to start phase.
|
|
560
|
+
|
|
561
|
+
### HLD Mode MIDI Behavior
|
|
562
|
+
|
|
563
|
+
**Finding:** The Digitakt only sends MIDI CC messages when the held value CHANGES between triggers.
|
|
564
|
+
|
|
565
|
+
**Behavior:**
|
|
566
|
+
- LFO continues running in background between triggers
|
|
567
|
+
- Each trigger captures and holds the current LFO value
|
|
568
|
+
- If multiple triggers capture the same value, only one CC is sent
|
|
569
|
+
- Verification: expect ≤N unique CC values for N triggers, all within valid range
|
|
570
|
+
|
|
571
|
+
**Test Updates:** TEST 5 verification should account for this - you may receive fewer CCs than triggers if values repeat.
|
|
572
|
+
|
|
573
|
+
### HLF Mode (Half Cycle)
|
|
574
|
+
|
|
575
|
+
**Finding:** HLF runs for exactly 0.5 phase distance then stops.
|
|
576
|
+
|
|
577
|
+
**Verification:**
|
|
578
|
+
- For TRI with startPhase=0: goes from middle to peak only = 50% amplitude range
|
|
579
|
+
- Correctly outputs half the expected full-cycle range
|
|
580
|
+
|
|
581
|
+
### Fade Formula (Empirical)
|
|
582
|
+
|
|
583
|
+
**Finding:** The fade envelope follows a piecewise formula:
|
|
584
|
+
|
|
585
|
+
```
|
|
586
|
+
Linear region (|FADE| ≤ 16): cycles = 0.1 * |FADE| + 0.6
|
|
587
|
+
Exponential region (|FADE| > 16): cycles = 2.2 * 2^((|FADE| - 16) / 4.5)
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
**Key observations:**
|
|
591
|
+
- Higher |FADE| = SLOWER fade (more cycles to complete)
|
|
592
|
+
- NO "disabled" threshold - even |FADE|=63 fades, just very slowly (~3000 cycles)
|
|
593
|
+
- Fade does NOT work in FRE mode (requires trigger to initiate)
|
|
594
|
+
- Fade-out: Cycle 1 has full amplitude; fade effect starts from cycle 2
|
|
595
|
+
- Each trigger resets fade progress to 0
|
|
596
|
+
|
|
597
|
+
**Formula validated at extremes (60-second tests):**
|
|
598
|
+
- FADE=-48 (304 cycles to complete): observed 20% progress ✓
|
|
599
|
+
- FADE=-56 (1043 cycles): observed 6% progress ✓
|
|
600
|
+
- FADE=-63 (3066 cycles): observed 2% progress ✓
|
|
601
|
+
|
|
602
|
+
**Measured values:**
|
|
603
|
+
| FADE | Cycles |
|
|
604
|
+
|------|--------|
|
|
605
|
+
| 4 | ~1 |
|
|
606
|
+
| 8 | ~1.4 |
|
|
607
|
+
| 16 | ~2.2 |
|
|
608
|
+
| 24 | ~7.5 |
|
|
609
|
+
| 32 | ~26 |
|
|
610
|
+
| 48 | ~300 |
|
|
611
|
+
| 63 | ~3000 |
|
|
612
|
+
|
|
613
|
+
**Test Updates:** TEST 8 and TEST 12 expected results should use this formula for accurate predictions.
|
|
614
|
+
|
|
615
|
+
---
|
|
616
|
+
|
|
544
617
|
## Document Metadata
|
|
545
618
|
|
|
546
619
|
- **Created:** Based on DIGITAKT_II_LFO_SPEC.md and DIGITAKT_II_LFO_PRESETS.md
|
package/package.json
CHANGED
package/src/engine/lfo.ts
CHANGED
|
@@ -67,13 +67,25 @@ export class LFO {
|
|
|
67
67
|
let newPhase = this.state.phase + phaseIncrement * deltaMs;
|
|
68
68
|
|
|
69
69
|
// Wrap phase to 0-1 range
|
|
70
|
+
let cycleCompleted = false;
|
|
70
71
|
if (newPhase >= 1) {
|
|
71
72
|
newPhase = newPhase % 1;
|
|
72
73
|
this.state.cycleCount++;
|
|
74
|
+
cycleCompleted = true;
|
|
73
75
|
} else if (newPhase < 0) {
|
|
74
76
|
newPhase = 1 + (newPhase % 1);
|
|
75
77
|
if (newPhase === 1) newPhase = 0;
|
|
76
78
|
this.state.cycleCount++;
|
|
79
|
+
cycleCompleted = true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Regenerate RND waveform values at cycle end (for FRE/TRG modes only)
|
|
83
|
+
// ONE/HLF modes don't cycle - they stop, and only regenerate on retrigger
|
|
84
|
+
if (cycleCompleted && this.config.waveform === 'RND') {
|
|
85
|
+
if (this.config.mode === 'FRE' || this.config.mode === 'TRG') {
|
|
86
|
+
this.state.randomValue = Math.random() * 2 - 1;
|
|
87
|
+
this.state.randomStep = Math.floor(newPhase * 16);
|
|
88
|
+
}
|
|
77
89
|
}
|
|
78
90
|
|
|
79
91
|
// Check for mode-based stopping (ONE/HLF)
|
|
@@ -86,9 +98,15 @@ export class LFO {
|
|
|
86
98
|
|
|
87
99
|
if (stopCheck.shouldStop) {
|
|
88
100
|
this.state.isRunning = false;
|
|
89
|
-
// Snap to stop position
|
|
101
|
+
// Snap to stop position - stay at END of waveform curve, not start
|
|
102
|
+
// Hardware verified (Jan 2026): ONE mode stops at the visual end of the curve
|
|
103
|
+
// With SPH offset, you traverse a partial curve (SPH → end), not a full cycle
|
|
90
104
|
if (this.config.mode === 'ONE') {
|
|
91
|
-
|
|
105
|
+
// Phase=1 is the end of the waveform curve
|
|
106
|
+
// Final output values for unipolar waveforms (EXP/RMP):
|
|
107
|
+
// speed+ depth+ → 0, speed+ depth- → 0
|
|
108
|
+
// speed- depth+ → +1, speed- depth- → -1
|
|
109
|
+
newPhase = 1;
|
|
92
110
|
} else if (this.config.mode === 'HLF') {
|
|
93
111
|
newPhase = (this.state.startPhaseNormalized + 0.5) % 1;
|
|
94
112
|
}
|
|
@@ -134,9 +152,20 @@ export class LFO {
|
|
|
134
152
|
effectiveRawOutput = this.state.heldOutput;
|
|
135
153
|
}
|
|
136
154
|
|
|
137
|
-
//
|
|
155
|
+
// Handle negative speed: flip waveform direction
|
|
156
|
+
// For unipolar waveforms (EXP, RMP), flip shape (1-x) so decay becomes attack
|
|
157
|
+
// For bipolar waveforms, invert sign (-x) to reverse direction
|
|
158
|
+
// This matches Digitakt II hardware behavior:
|
|
159
|
+
// EXP speed+ depth+ = +1→0, speed- depth+ = 0→+1
|
|
160
|
+
// EXP speed+ depth- = -1→0, speed- depth- = 0→-1
|
|
138
161
|
if (this.config.speed < 0) {
|
|
139
|
-
|
|
162
|
+
if (isUnipolar(this.config.waveform)) {
|
|
163
|
+
// Flip unipolar waveform: decay (1→0) becomes attack (0→1)
|
|
164
|
+
effectiveRawOutput = 1 - effectiveRawOutput;
|
|
165
|
+
} else {
|
|
166
|
+
// Invert bipolar waveform
|
|
167
|
+
effectiveRawOutput = -effectiveRawOutput;
|
|
168
|
+
}
|
|
140
169
|
}
|
|
141
170
|
|
|
142
171
|
// Apply depth
|
package/src/engine/timing.ts
CHANGED
|
@@ -101,10 +101,11 @@ export function calculateNoteValue(product: number): string {
|
|
|
101
101
|
|
|
102
102
|
// For products < 128: slower than 1 bar
|
|
103
103
|
const bars = 128 / product;
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
104
|
+
// Round to 1 decimal place, then drop trailing .0 for whole numbers
|
|
105
|
+
const rounded = Math.round(bars * 10) / 10;
|
|
106
|
+
const isWhole = rounded === Math.floor(rounded);
|
|
107
|
+
const barsStr = isWhole ? String(Math.floor(rounded)) : rounded.toFixed(1);
|
|
108
|
+
return `${barsStr} bars`;
|
|
108
109
|
}
|
|
109
110
|
|
|
110
111
|
/**
|
|
@@ -161,7 +161,10 @@ describe('Negative Speed - RMP waveform (unipolar)', () => {
|
|
|
161
161
|
expect(stateMid.rawOutput).toBeCloseTo(0.5, 1);
|
|
162
162
|
});
|
|
163
163
|
|
|
164
|
-
test('negative speed RMP has
|
|
164
|
+
test('negative speed RMP has flipped output (1 to 0) - Digitakt II verified', () => {
|
|
165
|
+
// Hardware behavior: negative speed flips unipolar waveforms
|
|
166
|
+
// RMP normally: 0→1 (ramp up)
|
|
167
|
+
// RMP speed-: 1→0 (ramp down) via 1-x transformation
|
|
165
168
|
const lfo = new LFO({
|
|
166
169
|
waveform: 'RMP',
|
|
167
170
|
speed: -16,
|
|
@@ -173,15 +176,15 @@ describe('Negative Speed - RMP waveform (unipolar)', () => {
|
|
|
173
176
|
lfo.update(0);
|
|
174
177
|
const stateStart = lfo.update(1);
|
|
175
178
|
|
|
176
|
-
// rawOutput is still 0 (RMP at phase 0), output is
|
|
179
|
+
// rawOutput is still 0 (RMP at phase 0), but output is flipped: 1-0=1
|
|
177
180
|
expect(stateStart.rawOutput).toBeCloseTo(0, 1);
|
|
178
|
-
expect(stateStart.output).toBeCloseTo(
|
|
181
|
+
expect(stateStart.output).toBeCloseTo(1, 1); // Flipped to 1
|
|
179
182
|
|
|
180
|
-
// After half cycle, rawOutput is 0.5,
|
|
183
|
+
// After half cycle, rawOutput is 0.5, flipped to 0.5
|
|
181
184
|
const cycleMs = lfo.getTimingInfo().cycleTimeMs;
|
|
182
185
|
const stateMid = lfo.update(cycleMs * 0.5);
|
|
183
186
|
expect(stateMid.rawOutput).toBeCloseTo(0.5, 1);
|
|
184
|
-
expect(stateMid.output).
|
|
187
|
+
expect(stateMid.output).toBeCloseTo(0.5, 1); // 1-0.5=0.5, still positive
|
|
185
188
|
});
|
|
186
189
|
});
|
|
187
190
|
|
|
@@ -208,7 +211,10 @@ describe('Negative Speed - EXP waveform (unipolar)', () => {
|
|
|
208
211
|
expect(stateEnd.rawOutput).toBeLessThan(0.2);
|
|
209
212
|
});
|
|
210
213
|
|
|
211
|
-
test('negative speed EXP has
|
|
214
|
+
test('negative speed EXP has flipped output (0 to 1) - Digitakt II verified', () => {
|
|
215
|
+
// Hardware behavior: negative speed flips unipolar waveforms
|
|
216
|
+
// EXP normally: 1→0 (decay)
|
|
217
|
+
// EXP speed-: 0→1 (attack/swell) via 1-x transformation
|
|
212
218
|
const lfo = new LFO({
|
|
213
219
|
waveform: 'EXP',
|
|
214
220
|
speed: -16,
|
|
@@ -220,9 +226,14 @@ describe('Negative Speed - EXP waveform (unipolar)', () => {
|
|
|
220
226
|
lfo.update(0);
|
|
221
227
|
const stateStart = lfo.update(1);
|
|
222
228
|
|
|
223
|
-
// rawOutput is still 1 (EXP at phase 0), output is
|
|
229
|
+
// rawOutput is still 1 (EXP at phase 0), but output is flipped: 1-1=0
|
|
224
230
|
expect(stateStart.rawOutput).toBeCloseTo(1, 1);
|
|
225
|
-
expect(stateStart.output).
|
|
231
|
+
expect(stateStart.output).toBeCloseTo(0, 1); // Flipped to 0
|
|
232
|
+
|
|
233
|
+
// Near end of cycle, rawOutput approaches 0, flipped to 1
|
|
234
|
+
const cycleMs = lfo.getTimingInfo().cycleTimeMs;
|
|
235
|
+
const stateEnd = lfo.update(cycleMs * 0.95);
|
|
236
|
+
expect(stateEnd.output).toBeGreaterThan(0.8); // Flipped: near 1
|
|
226
237
|
});
|
|
227
238
|
});
|
|
228
239
|
|