@williamzujkowski/live-coding-music-mcp 1.0.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 (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1735 -0
  3. package/dist/AudioAnalyzer.js +678 -0
  4. package/dist/PatternStore.js +169 -0
  5. package/dist/StrudelController.js +676 -0
  6. package/dist/__tests__/MusicTheory.test.js +245 -0
  7. package/dist/__tests__/PatternGenerator.test.js +337 -0
  8. package/dist/__tests__/PatternStore.test.js +153 -0
  9. package/dist/__tests__/utils/MockPlaywright.js +130 -0
  10. package/dist/index.js +21 -0
  11. package/dist/server/EnhancedMCPServer.js +654 -0
  12. package/dist/server/EnhancedMCPServerFixed.js +2506 -0
  13. package/dist/server/server.js +580 -0
  14. package/dist/server/tools/ai.js +530 -0
  15. package/dist/server/tools/analysis.js +132 -0
  16. package/dist/server/tools/capture.js +184 -0
  17. package/dist/server/tools/diagnostics.js +96 -0
  18. package/dist/server/tools/editor.js +128 -0
  19. package/dist/server/tools/generate.js +218 -0
  20. package/dist/server/tools/history.js +196 -0
  21. package/dist/server/tools/playback.js +41 -0
  22. package/dist/server/tools/session.js +115 -0
  23. package/dist/server/tools/storage.js +77 -0
  24. package/dist/server/tools/transform.js +399 -0
  25. package/dist/server/tools/types.js +9 -0
  26. package/dist/services/AudioCaptureService.js +279 -0
  27. package/dist/services/GeminiService.js +790 -0
  28. package/dist/services/MIDIExportService.js +412 -0
  29. package/dist/services/MusicTheory.js +186 -0
  30. package/dist/services/PatternGenerator.js +647 -0
  31. package/dist/services/SessionManager.js +316 -0
  32. package/dist/services/StrudelEngine.js +374 -0
  33. package/dist/services/__mocks__/StrudelEngine.js +157 -0
  34. package/dist/types/AudioAnalysis.js +4 -0
  35. package/dist/types/index.js +1 -0
  36. package/dist/utils/ErrorRecovery.js +248 -0
  37. package/dist/utils/InputValidator.js +281 -0
  38. package/dist/utils/Logger.js +19 -0
  39. package/dist/utils/PatternValidator.js +241 -0
  40. package/dist/utils/PerformanceMonitor.js +128 -0
  41. package/package.json +86 -0
@@ -0,0 +1,790 @@
1
+ import { Logger } from '../utils/Logger.js';
2
+ import { GoogleAuth } from 'google-auth-library';
3
+ import * as os from 'os';
4
+ import * as path from 'path';
5
+ import * as fs from 'fs/promises';
6
+ /**
7
+ * GeminiService provides AI-powered music analysis and suggestions
8
+ * using Google's Gemini API.
9
+ *
10
+ * Features:
11
+ * - Qualitative audio feedback (mood, style, suggestions)
12
+ * - Pattern variation suggestions
13
+ * - Creative feedback on pattern structure
14
+ * - Rate limiting and caching for cost control
15
+ *
16
+ * @example
17
+ * const gemini = new GeminiService();
18
+ * const feedback = await gemini.getCreativeFeedback(patternCode);
19
+ */
20
+ export class GeminiService {
21
+ logger = new Logger();
22
+ apiKey;
23
+ model;
24
+ maxTokens;
25
+ cacheTtlMs;
26
+ timeoutMs;
27
+ maxPatternLength;
28
+ adcAvailable = null; // null = not checked yet
29
+ adcCheckPromise = null;
30
+ cliCredentialsChecked = false;
31
+ cliCredentialsPromise = null;
32
+ // Rate limiting with better state tracking
33
+ rateLimit = {
34
+ requestCount: 0,
35
+ lastResetTime: Date.now(),
36
+ nextAvailableTime: 0
37
+ };
38
+ maxRequestsPerMinute = 10;
39
+ rateLimitWindowMs = 60000;
40
+ // Cache for analysis results
41
+ audioCache = new Map();
42
+ patternCache = new Map();
43
+ constructor(config) {
44
+ this.apiKey = config?.apiKey || process.env.GEMINI_API_KEY;
45
+ this.model = config?.model || 'gemini-2.0-flash';
46
+ this.maxTokens = config?.maxTokens || 1024;
47
+ this.cacheTtlMs = (config?.cacheTtlSeconds || 300) * 1000;
48
+ this.timeoutMs = (config?.timeoutSeconds || 30) * 1000;
49
+ this.maxPatternLength = config?.maxPatternLength || 5000;
50
+ }
51
+ /**
52
+ * Checks if Gemini service is available (API key or ADC configured)
53
+ * Note: This is synchronous and may return false before ADC check completes.
54
+ * Use isAvailableAsync() for accurate ADC detection.
55
+ */
56
+ isAvailable() {
57
+ return !!this.apiKey || this.adcAvailable === true;
58
+ }
59
+ /**
60
+ * Async check for Gemini service availability.
61
+ * Checks in order: API key, env var, Gemini CLI config, ADC.
62
+ * @returns Promise<boolean> indicating if service can make API calls
63
+ */
64
+ async isAvailableAsync() {
65
+ // 1. Explicit API key or env var already set
66
+ if (this.apiKey)
67
+ return true;
68
+ // 2. Check Gemini CLI config file
69
+ const cliKey = await this.loadGeminiCliCredentials();
70
+ if (cliKey)
71
+ return true;
72
+ // 3. Fall back to ADC
73
+ return await this.checkADC();
74
+ }
75
+ /**
76
+ * Check if Application Default Credentials (ADC) are available
77
+ * Results are cached after first check.
78
+ */
79
+ async checkADC() {
80
+ // Return cached result if available
81
+ if (this.adcAvailable !== null) {
82
+ return this.adcAvailable;
83
+ }
84
+ // If check is in progress, wait for it
85
+ if (this.adcCheckPromise) {
86
+ return this.adcCheckPromise;
87
+ }
88
+ // Perform the check
89
+ this.adcCheckPromise = this.performADCCheck();
90
+ return this.adcCheckPromise;
91
+ }
92
+ /**
93
+ * Perform the actual ADC credential check
94
+ */
95
+ async performADCCheck() {
96
+ try {
97
+ const auth = new GoogleAuth({
98
+ scopes: ['https://www.googleapis.com/auth/generative-language']
99
+ });
100
+ await auth.getClient();
101
+ this.adcAvailable = true;
102
+ this.logger.debug('ADC credentials available');
103
+ return true;
104
+ }
105
+ catch {
106
+ this.adcAvailable = false;
107
+ this.logger.debug('ADC credentials not available');
108
+ return false;
109
+ }
110
+ finally {
111
+ this.adcCheckPromise = null;
112
+ }
113
+ }
114
+ /**
115
+ * Get all possible config paths for the Gemini CLI based on platform
116
+ * @returns Array of paths to check for Gemini CLI settings
117
+ */
118
+ getGeminiCliConfigPaths() {
119
+ const homeDir = os.homedir();
120
+ const platform = process.platform;
121
+ const paths = [];
122
+ // Primary location per Gemini CLI docs: ~/.gemini/settings.json
123
+ paths.push(path.join(homeDir, '.gemini', 'settings.json'));
124
+ if (platform === 'win32') {
125
+ paths.push(path.join(process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'), 'gemini', 'settings.json'));
126
+ }
127
+ else if (platform === 'darwin') {
128
+ paths.push(path.join(homeDir, 'Library', 'Application Support', 'gemini', 'settings.json'));
129
+ }
130
+ // XDG config location as fallback
131
+ paths.push(path.join(homeDir, '.config', 'gemini', 'settings.json'));
132
+ return paths;
133
+ }
134
+ /**
135
+ * Get the primary config path for the Gemini CLI (for backwards compatibility)
136
+ * @returns The primary path to the Gemini CLI settings file
137
+ */
138
+ getGeminiCliConfigPath() {
139
+ return this.getGeminiCliConfigPaths()[0];
140
+ }
141
+ /**
142
+ * Load credentials from the Gemini CLI config file
143
+ * Checks platform-specific locations for settings.json
144
+ * @returns API key if found, undefined otherwise
145
+ */
146
+ async loadGeminiCliCredentials() {
147
+ // Return cached result if already checked
148
+ if (this.cliCredentialsChecked) {
149
+ return this.apiKey && !process.env.GEMINI_API_KEY ? this.apiKey : undefined;
150
+ }
151
+ // If check is in progress, wait for it
152
+ if (this.cliCredentialsPromise) {
153
+ return this.cliCredentialsPromise;
154
+ }
155
+ // Perform the check
156
+ this.cliCredentialsPromise = this.performCliCredentialsCheck();
157
+ return this.cliCredentialsPromise;
158
+ }
159
+ /**
160
+ * Perform the actual CLI credentials check across all possible locations
161
+ */
162
+ async performCliCredentialsCheck() {
163
+ const homeDir = os.homedir();
164
+ // First check for API keys in settings.json files
165
+ const configPaths = this.getGeminiCliConfigPaths();
166
+ for (const configPath of configPaths) {
167
+ try {
168
+ const content = await fs.readFile(configPath, 'utf-8');
169
+ const settings = JSON.parse(content);
170
+ // Check for various possible key names the CLI might use
171
+ const apiKey = settings.apiKey ||
172
+ settings.api_key ||
173
+ settings.geminiApiKey ||
174
+ settings.GEMINI_API_KEY ||
175
+ settings.googleApiKey ||
176
+ settings.GOOGLE_API_KEY;
177
+ if (apiKey && typeof apiKey === 'string' && apiKey.length > 0) {
178
+ this.logger.debug(`Gemini CLI credentials loaded from ${configPath}`);
179
+ // Set the API key if not already set
180
+ if (!this.apiKey) {
181
+ this.apiKey = apiKey;
182
+ }
183
+ this.cliCredentialsChecked = true;
184
+ return apiKey;
185
+ }
186
+ this.logger.debug(`Config found at ${configPath} but no API key present`);
187
+ }
188
+ catch (error) {
189
+ if (error.code === 'ENOENT') {
190
+ this.logger.debug(`Gemini CLI config not found at ${configPath}`);
191
+ }
192
+ else if (error instanceof SyntaxError) {
193
+ this.logger.debug(`Invalid JSON in ${configPath}`);
194
+ }
195
+ else {
196
+ this.logger.debug(`Failed to read ${configPath}`, error);
197
+ }
198
+ // Continue to next path
199
+ }
200
+ }
201
+ // Check for OAuth credentials from Gemini CLI (stored in oauth_creds.json)
202
+ const oauthCredsPath = path.join(homeDir, '.gemini', 'oauth_creds.json');
203
+ try {
204
+ const content = await fs.readFile(oauthCredsPath, 'utf-8');
205
+ const oauthCreds = JSON.parse(content);
206
+ // OAuth credentials from Gemini CLI contain access_token and refresh_token
207
+ if (oauthCreds.access_token || oauthCreds.accessToken) {
208
+ this.logger.debug('Gemini CLI OAuth credentials found - will use ADC flow');
209
+ // OAuth credentials exist - the ADC flow should be able to use them
210
+ // Mark as checked but return undefined to fall through to ADC
211
+ this.cliCredentialsChecked = true;
212
+ this.cliCredentialsPromise = null;
213
+ return undefined;
214
+ }
215
+ }
216
+ catch {
217
+ // OAuth creds file not found or invalid - that's fine
218
+ }
219
+ this.logger.debug('No Gemini CLI credentials found in any location');
220
+ this.cliCredentialsChecked = true;
221
+ this.cliCredentialsPromise = null;
222
+ return undefined;
223
+ }
224
+ /**
225
+ * Get authentication error message
226
+ */
227
+ getAuthErrorMessage() {
228
+ return 'Gemini API key not configured. Options:\n' +
229
+ '1. Set GEMINI_API_KEY environment variable\n' +
230
+ '2. Install Gemini CLI and run "gemini auth login"\n' +
231
+ '3. Run "gcloud auth application-default login" for ADC';
232
+ }
233
+ /**
234
+ * Ensure authentication is available (API key, Gemini CLI, or ADC)
235
+ * Priority: 1. Explicit API key, 2. Env var, 3. Gemini CLI config, 4. ADC
236
+ * @throws {Error} When no authentication method is available
237
+ */
238
+ async ensureAuthentication() {
239
+ // 1. Already have API key (explicit or from env var)
240
+ if (this.apiKey)
241
+ return;
242
+ // 2. Check Gemini CLI config file
243
+ const cliKey = await this.loadGeminiCliCredentials();
244
+ if (cliKey)
245
+ return;
246
+ // 3. Fall back to ADC
247
+ const adcAvailable = await this.checkADC();
248
+ if (!adcAvailable) {
249
+ throw new Error(this.getAuthErrorMessage());
250
+ }
251
+ }
252
+ /**
253
+ * Analyzes audio data and provides qualitative feedback
254
+ * @param audioData - Audio blob (WebM/Opus recommended)
255
+ * @param context - Optional pattern context for better analysis
256
+ * @returns Audio feedback with mood, style, and suggestions
257
+ * @throws {Error} When authentication not available, audio invalid, or rate limit exceeded
258
+ */
259
+ async analyzeAudio(audioData, context) {
260
+ await this.ensureAuthentication();
261
+ // Validate audio data
262
+ if (!audioData || audioData.size === 0) {
263
+ throw new Error('Audio analysis requires valid audio data. Ensure pattern is playing and audio is captured.');
264
+ }
265
+ this.checkRateLimit();
266
+ // Generate cache key from audio size + context
267
+ const cacheKey = `${audioData.size}-${JSON.stringify(context || {})}`;
268
+ const cached = this.getFromCache(this.audioCache, cacheKey);
269
+ if (cached) {
270
+ this.logger.debug('Returning cached audio analysis');
271
+ return cached;
272
+ }
273
+ try {
274
+ // Convert blob to base64 for API
275
+ const audioBase64 = await this.blobToBase64(audioData);
276
+ const prompt = this.buildAudioPrompt(context);
277
+ // Use timeout for audio analysis (may take longer due to media processing)
278
+ const audioTimeoutMs = this.timeoutMs * 2; // Double timeout for audio
279
+ const timeoutPromise = new Promise((_, reject) => {
280
+ setTimeout(() => {
281
+ reject(new Error(`Audio analysis timed out after ${audioTimeoutMs / 1000} seconds`));
282
+ }, audioTimeoutMs);
283
+ });
284
+ const response = await Promise.race([
285
+ this.callGeminiAPI(prompt, audioBase64, 'audio/webm'),
286
+ timeoutPromise
287
+ ]);
288
+ const feedback = this.parseAudioResponse(response);
289
+ this.audioCache.set(cacheKey, { result: feedback, timestamp: Date.now() });
290
+ return feedback;
291
+ }
292
+ catch (error) {
293
+ this.logger.error('Audio analysis failed', error);
294
+ // Provide actionable error messages
295
+ if (error.message?.includes('timed out')) {
296
+ throw new Error('Audio analysis timed out. The audio sample may be too long. Try a shorter recording.');
297
+ }
298
+ if (error.message?.includes('rate limit') || error.message?.includes('Rate limit')) {
299
+ throw error; // Re-throw rate limit errors as-is
300
+ }
301
+ throw new Error(`Audio analysis failed: ${error.message}`);
302
+ }
303
+ }
304
+ /**
305
+ * Suggests variations for a pattern
306
+ * @param pattern - Strudel pattern code
307
+ * @param style - Optional style hint (e.g., 'more minimal', 'add complexity')
308
+ * @returns Array of pattern suggestions with descriptions
309
+ * @throws {Error} When authentication not available, pattern invalid, or rate limit exceeded
310
+ */
311
+ async suggestVariations(pattern, style) {
312
+ await this.ensureAuthentication();
313
+ // Validate input
314
+ const validatedPattern = this.validateAndPreparePattern(pattern, 'suggestVariations');
315
+ this.checkRateLimit();
316
+ try {
317
+ const prompt = this.buildVariationPrompt(validatedPattern, style);
318
+ const response = await this.callGeminiAPIWithTimeout(prompt);
319
+ return this.parseVariationResponse(response);
320
+ }
321
+ catch (error) {
322
+ this.logger.error('Variation suggestion failed', error);
323
+ // Provide actionable error messages
324
+ if (error.message?.includes('timed out')) {
325
+ throw new Error('Variation suggestion timed out. The pattern may be too complex. Try a simpler pattern.');
326
+ }
327
+ if (error.message?.includes('rate limit') || error.message?.includes('Rate limit')) {
328
+ throw error; // Re-throw rate limit errors as-is
329
+ }
330
+ throw new Error(`Variation suggestion failed: ${error.message}`);
331
+ }
332
+ }
333
+ /**
334
+ * Gets creative feedback on a pattern's structure and style
335
+ * @param pattern - Strudel pattern code
336
+ * @returns Creative feedback with complexity, strengths, and suggestions
337
+ * @throws {Error} When authentication not available, pattern invalid, or rate limit exceeded
338
+ */
339
+ async getCreativeFeedback(pattern) {
340
+ await this.ensureAuthentication();
341
+ // Validate input
342
+ const validatedPattern = this.validateAndPreparePattern(pattern, 'getCreativeFeedback');
343
+ this.checkRateLimit();
344
+ // Check cache (use hash of truncated pattern for consistent keys)
345
+ const cacheKey = validatedPattern.slice(0, 200);
346
+ const cached = this.getFromCache(this.patternCache, cacheKey);
347
+ if (cached) {
348
+ this.logger.debug('Returning cached creative feedback');
349
+ return cached;
350
+ }
351
+ try {
352
+ const prompt = this.buildCreativeFeedbackPrompt(validatedPattern);
353
+ const response = await this.callGeminiAPIWithTimeout(prompt);
354
+ const feedback = this.parseCreativeFeedbackResponse(response);
355
+ this.patternCache.set(cacheKey, { result: feedback, timestamp: Date.now() });
356
+ return feedback;
357
+ }
358
+ catch (error) {
359
+ this.logger.error('Creative feedback failed', error);
360
+ // Provide actionable error messages
361
+ if (error.message?.includes('timed out')) {
362
+ throw new Error('Creative feedback timed out. The pattern may be too complex. Try a simpler pattern.');
363
+ }
364
+ if (error.message?.includes('rate limit') || error.message?.includes('Rate limit')) {
365
+ throw error; // Re-throw rate limit errors as-is
366
+ }
367
+ throw new Error(`Creative feedback failed: ${error.message}`);
368
+ }
369
+ }
370
+ /**
371
+ * Clears all cached analysis results
372
+ */
373
+ clearCache() {
374
+ this.audioCache.clear();
375
+ this.patternCache.clear();
376
+ this.logger.debug('Cache cleared');
377
+ }
378
+ // --- Private Methods ---
379
+ /**
380
+ * Checks rate limit and throws with actionable message if exceeded
381
+ * @throws {Error} When rate limit exceeded, with seconds until next available request
382
+ */
383
+ checkRateLimit() {
384
+ const now = Date.now();
385
+ // Reset counter if window has passed
386
+ if (now - this.rateLimit.lastResetTime > this.rateLimitWindowMs) {
387
+ this.rateLimit.requestCount = 0;
388
+ this.rateLimit.lastResetTime = now;
389
+ this.rateLimit.nextAvailableTime = 0;
390
+ }
391
+ if (this.rateLimit.requestCount >= this.maxRequestsPerMinute) {
392
+ const waitTimeMs = this.rateLimit.lastResetTime + this.rateLimitWindowMs - now;
393
+ const waitTimeSec = Math.ceil(waitTimeMs / 1000);
394
+ this.rateLimit.nextAvailableTime = now + waitTimeMs;
395
+ throw new Error(`Rate limit exceeded (${this.maxRequestsPerMinute} requests/minute). ` +
396
+ `Wait ${waitTimeSec} seconds before retrying.`);
397
+ }
398
+ this.rateLimit.requestCount++;
399
+ }
400
+ /**
401
+ * Returns seconds until next request is available, or 0 if available now
402
+ */
403
+ getSecondsUntilAvailable() {
404
+ const now = Date.now();
405
+ if (this.rateLimit.requestCount < this.maxRequestsPerMinute) {
406
+ return 0;
407
+ }
408
+ const waitTimeMs = this.rateLimit.lastResetTime + this.rateLimitWindowMs - now;
409
+ return Math.max(0, Math.ceil(waitTimeMs / 1000));
410
+ }
411
+ getFromCache(cache, key) {
412
+ const entry = cache.get(key);
413
+ if (!entry)
414
+ return null;
415
+ if (Date.now() - entry.timestamp > this.cacheTtlMs) {
416
+ cache.delete(key);
417
+ return null;
418
+ }
419
+ return entry.result;
420
+ }
421
+ async blobToBase64(blob) {
422
+ const buffer = await blob.arrayBuffer();
423
+ return Buffer.from(buffer).toString('base64');
424
+ }
425
+ /**
426
+ * Validates and prepares a pattern for API submission
427
+ * @param pattern - The raw pattern string
428
+ * @param methodName - The calling method name for error messages
429
+ * @returns Validated and trimmed pattern
430
+ * @throws {Error} When pattern is empty or exceeds max length
431
+ */
432
+ validateAndPreparePattern(pattern, methodName) {
433
+ // Check for null/undefined
434
+ if (pattern === null || pattern === undefined) {
435
+ throw new Error(`${methodName}: Pattern is required. Write a pattern first.`);
436
+ }
437
+ // Check for non-string types
438
+ if (typeof pattern !== 'string') {
439
+ throw new Error(`${methodName}: Pattern must be a string, got ${typeof pattern}.`);
440
+ }
441
+ // Check for empty or whitespace-only
442
+ const trimmed = pattern.trim();
443
+ if (trimmed.length === 0) {
444
+ throw new Error(`${methodName}: Pattern cannot be empty or whitespace-only. Write a pattern first.`);
445
+ }
446
+ // Check for excessive length
447
+ if (trimmed.length > this.maxPatternLength) {
448
+ throw new Error(`${methodName}: Pattern exceeds maximum length of ${this.maxPatternLength} characters ` +
449
+ `(current: ${trimmed.length}). Truncate or simplify the pattern.`);
450
+ }
451
+ return trimmed;
452
+ }
453
+ /**
454
+ * Calls Gemini API with timeout protection
455
+ * @param prompt - The prompt to send
456
+ * @returns API response text
457
+ * @throws {Error} When request times out or API fails
458
+ */
459
+ async callGeminiAPIWithTimeout(prompt) {
460
+ const timeoutPromise = new Promise((_, reject) => {
461
+ setTimeout(() => {
462
+ reject(new Error(`Request timed out after ${this.timeoutMs / 1000} seconds`));
463
+ }, this.timeoutMs);
464
+ });
465
+ return Promise.race([
466
+ this.callGeminiAPI(prompt),
467
+ timeoutPromise
468
+ ]);
469
+ }
470
+ async callGeminiAPI(prompt, mediaData, mimeType) {
471
+ // Dynamic import for ES module compatibility
472
+ const { GoogleGenerativeAI } = await import('@google/generative-ai');
473
+ let genAI;
474
+ if (this.apiKey) {
475
+ // Use API key authentication
476
+ genAI = new GoogleGenerativeAI(this.apiKey);
477
+ }
478
+ else {
479
+ // Use ADC to get access token
480
+ const accessToken = await this.getADCAccessToken();
481
+ // The SDK can accept an access token via the apiKey parameter
482
+ // when the token is used with the correct headers
483
+ genAI = new GoogleGenerativeAI(accessToken);
484
+ }
485
+ const model = genAI.getGenerativeModel({
486
+ model: this.model,
487
+ generationConfig: {
488
+ maxOutputTokens: this.maxTokens,
489
+ temperature: 0.7
490
+ }
491
+ });
492
+ let result;
493
+ if (mediaData && mimeType) {
494
+ result = await model.generateContent([
495
+ prompt,
496
+ {
497
+ inlineData: {
498
+ data: mediaData,
499
+ mimeType: mimeType
500
+ }
501
+ }
502
+ ]);
503
+ }
504
+ else {
505
+ result = await model.generateContent(prompt);
506
+ }
507
+ return result.response.text();
508
+ }
509
+ /**
510
+ * Get an access token from ADC for API authentication
511
+ */
512
+ async getADCAccessToken() {
513
+ const auth = new GoogleAuth({
514
+ scopes: ['https://www.googleapis.com/auth/generative-language']
515
+ });
516
+ const client = await auth.getClient();
517
+ const tokenResponse = await client.getAccessToken();
518
+ if (!tokenResponse.token) {
519
+ throw new Error('Failed to get access token from ADC');
520
+ }
521
+ return tokenResponse.token;
522
+ }
523
+ buildAudioPrompt(context) {
524
+ let prompt = `Analyze this audio clip from a live-coded music performance using Strudel.cc.
525
+
526
+ Provide feedback in the following JSON format:
527
+ {
528
+ "mood": "<one word describing the mood, e.g., energetic, melancholic, hypnotic, chaotic>",
529
+ "style": "<musical genre/style, e.g., minimal techno, ambient drone, glitch, house>",
530
+ "energy": "<low|medium|high>",
531
+ "suggestions": ["<suggestion 1>", "<suggestion 2>", "<suggestion 3>"],
532
+ "confidence": <0.0-1.0>
533
+ }
534
+
535
+ Focus on:
536
+ - Overall mood and atmosphere
537
+ - Rhythmic characteristics
538
+ - Tonal qualities
539
+ - Creative suggestions for improvement`;
540
+ if (context) {
541
+ prompt += `\n\nContext:`;
542
+ if (context.style)
543
+ prompt += `\n- Intended style: ${context.style}`;
544
+ if (context.bpm)
545
+ prompt += `\n- BPM: ${context.bpm}`;
546
+ if (context.key)
547
+ prompt += `\n- Key: ${context.key}`;
548
+ }
549
+ return prompt;
550
+ }
551
+ buildVariationPrompt(pattern, style) {
552
+ let prompt = `You are an expert in Strudel.cc live coding music.
553
+
554
+ Given this pattern:
555
+ \`\`\`javascript
556
+ ${pattern}
557
+ \`\`\`
558
+
559
+ Generate 3 variations that ${style || 'add interesting musical changes while preserving the core feel'}.
560
+
561
+ Return JSON array:
562
+ [
563
+ {
564
+ "description": "<brief description of the variation>",
565
+ "code": "<complete Strudel pattern code>",
566
+ "rationale": "<why this variation works musically>"
567
+ }
568
+ ]
569
+
570
+ Rules:
571
+ - Each variation must be valid Strudel.cc code
572
+ - Keep the core musical idea but transform it creatively
573
+ - Variations should be distinctly different from each other`;
574
+ return prompt;
575
+ }
576
+ buildCreativeFeedbackPrompt(pattern) {
577
+ return `You are an expert in Strudel.cc live coding music.
578
+
579
+ Analyze this pattern:
580
+ \`\`\`javascript
581
+ ${pattern}
582
+ \`\`\`
583
+
584
+ Provide creative feedback in this JSON format:
585
+ {
586
+ "complexity": "<simple|moderate|complex>",
587
+ "estimatedStyle": "<genre/style this sounds like>",
588
+ "strengths": ["<strength 1>", "<strength 2>"],
589
+ "suggestions": ["<improvement suggestion 1>", "<improvement suggestion 2>"]
590
+ }
591
+
592
+ Consider:
593
+ - Pattern complexity and layering
594
+ - Use of Strudel functions and effects
595
+ - Rhythmic interest
596
+ - Harmonic choices
597
+ - Creative potential`;
598
+ }
599
+ parseAudioResponse(response) {
600
+ try {
601
+ const jsonMatch = response.match(/\{[\s\S]*\}/);
602
+ if (!jsonMatch)
603
+ throw new Error('No JSON found in response');
604
+ const parsed = JSON.parse(jsonMatch[0]);
605
+ return {
606
+ mood: parsed.mood || 'unknown',
607
+ style: parsed.style || 'unknown',
608
+ energy: parsed.energy || 'medium',
609
+ suggestions: parsed.suggestions || [],
610
+ confidence: typeof parsed.confidence === 'number' ? parsed.confidence : 0.5
611
+ };
612
+ }
613
+ catch {
614
+ this.logger.warn('Failed to parse audio response, using defaults');
615
+ return {
616
+ mood: 'unknown',
617
+ style: 'unknown',
618
+ energy: 'medium',
619
+ suggestions: ['Unable to analyze audio clearly'],
620
+ confidence: 0
621
+ };
622
+ }
623
+ }
624
+ parseVariationResponse(response) {
625
+ try {
626
+ const jsonMatch = response.match(/\[[\s\S]*\]/);
627
+ if (!jsonMatch)
628
+ throw new Error('No JSON array found in response');
629
+ const parsed = JSON.parse(jsonMatch[0]);
630
+ return parsed.map((item) => ({
631
+ description: item.description || '',
632
+ code: item.code || '',
633
+ rationale: item.rationale || ''
634
+ }));
635
+ }
636
+ catch {
637
+ this.logger.warn('Failed to parse variation response');
638
+ return [];
639
+ }
640
+ }
641
+ parseCreativeFeedbackResponse(response) {
642
+ try {
643
+ const jsonMatch = response.match(/\{[\s\S]*\}/);
644
+ if (!jsonMatch)
645
+ throw new Error('No JSON found in response');
646
+ const parsed = JSON.parse(jsonMatch[0]);
647
+ return {
648
+ complexity: parsed.complexity || 'moderate',
649
+ estimatedStyle: parsed.estimatedStyle || 'unknown',
650
+ strengths: parsed.strengths || [],
651
+ suggestions: parsed.suggestions || []
652
+ };
653
+ }
654
+ catch {
655
+ this.logger.warn('Failed to parse creative feedback response');
656
+ return {
657
+ complexity: 'moderate',
658
+ estimatedStyle: 'unknown',
659
+ strengths: [],
660
+ suggestions: ['Unable to analyze pattern']
661
+ };
662
+ }
663
+ }
664
+ /**
665
+ * Modifies a Strudel pattern based on a natural language description.
666
+ * Uses Gemini to interpret the modification request and generate valid Strudel code.
667
+ *
668
+ * @param currentPattern - The current Strudel pattern code
669
+ * @param modification - Natural language description of the desired modification
670
+ * @returns Modified pattern code
671
+ * @throws {Error} When authentication not available, pattern invalid, or rate limit exceeded
672
+ *
673
+ * @example
674
+ * const modified = await gemini.modifyPatternWithNLP(
675
+ * 's("bd sd").fast(2)',
676
+ * 'make the bass heavier and add hi-hats'
677
+ * );
678
+ */
679
+ async modifyPatternWithNLP(currentPattern, modification) {
680
+ await this.ensureAuthentication();
681
+ // Validate inputs
682
+ const validatedPattern = this.validateAndPreparePattern(currentPattern, 'modifyPatternWithNLP');
683
+ if (modification === null || modification === undefined) {
684
+ throw new Error('modifyPatternWithNLP: Modification description is required.');
685
+ }
686
+ if (typeof modification !== 'string') {
687
+ throw new Error('modifyPatternWithNLP: Modification description must be a string.');
688
+ }
689
+ const trimmedModification = modification.trim();
690
+ if (trimmedModification.length === 0) {
691
+ throw new Error('modifyPatternWithNLP: Modification description cannot be empty.');
692
+ }
693
+ if (trimmedModification.length > 500) {
694
+ throw new Error('modifyPatternWithNLP: Modification description too long (max 500 characters).');
695
+ }
696
+ this.checkRateLimit();
697
+ try {
698
+ const prompt = this.buildNLPModifyPrompt(validatedPattern, trimmedModification);
699
+ const response = await this.callGeminiAPIWithTimeout(prompt);
700
+ return this.parseNLPModifyResponse(response, validatedPattern);
701
+ }
702
+ catch (error) {
703
+ this.logger.error('NLP pattern modification failed', error);
704
+ // Provide actionable error messages
705
+ if (error.message?.includes('timed out')) {
706
+ throw new Error('Pattern modification timed out. The pattern may be too complex. Try a simpler pattern.');
707
+ }
708
+ if (error.message?.includes('rate limit') || error.message?.includes('Rate limit')) {
709
+ throw error; // Re-throw rate limit errors as-is
710
+ }
711
+ throw new Error(`Pattern modification failed: ${error.message}`);
712
+ }
713
+ }
714
+ /**
715
+ * Builds the prompt for NLP-based pattern modification
716
+ */
717
+ buildNLPModifyPrompt(pattern, modification) {
718
+ return `You are an expert in Strudel.cc live coding music. Your task is to modify a pattern based on a natural language request.
719
+
720
+ CURRENT PATTERN:
721
+ \`\`\`javascript
722
+ ${pattern}
723
+ \`\`\`
724
+
725
+ MODIFICATION REQUEST: "${modification}"
726
+
727
+ STRUDEL SYNTAX REFERENCE:
728
+ - Basic sounds: s("bd sd hh") - plays samples
729
+ - Notes: note("c3 e3 g3") or n("0 4 7") - plays notes
730
+ - Speed: .fast(2) doubles speed, .slow(2) halves speed
731
+ - Effects: .gain(0.8), .lpf(800), .hpf(200), .room(0.5), .delay(0.25)
732
+ - Patterns: [a b] groups, <a b> alternates, a*4 repeats
733
+ - Stacking: stack(pattern1, pattern2) plays simultaneously
734
+ - Common samples: bd (kick), sd (snare), hh (hihat), cp (clap), oh (open hihat)
735
+
736
+ RULES:
737
+ 1. Return ONLY the modified Strudel code - no explanations, no markdown formatting
738
+ 2. The output must be valid Strudel.cc syntax
739
+ 3. Preserve the original pattern's structure where possible
740
+ 4. Make the requested changes clearly and musically appropriate
741
+ 5. If the modification is unclear, make a reasonable interpretation
742
+ 6. Keep gain values between 0 and 1.5 for safety
743
+ 7. Do not use eval(), Function(), or any dangerous constructs
744
+
745
+ Return the modified pattern code:`;
746
+ }
747
+ /**
748
+ * Parses the NLP modification response and extracts the pattern
749
+ * Falls back to original pattern if parsing fails
750
+ */
751
+ parseNLPModifyResponse(response, originalPattern) {
752
+ // Clean the response - remove markdown code blocks if present
753
+ let cleaned = response.trim();
754
+ // Remove markdown code block formatting
755
+ if (cleaned.startsWith('```')) {
756
+ const lines = cleaned.split('\n');
757
+ // Remove first line (```javascript or ```)
758
+ lines.shift();
759
+ // Remove last line if it's ```
760
+ if (lines[lines.length - 1]?.trim() === '```') {
761
+ lines.pop();
762
+ }
763
+ cleaned = lines.join('\n').trim();
764
+ }
765
+ // Basic validation - check for dangerous patterns
766
+ const dangerousPatterns = ['eval(', 'Function(', 'new Function', 'setTimeout', 'setInterval'];
767
+ for (const dangerous of dangerousPatterns) {
768
+ if (cleaned.includes(dangerous)) {
769
+ this.logger.warn(`NLP response contains dangerous pattern: ${dangerous}`);
770
+ throw new Error('Generated pattern contains unsafe code. Returning original pattern.');
771
+ }
772
+ }
773
+ // Check for excessive gain
774
+ const gainMatch = cleaned.match(/\.gain\s*\(\s*([0-9.]+)\s*\)/);
775
+ if (gainMatch) {
776
+ const gainValue = parseFloat(gainMatch[1]);
777
+ if (gainValue > 2.0) {
778
+ this.logger.warn(`NLP response contains dangerous gain: ${gainValue}`);
779
+ // Replace with safe gain
780
+ cleaned = cleaned.replace(/\.gain\s*\(\s*[0-9.]+\s*\)/, '.gain(1.0)');
781
+ }
782
+ }
783
+ // If response is empty or too short, return original
784
+ if (!cleaned || cleaned.length < 3) {
785
+ this.logger.warn('NLP response too short, returning original pattern');
786
+ return originalPattern;
787
+ }
788
+ return cleaned;
789
+ }
790
+ }