@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.
Files changed (53) hide show
  1. package/README.md +212 -780
  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 +391 -83
  7. package/dist/server/tools/ai.js +129 -152
  8. package/dist/server/tools/analysis.js +170 -91
  9. package/dist/server/tools/capture.js +128 -55
  10. package/dist/server/tools/compose.js +28 -42
  11. package/dist/server/tools/diagnostics.js +23 -53
  12. package/dist/server/tools/editor.js +63 -72
  13. package/dist/server/tools/generate.js +45 -167
  14. package/dist/server/tools/history.js +35 -66
  15. package/dist/server/tools/playback.js +7 -22
  16. package/dist/server/tools/session.js +41 -47
  17. package/dist/server/tools/storage.js +173 -50
  18. package/dist/server/tools/transform.js +136 -199
  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 +17 -5
  52. package/dist/services/__mocks__/StrudelEngine.js +0 -157
  53. package/dist/types/index.js +0 -1
@@ -1,19 +1,17 @@
1
1
  /**
2
2
  * transform domain — mutate the current pattern.
3
3
  *
4
- * Owns (14 tools): transpose, reverse, stretch, quantize, humanize,
5
- * add_swing, apply_scale, generate_variation, add_effect, remove_effect,
6
- * set_tempo, shift_mood, set_energy, refine.
7
- *
8
- * Per the #110 audit this eventually collapses into:
9
- * - `transform` (op enum) — 8 tools
10
- * - `effect` (add/remove enum) — 2 tools
11
- * - `shape` (mood/energy/refine enum) — 3 tools
12
- * - `set_tempo` stays — high-traffic verb
13
- *
14
- * For now we keep the individual verbs and just move them out of server.ts.
4
+ * Owns (4 tools):
5
+ * - `transform` (op enum: transpose/reverse/stretch/quantize/humanize/
6
+ * swing/scale/vary)
7
+ * - `effect` (action enum: add/remove)
8
+ * - `shape` (dimension enum: mood/energy/refine)
9
+ * - `set_tempo` — high-traffic standalone verb
15
10
  */
11
+ import { empty, err, withStashField, withStashNotice } from './types.js';
16
12
  import { InputValidator } from '../../utils/InputValidator.js';
13
+ import { BEATS_PER_CYCLE } from '../../utils/Tempo.js';
14
+ import { lookup } from '../../utils/TableLookup.js';
17
15
  const ENERGY_LEVELS = {
18
16
  0: { tempoAdjust: -20, roomAmount: 0.5, densityAdjust: '.slow(4)', description: 'minimal/ambient' },
19
17
  1: { tempoAdjust: -15, roomAmount: 0.4, densityAdjust: '.slow(3)', description: 'very sparse' },
@@ -43,7 +41,7 @@ function transposePattern(pattern, semitones) {
43
41
  'f#': 6, 'g': 7, 'g#': 8, 'a': 9, 'a#': 10, 'b': 11,
44
42
  };
45
43
  const currentNote = note.toLowerCase();
46
- const noteValue = noteMap[currentNote] || 0;
44
+ const noteValue = lookup(noteMap, currentNote, 0);
47
45
  const newNoteValue = (noteValue + semitones + 12) % 12;
48
46
  const noteNames = ['c', 'c#', 'd', 'd#', 'e', 'f', 'f#', 'g', 'g#', 'a', 'a#', 'b'];
49
47
  const newOctave = parseInt(octave) + Math.floor((noteValue + semitones) / 12);
@@ -65,9 +63,9 @@ export const tools = [
65
63
  'op=stretch slows by `factor` (>1 slower, <1 faster). ' +
66
64
  'op=quantize snaps to the `grid` (e.g. "1/16"). ' +
67
65
  'op=humanize adds rand-nudge timing of `amount` (0-1). ' +
68
- 'op=swing applies `.swing(amount)` (alias for add_swing). ' +
69
- 'op=scale applies a `root`/`scale` filter to notes (alias for apply_scale). ' +
70
- 'op=vary returns a variation of `type` (subtle/moderate/extreme/glitch/evolving — note: this is what generate_variation does today; the name is misleading because it transforms rather than generates). ' +
66
+ 'op=swing applies `.swing(amount)`. ' +
67
+ 'op=scale applies a `root`/`scale` filter to notes. ' +
68
+ 'op=vary returns a variation of `type` (subtle/moderate/extreme/glitch/evolving). ' +
71
69
  'Example: transform({ op: "transpose", semitones: 7 }). ' +
72
70
  'For effects (add/remove) use effect; for mood/energy/refine use shape; for tempo use set_tempo.',
73
71
  inputSchema: {
@@ -90,59 +88,11 @@ export const tools = [
90
88
  required: ['op'],
91
89
  },
92
90
  },
93
- {
94
- name: 'transpose',
95
- description: '[DEPRECATED — use transform({ op: "transpose" }) instead] Transpose notes by semitones',
96
- inputSchema: {
97
- type: 'object',
98
- properties: { semitones: { type: 'number', description: 'Semitones to transpose' }, ...SESSION_ID_PROP },
99
- required: ['semitones'],
100
- },
101
- },
102
- {
103
- name: 'reverse',
104
- description: '[DEPRECATED — use transform({ op: "reverse" }) instead] Reverse pattern',
105
- inputSchema: { type: 'object', properties: { ...SESSION_ID_PROP } },
106
- },
107
- {
108
- name: 'stretch',
109
- description: '[DEPRECATED — use transform({ op: "stretch" }) instead] Time stretch pattern',
110
- inputSchema: {
111
- type: 'object',
112
- properties: { factor: { type: 'number', description: 'Stretch factor' }, ...SESSION_ID_PROP },
113
- required: ['factor'],
114
- },
115
- },
116
- {
117
- name: 'quantize',
118
- description: '[DEPRECATED — use transform({ op: "quantize" }) instead] Quantize to grid',
119
- inputSchema: {
120
- type: 'object',
121
- properties: { grid: { type: 'string', description: 'Grid size (e.g., "1/16")' }, ...SESSION_ID_PROP },
122
- required: ['grid'],
123
- },
124
- },
125
- {
126
- name: 'humanize',
127
- description: '[DEPRECATED — use transform({ op: "humanize" }) instead] Add human timing variation',
128
- inputSchema: {
129
- type: 'object',
130
- properties: { amount: { type: 'number', description: 'Humanization amount (0-1)' }, ...SESSION_ID_PROP },
131
- },
132
- },
133
- {
134
- name: 'generate_variation',
135
- description: '[DEPRECATED — use transform({ op: "vary" }) instead] Create pattern variations (mis-named today; it transforms, not generates)',
136
- inputSchema: {
137
- type: 'object',
138
- properties: { type: { type: 'string', description: 'Variation type (subtle/moderate/extreme/glitch/evolving)' }, ...SESSION_ID_PROP },
139
- },
140
- },
141
91
  {
142
92
  name: 'effect',
143
93
  description: 'Add or remove a Strudel effect on the current session pattern. ' +
144
94
  'action=add appends `.<effect>(<params>)`. ' +
145
- 'action=remove strips the last `.<effect>(...)` call from the pattern. ' +
95
+ 'action=remove strips EVERY `.<effect>(...)` call from the pattern, not just the last. ' +
146
96
  'Example: effect({ action: "add", effect: "lpf", params: "1000" }). ' +
147
97
  'For higher-level effect bundles (mood/energy/refine) use shape; for raw transforms use transform.',
148
98
  inputSchema: {
@@ -156,59 +106,15 @@ export const tools = [
156
106
  required: ['action', 'effect'],
157
107
  },
158
108
  },
159
- {
160
- name: 'add_effect',
161
- description: '[DEPRECATED — use effect({ action: "add" }) instead] Add effect to pattern',
162
- inputSchema: {
163
- type: 'object',
164
- properties: {
165
- effect: { type: 'string', description: 'Effect name' },
166
- params: { type: 'string', description: 'Effect parameters' },
167
- ...SESSION_ID_PROP,
168
- },
169
- required: ['effect'],
170
- },
171
- },
172
- {
173
- name: 'remove_effect',
174
- description: '[DEPRECATED — use effect({ action: "remove" }) instead] Remove effect',
175
- inputSchema: {
176
- type: 'object',
177
- properties: { effect: { type: 'string', description: 'Effect to remove' }, ...SESSION_ID_PROP },
178
- required: ['effect'],
179
- },
180
- },
181
109
  {
182
110
  name: 'set_tempo',
183
- description: 'Set BPM',
111
+ description: 'Set BPM. Writes setcpm(bpm/4), assuming one bar of 4/4 per cycle.',
184
112
  inputSchema: {
185
113
  type: 'object',
186
114
  properties: { bpm: { type: 'number', description: 'Tempo in BPM' }, ...SESSION_ID_PROP },
187
115
  required: ['bpm'],
188
116
  },
189
117
  },
190
- {
191
- name: 'add_swing',
192
- description: '[DEPRECATED — use transform({ op: "swing" }) instead] Add swing to pattern',
193
- inputSchema: {
194
- type: 'object',
195
- properties: { amount: { type: 'number', description: 'Swing amount (0-1)' }, ...SESSION_ID_PROP },
196
- required: ['amount'],
197
- },
198
- },
199
- {
200
- name: 'apply_scale',
201
- description: '[DEPRECATED — use transform({ op: "scale" }) instead] Apply scale to notes',
202
- inputSchema: {
203
- type: 'object',
204
- properties: {
205
- scale: { type: 'string', description: 'Scale name' },
206
- root: { type: 'string', description: 'Root note' },
207
- ...SESSION_ID_PROP,
208
- },
209
- required: ['scale', 'root'],
210
- },
211
- },
212
118
  {
213
119
  name: 'shape',
214
120
  description: 'Shape the current pattern along one of three high-level dimensions. ' +
@@ -236,42 +142,6 @@ export const tools = [
236
142
  required: ['dimension'],
237
143
  },
238
144
  },
239
- {
240
- name: 'shift_mood',
241
- description: '[DEPRECATED — use shape({ dimension: "mood" }) instead] Transform current pattern to match a different emotional mood. Moods: dark, euphoric, melancholic, aggressive, dreamy, peaceful, energetic.',
242
- inputSchema: {
243
- type: 'object',
244
- properties: {
245
- target_mood: {
246
- type: 'string',
247
- enum: ['dark', 'euphoric', 'melancholic', 'aggressive', 'dreamy', 'peaceful', 'energetic'],
248
- description: 'Target mood',
249
- },
250
- intensity: { type: 'number', minimum: 0, maximum: 1, description: 'How strongly to apply the mood transformation (0-1, default: 0.5)' },
251
- auto_play: { type: 'boolean', description: 'Start playback after transformation (default: true)' },
252
- ...SESSION_ID_PROP,
253
- },
254
- required: ['target_mood'],
255
- },
256
- },
257
- {
258
- name: 'set_energy',
259
- description: '[DEPRECATED — use shape({ dimension: "energy" }) instead] Adjust energy level 0-10. Auto-plays after applying.',
260
- inputSchema: {
261
- type: 'object',
262
- properties: { level: { type: 'number', description: 'Energy level from 0 (minimal) to 10 (maximum)' }, ...SESSION_ID_PROP },
263
- required: ['level'],
264
- },
265
- },
266
- {
267
- name: 'refine',
268
- description: '[DEPRECATED — use shape({ dimension: "refine" }) instead] Incrementally refine current pattern: faster/slower/louder/quieter/brighter/darker/"more reverb"/drier.',
269
- inputSchema: {
270
- type: 'object',
271
- properties: { direction: { type: 'string', description: 'Refinement direction: faster, slower, louder, quieter, brighter, darker, "more reverb", or drier' }, ...SESSION_ID_PROP },
272
- required: ['direction'],
273
- },
274
- },
275
145
  ];
276
146
  export const toolNames = new Set(tools.map(t => t.name));
277
147
  // ---- per-op helpers (shared by transform({op}) and the deprecated aliases)
@@ -280,73 +150,139 @@ async function opTranspose(args, ctx, sid) {
280
150
  throw new Error('Semitones must be an integer');
281
151
  }
282
152
  const p = await ctx.getCurrentPatternSafe(sid);
283
- await ctx.writePatternSafe(transposePattern(p, args.semitones), sid);
284
- return `Transposed ${args.semitones} semitones`;
153
+ const written = await ctx.writePatternSafe(transposePattern(p, args.semitones), sid);
154
+ return withStashNotice(`Transposed ${args.semitones} semitones`, written);
285
155
  }
286
156
  async function opReverse(ctx, sid) {
287
157
  const p = await ctx.getCurrentPatternSafe(sid);
288
- await ctx.writePatternSafe(p + '.rev', sid);
289
- return 'Pattern reversed';
158
+ const written = await ctx.writePatternSafe(p + '.rev', sid);
159
+ return withStashNotice('Pattern reversed', written);
290
160
  }
291
161
  async function opStretch(args, ctx, sid) {
292
162
  InputValidator.validateGain(args.factor);
293
163
  const p = await ctx.getCurrentPatternSafe(sid);
294
- await ctx.writePatternSafe(p + `.slow(${args.factor})`, sid);
295
- return `Stretched by factor of ${args.factor}`;
164
+ const written = await ctx.writePatternSafe(p + `.slow(${args.factor})`, sid);
165
+ return withStashNotice(`Stretched by factor of ${args.factor}`, written);
296
166
  }
297
167
  async function opQuantize(args, ctx, sid) {
298
168
  InputValidator.validateStringLength(args.grid, 'grid', 50, false);
299
169
  const p = await ctx.getCurrentPatternSafe(sid);
300
- await ctx.writePatternSafe(p + `.struct("${args.grid}")`, sid);
301
- return `Quantized to ${args.grid} grid`;
170
+ const written = await ctx.writePatternSafe(p + `.struct("${args.grid}")`, sid);
171
+ return withStashNotice(`Quantized to ${args.grid} grid`, written);
302
172
  }
303
173
  async function opHumanize(args, ctx, sid) {
304
174
  if (args.amount !== undefined)
305
175
  InputValidator.validateNormalizedValue(args.amount, 'amount');
306
176
  const p = await ctx.getCurrentPatternSafe(sid);
307
177
  const amt = args.amount || 0.01;
308
- await ctx.writePatternSafe(p + `.nudge(rand.range(-${amt}, ${amt}))`, sid);
309
- return 'Added human timing';
178
+ const written = await ctx.writePatternSafe(p + `.nudge(rand.range(-${amt}, ${amt}))`, sid);
179
+ return withStashNotice('Added human timing', written);
310
180
  }
311
181
  async function opVary(args, ctx, sid) {
312
182
  const p = await ctx.getCurrentPatternSafe(sid);
313
183
  const varied = ctx.generator.generateVariation(p, args.type || 'subtle');
314
- await ctx.writePatternSafe(varied, sid);
315
- return `Added ${args.type || 'subtle'} variation`;
184
+ const written = await ctx.writePatternSafe(varied, sid);
185
+ return withStashNotice(`Added ${args.type || 'subtle'} variation`, written);
316
186
  }
317
187
  async function opSwing(args, ctx, sid) {
318
188
  InputValidator.validateNormalizedValue(args.amount, 'amount');
319
189
  const p = await ctx.getCurrentPatternSafe(sid);
320
- await ctx.writePatternSafe(p + `.swing(${args.amount})`, sid);
321
- return `Added swing: ${args.amount}`;
190
+ const written = await ctx.writePatternSafe(p + `.swing(${args.amount})`, sid);
191
+ return withStashNotice(`Added swing: ${args.amount}`, written);
322
192
  }
323
193
  async function opScale(args, ctx, sid) {
324
194
  InputValidator.validateStringLength(args.scale, 'scale', 50, false);
195
+ // Concatenated straight into the pattern source below, so it must be a
196
+ // scale name and not arbitrary code — the same reason `opEffectAdd`
197
+ // validates its effect name (#236). A length check is not a content
198
+ // check: `scale: 'minor") .gain(9) //'` closed the string literal and
199
+ // appended a call, which is how it got past `PatternValidator`'s
200
+ // dangerous-gain rule — that runs on the pattern before this is
201
+ // appended to it (#440).
202
+ //
203
+ // `validateScaleName` already existed and `generate.ts` already used
204
+ // it; this path never did.
205
+ InputValidator.validateScaleName(args.scale);
325
206
  InputValidator.validateRootNote(args.root);
326
207
  const p = await ctx.getCurrentPatternSafe(sid);
327
- await ctx.writePatternSafe(p + `.scale("${args.root}:${args.scale}")`, sid);
328
- return `Applied ${args.root} ${args.scale} scale`;
208
+ const written = await ctx.writePatternSafe(p + `.scale("${args.root}:${args.scale}")`, sid);
209
+ return withStashNotice(`Applied ${args.root} ${args.scale} scale`, written);
329
210
  }
330
211
  async function opEffectAdd(args, ctx, sid) {
331
212
  InputValidator.validateStringLength(args.effect, 'effect', 100, false);
213
+ // Concatenated straight into the pattern source, so it must be a plain
214
+ // method name and not arbitrary code (#236).
215
+ InputValidator.validateIdentifier(args.effect, 'effect');
332
216
  if (args.params)
333
217
  InputValidator.validateStringLength(args.params, 'params', 1000, true);
334
218
  const p = await ctx.getCurrentPatternSafe(sid);
335
219
  const withEffect = args.params
336
220
  ? p + `.${args.effect}(${args.params})`
337
221
  : p + `.${args.effect}()`;
338
- await ctx.writePatternSafe(withEffect, sid);
339
- return `Added ${args.effect} effect`;
222
+ const written = await ctx.writePatternSafe(withEffect, sid);
223
+ return withStashNotice(`Added ${args.effect} effect`, written);
224
+ }
225
+ /**
226
+ * Removes every `.effect(...)` call from a pattern.
227
+ *
228
+ * Deliberately not a RegExp. The old implementation interpolated the
229
+ * caller's effect name into `new RegExp(...)`, which handed the caller
230
+ * regex *syntax* — `(a+)+Z` against a crafted pattern blocked the event
231
+ * loop for 25 seconds, and a synchronous String.replace has no timeout to
232
+ * break out of (#236).
233
+ *
234
+ * Scanning also fixes a correctness bug the regex had: `[^)]*` stops at
235
+ * the FIRST `)`, so `.lpf(add(1,2))` was left as a stray `)`. This walks
236
+ * balanced parentheses instead.
237
+ *
238
+ * @param pattern - Pattern source to strip
239
+ * @param effect - Effect name (validated as an identifier by the caller)
240
+ * @returns The pattern with every matching call removed
241
+ */
242
+ function stripEffectCalls(pattern, effect) {
243
+ const needle = `.${effect}(`;
244
+ let out = '';
245
+ let cursor = 0;
246
+ for (;;) {
247
+ const start = pattern.indexOf(needle, cursor);
248
+ if (start === -1)
249
+ break;
250
+ // Walk to the matching close paren, respecting nesting.
251
+ let depth = 0;
252
+ let end = -1;
253
+ for (let i = start + needle.length - 1; i < pattern.length; i++) {
254
+ const ch = pattern[i];
255
+ if (ch === '(')
256
+ depth++;
257
+ else if (ch === ')') {
258
+ depth--;
259
+ if (depth === 0) {
260
+ end = i;
261
+ break;
262
+ }
263
+ }
264
+ }
265
+ // Unbalanced: leave the rest untouched rather than mangling it.
266
+ if (end === -1)
267
+ break;
268
+ out += pattern.slice(cursor, start);
269
+ cursor = end + 1;
270
+ }
271
+ return out + pattern.slice(cursor);
340
272
  }
341
273
  async function opEffectRemove(args, ctx, sid) {
342
274
  InputValidator.validateStringLength(args.effect, 'effect', 100, false);
275
+ // Must be a plain identifier: it is matched literally below, and was
276
+ // previously interpolated into a RegExp (#236).
277
+ InputValidator.validateIdentifier(args.effect, 'effect');
343
278
  const p = await ctx.getCurrentPatternSafe(sid);
344
- const regex = new RegExp(`\\.${args.effect}\\([^)]*\\)`, 'g');
345
- const stripped = p.replace(regex, '');
279
+ const stripped = stripEffectCalls(p, args.effect);
280
+ // The strip ran and matched nothing. That is a valid-empty result,
281
+ // not a failure and not a plain success (#288).
346
282
  if (stripped === p)
347
- return `No ${args.effect} effect found to remove`;
348
- await ctx.writePatternSafe(stripped, sid);
349
- return `Removed ${args.effect} effect`;
283
+ return empty(`No ${args.effect} effect found to remove`);
284
+ const written = await ctx.writePatternSafe(stripped, sid);
285
+ return withStashNotice(`Removed ${args.effect} effect`, written);
350
286
  }
351
287
  export async function execute(name, args, ctx) {
352
288
  const sid = args?.session_id;
@@ -369,15 +305,6 @@ export async function execute(name, args, ctx) {
369
305
  throw new Error(`Invalid op: ${op}. Must be one of: transpose, reverse, stretch, quantize, humanize, swing, scale, vary`);
370
306
  }
371
307
  }
372
- // Deprecated aliases — forward to per-op helpers.
373
- case 'transpose': return await opTranspose(args, ctx, sid);
374
- case 'reverse': return await opReverse(ctx, sid);
375
- case 'stretch': return await opStretch(args, ctx, sid);
376
- case 'quantize': return await opQuantize(args, ctx, sid);
377
- case 'humanize': return await opHumanize(args, ctx, sid);
378
- case 'generate_variation': return await opVary(args, ctx, sid);
379
- case 'add_swing': return await opSwing(args, ctx, sid);
380
- case 'apply_scale': return await opScale(args, ctx, sid);
381
308
  case 'effect': {
382
309
  const a = args?.action;
383
310
  if (a !== 'add' && a !== 'remove') {
@@ -387,13 +314,15 @@ export async function execute(name, args, ctx) {
387
314
  ? await opEffectAdd(args, ctx, sid)
388
315
  : await opEffectRemove(args, ctx, sid);
389
316
  }
390
- case 'add_effect': return await opEffectAdd(args, ctx, sid);
391
- case 'remove_effect': return await opEffectRemove(args, ctx, sid);
392
317
  case 'set_tempo': {
393
318
  InputValidator.validateBPM(args.bpm);
394
319
  const p = await ctx.getCurrentPatternSafe(sid);
395
- await ctx.writePatternSafe(`setcpm(${args.bpm})\n${p}`, sid);
396
- return `Set tempo to ${args.bpm} BPM`;
320
+ // `setcpm` is CYCLES per minute, and the Strudel convention this
321
+ // project follows is one bar of four beats per cycle — so the
322
+ // divisor is what makes `set_tempo({ bpm: 130 })` produce 130 BPM
323
+ // rather than 520 (#395).
324
+ const written = await ctx.writePatternSafe(`setcpm(${String(args.bpm)}/${String(BEATS_PER_CYCLE)})\n${p}`, sid);
325
+ return withStashNotice(`Set tempo to ${args.bpm} BPM`, written);
397
326
  }
398
327
  case 'shape': {
399
328
  const dim = args?.dimension;
@@ -405,21 +334,30 @@ export async function execute(name, args, ctx) {
405
334
  throw new Error(`Invalid dimension: ${dim}. Must be one of: mood, energy, refine`);
406
335
  }
407
336
  }
408
- case 'shift_mood': return await shiftMood(args, ctx, sid);
409
- case 'set_energy': return await setEnergyLevel(args.level, ctx, sid);
410
- case 'refine': return await refinePattern(args.direction, ctx, sid);
411
337
  default:
412
338
  throw new Error(`transform module does not handle tool: ${name}`);
413
339
  }
414
340
  }
415
341
  async function shiftMood(args, ctx, sid) {
416
342
  const mood = args.target_mood?.toLowerCase()?.trim();
417
- const profile = MOOD_PROFILES[mood];
343
+ // hasOwn, not a bare index: MOOD_PROFILES is a plain object literal,
344
+ // so MOOD_PROFILES['constructor'] returned Object — truthy, so the
345
+ // `if (!profile)` guard below never fired. The result was
346
+ // s("bd*4").slow(NaN).lpf(NaN).room(NaN).gain(NaN) written into the
347
+ // editor and reported as success:true (#308).
348
+ const profile = typeof mood === 'string' && Object.hasOwn(MOOD_PROFILES, mood)
349
+ ? MOOD_PROFILES[mood]
350
+ : undefined;
418
351
  if (!profile) {
419
- return {
420
- success: false,
421
- error: `Unknown mood: ${args.target_mood}. Valid moods: ${Object.keys(MOOD_PROFILES).join(', ')}.`,
422
- };
352
+ // `err('validation', ...)`, not a failure-shaped object.
353
+ //
354
+ // The dispatcher categorises those by running the MESSAGE through
355
+ // the string matcher, and "Valid moods: ..." contains none of the
356
+ // phrases it looks for — so a caller's typo landed in `internal`,
357
+ // which is the non-retryable fall-through for a fault that is not
358
+ // the caller's (#453). This is the audit #382 describes, at four
359
+ // sites it did not reach.
360
+ return err('validation', `Unknown mood: ${args.target_mood}. Valid moods: ${Object.keys(MOOD_PROFILES).join(', ')}.`);
423
361
  }
424
362
  const pattern = await ctx.getCurrentPatternSafe(sid);
425
363
  if (!pattern || pattern.trim().length === 0) {
@@ -463,11 +401,11 @@ async function shiftMood(args, ctx, sid) {
463
401
  result += `.gain(${g.toFixed(2)})`;
464
402
  applied.push(`gain ${profile.gainMod > 0 ? '+' : ''}${Math.round(profile.gainMod * 100 * intensity)}%`);
465
403
  }
466
- await ctx.writePatternSafe(result, sid);
404
+ const written = await ctx.writePatternSafe(result, sid);
467
405
  if (args.auto_play !== false && (sid || ctx.isInitialized())) {
468
406
  await ctx.getController(sid).play();
469
407
  }
470
- return { success: true, target_mood: mood, intensity, applied_effects: applied };
408
+ return withStashField({ success: true, target_mood: mood, intensity, applied_effects: applied }, written);
471
409
  }
472
410
  async function setEnergyLevel(level, ctx, sid) {
473
411
  if (level < 0 || level > 10 || !Number.isInteger(level)) {
@@ -482,10 +420,10 @@ async function setEnergyLevel(level, ctx, sid) {
482
420
  if (config.densityAdjust)
483
421
  result += config.densityAdjust;
484
422
  result += `.room(${config.roomAmount})`;
485
- await ctx.writePatternSafe(result, sid);
423
+ const written = await ctx.writePatternSafe(result, sid);
486
424
  if (sid || ctx.isInitialized())
487
425
  await ctx.getController(sid).play();
488
- return { success: true, level, description: config.description };
426
+ return withStashField({ success: true, level, description: config.description }, written);
489
427
  }
490
428
  async function refinePattern(direction, ctx, sid) {
491
429
  const pattern = await ctx.getCurrentPatternSafe(sid);
@@ -520,14 +458,13 @@ async function refinePattern(direction, ctx, sid) {
520
458
  modification = '.room(0.1)';
521
459
  break;
522
460
  default:
523
- return {
524
- success: false,
525
- error: `Unknown refinement direction: ${direction}. Supported: faster, slower, louder, quieter, brighter, darker, "more reverb", drier.`,
526
- };
461
+ // See the mood branch above: the caller's argument, categorised
462
+ // `validation` rather than `internal` (#453).
463
+ return err('validation', `Unknown refinement direction: ${direction}. Supported: faster, slower, louder, quieter, brighter, darker, "more reverb", drier.`);
527
464
  }
528
- await ctx.writePatternSafe(pattern + modification, sid);
465
+ const written = await ctx.writePatternSafe(pattern + modification, sid);
529
466
  if (sid || ctx.isInitialized())
530
467
  await ctx.getController(sid).play();
531
- return { success: true, direction: dir, applied: modification };
468
+ return withStashField({ success: true, direction: dir, applied: modification }, written);
532
469
  }
533
470
  export const transformModule = { tools, toolNames, execute };