@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
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AudioExportService - records live Strudel audio and writes it to disk
|
|
3
|
+
*
|
|
4
|
+
* `audio_capture` returns base64 inline in the MCP response. For a 30s
|
|
5
|
+
* capture that is roughly 1.5MB of response text — hundreds of thousands
|
|
6
|
+
* of tokens dropped into the calling agent's context, for data the agent
|
|
7
|
+
* cannot listen to anyway. And WebM/Opus is not what a DAW or a person
|
|
8
|
+
* wants at the other end (#223).
|
|
9
|
+
*
|
|
10
|
+
* This service exports instead: record a window, decode and encode it
|
|
11
|
+
* *in the browser*, and hand back a path.
|
|
12
|
+
*
|
|
13
|
+
* Everything that can happen in page context does. The bytes cross the
|
|
14
|
+
* CDP boundary once, as a base64 string, because that is the only shape
|
|
15
|
+
* Playwright serializes losslessly — returning a `Blob` yields `{}` on the
|
|
16
|
+
* Node side with no `arrayBuffer` method, which is the bug that made
|
|
17
|
+
* `audio_capture` silently broken since it was written.
|
|
18
|
+
*
|
|
19
|
+
* @module services/AudioExportService
|
|
20
|
+
* @nist si-10 "Information input validation"
|
|
21
|
+
* @nist ac-3 "Access enforcement"
|
|
22
|
+
*/
|
|
23
|
+
import { mkdirSync, writeFileSync } from 'fs';
|
|
24
|
+
import { Logger } from '../utils/Logger.js';
|
|
25
|
+
import { resolveSafeOutputPath, resolveExportDirectory } from '../utils/SafePath.js';
|
|
26
|
+
/** Longest window a single export may record. */
|
|
27
|
+
export const MAX_EXPORT_DURATION_MS = 30_000;
|
|
28
|
+
/** Shortest window worth recording. */
|
|
29
|
+
export const MIN_EXPORT_DURATION_MS = 100;
|
|
30
|
+
/**
|
|
31
|
+
* Peak amplitude at or below which a capture counts as silent.
|
|
32
|
+
*
|
|
33
|
+
* Strudel's graph emits a low noise floor even with nothing playing, so
|
|
34
|
+
* an exact-zero test would never fire and every silent export would be
|
|
35
|
+
* reported as successful audio.
|
|
36
|
+
*/
|
|
37
|
+
export const SILENCE_PEAK_THRESHOLD = 0.0005;
|
|
38
|
+
export class AudioExportService {
|
|
39
|
+
exportDir;
|
|
40
|
+
logger;
|
|
41
|
+
/**
|
|
42
|
+
* @param exportDir - Directory exports are confined to (default `./exports`)
|
|
43
|
+
*/
|
|
44
|
+
constructor(exportDir) {
|
|
45
|
+
this.exportDir = resolveExportDirectory(exportDir);
|
|
46
|
+
this.logger = new Logger();
|
|
47
|
+
}
|
|
48
|
+
/** Directory this service writes to. */
|
|
49
|
+
getExportDirectory() {
|
|
50
|
+
return this.exportDir;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Records a window of live audio and exports it.
|
|
54
|
+
*
|
|
55
|
+
* @param page - Page with the Strudel audio graph, already playing
|
|
56
|
+
* @param options - Duration, format, filename, and output mode
|
|
57
|
+
* @returns Path or base64 payload, plus what was actually recorded
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* const result = await service.exportAudio(page, { duration: 3000 });
|
|
61
|
+
* // -> { success: true, path: '<cwd>/exports/capture.wav', silent: false, ... }
|
|
62
|
+
*/
|
|
63
|
+
async exportAudio(page, options = {}) {
|
|
64
|
+
const format = options.format ?? 'wav';
|
|
65
|
+
const output = options.output ?? 'file';
|
|
66
|
+
const duration = options.duration ?? 5000;
|
|
67
|
+
if (!Number.isFinite(duration) || duration < MIN_EXPORT_DURATION_MS || duration > MAX_EXPORT_DURATION_MS) {
|
|
68
|
+
return {
|
|
69
|
+
success: false,
|
|
70
|
+
bytes: 0,
|
|
71
|
+
duration: 0,
|
|
72
|
+
format,
|
|
73
|
+
error: `Invalid duration: ${String(options.duration)}. ` +
|
|
74
|
+
`Must be between ${MIN_EXPORT_DURATION_MS} and ${MAX_EXPORT_DURATION_MS} ms.`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
let payload;
|
|
78
|
+
try {
|
|
79
|
+
payload = await page.evaluate(AudioExportService.browserCapture, { durationMs: duration, wantWav: format === 'wav' });
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
83
|
+
return { success: false, bytes: 0, duration: 0, format, error: `Audio export failed: ${message}` };
|
|
84
|
+
}
|
|
85
|
+
if (!payload.success || payload.base64 === undefined) {
|
|
86
|
+
return {
|
|
87
|
+
success: false,
|
|
88
|
+
bytes: 0,
|
|
89
|
+
duration: 0,
|
|
90
|
+
format,
|
|
91
|
+
error: payload.error ?? 'Audio export failed for an unknown reason.',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const bytes = Buffer.from(payload.base64, 'base64');
|
|
95
|
+
const warnings = [];
|
|
96
|
+
// Report silence rather than writing a silent file and calling it a
|
|
97
|
+
// success — the caller has no way to hear the difference (#223).
|
|
98
|
+
const silent = payload.peak !== undefined && payload.peak <= SILENCE_PEAK_THRESHOLD;
|
|
99
|
+
if (silent) {
|
|
100
|
+
warnings.push('Captured audio is silent. Is a pattern playing? ' +
|
|
101
|
+
'Call playback({ action: "play" }) before exporting.');
|
|
102
|
+
}
|
|
103
|
+
else if (payload.peak === undefined) {
|
|
104
|
+
// Not measured is not the same as not silent.
|
|
105
|
+
//
|
|
106
|
+
// A WebM capture the browser could not decode returns without a
|
|
107
|
+
// peak, so `silent` is false and the check above never fires. The
|
|
108
|
+
// tool promises it "reports silent captures instead of writing a
|
|
109
|
+
// silent file and claiming success" — and that promise was kept
|
|
110
|
+
// by staying quiet about a file it had not been able to look at
|
|
111
|
+
// (#437 item 2). Say so instead.
|
|
112
|
+
warnings.push('Captured audio could not be decoded, so it was not checked for silence. '
|
|
113
|
+
+ 'The bytes are the recorder\'s own output and may be empty.');
|
|
114
|
+
}
|
|
115
|
+
const base = {
|
|
116
|
+
success: true,
|
|
117
|
+
bytes: bytes.length,
|
|
118
|
+
duration: payload.duration ?? duration,
|
|
119
|
+
format,
|
|
120
|
+
...(payload.sampleRate !== undefined ? { sampleRate: payload.sampleRate } : {}),
|
|
121
|
+
...(payload.channels !== undefined ? { channels: payload.channels } : {}),
|
|
122
|
+
...(payload.peak !== undefined ? { peak: payload.peak, rms: payload.rms, silent } : {}),
|
|
123
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
124
|
+
};
|
|
125
|
+
if (output === 'base64') {
|
|
126
|
+
return { ...base, audio: payload.base64 };
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
const target = resolveSafeOutputPath(options.filename, {
|
|
130
|
+
directory: this.exportDir,
|
|
131
|
+
extension: format === 'wav' ? '.wav' : '.webm',
|
|
132
|
+
defaultName: `strudel-capture-${String(Date.now())}.${format}`,
|
|
133
|
+
});
|
|
134
|
+
mkdirSync(this.exportDir, { recursive: true });
|
|
135
|
+
writeFileSync(target.path, bytes);
|
|
136
|
+
this.logger.debug(`Exported ${String(bytes.length)} bytes to ${target.path}`);
|
|
137
|
+
return {
|
|
138
|
+
...base,
|
|
139
|
+
path: target.path,
|
|
140
|
+
...(target.wasModified
|
|
141
|
+
? { sanitizedFilename: target.filename, requestedFilename: target.requested }
|
|
142
|
+
: {}),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
147
|
+
return { ...base, success: false, error: `Failed to write audio file: ${message}` };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Runs in page context: record, decode, measure, encode.
|
|
152
|
+
*
|
|
153
|
+
* Defined as a static so the function passed to `page.evaluate` is a
|
|
154
|
+
* plain closure over its argument with no `this` — Playwright
|
|
155
|
+
* serializes the source, so anything captured from the enclosing scope
|
|
156
|
+
* would be undefined at runtime.
|
|
157
|
+
*
|
|
158
|
+
* Returns base64 rather than a Blob or Uint8Array: a Blob does not
|
|
159
|
+
* survive the CDP boundary at all, and a large typed array round-trips
|
|
160
|
+
* as a slow object map.
|
|
161
|
+
*/
|
|
162
|
+
/* istanbul ignore next -- runs in the browser; covered by npm run test:export-audio */
|
|
163
|
+
static browserCapture = async (args) => {
|
|
164
|
+
const capture = window.strudelAudioCapture;
|
|
165
|
+
if (!capture) {
|
|
166
|
+
return { success: false, error: 'Audio recorder not injected. Run init first.' };
|
|
167
|
+
}
|
|
168
|
+
if (!capture.isConnected) {
|
|
169
|
+
return {
|
|
170
|
+
success: false,
|
|
171
|
+
error: 'Audio recorder not connected to Strudel output. Play a pattern first.',
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
if (capture.isCapturing) {
|
|
175
|
+
return { success: false, error: 'A capture is already in progress.' };
|
|
176
|
+
}
|
|
177
|
+
const started = capture.startCapture();
|
|
178
|
+
if (started.success !== true) {
|
|
179
|
+
return { success: false, error: started.error ?? 'Failed to start capture.' };
|
|
180
|
+
}
|
|
181
|
+
await new Promise(resolve => setTimeout(resolve, args.durationMs));
|
|
182
|
+
const stopped = await capture.stopCapture();
|
|
183
|
+
if (stopped.success !== true || !stopped.blob) {
|
|
184
|
+
return { success: false, error: stopped.error ?? 'Capture produced no audio.' };
|
|
185
|
+
}
|
|
186
|
+
const recorded = await stopped.blob.arrayBuffer();
|
|
187
|
+
// Helpers live as object METHODS, not `const fn = () => {}`.
|
|
188
|
+
// esbuild (which tsx uses) rewrites a named inner function into
|
|
189
|
+
// `__name(fn, "fn")` to preserve `.name`, and `__name` does not exist
|
|
190
|
+
// in page context — so the whole evaluate throws
|
|
191
|
+
// `ReferenceError: __name is not defined` under `npm run dev`, while
|
|
192
|
+
// working fine from `dist/` where tsc emits it untouched. Method
|
|
193
|
+
// shorthand is the one form esbuild leaves alone.
|
|
194
|
+
const h = {
|
|
195
|
+
toBase64(buffer) {
|
|
196
|
+
const view = new Uint8Array(buffer);
|
|
197
|
+
let binary = '';
|
|
198
|
+
// Chunked: String.fromCharCode(...view) blows the stack on a
|
|
199
|
+
// multi-megabyte capture.
|
|
200
|
+
const CHUNK = 0x8000;
|
|
201
|
+
for (let i = 0; i < view.length; i += CHUNK) {
|
|
202
|
+
binary += String.fromCharCode(...view.subarray(i, i + CHUNK));
|
|
203
|
+
}
|
|
204
|
+
return btoa(binary);
|
|
205
|
+
},
|
|
206
|
+
writeAscii(view, offset, text) {
|
|
207
|
+
for (let i = 0; i < text.length; i++)
|
|
208
|
+
view.setUint8(offset + i, text.charCodeAt(i));
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
// Decode for BOTH formats.
|
|
212
|
+
//
|
|
213
|
+
// The webm path used to return here, before anything measured the
|
|
214
|
+
// signal — so `peak` was undefined, the silence check upstream
|
|
215
|
+
// (`payload.peak !== undefined && ...`) was false, and a silent webm
|
|
216
|
+
// was reported as a clean export. The tool description promises
|
|
217
|
+
// "Reports silent captures instead of writing a silent file and
|
|
218
|
+
// claiming success" with no format qualifier, and that promise held
|
|
219
|
+
// only for wav (#437).
|
|
220
|
+
//
|
|
221
|
+
// webm still ships the ORIGINAL Opus bytes; the decode is only to
|
|
222
|
+
// measure them. A decode failure is fatal for wav, which needs the
|
|
223
|
+
// samples, and merely means "no peak reported" for webm, which does
|
|
224
|
+
// not.
|
|
225
|
+
const AudioCtx = window.AudioContext ?? window.webkitAudioContext;
|
|
226
|
+
const ctx = new AudioCtx();
|
|
227
|
+
let decoded = null;
|
|
228
|
+
let decodeError = '';
|
|
229
|
+
try {
|
|
230
|
+
decoded = await ctx.decodeAudioData(recorded.slice(0));
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
decodeError = String(error?.message ?? error);
|
|
234
|
+
}
|
|
235
|
+
if (decoded === null) {
|
|
236
|
+
await ctx.close();
|
|
237
|
+
if (args.wantWav) {
|
|
238
|
+
return { success: false, error: `Failed to decode captured audio: ${decodeError}` };
|
|
239
|
+
}
|
|
240
|
+
return { success: true, base64: h.toBase64(recorded), duration: stopped.duration };
|
|
241
|
+
}
|
|
242
|
+
const channels = decoded.numberOfChannels;
|
|
243
|
+
const frames = decoded.length;
|
|
244
|
+
const sampleRate = decoded.sampleRate;
|
|
245
|
+
const channelData = [];
|
|
246
|
+
for (let c = 0; c < channels; c++)
|
|
247
|
+
channelData.push(decoded.getChannelData(c));
|
|
248
|
+
// Measure before quantising, so the numbers describe the real signal.
|
|
249
|
+
let peak = 0;
|
|
250
|
+
let sumSquares = 0;
|
|
251
|
+
for (const data of channelData) {
|
|
252
|
+
for (let i = 0; i < data.length; i++) {
|
|
253
|
+
const v = Math.abs(data[i]);
|
|
254
|
+
if (v > peak)
|
|
255
|
+
peak = v;
|
|
256
|
+
sumSquares += data[i] * data[i];
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const rms = frames > 0 ? Math.sqrt(sumSquares / (frames * channels)) : 0;
|
|
260
|
+
if (!args.wantWav) {
|
|
261
|
+
await ctx.close();
|
|
262
|
+
return {
|
|
263
|
+
success: true,
|
|
264
|
+
base64: h.toBase64(recorded),
|
|
265
|
+
// The audio's own length, not the wall clock. `stopped.duration`
|
|
266
|
+
// is `Date.now() - startTime`, which counts the time spent
|
|
267
|
+
// starting and stopping the recorder as if it were sound (#437).
|
|
268
|
+
duration: Math.round((frames / sampleRate) * 1000),
|
|
269
|
+
peak,
|
|
270
|
+
rms,
|
|
271
|
+
sampleRate,
|
|
272
|
+
channels,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
// 16-bit PCM WAV: 44-byte RIFF header then interleaved samples.
|
|
276
|
+
const bytesPerSample = 2;
|
|
277
|
+
const blockAlign = channels * bytesPerSample;
|
|
278
|
+
const dataSize = frames * blockAlign;
|
|
279
|
+
const buffer = new ArrayBuffer(44 + dataSize);
|
|
280
|
+
const view = new DataView(buffer);
|
|
281
|
+
h.writeAscii(view, 0, 'RIFF');
|
|
282
|
+
view.setUint32(4, 36 + dataSize, true);
|
|
283
|
+
h.writeAscii(view, 8, 'WAVE');
|
|
284
|
+
h.writeAscii(view, 12, 'fmt ');
|
|
285
|
+
view.setUint32(16, 16, true); // PCM chunk size
|
|
286
|
+
view.setUint16(20, 1, true); // format: PCM
|
|
287
|
+
view.setUint16(22, channels, true);
|
|
288
|
+
view.setUint32(24, sampleRate, true);
|
|
289
|
+
view.setUint32(28, sampleRate * blockAlign, true);
|
|
290
|
+
view.setUint16(32, blockAlign, true);
|
|
291
|
+
view.setUint16(34, 16, true); // bits per sample
|
|
292
|
+
h.writeAscii(view, 36, 'data');
|
|
293
|
+
view.setUint32(40, dataSize, true);
|
|
294
|
+
let offset = 44;
|
|
295
|
+
for (let i = 0; i < frames; i++) {
|
|
296
|
+
for (let c = 0; c < channels; c++) {
|
|
297
|
+
const sample = Math.max(-1, Math.min(1, channelData[c][i]));
|
|
298
|
+
view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true);
|
|
299
|
+
offset += bytesPerSample;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
await ctx.close();
|
|
303
|
+
return {
|
|
304
|
+
success: true,
|
|
305
|
+
base64: h.toBase64(buffer),
|
|
306
|
+
// The audio's own length. `stopped.duration` is
|
|
307
|
+
// `Date.now() - startTime`, so it counted the time spent starting
|
|
308
|
+
// and stopping the recorder as if it were sound — and a caller
|
|
309
|
+
// could compute (bytes - 44) / (sampleRate * channels * 2) from
|
|
310
|
+
// this very response and get a different answer (#437).
|
|
311
|
+
duration: Math.round((frames / sampleRate) * 1000),
|
|
312
|
+
sampleRate,
|
|
313
|
+
channels,
|
|
314
|
+
peak,
|
|
315
|
+
rms,
|
|
316
|
+
};
|
|
317
|
+
};
|
|
318
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strudel functions the local engine cannot provide.
|
|
3
|
+
*
|
|
4
|
+
* `StrudelEngine` builds its context from @strudel/core, @strudel/mini and
|
|
5
|
+
* @strudel/tonal. strudel.cc loads considerably more: sample loaders that
|
|
6
|
+
* fetch over the network, transport controls that need a running
|
|
7
|
+
* scheduler, MIDI/OSC bridges, and the visualisers, which draw to a
|
|
8
|
+
* canvas.
|
|
9
|
+
*
|
|
10
|
+
* Without this list those all fail with errors that blame the pattern:
|
|
11
|
+
*
|
|
12
|
+
* setcpm(120) -> "references unknown identifier 'setcpm'"
|
|
13
|
+
* s("bd").pianoroll() -> "s(...).pianoroll is not a function"
|
|
14
|
+
* samples("github:..") -> "Invalid argument"
|
|
15
|
+
*
|
|
16
|
+
* `setcpm` is the one that shows why this matters — it is a core, very
|
|
17
|
+
* common Strudel function, and telling someone it is an unknown
|
|
18
|
+
* identifier reads as "you typo'd" when the truth is "this validator
|
|
19
|
+
* cannot see it". Local and browser validation disagreeing about the same
|
|
20
|
+
* pattern is bad; disagreeing *and* misattributing the cause is worse.
|
|
21
|
+
*
|
|
22
|
+
* Every name here was verified to fail locally before being listed (#232).
|
|
23
|
+
*
|
|
24
|
+
* @module services/BrowserOnlyFunctions
|
|
25
|
+
*/
|
|
26
|
+
/** Functions that exist on strudel.cc but not in the local engine. */
|
|
27
|
+
export const BROWSER_ONLY_FUNCTIONS = new Map([
|
|
28
|
+
// Transport: need a running scheduler, which the local engine has no
|
|
29
|
+
// equivalent of — it builds patterns, it does not play them.
|
|
30
|
+
['setcpm', 'transport'],
|
|
31
|
+
// Lowercase. It was listed as 'setCps', which Strudel does not have,
|
|
32
|
+
// so `setcps(170/60)` fell through to "references unknown identifier
|
|
33
|
+
// 'setcps'" — the reads-as-a-typo message #232 exists to remove. The
|
|
34
|
+
// mechanism was fine; the key was mis-cased, which is invisible on
|
|
35
|
+
// inspection because the entry looks present (#355).
|
|
36
|
+
['setcps', 'transport'],
|
|
37
|
+
['getcps', 'transport'],
|
|
38
|
+
['hush', 'transport'],
|
|
39
|
+
// Siblings of hush and setcpm, and just as absent locally. Without
|
|
40
|
+
// them, panic() and getcpm() produced the "references unknown
|
|
41
|
+
// identifier — reads as a typo" message that #232 was filed to
|
|
42
|
+
// remove (#343).
|
|
43
|
+
['panic', 'transport'],
|
|
44
|
+
['getcpm', 'transport'],
|
|
45
|
+
['all', 'transport'],
|
|
46
|
+
// Used by two of Strudel's own flagship tunes (flatrave, amensister),
|
|
47
|
+
// so anyone pasting a real pattern in hits it immediately.
|
|
48
|
+
['useRNG', 'transport'],
|
|
49
|
+
// Loaders: fetch sample banks over the network at evaluation time.
|
|
50
|
+
['samples', 'loader'],
|
|
51
|
+
// An instrument shortcut, not a free identifier — it fails as
|
|
52
|
+
// "note(...).piano is not a function" rather than as an unknown
|
|
53
|
+
// name, so the allowlist never saw it. findBrowserOnlyCall matches
|
|
54
|
+
// `.name(` as well as `name(`, so listing it is enough (#355).
|
|
55
|
+
['piano', 'loader'],
|
|
56
|
+
// Visualisers: draw to a canvas.
|
|
57
|
+
['pianoroll', 'visual'],
|
|
58
|
+
['scope', 'visual'],
|
|
59
|
+
['punchcard', 'visual'],
|
|
60
|
+
['spiral', 'visual'],
|
|
61
|
+
['wordfall', 'visual'],
|
|
62
|
+
['animate', 'visual'],
|
|
63
|
+
['initHydra', 'visual'],
|
|
64
|
+
['H', 'visual'],
|
|
65
|
+
['P5', 'visual'],
|
|
66
|
+
// Bridges to hardware or other processes.
|
|
67
|
+
['midi', 'io'],
|
|
68
|
+
['osc', 'io'],
|
|
69
|
+
]);
|
|
70
|
+
const REASON_TEXT = {
|
|
71
|
+
transport: 'controls playback, which the local engine does not run',
|
|
72
|
+
loader: 'loads samples over the network',
|
|
73
|
+
visual: 'draws to a canvas',
|
|
74
|
+
io: 'bridges to MIDI/OSC hardware',
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Explains that a function is real but unavailable locally.
|
|
78
|
+
*
|
|
79
|
+
* @param name - Function the pattern referenced
|
|
80
|
+
* @returns An explanation, or null when the name is not browser-only
|
|
81
|
+
*/
|
|
82
|
+
export function explainBrowserOnly(name) {
|
|
83
|
+
const reason = BROWSER_ONLY_FUNCTIONS.get(name);
|
|
84
|
+
if (reason === undefined)
|
|
85
|
+
return null;
|
|
86
|
+
return (`'${name}' is a real Strudel function, but it ${REASON_TEXT[reason]}, ` +
|
|
87
|
+
'so local validation cannot evaluate it. The pattern may be perfectly ' +
|
|
88
|
+
'valid — check it in the browser with validate_pattern_runtime, or ' +
|
|
89
|
+
'remove this call to validate the rest locally.');
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Finds a browser-only function called in pattern source.
|
|
93
|
+
*
|
|
94
|
+
* Error-message matching is not enough on its own. `samples("github:...")`
|
|
95
|
+
* fails as "Invalid argument", because the transpiler rewrites every
|
|
96
|
+
* double-quoted string into a `mini()` call and a URL is not mini
|
|
97
|
+
* notation — so the failure never mentions `samples` at all. Scanning the
|
|
98
|
+
* source catches those too.
|
|
99
|
+
*
|
|
100
|
+
* @param code - Strudel pattern source
|
|
101
|
+
* @returns The first browser-only function called, or null
|
|
102
|
+
*/
|
|
103
|
+
export function findBrowserOnlyCall(code) {
|
|
104
|
+
// Comments are not calls.
|
|
105
|
+
//
|
|
106
|
+
// The regex only guarded the single character before the name, so a
|
|
107
|
+
// mention inside a comment matched. This is consulted on the ERROR
|
|
108
|
+
// path, so the effect was misattribution rather than false
|
|
109
|
+
// rejection — but it fired ahead of the real diagnosis: an
|
|
110
|
+
// unterminated string with `samples("x")` in a trailing comment was
|
|
111
|
+
// reported as "samples loads over the network" instead of as the
|
|
112
|
+
// syntax error it is (#343).
|
|
113
|
+
const withoutComments = code
|
|
114
|
+
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
|
115
|
+
.replace(/\/\/[^\n]*/g, ' ');
|
|
116
|
+
for (const name of BROWSER_ONLY_FUNCTIONS.keys()) {
|
|
117
|
+
// Called as a function or chained as a method, not merely mentioned
|
|
118
|
+
// inside a string.
|
|
119
|
+
const called = new RegExp(`(^|[^\\w$."'])${name}\\s*\\(|\\.${name}\\s*\\(`);
|
|
120
|
+
if (called.test(withoutComments))
|
|
121
|
+
return name;
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Rewrites an engine error when a browser-only function caused it.
|
|
127
|
+
*
|
|
128
|
+
* Handles both shapes the failure takes: an allowlist rejection for a
|
|
129
|
+
* bare call, and a "not a function" runtime error for a method call.
|
|
130
|
+
*
|
|
131
|
+
* @param message - The original error
|
|
132
|
+
* @returns A clearer message, or the original when nothing applies
|
|
133
|
+
*/
|
|
134
|
+
export function clarifyEngineError(message) {
|
|
135
|
+
const patterns = [
|
|
136
|
+
/unknown identifier '([A-Za-z_$][\w$]*)'/,
|
|
137
|
+
/\.\.\.\)\.([A-Za-z_$][\w$]*) is not a function/,
|
|
138
|
+
/([A-Za-z_$][\w$]*) is not a function/,
|
|
139
|
+
];
|
|
140
|
+
for (const pattern of patterns) {
|
|
141
|
+
const found = pattern.exec(message);
|
|
142
|
+
if (found === null)
|
|
143
|
+
continue;
|
|
144
|
+
const explanation = explainBrowserOnly(found[1]);
|
|
145
|
+
if (explanation !== null)
|
|
146
|
+
return explanation;
|
|
147
|
+
}
|
|
148
|
+
return message;
|
|
149
|
+
}
|