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