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.
- package/LICENSE +22 -0
- package/README.md +272 -0
- package/package.json +30 -0
- package/src/arrangement.js +282 -0
- package/src/arrangement.ts +317 -0
- package/src/assets/cover.png +0 -0
- package/src/cli.js +1398 -0
- package/src/cli.ts +1709 -0
- package/src/exportAudio.js +459 -0
- package/src/exportAudio.ts +499 -0
- package/src/exportMidi.js +85 -0
- package/src/exportMidi.ts +103 -0
- package/src/ffmpeg-static.d.ts +5 -0
- package/src/fx.js +28 -0
- package/src/fx.ts +49 -0
- package/src/generator.js +15 -0
- package/src/generator.ts +29 -0
- package/src/index.js +511 -0
- package/src/index.ts +642 -0
- package/src/instrument.js +35 -0
- package/src/instrument.ts +51 -0
- package/src/midi.js +167 -0
- package/src/midi.ts +218 -0
- package/src/openExport.js +22 -0
- package/src/openExport.ts +24 -0
- package/src/prompt.js +22 -0
- package/src/prompt.ts +25 -0
- package/src/providers/auth.js +23 -0
- package/src/providers/auth.ts +30 -0
- package/src/providers/claudeProvider.js +46 -0
- package/src/providers/claudeProvider.ts +50 -0
- package/src/providers/factory.js +39 -0
- package/src/providers/factory.ts +43 -0
- package/src/providers/geminiProvider.js +55 -0
- package/src/providers/geminiProvider.ts +71 -0
- package/src/providers/grokProvider.js +57 -0
- package/src/providers/grokProvider.ts +69 -0
- package/src/providers/groqProvider.js +57 -0
- package/src/providers/groqProvider.ts +69 -0
- package/src/providers/mockProvider.js +13 -0
- package/src/providers/mockProvider.ts +15 -0
- package/src/providers/openaiProvider.js +45 -0
- package/src/providers/openaiProvider.ts +49 -0
- package/src/providers/retry.js +46 -0
- package/src/providers/retry.ts +54 -0
- package/src/types.js +1 -0
- package/src/types.ts +17 -0
- package/src/validate.js +10 -0
- package/src/validate.ts +13 -0
- 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
|
+
}
|