@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
@@ -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
- this.context = {
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 syntax without execution
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: [transpileResult.error || 'Syntax error'],
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 fn = new Function(...Object.keys(this.context), transpileResult.transpiledCode);
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
- errors.push(`Runtime error: ${error.message}`);
137
- suggestions.push(...getSuggestionsForError(error.message));
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 fn = new Function(...Object.keys(this.context), transpileResult.transpiledCode);
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 fn = new Function(...Object.keys(this.context), transpileResult.transpiledCode);
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 from a `setcpm(<number>)` call. Returns undefined when
105
- * absent — Strudel's default tempo is documented elsewhere; this helper
106
- * stays narrow (no fallback) so callers can distinguish "not set" from
107
- * "set to default".
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
- const match = code.match(/setcpm\s*\(\s*(\d+(?:\.\d+)?)\s*\)/);
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 / 500) * 0.3 +
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 / 500) * 0.1,
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
+ }