elektron-lfo 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }
@@ -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
+ }