@williamzujkowski/live-coding-music-mcp 3.0.0 → 4.1.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/README.md +212 -780
- package/dist/AudioAnalyzer.js +1294 -91
- package/dist/PatternStore.js +276 -23
- package/dist/StrudelController.js +388 -56
- package/dist/server/resources.js +27 -20
- package/dist/server/server.js +391 -83
- package/dist/server/tools/ai.js +129 -152
- package/dist/server/tools/analysis.js +170 -91
- package/dist/server/tools/capture.js +128 -55
- package/dist/server/tools/compose.js +28 -42
- package/dist/server/tools/diagnostics.js +23 -53
- package/dist/server/tools/editor.js +63 -72
- package/dist/server/tools/generate.js +45 -167
- package/dist/server/tools/history.js +35 -66
- package/dist/server/tools/playback.js +7 -22
- package/dist/server/tools/session.js +41 -47
- package/dist/server/tools/storage.js +173 -50
- package/dist/server/tools/transform.js +136 -199
- package/dist/server/tools/types.js +209 -14
- package/dist/services/AudioCaptureService.js +302 -33
- package/dist/services/AudioExportService.js +318 -0
- package/dist/services/BrowserOnlyFunctions.js +149 -0
- package/dist/services/EventDensityProbe.js +324 -0
- package/dist/services/GeminiService.js +221 -36
- package/dist/services/IsolatedEngineRunner.js +308 -0
- package/dist/services/IsolatedStrudelEngine.js +135 -0
- package/dist/services/LocalPatternEngine.js +16 -0
- package/dist/services/MIDIExportService.js +765 -71
- package/dist/services/MIDIImportService.js +474 -0
- package/dist/services/MusicTheory.js +85 -13
- package/dist/services/PatternGenerator.js +168 -85
- package/dist/services/PatternSandbox.js +359 -0
- package/dist/services/SessionManager.js +338 -65
- package/dist/services/StrudelEngine.js +218 -12
- package/dist/services/StrudelEngineHelpers.js +24 -8
- package/dist/services/StyleRegistry.js +204 -0
- package/dist/services/Transferable.js +114 -0
- package/dist/services/ai/AiTransport.js +56 -0
- package/dist/services/ai/AudioMeasurements.js +155 -0
- package/dist/services/ai/CliTransport.js +256 -0
- package/dist/services/engineChild.js +83 -0
- package/dist/services/engineChildPath.js +28 -0
- package/dist/utils/CategorisedError.js +59 -0
- package/dist/utils/ErrorRecovery.js +130 -110
- package/dist/utils/InputValidator.js +55 -7
- package/dist/utils/PatternValidator.js +136 -17
- package/dist/utils/SafePath.js +112 -0
- package/dist/utils/ServerConfig.js +248 -0
- package/dist/utils/TableLookup.js +41 -0
- package/dist/utils/Tempo.js +143 -0
- package/package.json +17 -5
- package/dist/services/__mocks__/StrudelEngine.js +0 -157
- package/dist/types/index.js +0 -1
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* capture domain — audio recording and MIDI export.
|
|
3
3
|
*
|
|
4
|
-
* Owns
|
|
5
|
-
*
|
|
4
|
+
* Owns three tools: `audio_capture(action=start|stop|sample)`,
|
|
5
|
+
* `export_audio` (records a window and writes a file), and `export_midi`
|
|
6
|
+
* (kept separate — MIDI is symbolic, audio is waveform).
|
|
6
7
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* `
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* the current Playwright page for `injectRecorder()`, and that only
|
|
13
|
-
* exists after `init`. The singleton survives across tool calls.
|
|
8
|
+
* AudioCaptureService instances are lazy-created per session: we need the
|
|
9
|
+
* session's Playwright page for `injectRecorder()`, and that only exists
|
|
10
|
+
* after `init`. The server holds them in a Map keyed by session id (#180),
|
|
11
|
+
* so each instance survives across tool calls and sessions do not share
|
|
12
|
+
* a recorder.
|
|
14
13
|
*/
|
|
14
|
+
import { categorizeError, err } from './types.js';
|
|
15
15
|
import { InputValidator } from '../../utils/InputValidator.js';
|
|
16
|
+
import { declaredBpm } from '../../utils/Tempo.js';
|
|
16
17
|
// AudioCaptureService lifecycle lives on the server; we fetch a shared
|
|
17
18
|
// instance via ctx.getAudioCaptureService() so tests can mock the class
|
|
18
19
|
// at module boundary without the extractor caching a stale instance.
|
|
@@ -27,7 +28,7 @@ function blobToBase64(arrayBuffer) {
|
|
|
27
28
|
const SESSION_ID_PROP = {
|
|
28
29
|
session_id: {
|
|
29
30
|
type: 'string',
|
|
30
|
-
description: 'Optional session ID (#108). Routes
|
|
31
|
+
description: 'Optional session ID (#108). Routes the capture to the named session, which has its own recorder — concurrent captures across sessions are safe (#180).',
|
|
31
32
|
},
|
|
32
33
|
};
|
|
33
34
|
export const tools = [
|
|
@@ -40,13 +41,18 @@ export const tools = [
|
|
|
40
41
|
'Example: audio_capture({ action: "sample", duration: 3000 }). ' +
|
|
41
42
|
'Audio must be playing for capture to record meaningful data. ' +
|
|
42
43
|
'For MIDI export use export_midi; for runtime diagnostics use diagnostics. ' +
|
|
43
|
-
'
|
|
44
|
+
'Each session has its own recorder, so captures in different sessions do not interfere (#180).',
|
|
44
45
|
inputSchema: {
|
|
45
46
|
type: 'object',
|
|
46
47
|
properties: {
|
|
47
48
|
action: { type: 'string', enum: ['start', 'stop', 'sample'], description: 'Capture action' },
|
|
48
49
|
format: { type: 'string', enum: ['webm', 'opus'], description: 'action=start: audio format (default webm)' },
|
|
49
|
-
maxDuration: {
|
|
50
|
+
maxDuration: {
|
|
51
|
+
type: 'number',
|
|
52
|
+
description: 'action=start: stop the recorder automatically after this many ms. '
|
|
53
|
+
+ 'Defaults to 600000 (ten minutes); the recording so far is kept and '
|
|
54
|
+
+ 'returned by a later stop.',
|
|
55
|
+
},
|
|
50
56
|
duration: { type: 'number', description: 'action=sample: duration ms (100-60000, default 5000)' },
|
|
51
57
|
...SESSION_ID_PROP,
|
|
52
58
|
},
|
|
@@ -54,29 +60,21 @@ export const tools = [
|
|
|
54
60
|
},
|
|
55
61
|
},
|
|
56
62
|
{
|
|
57
|
-
name: '
|
|
58
|
-
description: '
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
},
|
|
66
|
-
},
|
|
67
|
-
},
|
|
68
|
-
{
|
|
69
|
-
name: 'stop_audio_capture',
|
|
70
|
-
description: '[DEPRECATED — use audio_capture({ action: "stop" }) instead] Stop audio capture and return base64-encoded audio.',
|
|
71
|
-
inputSchema: { type: 'object', properties: { ...SESSION_ID_PROP } },
|
|
72
|
-
},
|
|
73
|
-
{
|
|
74
|
-
name: 'capture_audio_sample',
|
|
75
|
-
description: '[DEPRECATED — use audio_capture({ action: "sample" }) instead] Capture a fixed-duration audio sample.',
|
|
63
|
+
name: 'export_audio',
|
|
64
|
+
description: 'Record a window of live Strudel audio and write it to a file. ' +
|
|
65
|
+
'Returns a path plus what was actually recorded, not a wall of base64 — ' +
|
|
66
|
+
'prefer this over audio_capture when you want the audio to exist somewhere. ' +
|
|
67
|
+
'format=wav (default) decodes to 16-bit PCM a DAW will open; format=webm writes the raw Opus recording. ' +
|
|
68
|
+
'Reports silent captures instead of writing a silent file and claiming success. ' +
|
|
69
|
+
'Audio must already be playing: call playback({ action: "play" }) first. ' +
|
|
70
|
+
'Example: export_audio({ duration: 4000, filename: "take-01" }).',
|
|
76
71
|
inputSchema: {
|
|
77
72
|
type: 'object',
|
|
78
73
|
properties: {
|
|
79
|
-
duration: { type: 'number', description: '
|
|
74
|
+
duration: { type: 'number', description: 'Window to record in ms (100-30000, default 5000)' },
|
|
75
|
+
format: { type: 'string', enum: ['wav', 'webm'], description: 'Output format (default wav)' },
|
|
76
|
+
filename: { type: 'string', description: 'Output filename; reduced to a basename and confined to the export directory' },
|
|
77
|
+
output: { type: 'string', enum: ['file', 'base64'], description: 'file (default) writes to disk and returns a path; base64 returns bytes inline' },
|
|
80
78
|
...SESSION_ID_PROP,
|
|
81
79
|
},
|
|
82
80
|
},
|
|
@@ -89,7 +87,7 @@ export const tools = [
|
|
|
89
87
|
properties: {
|
|
90
88
|
filename: { type: 'string', description: 'Output filename (optional, default: pattern.mid)' },
|
|
91
89
|
duration: { type: 'number', description: 'Export duration in bars (default: 4)' },
|
|
92
|
-
bpm: { type: 'number', description:
|
|
90
|
+
bpm: { type: 'number', description: "Tempo in BPM (default: the pattern's own tempo, or 120 if it declares none)" },
|
|
93
91
|
format: { type: 'string', enum: ['file', 'base64'], description: 'Output format: file or base64 (default: base64)' },
|
|
94
92
|
...SESSION_ID_PROP,
|
|
95
93
|
},
|
|
@@ -113,66 +111,98 @@ export async function execute(name, args, ctx) {
|
|
|
113
111
|
throw new Error(`Invalid action: ${a}. Must be one of: start, stop, sample`);
|
|
114
112
|
}
|
|
115
113
|
}
|
|
116
|
-
case '
|
|
117
|
-
return await
|
|
118
|
-
case 'stop_audio_capture':
|
|
119
|
-
return await stopAudioCapture(ctx, sid);
|
|
120
|
-
case 'capture_audio_sample':
|
|
121
|
-
return await captureAudioSample(args?.duration, ctx, sid);
|
|
114
|
+
case 'export_audio':
|
|
115
|
+
return await exportAudio(args, ctx, sid);
|
|
122
116
|
case 'export_midi':
|
|
123
117
|
return await exportMidi(args?.filename, args?.duration, args?.bpm, args?.format, ctx, sid);
|
|
124
118
|
default:
|
|
125
119
|
throw new Error(`capture module does not handle tool: ${name}`);
|
|
126
120
|
}
|
|
127
121
|
}
|
|
128
|
-
async function startAudioCapture(format,
|
|
122
|
+
async function startAudioCapture(format, maxDuration, ctx, sid) {
|
|
129
123
|
try {
|
|
130
124
|
const service = await ctx.getAudioCaptureService(sid);
|
|
131
125
|
if (service.isCapturing()) {
|
|
132
|
-
return
|
|
126
|
+
return err('business', 'Audio capture already in progress. Stop it first.');
|
|
133
127
|
}
|
|
134
|
-
|
|
128
|
+
if (maxDuration !== undefined) {
|
|
129
|
+
InputValidator.validatePositiveInteger(maxDuration, 'maxDuration');
|
|
130
|
+
}
|
|
131
|
+
await service.startCapture(ctx.getController(sid).page, { format, maxDuration });
|
|
135
132
|
return {
|
|
136
133
|
success: true,
|
|
137
|
-
message: 'Audio capture started. Use
|
|
138
|
-
|
|
134
|
+
message: 'Audio capture started. Use audio_capture({ action: "stop" }) to get the recorded audio.',
|
|
135
|
+
// The mime the recorder actually produces, which is what `stop`
|
|
136
|
+
// reports. Echoing the requested `format` here meant start said
|
|
137
|
+
// "opus" and the matching stop said "audio/webm;codecs=opus" for
|
|
138
|
+
// one recording (#437). MediaRecorder gives a webm container with
|
|
139
|
+
// an Opus stream either way.
|
|
140
|
+
format: service.getMimeType(),
|
|
139
141
|
};
|
|
140
142
|
}
|
|
141
143
|
catch (error) {
|
|
142
144
|
const message = error instanceof Error ? error.message : String(error);
|
|
143
|
-
return
|
|
145
|
+
return err(categorizeError(error), `Failed to start audio capture: ${message}`);
|
|
144
146
|
}
|
|
145
147
|
}
|
|
146
148
|
async function stopAudioCapture(ctx, sid) {
|
|
147
149
|
try {
|
|
148
150
|
const service = await ctx.getAudioCaptureService(sid);
|
|
149
151
|
if (!service.isCapturing()) {
|
|
150
|
-
|
|
152
|
+
// Distinguish "nothing is recording" from "something else is".
|
|
153
|
+
//
|
|
154
|
+
// `export_audio` drives the page-side recorder directly and never
|
|
155
|
+
// touches this service's mirror, so for the whole of an export
|
|
156
|
+
// this branch answered "No audio capture in progress" — the
|
|
157
|
+
// opposite of what was happening (#437 item 9). The export is
|
|
158
|
+
// left to finish on its own; stopping its recorder from here
|
|
159
|
+
// would leave its own stopCapture with nothing to collect.
|
|
160
|
+
const page = ctx.getController(sid).page;
|
|
161
|
+
// Optional-chained, as `server.ts` does for `isInjectedInto`.
|
|
162
|
+
if (page && (await service.isPageCapturing?.(page)) === true) {
|
|
163
|
+
return err('business', 'An audio export is recording on this page. It stops on its own when '
|
|
164
|
+
+ 'its duration elapses; audio_capture cannot end it early.');
|
|
165
|
+
}
|
|
166
|
+
return err('business', 'No audio capture in progress. Start capture first.');
|
|
151
167
|
}
|
|
152
168
|
const result = await service.stopCapture(ctx.getController(sid).page);
|
|
153
169
|
const buf = await result.blob.arrayBuffer();
|
|
154
170
|
return {
|
|
155
171
|
success: true,
|
|
156
|
-
|
|
172
|
+
// A capture that hit `maxDuration` is shorter than the caller
|
|
173
|
+
// asked for. Saying so is the whole point of the page computing
|
|
174
|
+
// it — this handler used to drop it (#464).
|
|
175
|
+
message: `Captured ${result.duration}ms of audio`
|
|
176
|
+
+ (result.warning ? ` — ${result.warning}` : ''),
|
|
157
177
|
audio: blobToBase64(buf),
|
|
158
178
|
duration: result.duration,
|
|
159
179
|
format: result.format,
|
|
180
|
+
...(result.warning ? { warning: result.warning } : {}),
|
|
160
181
|
};
|
|
161
182
|
}
|
|
162
183
|
catch (error) {
|
|
163
184
|
const message = error instanceof Error ? error.message : String(error);
|
|
164
|
-
return
|
|
185
|
+
return err(categorizeError(error), `Failed to stop audio capture: ${message}`);
|
|
165
186
|
}
|
|
166
187
|
}
|
|
167
188
|
async function captureAudioSample(duration, ctx, sid) {
|
|
168
|
-
|
|
189
|
+
// `?? 5000`, not `|| 5000`. Zero is a number the caller passed, and
|
|
190
|
+
// `||` swallowed it into the default, so `duration: 0` recorded five
|
|
191
|
+
// seconds instead of being refused by the bound two lines down. The
|
|
192
|
+
// same shape as every other falsy-vs-absent bug in this repo (#437
|
|
193
|
+
// item 7).
|
|
194
|
+
const durationMs = duration ?? 5000;
|
|
169
195
|
if (durationMs < 100 || durationMs > 60000) {
|
|
170
|
-
|
|
196
|
+
// Validation, not business: the caller passed a number outside the
|
|
197
|
+
// allowed range, and no amount of setup changes that. My codemod
|
|
198
|
+
// guessed `business` from the absence of a nearby catch, which is
|
|
199
|
+
// the same guessing-by-shape this work exists to remove.
|
|
200
|
+
return err('validation', 'Duration must be between 100ms and 60000ms (1 minute)');
|
|
171
201
|
}
|
|
172
202
|
try {
|
|
173
203
|
const service = await ctx.getAudioCaptureService(sid);
|
|
174
204
|
if (service.isCapturing()) {
|
|
175
|
-
return
|
|
205
|
+
return err('business', 'Audio capture already in progress. Stop it first.');
|
|
176
206
|
}
|
|
177
207
|
const result = await service.captureForDuration(ctx.getController(sid).page, durationMs);
|
|
178
208
|
const buf = await result.blob.arrayBuffer();
|
|
@@ -186,32 +216,71 @@ async function captureAudioSample(duration, ctx, sid) {
|
|
|
186
216
|
}
|
|
187
217
|
catch (error) {
|
|
188
218
|
const message = error instanceof Error ? error.message : String(error);
|
|
189
|
-
return
|
|
219
|
+
return err(categorizeError(error), `Failed to capture audio sample: ${message}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
async function exportAudio(args, ctx, sid) {
|
|
223
|
+
try {
|
|
224
|
+
const service = ctx.audioExportService;
|
|
225
|
+
// Ensures the recorder is injected for this session; export needs the
|
|
226
|
+
// same GainNode interception audio_capture uses.
|
|
227
|
+
await ctx.getAudioCaptureService(sid);
|
|
228
|
+
const result = await service.exportAudio(ctx.getController(sid).page, {
|
|
229
|
+
duration: args?.duration,
|
|
230
|
+
format: args?.format,
|
|
231
|
+
filename: args?.filename,
|
|
232
|
+
output: args?.output,
|
|
233
|
+
});
|
|
234
|
+
if (!result.success) {
|
|
235
|
+
return { success: false, message: result.error ?? 'Audio export failed.' };
|
|
236
|
+
}
|
|
237
|
+
return {
|
|
238
|
+
...result,
|
|
239
|
+
message: result.path
|
|
240
|
+
? `Exported ${String(result.bytes)} bytes of ${result.format} to ${result.path}`
|
|
241
|
+
: `Captured ${String(result.bytes)} bytes of ${result.format}`,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
246
|
+
return err(categorizeError(error), `Audio export failed: ${message}`);
|
|
190
247
|
}
|
|
191
248
|
}
|
|
192
249
|
async function exportMidi(filename, bars, bpm, format, ctx, sid) {
|
|
193
250
|
if (bpm !== undefined)
|
|
194
251
|
InputValidator.validateBPM(bpm);
|
|
195
252
|
if (bars !== undefined && (bars < 1 || bars > 128)) {
|
|
196
|
-
return
|
|
253
|
+
return err('validation', 'Bars must be between 1 and 128');
|
|
197
254
|
}
|
|
198
255
|
const pattern = await ctx.getCurrentPatternSafe(sid);
|
|
199
256
|
if (!pattern || pattern.trim().length === 0) {
|
|
200
|
-
return
|
|
257
|
+
return err('business', 'No pattern to export. Write a pattern first.');
|
|
201
258
|
}
|
|
202
|
-
|
|
259
|
+
// The pattern's own tempo, unless the caller asked for a different
|
|
260
|
+
// one. This used to be a flat `bpm || 120`, so exporting a 174 BPM
|
|
261
|
+
// pattern wrote a 120 BPM file and a round trip silently rewrote the
|
|
262
|
+
// tempo — the same guarantee #336 protects for the bar (#399).
|
|
263
|
+
const exportOptions = { bpm: bpm ?? declaredBpm(pattern) ?? 120, bars: bars ?? 4 };
|
|
203
264
|
const outputFormat = format || 'base64';
|
|
204
265
|
if (outputFormat === 'file') {
|
|
205
266
|
const result = ctx.midiExportService.exportToFile(pattern, filename, exportOptions);
|
|
206
267
|
return {
|
|
207
268
|
success: result.success,
|
|
269
|
+
// The warning has to reach the caller or computing it in the
|
|
270
|
+
// service is pointless: this handler builds its own response and
|
|
271
|
+
// used to drop it, so an export that skipped most of a pattern
|
|
272
|
+
// still reported "Exported 2 notes" and nothing else (#335).
|
|
208
273
|
message: result.success
|
|
209
274
|
? `Exported ${result.noteCount} notes to ${result.output}`
|
|
275
|
+
+ (result.warning ? ` — ${result.warning}` : '')
|
|
210
276
|
: result.error || 'Export failed',
|
|
211
277
|
output: result.output,
|
|
212
278
|
noteCount: result.noteCount,
|
|
213
279
|
bars: result.bars,
|
|
214
280
|
bpm: result.bpm,
|
|
281
|
+
...(result.warning ? { warning: result.warning } : {}),
|
|
282
|
+
...(result.unrepresented ? { unrepresented: result.unrepresented } : {}),
|
|
283
|
+
...(result.partiallyExported ? { partiallyExported: result.partiallyExported } : {}),
|
|
215
284
|
error: result.error,
|
|
216
285
|
};
|
|
217
286
|
}
|
|
@@ -220,11 +289,15 @@ async function exportMidi(filename, bars, bpm, format, ctx, sid) {
|
|
|
220
289
|
success: result.success,
|
|
221
290
|
message: result.success
|
|
222
291
|
? `Exported ${result.noteCount} notes as base64 MIDI data`
|
|
292
|
+
+ (result.warning ? ` — ${result.warning}` : '')
|
|
223
293
|
: result.error || 'Export failed',
|
|
224
294
|
output: result.output,
|
|
225
295
|
noteCount: result.noteCount,
|
|
226
296
|
bars: result.bars,
|
|
227
297
|
bpm: result.bpm,
|
|
298
|
+
...(result.warning ? { warning: result.warning } : {}),
|
|
299
|
+
...(result.unrepresented ? { unrepresented: result.unrepresented } : {}),
|
|
300
|
+
...(result.partiallyExported ? { partiallyExported: result.partiallyExported } : {}),
|
|
228
301
|
error: result.error,
|
|
229
302
|
};
|
|
230
303
|
}
|
|
@@ -1,16 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
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
|
-
* (
|
|
5
|
-
*
|
|
6
|
-
* Post-consolidation (per #110 audit):
|
|
7
|
-
* - `compose` absorbs `generate_pattern` after the alias period
|
|
8
|
-
* - `show_browser` merges with `screenshot` into `browser_window`
|
|
9
|
-
* These targets live in different epics (#120). Keeping the two tools
|
|
10
|
-
* together here matches today's "UX Tools" grouping; the file is small
|
|
11
|
-
* enough to split later without disruption.
|
|
3
|
+
* subsystem: `compose` (one-shot generate+write+play) and
|
|
4
|
+
* `browser_window` (show or screenshot the Strudel window).
|
|
12
5
|
*/
|
|
13
6
|
import { InputValidator } from '../../utils/InputValidator.js';
|
|
7
|
+
import { defaultTempoFor, resolveLayers } from '../../services/StyleRegistry.js';
|
|
14
8
|
const SESSION_ID_PROP = {
|
|
15
9
|
session_id: {
|
|
16
10
|
type: 'string',
|
|
@@ -35,18 +29,13 @@ export const tools = [
|
|
|
35
29
|
required: ['action'],
|
|
36
30
|
},
|
|
37
31
|
},
|
|
38
|
-
{
|
|
39
|
-
name: 'show_browser',
|
|
40
|
-
description: '[DEPRECATED — use browser_window({ action: "show" }) instead] Bring browser window to foreground',
|
|
41
|
-
inputSchema: { type: 'object', properties: { ...SESSION_ID_PROP } },
|
|
42
|
-
},
|
|
43
32
|
{
|
|
44
33
|
name: 'compose',
|
|
45
34
|
description: 'Generate, write, and play a complete pattern in one step. Auto-initializes default browser if needed.',
|
|
46
35
|
inputSchema: {
|
|
47
36
|
type: 'object',
|
|
48
37
|
properties: {
|
|
49
|
-
style: { type: 'string', description: 'Genre: techno, house, dnb,
|
|
38
|
+
style: { type: 'string', description: 'Genre: techno, house, dnb, breakbeat, trap, jungle, ambient, experimental, intelligent_dnb, trip_hop, boom_bap. Anything else (jazz included) gets techno drums, and the response says so.' },
|
|
50
39
|
tempo: { type: 'number', description: 'BPM (default: genre-appropriate)' },
|
|
51
40
|
key: { type: 'string', description: 'Musical key (default: C)' },
|
|
52
41
|
auto_play: { type: 'boolean', description: 'Start playback immediately (default: true)' },
|
|
@@ -58,27 +47,6 @@ export const tools = [
|
|
|
58
47
|
},
|
|
59
48
|
];
|
|
60
49
|
export const toolNames = new Set(tools.map(t => t.name));
|
|
61
|
-
const TEMPO_BY_STYLE = {
|
|
62
|
-
techno: 130,
|
|
63
|
-
house: 125,
|
|
64
|
-
dnb: 174,
|
|
65
|
-
'drum and bass': 174,
|
|
66
|
-
ambient: 80,
|
|
67
|
-
trap: 140,
|
|
68
|
-
jungle: 160,
|
|
69
|
-
jazz: 110,
|
|
70
|
-
experimental: 120,
|
|
71
|
-
dubstep: 140,
|
|
72
|
-
trance: 138,
|
|
73
|
-
breakbeat: 130,
|
|
74
|
-
garage: 130,
|
|
75
|
-
electro: 128,
|
|
76
|
-
downtempo: 90,
|
|
77
|
-
idm: 115,
|
|
78
|
-
};
|
|
79
|
-
function defaultTempo(style) {
|
|
80
|
-
return TEMPO_BY_STYLE[style.toLowerCase()] ?? 120;
|
|
81
|
-
}
|
|
82
50
|
async function doShow(ctx, sid) {
|
|
83
51
|
if (!sid && !ctx.isInitialized()) {
|
|
84
52
|
return 'Browser not initialized. Run init first.';
|
|
@@ -101,8 +69,6 @@ export async function execute(name, args, ctx) {
|
|
|
101
69
|
}
|
|
102
70
|
return a === 'show' ? await doShow(ctx, sid) : await doScreenshot(args, ctx, sid);
|
|
103
71
|
}
|
|
104
|
-
case 'show_browser':
|
|
105
|
-
return await doShow(ctx, sid);
|
|
106
72
|
case 'compose': {
|
|
107
73
|
InputValidator.validateStringLength(args.style, 'style', 100, false);
|
|
108
74
|
if (args.key) {
|
|
@@ -112,13 +78,21 @@ export async function execute(name, args, ctx) {
|
|
|
112
78
|
InputValidator.validateBPM(args.tempo);
|
|
113
79
|
}
|
|
114
80
|
// Auto-init only the default session. Named sessions must be
|
|
115
|
-
// created via `
|
|
81
|
+
// created via `session({ action: "create" })` first — ctx.getController(sid)
|
|
116
82
|
// throws if missing, which the dispatcher renders as err('business').
|
|
117
83
|
if (!sid) {
|
|
118
84
|
await ctx.ensureInitialized();
|
|
119
85
|
}
|
|
120
86
|
const controller = ctx.getController(sid);
|
|
121
|
-
|
|
87
|
+
// compose assembles four independently-resolved layers, each
|
|
88
|
+
// falling back on its own. One `style` field cannot describe that
|
|
89
|
+
// — reporting only the drum resolution called
|
|
90
|
+
// compose({style:'jazz'}) "techno" while its bassline, Dorian
|
|
91
|
+
// scale and jazz chords were all genuinely jazz, and let
|
|
92
|
+
// `breakbeat` claim itself supported over a techno bassline
|
|
93
|
+
// (#294, correcting #279).
|
|
94
|
+
const resolution = resolveLayers(args.style);
|
|
95
|
+
const tempo = args.tempo || defaultTempoFor(args.style);
|
|
122
96
|
const key = args.key || 'C';
|
|
123
97
|
const pattern = ctx.generator.generateCompletePattern(args.style, key, tempo);
|
|
124
98
|
await controller.writePattern(pattern);
|
|
@@ -129,9 +103,21 @@ export async function execute(name, args, ctx) {
|
|
|
129
103
|
const response = {
|
|
130
104
|
success: true,
|
|
131
105
|
pattern: pattern.substring(0, 200) + (pattern.length > 200 ? '...' : ''),
|
|
132
|
-
metadata: {
|
|
106
|
+
metadata: {
|
|
107
|
+
style: args.style,
|
|
108
|
+
bpm: tempo,
|
|
109
|
+
key,
|
|
110
|
+
layers: resolution.layers,
|
|
111
|
+
substituted: resolution.substituted,
|
|
112
|
+
},
|
|
133
113
|
status: shouldPlay ? 'playing' : 'ready',
|
|
134
|
-
message:
|
|
114
|
+
message: resolution.substituted.length === 0
|
|
115
|
+
? `Created ${args.style} pattern in ${key}${shouldPlay ? ' - now playing' : ''}`
|
|
116
|
+
: `Created pattern in ${key}${shouldPlay ? ' - now playing' : ''}. ` +
|
|
117
|
+
`No ${resolution.substituted.join(' or ')} defined for style "${args.style}" — ` +
|
|
118
|
+
resolution.substituted
|
|
119
|
+
.map(l => `${l}: ${resolution.layers[l]}`)
|
|
120
|
+
.join(', ') + '.',
|
|
135
121
|
};
|
|
136
122
|
if (args.get_feedback) {
|
|
137
123
|
if (ctx.geminiService.isAvailable()) {
|
|
@@ -2,18 +2,15 @@
|
|
|
2
2
|
* diagnostics domain — observability tools (browser status, errors,
|
|
3
3
|
* server metrics).
|
|
4
4
|
*
|
|
5
|
-
* Owns one consolidated tool (`diagnostics(level)`)
|
|
6
|
-
*
|
|
7
|
-
* per #120 / #144.
|
|
5
|
+
* Owns one consolidated tool (`diagnostics(level)`) with status/full/
|
|
6
|
+
* perf/memory/errors levels.
|
|
8
7
|
*
|
|
9
8
|
* The `<15ms` SLA on `level='status'` is preserved — that branch only
|
|
10
9
|
* reads from the controller's cache, not the browser. Other levels can
|
|
11
10
|
* touch the browser (`level='full'`) or just inspect server state
|
|
12
11
|
* (`perf`, `memory`).
|
|
13
|
-
*
|
|
14
|
-
* `screenshot` stays separate; it folds into `browser_window` per
|
|
15
|
-
* Phase 3 (#156).
|
|
16
12
|
*/
|
|
13
|
+
import { empty } from './types.js';
|
|
17
14
|
const SESSION_ID_PROP = {
|
|
18
15
|
session_id: {
|
|
19
16
|
type: 'string',
|
|
@@ -24,14 +21,14 @@ export const tools = [
|
|
|
24
21
|
{
|
|
25
22
|
name: 'diagnostics',
|
|
26
23
|
description: 'Inspect server and browser state. ' +
|
|
27
|
-
'level=status returns a quick state snapshot (cache read, <15ms SLA). ' +
|
|
24
|
+
'level=status returns a quick state snapshot (cache read, <15ms SLA), plus which config.json was read and any warnings from it. ' +
|
|
28
25
|
'level=full returns detailed browser diagnostics including caches, errors, and performance. ' +
|
|
29
26
|
'level=perf returns server-side timing metrics + top bottlenecks. ' +
|
|
30
27
|
'level=memory returns process memory usage. ' +
|
|
31
28
|
'level=errors returns captured console errors and warnings from Strudel. ' +
|
|
32
29
|
'Default level=full preserves the pre-consolidation behaviour. ' +
|
|
33
30
|
'Example: diagnostics({ level: "status" }) — millisecond-cheap. ' +
|
|
34
|
-
'For screenshots use browser_window
|
|
31
|
+
'For screenshots use browser_window; for tool listings use the strudel://docs/tools resource.',
|
|
35
32
|
inputSchema: {
|
|
36
33
|
type: 'object',
|
|
37
34
|
properties: {
|
|
@@ -44,37 +41,6 @@ export const tools = [
|
|
|
44
41
|
},
|
|
45
42
|
},
|
|
46
43
|
},
|
|
47
|
-
{
|
|
48
|
-
name: 'status',
|
|
49
|
-
description: '[DEPRECATED — use diagnostics({ level: "status" }) instead] Get current browser and playback status (quick state check)',
|
|
50
|
-
inputSchema: { type: 'object', properties: { ...SESSION_ID_PROP } },
|
|
51
|
-
},
|
|
52
|
-
{
|
|
53
|
-
name: 'show_errors',
|
|
54
|
-
description: '[DEPRECATED — use diagnostics({ level: "errors" }) instead] Display captured console errors and warnings from Strudel',
|
|
55
|
-
inputSchema: { type: 'object', properties: { ...SESSION_ID_PROP } },
|
|
56
|
-
},
|
|
57
|
-
{
|
|
58
|
-
name: 'performance_report',
|
|
59
|
-
description: '[DEPRECATED — use diagnostics({ level: "perf" }) instead] Get performance metrics and bottlenecks',
|
|
60
|
-
inputSchema: { type: 'object', properties: {} },
|
|
61
|
-
},
|
|
62
|
-
{
|
|
63
|
-
name: 'memory_usage',
|
|
64
|
-
description: '[DEPRECATED — use diagnostics({ level: "memory" }) instead] Get current memory usage statistics',
|
|
65
|
-
inputSchema: { type: 'object', properties: {} },
|
|
66
|
-
},
|
|
67
|
-
{
|
|
68
|
-
name: 'screenshot',
|
|
69
|
-
description: '[DEPRECATED — use browser_window({ action: "screenshot" }) instead] Take a screenshot of the current Strudel editor state',
|
|
70
|
-
inputSchema: {
|
|
71
|
-
type: 'object',
|
|
72
|
-
properties: {
|
|
73
|
-
filename: { type: 'string', description: 'Optional filename for screenshot' },
|
|
74
|
-
...SESSION_ID_PROP,
|
|
75
|
-
},
|
|
76
|
-
},
|
|
77
|
-
},
|
|
78
44
|
];
|
|
79
45
|
export const toolNames = new Set(tools.map(t => t.name));
|
|
80
46
|
function getPerformanceReport(ctx) {
|
|
@@ -87,7 +53,23 @@ function getMemoryUsage(ctx) {
|
|
|
87
53
|
return memory ? JSON.stringify(memory, null, 2) : 'Memory usage not available';
|
|
88
54
|
}
|
|
89
55
|
function getStatus(ctx, sid) {
|
|
90
|
-
|
|
56
|
+
const status = ctx.getController(sid).getStatus();
|
|
57
|
+
const report = ctx.configReport;
|
|
58
|
+
if (!report)
|
|
59
|
+
return status;
|
|
60
|
+
// Config problems reached `logger.warn` and nothing else — a client
|
|
61
|
+
// log file the user may never open. A `strudel_url` typo, or an
|
|
62
|
+
// `audio_analysis` block that parsed to nothing, was invisible from
|
|
63
|
+
// inside the session. The path is reported whether or not a file was
|
|
64
|
+
// found, so a cwd surprise is visible rather than guessed at (#442).
|
|
65
|
+
return {
|
|
66
|
+
...(typeof status === 'object' && status !== null ? status : { status }),
|
|
67
|
+
config: {
|
|
68
|
+
path: report.path,
|
|
69
|
+
found: report.found,
|
|
70
|
+
...(report.warnings.length > 0 ? { warnings: report.warnings } : {}),
|
|
71
|
+
},
|
|
72
|
+
};
|
|
91
73
|
}
|
|
92
74
|
async function getFullDiagnostics(ctx, sid) {
|
|
93
75
|
if (!sid && !ctx.isInitialized()) {
|
|
@@ -103,7 +85,7 @@ function getErrors(ctx, sid) {
|
|
|
103
85
|
const errors = controller.getConsoleErrors();
|
|
104
86
|
const warnings = controller.getConsoleWarnings();
|
|
105
87
|
if (errors.length === 0 && warnings.length === 0) {
|
|
106
|
-
return 'No errors or warnings captured.';
|
|
88
|
+
return empty('No errors or warnings captured.');
|
|
107
89
|
}
|
|
108
90
|
let result = '';
|
|
109
91
|
if (errors.length > 0) {
|
|
@@ -129,18 +111,6 @@ export async function execute(name, args, ctx) {
|
|
|
129
111
|
throw new Error(`Invalid level: ${level}. Must be one of: status, full, perf, memory, errors`);
|
|
130
112
|
}
|
|
131
113
|
}
|
|
132
|
-
// Deprecated aliases — forward to consolidated handler. Kept for ≥1
|
|
133
|
-
// release per #120 migration policy.
|
|
134
|
-
case 'status': return getStatus(ctx, sid);
|
|
135
|
-
case 'show_errors': return getErrors(ctx, sid);
|
|
136
|
-
case 'performance_report': return getPerformanceReport(ctx);
|
|
137
|
-
case 'memory_usage': return getMemoryUsage(ctx);
|
|
138
|
-
case 'screenshot': {
|
|
139
|
-
if (!sid && !ctx.isInitialized()) {
|
|
140
|
-
return 'Browser not initialized. Run init first.';
|
|
141
|
-
}
|
|
142
|
-
return await ctx.getController(sid).takeScreenshot(args?.filename);
|
|
143
|
-
}
|
|
144
114
|
default:
|
|
145
115
|
throw new Error(`diagnostics module does not handle tool: ${name}`);
|
|
146
116
|
}
|