@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.
Files changed (53) hide show
  1. package/README.md +202 -705
  2. package/dist/AudioAnalyzer.js +1294 -91
  3. package/dist/PatternStore.js +276 -23
  4. package/dist/StrudelController.js +388 -56
  5. package/dist/server/resources.js +27 -20
  6. package/dist/server/server.js +384 -76
  7. package/dist/server/tools/ai.js +124 -91
  8. package/dist/server/tools/analysis.js +168 -59
  9. package/dist/server/tools/capture.js +135 -24
  10. package/dist/server/tools/compose.js +26 -26
  11. package/dist/server/tools/diagnostics.js +20 -3
  12. package/dist/server/tools/editor.js +60 -6
  13. package/dist/server/tools/generate.js +38 -15
  14. package/dist/server/tools/history.js +30 -8
  15. package/dist/server/tools/playback.js +2 -1
  16. package/dist/server/tools/session.js +38 -6
  17. package/dist/server/tools/storage.js +179 -8
  18. package/dist/server/tools/transform.js +127 -43
  19. package/dist/server/tools/types.js +209 -14
  20. package/dist/services/AudioCaptureService.js +302 -33
  21. package/dist/services/AudioExportService.js +318 -0
  22. package/dist/services/BrowserOnlyFunctions.js +149 -0
  23. package/dist/services/EventDensityProbe.js +324 -0
  24. package/dist/services/GeminiService.js +221 -36
  25. package/dist/services/IsolatedEngineRunner.js +308 -0
  26. package/dist/services/IsolatedStrudelEngine.js +135 -0
  27. package/dist/services/LocalPatternEngine.js +16 -0
  28. package/dist/services/MIDIExportService.js +765 -71
  29. package/dist/services/MIDIImportService.js +474 -0
  30. package/dist/services/MusicTheory.js +85 -13
  31. package/dist/services/PatternGenerator.js +168 -85
  32. package/dist/services/PatternSandbox.js +359 -0
  33. package/dist/services/SessionManager.js +338 -65
  34. package/dist/services/StrudelEngine.js +218 -12
  35. package/dist/services/StrudelEngineHelpers.js +24 -8
  36. package/dist/services/StyleRegistry.js +204 -0
  37. package/dist/services/Transferable.js +114 -0
  38. package/dist/services/ai/AiTransport.js +56 -0
  39. package/dist/services/ai/AudioMeasurements.js +155 -0
  40. package/dist/services/ai/CliTransport.js +256 -0
  41. package/dist/services/engineChild.js +83 -0
  42. package/dist/services/engineChildPath.js +28 -0
  43. package/dist/utils/CategorisedError.js +59 -0
  44. package/dist/utils/ErrorRecovery.js +130 -110
  45. package/dist/utils/InputValidator.js +55 -7
  46. package/dist/utils/PatternValidator.js +136 -17
  47. package/dist/utils/SafePath.js +112 -0
  48. package/dist/utils/ServerConfig.js +248 -0
  49. package/dist/utils/TableLookup.js +41 -0
  50. package/dist/utils/Tempo.js +143 -0
  51. package/package.json +27 -16
  52. package/dist/services/__mocks__/StrudelEngine.js +0 -157
  53. 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 in-process StrudelEngine and
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-process StrudelEngine (no browser required)',
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
- try {
122
- const tempoAnalysis = await controller.detectTempo();
123
- if (!tempoAnalysis || tempoAnalysis.bpm === 0) {
124
- return {
125
- bpm: 0,
126
- confidence: 0,
127
- message: 'No tempo detected. Ensure audio is playing and has a clear rhythmic pattern.',
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: tempoAnalysis.bpm,
132
- confidence: Math.round(tempoAnalysis.confidence * 100) / 100,
133
- method: tempoAnalysis.method,
134
- message: `Detected ${tempoAnalysis.bpm} BPM with ${Math.round(tempoAnalysis.confidence * 100)}% confidence`,
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
- catch (error) {
138
- const message = error instanceof Error ? error.message : String(error);
139
- return { bpm: 0, confidence: 0, error: message || 'Tempo detection failed' };
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
- const keyAnalysis = await controller.detectKey();
145
- if (!keyAnalysis || keyAnalysis.confidence < 0.1) {
146
- return {
147
- key: 'Unknown',
148
- scale: 'unknown',
149
- confidence: 0,
150
- message: 'No clear key detected. Ensure audio is playing and has tonal content.',
151
- };
152
- }
153
- const result = {
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
- catch (error) {
169
- const message = error instanceof Error ? error.message : String(error);
170
- return { key: 'Unknown', scale: 'unknown', confidence: 0, error: message || 'Key detection failed' };
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
- return { error: 'Start must be less than end' };
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 { error: 'Maximum range is 16 cycles to prevent excessive output' };
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
- return {
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
- return {
285
- success: false,
286
- error: transpileResult.error,
287
- errorLocation: transpileResult.errorLocation,
288
- message: 'Transpilation failed',
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 two tools: `audio_capture(action=start|stop|sample)` and
5
- * `export_midi` (kept separate — MIDI is symbolic, audio is waveform).
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
- * The AudioCaptureService is lazy-instantiated module-side: we need
8
- * the current Playwright page for `injectRecorder()`, and that only
9
- * 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.
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 page reference to the named session. Note: AudioCaptureService is currently server-wide — concurrent captures across sessions will conflict.',
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
- 'Note: the AudioCaptureService is currently a server-wide singleton — concurrent captures across sessions will conflict.',
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: { type: 'number', description: 'action=start: maximum capture duration ms' },
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: 'Tempo in BPM (default: 120)' },
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, _maxDuration, ctx, sid) {
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 { success: false, message: 'Audio capture already in progress. Stop it first.' };
126
+ return err('business', 'Audio capture already in progress. Stop it first.');
95
127
  }
96
- await service.startCapture(ctx.getController(sid).page, { format });
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
- format: format || 'webm',
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 { success: false, message: `Failed to start audio capture: ${message}` };
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
- return { success: false, message: 'No audio capture in progress. Start capture first.' };
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
- message: `Captured ${result.duration}ms of audio`,
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 { success: false, message: `Failed to stop audio capture: ${message}` };
185
+ return err(categorizeError(error), `Failed to stop audio capture: ${message}`);
127
186
  }
128
187
  }
129
188
  async function captureAudioSample(duration, ctx, sid) {
130
- const durationMs = duration || 5000;
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
- return { success: false, message: 'Duration must be between 100ms and 60000ms (1 minute)' };
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 { success: false, message: 'Audio capture already in progress. Stop it first.' };
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 { success: false, message: `Failed to capture audio sample: ${message}` };
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 { success: false, message: 'Bars must be between 1 and 128' };
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 { success: false, message: 'No pattern to export. Write a pattern first.' };
257
+ return err('business', 'No pattern to export. Write a pattern first.');
163
258
  }
164
- const exportOptions = { bpm: bpm || 120, bars: bars || 4 };
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
  }