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.
- package/bun.lock +23 -0
- package/bunfig.toml +3 -0
- package/docs/PLAN.md +446 -0
- package/docs/TEST_QUESTIONS.md +550 -0
- package/package.json +26 -0
- package/src/cli/args.ts +182 -0
- package/src/cli/display.ts +237 -0
- package/src/cli/index.ts +129 -0
- package/src/cli/keyboard.ts +143 -0
- package/src/engine/fade.ts +137 -0
- package/src/engine/index.ts +72 -0
- package/src/engine/lfo.ts +269 -0
- package/src/engine/timing.ts +157 -0
- package/src/engine/triggers.ts +179 -0
- package/src/engine/types.ts +126 -0
- package/src/engine/waveforms.ts +152 -0
- package/src/index.ts +31 -0
- package/tests/depth-fade.test.ts +306 -0
- package/tests/phase.test.ts +219 -0
- package/tests/presets.test.ts +344 -0
- package/tests/timing.test.ts +232 -0
- package/tests/triggers.test.ts +345 -0
- package/tests/waveforms.test.ts +273 -0
- package/tsconfig.json +13 -0
|
@@ -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
|
+
}
|