Skip to main content
Glama
StrudelController.ts22.3 kB
import { chromium, Browser, Page } from 'playwright'; import { AudioAnalyzer } from './AudioAnalyzer.js'; import { PatternValidator, ValidationResult } from './utils/PatternValidator.js'; import { ErrorRecovery } from './utils/ErrorRecovery.js'; import { Logger } from './utils/Logger.js'; import { AudioAnalysisResult, KeyAnalysis, TempoAnalysis, PatternStats, BrowserDiagnostics } from './types/AudioAnalysis.js'; export class StrudelController { private browser: Browser | null = null; private _page: Page | null = null; public readonly analyzer: AudioAnalyzer; private validator: PatternValidator; private errorRecovery: ErrorRecovery; private logger: Logger; private isHeadless: boolean; private editorCache: string = ''; private cacheTimestamp: number = 0; private readonly CACHE_TTL = 100; // milliseconds private isPlaying: boolean = false; private consoleErrors: string[] = []; private consoleWarnings: string[] = []; constructor(headless: boolean = false) { this.isHeadless = headless; this.analyzer = new AudioAnalyzer(); this.validator = new PatternValidator(); this.errorRecovery = new ErrorRecovery(); this.logger = new Logger(); } /** * Initializes the browser and navigates to Strudel.cc * @returns Success message when initialization is complete * @throws {Error} When browser launch or page navigation fails */ async initialize(): Promise<string> { if (this.browser) { return 'Already initialized'; } this.browser = await chromium.launch({ headless: this.isHeadless, args: [ '--use-fake-ui-for-media-stream', '--disable-dev-shm-usage', '--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu', '--disable-software-rasterizer' ], }); const context = await this.browser.newContext({ permissions: ['microphone'], viewport: { width: 1280, height: 720 }, reducedMotion: 'reduce', }); this._page = await context.newPage(); // Optimize page loading await this._page.route('**/*', (route) => { const resourceType = route.request().resourceType(); // Block unnecessary resources if (['image', 'font', 'media'].includes(resourceType)) { route.abort(); } else { route.continue(); } }); await this._page.goto('https://strudel.cc/', { waitUntil: 'domcontentloaded', // Changed from networkidle for faster load timeout: 15000, }); // Wait for editor with optimized timeout await this._page.waitForSelector('.cm-content', { timeout: 8000 }); // Set up console monitoring for runtime errors this.setupConsoleMonitoring(); await this.analyzer.inject(this._page); return 'Strudel initialized successfully'; } /** * Sets up console error/warning monitoring * Captures Strudel runtime errors that static validation can't catch */ private setupConsoleMonitoring(): void { if (!this._page) return; this._page.on('console', (msg) => { const type = msg.type(); const text = msg.text(); if (type === 'error') { this.consoleErrors.push(text); this.logger.error('Strudel console error:', text); } else if (type === 'warning') { this.consoleWarnings.push(text); this.logger.warn('Strudel console warning:', text); } }); this._page.on('pageerror', (error) => { this.consoleErrors.push(error.message); this.logger.error('Strudel page error:', error.message); }); } /** * Writes a Strudel pattern to the editor * @param pattern - The pattern code to write * @returns Success message with pattern length * @throws {Error} When not initialized */ async writePattern(pattern: string): Promise<string> { if (!this._page) throw new Error('Browser not initialized. Run init tool first.'); // Invalidate cache before write to prevent stale reads this.invalidateCache(); // Use evaluate for faster direct manipulation const success = await this._page.evaluate((newPattern) => { const editor = document.querySelector('.cm-content') as HTMLElement; if (editor) { const view = (editor as any).__view; if (view) { view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: newPattern } }); return true; } } return false; }, pattern); if (!success) { throw new Error('Failed to write pattern - editor not found or view unavailable'); } // Verify the write by reading back from browser (fixes cache sync issues) const verified = await this._page.evaluate(() => { const editor = document.querySelector('.cm-content') as HTMLElement; if (editor) { const view = (editor as any).__view; if (view && view.state && view.state.doc) { return view.state.doc.toString(); } } return null; }); // Update cache with verified content this.editorCache = verified || pattern; this.cacheTimestamp = Date.now(); // Log warning if verification failed if (verified === null) { this.logger.warn('Pattern write verification failed - using input pattern for cache'); } return `Pattern written (${pattern.length} chars)`; } /** * Retrieves the current pattern from the editor * @returns The current pattern text content * @throws {Error} When not initialized */ async getCurrentPattern(): Promise<string> { if (!this._page) throw new Error('Browser not initialized. Run init tool first.'); // Return cached value if still valid const now = Date.now(); if (this.editorCache && (now - this.cacheTimestamp) < this.CACHE_TTL) { return this.editorCache; } const pattern = await this._page.evaluate(() => { const editor = document.querySelector('.cm-content') as HTMLElement; if (editor) { const view = (editor as any).__view; if (view && view.state && view.state.doc) { return view.state.doc.toString(); } } return ''; }); // Update cache this.editorCache = pattern; this.cacheTimestamp = now; return pattern; } /** * Starts playing the current pattern * @returns Success message * @throws {Error} When not initialized */ async play(): Promise<string> { if (!this._page) throw new Error('Browser not initialized. Run init tool first.'); // Always use keyboard shortcut for speed await this._page.keyboard.press('ControlOrMeta+Enter'); // Reduced wait time await this._page.waitForTimeout(100); this.isPlaying = true; return 'Playing'; } /** * Stops the currently playing pattern * @returns Success message * @throws {Error} When not initialized */ async stop(): Promise<string> { if (!this._page) throw new Error('Browser not initialized. Run init tool first.'); // Always use keyboard shortcut for speed await this._page.keyboard.press('ControlOrMeta+Period'); this.isPlaying = false; return 'Stopped'; } /** * Waits for audio analyzer to connect * @param timeoutMs - Maximum time to wait (default 5000ms) * @returns True if connected, false if timeout */ async waitForAudioConnection(timeoutMs: number = 5000): Promise<boolean> { if (!this._page) throw new Error('Browser not initialized. Run init tool first.'); const startTime = Date.now(); while (Date.now() - startTime < timeoutMs) { const isConnected = await this._page.evaluate(() => { return (window as any).strudelAudioAnalyzer?.isConnected || false; }); if (isConnected) { return true; } await this._page.waitForTimeout(100); } return false; } /** * Analyzes the current audio output * @returns Audio analysis data including frequency features * @throws {Error} When not initialized */ async analyzeAudio(): Promise<AudioAnalysisResult> { if (!this._page) throw new Error('Browser not initialized. Run init tool first.'); return await this.analyzer.getAnalysis(this._page); } /** * Detects the musical key of the currently playing audio * @returns Key analysis including key, scale/mode, and confidence * @throws {Error} When not initialized or analyzer not connected */ async detectKey(): Promise<KeyAnalysis | null> { if (!this._page) throw new Error('Browser not initialized. Run init tool first.'); return await this.analyzer.detectKey(this._page); } /** * Detects the tempo (BPM) of the currently playing audio * @returns Tempo analysis including BPM, confidence, and detection method * @throws {Error} When not initialized or analyzer not connected */ async detectTempo(): Promise<TempoAnalysis | null> { if (!this._page) throw new Error('Browser not initialized. Run init tool first.'); return await this.analyzer.detectTempo(this._page); } /** * Cleans up browser resources and closes the connection * @returns Promise that resolves when cleanup is complete * @example * const controller = new StrudelController(); * await controller.initialize(); * // ... use controller ... * await controller.cleanup(); */ async cleanup() { if (this.browser) { // Clear cache this.editorCache = ''; this.cacheTimestamp = 0; // Close browser properly await this.browser.close(); this.browser = null; this._page = null; } } /** * Invalidates the editor content cache * Forces next getCurrentPattern() call to fetch fresh content from browser * @example * controller.invalidateCache(); * const pattern = await controller.getCurrentPattern(); // Fetches from browser */ invalidateCache() { this.editorCache = ''; this.cacheTimestamp = 0; } /** * Validates a pattern before writing it * @param pattern - Pattern to validate * @param autoFix - Automatically fix common errors * @returns Validation result */ async validatePattern(pattern: string, autoFix: boolean = false): Promise<ValidationResult> { this.logger.debug('Validating pattern', { length: pattern.length }); const result = this.validator.validate(pattern); if (!result.valid && autoFix) { const { pattern: fixedPattern, fixes } = this.validator.autoFix(pattern); if (fixes.length > 0) { this.logger.info('Auto-fixed pattern errors', { fixes }); // Re-validate the fixed pattern const newResult = this.validator.validate(fixedPattern); return { ...newResult, suggestions: [...newResult.suggestions, ...fixes] }; } } return result; } /** * Writes pattern with validation * @param pattern - Pattern to write * @param options - Write options * @returns Write result with validation info */ async writePatternWithValidation( pattern: string, options: { autoFix?: boolean; skipValidation?: boolean } = {} ): Promise<{ result: string; validation?: ValidationResult }> { if (!this._page) { throw new Error('Browser not initialized. Run init tool first.'); } // Validate unless skipped if (!options.skipValidation) { const validation = await this.validatePattern(pattern, options.autoFix); if (!validation.valid) { this.logger.warn('Pattern validation failed', { errors: validation.errors, warnings: validation.warnings }); // If auto-fix is enabled, try to fix and re-validate if (options.autoFix) { const { pattern: fixedPattern } = this.validator.autoFix(pattern); pattern = fixedPattern; } else { return { result: `Validation failed: ${validation.errors.join(', ')}`, validation }; } } if (validation.warnings.length > 0) { this.logger.info('Pattern warnings', { warnings: validation.warnings }); } } // Write with error recovery const writeResult = await this.errorRecovery.handlePatternWrite( async (p) => this.writePattern(p), pattern ); return { result: writeResult }; } /** * Gets current playback state * @returns True if playing, false otherwise */ getPlaybackState(): boolean { return this.isPlaying; } /** * Appends code to current pattern safely * @param code - Code to append * @returns Result message */ async appendPattern(code: string): Promise<string> { if (!this._page) { throw new Error('Browser not initialized. Run init tool first.'); } const current = await this.getCurrentPattern(); const newPattern = current + '\n' + code; return this.writePattern(newPattern); } /** * Inserts code at specific line * @param line - Line number (0-indexed) * @param code - Code to insert * @returns Result message */ async insertAtLine(line: number, code: string): Promise<string> { if (!this._page) { throw new Error('Browser not initialized. Run init tool first.'); } const current = await this.getCurrentPattern(); const lines = current.split('\n'); if (line < 0 || line > lines.length) { throw new Error(`Invalid line number: ${line}. Must be between 0 and ${lines.length}`); } lines.splice(line, 0, code); return this.writePattern(lines.join('\n')); } /** * Escapes special regex characters in a string for safe use in RegExp * @nist si-10 "Input validation" * @param str - String to escape * @returns Escaped string safe for RegExp */ private escapeRegex(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } /** * Replaces text in pattern * @param search - Text to find (literal string, not regex) * @param replace - Replacement text * @returns Result message * @throws {Error} When browser not initialized */ async replaceInPattern(search: string, replace: string): Promise<string> { if (!this._page) { throw new Error('Browser not initialized. Run init tool first.'); } const current = await this.getCurrentPattern(); // Escape regex special characters to prevent injection const escaped = this.escapeRegex(search); const newPattern = current.replace(new RegExp(escaped, 'g'), replace); if (current === newPattern) { return `No matches found for: ${search}`; } return this.writePattern(newPattern); } /** * Gets pattern statistics * @returns Pattern statistics */ async getPatternStats(): Promise<PatternStats> { if (!this._page) { throw new Error('Browser not initialized. Run init tool first.'); } const pattern = await this.getCurrentPattern(); const lines = pattern.split('\n'); return { lines: lines.length, chars: pattern.length, sounds: (pattern.match(/s\(/g) || []).length, notes: (pattern.match(/note\(/g) || []).length, effects: (pattern.match(/\.(room|delay|reverb|lpf|hpf|bpf)\(/g) || []).length, functions: (pattern.match(/\b(stack|every|sometimes|rarely|often|fast|slow)\(/g) || []).length }; } /** * Takes a snapshot of the current editor state * @returns Snapshot data */ async takeSnapshot(): Promise<{ pattern: string; timestamp: string; isPlaying: boolean; stats: PatternStats; }> { if (!this._page) { throw new Error('Browser not initialized. Run init tool first.'); } const pattern = await this.getCurrentPattern(); const stats = await this.getPatternStats(); return { pattern, timestamp: new Date().toISOString(), isPlaying: this.isPlaying, stats }; } /** * Executes JavaScript in the Strudel context * @nist si-10 "Input validation" * @param code - JavaScript code to execute (must pass pattern validation) * @returns Execution result * @throws {Error} When browser not initialized or code validation fails */ async executeInStrudelContext(code: string): Promise<any> { if (!this._page) { throw new Error('Browser not initialized. Run init tool first.'); } // Validate code before execution to prevent dangerous patterns const validation = this.validator.validate(code); if (!validation.valid) { throw new Error( `Code validation failed: ${validation.errors.join('; ')}. ` + `Suggestions: ${validation.suggestions.join('; ')}` ); } try { // Use Function constructor with restricted scope instead of raw eval // This executes in Strudel's context via page.evaluate, not in Node return await this._page.evaluate((jsCode) => { // Execute in the page's Strudel context where Strudel functions are available const fn = new Function('return ' + jsCode); return fn(); }, code); } catch (error: any) { this.logger.error('Failed to execute code in Strudel context', { code: code.substring(0, 100), error: error.message }); throw new Error(`Execution failed: ${error.message}`); } } /** * Gets console errors captured since last clear * @returns Array of error messages from Strudel */ getConsoleErrors(): string[] { return [...this.consoleErrors]; } /** * Gets console warnings captured since last clear * @returns Array of warning messages from Strudel */ getConsoleWarnings(): string[] { return [...this.consoleWarnings]; } /** * Clears captured console errors and warnings */ clearConsoleMessages(): void { this.consoleErrors = []; this.consoleWarnings = []; } /** * Validates pattern with runtime checking * Writes pattern, briefly plays to trigger evaluation, then captures errors * @param pattern - Pattern to validate * @param waitMs - How long to wait for errors (default 500ms) * @returns Validation result with runtime errors */ async validatePatternRuntime(pattern: string, waitMs: number = 500): Promise<{ valid: boolean; errors: string[]; warnings: string[]; }> { if (!this._page) { throw new Error('Browser not initialized. Run init tool first.'); } // Clear previous errors this.clearConsoleMessages(); // Write pattern await this.writePattern(pattern); // Brief play to trigger evaluation - Strudel uses lazy evaluation // so errors only appear when the pattern is actually executed try { await this._page.keyboard.press('ControlOrMeta+Enter'); await this._page.waitForTimeout(Math.min(waitMs, 300)); await this._page.keyboard.press('ControlOrMeta+Period'); } catch (e) { this.logger.warn('Failed to trigger pattern evaluation', e); } // Wait for potential errors to appear await this._page.waitForTimeout(waitMs); const errors = this.getConsoleErrors(); const warnings = this.getConsoleWarnings(); return { valid: errors.length === 0, errors, warnings }; } /** * Gets the current page instance * @returns Page instance or null if not initialized */ get page(): Page | null { return this._page; } /** * Brings the browser window to the foreground * @returns Success message * @throws {Error} When browser not initialized */ async showBrowser(): Promise<string> { if (!this._page) { throw new Error('Browser not initialized. Run init tool first.'); } try { await this._page.bringToFront(); return 'Browser window brought to foreground'; } catch (error: any) { this.logger.error('Failed to show browser', error); throw new Error(`Failed to show browser: ${error.message}`); } } /** * Takes a screenshot of the current browser state * @param filename - Optional filename for the screenshot * @returns Path to saved screenshot or base64 data * @throws {Error} When browser not initialized */ async takeScreenshot(filename?: string): Promise<string> { if (!this._page) { throw new Error('Browser not initialized. Run init tool first.'); } try { const path = filename || `strudel-screenshot-${Date.now()}.png`; await this._page.screenshot({ path, fullPage: false }); return `Screenshot saved to ${path}`; } catch (error: any) { this.logger.error('Failed to take screenshot', error); throw new Error(`Failed to take screenshot: ${error.message}`); } } /** * Gets current browser and playback status * @returns Status object with initialization and playback state */ getStatus(): { initialized: boolean; playing: boolean; patternLength: number; cacheValid: boolean; errorCount: number; warningCount: number; } { return { initialized: this._page !== null, playing: this.isPlaying, patternLength: this.editorCache.length, cacheValid: this.editorCache.length > 0 && (Date.now() - this.cacheTimestamp) < this.CACHE_TTL, errorCount: this.consoleErrors.length, warningCount: this.consoleWarnings.length }; } /** * Gets detailed browser diagnostics * @returns Diagnostic information */ async getDiagnostics(): Promise<BrowserDiagnostics> { const diagnostics: BrowserDiagnostics = { browserConnected: this.browser !== null, pageLoaded: this._page !== null, editorReady: false, audioConnected: false, cacheStatus: { hasCache: this.editorCache.length > 0, cacheAge: this.cacheTimestamp > 0 ? Date.now() - this.cacheTimestamp : 0 }, errorStats: this.errorRecovery.getErrorStats() }; if (this._page) { try { diagnostics.editorReady = await this._page.evaluate(() => { return document.querySelector('.cm-content') !== null; }); diagnostics.audioConnected = await this._page.evaluate(() => { return (window as any).strudelAudioAnalyzer?.isConnected || false; }); } catch (error) { this.logger.warn('Failed to get diagnostics', error); } } return diagnostics; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/williamzujkowski/strudel-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server