@williamzujkowski/live-coding-music-mcp 1.0.0 → 3.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.
@@ -206,7 +206,7 @@ export class MIDIExportService {
206
206
  }
207
207
  // Handle sub-patterns in brackets [c4 e4]
208
208
  if (part.startsWith('[')) {
209
- const subContent = part.replace(/[\[\]]/g, '');
209
+ const subContent = part.replace(/[[\]]/g, '');
210
210
  const subParts = subContent.split(/[\s,]+/);
211
211
  subParts.forEach(subPart => {
212
212
  const midi = asMidiNumbers
@@ -271,7 +271,7 @@ export class MIDIExportService {
271
271
  parseInlineNotes(pattern) {
272
272
  const notes = [];
273
273
  // Find quoted strings that look like note sequences
274
- const quotedRegex = /["'`]([a-g][#b]?\d[\s,a-g#b0-9~\-\[\]]+)["'`]/gi;
274
+ const quotedRegex = /["'`]([a-g][#b]?\d[\s,a-g#b0-9~\-[\]]+)["'`]/gi;
275
275
  let match;
276
276
  while ((match = quotedRegex.exec(pattern)) !== null) {
277
277
  const noteSequence = match[1];
@@ -300,8 +300,6 @@ export class MIDIExportService {
300
300
  // Create track
301
301
  const track = midi.addTrack();
302
302
  track.name = trackName;
303
- // Calculate PPQ (pulses per quarter note) from the header
304
- const ppq = midi.header.ppq;
305
303
  // Add notes to track
306
304
  notes.forEach(noteEvent => {
307
305
  // Only add notes within the specified bar range
@@ -47,11 +47,13 @@ export class SessionManager {
47
47
  /**
48
48
  * Creates a new isolated Strudel session
49
49
  * @param id - Unique session identifier
50
- * @param headless - Override headless mode for this session (unused, uses shared browser)
50
+ * @param _headless - Override headless mode for this session (currently a
51
+ * no-op; sessions share the constructor's browser instance). Kept in the
52
+ * signature for API stability until multi-session work in #108 lands.
51
53
  * @returns Initialized StrudelController for the session
52
54
  * @throws {Error} When max sessions limit reached or session ID already exists
53
55
  */
54
- async createSession(id, headless) {
56
+ async createSession(id, _headless) {
55
57
  // Validate session ID
56
58
  if (!id || typeof id !== 'string') {
57
59
  throw new Error('Session ID must be a non-empty string');
@@ -15,6 +15,7 @@
15
15
  import * as strudelCore from '@strudel/core';
16
16
  import { mini } from '@strudel/mini';
17
17
  import { transpiler } from '@strudel/transpiler';
18
+ import { calculateComplexity, checkCommonIssues, extractBpm, extractFunctionsUsed, getSuggestionsForError, parseErrorLocation, } from './StrudelEngineHelpers.js';
18
19
  /**
19
20
  * Local Strudel pattern engine - executes patterns without browser.
20
21
  * Replaces fragile browser automation for pattern validation and analysis.
@@ -76,8 +77,7 @@ export class StrudelEngine {
76
77
  };
77
78
  }
78
79
  catch (error) {
79
- // Parse error location from Strudel/acorn errors
80
- const location = this.parseErrorLocation(error);
80
+ const location = parseErrorLocation(error);
81
81
  return {
82
82
  success: false,
83
83
  error: error.message || 'Transpilation failed',
@@ -112,24 +112,21 @@ export class StrudelEngine {
112
112
  suggestions: ['Add a pattern like: s("bd hh sd hh")'],
113
113
  };
114
114
  }
115
- // Try transpilation first - catches syntax errors
116
115
  const transpileResult = this.transpile(code);
117
116
  if (!transpileResult.success) {
118
117
  return {
119
118
  valid: false,
120
119
  errors: [transpileResult.error || 'Syntax error'],
121
120
  warnings,
122
- suggestions: this.getSuggestionsForError(transpileResult.error || ''),
121
+ suggestions: getSuggestionsForError(transpileResult.error || ''),
123
122
  errorLocation: transpileResult.errorLocation
124
123
  ? { line: transpileResult.errorLocation.line, column: transpileResult.errorLocation.column }
125
124
  : undefined,
126
125
  };
127
126
  }
128
- // Try to evaluate the pattern to catch runtime errors
129
127
  try {
130
128
  const fn = new Function(...Object.keys(this.context), transpileResult.transpiledCode);
131
129
  const pattern = fn(...Object.values(this.context));
132
- // Verify it's a valid pattern
133
130
  if (!pattern || typeof pattern.queryArc !== 'function') {
134
131
  errors.push('Code did not produce a valid pattern');
135
132
  suggestions.push('Ensure your code returns a pattern (e.g., s("bd"), note("c3"), stack(...))');
@@ -137,10 +134,9 @@ export class StrudelEngine {
137
134
  }
138
135
  catch (error) {
139
136
  errors.push(`Runtime error: ${error.message}`);
140
- suggestions.push(...this.getSuggestionsForError(error.message));
137
+ suggestions.push(...getSuggestionsForError(error.message));
141
138
  }
142
- // Check for common issues
143
- this.checkCommonIssues(code, warnings, suggestions);
139
+ checkCommonIssues(code, warnings, suggestions);
144
140
  return {
145
141
  valid: errors.length === 0,
146
142
  errors,
@@ -221,36 +217,22 @@ export class StrudelEngine {
221
217
  * ```
222
218
  */
223
219
  analyzePattern(code) {
224
- // Default metadata
225
220
  const metadata = {
226
221
  eventsPerCycle: 0,
227
222
  uniqueValues: [],
228
- usesSound: false,
229
- usesNote: false,
230
- isStack: false,
231
- functionsUsed: [],
223
+ usesSound: /\bs\s*\(/.test(code) || /\bsound\s*\(/.test(code),
224
+ usesNote: /\bnote\s*\(/.test(code),
225
+ isStack: /\bstack\s*\(/.test(code),
226
+ functionsUsed: extractFunctionsUsed(code),
232
227
  complexity: 0,
233
228
  };
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]);
229
+ const bpm = extractBpm(code);
230
+ if (bpm !== undefined) {
231
+ metadata.bpm = bpm;
238
232
  }
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
233
  try {
251
234
  const events = this.queryEvents(code, 0, 1);
252
235
  metadata.eventsPerCycle = events.length;
253
- // Extract unique values
254
236
  const values = events.map(e => {
255
237
  if (typeof e.value === 'object' && e.value !== null) {
256
238
  return e.value.s || e.value.note || e.value.n || JSON.stringify(e.value);
@@ -258,21 +240,20 @@ export class StrudelEngine {
258
240
  return String(e.value);
259
241
  });
260
242
  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);
243
+ metadata.complexity = calculateComplexity({
244
+ uniqueValues: metadata.uniqueValues,
245
+ functionsUsed: metadata.functionsUsed,
246
+ isStack: metadata.isStack,
247
+ codeLength: code.length,
248
+ }, events.length);
270
249
  }
271
250
  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);
251
+ metadata.complexity = calculateComplexity({
252
+ uniqueValues: metadata.uniqueValues,
253
+ functionsUsed: metadata.functionsUsed,
254
+ isStack: metadata.isStack,
255
+ codeLength: code.length,
256
+ });
276
257
  }
277
258
  return metadata;
278
259
  }
@@ -288,87 +269,4 @@ export class StrudelEngine {
288
269
  context: hap.context,
289
270
  };
290
271
  }
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
272
  }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Pure helpers extracted from StrudelEngine for direct unit testing.
3
+ *
4
+ * StrudelEngine.ts imports @strudel/* packages, which ship as ESM and
5
+ * fight Jest's CJS loader (see #107). Helpers that don't touch @strudel
6
+ * — string parsing, regex extraction, scoring — live here so they can
7
+ * be tested without the ESM workaround dance.
8
+ *
9
+ * @module services/StrudelEngineHelpers
10
+ */
11
+ /**
12
+ * Parse an error location from the assortment of shapes the Strudel
13
+ * stack produces:
14
+ * - acorn: { loc: { line, column }, pos }
15
+ * - mini: { location: { start: { line, column, offset } } }
16
+ * - other: message string containing "line N ... column N"
17
+ * Returns undefined if no shape matches — callers should treat that
18
+ * as "location unknown" rather than an error.
19
+ */
20
+ export function parseErrorLocation(error) {
21
+ if (error?.loc) {
22
+ return {
23
+ line: error.loc.line,
24
+ column: error.loc.column,
25
+ offset: error.pos ?? 0,
26
+ };
27
+ }
28
+ if (error?.location?.start) {
29
+ return {
30
+ line: error.location.start.line,
31
+ column: error.location.start.column,
32
+ offset: error.location.start.offset,
33
+ };
34
+ }
35
+ const lineColMatch = error?.message?.match(/line\s*(\d+).*column\s*(\d+)/i);
36
+ if (lineColMatch) {
37
+ return {
38
+ line: parseInt(lineColMatch[1], 10),
39
+ column: parseInt(lineColMatch[2], 10),
40
+ offset: 0,
41
+ };
42
+ }
43
+ return undefined;
44
+ }
45
+ /**
46
+ * Produce actionable suggestions for an error message. Recognises four
47
+ * common error families: unexpected token, undefined name, not-a-fn,
48
+ * unexpected end of input. Returns an empty array for anything else
49
+ * (callers should still surface the raw error).
50
+ */
51
+ export function getSuggestionsForError(error) {
52
+ const suggestions = [];
53
+ const lowerError = error.toLowerCase();
54
+ if (lowerError.includes('unexpected token')) {
55
+ suggestions.push('Check for missing quotes, parentheses, or brackets');
56
+ suggestions.push('Ensure all function calls have matching ()');
57
+ }
58
+ if (lowerError.includes('is not defined')) {
59
+ const match = error.match(/(\w+) is not defined/);
60
+ if (match) {
61
+ suggestions.push(`"${match[1]}" is not a known Strudel function`);
62
+ suggestions.push('Check spelling or use a valid function like s(), note(), stack()');
63
+ }
64
+ }
65
+ if (lowerError.includes('not a function')) {
66
+ suggestions.push('Check that you are calling methods on a pattern object');
67
+ }
68
+ if (lowerError.includes('unexpected end')) {
69
+ suggestions.push('Pattern appears incomplete - check for missing closing brackets');
70
+ }
71
+ return suggestions;
72
+ }
73
+ /**
74
+ * Append warnings/suggestions for common pattern-code issues:
75
+ * - gain values above safe thresholds (2.0 warn, 5.0 strong warn)
76
+ * - patterns with no sound-producing call (s/sound/note/n/stack)
77
+ * - patterns with no tempo set (setcpm/setbpm/cpm/bpm)
78
+ *
79
+ * Mutates the passed arrays in-place to match the calling convention
80
+ * in StrudelEngine.validate(). Returns nothing.
81
+ */
82
+ export function checkCommonIssues(code, warnings, suggestions) {
83
+ const gainMatches = code.match(/\.gain\s*\(\s*(\d+(?:\.\d+)?)\s*\)/g);
84
+ if (gainMatches) {
85
+ for (const match of gainMatches) {
86
+ const value = parseFloat(match.match(/(\d+(?:\.\d+)?)/)?.[1] || '0');
87
+ if (value > 2) {
88
+ warnings.push(`High gain value (${value}) may cause distortion`);
89
+ }
90
+ if (value > 5) {
91
+ warnings.push(`Dangerous gain value (${value}) - consider reducing to 2 or less`);
92
+ }
93
+ }
94
+ }
95
+ if (!/\b(s|sound|note|n)\s*\(/.test(code) && !/\bstack\s*\(/.test(code)) {
96
+ warnings.push('Pattern may not produce sound - no s(), note(), or stack() found');
97
+ suggestions.push('Add a sound source like s("bd") or note("c3")');
98
+ }
99
+ if (!/setcpm|setbpm|cpm|bpm/.test(code)) {
100
+ suggestions.push('Consider setting tempo with setcpm(120)');
101
+ }
102
+ }
103
+ /**
104
+ * Extract BPM from a `setcpm(<number>)` call. Returns undefined when
105
+ * absent — Strudel's default tempo is documented elsewhere; this helper
106
+ * stays narrow (no fallback) so callers can distinguish "not set" from
107
+ * "set to default".
108
+ */
109
+ export function extractBpm(code) {
110
+ const match = code.match(/setcpm\s*\(\s*(\d+(?:\.\d+)?)\s*\)/);
111
+ return match ? parseFloat(match[1]) : undefined;
112
+ }
113
+ /**
114
+ * Extract unique function-call names from pattern code. Matches the
115
+ * lowercase-starting identifier immediately before `(` — same heuristic
116
+ * Strudel patterns follow (s, note, stack, fast, ...). Returns names in
117
+ * first-seen order, deduplicated.
118
+ */
119
+ export function extractFunctionsUsed(code) {
120
+ const matches = code.match(/\b([a-z][a-zA-Z0-9_]*)\s*\(/g);
121
+ if (!matches)
122
+ return [];
123
+ const names = matches.map(m => m.replace(/\s*\($/, ''));
124
+ return [...new Set(names)];
125
+ }
126
+ /**
127
+ * Score pattern complexity on a 0..1 scale. Two paths:
128
+ * - with events: weighted sum across event density, value variety,
129
+ * function count, stack bonus, code length
130
+ * - without events (e.g. unqueryable pattern): fall back to a simpler
131
+ * code-only estimate so we still return something useful
132
+ *
133
+ * Weights and caps are calibrated to keep typical patterns around 0.3–0.7
134
+ * with truly dense patterns reaching 1.0. Changing them shifts every
135
+ * downstream "complexity" report — bump cautiously.
136
+ */
137
+ export function calculateComplexity(input, eventCount) {
138
+ if (eventCount === undefined) {
139
+ return Math.min((input.functionsUsed.length / 10) * 0.5 +
140
+ (input.codeLength / 500) * 0.3 +
141
+ (input.isStack ? 0.2 : 0), 1);
142
+ }
143
+ const factors = [
144
+ Math.min(eventCount / 16, 1) * 0.3,
145
+ Math.min(input.uniqueValues.length / 8, 1) * 0.2,
146
+ Math.min(input.functionsUsed.length / 10, 1) * 0.3,
147
+ input.isStack ? 0.1 : 0,
148
+ (input.codeLength / 500) * 0.1,
149
+ ];
150
+ return Math.min(factors.reduce((a, b) => a + b, 0), 1);
151
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamzujkowski/live-coding-music-mcp",
3
- "version": "1.0.0",
3
+ "version": "3.0.0",
4
4
  "description": "MCP server (unofficial, fan project) for AI-assisted live-coding music via strudel.cc. Lets Claude drive pattern generation, audio analysis, and MIDI export through the Strudel browser REPL.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -74,7 +74,7 @@
74
74
  "music-generation"
75
75
  ],
76
76
  "author": "William Zujkowski <william.zujkowski@gmail.com>",
77
- "license": "MIT",
77
+ "license": "AGPL-3.0-or-later",
78
78
  "bugs": {
79
79
  "url": "https://github.com/williamzujkowski/live-coding-music-mcp/issues"
80
80
  },