@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,316 @@
1
+ import { chromium } from 'playwright';
2
+ import { StrudelController } from '../StrudelController.js';
3
+ import { Logger } from '../utils/Logger.js';
4
+ /**
5
+ * Manages multiple concurrent Strudel browser sessions.
6
+ * Uses browser contexts for isolation - one browser instance with multiple contexts.
7
+ * Each context has isolated cookies, storage, and its own page.
8
+ */
9
+ export class SessionManager {
10
+ browser = null;
11
+ sessions = new Map();
12
+ defaultSessionId = 'default';
13
+ logger;
14
+ isHeadless;
15
+ /** Maximum concurrent sessions to prevent resource exhaustion */
16
+ MAX_SESSIONS = 5;
17
+ /** Inactivity timeout in ms (30 minutes) */
18
+ INACTIVITY_TIMEOUT = 30 * 60 * 1000;
19
+ /** Cleanup interval in ms (5 minutes) */
20
+ CLEANUP_INTERVAL = 5 * 60 * 1000;
21
+ cleanupTimer = null;
22
+ constructor(headless = false) {
23
+ this.isHeadless = headless;
24
+ this.logger = new Logger();
25
+ }
26
+ /**
27
+ * Ensures the shared browser instance is running
28
+ */
29
+ async ensureBrowser() {
30
+ if (!this.browser) {
31
+ this.browser = await chromium.launch({
32
+ headless: this.isHeadless,
33
+ args: [
34
+ '--use-fake-ui-for-media-stream',
35
+ '--disable-dev-shm-usage',
36
+ '--no-sandbox',
37
+ '--disable-setuid-sandbox',
38
+ '--disable-gpu',
39
+ '--disable-software-rasterizer'
40
+ ],
41
+ });
42
+ // Start cleanup timer when browser is created
43
+ this.startCleanupTimer();
44
+ }
45
+ return this.browser;
46
+ }
47
+ /**
48
+ * Creates a new isolated Strudel session
49
+ * @param id - Unique session identifier
50
+ * @param headless - Override headless mode for this session (unused, uses shared browser)
51
+ * @returns Initialized StrudelController for the session
52
+ * @throws {Error} When max sessions limit reached or session ID already exists
53
+ */
54
+ async createSession(id, headless) {
55
+ // Validate session ID
56
+ if (!id || typeof id !== 'string') {
57
+ throw new Error('Session ID must be a non-empty string');
58
+ }
59
+ // Check if session already exists
60
+ if (this.sessions.has(id)) {
61
+ throw new Error(`Session '${id}' already exists`);
62
+ }
63
+ // Check max sessions limit
64
+ if (this.sessions.size >= this.MAX_SESSIONS) {
65
+ throw new Error(`Maximum session limit (${this.MAX_SESSIONS}) reached. Destroy an existing session first.`);
66
+ }
67
+ const browser = await this.ensureBrowser();
68
+ // Create isolated browser context
69
+ const context = await browser.newContext({
70
+ permissions: ['microphone'],
71
+ viewport: { width: 1280, height: 720 },
72
+ reducedMotion: 'reduce',
73
+ });
74
+ const page = await context.newPage();
75
+ // Create controller with injected page (using a factory pattern)
76
+ const controller = new StrudelController(this.isHeadless);
77
+ // Initialize the controller with the existing page
78
+ await this.initializeControllerWithPage(controller, page);
79
+ const session = {
80
+ controller,
81
+ context,
82
+ page,
83
+ created: new Date(),
84
+ lastActivity: new Date(),
85
+ };
86
+ this.sessions.set(id, session);
87
+ this.logger.info(`Session '${id}' created`, {
88
+ totalSessions: this.sessions.size,
89
+ });
90
+ return controller;
91
+ }
92
+ /**
93
+ * Initializes a StrudelController with an existing page
94
+ * This bypasses the normal initialize() which creates its own browser
95
+ */
96
+ async initializeControllerWithPage(controller, page) {
97
+ // Set up page routing for resource optimization
98
+ await page.route('**/*', (route) => {
99
+ const resourceType = route.request().resourceType();
100
+ if (['image', 'font', 'media'].includes(resourceType)) {
101
+ route.abort();
102
+ }
103
+ else {
104
+ route.continue();
105
+ }
106
+ });
107
+ // Navigate to Strudel
108
+ await page.goto('https://strudel.cc/', {
109
+ waitUntil: 'domcontentloaded',
110
+ timeout: 15000,
111
+ });
112
+ // Wait for editor
113
+ await page.waitForSelector('.cm-content', { timeout: 8000 });
114
+ // Wait for CodeMirror to initialize
115
+ await page.waitForFunction(() => {
116
+ const editor = document.querySelector('.cm-content');
117
+ return editor && editor.__view;
118
+ }, { timeout: 5000 });
119
+ // Inject page into controller via internal method
120
+ controller._page = page;
121
+ controller.browser = null; // Controller doesn't own the browser
122
+ // Inject audio analyzer
123
+ await controller.analyzer.inject(page);
124
+ }
125
+ /**
126
+ * Gets an existing session's controller
127
+ * @param id - Session identifier
128
+ * @returns StrudelController or undefined if session doesn't exist
129
+ */
130
+ getSession(id) {
131
+ const session = this.sessions.get(id);
132
+ if (session) {
133
+ session.lastActivity = new Date();
134
+ return session.controller;
135
+ }
136
+ return undefined;
137
+ }
138
+ /**
139
+ * Destroys a session and releases its resources
140
+ * @param id - Session identifier
141
+ * @throws {Error} When session doesn't exist
142
+ */
143
+ async destroySession(id) {
144
+ const session = this.sessions.get(id);
145
+ if (!session) {
146
+ throw new Error(`Session '${id}' not found`);
147
+ }
148
+ try {
149
+ // Close the browser context (which closes the page)
150
+ await session.context.close();
151
+ }
152
+ catch (error) {
153
+ this.logger.warn(`Error closing session context: ${error.message}`);
154
+ }
155
+ this.sessions.delete(id);
156
+ // If this was the default session, reset to 'default'
157
+ if (this.defaultSessionId === id) {
158
+ this.defaultSessionId = 'default';
159
+ }
160
+ this.logger.info(`Session '${id}' destroyed`, {
161
+ totalSessions: this.sessions.size,
162
+ });
163
+ // If no more sessions, close browser
164
+ if (this.sessions.size === 0) {
165
+ await this.closeBrowser();
166
+ }
167
+ }
168
+ /**
169
+ * Lists all active session IDs
170
+ * @returns Array of session identifiers
171
+ */
172
+ listSessions() {
173
+ return Array.from(this.sessions.keys());
174
+ }
175
+ /**
176
+ * Gets detailed information about all sessions
177
+ * @returns Array of session info objects
178
+ */
179
+ getSessionsInfo() {
180
+ const info = [];
181
+ for (const [id, session] of this.sessions) {
182
+ info.push({
183
+ id,
184
+ created: session.created,
185
+ lastActivity: session.lastActivity,
186
+ isPlaying: session.controller.getPlaybackState(),
187
+ });
188
+ }
189
+ return info;
190
+ }
191
+ /**
192
+ * Gets the default session's controller
193
+ * @returns StrudelController or undefined if no default session exists
194
+ */
195
+ getDefaultSession() {
196
+ return this.getSession(this.defaultSessionId);
197
+ }
198
+ /**
199
+ * Sets the default session
200
+ * @param id - Session identifier to set as default
201
+ * @throws {Error} When session doesn't exist
202
+ */
203
+ setDefaultSession(id) {
204
+ if (!this.sessions.has(id)) {
205
+ throw new Error(`Session '${id}' not found`);
206
+ }
207
+ this.defaultSessionId = id;
208
+ this.logger.info(`Default session set to '${id}'`);
209
+ }
210
+ /**
211
+ * Gets the current default session ID
212
+ * @returns Default session identifier
213
+ */
214
+ getDefaultSessionId() {
215
+ return this.defaultSessionId;
216
+ }
217
+ /**
218
+ * Destroys all sessions and closes the browser
219
+ */
220
+ async destroyAll() {
221
+ const sessionIds = this.listSessions();
222
+ for (const id of sessionIds) {
223
+ try {
224
+ const session = this.sessions.get(id);
225
+ if (session) {
226
+ await session.context.close();
227
+ }
228
+ }
229
+ catch (error) {
230
+ this.logger.warn(`Error closing session '${id}': ${error.message}`);
231
+ }
232
+ }
233
+ this.sessions.clear();
234
+ await this.closeBrowser();
235
+ this.logger.info('All sessions destroyed');
236
+ }
237
+ /**
238
+ * Closes the shared browser instance
239
+ */
240
+ async closeBrowser() {
241
+ this.stopCleanupTimer();
242
+ if (this.browser) {
243
+ try {
244
+ await this.browser.close();
245
+ }
246
+ catch (error) {
247
+ this.logger.warn(`Error closing browser: ${error.message}`);
248
+ }
249
+ this.browser = null;
250
+ }
251
+ }
252
+ /**
253
+ * Starts the automatic cleanup timer
254
+ */
255
+ startCleanupTimer() {
256
+ if (this.cleanupTimer)
257
+ return;
258
+ this.cleanupTimer = setInterval(() => {
259
+ this.cleanupInactiveSessions().catch((err) => {
260
+ this.logger.error('Cleanup failed', err);
261
+ });
262
+ }, this.CLEANUP_INTERVAL);
263
+ }
264
+ /**
265
+ * Stops the automatic cleanup timer
266
+ */
267
+ stopCleanupTimer() {
268
+ if (this.cleanupTimer) {
269
+ clearInterval(this.cleanupTimer);
270
+ this.cleanupTimer = null;
271
+ }
272
+ }
273
+ /**
274
+ * Cleans up sessions that have been inactive beyond the timeout
275
+ */
276
+ async cleanupInactiveSessions() {
277
+ const now = Date.now();
278
+ const sessionsToDestroy = [];
279
+ for (const [id, session] of this.sessions) {
280
+ const inactiveTime = now - session.lastActivity.getTime();
281
+ if (inactiveTime > this.INACTIVITY_TIMEOUT) {
282
+ sessionsToDestroy.push(id);
283
+ }
284
+ }
285
+ for (const id of sessionsToDestroy) {
286
+ this.logger.info(`Auto-destroying inactive session '${id}'`);
287
+ try {
288
+ await this.destroySession(id);
289
+ }
290
+ catch (error) {
291
+ this.logger.error(`Failed to destroy session '${id}'`, error);
292
+ }
293
+ }
294
+ }
295
+ /**
296
+ * Gets the number of active sessions
297
+ * @returns Number of active sessions
298
+ */
299
+ getSessionCount() {
300
+ return this.sessions.size;
301
+ }
302
+ /**
303
+ * Checks if the browser is running
304
+ * @returns True if browser is initialized
305
+ */
306
+ isBrowserRunning() {
307
+ return this.browser !== null;
308
+ }
309
+ /**
310
+ * Gets the maximum allowed sessions
311
+ * @returns Maximum session limit
312
+ */
313
+ getMaxSessions() {
314
+ return this.MAX_SESSIONS;
315
+ }
316
+ }
@@ -0,0 +1,374 @@
1
+ /**
2
+ * StrudelEngine - Local Strudel pattern engine for Node.js
3
+ *
4
+ * Executes Strudel patterns without browser automation.
5
+ * Uses @strudel/core, @strudel/mini, and @strudel/transpiler for:
6
+ * - Pattern validation with precise error locations
7
+ * - Event querying for pattern analysis
8
+ * - Syntax checking without browser overhead
9
+ *
10
+ * Note: Audio playback still requires browser (Playwright) - this is for
11
+ * validation and analysis only.
12
+ *
13
+ * @module services/StrudelEngine
14
+ */
15
+ import * as strudelCore from '@strudel/core';
16
+ import { mini } from '@strudel/mini';
17
+ import { transpiler } from '@strudel/transpiler';
18
+ /**
19
+ * Local Strudel pattern engine - executes patterns without browser.
20
+ * Replaces fragile browser automation for pattern validation and analysis.
21
+ *
22
+ * @example
23
+ * ```typescript
24
+ * const engine = new StrudelEngine();
25
+ *
26
+ * // Validate a pattern
27
+ * const validation = engine.validate('s("bd hh").fast(2)');
28
+ * if (validation.valid) {
29
+ * console.log('Pattern is valid');
30
+ * }
31
+ *
32
+ * // Query events
33
+ * const events = engine.queryEvents('s("bd hh sd hh")', 0, 2);
34
+ * console.log(`Found ${events.length} events in 2 cycles`);
35
+ * ```
36
+ */
37
+ export class StrudelEngine {
38
+ /** Execution context with Strudel functions */
39
+ context;
40
+ constructor() {
41
+ // Build execution context with all Strudel functions
42
+ this.context = {
43
+ ...strudelCore,
44
+ m: mini,
45
+ mini,
46
+ };
47
+ }
48
+ /**
49
+ * Transpile a Strudel pattern to JavaScript
50
+ *
51
+ * @param code - Strudel pattern code
52
+ * @returns Transpilation result with code or error
53
+ *
54
+ * @example
55
+ * ```typescript
56
+ * const result = engine.transpile('s("bd hh").fast(2)');
57
+ * if (result.success) {
58
+ * console.log('Transpiled:', result.transpiledCode);
59
+ * }
60
+ * ```
61
+ */
62
+ transpile(code) {
63
+ if (!code || code.trim().length === 0) {
64
+ return {
65
+ success: false,
66
+ error: 'Empty pattern',
67
+ errorLocation: { line: 1, column: 1, offset: 0 },
68
+ };
69
+ }
70
+ try {
71
+ const result = transpiler(code);
72
+ return {
73
+ success: true,
74
+ transpiledCode: result.output,
75
+ locations: result.locations || [],
76
+ };
77
+ }
78
+ catch (error) {
79
+ // Parse error location from Strudel/acorn errors
80
+ const location = this.parseErrorLocation(error);
81
+ return {
82
+ success: false,
83
+ error: error.message || 'Transpilation failed',
84
+ errorLocation: location,
85
+ };
86
+ }
87
+ }
88
+ /**
89
+ * Validate pattern syntax without execution
90
+ *
91
+ * @param code - Strudel pattern code
92
+ * @returns Validation result with errors and suggestions
93
+ *
94
+ * @example
95
+ * ```typescript
96
+ * const result = engine.validate('s("bd hh").fast(');
97
+ * if (!result.valid) {
98
+ * console.log('Errors:', result.errors);
99
+ * }
100
+ * ```
101
+ */
102
+ validate(code) {
103
+ const errors = [];
104
+ const warnings = [];
105
+ const suggestions = [];
106
+ // Empty check
107
+ if (!code || code.trim().length === 0) {
108
+ return {
109
+ valid: false,
110
+ errors: ['Pattern is empty'],
111
+ warnings: [],
112
+ suggestions: ['Add a pattern like: s("bd hh sd hh")'],
113
+ };
114
+ }
115
+ // Try transpilation first - catches syntax errors
116
+ const transpileResult = this.transpile(code);
117
+ if (!transpileResult.success) {
118
+ return {
119
+ valid: false,
120
+ errors: [transpileResult.error || 'Syntax error'],
121
+ warnings,
122
+ suggestions: this.getSuggestionsForError(transpileResult.error || ''),
123
+ errorLocation: transpileResult.errorLocation
124
+ ? { line: transpileResult.errorLocation.line, column: transpileResult.errorLocation.column }
125
+ : undefined,
126
+ };
127
+ }
128
+ // Try to evaluate the pattern to catch runtime errors
129
+ try {
130
+ const fn = new Function(...Object.keys(this.context), transpileResult.transpiledCode);
131
+ const pattern = fn(...Object.values(this.context));
132
+ // Verify it's a valid pattern
133
+ if (!pattern || typeof pattern.queryArc !== 'function') {
134
+ errors.push('Code did not produce a valid pattern');
135
+ suggestions.push('Ensure your code returns a pattern (e.g., s("bd"), note("c3"), stack(...))');
136
+ }
137
+ }
138
+ catch (error) {
139
+ errors.push(`Runtime error: ${error.message}`);
140
+ suggestions.push(...this.getSuggestionsForError(error.message));
141
+ }
142
+ // Check for common issues
143
+ this.checkCommonIssues(code, warnings, suggestions);
144
+ return {
145
+ valid: errors.length === 0,
146
+ errors,
147
+ warnings,
148
+ suggestions,
149
+ };
150
+ }
151
+ /**
152
+ * Query pattern events for a time range
153
+ *
154
+ * @param code - Strudel pattern code
155
+ * @param start - Start time in cycles
156
+ * @param end - End time in cycles
157
+ * @returns Array of pattern events
158
+ * @throws {Error} If pattern is invalid
159
+ *
160
+ * @example
161
+ * ```typescript
162
+ * const events = engine.queryEvents('s("bd hh sd hh")', 0, 2);
163
+ * events.forEach(e => {
164
+ * console.log(`${e.value} at ${e.start}-${e.end}`);
165
+ * });
166
+ * ```
167
+ */
168
+ queryEvents(code, start, end) {
169
+ const transpileResult = this.transpile(code);
170
+ if (!transpileResult.success) {
171
+ throw new Error(`Transpilation failed: ${transpileResult.error}`);
172
+ }
173
+ try {
174
+ const fn = new Function(...Object.keys(this.context), transpileResult.transpiledCode);
175
+ const pattern = fn(...Object.values(this.context));
176
+ if (!pattern || typeof pattern.queryArc !== 'function') {
177
+ throw new Error('Code did not produce a valid pattern');
178
+ }
179
+ const haps = pattern.queryArc(start, end);
180
+ return haps.map((hap) => this.hapToEvent(hap));
181
+ }
182
+ catch (error) {
183
+ throw new Error(`Pattern execution failed: ${error.message}`);
184
+ }
185
+ }
186
+ /**
187
+ * Execute a pattern and return the compiled pattern object
188
+ *
189
+ * @param code - Strudel pattern code
190
+ * @returns Compiled pattern object
191
+ * @throws {Error} If pattern is invalid
192
+ */
193
+ compile(code) {
194
+ const transpileResult = this.transpile(code);
195
+ if (!transpileResult.success) {
196
+ throw new Error(`Transpilation failed: ${transpileResult.error}`);
197
+ }
198
+ try {
199
+ const fn = new Function(...Object.keys(this.context), transpileResult.transpiledCode);
200
+ const pattern = fn(...Object.values(this.context));
201
+ if (!pattern || typeof pattern.queryArc !== 'function') {
202
+ throw new Error('Code did not produce a valid pattern');
203
+ }
204
+ return pattern;
205
+ }
206
+ catch (error) {
207
+ throw new Error(`Pattern execution failed: ${error.message}`);
208
+ }
209
+ }
210
+ /**
211
+ * Extract pattern metadata by analyzing events and code
212
+ *
213
+ * @param code - Strudel pattern code
214
+ * @returns Pattern metadata
215
+ *
216
+ * @example
217
+ * ```typescript
218
+ * const meta = engine.analyzePattern('s("bd hh").fast(2)');
219
+ * console.log('Events per cycle:', meta.eventsPerCycle);
220
+ * console.log('Complexity:', meta.complexity);
221
+ * ```
222
+ */
223
+ analyzePattern(code) {
224
+ // Default metadata
225
+ const metadata = {
226
+ eventsPerCycle: 0,
227
+ uniqueValues: [],
228
+ usesSound: false,
229
+ usesNote: false,
230
+ isStack: false,
231
+ functionsUsed: [],
232
+ complexity: 0,
233
+ };
234
+ // Extract BPM from setcpm
235
+ const bpmMatch = code.match(/setcpm\s*\(\s*(\d+(?:\.\d+)?)\s*\)/);
236
+ if (bpmMatch) {
237
+ metadata.bpm = parseFloat(bpmMatch[1]);
238
+ }
239
+ // Analyze code structure
240
+ metadata.usesSound = /\bs\s*\(/.test(code) || /\bsound\s*\(/.test(code);
241
+ metadata.usesNote = /\bnote\s*\(/.test(code);
242
+ metadata.isStack = /\bstack\s*\(/.test(code);
243
+ // Extract function names used
244
+ const funcMatches = code.match(/\b([a-z][a-zA-Z0-9_]*)\s*\(/g);
245
+ if (funcMatches) {
246
+ const funcNames = funcMatches.map(m => m.replace(/\s*\($/, ''));
247
+ metadata.functionsUsed = [...new Set(funcNames)];
248
+ }
249
+ // Try to query events for detailed analysis
250
+ try {
251
+ const events = this.queryEvents(code, 0, 1);
252
+ metadata.eventsPerCycle = events.length;
253
+ // Extract unique values
254
+ const values = events.map(e => {
255
+ if (typeof e.value === 'object' && e.value !== null) {
256
+ return e.value.s || e.value.note || e.value.n || JSON.stringify(e.value);
257
+ }
258
+ return String(e.value);
259
+ });
260
+ metadata.uniqueValues = [...new Set(values)];
261
+ // Calculate complexity based on various factors
262
+ const complexityFactors = [
263
+ Math.min(events.length / 16, 1) * 0.3, // Event density
264
+ Math.min(metadata.uniqueValues.length / 8, 1) * 0.2, // Variety
265
+ Math.min(metadata.functionsUsed.length / 10, 1) * 0.3, // Function complexity
266
+ metadata.isStack ? 0.1 : 0, // Layering
267
+ (code.length / 500) * 0.1, // Code length
268
+ ];
269
+ metadata.complexity = Math.min(complexityFactors.reduce((a, b) => a + b, 0), 1);
270
+ }
271
+ catch {
272
+ // If we can't query events, estimate from code
273
+ metadata.complexity = Math.min((metadata.functionsUsed.length / 10) * 0.5 +
274
+ (code.length / 500) * 0.3 +
275
+ (metadata.isStack ? 0.2 : 0), 1);
276
+ }
277
+ return metadata;
278
+ }
279
+ /**
280
+ * Convert a Strudel Hap to our PatternEvent format
281
+ */
282
+ hapToEvent(hap) {
283
+ return {
284
+ value: hap.value,
285
+ start: hap.whole?.begin?.valueOf() ?? hap.part.begin.valueOf(),
286
+ end: hap.whole?.end?.valueOf() ?? hap.part.end.valueOf(),
287
+ isWhole: hap.whole !== undefined,
288
+ context: hap.context,
289
+ };
290
+ }
291
+ /**
292
+ * Parse error location from various error formats
293
+ */
294
+ parseErrorLocation(error) {
295
+ // Acorn-style location
296
+ if (error.loc) {
297
+ return {
298
+ line: error.loc.line,
299
+ column: error.loc.column,
300
+ offset: error.pos || 0,
301
+ };
302
+ }
303
+ // Strudel mini SyntaxError
304
+ if (error.location) {
305
+ return {
306
+ line: error.location.start.line,
307
+ column: error.location.start.column,
308
+ offset: error.location.start.offset,
309
+ };
310
+ }
311
+ // Try to parse from message
312
+ const lineColMatch = error.message?.match(/line\s*(\d+).*column\s*(\d+)/i);
313
+ if (lineColMatch) {
314
+ return {
315
+ line: parseInt(lineColMatch[1]),
316
+ column: parseInt(lineColMatch[2]),
317
+ offset: 0,
318
+ };
319
+ }
320
+ return undefined;
321
+ }
322
+ /**
323
+ * Get suggestions based on error message
324
+ */
325
+ getSuggestionsForError(error) {
326
+ const suggestions = [];
327
+ const lowerError = error.toLowerCase();
328
+ if (lowerError.includes('unexpected token')) {
329
+ suggestions.push('Check for missing quotes, parentheses, or brackets');
330
+ suggestions.push('Ensure all function calls have matching ()');
331
+ }
332
+ if (lowerError.includes('is not defined')) {
333
+ const match = error.match(/(\w+) is not defined/);
334
+ if (match) {
335
+ suggestions.push(`"${match[1]}" is not a known Strudel function`);
336
+ suggestions.push('Check spelling or use a valid function like s(), note(), stack()');
337
+ }
338
+ }
339
+ if (lowerError.includes('not a function')) {
340
+ suggestions.push('Check that you are calling methods on a pattern object');
341
+ }
342
+ if (lowerError.includes('unexpected end')) {
343
+ suggestions.push('Pattern appears incomplete - check for missing closing brackets');
344
+ }
345
+ return suggestions;
346
+ }
347
+ /**
348
+ * Check for common issues in pattern code
349
+ */
350
+ checkCommonIssues(code, warnings, suggestions) {
351
+ // Check for high gain values
352
+ const gainMatches = code.match(/\.gain\s*\(\s*(\d+(?:\.\d+)?)\s*\)/g);
353
+ if (gainMatches) {
354
+ for (const match of gainMatches) {
355
+ const value = parseFloat(match.match(/(\d+(?:\.\d+)?)/)?.[1] || '0');
356
+ if (value > 2) {
357
+ warnings.push(`High gain value (${value}) may cause distortion`);
358
+ }
359
+ if (value > 5) {
360
+ warnings.push(`Dangerous gain value (${value}) - consider reducing to 2 or less`);
361
+ }
362
+ }
363
+ }
364
+ // Check for missing sound-producing functions
365
+ if (!/\b(s|sound|note|n)\s*\(/.test(code) && !/\bstack\s*\(/.test(code)) {
366
+ warnings.push('Pattern may not produce sound - no s(), note(), or stack() found');
367
+ suggestions.push('Add a sound source like s("bd") or note("c3")');
368
+ }
369
+ // Suggest tempo if not set
370
+ if (!/setcpm|setbpm|cpm|bpm/.test(code)) {
371
+ suggestions.push('Consider setting tempo with setcpm(120)');
372
+ }
373
+ }
374
+ }