@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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1735 -0
  3. package/dist/AudioAnalyzer.js +678 -0
  4. package/dist/PatternStore.js +169 -0
  5. package/dist/StrudelController.js +676 -0
  6. package/dist/__tests__/MusicTheory.test.js +245 -0
  7. package/dist/__tests__/PatternGenerator.test.js +337 -0
  8. package/dist/__tests__/PatternStore.test.js +153 -0
  9. package/dist/__tests__/utils/MockPlaywright.js +130 -0
  10. package/dist/index.js +21 -0
  11. package/dist/server/EnhancedMCPServer.js +654 -0
  12. package/dist/server/EnhancedMCPServerFixed.js +2506 -0
  13. package/dist/server/server.js +580 -0
  14. package/dist/server/tools/ai.js +530 -0
  15. package/dist/server/tools/analysis.js +132 -0
  16. package/dist/server/tools/capture.js +184 -0
  17. package/dist/server/tools/diagnostics.js +96 -0
  18. package/dist/server/tools/editor.js +128 -0
  19. package/dist/server/tools/generate.js +218 -0
  20. package/dist/server/tools/history.js +196 -0
  21. package/dist/server/tools/playback.js +41 -0
  22. package/dist/server/tools/session.js +115 -0
  23. package/dist/server/tools/storage.js +77 -0
  24. package/dist/server/tools/transform.js +399 -0
  25. package/dist/server/tools/types.js +9 -0
  26. package/dist/services/AudioCaptureService.js +279 -0
  27. package/dist/services/GeminiService.js +790 -0
  28. package/dist/services/MIDIExportService.js +412 -0
  29. package/dist/services/MusicTheory.js +186 -0
  30. package/dist/services/PatternGenerator.js +647 -0
  31. package/dist/services/SessionManager.js +316 -0
  32. package/dist/services/StrudelEngine.js +374 -0
  33. package/dist/services/__mocks__/StrudelEngine.js +157 -0
  34. package/dist/types/AudioAnalysis.js +4 -0
  35. package/dist/types/index.js +1 -0
  36. package/dist/utils/ErrorRecovery.js +248 -0
  37. package/dist/utils/InputValidator.js +281 -0
  38. package/dist/utils/Logger.js +19 -0
  39. package/dist/utils/PatternValidator.js +241 -0
  40. package/dist/utils/PerformanceMonitor.js +128 -0
  41. 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
+ }