@williamzujkowski/live-coding-music-mcp 1.0.0 → 3.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 +661 -21
- package/README.md +228 -233
- package/dist/AudioAnalyzer.js +0 -1
- package/dist/PatternStore.js +0 -1
- package/dist/index.js +0 -0
- package/dist/server/resources.js +175 -0
- package/dist/server/server.js +226 -348
- package/dist/server/tools/ai.js +78 -22
- package/dist/server/tools/analysis.js +266 -73
- package/dist/server/tools/capture.js +67 -20
- package/dist/server/tools/compose.js +159 -0
- package/dist/server/tools/diagnostics.js +112 -60
- package/dist/server/tools/editor.js +130 -66
- package/dist/server/tools/generate.js +190 -74
- package/dist/server/tools/history.js +149 -88
- package/dist/server/tools/playback.js +49 -18
- package/dist/server/tools/session.js +108 -70
- package/dist/server/tools/storage.js +86 -30
- package/dist/server/tools/transform.js +250 -116
- package/dist/server/tools/types.js +66 -1
- package/dist/services/AudioCaptureService.js +4 -3
- package/dist/services/GeminiService.js +1 -1
- package/dist/services/MIDIExportService.js +2 -4
- package/dist/services/SessionManager.js +4 -2
- package/dist/services/StrudelEngine.js +24 -126
- package/dist/services/StrudelEngineHelpers.js +151 -0
- package/package.json +2 -2
- package/dist/__tests__/MusicTheory.test.js +0 -245
- package/dist/__tests__/PatternGenerator.test.js +0 -337
- package/dist/__tests__/PatternStore.test.js +0 -153
- package/dist/__tests__/utils/MockPlaywright.js +0 -130
- package/dist/server/EnhancedMCPServer.js +0 -654
- package/dist/server/EnhancedMCPServerFixed.js +0 -2506
package/dist/server/tools/ai.js
CHANGED
|
@@ -14,21 +14,63 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { Logger } from '../../utils/Logger.js';
|
|
16
16
|
const logger = new Logger();
|
|
17
|
+
const SESSION_ID_PROP = {
|
|
18
|
+
session_id: {
|
|
19
|
+
type: 'string',
|
|
20
|
+
description: 'Optional session ID (#108). Omit to use default session.',
|
|
21
|
+
},
|
|
22
|
+
};
|
|
17
23
|
export const tools = [
|
|
24
|
+
{
|
|
25
|
+
name: 'ai_assist',
|
|
26
|
+
description: 'Gemini-backed pattern assistance. ' +
|
|
27
|
+
'task=feedback returns creative critique on the current pattern (optionally with audio analysis). ' +
|
|
28
|
+
'task=suggest analyzes the currently playing audio and suggests a complementary Strudel pattern as text (not auto-executed). ' +
|
|
29
|
+
'task=jam generates a fresh layer (drums/bass/melody/pad/texture) and merges it into the current pattern, then auto-plays. ' +
|
|
30
|
+
'All three share Gemini auth + rate limiting. ' +
|
|
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.',
|
|
33
|
+
inputSchema: {
|
|
34
|
+
type: 'object',
|
|
35
|
+
properties: {
|
|
36
|
+
task: { type: 'string', enum: ['feedback', 'suggest', 'jam'], description: 'Which AI task' },
|
|
37
|
+
// feedback args
|
|
38
|
+
includeAudio: { type: 'boolean', description: 'task=feedback: include audio analysis (default false)' },
|
|
39
|
+
style: { type: 'string', description: 'task=feedback/suggest: style hint' },
|
|
40
|
+
// suggest args
|
|
41
|
+
role: {
|
|
42
|
+
type: 'string',
|
|
43
|
+
enum: ['complement', 'bassline', 'melody', 'percussion'],
|
|
44
|
+
description: 'task=suggest: role the suggested pattern fills (default complement)',
|
|
45
|
+
},
|
|
46
|
+
// jam args
|
|
47
|
+
layer: {
|
|
48
|
+
type: 'string',
|
|
49
|
+
enum: ['drums', 'bass', 'melody', 'pad', 'texture'],
|
|
50
|
+
description: 'task=jam: layer type to generate',
|
|
51
|
+
},
|
|
52
|
+
style_hint: { type: 'string', description: 'task=jam: style guidance' },
|
|
53
|
+
auto_play: { type: 'boolean', description: 'task=jam: start playback after merge (default true)' },
|
|
54
|
+
...SESSION_ID_PROP,
|
|
55
|
+
},
|
|
56
|
+
required: ['task'],
|
|
57
|
+
},
|
|
58
|
+
},
|
|
18
59
|
{
|
|
19
60
|
name: 'get_pattern_feedback',
|
|
20
|
-
description: 'Get AI-powered creative feedback on the current pattern using Google Gemini.
|
|
61
|
+
description: '[DEPRECATED — use ai_assist({ task: "feedback" }) instead] Get AI-powered creative feedback on the current pattern using Google Gemini.',
|
|
21
62
|
inputSchema: {
|
|
22
63
|
type: 'object',
|
|
23
64
|
properties: {
|
|
24
65
|
includeAudio: { type: 'boolean', description: 'Include audio analysis (plays pattern briefly). Default: false' },
|
|
25
66
|
style: { type: 'string', description: 'Optional style hint for context (e.g., "techno", "ambient")' },
|
|
67
|
+
...SESSION_ID_PROP,
|
|
26
68
|
},
|
|
27
69
|
},
|
|
28
70
|
},
|
|
29
71
|
{
|
|
30
72
|
name: 'suggest_pattern_from_audio',
|
|
31
|
-
description: '
|
|
73
|
+
description: '[DEPRECATED — use ai_assist({ task: "suggest" }) instead] Analyze playing audio and suggest a complementary Strudel pattern.',
|
|
32
74
|
inputSchema: {
|
|
33
75
|
type: 'object',
|
|
34
76
|
properties: {
|
|
@@ -38,12 +80,13 @@ export const tools = [
|
|
|
38
80
|
enum: ['complement', 'bassline', 'melody', 'percussion'],
|
|
39
81
|
description: 'What role the suggested pattern should fill. Default: complement',
|
|
40
82
|
},
|
|
83
|
+
...SESSION_ID_PROP,
|
|
41
84
|
},
|
|
42
85
|
},
|
|
43
86
|
},
|
|
44
87
|
{
|
|
45
88
|
name: 'jam_with',
|
|
46
|
-
description: 'AI generates a complementary layer to jam with your pattern.
|
|
89
|
+
description: '[DEPRECATED — use ai_assist({ task: "jam" }) instead] AI generates a complementary layer to jam with your pattern.',
|
|
47
90
|
inputSchema: {
|
|
48
91
|
type: 'object',
|
|
49
92
|
properties: {
|
|
@@ -54,6 +97,7 @@ export const tools = [
|
|
|
54
97
|
},
|
|
55
98
|
style_hint: { type: 'string', description: 'Optional style guidance (e.g., "funky", "minimal", "atmospheric")' },
|
|
56
99
|
auto_play: { type: 'boolean', description: 'Start playback after adding layer (default: true)' },
|
|
100
|
+
...SESSION_ID_PROP,
|
|
57
101
|
},
|
|
58
102
|
required: ['layer'],
|
|
59
103
|
},
|
|
@@ -61,13 +105,24 @@ export const tools = [
|
|
|
61
105
|
];
|
|
62
106
|
export const toolNames = new Set(tools.map(t => t.name));
|
|
63
107
|
export async function execute(name, args, ctx) {
|
|
108
|
+
const sid = args?.session_id;
|
|
64
109
|
switch (name) {
|
|
110
|
+
case 'ai_assist': {
|
|
111
|
+
const t = args?.task;
|
|
112
|
+
switch (t) {
|
|
113
|
+
case 'feedback': return await getPatternFeedback(args?.includeAudio || false, args?.style, ctx, sid);
|
|
114
|
+
case 'suggest': return await suggestPatternFromAudio(args?.style, args?.role || 'complement', ctx, sid);
|
|
115
|
+
case 'jam': return await jamWith(args.layer, args.style_hint, args.auto_play, ctx, sid);
|
|
116
|
+
default:
|
|
117
|
+
throw new Error(`Invalid task: ${t}. Must be one of: feedback, suggest, jam`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
65
120
|
case 'get_pattern_feedback':
|
|
66
|
-
return await getPatternFeedback(args?.includeAudio || false, args?.style, ctx);
|
|
121
|
+
return await getPatternFeedback(args?.includeAudio || false, args?.style, ctx, sid);
|
|
67
122
|
case 'suggest_pattern_from_audio':
|
|
68
|
-
return await suggestPatternFromAudio(args?.style, args?.role || 'complement', ctx);
|
|
123
|
+
return await suggestPatternFromAudio(args?.style, args?.role || 'complement', ctx, sid);
|
|
69
124
|
case 'jam_with':
|
|
70
|
-
return await jamWith(args.layer, args.style_hint, args.auto_play, ctx);
|
|
125
|
+
return await jamWith(args.layer, args.style_hint, args.auto_play, ctx, sid);
|
|
71
126
|
default:
|
|
72
127
|
throw new Error(`ai module does not handle tool: ${name}`);
|
|
73
128
|
}
|
|
@@ -75,14 +130,14 @@ export async function execute(name, args, ctx) {
|
|
|
75
130
|
// ---------------------------------------------------------------------------
|
|
76
131
|
// get_pattern_feedback
|
|
77
132
|
// ---------------------------------------------------------------------------
|
|
78
|
-
async function getPatternFeedback(includeAudio, style, ctx) {
|
|
133
|
+
async function getPatternFeedback(includeAudio, style, ctx, sid) {
|
|
79
134
|
if (!ctx.geminiService.isAvailable()) {
|
|
80
135
|
return {
|
|
81
136
|
gemini_available: false,
|
|
82
137
|
error: 'Gemini API not configured. Set GEMINI_API_KEY environment variable to enable AI feedback.',
|
|
83
138
|
};
|
|
84
139
|
}
|
|
85
|
-
const pattern = await ctx.getCurrentPatternSafe();
|
|
140
|
+
const pattern = await ctx.getCurrentPatternSafe(sid);
|
|
86
141
|
if (!pattern || pattern.trim().length === 0) {
|
|
87
142
|
return { gemini_available: true, error: 'No pattern to analyze. Write a pattern first.' };
|
|
88
143
|
}
|
|
@@ -98,9 +153,9 @@ async function getPatternFeedback(includeAudio, style, ctx) {
|
|
|
98
153
|
}
|
|
99
154
|
result.error = `Pattern analysis failed: ${message}`;
|
|
100
155
|
}
|
|
101
|
-
if (includeAudio && ctx.isInitialized()) {
|
|
156
|
+
if (includeAudio && (sid || ctx.isInitialized())) {
|
|
102
157
|
try {
|
|
103
|
-
const audioBlob = await captureAudioSampleForFeedback(ctx);
|
|
158
|
+
const audioBlob = await captureAudioSampleForFeedback(ctx, sid);
|
|
104
159
|
if (audioBlob) {
|
|
105
160
|
result.audio_analysis = await ctx.geminiService.analyzeAudio(audioBlob, { style, duration: 5 });
|
|
106
161
|
}
|
|
@@ -128,8 +183,8 @@ async function getPatternFeedback(includeAudio, style, ctx) {
|
|
|
128
183
|
* analyzer's AudioContext and records 5 seconds without disturbing
|
|
129
184
|
* the full AudioCaptureService lifecycle.
|
|
130
185
|
*/
|
|
131
|
-
async function captureAudioSampleForFeedback(ctx) {
|
|
132
|
-
const page = ctx.
|
|
186
|
+
async function captureAudioSampleForFeedback(ctx, sid) {
|
|
187
|
+
const page = ctx.getController(sid).page;
|
|
133
188
|
if (!page) {
|
|
134
189
|
logger.warn('Cannot capture audio: controller page not available');
|
|
135
190
|
return null;
|
|
@@ -154,7 +209,7 @@ async function captureAudioSampleForFeedback(ctx) {
|
|
|
154
209
|
try {
|
|
155
210
|
analyzer.analyser.disconnect(destination);
|
|
156
211
|
}
|
|
157
|
-
catch { }
|
|
212
|
+
catch { /* already disconnected */ }
|
|
158
213
|
if (chunks.length === 0) {
|
|
159
214
|
resolve(null);
|
|
160
215
|
return;
|
|
@@ -194,22 +249,23 @@ async function captureAudioSampleForFeedback(ctx) {
|
|
|
194
249
|
// ---------------------------------------------------------------------------
|
|
195
250
|
// suggest_pattern_from_audio
|
|
196
251
|
// ---------------------------------------------------------------------------
|
|
197
|
-
async function suggestPatternFromAudio(style, role, ctx) {
|
|
198
|
-
if (!ctx.isInitialized()) {
|
|
252
|
+
async function suggestPatternFromAudio(style, role, ctx, sid) {
|
|
253
|
+
if (!sid && !ctx.isInitialized()) {
|
|
199
254
|
return { error: 'Browser not initialized. Run init and play a pattern first.' };
|
|
200
255
|
}
|
|
201
256
|
if (!ctx.geminiService.isAvailable()) {
|
|
202
257
|
return { error: 'Gemini API not configured. Set GEMINI_API_KEY to enable AI features.' };
|
|
203
258
|
}
|
|
259
|
+
const controller = ctx.getController(sid);
|
|
204
260
|
let bpm = 0, key = 'C', scale = 'major';
|
|
205
261
|
try {
|
|
206
|
-
const tempoResult = await
|
|
262
|
+
const tempoResult = await controller.detectTempo();
|
|
207
263
|
if (tempoResult && tempoResult.bpm > 0)
|
|
208
264
|
bpm = tempoResult.bpm;
|
|
209
265
|
}
|
|
210
266
|
catch { /* best effort */ }
|
|
211
267
|
try {
|
|
212
|
-
const keyResult = await
|
|
268
|
+
const keyResult = await controller.detectKey();
|
|
213
269
|
if (keyResult && keyResult.confidence > 0.1) {
|
|
214
270
|
key = keyResult.key;
|
|
215
271
|
scale = keyResult.scale;
|
|
@@ -263,7 +319,7 @@ Example patterns:
|
|
|
263
319
|
// ---------------------------------------------------------------------------
|
|
264
320
|
// jam_with
|
|
265
321
|
// ---------------------------------------------------------------------------
|
|
266
|
-
async function jamWith(layer, styleHint, autoPlay = true, ctx) {
|
|
322
|
+
async function jamWith(layer, styleHint, autoPlay = true, ctx, sid) {
|
|
267
323
|
const validLayers = ['drums', 'bass', 'melody', 'pad', 'texture'];
|
|
268
324
|
if (!validLayers.includes(layer)) {
|
|
269
325
|
return {
|
|
@@ -274,7 +330,7 @@ async function jamWith(layer, styleHint, autoPlay = true, ctx) {
|
|
|
274
330
|
newLayer: '',
|
|
275
331
|
};
|
|
276
332
|
}
|
|
277
|
-
const currentPattern = await ctx.getCurrentPatternSafe();
|
|
333
|
+
const currentPattern = await ctx.getCurrentPatternSafe(sid);
|
|
278
334
|
if (!currentPattern || currentPattern.trim().length === 0) {
|
|
279
335
|
return {
|
|
280
336
|
success: false,
|
|
@@ -308,9 +364,9 @@ async function jamWith(layer, styleHint, autoPlay = true, ctx) {
|
|
|
308
364
|
}
|
|
309
365
|
const mergedPattern = mergeLayerIntoPattern(currentPattern, newLayer, layer);
|
|
310
366
|
try {
|
|
311
|
-
await ctx.writePatternSafe(mergedPattern);
|
|
312
|
-
if (autoPlay && ctx.isInitialized()) {
|
|
313
|
-
await ctx.
|
|
367
|
+
await ctx.writePatternSafe(mergedPattern, sid);
|
|
368
|
+
if (autoPlay && (sid || ctx.isInitialized())) {
|
|
369
|
+
await ctx.getController(sid).play();
|
|
314
370
|
}
|
|
315
371
|
return {
|
|
316
372
|
success: true,
|
|
@@ -1,40 +1,71 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* analysis domain — audio analysis + pattern
|
|
2
|
+
* analysis domain — audio analysis + pattern validation (browser + local).
|
|
3
3
|
*
|
|
4
|
-
* Owns (
|
|
5
|
-
*
|
|
4
|
+
* Owns (10 tools):
|
|
5
|
+
* browser-required: analyze, analyze_spectrum, analyze_rhythm,
|
|
6
|
+
* detect_tempo, detect_key, validate_pattern_runtime
|
|
7
|
+
* browser-free: validate_pattern_local, analyze_pattern_local,
|
|
8
|
+
* query_pattern_events, transpile_pattern
|
|
9
|
+
*
|
|
10
|
+
* The browser-free set runs against the in-process StrudelEngine and
|
|
11
|
+
* pairs naturally with validate_pattern_runtime (which monitors the
|
|
12
|
+
* actual Strudel console). They were orphan handlers — implementations
|
|
13
|
+
* present but not registered in tools/list — until #124.
|
|
6
14
|
*
|
|
7
15
|
* Post-consolidation (per #110 audit): a single `analyze` tool with
|
|
8
|
-
* an `include[]` filter absorbs the 4 detection
|
|
9
|
-
*
|
|
10
|
-
* scraping, not audio DSP.
|
|
16
|
+
* an `include[]` filter absorbs the 4 audio detection tools.
|
|
17
|
+
* Local-engine tools stay distinct — different subsystem.
|
|
11
18
|
*/
|
|
12
19
|
import { InputValidator } from '../../utils/InputValidator.js';
|
|
20
|
+
const SESSION_ID_PROP = {
|
|
21
|
+
session_id: {
|
|
22
|
+
type: 'string',
|
|
23
|
+
description: 'Optional session ID (#108). Omit to use default session.',
|
|
24
|
+
},
|
|
25
|
+
};
|
|
13
26
|
export const tools = [
|
|
14
27
|
{
|
|
15
28
|
name: 'analyze',
|
|
16
|
-
description: '
|
|
17
|
-
|
|
29
|
+
description: 'Audio analysis on the currently-playing pattern. ' +
|
|
30
|
+
'include=["all"] (default) returns the full spectrum + features object, matching the pre-consolidation behaviour. ' +
|
|
31
|
+
'include=["tempo"] returns only BPM with confidence. ' +
|
|
32
|
+
'include=["key"] returns detected key + scale + confidence. ' +
|
|
33
|
+
'include=["spectrum"] returns FFT features (bass, mid, treble, brightness, etc.). ' +
|
|
34
|
+
'include=["rhythm"] returns rhythm-only analysis (complexity, density, syncopation). ' +
|
|
35
|
+
'Combining values returns an object keyed by category, e.g. analyze({ include: ["tempo", "key"] }) → { tempo: {...}, key: {...} }. ' +
|
|
36
|
+
'Example: analyze({ include: ["tempo"] }) to cheaply re-check BPM during a session. ' +
|
|
37
|
+
'For static pattern analysis (no browser) use analyze_pattern_local; for runtime validation use validate_pattern_runtime.',
|
|
38
|
+
inputSchema: {
|
|
39
|
+
type: 'object',
|
|
40
|
+
properties: {
|
|
41
|
+
include: {
|
|
42
|
+
type: 'array',
|
|
43
|
+
items: { type: 'string', enum: ['all', 'spectrum', 'rhythm', 'tempo', 'key'] },
|
|
44
|
+
description: 'Which analyses to return. Default ["all"] preserves pre-consolidation behaviour.',
|
|
45
|
+
},
|
|
46
|
+
...SESSION_ID_PROP,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
18
49
|
},
|
|
19
50
|
{
|
|
20
51
|
name: 'analyze_spectrum',
|
|
21
|
-
description: 'FFT spectrum analysis',
|
|
22
|
-
inputSchema: { type: 'object', properties: {} },
|
|
52
|
+
description: '[DEPRECATED — use analyze({ include: ["spectrum"] }) instead] FFT spectrum analysis',
|
|
53
|
+
inputSchema: { type: 'object', properties: { ...SESSION_ID_PROP } },
|
|
23
54
|
},
|
|
24
55
|
{
|
|
25
56
|
name: 'analyze_rhythm',
|
|
26
|
-
description: 'Rhythm analysis',
|
|
27
|
-
inputSchema: { type: 'object', properties: {} },
|
|
57
|
+
description: '[DEPRECATED — use analyze({ include: ["rhythm"] }) instead] Rhythm analysis',
|
|
58
|
+
inputSchema: { type: 'object', properties: { ...SESSION_ID_PROP } },
|
|
28
59
|
},
|
|
29
60
|
{
|
|
30
61
|
name: 'detect_tempo',
|
|
31
|
-
description: 'BPM detection',
|
|
32
|
-
inputSchema: { type: 'object', properties: {} },
|
|
62
|
+
description: '[DEPRECATED — use analyze({ include: ["tempo"] }) instead] BPM detection',
|
|
63
|
+
inputSchema: { type: 'object', properties: { ...SESSION_ID_PROP } },
|
|
33
64
|
},
|
|
34
65
|
{
|
|
35
66
|
name: 'detect_key',
|
|
36
|
-
description: 'Key detection',
|
|
37
|
-
inputSchema: { type: 'object', properties: {} },
|
|
67
|
+
description: '[DEPRECATED — use analyze({ include: ["key"] }) instead] Key detection',
|
|
68
|
+
inputSchema: { type: 'object', properties: { ...SESSION_ID_PROP } },
|
|
38
69
|
},
|
|
39
70
|
{
|
|
40
71
|
name: 'validate_pattern_runtime',
|
|
@@ -44,86 +75,248 @@ export const tools = [
|
|
|
44
75
|
properties: {
|
|
45
76
|
pattern: { type: 'string', description: 'Pattern code to validate' },
|
|
46
77
|
waitMs: { type: 'number', description: 'How long to wait for errors (default 500ms)' },
|
|
78
|
+
...SESSION_ID_PROP,
|
|
79
|
+
},
|
|
80
|
+
required: ['pattern'],
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: 'validate_pattern_local',
|
|
85
|
+
description: 'Validate pattern syntax against the in-process StrudelEngine (no browser required)',
|
|
86
|
+
inputSchema: {
|
|
87
|
+
type: 'object',
|
|
88
|
+
properties: {
|
|
89
|
+
pattern: { type: 'string', description: 'Pattern code to validate' },
|
|
90
|
+
},
|
|
91
|
+
required: ['pattern'],
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: 'analyze_pattern_local',
|
|
96
|
+
description: 'Static analysis (events/cycle, complexity, optional BPM) without browser playback',
|
|
97
|
+
inputSchema: {
|
|
98
|
+
type: 'object',
|
|
99
|
+
properties: {
|
|
100
|
+
pattern: { type: 'string', description: 'Pattern code to analyze' },
|
|
101
|
+
},
|
|
102
|
+
required: ['pattern'],
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: 'query_pattern_events',
|
|
107
|
+
description: 'Enumerate events the pattern would emit between two cycle indices (max 16 cycles)',
|
|
108
|
+
inputSchema: {
|
|
109
|
+
type: 'object',
|
|
110
|
+
properties: {
|
|
111
|
+
pattern: { type: 'string', description: 'Pattern code to query' },
|
|
112
|
+
start: { type: 'number', description: 'Start cycle (default 0)' },
|
|
113
|
+
end: { type: 'number', description: 'End cycle (default 1)' },
|
|
114
|
+
},
|
|
115
|
+
required: ['pattern'],
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: 'transpile_pattern',
|
|
120
|
+
description: 'Transpile pattern source via StrudelEngine; returns transpiled code or syntax error',
|
|
121
|
+
inputSchema: {
|
|
122
|
+
type: 'object',
|
|
123
|
+
properties: {
|
|
124
|
+
pattern: { type: 'string', description: 'Pattern code to transpile' },
|
|
47
125
|
},
|
|
48
126
|
required: ['pattern'],
|
|
49
127
|
},
|
|
50
128
|
},
|
|
51
129
|
];
|
|
52
130
|
export const toolNames = new Set(tools.map(t => t.name));
|
|
131
|
+
// Tools in this module that do NOT require a browser session.
|
|
132
|
+
const LOCAL_ENGINE_TOOLS = new Set([
|
|
133
|
+
'validate_pattern_local',
|
|
134
|
+
'analyze_pattern_local',
|
|
135
|
+
'query_pattern_events',
|
|
136
|
+
'transpile_pattern',
|
|
137
|
+
]);
|
|
138
|
+
async function getSpectrum(controller) {
|
|
139
|
+
const spectrum = await controller.analyzeAudio();
|
|
140
|
+
return spectrum.features || spectrum;
|
|
141
|
+
}
|
|
142
|
+
async function getRhythm(controller) {
|
|
143
|
+
return await controller.analyzeRhythm();
|
|
144
|
+
}
|
|
145
|
+
async function getTempo(controller) {
|
|
146
|
+
try {
|
|
147
|
+
const tempoAnalysis = await controller.detectTempo();
|
|
148
|
+
if (!tempoAnalysis || tempoAnalysis.bpm === 0) {
|
|
149
|
+
return {
|
|
150
|
+
bpm: 0,
|
|
151
|
+
confidence: 0,
|
|
152
|
+
message: 'No tempo detected. Ensure audio is playing and has a clear rhythmic pattern.',
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
bpm: tempoAnalysis.bpm,
|
|
157
|
+
confidence: Math.round(tempoAnalysis.confidence * 100) / 100,
|
|
158
|
+
method: tempoAnalysis.method,
|
|
159
|
+
message: `Detected ${tempoAnalysis.bpm} BPM with ${Math.round(tempoAnalysis.confidence * 100)}% confidence`,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
164
|
+
return { bpm: 0, confidence: 0, error: message || 'Tempo detection failed' };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
async function getKey(controller) {
|
|
168
|
+
try {
|
|
169
|
+
const keyAnalysis = await controller.detectKey();
|
|
170
|
+
if (!keyAnalysis || keyAnalysis.confidence < 0.1) {
|
|
171
|
+
return {
|
|
172
|
+
key: 'Unknown',
|
|
173
|
+
scale: 'unknown',
|
|
174
|
+
confidence: 0,
|
|
175
|
+
message: 'No clear key detected. Ensure audio is playing and has tonal content.',
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
const result = {
|
|
179
|
+
key: keyAnalysis.key,
|
|
180
|
+
scale: keyAnalysis.scale,
|
|
181
|
+
confidence: Math.round(keyAnalysis.confidence * 100) / 100,
|
|
182
|
+
message: `Detected ${keyAnalysis.key} ${keyAnalysis.scale} with ${Math.round(keyAnalysis.confidence * 100)}% confidence`,
|
|
183
|
+
};
|
|
184
|
+
if (keyAnalysis.alternatives && keyAnalysis.alternatives.length > 0) {
|
|
185
|
+
result.alternatives = keyAnalysis.alternatives.map((alt) => ({
|
|
186
|
+
key: alt.key,
|
|
187
|
+
scale: alt.scale,
|
|
188
|
+
confidence: Math.round(alt.confidence * 100) / 100,
|
|
189
|
+
}));
|
|
190
|
+
}
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
195
|
+
return { key: 'Unknown', scale: 'unknown', confidence: 0, error: message || 'Key detection failed' };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async function doAnalyze(args, controller) {
|
|
199
|
+
const includeRaw = args?.include;
|
|
200
|
+
const include = Array.isArray(includeRaw) && includeRaw.length > 0
|
|
201
|
+
? includeRaw
|
|
202
|
+
: ['all'];
|
|
203
|
+
for (const v of include) {
|
|
204
|
+
if (!['all', 'spectrum', 'rhythm', 'tempo', 'key'].includes(v)) {
|
|
205
|
+
throw new Error(`Invalid include value: ${v}. Must be one of: all, spectrum, rhythm, tempo, key`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// include=['all'] is the most-common path and returns the unwrapped
|
|
209
|
+
// analyzeAudio result (no schema surprise vs old `analyze`).
|
|
210
|
+
if (include.includes('all')) {
|
|
211
|
+
return await controller.analyzeAudio();
|
|
212
|
+
}
|
|
213
|
+
// Otherwise return an object keyed by category, computed in parallel.
|
|
214
|
+
const tasks = {};
|
|
215
|
+
if (include.includes('spectrum'))
|
|
216
|
+
tasks.spectrum = getSpectrum(controller);
|
|
217
|
+
if (include.includes('rhythm'))
|
|
218
|
+
tasks.rhythm = getRhythm(controller);
|
|
219
|
+
if (include.includes('tempo'))
|
|
220
|
+
tasks.tempo = getTempo(controller);
|
|
221
|
+
if (include.includes('key'))
|
|
222
|
+
tasks.key = getKey(controller);
|
|
223
|
+
const entries = await Promise.all(Object.entries(tasks).map(async ([k, p]) => [k, await p]));
|
|
224
|
+
return Object.fromEntries(entries);
|
|
225
|
+
}
|
|
53
226
|
export async function execute(name, args, ctx) {
|
|
54
|
-
|
|
227
|
+
const sid = args?.session_id;
|
|
228
|
+
if (!LOCAL_ENGINE_TOOLS.has(name) && !sid && !ctx.isInitialized()) {
|
|
55
229
|
return 'Browser not initialized. Run init first.';
|
|
56
230
|
}
|
|
231
|
+
const controller = LOCAL_ENGINE_TOOLS.has(name) ? null : ctx.getController(sid);
|
|
57
232
|
switch (name) {
|
|
58
233
|
case 'analyze':
|
|
59
|
-
return await
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
234
|
+
return await doAnalyze(args, controller);
|
|
235
|
+
// Deprecated aliases — forward to consolidated handler.
|
|
236
|
+
case 'analyze_spectrum': return await getSpectrum(controller);
|
|
237
|
+
case 'analyze_rhythm': return await getRhythm(controller);
|
|
238
|
+
case 'detect_tempo': return await getTempo(controller);
|
|
239
|
+
case 'detect_key': return await getKey(controller);
|
|
240
|
+
case 'validate_pattern_runtime': {
|
|
241
|
+
InputValidator.validateStringLength(args.pattern, 'pattern', 10000, false);
|
|
242
|
+
const validation = await controller.validatePatternRuntime(args.pattern, args.waitMs || 500);
|
|
243
|
+
if (validation.valid) {
|
|
244
|
+
return '✅ Pattern valid - no runtime errors detected';
|
|
245
|
+
}
|
|
246
|
+
return `❌ Pattern has runtime errors:\n${validation.errors.join('\n')}\n` +
|
|
247
|
+
(validation.warnings.length > 0 ? `\nWarnings:\n${validation.warnings.join('\n')}` : '');
|
|
63
248
|
}
|
|
64
|
-
case '
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
249
|
+
case 'validate_pattern_local': {
|
|
250
|
+
InputValidator.validateStringLength(args.pattern, 'pattern', 10000, false);
|
|
251
|
+
const localValidation = ctx.strudelEngine.validate(args.pattern);
|
|
252
|
+
return {
|
|
253
|
+
valid: localValidation.valid,
|
|
254
|
+
errors: localValidation.errors,
|
|
255
|
+
warnings: localValidation.warnings,
|
|
256
|
+
suggestions: localValidation.suggestions,
|
|
257
|
+
errorLocation: localValidation.errorLocation,
|
|
258
|
+
message: localValidation.valid
|
|
259
|
+
? '✅ Pattern is valid'
|
|
260
|
+
: `❌ Pattern has ${localValidation.errors.length} error(s)`,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
case 'analyze_pattern_local': {
|
|
264
|
+
InputValidator.validateStringLength(args.pattern, 'pattern', 10000, false);
|
|
265
|
+
const patternMetadata = ctx.strudelEngine.analyzePattern(args.pattern);
|
|
266
|
+
return {
|
|
267
|
+
...patternMetadata,
|
|
268
|
+
message: `Pattern analysis: ${patternMetadata.eventsPerCycle} events/cycle, ` +
|
|
269
|
+
`complexity ${(patternMetadata.complexity * 100).toFixed(0)}%` +
|
|
270
|
+
(patternMetadata.bpm ? `, ${patternMetadata.bpm} BPM` : ''),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
case 'query_pattern_events': {
|
|
274
|
+
InputValidator.validateStringLength(args.pattern, 'pattern', 10000, false);
|
|
275
|
+
const startCycle = args.start ?? 0;
|
|
276
|
+
const endCycle = args.end ?? 1;
|
|
277
|
+
if (startCycle >= endCycle) {
|
|
278
|
+
return { error: 'Start must be less than end' };
|
|
82
279
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
return { bpm: 0, confidence: 0, error: message || 'Tempo detection failed' };
|
|
280
|
+
if (endCycle - startCycle > 16) {
|
|
281
|
+
return { error: 'Maximum range is 16 cycles to prevent excessive output' };
|
|
86
282
|
}
|
|
87
|
-
}
|
|
88
|
-
case 'detect_key': {
|
|
89
283
|
try {
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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`,
|
|
284
|
+
const events = ctx.strudelEngine.queryEvents(args.pattern, startCycle, endCycle);
|
|
285
|
+
return {
|
|
286
|
+
count: events.length,
|
|
287
|
+
range: { start: startCycle, end: endCycle },
|
|
288
|
+
events: events.map((e) => ({
|
|
289
|
+
value: e.value,
|
|
290
|
+
start: e.start,
|
|
291
|
+
end: e.end,
|
|
292
|
+
duration: e.end - e.start,
|
|
293
|
+
})),
|
|
104
294
|
};
|
|
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
295
|
}
|
|
114
296
|
catch (error) {
|
|
115
297
|
const message = error instanceof Error ? error.message : String(error);
|
|
116
|
-
return {
|
|
298
|
+
return {
|
|
299
|
+
error: message,
|
|
300
|
+
suggestion: 'Check pattern syntax with validate_pattern_local first',
|
|
301
|
+
};
|
|
117
302
|
}
|
|
118
303
|
}
|
|
119
|
-
case '
|
|
304
|
+
case 'transpile_pattern': {
|
|
120
305
|
InputValidator.validateStringLength(args.pattern, 'pattern', 10000, false);
|
|
121
|
-
const
|
|
122
|
-
if (
|
|
123
|
-
return
|
|
306
|
+
const transpileResult = ctx.strudelEngine.transpile(args.pattern);
|
|
307
|
+
if (transpileResult.success) {
|
|
308
|
+
return {
|
|
309
|
+
success: true,
|
|
310
|
+
transpiledCode: transpileResult.transpiledCode,
|
|
311
|
+
message: 'Pattern transpiled successfully',
|
|
312
|
+
};
|
|
124
313
|
}
|
|
125
|
-
return
|
|
126
|
-
|
|
314
|
+
return {
|
|
315
|
+
success: false,
|
|
316
|
+
error: transpileResult.error,
|
|
317
|
+
errorLocation: transpileResult.errorLocation,
|
|
318
|
+
message: 'Transpilation failed',
|
|
319
|
+
};
|
|
127
320
|
}
|
|
128
321
|
default:
|
|
129
322
|
throw new Error(`analysis module does not handle tool: ${name}`);
|