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,344 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
import { LFO } from '../src/engine/lfo';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Integration tests for the 5 presets from DIGITAKT_II_LFO_PRESETS.md
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
describe('Preset 1: Fade-In One-Shot', () => {
|
|
9
|
+
// RMP, SPD=8, MULT=16, ONE mode, FADE=-32
|
|
10
|
+
// 1 bar cycle (2000ms at 120 BPM), stops after one cycle
|
|
11
|
+
|
|
12
|
+
test('has correct timing (2000ms at 120 BPM)', () => {
|
|
13
|
+
const lfo = new LFO({
|
|
14
|
+
waveform: 'RMP',
|
|
15
|
+
speed: 8,
|
|
16
|
+
multiplier: 16, // 8 * 16 = 128 = 1 bar
|
|
17
|
+
mode: 'ONE',
|
|
18
|
+
fade: -32,
|
|
19
|
+
depth: 63,
|
|
20
|
+
}, 120);
|
|
21
|
+
|
|
22
|
+
const timing = lfo.getTimingInfo();
|
|
23
|
+
expect(timing.cycleTimeMs).toBeCloseTo(2000, 0);
|
|
24
|
+
expect(timing.noteValue).toBe('1 bar');
|
|
25
|
+
expect(timing.product).toBe(128);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('stops after one cycle', () => {
|
|
29
|
+
const lfo = new LFO({
|
|
30
|
+
waveform: 'RMP',
|
|
31
|
+
speed: 8,
|
|
32
|
+
multiplier: 16,
|
|
33
|
+
mode: 'ONE',
|
|
34
|
+
fade: -32,
|
|
35
|
+
depth: 63,
|
|
36
|
+
}, 120);
|
|
37
|
+
|
|
38
|
+
lfo.trigger();
|
|
39
|
+
lfo.update(0);
|
|
40
|
+
|
|
41
|
+
// Run for 2500ms (more than one 2000ms cycle)
|
|
42
|
+
let lastTime = 0;
|
|
43
|
+
for (let t = 0; t < 2500; t += 50) {
|
|
44
|
+
lfo.update(t);
|
|
45
|
+
lastTime = t;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
expect(lfo.isRunning()).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('fade multiplier increases over time (fade in)', () => {
|
|
52
|
+
const lfo = new LFO({
|
|
53
|
+
waveform: 'RMP',
|
|
54
|
+
speed: 8,
|
|
55
|
+
multiplier: 16,
|
|
56
|
+
mode: 'ONE',
|
|
57
|
+
fade: -32, // Negative = fade IN
|
|
58
|
+
depth: 63,
|
|
59
|
+
}, 120);
|
|
60
|
+
|
|
61
|
+
lfo.trigger();
|
|
62
|
+
lfo.update(0);
|
|
63
|
+
|
|
64
|
+
const state1 = lfo.update(100);
|
|
65
|
+
expect(state1.fadeMultiplier).toBeLessThan(0.5);
|
|
66
|
+
|
|
67
|
+
// Fade -32 = 0.5 cycles = 1000ms at 2000ms cycle
|
|
68
|
+
// After ~500ms, should be about halfway
|
|
69
|
+
let midFadeMultiplier = 0;
|
|
70
|
+
for (let t = 100; t < 600; t += 50) {
|
|
71
|
+
const state = lfo.update(t);
|
|
72
|
+
midFadeMultiplier = state.fadeMultiplier;
|
|
73
|
+
}
|
|
74
|
+
expect(midFadeMultiplier).toBeGreaterThan(0.3);
|
|
75
|
+
expect(midFadeMultiplier).toBeLessThan(0.8);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('Preset 2: Ambient Drift', () => {
|
|
80
|
+
// SIN, SPD=1, MULT=1, FRE mode
|
|
81
|
+
// 128 bars cycle (256000ms at 120 BPM)
|
|
82
|
+
|
|
83
|
+
test('has correct timing (256000ms at 120 BPM)', () => {
|
|
84
|
+
const lfo = new LFO({
|
|
85
|
+
waveform: 'SIN',
|
|
86
|
+
speed: 1,
|
|
87
|
+
multiplier: 1, // 1 * 1 = 1 = 128 bars
|
|
88
|
+
mode: 'FRE',
|
|
89
|
+
depth: 24,
|
|
90
|
+
fade: 0,
|
|
91
|
+
}, 120);
|
|
92
|
+
|
|
93
|
+
const timing = lfo.getTimingInfo();
|
|
94
|
+
expect(timing.cycleTimeMs).toBeCloseTo(256000, 0);
|
|
95
|
+
expect(timing.noteValue).toBe('128 bars');
|
|
96
|
+
expect(timing.product).toBe(1);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('continues running despite triggers', () => {
|
|
100
|
+
const lfo = new LFO({
|
|
101
|
+
waveform: 'SIN',
|
|
102
|
+
speed: 1,
|
|
103
|
+
multiplier: 1,
|
|
104
|
+
mode: 'FRE',
|
|
105
|
+
depth: 24,
|
|
106
|
+
}, 120);
|
|
107
|
+
|
|
108
|
+
lfo.update(0);
|
|
109
|
+
const state1 = lfo.update(1000);
|
|
110
|
+
const phase1 = state1.phase;
|
|
111
|
+
|
|
112
|
+
// Trigger should not affect phase in FRE mode
|
|
113
|
+
lfo.trigger();
|
|
114
|
+
const state2 = lfo.update(1100);
|
|
115
|
+
|
|
116
|
+
// Phase should have continued from where it was
|
|
117
|
+
expect(state2.phase).toBeGreaterThan(phase1);
|
|
118
|
+
expect(lfo.isRunning()).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('output is within moderate depth range', () => {
|
|
122
|
+
const lfo = new LFO({
|
|
123
|
+
waveform: 'SIN',
|
|
124
|
+
speed: 1,
|
|
125
|
+
multiplier: 1,
|
|
126
|
+
mode: 'FRE',
|
|
127
|
+
depth: 24, // Moderate depth
|
|
128
|
+
}, 120);
|
|
129
|
+
|
|
130
|
+
lfo.update(0);
|
|
131
|
+
let maxOutput = 0;
|
|
132
|
+
|
|
133
|
+
// Sample some outputs
|
|
134
|
+
for (let t = 0; t < 10000; t += 500) {
|
|
135
|
+
const state = lfo.update(t);
|
|
136
|
+
maxOutput = Math.max(maxOutput, Math.abs(state.output));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Depth 24/63 ≈ 0.38
|
|
140
|
+
expect(maxOutput).toBeLessThanOrEqual(24 / 63 + 0.05);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('Preset 3: Hi-Hat Humanizer', () => {
|
|
145
|
+
// RND, SPD=32, MULT=64, FRE mode
|
|
146
|
+
// 1/16 note cycle (125ms at 120 BPM)
|
|
147
|
+
|
|
148
|
+
test('has correct timing (125ms at 120 BPM)', () => {
|
|
149
|
+
const lfo = new LFO({
|
|
150
|
+
waveform: 'RND',
|
|
151
|
+
speed: 32,
|
|
152
|
+
multiplier: 64, // 32 * 64 = 2048 = 1/16
|
|
153
|
+
mode: 'FRE',
|
|
154
|
+
depth: 12,
|
|
155
|
+
}, 120);
|
|
156
|
+
|
|
157
|
+
const timing = lfo.getTimingInfo();
|
|
158
|
+
expect(timing.cycleTimeMs).toBeCloseTo(125, 0);
|
|
159
|
+
expect(timing.noteValue).toBe('1/16');
|
|
160
|
+
expect(timing.product).toBe(2048);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('random values change over time', () => {
|
|
164
|
+
const lfo = new LFO({
|
|
165
|
+
waveform: 'RND',
|
|
166
|
+
speed: 32,
|
|
167
|
+
multiplier: 64,
|
|
168
|
+
mode: 'FRE',
|
|
169
|
+
depth: 12,
|
|
170
|
+
}, 120);
|
|
171
|
+
|
|
172
|
+
lfo.update(0);
|
|
173
|
+
const values = new Set<number>();
|
|
174
|
+
|
|
175
|
+
// Collect random values over 500ms (4 cycles)
|
|
176
|
+
for (let t = 0; t < 500; t += 5) {
|
|
177
|
+
const state = lfo.update(t);
|
|
178
|
+
values.add(Math.round(state.rawOutput * 1000) / 1000);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Should have multiple different values
|
|
182
|
+
expect(values.size).toBeGreaterThan(5);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('output within depth range', () => {
|
|
186
|
+
const lfo = new LFO({
|
|
187
|
+
waveform: 'RND',
|
|
188
|
+
speed: 32,
|
|
189
|
+
multiplier: 64,
|
|
190
|
+
mode: 'FRE',
|
|
191
|
+
depth: 12, // Subtle depth
|
|
192
|
+
}, 120);
|
|
193
|
+
|
|
194
|
+
lfo.update(0);
|
|
195
|
+
let maxOutput = 0;
|
|
196
|
+
|
|
197
|
+
for (let t = 0; t < 500; t += 5) {
|
|
198
|
+
const state = lfo.update(t);
|
|
199
|
+
maxOutput = Math.max(maxOutput, Math.abs(state.output));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Depth 12/63 ≈ 0.19
|
|
203
|
+
expect(maxOutput).toBeLessThanOrEqual(12 / 63 + 0.02);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe('Preset 4: Pumping Sidechain', () => {
|
|
208
|
+
// EXP, SPD=32, MULT=4, TRG mode, DEP=-63
|
|
209
|
+
// 1 bar cycle (2000ms at 120 BPM)
|
|
210
|
+
|
|
211
|
+
test('has correct timing (2000ms at 120 BPM)', () => {
|
|
212
|
+
const lfo = new LFO({
|
|
213
|
+
waveform: 'EXP',
|
|
214
|
+
speed: 32,
|
|
215
|
+
multiplier: 4, // 32 * 4 = 128 = 1 bar
|
|
216
|
+
mode: 'TRG',
|
|
217
|
+
depth: -63, // Inverted
|
|
218
|
+
}, 120);
|
|
219
|
+
|
|
220
|
+
const timing = lfo.getTimingInfo();
|
|
221
|
+
expect(timing.cycleTimeMs).toBeCloseTo(2000, 0);
|
|
222
|
+
expect(timing.product).toBe(128);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('restarts on trigger', () => {
|
|
226
|
+
const lfo = new LFO({
|
|
227
|
+
waveform: 'EXP',
|
|
228
|
+
speed: 32,
|
|
229
|
+
multiplier: 4,
|
|
230
|
+
mode: 'TRG',
|
|
231
|
+
depth: -63,
|
|
232
|
+
}, 120);
|
|
233
|
+
|
|
234
|
+
lfo.trigger();
|
|
235
|
+
lfo.update(0);
|
|
236
|
+
|
|
237
|
+
// Let it run partway through
|
|
238
|
+
for (let t = 0; t < 1000; t += 50) {
|
|
239
|
+
lfo.update(t);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const stateBefore = lfo.update(1000);
|
|
243
|
+
const phaseBefore = stateBefore.phase;
|
|
244
|
+
|
|
245
|
+
// Trigger again
|
|
246
|
+
lfo.trigger();
|
|
247
|
+
const stateAfter = lfo.update(1050);
|
|
248
|
+
|
|
249
|
+
// Phase should have reset to near 0
|
|
250
|
+
expect(stateAfter.phase).toBeLessThan(phaseBefore);
|
|
251
|
+
expect(stateAfter.phase).toBeLessThan(0.1);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('produces inverted (negative) output', () => {
|
|
255
|
+
const lfo = new LFO({
|
|
256
|
+
waveform: 'EXP',
|
|
257
|
+
speed: 32,
|
|
258
|
+
multiplier: 4,
|
|
259
|
+
mode: 'TRG',
|
|
260
|
+
depth: -63, // Negative depth inverts
|
|
261
|
+
startPhase: 0,
|
|
262
|
+
}, 120);
|
|
263
|
+
|
|
264
|
+
lfo.trigger();
|
|
265
|
+
lfo.update(0);
|
|
266
|
+
|
|
267
|
+
// EXP goes 0 to 1, with -63 depth it should go 0 to -1
|
|
268
|
+
let minOutput = Infinity;
|
|
269
|
+
for (let t = 0; t < 2000; t += 50) {
|
|
270
|
+
const state = lfo.update(t);
|
|
271
|
+
minOutput = Math.min(minOutput, state.output);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
expect(minOutput).toBeLessThan(-0.8);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
describe('Preset 5: Wobble Bass', () => {
|
|
279
|
+
// SIN, SPD=16, MULT=8, TRG mode, SPH=32, DEP=+48
|
|
280
|
+
// 1 bar cycle (2000ms at 120 BPM), starts at peak
|
|
281
|
+
|
|
282
|
+
test('has correct timing (2000ms at 120 BPM)', () => {
|
|
283
|
+
const lfo = new LFO({
|
|
284
|
+
waveform: 'SIN',
|
|
285
|
+
speed: 16,
|
|
286
|
+
multiplier: 8, // 16 * 8 = 128 = 1 bar
|
|
287
|
+
mode: 'TRG',
|
|
288
|
+
startPhase: 32, // 90 degrees = peak
|
|
289
|
+
depth: 48,
|
|
290
|
+
}, 120);
|
|
291
|
+
|
|
292
|
+
const timing = lfo.getTimingInfo();
|
|
293
|
+
expect(timing.cycleTimeMs).toBeCloseTo(2000, 0);
|
|
294
|
+
expect(timing.noteValue).toBe('1 bar');
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test('starts at peak (phase 90 degrees)', () => {
|
|
298
|
+
const lfo = new LFO({
|
|
299
|
+
waveform: 'SIN',
|
|
300
|
+
speed: 16,
|
|
301
|
+
multiplier: 8,
|
|
302
|
+
mode: 'TRG',
|
|
303
|
+
startPhase: 32, // 32/128 = 0.25 = 90 degrees
|
|
304
|
+
depth: 48,
|
|
305
|
+
}, 120);
|
|
306
|
+
|
|
307
|
+
lfo.trigger();
|
|
308
|
+
lfo.update(0);
|
|
309
|
+
|
|
310
|
+
const state = lfo.update(1);
|
|
311
|
+
// At phase 0.25, SIN should be at +1
|
|
312
|
+
expect(state.phase).toBeCloseTo(0.25, 2);
|
|
313
|
+
// Raw output should be near peak
|
|
314
|
+
expect(state.rawOutput).toBeGreaterThan(0.95);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test('strong depth produces dramatic sweep', () => {
|
|
318
|
+
const lfo = new LFO({
|
|
319
|
+
waveform: 'SIN',
|
|
320
|
+
speed: 16,
|
|
321
|
+
multiplier: 8,
|
|
322
|
+
mode: 'TRG',
|
|
323
|
+
startPhase: 32,
|
|
324
|
+
depth: 48, // Strong depth
|
|
325
|
+
}, 120);
|
|
326
|
+
|
|
327
|
+
lfo.trigger();
|
|
328
|
+
lfo.update(0);
|
|
329
|
+
|
|
330
|
+
let maxOutput = 0;
|
|
331
|
+
let minOutput = Infinity;
|
|
332
|
+
|
|
333
|
+
for (let t = 0; t < 2500; t += 50) {
|
|
334
|
+
const state = lfo.update(t);
|
|
335
|
+
maxOutput = Math.max(maxOutput, state.output);
|
|
336
|
+
minOutput = Math.min(minOutput, state.output);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Depth 48/63 ≈ 0.76
|
|
340
|
+
const expectedRange = 48 / 63;
|
|
341
|
+
expect(maxOutput).toBeGreaterThan(expectedRange * 0.9);
|
|
342
|
+
expect(minOutput).toBeLessThan(-expectedRange * 0.9);
|
|
343
|
+
});
|
|
344
|
+
});
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
calculateProduct,
|
|
4
|
+
calculateCycleTimeMs,
|
|
5
|
+
calculateFrequencyHz,
|
|
6
|
+
calculatePhaseIncrement,
|
|
7
|
+
calculateCyclesPerBar,
|
|
8
|
+
calculateNoteValue,
|
|
9
|
+
calculateTimingInfo,
|
|
10
|
+
formatCycleTime,
|
|
11
|
+
formatFrequency,
|
|
12
|
+
} from '../src/engine/timing';
|
|
13
|
+
import type { LFOConfig } from '../src/engine/types';
|
|
14
|
+
import { DEFAULT_CONFIG } from '../src/engine/types';
|
|
15
|
+
|
|
16
|
+
// Helper to create config with defaults
|
|
17
|
+
function createConfig(overrides: Partial<LFOConfig>): LFOConfig {
|
|
18
|
+
return { ...DEFAULT_CONFIG, ...overrides };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('calculateProduct', () => {
|
|
22
|
+
test('calculates |SPD| × MULT', () => {
|
|
23
|
+
expect(calculateProduct(createConfig({ speed: 16, multiplier: 8 }))).toBe(128);
|
|
24
|
+
expect(calculateProduct(createConfig({ speed: 32, multiplier: 64 }))).toBe(2048);
|
|
25
|
+
expect(calculateProduct(createConfig({ speed: 1, multiplier: 1 }))).toBe(1);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('uses absolute value of speed', () => {
|
|
29
|
+
expect(calculateProduct(createConfig({ speed: -16, multiplier: 8 }))).toBe(128);
|
|
30
|
+
expect(calculateProduct(createConfig({ speed: -32, multiplier: 64 }))).toBe(2048);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('handles speed of 0', () => {
|
|
34
|
+
expect(calculateProduct(createConfig({ speed: 0, multiplier: 8 }))).toBe(0);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('calculateCycleTimeMs', () => {
|
|
39
|
+
test('calculates correct cycle time for 1 bar at 120 BPM', () => {
|
|
40
|
+
// Product 128 = 1 bar = 2000ms at 120 BPM
|
|
41
|
+
const config = createConfig({ speed: 16, multiplier: 8 }); // 128
|
|
42
|
+
expect(calculateCycleTimeMs(config, 120)).toBeCloseTo(2000, 1);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('calculates correct cycle time for 1/16 note at 120 BPM', () => {
|
|
46
|
+
// Product 2048 = 1/16 note = 125ms at 120 BPM
|
|
47
|
+
const config = createConfig({ speed: 32, multiplier: 64 }); // 2048
|
|
48
|
+
expect(calculateCycleTimeMs(config, 120)).toBeCloseTo(125, 1);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('calculates correct cycle time for 128 bars at 120 BPM', () => {
|
|
52
|
+
// Product 1 = 128 bars = 256000ms at 120 BPM
|
|
53
|
+
const config = createConfig({ speed: 1, multiplier: 1 }); // 1
|
|
54
|
+
expect(calculateCycleTimeMs(config, 120)).toBeCloseTo(256000, 1);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('uses fixed 120 BPM when useFixedBPM is true', () => {
|
|
58
|
+
const config = createConfig({ speed: 16, multiplier: 8, useFixedBPM: true });
|
|
59
|
+
// Should be 2000ms regardless of passed BPM
|
|
60
|
+
expect(calculateCycleTimeMs(config, 90)).toBeCloseTo(2000, 1);
|
|
61
|
+
expect(calculateCycleTimeMs(config, 180)).toBeCloseTo(2000, 1);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('returns Infinity for speed 0', () => {
|
|
65
|
+
const config = createConfig({ speed: 0, multiplier: 8 });
|
|
66
|
+
expect(calculateCycleTimeMs(config, 120)).toBe(Infinity);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('scales with BPM', () => {
|
|
70
|
+
const config = createConfig({ speed: 16, multiplier: 8 }); // 128 = 1 bar
|
|
71
|
+
// At 60 BPM, 1 bar = 4000ms
|
|
72
|
+
expect(calculateCycleTimeMs(config, 60)).toBeCloseTo(4000, 1);
|
|
73
|
+
// At 240 BPM, 1 bar = 1000ms
|
|
74
|
+
expect(calculateCycleTimeMs(config, 240)).toBeCloseTo(1000, 1);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('calculateFrequencyHz', () => {
|
|
79
|
+
test('calculates correct frequency for 1 bar at 120 BPM', () => {
|
|
80
|
+
// 1 bar at 120 BPM = 2000ms cycle = 0.5 Hz
|
|
81
|
+
const config = createConfig({ speed: 16, multiplier: 8 }); // 128
|
|
82
|
+
expect(calculateFrequencyHz(config, 120)).toBeCloseTo(0.5, 5);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('calculates correct frequency for 1/16 note at 120 BPM', () => {
|
|
86
|
+
// 1/16 at 120 BPM = 125ms cycle = 8 Hz
|
|
87
|
+
const config = createConfig({ speed: 32, multiplier: 64 }); // 2048
|
|
88
|
+
expect(calculateFrequencyHz(config, 120)).toBeCloseTo(8, 5);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('returns 0 for speed 0', () => {
|
|
92
|
+
const config = createConfig({ speed: 0, multiplier: 8 });
|
|
93
|
+
expect(calculateFrequencyHz(config, 120)).toBe(0);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('calculatePhaseIncrement', () => {
|
|
98
|
+
test('calculates positive increment for positive speed', () => {
|
|
99
|
+
const config = createConfig({ speed: 16, multiplier: 8 }); // 2000ms cycle
|
|
100
|
+
const increment = calculatePhaseIncrement(config, 120);
|
|
101
|
+
expect(increment).toBeGreaterThan(0);
|
|
102
|
+
// 1ms should give 1/2000 phase increment
|
|
103
|
+
expect(increment).toBeCloseTo(1 / 2000, 8);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('calculates negative increment for negative speed', () => {
|
|
107
|
+
const config = createConfig({ speed: -16, multiplier: 8 }); // 2000ms cycle, backward
|
|
108
|
+
const increment = calculatePhaseIncrement(config, 120);
|
|
109
|
+
expect(increment).toBeLessThan(0);
|
|
110
|
+
expect(increment).toBeCloseTo(-1 / 2000, 8);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('returns 0 for speed 0', () => {
|
|
114
|
+
const config = createConfig({ speed: 0, multiplier: 8 });
|
|
115
|
+
expect(calculatePhaseIncrement(config, 120)).toBe(0);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('calculateCyclesPerBar', () => {
|
|
120
|
+
test('returns 1 for product 128', () => {
|
|
121
|
+
const config = createConfig({ speed: 16, multiplier: 8 }); // 128
|
|
122
|
+
expect(calculateCyclesPerBar(config)).toBe(1);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('returns 16 for product 2048', () => {
|
|
126
|
+
const config = createConfig({ speed: 32, multiplier: 64 }); // 2048
|
|
127
|
+
expect(calculateCyclesPerBar(config)).toBe(16);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('returns 1/128 for product 1', () => {
|
|
131
|
+
const config = createConfig({ speed: 1, multiplier: 1 }); // 1
|
|
132
|
+
expect(calculateCyclesPerBar(config)).toBeCloseTo(1 / 128, 8);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('calculateNoteValue', () => {
|
|
137
|
+
test('returns correct note values', () => {
|
|
138
|
+
expect(calculateNoteValue(2048)).toBe('1/16');
|
|
139
|
+
expect(calculateNoteValue(1024)).toBe('1/8');
|
|
140
|
+
expect(calculateNoteValue(512)).toBe('1/4');
|
|
141
|
+
expect(calculateNoteValue(256)).toBe('1/2');
|
|
142
|
+
expect(calculateNoteValue(128)).toBe('1 bar');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('returns bar counts for slow cycles', () => {
|
|
146
|
+
expect(calculateNoteValue(64)).toBe('2 bars');
|
|
147
|
+
expect(calculateNoteValue(32)).toBe('4 bars');
|
|
148
|
+
expect(calculateNoteValue(16)).toBe('8 bars');
|
|
149
|
+
expect(calculateNoteValue(1)).toBe('128 bars');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('returns ∞ for product 0', () => {
|
|
153
|
+
expect(calculateNoteValue(0)).toBe('∞');
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('calculateTimingInfo', () => {
|
|
158
|
+
test('returns complete timing info', () => {
|
|
159
|
+
const config = createConfig({ speed: 16, multiplier: 8 });
|
|
160
|
+
const info = calculateTimingInfo(config, 120);
|
|
161
|
+
|
|
162
|
+
expect(info.product).toBe(128);
|
|
163
|
+
expect(info.cycleTimeMs).toBeCloseTo(2000, 1);
|
|
164
|
+
expect(info.frequencyHz).toBeCloseTo(0.5, 5);
|
|
165
|
+
expect(info.cyclesPerBar).toBe(1);
|
|
166
|
+
expect(info.noteValue).toBe('1 bar');
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe('formatCycleTime', () => {
|
|
171
|
+
test('formats milliseconds', () => {
|
|
172
|
+
expect(formatCycleTime(125)).toBe('125.0ms');
|
|
173
|
+
expect(formatCycleTime(500)).toBe('500.0ms');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('formats seconds', () => {
|
|
177
|
+
expect(formatCycleTime(2000)).toBe('2.00s');
|
|
178
|
+
expect(formatCycleTime(4500)).toBe('4.50s');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('formats minutes', () => {
|
|
182
|
+
expect(formatCycleTime(120000)).toBe('2.0min');
|
|
183
|
+
expect(formatCycleTime(256000)).toBe('4.3min');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('formats infinity', () => {
|
|
187
|
+
expect(formatCycleTime(Infinity)).toBe('∞');
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('formatFrequency', () => {
|
|
192
|
+
test('formats Hz', () => {
|
|
193
|
+
expect(formatFrequency(8)).toBe('8.00 Hz');
|
|
194
|
+
expect(formatFrequency(0.5)).toBe('0.500 Hz');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('formats mHz for very low frequencies', () => {
|
|
198
|
+
expect(formatFrequency(0.005)).toBe('5.000 mHz');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('formats 0 Hz', () => {
|
|
202
|
+
expect(formatFrequency(0)).toBe('0 Hz');
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('Spec timing examples', () => {
|
|
207
|
+
// Verify against the timing examples from the spec
|
|
208
|
+
|
|
209
|
+
test('SPD=32, MULT=64: 1/16 note, 125ms at 120 BPM', () => {
|
|
210
|
+
const config = createConfig({ speed: 32, multiplier: 64 });
|
|
211
|
+
expect(calculateCycleTimeMs(config, 120)).toBeCloseTo(125, 1);
|
|
212
|
+
expect(calculateNoteValue(32 * 64)).toBe('1/16');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('SPD=16, MULT=8: 1 bar, 2000ms at 120 BPM', () => {
|
|
216
|
+
const config = createConfig({ speed: 16, multiplier: 8 });
|
|
217
|
+
expect(calculateCycleTimeMs(config, 120)).toBeCloseTo(2000, 1);
|
|
218
|
+
expect(calculateNoteValue(16 * 8)).toBe('1 bar');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('SPD=1, MULT=1: 128 bars, 256000ms at 120 BPM', () => {
|
|
222
|
+
const config = createConfig({ speed: 1, multiplier: 1 });
|
|
223
|
+
expect(calculateCycleTimeMs(config, 120)).toBeCloseTo(256000, 1);
|
|
224
|
+
expect(calculateNoteValue(1 * 1)).toBe('128 bars');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('SPD=8, MULT=16: 1 bar, 2000ms at 120 BPM', () => {
|
|
228
|
+
const config = createConfig({ speed: 8, multiplier: 16 });
|
|
229
|
+
expect(calculateCycleTimeMs(config, 120)).toBeCloseTo(2000, 1);
|
|
230
|
+
expect(calculateNoteValue(8 * 16)).toBe('1 bar');
|
|
231
|
+
});
|
|
232
|
+
});
|