@williamzujkowski/live-coding-music-mcp 3.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +212 -780
  2. package/dist/AudioAnalyzer.js +1294 -91
  3. package/dist/PatternStore.js +276 -23
  4. package/dist/StrudelController.js +388 -56
  5. package/dist/server/resources.js +27 -20
  6. package/dist/server/server.js +391 -83
  7. package/dist/server/tools/ai.js +129 -152
  8. package/dist/server/tools/analysis.js +170 -91
  9. package/dist/server/tools/capture.js +128 -55
  10. package/dist/server/tools/compose.js +28 -42
  11. package/dist/server/tools/diagnostics.js +23 -53
  12. package/dist/server/tools/editor.js +63 -72
  13. package/dist/server/tools/generate.js +45 -167
  14. package/dist/server/tools/history.js +35 -66
  15. package/dist/server/tools/playback.js +7 -22
  16. package/dist/server/tools/session.js +41 -47
  17. package/dist/server/tools/storage.js +173 -50
  18. package/dist/server/tools/transform.js +136 -199
  19. package/dist/server/tools/types.js +209 -14
  20. package/dist/services/AudioCaptureService.js +302 -33
  21. package/dist/services/AudioExportService.js +318 -0
  22. package/dist/services/BrowserOnlyFunctions.js +149 -0
  23. package/dist/services/EventDensityProbe.js +324 -0
  24. package/dist/services/GeminiService.js +221 -36
  25. package/dist/services/IsolatedEngineRunner.js +308 -0
  26. package/dist/services/IsolatedStrudelEngine.js +135 -0
  27. package/dist/services/LocalPatternEngine.js +16 -0
  28. package/dist/services/MIDIExportService.js +765 -71
  29. package/dist/services/MIDIImportService.js +474 -0
  30. package/dist/services/MusicTheory.js +85 -13
  31. package/dist/services/PatternGenerator.js +168 -85
  32. package/dist/services/PatternSandbox.js +359 -0
  33. package/dist/services/SessionManager.js +338 -65
  34. package/dist/services/StrudelEngine.js +218 -12
  35. package/dist/services/StrudelEngineHelpers.js +24 -8
  36. package/dist/services/StyleRegistry.js +204 -0
  37. package/dist/services/Transferable.js +114 -0
  38. package/dist/services/ai/AiTransport.js +56 -0
  39. package/dist/services/ai/AudioMeasurements.js +155 -0
  40. package/dist/services/ai/CliTransport.js +256 -0
  41. package/dist/services/engineChild.js +83 -0
  42. package/dist/services/engineChildPath.js +28 -0
  43. package/dist/utils/CategorisedError.js +59 -0
  44. package/dist/utils/ErrorRecovery.js +130 -110
  45. package/dist/utils/InputValidator.js +55 -7
  46. package/dist/utils/PatternValidator.js +136 -17
  47. package/dist/utils/SafePath.js +112 -0
  48. package/dist/utils/ServerConfig.js +248 -0
  49. package/dist/utils/TableLookup.js +41 -0
  50. package/dist/utils/Tempo.js +143 -0
  51. package/package.json +17 -5
  52. package/dist/services/__mocks__/StrudelEngine.js +0 -157
  53. package/dist/types/index.js +0 -1
@@ -1,18 +1,18 @@
1
1
  /**
2
2
  * ai domain — AI-powered feedback, pattern suggestion, and jamming.
3
3
  *
4
- * Owns (3 tools): get_pattern_feedback, suggest_pattern_from_audio,
5
- * jam_with.
4
+ * Owns one consolidated tool (`ai_assist(task=feedback|suggest|jam)`) —
5
+ * all three tasks share Gemini's rate limiting and auth.
6
6
  *
7
- * Per the #110 audit, these collapse into a single `ai_assist(task=...)`
8
- * tool — they all share Gemini's rate limiting and auth, so the merge
9
- * is natural. Deferred until external contracts stabilize.
10
- *
11
- * `jam_with` carries six private helpers that analyze the current
7
+ * The `jam` task carries six private helpers that analyze the current
12
8
  * pattern (tempo/key/style detection, layer detection, merge logic)
13
- * and that are used only by this tool. They live here.
9
+ * and that are used only here. They live in this module.
14
10
  */
11
+ import { empty, err, withStashField } from './types.js';
12
+ import { AiRateLimitError } from '../../services/ai/AiTransport.js';
15
13
  import { Logger } from '../../utils/Logger.js';
14
+ import { lookup } from '../../utils/TableLookup.js';
15
+ import { declaredBpm } from '../../utils/Tempo.js';
16
16
  const logger = new Logger();
17
17
  const SESSION_ID_PROP = {
18
18
  session_id: {
@@ -29,7 +29,8 @@ export const tools = [
29
29
  'task=jam generates a fresh layer (drums/bass/melody/pad/texture) and merges it into the current pattern, then auto-plays. ' +
30
30
  'All three share Gemini auth + rate limiting. ' +
31
31
  'Example: ai_assist({ task: "jam", layer: "bass" }). ' +
32
- 'Requires GEMINI_API_KEY env var. For non-AI pattern generation use generate_part; for full compositions use compose.',
32
+ 'Needs GEMINI_API_KEY, or a logged-in claude/agy/codex CLI on PATH (#252). ' +
33
+ 'For non-AI pattern generation use generate_part; for full compositions use compose.',
33
34
  inputSchema: {
34
35
  type: 'object',
35
36
  properties: {
@@ -56,52 +57,6 @@ export const tools = [
56
57
  required: ['task'],
57
58
  },
58
59
  },
59
- {
60
- name: 'get_pattern_feedback',
61
- description: '[DEPRECATED — use ai_assist({ task: "feedback" }) instead] Get AI-powered creative feedback on the current pattern using Google Gemini.',
62
- inputSchema: {
63
- type: 'object',
64
- properties: {
65
- includeAudio: { type: 'boolean', description: 'Include audio analysis (plays pattern briefly). Default: false' },
66
- style: { type: 'string', description: 'Optional style hint for context (e.g., "techno", "ambient")' },
67
- ...SESSION_ID_PROP,
68
- },
69
- },
70
- },
71
- {
72
- name: 'suggest_pattern_from_audio',
73
- description: '[DEPRECATED — use ai_assist({ task: "suggest" }) instead] Analyze playing audio and suggest a complementary Strudel pattern.',
74
- inputSchema: {
75
- type: 'object',
76
- properties: {
77
- style: { type: 'string', description: 'Optional style hint (e.g., "ambient", "techno", "jazz")' },
78
- role: {
79
- type: 'string',
80
- enum: ['complement', 'bassline', 'melody', 'percussion'],
81
- description: 'What role the suggested pattern should fill. Default: complement',
82
- },
83
- ...SESSION_ID_PROP,
84
- },
85
- },
86
- },
87
- {
88
- name: 'jam_with',
89
- description: '[DEPRECATED — use ai_assist({ task: "jam" }) instead] AI generates a complementary layer to jam with your pattern.',
90
- inputSchema: {
91
- type: 'object',
92
- properties: {
93
- layer: {
94
- type: 'string',
95
- enum: ['drums', 'bass', 'melody', 'pad', 'texture'],
96
- description: 'Type of layer to add: drums, bass, melody, pad, or texture',
97
- },
98
- style_hint: { type: 'string', description: 'Optional style guidance (e.g., "funky", "minimal", "atmospheric")' },
99
- auto_play: { type: 'boolean', description: 'Start playback after adding layer (default: true)' },
100
- ...SESSION_ID_PROP,
101
- },
102
- required: ['layer'],
103
- },
104
- },
105
60
  ];
106
61
  export const toolNames = new Set(tools.map(t => t.name));
107
62
  export async function execute(name, args, ctx) {
@@ -117,12 +72,6 @@ export async function execute(name, args, ctx) {
117
72
  throw new Error(`Invalid task: ${t}. Must be one of: feedback, suggest, jam`);
118
73
  }
119
74
  }
120
- case 'get_pattern_feedback':
121
- return await getPatternFeedback(args?.includeAudio || false, args?.style, ctx, sid);
122
- case 'suggest_pattern_from_audio':
123
- return await suggestPatternFromAudio(args?.style, args?.role || 'complement', ctx, sid);
124
- case 'jam_with':
125
- return await jamWith(args.layer, args.style_hint, args.auto_play, ctx, sid);
126
75
  default:
127
76
  throw new Error(`ai module does not handle tool: ${name}`);
128
77
  }
@@ -134,7 +83,8 @@ async function getPatternFeedback(includeAudio, style, ctx, sid) {
134
83
  if (!ctx.geminiService.isAvailable()) {
135
84
  return {
136
85
  gemini_available: false,
137
- error: 'Gemini API not configured. Set GEMINI_API_KEY environment variable to enable AI feedback.',
86
+ error: 'No AI transport available. Set GEMINI_API_KEY, or log in to one of the '
87
+ + 'claude/agy/codex CLIs on PATH (#252).',
138
88
  };
139
89
  }
140
90
  const pattern = await ctx.getCurrentPatternSafe(sid);
@@ -148,19 +98,40 @@ async function getPatternFeedback(includeAudio, style, ctx, sid) {
148
98
  catch (error) {
149
99
  const message = error instanceof Error ? error.message : String(error);
150
100
  logger.error('Pattern feedback failed', { error: message });
151
- if (message?.includes('rate limit') || message?.includes('Rate limit')) {
152
- return { gemini_available: true, error: message };
101
+ // By type, not by phrase. `AiRateLimitError` is what the limiter
102
+ // throws; matching the words meant a reworded message would fall
103
+ // through to "Pattern analysis failed" and lose the one thing a
104
+ // caller can act on — that waiting works (#380, #392).
105
+ if (error instanceof AiRateLimitError) {
106
+ return err('transient', message, { isRetryable: true });
153
107
  }
154
108
  result.error = `Pattern analysis failed: ${message}`;
155
109
  }
156
110
  if (includeAudio && (sid || ctx.isInitialized())) {
157
111
  try {
158
- const audioBlob = await captureAudioSampleForFeedback(ctx, sid);
159
- if (audioBlob) {
160
- result.audio_analysis = await ctx.geminiService.analyzeAudio(audioBlob, { style, duration: 5 });
112
+ const sample = await captureAudioSampleForFeedback(ctx, sid);
113
+ if (sample === null) {
114
+ logger.warn('Audio capture returned no data');
115
+ if (!result.error)
116
+ result.error = 'Audio capture returned no data.';
117
+ }
118
+ else if (sample.silent) {
119
+ // Sending silence to Gemini does not fail — it returns a confident
120
+ // mood/style/energy for audio that is not there. Feedback silently
121
+ // decoupled from the audio is worse than no feedback, so refuse.
122
+ result.audio_analysis_skipped =
123
+ 'Captured audio was silent, so no feedback was requested. ' +
124
+ 'Is a pattern playing? Call playback({ action: "play" }) first.';
125
+ logger.warn('Skipping Gemini audio analysis: capture was silent', {
126
+ peak: sample.peak,
127
+ });
161
128
  }
162
129
  else {
163
- logger.warn('Audio capture returned no data');
130
+ // Measurements, not the waveform. Every installed CLI answers
131
+ // "CANNOT DECODE AUDIO", and agy will confabulate detailed
132
+ // analysis of audio it never examined rather than admit it.
133
+ result.audio_analysis = await ctx.geminiService.analyzeAudioMeasurements(sample.measurements, pattern, { style, duration: FEEDBACK_SAMPLE_MS / 1000 });
134
+ result.audio_levels = { peak: sample.peak, rms: sample.rms };
164
135
  }
165
136
  }
166
137
  catch (error) {
@@ -177,6 +148,8 @@ async function getPatternFeedback(includeAudio, style, ctx, sid) {
177
148
  }
178
149
  return result;
179
150
  }
151
+ /** Seconds of audio sent to Gemini for feedback. */
152
+ const FEEDBACK_SAMPLE_MS = 5000;
180
153
  /**
181
154
  * Captures a brief audio sample for Gemini analysis. Different from the
182
155
  * `capture_audio_sample` tool — this one connects directly to the
@@ -189,62 +162,31 @@ async function captureAudioSampleForFeedback(ctx, sid) {
189
162
  logger.warn('Cannot capture audio: controller page not available');
190
163
  return null;
191
164
  }
192
- try {
193
- const audioData = await page.evaluate(/* istanbul ignore next */ async () => {
194
- return new Promise((resolve) => {
195
- const analyzer = window.strudelAudioAnalyzer;
196
- if (!analyzer || !analyzer.analyser) {
197
- resolve(null);
198
- return;
199
- }
200
- try {
201
- const audioCtx = analyzer.analyser.context;
202
- const destination = audioCtx.createMediaStreamDestination();
203
- analyzer.analyser.connect(destination);
204
- const mediaRecorder = new MediaRecorder(destination.stream, { mimeType: 'audio/webm;codecs=opus' });
205
- const chunks = [];
206
- mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0)
207
- chunks.push(e.data); };
208
- mediaRecorder.onstop = async () => {
209
- try {
210
- analyzer.analyser.disconnect(destination);
211
- }
212
- catch { /* already disconnected */ }
213
- if (chunks.length === 0) {
214
- resolve(null);
215
- return;
216
- }
217
- const blob = new Blob(chunks, { type: 'audio/webm' });
218
- const reader = new FileReader();
219
- reader.onloadend = () => {
220
- const base64 = reader.result;
221
- resolve(base64.split(',')[1] || null);
222
- };
223
- reader.onerror = () => resolve(null);
224
- reader.readAsDataURL(blob);
225
- };
226
- mediaRecorder.start();
227
- setTimeout(() => { if (mediaRecorder.state === 'recording')
228
- mediaRecorder.stop(); }, 5000);
229
- }
230
- catch {
231
- resolve(null);
232
- }
233
- });
234
- });
235
- if (!audioData)
236
- return null;
237
- const binaryString = atob(audioData);
238
- const bytes = new Uint8Array(binaryString.length);
239
- for (let i = 0; i < binaryString.length; i++)
240
- bytes[i] = binaryString.charCodeAt(i);
241
- return new Blob([bytes], { type: 'audio/webm' });
242
- }
243
- catch (error) {
244
- const message = error instanceof Error ? error.message : String(error);
245
- logger.error('Audio capture failed', { error: message });
165
+ // Ensures the recorder is injected for this session before exporting.
166
+ await ctx.getAudioCaptureService(sid);
167
+ const result = await ctx.audioExportService.exportAudio(page, {
168
+ duration: FEEDBACK_SAMPLE_MS,
169
+ format: 'wav',
170
+ output: 'base64',
171
+ });
172
+ if (!result.success || result.audio === undefined) {
173
+ logger.warn('Audio capture returned no data', { error: result.error });
246
174
  return null;
247
175
  }
176
+ // The bytes are deliberately discarded. No model in play can decode
177
+ // audio, so what gets sent is the measurement set, not the waveform.
178
+ return {
179
+ measurements: {
180
+ durationMs: result.duration,
181
+ peak: result.peak,
182
+ rms: result.rms,
183
+ sampleRate: result.sampleRate,
184
+ channels: result.channels,
185
+ },
186
+ peak: result.peak,
187
+ rms: result.rms,
188
+ silent: result.silent === true,
189
+ };
248
190
  }
249
191
  // ---------------------------------------------------------------------------
250
192
  // suggest_pattern_from_audio
@@ -254,7 +196,10 @@ async function suggestPatternFromAudio(style, role, ctx, sid) {
254
196
  return { error: 'Browser not initialized. Run init and play a pattern first.' };
255
197
  }
256
198
  if (!ctx.geminiService.isAvailable()) {
257
- return { error: 'Gemini API not configured. Set GEMINI_API_KEY to enable AI features.' };
199
+ return {
200
+ error: 'No AI transport available. Set GEMINI_API_KEY, or log in to one of the '
201
+ + 'claude/agy/codex CLIs on PATH (#252).',
202
+ };
258
203
  }
259
204
  const controller = ctx.getController(sid);
260
205
  let bpm = 0, key = 'C', scale = 'major';
@@ -278,7 +223,7 @@ async function suggestPatternFromAudio(style, role, ctx, sid) {
278
223
  melody: 'a melodic line that harmonizes with the key',
279
224
  percussion: 'a percussion layer that adds rhythmic interest',
280
225
  };
281
- const roleText = roleDesc[role] || roleDesc['complement'];
226
+ const roleText = lookup(roleDesc, role, roleDesc['complement']);
282
227
  const styleText = style ? ` in a ${style} style` : '';
283
228
  const tempoText = bpm > 0 ? `Detected tempo: ${bpm} BPM. ` : '';
284
229
  const keyText = `Detected key: ${key} ${scale}. `;
@@ -296,10 +241,17 @@ Example patterns:
296
241
  try {
297
242
  const geminiResponse = await ctx.geminiService.suggestVariations(prompt, style);
298
243
  if (!geminiResponse || geminiResponse.length === 0) {
299
- return { error: 'Gemini returned no pattern suggestions.' };
244
+ // The model answered; it just had nothing to offer. Returning a
245
+ // bare `error` key made `isFailureShaped` convert this into an
246
+ // err() envelope, so a working call was reported as a failure —
247
+ // the same bug as #274, pointing the other way (#288).
248
+ return empty({
249
+ suggestions: [],
250
+ message: 'Gemini returned no pattern suggestions.',
251
+ });
300
252
  }
301
253
  const suggestedPattern = geminiResponse[0].code;
302
- const validation = ctx.strudelEngine.validate(suggestedPattern);
254
+ const validation = await ctx.strudelEngine.validate(suggestedPattern);
303
255
  return {
304
256
  suggested_pattern: suggestedPattern,
305
257
  analysis: { bpm, key, scale },
@@ -307,7 +259,7 @@ Example patterns:
307
259
  style: style || 'auto',
308
260
  valid: validation.valid,
309
261
  validation_errors: validation.valid ? [] : validation.errors,
310
- usage: 'Use write tool to load this pattern, then play to hear it.',
262
+ usage: 'Use edit_pattern({ mode: "write" }) to load this pattern, then playback({ action: "play" }) to hear it.',
311
263
  };
312
264
  }
313
265
  catch (error) {
@@ -349,7 +301,7 @@ async function jamWith(layer, styleHint, autoPlay = true, ctx, sid) {
349
301
  }
350
302
  let newLayer;
351
303
  try {
352
- newLayer = generateComplementaryLayer(layer, key, tempo, detectedStyle, existingLayers, ctx);
304
+ newLayer = generateComplementaryLayer(layer, key, detectedStyle, existingLayers, ctx);
353
305
  }
354
306
  catch (error) {
355
307
  const message = error instanceof Error ? error.message : String(error);
@@ -364,18 +316,18 @@ async function jamWith(layer, styleHint, autoPlay = true, ctx, sid) {
364
316
  }
365
317
  const mergedPattern = mergeLayerIntoPattern(currentPattern, newLayer, layer);
366
318
  try {
367
- await ctx.writePatternSafe(mergedPattern, sid);
319
+ const written = await ctx.writePatternSafe(mergedPattern, sid);
368
320
  if (autoPlay && (sid || ctx.isInitialized())) {
369
321
  await ctx.getController(sid).play();
370
322
  }
371
- return {
323
+ return withStashField({
372
324
  success: true,
373
325
  message: `Added ${layer} layer${styleHint ? ` (${styleHint} style)` : ''} to jam with your pattern`,
374
326
  layer,
375
327
  detected: { tempo, key, existingLayers },
376
328
  newLayer,
377
329
  pattern: mergedPattern.substring(0, 300) + (mergedPattern.length > 300 ? '...' : ''),
378
- };
330
+ }, written);
379
331
  }
380
332
  catch (error) {
381
333
  const message = error instanceof Error ? error.message : String(error);
@@ -389,20 +341,29 @@ async function jamWith(layer, styleHint, autoPlay = true, ctx, sid) {
389
341
  };
390
342
  }
391
343
  }
344
+ /**
345
+ * The tempo a pattern's author meant, for generating a layer to sit on
346
+ * top of it.
347
+ *
348
+ * Uses the canonical parser rather than a regex of its own. The regex it
349
+ * had required a bare `setcpm(<n>)`, so it matched nothing the generator
350
+ * writes — every generated pattern is `setcpm(130/4)` — and fell through
351
+ * to guessing from words in the text, which is how `jam_with` came to
352
+ * read a tempo off a comment (#397).
353
+ *
354
+ * `declaredBpm`, not `impliedBpm`: this reads patterns written by someone
355
+ * else, and only the number they wrote is knowable. Working out what one
356
+ * will actually sound like takes an assumption about beats per cycle
357
+ * that holds for what this project generates and not for an arbitrary
358
+ * hand-written pattern.
359
+ */
392
360
  function detectTempoFromPattern(pattern) {
393
- const cpmMatch = pattern.match(/setcpm\s*\(\s*(\d+(?:\.\d+)?)\s*\)/i);
394
- if (cpmMatch)
395
- return Math.round(parseFloat(cpmMatch[1]));
396
- const bpmMatch = pattern.match(/setbpm\s*\(\s*(\d+(?:\.\d+)?)\s*\)/i);
397
- if (bpmMatch)
398
- return Math.round(parseFloat(bpmMatch[1]));
399
- const cpsMatch = pattern.match(/setcps\s*\(\s*(\d+(?:\.\d+)?)\s*(?:\/\s*60)?\s*\)/i);
400
- if (cpsMatch) {
401
- const cps = parseFloat(cpsMatch[1]);
402
- if (pattern.includes(`setcps(${cpsMatch[1]}/60`))
403
- return Math.round(cps);
404
- return Math.round(cps * 60);
405
- }
361
+ const declared = declaredBpm(pattern);
362
+ if (declared !== undefined)
363
+ return Math.round(declared);
364
+ // No tempo call at all. Guessing from genre words is a poor answer,
365
+ // but it beats silently assuming 120 for a dnb pattern — and a real
366
+ // parse always wins over it now, which is the part that was broken.
406
367
  if (pattern.toLowerCase().includes('dnb'))
407
368
  return 174;
408
369
  if (pattern.toLowerCase().includes('techno'))
@@ -431,10 +392,13 @@ function detectKeyFromPattern(pattern) {
431
392
  }
432
393
  if (allNotes.length === 0)
433
394
  return 'C';
434
- const noteCounts = {};
395
+ // Object.create(null): the keys come from pattern text, and both
396
+ // reading and writing 'constructor'/'__proto__' on a plain literal
397
+ // misbehave (#318).
398
+ const noteCounts = Object.create(null);
435
399
  for (const note of allNotes) {
436
400
  const normalizedNote = note.charAt(0).toUpperCase() + note.slice(1);
437
- noteCounts[normalizedNote] = (noteCounts[normalizedNote] || 0) + 1;
401
+ noteCounts[normalizedNote] = lookup(noteCounts, normalizedNote, 0) + 1;
438
402
  }
439
403
  let mostCommonNote = 'C';
440
404
  let maxCount = 0;
@@ -486,7 +450,14 @@ function detectStyleFromPattern(pattern, styleHint) {
486
450
  return 'trap';
487
451
  return 'techno';
488
452
  }
489
- function generateComplementaryLayer(layer, key, tempo, style, existingLayers, ctx) {
453
+ function generateComplementaryLayer(layer, key, style, existingLayers, ctx) {
454
+ // No `tempo` parameter: it was declared, passed, and never read. The
455
+ // merged layer goes inside the host pattern's own stack and inherits
456
+ // its tempo call (see `mergeLayerIntoPattern`), so there is nothing
457
+ // here for a tempo to change. A cross-model reviewer read the dead
458
+ // parameter as evidence that this function retimes the layer, and
459
+ // concluded jam_with would desync by 4x; it cannot. Dead arguments
460
+ // are not free.
490
461
  switch (layer) {
491
462
  case 'drums':
492
463
  if (existingLayers.includes('drums')) {
@@ -499,7 +470,7 @@ function generateComplementaryLayer(layer, key, tempo, style, existingLayers, ct
499
470
  'jungle': 's("hh*32").gain(perlin.range(0.2, 0.4)).hpf(4000)',
500
471
  'jazz': 's("~ ride ~ ride, ~ ~ ~ hh").gain(0.3).room(0.3)',
501
472
  };
502
- return percOptions[style] || percOptions['techno'];
473
+ return lookup(percOptions, style, percOptions['techno']);
503
474
  }
504
475
  return ctx.generator.generateDrumPattern(style, 0.6);
505
476
  case 'bass':
@@ -507,8 +478,14 @@ function generateComplementaryLayer(layer, key, tempo, style, existingLayers, ct
507
478
  case 'melody': {
508
479
  let scaleName = 'minor';
509
480
  let octaveRange = [4, 5];
481
+ // `major`, not `dorian`. The convention names the mode of the ii
482
+ // CHORD; rooted on the key it is the wrong collection, and over
483
+ // the jazz progression this server generates (Dm7 G7 Cmaj7 in C)
484
+ // it puts Eb against Cmaj7's E (#449). `jam_with` cannot see the
485
+ // host pattern's chords, so it follows the generator's choice
486
+ // rather than disagreeing with it.
510
487
  if (style === 'jazz') {
511
- scaleName = 'dorian';
488
+ scaleName = 'major';
512
489
  octaveRange = [3, 5];
513
490
  }
514
491
  if (style === 'ambient') {
@@ -524,7 +501,7 @@ function generateComplementaryLayer(layer, key, tempo, style, existingLayers, ct
524
501
  'trap': '.gain(0.5).room(0.15)', 'jungle': '.delay(0.125).room(0.25).gain(0.55)',
525
502
  'jazz': '.room(0.4).gain(0.5)',
526
503
  };
527
- return ctx.generator.generateMelody(scale, 8, octaveRange) + (effects[style] || '.room(0.3).gain(0.5)');
504
+ return ctx.generator.generateMelody(scale, 8, octaveRange) + lookup(effects, style, '.room(0.3).gain(0.5)');
528
505
  }
529
506
  case 'pad': {
530
507
  const safeKey = key.toLowerCase();
@@ -539,7 +516,7 @@ function generateComplementaryLayer(layer, key, tempo, style, existingLayers, ct
539
516
  'jungle': `chord("<${safeKey}m9 ${fourth}m9>/8").dict('ireal').voicing().s("gm_epiano1").gain(0.25).room(0.4).delay(0.25)`,
540
517
  'jazz': `chord("<${safeKey}m9 ${fourth}m9 ${fifth}7>/4").dict('ireal').voicing().s("gm_epiano1").gain(0.3).room(0.5)`,
541
518
  };
542
- return padPatterns[style] || padPatterns['techno'];
519
+ return lookup(padPatterns, style, padPatterns['techno']);
543
520
  }
544
521
  case 'texture': {
545
522
  const texturePatterns = {
@@ -551,7 +528,7 @@ function generateComplementaryLayer(layer, key, tempo, style, existingLayers, ct
551
528
  'jungle': `s("breaks125:8").fit().chop(32).gain(0.05).hpf(5000).room(0.4).pan(perlin.range(0.2, 0.8))`,
552
529
  'jazz': `s("brush:1").struct("~ 1 ~ 1 ~ 1 ~ ~").gain(0.1).room(0.5)`,
553
530
  };
554
- return texturePatterns[style] || texturePatterns['techno'];
531
+ return lookup(texturePatterns, style, texturePatterns['techno']);
555
532
  }
556
533
  default:
557
534
  throw new Error(`Unknown layer type: ${layer}`);