@williamzujkowski/live-coding-music-mcp 4.0.0 → 4.1.1
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 +202 -705
- 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 +384 -76
- package/dist/server/tools/ai.js +124 -91
- package/dist/server/tools/analysis.js +168 -59
- package/dist/server/tools/capture.js +135 -24
- package/dist/server/tools/compose.js +26 -26
- package/dist/server/tools/diagnostics.js +20 -3
- package/dist/server/tools/editor.js +60 -6
- package/dist/server/tools/generate.js +38 -15
- package/dist/server/tools/history.js +30 -8
- package/dist/server/tools/playback.js +2 -1
- package/dist/server/tools/session.js +38 -6
- package/dist/server/tools/storage.js +179 -8
- package/dist/server/tools/transform.js +127 -43
- 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 +27 -16
- package/dist/services/__mocks__/StrudelEngine.js +0 -157
- package/dist/types/index.js +0 -1
|
@@ -6,12 +6,16 @@
|
|
|
6
6
|
* browser-free: validate_pattern_local, analyze_pattern_local,
|
|
7
7
|
* query_pattern_events, transpile_pattern
|
|
8
8
|
*
|
|
9
|
-
* The browser-free set runs against the
|
|
9
|
+
* The browser-free set runs against the local engine — since #307, a
|
|
10
|
+
* forked child with a heap cap and a deadline, not this process — and
|
|
10
11
|
* pairs naturally with validate_pattern_runtime (which monitors the
|
|
11
12
|
* actual Strudel console). They were orphan handlers — implementations
|
|
12
13
|
* present but not registered in tools/list — until #124.
|
|
13
14
|
*/
|
|
15
|
+
import { empty, err, ok } from './types.js';
|
|
14
16
|
import { InputValidator } from '../../utils/InputValidator.js';
|
|
17
|
+
import { IsolatedRunnerError } from '../../services/IsolatedEngineRunner.js';
|
|
18
|
+
import { ValidationError } from '../../utils/CategorisedError.js';
|
|
15
19
|
const SESSION_ID_PROP = {
|
|
16
20
|
session_id: {
|
|
17
21
|
type: 'string',
|
|
@@ -57,7 +61,7 @@ export const tools = [
|
|
|
57
61
|
},
|
|
58
62
|
{
|
|
59
63
|
name: 'validate_pattern_local',
|
|
60
|
-
description: 'Validate pattern syntax against the in
|
|
64
|
+
description: 'Validate pattern syntax against the local StrudelEngine, which runs in a sandboxed child process (no browser required)',
|
|
61
65
|
inputSchema: {
|
|
62
66
|
type: 'object',
|
|
63
67
|
properties: {
|
|
@@ -118,57 +122,80 @@ async function getRhythm(controller) {
|
|
|
118
122
|
return await controller.analyzeRhythm();
|
|
119
123
|
}
|
|
120
124
|
async function getTempo(controller) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
125
|
+
const tempoAnalysis = await controller.detectTempo();
|
|
126
|
+
if (!tempoAnalysis || tempoAnalysis.bpm === 0) {
|
|
127
|
+
// The measurement ran and detected nothing. `bpm: 0` read as a
|
|
128
|
+
// measured value — zero beats per minute — so null plus an
|
|
129
|
+
// explicit flag (#288). No envelope here: getTempo is a sub-result
|
|
130
|
+
// composed into analyze's object, and an envelope nested inside a
|
|
131
|
+
// data payload is not the contract.
|
|
132
|
+
//
|
|
133
|
+
// Two different states arrive here. A reading that is merely
|
|
134
|
+
// settling has a candidate already and needs another poll; telling
|
|
135
|
+
// its caller to check whether audio is playing, while audio IS
|
|
136
|
+
// playing, sends it to fix the wrong thing (#374).
|
|
130
137
|
return {
|
|
131
|
-
bpm:
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
message:
|
|
138
|
+
bpm: null,
|
|
139
|
+
detected: false,
|
|
140
|
+
confidence: 0,
|
|
141
|
+
message: tempoAnalysis?.settling === true
|
|
142
|
+
? 'Tempo is still settling — a reading was taken but not yet confirmed by a second '
|
|
143
|
+
+ 'window. Call again in a second or two.'
|
|
144
|
+
: 'No tempo detected. Ensure audio is playing and has a clear rhythmic pattern.',
|
|
135
145
|
};
|
|
136
146
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
147
|
+
return {
|
|
148
|
+
bpm: tempoAnalysis.bpm,
|
|
149
|
+
confidence: Math.round(tempoAnalysis.confidence * 100) / 100,
|
|
150
|
+
method: tempoAnalysis.method,
|
|
151
|
+
message: `Detected ${tempoAnalysis.bpm} BPM with ${Math.round(tempoAnalysis.confidence * 100)}% confidence`,
|
|
152
|
+
};
|
|
153
|
+
// No catch here on purpose.
|
|
154
|
+
//
|
|
155
|
+
// It used to return `{bpm: null, detected: false, error: message}`,
|
|
156
|
+
// which analyze nests under the `tempo` key — where `isFailureShaped`
|
|
157
|
+
// cannot see it, since that inspects only the top level. So a dead
|
|
158
|
+
// browser reached the client as:
|
|
159
|
+
//
|
|
160
|
+
// include:['tempo'] -> {ok:true, data:{tempo:{bpm:null,
|
|
161
|
+
// error:'Target page ... has been closed'}}}
|
|
162
|
+
// include:['all'] -> a failure envelope
|
|
163
|
+
//
|
|
164
|
+
// One tool, one fault, opposite verdicts (#453). Letting it propagate
|
|
165
|
+
// makes both agree, and keeps the ORIGINAL error so `categorizeError`
|
|
166
|
+
// reads its type rather than a rebuilt message (#382).
|
|
167
|
+
//
|
|
168
|
+
// The no-detection branch above is untouched: that is a measurement
|
|
169
|
+
// that ran and found nothing, which is data, not a failure (#288).
|
|
141
170
|
}
|
|
142
171
|
async function getKey(controller) {
|
|
143
|
-
try
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
key: keyAnalysis.key,
|
|
155
|
-
scale: keyAnalysis.scale,
|
|
156
|
-
confidence: Math.round(keyAnalysis.confidence * 100) / 100,
|
|
157
|
-
message: `Detected ${keyAnalysis.key} ${keyAnalysis.scale} with ${Math.round(keyAnalysis.confidence * 100)}% confidence`,
|
|
172
|
+
// No try/catch, for the reason given on getTempo: an error nested
|
|
173
|
+
// inside a data payload is invisible to `isFailureShaped` and reached
|
|
174
|
+
// the client as ok:true (#453).
|
|
175
|
+
const keyAnalysis = await controller.detectKey();
|
|
176
|
+
if (!keyAnalysis || keyAnalysis.confidence < 0.1) {
|
|
177
|
+
return {
|
|
178
|
+
key: null,
|
|
179
|
+
scale: null,
|
|
180
|
+
detected: false,
|
|
181
|
+
confidence: 0,
|
|
182
|
+
message: 'No clear key detected. Ensure audio is playing and has tonal content.',
|
|
158
183
|
};
|
|
159
|
-
if (keyAnalysis.alternatives && keyAnalysis.alternatives.length > 0) {
|
|
160
|
-
result.alternatives = keyAnalysis.alternatives.map((alt) => ({
|
|
161
|
-
key: alt.key,
|
|
162
|
-
scale: alt.scale,
|
|
163
|
-
confidence: Math.round(alt.confidence * 100) / 100,
|
|
164
|
-
}));
|
|
165
|
-
}
|
|
166
|
-
return result;
|
|
167
184
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
185
|
+
const result = {
|
|
186
|
+
key: keyAnalysis.key,
|
|
187
|
+
scale: keyAnalysis.scale,
|
|
188
|
+
confidence: Math.round(keyAnalysis.confidence * 100) / 100,
|
|
189
|
+
message: `Detected ${keyAnalysis.key} ${keyAnalysis.scale} with ${Math.round(keyAnalysis.confidence * 100)}% confidence`,
|
|
190
|
+
};
|
|
191
|
+
if (keyAnalysis.alternatives && keyAnalysis.alternatives.length > 0) {
|
|
192
|
+
result.alternatives = keyAnalysis.alternatives.map((alt) => ({
|
|
193
|
+
key: alt.key,
|
|
194
|
+
scale: alt.scale,
|
|
195
|
+
confidence: Math.round(alt.confidence * 100) / 100,
|
|
196
|
+
}));
|
|
171
197
|
}
|
|
198
|
+
return result;
|
|
172
199
|
}
|
|
173
200
|
async function doAnalyze(args, controller) {
|
|
174
201
|
const includeRaw = args?.include;
|
|
@@ -198,7 +225,44 @@ async function doAnalyze(args, controller) {
|
|
|
198
225
|
const entries = await Promise.all(Object.entries(tasks).map(async ([k, p]) => [k, await p]));
|
|
199
226
|
return Object.fromEntries(entries);
|
|
200
227
|
}
|
|
228
|
+
/**
|
|
229
|
+
* Turns a death of the isolated engine child into an envelope (#307).
|
|
230
|
+
*
|
|
231
|
+
* All four local-engine tools funnel through here, which is the point:
|
|
232
|
+
* one isolation path, one place that decides what a caller is told.
|
|
233
|
+
*
|
|
234
|
+
* The distinction the categories carry is whether retrying could possibly
|
|
235
|
+
* help. It cannot for an OOM — `new Array(5e7).fill(7)` will exhaust the
|
|
236
|
+
* cap every time it is sent — so that is the caller's input to fix, not a
|
|
237
|
+
* transient condition to wait out. A hang or an unexplained crash might
|
|
238
|
+
* well be transient. Recording this because the vote on #307 said "one
|
|
239
|
+
* retryable envelope" for all of them, and a blanket `isRetryable: true`
|
|
240
|
+
* would invite an agent into a loop it cannot win.
|
|
241
|
+
*/
|
|
242
|
+
function isolationFailure(error) {
|
|
243
|
+
// Not retryable, and for two different reasons. An OOM is the caller's
|
|
244
|
+
// input: `s("[bd*99999]*99999")` exhausts the cap every time it is
|
|
245
|
+
// sent. A spawn failure is the deployment: a missing or unbuilt child
|
|
246
|
+
// is missing on the next attempt too. Both would put an agent in a loop
|
|
247
|
+
// it cannot win, which is why the vote's "one retryable envelope for
|
|
248
|
+
// everything" is not what shipped.
|
|
249
|
+
if (error.kind === 'oom')
|
|
250
|
+
return err('validation', error.message);
|
|
251
|
+
if (error.kind === 'spawn')
|
|
252
|
+
return err('internal', error.message);
|
|
253
|
+
return err('transient', error.message, { isRetryable: true });
|
|
254
|
+
}
|
|
201
255
|
export async function execute(name, args, ctx) {
|
|
256
|
+
try {
|
|
257
|
+
return await run(name, args, ctx);
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
if (error instanceof IsolatedRunnerError)
|
|
261
|
+
return isolationFailure(error);
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
async function run(name, args, ctx) {
|
|
202
266
|
const sid = args?.session_id;
|
|
203
267
|
if (!LOCAL_ENGINE_TOOLS.has(name) && !sid && !ctx.isInitialized()) {
|
|
204
268
|
return 'Browser not initialized. Run init first.';
|
|
@@ -218,7 +282,7 @@ export async function execute(name, args, ctx) {
|
|
|
218
282
|
}
|
|
219
283
|
case 'validate_pattern_local': {
|
|
220
284
|
InputValidator.validateStringLength(args.pattern, 'pattern', 10000, false);
|
|
221
|
-
const localValidation = ctx.strudelEngine.validate(args.pattern);
|
|
285
|
+
const localValidation = await ctx.strudelEngine.validate(args.pattern);
|
|
222
286
|
return {
|
|
223
287
|
valid: localValidation.valid,
|
|
224
288
|
errors: localValidation.errors,
|
|
@@ -232,7 +296,29 @@ export async function execute(name, args, ctx) {
|
|
|
232
296
|
}
|
|
233
297
|
case 'analyze_pattern_local': {
|
|
234
298
|
InputValidator.validateStringLength(args.pattern, 'pattern', 10000, false);
|
|
235
|
-
const patternMetadata = ctx.strudelEngine.analyzePattern(args.pattern);
|
|
299
|
+
const patternMetadata = await ctx.strudelEngine.analyzePattern(args.pattern);
|
|
300
|
+
// Do not report event counts the engine never measured. When
|
|
301
|
+
// evaluation failed, `eventsPerCycle: 0` is a default, not a
|
|
302
|
+
// finding — and it is indistinguishable from a genuinely silent
|
|
303
|
+
// pattern, so an agent was told working code produced nothing
|
|
304
|
+
// (#276). Say what happened instead.
|
|
305
|
+
if (!patternMetadata.evaluated) {
|
|
306
|
+
// Build explicitly rather than destructuring the unwanted fields
|
|
307
|
+
// out: naming them only to discard them trips no-unused-vars, and
|
|
308
|
+
// listing what IS reported is clearer about the contract anyway.
|
|
309
|
+
return {
|
|
310
|
+
usesSound: patternMetadata.usesSound,
|
|
311
|
+
usesNote: patternMetadata.usesNote,
|
|
312
|
+
isStack: patternMetadata.isStack,
|
|
313
|
+
functionsUsed: patternMetadata.functionsUsed,
|
|
314
|
+
complexity: patternMetadata.complexity,
|
|
315
|
+
...(patternMetadata.bpm !== undefined ? { bpm: patternMetadata.bpm } : {}),
|
|
316
|
+
evaluated: false,
|
|
317
|
+
message: 'Pattern could not be evaluated, so event counts are unavailable. ' +
|
|
318
|
+
'The static analysis below is still valid. ' +
|
|
319
|
+
(patternMetadata.evaluationError ?? ''),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
236
322
|
return {
|
|
237
323
|
...patternMetadata,
|
|
238
324
|
message: `Pattern analysis: ${patternMetadata.eventsPerCycle} events/cycle, ` +
|
|
@@ -245,14 +331,22 @@ export async function execute(name, args, ctx) {
|
|
|
245
331
|
const startCycle = args.start ?? 0;
|
|
246
332
|
const endCycle = args.end ?? 1;
|
|
247
333
|
if (startCycle >= endCycle) {
|
|
248
|
-
|
|
334
|
+
// Categorised explicitly rather than by whether the message
|
|
335
|
+
// happens to contain "must be" — which is the only reason this
|
|
336
|
+
// one landed in `validation` while the range check below landed
|
|
337
|
+
// in `internal` (#453).
|
|
338
|
+
return err('validation', 'Start must be less than end');
|
|
249
339
|
}
|
|
250
340
|
if (endCycle - startCycle > 16) {
|
|
251
|
-
return
|
|
341
|
+
return err('validation', 'Maximum range is 16 cycles to prevent excessive output');
|
|
252
342
|
}
|
|
253
343
|
try {
|
|
254
|
-
const events = ctx.strudelEngine.queryEvents(args.pattern, startCycle, endCycle);
|
|
255
|
-
|
|
344
|
+
const events = await ctx.strudelEngine.queryEvents(args.pattern, startCycle, endCycle);
|
|
345
|
+
// A silent pattern is a valid-empty query, not a failed one —
|
|
346
|
+
// and not the same as a pattern with events, which is what a
|
|
347
|
+
// plain ok() made it look like (#288).
|
|
348
|
+
const wrap = events.length === 0 ? empty : ok;
|
|
349
|
+
return wrap({
|
|
256
350
|
count: events.length,
|
|
257
351
|
range: { start: startCycle, end: endCycle },
|
|
258
352
|
events: events.map((e) => ({
|
|
@@ -261,9 +355,22 @@ export async function execute(name, args, ctx) {
|
|
|
261
355
|
end: e.end,
|
|
262
356
|
duration: e.end - e.start,
|
|
263
357
|
})),
|
|
264
|
-
};
|
|
358
|
+
});
|
|
265
359
|
}
|
|
266
360
|
catch (error) {
|
|
361
|
+
// The engine dying is not a syntax problem, and telling the
|
|
362
|
+
// caller to go run the validator would send them to a tool that
|
|
363
|
+
// will die the same way. Let it reach isolationFailure (#307).
|
|
364
|
+
if (error instanceof IsolatedRunnerError)
|
|
365
|
+
throw error;
|
|
366
|
+
// A refusal that already knows it is the caller's input keeps
|
|
367
|
+
// that verdict. Flattening it into this shape sent it through
|
|
368
|
+
// the dispatcher as a bare message, which categorised "Pattern
|
|
369
|
+
// produces roughly 2e10 events, above the 50000 cap" as
|
|
370
|
+
// `internal` — found by the protocol route check, after two
|
|
371
|
+
// rounds of fixing this exact class of bug (#382).
|
|
372
|
+
if (error instanceof ValidationError)
|
|
373
|
+
return err('validation', error.message);
|
|
267
374
|
const message = error instanceof Error ? error.message : String(error);
|
|
268
375
|
return {
|
|
269
376
|
error: message,
|
|
@@ -273,7 +380,7 @@ export async function execute(name, args, ctx) {
|
|
|
273
380
|
}
|
|
274
381
|
case 'transpile_pattern': {
|
|
275
382
|
InputValidator.validateStringLength(args.pattern, 'pattern', 10000, false);
|
|
276
|
-
const transpileResult = ctx.strudelEngine.transpile(args.pattern);
|
|
383
|
+
const transpileResult = await ctx.strudelEngine.transpile(args.pattern);
|
|
277
384
|
if (transpileResult.success) {
|
|
278
385
|
return {
|
|
279
386
|
success: true,
|
|
@@ -281,12 +388,14 @@ export async function execute(name, args, ctx) {
|
|
|
281
388
|
message: 'Pattern transpiled successfully',
|
|
282
389
|
};
|
|
283
390
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
391
|
+
// A syntax error in the caller's pattern is the caller's input,
|
|
392
|
+
// not an internal fault — and `Unexpected token (1:6)` matches
|
|
393
|
+
// nothing the string matcher looks for (#453). The location is
|
|
394
|
+
// kept in the message, since an envelope carries no extra fields.
|
|
395
|
+
return err('validation', `Transpilation failed: ${String(transpileResult.error)}`
|
|
396
|
+
+ (transpileResult.errorLocation !== undefined
|
|
397
|
+
? ` (at ${String(transpileResult.errorLocation)})`
|
|
398
|
+
: ''));
|
|
290
399
|
}
|
|
291
400
|
default:
|
|
292
401
|
throw new Error(`analysis module does not handle tool: ${name}`);
|
|
@@ -1,14 +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
|
-
*
|
|
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.
|
|
10
13
|
*/
|
|
14
|
+
import { categorizeError, err } from './types.js';
|
|
11
15
|
import { InputValidator } from '../../utils/InputValidator.js';
|
|
16
|
+
import { declaredBpm } from '../../utils/Tempo.js';
|
|
12
17
|
// AudioCaptureService lifecycle lives on the server; we fetch a shared
|
|
13
18
|
// instance via ctx.getAudioCaptureService() so tests can mock the class
|
|
14
19
|
// at module boundary without the extractor caching a stale instance.
|
|
@@ -23,7 +28,7 @@ function blobToBase64(arrayBuffer) {
|
|
|
23
28
|
const SESSION_ID_PROP = {
|
|
24
29
|
session_id: {
|
|
25
30
|
type: 'string',
|
|
26
|
-
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).',
|
|
27
32
|
},
|
|
28
33
|
};
|
|
29
34
|
export const tools = [
|
|
@@ -36,19 +41,44 @@ export const tools = [
|
|
|
36
41
|
'Example: audio_capture({ action: "sample", duration: 3000 }). ' +
|
|
37
42
|
'Audio must be playing for capture to record meaningful data. ' +
|
|
38
43
|
'For MIDI export use export_midi; for runtime diagnostics use diagnostics. ' +
|
|
39
|
-
'
|
|
44
|
+
'Each session has its own recorder, so captures in different sessions do not interfere (#180).',
|
|
40
45
|
inputSchema: {
|
|
41
46
|
type: 'object',
|
|
42
47
|
properties: {
|
|
43
48
|
action: { type: 'string', enum: ['start', 'stop', 'sample'], description: 'Capture action' },
|
|
44
49
|
format: { type: 'string', enum: ['webm', 'opus'], description: 'action=start: audio format (default webm)' },
|
|
45
|
-
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
|
+
},
|
|
46
56
|
duration: { type: 'number', description: 'action=sample: duration ms (100-60000, default 5000)' },
|
|
47
57
|
...SESSION_ID_PROP,
|
|
48
58
|
},
|
|
49
59
|
required: ['action'],
|
|
50
60
|
},
|
|
51
61
|
},
|
|
62
|
+
{
|
|
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" }).',
|
|
71
|
+
inputSchema: {
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties: {
|
|
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' },
|
|
78
|
+
...SESSION_ID_PROP,
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
},
|
|
52
82
|
{
|
|
53
83
|
name: 'export_midi',
|
|
54
84
|
description: 'Export current pattern to MIDI file. Parses note(), n(), and chord() functions.',
|
|
@@ -57,7 +87,7 @@ export const tools = [
|
|
|
57
87
|
properties: {
|
|
58
88
|
filename: { type: 'string', description: 'Output filename (optional, default: pattern.mid)' },
|
|
59
89
|
duration: { type: 'number', description: 'Export duration in bars (default: 4)' },
|
|
60
|
-
bpm: { type: 'number', description:
|
|
90
|
+
bpm: { type: 'number', description: "Tempo in BPM (default: the pattern's own tempo, or 120 if it declares none)" },
|
|
61
91
|
format: { type: 'string', enum: ['file', 'base64'], description: 'Output format: file or base64 (default: base64)' },
|
|
62
92
|
...SESSION_ID_PROP,
|
|
63
93
|
},
|
|
@@ -81,60 +111,98 @@ export async function execute(name, args, ctx) {
|
|
|
81
111
|
throw new Error(`Invalid action: ${a}. Must be one of: start, stop, sample`);
|
|
82
112
|
}
|
|
83
113
|
}
|
|
114
|
+
case 'export_audio':
|
|
115
|
+
return await exportAudio(args, ctx, sid);
|
|
84
116
|
case 'export_midi':
|
|
85
117
|
return await exportMidi(args?.filename, args?.duration, args?.bpm, args?.format, ctx, sid);
|
|
86
118
|
default:
|
|
87
119
|
throw new Error(`capture module does not handle tool: ${name}`);
|
|
88
120
|
}
|
|
89
121
|
}
|
|
90
|
-
async function startAudioCapture(format,
|
|
122
|
+
async function startAudioCapture(format, maxDuration, ctx, sid) {
|
|
91
123
|
try {
|
|
92
124
|
const service = await ctx.getAudioCaptureService(sid);
|
|
93
125
|
if (service.isCapturing()) {
|
|
94
|
-
return
|
|
126
|
+
return err('business', 'Audio capture already in progress. Stop it first.');
|
|
95
127
|
}
|
|
96
|
-
|
|
128
|
+
if (maxDuration !== undefined) {
|
|
129
|
+
InputValidator.validatePositiveInteger(maxDuration, 'maxDuration');
|
|
130
|
+
}
|
|
131
|
+
await service.startCapture(ctx.getController(sid).page, { format, maxDuration });
|
|
97
132
|
return {
|
|
98
133
|
success: true,
|
|
99
134
|
message: 'Audio capture started. Use audio_capture({ action: "stop" }) to get the recorded audio.',
|
|
100
|
-
|
|
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(),
|
|
101
141
|
};
|
|
102
142
|
}
|
|
103
143
|
catch (error) {
|
|
104
144
|
const message = error instanceof Error ? error.message : String(error);
|
|
105
|
-
return
|
|
145
|
+
return err(categorizeError(error), `Failed to start audio capture: ${message}`);
|
|
106
146
|
}
|
|
107
147
|
}
|
|
108
148
|
async function stopAudioCapture(ctx, sid) {
|
|
109
149
|
try {
|
|
110
150
|
const service = await ctx.getAudioCaptureService(sid);
|
|
111
151
|
if (!service.isCapturing()) {
|
|
112
|
-
|
|
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.');
|
|
113
167
|
}
|
|
114
168
|
const result = await service.stopCapture(ctx.getController(sid).page);
|
|
115
169
|
const buf = await result.blob.arrayBuffer();
|
|
116
170
|
return {
|
|
117
171
|
success: true,
|
|
118
|
-
|
|
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}` : ''),
|
|
119
177
|
audio: blobToBase64(buf),
|
|
120
178
|
duration: result.duration,
|
|
121
179
|
format: result.format,
|
|
180
|
+
...(result.warning ? { warning: result.warning } : {}),
|
|
122
181
|
};
|
|
123
182
|
}
|
|
124
183
|
catch (error) {
|
|
125
184
|
const message = error instanceof Error ? error.message : String(error);
|
|
126
|
-
return
|
|
185
|
+
return err(categorizeError(error), `Failed to stop audio capture: ${message}`);
|
|
127
186
|
}
|
|
128
187
|
}
|
|
129
188
|
async function captureAudioSample(duration, ctx, sid) {
|
|
130
|
-
|
|
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;
|
|
131
195
|
if (durationMs < 100 || durationMs > 60000) {
|
|
132
|
-
|
|
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)');
|
|
133
201
|
}
|
|
134
202
|
try {
|
|
135
203
|
const service = await ctx.getAudioCaptureService(sid);
|
|
136
204
|
if (service.isCapturing()) {
|
|
137
|
-
return
|
|
205
|
+
return err('business', 'Audio capture already in progress. Stop it first.');
|
|
138
206
|
}
|
|
139
207
|
const result = await service.captureForDuration(ctx.getController(sid).page, durationMs);
|
|
140
208
|
const buf = await result.blob.arrayBuffer();
|
|
@@ -148,32 +216,71 @@ async function captureAudioSample(duration, ctx, sid) {
|
|
|
148
216
|
}
|
|
149
217
|
catch (error) {
|
|
150
218
|
const message = error instanceof Error ? error.message : String(error);
|
|
151
|
-
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}`);
|
|
152
247
|
}
|
|
153
248
|
}
|
|
154
249
|
async function exportMidi(filename, bars, bpm, format, ctx, sid) {
|
|
155
250
|
if (bpm !== undefined)
|
|
156
251
|
InputValidator.validateBPM(bpm);
|
|
157
252
|
if (bars !== undefined && (bars < 1 || bars > 128)) {
|
|
158
|
-
return
|
|
253
|
+
return err('validation', 'Bars must be between 1 and 128');
|
|
159
254
|
}
|
|
160
255
|
const pattern = await ctx.getCurrentPatternSafe(sid);
|
|
161
256
|
if (!pattern || pattern.trim().length === 0) {
|
|
162
|
-
return
|
|
257
|
+
return err('business', 'No pattern to export. Write a pattern first.');
|
|
163
258
|
}
|
|
164
|
-
|
|
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 };
|
|
165
264
|
const outputFormat = format || 'base64';
|
|
166
265
|
if (outputFormat === 'file') {
|
|
167
266
|
const result = ctx.midiExportService.exportToFile(pattern, filename, exportOptions);
|
|
168
267
|
return {
|
|
169
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).
|
|
170
273
|
message: result.success
|
|
171
274
|
? `Exported ${result.noteCount} notes to ${result.output}`
|
|
275
|
+
+ (result.warning ? ` — ${result.warning}` : '')
|
|
172
276
|
: result.error || 'Export failed',
|
|
173
277
|
output: result.output,
|
|
174
278
|
noteCount: result.noteCount,
|
|
175
279
|
bars: result.bars,
|
|
176
280
|
bpm: result.bpm,
|
|
281
|
+
...(result.warning ? { warning: result.warning } : {}),
|
|
282
|
+
...(result.unrepresented ? { unrepresented: result.unrepresented } : {}),
|
|
283
|
+
...(result.partiallyExported ? { partiallyExported: result.partiallyExported } : {}),
|
|
177
284
|
error: result.error,
|
|
178
285
|
};
|
|
179
286
|
}
|
|
@@ -182,11 +289,15 @@ async function exportMidi(filename, bars, bpm, format, ctx, sid) {
|
|
|
182
289
|
success: result.success,
|
|
183
290
|
message: result.success
|
|
184
291
|
? `Exported ${result.noteCount} notes as base64 MIDI data`
|
|
292
|
+
+ (result.warning ? ` — ${result.warning}` : '')
|
|
185
293
|
: result.error || 'Export failed',
|
|
186
294
|
output: result.output,
|
|
187
295
|
noteCount: result.noteCount,
|
|
188
296
|
bars: result.bars,
|
|
189
297
|
bpm: result.bpm,
|
|
298
|
+
...(result.warning ? { warning: result.warning } : {}),
|
|
299
|
+
...(result.unrepresented ? { unrepresented: result.unrepresented } : {}),
|
|
300
|
+
...(result.partiallyExported ? { partiallyExported: result.partiallyExported } : {}),
|
|
190
301
|
error: result.error,
|
|
191
302
|
};
|
|
192
303
|
}
|