@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
package/dist/server/tools/ai.js
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ai domain — AI-powered feedback, pattern suggestion, and jamming.
|
|
3
3
|
*
|
|
4
|
-
* Owns (
|
|
5
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
-
'
|
|
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: '
|
|
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
|
-
|
|
152
|
-
|
|
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
|
|
159
|
-
if (
|
|
160
|
-
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
394
|
-
if (
|
|
395
|
-
return Math.round(
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
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 = '
|
|
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
|
|
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
|
|
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
|
|
531
|
+
return lookup(texturePatterns, style, texturePatterns['techno']);
|
|
555
532
|
}
|
|
556
533
|
default:
|
|
557
534
|
throw new Error(`Unknown layer type: ${layer}`);
|