@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
@@ -20,6 +20,8 @@ export class AudioCaptureService {
20
20
  DEFAULT_SAMPLE_RATE = 48000;
21
21
  DEFAULT_DURATION_MS = 5000;
22
22
  MIME_TYPE = 'audio/webm;codecs=opus';
23
+ /** Cap on an unbounded streaming capture. Ten minutes (#437). */
24
+ static DEFAULT_MAX_CAPTURE_MS = 10 * 60 * 1000;
23
25
  /**
24
26
  * Injects audio capture code into the Strudel page.
25
27
  * Must be called before any capture operations.
@@ -27,10 +29,83 @@ export class AudioCaptureService {
27
29
  * @param page - Playwright page instance to inject into
28
30
  * @throws {Error} When injection fails
29
31
  */
32
+ /**
33
+ * The page this service's recorder was injected into.
34
+ *
35
+ * A service is bound to one page: `injectRecorder` defines
36
+ * `window.strudelAudioCapture` there, and every later call evaluates
37
+ * against it. Callers that cache services must be able to tell whether
38
+ * a cached one still matches the page they are about to use — a session
39
+ * recreated under the same id, or a browser recovered by `init`, gets a
40
+ * NEW page, and the old recorder does not exist on it (#264).
41
+ */
42
+ injectedPage = null;
43
+ /**
44
+ * Whether this service's recorder is live on the given page.
45
+ *
46
+ * Asks the page, rather than comparing object identity. A Playwright
47
+ * `Page` outlives the JS realm it points at: reload strudel.cc — which
48
+ * a user can do at any time, since CLAUDE.md makes the visible browser
49
+ * window the intended interface — and `window.strudelAudioCapture` is
50
+ * gone while `injectedPage === page` still holds.
51
+ *
52
+ * The cached service was then returned without re-injecting, and every
53
+ * capture and export failed with "Audio capture not initialized" for
54
+ * the rest of the session. `init` could not recover it either:
55
+ * `initialize()` returns 'Already initialized' whenever the page is
56
+ * alive, so the same page and the same cached service came back (#437).
57
+ *
58
+ * Identity is still checked first — it is free, and it catches the
59
+ * recreated-session and recovered-browser cases (#264) without a round
60
+ * trip to the page.
61
+ */
62
+ async isInjectedInto(page) {
63
+ if (this.injectedPage !== page)
64
+ return false;
65
+ try {
66
+ return await page.evaluate(/* istanbul ignore next */ () => typeof window.strudelAudioCapture === 'object'
67
+ && window.strudelAudioCapture !== null);
68
+ }
69
+ catch {
70
+ // An unreadable page is not an injected one.
71
+ return false;
72
+ }
73
+ }
30
74
  async injectRecorder(page) {
31
75
  /* istanbul ignore next -- browser-injected IIFE, covered by integration tests */
32
- await page.evaluate(() => {
76
+ await page.evaluate(/* istanbul ignore next */ () => {
77
+ // Clear the previous object's timers before replacing it.
78
+ //
79
+ // This assignment overwrites `strudelAudioCapture` outright, and
80
+ // a `setTimeout` does not stop because nothing points at its
81
+ // owner any more. `server.ts` builds a fresh service for a page
82
+ // that may already carry an injected object, so a cap timer or a
83
+ // stop watchdog from the old one could still fire — and both of
84
+ // them reach in and empty `chunks`, which is how the previous
85
+ // capture's watchdog destroyed the NEXT capture in #464.
86
+ //
87
+ // The same hazard, measured on the analyzer's collector, is
88
+ // #479: a re-inject there left the old 20ms interval sampling
89
+ // into an orphaned array.
90
+ const previous = window.strudelAudioCapture;
91
+ if (previous) {
92
+ if (previous.capTimer)
93
+ clearTimeout(previous.capTimer);
94
+ if (previous.stopWatchdog)
95
+ clearTimeout(previous.stopWatchdog);
96
+ previous.capTimer = null;
97
+ previous.stopWatchdog = null;
98
+ previous.isCapturing = false;
99
+ }
33
100
  window.strudelAudioCapture = {
101
+ /** In-flight stop, so a second one waits rather than racing it. */
102
+ stopping: null,
103
+ /** Auto-stop at maxDuration; cleared when a stop lands first. */
104
+ capTimer: null,
105
+ /** Set when the cap ended the recording, so a later stop knows. */
106
+ reachedCap: false,
107
+ /** Watchdog for a stop that never completes; cleared when it does. */
108
+ stopWatchdog: null,
34
109
  mediaStreamDest: null,
35
110
  recorder: null,
36
111
  isConnected: false,
@@ -64,7 +139,7 @@ export class AudioCaptureService {
64
139
  * Starts audio capture
65
140
  * @returns Success status and any error message
66
141
  */
67
- startCapture() {
142
+ startCapture(maxDurationMs) {
68
143
  const capture = window.strudelAudioCapture;
69
144
  if (!capture.isConnected || !capture.mediaStreamDest) {
70
145
  return {
@@ -76,9 +151,21 @@ export class AudioCaptureService {
76
151
  return { success: false, error: 'Capture already in progress.' };
77
152
  }
78
153
  try {
79
- // Reset state
154
+ // Reset state. Timers included: a leftover timer from an
155
+ // earlier capture fires against THIS one, and both of them
156
+ // reach in and empty `chunks` (#464).
80
157
  capture.chunks = [];
81
158
  capture.error = null;
159
+ capture.reachedCap = false;
160
+ if (capture.capTimer !== null) {
161
+ clearTimeout(capture.capTimer);
162
+ capture.capTimer = null;
163
+ }
164
+ if (capture.stopWatchdog !== null) {
165
+ clearTimeout(capture.stopWatchdog);
166
+ capture.stopWatchdog = null;
167
+ }
168
+ capture.stopping = null;
82
169
  // Create MediaRecorder with WebM/Opus codec (Gemini compatible)
83
170
  const options = {
84
171
  mimeType: 'audio/webm;codecs=opus'
@@ -98,10 +185,45 @@ export class AudioCaptureService {
98
185
  };
99
186
  capture.startTime = Date.now();
100
187
  capture.isCapturing = true;
188
+ // A previous capture's error is not this one's.
189
+ capture.error = null;
101
190
  capture.recorder.start(100); // Collect data every 100ms
191
+ // A streaming capture had no upper bound at all: it
192
+ // accumulated a 100ms blob in page memory until someone
193
+ // stopped it, and `maxDuration` — advertised by the tool —
194
+ // was accepted and discarded (#437).
195
+ //
196
+ // The cap stops the recorder and marks WHY. Without the mark
197
+ // the recording was thrown away: `onstop` clears
198
+ // `isCapturing`, so a later `stop` fell into the "No capture
199
+ // in progress" branch with every chunk still sitting in
200
+ // `capture.chunks`. The comment here used to claim the
201
+ // opposite — and with a ten-minute default on every capture,
202
+ // any long take lost all of its audio (#464).
203
+ if (typeof maxDurationMs === 'number' && maxDurationMs > 0) {
204
+ capture.capTimer = setTimeout(() => {
205
+ if (!capture.isCapturing)
206
+ return;
207
+ capture.reachedCap = true;
208
+ try {
209
+ capture.recorder.stop();
210
+ }
211
+ catch { /* already stopped */ }
212
+ }, maxDurationMs);
213
+ }
102
214
  return { success: true };
103
215
  }
104
216
  catch (err) {
217
+ // Reset before returning, or the page is wedged for good.
218
+ //
219
+ // `isCapturing` is set just above, and `recorder.start()` can
220
+ // throw — on a track-less stream, for instance. The flag then
221
+ // stayed true while the Node mirror stayed false, so every
222
+ // later start got "Capture already in progress", every export
223
+ // got "A capture is already in progress", and stop was
224
+ // refused by the Node-side gate. Nothing cleared it short of
225
+ // a new page (#437).
226
+ capture.isCapturing = false;
105
227
  return { success: false, error: err.message || 'Failed to start capture' };
106
228
  }
107
229
  },
@@ -112,13 +234,89 @@ export class AudioCaptureService {
112
234
  async stopCapture() {
113
235
  const capture = window.strudelAudioCapture;
114
236
  if (!capture.recorder || !capture.isCapturing) {
237
+ // The cap ended it. That is a finished recording, not an
238
+ // absent one — hand it over (#464).
239
+ if (capture.reachedCap === true && capture.chunks.length > 0) {
240
+ const collected = capture.chunks;
241
+ capture.chunks = [];
242
+ capture.reachedCap = false;
243
+ return {
244
+ success: true,
245
+ blob: new Blob(collected, { type: 'audio/webm;codecs=opus' }),
246
+ duration: Date.now() - capture.startTime,
247
+ format: 'audio/webm;codecs=opus',
248
+ warning: 'Recording stopped at maxDuration; this is the audio up to that point.',
249
+ };
250
+ }
251
+ // A recorder error clears `isCapturing` too, so this branch
252
+ // covered two very different situations with one message.
253
+ // `capture.error` was set and nothing ever read it — the
254
+ // real cause was discarded and the caller was told there had
255
+ // been no capture at all (#437).
256
+ if (capture.error !== null) {
257
+ const failure = capture.error;
258
+ const orphaned = capture.chunks.length;
259
+ capture.error = null;
260
+ capture.chunks = [];
261
+ return {
262
+ success: false,
263
+ error: `${failure}. ${String(orphaned)} chunk(s) were recorded before it and have been discarded.`,
264
+ };
265
+ }
115
266
  return { success: false, error: 'No capture in progress.' };
116
267
  }
117
- return new Promise((resolve) => {
268
+ // A stop already in flight is awaited, not started again.
269
+ //
270
+ // `isCapturing` is cleared inside `onstop`, which has not run
271
+ // yet when a second stop arrives — so both calls got past the
272
+ // check above, the second overwrote the first's `onstop` and
273
+ // called `stop()` on an inactive recorder. The second threw
274
+ // InvalidStateError; the FIRST call's resolver became
275
+ // unreachable, and `page.evaluate` has no timeout, so that MCP
276
+ // request never returned (#437).
277
+ if (capture.stopping) {
278
+ return await capture.stopping;
279
+ }
280
+ capture.stopping = new Promise((resolve) => {
118
281
  const recorder = capture.recorder;
119
282
  const startTime = capture.startTime;
283
+ // Both timers die on whichever path settles first. The
284
+ // watchdog handle was not kept at all, so it outlived its
285
+ // own capture and fired during the NEXT one — passing its
286
+ // guard, clearing `isCapturing` and emptying `chunks`, which
287
+ // destroyed a recording that was going perfectly well. The
288
+ // cap timer leaked the same way across captures and could
289
+ // stop a recorder that belonged to a later take (#464).
290
+ // Method shorthand, not `const clearTimers = () => {}`:
291
+ // esbuild rewrites the arrow into `__name(fn, "fn")` and
292
+ // `__name` does not exist in page context. Written as an
293
+ // arrow first; PageEvaluateNameWrapping.test.ts caught it.
294
+ const timers = {
295
+ clear() {
296
+ if (capture.capTimer !== null) {
297
+ clearTimeout(capture.capTimer);
298
+ capture.capTimer = null;
299
+ }
300
+ if (capture.stopWatchdog !== null) {
301
+ clearTimeout(capture.stopWatchdog);
302
+ capture.stopWatchdog = null;
303
+ }
304
+ },
305
+ };
120
306
  recorder.onstop = () => {
121
307
  capture.isCapturing = false;
308
+ timers.clear();
309
+ // An error that fired after this handler was installed
310
+ // still delivers `dataavailable` and `stop`, so this used
311
+ // to return success:true over a truncated recording and
312
+ // the tool reported a normal capture (#437).
313
+ if (capture.error !== null) {
314
+ const failure = capture.error;
315
+ capture.error = null;
316
+ capture.chunks = [];
317
+ resolve({ success: false, error: `${failure} during stop; the recording is incomplete.` });
318
+ return;
319
+ }
122
320
  const duration = Date.now() - startTime;
123
321
  if (capture.chunks.length === 0) {
124
322
  resolve({ success: false, error: 'No audio data captured.' });
@@ -133,8 +331,41 @@ export class AudioCaptureService {
133
331
  format: 'audio/webm;codecs=opus'
134
332
  });
135
333
  };
334
+ // A stop that never completes must not hang the request.
335
+ //
336
+ // `page.evaluate` has no timeout, so if `onstop` never fires
337
+ // — a wedged recorder, a stream whose tracks died — the MCP
338
+ // call waits forever. Resolving with whatever was collected
339
+ // is a worse recording than the caller asked for and an
340
+ // infinitely better outcome than no answer (#437).
341
+ capture.stopWatchdog = setTimeout(() => {
342
+ // Always settles. This used to `return` without resolving
343
+ // when the capture was already flagged done and empty —
344
+ // which is precisely the never-returning request #437 was
345
+ // written to remove, reintroduced by its own fix (#464).
346
+ capture.stopWatchdog = null;
347
+ timers.clear();
348
+ capture.isCapturing = false;
349
+ const collected = capture.chunks;
350
+ capture.chunks = [];
351
+ resolve(collected.length === 0
352
+ ? { success: false, error: 'Recorder did not stop, and nothing was captured.' }
353
+ : {
354
+ success: true,
355
+ blob: new Blob(collected, { type: 'audio/webm;codecs=opus' }),
356
+ duration: Date.now() - startTime,
357
+ format: 'audio/webm;codecs=opus',
358
+ warning: 'Recorder did not stop cleanly; returned what was captured.',
359
+ });
360
+ }, 5000);
136
361
  recorder.stop();
137
362
  });
363
+ try {
364
+ return await capture.stopping;
365
+ }
366
+ finally {
367
+ capture.stopping = null;
368
+ }
138
369
  },
139
370
  /**
140
371
  * Returns current capture state
@@ -151,6 +382,7 @@ export class AudioCaptureService {
151
382
  // Initialize connection interception
152
383
  window.strudelAudioCapture.connect();
153
384
  });
385
+ this.injectedPage = page;
154
386
  this.logger.debug('Audio capture injected');
155
387
  }
156
388
  /**
@@ -161,14 +393,19 @@ export class AudioCaptureService {
161
393
  * recorder config is fixed at injectRecorder time)
162
394
  * @throws {Error} When capture fails to start
163
395
  */
164
- async startCapture(page, _config) {
165
- const result = await page.evaluate(/* istanbul ignore next */ () => {
396
+ async startCapture(page, config) {
397
+ // Bounded even when the caller does not ask. An unbounded streaming
398
+ // capture grows in page memory until someone remembers to stop it,
399
+ // and nothing here ever timed it out (#437). Ten minutes is far
400
+ // longer than any musical take and far shorter than forever.
401
+ const maxDurationMs = config?.maxDuration ?? AudioCaptureService.DEFAULT_MAX_CAPTURE_MS;
402
+ const result = await page.evaluate(/* istanbul ignore next */ (capMs) => {
166
403
  const capture = window.strudelAudioCapture;
167
404
  if (!capture) {
168
405
  return { success: false, error: 'Audio capture not initialized. Call injectRecorder first.' };
169
406
  }
170
- return capture.startCapture();
171
- });
407
+ return capture.startCapture(capMs);
408
+ }, maxDurationMs);
172
409
  if (!result.success) {
173
410
  throw new Error(result.error || 'Failed to start capture');
174
411
  }
@@ -189,18 +426,50 @@ export class AudioCaptureService {
189
426
  if (!capture) {
190
427
  return { success: false, error: 'Audio capture not initialized.' };
191
428
  }
192
- return await capture.stopCapture();
429
+ const captureResult = await capture.stopCapture();
430
+ if (!captureResult.success || !captureResult.blob) {
431
+ return captureResult;
432
+ }
433
+ // Playwright cannot serialize a browser Blob into a Node Blob with
434
+ // methods intact. Convert to base64 inside the browser context and
435
+ // rehydrate a real Node-side Blob below.
436
+ const buffer = await captureResult.blob.arrayBuffer();
437
+ const bytes = new Uint8Array(buffer);
438
+ let binary = '';
439
+ for (let i = 0; i < bytes.length; i++) {
440
+ binary += String.fromCharCode(bytes[i]);
441
+ }
442
+ return {
443
+ success: true,
444
+ audio: btoa(binary),
445
+ duration: captureResult.duration,
446
+ format: captureResult.format,
447
+ warning: captureResult.warning,
448
+ };
449
+ }).finally(() => {
450
+ // Cleared whatever happens.
451
+ //
452
+ // This assignment sat after the await, so a rejected evaluate --
453
+ // a closed page, a navigation, a crashed renderer -- skipped it
454
+ // and left the Node mirror reading "capturing" for a capture that
455
+ // is over. Recoverable, since a later stop clears it, but it
456
+ // costs a wasted call and makes `isCapturing()` lie until then
457
+ // (#437 item 8).
458
+ this._isCapturing = false;
193
459
  });
194
- this._isCapturing = false;
195
460
  if (!result.success) {
196
461
  throw new Error(result.error || 'Failed to stop capture');
197
462
  }
198
463
  this.logger.debug(`Audio capture stopped: ${result.duration}ms`);
464
+ const blob = result.audio
465
+ ? new Blob([Buffer.from(result.audio, 'base64')], { type: result.format })
466
+ : result.blob;
199
467
  return {
200
- blob: result.blob,
468
+ blob,
201
469
  duration: result.duration,
202
470
  format: result.format,
203
- timestamp: Date.now()
471
+ timestamp: Date.now(),
472
+ ...(result.warning ? { warning: result.warning } : {}),
204
473
  };
205
474
  }
206
475
  /**
@@ -232,16 +501,30 @@ export class AudioCaptureService {
232
501
  return this._isCapturing;
233
502
  }
234
503
  /**
235
- * Returns the elapsed capture time in milliseconds.
236
- * Returns 0 if not currently capturing.
504
+ * Whether the PAGE thinks a capture is running, whoever started it.
505
+ *
506
+ * `isCapturing()` reports this service's own mirror. `export_audio`
507
+ * drives the page-side recorder directly and never touches that
508
+ * mirror, so for the whole of a 30s export the mirror read false and
509
+ * `audio_capture({action:'stop'})` answered "No audio capture in
510
+ * progress" — which is not true, and told the caller the opposite of
511
+ * what was happening (#437 item 9).
237
512
  *
238
- * @returns Elapsed time in milliseconds
513
+ * Reported rather than acted on: stopping the export's recorder from
514
+ * here would leave the export's own `stopCapture` with nothing to
515
+ * collect, which is a worse outcome than saying what is going on.
516
+ *
517
+ * @param page - Playwright page instance
518
+ * @returns true when the injected recorder is mid-capture
239
519
  */
240
- getElapsedTime() {
241
- if (!this._isCapturing) {
242
- return 0;
520
+ async isPageCapturing(page) {
521
+ try {
522
+ return await page.evaluate(/* istanbul ignore next */ () => window.strudelAudioCapture?.isCapturing === true);
523
+ }
524
+ catch {
525
+ // An unreadable page is not a capturing one.
526
+ return false;
243
527
  }
244
- return Date.now() - this._startTime;
245
528
  }
246
529
  /**
247
530
  * Checks if audio capture is connected to Strudel's audio output.
@@ -255,20 +538,6 @@ export class AudioCaptureService {
255
538
  return capture?.isConnected ?? false;
256
539
  });
257
540
  }
258
- /**
259
- * Clears any recorded chunks without stopping capture.
260
- * Useful for discarding unwanted audio.
261
- *
262
- * @param page - Playwright page instance
263
- */
264
- async clearChunks(page) {
265
- await page.evaluate(/* istanbul ignore next */ () => {
266
- const capture = window.strudelAudioCapture;
267
- if (capture) {
268
- capture.chunks = [];
269
- }
270
- });
271
- }
272
541
  /**
273
542
  * Gets the MIME type used for recording.
274
543
  *