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/fx.js ADDED
@@ -0,0 +1,28 @@
1
+ function clamp(n, min, max) {
2
+ return Math.max(min, Math.min(max, n));
3
+ }
4
+ export function buildFxSettings(preset, decay) {
5
+ const base = {
6
+ clean: { waveform: 'sine', drive: 0.02, reverb: 0.04, bitcrush: 0.0 },
7
+ dark: { waveform: 'saw', drive: 0.16, reverb: 0.12, bitcrush: 0.05 },
8
+ grime: { waveform: 'square', drive: 0.45, reverb: 0.08, bitcrush: 0.32 },
9
+ lush: { waveform: 'saw', drive: 0.08, reverb: 0.38, bitcrush: 0.0 },
10
+ punch: { waveform: 'square', drive: 0.28, reverb: 0.02, bitcrush: 0.1 },
11
+ };
12
+ const decayScale = decay === 'tight' ? 0.62 : decay === 'long' ? 1.5 : 1;
13
+ const velocityScale = preset === 'punch' ? 1.12 : preset === 'dark' ? 1.06 : 1;
14
+ return {
15
+ preset,
16
+ decay,
17
+ ...base[preset],
18
+ durationScale: decayScale,
19
+ velocityScale,
20
+ };
21
+ }
22
+ export function applyFxToSequence(sequence, fx) {
23
+ return sequence.map((note) => ({
24
+ pitch: clamp(Math.round(note.pitch), 0, 127),
25
+ velocity: clamp(Math.round(note.velocity * fx.velocityScale), 1, 127),
26
+ durationMs: clamp(Math.round(note.durationMs * fx.durationScale), 35, 6000),
27
+ }));
28
+ }
package/src/fx.ts ADDED
@@ -0,0 +1,49 @@
1
+ import type { GeneratedNote } from './types';
2
+
3
+ export type FxPresetName = 'clean' | 'dark' | 'grime' | 'lush' | 'punch';
4
+ export type DecayStyle = 'tight' | 'balanced' | 'long';
5
+ export type Waveform = 'sine' | 'saw' | 'square';
6
+
7
+ export type FxSettings = {
8
+ preset: FxPresetName;
9
+ decay: DecayStyle;
10
+ waveform: Waveform;
11
+ drive: number; // 0..1
12
+ reverb: number; // 0..1
13
+ bitcrush: number; // 0..1
14
+ durationScale: number;
15
+ velocityScale: number;
16
+ };
17
+
18
+ function clamp(n: number, min: number, max: number): number {
19
+ return Math.max(min, Math.min(max, n));
20
+ }
21
+
22
+ export function buildFxSettings(preset: FxPresetName, decay: DecayStyle): FxSettings {
23
+ const base: Record<FxPresetName, Omit<FxSettings, 'preset' | 'decay' | 'durationScale' | 'velocityScale'>> = {
24
+ clean: { waveform: 'sine', drive: 0.02, reverb: 0.04, bitcrush: 0.0 },
25
+ dark: { waveform: 'saw', drive: 0.16, reverb: 0.12, bitcrush: 0.05 },
26
+ grime: { waveform: 'square', drive: 0.45, reverb: 0.08, bitcrush: 0.32 },
27
+ lush: { waveform: 'saw', drive: 0.08, reverb: 0.38, bitcrush: 0.0 },
28
+ punch: { waveform: 'square', drive: 0.28, reverb: 0.02, bitcrush: 0.1 },
29
+ };
30
+
31
+ const decayScale = decay === 'tight' ? 0.62 : decay === 'long' ? 1.5 : 1;
32
+ const velocityScale = preset === 'punch' ? 1.12 : preset === 'dark' ? 1.06 : 1;
33
+ return {
34
+ preset,
35
+ decay,
36
+ ...base[preset],
37
+ durationScale: decayScale,
38
+ velocityScale,
39
+ };
40
+ }
41
+
42
+ export function applyFxToSequence(sequence: GeneratedNote[], fx: FxSettings): GeneratedNote[] {
43
+ return sequence.map((note) => ({
44
+ pitch: clamp(Math.round(note.pitch), 0, 127),
45
+ velocity: clamp(Math.round(note.velocity * fx.velocityScale), 1, 127),
46
+ durationMs: clamp(Math.round(note.durationMs * fx.durationScale), 35, 6000),
47
+ }));
48
+ }
49
+
@@ -0,0 +1,15 @@
1
+ import { sanitizeNote } from './validate';
2
+ export async function generateSequence(provider, seedPitch, config) {
3
+ const history = [];
4
+ for (let i = 0; i < config.targetLength; i++) {
5
+ const raw = await provider.nextNote({
6
+ theme: config.theme,
7
+ targetLength: config.targetLength,
8
+ seedPitch,
9
+ history,
10
+ bpm: config.bpm,
11
+ });
12
+ history.push(sanitizeNote(raw));
13
+ }
14
+ return history;
15
+ }
@@ -0,0 +1,29 @@
1
+ import type { GeneratedNote, LLMProvider } from './types';
2
+ import { sanitizeNote } from './validate';
3
+
4
+ export type GenerationConfig = {
5
+ theme: string;
6
+ targetLength: number;
7
+ bpm: number;
8
+ };
9
+
10
+ export async function generateSequence(
11
+ provider: LLMProvider,
12
+ seedPitch: number,
13
+ config: GenerationConfig,
14
+ ): Promise<GeneratedNote[]> {
15
+ const history: GeneratedNote[] = [];
16
+
17
+ for (let i = 0; i < config.targetLength; i++) {
18
+ const raw = await provider.nextNote({
19
+ theme: config.theme,
20
+ targetLength: config.targetLength,
21
+ seedPitch,
22
+ history,
23
+ bpm: config.bpm,
24
+ });
25
+ history.push(sanitizeNote(raw));
26
+ }
27
+
28
+ return history;
29
+ }
package/src/index.js ADDED
@@ -0,0 +1,511 @@
1
+ import { dirname, extname, join, basename } from 'node:path';
2
+ import { unlink } from 'node:fs/promises';
3
+ import { exportSequenceToMidi } from './exportMidi';
4
+ import { exportSequenceToWav, exportWavToMp3, exportWavToMp4, isFfmpegMissing, processRecordedWav, } from './exportAudio';
5
+ import { applyGrowthAndDuration, applyTimingFeel, buildBackingEvents, sequenceToEvents, transformMelody, } from './arrangement';
6
+ import { applyFxToSequence, buildFxSettings } from './fx';
7
+ import { applyInstrumentProfile, getInstrumentProfile } from './instrument';
8
+ import { promptCliConfig } from './cli';
9
+ import { generateSequence } from './generator';
10
+ import { playSequenceToOutput, waitForSeedNote } from './midi';
11
+ import { openExportTarget } from './openExport';
12
+ import { buildProvider } from './providers/factory';
13
+ const c = {
14
+ reset: '\x1b[0m',
15
+ bold: '\x1b[1m',
16
+ };
17
+ function fgHex(hex) {
18
+ const clean = hex.replace('#', '');
19
+ const r = Number.parseInt(clean.slice(0, 2), 16);
20
+ const g = Number.parseInt(clean.slice(2, 4), 16);
21
+ const b = Number.parseInt(clean.slice(4, 6), 16);
22
+ return `\x1b[38;2;${r};${g};${b}m`;
23
+ }
24
+ const palette = {
25
+ primary: fgHex('#d199ff'),
26
+ soft: fgHex('#ecebff'),
27
+ };
28
+ function color(text, code) {
29
+ return `${code}${text}${c.reset}`;
30
+ }
31
+ const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
32
+ function pitchToName(pitch) {
33
+ const p = Math.max(0, Math.min(127, Math.round(pitch)));
34
+ const octave = Math.floor(p / 12) - 1;
35
+ return `${NOTE_NAMES[p % 12]}${octave}`;
36
+ }
37
+ function formatNotePreview(sequence, limit = 16) {
38
+ const names = sequence.slice(0, limit).map((n) => pitchToName(n.pitch));
39
+ const suffix = sequence.length > limit ? ' ...' : '';
40
+ return `${names.join(' ')}${suffix}`;
41
+ }
42
+ function logKV(label, value) {
43
+ console.log(color(`${label} `, palette.soft) + color(String(value), palette.primary));
44
+ }
45
+ async function withSpinner(message, task) {
46
+ const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
47
+ let i = 0;
48
+ process.stdout.write(`${color(frames[i], palette.primary)} ${color(message, palette.soft)}`);
49
+ const timer = setInterval(() => {
50
+ i = (i + 1) % frames.length;
51
+ process.stdout.write(`\r${color(frames[i], palette.primary)} ${color(message, palette.soft)}`);
52
+ }, 90);
53
+ try {
54
+ const result = await task;
55
+ process.stdout.write(`\r${color('✓', palette.primary)} ${color(`${message} done`, palette.soft)}\n`);
56
+ return result;
57
+ }
58
+ finally {
59
+ clearInterval(timer);
60
+ }
61
+ }
62
+ function arg(name, fallback) {
63
+ const found = process.argv.find((a) => a.startsWith(`--${name}=`));
64
+ return found ? found.split('=').slice(1).join('=') : fallback;
65
+ }
66
+ function boolArg(name, fallback) {
67
+ const raw = arg(name, fallback ? 'true' : 'false').trim().toLowerCase();
68
+ if (raw === 'true' || raw === '1' || raw === 'yes' || raw === 'on')
69
+ return true;
70
+ if (raw === 'false' || raw === '0' || raw === 'no' || raw === 'off')
71
+ return false;
72
+ return fallback;
73
+ }
74
+ function recordScratchArg(name, fallback) {
75
+ const raw = arg(name, fallback).trim().toLowerCase();
76
+ if (raw === 'off' || raw === 'false' || raw === '0' || raw === 'no')
77
+ return 'off';
78
+ if (raw === 'texture' || raw === 'on' || raw === 'true' || raw === '1' || raw === 'yes')
79
+ return 'texture';
80
+ if (raw === 'dj' || raw === 'replay' || raw === 'scratch')
81
+ return 'dj';
82
+ return fallback;
83
+ }
84
+ function intArg(name, fallback, min, max) {
85
+ const raw = Number.parseInt(arg(name, String(fallback)), 10);
86
+ if (!Number.isFinite(raw))
87
+ return fallback;
88
+ return Math.max(min, Math.min(max, raw));
89
+ }
90
+ function floatArg(name, fallback, min, max) {
91
+ const raw = Number.parseFloat(arg(name, String(fallback)));
92
+ if (!Number.isFinite(raw))
93
+ return fallback;
94
+ return Math.max(min, Math.min(max, raw));
95
+ }
96
+ const inquirerTheme = {
97
+ icon: {
98
+ cursor: '❯',
99
+ },
100
+ style: {
101
+ highlight: (text) => color(text, palette.primary),
102
+ message: (text) => color(text, `${c.bold}${palette.soft}`),
103
+ description: (text) => color(text, palette.soft),
104
+ answer: (text) => color(text, palette.primary),
105
+ defaultAnswer: (text) => color(text, palette.soft),
106
+ help: (text) => color(text, palette.soft),
107
+ key: (text) => color(text, palette.primary),
108
+ error: (text) => color(text, palette.soft),
109
+ },
110
+ };
111
+ function openActionLabel(openAfterExport) {
112
+ if (openAfterExport === 'finder')
113
+ return 'reveal in Finder';
114
+ if (openAfterExport === 'garageband')
115
+ return 'open in GarageBand';
116
+ return 'do nothing after export';
117
+ }
118
+ async function promptPostRunAction(openAfterExport, source) {
119
+ const inquirer = await import('@inquirer/prompts');
120
+ const exportLabel = source === 'record' ? 'Export MIDI (record-player FX)' : 'Export MIDI';
121
+ const selection = await inquirer.select({
122
+ message: `Next action (export will ${openActionLabel(openAfterExport)})`,
123
+ choices: [
124
+ { value: 'export-finish', name: `${exportLabel} + ${openActionLabel(openAfterExport)} + finish` },
125
+ { value: 'export-retry', name: `${exportLabel} + ${openActionLabel(openAfterExport)} + retry` },
126
+ { value: 'retry', name: 'Retry (new take)' },
127
+ { value: 'finish', name: 'Finish' },
128
+ ],
129
+ theme: inquirerTheme,
130
+ });
131
+ return selection;
132
+ }
133
+ function defaultMidiPath() {
134
+ const stamp = new Date().toISOString().replaceAll(':', '-').slice(0, 19);
135
+ return `./exports/opennote-${stamp}.mid`;
136
+ }
137
+ async function maybeExportMidi(sequence, bpm, explicitPath, openAfterExport, exportAudio, exportStems, stems, coverImagePath, fx, recordPlayerFx) {
138
+ const path = explicitPath ?? defaultMidiPath();
139
+ const written = await exportSequenceToMidi(sequence, bpm, path);
140
+ console.log(color('MIDI exported:', palette.soft), written);
141
+ let openPath = written;
142
+ if (exportStems) {
143
+ const ext = extname(written).toLowerCase();
144
+ const baseNoExt = ext === '.mid' || ext === '.midi'
145
+ ? written.slice(0, -ext.length)
146
+ : join(dirname(written), basename(written));
147
+ if (stems.melody.length > 0) {
148
+ const melodyPath = `${baseNoExt}-melody.mid`;
149
+ await exportSequenceToMidi(stems.melody, bpm, melodyPath);
150
+ console.log(color('Stem exported:', palette.soft), melodyPath);
151
+ }
152
+ if (stems.bass.length > 0) {
153
+ const bassPath = `${baseNoExt}-bass.mid`;
154
+ await exportSequenceToMidi(stems.bass, bpm, bassPath);
155
+ console.log(color('Stem exported:', palette.soft), bassPath);
156
+ }
157
+ if (stems.drums.length > 0) {
158
+ const drumsPath = `${baseNoExt}-drums.mid`;
159
+ await exportSequenceToMidi(stems.drums, bpm, drumsPath);
160
+ console.log(color('Stem exported:', palette.soft), drumsPath);
161
+ }
162
+ }
163
+ if (exportAudio !== 'none') {
164
+ try {
165
+ const ext = extname(written).toLowerCase();
166
+ const baseNoExt = ext === '.mid' || ext === '.midi'
167
+ ? written.slice(0, -ext.length)
168
+ : join(dirname(written), basename(written));
169
+ const wavPath = `${baseNoExt}.__temp.wav`;
170
+ const writtenWav = await exportSequenceToWav(sequence, wavPath, fx);
171
+ let renderWav = writtenWav;
172
+ let processedWav = null;
173
+ const shouldProcess = recordPlayerFx.enabled && (recordPlayerFx.eqMode !== 'flat'
174
+ || recordPlayerFx.profile !== 'default'
175
+ || recordPlayerFx.family !== 'character'
176
+ || recordPlayerFx.bugMode !== 'off'
177
+ || recordPlayerFx.scratch !== 'off'
178
+ || recordPlayerFx.wavy > 0
179
+ || recordPlayerFx.intensity > 0
180
+ || recordPlayerFx.mix > 0
181
+ || recordPlayerFx.chaos > 0);
182
+ if (shouldProcess) {
183
+ try {
184
+ processedWav = `${baseNoExt}.__recordfx.wav`;
185
+ renderWav = await processRecordedWav(writtenWav, processedWav, {
186
+ eqMode: recordPlayerFx.eqMode,
187
+ profile: recordPlayerFx.profile,
188
+ family: recordPlayerFx.family,
189
+ bugMode: recordPlayerFx.bugMode,
190
+ intensity: recordPlayerFx.intensity,
191
+ chaos: recordPlayerFx.chaos,
192
+ mix: recordPlayerFx.mix,
193
+ scratch: recordPlayerFx.scratch,
194
+ wavy: recordPlayerFx.wavy,
195
+ });
196
+ }
197
+ catch (fxErr) {
198
+ processedWav = null;
199
+ renderWav = writtenWav;
200
+ console.error(color('Record-player FX failed; exporting without FX.', palette.soft));
201
+ console.error(fxErr);
202
+ }
203
+ }
204
+ if (exportAudio === 'mp3') {
205
+ const mp3Path = `${baseNoExt}.mp3`;
206
+ const writtenMp3 = await exportWavToMp3(renderWav, mp3Path);
207
+ console.log(color('MP3 exported:', palette.soft), writtenMp3);
208
+ openPath = writtenMp3;
209
+ }
210
+ else {
211
+ const mp4Path = `${baseNoExt}.mp4`;
212
+ const writtenMp4 = await exportWavToMp4(renderWav, mp4Path, coverImagePath);
213
+ console.log(color('MP4 exported:', palette.soft), writtenMp4);
214
+ openPath = writtenMp4;
215
+ }
216
+ try {
217
+ await unlink(writtenWav);
218
+ }
219
+ catch {
220
+ // Ignore temp cleanup errors.
221
+ }
222
+ if (processedWav) {
223
+ try {
224
+ await unlink(processedWav);
225
+ }
226
+ catch {
227
+ // Ignore temp cleanup errors.
228
+ }
229
+ }
230
+ }
231
+ catch (err) {
232
+ if (isFfmpegMissing(err)) {
233
+ console.error(color('ffmpeg not found: MP3/MP4 export skipped.', palette.soft));
234
+ console.error(color('Install hint (macOS): brew install ffmpeg', palette.soft));
235
+ }
236
+ else {
237
+ throw err;
238
+ }
239
+ }
240
+ }
241
+ if (openAfterExport !== 'none') {
242
+ try {
243
+ await openExportTarget(openPath, openAfterExport);
244
+ console.log(color('Opened:', palette.soft), openAfterExport);
245
+ }
246
+ catch (err) {
247
+ console.error(color(`Failed to open (${openAfterExport}):`, palette.soft), err);
248
+ }
249
+ }
250
+ return written;
251
+ }
252
+ function printHelp() {
253
+ console.log(color('OpenNote CLI', `${c.bold}${palette.primary}`));
254
+ console.log(`
255
+ Interactive mode (recommended):
256
+ npm run note
257
+
258
+ Non-interactive mode:
259
+ npm run note -- --no-interactive --provider=mock --theme="ambient" --length=16 --bpm=120 --seed=60
260
+
261
+ Flags:
262
+ --provider=mock|openai|gemini|claude|groq|grok
263
+ --source=generated|record
264
+ --instrument=lead|bass|pad|keys|drums
265
+ --fx=clean|dark|grime|lush|punch
266
+ --decay=tight|balanced|long
267
+ --mode=single|backing
268
+ --transpose=-12..12
269
+ --pitch-range=low|mid|high
270
+ --snap-scale=true|false
271
+ --mod-rate=off|slow|med|fast
272
+ --mod-depth=0..100
273
+ --mod-target=velocity|duration|pitch
274
+ --growth=flat|build
275
+ --duration-stretch=1..4
276
+ --timing-feel=tight|human|offbeat|loose
277
+ --timing-amount=0..100
278
+ --backing-drums=true|false
279
+ --backing-bass=true|false
280
+ --backing-clap=true|false
281
+ --backing-open-hat=true|false
282
+ --backing-perc=true|false
283
+ --metronome=off|count-in|always
284
+ --swing=0..100
285
+ --gate=tight|balanced|long
286
+ --mutate=0..100
287
+ --deviate=0..100
288
+ --theme="<style prompt>"
289
+ --length=<notes>
290
+ --bpm=<tempo>
291
+ --seed=<midi pitch 0-127>
292
+ --seed-source=keyboard|manual
293
+ --beep=true|false
294
+ --export-midi=<path.mid>
295
+ --open-after-export=none|finder|garageband
296
+ --export-audio=none|mp3|mp4
297
+ --export-stems=true|false
298
+ --eq-mode=balanced|flat|warm|bright|bass|phone
299
+ --record-profile=default|vinyl|dust
300
+ --record-family=character|motion|space|bug
301
+ --record-bug-mode=off|pll-drift|buffer-tear|clock-bleed|memory-rot|crc-glitch
302
+ --record-intensity=0..100
303
+ --record-chaos=0..100
304
+ --record-mix=0..100
305
+ --record-scratch=off|texture|dj
306
+ --record-wavy=0..100
307
+ --no-interactive
308
+ --help
309
+
310
+ Provider keys:
311
+ OPENAI_API_KEY for openai
312
+ GEMINI_API_KEY for gemini
313
+ ANTHROPIC_API_KEY for claude
314
+ GROQ_API_KEY for groq
315
+ XAI_API_KEY for grok
316
+ In interactive mode, if key is missing you can paste it at prompt for the current session.
317
+
318
+ Keyboard seed mode:
319
+ 1-8 = notes in scale
320
+ Shift+1-8 = sharp notes
321
+ +/- = octave up/down
322
+ `);
323
+ }
324
+ async function main() {
325
+ if (process.argv.includes('--help')) {
326
+ printHelp();
327
+ return;
328
+ }
329
+ const interactive = !process.argv.includes('--no-interactive');
330
+ const exportPathFlag = process.argv.find((a) => a.startsWith('--export-midi='))?.split('=').slice(1).join('=');
331
+ const mode = arg('mode', 'single');
332
+ const backing = {
333
+ drums: boolArg('backing-drums', mode === 'backing'),
334
+ bass: boolArg('backing-bass', false),
335
+ clap: boolArg('backing-clap', false),
336
+ openHat: boolArg('backing-open-hat', false),
337
+ perc: boolArg('backing-perc', false),
338
+ metronome: arg('metronome', mode === 'backing' ? 'count-in' : 'off'),
339
+ swing: intArg('swing', 0, 0, 100),
340
+ gate: arg('gate', 'balanced'),
341
+ mutate: intArg('mutate', 0, 0, 100),
342
+ deviate: intArg('deviate', 0, 0, 100),
343
+ };
344
+ const defaults = {
345
+ provider: arg('provider', 'mock'),
346
+ providerAuth: (arg('provider', 'mock') === 'mock' ? 'none' : 'env'),
347
+ source: arg('source', 'generated'),
348
+ mode,
349
+ instrument: arg('instrument', 'lead'),
350
+ fxPreset: arg('fx', 'clean'),
351
+ decayStyle: arg('decay', 'balanced'),
352
+ transpose: intArg('transpose', 0, -12, 12),
353
+ pitchRange: arg('pitch-range', 'mid'),
354
+ snapScale: boolArg('snap-scale', false),
355
+ modRate: arg('mod-rate', 'off'),
356
+ modDepth: intArg('mod-depth', 0, 0, 100),
357
+ modTarget: arg('mod-target', 'velocity'),
358
+ growthStyle: arg('growth', 'flat'),
359
+ durationStretch: floatArg('duration-stretch', 1, 1, 4),
360
+ timingFeel: arg('timing-feel', 'tight'),
361
+ timingAmount: intArg('timing-amount', 0, 0, 100),
362
+ backing,
363
+ theme: arg('theme', 'dark ambient techno'),
364
+ length: intArg('length', 16, 1, 512),
365
+ bpm: intArg('bpm', 120, 1, 400),
366
+ seedPitch: intArg('seed', 60, 0, 127),
367
+ seedSource: arg('seed-source', 'keyboard'),
368
+ beep: boolArg('beep', false),
369
+ openAfterExport: arg('open-after-export', 'finder'),
370
+ exportAudio: arg('export-audio', 'none'),
371
+ exportStems: boolArg('export-stems', arg('export-audio', 'none') !== 'none'),
372
+ eqMode: arg('eq-mode', 'balanced'),
373
+ recordDevice: '',
374
+ recordSeconds: 8,
375
+ recordMonitor: false,
376
+ recordProfile: arg('record-profile', 'default'),
377
+ recordFamily: arg('record-family', 'character'),
378
+ recordBugMode: arg('record-bug-mode', 'off'),
379
+ recordIntensity: intArg('record-intensity', 45, 0, 100),
380
+ recordChaos: intArg('record-chaos', 25, 0, 100),
381
+ recordMix: intArg('record-mix', 40, 0, 100),
382
+ recordScratch: recordScratchArg('record-scratch', 'off'),
383
+ recordWavy: intArg('record-wavy', 0, 0, 100),
384
+ };
385
+ const config = interactive ? await promptCliConfig(defaults) : defaults;
386
+ const provider = buildProvider(config.provider);
387
+ let keepRunning = true;
388
+ while (keepRunning) {
389
+ const seedPitch = config.seedSource === 'keyboard' ? (await waitForSeedNote({ baseOctave: 4 })).pitch : config.seedPitch;
390
+ const sequence = await withSpinner(`Generating ${config.length} notes`, generateSequence(provider, seedPitch, {
391
+ theme: config.theme,
392
+ targetLength: config.length,
393
+ bpm: config.bpm,
394
+ }));
395
+ const transformedSequence = transformMelody(sequence, {
396
+ transpose: config.transpose,
397
+ range: config.pitchRange,
398
+ snapScale: config.snapScale,
399
+ }, {
400
+ rate: config.modRate,
401
+ depth: config.modDepth,
402
+ target: config.modTarget,
403
+ }, config.backing);
404
+ const arrangedSequence = applyGrowthAndDuration(transformedSequence, config.growthStyle, config.durationStretch);
405
+ const fxSettings = buildFxSettings(config.fxPreset, config.decayStyle);
406
+ const fxSequence = applyFxToSequence(arrangedSequence, fxSettings);
407
+ const instrumentSequence = applyInstrumentProfile(fxSequence, config.instrument);
408
+ const instrumentProfile = getInstrumentProfile(config.instrument);
409
+ const melodyEvents = sequenceToEvents(instrumentSequence, instrumentProfile.midiChannel);
410
+ const backingEvents = config.mode === 'backing'
411
+ ? buildBackingEvents(melodyEvents, config.theme, config.bpm, config.backing, config.growthStyle)
412
+ : [];
413
+ const stemGroups = {
414
+ melody: melodyEvents,
415
+ bass: backingEvents.filter((e) => e.channel === 1),
416
+ drums: backingEvents.filter((e) => e.channel === 9),
417
+ };
418
+ const mergedEvents = [...melodyEvents, ...backingEvents].sort((a, b) => a.startMs - b.startMs);
419
+ const playbackEvents = applyTimingFeel(mergedEvents, config.bpm, config.timingFeel, config.timingAmount);
420
+ console.log(color('Session', `${c.bold}${palette.primary}`));
421
+ logKV('Source:', config.source === 'record' ? 'record-player' : 'generated');
422
+ logKV('Provider:', config.provider);
423
+ logKV('Auth:', config.providerAuth);
424
+ logKV('Mode:', config.mode);
425
+ logKV('Instrument:', `${instrumentProfile.label} (ch ${instrumentProfile.midiChannel + 1}, program ${instrumentProfile.program})`);
426
+ logKV('FX:', `${config.fxPreset} / ${config.decayStyle}`);
427
+ logKV('Pitch:', `transpose ${config.transpose}, range ${config.pitchRange}, snap ${config.snapScale ? 'on' : 'off'}`);
428
+ logKV('Modulate:', `${config.modRate} depth ${config.modDepth} target ${config.modTarget}`);
429
+ logKV('Growth:', config.growthStyle);
430
+ logKV('Duration:', `${config.durationStretch}x`);
431
+ logKV('Timing:', `${config.timingFeel} (${config.timingAmount})`);
432
+ logKV('Export stems:', config.exportStems ? 'on' : 'off');
433
+ if (config.source === 'record' || config.recordBugMode !== 'off') {
434
+ logKV('Record FX:', `eq ${config.eqMode}, ${config.recordProfile}/${config.recordFamily}, bug ${config.recordBugMode}, scratch ${config.recordScratch}, wobble ${config.recordWavy}, intensity ${config.recordIntensity}, mix ${config.recordMix}, chaos ${config.recordChaos}`);
435
+ }
436
+ if (config.mode === 'backing') {
437
+ logKV('Backing:', `drums ${config.backing.drums ? 'on' : 'off'}, bass ${config.backing.bass ? 'on' : 'off'}, metronome ${config.backing.metronome}`);
438
+ logKV('Drum FX:', `clap ${config.backing.clap ? 'on' : 'off'}, open hat ${config.backing.openHat ? 'on' : 'off'}, perc ${config.backing.perc ? 'on' : 'off'}`);
439
+ logKV('Groove:', `swing ${config.backing.swing}, gate ${config.backing.gate}, mutate ${config.backing.mutate}, deviate ${config.backing.deviate}`);
440
+ }
441
+ logKV('Theme:', config.theme);
442
+ logKV('Seed source:', config.seedSource);
443
+ logKV('Seed pitch:', seedPitch);
444
+ logKV('Generated notes:', `${instrumentSequence.length} notes`);
445
+ logKV('Preview:', formatNotePreview(instrumentSequence));
446
+ await playSequenceToOutput(playbackEvents, {
447
+ beep: config.beep,
448
+ instrument: instrumentProfile,
449
+ metronome: config.backing.metronome,
450
+ bpm: config.bpm,
451
+ });
452
+ if (exportPathFlag && !interactive) {
453
+ await maybeExportMidi(playbackEvents, config.bpm, exportPathFlag, config.openAfterExport, config.exportAudio, config.exportStems, stemGroups, './src/assets/cover.png', fxSettings, {
454
+ enabled: config.source === 'record' || config.recordBugMode !== 'off',
455
+ eqMode: config.eqMode,
456
+ profile: config.recordProfile,
457
+ family: config.recordFamily,
458
+ bugMode: config.recordBugMode,
459
+ intensity: config.recordIntensity,
460
+ chaos: config.recordChaos,
461
+ mix: config.recordMix,
462
+ scratch: config.recordScratch,
463
+ wavy: config.recordWavy,
464
+ });
465
+ break;
466
+ }
467
+ if (!interactive)
468
+ break;
469
+ const action = await promptPostRunAction(config.openAfterExport, config.source);
470
+ if (action === 'finish') {
471
+ keepRunning = false;
472
+ continue;
473
+ }
474
+ if (action === 'retry') {
475
+ continue;
476
+ }
477
+ if (action === 'export-finish') {
478
+ await maybeExportMidi(playbackEvents, config.bpm, undefined, config.openAfterExport, config.exportAudio, config.exportStems, stemGroups, './src/assets/cover.png', fxSettings, {
479
+ enabled: config.source === 'record' || config.recordBugMode !== 'off',
480
+ eqMode: config.eqMode,
481
+ profile: config.recordProfile,
482
+ family: config.recordFamily,
483
+ bugMode: config.recordBugMode,
484
+ intensity: config.recordIntensity,
485
+ chaos: config.recordChaos,
486
+ mix: config.recordMix,
487
+ scratch: config.recordScratch,
488
+ wavy: config.recordWavy,
489
+ });
490
+ keepRunning = false;
491
+ continue;
492
+ }
493
+ await maybeExportMidi(playbackEvents, config.bpm, undefined, config.openAfterExport, config.exportAudio, config.exportStems, stemGroups, './src/assets/cover.png', fxSettings, {
494
+ enabled: config.source === 'record' || config.recordBugMode !== 'off',
495
+ eqMode: config.eqMode,
496
+ profile: config.recordProfile,
497
+ family: config.recordFamily,
498
+ bugMode: config.recordBugMode,
499
+ intensity: config.recordIntensity,
500
+ chaos: config.recordChaos,
501
+ mix: config.recordMix,
502
+ scratch: config.recordScratch,
503
+ wavy: config.recordWavy,
504
+ });
505
+ }
506
+ console.log(color('Finished.', `${c.bold}${palette.primary}`));
507
+ }
508
+ main().catch((err) => {
509
+ console.error(err);
510
+ process.exit(1);
511
+ });