@williamzujkowski/live-coding-music-mcp 1.0.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/LICENSE +21 -0
- package/README.md +1735 -0
- package/dist/AudioAnalyzer.js +678 -0
- package/dist/PatternStore.js +169 -0
- package/dist/StrudelController.js +676 -0
- package/dist/__tests__/MusicTheory.test.js +245 -0
- package/dist/__tests__/PatternGenerator.test.js +337 -0
- package/dist/__tests__/PatternStore.test.js +153 -0
- package/dist/__tests__/utils/MockPlaywright.js +130 -0
- package/dist/index.js +21 -0
- package/dist/server/EnhancedMCPServer.js +654 -0
- package/dist/server/EnhancedMCPServerFixed.js +2506 -0
- package/dist/server/server.js +580 -0
- package/dist/server/tools/ai.js +530 -0
- package/dist/server/tools/analysis.js +132 -0
- package/dist/server/tools/capture.js +184 -0
- package/dist/server/tools/diagnostics.js +96 -0
- package/dist/server/tools/editor.js +128 -0
- package/dist/server/tools/generate.js +218 -0
- package/dist/server/tools/history.js +196 -0
- package/dist/server/tools/playback.js +41 -0
- package/dist/server/tools/session.js +115 -0
- package/dist/server/tools/storage.js +77 -0
- package/dist/server/tools/transform.js +399 -0
- package/dist/server/tools/types.js +9 -0
- package/dist/services/AudioCaptureService.js +279 -0
- package/dist/services/GeminiService.js +790 -0
- package/dist/services/MIDIExportService.js +412 -0
- package/dist/services/MusicTheory.js +186 -0
- package/dist/services/PatternGenerator.js +647 -0
- package/dist/services/SessionManager.js +316 -0
- package/dist/services/StrudelEngine.js +374 -0
- package/dist/services/__mocks__/StrudelEngine.js +157 -0
- package/dist/types/AudioAnalysis.js +4 -0
- package/dist/types/index.js +1 -0
- package/dist/utils/ErrorRecovery.js +248 -0
- package/dist/utils/InputValidator.js +281 -0
- package/dist/utils/Logger.js +19 -0
- package/dist/utils/PatternValidator.js +241 -0
- package/dist/utils/PerformanceMonitor.js +128 -0
- package/package.json +86 -0
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ai domain — AI-powered feedback, pattern suggestion, and jamming.
|
|
3
|
+
*
|
|
4
|
+
* Owns (3 tools): get_pattern_feedback, suggest_pattern_from_audio,
|
|
5
|
+
* jam_with.
|
|
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
|
|
12
|
+
* pattern (tempo/key/style detection, layer detection, merge logic)
|
|
13
|
+
* and that are used only by this tool. They live here.
|
|
14
|
+
*/
|
|
15
|
+
import { Logger } from '../../utils/Logger.js';
|
|
16
|
+
const logger = new Logger();
|
|
17
|
+
export const tools = [
|
|
18
|
+
{
|
|
19
|
+
name: 'get_pattern_feedback',
|
|
20
|
+
description: 'Get AI-powered creative feedback on the current pattern using Google Gemini. Analyzes pattern structure and optionally audio.',
|
|
21
|
+
inputSchema: {
|
|
22
|
+
type: 'object',
|
|
23
|
+
properties: {
|
|
24
|
+
includeAudio: { type: 'boolean', description: 'Include audio analysis (plays pattern briefly). Default: false' },
|
|
25
|
+
style: { type: 'string', description: 'Optional style hint for context (e.g., "techno", "ambient")' },
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'suggest_pattern_from_audio',
|
|
31
|
+
description: 'Analyze the currently playing audio and suggest a complementary Strudel pattern using Gemini AI. Extracts tempo, key, and spectral features locally, then uses AI to generate a matching pattern. Returns pattern text (not auto-executed).',
|
|
32
|
+
inputSchema: {
|
|
33
|
+
type: 'object',
|
|
34
|
+
properties: {
|
|
35
|
+
style: { type: 'string', description: 'Optional style hint (e.g., "ambient", "techno", "jazz")' },
|
|
36
|
+
role: {
|
|
37
|
+
type: 'string',
|
|
38
|
+
enum: ['complement', 'bassline', 'melody', 'percussion'],
|
|
39
|
+
description: 'What role the suggested pattern should fill. Default: complement',
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'jam_with',
|
|
46
|
+
description: 'AI generates a complementary layer to jam with your pattern. Analyzes current pattern to detect tempo, key, and existing layers, then generates a matching layer that fits musically.',
|
|
47
|
+
inputSchema: {
|
|
48
|
+
type: 'object',
|
|
49
|
+
properties: {
|
|
50
|
+
layer: {
|
|
51
|
+
type: 'string',
|
|
52
|
+
enum: ['drums', 'bass', 'melody', 'pad', 'texture'],
|
|
53
|
+
description: 'Type of layer to add: drums, bass, melody, pad, or texture',
|
|
54
|
+
},
|
|
55
|
+
style_hint: { type: 'string', description: 'Optional style guidance (e.g., "funky", "minimal", "atmospheric")' },
|
|
56
|
+
auto_play: { type: 'boolean', description: 'Start playback after adding layer (default: true)' },
|
|
57
|
+
},
|
|
58
|
+
required: ['layer'],
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
export const toolNames = new Set(tools.map(t => t.name));
|
|
63
|
+
export async function execute(name, args, ctx) {
|
|
64
|
+
switch (name) {
|
|
65
|
+
case 'get_pattern_feedback':
|
|
66
|
+
return await getPatternFeedback(args?.includeAudio || false, args?.style, ctx);
|
|
67
|
+
case 'suggest_pattern_from_audio':
|
|
68
|
+
return await suggestPatternFromAudio(args?.style, args?.role || 'complement', ctx);
|
|
69
|
+
case 'jam_with':
|
|
70
|
+
return await jamWith(args.layer, args.style_hint, args.auto_play, ctx);
|
|
71
|
+
default:
|
|
72
|
+
throw new Error(`ai module does not handle tool: ${name}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// get_pattern_feedback
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
async function getPatternFeedback(includeAudio, style, ctx) {
|
|
79
|
+
if (!ctx.geminiService.isAvailable()) {
|
|
80
|
+
return {
|
|
81
|
+
gemini_available: false,
|
|
82
|
+
error: 'Gemini API not configured. Set GEMINI_API_KEY environment variable to enable AI feedback.',
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const pattern = await ctx.getCurrentPatternSafe();
|
|
86
|
+
if (!pattern || pattern.trim().length === 0) {
|
|
87
|
+
return { gemini_available: true, error: 'No pattern to analyze. Write a pattern first.' };
|
|
88
|
+
}
|
|
89
|
+
const result = { gemini_available: true };
|
|
90
|
+
try {
|
|
91
|
+
result.pattern_analysis = await ctx.geminiService.getCreativeFeedback(pattern);
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
95
|
+
logger.error('Pattern feedback failed', { error: message });
|
|
96
|
+
if (message?.includes('rate limit') || message?.includes('Rate limit')) {
|
|
97
|
+
return { gemini_available: true, error: message };
|
|
98
|
+
}
|
|
99
|
+
result.error = `Pattern analysis failed: ${message}`;
|
|
100
|
+
}
|
|
101
|
+
if (includeAudio && ctx.isInitialized()) {
|
|
102
|
+
try {
|
|
103
|
+
const audioBlob = await captureAudioSampleForFeedback(ctx);
|
|
104
|
+
if (audioBlob) {
|
|
105
|
+
result.audio_analysis = await ctx.geminiService.analyzeAudio(audioBlob, { style, duration: 5 });
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
logger.warn('Audio capture returned no data');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
113
|
+
logger.error('Audio analysis failed', { error: message });
|
|
114
|
+
if (!result.error)
|
|
115
|
+
result.error = `Audio analysis failed: ${message}`;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
else if (includeAudio && !ctx.isInitialized()) {
|
|
119
|
+
if (!result.error) {
|
|
120
|
+
result.error = 'Audio analysis requires browser initialization. Run init first or set includeAudio to false.';
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Captures a brief audio sample for Gemini analysis. Different from the
|
|
127
|
+
* `capture_audio_sample` tool — this one connects directly to the
|
|
128
|
+
* analyzer's AudioContext and records 5 seconds without disturbing
|
|
129
|
+
* the full AudioCaptureService lifecycle.
|
|
130
|
+
*/
|
|
131
|
+
async function captureAudioSampleForFeedback(ctx) {
|
|
132
|
+
const page = ctx.controller.page;
|
|
133
|
+
if (!page) {
|
|
134
|
+
logger.warn('Cannot capture audio: controller page not available');
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
const audioData = await page.evaluate(/* istanbul ignore next */ async () => {
|
|
139
|
+
return new Promise((resolve) => {
|
|
140
|
+
const analyzer = window.strudelAudioAnalyzer;
|
|
141
|
+
if (!analyzer || !analyzer.analyser) {
|
|
142
|
+
resolve(null);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
const audioCtx = analyzer.analyser.context;
|
|
147
|
+
const destination = audioCtx.createMediaStreamDestination();
|
|
148
|
+
analyzer.analyser.connect(destination);
|
|
149
|
+
const mediaRecorder = new MediaRecorder(destination.stream, { mimeType: 'audio/webm;codecs=opus' });
|
|
150
|
+
const chunks = [];
|
|
151
|
+
mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0)
|
|
152
|
+
chunks.push(e.data); };
|
|
153
|
+
mediaRecorder.onstop = async () => {
|
|
154
|
+
try {
|
|
155
|
+
analyzer.analyser.disconnect(destination);
|
|
156
|
+
}
|
|
157
|
+
catch { }
|
|
158
|
+
if (chunks.length === 0) {
|
|
159
|
+
resolve(null);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const blob = new Blob(chunks, { type: 'audio/webm' });
|
|
163
|
+
const reader = new FileReader();
|
|
164
|
+
reader.onloadend = () => {
|
|
165
|
+
const base64 = reader.result;
|
|
166
|
+
resolve(base64.split(',')[1] || null);
|
|
167
|
+
};
|
|
168
|
+
reader.onerror = () => resolve(null);
|
|
169
|
+
reader.readAsDataURL(blob);
|
|
170
|
+
};
|
|
171
|
+
mediaRecorder.start();
|
|
172
|
+
setTimeout(() => { if (mediaRecorder.state === 'recording')
|
|
173
|
+
mediaRecorder.stop(); }, 5000);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
resolve(null);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
if (!audioData)
|
|
181
|
+
return null;
|
|
182
|
+
const binaryString = atob(audioData);
|
|
183
|
+
const bytes = new Uint8Array(binaryString.length);
|
|
184
|
+
for (let i = 0; i < binaryString.length; i++)
|
|
185
|
+
bytes[i] = binaryString.charCodeAt(i);
|
|
186
|
+
return new Blob([bytes], { type: 'audio/webm' });
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
190
|
+
logger.error('Audio capture failed', { error: message });
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// suggest_pattern_from_audio
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
async function suggestPatternFromAudio(style, role, ctx) {
|
|
198
|
+
if (!ctx.isInitialized()) {
|
|
199
|
+
return { error: 'Browser not initialized. Run init and play a pattern first.' };
|
|
200
|
+
}
|
|
201
|
+
if (!ctx.geminiService.isAvailable()) {
|
|
202
|
+
return { error: 'Gemini API not configured. Set GEMINI_API_KEY to enable AI features.' };
|
|
203
|
+
}
|
|
204
|
+
let bpm = 0, key = 'C', scale = 'major';
|
|
205
|
+
try {
|
|
206
|
+
const tempoResult = await ctx.controller.detectTempo();
|
|
207
|
+
if (tempoResult && tempoResult.bpm > 0)
|
|
208
|
+
bpm = tempoResult.bpm;
|
|
209
|
+
}
|
|
210
|
+
catch { /* best effort */ }
|
|
211
|
+
try {
|
|
212
|
+
const keyResult = await ctx.controller.detectKey();
|
|
213
|
+
if (keyResult && keyResult.confidence > 0.1) {
|
|
214
|
+
key = keyResult.key;
|
|
215
|
+
scale = keyResult.scale;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
catch { /* best effort */ }
|
|
219
|
+
const roleDesc = {
|
|
220
|
+
complement: 'a complementary layer that fills sonic gaps',
|
|
221
|
+
bassline: 'a bassline that grooves with the rhythm',
|
|
222
|
+
melody: 'a melodic line that harmonizes with the key',
|
|
223
|
+
percussion: 'a percussion layer that adds rhythmic interest',
|
|
224
|
+
};
|
|
225
|
+
const roleText = roleDesc[role] || roleDesc['complement'];
|
|
226
|
+
const styleText = style ? ` in a ${style} style` : '';
|
|
227
|
+
const tempoText = bpm > 0 ? `Detected tempo: ${bpm} BPM. ` : '';
|
|
228
|
+
const keyText = `Detected key: ${key} ${scale}. `;
|
|
229
|
+
const prompt = `You are a Strudel.cc live coding expert. Generate ${roleText}${styleText} for an existing pattern.
|
|
230
|
+
|
|
231
|
+
${tempoText}${keyText}
|
|
232
|
+
|
|
233
|
+
Generate ONLY valid Strudel.cc pattern code. Use functions like s(), note(), n(), .speed(), .gain(), .lpf(), .delay(), .room(), .pan(). Respond with ONLY the pattern code, no explanation.
|
|
234
|
+
|
|
235
|
+
Example patterns:
|
|
236
|
+
- Bass: note("c2 eb2 g2 bb2").s("sawtooth").lpf(800).gain(0.6)
|
|
237
|
+
- Melody: note("c4 e4 g4 c5").s("triangle").delay(0.3).room(0.4)
|
|
238
|
+
- Drums: s("bd*4, ~ sd ~ sd, hh*8").gain(0.7)
|
|
239
|
+
- Ambient: note("c3 e3 g3").s("sine").room(0.8).delay(0.5).gain(0.3)`;
|
|
240
|
+
try {
|
|
241
|
+
const geminiResponse = await ctx.geminiService.suggestVariations(prompt, style);
|
|
242
|
+
if (!geminiResponse || geminiResponse.length === 0) {
|
|
243
|
+
return { error: 'Gemini returned no pattern suggestions.' };
|
|
244
|
+
}
|
|
245
|
+
const suggestedPattern = geminiResponse[0].code;
|
|
246
|
+
const validation = ctx.strudelEngine.validate(suggestedPattern);
|
|
247
|
+
return {
|
|
248
|
+
suggested_pattern: suggestedPattern,
|
|
249
|
+
analysis: { bpm, key, scale },
|
|
250
|
+
role,
|
|
251
|
+
style: style || 'auto',
|
|
252
|
+
valid: validation.valid,
|
|
253
|
+
validation_errors: validation.valid ? [] : validation.errors,
|
|
254
|
+
usage: 'Use write tool to load this pattern, then play to hear it.',
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
259
|
+
logger.error('Audio-to-pattern suggestion failed', { error: message });
|
|
260
|
+
return { error: `Pattern suggestion failed: ${message}` };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
// jam_with
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
async function jamWith(layer, styleHint, autoPlay = true, ctx) {
|
|
267
|
+
const validLayers = ['drums', 'bass', 'melody', 'pad', 'texture'];
|
|
268
|
+
if (!validLayers.includes(layer)) {
|
|
269
|
+
return {
|
|
270
|
+
success: false,
|
|
271
|
+
message: `Invalid layer type: ${layer}. Must be one of: ${validLayers.join(', ')}`,
|
|
272
|
+
layer,
|
|
273
|
+
detected: { tempo: 120, key: 'C', existingLayers: [] },
|
|
274
|
+
newLayer: '',
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
const currentPattern = await ctx.getCurrentPatternSafe();
|
|
278
|
+
if (!currentPattern || currentPattern.trim().length === 0) {
|
|
279
|
+
return {
|
|
280
|
+
success: false,
|
|
281
|
+
message: 'No pattern to jam with. Write a pattern first.',
|
|
282
|
+
layer,
|
|
283
|
+
detected: { tempo: 120, key: 'C', existingLayers: [] },
|
|
284
|
+
newLayer: '',
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
const tempo = detectTempoFromPattern(currentPattern);
|
|
288
|
+
const key = detectKeyFromPattern(currentPattern);
|
|
289
|
+
const existingLayers = detectExistingLayers(currentPattern);
|
|
290
|
+
const detectedStyle = detectStyleFromPattern(currentPattern, styleHint);
|
|
291
|
+
if (existingLayers.includes(layer) && layer !== 'texture') {
|
|
292
|
+
logger.warn(`Pattern already contains ${layer} layer, adding anyway`);
|
|
293
|
+
}
|
|
294
|
+
let newLayer;
|
|
295
|
+
try {
|
|
296
|
+
newLayer = generateComplementaryLayer(layer, key, tempo, detectedStyle, existingLayers, ctx);
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
300
|
+
return {
|
|
301
|
+
success: false,
|
|
302
|
+
message: `Failed to generate ${layer} layer: ${message}`,
|
|
303
|
+
layer,
|
|
304
|
+
detected: { tempo, key, existingLayers },
|
|
305
|
+
newLayer: '',
|
|
306
|
+
error: message,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
const mergedPattern = mergeLayerIntoPattern(currentPattern, newLayer, layer);
|
|
310
|
+
try {
|
|
311
|
+
await ctx.writePatternSafe(mergedPattern);
|
|
312
|
+
if (autoPlay && ctx.isInitialized()) {
|
|
313
|
+
await ctx.controller.play();
|
|
314
|
+
}
|
|
315
|
+
return {
|
|
316
|
+
success: true,
|
|
317
|
+
message: `Added ${layer} layer${styleHint ? ` (${styleHint} style)` : ''} to jam with your pattern`,
|
|
318
|
+
layer,
|
|
319
|
+
detected: { tempo, key, existingLayers },
|
|
320
|
+
newLayer,
|
|
321
|
+
pattern: mergedPattern.substring(0, 300) + (mergedPattern.length > 300 ? '...' : ''),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
326
|
+
return {
|
|
327
|
+
success: false,
|
|
328
|
+
message: `Failed to write merged pattern: ${message}`,
|
|
329
|
+
layer,
|
|
330
|
+
detected: { tempo, key, existingLayers },
|
|
331
|
+
newLayer,
|
|
332
|
+
error: message,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
function detectTempoFromPattern(pattern) {
|
|
337
|
+
const cpmMatch = pattern.match(/setcpm\s*\(\s*(\d+(?:\.\d+)?)\s*\)/i);
|
|
338
|
+
if (cpmMatch)
|
|
339
|
+
return Math.round(parseFloat(cpmMatch[1]));
|
|
340
|
+
const bpmMatch = pattern.match(/setbpm\s*\(\s*(\d+(?:\.\d+)?)\s*\)/i);
|
|
341
|
+
if (bpmMatch)
|
|
342
|
+
return Math.round(parseFloat(bpmMatch[1]));
|
|
343
|
+
const cpsMatch = pattern.match(/setcps\s*\(\s*(\d+(?:\.\d+)?)\s*(?:\/\s*60)?\s*\)/i);
|
|
344
|
+
if (cpsMatch) {
|
|
345
|
+
const cps = parseFloat(cpsMatch[1]);
|
|
346
|
+
if (pattern.includes(`setcps(${cpsMatch[1]}/60`))
|
|
347
|
+
return Math.round(cps);
|
|
348
|
+
return Math.round(cps * 60);
|
|
349
|
+
}
|
|
350
|
+
if (pattern.toLowerCase().includes('dnb'))
|
|
351
|
+
return 174;
|
|
352
|
+
if (pattern.toLowerCase().includes('techno'))
|
|
353
|
+
return 130;
|
|
354
|
+
if (pattern.toLowerCase().includes('house'))
|
|
355
|
+
return 125;
|
|
356
|
+
return 120;
|
|
357
|
+
}
|
|
358
|
+
function detectKeyFromPattern(pattern) {
|
|
359
|
+
const noteMatches = pattern.match(/note\s*\(\s*["']([^"']+)["']\s*\)/gi) || [];
|
|
360
|
+
const nMatches = pattern.match(/\.n\s*\(\s*["']([^"']+)["']\s*\)/gi) || [];
|
|
361
|
+
const allNotes = [];
|
|
362
|
+
for (const match of noteMatches) {
|
|
363
|
+
const notesInMatch = match.match(/[a-g][#b]?\d?/gi) || [];
|
|
364
|
+
allNotes.push(...notesInMatch.map(n => n.toLowerCase().replace(/\d/g, '')));
|
|
365
|
+
}
|
|
366
|
+
for (const match of nMatches) {
|
|
367
|
+
const notesInMatch = match.match(/[a-g][#b]?\d?/gi) || [];
|
|
368
|
+
allNotes.push(...notesInMatch.map(n => n.toLowerCase().replace(/\d/g, '')));
|
|
369
|
+
}
|
|
370
|
+
const chordMatches = pattern.match(/chord\s*\(\s*["']<([^>]+)>/gi) || [];
|
|
371
|
+
for (const match of chordMatches) {
|
|
372
|
+
const rootMatch = match.match(/[a-g][#b]?/i);
|
|
373
|
+
if (rootMatch)
|
|
374
|
+
allNotes.push(rootMatch[0].toLowerCase());
|
|
375
|
+
}
|
|
376
|
+
if (allNotes.length === 0)
|
|
377
|
+
return 'C';
|
|
378
|
+
const noteCounts = {};
|
|
379
|
+
for (const note of allNotes) {
|
|
380
|
+
const normalizedNote = note.charAt(0).toUpperCase() + note.slice(1);
|
|
381
|
+
noteCounts[normalizedNote] = (noteCounts[normalizedNote] || 0) + 1;
|
|
382
|
+
}
|
|
383
|
+
let mostCommonNote = 'C';
|
|
384
|
+
let maxCount = 0;
|
|
385
|
+
for (const [note, count] of Object.entries(noteCounts)) {
|
|
386
|
+
if (count > maxCount) {
|
|
387
|
+
maxCount = count;
|
|
388
|
+
mostCommonNote = note;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return mostCommonNote;
|
|
392
|
+
}
|
|
393
|
+
function detectExistingLayers(pattern) {
|
|
394
|
+
const layers = [];
|
|
395
|
+
const lowerPattern = pattern.toLowerCase();
|
|
396
|
+
if (lowerPattern.includes('bd') || lowerPattern.includes('cp') ||
|
|
397
|
+
lowerPattern.includes('hh') || lowerPattern.includes('sd') ||
|
|
398
|
+
lowerPattern.includes('sn') || lowerPattern.includes('oh') ||
|
|
399
|
+
lowerPattern.includes('breaks') || lowerPattern.includes('drum')) {
|
|
400
|
+
layers.push('drums');
|
|
401
|
+
}
|
|
402
|
+
if (pattern.match(/note\s*\([^)]*[12]\s*["']/i) || lowerPattern.includes('bass')) {
|
|
403
|
+
layers.push('bass');
|
|
404
|
+
}
|
|
405
|
+
if (pattern.match(/note\s*\([^)]*[34567]\s*["']/i) ||
|
|
406
|
+
lowerPattern.includes('melody') || lowerPattern.includes('lead')) {
|
|
407
|
+
layers.push('melody');
|
|
408
|
+
}
|
|
409
|
+
if (lowerPattern.includes('chord(') || lowerPattern.includes('pad') ||
|
|
410
|
+
lowerPattern.includes('strings') || lowerPattern.includes('.voicing')) {
|
|
411
|
+
layers.push('pad');
|
|
412
|
+
}
|
|
413
|
+
return layers;
|
|
414
|
+
}
|
|
415
|
+
function detectStyleFromPattern(pattern, styleHint) {
|
|
416
|
+
if (styleHint)
|
|
417
|
+
return styleHint.toLowerCase();
|
|
418
|
+
const lowerPattern = pattern.toLowerCase();
|
|
419
|
+
const tempo = detectTempoFromPattern(pattern);
|
|
420
|
+
if (tempo >= 160 && lowerPattern.includes('breaks'))
|
|
421
|
+
return 'jungle';
|
|
422
|
+
if (tempo >= 165 && tempo <= 180)
|
|
423
|
+
return 'dnb';
|
|
424
|
+
if (tempo >= 125 && tempo <= 135 && lowerPattern.includes('bd*4')) {
|
|
425
|
+
return lowerPattern.includes('cp') ? 'techno' : 'house';
|
|
426
|
+
}
|
|
427
|
+
if (tempo <= 100 && lowerPattern.includes('room'))
|
|
428
|
+
return 'ambient';
|
|
429
|
+
if (lowerPattern.includes('trap'))
|
|
430
|
+
return 'trap';
|
|
431
|
+
return 'techno';
|
|
432
|
+
}
|
|
433
|
+
function generateComplementaryLayer(layer, key, tempo, style, existingLayers, ctx) {
|
|
434
|
+
switch (layer) {
|
|
435
|
+
case 'drums':
|
|
436
|
+
if (existingLayers.includes('drums')) {
|
|
437
|
+
const percOptions = {
|
|
438
|
+
'techno': 's("~ hh ~ hh, ~ ~ oh ~").gain(0.4).hpf(5000)',
|
|
439
|
+
'house': 's("[~ hh]*4, ~ ~ oh ~").gain(0.35).room(0.2)',
|
|
440
|
+
'dnb': 's("hh*16").gain(perlin.range(0.2, 0.4)).hpf(6000)',
|
|
441
|
+
'ambient': 's("~ ~ ~ hh:8").room(0.8).gain(0.2).slow(2)',
|
|
442
|
+
'trap': 's("hh*16").gain(perlin.range(0.15, 0.35)).hpf(5000)',
|
|
443
|
+
'jungle': 's("hh*32").gain(perlin.range(0.2, 0.4)).hpf(4000)',
|
|
444
|
+
'jazz': 's("~ ride ~ ride, ~ ~ ~ hh").gain(0.3).room(0.3)',
|
|
445
|
+
};
|
|
446
|
+
return percOptions[style] || percOptions['techno'];
|
|
447
|
+
}
|
|
448
|
+
return ctx.generator.generateDrumPattern(style, 0.6);
|
|
449
|
+
case 'bass':
|
|
450
|
+
return ctx.generator.generateBassline(key, style);
|
|
451
|
+
case 'melody': {
|
|
452
|
+
let scaleName = 'minor';
|
|
453
|
+
let octaveRange = [4, 5];
|
|
454
|
+
if (style === 'jazz') {
|
|
455
|
+
scaleName = 'dorian';
|
|
456
|
+
octaveRange = [3, 5];
|
|
457
|
+
}
|
|
458
|
+
if (style === 'ambient') {
|
|
459
|
+
scaleName = 'major';
|
|
460
|
+
octaveRange = [4, 6];
|
|
461
|
+
}
|
|
462
|
+
if (existingLayers.includes('bass'))
|
|
463
|
+
octaveRange = [4, 6];
|
|
464
|
+
const scale = ctx.theory.generateScale(key, scaleName);
|
|
465
|
+
const effects = {
|
|
466
|
+
'techno': '.delay(0.25).room(0.2)', 'house': '.room(0.3).gain(0.6)',
|
|
467
|
+
'dnb': '.delay(0.125).room(0.2).gain(0.5)', 'ambient': '.room(0.7).delay(0.5).gain(0.4)',
|
|
468
|
+
'trap': '.gain(0.5).room(0.15)', 'jungle': '.delay(0.125).room(0.25).gain(0.55)',
|
|
469
|
+
'jazz': '.room(0.4).gain(0.5)',
|
|
470
|
+
};
|
|
471
|
+
return ctx.generator.generateMelody(scale, 8, octaveRange) + (effects[style] || '.room(0.3).gain(0.5)');
|
|
472
|
+
}
|
|
473
|
+
case 'pad': {
|
|
474
|
+
const safeKey = key.toLowerCase();
|
|
475
|
+
const fourth = ctx.theory.getNote(key, 5).toLowerCase();
|
|
476
|
+
const fifth = ctx.theory.getNote(key, 7).toLowerCase();
|
|
477
|
+
const padPatterns = {
|
|
478
|
+
'techno': `chord("<${safeKey}m7 ${fourth}m7>/4").dict('ireal').voicing().s("sawtooth").attack(0.5).release(2).lpf(2000).gain(0.2).room(0.4)`,
|
|
479
|
+
'house': `chord("<${safeKey}m9 ${fourth}7 ${fifth}m7>/2").dict('ireal').voicing().s("gm_epiano1").gain(0.3).room(0.4)`,
|
|
480
|
+
'dnb': `chord("<${safeKey}m9 ${fourth}m9>/8").dict('ireal').voicing().s("gm_strings").attack(1).release(2).gain(0.2).room(0.5).lpf(3500)`,
|
|
481
|
+
'ambient': `chord("<${safeKey}maj7 ${fourth}maj7 ${fifth}m7>/8").dict('ireal').voicing().s("sawtooth").attack(3).release(5).lpf(sine.range(400, 1200).slow(16)).gain(0.15).room(0.9)`,
|
|
482
|
+
'trap': `chord("<${safeKey}m7>/4").dict('ireal').voicing().s("sawtooth").attack(0.1).release(0.5).lpf(1500).gain(0.25).room(0.3)`,
|
|
483
|
+
'jungle': `chord("<${safeKey}m9 ${fourth}m9>/8").dict('ireal').voicing().s("gm_epiano1").gain(0.25).room(0.4).delay(0.25)`,
|
|
484
|
+
'jazz': `chord("<${safeKey}m9 ${fourth}m9 ${fifth}7>/4").dict('ireal').voicing().s("gm_epiano1").gain(0.3).room(0.5)`,
|
|
485
|
+
};
|
|
486
|
+
return padPatterns[style] || padPatterns['techno'];
|
|
487
|
+
}
|
|
488
|
+
case 'texture': {
|
|
489
|
+
const texturePatterns = {
|
|
490
|
+
'techno': `s("hh:8*16").gain(perlin.range(0.02, 0.06)).hpf(8000).room(0.6).pan(perlin.range(0.2, 0.8).slow(8))`,
|
|
491
|
+
'house': `s("~ noise:2 ~ noise:2").gain(0.04).hpf(6000).room(0.4)`,
|
|
492
|
+
'dnb': `s("~ ~ ~ noise:4").gain(perlin.range(0.02, 0.05)).hpf(7000).room(0.5).pan(perlin.range(0.3, 0.7))`,
|
|
493
|
+
'ambient': `s("pad:1").n(perlin.range(0, 8).floor()).gain(0.08).room(0.95).lpf(sine.range(500, 2000).slow(32)).slow(4)`,
|
|
494
|
+
'trap': `s("~ ~ noise:3 ~").gain(0.03).hpf(10000).room(0.3)`,
|
|
495
|
+
'jungle': `s("breaks125:8").fit().chop(32).gain(0.05).hpf(5000).room(0.4).pan(perlin.range(0.2, 0.8))`,
|
|
496
|
+
'jazz': `s("brush:1").struct("~ 1 ~ 1 ~ 1 ~ ~").gain(0.1).room(0.5)`,
|
|
497
|
+
};
|
|
498
|
+
return texturePatterns[style] || texturePatterns['techno'];
|
|
499
|
+
}
|
|
500
|
+
default:
|
|
501
|
+
throw new Error(`Unknown layer type: ${layer}`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
function mergeLayerIntoPattern(currentPattern, newLayer, layerType) {
|
|
505
|
+
const trimmedPattern = currentPattern.trim();
|
|
506
|
+
const trimmedLayer = newLayer.trim();
|
|
507
|
+
const stackMatch = trimmedPattern.match(/^([\s\S]*?)stack\s*\(\s*([\s\S]*?)\s*\)([\s\S]*)$/);
|
|
508
|
+
if (stackMatch) {
|
|
509
|
+
const prefix = stackMatch[1];
|
|
510
|
+
const stackContents = stackMatch[2].trimEnd().replace(/,\s*$/, '');
|
|
511
|
+
const suffix = stackMatch[3];
|
|
512
|
+
return `${prefix}stack(
|
|
513
|
+
${stackContents},
|
|
514
|
+
|
|
515
|
+
// Jam ${layerType} layer
|
|
516
|
+
${trimmedLayer}
|
|
517
|
+
)${suffix}`;
|
|
518
|
+
}
|
|
519
|
+
const tempoMatch = trimmedPattern.match(/^(\s*(?:setcp[ms]|setbpm)\s*\([^)]+\)\s*\n?)/);
|
|
520
|
+
const tempoPrefix = tempoMatch ? tempoMatch[1] : '';
|
|
521
|
+
const patternBody = tempoMatch ? trimmedPattern.slice(tempoMatch[0].length) : trimmedPattern;
|
|
522
|
+
return `${tempoPrefix}stack(
|
|
523
|
+
// Original pattern
|
|
524
|
+
${patternBody},
|
|
525
|
+
|
|
526
|
+
// Jam ${layerType} layer
|
|
527
|
+
${trimmedLayer}
|
|
528
|
+
)`;
|
|
529
|
+
}
|
|
530
|
+
export const aiModule = { tools, toolNames, execute };
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* analysis domain — audio analysis + pattern runtime validation.
|
|
3
|
+
*
|
|
4
|
+
* Owns (6 tools): analyze, analyze_spectrum, analyze_rhythm,
|
|
5
|
+
* detect_tempo, detect_key, validate_pattern_runtime.
|
|
6
|
+
*
|
|
7
|
+
* Post-consolidation (per #110 audit): a single `analyze` tool with
|
|
8
|
+
* an `include[]` filter absorbs the 4 detection-specific tools.
|
|
9
|
+
* `validate_pattern_runtime` stays separate — it's console/syntax
|
|
10
|
+
* scraping, not audio DSP.
|
|
11
|
+
*/
|
|
12
|
+
import { InputValidator } from '../../utils/InputValidator.js';
|
|
13
|
+
export const tools = [
|
|
14
|
+
{
|
|
15
|
+
name: 'analyze',
|
|
16
|
+
description: 'Complete audio analysis',
|
|
17
|
+
inputSchema: { type: 'object', properties: {} },
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: 'analyze_spectrum',
|
|
21
|
+
description: 'FFT spectrum analysis',
|
|
22
|
+
inputSchema: { type: 'object', properties: {} },
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: 'analyze_rhythm',
|
|
26
|
+
description: 'Rhythm analysis',
|
|
27
|
+
inputSchema: { type: 'object', properties: {} },
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'detect_tempo',
|
|
31
|
+
description: 'BPM detection',
|
|
32
|
+
inputSchema: { type: 'object', properties: {} },
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'detect_key',
|
|
36
|
+
description: 'Key detection',
|
|
37
|
+
inputSchema: { type: 'object', properties: {} },
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'validate_pattern_runtime',
|
|
41
|
+
description: 'Validate pattern with runtime error checking (monitors Strudel console for errors)',
|
|
42
|
+
inputSchema: {
|
|
43
|
+
type: 'object',
|
|
44
|
+
properties: {
|
|
45
|
+
pattern: { type: 'string', description: 'Pattern code to validate' },
|
|
46
|
+
waitMs: { type: 'number', description: 'How long to wait for errors (default 500ms)' },
|
|
47
|
+
},
|
|
48
|
+
required: ['pattern'],
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
export const toolNames = new Set(tools.map(t => t.name));
|
|
53
|
+
export async function execute(name, args, ctx) {
|
|
54
|
+
if (!ctx.isInitialized()) {
|
|
55
|
+
return 'Browser not initialized. Run init first.';
|
|
56
|
+
}
|
|
57
|
+
switch (name) {
|
|
58
|
+
case 'analyze':
|
|
59
|
+
return await ctx.controller.analyzeAudio();
|
|
60
|
+
case 'analyze_spectrum': {
|
|
61
|
+
const spectrum = await ctx.controller.analyzeAudio();
|
|
62
|
+
return spectrum.features || spectrum;
|
|
63
|
+
}
|
|
64
|
+
case 'analyze_rhythm':
|
|
65
|
+
return await ctx.controller.analyzeRhythm();
|
|
66
|
+
case 'detect_tempo': {
|
|
67
|
+
try {
|
|
68
|
+
const tempoAnalysis = await ctx.controller.detectTempo();
|
|
69
|
+
if (!tempoAnalysis || tempoAnalysis.bpm === 0) {
|
|
70
|
+
return {
|
|
71
|
+
bpm: 0,
|
|
72
|
+
confidence: 0,
|
|
73
|
+
message: 'No tempo detected. Ensure audio is playing and has a clear rhythmic pattern.',
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
bpm: tempoAnalysis.bpm,
|
|
78
|
+
confidence: Math.round(tempoAnalysis.confidence * 100) / 100,
|
|
79
|
+
method: tempoAnalysis.method,
|
|
80
|
+
message: `Detected ${tempoAnalysis.bpm} BPM with ${Math.round(tempoAnalysis.confidence * 100)}% confidence`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
85
|
+
return { bpm: 0, confidence: 0, error: message || 'Tempo detection failed' };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
case 'detect_key': {
|
|
89
|
+
try {
|
|
90
|
+
const keyAnalysis = await ctx.controller.detectKey();
|
|
91
|
+
if (!keyAnalysis || keyAnalysis.confidence < 0.1) {
|
|
92
|
+
return {
|
|
93
|
+
key: 'Unknown',
|
|
94
|
+
scale: 'unknown',
|
|
95
|
+
confidence: 0,
|
|
96
|
+
message: 'No clear key detected. Ensure audio is playing and has tonal content.',
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const result = {
|
|
100
|
+
key: keyAnalysis.key,
|
|
101
|
+
scale: keyAnalysis.scale,
|
|
102
|
+
confidence: Math.round(keyAnalysis.confidence * 100) / 100,
|
|
103
|
+
message: `Detected ${keyAnalysis.key} ${keyAnalysis.scale} with ${Math.round(keyAnalysis.confidence * 100)}% confidence`,
|
|
104
|
+
};
|
|
105
|
+
if (keyAnalysis.alternatives && keyAnalysis.alternatives.length > 0) {
|
|
106
|
+
result.alternatives = keyAnalysis.alternatives.map((alt) => ({
|
|
107
|
+
key: alt.key,
|
|
108
|
+
scale: alt.scale,
|
|
109
|
+
confidence: Math.round(alt.confidence * 100) / 100,
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
116
|
+
return { key: 'Unknown', scale: 'unknown', confidence: 0, error: message || 'Key detection failed' };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
case 'validate_pattern_runtime': {
|
|
120
|
+
InputValidator.validateStringLength(args.pattern, 'pattern', 10000, false);
|
|
121
|
+
const validation = await ctx.controller.validatePatternRuntime(args.pattern, args.waitMs || 500);
|
|
122
|
+
if (validation.valid) {
|
|
123
|
+
return '✅ Pattern valid - no runtime errors detected';
|
|
124
|
+
}
|
|
125
|
+
return `❌ Pattern has runtime errors:\n${validation.errors.join('\n')}\n` +
|
|
126
|
+
(validation.warnings.length > 0 ? `\nWarnings:\n${validation.warnings.join('\n')}` : '');
|
|
127
|
+
}
|
|
128
|
+
default:
|
|
129
|
+
throw new Error(`analysis module does not handle tool: ${name}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
export const analysisModule = { tools, toolNames, execute };
|