@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.
- package/README.md +202 -705
- package/dist/AudioAnalyzer.js +1294 -91
- package/dist/PatternStore.js +276 -23
- package/dist/StrudelController.js +388 -56
- package/dist/server/resources.js +27 -20
- package/dist/server/server.js +384 -76
- package/dist/server/tools/ai.js +124 -91
- package/dist/server/tools/analysis.js +168 -59
- package/dist/server/tools/capture.js +135 -24
- package/dist/server/tools/compose.js +26 -26
- package/dist/server/tools/diagnostics.js +20 -3
- package/dist/server/tools/editor.js +60 -6
- package/dist/server/tools/generate.js +38 -15
- package/dist/server/tools/history.js +30 -8
- package/dist/server/tools/playback.js +2 -1
- package/dist/server/tools/session.js +38 -6
- package/dist/server/tools/storage.js +179 -8
- package/dist/server/tools/transform.js +127 -43
- package/dist/server/tools/types.js +209 -14
- package/dist/services/AudioCaptureService.js +302 -33
- package/dist/services/AudioExportService.js +318 -0
- package/dist/services/BrowserOnlyFunctions.js +149 -0
- package/dist/services/EventDensityProbe.js +324 -0
- package/dist/services/GeminiService.js +221 -36
- package/dist/services/IsolatedEngineRunner.js +308 -0
- package/dist/services/IsolatedStrudelEngine.js +135 -0
- package/dist/services/LocalPatternEngine.js +16 -0
- package/dist/services/MIDIExportService.js +765 -71
- package/dist/services/MIDIImportService.js +474 -0
- package/dist/services/MusicTheory.js +85 -13
- package/dist/services/PatternGenerator.js +168 -85
- package/dist/services/PatternSandbox.js +359 -0
- package/dist/services/SessionManager.js +338 -65
- package/dist/services/StrudelEngine.js +218 -12
- package/dist/services/StrudelEngineHelpers.js +24 -8
- package/dist/services/StyleRegistry.js +204 -0
- package/dist/services/Transferable.js +114 -0
- package/dist/services/ai/AiTransport.js +56 -0
- package/dist/services/ai/AudioMeasurements.js +155 -0
- package/dist/services/ai/CliTransport.js +256 -0
- package/dist/services/engineChild.js +83 -0
- package/dist/services/engineChildPath.js +28 -0
- package/dist/utils/CategorisedError.js +59 -0
- package/dist/utils/ErrorRecovery.js +130 -110
- package/dist/utils/InputValidator.js +55 -7
- package/dist/utils/PatternValidator.js +136 -17
- package/dist/utils/SafePath.js +112 -0
- package/dist/utils/ServerConfig.js +248 -0
- package/dist/utils/TableLookup.js +41 -0
- package/dist/utils/Tempo.js +143 -0
- package/package.json +17 -5
- package/dist/services/__mocks__/StrudelEngine.js +0 -157
- 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.
|
|
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
|
-
*
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
65
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
if (
|
|
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
|
-
|
|
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
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
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.
|
|
510
|
+
await this._page.evaluate(/* istanbul ignore next */ () => {
|
|
511
|
+
window.strudelMirror?.stop?.();
|
|
512
|
+
});
|
|
247
513
|
}
|
|
248
514
|
catch {
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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
|
-
|
|
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
|
-
|
|
650
|
-
|
|
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
|
}
|