@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,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
|
+
}
|