@williamzujkowski/live-coding-music-mcp 4.0.0 → 4.1.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 (53) hide show
  1. package/README.md +202 -705
  2. package/dist/AudioAnalyzer.js +1294 -91
  3. package/dist/PatternStore.js +276 -23
  4. package/dist/StrudelController.js +388 -56
  5. package/dist/server/resources.js +27 -20
  6. package/dist/server/server.js +384 -76
  7. package/dist/server/tools/ai.js +124 -91
  8. package/dist/server/tools/analysis.js +168 -59
  9. package/dist/server/tools/capture.js +135 -24
  10. package/dist/server/tools/compose.js +26 -26
  11. package/dist/server/tools/diagnostics.js +20 -3
  12. package/dist/server/tools/editor.js +60 -6
  13. package/dist/server/tools/generate.js +38 -15
  14. package/dist/server/tools/history.js +30 -8
  15. package/dist/server/tools/playback.js +2 -1
  16. package/dist/server/tools/session.js +38 -6
  17. package/dist/server/tools/storage.js +179 -8
  18. package/dist/server/tools/transform.js +127 -43
  19. package/dist/server/tools/types.js +209 -14
  20. package/dist/services/AudioCaptureService.js +302 -33
  21. package/dist/services/AudioExportService.js +318 -0
  22. package/dist/services/BrowserOnlyFunctions.js +149 -0
  23. package/dist/services/EventDensityProbe.js +324 -0
  24. package/dist/services/GeminiService.js +221 -36
  25. package/dist/services/IsolatedEngineRunner.js +308 -0
  26. package/dist/services/IsolatedStrudelEngine.js +135 -0
  27. package/dist/services/LocalPatternEngine.js +16 -0
  28. package/dist/services/MIDIExportService.js +765 -71
  29. package/dist/services/MIDIImportService.js +474 -0
  30. package/dist/services/MusicTheory.js +85 -13
  31. package/dist/services/PatternGenerator.js +168 -85
  32. package/dist/services/PatternSandbox.js +359 -0
  33. package/dist/services/SessionManager.js +338 -65
  34. package/dist/services/StrudelEngine.js +218 -12
  35. package/dist/services/StrudelEngineHelpers.js +24 -8
  36. package/dist/services/StyleRegistry.js +204 -0
  37. package/dist/services/Transferable.js +114 -0
  38. package/dist/services/ai/AiTransport.js +56 -0
  39. package/dist/services/ai/AudioMeasurements.js +155 -0
  40. package/dist/services/ai/CliTransport.js +256 -0
  41. package/dist/services/engineChild.js +83 -0
  42. package/dist/services/engineChildPath.js +28 -0
  43. package/dist/utils/CategorisedError.js +59 -0
  44. package/dist/utils/ErrorRecovery.js +130 -110
  45. package/dist/utils/InputValidator.js +55 -7
  46. package/dist/utils/PatternValidator.js +136 -17
  47. package/dist/utils/SafePath.js +112 -0
  48. package/dist/utils/ServerConfig.js +248 -0
  49. package/dist/utils/TableLookup.js +41 -0
  50. package/dist/utils/Tempo.js +143 -0
  51. package/package.json +17 -5
  52. package/dist/services/__mocks__/StrudelEngine.js +0 -157
  53. package/dist/types/index.js +0 -1
@@ -8,7 +8,11 @@
8
8
  * pattern (tempo/key/style detection, layer detection, merge logic)
9
9
  * and that are used only here. They live in this module.
10
10
  */
11
+ import { empty, err, withStashField } from './types.js';
12
+ import { AiRateLimitError } from '../../services/ai/AiTransport.js';
11
13
  import { Logger } from '../../utils/Logger.js';
14
+ import { lookup } from '../../utils/TableLookup.js';
15
+ import { declaredBpm } from '../../utils/Tempo.js';
12
16
  const logger = new Logger();
13
17
  const SESSION_ID_PROP = {
14
18
  session_id: {
@@ -25,7 +29,8 @@ export const tools = [
25
29
  'task=jam generates a fresh layer (drums/bass/melody/pad/texture) and merges it into the current pattern, then auto-plays. ' +
26
30
  'All three share Gemini auth + rate limiting. ' +
27
31
  'Example: ai_assist({ task: "jam", layer: "bass" }). ' +
28
- 'Requires GEMINI_API_KEY env var. For non-AI pattern generation use generate_part; for full compositions use compose.',
32
+ 'Needs GEMINI_API_KEY, or a logged-in claude/agy/codex CLI on PATH (#252). ' +
33
+ 'For non-AI pattern generation use generate_part; for full compositions use compose.',
29
34
  inputSchema: {
30
35
  type: 'object',
31
36
  properties: {
@@ -78,7 +83,8 @@ async function getPatternFeedback(includeAudio, style, ctx, sid) {
78
83
  if (!ctx.geminiService.isAvailable()) {
79
84
  return {
80
85
  gemini_available: false,
81
- error: 'Gemini API not configured. Set GEMINI_API_KEY environment variable to enable AI feedback.',
86
+ error: 'No AI transport available. Set GEMINI_API_KEY, or log in to one of the '
87
+ + 'claude/agy/codex CLIs on PATH (#252).',
82
88
  };
83
89
  }
84
90
  const pattern = await ctx.getCurrentPatternSafe(sid);
@@ -92,19 +98,40 @@ async function getPatternFeedback(includeAudio, style, ctx, sid) {
92
98
  catch (error) {
93
99
  const message = error instanceof Error ? error.message : String(error);
94
100
  logger.error('Pattern feedback failed', { error: message });
95
- if (message?.includes('rate limit') || message?.includes('Rate limit')) {
96
- return { gemini_available: true, error: message };
101
+ // By type, not by phrase. `AiRateLimitError` is what the limiter
102
+ // throws; matching the words meant a reworded message would fall
103
+ // through to "Pattern analysis failed" and lose the one thing a
104
+ // caller can act on — that waiting works (#380, #392).
105
+ if (error instanceof AiRateLimitError) {
106
+ return err('transient', message, { isRetryable: true });
97
107
  }
98
108
  result.error = `Pattern analysis failed: ${message}`;
99
109
  }
100
110
  if (includeAudio && (sid || ctx.isInitialized())) {
101
111
  try {
102
- const audioBlob = await captureAudioSampleForFeedback(ctx, sid);
103
- if (audioBlob) {
104
- result.audio_analysis = await ctx.geminiService.analyzeAudio(audioBlob, { style, duration: 5 });
112
+ const sample = await captureAudioSampleForFeedback(ctx, sid);
113
+ if (sample === null) {
114
+ logger.warn('Audio capture returned no data');
115
+ if (!result.error)
116
+ result.error = 'Audio capture returned no data.';
117
+ }
118
+ else if (sample.silent) {
119
+ // Sending silence to Gemini does not fail — it returns a confident
120
+ // mood/style/energy for audio that is not there. Feedback silently
121
+ // decoupled from the audio is worse than no feedback, so refuse.
122
+ result.audio_analysis_skipped =
123
+ 'Captured audio was silent, so no feedback was requested. ' +
124
+ 'Is a pattern playing? Call playback({ action: "play" }) first.';
125
+ logger.warn('Skipping Gemini audio analysis: capture was silent', {
126
+ peak: sample.peak,
127
+ });
105
128
  }
106
129
  else {
107
- logger.warn('Audio capture returned no data');
130
+ // Measurements, not the waveform. Every installed CLI answers
131
+ // "CANNOT DECODE AUDIO", and agy will confabulate detailed
132
+ // analysis of audio it never examined rather than admit it.
133
+ result.audio_analysis = await ctx.geminiService.analyzeAudioMeasurements(sample.measurements, pattern, { style, duration: FEEDBACK_SAMPLE_MS / 1000 });
134
+ result.audio_levels = { peak: sample.peak, rms: sample.rms };
108
135
  }
109
136
  }
110
137
  catch (error) {
@@ -121,6 +148,8 @@ async function getPatternFeedback(includeAudio, style, ctx, sid) {
121
148
  }
122
149
  return result;
123
150
  }
151
+ /** Seconds of audio sent to Gemini for feedback. */
152
+ const FEEDBACK_SAMPLE_MS = 5000;
124
153
  /**
125
154
  * Captures a brief audio sample for Gemini analysis. Different from the
126
155
  * `capture_audio_sample` tool — this one connects directly to the
@@ -133,62 +162,31 @@ async function captureAudioSampleForFeedback(ctx, sid) {
133
162
  logger.warn('Cannot capture audio: controller page not available');
134
163
  return null;
135
164
  }
136
- try {
137
- const audioData = await page.evaluate(/* istanbul ignore next */ async () => {
138
- return new Promise((resolve) => {
139
- const analyzer = window.strudelAudioAnalyzer;
140
- if (!analyzer || !analyzer.analyser) {
141
- resolve(null);
142
- return;
143
- }
144
- try {
145
- const audioCtx = analyzer.analyser.context;
146
- const destination = audioCtx.createMediaStreamDestination();
147
- analyzer.analyser.connect(destination);
148
- const mediaRecorder = new MediaRecorder(destination.stream, { mimeType: 'audio/webm;codecs=opus' });
149
- const chunks = [];
150
- mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0)
151
- chunks.push(e.data); };
152
- mediaRecorder.onstop = async () => {
153
- try {
154
- analyzer.analyser.disconnect(destination);
155
- }
156
- catch { /* already disconnected */ }
157
- if (chunks.length === 0) {
158
- resolve(null);
159
- return;
160
- }
161
- const blob = new Blob(chunks, { type: 'audio/webm' });
162
- const reader = new FileReader();
163
- reader.onloadend = () => {
164
- const base64 = reader.result;
165
- resolve(base64.split(',')[1] || null);
166
- };
167
- reader.onerror = () => resolve(null);
168
- reader.readAsDataURL(blob);
169
- };
170
- mediaRecorder.start();
171
- setTimeout(() => { if (mediaRecorder.state === 'recording')
172
- mediaRecorder.stop(); }, 5000);
173
- }
174
- catch {
175
- resolve(null);
176
- }
177
- });
178
- });
179
- if (!audioData)
180
- return null;
181
- const binaryString = atob(audioData);
182
- const bytes = new Uint8Array(binaryString.length);
183
- for (let i = 0; i < binaryString.length; i++)
184
- bytes[i] = binaryString.charCodeAt(i);
185
- return new Blob([bytes], { type: 'audio/webm' });
186
- }
187
- catch (error) {
188
- const message = error instanceof Error ? error.message : String(error);
189
- logger.error('Audio capture failed', { error: message });
165
+ // Ensures the recorder is injected for this session before exporting.
166
+ await ctx.getAudioCaptureService(sid);
167
+ const result = await ctx.audioExportService.exportAudio(page, {
168
+ duration: FEEDBACK_SAMPLE_MS,
169
+ format: 'wav',
170
+ output: 'base64',
171
+ });
172
+ if (!result.success || result.audio === undefined) {
173
+ logger.warn('Audio capture returned no data', { error: result.error });
190
174
  return null;
191
175
  }
176
+ // The bytes are deliberately discarded. No model in play can decode
177
+ // audio, so what gets sent is the measurement set, not the waveform.
178
+ return {
179
+ measurements: {
180
+ durationMs: result.duration,
181
+ peak: result.peak,
182
+ rms: result.rms,
183
+ sampleRate: result.sampleRate,
184
+ channels: result.channels,
185
+ },
186
+ peak: result.peak,
187
+ rms: result.rms,
188
+ silent: result.silent === true,
189
+ };
192
190
  }
193
191
  // ---------------------------------------------------------------------------
194
192
  // suggest_pattern_from_audio
@@ -198,7 +196,10 @@ async function suggestPatternFromAudio(style, role, ctx, sid) {
198
196
  return { error: 'Browser not initialized. Run init and play a pattern first.' };
199
197
  }
200
198
  if (!ctx.geminiService.isAvailable()) {
201
- return { error: 'Gemini API not configured. Set GEMINI_API_KEY to enable AI features.' };
199
+ return {
200
+ error: 'No AI transport available. Set GEMINI_API_KEY, or log in to one of the '
201
+ + 'claude/agy/codex CLIs on PATH (#252).',
202
+ };
202
203
  }
203
204
  const controller = ctx.getController(sid);
204
205
  let bpm = 0, key = 'C', scale = 'major';
@@ -222,7 +223,7 @@ async function suggestPatternFromAudio(style, role, ctx, sid) {
222
223
  melody: 'a melodic line that harmonizes with the key',
223
224
  percussion: 'a percussion layer that adds rhythmic interest',
224
225
  };
225
- const roleText = roleDesc[role] || roleDesc['complement'];
226
+ const roleText = lookup(roleDesc, role, roleDesc['complement']);
226
227
  const styleText = style ? ` in a ${style} style` : '';
227
228
  const tempoText = bpm > 0 ? `Detected tempo: ${bpm} BPM. ` : '';
228
229
  const keyText = `Detected key: ${key} ${scale}. `;
@@ -240,10 +241,17 @@ Example patterns:
240
241
  try {
241
242
  const geminiResponse = await ctx.geminiService.suggestVariations(prompt, style);
242
243
  if (!geminiResponse || geminiResponse.length === 0) {
243
- return { error: 'Gemini returned no pattern suggestions.' };
244
+ // The model answered; it just had nothing to offer. Returning a
245
+ // bare `error` key made `isFailureShaped` convert this into an
246
+ // err() envelope, so a working call was reported as a failure —
247
+ // the same bug as #274, pointing the other way (#288).
248
+ return empty({
249
+ suggestions: [],
250
+ message: 'Gemini returned no pattern suggestions.',
251
+ });
244
252
  }
245
253
  const suggestedPattern = geminiResponse[0].code;
246
- const validation = ctx.strudelEngine.validate(suggestedPattern);
254
+ const validation = await ctx.strudelEngine.validate(suggestedPattern);
247
255
  return {
248
256
  suggested_pattern: suggestedPattern,
249
257
  analysis: { bpm, key, scale },
@@ -293,7 +301,7 @@ async function jamWith(layer, styleHint, autoPlay = true, ctx, sid) {
293
301
  }
294
302
  let newLayer;
295
303
  try {
296
- newLayer = generateComplementaryLayer(layer, key, tempo, detectedStyle, existingLayers, ctx);
304
+ newLayer = generateComplementaryLayer(layer, key, detectedStyle, existingLayers, ctx);
297
305
  }
298
306
  catch (error) {
299
307
  const message = error instanceof Error ? error.message : String(error);
@@ -308,18 +316,18 @@ async function jamWith(layer, styleHint, autoPlay = true, ctx, sid) {
308
316
  }
309
317
  const mergedPattern = mergeLayerIntoPattern(currentPattern, newLayer, layer);
310
318
  try {
311
- await ctx.writePatternSafe(mergedPattern, sid);
319
+ const written = await ctx.writePatternSafe(mergedPattern, sid);
312
320
  if (autoPlay && (sid || ctx.isInitialized())) {
313
321
  await ctx.getController(sid).play();
314
322
  }
315
- return {
323
+ return withStashField({
316
324
  success: true,
317
325
  message: `Added ${layer} layer${styleHint ? ` (${styleHint} style)` : ''} to jam with your pattern`,
318
326
  layer,
319
327
  detected: { tempo, key, existingLayers },
320
328
  newLayer,
321
329
  pattern: mergedPattern.substring(0, 300) + (mergedPattern.length > 300 ? '...' : ''),
322
- };
330
+ }, written);
323
331
  }
324
332
  catch (error) {
325
333
  const message = error instanceof Error ? error.message : String(error);
@@ -333,20 +341,29 @@ async function jamWith(layer, styleHint, autoPlay = true, ctx, sid) {
333
341
  };
334
342
  }
335
343
  }
344
+ /**
345
+ * The tempo a pattern's author meant, for generating a layer to sit on
346
+ * top of it.
347
+ *
348
+ * Uses the canonical parser rather than a regex of its own. The regex it
349
+ * had required a bare `setcpm(<n>)`, so it matched nothing the generator
350
+ * writes — every generated pattern is `setcpm(130/4)` — and fell through
351
+ * to guessing from words in the text, which is how `jam_with` came to
352
+ * read a tempo off a comment (#397).
353
+ *
354
+ * `declaredBpm`, not `impliedBpm`: this reads patterns written by someone
355
+ * else, and only the number they wrote is knowable. Working out what one
356
+ * will actually sound like takes an assumption about beats per cycle
357
+ * that holds for what this project generates and not for an arbitrary
358
+ * hand-written pattern.
359
+ */
336
360
  function detectTempoFromPattern(pattern) {
337
- const cpmMatch = pattern.match(/setcpm\s*\(\s*(\d+(?:\.\d+)?)\s*\)/i);
338
- if (cpmMatch)
339
- return Math.round(parseFloat(cpmMatch[1]));
340
- const bpmMatch = pattern.match(/setbpm\s*\(\s*(\d+(?:\.\d+)?)\s*\)/i);
341
- if (bpmMatch)
342
- return Math.round(parseFloat(bpmMatch[1]));
343
- const cpsMatch = pattern.match(/setcps\s*\(\s*(\d+(?:\.\d+)?)\s*(?:\/\s*60)?\s*\)/i);
344
- if (cpsMatch) {
345
- const cps = parseFloat(cpsMatch[1]);
346
- if (pattern.includes(`setcps(${cpsMatch[1]}/60`))
347
- return Math.round(cps);
348
- return Math.round(cps * 60);
349
- }
361
+ const declared = declaredBpm(pattern);
362
+ if (declared !== undefined)
363
+ return Math.round(declared);
364
+ // No tempo call at all. Guessing from genre words is a poor answer,
365
+ // but it beats silently assuming 120 for a dnb pattern — and a real
366
+ // parse always wins over it now, which is the part that was broken.
350
367
  if (pattern.toLowerCase().includes('dnb'))
351
368
  return 174;
352
369
  if (pattern.toLowerCase().includes('techno'))
@@ -375,10 +392,13 @@ function detectKeyFromPattern(pattern) {
375
392
  }
376
393
  if (allNotes.length === 0)
377
394
  return 'C';
378
- const noteCounts = {};
395
+ // Object.create(null): the keys come from pattern text, and both
396
+ // reading and writing 'constructor'/'__proto__' on a plain literal
397
+ // misbehave (#318).
398
+ const noteCounts = Object.create(null);
379
399
  for (const note of allNotes) {
380
400
  const normalizedNote = note.charAt(0).toUpperCase() + note.slice(1);
381
- noteCounts[normalizedNote] = (noteCounts[normalizedNote] || 0) + 1;
401
+ noteCounts[normalizedNote] = lookup(noteCounts, normalizedNote, 0) + 1;
382
402
  }
383
403
  let mostCommonNote = 'C';
384
404
  let maxCount = 0;
@@ -430,7 +450,14 @@ function detectStyleFromPattern(pattern, styleHint) {
430
450
  return 'trap';
431
451
  return 'techno';
432
452
  }
433
- function generateComplementaryLayer(layer, key, tempo, style, existingLayers, ctx) {
453
+ function generateComplementaryLayer(layer, key, style, existingLayers, ctx) {
454
+ // No `tempo` parameter: it was declared, passed, and never read. The
455
+ // merged layer goes inside the host pattern's own stack and inherits
456
+ // its tempo call (see `mergeLayerIntoPattern`), so there is nothing
457
+ // here for a tempo to change. A cross-model reviewer read the dead
458
+ // parameter as evidence that this function retimes the layer, and
459
+ // concluded jam_with would desync by 4x; it cannot. Dead arguments
460
+ // are not free.
434
461
  switch (layer) {
435
462
  case 'drums':
436
463
  if (existingLayers.includes('drums')) {
@@ -443,7 +470,7 @@ function generateComplementaryLayer(layer, key, tempo, style, existingLayers, ct
443
470
  'jungle': 's("hh*32").gain(perlin.range(0.2, 0.4)).hpf(4000)',
444
471
  'jazz': 's("~ ride ~ ride, ~ ~ ~ hh").gain(0.3).room(0.3)',
445
472
  };
446
- return percOptions[style] || percOptions['techno'];
473
+ return lookup(percOptions, style, percOptions['techno']);
447
474
  }
448
475
  return ctx.generator.generateDrumPattern(style, 0.6);
449
476
  case 'bass':
@@ -451,8 +478,14 @@ function generateComplementaryLayer(layer, key, tempo, style, existingLayers, ct
451
478
  case 'melody': {
452
479
  let scaleName = 'minor';
453
480
  let octaveRange = [4, 5];
481
+ // `major`, not `dorian`. The convention names the mode of the ii
482
+ // CHORD; rooted on the key it is the wrong collection, and over
483
+ // the jazz progression this server generates (Dm7 G7 Cmaj7 in C)
484
+ // it puts Eb against Cmaj7's E (#449). `jam_with` cannot see the
485
+ // host pattern's chords, so it follows the generator's choice
486
+ // rather than disagreeing with it.
454
487
  if (style === 'jazz') {
455
- scaleName = 'dorian';
488
+ scaleName = 'major';
456
489
  octaveRange = [3, 5];
457
490
  }
458
491
  if (style === 'ambient') {
@@ -468,7 +501,7 @@ function generateComplementaryLayer(layer, key, tempo, style, existingLayers, ct
468
501
  'trap': '.gain(0.5).room(0.15)', 'jungle': '.delay(0.125).room(0.25).gain(0.55)',
469
502
  'jazz': '.room(0.4).gain(0.5)',
470
503
  };
471
- return ctx.generator.generateMelody(scale, 8, octaveRange) + (effects[style] || '.room(0.3).gain(0.5)');
504
+ return ctx.generator.generateMelody(scale, 8, octaveRange) + lookup(effects, style, '.room(0.3).gain(0.5)');
472
505
  }
473
506
  case 'pad': {
474
507
  const safeKey = key.toLowerCase();
@@ -483,7 +516,7 @@ function generateComplementaryLayer(layer, key, tempo, style, existingLayers, ct
483
516
  'jungle': `chord("<${safeKey}m9 ${fourth}m9>/8").dict('ireal').voicing().s("gm_epiano1").gain(0.25).room(0.4).delay(0.25)`,
484
517
  'jazz': `chord("<${safeKey}m9 ${fourth}m9 ${fifth}7>/4").dict('ireal').voicing().s("gm_epiano1").gain(0.3).room(0.5)`,
485
518
  };
486
- return padPatterns[style] || padPatterns['techno'];
519
+ return lookup(padPatterns, style, padPatterns['techno']);
487
520
  }
488
521
  case 'texture': {
489
522
  const texturePatterns = {
@@ -495,7 +528,7 @@ function generateComplementaryLayer(layer, key, tempo, style, existingLayers, ct
495
528
  'jungle': `s("breaks125:8").fit().chop(32).gain(0.05).hpf(5000).room(0.4).pan(perlin.range(0.2, 0.8))`,
496
529
  'jazz': `s("brush:1").struct("~ 1 ~ 1 ~ 1 ~ ~").gain(0.1).room(0.5)`,
497
530
  };
498
- return texturePatterns[style] || texturePatterns['techno'];
531
+ return lookup(texturePatterns, style, texturePatterns['techno']);
499
532
  }
500
533
  default:
501
534
  throw new Error(`Unknown layer type: ${layer}`);