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,137 @@
1
+ /**
2
+ * Fade envelope system for Elektron Digitakt II LFO
3
+ *
4
+ * Fade behavior:
5
+ * - FADE = 0: No fade, full modulation immediately
6
+ * - FADE < 0 (negative): Fade IN - starts at 0, increases to full over |FADE| cycles
7
+ * - FADE > 0 (positive): Fade OUT - starts at full, decreases to 0 over FADE cycles
8
+ *
9
+ * Important notes from research:
10
+ * - Fade does NOT work in FRE mode (requires trigger to initiate)
11
+ * - Fade resets on trigger for TRG, ONE, HLF, and HLD modes
12
+ * - Fade timing is relative to LFO cycles, not absolute time
13
+ */
14
+
15
+ import type { LFOConfig, LFOState, TriggerMode } from './types';
16
+
17
+ /**
18
+ * Calculate the fade multiplier based on fade progress
19
+ *
20
+ * @param fadeValue - The FADE parameter (-64 to +63)
21
+ * @param fadeProgress - Progress through the fade (0.0 to 1.0)
22
+ * @returns The fade multiplier (0.0 to 1.0)
23
+ */
24
+ export function calculateFadeMultiplier(
25
+ fadeValue: number,
26
+ fadeProgress: number
27
+ ): number {
28
+ if (fadeValue === 0) {
29
+ // No fade - always full modulation
30
+ return 1;
31
+ }
32
+
33
+ // Clamp progress to 0-1 range
34
+ const progress = Math.max(0, Math.min(1, fadeProgress));
35
+
36
+ if (fadeValue < 0) {
37
+ // Fade IN: starts at 0, increases to 1
38
+ // Linear interpolation from 0 to 1
39
+ return progress;
40
+ } else {
41
+ // Fade OUT: starts at 1, decreases to 0
42
+ // Linear interpolation from 1 to 0
43
+ return 1 - progress;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Calculate fade cycles - how many LFO cycles for complete fade
49
+ *
50
+ * The FADE parameter (-64 to +63) maps to fade duration in cycles:
51
+ * - |FADE| / 64 gives the number of cycles (approximately)
52
+ * - At |FADE| = 64, fade takes 1 cycle
53
+ * - At |FADE| = 32, fade takes 0.5 cycles
54
+ * - At |FADE| = 1, fade takes ~1/64 of a cycle
55
+ */
56
+ export function calculateFadeCycles(fadeValue: number): number {
57
+ if (fadeValue === 0) return 0;
58
+ // Map |FADE| to cycles: |FADE| / 64 cycles
59
+ // Maximum fade (64) = 1 cycle, minimum (1) = 1/64 cycle
60
+ return Math.abs(fadeValue) / 64;
61
+ }
62
+
63
+ /**
64
+ * Update fade progress based on elapsed time
65
+ *
66
+ * @param config - LFO configuration
67
+ * @param state - Current LFO state
68
+ * @param cycleTimeMs - Duration of one LFO cycle in milliseconds
69
+ * @param deltaMs - Time elapsed since last update
70
+ * @returns Updated fade progress and multiplier
71
+ */
72
+ export function updateFade(
73
+ config: LFOConfig,
74
+ state: LFOState,
75
+ cycleTimeMs: number,
76
+ deltaMs: number
77
+ ): { fadeProgress: number; fadeMultiplier: number } {
78
+ // No fade or FRE mode - fade doesn't work in FRE mode
79
+ if (config.fade === 0 || config.mode === 'FRE') {
80
+ return {
81
+ fadeProgress: 1,
82
+ fadeMultiplier: 1,
83
+ };
84
+ }
85
+
86
+ // Calculate how many cycles the fade takes
87
+ const fadeCycles = calculateFadeCycles(config.fade);
88
+ const fadeDurationMs = fadeCycles * cycleTimeMs;
89
+
90
+ if (fadeDurationMs === 0 || fadeDurationMs === Infinity) {
91
+ return {
92
+ fadeProgress: 1,
93
+ fadeMultiplier: config.fade < 0 ? 0 : 1,
94
+ };
95
+ }
96
+
97
+ // Calculate progress increment
98
+ const progressIncrement = deltaMs / fadeDurationMs;
99
+ const newProgress = Math.min(1, state.fadeProgress + progressIncrement);
100
+
101
+ return {
102
+ fadeProgress: newProgress,
103
+ fadeMultiplier: calculateFadeMultiplier(config.fade, newProgress),
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Reset fade state (called on trigger for modes that reset fade)
109
+ */
110
+ export function resetFade(config: LFOConfig): { fadeProgress: number; fadeMultiplier: number } {
111
+ if (config.fade === 0) {
112
+ return { fadeProgress: 1, fadeMultiplier: 1 };
113
+ }
114
+
115
+ if (config.fade < 0) {
116
+ // Fade IN: start at 0
117
+ return { fadeProgress: 0, fadeMultiplier: 0 };
118
+ } else {
119
+ // Fade OUT: start at 1
120
+ return { fadeProgress: 0, fadeMultiplier: 1 };
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Check if fade should reset on trigger for a given mode
126
+ */
127
+ export function shouldResetFadeOnTrigger(mode: TriggerMode): boolean {
128
+ // FRE mode never resets fade (and fade doesn't work in FRE mode)
129
+ return mode !== 'FRE';
130
+ }
131
+
132
+ /**
133
+ * Apply fade multiplier to output value
134
+ */
135
+ export function applyFade(rawOutput: number, fadeMultiplier: number): number {
136
+ return rawOutput * fadeMultiplier;
137
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Elektron LFO Engine
3
+ *
4
+ * A TypeScript implementation of the Elektron Digitakt II LFO engine
5
+ */
6
+
7
+ // Main LFO class
8
+ export { LFO } from './lfo';
9
+
10
+ // Types
11
+ export type {
12
+ Waveform,
13
+ TriggerMode,
14
+ Multiplier,
15
+ LFOConfig,
16
+ LFOState,
17
+ TimingInfo,
18
+ } from './types';
19
+
20
+ export {
21
+ DEFAULT_CONFIG,
22
+ createInitialState,
23
+ clamp,
24
+ VALID_MULTIPLIERS,
25
+ isValidMultiplier,
26
+ } from './types';
27
+
28
+ // Waveform functions
29
+ export {
30
+ generateTriangle,
31
+ generateSine,
32
+ generateSquare,
33
+ generateSawtooth,
34
+ generateExponential,
35
+ generateRamp,
36
+ generateRandom,
37
+ generateWaveform,
38
+ isUnipolar,
39
+ getWaveformRange,
40
+ } from './waveforms';
41
+
42
+ // Timing functions
43
+ export {
44
+ calculateProduct,
45
+ calculateCycleTimeMs,
46
+ calculateFrequencyHz,
47
+ calculatePhaseIncrement,
48
+ calculateCyclesPerBar,
49
+ calculateNoteValue,
50
+ calculateTimingInfo,
51
+ formatCycleTime,
52
+ formatFrequency,
53
+ } from './timing';
54
+
55
+ // Trigger functions
56
+ export {
57
+ handleTrigger,
58
+ checkModeStop,
59
+ requiresTriggerToStart,
60
+ resetsPhaseOnTrigger,
61
+ resetsFadeOnTrigger,
62
+ } from './triggers';
63
+
64
+ // Fade functions
65
+ export {
66
+ calculateFadeMultiplier,
67
+ calculateFadeCycles,
68
+ updateFade,
69
+ resetFade,
70
+ shouldResetFadeOnTrigger,
71
+ applyFade,
72
+ } from './fade';
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Main LFO class for Elektron Digitakt II LFO engine
3
+ *
4
+ * This class ties together all the components:
5
+ * - Waveform generation
6
+ * - Timing calculations
7
+ * - Trigger mode handling
8
+ * - Fade envelope
9
+ */
10
+
11
+ import type { LFOConfig, LFOState, TimingInfo } from './types';
12
+ import { DEFAULT_CONFIG, createInitialState, clamp } from './types';
13
+ import { generateWaveform, isUnipolar } from './waveforms';
14
+ import {
15
+ calculatePhaseIncrement,
16
+ calculateTimingInfo,
17
+ calculateCycleTimeMs,
18
+ } from './timing';
19
+ import { handleTrigger, checkModeStop } from './triggers';
20
+ import { updateFade, resetFade } from './fade';
21
+
22
+ export class LFO {
23
+ private config: LFOConfig;
24
+ private state: LFOState;
25
+ private bpm: number;
26
+ private lastUpdateTime: number;
27
+
28
+ constructor(config: Partial<LFOConfig> = {}, bpm: number = 120) {
29
+ this.config = { ...DEFAULT_CONFIG, ...config };
30
+ this.bpm = bpm;
31
+ this.state = createInitialState(this.config);
32
+ this.lastUpdateTime = 0;
33
+
34
+ // For FRE mode, fade is always 1 (fade doesn't work in FRE mode)
35
+ if (this.config.mode === 'FRE') {
36
+ this.state.fadeMultiplier = 1;
37
+ this.state.fadeProgress = 1;
38
+ }
39
+
40
+ // For ONE/HLF modes, don't run until triggered
41
+ if (this.config.mode === 'ONE' || this.config.mode === 'HLF') {
42
+ this.state.isRunning = false;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Update the LFO state based on elapsed time
48
+ *
49
+ * @param currentTimeMs - Current time in milliseconds (e.g., performance.now())
50
+ * @returns The current LFO state
51
+ */
52
+ update(currentTimeMs: number): LFOState {
53
+ // Calculate delta time
54
+ const deltaMs = this.lastUpdateTime === 0 ? 0 : currentTimeMs - this.lastUpdateTime;
55
+ this.lastUpdateTime = currentTimeMs;
56
+
57
+ // Skip phase update if not running (but still generate output)
58
+ const shouldUpdatePhase = this.state.isRunning && deltaMs > 0;
59
+
60
+ // Calculate timing
61
+ const cycleTimeMs = calculateCycleTimeMs(this.config, this.bpm);
62
+ const phaseIncrement = calculatePhaseIncrement(this.config, this.bpm);
63
+
64
+ // Update phase if running
65
+ if (shouldUpdatePhase) {
66
+ const previousPhase = this.state.phase;
67
+ let newPhase = this.state.phase + phaseIncrement * deltaMs;
68
+
69
+ // Wrap phase to 0-1 range
70
+ if (newPhase >= 1) {
71
+ newPhase = newPhase % 1;
72
+ this.state.cycleCount++;
73
+ } else if (newPhase < 0) {
74
+ newPhase = 1 + (newPhase % 1);
75
+ if (newPhase === 1) newPhase = 0;
76
+ this.state.cycleCount++;
77
+ }
78
+
79
+ // Check for mode-based stopping (ONE/HLF)
80
+ const stopCheck = checkModeStop(
81
+ this.config,
82
+ this.state,
83
+ previousPhase,
84
+ newPhase
85
+ );
86
+
87
+ if (stopCheck.shouldStop) {
88
+ this.state.isRunning = false;
89
+ // Snap to stop position
90
+ if (this.config.mode === 'ONE') {
91
+ newPhase = this.state.startPhaseNormalized;
92
+ } else if (this.config.mode === 'HLF') {
93
+ newPhase = (this.state.startPhaseNormalized + 0.5) % 1;
94
+ }
95
+ }
96
+
97
+ this.state.previousPhase = previousPhase;
98
+ this.state.phase = newPhase;
99
+ }
100
+
101
+ // Generate waveform output
102
+ const waveformResult = generateWaveform(
103
+ this.config.waveform,
104
+ this.state.phase,
105
+ this.state
106
+ );
107
+
108
+ // Update random state if needed
109
+ if (waveformResult.newRandomValue !== undefined) {
110
+ this.state.randomValue = waveformResult.newRandomValue;
111
+ }
112
+ if (waveformResult.newRandomStep !== undefined) {
113
+ this.state.randomStep = waveformResult.newRandomStep;
114
+ }
115
+
116
+ this.state.rawOutput = waveformResult.value;
117
+
118
+ // Update fade (only if running and time has passed)
119
+ if (shouldUpdatePhase) {
120
+ const fadeResult = updateFade(
121
+ this.config,
122
+ this.state,
123
+ cycleTimeMs,
124
+ deltaMs
125
+ );
126
+ this.state.fadeProgress = fadeResult.fadeProgress;
127
+ this.state.fadeMultiplier = fadeResult.fadeMultiplier;
128
+ }
129
+
130
+ // Calculate final output
131
+ // For HLD mode, use held output
132
+ let effectiveRawOutput = this.state.rawOutput;
133
+ if (this.config.mode === 'HLD' && this.state.triggerCount > 0) {
134
+ effectiveRawOutput = this.state.heldOutput;
135
+ }
136
+
137
+ // Apply depth
138
+ // Depth scales the output: depth of 63 = 100%, depth of 0 = 0%
139
+ // Negative depth inverts the waveform
140
+ const depthScale = this.config.depth / 63;
141
+ let scaledOutput = effectiveRawOutput * depthScale;
142
+
143
+ // Apply fade
144
+ scaledOutput *= this.state.fadeMultiplier;
145
+
146
+ // For unipolar waveforms with negative depth, clamp to valid range
147
+ if (isUnipolar(this.config.waveform)) {
148
+ // Unipolar waveforms output 0 to 1
149
+ // With negative depth, they output 0 to -1
150
+ // The output range becomes -1 to +1 depending on depth sign
151
+ }
152
+
153
+ this.state.output = scaledOutput;
154
+
155
+ return { ...this.state };
156
+ }
157
+
158
+ /**
159
+ * Trigger the LFO
160
+ */
161
+ trigger(): void {
162
+ this.state = handleTrigger(this.config, this.state, this.state.rawOutput);
163
+ }
164
+
165
+ /**
166
+ * Get current state
167
+ */
168
+ getState(): LFOState {
169
+ return { ...this.state };
170
+ }
171
+
172
+ /**
173
+ * Get current configuration
174
+ */
175
+ getConfig(): LFOConfig {
176
+ return { ...this.config };
177
+ }
178
+
179
+ /**
180
+ * Update configuration
181
+ */
182
+ setConfig(config: Partial<LFOConfig>): void {
183
+ const previousMode = this.config.mode;
184
+ this.config = { ...this.config, ...config };
185
+
186
+ // Update start phase normalization if startPhase changed
187
+ if (config.startPhase !== undefined) {
188
+ this.state.startPhaseNormalized = config.startPhase / 128;
189
+ }
190
+
191
+ // If switching to ONE/HLF mode, stop until triggered
192
+ if (
193
+ (config.mode === 'ONE' || config.mode === 'HLF') &&
194
+ previousMode !== config.mode
195
+ ) {
196
+ this.state.isRunning = false;
197
+ this.state.hasTriggered = false;
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Set BPM
203
+ */
204
+ setBpm(bpm: number): void {
205
+ this.bpm = clamp(bpm, 1, 999);
206
+ }
207
+
208
+ /**
209
+ * Get current BPM
210
+ */
211
+ getBpm(): number {
212
+ return this.bpm;
213
+ }
214
+
215
+ /**
216
+ * Get timing information
217
+ */
218
+ getTimingInfo(): TimingInfo {
219
+ return calculateTimingInfo(this.config, this.bpm);
220
+ }
221
+
222
+ /**
223
+ * Reset the LFO to initial state
224
+ */
225
+ reset(): void {
226
+ this.state = createInitialState(this.config);
227
+
228
+ // For ONE/HLF modes, don't run until triggered
229
+ if (this.config.mode === 'ONE' || this.config.mode === 'HLF') {
230
+ this.state.isRunning = false;
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Check if the LFO is running
236
+ */
237
+ isRunning(): boolean {
238
+ return this.state.isRunning;
239
+ }
240
+
241
+ /**
242
+ * Get the current output value
243
+ */
244
+ getOutput(): number {
245
+ return this.state.output;
246
+ }
247
+
248
+ /**
249
+ * Get the current phase (0-1)
250
+ */
251
+ getPhase(): number {
252
+ return this.state.phase;
253
+ }
254
+
255
+ /**
256
+ * Start the LFO (for ONE/HLF modes that are stopped)
257
+ */
258
+ start(): void {
259
+ this.state.isRunning = true;
260
+ this.state.hasTriggered = true;
261
+ }
262
+
263
+ /**
264
+ * Stop the LFO
265
+ */
266
+ stop(): void {
267
+ this.state.isRunning = false;
268
+ }
269
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Timing calculations for Elektron Digitakt II LFO
3
+ *
4
+ * Core formulas from the spec:
5
+ * - phase_steps_per_bar = |SPD| × MULT
6
+ * - cycle_time_ms = (60000 / BPM) × 4 × (128 / (|SPD| × MULT))
7
+ * - frequency_hz = (BPM / 60) × (|SPD| × MULT / 128)
8
+ */
9
+
10
+ import type { LFOConfig, TimingInfo } from './types';
11
+
12
+ /**
13
+ * Calculate the product of |SPD| × MULT
14
+ */
15
+ export function calculateProduct(config: LFOConfig): number {
16
+ return Math.abs(config.speed) * config.multiplier;
17
+ }
18
+
19
+ /**
20
+ * Calculate cycle time in milliseconds
21
+ *
22
+ * Formula: cycle_time_ms = (60000 / BPM) × 4 × (128 / product)
23
+ */
24
+ export function calculateCycleTimeMs(config: LFOConfig, bpm: number): number {
25
+ const effectiveBpm = config.useFixedBPM ? 120 : bpm;
26
+ const product = calculateProduct(config);
27
+
28
+ if (product === 0) {
29
+ return Infinity; // Speed of 0 means infinite cycle time
30
+ }
31
+
32
+ return (60000 / effectiveBpm) * 4 * (128 / product);
33
+ }
34
+
35
+ /**
36
+ * Calculate LFO frequency in Hz
37
+ *
38
+ * Frequency is simply 1 / cycleTime in seconds
39
+ * frequency_hz = 1000 / cycleTimeMs
40
+ */
41
+ export function calculateFrequencyHz(config: LFOConfig, bpm: number): number {
42
+ const cycleTimeMs = calculateCycleTimeMs(config, bpm);
43
+
44
+ if (cycleTimeMs === Infinity || cycleTimeMs === 0) {
45
+ return 0;
46
+ }
47
+
48
+ return 1000 / cycleTimeMs;
49
+ }
50
+
51
+ /**
52
+ * Calculate phase increment per millisecond
53
+ *
54
+ * Phase goes from 0 to 1 over one cycle
55
+ * Increment = 1 / cycle_time_ms (for positive speed)
56
+ * Increment = -1 / cycle_time_ms (for negative speed)
57
+ */
58
+ export function calculatePhaseIncrement(config: LFOConfig, bpm: number): number {
59
+ const cycleTimeMs = calculateCycleTimeMs(config, bpm);
60
+
61
+ if (cycleTimeMs === Infinity || cycleTimeMs === 0) {
62
+ return 0;
63
+ }
64
+
65
+ // Negative speed runs phase backwards
66
+ const direction = config.speed >= 0 ? 1 : -1;
67
+ return direction / cycleTimeMs;
68
+ }
69
+
70
+ /**
71
+ * Calculate cycles per bar
72
+ *
73
+ * At product = 128, we get exactly 1 cycle per bar
74
+ * At product > 128, we get product/128 cycles per bar
75
+ * At product < 128, we get product/128 cycles per bar (fraction)
76
+ */
77
+ export function calculateCyclesPerBar(config: LFOConfig): number {
78
+ const product = calculateProduct(config);
79
+ if (product === 0) return 0;
80
+ return product / 128;
81
+ }
82
+
83
+ /**
84
+ * Convert product to musical note value string
85
+ */
86
+ export function calculateNoteValue(product: number): string {
87
+ if (product === 0) return '∞';
88
+
89
+ // Product to note value mapping
90
+ // 128 = 1 bar (whole note in 4/4)
91
+ // 256 = 1/2 bar (half note)
92
+ // 512 = 1/4 note
93
+ // 1024 = 1/8 note
94
+ // 2048 = 1/16 note
95
+
96
+ // For products > 128: faster than 1 bar
97
+ if (product >= 2048) return '1/16';
98
+ if (product >= 1024) return '1/8';
99
+ if (product >= 512) return '1/4';
100
+ if (product >= 256) return '1/2';
101
+ if (product >= 128) return '1 bar';
102
+
103
+ // For products < 128: slower than 1 bar
104
+ const bars = 128 / product;
105
+ if (bars === Math.floor(bars)) {
106
+ return `${bars} bars`;
107
+ }
108
+ return `${bars.toFixed(1)} bars`;
109
+ }
110
+
111
+ /**
112
+ * Calculate complete timing information
113
+ */
114
+ export function calculateTimingInfo(config: LFOConfig, bpm: number): TimingInfo {
115
+ const product = calculateProduct(config);
116
+ const cycleTimeMs = calculateCycleTimeMs(config, bpm);
117
+ const frequencyHz = calculateFrequencyHz(config, bpm);
118
+ const cyclesPerBar = calculateCyclesPerBar(config);
119
+ const noteValue = calculateNoteValue(product);
120
+
121
+ return {
122
+ cycleTimeMs,
123
+ noteValue,
124
+ frequencyHz,
125
+ cyclesPerBar,
126
+ product,
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Format cycle time for display
132
+ */
133
+ export function formatCycleTime(cycleTimeMs: number): string {
134
+ if (cycleTimeMs === Infinity) return '∞';
135
+ if (cycleTimeMs >= 60000) {
136
+ const minutes = cycleTimeMs / 60000;
137
+ return `${minutes.toFixed(1)}min`;
138
+ }
139
+ if (cycleTimeMs >= 1000) {
140
+ return `${(cycleTimeMs / 1000).toFixed(2)}s`;
141
+ }
142
+ return `${cycleTimeMs.toFixed(1)}ms`;
143
+ }
144
+
145
+ /**
146
+ * Format frequency for display
147
+ */
148
+ export function formatFrequency(frequencyHz: number): string {
149
+ if (frequencyHz === 0) return '0 Hz';
150
+ if (frequencyHz < 0.01) {
151
+ return `${(frequencyHz * 1000).toFixed(3)} mHz`;
152
+ }
153
+ if (frequencyHz < 1) {
154
+ return `${frequencyHz.toFixed(3)} Hz`;
155
+ }
156
+ return `${frequencyHz.toFixed(2)} Hz`;
157
+ }