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
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { GeneratedNote } from './types';
|
|
2
|
+
|
|
3
|
+
export type InstrumentName = 'lead' | 'bass' | 'pad' | 'keys' | 'drums';
|
|
4
|
+
|
|
5
|
+
export type InstrumentProfile = {
|
|
6
|
+
name: InstrumentName;
|
|
7
|
+
label: string;
|
|
8
|
+
midiChannel: number; // 0-15
|
|
9
|
+
program: number; // 0-127
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const DRUM_NOTES = [36, 38, 42, 46, 41, 43, 49, 51];
|
|
13
|
+
|
|
14
|
+
function clamp(n: number, min: number, max: number): number {
|
|
15
|
+
return Math.max(min, Math.min(max, n));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getInstrumentProfile(name: InstrumentName): InstrumentProfile {
|
|
19
|
+
if (name === 'bass') return { name, label: 'Bass', midiChannel: 0, program: 38 };
|
|
20
|
+
if (name === 'pad') return { name, label: 'Pad', midiChannel: 0, program: 88 };
|
|
21
|
+
if (name === 'keys') return { name, label: 'Keys', midiChannel: 0, program: 4 };
|
|
22
|
+
if (name === 'drums') return { name, label: 'Drums', midiChannel: 9, program: 0 };
|
|
23
|
+
return { name: 'lead', label: 'Lead', midiChannel: 0, program: 80 };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function applyInstrumentProfile(
|
|
27
|
+
sequence: GeneratedNote[],
|
|
28
|
+
instrument: InstrumentName,
|
|
29
|
+
): GeneratedNote[] {
|
|
30
|
+
if (instrument === 'drums') {
|
|
31
|
+
return sequence.map((note) => {
|
|
32
|
+
const idx = Math.abs(Math.round(note.pitch)) % DRUM_NOTES.length;
|
|
33
|
+
return {
|
|
34
|
+
pitch: DRUM_NOTES[idx],
|
|
35
|
+
velocity: clamp(note.velocity, 1, 127),
|
|
36
|
+
durationMs: clamp(note.durationMs, 40, 400),
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const shift = instrument === 'bass' ? -24 : instrument === 'pad' ? -12 : instrument === 'keys' ? 0 : 12;
|
|
42
|
+
const min = instrument === 'bass' ? 28 : instrument === 'pad' ? 36 : instrument === 'keys' ? 48 : 60;
|
|
43
|
+
const max = instrument === 'bass' ? 60 : instrument === 'pad' ? 84 : instrument === 'keys' ? 92 : 108;
|
|
44
|
+
|
|
45
|
+
return sequence.map((note) => ({
|
|
46
|
+
pitch: clamp(note.pitch + shift, min, max),
|
|
47
|
+
velocity: clamp(note.velocity, 1, 127),
|
|
48
|
+
durationMs: clamp(note.durationMs, 40, 4000),
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
|
package/src/midi.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import readline from 'node:readline';
|
|
2
|
+
import { createInterface } from 'node:readline/promises';
|
|
3
|
+
const SCALE_OFFSETS = [0, 2, 4, 5, 7, 9, 11, 12];
|
|
4
|
+
const SHIFT_MAP = {
|
|
5
|
+
'!': 1,
|
|
6
|
+
'@': 2,
|
|
7
|
+
'#': 3,
|
|
8
|
+
'$': 4,
|
|
9
|
+
'%': 5,
|
|
10
|
+
'^': 6,
|
|
11
|
+
'&': 7,
|
|
12
|
+
'*': 8,
|
|
13
|
+
};
|
|
14
|
+
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
|
15
|
+
const c = {
|
|
16
|
+
reset: '\x1b[0m',
|
|
17
|
+
bold: '\x1b[1m',
|
|
18
|
+
};
|
|
19
|
+
function fgHex(hex) {
|
|
20
|
+
const clean = hex.replace('#', '');
|
|
21
|
+
const r = Number.parseInt(clean.slice(0, 2), 16);
|
|
22
|
+
const g = Number.parseInt(clean.slice(2, 4), 16);
|
|
23
|
+
const b = Number.parseInt(clean.slice(4, 6), 16);
|
|
24
|
+
return `\x1b[38;2;${r};${g};${b}m`;
|
|
25
|
+
}
|
|
26
|
+
const palette = {
|
|
27
|
+
primary: fgHex('#d199ff'),
|
|
28
|
+
soft: fgHex('#ecebff'),
|
|
29
|
+
};
|
|
30
|
+
function color(text, code) {
|
|
31
|
+
return `${code}${text}${c.reset}`;
|
|
32
|
+
}
|
|
33
|
+
function pitchToName(pitch) {
|
|
34
|
+
const octave = Math.floor(pitch / 12) - 1;
|
|
35
|
+
return `${NOTE_NAMES[pitch % 12]}${octave}`;
|
|
36
|
+
}
|
|
37
|
+
function numberToPitch(numberKey, octave, sharp) {
|
|
38
|
+
const base = 12 * (octave + 1) + SCALE_OFFSETS[numberKey - 1];
|
|
39
|
+
return Math.max(0, Math.min(127, base + (sharp ? 1 : 0)));
|
|
40
|
+
}
|
|
41
|
+
async function lineFallbackSeed(baseOctave) {
|
|
42
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
43
|
+
try {
|
|
44
|
+
const raw = await rl.question('Seed key fallback (1-8, or !@#$%^&* for sharps, +/- octave not supported here): ');
|
|
45
|
+
const str = raw.trim();
|
|
46
|
+
const fromShiftSymbol = SHIFT_MAP[str];
|
|
47
|
+
const fromDigit = /^[1-8]$/.test(str) ? Number.parseInt(str, 10) : NaN;
|
|
48
|
+
const degree = Number.isFinite(fromShiftSymbol)
|
|
49
|
+
? fromShiftSymbol
|
|
50
|
+
: Number.isFinite(fromDigit)
|
|
51
|
+
? fromDigit
|
|
52
|
+
: 1;
|
|
53
|
+
const sharp = Boolean(fromShiftSymbol);
|
|
54
|
+
const pitch = numberToPitch(degree, baseOctave, sharp);
|
|
55
|
+
console.log(`Selected seed: ${pitchToName(pitch)} (${pitch})`);
|
|
56
|
+
return { pitch, velocity: 100 };
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
rl.close();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// Test-mode keyboard input:
|
|
63
|
+
// 1-8 = scale degrees, Shift+1-8 = sharps, +/- changes octave.
|
|
64
|
+
export async function waitForSeedNote(options = {}) {
|
|
65
|
+
let octave = options.baseOctave ?? 4;
|
|
66
|
+
console.log(`\n${color('Keyboard seed mode', `${c.bold}${palette.primary}`)}`);
|
|
67
|
+
console.log(color('Press 1-8 to choose note, Shift+1-8 for sharp, +/- for octave, q to cancel.', palette.soft));
|
|
68
|
+
console.log(color('Click this terminal window first if key presses are not detected.', `${c.bold}${palette.primary}`));
|
|
69
|
+
console.log(color(`Current octave: ${octave}`, palette.soft));
|
|
70
|
+
readline.emitKeypressEvents(process.stdin);
|
|
71
|
+
if (!process.stdin.isTTY) {
|
|
72
|
+
return lineFallbackSeed(octave);
|
|
73
|
+
}
|
|
74
|
+
process.stdin.setRawMode(true);
|
|
75
|
+
process.stdin.resume();
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
const cleanup = () => {
|
|
78
|
+
process.stdin.off('keypress', onKeypress);
|
|
79
|
+
process.stdin.setRawMode(false);
|
|
80
|
+
process.stdin.pause();
|
|
81
|
+
};
|
|
82
|
+
const onKeypress = (str, key) => {
|
|
83
|
+
if (key.ctrl && key.name === 'c') {
|
|
84
|
+
cleanup();
|
|
85
|
+
reject(new Error('Seed note selection cancelled.'));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (str === '+' || str === '=') {
|
|
89
|
+
octave = Math.min(8, octave + 1);
|
|
90
|
+
console.log(color(`Octave: ${octave}`, palette.soft));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (str === '-') {
|
|
94
|
+
octave = Math.max(0, octave - 1);
|
|
95
|
+
console.log(color(`Octave: ${octave}`, palette.soft));
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (key.name === 'q' || key.name === 'escape') {
|
|
99
|
+
cleanup();
|
|
100
|
+
reject(new Error('Seed note selection cancelled.'));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const fromShiftSymbol = SHIFT_MAP[str];
|
|
104
|
+
const fromKeyName = key.name && /^[1-8]$/.test(key.name) ? Number.parseInt(key.name, 10) : NaN;
|
|
105
|
+
const degree = Number.isFinite(fromShiftSymbol)
|
|
106
|
+
? fromShiftSymbol
|
|
107
|
+
: Number.isFinite(fromKeyName)
|
|
108
|
+
? fromKeyName
|
|
109
|
+
: NaN;
|
|
110
|
+
if (!Number.isFinite(degree))
|
|
111
|
+
return;
|
|
112
|
+
const sharp = Boolean(fromShiftSymbol) || Boolean(key.shift);
|
|
113
|
+
const pitch = numberToPitch(degree, octave, sharp);
|
|
114
|
+
const noteName = pitchToName(pitch);
|
|
115
|
+
console.log(color(`Selected seed: ${noteName} (${pitch})`, palette.primary));
|
|
116
|
+
cleanup();
|
|
117
|
+
resolve({ pitch, velocity: 100 });
|
|
118
|
+
};
|
|
119
|
+
process.stdin.on('keypress', onKeypress);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
// Replace with real MIDI output to your selected synth/device.
|
|
123
|
+
export async function playSequenceToOutput(sequence, options = {}) {
|
|
124
|
+
const events = sequence[0]?.startMs != null
|
|
125
|
+
? sequence
|
|
126
|
+
: (() => {
|
|
127
|
+
const out = [];
|
|
128
|
+
let t = 0;
|
|
129
|
+
for (const n of sequence) {
|
|
130
|
+
out.push({ pitch: n.pitch, velocity: n.velocity, durationMs: n.durationMs, startMs: t, channel: 0 });
|
|
131
|
+
t += n.durationMs;
|
|
132
|
+
}
|
|
133
|
+
return out;
|
|
134
|
+
})();
|
|
135
|
+
events.sort((a, b) => a.startMs - b.startMs);
|
|
136
|
+
if (options.instrument) {
|
|
137
|
+
console.log(color(`MIDI PROGRAM_CHANGE ch=${options.instrument.midiChannel + 1} program=${options.instrument.program} (${options.instrument.label})`, `${c.bold}${palette.primary}`));
|
|
138
|
+
}
|
|
139
|
+
const beatMs = 60000 / Math.max(1, options.bpm ?? 120);
|
|
140
|
+
if (options.metronome === 'count-in' || options.metronome === 'always') {
|
|
141
|
+
for (let i = 0; i < 4; i++) {
|
|
142
|
+
console.log(color(`METRONOME ${i + 1}`, palette.soft));
|
|
143
|
+
if (options.beep)
|
|
144
|
+
process.stdout.write('\x07');
|
|
145
|
+
// eslint-disable-next-line no-await-in-loop
|
|
146
|
+
await new Promise((r) => setTimeout(r, beatMs));
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
let prevStart = 0;
|
|
150
|
+
for (const note of events) {
|
|
151
|
+
const wait = Math.max(0, note.startMs - prevStart);
|
|
152
|
+
if (wait > 0) {
|
|
153
|
+
// eslint-disable-next-line no-await-in-loop
|
|
154
|
+
await new Promise((r) => setTimeout(r, wait));
|
|
155
|
+
}
|
|
156
|
+
console.log(color(`MIDI OUT NOTE_ON ${note.pitch} ${note.velocity}`, palette.primary));
|
|
157
|
+
if (options.beep)
|
|
158
|
+
process.stdout.write('\x07');
|
|
159
|
+
if (options.metronome === 'always' && note.startMs % Math.max(1, Math.round(beatMs)) < 24) {
|
|
160
|
+
console.log(color('METRONOME', palette.soft));
|
|
161
|
+
}
|
|
162
|
+
// eslint-disable-next-line no-await-in-loop
|
|
163
|
+
await new Promise((r) => setTimeout(r, note.durationMs));
|
|
164
|
+
console.log(color(`MIDI OUT NOTE_OFF ${note.pitch}`, palette.primary));
|
|
165
|
+
prevStart = note.startMs;
|
|
166
|
+
}
|
|
167
|
+
}
|
package/src/midi.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import readline from 'node:readline';
|
|
2
|
+
import { createInterface } from 'node:readline/promises';
|
|
3
|
+
import type { MetronomeMode, NoteEvent } from './arrangement';
|
|
4
|
+
import type { InstrumentProfile } from './instrument';
|
|
5
|
+
import type { GeneratedNote } from './types';
|
|
6
|
+
|
|
7
|
+
export type MidiNoteOn = {
|
|
8
|
+
pitch: number;
|
|
9
|
+
velocity: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type SeedOptions = {
|
|
13
|
+
baseOctave?: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type PlaybackOptions = {
|
|
17
|
+
beep?: boolean;
|
|
18
|
+
instrument?: InstrumentProfile;
|
|
19
|
+
metronome?: MetronomeMode;
|
|
20
|
+
bpm?: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const SCALE_OFFSETS = [0, 2, 4, 5, 7, 9, 11, 12];
|
|
24
|
+
const SHIFT_MAP: Record<string, number> = {
|
|
25
|
+
'!': 1,
|
|
26
|
+
'@': 2,
|
|
27
|
+
'#': 3,
|
|
28
|
+
'$': 4,
|
|
29
|
+
'%': 5,
|
|
30
|
+
'^': 6,
|
|
31
|
+
'&': 7,
|
|
32
|
+
'*': 8,
|
|
33
|
+
};
|
|
34
|
+
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
|
35
|
+
|
|
36
|
+
const c = {
|
|
37
|
+
reset: '\x1b[0m',
|
|
38
|
+
bold: '\x1b[1m',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function fgHex(hex: string): string {
|
|
42
|
+
const clean = hex.replace('#', '');
|
|
43
|
+
const r = Number.parseInt(clean.slice(0, 2), 16);
|
|
44
|
+
const g = Number.parseInt(clean.slice(2, 4), 16);
|
|
45
|
+
const b = Number.parseInt(clean.slice(4, 6), 16);
|
|
46
|
+
return `\x1b[38;2;${r};${g};${b}m`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const palette = {
|
|
50
|
+
primary: fgHex('#d199ff'),
|
|
51
|
+
soft: fgHex('#ecebff'),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function color(text: string, code: string): string {
|
|
55
|
+
return `${code}${text}${c.reset}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function pitchToName(pitch: number): string {
|
|
59
|
+
const octave = Math.floor(pitch / 12) - 1;
|
|
60
|
+
return `${NOTE_NAMES[pitch % 12]}${octave}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function numberToPitch(numberKey: number, octave: number, sharp: boolean): number {
|
|
64
|
+
const base = 12 * (octave + 1) + SCALE_OFFSETS[numberKey - 1];
|
|
65
|
+
return Math.max(0, Math.min(127, base + (sharp ? 1 : 0)));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function lineFallbackSeed(baseOctave: number): Promise<MidiNoteOn> {
|
|
69
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
70
|
+
try {
|
|
71
|
+
const raw = await rl.question(
|
|
72
|
+
'Seed key fallback (1-8, or !@#$%^&* for sharps, +/- octave not supported here): ',
|
|
73
|
+
);
|
|
74
|
+
const str = raw.trim();
|
|
75
|
+
const fromShiftSymbol = SHIFT_MAP[str];
|
|
76
|
+
const fromDigit = /^[1-8]$/.test(str) ? Number.parseInt(str, 10) : NaN;
|
|
77
|
+
const degree = Number.isFinite(fromShiftSymbol)
|
|
78
|
+
? fromShiftSymbol
|
|
79
|
+
: Number.isFinite(fromDigit)
|
|
80
|
+
? fromDigit
|
|
81
|
+
: 1;
|
|
82
|
+
const sharp = Boolean(fromShiftSymbol);
|
|
83
|
+
const pitch = numberToPitch(degree, baseOctave, sharp);
|
|
84
|
+
console.log(`Selected seed: ${pitchToName(pitch)} (${pitch})`);
|
|
85
|
+
return { pitch, velocity: 100 };
|
|
86
|
+
} finally {
|
|
87
|
+
rl.close();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Test-mode keyboard input:
|
|
92
|
+
// 1-8 = scale degrees, Shift+1-8 = sharps, +/- changes octave.
|
|
93
|
+
export async function waitForSeedNote(options: SeedOptions = {}): Promise<MidiNoteOn> {
|
|
94
|
+
let octave = options.baseOctave ?? 4;
|
|
95
|
+
|
|
96
|
+
console.log(`\n${color('Keyboard seed mode', `${c.bold}${palette.primary}`)}`);
|
|
97
|
+
console.log(color('Press 1-8 to choose note, Shift+1-8 for sharp, +/- for octave, q to cancel.', palette.soft));
|
|
98
|
+
console.log(color('Click this terminal window first if key presses are not detected.', `${c.bold}${palette.primary}`));
|
|
99
|
+
console.log(color(`Current octave: ${octave}`, palette.soft));
|
|
100
|
+
|
|
101
|
+
readline.emitKeypressEvents(process.stdin);
|
|
102
|
+
if (!process.stdin.isTTY) {
|
|
103
|
+
return lineFallbackSeed(octave);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
process.stdin.setRawMode(true);
|
|
107
|
+
process.stdin.resume();
|
|
108
|
+
|
|
109
|
+
return new Promise<MidiNoteOn>((resolve, reject) => {
|
|
110
|
+
const cleanup = () => {
|
|
111
|
+
process.stdin.off('keypress', onKeypress);
|
|
112
|
+
process.stdin.setRawMode(false);
|
|
113
|
+
process.stdin.pause();
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const onKeypress = (str: string, key: { name?: string; shift?: boolean; ctrl?: boolean }) => {
|
|
117
|
+
if (key.ctrl && key.name === 'c') {
|
|
118
|
+
cleanup();
|
|
119
|
+
reject(new Error('Seed note selection cancelled.'));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (str === '+' || str === '=') {
|
|
124
|
+
octave = Math.min(8, octave + 1);
|
|
125
|
+
console.log(color(`Octave: ${octave}`, palette.soft));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (str === '-') {
|
|
130
|
+
octave = Math.max(0, octave - 1);
|
|
131
|
+
console.log(color(`Octave: ${octave}`, palette.soft));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (key.name === 'q' || key.name === 'escape') {
|
|
136
|
+
cleanup();
|
|
137
|
+
reject(new Error('Seed note selection cancelled.'));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const fromShiftSymbol = SHIFT_MAP[str];
|
|
142
|
+
const fromKeyName = key.name && /^[1-8]$/.test(key.name) ? Number.parseInt(key.name, 10) : NaN;
|
|
143
|
+
const degree = Number.isFinite(fromShiftSymbol)
|
|
144
|
+
? fromShiftSymbol
|
|
145
|
+
: Number.isFinite(fromKeyName)
|
|
146
|
+
? fromKeyName
|
|
147
|
+
: NaN;
|
|
148
|
+
|
|
149
|
+
if (!Number.isFinite(degree)) return;
|
|
150
|
+
|
|
151
|
+
const sharp = Boolean(fromShiftSymbol) || Boolean(key.shift);
|
|
152
|
+
const pitch = numberToPitch(degree, octave, sharp);
|
|
153
|
+
const noteName = pitchToName(pitch);
|
|
154
|
+
console.log(color(`Selected seed: ${noteName} (${pitch})`, palette.primary));
|
|
155
|
+
cleanup();
|
|
156
|
+
resolve({ pitch, velocity: 100 });
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
process.stdin.on('keypress', onKeypress);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Replace with real MIDI output to your selected synth/device.
|
|
164
|
+
export async function playSequenceToOutput(
|
|
165
|
+
sequence: GeneratedNote[] | NoteEvent[],
|
|
166
|
+
options: PlaybackOptions = {},
|
|
167
|
+
): Promise<void> {
|
|
168
|
+
const events: NoteEvent[] = (sequence as NoteEvent[])[0]?.startMs != null
|
|
169
|
+
? (sequence as NoteEvent[])
|
|
170
|
+
: (() => {
|
|
171
|
+
const out: NoteEvent[] = [];
|
|
172
|
+
let t = 0;
|
|
173
|
+
for (const n of sequence as GeneratedNote[]) {
|
|
174
|
+
out.push({ pitch: n.pitch, velocity: n.velocity, durationMs: n.durationMs, startMs: t, channel: 0 });
|
|
175
|
+
t += n.durationMs;
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
})();
|
|
179
|
+
|
|
180
|
+
events.sort((a, b) => a.startMs - b.startMs);
|
|
181
|
+
|
|
182
|
+
if (options.instrument) {
|
|
183
|
+
console.log(
|
|
184
|
+
color(
|
|
185
|
+
`MIDI PROGRAM_CHANGE ch=${options.instrument.midiChannel + 1} program=${options.instrument.program} (${options.instrument.label})`,
|
|
186
|
+
`${c.bold}${palette.primary}`,
|
|
187
|
+
),
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const beatMs = 60000 / Math.max(1, options.bpm ?? 120);
|
|
192
|
+
if (options.metronome === 'count-in' || options.metronome === 'always') {
|
|
193
|
+
for (let i = 0; i < 4; i++) {
|
|
194
|
+
console.log(color(`METRONOME ${i + 1}`, palette.soft));
|
|
195
|
+
if (options.beep) process.stdout.write('\x07');
|
|
196
|
+
// eslint-disable-next-line no-await-in-loop
|
|
197
|
+
await new Promise((r) => setTimeout(r, beatMs));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let prevStart = 0;
|
|
202
|
+
for (const note of events) {
|
|
203
|
+
const wait = Math.max(0, note.startMs - prevStart);
|
|
204
|
+
if (wait > 0) {
|
|
205
|
+
// eslint-disable-next-line no-await-in-loop
|
|
206
|
+
await new Promise((r) => setTimeout(r, wait));
|
|
207
|
+
}
|
|
208
|
+
console.log(color(`MIDI OUT NOTE_ON ${note.pitch} ${note.velocity}`, palette.primary));
|
|
209
|
+
if (options.beep) process.stdout.write('\x07');
|
|
210
|
+
if (options.metronome === 'always' && note.startMs % Math.max(1, Math.round(beatMs)) < 24) {
|
|
211
|
+
console.log(color('METRONOME', palette.soft));
|
|
212
|
+
}
|
|
213
|
+
// eslint-disable-next-line no-await-in-loop
|
|
214
|
+
await new Promise((r) => setTimeout(r, note.durationMs));
|
|
215
|
+
console.log(color(`MIDI OUT NOTE_OFF ${note.pitch}`, palette.primary));
|
|
216
|
+
prevStart = note.startMs;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
function runOpen(args) {
|
|
3
|
+
return new Promise((resolve, reject) => {
|
|
4
|
+
const child = spawn('open', args, { stdio: 'ignore' });
|
|
5
|
+
child.on('error', reject);
|
|
6
|
+
child.on('close', (code) => {
|
|
7
|
+
if (code === 0)
|
|
8
|
+
resolve();
|
|
9
|
+
else
|
|
10
|
+
reject(new Error(`open exited with code ${code ?? -1}`));
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
export async function openExportTarget(path, target) {
|
|
15
|
+
if (target === 'none')
|
|
16
|
+
return;
|
|
17
|
+
if (target === 'finder') {
|
|
18
|
+
await runOpen(['-R', path]);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
await runOpen(['-a', 'GarageBand', path]);
|
|
22
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
export type OpenAfterExport = 'none' | 'finder' | 'garageband';
|
|
4
|
+
|
|
5
|
+
function runOpen(args: string[]): Promise<void> {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
const child = spawn('open', args, { stdio: 'ignore' });
|
|
8
|
+
child.on('error', reject);
|
|
9
|
+
child.on('close', (code) => {
|
|
10
|
+
if (code === 0) resolve();
|
|
11
|
+
else reject(new Error(`open exited with code ${code ?? -1}`));
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function openExportTarget(path: string, target: OpenAfterExport): Promise<void> {
|
|
17
|
+
if (target === 'none') return;
|
|
18
|
+
if (target === 'finder') {
|
|
19
|
+
await runOpen(['-R', path]);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
await runOpen(['-a', 'GarageBand', path]);
|
|
23
|
+
}
|
|
24
|
+
|
package/src/prompt.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function systemPrompt() {
|
|
2
|
+
return [
|
|
3
|
+
'You are a melody continuation engine for MIDI.',
|
|
4
|
+
'Return JSON only, no markdown, no prose.',
|
|
5
|
+
'JSON shape: {"pitch":number,"velocity":number,"durationMs":number}.',
|
|
6
|
+
'pitch must be integer 0..127, velocity integer 1..127, durationMs integer >= 1.',
|
|
7
|
+
].join(' ');
|
|
8
|
+
}
|
|
9
|
+
export function userPrompt(input) {
|
|
10
|
+
return JSON.stringify({
|
|
11
|
+
instruction: 'Generate exactly one next MIDI note that musically continues the melody.',
|
|
12
|
+
theme: input.theme,
|
|
13
|
+
targetLength: input.targetLength,
|
|
14
|
+
bpm: input.bpm,
|
|
15
|
+
seedPitch: input.seedPitch,
|
|
16
|
+
history: input.history,
|
|
17
|
+
constraints: {
|
|
18
|
+
maxJumpSemitones: 7,
|
|
19
|
+
preferredPitchRange: [48, 84],
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
}
|
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { NextNoteRequest } from './types';
|
|
2
|
+
|
|
3
|
+
export function systemPrompt(): string {
|
|
4
|
+
return [
|
|
5
|
+
'You are a melody continuation engine for MIDI.',
|
|
6
|
+
'Return JSON only, no markdown, no prose.',
|
|
7
|
+
'JSON shape: {"pitch":number,"velocity":number,"durationMs":number}.',
|
|
8
|
+
'pitch must be integer 0..127, velocity integer 1..127, durationMs integer >= 1.',
|
|
9
|
+
].join(' ');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function userPrompt(input: NextNoteRequest): string {
|
|
13
|
+
return JSON.stringify({
|
|
14
|
+
instruction: 'Generate exactly one next MIDI note that musically continues the melody.',
|
|
15
|
+
theme: input.theme,
|
|
16
|
+
targetLength: input.targetLength,
|
|
17
|
+
bpm: input.bpm,
|
|
18
|
+
seedPitch: input.seedPitch,
|
|
19
|
+
history: input.history,
|
|
20
|
+
constraints: {
|
|
21
|
+
maxJumpSemitones: 7,
|
|
22
|
+
preferredPitchRange: [48, 84],
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export function normalizeApiKey(raw) {
|
|
2
|
+
let key = raw.trim();
|
|
3
|
+
if (key.toLowerCase().startsWith('bearer ')) {
|
|
4
|
+
key = key.slice(7).trim();
|
|
5
|
+
}
|
|
6
|
+
return key;
|
|
7
|
+
}
|
|
8
|
+
export function assertHeaderSafeApiKey(provider, raw) {
|
|
9
|
+
const key = normalizeApiKey(raw);
|
|
10
|
+
if (!key) {
|
|
11
|
+
throw new Error(`${provider} API key is empty.`);
|
|
12
|
+
}
|
|
13
|
+
// HTTP header values must be ByteString-safe; reject non-Latin-1 characters.
|
|
14
|
+
const badIndex = [...key].findIndex((ch) => ch.codePointAt(0) > 255);
|
|
15
|
+
if (badIndex >= 0) {
|
|
16
|
+
const bad = [...key][badIndex];
|
|
17
|
+
throw new Error(`${provider} API key contains invalid character "${bad}". Re-enter key without extra symbols.`);
|
|
18
|
+
}
|
|
19
|
+
if (/\s/.test(key)) {
|
|
20
|
+
throw new Error(`${provider} API key contains whitespace. Re-enter key.`);
|
|
21
|
+
}
|
|
22
|
+
return key;
|
|
23
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export function normalizeApiKey(raw: string): string {
|
|
2
|
+
let key = raw.trim();
|
|
3
|
+
if (key.toLowerCase().startsWith('bearer ')) {
|
|
4
|
+
key = key.slice(7).trim();
|
|
5
|
+
}
|
|
6
|
+
return key;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function assertHeaderSafeApiKey(provider: string, raw: string): string {
|
|
10
|
+
const key = normalizeApiKey(raw);
|
|
11
|
+
if (!key) {
|
|
12
|
+
throw new Error(`${provider} API key is empty.`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// HTTP header values must be ByteString-safe; reject non-Latin-1 characters.
|
|
16
|
+
const badIndex = [...key].findIndex((ch) => ch.codePointAt(0)! > 255);
|
|
17
|
+
if (badIndex >= 0) {
|
|
18
|
+
const bad = [...key][badIndex];
|
|
19
|
+
throw new Error(
|
|
20
|
+
`${provider} API key contains invalid character "${bad}". Re-enter key without extra symbols.`,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (/\s/.test(key)) {
|
|
25
|
+
throw new Error(`${provider} API key contains whitespace. Re-enter key.`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return key;
|
|
29
|
+
}
|
|
30
|
+
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { systemPrompt, userPrompt } from '../prompt';
|
|
2
|
+
import { computeRetryDelayMs, shouldRetryStatus, waitForRetry } from './retry';
|
|
3
|
+
export class ClaudeProvider {
|
|
4
|
+
apiKey;
|
|
5
|
+
model;
|
|
6
|
+
constructor(apiKey, model = 'claude-3-5-sonnet-latest') {
|
|
7
|
+
this.apiKey = apiKey;
|
|
8
|
+
this.model = model;
|
|
9
|
+
}
|
|
10
|
+
async nextNote(input) {
|
|
11
|
+
const maxRetries = 4;
|
|
12
|
+
for (let attempt = 0;; attempt++) {
|
|
13
|
+
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
14
|
+
method: 'POST',
|
|
15
|
+
headers: {
|
|
16
|
+
'x-api-key': this.apiKey,
|
|
17
|
+
'anthropic-version': '2023-06-01',
|
|
18
|
+
'content-type': 'application/json',
|
|
19
|
+
},
|
|
20
|
+
body: JSON.stringify({
|
|
21
|
+
model: this.model,
|
|
22
|
+
max_tokens: 120,
|
|
23
|
+
system: systemPrompt(),
|
|
24
|
+
messages: [
|
|
25
|
+
{ role: 'user', content: userPrompt(input) },
|
|
26
|
+
],
|
|
27
|
+
}),
|
|
28
|
+
});
|
|
29
|
+
if (!res.ok) {
|
|
30
|
+
const text = await res.text();
|
|
31
|
+
if (attempt < maxRetries && shouldRetryStatus(res.status)) {
|
|
32
|
+
const delayMs = computeRetryDelayMs(res.headers.get('retry-after'), text, attempt);
|
|
33
|
+
await waitForRetry(delayMs);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
throw new Error(`Claude request failed: ${res.status} ${text}`);
|
|
37
|
+
}
|
|
38
|
+
const json = await res.json();
|
|
39
|
+
const text = json.content?.find((item) => item.type === 'text')?.text;
|
|
40
|
+
if (!text) {
|
|
41
|
+
throw new Error('Claude response missing text content');
|
|
42
|
+
}
|
|
43
|
+
return JSON.parse(text);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { GeneratedNote, LLMProvider, NextNoteRequest } from '../types';
|
|
2
|
+
import { systemPrompt, userPrompt } from '../prompt';
|
|
3
|
+
import { computeRetryDelayMs, shouldRetryStatus, waitForRetry } from './retry';
|
|
4
|
+
|
|
5
|
+
export class ClaudeProvider implements LLMProvider {
|
|
6
|
+
constructor(
|
|
7
|
+
private readonly apiKey: string,
|
|
8
|
+
private readonly model = 'claude-3-5-sonnet-latest',
|
|
9
|
+
) {}
|
|
10
|
+
|
|
11
|
+
async nextNote(input: NextNoteRequest): Promise<GeneratedNote> {
|
|
12
|
+
const maxRetries = 4;
|
|
13
|
+
for (let attempt = 0; ; attempt++) {
|
|
14
|
+
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
|
15
|
+
method: 'POST',
|
|
16
|
+
headers: {
|
|
17
|
+
'x-api-key': this.apiKey,
|
|
18
|
+
'anthropic-version': '2023-06-01',
|
|
19
|
+
'content-type': 'application/json',
|
|
20
|
+
},
|
|
21
|
+
body: JSON.stringify({
|
|
22
|
+
model: this.model,
|
|
23
|
+
max_tokens: 120,
|
|
24
|
+
system: systemPrompt(),
|
|
25
|
+
messages: [
|
|
26
|
+
{ role: 'user', content: userPrompt(input) },
|
|
27
|
+
],
|
|
28
|
+
}),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (!res.ok) {
|
|
32
|
+
const text = await res.text();
|
|
33
|
+
if (attempt < maxRetries && shouldRetryStatus(res.status)) {
|
|
34
|
+
const delayMs = computeRetryDelayMs(res.headers.get('retry-after'), text, attempt);
|
|
35
|
+
await waitForRetry(delayMs);
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
throw new Error(`Claude request failed: ${res.status} ${text}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const json = await res.json() as { content?: Array<{ type?: string; text?: string }> };
|
|
42
|
+
const text = json.content?.find((item) => item.type === 'text')?.text;
|
|
43
|
+
if (!text) {
|
|
44
|
+
throw new Error('Claude response missing text content');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return JSON.parse(text) as GeneratedNote;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|