elektron-lfo 1.0.0

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.
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Trigger mode handling for Elektron Digitakt II LFO
3
+ *
4
+ * Trigger Modes:
5
+ * - FRE (Free): LFO runs continuously, triggers ignored
6
+ * - TRG (Trigger): Restarts LFO phase and fade on trigger
7
+ * - HLD (Hold): Captures and holds output on trigger, LFO continues in background
8
+ * - ONE (One-shot): Runs one complete cycle then stops, can be retriggered
9
+ * - HLF (Half): Runs half cycle then stops, can be retriggered
10
+ */
11
+
12
+ import type { LFOConfig, LFOState, TriggerMode } from './types';
13
+
14
+ /**
15
+ * Handle a trigger event based on the current mode
16
+ * Returns updated state after trigger processing
17
+ */
18
+ export function handleTrigger(
19
+ config: LFOConfig,
20
+ state: LFOState,
21
+ currentRawOutput: number
22
+ ): LFOState {
23
+ const newState = { ...state };
24
+ newState.triggerCount++;
25
+
26
+ switch (config.mode) {
27
+ case 'FRE':
28
+ // Free running - triggers are ignored
29
+ // LFO continues without interruption
30
+ break;
31
+
32
+ case 'TRG':
33
+ // Trigger mode - reset phase and fade
34
+ newState.phase = newState.startPhaseNormalized;
35
+ newState.previousPhase = newState.startPhaseNormalized;
36
+ newState.fadeProgress = 0;
37
+ newState.fadeMultiplier = config.fade < 0 ? 0 : 1;
38
+ newState.cycleCount = 0;
39
+ // Generate new random value on trigger for RND waveform
40
+ if (config.waveform === 'RND') {
41
+ newState.randomValue = Math.random() * 2 - 1;
42
+ newState.randomStep = Math.floor(newState.phase * 16);
43
+ }
44
+ break;
45
+
46
+ case 'HLD':
47
+ // Hold mode - capture current output, LFO continues in background
48
+ newState.heldOutput = currentRawOutput;
49
+ // Note: Phase continues running, only output is held
50
+ // Fade resets on trigger
51
+ newState.fadeProgress = 0;
52
+ newState.fadeMultiplier = config.fade < 0 ? 0 : 1;
53
+ break;
54
+
55
+ case 'ONE':
56
+ // One-shot mode - reset and run one complete cycle
57
+ newState.phase = newState.startPhaseNormalized;
58
+ newState.previousPhase = newState.startPhaseNormalized;
59
+ newState.isRunning = true;
60
+ newState.hasTriggered = true;
61
+ newState.fadeProgress = 0;
62
+ newState.fadeMultiplier = config.fade < 0 ? 0 : 1;
63
+ newState.cycleCount = 0;
64
+ if (config.waveform === 'RND') {
65
+ newState.randomValue = Math.random() * 2 - 1;
66
+ newState.randomStep = Math.floor(newState.phase * 16);
67
+ }
68
+ break;
69
+
70
+ case 'HLF':
71
+ // Half mode - reset and run half cycle
72
+ newState.phase = newState.startPhaseNormalized;
73
+ newState.previousPhase = newState.startPhaseNormalized;
74
+ newState.isRunning = true;
75
+ newState.hasTriggered = true;
76
+ newState.fadeProgress = 0;
77
+ newState.fadeMultiplier = config.fade < 0 ? 0 : 1;
78
+ newState.cycleCount = 0;
79
+ if (config.waveform === 'RND') {
80
+ newState.randomValue = Math.random() * 2 - 1;
81
+ newState.randomStep = Math.floor(newState.phase * 16);
82
+ }
83
+ break;
84
+ }
85
+
86
+ return newState;
87
+ }
88
+
89
+ /**
90
+ * Check if the LFO should stop based on mode and phase
91
+ *
92
+ * For ONE mode: Stop when phase wraps and returns to start phase (one complete cycle)
93
+ * For HLF mode: Stop when phase reaches 0.5 past start phase (half cycle)
94
+ */
95
+ export function checkModeStop(
96
+ config: LFOConfig,
97
+ state: LFOState,
98
+ previousPhase: number,
99
+ currentPhase: number
100
+ ): { shouldStop: boolean; cycleCompleted: boolean } {
101
+ // Only ONE and HLF modes can stop
102
+ if (config.mode !== 'ONE' && config.mode !== 'HLF') {
103
+ return { shouldStop: false, cycleCompleted: false };
104
+ }
105
+
106
+ // Need to have been triggered to run
107
+ if (!state.hasTriggered) {
108
+ return { shouldStop: true, cycleCompleted: false };
109
+ }
110
+
111
+ const startPhase = state.startPhaseNormalized;
112
+ const isForward = config.speed >= 0;
113
+
114
+ if (config.mode === 'ONE') {
115
+ // ONE mode: Stop after completing one full cycle
116
+ // We detect this by checking if cycleCount has incremented (phase wrapped)
117
+ // and we've returned to or passed the start phase
118
+ if (state.cycleCount >= 1) {
119
+ return { shouldStop: true, cycleCompleted: true };
120
+ }
121
+ } else if (config.mode === 'HLF') {
122
+ // HLF mode: Stop after half cycle (0.5 phase distance from start)
123
+ const halfPhase = (startPhase + 0.5) % 1;
124
+
125
+ if (isForward) {
126
+ // Check if we crossed the half-point
127
+ if (startPhase < 0.5) {
128
+ // Half point is greater than start (no wrap needed)
129
+ if (previousPhase < halfPhase && currentPhase >= halfPhase) {
130
+ return { shouldStop: true, cycleCompleted: true };
131
+ }
132
+ } else {
133
+ // Half point wraps around through 0 (halfPhase < startPhase)
134
+ // We need to cross 1->0 boundary first, then reach halfPhase
135
+ if (state.cycleCount >= 1 || (previousPhase < halfPhase && currentPhase >= halfPhase)) {
136
+ return { shouldStop: true, cycleCompleted: true };
137
+ }
138
+ }
139
+ } else {
140
+ // Backward direction: halfPhase is 0.5 BEHIND start
141
+ const halfPhaseBackward = (startPhase - 0.5 + 1) % 1;
142
+ if (startPhase >= 0.5) {
143
+ // Half point is less than start (no wrap needed)
144
+ if (previousPhase > halfPhaseBackward && currentPhase <= halfPhaseBackward) {
145
+ return { shouldStop: true, cycleCompleted: true };
146
+ }
147
+ } else {
148
+ // Half point wraps around through 1
149
+ if (state.cycleCount >= 1 || (previousPhase > halfPhaseBackward && currentPhase <= halfPhaseBackward)) {
150
+ return { shouldStop: true, cycleCompleted: true };
151
+ }
152
+ }
153
+ }
154
+ }
155
+
156
+ return { shouldStop: false, cycleCompleted: false };
157
+ }
158
+
159
+ /**
160
+ * Check if a trigger mode requires a trigger to start
161
+ */
162
+ export function requiresTriggerToStart(mode: TriggerMode): boolean {
163
+ return mode === 'ONE' || mode === 'HLF';
164
+ }
165
+
166
+ /**
167
+ * Check if a trigger mode resets phase on trigger
168
+ */
169
+ export function resetsPhaseOnTrigger(mode: TriggerMode): boolean {
170
+ return mode === 'TRG' || mode === 'ONE' || mode === 'HLF';
171
+ }
172
+
173
+ /**
174
+ * Check if a trigger mode resets fade on trigger
175
+ */
176
+ export function resetsFadeOnTrigger(mode: TriggerMode): boolean {
177
+ // FRE mode doesn't reset fade (fade doesn't work in FRE mode per spec)
178
+ return mode !== 'FRE';
179
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Elektron Digitakt II LFO Engine Types
3
+ */
4
+
5
+ /** Available LFO waveform types */
6
+ export type Waveform = 'TRI' | 'SIN' | 'SQR' | 'SAW' | 'EXP' | 'RMP' | 'RND';
7
+
8
+ /** LFO trigger/behavior modes */
9
+ export type TriggerMode = 'FRE' | 'TRG' | 'HLD' | 'ONE' | 'HLF';
10
+
11
+ /** Available tempo multiplier values */
12
+ export type Multiplier = 1 | 2 | 4 | 8 | 16 | 32 | 64 | 128 | 256 | 512 | 1024 | 2048;
13
+
14
+ /** LFO configuration parameters */
15
+ export interface LFOConfig {
16
+ /** Waveform type */
17
+ waveform: Waveform;
18
+ /** Speed parameter: -64.00 to +63.00 */
19
+ speed: number;
20
+ /** Tempo multiplier */
21
+ multiplier: Multiplier;
22
+ /** Use fixed 120 BPM instead of project BPM */
23
+ useFixedBPM: boolean;
24
+ /** Start phase: 0 to 127 (maps to 0-360 degrees) */
25
+ startPhase: number;
26
+ /** Trigger/behavior mode */
27
+ mode: TriggerMode;
28
+ /** Modulation depth: -64.00 to +63.00 */
29
+ depth: number;
30
+ /** Fade amount: -64 to +63 (negative = fade in, positive = fade out) */
31
+ fade: number;
32
+ }
33
+
34
+ /** LFO runtime state */
35
+ export interface LFOState {
36
+ /** Current phase position: 0.0 to 1.0 */
37
+ phase: number;
38
+ /** Final output value after depth and fade processing */
39
+ output: number;
40
+ /** Raw waveform output before depth/fade processing */
41
+ rawOutput: number;
42
+ /** Whether the LFO is currently running */
43
+ isRunning: boolean;
44
+ /** Current fade envelope multiplier: 0.0 to 1.0 */
45
+ fadeMultiplier: number;
46
+ /** Fade envelope progress: 0.0 to 1.0 */
47
+ fadeProgress: number;
48
+ /** Current random value for RND waveform */
49
+ randomValue: number;
50
+ /** Previous phase value for detecting step changes */
51
+ previousPhase: number;
52
+ /** Held output value for HLD mode */
53
+ heldOutput: number;
54
+ /** Normalized start phase: 0.0 to 1.0 */
55
+ startPhaseNormalized: number;
56
+ /** Number of complete cycles */
57
+ cycleCount: number;
58
+ /** Number of triggers received */
59
+ triggerCount: number;
60
+ /** Whether the LFO has been triggered at least once (for ONE/HLF modes) */
61
+ hasTriggered: boolean;
62
+ /** Current random step (0-15 for 16 steps per cycle) */
63
+ randomStep: number;
64
+ }
65
+
66
+ /** Timing information for an LFO configuration */
67
+ export interface TimingInfo {
68
+ /** Duration of one complete cycle in milliseconds */
69
+ cycleTimeMs: number;
70
+ /** Musical note value representation (e.g., "1/4", "1 bar", "4 bars") */
71
+ noteValue: string;
72
+ /** LFO frequency in Hz */
73
+ frequencyHz: number;
74
+ /** Number of LFO cycles per bar */
75
+ cyclesPerBar: number;
76
+ /** Product of |SPD| × MULT */
77
+ product: number;
78
+ }
79
+
80
+ /** Default LFO configuration */
81
+ export const DEFAULT_CONFIG: LFOConfig = {
82
+ waveform: 'TRI',
83
+ speed: 16,
84
+ multiplier: 8,
85
+ useFixedBPM: false,
86
+ startPhase: 0,
87
+ mode: 'FRE',
88
+ depth: 63,
89
+ fade: 0,
90
+ };
91
+
92
+ /** Create initial LFO state */
93
+ export function createInitialState(config: LFOConfig): LFOState {
94
+ const startPhaseNormalized = config.startPhase / 128;
95
+ return {
96
+ phase: startPhaseNormalized,
97
+ output: 0,
98
+ rawOutput: 0,
99
+ isRunning: true,
100
+ fadeMultiplier: config.fade < 0 ? 0 : 1, // Fade in starts at 0, fade out starts at 1
101
+ fadeProgress: 0,
102
+ randomValue: Math.random() * 2 - 1, // Initial random value: -1 to +1
103
+ previousPhase: startPhaseNormalized,
104
+ heldOutput: 0,
105
+ startPhaseNormalized,
106
+ cycleCount: 0,
107
+ triggerCount: 0,
108
+ hasTriggered: false,
109
+ randomStep: 0,
110
+ };
111
+ }
112
+
113
+ /** Clamp a value to a range */
114
+ export function clamp(value: number, min: number, max: number): number {
115
+ return Math.max(min, Math.min(max, value));
116
+ }
117
+
118
+ /** Valid multiplier values for validation */
119
+ export const VALID_MULTIPLIERS: readonly Multiplier[] = [
120
+ 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048,
121
+ ] as const;
122
+
123
+ /** Check if a number is a valid multiplier */
124
+ export function isValidMultiplier(value: number): value is Multiplier {
125
+ return VALID_MULTIPLIERS.includes(value as Multiplier);
126
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Waveform generators for Elektron Digitakt II LFO
3
+ *
4
+ * Waveform types:
5
+ * - Bipolar (-1 to +1): TRI, SIN, SQR, SAW, RND
6
+ * - Unipolar (0 to +1): EXP, RMP
7
+ */
8
+
9
+ import type { Waveform, LFOState } from './types';
10
+
11
+ /**
12
+ * Triangle waveform - Bipolar
13
+ * Starts at 0, peaks at +1 at phase 0.25, troughs at -1 at phase 0.75
14
+ */
15
+ export function generateTriangle(phase: number): number {
16
+ if (phase < 0.25) {
17
+ return phase * 4; // 0 to +1
18
+ }
19
+ if (phase < 0.75) {
20
+ return 1 - (phase - 0.25) * 4; // +1 to -1
21
+ }
22
+ return -1 + (phase - 0.75) * 4; // -1 to 0
23
+ }
24
+
25
+ /**
26
+ * Sine waveform - Bipolar
27
+ * Standard sine wave starting at 0
28
+ */
29
+ export function generateSine(phase: number): number {
30
+ return Math.sin(phase * 2 * Math.PI);
31
+ }
32
+
33
+ /**
34
+ * Square waveform - Bipolar
35
+ * +1 for first half, -1 for second half
36
+ */
37
+ export function generateSquare(phase: number): number {
38
+ return phase < 0.5 ? 1 : -1;
39
+ }
40
+
41
+ /**
42
+ * Sawtooth waveform - Bipolar
43
+ * Linear rise from -1 to +1
44
+ */
45
+ export function generateSawtooth(phase: number): number {
46
+ return phase * 2 - 1;
47
+ }
48
+
49
+ /**
50
+ * Exponential waveform - Unipolar (0 to +1)
51
+ * Accelerating curve from 0 to 1
52
+ */
53
+ export function generateExponential(phase: number): number {
54
+ const k = 4; // Steepness factor
55
+ return (Math.exp(phase * k) - 1) / (Math.exp(k) - 1);
56
+ }
57
+
58
+ /**
59
+ * Ramp waveform - Unipolar (0 to +1)
60
+ * Linear fall from +1 to 0
61
+ */
62
+ export function generateRamp(phase: number): number {
63
+ return 1 - phase;
64
+ }
65
+
66
+ /**
67
+ * Random waveform - Bipolar (-1 to +1)
68
+ * Sample-and-hold with 16 steps per cycle (16x frequency)
69
+ *
70
+ * Returns the current random value and potentially a new random value
71
+ * if a step boundary was crossed.
72
+ */
73
+ export function generateRandom(
74
+ phase: number,
75
+ state: LFOState
76
+ ): { value: number; newRandomValue: number; newRandomStep: number } {
77
+ // 16 steps per cycle
78
+ const stepsPerCycle = 16;
79
+ const currentStep = Math.floor(phase * stepsPerCycle);
80
+
81
+ // Check if we crossed a step boundary
82
+ if (currentStep !== state.randomStep) {
83
+ // Generate new random value between -1 and +1
84
+ const newRandomValue = Math.random() * 2 - 1;
85
+ return {
86
+ value: newRandomValue,
87
+ newRandomValue,
88
+ newRandomStep: currentStep,
89
+ };
90
+ }
91
+
92
+ // No step change, return current value
93
+ return {
94
+ value: state.randomValue,
95
+ newRandomValue: state.randomValue,
96
+ newRandomStep: state.randomStep,
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Generate waveform output for a given phase and waveform type
102
+ */
103
+ export function generateWaveform(
104
+ waveform: Waveform,
105
+ phase: number,
106
+ state: LFOState
107
+ ): { value: number; newRandomValue?: number; newRandomStep?: number } {
108
+ switch (waveform) {
109
+ case 'TRI':
110
+ return { value: generateTriangle(phase) };
111
+ case 'SIN':
112
+ return { value: generateSine(phase) };
113
+ case 'SQR':
114
+ return { value: generateSquare(phase) };
115
+ case 'SAW':
116
+ return { value: generateSawtooth(phase) };
117
+ case 'EXP':
118
+ return { value: generateExponential(phase) };
119
+ case 'RMP':
120
+ return { value: generateRamp(phase) };
121
+ case 'RND': {
122
+ const result = generateRandom(phase, state);
123
+ return {
124
+ value: result.value,
125
+ newRandomValue: result.newRandomValue,
126
+ newRandomStep: result.newRandomStep,
127
+ };
128
+ }
129
+ default: {
130
+ // Exhaustive check
131
+ const _exhaustive: never = waveform;
132
+ throw new Error(`Unknown waveform: ${_exhaustive}`);
133
+ }
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Check if a waveform is unipolar (0 to +1) vs bipolar (-1 to +1)
139
+ */
140
+ export function isUnipolar(waveform: Waveform): boolean {
141
+ return waveform === 'EXP' || waveform === 'RMP';
142
+ }
143
+
144
+ /**
145
+ * Get the range of a waveform
146
+ */
147
+ export function getWaveformRange(waveform: Waveform): { min: number; max: number } {
148
+ if (isUnipolar(waveform)) {
149
+ return { min: 0, max: 1 };
150
+ }
151
+ return { min: -1, max: 1 };
152
+ }
package/src/index.ts ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Elektron LFO
3
+ *
4
+ * A TypeScript implementation of the Elektron Digitakt II LFO engine
5
+ * with real-time terminal visualization.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { LFO } from 'elektron-lfo';
10
+ *
11
+ * const lfo = new LFO({
12
+ * waveform: 'SIN',
13
+ * speed: 16,
14
+ * multiplier: 8,
15
+ * mode: 'TRG',
16
+ * depth: 63,
17
+ * }, 120);
18
+ *
19
+ * // Update at 60fps
20
+ * setInterval(() => {
21
+ * const state = lfo.update(performance.now());
22
+ * console.log('Output:', state.output);
23
+ * }, 1000 / 60);
24
+ *
25
+ * // Trigger the LFO
26
+ * lfo.trigger();
27
+ * ```
28
+ */
29
+
30
+ // Re-export everything from engine
31
+ export * from './engine';