opennote-cli 1.3.0 → 1.4.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 (93) hide show
  1. package/dist/arrangement.d.ts +43 -0
  2. package/{src → dist}/arrangement.js +1 -0
  3. package/dist/arrangement.js.map +1 -0
  4. package/dist/cli.d.ts +46 -0
  5. package/{src → dist}/cli.js +1 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/exportAudio.d.ts +19 -0
  8. package/{src → dist}/exportAudio.js +1 -0
  9. package/dist/exportAudio.js.map +1 -0
  10. package/dist/exportMidi.d.ts +3 -0
  11. package/{src → dist}/exportMidi.js +1 -0
  12. package/dist/exportMidi.js.map +1 -0
  13. package/dist/fx.d.ts +16 -0
  14. package/{src → dist}/fx.js +1 -0
  15. package/dist/fx.js.map +1 -0
  16. package/dist/generator.d.ts +7 -0
  17. package/{src → dist}/generator.js +2 -1
  18. package/dist/generator.js.map +1 -0
  19. package/dist/index.d.ts +2 -0
  20. package/{src → dist}/index.js +12 -10
  21. package/dist/index.js.map +1 -0
  22. package/dist/instrument.d.ts +10 -0
  23. package/{src → dist}/instrument.js +1 -0
  24. package/dist/instrument.js.map +1 -0
  25. package/dist/midi.d.ts +19 -0
  26. package/{src → dist}/midi.js +1 -0
  27. package/dist/midi.js.map +1 -0
  28. package/dist/openExport.d.ts +2 -0
  29. package/{src → dist}/openExport.js +1 -0
  30. package/dist/openExport.js.map +1 -0
  31. package/dist/prompt.d.ts +3 -0
  32. package/{src → dist}/prompt.js +1 -0
  33. package/dist/prompt.js.map +1 -0
  34. package/dist/providers/auth.d.ts +2 -0
  35. package/{src → dist}/providers/auth.js +1 -0
  36. package/dist/providers/auth.js.map +1 -0
  37. package/dist/providers/claudeProvider.d.ts +7 -0
  38. package/{src → dist}/providers/claudeProvider.js +3 -2
  39. package/dist/providers/claudeProvider.js.map +1 -0
  40. package/dist/providers/factory.d.ts +3 -0
  41. package/{src → dist}/providers/factory.js +7 -6
  42. package/dist/providers/factory.js.map +1 -0
  43. package/dist/providers/geminiProvider.d.ts +7 -0
  44. package/{src → dist}/providers/geminiProvider.js +4 -3
  45. package/dist/providers/geminiProvider.js.map +1 -0
  46. package/dist/providers/grokProvider.d.ts +7 -0
  47. package/{src → dist}/providers/grokProvider.js +4 -3
  48. package/dist/providers/grokProvider.js.map +1 -0
  49. package/dist/providers/groqProvider.d.ts +7 -0
  50. package/{src → dist}/providers/groqProvider.js +4 -3
  51. package/dist/providers/groqProvider.js.map +1 -0
  52. package/dist/providers/mockProvider.d.ts +4 -0
  53. package/{src → dist}/providers/mockProvider.js +1 -0
  54. package/dist/providers/mockProvider.js.map +1 -0
  55. package/dist/providers/openaiProvider.d.ts +7 -0
  56. package/{src → dist}/providers/openaiProvider.js +4 -3
  57. package/dist/providers/openaiProvider.js.map +1 -0
  58. package/dist/providers/retry.d.ts +3 -0
  59. package/{src → dist}/providers/retry.js +1 -0
  60. package/dist/providers/retry.js.map +1 -0
  61. package/dist/types.d.ts +15 -0
  62. package/dist/types.js +2 -0
  63. package/dist/types.js.map +1 -0
  64. package/dist/validate.d.ts +2 -0
  65. package/{src → dist}/validate.js +1 -0
  66. package/dist/validate.js.map +1 -0
  67. package/package.json +9 -5
  68. package/src/arrangement.ts +0 -317
  69. package/src/assets/cover.png +0 -0
  70. package/src/cli.ts +0 -1709
  71. package/src/exportAudio.ts +0 -499
  72. package/src/exportMidi.ts +0 -103
  73. package/src/ffmpeg-static.d.ts +0 -5
  74. package/src/fx.ts +0 -49
  75. package/src/generator.ts +0 -29
  76. package/src/index.ts +0 -642
  77. package/src/instrument.ts +0 -51
  78. package/src/midi.ts +0 -218
  79. package/src/openExport.ts +0 -24
  80. package/src/prompt.ts +0 -25
  81. package/src/providers/auth.ts +0 -30
  82. package/src/providers/claudeProvider.ts +0 -50
  83. package/src/providers/factory.ts +0 -43
  84. package/src/providers/geminiProvider.ts +0 -71
  85. package/src/providers/grokProvider.ts +0 -69
  86. package/src/providers/groqProvider.ts +0 -69
  87. package/src/providers/mockProvider.ts +0 -15
  88. package/src/providers/openaiProvider.ts +0 -49
  89. package/src/providers/retry.ts +0 -54
  90. package/src/types.js +0 -1
  91. package/src/types.ts +0 -17
  92. package/src/validate.ts +0 -13
  93. package/tsconfig.json +0 -13
package/src/midi.ts DELETED
@@ -1,218 +0,0 @@
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
- }
package/src/openExport.ts DELETED
@@ -1,24 +0,0 @@
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.ts DELETED
@@ -1,25 +0,0 @@
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
- }
@@ -1,30 +0,0 @@
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
-
@@ -1,50 +0,0 @@
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
- }
@@ -1,43 +0,0 @@
1
- import type { LLMProvider } from '../types';
2
- import { ClaudeProvider } from './claudeProvider';
3
- import { GeminiProvider } from './geminiProvider';
4
- import { GrokProvider } from './grokProvider';
5
- import { GroqProvider } from './groqProvider';
6
- import { MockProvider } from './mockProvider';
7
- import { OpenAIProvider } from './openaiProvider';
8
-
9
- export type ProviderName = 'mock' | 'openai' | 'gemini' | 'claude' | 'groq' | 'grok';
10
-
11
- export function buildProvider(name: ProviderName): LLMProvider {
12
- if (name === 'mock') {
13
- return new MockProvider();
14
- }
15
-
16
- if (name === 'openai') {
17
- const apiKey = process.env.OPENAI_API_KEY;
18
- if (!apiKey) throw new Error('Missing OPENAI_API_KEY');
19
- return new OpenAIProvider(apiKey, process.env.OPENAI_MODEL || 'gpt-4.1-mini');
20
- }
21
-
22
- if (name === 'gemini') {
23
- const apiKey = process.env.GEMINI_API_KEY;
24
- if (!apiKey) throw new Error('Missing GEMINI_API_KEY');
25
- return new GeminiProvider(apiKey, process.env.GEMINI_MODEL || 'gemini-2.0-flash');
26
- }
27
-
28
- if (name === 'groq') {
29
- const apiKey = process.env.GROQ_API_KEY;
30
- if (!apiKey) throw new Error('Missing GROQ_API_KEY');
31
- return new GroqProvider(apiKey, process.env.GROQ_MODEL || 'llama-3.3-70b-versatile');
32
- }
33
-
34
- if (name === 'grok') {
35
- const apiKey = process.env.XAI_API_KEY;
36
- if (!apiKey) throw new Error('Missing XAI_API_KEY');
37
- return new GrokProvider(apiKey, process.env.GROK_MODEL || 'grok-2-latest');
38
- }
39
-
40
- const apiKey = process.env.ANTHROPIC_API_KEY;
41
- if (!apiKey) throw new Error('Missing ANTHROPIC_API_KEY');
42
- return new ClaudeProvider(apiKey, process.env.CLAUDE_MODEL || 'claude-3-5-sonnet-latest');
43
- }
@@ -1,71 +0,0 @@
1
- import type { GeneratedNote, LLMProvider, NextNoteRequest } from '../types';
2
- import { assertHeaderSafeApiKey } from './auth';
3
- import { systemPrompt, userPrompt } from '../prompt';
4
- import { computeRetryDelayMs, shouldRetryStatus, waitForRetry } from './retry';
5
-
6
- type GeminiResponse = {
7
- candidates?: Array<{
8
- content?: {
9
- parts?: Array<{ text?: string }>;
10
- };
11
- }>;
12
- };
13
-
14
- function extractText(json: GeminiResponse): string {
15
- const parts = json.candidates?.[0]?.content?.parts ?? [];
16
- return parts
17
- .map((p) => (typeof p.text === 'string' ? p.text : ''))
18
- .join('')
19
- .trim();
20
- }
21
-
22
- export class GeminiProvider implements LLMProvider {
23
- constructor(
24
- private readonly apiKey: string,
25
- private readonly model = 'gemini-2.0-flash',
26
- ) {}
27
-
28
- async nextNote(input: NextNoteRequest): Promise<GeneratedNote> {
29
- const apiKey = assertHeaderSafeApiKey('Gemini', this.apiKey);
30
- const maxRetries = 4;
31
-
32
- for (let attempt = 0; ; attempt++) {
33
- const res = await fetch(
34
- `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(this.model)}:generateContent?key=${encodeURIComponent(apiKey)}`,
35
- {
36
- method: 'POST',
37
- headers: {
38
- 'Content-Type': 'application/json',
39
- },
40
- body: JSON.stringify({
41
- systemInstruction: {
42
- parts: [{ text: systemPrompt() }],
43
- },
44
- contents: [
45
- {
46
- role: 'user',
47
- parts: [{ text: userPrompt(input) }],
48
- },
49
- ],
50
- }),
51
- },
52
- );
53
-
54
- if (!res.ok) {
55
- const text = await res.text();
56
- if (attempt < maxRetries && shouldRetryStatus(res.status)) {
57
- const delayMs = computeRetryDelayMs(res.headers.get('retry-after'), text, attempt);
58
- await waitForRetry(delayMs);
59
- continue;
60
- }
61
- throw new Error(`Gemini request failed: ${res.status} ${text}`);
62
- }
63
-
64
- const json = (await res.json()) as GeminiResponse;
65
- const text = extractText(json);
66
- if (!text) throw new Error('Gemini response missing text content');
67
- return JSON.parse(text) as GeneratedNote;
68
- }
69
- }
70
- }
71
-
@@ -1,69 +0,0 @@
1
- import type { GeneratedNote, LLMProvider, NextNoteRequest } from '../types';
2
- import { assertHeaderSafeApiKey } from './auth';
3
- import { systemPrompt, userPrompt } from '../prompt';
4
- import { computeRetryDelayMs, shouldRetryStatus, waitForRetry } from './retry';
5
-
6
- type ChatCompletionResponse = {
7
- choices?: Array<{
8
- message?: {
9
- content?: string | Array<{ type?: string; text?: string }>;
10
- };
11
- }>;
12
- };
13
-
14
- type ChatContent = string | Array<{ type?: string; text?: string }> | undefined;
15
-
16
- function extractContent(content: ChatContent): string {
17
- if (typeof content === 'string') return content;
18
- if (Array.isArray(content)) {
19
- return content
20
- .map((c) => (typeof c?.text === 'string' ? c.text : ''))
21
- .join('')
22
- .trim();
23
- }
24
- return '';
25
- }
26
-
27
- export class GrokProvider implements LLMProvider {
28
- constructor(
29
- private readonly apiKey: string,
30
- private readonly model = 'grok-2-latest',
31
- ) {}
32
-
33
- async nextNote(input: NextNoteRequest): Promise<GeneratedNote> {
34
- const apiKey = assertHeaderSafeApiKey('Grok', this.apiKey);
35
- const maxRetries = 4;
36
- for (let attempt = 0; ; attempt++) {
37
- const res = await fetch('https://api.x.ai/v1/chat/completions', {
38
- method: 'POST',
39
- headers: {
40
- Authorization: `Bearer ${apiKey}`,
41
- 'Content-Type': 'application/json',
42
- },
43
- body: JSON.stringify({
44
- model: this.model,
45
- temperature: 0.7,
46
- messages: [
47
- { role: 'system', content: systemPrompt() },
48
- { role: 'user', content: userPrompt(input) },
49
- ],
50
- }),
51
- });
52
-
53
- if (!res.ok) {
54
- const text = await res.text();
55
- if (attempt < maxRetries && shouldRetryStatus(res.status)) {
56
- const delayMs = computeRetryDelayMs(res.headers.get('retry-after'), text, attempt);
57
- await waitForRetry(delayMs);
58
- continue;
59
- }
60
- throw new Error(`Grok request failed: ${res.status} ${text}`);
61
- }
62
-
63
- const json = (await res.json()) as ChatCompletionResponse;
64
- const text = extractContent(json.choices?.[0]?.message?.content);
65
- if (!text) throw new Error('Grok response missing content');
66
- return JSON.parse(text) as GeneratedNote;
67
- }
68
- }
69
- }
@@ -1,69 +0,0 @@
1
- import type { GeneratedNote, LLMProvider, NextNoteRequest } from '../types';
2
- import { assertHeaderSafeApiKey } from './auth';
3
- import { systemPrompt, userPrompt } from '../prompt';
4
- import { computeRetryDelayMs, shouldRetryStatus, waitForRetry } from './retry';
5
-
6
- type ChatCompletionResponse = {
7
- choices?: Array<{
8
- message?: {
9
- content?: string | Array<{ type?: string; text?: string }>;
10
- };
11
- }>;
12
- };
13
-
14
- type ChatContent = string | Array<{ type?: string; text?: string }> | undefined;
15
-
16
- function extractContent(content: ChatContent): string {
17
- if (typeof content === 'string') return content;
18
- if (Array.isArray(content)) {
19
- return content
20
- .map((c) => (typeof c?.text === 'string' ? c.text : ''))
21
- .join('')
22
- .trim();
23
- }
24
- return '';
25
- }
26
-
27
- export class GroqProvider implements LLMProvider {
28
- constructor(
29
- private readonly apiKey: string,
30
- private readonly model = 'llama-3.3-70b-versatile',
31
- ) {}
32
-
33
- async nextNote(input: NextNoteRequest): Promise<GeneratedNote> {
34
- const apiKey = assertHeaderSafeApiKey('Groq', this.apiKey);
35
- const maxRetries = 4;
36
- for (let attempt = 0; ; attempt++) {
37
- const res = await fetch('https://api.groq.com/openai/v1/chat/completions', {
38
- method: 'POST',
39
- headers: {
40
- Authorization: `Bearer ${apiKey}`,
41
- 'Content-Type': 'application/json',
42
- },
43
- body: JSON.stringify({
44
- model: this.model,
45
- temperature: 0.7,
46
- messages: [
47
- { role: 'system', content: systemPrompt() },
48
- { role: 'user', content: userPrompt(input) },
49
- ],
50
- }),
51
- });
52
-
53
- if (!res.ok) {
54
- const text = await res.text();
55
- if (attempt < maxRetries && shouldRetryStatus(res.status)) {
56
- const delayMs = computeRetryDelayMs(res.headers.get('retry-after'), text, attempt);
57
- await waitForRetry(delayMs);
58
- continue;
59
- }
60
- throw new Error(`Groq request failed: ${res.status} ${text}`);
61
- }
62
-
63
- const json = (await res.json()) as ChatCompletionResponse;
64
- const text = extractContent(json.choices?.[0]?.message?.content);
65
- if (!text) throw new Error('Groq response missing content');
66
- return JSON.parse(text) as GeneratedNote;
67
- }
68
- }
69
- }
@@ -1,15 +0,0 @@
1
- import type { GeneratedNote, LLMProvider, NextNoteRequest } from '../types';
2
-
3
- // Simple fallback provider for local testing without API keys.
4
- export class MockProvider implements LLMProvider {
5
- async nextNote(input: NextNoteRequest): Promise<GeneratedNote> {
6
- const last = input.history.at(-1)?.pitch ?? input.seedPitch;
7
- const stepChoices = [-2, -1, 1, 2, 3];
8
- const step = stepChoices[Math.floor(Math.random() * stepChoices.length)];
9
- return {
10
- pitch: Math.max(48, Math.min(84, last + step)),
11
- velocity: 90,
12
- durationMs: 300,
13
- };
14
- }
15
- }
@@ -1,49 +0,0 @@
1
- import type { GeneratedNote, LLMProvider, NextNoteRequest } from '../types';
2
- import { assertHeaderSafeApiKey } from './auth';
3
- import { systemPrompt, userPrompt } from '../prompt';
4
- import { computeRetryDelayMs, shouldRetryStatus, waitForRetry } from './retry';
5
-
6
- export class OpenAIProvider implements LLMProvider {
7
- constructor(
8
- private readonly apiKey: string,
9
- private readonly model = 'gpt-4.1-mini',
10
- ) {}
11
-
12
- async nextNote(input: NextNoteRequest): Promise<GeneratedNote> {
13
- const apiKey = assertHeaderSafeApiKey('OpenAI', this.apiKey);
14
- const maxRetries = 4;
15
- for (let attempt = 0; ; attempt++) {
16
- const res = await fetch('https://api.openai.com/v1/responses', {
17
- method: 'POST',
18
- headers: {
19
- 'Authorization': `Bearer ${apiKey}`,
20
- 'Content-Type': 'application/json',
21
- },
22
- body: JSON.stringify({
23
- model: this.model,
24
- input: [
25
- { role: 'system', content: [{ type: 'input_text', text: systemPrompt() }] },
26
- { role: 'user', content: [{ type: 'input_text', text: 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(`OpenAI request failed: ${res.status} ${text}`);
39
- }
40
-
41
- const json = await res.json() as { output_text?: string };
42
- if (!json.output_text) {
43
- throw new Error('OpenAI response missing output_text');
44
- }
45
-
46
- return JSON.parse(json.output_text) as GeneratedNote;
47
- }
48
- }
49
- }