@williamzujkowski/live-coding-music-mcp 3.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 +212 -780
  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 +391 -83
  7. package/dist/server/tools/ai.js +129 -152
  8. package/dist/server/tools/analysis.js +170 -91
  9. package/dist/server/tools/capture.js +128 -55
  10. package/dist/server/tools/compose.js +28 -42
  11. package/dist/server/tools/diagnostics.js +23 -53
  12. package/dist/server/tools/editor.js +63 -72
  13. package/dist/server/tools/generate.js +45 -167
  14. package/dist/server/tools/history.js +35 -66
  15. package/dist/server/tools/playback.js +7 -22
  16. package/dist/server/tools/session.js +41 -47
  17. package/dist/server/tools/storage.js +173 -50
  18. package/dist/server/tools/transform.js +136 -199
  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
@@ -1,22 +1,21 @@
1
1
  /**
2
2
  * analysis domain — audio analysis + pattern validation (browser + local).
3
3
  *
4
- * Owns (10 tools):
5
- * browser-required: analyze, analyze_spectrum, analyze_rhythm,
6
- * detect_tempo, detect_key, validate_pattern_runtime
4
+ * Owns (6 tools):
5
+ * browser-required: analyze (include[] filter), validate_pattern_runtime
7
6
  * browser-free: validate_pattern_local, analyze_pattern_local,
8
7
  * query_pattern_events, transpile_pattern
9
8
  *
10
- * The browser-free set runs against the in-process StrudelEngine and
9
+ * The browser-free set runs against the local engine — since #307, a
10
+ * forked child with a heap cap and a deadline, not this process — and
11
11
  * pairs naturally with validate_pattern_runtime (which monitors the
12
12
  * actual Strudel console). They were orphan handlers — implementations
13
13
  * present but not registered in tools/list — until #124.
14
- *
15
- * Post-consolidation (per #110 audit): a single `analyze` tool with
16
- * an `include[]` filter absorbs the 4 audio detection tools.
17
- * Local-engine tools stay distinct — different subsystem.
18
14
  */
15
+ import { empty, err, ok } from './types.js';
19
16
  import { InputValidator } from '../../utils/InputValidator.js';
17
+ import { IsolatedRunnerError } from '../../services/IsolatedEngineRunner.js';
18
+ import { ValidationError } from '../../utils/CategorisedError.js';
20
19
  const SESSION_ID_PROP = {
21
20
  session_id: {
22
21
  type: 'string',
@@ -47,26 +46,6 @@ export const tools = [
47
46
  },
48
47
  },
49
48
  },
50
- {
51
- name: 'analyze_spectrum',
52
- description: '[DEPRECATED — use analyze({ include: ["spectrum"] }) instead] FFT spectrum analysis',
53
- inputSchema: { type: 'object', properties: { ...SESSION_ID_PROP } },
54
- },
55
- {
56
- name: 'analyze_rhythm',
57
- description: '[DEPRECATED — use analyze({ include: ["rhythm"] }) instead] Rhythm analysis',
58
- inputSchema: { type: 'object', properties: { ...SESSION_ID_PROP } },
59
- },
60
- {
61
- name: 'detect_tempo',
62
- description: '[DEPRECATED — use analyze({ include: ["tempo"] }) instead] BPM detection',
63
- inputSchema: { type: 'object', properties: { ...SESSION_ID_PROP } },
64
- },
65
- {
66
- name: 'detect_key',
67
- description: '[DEPRECATED — use analyze({ include: ["key"] }) instead] Key detection',
68
- inputSchema: { type: 'object', properties: { ...SESSION_ID_PROP } },
69
- },
70
49
  {
71
50
  name: 'validate_pattern_runtime',
72
51
  description: 'Validate pattern with runtime error checking (monitors Strudel console for errors)',
@@ -82,7 +61,7 @@ export const tools = [
82
61
  },
83
62
  {
84
63
  name: 'validate_pattern_local',
85
- description: 'Validate pattern syntax against the in-process StrudelEngine (no browser required)',
64
+ description: 'Validate pattern syntax against the local StrudelEngine, which runs in a sandboxed child process (no browser required)',
86
65
  inputSchema: {
87
66
  type: 'object',
88
67
  properties: {
@@ -143,57 +122,80 @@ async function getRhythm(controller) {
143
122
  return await controller.analyzeRhythm();
144
123
  }
145
124
  async function getTempo(controller) {
146
- try {
147
- const tempoAnalysis = await controller.detectTempo();
148
- if (!tempoAnalysis || tempoAnalysis.bpm === 0) {
149
- return {
150
- bpm: 0,
151
- confidence: 0,
152
- message: 'No tempo detected. Ensure audio is playing and has a clear rhythmic pattern.',
153
- };
154
- }
125
+ const tempoAnalysis = await controller.detectTempo();
126
+ if (!tempoAnalysis || tempoAnalysis.bpm === 0) {
127
+ // The measurement ran and detected nothing. `bpm: 0` read as a
128
+ // measured value — zero beats per minute — so null plus an
129
+ // explicit flag (#288). No envelope here: getTempo is a sub-result
130
+ // composed into analyze's object, and an envelope nested inside a
131
+ // data payload is not the contract.
132
+ //
133
+ // Two different states arrive here. A reading that is merely
134
+ // settling has a candidate already and needs another poll; telling
135
+ // its caller to check whether audio is playing, while audio IS
136
+ // playing, sends it to fix the wrong thing (#374).
155
137
  return {
156
- bpm: tempoAnalysis.bpm,
157
- confidence: Math.round(tempoAnalysis.confidence * 100) / 100,
158
- method: tempoAnalysis.method,
159
- message: `Detected ${tempoAnalysis.bpm} BPM with ${Math.round(tempoAnalysis.confidence * 100)}% confidence`,
138
+ bpm: null,
139
+ detected: false,
140
+ confidence: 0,
141
+ message: tempoAnalysis?.settling === true
142
+ ? 'Tempo is still settling — a reading was taken but not yet confirmed by a second '
143
+ + 'window. Call again in a second or two.'
144
+ : 'No tempo detected. Ensure audio is playing and has a clear rhythmic pattern.',
160
145
  };
161
146
  }
162
- catch (error) {
163
- const message = error instanceof Error ? error.message : String(error);
164
- return { bpm: 0, confidence: 0, error: message || 'Tempo detection failed' };
165
- }
147
+ return {
148
+ bpm: tempoAnalysis.bpm,
149
+ confidence: Math.round(tempoAnalysis.confidence * 100) / 100,
150
+ method: tempoAnalysis.method,
151
+ message: `Detected ${tempoAnalysis.bpm} BPM with ${Math.round(tempoAnalysis.confidence * 100)}% confidence`,
152
+ };
153
+ // No catch here on purpose.
154
+ //
155
+ // It used to return `{bpm: null, detected: false, error: message}`,
156
+ // which analyze nests under the `tempo` key — where `isFailureShaped`
157
+ // cannot see it, since that inspects only the top level. So a dead
158
+ // browser reached the client as:
159
+ //
160
+ // include:['tempo'] -> {ok:true, data:{tempo:{bpm:null,
161
+ // error:'Target page ... has been closed'}}}
162
+ // include:['all'] -> a failure envelope
163
+ //
164
+ // One tool, one fault, opposite verdicts (#453). Letting it propagate
165
+ // makes both agree, and keeps the ORIGINAL error so `categorizeError`
166
+ // reads its type rather than a rebuilt message (#382).
167
+ //
168
+ // The no-detection branch above is untouched: that is a measurement
169
+ // that ran and found nothing, which is data, not a failure (#288).
166
170
  }
167
171
  async function getKey(controller) {
168
- try {
169
- const keyAnalysis = await controller.detectKey();
170
- if (!keyAnalysis || keyAnalysis.confidence < 0.1) {
171
- return {
172
- key: 'Unknown',
173
- scale: 'unknown',
174
- confidence: 0,
175
- message: 'No clear key detected. Ensure audio is playing and has tonal content.',
176
- };
177
- }
178
- const result = {
179
- key: keyAnalysis.key,
180
- scale: keyAnalysis.scale,
181
- confidence: Math.round(keyAnalysis.confidence * 100) / 100,
182
- message: `Detected ${keyAnalysis.key} ${keyAnalysis.scale} with ${Math.round(keyAnalysis.confidence * 100)}% confidence`,
172
+ // No try/catch, for the reason given on getTempo: an error nested
173
+ // inside a data payload is invisible to `isFailureShaped` and reached
174
+ // the client as ok:true (#453).
175
+ const keyAnalysis = await controller.detectKey();
176
+ if (!keyAnalysis || keyAnalysis.confidence < 0.1) {
177
+ return {
178
+ key: null,
179
+ scale: null,
180
+ detected: false,
181
+ confidence: 0,
182
+ message: 'No clear key detected. Ensure audio is playing and has tonal content.',
183
183
  };
184
- if (keyAnalysis.alternatives && keyAnalysis.alternatives.length > 0) {
185
- result.alternatives = keyAnalysis.alternatives.map((alt) => ({
186
- key: alt.key,
187
- scale: alt.scale,
188
- confidence: Math.round(alt.confidence * 100) / 100,
189
- }));
190
- }
191
- return result;
192
184
  }
193
- catch (error) {
194
- const message = error instanceof Error ? error.message : String(error);
195
- return { key: 'Unknown', scale: 'unknown', confidence: 0, error: message || 'Key detection failed' };
185
+ const result = {
186
+ key: keyAnalysis.key,
187
+ scale: keyAnalysis.scale,
188
+ confidence: Math.round(keyAnalysis.confidence * 100) / 100,
189
+ message: `Detected ${keyAnalysis.key} ${keyAnalysis.scale} with ${Math.round(keyAnalysis.confidence * 100)}% confidence`,
190
+ };
191
+ if (keyAnalysis.alternatives && keyAnalysis.alternatives.length > 0) {
192
+ result.alternatives = keyAnalysis.alternatives.map((alt) => ({
193
+ key: alt.key,
194
+ scale: alt.scale,
195
+ confidence: Math.round(alt.confidence * 100) / 100,
196
+ }));
196
197
  }
198
+ return result;
197
199
  }
198
200
  async function doAnalyze(args, controller) {
199
201
  const includeRaw = args?.include;
@@ -223,7 +225,44 @@ async function doAnalyze(args, controller) {
223
225
  const entries = await Promise.all(Object.entries(tasks).map(async ([k, p]) => [k, await p]));
224
226
  return Object.fromEntries(entries);
225
227
  }
228
+ /**
229
+ * Turns a death of the isolated engine child into an envelope (#307).
230
+ *
231
+ * All four local-engine tools funnel through here, which is the point:
232
+ * one isolation path, one place that decides what a caller is told.
233
+ *
234
+ * The distinction the categories carry is whether retrying could possibly
235
+ * help. It cannot for an OOM — `new Array(5e7).fill(7)` will exhaust the
236
+ * cap every time it is sent — so that is the caller's input to fix, not a
237
+ * transient condition to wait out. A hang or an unexplained crash might
238
+ * well be transient. Recording this because the vote on #307 said "one
239
+ * retryable envelope" for all of them, and a blanket `isRetryable: true`
240
+ * would invite an agent into a loop it cannot win.
241
+ */
242
+ function isolationFailure(error) {
243
+ // Not retryable, and for two different reasons. An OOM is the caller's
244
+ // input: `s("[bd*99999]*99999")` exhausts the cap every time it is
245
+ // sent. A spawn failure is the deployment: a missing or unbuilt child
246
+ // is missing on the next attempt too. Both would put an agent in a loop
247
+ // it cannot win, which is why the vote's "one retryable envelope for
248
+ // everything" is not what shipped.
249
+ if (error.kind === 'oom')
250
+ return err('validation', error.message);
251
+ if (error.kind === 'spawn')
252
+ return err('internal', error.message);
253
+ return err('transient', error.message, { isRetryable: true });
254
+ }
226
255
  export async function execute(name, args, ctx) {
256
+ try {
257
+ return await run(name, args, ctx);
258
+ }
259
+ catch (error) {
260
+ if (error instanceof IsolatedRunnerError)
261
+ return isolationFailure(error);
262
+ throw error;
263
+ }
264
+ }
265
+ async function run(name, args, ctx) {
227
266
  const sid = args?.session_id;
228
267
  if (!LOCAL_ENGINE_TOOLS.has(name) && !sid && !ctx.isInitialized()) {
229
268
  return 'Browser not initialized. Run init first.';
@@ -232,11 +271,6 @@ export async function execute(name, args, ctx) {
232
271
  switch (name) {
233
272
  case 'analyze':
234
273
  return await doAnalyze(args, controller);
235
- // Deprecated aliases — forward to consolidated handler.
236
- case 'analyze_spectrum': return await getSpectrum(controller);
237
- case 'analyze_rhythm': return await getRhythm(controller);
238
- case 'detect_tempo': return await getTempo(controller);
239
- case 'detect_key': return await getKey(controller);
240
274
  case 'validate_pattern_runtime': {
241
275
  InputValidator.validateStringLength(args.pattern, 'pattern', 10000, false);
242
276
  const validation = await controller.validatePatternRuntime(args.pattern, args.waitMs || 500);
@@ -248,7 +282,7 @@ export async function execute(name, args, ctx) {
248
282
  }
249
283
  case 'validate_pattern_local': {
250
284
  InputValidator.validateStringLength(args.pattern, 'pattern', 10000, false);
251
- const localValidation = ctx.strudelEngine.validate(args.pattern);
285
+ const localValidation = await ctx.strudelEngine.validate(args.pattern);
252
286
  return {
253
287
  valid: localValidation.valid,
254
288
  errors: localValidation.errors,
@@ -262,7 +296,29 @@ export async function execute(name, args, ctx) {
262
296
  }
263
297
  case 'analyze_pattern_local': {
264
298
  InputValidator.validateStringLength(args.pattern, 'pattern', 10000, false);
265
- const patternMetadata = ctx.strudelEngine.analyzePattern(args.pattern);
299
+ const patternMetadata = await ctx.strudelEngine.analyzePattern(args.pattern);
300
+ // Do not report event counts the engine never measured. When
301
+ // evaluation failed, `eventsPerCycle: 0` is a default, not a
302
+ // finding — and it is indistinguishable from a genuinely silent
303
+ // pattern, so an agent was told working code produced nothing
304
+ // (#276). Say what happened instead.
305
+ if (!patternMetadata.evaluated) {
306
+ // Build explicitly rather than destructuring the unwanted fields
307
+ // out: naming them only to discard them trips no-unused-vars, and
308
+ // listing what IS reported is clearer about the contract anyway.
309
+ return {
310
+ usesSound: patternMetadata.usesSound,
311
+ usesNote: patternMetadata.usesNote,
312
+ isStack: patternMetadata.isStack,
313
+ functionsUsed: patternMetadata.functionsUsed,
314
+ complexity: patternMetadata.complexity,
315
+ ...(patternMetadata.bpm !== undefined ? { bpm: patternMetadata.bpm } : {}),
316
+ evaluated: false,
317
+ message: 'Pattern could not be evaluated, so event counts are unavailable. ' +
318
+ 'The static analysis below is still valid. ' +
319
+ (patternMetadata.evaluationError ?? ''),
320
+ };
321
+ }
266
322
  return {
267
323
  ...patternMetadata,
268
324
  message: `Pattern analysis: ${patternMetadata.eventsPerCycle} events/cycle, ` +
@@ -275,14 +331,22 @@ export async function execute(name, args, ctx) {
275
331
  const startCycle = args.start ?? 0;
276
332
  const endCycle = args.end ?? 1;
277
333
  if (startCycle >= endCycle) {
278
- return { error: 'Start must be less than end' };
334
+ // Categorised explicitly rather than by whether the message
335
+ // happens to contain "must be" — which is the only reason this
336
+ // one landed in `validation` while the range check below landed
337
+ // in `internal` (#453).
338
+ return err('validation', 'Start must be less than end');
279
339
  }
280
340
  if (endCycle - startCycle > 16) {
281
- return { error: 'Maximum range is 16 cycles to prevent excessive output' };
341
+ return err('validation', 'Maximum range is 16 cycles to prevent excessive output');
282
342
  }
283
343
  try {
284
- const events = ctx.strudelEngine.queryEvents(args.pattern, startCycle, endCycle);
285
- return {
344
+ const events = await ctx.strudelEngine.queryEvents(args.pattern, startCycle, endCycle);
345
+ // A silent pattern is a valid-empty query, not a failed one —
346
+ // and not the same as a pattern with events, which is what a
347
+ // plain ok() made it look like (#288).
348
+ const wrap = events.length === 0 ? empty : ok;
349
+ return wrap({
286
350
  count: events.length,
287
351
  range: { start: startCycle, end: endCycle },
288
352
  events: events.map((e) => ({
@@ -291,9 +355,22 @@ export async function execute(name, args, ctx) {
291
355
  end: e.end,
292
356
  duration: e.end - e.start,
293
357
  })),
294
- };
358
+ });
295
359
  }
296
360
  catch (error) {
361
+ // The engine dying is not a syntax problem, and telling the
362
+ // caller to go run the validator would send them to a tool that
363
+ // will die the same way. Let it reach isolationFailure (#307).
364
+ if (error instanceof IsolatedRunnerError)
365
+ throw error;
366
+ // A refusal that already knows it is the caller's input keeps
367
+ // that verdict. Flattening it into this shape sent it through
368
+ // the dispatcher as a bare message, which categorised "Pattern
369
+ // produces roughly 2e10 events, above the 50000 cap" as
370
+ // `internal` — found by the protocol route check, after two
371
+ // rounds of fixing this exact class of bug (#382).
372
+ if (error instanceof ValidationError)
373
+ return err('validation', error.message);
297
374
  const message = error instanceof Error ? error.message : String(error);
298
375
  return {
299
376
  error: message,
@@ -303,7 +380,7 @@ export async function execute(name, args, ctx) {
303
380
  }
304
381
  case 'transpile_pattern': {
305
382
  InputValidator.validateStringLength(args.pattern, 'pattern', 10000, false);
306
- const transpileResult = ctx.strudelEngine.transpile(args.pattern);
383
+ const transpileResult = await ctx.strudelEngine.transpile(args.pattern);
307
384
  if (transpileResult.success) {
308
385
  return {
309
386
  success: true,
@@ -311,12 +388,14 @@ export async function execute(name, args, ctx) {
311
388
  message: 'Pattern transpiled successfully',
312
389
  };
313
390
  }
314
- return {
315
- success: false,
316
- error: transpileResult.error,
317
- errorLocation: transpileResult.errorLocation,
318
- message: 'Transpilation failed',
319
- };
391
+ // A syntax error in the caller's pattern is the caller's input,
392
+ // not an internal fault — and `Unexpected token (1:6)` matches
393
+ // nothing the string matcher looks for (#453). The location is
394
+ // kept in the message, since an envelope carries no extra fields.
395
+ return err('validation', `Transpilation failed: ${String(transpileResult.error)}`
396
+ + (transpileResult.errorLocation !== undefined
397
+ ? ` (at ${String(transpileResult.errorLocation)})`
398
+ : ''));
320
399
  }
321
400
  default:
322
401
  throw new Error(`analysis module does not handle tool: ${name}`);