@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.
- package/README.md +212 -780
- 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 +391 -83
- package/dist/server/tools/ai.js +129 -152
- package/dist/server/tools/analysis.js +170 -91
- package/dist/server/tools/capture.js +128 -55
- package/dist/server/tools/compose.js +28 -42
- package/dist/server/tools/diagnostics.js +23 -53
- package/dist/server/tools/editor.js +63 -72
- package/dist/server/tools/generate.js +45 -167
- package/dist/server/tools/history.js +35 -66
- package/dist/server/tools/playback.js +7 -22
- package/dist/server/tools/session.js +41 -47
- package/dist/server/tools/storage.js +173 -50
- package/dist/server/tools/transform.js +136 -199
- 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
|
@@ -13,8 +13,21 @@
|
|
|
13
13
|
* @module services/StrudelEngine
|
|
14
14
|
*/
|
|
15
15
|
import * as strudelCore from '@strudel/core';
|
|
16
|
+
// Imported for its side effects as well as its exports: Strudel's
|
|
17
|
+
// register() attaches .scale()/.voicing()/.transpose() onto
|
|
18
|
+
// Pattern.prototype at module load. Without this, local validation
|
|
19
|
+
// rejected `n("0 2 4").scale("C:minor")` — an idiom that appears
|
|
20
|
+
// throughout this project's own docs — with the misleading
|
|
21
|
+
// "n(...).scale is not a function", while the browser path accepted it
|
|
22
|
+
// (#232). Local and browser validation disagreeing about the same
|
|
23
|
+
// pattern is worse than either being wrong consistently.
|
|
24
|
+
import * as strudelTonal from '@strudel/tonal';
|
|
16
25
|
import { mini } from '@strudel/mini';
|
|
17
26
|
import { transpiler } from '@strudel/transpiler';
|
|
27
|
+
import { assertPatternIsSafe, runPatternCode, PatternSafetyError } from './PatternSandbox.js';
|
|
28
|
+
import { clarifyEngineError, explainBrowserOnly, findBrowserOnlyCall } from './BrowserOnlyFunctions.js';
|
|
29
|
+
import { probeEventDensity } from './EventDensityProbe.js';
|
|
30
|
+
import { TransientError, ValidationError } from '../utils/CategorisedError.js';
|
|
18
31
|
import { calculateComplexity, checkCommonIssues, extractBpm, extractFunctionsUsed, getSuggestionsForError, parseErrorLocation, } from './StrudelEngineHelpers.js';
|
|
19
32
|
/**
|
|
20
33
|
* Local Strudel pattern engine - executes patterns without browser.
|
|
@@ -35,16 +48,61 @@ import { calculateComplexity, checkCommonIssues, extractBpm, extractFunctionsUse
|
|
|
35
48
|
* console.log(`Found ${events.length} events in 2 cycles`);
|
|
36
49
|
* ```
|
|
37
50
|
*/
|
|
51
|
+
/**
|
|
52
|
+
* Strudel exports that compile or evaluate source at runtime.
|
|
53
|
+
*
|
|
54
|
+
* Removed from the sandbox context. Each is a route from pattern text
|
|
55
|
+
* to main-realm execution that no legitimate pattern needs: patterns
|
|
56
|
+
* build and combine Strudel values, they do not evaluate new source.
|
|
57
|
+
*
|
|
58
|
+
* @nist si-10 "Information input validation"
|
|
59
|
+
* @nist ac-6 "Least privilege"
|
|
60
|
+
*/
|
|
61
|
+
/**
|
|
62
|
+
* Most events `queryEvents` will materialize.
|
|
63
|
+
*
|
|
64
|
+
* ~1.5 KB per event in practice, so this bounds a query at roughly
|
|
65
|
+
* 75 MB. Well above any musical pattern: a dense 16-cycle drum pattern
|
|
66
|
+
* is a few thousand events.
|
|
67
|
+
*/
|
|
68
|
+
export const MAX_QUERY_EVENTS = 50_000;
|
|
69
|
+
export const EVALUATOR_EXPORTS = [
|
|
70
|
+
'evaluate',
|
|
71
|
+
'evalScope',
|
|
72
|
+
'safeEval',
|
|
73
|
+
'webaudioEvaluate',
|
|
74
|
+
'transpiler',
|
|
75
|
+
'transpile',
|
|
76
|
+
'Function',
|
|
77
|
+
'eval',
|
|
78
|
+
'require',
|
|
79
|
+
'process',
|
|
80
|
+
'globalThis',
|
|
81
|
+
];
|
|
38
82
|
export class StrudelEngine {
|
|
39
83
|
/** Execution context with Strudel functions */
|
|
40
84
|
context;
|
|
41
85
|
constructor() {
|
|
42
86
|
// Build execution context with all Strudel functions
|
|
43
|
-
|
|
87
|
+
const context = {
|
|
44
88
|
...strudelCore,
|
|
89
|
+
...strudelTonal,
|
|
45
90
|
m: mini,
|
|
46
91
|
mini,
|
|
47
92
|
};
|
|
93
|
+
// Strip the evaluators before anything can reach them.
|
|
94
|
+
//
|
|
95
|
+
// @strudel/core exports its own transpile-and-`new Function` under
|
|
96
|
+
// several names. Spreading the module put them in the sandbox
|
|
97
|
+
// context, and every context key is automatically an allowed
|
|
98
|
+
// identifier — so a pattern could call one directly, with no banned
|
|
99
|
+
// syntax, no member access and no destructuring, and run arbitrary
|
|
100
|
+
// JavaScript in the main realm. The AST allowlist cannot defend a
|
|
101
|
+
// context function that is itself an evaluator; the only fix is for
|
|
102
|
+
// it not to be there.
|
|
103
|
+
for (const name of EVALUATOR_EXPORTS)
|
|
104
|
+
delete context[name];
|
|
105
|
+
this.context = context;
|
|
48
106
|
}
|
|
49
107
|
/**
|
|
50
108
|
* Transpile a Strudel pattern to JavaScript
|
|
@@ -60,6 +118,23 @@ export class StrudelEngine {
|
|
|
60
118
|
* }
|
|
61
119
|
* ```
|
|
62
120
|
*/
|
|
121
|
+
/**
|
|
122
|
+
* Validates then executes pattern code inside the sandbox.
|
|
123
|
+
*
|
|
124
|
+
* Replaces the bare `new Function(...)` this class used at three sites,
|
|
125
|
+
* which executed caller-supplied JavaScript directly in the server
|
|
126
|
+
* process (#229).
|
|
127
|
+
*
|
|
128
|
+
* @param code - Original pattern source, for the AST allowlist
|
|
129
|
+
* @param transpiledCode - Transpiler output, actually executed
|
|
130
|
+
* @returns The Pattern the code produced
|
|
131
|
+
* @throws {PatternSafetyError} When the pattern is rejected pre-execution
|
|
132
|
+
* @nist si-10 "Information input validation"
|
|
133
|
+
*/
|
|
134
|
+
evaluatePattern(code, transpiledCode) {
|
|
135
|
+
assertPatternIsSafe(code, Object.keys(this.context));
|
|
136
|
+
return runPatternCode(transpiledCode, this.context);
|
|
137
|
+
}
|
|
63
138
|
transpile(code) {
|
|
64
139
|
if (!code || code.trim().length === 0) {
|
|
65
140
|
return {
|
|
@@ -86,7 +161,11 @@ export class StrudelEngine {
|
|
|
86
161
|
}
|
|
87
162
|
}
|
|
88
163
|
/**
|
|
89
|
-
* Validate pattern
|
|
164
|
+
* Validate a pattern by building it in the sandbox
|
|
165
|
+
*
|
|
166
|
+
* Note: this *does* execute the pattern (that is the only way to learn
|
|
167
|
+
* whether it produces a Pattern), but inside PatternSandbox rather than
|
|
168
|
+
* in the server process. The JSDoc previously claimed no execution.
|
|
90
169
|
*
|
|
91
170
|
* @param code - Strudel pattern code
|
|
92
171
|
* @returns Validation result with errors and suggestions
|
|
@@ -114,9 +193,17 @@ export class StrudelEngine {
|
|
|
114
193
|
}
|
|
115
194
|
const transpileResult = this.transpile(code);
|
|
116
195
|
if (!transpileResult.success) {
|
|
196
|
+
// A browser-only call can fail here rather than at execution:
|
|
197
|
+
// samples("github:...") dies as "Invalid argument" because the
|
|
198
|
+
// transpiler rewrites every double-quoted string into mini(), and a
|
|
199
|
+
// URL is not mini notation (#232).
|
|
200
|
+
const browserOnlyAtTranspile = findBrowserOnlyCall(code);
|
|
201
|
+
const transpileError = browserOnlyAtTranspile !== null
|
|
202
|
+
? (explainBrowserOnly(browserOnlyAtTranspile) ?? transpileResult.error ?? 'Syntax error')
|
|
203
|
+
: (transpileResult.error ?? 'Syntax error');
|
|
117
204
|
return {
|
|
118
205
|
valid: false,
|
|
119
|
-
errors: [
|
|
206
|
+
errors: [transpileError],
|
|
120
207
|
warnings,
|
|
121
208
|
suggestions: getSuggestionsForError(transpileResult.error || ''),
|
|
122
209
|
errorLocation: transpileResult.errorLocation
|
|
@@ -125,16 +212,36 @@ export class StrudelEngine {
|
|
|
125
212
|
};
|
|
126
213
|
}
|
|
127
214
|
try {
|
|
128
|
-
const
|
|
129
|
-
const pattern = fn(...Object.values(this.context));
|
|
215
|
+
const pattern = this.evaluatePattern(code, transpileResult.transpiledCode);
|
|
130
216
|
if (!pattern || typeof pattern.queryArc !== 'function') {
|
|
131
217
|
errors.push('Code did not produce a valid pattern');
|
|
132
218
|
suggestions.push('Ensure your code returns a pattern (e.g., s("bd"), note("c3"), stack(...))');
|
|
133
219
|
}
|
|
134
220
|
}
|
|
135
221
|
catch (error) {
|
|
136
|
-
|
|
137
|
-
|
|
222
|
+
// A browser-only function is not a broken pattern. Saying
|
|
223
|
+
// "unknown identifier 'setcpm'" about a core Strudel function
|
|
224
|
+
// reads as "you typo'd" when the truth is "this validator cannot
|
|
225
|
+
// see it" (#232).
|
|
226
|
+
// Check the source too: samples("github:...") fails as "Invalid
|
|
227
|
+
// argument" because the transpiler rewrites the URL into mini(),
|
|
228
|
+
// so the error never names the function.
|
|
229
|
+
const browserOnly = findBrowserOnlyCall(code);
|
|
230
|
+
const clarified = browserOnly !== null
|
|
231
|
+
? (explainBrowserOnly(browserOnly) ?? error.message)
|
|
232
|
+
: clarifyEngineError(error.message);
|
|
233
|
+
if (clarified !== error.message) {
|
|
234
|
+
errors.push(clarified);
|
|
235
|
+
}
|
|
236
|
+
else if (error instanceof PatternSafetyError) {
|
|
237
|
+
// Rejected before execution — report it as such rather than as a
|
|
238
|
+
// runtime error, so callers can tell "unsafe" from "buggy" (#229).
|
|
239
|
+
errors.push(`Pattern rejected: ${error.message}`);
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
errors.push(`Runtime error: ${error.message}`);
|
|
243
|
+
suggestions.push(...getSuggestionsForError(error.message));
|
|
244
|
+
}
|
|
138
245
|
}
|
|
139
246
|
checkCommonIssues(code, warnings, suggestions);
|
|
140
247
|
return {
|
|
@@ -167,15 +274,84 @@ export class StrudelEngine {
|
|
|
167
274
|
throw new Error(`Transpilation failed: ${transpileResult.error}`);
|
|
168
275
|
}
|
|
169
276
|
try {
|
|
170
|
-
const
|
|
171
|
-
const pattern = fn(...Object.values(this.context));
|
|
277
|
+
const pattern = this.evaluatePattern(code, transpileResult.transpiledCode);
|
|
172
278
|
if (!pattern || typeof pattern.queryArc !== 'function') {
|
|
173
279
|
throw new Error('Code did not produce a valid pattern');
|
|
174
280
|
}
|
|
281
|
+
// Probe before materializing.
|
|
282
|
+
//
|
|
283
|
+
// queryArc builds the whole array, so a cap applied to its result
|
|
284
|
+
// is applied after the memory is already gone: `s('bd').fast(1e5)`
|
|
285
|
+
// over one cycle is 100,000 events and 153 MB from a 20-character
|
|
286
|
+
// pattern, and a slightly larger multiplier ends in V8
|
|
287
|
+
// FatalProcessOutOfMemory — which no try/catch can intercept, so
|
|
288
|
+
// the server dies and takes every open browser session with it
|
|
289
|
+
// (#307). The cycle range was capped at 16; events per cycle were
|
|
290
|
+
// not capped at all.
|
|
291
|
+
//
|
|
292
|
+
// The single leading window this used to sample had two holes,
|
|
293
|
+
// both measured: it died on patterns dense enough that 1e-4 of the
|
|
294
|
+
// range was itself too big, and it saw nothing at all when the
|
|
295
|
+
// pattern opened with a rest. See EventDensityProbe (#360).
|
|
296
|
+
const span = end - start;
|
|
297
|
+
const verdict = probeEventDensity((begin, until) => pattern.queryArc(begin, until), start, end, { maxEvents: MAX_QUERY_EVENTS });
|
|
298
|
+
if (verdict.kind === 'refuse') {
|
|
299
|
+
throw new ValidationError(`Pattern produces roughly ${String(verdict.projected)} events over ` +
|
|
300
|
+
`${String(span)} cycle(s), above the ${String(MAX_QUERY_EVENTS)} cap. ` +
|
|
301
|
+
'Narrow the range, or reduce the pattern density (a large .fast() ' +
|
|
302
|
+
'multiplier is the usual cause).');
|
|
303
|
+
}
|
|
175
304
|
const haps = pattern.queryArc(start, end);
|
|
305
|
+
// The probe found events; the full query found none. That is not a
|
|
306
|
+
// silent pattern, it is a query that failed and said nothing —
|
|
307
|
+
// Strudel answers a large enough `.fast()` with an internal
|
|
308
|
+
// "Maximum call stack size exceeded" and an EMPTY array, and
|
|
309
|
+
// reporting that as a successful zero-event result tells an agent
|
|
310
|
+
// its working pattern produces nothing (the #276 mistake again).
|
|
311
|
+
// A stricter bar than "saw anything": a pattern whose sub-arc
|
|
312
|
+
// behaviour genuinely differs from its full-arc behaviour (a mask
|
|
313
|
+
// or a span-conditional transform) could show a stray onset in a
|
|
314
|
+
// probe and legitimately none over the whole range. Requiring a
|
|
315
|
+
// real sample keeps that from being reported as a crash.
|
|
316
|
+
if (verdict.observedOnsets >= 32 && verdict.windowsWithOnsets >= 2 && haps.length === 0) {
|
|
317
|
+
throw new TransientError('The pattern produced no events, but sampling it found some — the query ' +
|
|
318
|
+
'failed inside Strudel rather than returning an empty result. A very large ' +
|
|
319
|
+
'.fast() or `*` multiplier is the usual cause; reduce it.');
|
|
320
|
+
}
|
|
321
|
+
// Belt and braces: the projection is an extrapolation from a
|
|
322
|
+
// sample, so a pattern whose density is wildly uneven could still
|
|
323
|
+
// land above the cap. Truncating beats returning 40 MB of JSON.
|
|
324
|
+
if (haps.length > MAX_QUERY_EVENTS) {
|
|
325
|
+
throw new ValidationError(`Pattern produced ${String(haps.length)} events, above the ` +
|
|
326
|
+
`${String(MAX_QUERY_EVENTS)} cap. Narrow the range.`);
|
|
327
|
+
}
|
|
176
328
|
return haps.map((hap) => this.hapToEvent(hap));
|
|
177
329
|
}
|
|
178
330
|
catch (error) {
|
|
331
|
+
// Same clarification validate() applies. Without it, queryEvents and
|
|
332
|
+
// compile still emit "references unknown identifier 'setcpm'" — the
|
|
333
|
+
// exact "reads as a typo" wording #232 was filed to remove — and
|
|
334
|
+
// flatten a pre-execution PatternSafetyError into an execution
|
|
335
|
+
// failure, which it is not.
|
|
336
|
+
const browserOnly = findBrowserOnlyCall(code);
|
|
337
|
+
if (browserOnly !== null) {
|
|
338
|
+
// The caller used a function the local engine cannot run. That
|
|
339
|
+
// is their pattern to change — the message even says which tool
|
|
340
|
+
// to use instead — and it was landing in `internal`, which tells
|
|
341
|
+
// an agent the server is broken and not to retry (#382).
|
|
342
|
+
throw new ValidationError(explainBrowserOnly(browserOnly) ?? error.message);
|
|
343
|
+
}
|
|
344
|
+
// Already categorised: the density caps above throw
|
|
345
|
+
// ValidationError, and flattening them here into a generic
|
|
346
|
+
// "Pattern execution failed" was the third time in one day that a
|
|
347
|
+
// verdict was destroyed one frame above the code that reads it.
|
|
348
|
+
if (error instanceof ValidationError || error instanceof TransientError)
|
|
349
|
+
throw error;
|
|
350
|
+
if (error instanceof PatternSafetyError) {
|
|
351
|
+
// The caller's pattern was refused before it ran. That is their
|
|
352
|
+
// input to change, not an internal failure.
|
|
353
|
+
throw new ValidationError(`Pattern rejected: ${error.message}`);
|
|
354
|
+
}
|
|
179
355
|
throw new Error(`Pattern execution failed: ${error.message}`);
|
|
180
356
|
}
|
|
181
357
|
}
|
|
@@ -192,14 +368,37 @@ export class StrudelEngine {
|
|
|
192
368
|
throw new Error(`Transpilation failed: ${transpileResult.error}`);
|
|
193
369
|
}
|
|
194
370
|
try {
|
|
195
|
-
const
|
|
196
|
-
const pattern = fn(...Object.values(this.context));
|
|
371
|
+
const pattern = this.evaluatePattern(code, transpileResult.transpiledCode);
|
|
197
372
|
if (!pattern || typeof pattern.queryArc !== 'function') {
|
|
198
373
|
throw new Error('Code did not produce a valid pattern');
|
|
199
374
|
}
|
|
200
375
|
return pattern;
|
|
201
376
|
}
|
|
202
377
|
catch (error) {
|
|
378
|
+
// Same clarification validate() applies. Without it, queryEvents and
|
|
379
|
+
// compile still emit "references unknown identifier 'setcpm'" — the
|
|
380
|
+
// exact "reads as a typo" wording #232 was filed to remove — and
|
|
381
|
+
// flatten a pre-execution PatternSafetyError into an execution
|
|
382
|
+
// failure, which it is not.
|
|
383
|
+
const browserOnly = findBrowserOnlyCall(code);
|
|
384
|
+
if (browserOnly !== null) {
|
|
385
|
+
// The caller used a function the local engine cannot run. That
|
|
386
|
+
// is their pattern to change — the message even says which tool
|
|
387
|
+
// to use instead — and it was landing in `internal`, which tells
|
|
388
|
+
// an agent the server is broken and not to retry (#382).
|
|
389
|
+
throw new ValidationError(explainBrowserOnly(browserOnly) ?? error.message);
|
|
390
|
+
}
|
|
391
|
+
// Already categorised: the density caps above throw
|
|
392
|
+
// ValidationError, and flattening them here into a generic
|
|
393
|
+
// "Pattern execution failed" was the third time in one day that a
|
|
394
|
+
// verdict was destroyed one frame above the code that reads it.
|
|
395
|
+
if (error instanceof ValidationError || error instanceof TransientError)
|
|
396
|
+
throw error;
|
|
397
|
+
if (error instanceof PatternSafetyError) {
|
|
398
|
+
// The caller's pattern was refused before it ran. That is their
|
|
399
|
+
// input to change, not an internal failure.
|
|
400
|
+
throw new ValidationError(`Pattern rejected: ${error.message}`);
|
|
401
|
+
}
|
|
203
402
|
throw new Error(`Pattern execution failed: ${error.message}`);
|
|
204
403
|
}
|
|
205
404
|
}
|
|
@@ -225,6 +424,7 @@ export class StrudelEngine {
|
|
|
225
424
|
isStack: /\bstack\s*\(/.test(code),
|
|
226
425
|
functionsUsed: extractFunctionsUsed(code),
|
|
227
426
|
complexity: 0,
|
|
427
|
+
evaluated: false,
|
|
228
428
|
};
|
|
229
429
|
const bpm = extractBpm(code);
|
|
230
430
|
if (bpm !== undefined) {
|
|
@@ -246,14 +446,20 @@ export class StrudelEngine {
|
|
|
246
446
|
isStack: metadata.isStack,
|
|
247
447
|
codeLength: code.length,
|
|
248
448
|
}, events.length);
|
|
449
|
+
metadata.evaluated = true;
|
|
249
450
|
}
|
|
250
|
-
catch {
|
|
451
|
+
catch (error) {
|
|
452
|
+
// Complexity from the static signals only. eventsPerCycle and
|
|
453
|
+
// uniqueValues stay at their defaults, and `evaluated: false` says
|
|
454
|
+
// so — the caller can tell "I could not run this" from "this
|
|
455
|
+
// produces nothing", which sharing the value 0 made impossible.
|
|
251
456
|
metadata.complexity = calculateComplexity({
|
|
252
457
|
uniqueValues: metadata.uniqueValues,
|
|
253
458
|
functionsUsed: metadata.functionsUsed,
|
|
254
459
|
isStack: metadata.isStack,
|
|
255
460
|
codeLength: code.length,
|
|
256
461
|
});
|
|
462
|
+
metadata.evaluationError = clarifyEngineError(String(error?.message ?? error));
|
|
257
463
|
}
|
|
258
464
|
return metadata;
|
|
259
465
|
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*
|
|
9
9
|
* @module services/StrudelEngineHelpers
|
|
10
10
|
*/
|
|
11
|
+
import { declaredBpm } from '../utils/Tempo.js';
|
|
11
12
|
/**
|
|
12
13
|
* Parse an error location from the assortment of shapes the Strudel
|
|
13
14
|
* stack produces:
|
|
@@ -101,14 +102,15 @@ export function checkCommonIssues(code, warnings, suggestions) {
|
|
|
101
102
|
}
|
|
102
103
|
}
|
|
103
104
|
/**
|
|
104
|
-
* Extract BPM
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
105
|
+
* Extract the BPM a pattern declares, or undefined when it declares none.
|
|
106
|
+
*
|
|
107
|
+
* Thin wrapper over the canonical parser, kept because callers import it
|
|
108
|
+
* from here. It used to carry its own regex, which #341 taught to read
|
|
109
|
+
* `setcpm(130/4)` — a lesson the two other copies of that regex never
|
|
110
|
+
* got (#397).
|
|
108
111
|
*/
|
|
109
112
|
export function extractBpm(code) {
|
|
110
|
-
|
|
111
|
-
return match ? parseFloat(match[1]) : undefined;
|
|
113
|
+
return declaredBpm(code);
|
|
112
114
|
}
|
|
113
115
|
/**
|
|
114
116
|
* Extract unique function-call names from pattern code. Matches the
|
|
@@ -134,10 +136,24 @@ export function extractFunctionsUsed(code) {
|
|
|
134
136
|
* with truly dense patterns reaching 1.0. Changing them shifts every
|
|
135
137
|
* downstream "complexity" report — bump cautiously.
|
|
136
138
|
*/
|
|
139
|
+
/**
|
|
140
|
+
* Code length at which the length term reaches its maximum.
|
|
141
|
+
*
|
|
142
|
+
* The term was `codeLength / 500` UNCAPPED while the other four factors
|
|
143
|
+
* were all `Math.min(..., 1)`. Any pattern past ~1700 characters
|
|
144
|
+
* therefore pinned the whole score to 1.0 regardless of what was in it,
|
|
145
|
+
* and the shipped corpus spans 388 to 7216 characters — an 18x range
|
|
146
|
+
* that scored identically. The docstring promised "typical patterns
|
|
147
|
+
* around 0.3-0.7 with truly dense patterns reaching 1.0" (#341).
|
|
148
|
+
*
|
|
149
|
+
* 2000 puts the shipped examples across a usable spread rather than
|
|
150
|
+
* bunched at either end.
|
|
151
|
+
*/
|
|
152
|
+
const LENGTH_SATURATION = 2000;
|
|
137
153
|
export function calculateComplexity(input, eventCount) {
|
|
138
154
|
if (eventCount === undefined) {
|
|
139
155
|
return Math.min((input.functionsUsed.length / 10) * 0.5 +
|
|
140
|
-
(input.codeLength /
|
|
156
|
+
Math.min(input.codeLength / LENGTH_SATURATION, 1) * 0.3 +
|
|
141
157
|
(input.isStack ? 0.2 : 0), 1);
|
|
142
158
|
}
|
|
143
159
|
const factors = [
|
|
@@ -145,7 +161,7 @@ export function calculateComplexity(input, eventCount) {
|
|
|
145
161
|
Math.min(input.uniqueValues.length / 8, 1) * 0.2,
|
|
146
162
|
Math.min(input.functionsUsed.length / 10, 1) * 0.3,
|
|
147
163
|
input.isStack ? 0.1 : 0,
|
|
148
|
-
(input.codeLength /
|
|
164
|
+
Math.min(input.codeLength / LENGTH_SATURATION, 1) * 0.1,
|
|
149
165
|
];
|
|
150
166
|
return Math.min(factors.reduce((a, b) => a + b, 0), 1);
|
|
151
167
|
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which styles exist, and what a caller actually gets when they ask for
|
|
3
|
+
* one that doesn't.
|
|
4
|
+
*
|
|
5
|
+
* This lives apart from PatternGenerator on purpose. It is pure data
|
|
6
|
+
* plus one lookup — the tool layer needs it to *describe* a result
|
|
7
|
+
* honestly, which is a different job from generating the result. Keeping
|
|
8
|
+
* it separate also means a test that mocks the generator still gets real
|
|
9
|
+
* style facts rather than `undefined`.
|
|
10
|
+
*
|
|
11
|
+
* Background (#279): an unknown genre silently fell back to techno while
|
|
12
|
+
* every layer above echoed the requested style back, so an agent asking
|
|
13
|
+
* for vaporwave was told it received vaporwave — in the metadata, the
|
|
14
|
+
* message, and the pattern's own header comment.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Alias → canonical style.
|
|
18
|
+
*
|
|
19
|
+
* Three near-copies of this table lived inline in generateDrumPattern /
|
|
20
|
+
* generateBassline / generateCompletePattern, and they had drifted: the
|
|
21
|
+
* bassline copy was missing `atmospheric`, `flying_lotus`, `alchemist`,
|
|
22
|
+
* `daringer` and `hitboy`, so compose("flying_lotus") produced trip-hop
|
|
23
|
+
* drums over a techno bassline.
|
|
24
|
+
*/
|
|
25
|
+
export const STYLE_ALIASES = {
|
|
26
|
+
liquid_dnb: 'intelligent_dnb',
|
|
27
|
+
atmospheric_dnb: 'intelligent_dnb',
|
|
28
|
+
intelligent: 'intelligent_dnb',
|
|
29
|
+
liquid: 'intelligent_dnb',
|
|
30
|
+
atmospheric: 'intelligent_dnb',
|
|
31
|
+
bukem: 'intelligent_dnb',
|
|
32
|
+
triphop: 'trip_hop',
|
|
33
|
+
portishead: 'trip_hop',
|
|
34
|
+
massive_attack: 'trip_hop',
|
|
35
|
+
flying_lotus: 'trip_hop',
|
|
36
|
+
'drum and bass': 'dnb',
|
|
37
|
+
'drum-and-bass': 'dnb',
|
|
38
|
+
boombap: 'boom_bap',
|
|
39
|
+
golden_era: 'boom_bap',
|
|
40
|
+
premier: 'boom_bap',
|
|
41
|
+
alchemist: 'boom_bap',
|
|
42
|
+
daringer: 'boom_bap',
|
|
43
|
+
hitboy: 'boom_bap',
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Styles with a drum pattern of their own. Anything else — `jazz`
|
|
47
|
+
* included, despite being documented as a style — takes techno drums.
|
|
48
|
+
*/
|
|
49
|
+
export const DRUM_STYLES = [
|
|
50
|
+
'techno', 'house', 'dnb', 'breakbeat', 'trap', 'jungle',
|
|
51
|
+
'ambient', 'experimental', 'intelligent_dnb', 'trip_hop', 'boom_bap',
|
|
52
|
+
];
|
|
53
|
+
/**
|
|
54
|
+
* Styles with a bassline of their own. A different set from
|
|
55
|
+
* `DRUM_STYLES`, which is the whole problem: `jazz` has a bassline but
|
|
56
|
+
* no drums, and `breakbeat` / `trap` / `jungle` / `experimental` have
|
|
57
|
+
* drums but no bassline. Reporting one collapsed `style` had to lie in
|
|
58
|
+
* one direction or the other (#294).
|
|
59
|
+
*/
|
|
60
|
+
export const BASS_STYLES = [
|
|
61
|
+
'techno', 'house', 'dnb', 'acid', 'dub', 'funk', 'jazz', 'ambient',
|
|
62
|
+
'intelligent_dnb', 'trip_hop', 'boom_bap',
|
|
63
|
+
];
|
|
64
|
+
/**
|
|
65
|
+
* Resolves a caller's style to one that actually has drums.
|
|
66
|
+
*
|
|
67
|
+
* @param style - Style or alias the caller asked for
|
|
68
|
+
* @returns The request, what will really be used, and whether they match
|
|
69
|
+
* @example
|
|
70
|
+
* resolveDrumStyle('bukem'); // → intelligent_dnb, supported
|
|
71
|
+
* resolveDrumStyle('vaporwave'); // → techno, not supported
|
|
72
|
+
*/
|
|
73
|
+
export function resolveDrumStyle(style) {
|
|
74
|
+
const lower = String(style ?? '').toLowerCase();
|
|
75
|
+
// hasOwn, not `?? lower`: STYLE_ALIASES is a plain object literal, so
|
|
76
|
+
// STYLE_ALIASES['constructor'] returns Object and STYLE_ALIASES['__proto__']
|
|
77
|
+
// returns the prototype — neither is nullish, so `??` never fires (#295).
|
|
78
|
+
const resolved = Object.hasOwn(STYLE_ALIASES, lower) ? STYLE_ALIASES[lower] : lower;
|
|
79
|
+
const supported = DRUM_STYLES.includes(resolved);
|
|
80
|
+
return { requested: style, resolved: supported ? resolved : 'techno', supported };
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Resolves a caller's style for the bassline layer.
|
|
84
|
+
*
|
|
85
|
+
* @param style - Style or alias the caller asked for
|
|
86
|
+
* @returns The request, the bassline that will really play, and whether they match
|
|
87
|
+
*/
|
|
88
|
+
export function resolveBassStyle(style) {
|
|
89
|
+
const lower = String(style ?? '').toLowerCase();
|
|
90
|
+
const resolved = Object.hasOwn(STYLE_ALIASES, lower) ? STYLE_ALIASES[lower] : lower;
|
|
91
|
+
const supported = BASS_STYLES.includes(resolved);
|
|
92
|
+
return { requested: style, resolved: supported ? resolved : 'techno', supported };
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Describes what a requested style really produces, layer by layer.
|
|
96
|
+
*
|
|
97
|
+
* `compose` assembles a pattern from four independently-resolved
|
|
98
|
+
* layers, each falling back on its own. One `style` field cannot
|
|
99
|
+
* describe that: reporting the drum resolution called
|
|
100
|
+
* compose({style:'jazz'}) "techno" even though the bassline, Dorian
|
|
101
|
+
* scale and jazz chord progression are all genuinely jazz, while
|
|
102
|
+
* `breakbeat` reported itself supported with a techno bassline
|
|
103
|
+
* underneath (#294).
|
|
104
|
+
*
|
|
105
|
+
* @param style - Style or alias the caller asked for
|
|
106
|
+
* @returns What each layer will actually use, and which ones were substituted
|
|
107
|
+
* @example
|
|
108
|
+
* resolveLayers('jazz');
|
|
109
|
+
* // layers: { drums: 'techno', bass: 'jazz', chords: 'jazz', scale: 'dorian' }
|
|
110
|
+
* // substituted: ['drums']
|
|
111
|
+
*/
|
|
112
|
+
export function resolveLayers(style) {
|
|
113
|
+
const drums = resolveDrumStyle(style);
|
|
114
|
+
const bass = resolveBassStyle(style);
|
|
115
|
+
const canonical = drums.supported ? drums.resolved : bass.resolved;
|
|
116
|
+
// Mirrors PatternGenerator.generateCompletePattern: chord family and
|
|
117
|
+
// scale are derived from the resolved style, not looked up, so they
|
|
118
|
+
// never "fall back" — but the caller still wants to know what they are.
|
|
119
|
+
const lower = String(style ?? '').toLowerCase();
|
|
120
|
+
const resolved = Object.hasOwn(STYLE_ALIASES, lower) ? STYLE_ALIASES[lower] : lower;
|
|
121
|
+
const chords = resolved === 'jazz' ? 'jazz' : resolved === 'house' ? 'pop'
|
|
122
|
+
: resolved === 'techno' ? 'edm' : 'pop';
|
|
123
|
+
const scale = resolved === 'jazz' ? 'dorian' : 'minor';
|
|
124
|
+
const substituted = [];
|
|
125
|
+
if (!drums.supported)
|
|
126
|
+
substituted.push('drums');
|
|
127
|
+
if (!bass.supported)
|
|
128
|
+
substituted.push('bass');
|
|
129
|
+
void canonical;
|
|
130
|
+
return {
|
|
131
|
+
requested: style,
|
|
132
|
+
layers: { drums: drums.resolved, bass: bass.resolved, chords, scale },
|
|
133
|
+
substituted,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Default BPM per canonical style.
|
|
138
|
+
*
|
|
139
|
+
* This lived in compose.ts and disagreed with the style tables in both
|
|
140
|
+
* directions (#296). It had no entry for `intelligent_dnb`, `trip_hop`
|
|
141
|
+
* or `boom_bap`, so those three fell to 120 — and because compose
|
|
142
|
+
* passes that 120 down, the generator's own `bpm || 170` fallback was
|
|
143
|
+
* never reached: compose({style:'bukem'}) produced
|
|
144
|
+
* "// Intelligent DnB in C at 120 BPM". Meanwhile it carried
|
|
145
|
+
* 'drum and bass' at 174, a style STYLE_ALIASES had never heard of, so
|
|
146
|
+
* that request became 174-BPM techno.
|
|
147
|
+
*
|
|
148
|
+
* Lookup happens after alias resolution now, so `bukem` gets
|
|
149
|
+
* intelligent_dnb's tempo. A test asserts every key here is a style the
|
|
150
|
+
* registry actually knows.
|
|
151
|
+
*/
|
|
152
|
+
export const TEMPO_BY_STYLE = {
|
|
153
|
+
techno: 130,
|
|
154
|
+
house: 125,
|
|
155
|
+
dnb: 174,
|
|
156
|
+
ambient: 80,
|
|
157
|
+
trap: 140,
|
|
158
|
+
jungle: 160,
|
|
159
|
+
jazz: 110,
|
|
160
|
+
experimental: 120,
|
|
161
|
+
breakbeat: 130,
|
|
162
|
+
// The three specialized genres, which had no entry at all.
|
|
163
|
+
intelligent_dnb: 170,
|
|
164
|
+
trip_hop: 90,
|
|
165
|
+
boom_bap: 92,
|
|
166
|
+
// Styles with a bassline but no drums of their own.
|
|
167
|
+
acid: 130,
|
|
168
|
+
dub: 140,
|
|
169
|
+
funk: 110,
|
|
170
|
+
// Tempo-only: see TEMPO_ONLY_STYLES.
|
|
171
|
+
dubstep: 140,
|
|
172
|
+
trance: 138,
|
|
173
|
+
garage: 130,
|
|
174
|
+
electro: 128,
|
|
175
|
+
downtempo: 90,
|
|
176
|
+
idm: 115,
|
|
177
|
+
};
|
|
178
|
+
/**
|
|
179
|
+
* Styles that have a sensible tempo but no drums or bassline of their
|
|
180
|
+
* own. A caller asking for one gets that tempo with techno material,
|
|
181
|
+
* and `resolveLayers` reports both layers as substituted — so the
|
|
182
|
+
* result is honest, and these are listed rather than left to look like
|
|
183
|
+
* an oversight.
|
|
184
|
+
*
|
|
185
|
+
* Anything here is a candidate for real content; adding it to
|
|
186
|
+
* DRUM_STYLES or BASS_STYLES without the content will fail the registry
|
|
187
|
+
* accuracy test in PerLayerStyleReporting.
|
|
188
|
+
*/
|
|
189
|
+
export const TEMPO_ONLY_STYLES = [
|
|
190
|
+
'dubstep', 'trance', 'garage', 'electro', 'downtempo', 'idm',
|
|
191
|
+
];
|
|
192
|
+
/** Default BPM for a style, resolving aliases first. */
|
|
193
|
+
export function defaultTempoFor(style) {
|
|
194
|
+
const lower = String(style ?? '').toLowerCase();
|
|
195
|
+
const resolved = Object.hasOwn(STYLE_ALIASES, lower) ? STYLE_ALIASES[lower] : lower;
|
|
196
|
+
// hasOwn throughout, for the reason in #295: TEMPO_BY_STYLE is a plain
|
|
197
|
+
// object literal, so TEMPO_BY_STYLE['__proto__'] returns the prototype
|
|
198
|
+
// and `?? 120` never fires.
|
|
199
|
+
if (Object.hasOwn(TEMPO_BY_STYLE, resolved))
|
|
200
|
+
return TEMPO_BY_STYLE[resolved];
|
|
201
|
+
if (Object.hasOwn(TEMPO_BY_STYLE, lower))
|
|
202
|
+
return TEMPO_BY_STYLE[lower];
|
|
203
|
+
return 120;
|
|
204
|
+
}
|