@williamzujkowski/live-coding-music-mcp 1.0.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/LICENSE +21 -0
- package/README.md +1735 -0
- package/dist/AudioAnalyzer.js +678 -0
- package/dist/PatternStore.js +169 -0
- package/dist/StrudelController.js +676 -0
- package/dist/__tests__/MusicTheory.test.js +245 -0
- package/dist/__tests__/PatternGenerator.test.js +337 -0
- package/dist/__tests__/PatternStore.test.js +153 -0
- package/dist/__tests__/utils/MockPlaywright.js +130 -0
- package/dist/index.js +21 -0
- package/dist/server/EnhancedMCPServer.js +654 -0
- package/dist/server/EnhancedMCPServerFixed.js +2506 -0
- package/dist/server/server.js +580 -0
- package/dist/server/tools/ai.js +530 -0
- package/dist/server/tools/analysis.js +132 -0
- package/dist/server/tools/capture.js +184 -0
- package/dist/server/tools/diagnostics.js +96 -0
- package/dist/server/tools/editor.js +128 -0
- package/dist/server/tools/generate.js +218 -0
- package/dist/server/tools/history.js +196 -0
- package/dist/server/tools/playback.js +41 -0
- package/dist/server/tools/session.js +115 -0
- package/dist/server/tools/storage.js +77 -0
- package/dist/server/tools/transform.js +399 -0
- package/dist/server/tools/types.js +9 -0
- package/dist/services/AudioCaptureService.js +279 -0
- package/dist/services/GeminiService.js +790 -0
- package/dist/services/MIDIExportService.js +412 -0
- package/dist/services/MusicTheory.js +186 -0
- package/dist/services/PatternGenerator.js +647 -0
- package/dist/services/SessionManager.js +316 -0
- package/dist/services/StrudelEngine.js +374 -0
- package/dist/services/__mocks__/StrudelEngine.js +157 -0
- package/dist/types/AudioAnalysis.js +4 -0
- package/dist/types/index.js +1 -0
- package/dist/utils/ErrorRecovery.js +248 -0
- package/dist/utils/InputValidator.js +281 -0
- package/dist/utils/Logger.js +19 -0
- package/dist/utils/PatternValidator.js +241 -0
- package/dist/utils/PerformanceMonitor.js +128 -0
- package/package.json +86 -0
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
import { chromium } from 'playwright';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { promises as fs } from 'fs';
|
|
4
|
+
import { AudioAnalyzer } from './AudioAnalyzer.js';
|
|
5
|
+
import { PatternValidator } from './utils/PatternValidator.js';
|
|
6
|
+
import { ErrorRecovery } from './utils/ErrorRecovery.js';
|
|
7
|
+
import { Logger } from './utils/Logger.js';
|
|
8
|
+
export class StrudelController {
|
|
9
|
+
browser = null;
|
|
10
|
+
_page = null;
|
|
11
|
+
analyzer;
|
|
12
|
+
validator;
|
|
13
|
+
errorRecovery;
|
|
14
|
+
logger;
|
|
15
|
+
isHeadless;
|
|
16
|
+
editorCache = '';
|
|
17
|
+
cacheTimestamp = 0;
|
|
18
|
+
CACHE_TTL = 100; // milliseconds
|
|
19
|
+
MAX_CONSOLE_MESSAGES = 1000; // Ring buffer limit
|
|
20
|
+
isPlaying = false;
|
|
21
|
+
// True once the user gesture for AudioContext has been established
|
|
22
|
+
// (first play-button click per session). Subsequent plays can use the
|
|
23
|
+
// keyboard shortcut cleanly because AudioContext is already resumed.
|
|
24
|
+
hasEstablishedGesture = false;
|
|
25
|
+
consoleErrors = [];
|
|
26
|
+
consoleWarnings = [];
|
|
27
|
+
constructor(headless = false) {
|
|
28
|
+
this.isHeadless = headless;
|
|
29
|
+
this.analyzer = new AudioAnalyzer();
|
|
30
|
+
this.validator = new PatternValidator();
|
|
31
|
+
this.errorRecovery = new ErrorRecovery();
|
|
32
|
+
this.logger = new Logger();
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Initializes the browser and navigates to Strudel.cc
|
|
36
|
+
* @returns Success message when initialization is complete
|
|
37
|
+
* @throws {Error} When browser launch or page navigation fails
|
|
38
|
+
*/
|
|
39
|
+
async initialize() {
|
|
40
|
+
if (this.browser) {
|
|
41
|
+
return 'Already initialized';
|
|
42
|
+
}
|
|
43
|
+
this.browser = await chromium.launch({
|
|
44
|
+
headless: this.isHeadless,
|
|
45
|
+
args: [
|
|
46
|
+
'--use-fake-ui-for-media-stream',
|
|
47
|
+
'--autoplay-policy=no-user-gesture-required',
|
|
48
|
+
'--disable-dev-shm-usage',
|
|
49
|
+
'--no-sandbox',
|
|
50
|
+
'--disable-setuid-sandbox',
|
|
51
|
+
'--disable-gpu',
|
|
52
|
+
'--disable-software-rasterizer'
|
|
53
|
+
],
|
|
54
|
+
});
|
|
55
|
+
const context = await this.browser.newContext({
|
|
56
|
+
permissions: ['microphone'],
|
|
57
|
+
viewport: { width: 1280, height: 720 },
|
|
58
|
+
reducedMotion: 'reduce',
|
|
59
|
+
});
|
|
60
|
+
this._page = await context.newPage();
|
|
61
|
+
// Optimize page loading
|
|
62
|
+
await this._page.route('**/*', (route) => {
|
|
63
|
+
const resourceType = route.request().resourceType();
|
|
64
|
+
// Block unnecessary resources
|
|
65
|
+
if (['image', 'font', 'media'].includes(resourceType)) {
|
|
66
|
+
route.abort();
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
route.continue();
|
|
70
|
+
}
|
|
71
|
+
});
|
|
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);
|
|
84
|
+
return 'Strudel initialized successfully';
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Pushes a message to an array with ring buffer behavior (FIFO eviction)
|
|
88
|
+
*/
|
|
89
|
+
pushWithLimit(arr, message) {
|
|
90
|
+
if (arr.length >= this.MAX_CONSOLE_MESSAGES) {
|
|
91
|
+
arr.shift(); // Remove oldest entry
|
|
92
|
+
}
|
|
93
|
+
arr.push(message);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Sets up console error/warning monitoring
|
|
97
|
+
* Captures Strudel runtime errors that static validation can't catch
|
|
98
|
+
*/
|
|
99
|
+
setupConsoleMonitoring() {
|
|
100
|
+
if (!this._page)
|
|
101
|
+
return;
|
|
102
|
+
this._page.on('console', (msg) => {
|
|
103
|
+
const type = msg.type();
|
|
104
|
+
const text = msg.text();
|
|
105
|
+
if (type === 'error') {
|
|
106
|
+
this.pushWithLimit(this.consoleErrors, text);
|
|
107
|
+
this.logger.error('Strudel console error:', text);
|
|
108
|
+
}
|
|
109
|
+
else if (type === 'warning') {
|
|
110
|
+
this.pushWithLimit(this.consoleWarnings, text);
|
|
111
|
+
this.logger.warn('Strudel console warning:', text);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
this._page.on('pageerror', (error) => {
|
|
115
|
+
this.pushWithLimit(this.consoleErrors, error.message);
|
|
116
|
+
this.logger.error('Strudel page error:', error.message);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Writes a Strudel pattern to the editor
|
|
121
|
+
* @param pattern - The pattern code to write
|
|
122
|
+
* @returns Success message with pattern length
|
|
123
|
+
* @throws {Error} When not initialized
|
|
124
|
+
*/
|
|
125
|
+
async writePattern(pattern) {
|
|
126
|
+
if (!this._page)
|
|
127
|
+
throw new Error('Browser not initialized. Run init tool first.');
|
|
128
|
+
// Invalidate cache before write to prevent stale reads
|
|
129
|
+
this.invalidateCache();
|
|
130
|
+
// Retry logic for timing edge cases
|
|
131
|
+
const maxRetries = 3;
|
|
132
|
+
const retryDelay = 100; // ms
|
|
133
|
+
let success = false;
|
|
134
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
135
|
+
// Use strudelMirror API (correct approach for strudel.cc)
|
|
136
|
+
success = await this._page.evaluate((newPattern) => {
|
|
137
|
+
const sm = window.strudelMirror;
|
|
138
|
+
if (sm?.editor?.dispatch) {
|
|
139
|
+
const view = sm.editor;
|
|
140
|
+
view.dispatch({
|
|
141
|
+
changes: { from: 0, to: view.state.doc.length, insert: newPattern }
|
|
142
|
+
});
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
return false;
|
|
146
|
+
}, pattern);
|
|
147
|
+
if (success) {
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
if (attempt < maxRetries - 1) {
|
|
151
|
+
this.logger.warn(`Write attempt ${attempt + 1} failed, retrying...`);
|
|
152
|
+
await this._page.waitForTimeout(retryDelay);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (!success) {
|
|
156
|
+
throw new Error('Failed to write pattern - editor not found or view unavailable after 3 attempts');
|
|
157
|
+
}
|
|
158
|
+
// Verify the write by reading back from browser (fixes cache sync issues)
|
|
159
|
+
const verified = await this._page.evaluate(() => {
|
|
160
|
+
const sm = window.strudelMirror;
|
|
161
|
+
// Use code property or editor state
|
|
162
|
+
if (sm?.code)
|
|
163
|
+
return sm.code;
|
|
164
|
+
if (sm?.editor?.state?.doc)
|
|
165
|
+
return sm.editor.state.doc.toString();
|
|
166
|
+
return null;
|
|
167
|
+
});
|
|
168
|
+
// Update cache with verified content
|
|
169
|
+
this.editorCache = verified || pattern;
|
|
170
|
+
this.cacheTimestamp = Date.now();
|
|
171
|
+
// Log warning if verification failed
|
|
172
|
+
if (verified === null) {
|
|
173
|
+
this.logger.warn('Pattern write verification failed - using input pattern for cache');
|
|
174
|
+
}
|
|
175
|
+
return `Pattern written (${pattern.length} chars)`;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Retrieves the current pattern from the editor
|
|
179
|
+
* @returns The current pattern text content
|
|
180
|
+
* @throws {Error} When not initialized
|
|
181
|
+
*/
|
|
182
|
+
async getCurrentPattern() {
|
|
183
|
+
if (!this._page)
|
|
184
|
+
throw new Error('Browser not initialized. Run init tool first.');
|
|
185
|
+
// Return cached value if still valid
|
|
186
|
+
const now = Date.now();
|
|
187
|
+
if (this.editorCache && (now - this.cacheTimestamp) < this.CACHE_TTL) {
|
|
188
|
+
return this.editorCache;
|
|
189
|
+
}
|
|
190
|
+
const pattern = await this._page.evaluate(() => {
|
|
191
|
+
const sm = window.strudelMirror;
|
|
192
|
+
// Use code property or editor state
|
|
193
|
+
if (sm?.code)
|
|
194
|
+
return sm.code;
|
|
195
|
+
if (sm?.editor?.state?.doc)
|
|
196
|
+
return sm.editor.state.doc.toString();
|
|
197
|
+
return '';
|
|
198
|
+
});
|
|
199
|
+
// Update cache
|
|
200
|
+
this.editorCache = pattern;
|
|
201
|
+
this.cacheTimestamp = now;
|
|
202
|
+
return pattern;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Starts playing the current pattern
|
|
206
|
+
* @returns Success message
|
|
207
|
+
* @throws {Error} When not initialized
|
|
208
|
+
*/
|
|
209
|
+
async play() {
|
|
210
|
+
if (!this._page)
|
|
211
|
+
throw new Error('Browser not initialized. Run init tool first.');
|
|
212
|
+
// First play per session: click the play button to establish the user
|
|
213
|
+
// 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) {
|
|
217
|
+
try {
|
|
218
|
+
const playButton = this._page.locator('button[title="play"]').first();
|
|
219
|
+
await playButton.click({ timeout: 3000 });
|
|
220
|
+
this.hasEstablishedGesture = true;
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
await this._page.keyboard.press('ControlOrMeta+Enter');
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
await this._page.keyboard.press('ControlOrMeta+Enter');
|
|
228
|
+
}
|
|
229
|
+
await this._page.waitForTimeout(50);
|
|
230
|
+
this.isPlaying = true;
|
|
231
|
+
return 'Playing';
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Stops the currently playing pattern
|
|
235
|
+
* @returns Success message
|
|
236
|
+
* @throws {Error} When not initialized
|
|
237
|
+
*/
|
|
238
|
+
async stop() {
|
|
239
|
+
if (!this._page)
|
|
240
|
+
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).
|
|
245
|
+
try {
|
|
246
|
+
await this._page.keyboard.press('ControlOrMeta+Period');
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
const playButton = this._page.locator('button[title="play"]').first();
|
|
250
|
+
await playButton.click({ timeout: 2000 });
|
|
251
|
+
}
|
|
252
|
+
this.isPlaying = false;
|
|
253
|
+
return 'Stopped';
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Waits for audio analyzer to connect
|
|
257
|
+
* @param timeoutMs - Maximum time to wait (default 5000ms)
|
|
258
|
+
* @returns True if connected, false if timeout
|
|
259
|
+
*/
|
|
260
|
+
async waitForAudioConnection(timeoutMs = 5000) {
|
|
261
|
+
if (!this._page)
|
|
262
|
+
throw new Error('Browser not initialized. Run init tool first.');
|
|
263
|
+
const startTime = Date.now();
|
|
264
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
265
|
+
const isConnected = await this._page.evaluate(() => {
|
|
266
|
+
return window.strudelAudioAnalyzer?.isConnected || false;
|
|
267
|
+
});
|
|
268
|
+
if (isConnected) {
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
await this._page.waitForTimeout(100);
|
|
272
|
+
}
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Analyzes the current audio output
|
|
277
|
+
* @returns Audio analysis data including frequency features
|
|
278
|
+
* @throws {Error} When not initialized
|
|
279
|
+
*/
|
|
280
|
+
async analyzeAudio() {
|
|
281
|
+
if (!this._page)
|
|
282
|
+
throw new Error('Browser not initialized. Run init tool first.');
|
|
283
|
+
return await this.analyzer.getAnalysis(this._page);
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Detects the musical key of the currently playing audio
|
|
287
|
+
* @returns Key analysis including key, scale/mode, and confidence
|
|
288
|
+
* @throws {Error} When not initialized or analyzer not connected
|
|
289
|
+
*/
|
|
290
|
+
async detectKey() {
|
|
291
|
+
if (!this._page)
|
|
292
|
+
throw new Error('Browser not initialized. Run init tool first.');
|
|
293
|
+
return await this.analyzer.detectKey(this._page);
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Detects the tempo (BPM) of the currently playing audio
|
|
297
|
+
* @returns Tempo analysis including BPM, confidence, and detection method
|
|
298
|
+
* @throws {Error} When not initialized or analyzer not connected
|
|
299
|
+
*/
|
|
300
|
+
async detectTempo() {
|
|
301
|
+
if (!this._page)
|
|
302
|
+
throw new Error('Browser not initialized. Run init tool first.');
|
|
303
|
+
return await this.analyzer.detectTempo(this._page);
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Analyzes the rhythm pattern of the currently playing audio
|
|
307
|
+
* @returns Rhythm analysis including density, complexity, syncopation, regularity
|
|
308
|
+
* @throws {Error} When not initialized
|
|
309
|
+
*/
|
|
310
|
+
async analyzeRhythm() {
|
|
311
|
+
if (!this._page)
|
|
312
|
+
throw new Error('Browser not initialized. Run init tool first.');
|
|
313
|
+
return await this.analyzer.analyzeRhythm(this._page);
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Cleans up browser resources and closes the connection
|
|
317
|
+
* @returns Promise that resolves when cleanup is complete
|
|
318
|
+
* @example
|
|
319
|
+
* const controller = new StrudelController();
|
|
320
|
+
* await controller.initialize();
|
|
321
|
+
* // ... use controller ...
|
|
322
|
+
* await controller.cleanup();
|
|
323
|
+
*/
|
|
324
|
+
async cleanup() {
|
|
325
|
+
if (this.browser) {
|
|
326
|
+
// Clear cache
|
|
327
|
+
this.editorCache = '';
|
|
328
|
+
this.cacheTimestamp = 0;
|
|
329
|
+
// Reset gesture flag so a new session re-establishes the user gesture
|
|
330
|
+
this.hasEstablishedGesture = false;
|
|
331
|
+
this.isPlaying = false;
|
|
332
|
+
// Close browser properly
|
|
333
|
+
await this.browser.close();
|
|
334
|
+
this.browser = null;
|
|
335
|
+
this._page = null;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Invalidates the editor content cache
|
|
340
|
+
* Forces next getCurrentPattern() call to fetch fresh content from browser
|
|
341
|
+
* @example
|
|
342
|
+
* controller.invalidateCache();
|
|
343
|
+
* const pattern = await controller.getCurrentPattern(); // Fetches from browser
|
|
344
|
+
*/
|
|
345
|
+
invalidateCache() {
|
|
346
|
+
this.editorCache = '';
|
|
347
|
+
this.cacheTimestamp = 0;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Validates a pattern before writing it
|
|
351
|
+
* @param pattern - Pattern to validate
|
|
352
|
+
* @param autoFix - Automatically fix common errors
|
|
353
|
+
* @returns Validation result
|
|
354
|
+
*/
|
|
355
|
+
async validatePattern(pattern, autoFix = false) {
|
|
356
|
+
this.logger.debug('Validating pattern', { length: pattern.length });
|
|
357
|
+
const result = this.validator.validate(pattern);
|
|
358
|
+
if (!result.valid && autoFix) {
|
|
359
|
+
const { pattern: fixedPattern, fixes } = this.validator.autoFix(pattern);
|
|
360
|
+
if (fixes.length > 0) {
|
|
361
|
+
this.logger.info('Auto-fixed pattern errors', { fixes });
|
|
362
|
+
// Re-validate the fixed pattern
|
|
363
|
+
const newResult = this.validator.validate(fixedPattern);
|
|
364
|
+
return {
|
|
365
|
+
...newResult,
|
|
366
|
+
suggestions: [...newResult.suggestions, ...fixes]
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return result;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Writes pattern with validation
|
|
374
|
+
* @param pattern - Pattern to write
|
|
375
|
+
* @param options - Write options
|
|
376
|
+
* @returns Write result with validation info
|
|
377
|
+
*/
|
|
378
|
+
async writePatternWithValidation(pattern, options = {}) {
|
|
379
|
+
if (!this._page) {
|
|
380
|
+
throw new Error('Browser not initialized. Run init tool first.');
|
|
381
|
+
}
|
|
382
|
+
// Validate unless skipped
|
|
383
|
+
if (!options.skipValidation) {
|
|
384
|
+
const validation = await this.validatePattern(pattern, options.autoFix);
|
|
385
|
+
if (!validation.valid) {
|
|
386
|
+
this.logger.warn('Pattern validation failed', {
|
|
387
|
+
errors: validation.errors,
|
|
388
|
+
warnings: validation.warnings
|
|
389
|
+
});
|
|
390
|
+
// If auto-fix is enabled, try to fix and re-validate
|
|
391
|
+
if (options.autoFix) {
|
|
392
|
+
const { pattern: fixedPattern } = this.validator.autoFix(pattern);
|
|
393
|
+
pattern = fixedPattern;
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
return {
|
|
397
|
+
result: `Validation failed: ${validation.errors.join(', ')}`,
|
|
398
|
+
validation
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (validation.warnings.length > 0) {
|
|
403
|
+
this.logger.info('Pattern warnings', { warnings: validation.warnings });
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
// Write with error recovery
|
|
407
|
+
const writeResult = await this.errorRecovery.handlePatternWrite(async (p) => this.writePattern(p), pattern);
|
|
408
|
+
return { result: writeResult };
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Gets current playback state
|
|
412
|
+
* @returns True if playing, false otherwise
|
|
413
|
+
*/
|
|
414
|
+
getPlaybackState() {
|
|
415
|
+
return this.isPlaying;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Appends code to current pattern safely
|
|
419
|
+
* @param code - Code to append
|
|
420
|
+
* @returns Result message
|
|
421
|
+
*/
|
|
422
|
+
async appendPattern(code) {
|
|
423
|
+
if (!this._page) {
|
|
424
|
+
throw new Error('Browser not initialized. Run init tool first.');
|
|
425
|
+
}
|
|
426
|
+
const current = await this.getCurrentPattern();
|
|
427
|
+
const newPattern = current + '\n' + code;
|
|
428
|
+
return this.writePattern(newPattern);
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Inserts code at specific line
|
|
432
|
+
* @param line - Line number (0-indexed)
|
|
433
|
+
* @param code - Code to insert
|
|
434
|
+
* @returns Result message
|
|
435
|
+
*/
|
|
436
|
+
async insertAtLine(line, code) {
|
|
437
|
+
if (!this._page) {
|
|
438
|
+
throw new Error('Browser not initialized. Run init tool first.');
|
|
439
|
+
}
|
|
440
|
+
const current = await this.getCurrentPattern();
|
|
441
|
+
const lines = current.split('\n');
|
|
442
|
+
if (line < 0 || line > lines.length) {
|
|
443
|
+
throw new Error(`Invalid line number: ${line}. Must be between 0 and ${lines.length}`);
|
|
444
|
+
}
|
|
445
|
+
lines.splice(line, 0, code);
|
|
446
|
+
return this.writePattern(lines.join('\n'));
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Escapes special regex characters in a string for safe use in RegExp
|
|
450
|
+
* @nist si-10 "Input validation"
|
|
451
|
+
* @param str - String to escape
|
|
452
|
+
* @returns Escaped string safe for RegExp
|
|
453
|
+
*/
|
|
454
|
+
escapeRegex(str) {
|
|
455
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Replaces text in pattern
|
|
459
|
+
* @param search - Text to find (literal string, not regex)
|
|
460
|
+
* @param replace - Replacement text
|
|
461
|
+
* @returns Result message
|
|
462
|
+
* @throws {Error} When browser not initialized
|
|
463
|
+
*/
|
|
464
|
+
async replaceInPattern(search, replace) {
|
|
465
|
+
if (!this._page) {
|
|
466
|
+
throw new Error('Browser not initialized. Run init tool first.');
|
|
467
|
+
}
|
|
468
|
+
const current = await this.getCurrentPattern();
|
|
469
|
+
// Escape regex special characters to prevent injection
|
|
470
|
+
const escaped = this.escapeRegex(search);
|
|
471
|
+
const newPattern = current.replace(new RegExp(escaped, 'g'), replace);
|
|
472
|
+
if (current === newPattern) {
|
|
473
|
+
return `No matches found for: ${search}`;
|
|
474
|
+
}
|
|
475
|
+
return this.writePattern(newPattern);
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Gets pattern statistics
|
|
479
|
+
* @returns Pattern statistics
|
|
480
|
+
*/
|
|
481
|
+
async getPatternStats() {
|
|
482
|
+
if (!this._page) {
|
|
483
|
+
throw new Error('Browser not initialized. Run init tool first.');
|
|
484
|
+
}
|
|
485
|
+
const pattern = await this.getCurrentPattern();
|
|
486
|
+
const lines = pattern.split('\n');
|
|
487
|
+
return {
|
|
488
|
+
lines: lines.length,
|
|
489
|
+
chars: pattern.length,
|
|
490
|
+
sounds: (pattern.match(/s\(/g) || []).length,
|
|
491
|
+
notes: (pattern.match(/note\(/g) || []).length,
|
|
492
|
+
effects: (pattern.match(/\.(room|delay|reverb|lpf|hpf|bpf)\(/g) || []).length,
|
|
493
|
+
functions: (pattern.match(/\b(stack|every|sometimes|rarely|often|fast|slow)\(/g) || []).length
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Takes a snapshot of the current editor state
|
|
498
|
+
* @returns Snapshot data
|
|
499
|
+
*/
|
|
500
|
+
async takeSnapshot() {
|
|
501
|
+
if (!this._page) {
|
|
502
|
+
throw new Error('Browser not initialized. Run init tool first.');
|
|
503
|
+
}
|
|
504
|
+
const pattern = await this.getCurrentPattern();
|
|
505
|
+
const stats = await this.getPatternStats();
|
|
506
|
+
return {
|
|
507
|
+
pattern,
|
|
508
|
+
timestamp: new Date().toISOString(),
|
|
509
|
+
isPlaying: this.isPlaying,
|
|
510
|
+
stats
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
// SECURITY: executeInStrudelContext was removed in issue #56
|
|
514
|
+
// It used new Function() which is equivalent to eval() and poses injection risks.
|
|
515
|
+
// Pattern execution should go through writePattern() + play() which is the safe path.
|
|
516
|
+
/**
|
|
517
|
+
* Gets console errors captured since last clear
|
|
518
|
+
* @returns Array of error messages from Strudel
|
|
519
|
+
*/
|
|
520
|
+
getConsoleErrors() {
|
|
521
|
+
return [...this.consoleErrors];
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Gets console warnings captured since last clear
|
|
525
|
+
* @returns Array of warning messages from Strudel
|
|
526
|
+
*/
|
|
527
|
+
getConsoleWarnings() {
|
|
528
|
+
return [...this.consoleWarnings];
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Clears captured console errors and warnings
|
|
532
|
+
*/
|
|
533
|
+
clearConsoleMessages() {
|
|
534
|
+
this.consoleErrors = [];
|
|
535
|
+
this.consoleWarnings = [];
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Validates pattern with runtime checking
|
|
539
|
+
* Writes pattern, briefly plays to trigger evaluation, then captures errors
|
|
540
|
+
* @param pattern - Pattern to validate
|
|
541
|
+
* @param waitMs - How long to wait for errors (default 500ms)
|
|
542
|
+
* @returns Validation result with runtime errors
|
|
543
|
+
*/
|
|
544
|
+
async validatePatternRuntime(pattern, waitMs = 500) {
|
|
545
|
+
if (!this._page) {
|
|
546
|
+
throw new Error('Browser not initialized. Run init tool first.');
|
|
547
|
+
}
|
|
548
|
+
// Clear previous errors
|
|
549
|
+
this.clearConsoleMessages();
|
|
550
|
+
// Write pattern
|
|
551
|
+
await this.writePattern(pattern);
|
|
552
|
+
// Brief play to trigger evaluation - Strudel uses lazy evaluation
|
|
553
|
+
// so errors only appear when the pattern is actually executed
|
|
554
|
+
try {
|
|
555
|
+
await this._page.keyboard.press('ControlOrMeta+Enter');
|
|
556
|
+
await this._page.waitForTimeout(Math.min(waitMs, 300));
|
|
557
|
+
await this._page.keyboard.press('ControlOrMeta+Period');
|
|
558
|
+
}
|
|
559
|
+
catch (e) {
|
|
560
|
+
this.logger.warn('Failed to trigger pattern evaluation', e);
|
|
561
|
+
}
|
|
562
|
+
// Wait for potential errors to appear
|
|
563
|
+
await this._page.waitForTimeout(waitMs);
|
|
564
|
+
const errors = this.getConsoleErrors();
|
|
565
|
+
const warnings = this.getConsoleWarnings();
|
|
566
|
+
return {
|
|
567
|
+
valid: errors.length === 0,
|
|
568
|
+
errors,
|
|
569
|
+
warnings
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Gets the current page instance
|
|
574
|
+
* @returns Page instance or null if not initialized
|
|
575
|
+
*/
|
|
576
|
+
get page() {
|
|
577
|
+
return this._page;
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Brings the browser window to the foreground
|
|
581
|
+
* @returns Success message
|
|
582
|
+
* @throws {Error} When browser not initialized
|
|
583
|
+
*/
|
|
584
|
+
async showBrowser() {
|
|
585
|
+
if (!this._page) {
|
|
586
|
+
throw new Error('Browser not initialized. Run init tool first.');
|
|
587
|
+
}
|
|
588
|
+
try {
|
|
589
|
+
await this._page.bringToFront();
|
|
590
|
+
return 'Browser window brought to foreground';
|
|
591
|
+
}
|
|
592
|
+
catch (error) {
|
|
593
|
+
this.logger.error('Failed to show browser', error);
|
|
594
|
+
throw new Error(`Failed to show browser: ${error.message}`);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Takes a screenshot of the current browser state
|
|
599
|
+
* Screenshots are saved to the tmp/ directory by default
|
|
600
|
+
* @param filename - Optional filename for the screenshot (saved in tmp/ if no path specified)
|
|
601
|
+
* @returns Path to saved screenshot
|
|
602
|
+
* @throws {Error} When browser not initialized
|
|
603
|
+
*/
|
|
604
|
+
async takeScreenshot(filename) {
|
|
605
|
+
if (!this._page) {
|
|
606
|
+
throw new Error('Browser not initialized. Run init tool first.');
|
|
607
|
+
}
|
|
608
|
+
try {
|
|
609
|
+
// Default to tmp/ directory for screenshots
|
|
610
|
+
const tmpDir = path.join(process.cwd(), 'tmp');
|
|
611
|
+
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}`;
|
|
623
|
+
}
|
|
624
|
+
catch (error) {
|
|
625
|
+
this.logger.error('Failed to take screenshot', error);
|
|
626
|
+
throw new Error(`Failed to take screenshot: ${error.message}`);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
/**
|
|
630
|
+
* Gets current browser and playback status
|
|
631
|
+
* @returns Status object with initialization and playback state
|
|
632
|
+
*/
|
|
633
|
+
getStatus() {
|
|
634
|
+
return {
|
|
635
|
+
initialized: this._page !== null,
|
|
636
|
+
playing: this.isPlaying,
|
|
637
|
+
patternLength: this.editorCache.length,
|
|
638
|
+
cacheValid: this.editorCache.length > 0 && (Date.now() - this.cacheTimestamp) < this.CACHE_TTL,
|
|
639
|
+
errorCount: this.consoleErrors.length,
|
|
640
|
+
warningCount: this.consoleWarnings.length
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Gets detailed browser diagnostics
|
|
645
|
+
* @returns Diagnostic information
|
|
646
|
+
*/
|
|
647
|
+
async getDiagnostics() {
|
|
648
|
+
const diagnostics = {
|
|
649
|
+
browserConnected: this.browser !== null,
|
|
650
|
+
pageLoaded: this._page !== null,
|
|
651
|
+
editorReady: false,
|
|
652
|
+
audioConnected: false,
|
|
653
|
+
cacheStatus: {
|
|
654
|
+
hasCache: this.editorCache.length > 0,
|
|
655
|
+
cacheAge: this.cacheTimestamp > 0 ? Date.now() - this.cacheTimestamp : 0
|
|
656
|
+
},
|
|
657
|
+
errorStats: this.errorRecovery.getErrorStats()
|
|
658
|
+
};
|
|
659
|
+
if (this._page) {
|
|
660
|
+
try {
|
|
661
|
+
// Check that strudelMirror API is available
|
|
662
|
+
diagnostics.editorReady = await this._page.evaluate(() => {
|
|
663
|
+
const sm = window.strudelMirror;
|
|
664
|
+
return sm?.editor?.dispatch !== undefined;
|
|
665
|
+
});
|
|
666
|
+
diagnostics.audioConnected = await this._page.evaluate(() => {
|
|
667
|
+
return window.strudelAudioAnalyzer?.isConnected || false;
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
catch (error) {
|
|
671
|
+
this.logger.warn('Failed to get diagnostics', error);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
return diagnostics;
|
|
675
|
+
}
|
|
676
|
+
}
|