elektron-lfo 1.0.0 → 1.0.2

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,60 @@
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
+ import type { LFOConfig, LFOState, TriggerMode } from './types';
15
+ /**
16
+ * Calculate the fade multiplier based on fade progress
17
+ *
18
+ * @param fadeValue - The FADE parameter (-64 to +63)
19
+ * @param fadeProgress - Progress through the fade (0.0 to 1.0)
20
+ * @returns The fade multiplier (0.0 to 1.0)
21
+ */
22
+ export declare function calculateFadeMultiplier(fadeValue: number, fadeProgress: number): number;
23
+ /**
24
+ * Calculate fade cycles - how many LFO cycles for complete fade
25
+ *
26
+ * The FADE parameter (-64 to +63) maps to fade duration in cycles:
27
+ * - |FADE| / 64 gives the number of cycles (approximately)
28
+ * - At |FADE| = 64, fade takes 1 cycle
29
+ * - At |FADE| = 32, fade takes 0.5 cycles
30
+ * - At |FADE| = 1, fade takes ~1/64 of a cycle
31
+ */
32
+ export declare function calculateFadeCycles(fadeValue: number): number;
33
+ /**
34
+ * Update fade progress based on elapsed time
35
+ *
36
+ * @param config - LFO configuration
37
+ * @param state - Current LFO state
38
+ * @param cycleTimeMs - Duration of one LFO cycle in milliseconds
39
+ * @param deltaMs - Time elapsed since last update
40
+ * @returns Updated fade progress and multiplier
41
+ */
42
+ export declare function updateFade(config: LFOConfig, state: LFOState, cycleTimeMs: number, deltaMs: number): {
43
+ fadeProgress: number;
44
+ fadeMultiplier: number;
45
+ };
46
+ /**
47
+ * Reset fade state (called on trigger for modes that reset fade)
48
+ */
49
+ export declare function resetFade(config: LFOConfig): {
50
+ fadeProgress: number;
51
+ fadeMultiplier: number;
52
+ };
53
+ /**
54
+ * Check if fade should reset on trigger for a given mode
55
+ */
56
+ export declare function shouldResetFadeOnTrigger(mode: TriggerMode): boolean;
57
+ /**
58
+ * Apply fade multiplier to output value
59
+ */
60
+ export declare function applyFade(rawOutput: number, fadeMultiplier: number): number;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Elektron LFO Engine
3
+ *
4
+ * A TypeScript implementation of the Elektron Digitakt II LFO engine
5
+ */
6
+ export { LFO } from './lfo';
7
+ export type { Waveform, TriggerMode, Multiplier, LFOConfig, LFOState, TimingInfo, } from './types';
8
+ export { DEFAULT_CONFIG, createInitialState, clamp, VALID_MULTIPLIERS, isValidMultiplier, } from './types';
9
+ export { generateTriangle, generateSine, generateSquare, generateSawtooth, generateExponential, generateRamp, generateRandom, generateWaveform, isUnipolar, getWaveformRange, } from './waveforms';
10
+ export { calculateProduct, calculateCycleTimeMs, calculateFrequencyHz, calculatePhaseIncrement, calculateCyclesPerBar, calculateNoteValue, calculateTimingInfo, formatCycleTime, formatFrequency, } from './timing';
11
+ export { handleTrigger, checkModeStop, requiresTriggerToStart, resetsPhaseOnTrigger, resetsFadeOnTrigger, } from './triggers';
12
+ export { calculateFadeMultiplier, calculateFadeCycles, updateFade, resetFade, shouldResetFadeOnTrigger, applyFade, } from './fade';
@@ -0,0 +1,76 @@
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
+ import type { LFOConfig, LFOState, TimingInfo } from './types';
11
+ export declare class LFO {
12
+ private config;
13
+ private state;
14
+ private bpm;
15
+ private lastUpdateTime;
16
+ constructor(config?: Partial<LFOConfig>, bpm?: number);
17
+ /**
18
+ * Update the LFO state based on elapsed time
19
+ *
20
+ * @param currentTimeMs - Current time in milliseconds (e.g., performance.now())
21
+ * @returns The current LFO state
22
+ */
23
+ update(currentTimeMs: number): LFOState;
24
+ /**
25
+ * Trigger the LFO
26
+ */
27
+ trigger(): void;
28
+ /**
29
+ * Get current state
30
+ */
31
+ getState(): LFOState;
32
+ /**
33
+ * Get current configuration
34
+ */
35
+ getConfig(): LFOConfig;
36
+ /**
37
+ * Update configuration
38
+ */
39
+ setConfig(config: Partial<LFOConfig>): void;
40
+ /**
41
+ * Set BPM
42
+ */
43
+ setBpm(bpm: number): void;
44
+ /**
45
+ * Get current BPM
46
+ */
47
+ getBpm(): number;
48
+ /**
49
+ * Get timing information
50
+ */
51
+ getTimingInfo(): TimingInfo;
52
+ /**
53
+ * Reset the LFO to initial state
54
+ */
55
+ reset(): void;
56
+ /**
57
+ * Check if the LFO is running
58
+ */
59
+ isRunning(): boolean;
60
+ /**
61
+ * Get the current output value
62
+ */
63
+ getOutput(): number;
64
+ /**
65
+ * Get the current phase (0-1)
66
+ */
67
+ getPhase(): number;
68
+ /**
69
+ * Start the LFO (for ONE/HLF modes that are stopped)
70
+ */
71
+ start(): void;
72
+ /**
73
+ * Stop the LFO
74
+ */
75
+ stop(): void;
76
+ }
@@ -0,0 +1,58 @@
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
+ import type { LFOConfig, TimingInfo } from './types';
10
+ /**
11
+ * Calculate the product of |SPD| × MULT
12
+ */
13
+ export declare function calculateProduct(config: LFOConfig): number;
14
+ /**
15
+ * Calculate cycle time in milliseconds
16
+ *
17
+ * Formula: cycle_time_ms = (60000 / BPM) × 4 × (128 / product)
18
+ */
19
+ export declare function calculateCycleTimeMs(config: LFOConfig, bpm: number): number;
20
+ /**
21
+ * Calculate LFO frequency in Hz
22
+ *
23
+ * Frequency is simply 1 / cycleTime in seconds
24
+ * frequency_hz = 1000 / cycleTimeMs
25
+ */
26
+ export declare function calculateFrequencyHz(config: LFOConfig, bpm: number): number;
27
+ /**
28
+ * Calculate phase increment per millisecond
29
+ *
30
+ * Phase goes from 0 to 1 over one cycle
31
+ * Increment = 1 / cycle_time_ms (for positive speed)
32
+ * Increment = -1 / cycle_time_ms (for negative speed)
33
+ */
34
+ export declare function calculatePhaseIncrement(config: LFOConfig, bpm: number): number;
35
+ /**
36
+ * Calculate cycles per bar
37
+ *
38
+ * At product = 128, we get exactly 1 cycle per bar
39
+ * At product > 128, we get product/128 cycles per bar
40
+ * At product < 128, we get product/128 cycles per bar (fraction)
41
+ */
42
+ export declare function calculateCyclesPerBar(config: LFOConfig): number;
43
+ /**
44
+ * Convert product to musical note value string
45
+ */
46
+ export declare function calculateNoteValue(product: number): string;
47
+ /**
48
+ * Calculate complete timing information
49
+ */
50
+ export declare function calculateTimingInfo(config: LFOConfig, bpm: number): TimingInfo;
51
+ /**
52
+ * Format cycle time for display
53
+ */
54
+ export declare function formatCycleTime(cycleTimeMs: number): string;
55
+ /**
56
+ * Format frequency for display
57
+ */
58
+ export declare function formatFrequency(frequencyHz: number): string;
@@ -0,0 +1,38 @@
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
+ import type { LFOConfig, LFOState, TriggerMode } from './types';
12
+ /**
13
+ * Handle a trigger event based on the current mode
14
+ * Returns updated state after trigger processing
15
+ */
16
+ export declare function handleTrigger(config: LFOConfig, state: LFOState, currentRawOutput: number): LFOState;
17
+ /**
18
+ * Check if the LFO should stop based on mode and phase
19
+ *
20
+ * For ONE mode: Stop when phase wraps and returns to start phase (one complete cycle)
21
+ * For HLF mode: Stop when phase reaches 0.5 past start phase (half cycle)
22
+ */
23
+ export declare function checkModeStop(config: LFOConfig, state: LFOState, previousPhase: number, currentPhase: number): {
24
+ shouldStop: boolean;
25
+ cycleCompleted: boolean;
26
+ };
27
+ /**
28
+ * Check if a trigger mode requires a trigger to start
29
+ */
30
+ export declare function requiresTriggerToStart(mode: TriggerMode): boolean;
31
+ /**
32
+ * Check if a trigger mode resets phase on trigger
33
+ */
34
+ export declare function resetsPhaseOnTrigger(mode: TriggerMode): boolean;
35
+ /**
36
+ * Check if a trigger mode resets fade on trigger
37
+ */
38
+ export declare function resetsFadeOnTrigger(mode: TriggerMode): boolean;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Elektron Digitakt II LFO Engine Types
3
+ */
4
+ /** Available LFO waveform types */
5
+ export type Waveform = 'TRI' | 'SIN' | 'SQR' | 'SAW' | 'EXP' | 'RMP' | 'RND';
6
+ /** LFO trigger/behavior modes */
7
+ export type TriggerMode = 'FRE' | 'TRG' | 'HLD' | 'ONE' | 'HLF';
8
+ /** Available tempo multiplier values */
9
+ export type Multiplier = 1 | 2 | 4 | 8 | 16 | 32 | 64 | 128 | 256 | 512 | 1024 | 2048;
10
+ /** LFO configuration parameters */
11
+ export interface LFOConfig {
12
+ /** Waveform type */
13
+ waveform: Waveform;
14
+ /** Speed parameter: -64.00 to +63.00 */
15
+ speed: number;
16
+ /** Tempo multiplier */
17
+ multiplier: Multiplier;
18
+ /** Use fixed 120 BPM instead of project BPM */
19
+ useFixedBPM: boolean;
20
+ /** Start phase: 0 to 127 (maps to 0-360 degrees) */
21
+ startPhase: number;
22
+ /** Trigger/behavior mode */
23
+ mode: TriggerMode;
24
+ /** Modulation depth: -64.00 to +63.00 */
25
+ depth: number;
26
+ /** Fade amount: -64 to +63 (negative = fade in, positive = fade out) */
27
+ fade: number;
28
+ }
29
+ /** LFO runtime state */
30
+ export interface LFOState {
31
+ /** Current phase position: 0.0 to 1.0 */
32
+ phase: number;
33
+ /** Final output value after depth and fade processing */
34
+ output: number;
35
+ /** Raw waveform output before depth/fade processing */
36
+ rawOutput: number;
37
+ /** Whether the LFO is currently running */
38
+ isRunning: boolean;
39
+ /** Current fade envelope multiplier: 0.0 to 1.0 */
40
+ fadeMultiplier: number;
41
+ /** Fade envelope progress: 0.0 to 1.0 */
42
+ fadeProgress: number;
43
+ /** Current random value for RND waveform */
44
+ randomValue: number;
45
+ /** Previous phase value for detecting step changes */
46
+ previousPhase: number;
47
+ /** Held output value for HLD mode */
48
+ heldOutput: number;
49
+ /** Normalized start phase: 0.0 to 1.0 */
50
+ startPhaseNormalized: number;
51
+ /** Number of complete cycles */
52
+ cycleCount: number;
53
+ /** Number of triggers received */
54
+ triggerCount: number;
55
+ /** Whether the LFO has been triggered at least once (for ONE/HLF modes) */
56
+ hasTriggered: boolean;
57
+ /** Current random step (0-15 for 16 steps per cycle) */
58
+ randomStep: number;
59
+ }
60
+ /** Timing information for an LFO configuration */
61
+ export interface TimingInfo {
62
+ /** Duration of one complete cycle in milliseconds */
63
+ cycleTimeMs: number;
64
+ /** Musical note value representation (e.g., "1/4", "1 bar", "4 bars") */
65
+ noteValue: string;
66
+ /** LFO frequency in Hz */
67
+ frequencyHz: number;
68
+ /** Number of LFO cycles per bar */
69
+ cyclesPerBar: number;
70
+ /** Product of |SPD| × MULT */
71
+ product: number;
72
+ }
73
+ /** Default LFO configuration */
74
+ export declare const DEFAULT_CONFIG: LFOConfig;
75
+ /** Create initial LFO state */
76
+ export declare function createInitialState(config: LFOConfig): LFOState;
77
+ /** Clamp a value to a range */
78
+ export declare function clamp(value: number, min: number, max: number): number;
79
+ /** Valid multiplier values for validation */
80
+ export declare const VALID_MULTIPLIERS: readonly Multiplier[];
81
+ /** Check if a number is a valid multiplier */
82
+ export declare function isValidMultiplier(value: number): value is Multiplier;
@@ -0,0 +1,69 @@
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
+ import type { Waveform, LFOState } from './types';
9
+ /**
10
+ * Triangle waveform - Bipolar
11
+ * Starts at 0, peaks at +1 at phase 0.25, troughs at -1 at phase 0.75
12
+ */
13
+ export declare function generateTriangle(phase: number): number;
14
+ /**
15
+ * Sine waveform - Bipolar
16
+ * Standard sine wave starting at 0
17
+ */
18
+ export declare function generateSine(phase: number): number;
19
+ /**
20
+ * Square waveform - Bipolar
21
+ * +1 for first half, -1 for second half
22
+ */
23
+ export declare function generateSquare(phase: number): number;
24
+ /**
25
+ * Sawtooth waveform - Bipolar
26
+ * Linear rise from -1 to +1
27
+ */
28
+ export declare function generateSawtooth(phase: number): number;
29
+ /**
30
+ * Exponential waveform - Unipolar (0 to +1)
31
+ * Accelerating curve from 0 to 1
32
+ */
33
+ export declare function generateExponential(phase: number): number;
34
+ /**
35
+ * Ramp waveform - Unipolar (0 to +1)
36
+ * Linear fall from +1 to 0
37
+ */
38
+ export declare function generateRamp(phase: number): number;
39
+ /**
40
+ * Random waveform - Bipolar (-1 to +1)
41
+ * Sample-and-hold with 16 steps per cycle (16x frequency)
42
+ *
43
+ * Returns the current random value and potentially a new random value
44
+ * if a step boundary was crossed.
45
+ */
46
+ export declare function generateRandom(phase: number, state: LFOState): {
47
+ value: number;
48
+ newRandomValue: number;
49
+ newRandomStep: number;
50
+ };
51
+ /**
52
+ * Generate waveform output for a given phase and waveform type
53
+ */
54
+ export declare function generateWaveform(waveform: Waveform, phase: number, state: LFOState): {
55
+ value: number;
56
+ newRandomValue?: number;
57
+ newRandomStep?: number;
58
+ };
59
+ /**
60
+ * Check if a waveform is unipolar (0 to +1) vs bipolar (-1 to +1)
61
+ */
62
+ export declare function isUnipolar(waveform: Waveform): boolean;
63
+ /**
64
+ * Get the range of a waveform
65
+ */
66
+ export declare function getWaveformRange(waveform: Waveform): {
67
+ min: number;
68
+ max: number;
69
+ };
@@ -0,0 +1,29 @@
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
+ export * from './engine';
package/dist/index.js ADDED
@@ -0,0 +1,543 @@
1
+ // src/engine/types.ts
2
+ var DEFAULT_CONFIG = {
3
+ waveform: "TRI",
4
+ speed: 16,
5
+ multiplier: 8,
6
+ useFixedBPM: false,
7
+ startPhase: 0,
8
+ mode: "FRE",
9
+ depth: 63,
10
+ fade: 0
11
+ };
12
+ function createInitialState(config) {
13
+ const startPhaseNormalized = config.startPhase / 128;
14
+ return {
15
+ phase: startPhaseNormalized,
16
+ output: 0,
17
+ rawOutput: 0,
18
+ isRunning: true,
19
+ fadeMultiplier: config.fade < 0 ? 0 : 1,
20
+ fadeProgress: 0,
21
+ randomValue: Math.random() * 2 - 1,
22
+ previousPhase: startPhaseNormalized,
23
+ heldOutput: 0,
24
+ startPhaseNormalized,
25
+ cycleCount: 0,
26
+ triggerCount: 0,
27
+ hasTriggered: false,
28
+ randomStep: 0
29
+ };
30
+ }
31
+ function clamp(value, min, max) {
32
+ return Math.max(min, Math.min(max, value));
33
+ }
34
+ var VALID_MULTIPLIERS = [
35
+ 1,
36
+ 2,
37
+ 4,
38
+ 8,
39
+ 16,
40
+ 32,
41
+ 64,
42
+ 128,
43
+ 256,
44
+ 512,
45
+ 1024,
46
+ 2048
47
+ ];
48
+ function isValidMultiplier(value) {
49
+ return VALID_MULTIPLIERS.includes(value);
50
+ }
51
+
52
+ // src/engine/waveforms.ts
53
+ function generateTriangle(phase) {
54
+ if (phase < 0.25) {
55
+ return phase * 4;
56
+ }
57
+ if (phase < 0.75) {
58
+ return 1 - (phase - 0.25) * 4;
59
+ }
60
+ return -1 + (phase - 0.75) * 4;
61
+ }
62
+ function generateSine(phase) {
63
+ return Math.sin(phase * 2 * Math.PI);
64
+ }
65
+ function generateSquare(phase) {
66
+ return phase < 0.5 ? 1 : -1;
67
+ }
68
+ function generateSawtooth(phase) {
69
+ return phase * 2 - 1;
70
+ }
71
+ function generateExponential(phase) {
72
+ const k = 4;
73
+ return (Math.exp(phase * k) - 1) / (Math.exp(k) - 1);
74
+ }
75
+ function generateRamp(phase) {
76
+ return 1 - phase;
77
+ }
78
+ function generateRandom(phase, state) {
79
+ const stepsPerCycle = 16;
80
+ const currentStep = Math.floor(phase * stepsPerCycle);
81
+ if (currentStep !== state.randomStep) {
82
+ const newRandomValue = Math.random() * 2 - 1;
83
+ return {
84
+ value: newRandomValue,
85
+ newRandomValue,
86
+ newRandomStep: currentStep
87
+ };
88
+ }
89
+ return {
90
+ value: state.randomValue,
91
+ newRandomValue: state.randomValue,
92
+ newRandomStep: state.randomStep
93
+ };
94
+ }
95
+ function generateWaveform(waveform, phase, state) {
96
+ switch (waveform) {
97
+ case "TRI":
98
+ return { value: generateTriangle(phase) };
99
+ case "SIN":
100
+ return { value: generateSine(phase) };
101
+ case "SQR":
102
+ return { value: generateSquare(phase) };
103
+ case "SAW":
104
+ return { value: generateSawtooth(phase) };
105
+ case "EXP":
106
+ return { value: generateExponential(phase) };
107
+ case "RMP":
108
+ return { value: generateRamp(phase) };
109
+ case "RND": {
110
+ const result = generateRandom(phase, state);
111
+ return {
112
+ value: result.value,
113
+ newRandomValue: result.newRandomValue,
114
+ newRandomStep: result.newRandomStep
115
+ };
116
+ }
117
+ default: {
118
+ const _exhaustive = waveform;
119
+ throw new Error(`Unknown waveform: ${_exhaustive}`);
120
+ }
121
+ }
122
+ }
123
+ function isUnipolar(waveform) {
124
+ return waveform === "EXP" || waveform === "RMP";
125
+ }
126
+ function getWaveformRange(waveform) {
127
+ if (isUnipolar(waveform)) {
128
+ return { min: 0, max: 1 };
129
+ }
130
+ return { min: -1, max: 1 };
131
+ }
132
+
133
+ // src/engine/timing.ts
134
+ function calculateProduct(config) {
135
+ return Math.abs(config.speed) * config.multiplier;
136
+ }
137
+ function calculateCycleTimeMs(config, bpm) {
138
+ const effectiveBpm = config.useFixedBPM ? 120 : bpm;
139
+ const product = calculateProduct(config);
140
+ if (product === 0) {
141
+ return Infinity;
142
+ }
143
+ return 60000 / effectiveBpm * 4 * (128 / product);
144
+ }
145
+ function calculateFrequencyHz(config, bpm) {
146
+ const cycleTimeMs = calculateCycleTimeMs(config, bpm);
147
+ if (cycleTimeMs === Infinity || cycleTimeMs === 0) {
148
+ return 0;
149
+ }
150
+ return 1000 / cycleTimeMs;
151
+ }
152
+ function calculatePhaseIncrement(config, bpm) {
153
+ const cycleTimeMs = calculateCycleTimeMs(config, bpm);
154
+ if (cycleTimeMs === Infinity || cycleTimeMs === 0) {
155
+ return 0;
156
+ }
157
+ const direction = config.speed >= 0 ? 1 : -1;
158
+ return direction / cycleTimeMs;
159
+ }
160
+ function calculateCyclesPerBar(config) {
161
+ const product = calculateProduct(config);
162
+ if (product === 0)
163
+ return 0;
164
+ return product / 128;
165
+ }
166
+ function calculateNoteValue(product) {
167
+ if (product === 0)
168
+ return "∞";
169
+ if (product >= 2048)
170
+ return "1/16";
171
+ if (product >= 1024)
172
+ return "1/8";
173
+ if (product >= 512)
174
+ return "1/4";
175
+ if (product >= 256)
176
+ return "1/2";
177
+ if (product >= 128)
178
+ return "1 bar";
179
+ const bars = 128 / product;
180
+ if (bars === Math.floor(bars)) {
181
+ return `${bars} bars`;
182
+ }
183
+ return `${bars.toFixed(1)} bars`;
184
+ }
185
+ function calculateTimingInfo(config, bpm) {
186
+ const product = calculateProduct(config);
187
+ const cycleTimeMs = calculateCycleTimeMs(config, bpm);
188
+ const frequencyHz = calculateFrequencyHz(config, bpm);
189
+ const cyclesPerBar = calculateCyclesPerBar(config);
190
+ const noteValue = calculateNoteValue(product);
191
+ return {
192
+ cycleTimeMs,
193
+ noteValue,
194
+ frequencyHz,
195
+ cyclesPerBar,
196
+ product
197
+ };
198
+ }
199
+ function formatCycleTime(cycleTimeMs) {
200
+ if (cycleTimeMs === Infinity)
201
+ return "∞";
202
+ if (cycleTimeMs >= 60000) {
203
+ const minutes = cycleTimeMs / 60000;
204
+ return `${minutes.toFixed(1)}min`;
205
+ }
206
+ if (cycleTimeMs >= 1000) {
207
+ return `${(cycleTimeMs / 1000).toFixed(2)}s`;
208
+ }
209
+ return `${cycleTimeMs.toFixed(1)}ms`;
210
+ }
211
+ function formatFrequency(frequencyHz) {
212
+ if (frequencyHz === 0)
213
+ return "0 Hz";
214
+ if (frequencyHz < 0.01) {
215
+ return `${(frequencyHz * 1000).toFixed(3)} mHz`;
216
+ }
217
+ if (frequencyHz < 1) {
218
+ return `${frequencyHz.toFixed(3)} Hz`;
219
+ }
220
+ return `${frequencyHz.toFixed(2)} Hz`;
221
+ }
222
+
223
+ // src/engine/triggers.ts
224
+ function handleTrigger(config, state, currentRawOutput) {
225
+ const newState = { ...state };
226
+ newState.triggerCount++;
227
+ switch (config.mode) {
228
+ case "FRE":
229
+ break;
230
+ case "TRG":
231
+ newState.phase = newState.startPhaseNormalized;
232
+ newState.previousPhase = newState.startPhaseNormalized;
233
+ newState.fadeProgress = 0;
234
+ newState.fadeMultiplier = config.fade < 0 ? 0 : 1;
235
+ newState.cycleCount = 0;
236
+ if (config.waveform === "RND") {
237
+ newState.randomValue = Math.random() * 2 - 1;
238
+ newState.randomStep = Math.floor(newState.phase * 16);
239
+ }
240
+ break;
241
+ case "HLD":
242
+ newState.heldOutput = currentRawOutput;
243
+ newState.fadeProgress = 0;
244
+ newState.fadeMultiplier = config.fade < 0 ? 0 : 1;
245
+ break;
246
+ case "ONE":
247
+ newState.phase = newState.startPhaseNormalized;
248
+ newState.previousPhase = newState.startPhaseNormalized;
249
+ newState.isRunning = true;
250
+ newState.hasTriggered = true;
251
+ newState.fadeProgress = 0;
252
+ newState.fadeMultiplier = config.fade < 0 ? 0 : 1;
253
+ newState.cycleCount = 0;
254
+ if (config.waveform === "RND") {
255
+ newState.randomValue = Math.random() * 2 - 1;
256
+ newState.randomStep = Math.floor(newState.phase * 16);
257
+ }
258
+ break;
259
+ case "HLF":
260
+ newState.phase = newState.startPhaseNormalized;
261
+ newState.previousPhase = newState.startPhaseNormalized;
262
+ newState.isRunning = true;
263
+ newState.hasTriggered = true;
264
+ newState.fadeProgress = 0;
265
+ newState.fadeMultiplier = config.fade < 0 ? 0 : 1;
266
+ newState.cycleCount = 0;
267
+ if (config.waveform === "RND") {
268
+ newState.randomValue = Math.random() * 2 - 1;
269
+ newState.randomStep = Math.floor(newState.phase * 16);
270
+ }
271
+ break;
272
+ }
273
+ return newState;
274
+ }
275
+ function checkModeStop(config, state, previousPhase, currentPhase) {
276
+ if (config.mode !== "ONE" && config.mode !== "HLF") {
277
+ return { shouldStop: false, cycleCompleted: false };
278
+ }
279
+ if (!state.hasTriggered) {
280
+ return { shouldStop: true, cycleCompleted: false };
281
+ }
282
+ const startPhase = state.startPhaseNormalized;
283
+ const isForward = config.speed >= 0;
284
+ if (config.mode === "ONE") {
285
+ if (state.cycleCount >= 1) {
286
+ return { shouldStop: true, cycleCompleted: true };
287
+ }
288
+ } else if (config.mode === "HLF") {
289
+ const halfPhase = (startPhase + 0.5) % 1;
290
+ if (isForward) {
291
+ if (startPhase < 0.5) {
292
+ if (previousPhase < halfPhase && currentPhase >= halfPhase) {
293
+ return { shouldStop: true, cycleCompleted: true };
294
+ }
295
+ } else {
296
+ if (state.cycleCount >= 1 || previousPhase < halfPhase && currentPhase >= halfPhase) {
297
+ return { shouldStop: true, cycleCompleted: true };
298
+ }
299
+ }
300
+ } else {
301
+ const halfPhaseBackward = (startPhase - 0.5 + 1) % 1;
302
+ if (startPhase >= 0.5) {
303
+ if (previousPhase > halfPhaseBackward && currentPhase <= halfPhaseBackward) {
304
+ return { shouldStop: true, cycleCompleted: true };
305
+ }
306
+ } else {
307
+ if (state.cycleCount >= 1 || previousPhase > halfPhaseBackward && currentPhase <= halfPhaseBackward) {
308
+ return { shouldStop: true, cycleCompleted: true };
309
+ }
310
+ }
311
+ }
312
+ }
313
+ return { shouldStop: false, cycleCompleted: false };
314
+ }
315
+ function requiresTriggerToStart(mode) {
316
+ return mode === "ONE" || mode === "HLF";
317
+ }
318
+ function resetsPhaseOnTrigger(mode) {
319
+ return mode === "TRG" || mode === "ONE" || mode === "HLF";
320
+ }
321
+ function resetsFadeOnTrigger(mode) {
322
+ return mode !== "FRE";
323
+ }
324
+
325
+ // src/engine/fade.ts
326
+ function calculateFadeMultiplier(fadeValue, fadeProgress) {
327
+ if (fadeValue === 0) {
328
+ return 1;
329
+ }
330
+ const progress = Math.max(0, Math.min(1, fadeProgress));
331
+ if (fadeValue < 0) {
332
+ return progress;
333
+ } else {
334
+ return 1 - progress;
335
+ }
336
+ }
337
+ function calculateFadeCycles(fadeValue) {
338
+ if (fadeValue === 0)
339
+ return 0;
340
+ return Math.abs(fadeValue) / 64;
341
+ }
342
+ function updateFade(config, state, cycleTimeMs, deltaMs) {
343
+ if (config.fade === 0 || config.mode === "FRE") {
344
+ return {
345
+ fadeProgress: 1,
346
+ fadeMultiplier: 1
347
+ };
348
+ }
349
+ const fadeCycles = calculateFadeCycles(config.fade);
350
+ const fadeDurationMs = fadeCycles * cycleTimeMs;
351
+ if (fadeDurationMs === 0 || fadeDurationMs === Infinity) {
352
+ return {
353
+ fadeProgress: 1,
354
+ fadeMultiplier: config.fade < 0 ? 0 : 1
355
+ };
356
+ }
357
+ const progressIncrement = deltaMs / fadeDurationMs;
358
+ const newProgress = Math.min(1, state.fadeProgress + progressIncrement);
359
+ return {
360
+ fadeProgress: newProgress,
361
+ fadeMultiplier: calculateFadeMultiplier(config.fade, newProgress)
362
+ };
363
+ }
364
+ function resetFade(config) {
365
+ if (config.fade === 0) {
366
+ return { fadeProgress: 1, fadeMultiplier: 1 };
367
+ }
368
+ if (config.fade < 0) {
369
+ return { fadeProgress: 0, fadeMultiplier: 0 };
370
+ } else {
371
+ return { fadeProgress: 0, fadeMultiplier: 1 };
372
+ }
373
+ }
374
+ function shouldResetFadeOnTrigger(mode) {
375
+ return mode !== "FRE";
376
+ }
377
+ function applyFade(rawOutput, fadeMultiplier) {
378
+ return rawOutput * fadeMultiplier;
379
+ }
380
+
381
+ // src/engine/lfo.ts
382
+ class LFO {
383
+ config;
384
+ state;
385
+ bpm;
386
+ lastUpdateTime;
387
+ constructor(config = {}, bpm = 120) {
388
+ this.config = { ...DEFAULT_CONFIG, ...config };
389
+ this.bpm = bpm;
390
+ this.state = createInitialState(this.config);
391
+ this.lastUpdateTime = 0;
392
+ if (this.config.mode === "FRE") {
393
+ this.state.fadeMultiplier = 1;
394
+ this.state.fadeProgress = 1;
395
+ }
396
+ if (this.config.mode === "ONE" || this.config.mode === "HLF") {
397
+ this.state.isRunning = false;
398
+ }
399
+ }
400
+ update(currentTimeMs) {
401
+ const deltaMs = this.lastUpdateTime === 0 ? 0 : currentTimeMs - this.lastUpdateTime;
402
+ this.lastUpdateTime = currentTimeMs;
403
+ const shouldUpdatePhase = this.state.isRunning && deltaMs > 0;
404
+ const cycleTimeMs = calculateCycleTimeMs(this.config, this.bpm);
405
+ const phaseIncrement = calculatePhaseIncrement(this.config, this.bpm);
406
+ if (shouldUpdatePhase) {
407
+ const previousPhase = this.state.phase;
408
+ let newPhase = this.state.phase + phaseIncrement * deltaMs;
409
+ if (newPhase >= 1) {
410
+ newPhase = newPhase % 1;
411
+ this.state.cycleCount++;
412
+ } else if (newPhase < 0) {
413
+ newPhase = 1 + newPhase % 1;
414
+ if (newPhase === 1)
415
+ newPhase = 0;
416
+ this.state.cycleCount++;
417
+ }
418
+ const stopCheck = checkModeStop(this.config, this.state, previousPhase, newPhase);
419
+ if (stopCheck.shouldStop) {
420
+ this.state.isRunning = false;
421
+ if (this.config.mode === "ONE") {
422
+ newPhase = this.state.startPhaseNormalized;
423
+ } else if (this.config.mode === "HLF") {
424
+ newPhase = (this.state.startPhaseNormalized + 0.5) % 1;
425
+ }
426
+ }
427
+ this.state.previousPhase = previousPhase;
428
+ this.state.phase = newPhase;
429
+ }
430
+ const waveformResult = generateWaveform(this.config.waveform, this.state.phase, this.state);
431
+ if (waveformResult.newRandomValue !== undefined) {
432
+ this.state.randomValue = waveformResult.newRandomValue;
433
+ }
434
+ if (waveformResult.newRandomStep !== undefined) {
435
+ this.state.randomStep = waveformResult.newRandomStep;
436
+ }
437
+ this.state.rawOutput = waveformResult.value;
438
+ if (shouldUpdatePhase) {
439
+ const fadeResult = updateFade(this.config, this.state, cycleTimeMs, deltaMs);
440
+ this.state.fadeProgress = fadeResult.fadeProgress;
441
+ this.state.fadeMultiplier = fadeResult.fadeMultiplier;
442
+ }
443
+ let effectiveRawOutput = this.state.rawOutput;
444
+ if (this.config.mode === "HLD" && this.state.triggerCount > 0) {
445
+ effectiveRawOutput = this.state.heldOutput;
446
+ }
447
+ const depthScale = this.config.depth / 63;
448
+ let scaledOutput = effectiveRawOutput * depthScale;
449
+ scaledOutput *= this.state.fadeMultiplier;
450
+ if (isUnipolar(this.config.waveform)) {}
451
+ this.state.output = scaledOutput;
452
+ return { ...this.state };
453
+ }
454
+ trigger() {
455
+ this.state = handleTrigger(this.config, this.state, this.state.rawOutput);
456
+ }
457
+ getState() {
458
+ return { ...this.state };
459
+ }
460
+ getConfig() {
461
+ return { ...this.config };
462
+ }
463
+ setConfig(config) {
464
+ const previousMode = this.config.mode;
465
+ this.config = { ...this.config, ...config };
466
+ if (config.startPhase !== undefined) {
467
+ this.state.startPhaseNormalized = config.startPhase / 128;
468
+ }
469
+ if ((config.mode === "ONE" || config.mode === "HLF") && previousMode !== config.mode) {
470
+ this.state.isRunning = false;
471
+ this.state.hasTriggered = false;
472
+ }
473
+ }
474
+ setBpm(bpm) {
475
+ this.bpm = clamp(bpm, 1, 999);
476
+ }
477
+ getBpm() {
478
+ return this.bpm;
479
+ }
480
+ getTimingInfo() {
481
+ return calculateTimingInfo(this.config, this.bpm);
482
+ }
483
+ reset() {
484
+ this.state = createInitialState(this.config);
485
+ if (this.config.mode === "ONE" || this.config.mode === "HLF") {
486
+ this.state.isRunning = false;
487
+ }
488
+ }
489
+ isRunning() {
490
+ return this.state.isRunning;
491
+ }
492
+ getOutput() {
493
+ return this.state.output;
494
+ }
495
+ getPhase() {
496
+ return this.state.phase;
497
+ }
498
+ start() {
499
+ this.state.isRunning = true;
500
+ this.state.hasTriggered = true;
501
+ }
502
+ stop() {
503
+ this.state.isRunning = false;
504
+ }
505
+ }
506
+ export {
507
+ updateFade,
508
+ shouldResetFadeOnTrigger,
509
+ resetsPhaseOnTrigger,
510
+ resetsFadeOnTrigger,
511
+ resetFade,
512
+ requiresTriggerToStart,
513
+ isValidMultiplier,
514
+ isUnipolar,
515
+ handleTrigger,
516
+ getWaveformRange,
517
+ generateWaveform,
518
+ generateTriangle,
519
+ generateSquare,
520
+ generateSine,
521
+ generateSawtooth,
522
+ generateRandom,
523
+ generateRamp,
524
+ generateExponential,
525
+ formatFrequency,
526
+ formatCycleTime,
527
+ createInitialState,
528
+ clamp,
529
+ checkModeStop,
530
+ calculateTimingInfo,
531
+ calculateProduct,
532
+ calculatePhaseIncrement,
533
+ calculateNoteValue,
534
+ calculateFrequencyHz,
535
+ calculateFadeMultiplier,
536
+ calculateFadeCycles,
537
+ calculateCyclesPerBar,
538
+ calculateCycleTimeMs,
539
+ applyFade,
540
+ VALID_MULTIPLIERS,
541
+ LFO,
542
+ DEFAULT_CONFIG
543
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "elektron-lfo",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Elektron LFO engine simulator implementation with CLI visualization",
5
5
  "main": "dist/index.js",
6
6
  "module": "src/index.ts",
@@ -112,11 +112,23 @@ export function checkModeStop(
112
112
  const isForward = config.speed >= 0;
113
113
 
114
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
115
+ // ONE mode: Stop after completing one full cycle back to startPhase
116
+ // For non-zero startPhase, we need to:
117
+ // 1. Wait for phase to wrap (cycleCount >= 1)
118
+ // 2. Then continue until we reach/pass the startPhase again
118
119
  if (state.cycleCount >= 1) {
119
- return { shouldStop: true, cycleCompleted: true };
120
+ if (isForward) {
121
+ // Forward: stop when current phase reaches or passes startPhase after wrapping
122
+ // Handle the case where startPhase is 0 (stop immediately on wrap)
123
+ if (startPhase === 0 || currentPhase >= startPhase) {
124
+ return { shouldStop: true, cycleCompleted: true };
125
+ }
126
+ } else {
127
+ // Backward: stop when current phase reaches or goes below startPhase after wrapping
128
+ if (startPhase === 0 || currentPhase <= startPhase) {
129
+ return { shouldStop: true, cycleCompleted: true };
130
+ }
131
+ }
120
132
  }
121
133
  } else if (config.mode === 'HLF') {
122
134
  // HLF mode: Stop after half cycle (0.5 phase distance from start)
@@ -240,6 +240,64 @@ describe('checkModeStop - ONE mode', () => {
240
240
  const result = checkModeStop(config, state, 0.05, 0.95);
241
241
  expect(result.shouldStop).toBe(true);
242
242
  });
243
+
244
+ test('with non-zero startPhase, does NOT stop immediately after wrap - must reach startPhase', () => {
245
+ // Bug fix: With startPhase 53 (~0.414), the LFO should:
246
+ // 1. Start at 0.414
247
+ // 2. Run to 1.0, wrap to 0.0 (cycleCount = 1)
248
+ // 3. Continue from 0.0 to 0.414 (startPhase)
249
+ // 4. THEN stop
250
+ const config = createConfig({ mode: 'ONE', speed: 16, startPhase: 53 });
251
+ const startPhaseNormalized = 53 / 128; // ~0.414
252
+
253
+ // After wrap, phase is 0.1, which is BEFORE startPhase (0.414)
254
+ // Should NOT stop yet
255
+ const stateAfterWrap = createState({
256
+ hasTriggered: true,
257
+ startPhaseNormalized,
258
+ cycleCount: 1,
259
+ });
260
+ const resultBeforeStart = checkModeStop(config, stateAfterWrap, 0.05, 0.1);
261
+ expect(resultBeforeStart.shouldStop).toBe(false);
262
+
263
+ // Phase reaches 0.3, still before startPhase - should NOT stop
264
+ const resultStillBefore = checkModeStop(config, stateAfterWrap, 0.2, 0.3);
265
+ expect(resultStillBefore.shouldStop).toBe(false);
266
+
267
+ // Phase crosses startPhase (0.414) - NOW should stop
268
+ const resultAtStart = checkModeStop(config, stateAfterWrap, 0.4, 0.45);
269
+ expect(resultAtStart.shouldStop).toBe(true);
270
+ expect(resultAtStart.cycleCompleted).toBe(true);
271
+ });
272
+
273
+ test('with non-zero startPhase (backward), continues after wrap until reaching startPhase', () => {
274
+ // Backward direction with startPhase 53 (~0.414):
275
+ // 1. Start at 0.414
276
+ // 2. Run backward to 0.0, wrap to 1.0 (cycleCount = 1)
277
+ // 3. Continue from 1.0 down to 0.414
278
+ // 4. THEN stop
279
+ const config = createConfig({ mode: 'ONE', speed: -16, startPhase: 53 });
280
+ const startPhaseNormalized = 53 / 128; // ~0.414
281
+
282
+ // After wrap going backward, phase is 0.9, which is ABOVE startPhase (0.414)
283
+ // Should NOT stop yet
284
+ const stateAfterWrap = createState({
285
+ hasTriggered: true,
286
+ startPhaseNormalized,
287
+ cycleCount: 1,
288
+ });
289
+ const resultAfterWrap = checkModeStop(config, stateAfterWrap, 0.95, 0.9);
290
+ expect(resultAfterWrap.shouldStop).toBe(false);
291
+
292
+ // Phase reaches 0.5, still above startPhase - should NOT stop
293
+ const resultStillAbove = checkModeStop(config, stateAfterWrap, 0.6, 0.5);
294
+ expect(resultStillAbove.shouldStop).toBe(false);
295
+
296
+ // Phase crosses startPhase (0.414) going down - NOW should stop
297
+ const resultAtStart = checkModeStop(config, stateAfterWrap, 0.45, 0.4);
298
+ expect(resultAtStart.shouldStop).toBe(true);
299
+ expect(resultAtStart.cycleCompleted).toBe(true);
300
+ });
243
301
  });
244
302
 
245
303
  describe('checkModeStop - HLF mode', () => {