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
package/src/cli/args.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command-line argument parsing for Elektron LFO CLI
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { LFOConfig, Waveform, TriggerMode, Multiplier } from '../engine/types';
|
|
6
|
+
import { DEFAULT_CONFIG, VALID_MULTIPLIERS, isValidMultiplier } from '../engine/types';
|
|
7
|
+
|
|
8
|
+
export interface CLIArgs extends LFOConfig {
|
|
9
|
+
bpm: number;
|
|
10
|
+
help: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const WAVEFORMS: Waveform[] = ['TRI', 'SIN', 'SQR', 'SAW', 'EXP', 'RMP', 'RND'];
|
|
14
|
+
const TRIGGER_MODES: TriggerMode[] = ['FRE', 'TRG', 'HLD', 'ONE', 'HLF'];
|
|
15
|
+
|
|
16
|
+
export function printHelp(): void {
|
|
17
|
+
console.log(`
|
|
18
|
+
Elektron LFO Visualizer - Digitakt II LFO Engine
|
|
19
|
+
|
|
20
|
+
Usage: elektron-lfo [options]
|
|
21
|
+
|
|
22
|
+
Options:
|
|
23
|
+
-w, --waveform <type> Waveform type: TRI, SIN, SQR, SAW, EXP, RMP, RND
|
|
24
|
+
(default: ${DEFAULT_CONFIG.waveform})
|
|
25
|
+
-s, --speed <value> Speed: -64.00 to +63.00 (default: ${DEFAULT_CONFIG.speed})
|
|
26
|
+
-m, --multiplier <val> Multiplier: 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048
|
|
27
|
+
(default: ${DEFAULT_CONFIG.multiplier})
|
|
28
|
+
-f, --fixed Use fixed 120 BPM instead of project BPM
|
|
29
|
+
-p, --phase <value> Start phase: 0-127 (default: ${DEFAULT_CONFIG.startPhase})
|
|
30
|
+
-M, --mode <mode> Trigger mode: FRE, TRG, HLD, ONE, HLF
|
|
31
|
+
(default: ${DEFAULT_CONFIG.mode})
|
|
32
|
+
-d, --depth <value> Depth: -64.00 to +63.00 (default: ${DEFAULT_CONFIG.depth})
|
|
33
|
+
-F, --fade <value> Fade: -64 to +63 (default: ${DEFAULT_CONFIG.fade})
|
|
34
|
+
-b, --bpm <value> Project BPM: 1-999 (default: 120)
|
|
35
|
+
-h, --help Show this help
|
|
36
|
+
|
|
37
|
+
Controls:
|
|
38
|
+
[SPACE] Trigger the LFO
|
|
39
|
+
[Q] Quit
|
|
40
|
+
[↑/↓] Adjust BPM (+/- 1)
|
|
41
|
+
[←/→] Adjust speed (+/- 1)
|
|
42
|
+
[W] Cycle waveform
|
|
43
|
+
[M] Cycle mode
|
|
44
|
+
|
|
45
|
+
Examples:
|
|
46
|
+
elektron-lfo # Default triangle LFO
|
|
47
|
+
elektron-lfo -w SIN -s 16 -m 8 # Sine wave, 1 bar cycle at 120 BPM
|
|
48
|
+
elektron-lfo -w RND -s 32 -m 64 -M FRE # Random hi-hat humanizer
|
|
49
|
+
elektron-lfo -w EXP -M TRG -d -63 # Pumping sidechain effect
|
|
50
|
+
`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function parseArgs(args: string[]): CLIArgs {
|
|
54
|
+
const result: CLIArgs = {
|
|
55
|
+
...DEFAULT_CONFIG,
|
|
56
|
+
bpm: 120,
|
|
57
|
+
help: false,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
for (let i = 0; i < args.length; i++) {
|
|
61
|
+
const arg = args[i];
|
|
62
|
+
const nextArg = args[i + 1];
|
|
63
|
+
|
|
64
|
+
switch (arg) {
|
|
65
|
+
case '-h':
|
|
66
|
+
case '--help':
|
|
67
|
+
result.help = true;
|
|
68
|
+
break;
|
|
69
|
+
|
|
70
|
+
case '-w':
|
|
71
|
+
case '--waveform': {
|
|
72
|
+
const waveform = nextArg?.toUpperCase() as Waveform;
|
|
73
|
+
if (!WAVEFORMS.includes(waveform)) {
|
|
74
|
+
console.error(`Invalid waveform: ${nextArg}. Valid: ${WAVEFORMS.join(', ')}`);
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
result.waveform = waveform;
|
|
78
|
+
i++;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
case '-s':
|
|
83
|
+
case '--speed': {
|
|
84
|
+
const speed = parseFloat(nextArg);
|
|
85
|
+
if (isNaN(speed) || speed < -64 || speed > 63) {
|
|
86
|
+
console.error(`Invalid speed: ${nextArg}. Must be -64.00 to +63.00`);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
result.speed = speed;
|
|
90
|
+
i++;
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
case '-m':
|
|
95
|
+
case '--multiplier': {
|
|
96
|
+
const mult = parseInt(nextArg, 10);
|
|
97
|
+
if (!isValidMultiplier(mult)) {
|
|
98
|
+
console.error(`Invalid multiplier: ${nextArg}. Valid: ${VALID_MULTIPLIERS.join(', ')}`);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
result.multiplier = mult;
|
|
102
|
+
i++;
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
case '-f':
|
|
107
|
+
case '--fixed':
|
|
108
|
+
result.useFixedBPM = true;
|
|
109
|
+
break;
|
|
110
|
+
|
|
111
|
+
case '-p':
|
|
112
|
+
case '--phase': {
|
|
113
|
+
const phase = parseInt(nextArg, 10);
|
|
114
|
+
if (isNaN(phase) || phase < 0 || phase > 127) {
|
|
115
|
+
console.error(`Invalid phase: ${nextArg}. Must be 0-127`);
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
result.startPhase = phase;
|
|
119
|
+
i++;
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
case '-M':
|
|
124
|
+
case '--mode': {
|
|
125
|
+
const mode = nextArg?.toUpperCase() as TriggerMode;
|
|
126
|
+
if (!TRIGGER_MODES.includes(mode)) {
|
|
127
|
+
console.error(`Invalid mode: ${nextArg}. Valid: ${TRIGGER_MODES.join(', ')}`);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
result.mode = mode;
|
|
131
|
+
i++;
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
case '-d':
|
|
136
|
+
case '--depth': {
|
|
137
|
+
const depth = parseFloat(nextArg);
|
|
138
|
+
if (isNaN(depth) || depth < -64 || depth > 63) {
|
|
139
|
+
console.error(`Invalid depth: ${nextArg}. Must be -64.00 to +63.00`);
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
result.depth = depth;
|
|
143
|
+
i++;
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
case '-F':
|
|
148
|
+
case '--fade': {
|
|
149
|
+
const fade = parseInt(nextArg, 10);
|
|
150
|
+
if (isNaN(fade) || fade < -64 || fade > 63) {
|
|
151
|
+
console.error(`Invalid fade: ${nextArg}. Must be -64 to +63`);
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
result.fade = fade;
|
|
155
|
+
i++;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
case '-b':
|
|
160
|
+
case '--bpm': {
|
|
161
|
+
const bpm = parseInt(nextArg, 10);
|
|
162
|
+
if (isNaN(bpm) || bpm < 1 || bpm > 999) {
|
|
163
|
+
console.error(`Invalid BPM: ${nextArg}. Must be 1-999`);
|
|
164
|
+
process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
result.bpm = bpm;
|
|
167
|
+
i++;
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
default:
|
|
172
|
+
if (arg.startsWith('-')) {
|
|
173
|
+
console.error(`Unknown option: ${arg}`);
|
|
174
|
+
printHelp();
|
|
175
|
+
process.exit(1);
|
|
176
|
+
}
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal display for Elektron LFO CLI
|
|
3
|
+
*
|
|
4
|
+
* Renders the LFO state with ASCII waveform visualization
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { LFOConfig, LFOState, TimingInfo, Waveform } from '../engine/types';
|
|
8
|
+
import { formatCycleTime, formatFrequency } from '../engine/timing';
|
|
9
|
+
import { generateWaveform, isUnipolar } from '../engine/waveforms';
|
|
10
|
+
|
|
11
|
+
// ANSI escape codes
|
|
12
|
+
const CLEAR_SCREEN = '\x1b[2J';
|
|
13
|
+
const CURSOR_HOME = '\x1b[H';
|
|
14
|
+
const BOLD = '\x1b[1m';
|
|
15
|
+
const DIM = '\x1b[2m';
|
|
16
|
+
const RESET = '\x1b[0m';
|
|
17
|
+
const GREEN = '\x1b[32m';
|
|
18
|
+
const YELLOW = '\x1b[33m';
|
|
19
|
+
const CYAN = '\x1b[36m';
|
|
20
|
+
const RED = '\x1b[31m';
|
|
21
|
+
const MAGENTA = '\x1b[35m';
|
|
22
|
+
|
|
23
|
+
const WAVEFORM_WIDTH = 60;
|
|
24
|
+
const WAVEFORM_HEIGHT = 9;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Generate ASCII waveform preview
|
|
28
|
+
*/
|
|
29
|
+
function generateWaveformPreview(
|
|
30
|
+
waveform: Waveform,
|
|
31
|
+
phase: number,
|
|
32
|
+
state: LFOState
|
|
33
|
+
): string[] {
|
|
34
|
+
const lines: string[] = [];
|
|
35
|
+
const unipolar = isUnipolar(waveform);
|
|
36
|
+
|
|
37
|
+
// Sample the waveform at WAVEFORM_WIDTH points
|
|
38
|
+
const samples: number[] = [];
|
|
39
|
+
const tempState = { ...state };
|
|
40
|
+
|
|
41
|
+
for (let i = 0; i < WAVEFORM_WIDTH; i++) {
|
|
42
|
+
const samplePhase = i / WAVEFORM_WIDTH;
|
|
43
|
+
const result = generateWaveform(waveform, samplePhase, tempState);
|
|
44
|
+
samples.push(result.value);
|
|
45
|
+
if (result.newRandomValue !== undefined) {
|
|
46
|
+
tempState.randomValue = result.newRandomValue;
|
|
47
|
+
}
|
|
48
|
+
if (result.newRandomStep !== undefined) {
|
|
49
|
+
tempState.randomStep = result.newRandomStep;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Find phase position
|
|
54
|
+
const phasePos = Math.floor(phase * WAVEFORM_WIDTH);
|
|
55
|
+
|
|
56
|
+
// Build character grid
|
|
57
|
+
for (let row = 0; row < WAVEFORM_HEIGHT; row++) {
|
|
58
|
+
let line = '';
|
|
59
|
+
const rowValue = unipolar
|
|
60
|
+
? 1 - row / (WAVEFORM_HEIGHT - 1) // 0 to 1 mapping
|
|
61
|
+
: 1 - (row / (WAVEFORM_HEIGHT - 1)) * 2; // -1 to 1 mapping
|
|
62
|
+
|
|
63
|
+
for (let col = 0; col < WAVEFORM_WIDTH; col++) {
|
|
64
|
+
const sampleValue = samples[col];
|
|
65
|
+
const isPhaseIndicator = col === phasePos;
|
|
66
|
+
|
|
67
|
+
// Check if this cell should show the waveform
|
|
68
|
+
const cellThreshold = unipolar
|
|
69
|
+
? rowValue - 0.5 / (WAVEFORM_HEIGHT - 1)
|
|
70
|
+
: rowValue - 1 / (WAVEFORM_HEIGHT - 1);
|
|
71
|
+
|
|
72
|
+
let char = ' ';
|
|
73
|
+
|
|
74
|
+
if (unipolar) {
|
|
75
|
+
// For unipolar, show the waveform as filled area from bottom
|
|
76
|
+
if (sampleValue >= cellThreshold && rowValue <= 1) {
|
|
77
|
+
char = isPhaseIndicator ? '█' : '░';
|
|
78
|
+
}
|
|
79
|
+
} else {
|
|
80
|
+
// For bipolar, show just the line
|
|
81
|
+
const nextRowValue = row < WAVEFORM_HEIGHT - 1
|
|
82
|
+
? 1 - ((row + 1) / (WAVEFORM_HEIGHT - 1)) * 2
|
|
83
|
+
: -2;
|
|
84
|
+
|
|
85
|
+
if (
|
|
86
|
+
(sampleValue >= cellThreshold && sampleValue < rowValue) ||
|
|
87
|
+
(sampleValue <= rowValue && sampleValue > nextRowValue)
|
|
88
|
+
) {
|
|
89
|
+
char = isPhaseIndicator ? '█' : '●';
|
|
90
|
+
} else if (row === Math.floor((WAVEFORM_HEIGHT - 1) / 2)) {
|
|
91
|
+
// Center line (zero crossing)
|
|
92
|
+
char = isPhaseIndicator ? '█' : '─';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (isPhaseIndicator && char === ' ') {
|
|
97
|
+
char = '│';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
line += char;
|
|
101
|
+
}
|
|
102
|
+
lines.push(line);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return lines;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Format output bar
|
|
110
|
+
*/
|
|
111
|
+
function formatOutputBar(output: number, width: number = 30): string {
|
|
112
|
+
const normalizedOutput = (output + 1) / 2; // Convert -1..1 to 0..1
|
|
113
|
+
const position = Math.round(normalizedOutput * (width - 1));
|
|
114
|
+
const centerPos = Math.floor(width / 2);
|
|
115
|
+
|
|
116
|
+
let bar = '';
|
|
117
|
+
for (let i = 0; i < width; i++) {
|
|
118
|
+
if (i === centerPos) {
|
|
119
|
+
bar += '│';
|
|
120
|
+
} else if (
|
|
121
|
+
(output >= 0 && i > centerPos && i <= position) ||
|
|
122
|
+
(output < 0 && i >= position && i < centerPos)
|
|
123
|
+
) {
|
|
124
|
+
bar += '=';
|
|
125
|
+
} else {
|
|
126
|
+
bar += ' ';
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return `[${bar}]`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Get status color based on mode and running state
|
|
135
|
+
*/
|
|
136
|
+
function getStatusColor(isRunning: boolean, mode: string): string {
|
|
137
|
+
if (!isRunning) return RED;
|
|
138
|
+
if (mode === 'FRE') return GREEN;
|
|
139
|
+
return CYAN;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Get status text
|
|
144
|
+
*/
|
|
145
|
+
function getStatusText(isRunning: boolean, mode: string): string {
|
|
146
|
+
if (!isRunning) {
|
|
147
|
+
return mode === 'ONE' || mode === 'HLF' ? 'STOPPED - Press SPACE to trigger' : 'STOPPED';
|
|
148
|
+
}
|
|
149
|
+
return mode === 'FRE' ? 'FREE RUNNING' : 'RUNNING';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Render the full display
|
|
154
|
+
*/
|
|
155
|
+
export function render(
|
|
156
|
+
config: LFOConfig,
|
|
157
|
+
state: LFOState,
|
|
158
|
+
timing: TimingInfo,
|
|
159
|
+
bpm: number
|
|
160
|
+
): string {
|
|
161
|
+
const lines: string[] = [];
|
|
162
|
+
|
|
163
|
+
// Header
|
|
164
|
+
lines.push(`${BOLD}═══ Elektron LFO Visualizer ═══${RESET}`);
|
|
165
|
+
lines.push('');
|
|
166
|
+
|
|
167
|
+
// Parameters row 1
|
|
168
|
+
const waveColor = isUnipolar(config.waveform) ? MAGENTA : CYAN;
|
|
169
|
+
lines.push(
|
|
170
|
+
`${BOLD}WAVE:${RESET} ${waveColor}${config.waveform}${RESET} ` +
|
|
171
|
+
`${BOLD}SPD:${RESET} ${config.speed >= 0 ? '+' : ''}${config.speed.toFixed(2)} ` +
|
|
172
|
+
`${BOLD}MULT:${RESET} ${config.multiplier} ` +
|
|
173
|
+
`${BOLD}MODE:${RESET} ${YELLOW}${config.mode}${RESET}`
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
// Parameters row 2
|
|
177
|
+
const phaseDegrees = Math.round((config.startPhase / 128) * 360);
|
|
178
|
+
const fadeSign = config.fade > 0 ? '+' : config.fade < 0 ? '' : ' ';
|
|
179
|
+
const fadeLabel = config.fade < 0 ? 'IN' : config.fade > 0 ? 'OUT' : '';
|
|
180
|
+
lines.push(
|
|
181
|
+
`${BOLD}SPH:${RESET} ${config.startPhase} (${phaseDegrees}°) ` +
|
|
182
|
+
`${BOLD}DEP:${RESET} ${config.depth >= 0 ? '+' : ''}${config.depth.toFixed(2)} ` +
|
|
183
|
+
`${BOLD}FADE:${RESET} ${fadeSign}${config.fade} ${fadeLabel}`
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
// Timing info
|
|
187
|
+
const effectiveBpm = config.useFixedBPM ? 120 : bpm;
|
|
188
|
+
const bpmSuffix = config.useFixedBPM ? ' (fixed)' : '';
|
|
189
|
+
lines.push(
|
|
190
|
+
`${BOLD}BPM:${RESET} ${effectiveBpm}${bpmSuffix} ` +
|
|
191
|
+
`${BOLD}Cycle:${RESET} ${formatCycleTime(timing.cycleTimeMs)} (${timing.noteValue}) ` +
|
|
192
|
+
`${BOLD}Hz:${RESET} ${formatFrequency(timing.frequencyHz)}`
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
lines.push('');
|
|
196
|
+
|
|
197
|
+
// Waveform visualization
|
|
198
|
+
const waveformLines = generateWaveformPreview(config.waveform, state.phase, state);
|
|
199
|
+
for (const line of waveformLines) {
|
|
200
|
+
lines.push(` ${DIM}${line}${RESET}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
lines.push('');
|
|
204
|
+
|
|
205
|
+
// Output display
|
|
206
|
+
const outputSign = state.output >= 0 ? '+' : '';
|
|
207
|
+
const outputBar = formatOutputBar(state.output);
|
|
208
|
+
lines.push(
|
|
209
|
+
`${BOLD}Output:${RESET} ${outputSign}${state.output.toFixed(4)} ${outputBar}`
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
// State info
|
|
213
|
+
const fadePercent = Math.round(state.fadeMultiplier * 100);
|
|
214
|
+
const phasePercent = (state.phase * 100).toFixed(1);
|
|
215
|
+
lines.push(
|
|
216
|
+
`${BOLD}Phase:${RESET} ${phasePercent}% ` +
|
|
217
|
+
`${BOLD}Fade:${RESET} ${fadePercent}% ` +
|
|
218
|
+
`${BOLD}Cycles:${RESET} ${state.cycleCount} ` +
|
|
219
|
+
`${BOLD}Triggers:${RESET} ${state.triggerCount}`
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
lines.push('');
|
|
223
|
+
|
|
224
|
+
// Status
|
|
225
|
+
const statusColor = getStatusColor(state.isRunning, config.mode);
|
|
226
|
+
const statusText = getStatusText(state.isRunning, config.mode);
|
|
227
|
+
lines.push(`${statusColor}[${statusText}]${RESET}`);
|
|
228
|
+
|
|
229
|
+
lines.push('');
|
|
230
|
+
|
|
231
|
+
// Controls
|
|
232
|
+
lines.push(
|
|
233
|
+
`${DIM}Controls: [SPACE] Trigger [Q] Quit [↑/↓] BPM [←/→] Speed [W] Wave [M] Mode${RESET}`
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
return CLEAR_SCREEN + CURSOR_HOME + lines.join('\n');
|
|
237
|
+
}
|
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Elektron LFO CLI - Main Entry Point
|
|
4
|
+
*
|
|
5
|
+
* Real-time terminal visualization of the Digitakt II LFO engine
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { LFO } from '../engine/lfo';
|
|
9
|
+
import { clamp } from '../engine/types';
|
|
10
|
+
import { parseArgs, printHelp } from './args';
|
|
11
|
+
import { render } from './display';
|
|
12
|
+
import { setupKeyboardInput, cleanupKeyboardInput, parseKey } from './keyboard';
|
|
13
|
+
|
|
14
|
+
const TARGET_FPS = 60;
|
|
15
|
+
const FRAME_TIME_MS = 1000 / TARGET_FPS;
|
|
16
|
+
|
|
17
|
+
async function main() {
|
|
18
|
+
// Parse command line arguments
|
|
19
|
+
const args = parseArgs(process.argv.slice(2));
|
|
20
|
+
|
|
21
|
+
if (args.help) {
|
|
22
|
+
printHelp();
|
|
23
|
+
process.exit(0);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Create LFO instance
|
|
27
|
+
const lfo = new LFO(
|
|
28
|
+
{
|
|
29
|
+
waveform: args.waveform,
|
|
30
|
+
speed: args.speed,
|
|
31
|
+
multiplier: args.multiplier,
|
|
32
|
+
useFixedBPM: args.useFixedBPM,
|
|
33
|
+
startPhase: args.startPhase,
|
|
34
|
+
mode: args.mode,
|
|
35
|
+
depth: args.depth,
|
|
36
|
+
fade: args.fade,
|
|
37
|
+
},
|
|
38
|
+
args.bpm
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
let bpm = args.bpm;
|
|
42
|
+
let running = true;
|
|
43
|
+
|
|
44
|
+
// Hide cursor
|
|
45
|
+
process.stdout.write('\x1b[?25l');
|
|
46
|
+
|
|
47
|
+
// Set up keyboard input
|
|
48
|
+
setupKeyboardInput(
|
|
49
|
+
(key: Buffer) => {
|
|
50
|
+
const config = lfo.getConfig();
|
|
51
|
+
const action = parseKey(key, config.waveform, config.mode, config.multiplier);
|
|
52
|
+
|
|
53
|
+
switch (action.type) {
|
|
54
|
+
case 'quit':
|
|
55
|
+
running = false;
|
|
56
|
+
break;
|
|
57
|
+
|
|
58
|
+
case 'trigger':
|
|
59
|
+
lfo.trigger();
|
|
60
|
+
break;
|
|
61
|
+
|
|
62
|
+
case 'bpm':
|
|
63
|
+
bpm = clamp(bpm + action.delta, 1, 999);
|
|
64
|
+
lfo.setBpm(bpm);
|
|
65
|
+
break;
|
|
66
|
+
|
|
67
|
+
case 'speed': {
|
|
68
|
+
const newSpeed = clamp(config.speed + action.delta, -64, 63);
|
|
69
|
+
lfo.setConfig({ speed: newSpeed });
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
case 'waveform':
|
|
74
|
+
lfo.setConfig({ waveform: action.waveform });
|
|
75
|
+
break;
|
|
76
|
+
|
|
77
|
+
case 'mode':
|
|
78
|
+
lfo.setConfig({ mode: action.mode });
|
|
79
|
+
break;
|
|
80
|
+
|
|
81
|
+
case 'multiplier':
|
|
82
|
+
lfo.setConfig({ multiplier: action.multiplier });
|
|
83
|
+
break;
|
|
84
|
+
|
|
85
|
+
case 'depth': {
|
|
86
|
+
const newDepth = clamp(config.depth + action.delta, -64, 63);
|
|
87
|
+
lfo.setConfig({ depth: newDepth });
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
case 'fade': {
|
|
92
|
+
const newFade = clamp(config.fade + action.delta, -64, 63);
|
|
93
|
+
lfo.setConfig({ fade: newFade });
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
() => {
|
|
99
|
+
running = false;
|
|
100
|
+
}
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// Main render loop
|
|
104
|
+
const startTime = performance.now();
|
|
105
|
+
|
|
106
|
+
while (running) {
|
|
107
|
+
const currentTime = performance.now();
|
|
108
|
+
const state = lfo.update(currentTime - startTime);
|
|
109
|
+
const config = lfo.getConfig();
|
|
110
|
+
const timing = lfo.getTimingInfo();
|
|
111
|
+
|
|
112
|
+
// Render display
|
|
113
|
+
const display = render(config, state, timing, bpm);
|
|
114
|
+
process.stdout.write(display);
|
|
115
|
+
|
|
116
|
+
// Wait for next frame
|
|
117
|
+
await Bun.sleep(FRAME_TIME_MS);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Cleanup
|
|
121
|
+
cleanupKeyboardInput();
|
|
122
|
+
console.log('Goodbye!');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
main().catch((error) => {
|
|
126
|
+
cleanupKeyboardInput();
|
|
127
|
+
console.error('Error:', error);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyboard input handling for Elektron LFO CLI
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Waveform, TriggerMode, Multiplier } from '../engine/types';
|
|
6
|
+
import { VALID_MULTIPLIERS } from '../engine/types';
|
|
7
|
+
|
|
8
|
+
const WAVEFORMS: Waveform[] = ['TRI', 'SIN', 'SQR', 'SAW', 'EXP', 'RMP', 'RND'];
|
|
9
|
+
const TRIGGER_MODES: TriggerMode[] = ['FRE', 'TRG', 'HLD', 'ONE', 'HLF'];
|
|
10
|
+
|
|
11
|
+
export type KeyAction =
|
|
12
|
+
| { type: 'trigger' }
|
|
13
|
+
| { type: 'quit' }
|
|
14
|
+
| { type: 'bpm'; delta: number }
|
|
15
|
+
| { type: 'speed'; delta: number }
|
|
16
|
+
| { type: 'waveform'; waveform: Waveform }
|
|
17
|
+
| { type: 'mode'; mode: TriggerMode }
|
|
18
|
+
| { type: 'multiplier'; multiplier: Multiplier }
|
|
19
|
+
| { type: 'depth'; delta: number }
|
|
20
|
+
| { type: 'fade'; delta: number }
|
|
21
|
+
| { type: 'unknown' };
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parse a key input into an action
|
|
25
|
+
*/
|
|
26
|
+
export function parseKey(
|
|
27
|
+
key: Buffer,
|
|
28
|
+
currentWaveform: Waveform,
|
|
29
|
+
currentMode: TriggerMode,
|
|
30
|
+
currentMultiplier: Multiplier
|
|
31
|
+
): KeyAction {
|
|
32
|
+
const keyStr = key.toString();
|
|
33
|
+
|
|
34
|
+
// Check for Ctrl+C
|
|
35
|
+
if (key[0] === 3) {
|
|
36
|
+
return { type: 'quit' };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Check for escape sequences (arrow keys)
|
|
40
|
+
if (key[0] === 27 && key[1] === 91) {
|
|
41
|
+
switch (key[2]) {
|
|
42
|
+
case 65: // Up arrow
|
|
43
|
+
return { type: 'bpm', delta: 1 };
|
|
44
|
+
case 66: // Down arrow
|
|
45
|
+
return { type: 'bpm', delta: -1 };
|
|
46
|
+
case 67: // Right arrow
|
|
47
|
+
return { type: 'speed', delta: 1 };
|
|
48
|
+
case 68: // Left arrow
|
|
49
|
+
return { type: 'speed', delta: -1 };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Single character commands
|
|
54
|
+
switch (keyStr.toLowerCase()) {
|
|
55
|
+
case ' ':
|
|
56
|
+
return { type: 'trigger' };
|
|
57
|
+
|
|
58
|
+
case 'q':
|
|
59
|
+
return { type: 'quit' };
|
|
60
|
+
|
|
61
|
+
case 'w': {
|
|
62
|
+
// Cycle to next waveform
|
|
63
|
+
const currentIndex = WAVEFORMS.indexOf(currentWaveform);
|
|
64
|
+
const nextIndex = (currentIndex + 1) % WAVEFORMS.length;
|
|
65
|
+
return { type: 'waveform', waveform: WAVEFORMS[nextIndex] };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
case 'm': {
|
|
69
|
+
// Cycle to next mode
|
|
70
|
+
const currentIndex = TRIGGER_MODES.indexOf(currentMode);
|
|
71
|
+
const nextIndex = (currentIndex + 1) % TRIGGER_MODES.length;
|
|
72
|
+
return { type: 'mode', mode: TRIGGER_MODES[nextIndex] };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
case '[': {
|
|
76
|
+
// Previous multiplier
|
|
77
|
+
const currentIndex = VALID_MULTIPLIERS.indexOf(currentMultiplier);
|
|
78
|
+
const prevIndex = Math.max(0, currentIndex - 1);
|
|
79
|
+
return { type: 'multiplier', multiplier: VALID_MULTIPLIERS[prevIndex] };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
case ']': {
|
|
83
|
+
// Next multiplier
|
|
84
|
+
const currentIndex = VALID_MULTIPLIERS.indexOf(currentMultiplier);
|
|
85
|
+
const nextIndex = Math.min(VALID_MULTIPLIERS.length - 1, currentIndex + 1);
|
|
86
|
+
return { type: 'multiplier', multiplier: VALID_MULTIPLIERS[nextIndex] };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
case '-':
|
|
90
|
+
case '_':
|
|
91
|
+
return { type: 'depth', delta: -1 };
|
|
92
|
+
|
|
93
|
+
case '=':
|
|
94
|
+
case '+':
|
|
95
|
+
return { type: 'depth', delta: 1 };
|
|
96
|
+
|
|
97
|
+
case ',':
|
|
98
|
+
case '<':
|
|
99
|
+
return { type: 'fade', delta: -1 };
|
|
100
|
+
|
|
101
|
+
case '.':
|
|
102
|
+
case '>':
|
|
103
|
+
return { type: 'fade', delta: 1 };
|
|
104
|
+
|
|
105
|
+
default:
|
|
106
|
+
return { type: 'unknown' };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Set up raw mode input handling
|
|
112
|
+
*/
|
|
113
|
+
export function setupKeyboardInput(
|
|
114
|
+
onKey: (key: Buffer) => void,
|
|
115
|
+
onExit: () => void
|
|
116
|
+
): void {
|
|
117
|
+
// Enable raw mode for single keypress detection
|
|
118
|
+
if (process.stdin.isTTY) {
|
|
119
|
+
process.stdin.setRawMode(true);
|
|
120
|
+
}
|
|
121
|
+
process.stdin.resume();
|
|
122
|
+
|
|
123
|
+
process.stdin.on('data', (key: Buffer) => {
|
|
124
|
+
onKey(key);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Handle process termination
|
|
128
|
+
process.on('SIGINT', onExit);
|
|
129
|
+
process.on('SIGTERM', onExit);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Clean up keyboard input handling
|
|
134
|
+
*/
|
|
135
|
+
export function cleanupKeyboardInput(): void {
|
|
136
|
+
if (process.stdin.isTTY) {
|
|
137
|
+
process.stdin.setRawMode(false);
|
|
138
|
+
}
|
|
139
|
+
process.stdin.pause();
|
|
140
|
+
|
|
141
|
+
// Clear screen and show cursor
|
|
142
|
+
process.stdout.write('\x1b[2J\x1b[H\x1b[?25h');
|
|
143
|
+
}
|