@williamzujkowski/live-coding-music-mcp 4.0.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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 +17 -5
- package/dist/services/__mocks__/StrudelEngine.js +0 -157
- package/dist/types/index.js +0 -1
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
* Owns one consolidated tool (`pattern_store`) with save/load/list actions.
|
|
5
5
|
*/
|
|
6
6
|
import { InputValidator } from '../../utils/InputValidator.js';
|
|
7
|
+
import { ok, err, categorizeError, withStashNotice } from './types.js';
|
|
8
|
+
import { readFile } from 'fs/promises';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { ValidationError } from '../../utils/CategorisedError.js';
|
|
11
|
+
/** Hard cap on .mid input size — protects @tonejs/midi from pathological allocations. */
|
|
12
|
+
const MAX_MIDI_BYTES = 1_000_000; // 1 MB
|
|
7
13
|
const SESSION_ID_PROP = {
|
|
8
14
|
session_id: {
|
|
9
15
|
type: 'string',
|
|
@@ -29,38 +35,201 @@ export const tools = [
|
|
|
29
35
|
},
|
|
30
36
|
name: { type: 'string', description: 'Pattern name (required for save/load)' },
|
|
31
37
|
tags: { type: 'array', items: { type: 'string' }, description: 'Tags to attach (save only)' },
|
|
38
|
+
overwrite: {
|
|
39
|
+
type: 'boolean',
|
|
40
|
+
description: 'save only: replace a pattern saved under a DIFFERENT name that maps to the '
|
|
41
|
+
+ 'same file (names are lowercased and stripped of punctuation, so "My Jam" '
|
|
42
|
+
+ 'and "my-jam" collide). Off by default; the replaced pattern is unrecoverable.',
|
|
43
|
+
},
|
|
32
44
|
tag: { type: 'string', description: 'Filter by tag (list only)' },
|
|
33
45
|
...SESSION_ID_PROP,
|
|
34
46
|
},
|
|
35
47
|
required: ['action'],
|
|
36
48
|
},
|
|
37
49
|
},
|
|
50
|
+
{
|
|
51
|
+
name: 'import_midi',
|
|
52
|
+
description: 'Convert a .mid file into a playable Strudel pattern (Phase 1: literal transcription, #201). ' +
|
|
53
|
+
'Use source="base64" with data=<base64 string> for inline bytes, or source="path" with data=<basename> ' +
|
|
54
|
+
'to read from the patterns/midi/ directory (path traversal blocked). ' +
|
|
55
|
+
'Drum tracks (MIDI channel 10) emit one s() lane per sample so simultaneous kicks/hats do not collide. ' +
|
|
56
|
+
'Pitched tracks emit note("...").s("piano") with simultaneous notes merged into [a,b,c] chord tokens. ' +
|
|
57
|
+
'Phase 2+ (structural compression, voice separation, LLM idiomatic pass) tracked in separate issues. ' +
|
|
58
|
+
'Example: import_midi({ source: "path", data: "drumloop.mid", steps_per_cycle: 16 }). ' +
|
|
59
|
+
'For the reverse direction (Strudel → MIDI) use the export_midi tool.',
|
|
60
|
+
inputSchema: {
|
|
61
|
+
type: 'object',
|
|
62
|
+
properties: {
|
|
63
|
+
source: {
|
|
64
|
+
type: 'string',
|
|
65
|
+
enum: ['base64', 'path'],
|
|
66
|
+
description: 'How to interpret `data` — raw base64 bytes or a filename under patterns/midi/',
|
|
67
|
+
},
|
|
68
|
+
data: {
|
|
69
|
+
type: 'string',
|
|
70
|
+
description: 'For source=base64: base64-encoded .mid bytes (≤1MB decoded). ' +
|
|
71
|
+
'For source=path: basename of a .mid file under ./patterns/midi/ (path-traversal blocked).',
|
|
72
|
+
},
|
|
73
|
+
steps_per_cycle: {
|
|
74
|
+
type: 'number',
|
|
75
|
+
description: 'Grid resolution per bar (integer 1-64). Default 16.',
|
|
76
|
+
},
|
|
77
|
+
bars: {
|
|
78
|
+
type: 'number',
|
|
79
|
+
description: 'Cap on bars to emit. Default: full file.',
|
|
80
|
+
},
|
|
81
|
+
drum_map: {
|
|
82
|
+
type: 'object',
|
|
83
|
+
description: 'Optional override / extension to the default GM percussion map. ' +
|
|
84
|
+
'Keys are MIDI note numbers (as strings, since JSON object keys), values are Strudel sample names. ' +
|
|
85
|
+
'Example: { "60": "cp", "61": "rim" }. Unmapped drums fall back to rests and are surfaced in the result summary.',
|
|
86
|
+
additionalProperties: { type: 'string' },
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
required: ['source', 'data'],
|
|
90
|
+
},
|
|
91
|
+
},
|
|
38
92
|
];
|
|
39
93
|
export const toolNames = new Set(tools.map(t => t.name));
|
|
40
94
|
async function doSave(args, ctx, sid) {
|
|
41
95
|
InputValidator.validateStringLength(args.name, 'name', 255, false);
|
|
42
96
|
const toSave = await ctx.getCurrentPatternSafe(sid);
|
|
43
|
-
|
|
44
|
-
|
|
97
|
+
// Whitespace counts as nothing. `!toSave` covered '' only, so a
|
|
98
|
+
// pattern of " \n\t " was written to disk and reported as a real
|
|
99
|
+
// save (#293).
|
|
100
|
+
if (!toSave || toSave.trim().length === 0) {
|
|
101
|
+
// Nothing was written to disk, so this is a failure — but as a bare
|
|
102
|
+
// string it was none of the three things the dispatcher recognises
|
|
103
|
+
// (envelope, failure-shaped object, legacy 'Error: ' prefix), so it
|
|
104
|
+
// reached clients as { ok: true, data: 'No pattern to save' }. An
|
|
105
|
+
// agent branching on `ok` recorded a successful save and moved on,
|
|
106
|
+
// and the pattern was gone (#287).
|
|
107
|
+
return err('business', 'No pattern to save. Write or generate a pattern first.');
|
|
45
108
|
}
|
|
46
|
-
|
|
109
|
+
// A collision throws ValidationError, which `categorizeError` maps to
|
|
110
|
+
// the validation envelope at the dispatcher — so the caller gets a
|
|
111
|
+
// refusal naming the pattern in the way, not a success (#428).
|
|
112
|
+
await ctx.store.save(args.name, toSave, args.tags || [], {
|
|
113
|
+
overwrite: args.overwrite === true,
|
|
114
|
+
});
|
|
47
115
|
return `Pattern saved as "${args.name}"`;
|
|
48
116
|
}
|
|
49
117
|
async function doLoad(args, ctx, sid) {
|
|
50
118
|
InputValidator.validateStringLength(args.name, 'name', 255, false);
|
|
51
119
|
const saved = await ctx.store.load(args.name);
|
|
52
120
|
if (saved) {
|
|
53
|
-
await ctx.writePatternSafe(saved.content, sid);
|
|
54
|
-
return `Loaded pattern "${args.name}"
|
|
121
|
+
const written = await ctx.writePatternSafe(saved.content, sid);
|
|
122
|
+
return withStashNotice(`Loaded pattern "${args.name}"`, written);
|
|
55
123
|
}
|
|
56
|
-
|
|
124
|
+
// Bare prose, so the dispatcher's legacy normalisation — which only
|
|
125
|
+
// recognises 'Error: ' and 'Browser not initialized' prefixes — let
|
|
126
|
+
// this through as { ok: true, data: 'Pattern "x" not found' }. Same
|
|
127
|
+
// false success as #287, twenty lines below the code that fixed it
|
|
128
|
+
// (#293).
|
|
129
|
+
return err('business', `Pattern "${args.name}" not found. ` +
|
|
130
|
+
'Use pattern_store({ action: "list" }) to see what is saved.');
|
|
57
131
|
}
|
|
58
132
|
async function doList(args, ctx) {
|
|
59
133
|
if (args?.tag) {
|
|
60
134
|
InputValidator.validateStringLength(args.tag, 'tag', 100, false);
|
|
61
135
|
}
|
|
62
|
-
const patterns = await ctx.store.
|
|
63
|
-
|
|
136
|
+
const { patterns, skipped } = await ctx.store.listDetailed(args?.tag);
|
|
137
|
+
// A file the store could not read is not the same as a pattern that
|
|
138
|
+
// is not there, and the caller has no other way to learn the
|
|
139
|
+
// difference — the store logs it, and no MCP client reads the log
|
|
140
|
+
// (#426, same reasoning as the export warning in #335).
|
|
141
|
+
const note = skipped > 0
|
|
142
|
+
? `\n\n(${String(skipped)} file(s) in the pattern directory could not be read and were skipped.)`
|
|
143
|
+
: '';
|
|
144
|
+
if (patterns.length === 0) {
|
|
145
|
+
return skipped > 0
|
|
146
|
+
? `No readable patterns found.${note}`
|
|
147
|
+
: 'No patterns found';
|
|
148
|
+
}
|
|
149
|
+
return patterns.map(p => `• ${p.name} [${p.tags.join(', ')}] - ${p.timestamp}`).join('\n') + note;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Resolve `args.data` to a Buffer based on `args.source`. Enforces the
|
|
153
|
+
* 1 MB cap and, for path sources, blocks path traversal by clamping to
|
|
154
|
+
* `./patterns/midi/` via `path.basename` (mirrors PatternStore).
|
|
155
|
+
*/
|
|
156
|
+
async function loadMidiBuffer(args) {
|
|
157
|
+
const source = args?.source;
|
|
158
|
+
const data = args?.data;
|
|
159
|
+
if (typeof data !== 'string' || data.length === 0) {
|
|
160
|
+
throw new Error('Invalid data: must be a non-empty string');
|
|
161
|
+
}
|
|
162
|
+
if (source === 'base64') {
|
|
163
|
+
// Decoded length ≤ raw length × 3/4; reject before allocating.
|
|
164
|
+
if (data.length > MAX_MIDI_BYTES * 2) {
|
|
165
|
+
throw new ValidationError(`MIDI base64 input too large (>${MAX_MIDI_BYTES} bytes decoded).`);
|
|
166
|
+
}
|
|
167
|
+
const buf = Buffer.from(data, 'base64');
|
|
168
|
+
if (buf.length === 0) {
|
|
169
|
+
throw new Error('Invalid data: base64 decoded to zero bytes');
|
|
170
|
+
}
|
|
171
|
+
if (buf.length > MAX_MIDI_BYTES) {
|
|
172
|
+
throw new ValidationError(`MIDI input too large (${buf.length} > ${MAX_MIDI_BYTES} bytes).`);
|
|
173
|
+
}
|
|
174
|
+
return buf;
|
|
175
|
+
}
|
|
176
|
+
if (source === 'path') {
|
|
177
|
+
InputValidator.validateStringLength(data, 'data', 255, false);
|
|
178
|
+
const safeName = path.basename(data);
|
|
179
|
+
if (safeName !== data) {
|
|
180
|
+
throw new Error('Invalid filename: path traversal detected');
|
|
181
|
+
}
|
|
182
|
+
const fullPath = path.resolve('./patterns/midi', safeName);
|
|
183
|
+
const expectedRoot = path.resolve('./patterns/midi');
|
|
184
|
+
if (!fullPath.startsWith(expectedRoot + path.sep)) {
|
|
185
|
+
throw new Error('Invalid filename: must resolve under patterns/midi/');
|
|
186
|
+
}
|
|
187
|
+
const buf = await readFile(fullPath);
|
|
188
|
+
if (buf.length > MAX_MIDI_BYTES) {
|
|
189
|
+
throw new ValidationError(`MIDI input too large (${buf.length} > ${MAX_MIDI_BYTES} bytes).`);
|
|
190
|
+
}
|
|
191
|
+
return buf;
|
|
192
|
+
}
|
|
193
|
+
throw new Error(`Invalid source: ${source}. Must be "base64" or "path".`);
|
|
194
|
+
}
|
|
195
|
+
async function doImportMidi(args, ctx) {
|
|
196
|
+
let buffer;
|
|
197
|
+
try {
|
|
198
|
+
buffer = await loadMidiBuffer(args);
|
|
199
|
+
}
|
|
200
|
+
catch (e) {
|
|
201
|
+
return err('validation', e?.message ?? 'Failed to load MIDI input');
|
|
202
|
+
}
|
|
203
|
+
// Normalize drum_map keys: JSON object keys are strings, the service wants number keys.
|
|
204
|
+
let drumMapNormalized;
|
|
205
|
+
if (args?.drum_map && typeof args.drum_map === 'object') {
|
|
206
|
+
drumMapNormalized = {};
|
|
207
|
+
for (const [k, v] of Object.entries(args.drum_map)) {
|
|
208
|
+
const n = Number(k);
|
|
209
|
+
if (!Number.isInteger(n) || n < 0 || n > 127 || typeof v !== 'string' || v.length === 0) {
|
|
210
|
+
return err('validation', `Invalid drum_map entry: ${k} -> ${String(v)}`);
|
|
211
|
+
}
|
|
212
|
+
drumMapNormalized[n] = v;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
const result = ctx.midiImportService.convertBuffer(buffer, {
|
|
217
|
+
steps_per_cycle: args?.steps_per_cycle,
|
|
218
|
+
bars: args?.bars,
|
|
219
|
+
drum_map: drumMapNormalized,
|
|
220
|
+
});
|
|
221
|
+
return ok(result);
|
|
222
|
+
}
|
|
223
|
+
catch (e) {
|
|
224
|
+
// Use the shared categoriser rather than a second, narrower one.
|
|
225
|
+
// This local matcher knew only parse/invalid/must-be, so every cap
|
|
226
|
+
// added in #235 landed in `internal` — including "MIDI file spans too
|
|
227
|
+
// many bars (N > 512). Pass bars=<n> to import a prefix.", which is
|
|
228
|
+
// the most actionable message in the file and was being delivered as
|
|
229
|
+
// a server bug the caller could do nothing about (#280).
|
|
230
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
231
|
+
return err(categorizeError(e), msg);
|
|
232
|
+
}
|
|
64
233
|
}
|
|
65
234
|
export async function execute(name, args, ctx) {
|
|
66
235
|
const sid = args?.session_id;
|
|
@@ -78,6 +247,8 @@ export async function execute(name, args, ctx) {
|
|
|
78
247
|
// Unreachable due to enum check above, but TypeScript wants it.
|
|
79
248
|
return undefined;
|
|
80
249
|
}
|
|
250
|
+
case 'import_midi':
|
|
251
|
+
return await doImportMidi(args, ctx);
|
|
81
252
|
default:
|
|
82
253
|
throw new Error(`storage module does not handle tool: ${name}`);
|
|
83
254
|
}
|
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
* - `shape` (dimension enum: mood/energy/refine)
|
|
9
9
|
* - `set_tempo` — high-traffic standalone verb
|
|
10
10
|
*/
|
|
11
|
+
import { empty, err, withStashField, withStashNotice } from './types.js';
|
|
11
12
|
import { InputValidator } from '../../utils/InputValidator.js';
|
|
13
|
+
import { BEATS_PER_CYCLE } from '../../utils/Tempo.js';
|
|
14
|
+
import { lookup } from '../../utils/TableLookup.js';
|
|
12
15
|
const ENERGY_LEVELS = {
|
|
13
16
|
0: { tempoAdjust: -20, roomAmount: 0.5, densityAdjust: '.slow(4)', description: 'minimal/ambient' },
|
|
14
17
|
1: { tempoAdjust: -15, roomAmount: 0.4, densityAdjust: '.slow(3)', description: 'very sparse' },
|
|
@@ -38,7 +41,7 @@ function transposePattern(pattern, semitones) {
|
|
|
38
41
|
'f#': 6, 'g': 7, 'g#': 8, 'a': 9, 'a#': 10, 'b': 11,
|
|
39
42
|
};
|
|
40
43
|
const currentNote = note.toLowerCase();
|
|
41
|
-
const noteValue = noteMap
|
|
44
|
+
const noteValue = lookup(noteMap, currentNote, 0);
|
|
42
45
|
const newNoteValue = (noteValue + semitones + 12) % 12;
|
|
43
46
|
const noteNames = ['c', 'c#', 'd', 'd#', 'e', 'f', 'f#', 'g', 'g#', 'a', 'a#', 'b'];
|
|
44
47
|
const newOctave = parseInt(octave) + Math.floor((noteValue + semitones) / 12);
|
|
@@ -89,7 +92,7 @@ export const tools = [
|
|
|
89
92
|
name: 'effect',
|
|
90
93
|
description: 'Add or remove a Strudel effect on the current session pattern. ' +
|
|
91
94
|
'action=add appends `.<effect>(<params>)`. ' +
|
|
92
|
-
'action=remove strips
|
|
95
|
+
'action=remove strips EVERY `.<effect>(...)` call from the pattern, not just the last. ' +
|
|
93
96
|
'Example: effect({ action: "add", effect: "lpf", params: "1000" }). ' +
|
|
94
97
|
'For higher-level effect bundles (mood/energy/refine) use shape; for raw transforms use transform.',
|
|
95
98
|
inputSchema: {
|
|
@@ -105,7 +108,7 @@ export const tools = [
|
|
|
105
108
|
},
|
|
106
109
|
{
|
|
107
110
|
name: 'set_tempo',
|
|
108
|
-
description: 'Set BPM',
|
|
111
|
+
description: 'Set BPM. Writes setcpm(bpm/4), assuming one bar of 4/4 per cycle.',
|
|
109
112
|
inputSchema: {
|
|
110
113
|
type: 'object',
|
|
111
114
|
properties: { bpm: { type: 'number', description: 'Tempo in BPM' }, ...SESSION_ID_PROP },
|
|
@@ -147,73 +150,139 @@ async function opTranspose(args, ctx, sid) {
|
|
|
147
150
|
throw new Error('Semitones must be an integer');
|
|
148
151
|
}
|
|
149
152
|
const p = await ctx.getCurrentPatternSafe(sid);
|
|
150
|
-
await ctx.writePatternSafe(transposePattern(p, args.semitones), sid);
|
|
151
|
-
return `Transposed ${args.semitones} semitones
|
|
153
|
+
const written = await ctx.writePatternSafe(transposePattern(p, args.semitones), sid);
|
|
154
|
+
return withStashNotice(`Transposed ${args.semitones} semitones`, written);
|
|
152
155
|
}
|
|
153
156
|
async function opReverse(ctx, sid) {
|
|
154
157
|
const p = await ctx.getCurrentPatternSafe(sid);
|
|
155
|
-
await ctx.writePatternSafe(p + '.rev', sid);
|
|
156
|
-
return 'Pattern reversed';
|
|
158
|
+
const written = await ctx.writePatternSafe(p + '.rev', sid);
|
|
159
|
+
return withStashNotice('Pattern reversed', written);
|
|
157
160
|
}
|
|
158
161
|
async function opStretch(args, ctx, sid) {
|
|
159
162
|
InputValidator.validateGain(args.factor);
|
|
160
163
|
const p = await ctx.getCurrentPatternSafe(sid);
|
|
161
|
-
await ctx.writePatternSafe(p + `.slow(${args.factor})`, sid);
|
|
162
|
-
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);
|
|
163
166
|
}
|
|
164
167
|
async function opQuantize(args, ctx, sid) {
|
|
165
168
|
InputValidator.validateStringLength(args.grid, 'grid', 50, false);
|
|
166
169
|
const p = await ctx.getCurrentPatternSafe(sid);
|
|
167
|
-
await ctx.writePatternSafe(p + `.struct("${args.grid}")`, sid);
|
|
168
|
-
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);
|
|
169
172
|
}
|
|
170
173
|
async function opHumanize(args, ctx, sid) {
|
|
171
174
|
if (args.amount !== undefined)
|
|
172
175
|
InputValidator.validateNormalizedValue(args.amount, 'amount');
|
|
173
176
|
const p = await ctx.getCurrentPatternSafe(sid);
|
|
174
177
|
const amt = args.amount || 0.01;
|
|
175
|
-
await ctx.writePatternSafe(p + `.nudge(rand.range(-${amt}, ${amt}))`, sid);
|
|
176
|
-
return 'Added human timing';
|
|
178
|
+
const written = await ctx.writePatternSafe(p + `.nudge(rand.range(-${amt}, ${amt}))`, sid);
|
|
179
|
+
return withStashNotice('Added human timing', written);
|
|
177
180
|
}
|
|
178
181
|
async function opVary(args, ctx, sid) {
|
|
179
182
|
const p = await ctx.getCurrentPatternSafe(sid);
|
|
180
183
|
const varied = ctx.generator.generateVariation(p, args.type || 'subtle');
|
|
181
|
-
await ctx.writePatternSafe(varied, sid);
|
|
182
|
-
return `Added ${args.type || 'subtle'} variation
|
|
184
|
+
const written = await ctx.writePatternSafe(varied, sid);
|
|
185
|
+
return withStashNotice(`Added ${args.type || 'subtle'} variation`, written);
|
|
183
186
|
}
|
|
184
187
|
async function opSwing(args, ctx, sid) {
|
|
185
188
|
InputValidator.validateNormalizedValue(args.amount, 'amount');
|
|
186
189
|
const p = await ctx.getCurrentPatternSafe(sid);
|
|
187
|
-
await ctx.writePatternSafe(p + `.swing(${args.amount})`, sid);
|
|
188
|
-
return `Added swing: ${args.amount}
|
|
190
|
+
const written = await ctx.writePatternSafe(p + `.swing(${args.amount})`, sid);
|
|
191
|
+
return withStashNotice(`Added swing: ${args.amount}`, written);
|
|
189
192
|
}
|
|
190
193
|
async function opScale(args, ctx, sid) {
|
|
191
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);
|
|
192
206
|
InputValidator.validateRootNote(args.root);
|
|
193
207
|
const p = await ctx.getCurrentPatternSafe(sid);
|
|
194
|
-
await ctx.writePatternSafe(p + `.scale("${args.root}:${args.scale}")`, sid);
|
|
195
|
-
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);
|
|
196
210
|
}
|
|
197
211
|
async function opEffectAdd(args, ctx, sid) {
|
|
198
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');
|
|
199
216
|
if (args.params)
|
|
200
217
|
InputValidator.validateStringLength(args.params, 'params', 1000, true);
|
|
201
218
|
const p = await ctx.getCurrentPatternSafe(sid);
|
|
202
219
|
const withEffect = args.params
|
|
203
220
|
? p + `.${args.effect}(${args.params})`
|
|
204
221
|
: p + `.${args.effect}()`;
|
|
205
|
-
await ctx.writePatternSafe(withEffect, sid);
|
|
206
|
-
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);
|
|
207
272
|
}
|
|
208
273
|
async function opEffectRemove(args, ctx, sid) {
|
|
209
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');
|
|
210
278
|
const p = await ctx.getCurrentPatternSafe(sid);
|
|
211
|
-
const
|
|
212
|
-
|
|
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).
|
|
213
282
|
if (stripped === p)
|
|
214
|
-
return `No ${args.effect} effect found to remove
|
|
215
|
-
await ctx.writePatternSafe(stripped, sid);
|
|
216
|
-
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);
|
|
217
286
|
}
|
|
218
287
|
export async function execute(name, args, ctx) {
|
|
219
288
|
const sid = args?.session_id;
|
|
@@ -248,8 +317,12 @@ export async function execute(name, args, ctx) {
|
|
|
248
317
|
case 'set_tempo': {
|
|
249
318
|
InputValidator.validateBPM(args.bpm);
|
|
250
319
|
const p = await ctx.getCurrentPatternSafe(sid);
|
|
251
|
-
|
|
252
|
-
|
|
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);
|
|
253
326
|
}
|
|
254
327
|
case 'shape': {
|
|
255
328
|
const dim = args?.dimension;
|
|
@@ -267,12 +340,24 @@ export async function execute(name, args, ctx) {
|
|
|
267
340
|
}
|
|
268
341
|
async function shiftMood(args, ctx, sid) {
|
|
269
342
|
const mood = args.target_mood?.toLowerCase()?.trim();
|
|
270
|
-
|
|
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;
|
|
271
351
|
if (!profile) {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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(', ')}.`);
|
|
276
361
|
}
|
|
277
362
|
const pattern = await ctx.getCurrentPatternSafe(sid);
|
|
278
363
|
if (!pattern || pattern.trim().length === 0) {
|
|
@@ -316,11 +401,11 @@ async function shiftMood(args, ctx, sid) {
|
|
|
316
401
|
result += `.gain(${g.toFixed(2)})`;
|
|
317
402
|
applied.push(`gain ${profile.gainMod > 0 ? '+' : ''}${Math.round(profile.gainMod * 100 * intensity)}%`);
|
|
318
403
|
}
|
|
319
|
-
await ctx.writePatternSafe(result, sid);
|
|
404
|
+
const written = await ctx.writePatternSafe(result, sid);
|
|
320
405
|
if (args.auto_play !== false && (sid || ctx.isInitialized())) {
|
|
321
406
|
await ctx.getController(sid).play();
|
|
322
407
|
}
|
|
323
|
-
return { success: true, target_mood: mood, intensity, applied_effects: applied };
|
|
408
|
+
return withStashField({ success: true, target_mood: mood, intensity, applied_effects: applied }, written);
|
|
324
409
|
}
|
|
325
410
|
async function setEnergyLevel(level, ctx, sid) {
|
|
326
411
|
if (level < 0 || level > 10 || !Number.isInteger(level)) {
|
|
@@ -335,10 +420,10 @@ async function setEnergyLevel(level, ctx, sid) {
|
|
|
335
420
|
if (config.densityAdjust)
|
|
336
421
|
result += config.densityAdjust;
|
|
337
422
|
result += `.room(${config.roomAmount})`;
|
|
338
|
-
await ctx.writePatternSafe(result, sid);
|
|
423
|
+
const written = await ctx.writePatternSafe(result, sid);
|
|
339
424
|
if (sid || ctx.isInitialized())
|
|
340
425
|
await ctx.getController(sid).play();
|
|
341
|
-
return { success: true, level, description: config.description };
|
|
426
|
+
return withStashField({ success: true, level, description: config.description }, written);
|
|
342
427
|
}
|
|
343
428
|
async function refinePattern(direction, ctx, sid) {
|
|
344
429
|
const pattern = await ctx.getCurrentPatternSafe(sid);
|
|
@@ -373,14 +458,13 @@ async function refinePattern(direction, ctx, sid) {
|
|
|
373
458
|
modification = '.room(0.1)';
|
|
374
459
|
break;
|
|
375
460
|
default:
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
};
|
|
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.`);
|
|
380
464
|
}
|
|
381
|
-
await ctx.writePatternSafe(pattern + modification, sid);
|
|
465
|
+
const written = await ctx.writePatternSafe(pattern + modification, sid);
|
|
382
466
|
if (sid || ctx.isInitialized())
|
|
383
467
|
await ctx.getController(sid).play();
|
|
384
|
-
return { success: true, direction: dir, applied: modification };
|
|
468
|
+
return withStashField({ success: true, direction: dir, applied: modification }, written);
|
|
385
469
|
}
|
|
386
470
|
export const transformModule = { tools, toolNames, execute };
|