@williamzujkowski/live-coding-music-mcp 2.0.0 → 4.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.
@@ -1,12 +1,8 @@
1
1
  /**
2
2
  * capture domain — audio recording and MIDI export.
3
3
  *
4
- * Owns (4 tools): start_audio_capture, stop_audio_capture,
5
- * capture_audio_sample, export_midi.
6
- *
7
- * Per the #110 audit, the three audio_capture tools collapse into a
8
- * single `audio_capture(action=start|stop|sample)` post-split.
9
- * `export_midi` stays separate — MIDI is symbolic, audio is waveform.
4
+ * Owns two tools: `audio_capture(action=start|stop|sample)` and
5
+ * `export_midi` (kept separate — MIDI is symbolic, audio is waveform).
10
6
  *
11
7
  * The AudioCaptureService is lazy-instantiated module-side: we need
12
8
  * the current Playwright page for `injectRecorder()`, and that only
@@ -24,29 +20,33 @@ function blobToBase64(arrayBuffer) {
24
20
  }
25
21
  return btoa(binary);
26
22
  }
23
+ const SESSION_ID_PROP = {
24
+ session_id: {
25
+ type: 'string',
26
+ description: 'Optional session ID (#108). Routes page reference to the named session. Note: AudioCaptureService is currently server-wide — concurrent captures across sessions will conflict.',
27
+ },
28
+ };
27
29
  export const tools = [
28
30
  {
29
- name: 'start_audio_capture',
30
- description: 'Start capturing audio from Strudel output. Audio must be playing for capture to work.',
31
+ name: 'audio_capture',
32
+ description: 'Record audio output from the live Strudel session. ' +
33
+ 'action=start begins streaming capture (optional `format` webm/opus, default webm). ' +
34
+ 'action=stop ends the stream and returns base64-encoded audio. ' +
35
+ 'action=sample captures a fixed-`duration` window in one call (100-60000ms, default 5000ms). ' +
36
+ 'Example: audio_capture({ action: "sample", duration: 3000 }). ' +
37
+ 'Audio must be playing for capture to record meaningful data. ' +
38
+ 'For MIDI export use export_midi; for runtime diagnostics use diagnostics. ' +
39
+ 'Note: the AudioCaptureService is currently a server-wide singleton — concurrent captures across sessions will conflict.',
31
40
  inputSchema: {
32
41
  type: 'object',
33
42
  properties: {
34
- format: { type: 'string', enum: ['webm', 'opus'], description: 'Audio format (default: webm)' },
35
- maxDuration: { type: 'number', description: 'Maximum capture duration in milliseconds' },
43
+ action: { type: 'string', enum: ['start', 'stop', 'sample'], description: 'Capture action' },
44
+ format: { type: 'string', enum: ['webm', 'opus'], description: 'action=start: audio format (default webm)' },
45
+ maxDuration: { type: 'number', description: 'action=start: maximum capture duration ms' },
46
+ duration: { type: 'number', description: 'action=sample: duration ms (100-60000, default 5000)' },
47
+ ...SESSION_ID_PROP,
36
48
  },
37
- },
38
- },
39
- {
40
- name: 'stop_audio_capture',
41
- description: 'Stop audio capture and return the recorded audio as base64-encoded data.',
42
- inputSchema: { type: 'object', properties: {} },
43
- },
44
- {
45
- name: 'capture_audio_sample',
46
- description: 'Capture a fixed-duration audio sample from Strudel output. Audio must be playing.',
47
- inputSchema: {
48
- type: 'object',
49
- properties: { duration: { type: 'number', description: 'Duration in milliseconds (default: 5000)' } },
49
+ required: ['action'],
50
50
  },
51
51
  },
52
52
  {
@@ -59,35 +59,44 @@ export const tools = [
59
59
  duration: { type: 'number', description: 'Export duration in bars (default: 4)' },
60
60
  bpm: { type: 'number', description: 'Tempo in BPM (default: 120)' },
61
61
  format: { type: 'string', enum: ['file', 'base64'], description: 'Output format: file or base64 (default: base64)' },
62
+ ...SESSION_ID_PROP,
62
63
  },
63
64
  },
64
65
  },
65
66
  ];
66
67
  export const toolNames = new Set(tools.map(t => t.name));
67
68
  export async function execute(name, args, ctx) {
69
+ const sid = args?.session_id;
70
+ // No outer init check: each capture handler has try/catch that
71
+ // produces a structured { success: false, message } from any error,
72
+ // including AudioCaptureService's "Browser not initialized" throw.
68
73
  switch (name) {
69
- case 'start_audio_capture':
70
- return await startAudioCapture(args?.format, args?.maxDuration, ctx);
71
- case 'stop_audio_capture':
72
- return await stopAudioCapture(ctx);
73
- case 'capture_audio_sample':
74
- return await captureAudioSample(args?.duration, ctx);
74
+ case 'audio_capture': {
75
+ const a = args?.action;
76
+ switch (a) {
77
+ case 'start': return await startAudioCapture(args?.format, args?.maxDuration, ctx, sid);
78
+ case 'stop': return await stopAudioCapture(ctx, sid);
79
+ case 'sample': return await captureAudioSample(args?.duration, ctx, sid);
80
+ default:
81
+ throw new Error(`Invalid action: ${a}. Must be one of: start, stop, sample`);
82
+ }
83
+ }
75
84
  case 'export_midi':
76
- return await exportMidi(args?.filename, args?.duration, args?.bpm, args?.format, ctx);
85
+ return await exportMidi(args?.filename, args?.duration, args?.bpm, args?.format, ctx, sid);
77
86
  default:
78
87
  throw new Error(`capture module does not handle tool: ${name}`);
79
88
  }
80
89
  }
81
- async function startAudioCapture(format, _maxDuration, ctx) {
90
+ async function startAudioCapture(format, _maxDuration, ctx, sid) {
82
91
  try {
83
- const service = await ctx.getAudioCaptureService();
92
+ const service = await ctx.getAudioCaptureService(sid);
84
93
  if (service.isCapturing()) {
85
94
  return { success: false, message: 'Audio capture already in progress. Stop it first.' };
86
95
  }
87
- await service.startCapture(ctx.controller.page, { format });
96
+ await service.startCapture(ctx.getController(sid).page, { format });
88
97
  return {
89
98
  success: true,
90
- message: 'Audio capture started. Use stop_audio_capture to get the recorded audio.',
99
+ message: 'Audio capture started. Use audio_capture({ action: "stop" }) to get the recorded audio.',
91
100
  format: format || 'webm',
92
101
  };
93
102
  }
@@ -96,13 +105,13 @@ async function startAudioCapture(format, _maxDuration, ctx) {
96
105
  return { success: false, message: `Failed to start audio capture: ${message}` };
97
106
  }
98
107
  }
99
- async function stopAudioCapture(ctx) {
108
+ async function stopAudioCapture(ctx, sid) {
100
109
  try {
101
- const service = await ctx.getAudioCaptureService();
110
+ const service = await ctx.getAudioCaptureService(sid);
102
111
  if (!service.isCapturing()) {
103
112
  return { success: false, message: 'No audio capture in progress. Start capture first.' };
104
113
  }
105
- const result = await service.stopCapture(ctx.controller.page);
114
+ const result = await service.stopCapture(ctx.getController(sid).page);
106
115
  const buf = await result.blob.arrayBuffer();
107
116
  return {
108
117
  success: true,
@@ -117,17 +126,17 @@ async function stopAudioCapture(ctx) {
117
126
  return { success: false, message: `Failed to stop audio capture: ${message}` };
118
127
  }
119
128
  }
120
- async function captureAudioSample(duration, ctx) {
129
+ async function captureAudioSample(duration, ctx, sid) {
121
130
  const durationMs = duration || 5000;
122
131
  if (durationMs < 100 || durationMs > 60000) {
123
132
  return { success: false, message: 'Duration must be between 100ms and 60000ms (1 minute)' };
124
133
  }
125
134
  try {
126
- const service = await ctx.getAudioCaptureService();
135
+ const service = await ctx.getAudioCaptureService(sid);
127
136
  if (service.isCapturing()) {
128
137
  return { success: false, message: 'Audio capture already in progress. Stop it first.' };
129
138
  }
130
- const result = await service.captureForDuration(ctx.controller.page, durationMs);
139
+ const result = await service.captureForDuration(ctx.getController(sid).page, durationMs);
131
140
  const buf = await result.blob.arrayBuffer();
132
141
  return {
133
142
  success: true,
@@ -142,13 +151,13 @@ async function captureAudioSample(duration, ctx) {
142
151
  return { success: false, message: `Failed to capture audio sample: ${message}` };
143
152
  }
144
153
  }
145
- async function exportMidi(filename, bars, bpm, format, ctx) {
154
+ async function exportMidi(filename, bars, bpm, format, ctx, sid) {
146
155
  if (bpm !== undefined)
147
156
  InputValidator.validateBPM(bpm);
148
157
  if (bars !== undefined && (bars < 1 || bars > 128)) {
149
158
  return { success: false, message: 'Bars must be between 1 and 128' };
150
159
  }
151
- const pattern = await ctx.getCurrentPatternSafe();
160
+ const pattern = await ctx.getCurrentPatternSafe(sid);
152
161
  if (!pattern || pattern.trim().length === 0) {
153
162
  return { success: false, message: 'No pattern to export. Write a pattern first.' };
154
163
  }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * compose domain — high-level UX tools that aren't a thin verb on a
3
+ * subsystem: `compose` (one-shot generate+write+play) and
4
+ * `browser_window` (show or screenshot the Strudel window).
5
+ */
6
+ import { InputValidator } from '../../utils/InputValidator.js';
7
+ const SESSION_ID_PROP = {
8
+ session_id: {
9
+ type: 'string',
10
+ description: 'Optional session ID (#108). Omit to use default session. compose auto-init only applies to default session — named sessions must already exist.',
11
+ },
12
+ };
13
+ export const tools = [
14
+ {
15
+ name: 'browser_window',
16
+ description: 'Interact with the visible Strudel browser window. ' +
17
+ 'action=show brings the window to the foreground. ' +
18
+ 'action=screenshot captures the current editor view to disk (optional `filename`). ' +
19
+ 'Example: browser_window({ action: "screenshot", filename: "demo.png" }). ' +
20
+ 'For diagnostics (status/errors/perf) use diagnostics; for the editor itself use edit_pattern.',
21
+ inputSchema: {
22
+ type: 'object',
23
+ properties: {
24
+ action: { type: 'string', enum: ['show', 'screenshot'], description: 'Window action' },
25
+ filename: { type: 'string', description: 'action=screenshot: optional output filename' },
26
+ ...SESSION_ID_PROP,
27
+ },
28
+ required: ['action'],
29
+ },
30
+ },
31
+ {
32
+ name: 'compose',
33
+ description: 'Generate, write, and play a complete pattern in one step. Auto-initializes default browser if needed.',
34
+ inputSchema: {
35
+ type: 'object',
36
+ properties: {
37
+ style: { type: 'string', description: 'Genre: techno, house, dnb, ambient, trap, jungle, jazz, experimental' },
38
+ tempo: { type: 'number', description: 'BPM (default: genre-appropriate)' },
39
+ key: { type: 'string', description: 'Musical key (default: C)' },
40
+ auto_play: { type: 'boolean', description: 'Start playback immediately (default: true)' },
41
+ get_feedback: { type: 'boolean', description: 'Get AI feedback on the generated pattern (default: false)' },
42
+ ...SESSION_ID_PROP,
43
+ },
44
+ required: ['style'],
45
+ },
46
+ },
47
+ ];
48
+ export const toolNames = new Set(tools.map(t => t.name));
49
+ const TEMPO_BY_STYLE = {
50
+ techno: 130,
51
+ house: 125,
52
+ dnb: 174,
53
+ 'drum and bass': 174,
54
+ ambient: 80,
55
+ trap: 140,
56
+ jungle: 160,
57
+ jazz: 110,
58
+ experimental: 120,
59
+ dubstep: 140,
60
+ trance: 138,
61
+ breakbeat: 130,
62
+ garage: 130,
63
+ electro: 128,
64
+ downtempo: 90,
65
+ idm: 115,
66
+ };
67
+ function defaultTempo(style) {
68
+ return TEMPO_BY_STYLE[style.toLowerCase()] ?? 120;
69
+ }
70
+ async function doShow(ctx, sid) {
71
+ if (!sid && !ctx.isInitialized()) {
72
+ return 'Browser not initialized. Run init first.';
73
+ }
74
+ return await ctx.getController(sid).showBrowser();
75
+ }
76
+ async function doScreenshot(args, ctx, sid) {
77
+ if (!sid && !ctx.isInitialized()) {
78
+ return 'Browser not initialized. Run init first.';
79
+ }
80
+ return await ctx.getController(sid).takeScreenshot(args?.filename);
81
+ }
82
+ export async function execute(name, args, ctx) {
83
+ const sid = args?.session_id;
84
+ switch (name) {
85
+ case 'browser_window': {
86
+ const a = args?.action;
87
+ if (a !== 'show' && a !== 'screenshot') {
88
+ throw new Error(`Invalid action: ${a}. Must be one of: show, screenshot`);
89
+ }
90
+ return a === 'show' ? await doShow(ctx, sid) : await doScreenshot(args, ctx, sid);
91
+ }
92
+ case 'compose': {
93
+ InputValidator.validateStringLength(args.style, 'style', 100, false);
94
+ if (args.key) {
95
+ InputValidator.validateRootNote(args.key);
96
+ }
97
+ if (args.tempo !== undefined) {
98
+ InputValidator.validateBPM(args.tempo);
99
+ }
100
+ // Auto-init only the default session. Named sessions must be
101
+ // created via `create_session` first — ctx.getController(sid)
102
+ // throws if missing, which the dispatcher renders as err('business').
103
+ if (!sid) {
104
+ await ctx.ensureInitialized();
105
+ }
106
+ const controller = ctx.getController(sid);
107
+ const tempo = args.tempo || defaultTempo(args.style);
108
+ const key = args.key || 'C';
109
+ const pattern = ctx.generator.generateCompletePattern(args.style, key, tempo);
110
+ await controller.writePattern(pattern);
111
+ const shouldPlay = args.auto_play !== false;
112
+ if (shouldPlay) {
113
+ await controller.play();
114
+ }
115
+ const response = {
116
+ success: true,
117
+ pattern: pattern.substring(0, 200) + (pattern.length > 200 ? '...' : ''),
118
+ metadata: { style: args.style, bpm: tempo, key },
119
+ status: shouldPlay ? 'playing' : 'ready',
120
+ message: `Created ${args.style} pattern in ${key}${shouldPlay ? ' - now playing' : ''}`,
121
+ };
122
+ if (args.get_feedback) {
123
+ if (ctx.geminiService.isAvailable()) {
124
+ try {
125
+ const feedback = await ctx.geminiService.getCreativeFeedback(pattern);
126
+ response.feedback = feedback;
127
+ response.message += ` (AI feedback: ${feedback.complexity} complexity, estimated ${feedback.estimatedStyle})`;
128
+ }
129
+ catch (error) {
130
+ const message = error instanceof Error ? error.message : String(error);
131
+ ctx.logger.warn('Failed to get AI feedback for compose', { error: message });
132
+ response.message += ' (AI feedback unavailable)';
133
+ }
134
+ }
135
+ else {
136
+ response.message += ' (AI feedback requires GEMINI_API_KEY)';
137
+ }
138
+ }
139
+ return response;
140
+ }
141
+ default:
142
+ throw new Error(`compose module does not handle tool: ${name}`);
143
+ }
144
+ }
145
+ export const composeModule = { tools, toolNames, execute };
@@ -1,93 +1,98 @@
1
1
  /**
2
- * diagnostics domain — performance, memory, status, errors, screenshots.
2
+ * diagnostics domain — observability tools (browser status, errors,
3
+ * server metrics).
3
4
  *
4
- * Owns (6 tools today): performance_report, memory_usage, screenshot,
5
- * status, diagnostics, show_errors.
5
+ * Owns one consolidated tool (`diagnostics(level)`) with status/full/
6
+ * perf/memory/errors levels.
6
7
  *
7
- * Post-consolidation target (per #110 audit): one `diagnostics` tool with
8
- * a `level` enum. For now we keep the individual verbs and only move them
9
- * out of server.ts; consolidation happens later under #120.
8
+ * The `<15ms` SLA on `level='status'` is preserved — that branch only
9
+ * reads from the controller's cache, not the browser. Other levels can
10
+ * touch the browser (`level='full'`) or just inspect server state
11
+ * (`perf`, `memory`).
10
12
  */
11
- export const tools = [
12
- {
13
- name: 'performance_report',
14
- description: 'Get performance metrics and bottlenecks',
15
- inputSchema: { type: 'object', properties: {} },
16
- },
17
- {
18
- name: 'memory_usage',
19
- description: 'Get current memory usage statistics',
20
- inputSchema: { type: 'object', properties: {} },
13
+ const SESSION_ID_PROP = {
14
+ session_id: {
15
+ type: 'string',
16
+ description: 'Optional session ID (#108). Applies to level=status, full, errors. Ignored for perf/memory (server-wide metrics).',
21
17
  },
18
+ };
19
+ export const tools = [
22
20
  {
23
- name: 'screenshot',
24
- description: 'Take a screenshot of the current Strudel editor state',
21
+ name: 'diagnostics',
22
+ description: 'Inspect server and browser state. ' +
23
+ 'level=status returns a quick state snapshot (cache read, <15ms SLA). ' +
24
+ 'level=full returns detailed browser diagnostics including caches, errors, and performance. ' +
25
+ 'level=perf returns server-side timing metrics + top bottlenecks. ' +
26
+ 'level=memory returns process memory usage. ' +
27
+ 'level=errors returns captured console errors and warnings from Strudel. ' +
28
+ 'Default level=full preserves the pre-consolidation behaviour. ' +
29
+ 'Example: diagnostics({ level: "status" }) — millisecond-cheap. ' +
30
+ 'For screenshots use browser_window; for tool listings use the strudel://docs/tools resource.',
25
31
  inputSchema: {
26
32
  type: 'object',
27
33
  properties: {
28
- filename: { type: 'string', description: 'Optional filename for screenshot' },
34
+ level: {
35
+ type: 'string',
36
+ enum: ['status', 'full', 'perf', 'memory', 'errors'],
37
+ description: 'Which diagnostic surface to read (default: full)',
38
+ },
39
+ ...SESSION_ID_PROP,
29
40
  },
30
41
  },
31
42
  },
32
- {
33
- name: 'status',
34
- description: 'Get current browser and playback status (quick state check)',
35
- inputSchema: { type: 'object', properties: {} },
36
- },
37
- {
38
- name: 'diagnostics',
39
- description: 'Get detailed browser diagnostics including cache, errors, and performance',
40
- inputSchema: { type: 'object', properties: {} },
41
- },
42
- {
43
- name: 'show_errors',
44
- description: 'Display captured console errors and warnings from Strudel',
45
- inputSchema: { type: 'object', properties: {} },
46
- },
47
43
  ];
48
44
  export const toolNames = new Set(tools.map(t => t.name));
45
+ function getPerformanceReport(ctx) {
46
+ const report = ctx.perfMonitor.getReport();
47
+ const bottlenecks = ctx.perfMonitor.getBottlenecks(5);
48
+ return `${report}\n\nTop 5 Bottlenecks:\n${JSON.stringify(bottlenecks, null, 2)}`;
49
+ }
50
+ function getMemoryUsage(ctx) {
51
+ const memory = ctx.perfMonitor.getMemoryUsage();
52
+ return memory ? JSON.stringify(memory, null, 2) : 'Memory usage not available';
53
+ }
54
+ function getStatus(ctx, sid) {
55
+ return ctx.getController(sid).getStatus();
56
+ }
57
+ async function getFullDiagnostics(ctx, sid) {
58
+ if (!sid && !ctx.isInitialized()) {
59
+ return {
60
+ initialized: false,
61
+ message: 'Browser not initialized. Run init first for full diagnostics.',
62
+ };
63
+ }
64
+ return await ctx.getController(sid).getDiagnostics();
65
+ }
66
+ function getErrors(ctx, sid) {
67
+ const controller = ctx.getController(sid);
68
+ const errors = controller.getConsoleErrors();
69
+ const warnings = controller.getConsoleWarnings();
70
+ if (errors.length === 0 && warnings.length === 0) {
71
+ return 'No errors or warnings captured.';
72
+ }
73
+ let result = '';
74
+ if (errors.length > 0) {
75
+ result += `❌ Errors (${errors.length}):\n${errors.map(e => ` • ${e}`).join('\n')}\n`;
76
+ }
77
+ if (warnings.length > 0) {
78
+ result += `⚠️ Warnings (${warnings.length}):\n${warnings.map(w => ` • ${w}`).join('\n')}`;
79
+ }
80
+ return result.trim();
81
+ }
49
82
  export async function execute(name, args, ctx) {
83
+ const sid = args?.session_id;
50
84
  switch (name) {
51
- case 'performance_report': {
52
- const report = ctx.perfMonitor.getReport();
53
- const bottlenecks = ctx.perfMonitor.getBottlenecks(5);
54
- return `${report}\n\nTop 5 Bottlenecks:\n${JSON.stringify(bottlenecks, null, 2)}`;
55
- }
56
- case 'memory_usage': {
57
- const memory = ctx.perfMonitor.getMemoryUsage();
58
- return memory ? JSON.stringify(memory, null, 2) : 'Memory usage not available';
59
- }
60
- case 'screenshot': {
61
- if (!ctx.isInitialized()) {
62
- return 'Browser not initialized. Run init first.';
63
- }
64
- return await ctx.controller.takeScreenshot(args?.filename);
65
- }
66
- case 'status':
67
- return ctx.controller.getStatus();
68
85
  case 'diagnostics': {
69
- if (!ctx.isInitialized()) {
70
- return {
71
- initialized: false,
72
- message: 'Browser not initialized. Run init first for full diagnostics.',
73
- };
74
- }
75
- return await ctx.controller.getDiagnostics();
76
- }
77
- case 'show_errors': {
78
- const errors = ctx.controller.getConsoleErrors();
79
- const warnings = ctx.controller.getConsoleWarnings();
80
- if (errors.length === 0 && warnings.length === 0) {
81
- return 'No errors or warnings captured.';
82
- }
83
- let result = '';
84
- if (errors.length > 0) {
85
- result += `❌ Errors (${errors.length}):\n${errors.map(e => ` • ${e}`).join('\n')}\n`;
86
- }
87
- if (warnings.length > 0) {
88
- result += `⚠️ Warnings (${warnings.length}):\n${warnings.map(w => ` • ${w}`).join('\n')}`;
86
+ const level = args?.level ?? 'full';
87
+ switch (level) {
88
+ case 'status': return getStatus(ctx, sid);
89
+ case 'full': return await getFullDiagnostics(ctx, sid);
90
+ case 'perf': return getPerformanceReport(ctx);
91
+ case 'memory': return getMemoryUsage(ctx);
92
+ case 'errors': return getErrors(ctx, sid);
93
+ default:
94
+ throw new Error(`Invalid level: ${level}. Must be one of: status, full, perf, memory, errors`);
89
95
  }
90
- return result.trim();
91
96
  }
92
97
  default:
93
98
  throw new Error(`diagnostics module does not handle tool: ${name}`);