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