opennote-cli 1.3.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.
Files changed (50) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +272 -0
  3. package/package.json +30 -0
  4. package/src/arrangement.js +282 -0
  5. package/src/arrangement.ts +317 -0
  6. package/src/assets/cover.png +0 -0
  7. package/src/cli.js +1398 -0
  8. package/src/cli.ts +1709 -0
  9. package/src/exportAudio.js +459 -0
  10. package/src/exportAudio.ts +499 -0
  11. package/src/exportMidi.js +85 -0
  12. package/src/exportMidi.ts +103 -0
  13. package/src/ffmpeg-static.d.ts +5 -0
  14. package/src/fx.js +28 -0
  15. package/src/fx.ts +49 -0
  16. package/src/generator.js +15 -0
  17. package/src/generator.ts +29 -0
  18. package/src/index.js +511 -0
  19. package/src/index.ts +642 -0
  20. package/src/instrument.js +35 -0
  21. package/src/instrument.ts +51 -0
  22. package/src/midi.js +167 -0
  23. package/src/midi.ts +218 -0
  24. package/src/openExport.js +22 -0
  25. package/src/openExport.ts +24 -0
  26. package/src/prompt.js +22 -0
  27. package/src/prompt.ts +25 -0
  28. package/src/providers/auth.js +23 -0
  29. package/src/providers/auth.ts +30 -0
  30. package/src/providers/claudeProvider.js +46 -0
  31. package/src/providers/claudeProvider.ts +50 -0
  32. package/src/providers/factory.js +39 -0
  33. package/src/providers/factory.ts +43 -0
  34. package/src/providers/geminiProvider.js +55 -0
  35. package/src/providers/geminiProvider.ts +71 -0
  36. package/src/providers/grokProvider.js +57 -0
  37. package/src/providers/grokProvider.ts +69 -0
  38. package/src/providers/groqProvider.js +57 -0
  39. package/src/providers/groqProvider.ts +69 -0
  40. package/src/providers/mockProvider.js +13 -0
  41. package/src/providers/mockProvider.ts +15 -0
  42. package/src/providers/openaiProvider.js +45 -0
  43. package/src/providers/openaiProvider.ts +49 -0
  44. package/src/providers/retry.js +46 -0
  45. package/src/providers/retry.ts +54 -0
  46. package/src/types.js +1 -0
  47. package/src/types.ts +17 -0
  48. package/src/validate.js +10 -0
  49. package/src/validate.ts +13 -0
  50. package/tsconfig.json +13 -0
package/src/cli.ts ADDED
@@ -0,0 +1,1709 @@
1
+ import type { InstrumentName } from './instrument';
2
+ import type {
3
+ BackingControls,
4
+ GenerationMode,
5
+ GateStyle,
6
+ GrowthStyle,
7
+ MetronomeMode,
8
+ ModRate,
9
+ ModTarget,
10
+ PitchRange,
11
+ TimingFeel,
12
+ } from './arrangement';
13
+ import type { DecayStyle, FxPresetName } from './fx';
14
+ import type { ProviderName } from './providers/factory';
15
+
16
+ export type CliConfig = {
17
+ provider: ProviderName;
18
+ providerAuth: 'none' | 'env' | 'session';
19
+ source: 'generated' | 'record';
20
+ mode: GenerationMode;
21
+ instrument: InstrumentName;
22
+ fxPreset: FxPresetName;
23
+ decayStyle: DecayStyle;
24
+ transpose: number;
25
+ pitchRange: PitchRange;
26
+ snapScale: boolean;
27
+ modRate: ModRate;
28
+ modDepth: number;
29
+ modTarget: ModTarget;
30
+ growthStyle: GrowthStyle;
31
+ durationStretch: number;
32
+ timingFeel: TimingFeel;
33
+ timingAmount: number;
34
+ backing: BackingControls;
35
+ theme: string;
36
+ length: number;
37
+ bpm: number;
38
+ seedPitch: number;
39
+ seedSource: 'manual' | 'keyboard';
40
+ beep: boolean;
41
+ openAfterExport: 'none' | 'finder' | 'garageband';
42
+ exportAudio: 'none' | 'mp3' | 'mp4';
43
+ exportStems: boolean;
44
+ eqMode: 'balanced' | 'flat' | 'warm' | 'bright' | 'bass' | 'phone';
45
+ recordDevice: string;
46
+ recordSeconds: number;
47
+ recordMonitor: boolean;
48
+ recordProfile: 'default' | 'vinyl' | 'dust';
49
+ recordFamily: 'character' | 'motion' | 'space' | 'bug';
50
+ recordBugMode: 'off' | 'pll-drift' | 'buffer-tear' | 'clock-bleed' | 'memory-rot' | 'crc-glitch';
51
+ recordIntensity: number;
52
+ recordChaos: number;
53
+ recordMix: number;
54
+ recordScratch: 'off' | 'texture' | 'dj';
55
+ recordWavy: number;
56
+ };
57
+
58
+ const c = {
59
+ reset: '\x1b[0m',
60
+ bold: '\x1b[1m',
61
+ dim: '\x1b[2m',
62
+ yellow: '\x1b[33m',
63
+ red: '\x1b[31m',
64
+ };
65
+
66
+ function fgHex(hex: string): string {
67
+ const clean = hex.replace('#', '');
68
+ const r = Number.parseInt(clean.slice(0, 2), 16);
69
+ const g = Number.parseInt(clean.slice(2, 4), 16);
70
+ const b = Number.parseInt(clean.slice(4, 6), 16);
71
+ return `\x1b[38;2;${r};${g};${b}m`;
72
+ }
73
+
74
+ const palette = {
75
+ primary: fgHex('#d199ff'),
76
+ soft: fgHex('#ecebff'),
77
+ };
78
+
79
+ function color(text: string, code: string): string {
80
+ return `${code}${text}${c.reset}`;
81
+ }
82
+
83
+ function asPositiveInt(value: string, fallback: number): number {
84
+ const parsed = Number.parseInt(value, 10);
85
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
86
+ }
87
+
88
+ function asMidiPitch(value: string, fallback: number): number {
89
+ const parsed = Number.parseInt(value, 10);
90
+ if (!Number.isFinite(parsed)) return fallback;
91
+ return Math.max(0, Math.min(127, parsed));
92
+ }
93
+
94
+ function asBoundedInt(value: string, fallback: number, min: number, max: number): number {
95
+ const parsed = Number.parseInt(value, 10);
96
+ if (!Number.isFinite(parsed)) return fallback;
97
+ return Math.max(min, Math.min(max, parsed));
98
+ }
99
+
100
+ type Option<T> = {
101
+ value: T;
102
+ label: string;
103
+ help?: string;
104
+ };
105
+
106
+ type SelectPromptFn = (cfg: any) => Promise<any>;
107
+ type InputPromptFn = (cfg: any) => Promise<string>;
108
+ type PasswordPromptFn = (cfg: any) => Promise<string>;
109
+ type SetupPath = 'basic' | 'surprise' | 'advanced';
110
+ type SurpriseStrength = 'low' | 'medium' | 'wild';
111
+
112
+ const inquirerTheme = {
113
+ icon: {
114
+ cursor: '❯',
115
+ },
116
+ style: {
117
+ highlight: (text: string) => color(text, palette.primary),
118
+ message: (text: string) => color(text, `${c.bold}${palette.soft}`),
119
+ description: (text: string) => color(text, `${c.dim}${palette.soft}`),
120
+ answer: (text: string) => color(text, palette.primary),
121
+ defaultAnswer: (text: string) => color(text, `${c.dim}${palette.soft}`),
122
+ help: (text: string) => color(text, `${c.dim}${palette.soft}`),
123
+ key: (text: string) => color(text, palette.primary),
124
+ error: (text: string) => color(text, c.red),
125
+ },
126
+ };
127
+
128
+ const THEME_OPTIONS: Option<string>[] = [
129
+ { value: 'ambient cinematic', label: 'Chill Ambient', help: 'Slow, atmospheric, spacious tones.' },
130
+ { value: 'lofi chillhop', label: 'Chill Lo-fi', help: 'Warm, mellow, relaxed melodic movement.' },
131
+ { value: 'minimal techno groove', label: 'Electronic Techno', help: 'Repetitive, driving, club-like phrasing.' },
132
+ { value: 'melodic house uplifting', label: 'Electronic House', help: 'Steady and emotive dance melodies.' },
133
+ { value: 'synthwave retro neon', label: 'Electronic Synthwave', help: '80s-inspired arps and nostalgic hooks.' },
134
+ { value: 'trap melodic lead', label: 'Modern Trap', help: 'Modern, sparse, hook-focused movement.' },
135
+ { value: 'jazz neo-soul phrasing', label: 'Organic Jazz / Neo Soul', help: 'Colorful, expressive note choices.' },
136
+ { value: 'cinematic suspense score', label: 'Score Cinematic', help: 'Dramatic, soundtrack-style motifs.' },
137
+ { value: 'dark industrial pulse', label: 'Score Industrial', help: 'Tense, gritty, mechanical feel.' },
138
+ { value: '__custom__', label: 'Custom', help: 'Type your own style prompt.' },
139
+ ];
140
+
141
+ const LENGTH_OPTIONS: Option<number>[] = [
142
+ { value: 8, label: 'Short (8 notes)' },
143
+ { value: 16, label: 'Medium (16 notes)' },
144
+ { value: 32, label: 'Long (32 notes)' },
145
+ { value: -1, label: 'Custom' },
146
+ ];
147
+
148
+ const BPM_OPTIONS: Option<number>[] = [
149
+ { value: 90, label: 'Chill (90 BPM)' },
150
+ { value: 110, label: 'Groove (110 BPM)' },
151
+ { value: 120, label: 'Standard (120 BPM)' },
152
+ { value: 128, label: 'Club (128 BPM)' },
153
+ { value: -1, label: 'Custom' },
154
+ ];
155
+
156
+ const SEED_SOURCE_OPTIONS: Option<'manual' | 'keyboard'>[] = [
157
+ { value: 'keyboard', label: 'Keyboard test mode (1-8 keys)', help: 'Use computer keyboard to pick seed note.' },
158
+ { value: 'manual', label: 'Manual MIDI pitch value', help: 'Type pitch number directly (0-127).' },
159
+ ];
160
+
161
+ const OPEN_AFTER_EXPORT_OPTIONS: Option<'none' | 'finder' | 'garageband'>[] = [
162
+ { value: 'none', label: 'Do nothing', help: 'Keep control in CLI only.' },
163
+ { value: 'finder', label: 'Reveal in Finder', help: 'Open Finder and highlight exported MIDI.' },
164
+ { value: 'garageband', label: 'Open in GarageBand', help: 'Launch GarageBand with exported MIDI.' },
165
+ ];
166
+
167
+ const EXPORT_AUDIO_OPTIONS: Option<'none' | 'mp3' | 'mp4'>[] = [
168
+ { value: 'mp4', label: 'MIDI + MP4', help: 'Render video with static cover image + audio.' },
169
+ { value: 'mp3', label: 'MIDI + MP3', help: 'Render audio from generated notes.' },
170
+ { value: 'none', label: 'MIDI only (.mid)', help: 'No extra audio/video export.' },
171
+ ];
172
+
173
+ const EXPORT_AUDIO_RECORD_OPTIONS: Option<'none' | 'mp3' | 'mp4'>[] = [
174
+ { value: 'mp4', label: 'MIDI + MP4', help: 'Render video with record-player character FX.' },
175
+ { value: 'mp3', label: 'MIDI + MP3', help: 'Render audio with record-player character FX.' },
176
+ { value: 'none', label: 'MIDI only (.mid)', help: 'No extra audio/video export.' },
177
+ ];
178
+
179
+ const INSTRUMENT_OPTIONS: Option<InstrumentName>[] = [
180
+ { value: 'lead', label: 'Lead', help: 'Higher melodic line.' },
181
+ { value: 'bass', label: 'Bass', help: 'Lower-end groove line.' },
182
+ { value: 'pad', label: 'Pad', help: 'Smoother, wider note range.' },
183
+ { value: 'keys', label: 'Keys', help: 'Mid register keyboard tone.' },
184
+ { value: 'drums', label: 'Drums', help: 'Maps notes to drum hits (channel 10).' },
185
+ ];
186
+
187
+ const FX_PRESET_OPTIONS: Option<FxPresetName>[] = [
188
+ { value: 'clean', label: 'Clean', help: 'Minimal coloration.' },
189
+ { value: 'dark', label: 'Dark', help: 'Darker tone with mild grit.' },
190
+ { value: 'grime', label: 'Grime', help: 'Heavy drive + crunch.' },
191
+ { value: 'lush', label: 'Lush', help: 'Wider, wetter ambience.' },
192
+ { value: 'punch', label: 'Punch', help: 'Tighter attack and impact.' },
193
+ ];
194
+
195
+ const DECAY_OPTIONS: Option<DecayStyle>[] = [
196
+ { value: 'tight', label: 'Tight', help: 'Shorter decay, plucky feel.' },
197
+ { value: 'balanced', label: 'Balanced', help: 'Default decay.' },
198
+ { value: 'long', label: 'Long', help: 'Extended tail/sustain.' },
199
+ ];
200
+
201
+ const MODE_OPTIONS: Option<GenerationMode>[] = [
202
+ { value: 'single', label: 'Single Track', help: 'Current flow, melody only.' },
203
+ { value: 'backing', label: 'AI Backing', help: 'Reveal drums/bass/groove controls.' },
204
+ ];
205
+
206
+ const SOURCE_OPTIONS: Option<'generated' | 'record'>[] = [
207
+ { value: 'generated', label: 'Generate', help: 'Default flow: generate MIDI notes (melody + optional backing).'},
208
+ { value: 'record', label: 'Record Player', help: 'Generate notes, then render with record-player FX controls.' },
209
+ ];
210
+
211
+ const RECORD_PROFILE_OPTIONS: Option<'default' | 'vinyl' | 'dust'>[] = [
212
+ { value: 'default', label: 'Default', help: 'Cleanest render with no extra record color.' },
213
+ { value: 'vinyl', label: 'Vinyl', help: 'Warm deck tone with gentle saturation.' },
214
+ { value: 'dust', label: 'Dust', help: 'Darker lo-fi texture with grit.' },
215
+ ];
216
+
217
+ const EQ_MODE_OPTIONS: Option<'balanced' | 'flat' | 'warm' | 'bright' | 'bass' | 'phone'>[] = [
218
+ { value: 'balanced', label: 'Balanced', help: 'General-purpose leveling and tone balance.' },
219
+ { value: 'flat', label: 'Flat', help: 'Minimal tonal shaping.' },
220
+ { value: 'warm', label: 'Warm', help: 'Softer highs with fuller low-mids.' },
221
+ { value: 'bright', label: 'Bright', help: 'Extra top-end clarity.' },
222
+ { value: 'bass', label: 'Bass Boost', help: 'Low-end emphasis for heavier vibe.' },
223
+ { value: 'phone', label: 'Phone / Radio', help: 'Narrow band-limited texture.' },
224
+ ];
225
+
226
+ const RECORD_FAMILY_OPTIONS: Option<'character' | 'motion' | 'space' | 'bug'>[] = [
227
+ { value: 'character', label: 'Character', help: 'Tone color and saturation.' },
228
+ { value: 'motion', label: 'Motion', help: 'Time movement and wobble behavior.' },
229
+ { value: 'space', label: 'Space', help: 'Echo and room-style width.' },
230
+ { value: 'bug', label: 'Bug Mode', help: 'Intentional unstable/glitch character.' },
231
+ ];
232
+
233
+ const BUG_MODE_OPTIONS: Option<'off' | 'pll-drift' | 'buffer-tear' | 'clock-bleed' | 'memory-rot' | 'crc-glitch'>[] = [
234
+ { value: 'off', label: 'Off', help: 'No bug modulation.' },
235
+ { value: 'pll-drift', label: 'PLL Drift', help: 'Pitch hunts around center lock.' },
236
+ { value: 'buffer-tear', label: 'Buffer Tear', help: 'Micro pull/tear movement.' },
237
+ { value: 'clock-bleed', label: 'Clock Bleed', help: 'Aliased digital grit.' },
238
+ { value: 'memory-rot', label: 'Memory Rot', help: 'Decaying lo-fi loss.' },
239
+ { value: 'crc-glitch', label: 'CRC Glitch', help: 'Sharp error-like gating.' },
240
+ ];
241
+
242
+ const SETUP_PATH_OPTIONS: Option<SetupPath>[] = [
243
+ { value: 'basic', label: 'Basic', help: 'Fast setup using default pitch/mod/backing values.' },
244
+ { value: 'surprise', label: 'Surprise me', help: 'Auto-pick presets and controls from your style.' },
245
+ { value: 'advanced', label: 'Advanced', help: 'Full control over pitch, modulation, and groove.' },
246
+ ];
247
+
248
+ const SURPRISE_STRENGTH_OPTIONS: Option<SurpriseStrength>[] = [
249
+ { value: 'low', label: 'Low', help: 'Safer combinations, subtle variation.' },
250
+ { value: 'medium', label: 'Medium', help: 'Balanced novelty and musical stability.' },
251
+ { value: 'wild', label: 'Wild', help: 'Higher-risk, stronger character jumps.' },
252
+ ];
253
+
254
+ function pickOne<T>(items: T[]): T {
255
+ return items[Math.floor(Math.random() * items.length)];
256
+ }
257
+
258
+ function rangeInt(min: number, max: number): number {
259
+ if (max <= min) return min;
260
+ return min + Math.floor(Math.random() * (max - min + 1));
261
+ }
262
+
263
+ function strengthChance(strength: SurpriseStrength, low: number, medium: number, wild: number): boolean {
264
+ const p = strength === 'low' ? low : strength === 'medium' ? medium : wild;
265
+ return Math.random() < p;
266
+ }
267
+
268
+ function surprisePreset(theme: string, strength: SurpriseStrength): {
269
+ source: 'generated' | 'record';
270
+ mode: GenerationMode;
271
+ instrument: InstrumentName;
272
+ eqMode: 'balanced' | 'flat' | 'warm' | 'bright' | 'bass' | 'phone';
273
+ fxPreset: FxPresetName;
274
+ decayStyle: DecayStyle;
275
+ transpose: number;
276
+ pitchRange: PitchRange;
277
+ snapScale: boolean;
278
+ modRate: ModRate;
279
+ modDepth: number;
280
+ modTarget: ModTarget;
281
+ growthStyle: GrowthStyle;
282
+ durationStretch: number;
283
+ timingFeel: TimingFeel;
284
+ timingAmount: number;
285
+ backing: BackingControls;
286
+ recordProfile: 'default' | 'vinyl' | 'dust';
287
+ recordFamily: 'character' | 'motion' | 'space' | 'bug';
288
+ recordBugMode: 'off' | 'pll-drift' | 'buffer-tear' | 'clock-bleed' | 'memory-rot' | 'crc-glitch';
289
+ recordIntensity: number;
290
+ recordChaos: number;
291
+ recordMix: number;
292
+ recordScratch: 'off' | 'texture' | 'dj';
293
+ recordWavy: number;
294
+ openAfterExport: 'none' | 'finder' | 'garageband';
295
+ exportAudio: 'none' | 'mp3' | 'mp4';
296
+ exportStems: boolean;
297
+ length: number;
298
+ bpm: number;
299
+ seedPitch: number;
300
+ rationale: string;
301
+ } {
302
+ const t = theme.toLowerCase();
303
+ const isAmbient = t.includes('ambient') || t.includes('cinematic');
304
+ const isTrap = t.includes('trap');
305
+ const isTech = t.includes('techno') || t.includes('house') || t.includes('industrial');
306
+ const source: 'generated' | 'record' = isAmbient
307
+ ? (strengthChance(strength, 0.2, 0.4, 0.6) ? 'record' : 'generated')
308
+ : strengthChance(strength, 0.18, 0.35, 0.5) ? 'record' : 'generated';
309
+ const mode: GenerationMode = isAmbient
310
+ ? 'single'
311
+ : strengthChance(strength, 0.45, 0.6, 0.72) ? 'backing' : 'single';
312
+ const instrument = isAmbient
313
+ ? pickOne<InstrumentName>(['pad', 'keys', 'lead'])
314
+ : isTrap
315
+ ? pickOne<InstrumentName>(['lead', 'bass', 'keys'])
316
+ : isTech
317
+ ? pickOne<InstrumentName>(['lead', 'keys', 'bass'])
318
+ : pickOne<InstrumentName>(['lead', 'keys', 'pad', 'bass']);
319
+ const fxPreset = isAmbient
320
+ ? pickOne<FxPresetName>(['lush', 'dark', 'clean'])
321
+ : isTrap
322
+ ? pickOne<FxPresetName>(['grime', 'punch', 'dark'])
323
+ : isTech
324
+ ? pickOne<FxPresetName>(['punch', 'dark', 'grime'])
325
+ : pickOne<FxPresetName>(['clean', 'dark', 'punch', 'lush']);
326
+ const decayStyle = isAmbient
327
+ ? pickOne<DecayStyle>(['long', 'balanced'])
328
+ : isTrap
329
+ ? pickOne<DecayStyle>(['tight', 'balanced'])
330
+ : pickOne<DecayStyle>(['balanced', 'tight', 'long']);
331
+ const eqMode = isAmbient
332
+ ? pickOne<'balanced' | 'flat' | 'warm' | 'bright' | 'bass' | 'phone'>(['balanced', 'warm', 'flat'])
333
+ : isTrap
334
+ ? pickOne<'balanced' | 'flat' | 'warm' | 'bright' | 'bass' | 'phone'>(['bass', 'balanced', 'warm'])
335
+ : isTech
336
+ ? pickOne<'balanced' | 'flat' | 'warm' | 'bright' | 'bass' | 'phone'>(['balanced', 'bright', 'phone'])
337
+ : pickOne<'balanced' | 'flat' | 'warm' | 'bright' | 'bass' | 'phone'>(['balanced', 'warm', 'bright', 'flat']);
338
+
339
+ const transpose = rangeInt(-5, 5);
340
+ const pitchRange = instrument === 'bass'
341
+ ? 'low'
342
+ : instrument === 'pad'
343
+ ? pickOne<PitchRange>(['mid', 'high'])
344
+ : pickOne<PitchRange>(['low', 'mid', 'high']);
345
+ const snapScale = strengthChance(strength, isTrap ? 0.45 : 0.8, isTrap ? 0.5 : 0.72, isTrap ? 0.55 : 0.62);
346
+ const modRate = pickOne<ModRate>(['off', 'slow', 'med', 'fast']);
347
+ const modDepth = modRate === 'off' ? 0 : (
348
+ strength === 'low' ? rangeInt(6, 30) : strength === 'medium' ? rangeInt(10, 54) : rangeInt(14, 72)
349
+ );
350
+ const modTarget = pickOne<ModTarget>(['velocity', 'duration', 'pitch']);
351
+ const growthStyle: GrowthStyle = strengthChance(strength, 0.55, 0.65, 0.72) ? 'build' : 'flat';
352
+ const durationStretch = pickOne([1, 1.25, 1.5, 2]);
353
+ const timingFeel = pickOne<TimingFeel>(['tight', 'human', 'offbeat', 'loose']);
354
+ const timingAmount = timingFeel === 'tight'
355
+ ? 0
356
+ : strength === 'low' ? rangeInt(6, 26) : strength === 'medium' ? rangeInt(10, 44) : rangeInt(16, 62);
357
+
358
+ const backing: BackingControls = mode === 'backing'
359
+ ? {
360
+ drums: true,
361
+ bass: strengthChance(strength, 0.55, 0.72, 0.85),
362
+ clap: strengthChance(strength, 0.42, 0.56, 0.72),
363
+ openHat: strengthChance(strength, 0.35, 0.55, 0.7),
364
+ perc: strengthChance(strength, 0.25, 0.45, 0.62),
365
+ metronome: strengthChance(strength, 0.45, 0.35, 0.2) ? 'count-in' : 'off',
366
+ swing: isTech ? rangeInt(2, strength === 'wild' ? 30 : 22) : rangeInt(0, strength === 'wild' ? 22 : 14),
367
+ gate: pickOne<GateStyle>(['tight', 'balanced', 'long']),
368
+ mutate: strength === 'low' ? rangeInt(0, 14) : strength === 'medium' ? rangeInt(4, 24) : rangeInt(8, 40),
369
+ deviate: strength === 'low' ? rangeInt(0, 12) : strength === 'medium' ? rangeInt(4, 20) : rangeInt(8, 32),
370
+ }
371
+ : {
372
+ drums: false,
373
+ bass: false,
374
+ clap: false,
375
+ openHat: false,
376
+ perc: false,
377
+ metronome: 'off',
378
+ swing: 0,
379
+ gate: 'balanced',
380
+ mutate: 0,
381
+ deviate: 0,
382
+ };
383
+
384
+ const recordProfile: 'default' | 'vinyl' | 'dust' = pickOne(['default', 'vinyl', 'dust']);
385
+ const recordFamily: 'character' | 'motion' | 'space' | 'bug' = source === 'record'
386
+ ? (strength === 'low' ? pickOne(['character', 'motion', 'space']) : pickOne(['character', 'motion', 'space', 'bug']))
387
+ : 'bug';
388
+ const recordBugMode: 'off' | 'pll-drift' | 'buffer-tear' | 'clock-bleed' | 'memory-rot' | 'crc-glitch' =
389
+ recordFamily === 'bug'
390
+ ? (strength === 'low'
391
+ ? pickOne(['pll-drift', 'buffer-tear'])
392
+ : strength === 'medium'
393
+ ? pickOne(['pll-drift', 'buffer-tear', 'clock-bleed', 'memory-rot'])
394
+ : pickOne(['pll-drift', 'buffer-tear', 'clock-bleed', 'memory-rot', 'crc-glitch']))
395
+ : 'off';
396
+
397
+ const recordIntensity = recordFamily === 'bug'
398
+ ? (strength === 'low' ? rangeInt(18, 44) : strength === 'medium' ? rangeInt(30, 62) : rangeInt(45, 84))
399
+ : (source === 'record' ? rangeInt(20, 58) : 0);
400
+ const recordMix = recordFamily === 'bug'
401
+ ? (strength === 'low' ? rangeInt(18, 42) : strength === 'medium' ? rangeInt(26, 56) : rangeInt(34, 70))
402
+ : (source === 'record' ? rangeInt(20, 50) : 0);
403
+ const recordChaos = recordFamily === 'bug'
404
+ ? (strength === 'low' ? rangeInt(10, 32) : strength === 'medium' ? rangeInt(16, 46) : rangeInt(24, 72))
405
+ : (source === 'record' ? rangeInt(8, 36) : 0);
406
+ const recordScratch: 'off' | 'texture' | 'dj' = source === 'record'
407
+ ? (strength === 'low' ? pickOne(['off', 'texture']) : pickOne(['off', 'texture', 'dj']))
408
+ : 'off';
409
+ const recordWavy = source === 'record'
410
+ ? (strength === 'low' ? rangeInt(8, 34) : strength === 'medium' ? rangeInt(14, 52) : rangeInt(24, 72))
411
+ : 0;
412
+
413
+ const length = surpriseLength();
414
+ const bpm = surpriseBpm(theme);
415
+ const seedPitch = surpriseSeed();
416
+ const exportAudio: 'none' | 'mp3' | 'mp4' = 'mp4';
417
+ const exportStems = true;
418
+ const openAfterExport: 'none' | 'finder' | 'garageband' = 'finder';
419
+ const rationale = [
420
+ source === 'record' ? 'Picked Record Player for character rendering.' : 'Picked Generate for direct melodic flow.',
421
+ mode === 'backing' ? 'Enabled backing for fuller arrangement.' : 'Kept single-track focus.',
422
+ recordFamily === 'bug' ? `Bug mode ${recordBugMode} selected for controlled instability.` : `Using ${recordFamily} FX family for texture.`,
423
+ ].join(' ');
424
+
425
+ return {
426
+ source,
427
+ mode,
428
+ instrument,
429
+ eqMode,
430
+ fxPreset,
431
+ decayStyle,
432
+ transpose,
433
+ pitchRange,
434
+ snapScale,
435
+ modRate,
436
+ modDepth,
437
+ modTarget,
438
+ growthStyle,
439
+ durationStretch,
440
+ timingFeel,
441
+ timingAmount,
442
+ backing,
443
+ recordProfile,
444
+ recordFamily,
445
+ recordBugMode,
446
+ recordIntensity,
447
+ recordChaos,
448
+ recordMix,
449
+ recordScratch,
450
+ recordWavy,
451
+ openAfterExport,
452
+ exportAudio,
453
+ exportStems,
454
+ length,
455
+ bpm,
456
+ seedPitch,
457
+ rationale,
458
+ };
459
+ }
460
+
461
+ function surpriseTheme(): string {
462
+ const options = THEME_OPTIONS.filter((opt) => opt.value !== '__custom__').map((opt) => opt.value);
463
+ return pickOne(options);
464
+ }
465
+
466
+ function surpriseLength(): number {
467
+ return pickOne([8, 16, 16, 32]);
468
+ }
469
+
470
+ function surpriseBpm(theme: string): number {
471
+ const t = theme.toLowerCase();
472
+ if (t.includes('ambient') || t.includes('cinematic')) return pickOne([82, 90, 96, 104]);
473
+ if (t.includes('trap')) return pickOne([132, 140, 148]);
474
+ if (t.includes('techno') || t.includes('house') || t.includes('industrial')) return pickOne([122, 128, 132, 136]);
475
+ return pickOne([90, 100, 110, 120, 128]);
476
+ }
477
+
478
+ function surpriseSeed(): number {
479
+ return 48 + Math.floor(Math.random() * 25);
480
+ }
481
+
482
+ const RANGE_OPTIONS: Option<PitchRange>[] = [
483
+ { value: 'low', label: 'Low', help: 'Darker lower register.' },
484
+ { value: 'mid', label: 'Mid', help: 'Balanced melodic register.' },
485
+ { value: 'high', label: 'High', help: 'Brighter upper register.' },
486
+ ];
487
+
488
+ const TRANSPOSE_AMOUNT_OPTIONS: Option<number>[] = Array.from({ length: 12 }, (_, idx) => {
489
+ const value = idx + 1;
490
+ return { value, label: String(value), help: `${value} semitone${value === 1 ? '' : 's'}` };
491
+ });
492
+
493
+ const MOD_RATE_OPTIONS: Option<ModRate>[] = [
494
+ { value: 'off', label: 'Off', help: 'No modulation.' },
495
+ { value: 'slow', label: 'Slow', help: 'Gradual movement.' },
496
+ { value: 'med', label: 'Medium', help: 'Moderate movement.' },
497
+ { value: 'fast', label: 'Fast', help: 'Rapid movement.' },
498
+ ];
499
+
500
+ const MOD_TARGET_OPTIONS: Option<ModTarget>[] = [
501
+ { value: 'velocity', label: 'Velocity', help: 'Accent movement.' },
502
+ { value: 'duration', label: 'Duration', help: 'Gate movement.' },
503
+ { value: 'pitch', label: 'Pitch', help: 'Pitch movement.' },
504
+ ];
505
+
506
+ const GROWTH_OPTIONS: Option<GrowthStyle>[] = [
507
+ { value: 'flat', label: 'Flat', help: 'Keep energy level steady.' },
508
+ { value: 'build', label: 'Build', help: 'Grow intensity over time.' },
509
+ ];
510
+
511
+ const DURATION_STRETCH_OPTIONS: Option<number>[] = [
512
+ { value: 1, label: '1.0x', help: 'Original duration feel.' },
513
+ { value: 1.25, label: '1.25x', help: 'Slightly longer notes.' },
514
+ { value: 1.5, label: '1.5x', help: 'Longer sustained notes.' },
515
+ { value: 2, label: '2.0x', help: 'Much longer phrasing.' },
516
+ { value: 3, label: '3.0x', help: 'Extended ambient feel.' },
517
+ ];
518
+
519
+ const TIMING_FEEL_OPTIONS: Option<TimingFeel>[] = [
520
+ { value: 'tight', label: 'Tight', help: 'Locked to grid feel.' },
521
+ { value: 'human', label: 'Human', help: 'Subtle micro-timing drift.' },
522
+ { value: 'offbeat', label: 'Offbeat', help: 'Intentional push/pull syncopation.' },
523
+ { value: 'loose', label: 'Loose', help: 'Wider unquantized timing.' },
524
+ ];
525
+
526
+ const GATE_OPTIONS: Option<GateStyle>[] = [
527
+ { value: 'tight', label: 'Tight', help: 'Shorter note gates.' },
528
+ { value: 'balanced', label: 'Balanced', help: 'Neutral gates.' },
529
+ { value: 'long', label: 'Long', help: 'Longer gates.' },
530
+ ];
531
+
532
+ const METRONOME_OPTIONS: Option<MetronomeMode>[] = [
533
+ { value: 'off', label: 'Off', help: 'No click.' },
534
+ { value: 'count-in', label: 'Count-in', help: '4-beat count before playback.' },
535
+ { value: 'always', label: 'Always', help: 'Click during playback.' },
536
+ ];
537
+
538
+ function recommendedProvider(defaultProvider: ProviderName): ProviderName {
539
+ if (process.env.OPENAI_API_KEY) return 'openai';
540
+ if (process.env.GEMINI_API_KEY) return 'gemini';
541
+ if (process.env.GROQ_API_KEY) return 'groq';
542
+ if (process.env.XAI_API_KEY) return 'grok';
543
+ if (process.env.ANTHROPIC_API_KEY) return 'claude';
544
+ return defaultProvider;
545
+ }
546
+
547
+ function providerOptions(): Option<ProviderName>[] {
548
+ return [
549
+ {
550
+ value: 'mock',
551
+ label: 'Demo mode',
552
+ help: 'No API key needed. Best for first run.',
553
+ },
554
+ {
555
+ value: 'openai',
556
+ label: 'OpenAI / Codex',
557
+ help: process.env.OPENAI_API_KEY
558
+ ? 'Configured via OPENAI_API_KEY.'
559
+ : 'Requires OPENAI_API_KEY.',
560
+ },
561
+ {
562
+ value: 'gemini',
563
+ label: 'Gemini',
564
+ help: process.env.GEMINI_API_KEY
565
+ ? 'Configured via GEMINI_API_KEY.'
566
+ : 'Requires GEMINI_API_KEY.',
567
+ },
568
+ {
569
+ value: 'claude',
570
+ label: 'Claude',
571
+ help: process.env.ANTHROPIC_API_KEY
572
+ ? 'Configured via ANTHROPIC_API_KEY.'
573
+ : 'Requires ANTHROPIC_API_KEY.',
574
+ },
575
+ {
576
+ value: 'groq',
577
+ label: 'Groq',
578
+ help: process.env.GROQ_API_KEY
579
+ ? 'Configured via GROQ_API_KEY.'
580
+ : 'Requires GROQ_API_KEY.',
581
+ },
582
+ {
583
+ value: 'grok',
584
+ label: 'Grok (xAI)',
585
+ help: process.env.XAI_API_KEY
586
+ ? 'Configured via XAI_API_KEY.'
587
+ : 'Requires XAI_API_KEY.',
588
+ },
589
+ ];
590
+ }
591
+
592
+ function parseProvider(inputValue: string): ProviderName | null {
593
+ const normalized = inputValue.trim().toLowerCase();
594
+ if (normalized === '1' || normalized === 'mock' || normalized === 'demo') return 'mock';
595
+ if (normalized === '2' || normalized === 'openai' || normalized === 'codex') return 'openai';
596
+ if (normalized === '3' || normalized === 'gemini') return 'gemini';
597
+ if (normalized === '4' || normalized === 'claude') return 'claude';
598
+ if (normalized === '5' || normalized === 'groq') return 'groq';
599
+ if (normalized === '6' || normalized === 'grok' || normalized === 'xai') return 'grok';
600
+ return null;
601
+ }
602
+
603
+ function section(title: string, subtitle?: string): void {
604
+ console.log('');
605
+ console.log(color(title, `${c.bold}${palette.primary}`));
606
+ if (subtitle) console.log(color(subtitle, `${c.dim}${palette.soft}`));
607
+ }
608
+
609
+ async function ensureProviderCredentials(
610
+ inputPrompt: InputPromptFn,
611
+ passwordPrompt: PasswordPromptFn,
612
+ provider: ProviderName,
613
+ ): Promise<{ ok: boolean; auth: 'none' | 'env' | 'session' }> {
614
+ if (provider === 'mock') {
615
+ console.log(color('Using demo mode (no API key needed).', `${c.dim}${palette.soft}`));
616
+ return { ok: true, auth: 'none' };
617
+ }
618
+
619
+ if (provider === 'openai' && process.env.OPENAI_API_KEY) {
620
+ console.log(color('OPENAI_API_KEY detected in environment.', `${c.dim}${palette.soft}`));
621
+ return { ok: true, auth: 'env' };
622
+ }
623
+ if (provider === 'gemini' && process.env.GEMINI_API_KEY) {
624
+ console.log(color('GEMINI_API_KEY detected in environment.', `${c.dim}${palette.soft}`));
625
+ return { ok: true, auth: 'env' };
626
+ }
627
+ if (provider === 'claude' && process.env.ANTHROPIC_API_KEY) {
628
+ console.log(color('ANTHROPIC_API_KEY detected in environment.', `${c.dim}${palette.soft}`));
629
+ return { ok: true, auth: 'env' };
630
+ }
631
+ if (provider === 'groq' && process.env.GROQ_API_KEY) {
632
+ console.log(color('GROQ_API_KEY detected in environment.', `${c.dim}${palette.soft}`));
633
+ return { ok: true, auth: 'env' };
634
+ }
635
+ if (provider === 'grok' && process.env.XAI_API_KEY) {
636
+ console.log(color('XAI_API_KEY detected in environment.', `${c.dim}${palette.soft}`));
637
+ return { ok: true, auth: 'env' };
638
+ }
639
+
640
+ const keyName = provider === 'openai'
641
+ ? 'OPENAI_API_KEY'
642
+ : provider === 'gemini'
643
+ ? 'GEMINI_API_KEY'
644
+ : provider === 'claude'
645
+ ? 'ANTHROPIC_API_KEY'
646
+ : provider === 'groq'
647
+ ? 'GROQ_API_KEY'
648
+ : 'XAI_API_KEY';
649
+ console.log(color(`${keyName} is not set.`, c.yellow));
650
+ console.log(color('Paste key for this session (not saved to disk).', c.dim));
651
+ const key = await passwordPrompt({
652
+ message: `${keyName}`,
653
+ validate: (value: string) => (value.trim() ? true : 'Key is required'),
654
+ });
655
+ const trimmed = key.trim();
656
+ if (!trimmed) {
657
+ console.log(color('No key entered.', c.red));
658
+ return { ok: false, auth: 'none' };
659
+ }
660
+
661
+ if (provider === 'openai') {
662
+ process.env.OPENAI_API_KEY = trimmed;
663
+ } else if (provider === 'gemini') {
664
+ process.env.GEMINI_API_KEY = trimmed;
665
+ } else if (provider === 'claude') {
666
+ process.env.ANTHROPIC_API_KEY = trimmed;
667
+ } else if (provider === 'groq') {
668
+ process.env.GROQ_API_KEY = trimmed;
669
+ } else {
670
+ process.env.XAI_API_KEY = trimmed;
671
+ }
672
+ console.log(color(`${keyName} received for this session.`, `${c.bold}${palette.primary}`));
673
+ return { ok: true, auth: 'session' };
674
+ }
675
+
676
+ async function pickProvider(
677
+ selectPrompt: SelectPromptFn,
678
+ inputPrompt: InputPromptFn,
679
+ passwordPrompt: PasswordPromptFn,
680
+ fallback: ProviderName,
681
+ ): Promise<{ provider: ProviderName; auth: 'none' | 'env' | 'session' }> {
682
+ const recommended = recommendedProvider(fallback);
683
+ const options = providerOptions();
684
+
685
+ while (true) {
686
+ const selected = await selectPrompt({
687
+ message: 'Choose AI provider',
688
+ default: recommended,
689
+ choices: options.map((option) => ({
690
+ value: option.value,
691
+ name: option.label,
692
+ description: option.help,
693
+ })),
694
+ theme: inquirerTheme,
695
+ });
696
+
697
+ const cred = await ensureProviderCredentials(inputPrompt, passwordPrompt, selected);
698
+ if (cred.ok) {
699
+ return { provider: selected, auth: cred.auth };
700
+ }
701
+
702
+ console.log(color('Provider key not set. Choose provider again or select mock mode.', c.red));
703
+ }
704
+ }
705
+
706
+ async function pickTheme(
707
+ selectPrompt: SelectPromptFn,
708
+ inputPrompt: InputPromptFn,
709
+ fallback: string,
710
+ ): Promise<string> {
711
+ const selected = await selectPrompt({
712
+ message: 'Pick music style',
713
+ choices: THEME_OPTIONS.map((option) => ({
714
+ value: option.value,
715
+ name: option.label,
716
+ description: option.help,
717
+ })),
718
+ theme: inquirerTheme,
719
+ });
720
+
721
+ if (selected === '__custom__') {
722
+ const custom = await inputPrompt({
723
+ message: 'Enter custom theme',
724
+ default: fallback,
725
+ validate: (value: string) => (value.trim() ? true : 'Theme is required'),
726
+ theme: inquirerTheme,
727
+ });
728
+ return custom.trim() || fallback;
729
+ }
730
+ return selected;
731
+ }
732
+
733
+ async function pickLength(
734
+ selectPrompt: SelectPromptFn,
735
+ inputPrompt: InputPromptFn,
736
+ fallback: number,
737
+ ): Promise<number> {
738
+ const selected = await selectPrompt({
739
+ message: 'Pick length',
740
+ choices: LENGTH_OPTIONS.map((option) => ({
741
+ value: option.value,
742
+ name: option.label,
743
+ description: option.help,
744
+ })),
745
+ theme: inquirerTheme,
746
+ });
747
+ if (selected === -1) {
748
+ const custom = await inputPrompt({
749
+ message: 'Custom length in notes',
750
+ default: String(fallback),
751
+ validate: (value: string) => (asPositiveInt(value, 0) > 0 ? true : 'Enter a positive number'),
752
+ theme: inquirerTheme,
753
+ });
754
+ return asPositiveInt(custom, fallback);
755
+ }
756
+ return selected;
757
+ }
758
+
759
+ async function pickBpm(
760
+ selectPrompt: SelectPromptFn,
761
+ inputPrompt: InputPromptFn,
762
+ fallback: number,
763
+ ): Promise<number> {
764
+ const selected = await selectPrompt({
765
+ message: 'Pick BPM',
766
+ choices: BPM_OPTIONS.map((option) => ({
767
+ value: option.value,
768
+ name: option.label,
769
+ description: option.help,
770
+ })),
771
+ theme: inquirerTheme,
772
+ });
773
+ if (selected === -1) {
774
+ const custom = await inputPrompt({
775
+ message: 'Custom BPM',
776
+ default: String(fallback),
777
+ validate: (value: string) => (asPositiveInt(value, 0) > 0 ? true : 'Enter a positive number'),
778
+ theme: inquirerTheme,
779
+ });
780
+ return asPositiveInt(custom, fallback);
781
+ }
782
+ return selected;
783
+ }
784
+
785
+ async function pickBoundedNumber(
786
+ selectPrompt: SelectPromptFn,
787
+ inputPrompt: InputPromptFn,
788
+ opts: {
789
+ message: string;
790
+ fallback: number;
791
+ min: number;
792
+ max: number;
793
+ presets?: number[];
794
+ fractions?: number[];
795
+ customLabel?: string;
796
+ customMessage?: string;
797
+ },
798
+ ): Promise<number> {
799
+ const fractionValues = (opts.fractions ?? [0, 0.25, 0.5, 0.75, 1]).map((f) =>
800
+ Math.round(opts.min + (opts.max - opts.min) * Math.max(0, Math.min(1, f))),
801
+ );
802
+ const presetValues = [...new Set([...(opts.presets ?? []), ...fractionValues])]
803
+ .filter((v) => v >= opts.min && v <= opts.max)
804
+ .sort((a, b) => a - b);
805
+
806
+ const choices = [
807
+ { value: -1, name: opts.customLabel ?? 'Custom', description: `Type ${opts.min}..${opts.max}` },
808
+ ...presetValues.map((value) => ({
809
+ value,
810
+ name: String(value),
811
+ description: value === opts.fallback ? 'Current default' : undefined,
812
+ })),
813
+ ];
814
+
815
+ const selected = (await selectPrompt({
816
+ message: opts.message,
817
+ choices,
818
+ default: presetValues.includes(opts.fallback) ? opts.fallback : -1,
819
+ theme: inquirerTheme,
820
+ })) as number;
821
+
822
+ if (selected !== -1) return selected;
823
+ const custom = await inputPrompt({
824
+ message: opts.customMessage ?? `${opts.message} (${opts.min}..${opts.max})`,
825
+ default: String(opts.fallback),
826
+ validate: (value: string) => {
827
+ const parsed = Number.parseInt(value, 10);
828
+ if (!Number.isFinite(parsed)) return 'Enter a number';
829
+ if (parsed < opts.min || parsed > opts.max) return `Must be ${opts.min}..${opts.max}`;
830
+ return true;
831
+ },
832
+ theme: inquirerTheme,
833
+ });
834
+ return asBoundedInt(custom, opts.fallback, opts.min, opts.max);
835
+ }
836
+
837
+ export async function promptCliConfig(defaults: CliConfig): Promise<CliConfig> {
838
+ console.log(`\n${color('OpenNote CLI Setup', `${c.bold}${palette.primary}`)}`);
839
+ console.log(color('Use arrow keys and Enter.', `${c.dim}${palette.soft}`));
840
+ console.log(color('----------------------------------------', `${c.dim}${palette.soft}`));
841
+
842
+ let selectPrompt: SelectPromptFn;
843
+ let inputPrompt: InputPromptFn;
844
+ let passwordPrompt: PasswordPromptFn;
845
+
846
+ try {
847
+ const inquirer = await import('@inquirer/prompts');
848
+ selectPrompt = inquirer.select as typeof selectPrompt;
849
+ inputPrompt = inquirer.input;
850
+ passwordPrompt = inquirer.password;
851
+ } catch {
852
+ console.log(color('Arrow-key prompts need @inquirer/prompts installed.', c.yellow));
853
+ console.log('Run: npm install');
854
+ throw new Error('Missing dependency: @inquirer/prompts');
855
+ }
856
+
857
+ while (true) {
858
+ section('1) Provider', 'Pick model provider (Demo mode - Quickstart).');
859
+ const providerResult = await pickProvider(
860
+ (cfg) => selectPrompt(cfg) as Promise<ProviderName>,
861
+ inputPrompt,
862
+ passwordPrompt,
863
+ defaults.provider,
864
+ );
865
+ const provider = providerResult.provider;
866
+ const providerAuth = providerResult.auth;
867
+
868
+ section('2) Setup Path', 'Choose Basic, Surprise me or Advanced for full audio controls.');
869
+ const setupPath = (await selectPrompt({
870
+ message: 'Setup path',
871
+ choices: SETUP_PATH_OPTIONS.map((option) => ({
872
+ value: option.value,
873
+ name: option.label,
874
+ description: option.help,
875
+ })),
876
+ default: 'basic',
877
+ theme: inquirerTheme,
878
+ })) as SetupPath;
879
+
880
+ if (setupPath === 'surprise') {
881
+ const surpriseStrength = (await selectPrompt({
882
+ message: 'Surprise strength',
883
+ choices: SURPRISE_STRENGTH_OPTIONS.map((option) => ({
884
+ value: option.value,
885
+ name: option.label,
886
+ description: option.help,
887
+ })),
888
+ default: 'medium',
889
+ theme: inquirerTheme,
890
+ })) as SurpriseStrength;
891
+ const theme = surpriseTheme();
892
+ const picked = surprisePreset(theme, surpriseStrength);
893
+ const config: CliConfig = {
894
+ provider,
895
+ providerAuth,
896
+ source: picked.source,
897
+ mode: picked.mode,
898
+ instrument: picked.instrument,
899
+ fxPreset: picked.fxPreset,
900
+ decayStyle: picked.decayStyle,
901
+ transpose: picked.transpose,
902
+ pitchRange: picked.pitchRange,
903
+ snapScale: picked.snapScale,
904
+ modRate: picked.modRate,
905
+ modDepth: picked.modDepth,
906
+ modTarget: picked.modTarget,
907
+ growthStyle: picked.growthStyle,
908
+ durationStretch: picked.durationStretch,
909
+ timingFeel: picked.timingFeel,
910
+ timingAmount: picked.timingAmount,
911
+ backing: picked.backing,
912
+ theme,
913
+ length: picked.length,
914
+ bpm: picked.bpm,
915
+ seedPitch: picked.seedPitch,
916
+ seedSource: 'manual',
917
+ beep: false,
918
+ openAfterExport: picked.openAfterExport,
919
+ exportAudio: picked.exportAudio,
920
+ exportStems: picked.exportStems,
921
+ eqMode: picked.eqMode,
922
+ recordDevice: defaults.recordDevice,
923
+ recordSeconds: defaults.recordSeconds,
924
+ recordMonitor: defaults.recordMonitor,
925
+ recordProfile: picked.recordProfile,
926
+ recordFamily: picked.recordFamily,
927
+ recordBugMode: picked.recordBugMode,
928
+ recordIntensity: picked.recordIntensity,
929
+ recordChaos: picked.recordChaos,
930
+ recordMix: picked.recordMix,
931
+ recordScratch: picked.recordScratch,
932
+ recordWavy: picked.recordWavy,
933
+ };
934
+
935
+ section('Surprise Setup', 'Auto-picked configuration. Starting generation now.');
936
+ console.log(color(`Strength: ${surpriseStrength}`, `${c.dim}${palette.soft}`));
937
+ console.log(color(`Theme: ${config.theme}`, `${c.dim}${palette.soft}`));
938
+ console.log(color(`Source: ${config.source}`, `${c.dim}${palette.soft}`));
939
+ console.log(color(`Mode: ${config.mode}`, `${c.dim}${palette.soft}`));
940
+ console.log(color(`Instrument: ${config.instrument}`, `${c.dim}${palette.soft}`));
941
+ console.log(color(`EQ: ${config.eqMode}`, `${c.dim}${palette.soft}`));
942
+ console.log(color(`FX: ${config.fxPreset} / ${config.decayStyle}`, `${c.dim}${palette.soft}`));
943
+ console.log(color(`Length/BPM: ${config.length} / ${config.bpm}`, `${c.dim}${palette.soft}`));
944
+ console.log(color(`Seed pitch: ${config.seedPitch}`, `${c.dim}${palette.soft}`));
945
+ if (config.source === 'record' || config.recordBugMode !== 'off') {
946
+ console.log(color(`Record FX: ${config.recordFamily} / ${config.recordBugMode} / ${config.recordScratch}`, `${c.dim}${palette.soft}`));
947
+ }
948
+ console.log(color(`Rationale: ${picked.rationale}`, `${c.dim}${palette.soft}`));
949
+ console.log('');
950
+ return config;
951
+ }
952
+
953
+ if (setupPath === 'basic') {
954
+ section('3) Style', 'Choose a preset music category or custom theme.');
955
+ const theme = await pickTheme((cfg) => selectPrompt(cfg) as Promise<string>, inputPrompt, defaults.theme);
956
+
957
+ section('4) Structure', 'Set length and tempo.');
958
+ const length = await pickLength(
959
+ (cfg) => selectPrompt(cfg) as Promise<number>,
960
+ inputPrompt,
961
+ defaults.length,
962
+ );
963
+ const bpm = await pickBpm((cfg) => selectPrompt(cfg) as Promise<number>, inputPrompt, defaults.bpm);
964
+
965
+ section('5) Input', 'Basic path uses manual seed input.');
966
+ const seedPitch = await pickBoundedNumber(
967
+ (cfg) => selectPrompt(cfg) as Promise<number>,
968
+ inputPrompt,
969
+ {
970
+ message: 'Choose note (MIDI 0..127)',
971
+ fallback: defaults.seedPitch,
972
+ min: 0,
973
+ max: 127,
974
+ fractions: [0, 0.25, 0.5, 0.75, 1],
975
+ customMessage: 'Custom note (MIDI 0..127)',
976
+ },
977
+ );
978
+
979
+ const config: CliConfig = {
980
+ provider,
981
+ providerAuth,
982
+ source: 'generated',
983
+ mode: 'single',
984
+ instrument: 'lead',
985
+ fxPreset: 'clean',
986
+ decayStyle: 'balanced',
987
+ transpose: 0,
988
+ pitchRange: 'mid',
989
+ snapScale: false,
990
+ modRate: 'off',
991
+ modDepth: 0,
992
+ modTarget: 'velocity',
993
+ growthStyle: 'flat',
994
+ durationStretch: 1.25,
995
+ timingFeel: 'tight',
996
+ timingAmount: 0,
997
+ backing: {
998
+ drums: false,
999
+ bass: false,
1000
+ clap: false,
1001
+ openHat: false,
1002
+ perc: false,
1003
+ metronome: 'off',
1004
+ swing: 0,
1005
+ gate: 'balanced',
1006
+ mutate: 0,
1007
+ deviate: 0,
1008
+ },
1009
+ theme,
1010
+ length,
1011
+ bpm,
1012
+ seedPitch,
1013
+ seedSource: 'manual',
1014
+ beep: false,
1015
+ openAfterExport: 'finder',
1016
+ exportAudio: 'mp4',
1017
+ exportStems: true,
1018
+ eqMode: defaults.eqMode,
1019
+ recordDevice: defaults.recordDevice,
1020
+ recordSeconds: defaults.recordSeconds,
1021
+ recordMonitor: defaults.recordMonitor,
1022
+ recordProfile: defaults.recordProfile,
1023
+ recordFamily: defaults.recordFamily,
1024
+ recordBugMode: defaults.recordBugMode,
1025
+ recordIntensity: defaults.recordIntensity,
1026
+ recordChaos: defaults.recordChaos,
1027
+ recordMix: defaults.recordMix,
1028
+ recordScratch: defaults.recordScratch,
1029
+ recordWavy: defaults.recordWavy,
1030
+ };
1031
+
1032
+ section('Basic Setup', 'Using quick defaults. Starting generation now.');
1033
+ console.log(color(`Theme: ${config.theme}`, `${c.dim}${palette.soft}`));
1034
+ console.log(color(`Length/BPM: ${config.length} / ${config.bpm}`, `${c.dim}${palette.soft}`));
1035
+ console.log(color(`Seed pitch: ${config.seedPitch}`, `${c.dim}${palette.soft}`));
1036
+ console.log(color(`Export: ${config.exportAudio} + ${config.openAfterExport}`, `${c.dim}${palette.soft}`));
1037
+ console.log('');
1038
+ return config;
1039
+ }
1040
+
1041
+ let source = defaults.source;
1042
+ let mode = defaults.mode;
1043
+ let instrument = defaults.instrument;
1044
+ let fxPreset = defaults.fxPreset;
1045
+ let decayStyle = defaults.decayStyle;
1046
+ let recordDevice = defaults.recordDevice;
1047
+ let recordSeconds = defaults.recordSeconds;
1048
+ let recordMonitor = defaults.recordMonitor;
1049
+ let recordProfile = defaults.recordProfile;
1050
+ let recordFamily = defaults.recordFamily;
1051
+ let recordBugMode = defaults.recordBugMode;
1052
+ let recordIntensity = defaults.recordIntensity;
1053
+ let recordChaos = defaults.recordChaos;
1054
+ let recordMix = defaults.recordMix;
1055
+ let recordScratch = defaults.recordScratch;
1056
+ let recordWavy = defaults.recordWavy;
1057
+ let eqMode = defaults.eqMode;
1058
+
1059
+ section('3) Mode', 'Single track keeps it minimal. AI backing reveals arrangement controls.');
1060
+ mode = (await selectPrompt({
1061
+ message: 'Generation mode',
1062
+ choices: MODE_OPTIONS.map((option) => ({
1063
+ value: option.value,
1064
+ name: option.label,
1065
+ description: option.help,
1066
+ })),
1067
+ default: defaults.mode,
1068
+ theme: inquirerTheme,
1069
+ })) as GenerationMode;
1070
+
1071
+ section('4) Instrument', 'Choose instrument profile for output mapping.');
1072
+ instrument = (await selectPrompt({
1073
+ message: 'Instrument',
1074
+ choices: INSTRUMENT_OPTIONS.map((option) => ({
1075
+ value: option.value,
1076
+ name: option.label,
1077
+ description: option.help,
1078
+ })),
1079
+ default: defaults.instrument,
1080
+ theme: inquirerTheme,
1081
+ })) as InstrumentName;
1082
+
1083
+ section('5) Source', 'Choose output style for the generated take.');
1084
+ source = (await selectPrompt({
1085
+ message: 'Source',
1086
+ choices: SOURCE_OPTIONS.map((option) => ({
1087
+ value: option.value,
1088
+ name: option.label,
1089
+ description: option.help,
1090
+ })),
1091
+ default: defaults.source,
1092
+ theme: inquirerTheme,
1093
+ })) as 'generated' | 'record';
1094
+
1095
+ section('5b) EQ', 'General output equalizer profile.');
1096
+ eqMode = (await selectPrompt({
1097
+ message: 'EQ mode',
1098
+ choices: EQ_MODE_OPTIONS.map((option) => ({
1099
+ value: option.value,
1100
+ name: option.label,
1101
+ description: option.help,
1102
+ })),
1103
+ default: defaults.eqMode,
1104
+ theme: inquirerTheme,
1105
+ })) as 'balanced' | 'flat' | 'warm' | 'bright' | 'bass' | 'phone';
1106
+
1107
+ if (source === 'generated') {
1108
+ section('6) FX', 'Choose tone shaping profile and decay behavior.');
1109
+ fxPreset = (await selectPrompt({
1110
+ message: 'FX preset',
1111
+ choices: FX_PRESET_OPTIONS.map((option) => ({
1112
+ value: option.value,
1113
+ name: option.label,
1114
+ description: option.help,
1115
+ })),
1116
+ default: defaults.fxPreset,
1117
+ theme: inquirerTheme,
1118
+ })) as FxPresetName;
1119
+
1120
+ decayStyle = (await selectPrompt({
1121
+ message: 'Decay style',
1122
+ choices: DECAY_OPTIONS.map((option) => ({
1123
+ value: option.value,
1124
+ name: option.label,
1125
+ description: option.help,
1126
+ })),
1127
+ default: defaults.decayStyle,
1128
+ theme: inquirerTheme,
1129
+ })) as DecayStyle;
1130
+
1131
+ section('6b) Experimental', 'Optional post-render bug mode (works in Generate too).');
1132
+ recordBugMode = (await selectPrompt({
1133
+ message: 'Bug mode',
1134
+ choices: BUG_MODE_OPTIONS.map((option) => ({
1135
+ value: option.value,
1136
+ name: option.label,
1137
+ description: option.help,
1138
+ })),
1139
+ default: defaults.recordBugMode,
1140
+ theme: inquirerTheme,
1141
+ })) as 'off' | 'pll-drift' | 'buffer-tear' | 'clock-bleed' | 'memory-rot' | 'crc-glitch';
1142
+ if (recordBugMode !== 'off') {
1143
+ recordFamily = 'bug';
1144
+ recordIntensity = await pickBoundedNumber(
1145
+ (cfg) => selectPrompt(cfg) as Promise<number>,
1146
+ inputPrompt,
1147
+ { message: 'Bug intensity (0..100)', fallback: defaults.recordIntensity, min: 0, max: 100, fractions: [0, 0.2, 0.4, 0.6, 0.8, 1] },
1148
+ );
1149
+ recordMix = await pickBoundedNumber(
1150
+ (cfg) => selectPrompt(cfg) as Promise<number>,
1151
+ inputPrompt,
1152
+ { message: 'Bug mix (0..100)', fallback: defaults.recordMix, min: 0, max: 100, fractions: [0, 0.2, 0.4, 0.6, 0.8, 1] },
1153
+ );
1154
+ recordChaos = await pickBoundedNumber(
1155
+ (cfg) => selectPrompt(cfg) as Promise<number>,
1156
+ inputPrompt,
1157
+ { message: 'Bug chaos (0..100)', fallback: defaults.recordChaos, min: 0, max: 100, fractions: [0, 0.2, 0.4, 0.6, 0.8, 1] },
1158
+ );
1159
+ } else {
1160
+ recordFamily = 'character';
1161
+ recordIntensity = 0;
1162
+ recordMix = 0;
1163
+ recordChaos = 0;
1164
+ }
1165
+ recordProfile = 'default';
1166
+ recordScratch = 'off';
1167
+ recordWavy = 0;
1168
+ } else {
1169
+ section('6) Record Player', 'Emulate vinyl tone and scratching on rendered audio.');
1170
+ recordProfile = (await selectPrompt({
1171
+ message: 'Record tone',
1172
+ choices: RECORD_PROFILE_OPTIONS.map((option) => ({
1173
+ value: option.value,
1174
+ name: option.label,
1175
+ description: option.help,
1176
+ })),
1177
+ default: defaults.recordProfile,
1178
+ theme: inquirerTheme,
1179
+ })) as 'default' | 'vinyl' | 'dust';
1180
+ recordFamily = (await selectPrompt({
1181
+ message: 'FX family',
1182
+ choices: RECORD_FAMILY_OPTIONS.map((option) => ({
1183
+ value: option.value,
1184
+ name: option.label,
1185
+ description: option.help,
1186
+ })),
1187
+ default: defaults.recordFamily,
1188
+ theme: inquirerTheme,
1189
+ })) as 'character' | 'motion' | 'space' | 'bug';
1190
+ if (recordFamily === 'bug') {
1191
+ recordBugMode = (await selectPrompt({
1192
+ message: 'Bug mode',
1193
+ choices: BUG_MODE_OPTIONS.map((option) => ({
1194
+ value: option.value,
1195
+ name: option.label,
1196
+ description: option.help,
1197
+ })),
1198
+ default: defaults.recordBugMode,
1199
+ theme: inquirerTheme,
1200
+ })) as 'off' | 'pll-drift' | 'buffer-tear' | 'clock-bleed' | 'memory-rot' | 'crc-glitch';
1201
+ } else {
1202
+ recordBugMode = 'off';
1203
+ }
1204
+ const scratchRaw = (await selectPrompt({
1205
+ message: 'Scratch technique',
1206
+ choices: [
1207
+ { value: 'off', name: 'Off', description: 'No scratch texture.' },
1208
+ { value: 'texture', name: 'Texture', description: 'Subtle scratch character.' },
1209
+ { value: 'dj', name: 'DJ Scratch', description: 'Stronger replay-style scratch motion.' },
1210
+ ],
1211
+ default: defaults.recordScratch,
1212
+ theme: inquirerTheme,
1213
+ })) as 'off' | 'texture' | 'dj';
1214
+ recordScratch = scratchRaw;
1215
+ recordWavy = await pickBoundedNumber(
1216
+ (cfg) => selectPrompt(cfg) as Promise<number>,
1217
+ inputPrompt,
1218
+ { message: 'Wobble amount (0..100)', fallback: defaults.recordWavy, min: 0, max: 100, fractions: [0, 0.2, 0.4, 0.6, 0.8, 1] },
1219
+ );
1220
+ recordIntensity = await pickBoundedNumber(
1221
+ (cfg) => selectPrompt(cfg) as Promise<number>,
1222
+ inputPrompt,
1223
+ { message: 'FX intensity (0..100)', fallback: defaults.recordIntensity, min: 0, max: 100, fractions: [0, 0.2, 0.4, 0.6, 0.8, 1] },
1224
+ );
1225
+ recordMix = await pickBoundedNumber(
1226
+ (cfg) => selectPrompt(cfg) as Promise<number>,
1227
+ inputPrompt,
1228
+ { message: 'FX mix (0..100)', fallback: defaults.recordMix, min: 0, max: 100, fractions: [0, 0.2, 0.4, 0.6, 0.8, 1] },
1229
+ );
1230
+ recordChaos = await pickBoundedNumber(
1231
+ (cfg) => selectPrompt(cfg) as Promise<number>,
1232
+ inputPrompt,
1233
+ { message: 'Chaos (0..100)', fallback: defaults.recordChaos, min: 0, max: 100, fractions: [0, 0.2, 0.4, 0.6, 0.8, 1] },
1234
+ );
1235
+ recordDevice = '';
1236
+ recordSeconds = defaults.recordSeconds;
1237
+ recordMonitor = false;
1238
+ }
1239
+
1240
+ let transpose = defaults.transpose;
1241
+ let pitchRange = defaults.pitchRange;
1242
+ let snapScale = defaults.snapScale;
1243
+ let modRate = defaults.modRate;
1244
+ let modDepth = defaults.modDepth;
1245
+ let modTarget = defaults.modTarget;
1246
+ let growthStyle = defaults.growthStyle;
1247
+ let durationStretch = defaults.durationStretch;
1248
+ let timingFeel = defaults.timingFeel;
1249
+ let timingAmount = defaults.timingAmount;
1250
+ let backing: BackingControls = mode === 'backing'
1251
+ ? { ...defaults.backing }
1252
+ : {
1253
+ drums: false,
1254
+ bass: false,
1255
+ clap: false,
1256
+ openHat: false,
1257
+ perc: false,
1258
+ metronome: 'off',
1259
+ swing: 0,
1260
+ gate: 'balanced',
1261
+ mutate: 0,
1262
+ deviate: 0,
1263
+ };
1264
+
1265
+ if (setupPath === 'advanced') {
1266
+ section('7) Pitch', 'Transpose and range controls with optional scale snap.');
1267
+ const transposeDirection = (await selectPrompt({
1268
+ message: 'Transpose direction',
1269
+ choices: [
1270
+ { value: 'down', name: 'Down (-)', description: 'Shift pitches lower.' },
1271
+ { value: 'none', name: 'Neutral (0)', description: 'No transpose shift.' },
1272
+ { value: 'up', name: 'Up (+)', description: 'Shift pitches higher.' },
1273
+ ],
1274
+ default: defaults.transpose === 0 ? 'none' : defaults.transpose < 0 ? 'down' : 'up',
1275
+ theme: inquirerTheme,
1276
+ })) as 'down' | 'none' | 'up';
1277
+
1278
+ if (transposeDirection === 'none') {
1279
+ transpose = 0;
1280
+ } else {
1281
+ const transposeAmount = (await selectPrompt({
1282
+ message: 'Transpose amount',
1283
+ choices: TRANSPOSE_AMOUNT_OPTIONS.map((option) => ({
1284
+ value: option.value,
1285
+ name: option.label,
1286
+ description: option.help,
1287
+ })),
1288
+ default: Math.max(1, Math.min(12, Math.abs(defaults.transpose))),
1289
+ theme: inquirerTheme,
1290
+ })) as number;
1291
+ transpose = transposeDirection === 'down' ? -transposeAmount : transposeAmount;
1292
+ }
1293
+
1294
+ pitchRange = (await selectPrompt({
1295
+ message: 'Pitch range',
1296
+ choices: RANGE_OPTIONS.map((option) => ({
1297
+ value: option.value,
1298
+ name: option.label,
1299
+ description: option.help,
1300
+ })),
1301
+ default: defaults.pitchRange,
1302
+ theme: inquirerTheme,
1303
+ })) as PitchRange;
1304
+
1305
+ const snapScaleRaw = (await selectPrompt({
1306
+ message: 'Snap to scale',
1307
+ choices: [
1308
+ { value: 'off', name: 'Off', description: 'Keep full chromatic freedom.' },
1309
+ { value: 'on', name: 'On', description: 'Constrain to major scale tones.' },
1310
+ ],
1311
+ default: defaults.snapScale ? 'on' : 'off',
1312
+ theme: inquirerTheme,
1313
+ })) as 'on' | 'off';
1314
+ snapScale = snapScaleRaw === 'on';
1315
+
1316
+ section('8) Modulate', 'Add movement to velocity, duration, or pitch.');
1317
+ modRate = (await selectPrompt({
1318
+ message: 'Modulation rate',
1319
+ choices: MOD_RATE_OPTIONS.map((option) => ({
1320
+ value: option.value,
1321
+ name: option.label,
1322
+ description: option.help,
1323
+ })),
1324
+ default: defaults.modRate,
1325
+ theme: inquirerTheme,
1326
+ })) as ModRate;
1327
+
1328
+ modDepth = await pickBoundedNumber(
1329
+ (cfg) => selectPrompt(cfg) as Promise<number>,
1330
+ inputPrompt,
1331
+ { message: 'Modulation depth (0..100)', fallback: defaults.modDepth, min: 0, max: 100, fractions: [0, 0.2, 0.4, 0.6, 0.8, 1] },
1332
+ );
1333
+
1334
+ modTarget = (await selectPrompt({
1335
+ message: 'Modulation target',
1336
+ choices: MOD_TARGET_OPTIONS.map((option) => ({
1337
+ value: option.value,
1338
+ name: option.label,
1339
+ description: option.help,
1340
+ })),
1341
+ default: defaults.modTarget,
1342
+ theme: inquirerTheme,
1343
+ })) as ModTarget;
1344
+
1345
+ section('9) Movement', 'Control song growth over time and overall note length.');
1346
+ growthStyle = (await selectPrompt({
1347
+ message: 'Growth over time',
1348
+ choices: GROWTH_OPTIONS.map((option) => ({
1349
+ value: option.value,
1350
+ name: option.label,
1351
+ description: option.help,
1352
+ })),
1353
+ default: defaults.growthStyle,
1354
+ theme: inquirerTheme,
1355
+ })) as GrowthStyle;
1356
+
1357
+ durationStretch = (await selectPrompt({
1358
+ message: 'Duration stretch',
1359
+ choices: DURATION_STRETCH_OPTIONS.map((option) => ({
1360
+ value: option.value,
1361
+ name: option.label,
1362
+ description: option.help,
1363
+ })),
1364
+ default: defaults.durationStretch,
1365
+ theme: inquirerTheme,
1366
+ })) as number;
1367
+
1368
+ timingFeel = (await selectPrompt({
1369
+ message: 'Timing feel',
1370
+ choices: TIMING_FEEL_OPTIONS.map((option) => ({
1371
+ value: option.value,
1372
+ name: option.label,
1373
+ description: option.help,
1374
+ })),
1375
+ default: defaults.timingFeel,
1376
+ theme: inquirerTheme,
1377
+ })) as TimingFeel;
1378
+
1379
+ timingAmount = await pickBoundedNumber(
1380
+ (cfg) => selectPrompt(cfg) as Promise<number>,
1381
+ inputPrompt,
1382
+ { message: 'Timing amount (0..100)', fallback: defaults.timingAmount, min: 0, max: 100, fractions: [0, 0.2, 0.4, 0.6, 0.8, 1] },
1383
+ );
1384
+
1385
+ if (mode === 'backing') {
1386
+ section('10) Backing', 'Enable drums/bass and shape groove behavior.');
1387
+ const drumsRaw = (await selectPrompt({
1388
+ message: 'Drums',
1389
+ choices: [
1390
+ { value: 'on', name: 'On', description: 'Add style-based kick/snare/hihat.' },
1391
+ { value: 'off', name: 'Off', description: 'No drum backing.' },
1392
+ ],
1393
+ default: defaults.backing.drums ? 'on' : 'off',
1394
+ theme: inquirerTheme,
1395
+ })) as 'on' | 'off';
1396
+ const bassRaw = (await selectPrompt({
1397
+ message: 'Bass',
1398
+ choices: [
1399
+ { value: 'off', name: 'Off', description: 'Melody and drums only.' },
1400
+ { value: 'on', name: 'On', description: 'Add low-register backing notes.' },
1401
+ ],
1402
+ default: defaults.backing.bass ? 'on' : 'off',
1403
+ theme: inquirerTheme,
1404
+ })) as 'on' | 'off';
1405
+ const clapRaw = (await selectPrompt({
1406
+ message: 'Clap',
1407
+ choices: [
1408
+ { value: 'off', name: 'Off', description: 'No clap layer.' },
1409
+ { value: 'on', name: 'On', description: 'Add clap accents.' },
1410
+ ],
1411
+ default: defaults.backing.clap ? 'on' : 'off',
1412
+ theme: inquirerTheme,
1413
+ })) as 'on' | 'off';
1414
+ const openHatRaw = (await selectPrompt({
1415
+ message: 'Open hat',
1416
+ choices: [
1417
+ { value: 'off', name: 'Off', description: 'No open hat layer.' },
1418
+ { value: 'on', name: 'On', description: 'Add open hat accents.' },
1419
+ ],
1420
+ default: defaults.backing.openHat ? 'on' : 'off',
1421
+ theme: inquirerTheme,
1422
+ })) as 'on' | 'off';
1423
+ const percRaw = (await selectPrompt({
1424
+ message: 'Perc',
1425
+ choices: [
1426
+ { value: 'off', name: 'Off', description: 'No extra percussion.' },
1427
+ { value: 'on', name: 'On', description: 'Add extra percussion hits.' },
1428
+ ],
1429
+ default: defaults.backing.perc ? 'on' : 'off',
1430
+ theme: inquirerTheme,
1431
+ })) as 'on' | 'off';
1432
+ const metronome = (await selectPrompt({
1433
+ message: 'Metronome',
1434
+ choices: METRONOME_OPTIONS.map((option) => ({
1435
+ value: option.value,
1436
+ name: option.label,
1437
+ description: option.help,
1438
+ })),
1439
+ default: defaults.backing.metronome,
1440
+ theme: inquirerTheme,
1441
+ })) as MetronomeMode;
1442
+ const gate = (await selectPrompt({
1443
+ message: 'Gate',
1444
+ choices: GATE_OPTIONS.map((option) => ({
1445
+ value: option.value,
1446
+ name: option.label,
1447
+ description: option.help,
1448
+ })),
1449
+ default: defaults.backing.gate,
1450
+ theme: inquirerTheme,
1451
+ })) as GateStyle;
1452
+
1453
+ const swing = await pickBoundedNumber(
1454
+ (cfg) => selectPrompt(cfg) as Promise<number>,
1455
+ inputPrompt,
1456
+ { message: 'Swing (0..100)', fallback: defaults.backing.swing, min: 0, max: 100, fractions: [0, 0.2, 0.4, 0.6, 0.8, 1] },
1457
+ );
1458
+ const mutate = await pickBoundedNumber(
1459
+ (cfg) => selectPrompt(cfg) as Promise<number>,
1460
+ inputPrompt,
1461
+ { message: 'Mutate (0..100)', fallback: defaults.backing.mutate, min: 0, max: 100, fractions: [0, 0.2, 0.4, 0.6, 0.8, 1] },
1462
+ );
1463
+ const deviate = await pickBoundedNumber(
1464
+ (cfg) => selectPrompt(cfg) as Promise<number>,
1465
+ inputPrompt,
1466
+ { message: 'Deviate (0..100)', fallback: defaults.backing.deviate, min: 0, max: 100, fractions: [0, 0.2, 0.4, 0.6, 0.8, 1] },
1467
+ );
1468
+
1469
+ backing = {
1470
+ drums: drumsRaw === 'on',
1471
+ bass: bassRaw === 'on',
1472
+ clap: clapRaw === 'on',
1473
+ openHat: openHatRaw === 'on',
1474
+ perc: percRaw === 'on',
1475
+ metronome,
1476
+ swing,
1477
+ gate,
1478
+ mutate,
1479
+ deviate,
1480
+ };
1481
+ }
1482
+ }
1483
+
1484
+ section('11) Style', 'Choose a preset music category or custom theme.');
1485
+ const theme = await pickTheme((cfg) => selectPrompt(cfg) as Promise<string>, inputPrompt, defaults.theme);
1486
+ section('12) Structure', 'Set length and tempo.');
1487
+ const length = await pickLength((cfg) => selectPrompt(cfg) as Promise<number>, inputPrompt, defaults.length);
1488
+ const bpm = await pickBpm((cfg) => selectPrompt(cfg) as Promise<number>, inputPrompt, defaults.bpm);
1489
+
1490
+ let seedSource = setupPath === 'advanced'
1491
+ ? (await selectPrompt({
1492
+ message: 'Seed input mode',
1493
+ choices: SEED_SOURCE_OPTIONS.map((option) => ({
1494
+ value: option.value,
1495
+ name: option.label,
1496
+ description: option.help,
1497
+ })),
1498
+ default: defaults.seedSource,
1499
+ theme: inquirerTheme,
1500
+ })) as 'manual' | 'keyboard'
1501
+ : 'manual';
1502
+
1503
+ let seedPitch = defaults.seedPitch;
1504
+ let seedPitchChosen = false;
1505
+ if (seedSource === 'keyboard') {
1506
+ const keyboardPath = (await selectPrompt({
1507
+ message: 'Keyboard input',
1508
+ choices: [
1509
+ { value: 'keyboard', name: 'Live keyboard capture', description: 'Use 1-8 keys when generation starts.' },
1510
+ { value: 'preset', name: 'Preset note list', description: 'Pick from quick MIDI note presets.' },
1511
+ { value: 'custom', name: 'Custom MIDI value', description: 'Type a note value directly.' },
1512
+ ],
1513
+ default: 'keyboard',
1514
+ theme: inquirerTheme,
1515
+ })) as 'keyboard' | 'preset' | 'custom';
1516
+
1517
+ if (keyboardPath === 'preset') {
1518
+ seedPitch = await pickBoundedNumber(
1519
+ (cfg) => selectPrompt(cfg) as Promise<number>,
1520
+ inputPrompt,
1521
+ {
1522
+ message: 'Choose note (MIDI 0..127)',
1523
+ fallback: defaults.seedPitch,
1524
+ min: 0,
1525
+ max: 127,
1526
+ fractions: [0, 0.25, 0.5, 0.75, 1],
1527
+ customMessage: 'Custom note (MIDI 0..127)',
1528
+ },
1529
+ );
1530
+ seedSource = 'manual';
1531
+ seedPitchChosen = true;
1532
+ } else if (keyboardPath === 'custom') {
1533
+ seedPitch = await pickBoundedNumber(
1534
+ (cfg) => selectPrompt(cfg) as Promise<number>,
1535
+ inputPrompt,
1536
+ {
1537
+ message: 'Choose note (MIDI 0..127)',
1538
+ fallback: defaults.seedPitch,
1539
+ min: 0,
1540
+ max: 127,
1541
+ fractions: [],
1542
+ customMessage: 'Custom note (MIDI 0..127)',
1543
+ },
1544
+ );
1545
+ seedSource = 'manual';
1546
+ seedPitchChosen = true;
1547
+ }
1548
+ }
1549
+
1550
+ if (seedSource === 'manual' && !seedPitchChosen) {
1551
+ section('13) Input', setupPath === 'advanced' ? 'Choose seed note input mode.' : 'Basic path uses manual seed input.');
1552
+ seedPitch = await pickBoundedNumber(
1553
+ (cfg) => selectPrompt(cfg) as Promise<number>,
1554
+ inputPrompt,
1555
+ {
1556
+ message: 'Choose note (MIDI 0..127)',
1557
+ fallback: defaults.seedPitch,
1558
+ min: 0,
1559
+ max: 127,
1560
+ fractions: [0, 0.25, 0.5, 0.75, 1],
1561
+ customMessage: 'Custom note (MIDI 0..127)',
1562
+ },
1563
+ );
1564
+ }
1565
+
1566
+ const beep = false;
1567
+
1568
+ section('14) Export Open Action', 'What should happen right after export?');
1569
+ const openAfterExport = (await selectPrompt({
1570
+ message: 'After export',
1571
+ choices: OPEN_AFTER_EXPORT_OPTIONS.map((option) => ({
1572
+ value: option.value,
1573
+ name: option.label,
1574
+ description: option.help,
1575
+ })),
1576
+ default: defaults.openAfterExport,
1577
+ theme: inquirerTheme,
1578
+ })) as 'none' | 'finder' | 'garageband';
1579
+
1580
+ section('15) Export Media Format', 'Optional extra export format when you choose export actions.');
1581
+ const exportAudioOptions = source === 'record' ? EXPORT_AUDIO_RECORD_OPTIONS : EXPORT_AUDIO_OPTIONS;
1582
+ const exportAudio = (await selectPrompt({
1583
+ message: 'Export profile',
1584
+ choices: exportAudioOptions.map((option) => ({
1585
+ value: option.value,
1586
+ name: option.label,
1587
+ description: option.help,
1588
+ })),
1589
+ default: defaults.exportAudio === 'none' ? 'mp4' : defaults.exportAudio,
1590
+ theme: inquirerTheme,
1591
+ })) as 'none' | 'mp3' | 'mp4';
1592
+
1593
+ const config = {
1594
+ provider,
1595
+ providerAuth,
1596
+ source,
1597
+ mode,
1598
+ instrument,
1599
+ fxPreset,
1600
+ decayStyle,
1601
+ transpose,
1602
+ pitchRange,
1603
+ snapScale,
1604
+ modRate,
1605
+ modDepth,
1606
+ modTarget,
1607
+ growthStyle,
1608
+ durationStretch,
1609
+ timingFeel,
1610
+ timingAmount,
1611
+ backing,
1612
+ theme,
1613
+ length,
1614
+ bpm,
1615
+ seedPitch,
1616
+ seedSource,
1617
+ beep,
1618
+ openAfterExport,
1619
+ exportAudio,
1620
+ exportStems: exportAudio === 'none' ? defaults.exportStems : true,
1621
+ eqMode,
1622
+ recordDevice,
1623
+ recordSeconds,
1624
+ recordMonitor,
1625
+ recordProfile,
1626
+ recordFamily,
1627
+ recordBugMode,
1628
+ recordIntensity,
1629
+ recordChaos,
1630
+ recordMix,
1631
+ recordScratch,
1632
+ recordWavy,
1633
+ };
1634
+
1635
+ section('Summary');
1636
+ console.log(color(`Provider: ${config.provider}`, `${c.dim}${palette.soft}`));
1637
+ console.log(color(`Auth: ${config.providerAuth}`, `${c.dim}${palette.soft}`));
1638
+ console.log(color(`Path: ${setupPath}`, `${c.dim}${palette.soft}`));
1639
+ console.log(color(`Source: ${config.source}`, `${c.dim}${palette.soft}`));
1640
+ console.log(color(`Mode: ${config.mode}`, `${c.dim}${palette.soft}`));
1641
+ console.log(color(`Instrument: ${config.instrument}`, `${c.dim}${palette.soft}`));
1642
+ console.log(color(`FX preset: ${config.fxPreset}`, `${c.dim}${palette.soft}`));
1643
+ console.log(color(`Decay: ${config.decayStyle}`, `${c.dim}${palette.soft}`));
1644
+ console.log(color(`Pitch: transpose ${config.transpose}, range ${config.pitchRange}, snap ${config.snapScale ? 'on' : 'off'}`, `${c.dim}${palette.soft}`));
1645
+ console.log(color(`Modulate: ${config.modRate} depth ${config.modDepth} target ${config.modTarget}`, `${c.dim}${palette.soft}`));
1646
+ console.log(color(`Growth: ${config.growthStyle}`, `${c.dim}${palette.soft}`));
1647
+ console.log(color(`Duration: ${config.durationStretch}x`, `${c.dim}${palette.soft}`));
1648
+ console.log(color(`Timing: ${config.timingFeel} (${config.timingAmount})`, `${c.dim}${palette.soft}`));
1649
+ console.log(color(`EQ mode: ${config.eqMode}`, `${c.dim}${palette.soft}`));
1650
+ if (config.source === 'record' || config.recordBugMode !== 'off') {
1651
+ console.log(color(`Record tone: ${config.recordProfile}`, `${c.dim}${palette.soft}`));
1652
+ console.log(color(`FX family: ${config.recordFamily}`, `${c.dim}${palette.soft}`));
1653
+ if (config.recordFamily === 'bug' || config.recordBugMode !== 'off') {
1654
+ console.log(color(`Bug mode: ${config.recordBugMode}`, `${c.dim}${palette.soft}`));
1655
+ }
1656
+ if (config.source === 'record') {
1657
+ console.log(color(`Scratch: ${config.recordScratch}`, `${c.dim}${palette.soft}`));
1658
+ console.log(color(`Wobble FX: ${config.recordWavy}`, `${c.dim}${palette.soft}`));
1659
+ }
1660
+ console.log(color(`Intensity: ${config.recordIntensity}`, `${c.dim}${palette.soft}`));
1661
+ console.log(color(`Mix: ${config.recordMix}`, `${c.dim}${palette.soft}`));
1662
+ console.log(color(`Chaos: ${config.recordChaos}`, `${c.dim}${palette.soft}`));
1663
+ }
1664
+ if (config.mode === 'backing' && config.source === 'generated') {
1665
+ console.log(
1666
+ color(
1667
+ `Backing: drums ${config.backing.drums ? 'on' : 'off'}, bass ${config.backing.bass ? 'on' : 'off'}, metronome ${config.backing.metronome}`,
1668
+ `${c.dim}${palette.soft}`,
1669
+ ),
1670
+ );
1671
+ console.log(
1672
+ color(
1673
+ `Drum FX: clap ${config.backing.clap ? 'on' : 'off'}, open hat ${config.backing.openHat ? 'on' : 'off'}, perc ${config.backing.perc ? 'on' : 'off'}`,
1674
+ `${c.dim}${palette.soft}`,
1675
+ ),
1676
+ );
1677
+ console.log(
1678
+ color(
1679
+ `Groove: swing ${config.backing.swing}, gate ${config.backing.gate}, mutate ${config.backing.mutate}, deviate ${config.backing.deviate}`,
1680
+ `${c.dim}${palette.soft}`,
1681
+ ),
1682
+ );
1683
+ }
1684
+ console.log(color(`Theme: ${config.theme}`, `${c.dim}${palette.soft}`));
1685
+ console.log(color(`Length: ${config.length} notes`, `${c.dim}${palette.soft}`));
1686
+ console.log(color(`BPM: ${config.bpm}`, `${c.dim}${palette.soft}`));
1687
+ console.log(color(`Seed source:${config.seedSource}`, `${c.dim}${palette.soft}`));
1688
+ if (config.seedSource === 'manual') {
1689
+ console.log(color(`Seed pitch: ${config.seedPitch}`, `${c.dim}${palette.soft}`));
1690
+ }
1691
+ console.log(color(`After export:${config.openAfterExport}`, `${c.dim}${palette.soft}`));
1692
+ console.log(color(`Export media:${config.exportAudio}`, `${c.dim}${palette.soft}`));
1693
+ console.log(color(`Export stems:${config.exportStems ? 'on' : 'off'}`, `${c.dim}${palette.soft}`));
1694
+ if (config.exportAudio === 'mp4') console.log(color('Cover image: ./src/assets/cover.png', `${c.dim}${palette.soft}`));
1695
+ console.log('');
1696
+
1697
+ const next = (await selectPrompt({
1698
+ message: 'Continue',
1699
+ choices: [
1700
+ { value: 'start', name: 'Start generation' },
1701
+ { value: 'back', name: 'Back to setup' },
1702
+ ],
1703
+ default: 'start',
1704
+ theme: inquirerTheme,
1705
+ })) as 'start' | 'back';
1706
+
1707
+ if (next === 'start') return config;
1708
+ }
1709
+ }