@williamzujkowski/live-coding-music-mcp 4.0.0 → 4.1.1

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 +27 -16
  52. package/dist/services/__mocks__/StrudelEngine.js +0 -157
  53. package/dist/types/index.js +0 -1
@@ -3,8 +3,25 @@ import * as path from 'path';
3
3
  import { promises as fs } from 'fs';
4
4
  import { AudioAnalyzer } from './AudioAnalyzer.js';
5
5
  import { PatternValidator } from './utils/PatternValidator.js';
6
+ import { DEFAULT_BLOCK_RESOURCES, DEFAULT_STRUDEL_URL, DEFAULT_VIEWPORT, } from './utils/ServerConfig.js';
7
+ import { resolveSafeOutputPath } from './utils/SafePath.js';
6
8
  import { ErrorRecovery } from './utils/ErrorRecovery.js';
7
9
  import { Logger } from './utils/Logger.js';
10
+ export async function waitForStrudelReady(page, timeoutMs = 5000) {
11
+ await page.waitForSelector('.cm-content', { timeout: 8000 });
12
+ try {
13
+ await page.waitForFunction(
14
+ /* istanbul ignore next */ () => Boolean(window.strudelMirror?.editor?.dispatch), { timeout: timeoutMs });
15
+ }
16
+ catch {
17
+ throw new Error(`Strudel editor did not become ready within ${timeoutMs}ms. ` +
18
+ 'The page may have failed to load or strudel.cc may have changed its API.');
19
+ }
20
+ }
21
+ export const DEFAULT_PLAYBACK_BUDGET = {
22
+ firstPlayMs: 15_000,
23
+ subsequentPlayMs: 3_000,
24
+ };
8
25
  export class StrudelController {
9
26
  browser = null;
10
27
  _page = null;
@@ -13,6 +30,12 @@ export class StrudelController {
13
30
  errorRecovery;
14
31
  logger;
15
32
  isHeadless;
33
+ /** Strudel REPL URL; `strudel_url` in config.json (#227). */
34
+ strudelUrl;
35
+ /** Playwright viewport; null lets the page track the OS window (#514). */
36
+ viewport;
37
+ /** Resource types the init route aborts (#514). */
38
+ blockResources;
16
39
  editorCache = '';
17
40
  cacheTimestamp = 0;
18
41
  CACHE_TTL = 100; // milliseconds
@@ -22,24 +45,65 @@ export class StrudelController {
22
45
  // (first play-button click per session). Subsequent plays can use the
23
46
  // keyboard shortcut cleanly because AudioContext is already resumed.
24
47
  hasEstablishedGesture = false;
48
+ playbackBudget;
25
49
  consoleErrors = [];
26
50
  consoleWarnings = [];
27
- constructor(headless = false) {
51
+ constructor(headless = false, audioAnalysisConfig, strudelUrl = DEFAULT_STRUDEL_URL,
52
+ // Injectable so a test asserting a start FAILURE does not have to
53
+ // wait out a budget sized for a real cold start.
54
+ playbackBudget = DEFAULT_PLAYBACK_BUDGET,
55
+ // Page-setup overrides from config.json `viewport` / `block_resources` (#514).
56
+ fidelity = {}) {
57
+ this.playbackBudget = playbackBudget;
28
58
  this.isHeadless = headless;
29
- this.analyzer = new AudioAnalyzer();
59
+ this.strudelUrl = strudelUrl;
60
+ this.viewport = fidelity.viewport !== undefined ? fidelity.viewport : DEFAULT_VIEWPORT;
61
+ this.blockResources = fidelity.blockResources ?? DEFAULT_BLOCK_RESOURCES;
62
+ this.analyzer = new AudioAnalyzer(audioAnalysisConfig);
30
63
  this.validator = new PatternValidator();
31
64
  this.errorRecovery = new ErrorRecovery();
32
65
  this.logger = new Logger();
33
66
  }
34
67
  /**
35
- * Initializes the browser and navigates to Strudel.cc
68
+ * Returns true iff both the browser and its page are still alive.
69
+ * Used by `initialize()` to detect a stale session where the user
70
+ * (or a crash) closed the page out from under us, and by callers
71
+ * that want to verify before issuing a tool call (#202).
72
+ */
73
+ /** The page whose console this controller is already listening to. */
74
+ consoleMonitoredPage = null;
75
+ isAlive() {
76
+ return this.browser !== null && this._page !== null && !this._page.isClosed();
77
+ }
78
+ /**
79
+ * Initializes the browser and navigates to Strudel.cc.
80
+ *
81
+ * Idempotent and self-healing (#202): if a previous init succeeded but
82
+ * the page has since been closed (manual user close, crash, navigation
83
+ * error), the next call tears down the dead browser and re-initializes
84
+ * rather than returning "Already initialized" and leaving callers to
85
+ * fail with `Target page, context or browser has been closed`.
86
+ *
36
87
  * @returns Success message when initialization is complete
37
88
  * @throws {Error} When browser launch or page navigation fails
38
89
  */
39
90
  async initialize() {
40
- if (this.browser) {
91
+ if (this.isAlive()) {
41
92
  return 'Already initialized';
42
93
  }
94
+ // Browser handle exists but page is dead — clean up before re-init so
95
+ // we don't leak a dangling Chromium process.
96
+ if (this.browser !== null) {
97
+ this.logger.warn('Stale browser detected (page closed); recreating');
98
+ try {
99
+ await this.cleanup();
100
+ }
101
+ catch (e) {
102
+ // cleanup failures shouldn't block re-init; the next launch is
103
+ // what matters. The stale process becomes the OS's problem.
104
+ this.logger.warn(`Cleanup during recovery failed: ${e?.message ?? e}`);
105
+ }
106
+ }
43
107
  this.browser = await chromium.launch({
44
108
  headless: this.isHeadless,
45
109
  args: [
@@ -54,33 +118,69 @@ export class StrudelController {
54
118
  });
55
119
  const context = await this.browser.newContext({
56
120
  permissions: ['microphone'],
57
- viewport: { width: 1280, height: 720 },
121
+ // null is meaningful: Playwright tracks the real window size, so
122
+ // resizes reach the page and canvases re-measure (#514).
123
+ viewport: this.viewport,
58
124
  reducedMotion: 'reduce',
59
125
  });
60
126
  this._page = await context.newPage();
61
127
  // Optimize page loading
62
128
  await this._page.route('**/*', (route) => {
63
129
  const resourceType = route.request().resourceType();
64
- // Block unnecessary resources
65
- if (['image', 'font', 'media'].includes(resourceType)) {
130
+ // Abort the configured resource classes; an empty
131
+ // block_resources blocks nothing (#514).
132
+ if (this.blockResources.includes(resourceType)) {
66
133
  route.abort();
67
134
  }
68
135
  else {
69
136
  route.continue();
70
137
  }
71
138
  });
72
- await this._page.goto('https://strudel.cc/', {
73
- waitUntil: 'domcontentloaded', // Changed from networkidle for faster load
74
- timeout: 15000,
75
- });
76
- // Wait for editor element to appear
77
- await this._page.waitForSelector('.cm-content', { timeout: 8000 });
78
- // Wait for strudelMirror API to be available
79
- // This is the correct API exposed by strudel.cc (not __view on DOM elements)
80
- await this._page.waitForFunction(() => window.strudelMirror?.editor?.dispatch, { timeout: 5000 });
81
- // Set up console monitoring for runtime errors
82
- this.setupConsoleMonitoring();
83
- await this.analyzer.inject(this._page);
139
+ // Navigation and readiness are the only parts of init that depend on
140
+ // a live third-party site, and they had no retry at all: one hiccup
141
+ // from strudel.cc surfaced to the calling agent as a hard error
142
+ // (#315). chromium.launch is deliberately outside the retry — a
143
+ // local process launch does not fail transiently, and relaunching it
144
+ // would cost seconds per attempt.
145
+ const page = this._page;
146
+ // Everything past `newPage()` is rolled back if it fails.
147
+ //
148
+ // Without this, a failed navigation left `browser` and `_page`
149
+ // assigned with the page sitting on about:blank. `isAlive()` only
150
+ // asks whether the page is CLOSED, so it said yes; the server clears
151
+ // `isInitialized` only when `isAlive()` is false, so the next call
152
+ // re-entered `initialize()`, hit the `'Already initialized'`
153
+ // shortcut, and every tool after that failed on a blank page with
154
+ // "editor not found after 3 attempts" — permanently, with no path
155
+ // back short of a restart.
156
+ //
157
+ // #202 and #206 cover a page that DIED. This is a page that was
158
+ // never born, which their `isAlive()` check cannot distinguish from
159
+ // a healthy one (#456).
160
+ try {
161
+ await this.errorRecovery.executeWithRetry(async () => {
162
+ await page.goto(this.strudelUrl, {
163
+ waitUntil: 'domcontentloaded', // Changed from networkidle for faster load
164
+ timeout: 15000,
165
+ });
166
+ await waitForStrudelReady(page);
167
+ }, 'Browser Init', {
168
+ maxRetries: 2,
169
+ retryDelay: 750,
170
+ exponentialBackoff: true,
171
+ // Sessions that hit the same upstream hiccup must not retry in
172
+ // lockstep and concentrate load on a struggling service.
173
+ jitter: true,
174
+ });
175
+ // Set up console monitoring for runtime errors
176
+ this.setupConsoleMonitoring();
177
+ await this.analyzer.inject(this._page);
178
+ }
179
+ catch (error) {
180
+ // Leave nothing half-built behind, so the next init starts clean.
181
+ await this.cleanup().catch(() => undefined);
182
+ throw error;
183
+ }
84
184
  return 'Strudel initialized successfully';
85
185
  }
86
186
  /**
@@ -99,6 +199,16 @@ export class StrudelController {
99
199
  setupConsoleMonitoring() {
100
200
  if (!this._page)
101
201
  return;
202
+ // One listener per page.
203
+ //
204
+ // `page.on` appends, and nothing removed the previous listener, so
205
+ // a second call recorded every console message twice and doubled
206
+ // the error count `diagnostics` reports. `initialize()` calls this,
207
+ // and so does SessionManager on its own path; they do not currently
208
+ // overlap, but nothing stopped them (#488).
209
+ if (this.consoleMonitoredPage === this._page)
210
+ return;
211
+ this.consoleMonitoredPage = this._page;
102
212
  this._page.on('console', (msg) => {
103
213
  const type = msg.type();
104
214
  const text = msg.text();
@@ -127,13 +237,18 @@ export class StrudelController {
127
237
  throw new Error('Browser not initialized. Run init tool first.');
128
238
  // Invalidate cache before write to prevent stale reads
129
239
  this.invalidateCache();
240
+ // And forget the onsets collected from the PREVIOUS pattern. Without
241
+ // this the first tempo reading after a change describes the pattern
242
+ // before it: playing dnb at 174 and then house at 125, the first
243
+ // house reading came back 174 (#366).
244
+ await this.analyzer.resetTempoHistory(this._page);
130
245
  // Retry logic for timing edge cases
131
246
  const maxRetries = 3;
132
247
  const retryDelay = 100; // ms
133
248
  let success = false;
134
249
  for (let attempt = 0; attempt < maxRetries; attempt++) {
135
250
  // Use strudelMirror API (correct approach for strudel.cc)
136
- success = await this._page.evaluate((newPattern) => {
251
+ success = await this._page.evaluate(/* istanbul ignore next */ (newPattern) => {
137
252
  const sm = window.strudelMirror;
138
253
  if (sm?.editor?.dispatch) {
139
254
  const view = sm.editor;
@@ -156,7 +271,7 @@ export class StrudelController {
156
271
  throw new Error('Failed to write pattern - editor not found or view unavailable after 3 attempts');
157
272
  }
158
273
  // Verify the write by reading back from browser (fixes cache sync issues)
159
- const verified = await this._page.evaluate(() => {
274
+ const verified = await this._page.evaluate(/* istanbul ignore next */ () => {
160
275
  const sm = window.strudelMirror;
161
276
  // Use code property or editor state
162
277
  if (sm?.code)
@@ -187,7 +302,7 @@ export class StrudelController {
187
302
  if (this.editorCache && (now - this.cacheTimestamp) < this.CACHE_TTL) {
188
303
  return this.editorCache;
189
304
  }
190
- const pattern = await this._page.evaluate(() => {
305
+ const pattern = await this._page.evaluate(/* istanbul ignore next */ () => {
191
306
  const sm = window.strudelMirror;
192
307
  // Use code property or editor state
193
308
  if (sm?.code)
@@ -201,55 +316,236 @@ export class StrudelController {
201
316
  this.cacheTimestamp = now;
202
317
  return pattern;
203
318
  }
319
+ /**
320
+ * Reads Strudel's own scheduler state.
321
+ *
322
+ * `this.isPlaying` used to be set optimistically, which is how #218
323
+ * happened: stop() reported "Stopped" and diagnostics reported
324
+ * playing:false while audio kept running. Ask the page instead.
325
+ *
326
+ * @returns True if Strudel's REPL scheduler is started
327
+ */
328
+ async readPlaybackState() {
329
+ if (!this._page)
330
+ return 'unreadable';
331
+ try {
332
+ const started = await this._page.evaluate(/* istanbul ignore next */ () => {
333
+ const sm = window.strudelMirror;
334
+ return Boolean(sm?.repl?.state?.started ?? sm?.repl?.scheduler?.started);
335
+ });
336
+ return started ? 'playing' : 'stopped';
337
+ }
338
+ catch {
339
+ // Three values, not two. Returning `false` here meant "could not
340
+ // read" and "not playing" were the same answer — so
341
+ // waitForPlaybackState(false) succeeded instantly on a dead page and
342
+ // stop() reported "Stopped" for audio it never touched. That is the
343
+ // symptom #218 was filed for, reintroduced through the error path
344
+ // rather than through optimistic state (#278).
345
+ return 'unreadable';
346
+ }
347
+ }
348
+ /**
349
+ * Polls until Strudel's scheduler reaches the desired state.
350
+ * @param desired - State to wait for
351
+ * @param timeoutMs - Maximum time to wait
352
+ * @returns True if the state was reached before the timeout
353
+ */
354
+ async waitForPlaybackState(desired, timeoutMs) {
355
+ const target = desired ? 'playing' : 'stopped';
356
+ const deadline = Date.now() + timeoutMs;
357
+ for (;;) {
358
+ // 'unreadable' never satisfies the wait. It used to satisfy the
359
+ // stop case by coincidence, because it shared a value with
360
+ // 'stopped'.
361
+ if (await this.readPlaybackState() === target)
362
+ return true;
363
+ if (Date.now() >= deadline)
364
+ return false;
365
+ await this._page?.waitForTimeout(50);
366
+ }
367
+ }
204
368
  /**
205
369
  * Starts playing the current pattern
206
370
  * @returns Success message
207
- * @throws {Error} When not initialized
371
+ * @throws {Error} When not initialized, or when playback fails to start
208
372
  */
209
373
  async play() {
210
374
  if (!this._page)
211
375
  throw new Error('Browser not initialized. Run init tool first.');
212
376
  // First play per session: click the play button to establish the user
213
377
  // gesture the AudioContext needs. Slow (~3s in headless) but only once.
214
- // Subsequent plays: use Ctrl+Enter — AudioContext is already resumed,
215
- // so the keyboard shortcut resumes playback cleanly and fast.
216
- if (!this.hasEstablishedGesture) {
378
+ const firstPlay = !this.hasEstablishedGesture;
379
+ let usedKeyboardFallback = false;
380
+ if (firstPlay) {
217
381
  try {
218
382
  const playButton = this._page.locator('button[title="play"]').first();
219
383
  await playButton.click({ timeout: 3000 });
220
384
  this.hasEstablishedGesture = true;
221
385
  }
222
386
  catch {
387
+ usedKeyboardFallback = true;
223
388
  await this._page.keyboard.press('ControlOrMeta+Enter');
224
389
  }
225
390
  }
226
391
  else {
227
- await this._page.keyboard.press('ControlOrMeta+Enter');
392
+ // AudioContext is already resumed, so drive strudelMirror directly.
393
+ // Ctrl+Enter is a CodeMirror keymap binding and only fires when the
394
+ // editor holds focus — see the comment on stop() (#218).
395
+ await this._page.evaluate(/* istanbul ignore next */ () => {
396
+ window.strudelMirror?.evaluate?.();
397
+ });
398
+ }
399
+ // The first play of a session pays for cold caches: the AudioContext
400
+ // resuming, and every sample the pattern names being fetched before
401
+ // a cycle can be scheduled. Measured, the first play sat right on a
402
+ // 3000ms budget — a corpus example failed at 3128ms while the one
403
+ // that followed it succeeded in 4008ms, so the margin was under a
404
+ // second either way and the first test to play went red about one
405
+ // run in five (#416). Later plays reuse everything and stay fast.
406
+ const startBudgetMs = firstPlay
407
+ ? this.playbackBudget.firstPlayMs
408
+ : this.playbackBudget.subsequentPlayMs;
409
+ if (!(await this.waitForPlaybackState(true, startBudgetMs))) {
410
+ const state = await this.readPlaybackState();
411
+ this.isPlaying = state === 'playing';
412
+ this.analyzer.clearCache();
413
+ // Name the cause we actually observed. This used to blame a syntax
414
+ // error for every startup failure, including a page that had gone
415
+ // away and an AudioContext that never resumed — confident about one
416
+ // cause when three were possible.
417
+ //
418
+ // The gesture-click fallback is a fourth: when the transport button
419
+ // could not be clicked we pressed Ctrl+Enter instead, which only
420
+ // reaches CodeMirror while the editor holds focus. That case used
421
+ // to be reported as a possible syntax error, sending the reader to
422
+ // look for a bug that is not in their pattern.
423
+ throw new Error(state === 'unreadable'
424
+ ? 'Playback did not start: the page could not be read. It may have ' +
425
+ 'navigated or closed — run init to recover.'
426
+ : usedKeyboardFallback
427
+ ? 'Playback did not start: the transport button could not be clicked, ' +
428
+ 'so the keyboard fallback was used and the AudioContext may never ' +
429
+ 'have resumed. Run init to recover; the pattern is probably fine.'
430
+ : `Playback did not start within ${String(startBudgetMs)}ms. The pattern ` +
431
+ 'may have a syntax error, or the AudioContext may not have resumed — ' +
432
+ 'check diagnostics({ level: "errors" }).');
228
433
  }
229
- await this._page.waitForTimeout(50);
230
434
  this.isPlaying = true;
435
+ // Playback state just changed, so any cached analysis describes the
436
+ // previous state. Without this, analyze() can report the old state for
437
+ // up to ANALYSIS_CACHE_TTL after play/stop (#211).
438
+ this.analyzer.clearCache();
231
439
  return 'Playing';
232
440
  }
441
+ /**
442
+ * Halts playback without resetting the cycle position.
443
+ *
444
+ * `playback({ action: "pause" })` claimed this for months and did not
445
+ * do it — `pause` fell through to `stop` (#406). Strudel's scheduler
446
+ * distinguishes them: `stop()` zeroes the phase counters, `pause()`
447
+ * only halts the clock. Measured against live strudel.cc:
448
+ *
449
+ * playing started:true lastEnd:1.35 phase:5.302
450
+ * paused started:false lastEnd:1.35 phase:5.302
451
+ * play() after pause started:true lastEnd:2.50 phase:7.602
452
+ * stopped started:false lastEnd:0 phase:0
453
+ *
454
+ * So `play()` resumes rather than restarting, and needs no change.
455
+ *
456
+ * Tempo history is deliberately NOT cleared here, unlike `stop()`. A
457
+ * pause is a gap in one performance, and the onsets either side of it
458
+ * belong to the same pattern — but the gap itself would read as a
459
+ * silence, so the analyzer drops history across one longer than a bar
460
+ * on its own (#366). Clearing on top of that would throw away a
461
+ * reading that is about to be valid again.
462
+ *
463
+ * @returns Success message
464
+ * @throws {Error} When not initialized, or when playback fails to pause
465
+ */
466
+ async pause() {
467
+ if (!this._page)
468
+ throw new Error('Browser not initialized. Run init tool first.');
469
+ try {
470
+ await this._page.evaluate(/* istanbul ignore next */ () => {
471
+ window.strudelMirror?.repl?.pause?.();
472
+ });
473
+ }
474
+ catch {
475
+ // Same reasoning as stop(): the state check below is what decides
476
+ // whether this worked, and it produces a better message (#278).
477
+ }
478
+ if (!(await this.waitForPlaybackState(false, 2000))) {
479
+ const state = await this.readPlaybackState();
480
+ this.isPlaying = state === 'playing';
481
+ this.analyzer.clearCache();
482
+ throw new Error(state === 'unreadable'
483
+ ? 'Failed to pause playback: the page could not be read, so whether ' +
484
+ 'audio is still running is unknown. Run init to recover.'
485
+ : 'Failed to pause playback: Strudel scheduler is still running. ' +
486
+ 'Use playback({ action: "stop" }) to end playback outright.');
487
+ }
488
+ this.isPlaying = false;
489
+ // See play(): a stale cached analysis would report audio still
490
+ // playing immediately after a successful pause (#211).
491
+ this.analyzer.clearCache();
492
+ return 'Paused';
493
+ }
233
494
  /**
234
495
  * Stops the currently playing pattern
235
496
  * @returns Success message
236
- * @throws {Error} When not initialized
497
+ * @throws {Error} When not initialized, or when playback fails to stop
237
498
  */
238
499
  async stop() {
239
500
  if (!this._page)
240
501
  throw new Error('Browser not initialized. Run init tool first.');
241
- // Ctrl+Period stops cleanly when AudioContext is running (i.e. after
242
- // a successful play). No user gesture needed — we only need one to
243
- // RESUME AudioContext, not to stop it. Fall back to the button click
244
- // if keyboard fails (e.g. editor lost focus).
502
+ // Drive strudelMirror.stop() directly. Ctrl+Period is bound in the
503
+ // CodeMirror keymap, so page.keyboard.press only reaches it while the
504
+ // editor holds focus — and it RESOLVES either way, so the old catch
505
+ // block never fired and stop() reported success while audio kept
506
+ // playing (#218). Verified against live strudel.cc: after a keyboard
507
+ // stop without focus, repl.state.started stayed true; after
508
+ // strudelMirror.stop() it went false.
245
509
  try {
246
- await this._page.keyboard.press('ControlOrMeta+Period');
510
+ await this._page.evaluate(/* istanbul ignore next */ () => {
511
+ window.strudelMirror?.stop?.();
512
+ });
247
513
  }
248
514
  catch {
249
- const playButton = this._page.locator('button[title="play"]').first();
250
- await playButton.click({ timeout: 2000 });
515
+ // Fall through rather than surfacing a raw Playwright error. The
516
+ // state check below establishes whether the page is readable at all
517
+ // and produces a message that says what to do about it (#278).
518
+ }
519
+ if (!(await this.waitForPlaybackState(false, 2000))) {
520
+ // Fall back to the transport button. Note the title flips to "stop"
521
+ // while playing, so the old `button[title="play"]` locator could not
522
+ // have matched here either.
523
+ try {
524
+ await this._page.locator('button[title="stop"]').first().click({ timeout: 2000 });
525
+ }
526
+ catch {
527
+ await this._page.keyboard.press('ControlOrMeta+Period');
528
+ }
529
+ if (!(await this.waitForPlaybackState(false, 2000))) {
530
+ const state = await this.readPlaybackState();
531
+ this.isPlaying = state === 'playing';
532
+ this.analyzer.clearCache();
533
+ // An unreadable page is not a successful stop. Saying "Stopped"
534
+ // here is exactly what #218 was filed for.
535
+ throw new Error(state === 'unreadable'
536
+ ? 'Failed to stop playback: the page could not be read, so whether ' +
537
+ 'audio is still running is unknown. Run init to recover.'
538
+ : 'Failed to stop playback: Strudel scheduler is still running.');
539
+ }
251
540
  }
252
541
  this.isPlaying = false;
542
+ // See play(): a stale cached analysis would report audio still playing
543
+ // immediately after a successful stop (#211).
544
+ this.analyzer.clearCache();
545
+ // Onsets from before a stop belong to a finished performance. Kept,
546
+ // they put a multi-second silence in the middle of the next tempo
547
+ // reading's intervals (#366).
548
+ await this.analyzer.resetTempoHistory(this._page);
253
549
  return 'Stopped';
254
550
  }
255
551
  /**
@@ -262,7 +558,7 @@ export class StrudelController {
262
558
  throw new Error('Browser not initialized. Run init tool first.');
263
559
  const startTime = Date.now();
264
560
  while (Date.now() - startTime < timeoutMs) {
265
- const isConnected = await this._page.evaluate(() => {
561
+ const isConnected = await this._page.evaluate(/* istanbul ignore next */ () => {
266
562
  return window.strudelAudioAnalyzer?.isConnected || false;
267
563
  });
268
564
  if (isConnected) {
@@ -333,6 +629,8 @@ export class StrudelController {
333
629
  await this.browser.close();
334
630
  this.browser = null;
335
631
  this._page = null;
632
+ // The next page gets its own listener (#488).
633
+ this.consoleMonitoredPage = null;
336
634
  }
337
635
  }
338
636
  /**
@@ -466,9 +764,21 @@ export class StrudelController {
466
764
  throw new Error('Browser not initialized. Run init tool first.');
467
765
  }
468
766
  const current = await this.getCurrentPattern();
469
- // Escape regex special characters to prevent injection
767
+ // Both sides literal.
768
+ //
769
+ // The search was escaped and the REPLACEMENT was not, so
770
+ // `String.replace` interpreted its special patterns. Measured:
771
+ //
772
+ // replaceInPattern('bd', '$&x') -> "bdx" wanted "$&x"
773
+ // replaceInPattern('bd', 'a$`b') -> "ab" wanted "a$`b"
774
+ // replaceInPattern('bd', 'x$$y') -> "x$y" wanted "x$$y"
775
+ //
776
+ // The comment above the escape said it was there "to prevent
777
+ // injection", which is exactly half of the job it was doing.
778
+ // A replacer function is passed the match and its return value is
779
+ // used verbatim, so nothing in `replace` is interpreted (#488).
470
780
  const escaped = this.escapeRegex(search);
471
- const newPattern = current.replace(new RegExp(escaped, 'g'), replace);
781
+ const newPattern = current.replace(new RegExp(escaped, 'g'), () => replace);
472
782
  if (current === newPattern) {
473
783
  return `No matches found for: ${search}`;
474
784
  }
@@ -606,20 +916,20 @@ export class StrudelController {
606
916
  throw new Error('Browser not initialized. Run init tool first.');
607
917
  }
608
918
  try {
609
- // Default to tmp/ directory for screenshots
919
+ // Confine to tmp/. The old guard was inverted — it passed any
920
+ // filename *containing* a separator through unsanitized, which is
921
+ // exactly what a traversal payload looks like (#234).
610
922
  const tmpDir = path.join(process.cwd(), 'tmp');
611
923
  await fs.mkdir(tmpDir, { recursive: true });
612
- // If filename has no path component, save to tmp/
613
- let screenshotPath;
614
- if (filename && path.dirname(filename) !== '.') {
615
- screenshotPath = filename;
616
- }
617
- else {
618
- const name = filename || `strudel-screenshot-${Date.now()}.png`;
619
- screenshotPath = path.join(tmpDir, name);
620
- }
621
- await this._page.screenshot({ path: screenshotPath, fullPage: false });
622
- return `Screenshot saved to ${screenshotPath}`;
924
+ const target = resolveSafeOutputPath(filename, {
925
+ directory: tmpDir,
926
+ extension: '.png',
927
+ defaultName: `strudel-screenshot-${Date.now()}.png`,
928
+ });
929
+ await this._page.screenshot({ path: target.path, fullPage: false });
930
+ return target.wasModified
931
+ ? `Screenshot saved to ${target.path} (requested name '${String(target.requested)}' was not usable)`
932
+ : `Screenshot saved to ${target.path}`;
623
933
  }
624
934
  catch (error) {
625
935
  this.logger.error('Failed to take screenshot', error);
@@ -632,7 +942,16 @@ export class StrudelController {
632
942
  */
633
943
  getStatus() {
634
944
  return {
635
- initialized: this._page !== null,
945
+ // `isAlive()`, not "a page object exists". A user closing the
946
+ // visible browser window — the documented default is headless
947
+ // false — left this reporting `initialized: true` for a page that
948
+ // was gone (#456).
949
+ initialized: this.isAlive(),
950
+ // Cached: set by play/pause/stop and NOT reconciled with the page,
951
+ // so it is stale if playback changed outside this controller (the
952
+ // user pressing stop in the window). `readPlaybackState()` asks
953
+ // the page; this field is a last-known value and is named as such
954
+ // in the returned object.
636
955
  playing: this.isPlaying,
637
956
  patternLength: this.editorCache.length,
638
957
  cacheValid: this.editorCache.length > 0 && (Date.now() - this.cacheTimestamp) < this.CACHE_TTL,
@@ -646,8 +965,21 @@ export class StrudelController {
646
965
  */
647
966
  async getDiagnostics() {
648
967
  const diagnostics = {
649
- browserConnected: this.browser !== null,
650
- pageLoaded: this._page !== null,
968
+ // Connectivity, not existence.
969
+ //
970
+ // These were `this.browser !== null` and `this._page !== null`, so
971
+ // closing the browser out from under the controller left
972
+ // diagnostics reporting a connected browser and a loaded page.
973
+ // `isAlive()` two hundred lines up already asks `isClosed()`; the
974
+ // tool whose entire job is reporting system state was the one
975
+ // place that did not (#488).
976
+ //
977
+ // Note `browser` is deliberately null for session-managed
978
+ // controllers, which do not own it — so this reads false there,
979
+ // as it did before. That is a separate reporting gap, filed
980
+ // rather than papered over here.
981
+ browserConnected: this.browser?.isConnected() ?? false,
982
+ pageLoaded: this._page !== null && !this._page.isClosed(),
651
983
  editorReady: false,
652
984
  audioConnected: false,
653
985
  cacheStatus: {
@@ -659,11 +991,11 @@ export class StrudelController {
659
991
  if (this._page) {
660
992
  try {
661
993
  // Check that strudelMirror API is available
662
- diagnostics.editorReady = await this._page.evaluate(() => {
994
+ diagnostics.editorReady = await this._page.evaluate(/* istanbul ignore next */ () => {
663
995
  const sm = window.strudelMirror;
664
996
  return sm?.editor?.dispatch !== undefined;
665
997
  });
666
- diagnostics.audioConnected = await this._page.evaluate(() => {
998
+ diagnostics.audioConnected = await this._page.evaluate(/* istanbul ignore next */ () => {
667
999
  return window.strudelAudioAnalyzer?.isConnected || false;
668
1000
  });
669
1001
  }