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 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
- if (bars === Math.floor(bars)) {
181
- return `${bars} bars`;
182
- }
183
- return `${bars.toFixed(1)} bars`;
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 = this.state.startPhaseNormalized;
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
- effectiveRawOutput = -effectiveRawOutput;
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** requires careful detection of phase wrapping past the start phase. Must work for both positive AND negative speed.
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, but the LFO continues running in the background.
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. This ensures consistency across BPM changes.
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
 
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "elektron-lfo",
3
- "version": "1.0.13",
3
+ "version": "1.0.16",
4
4
  "description": "Elektron LFO engine simulator implementation with CLI visualization",
5
5
  "main": "dist/index.js",
6
6
  "module": "src/index.ts",
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
- newPhase = this.state.startPhaseNormalized;
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
- // Invert output for negative speed (phase still runs forward)
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
- effectiveRawOutput = -effectiveRawOutput;
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
@@ -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
- if (bars === Math.floor(bars)) {
105
- return `${bars} bars`;
106
- }
107
- return `${bars.toFixed(1)} bars`;
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 inverted output (0 to -1)', () => {
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 negated to 0
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(0, 1); // -0 = 0
181
+ expect(stateStart.output).toBeCloseTo(1, 1); // Flipped to 1
179
182
 
180
- // After half cycle, rawOutput is 0.5, output is -0.5 * depth
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).toBeLessThan(0); // Negated
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 inverted output (-1 to 0)', () => {
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 negated to -1
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).toBeLessThan(-0.9); // Inverted!
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