@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,2506 @@
|
|
|
1
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
3
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
4
|
+
import { StrudelController } from '../StrudelController.js';
|
|
5
|
+
import { PatternStore } from '../PatternStore.js';
|
|
6
|
+
import { MusicTheory } from '../services/MusicTheory.js';
|
|
7
|
+
import { PatternGenerator } from '../services/PatternGenerator.js';
|
|
8
|
+
import { GeminiService } from '../services/GeminiService.js';
|
|
9
|
+
import { AudioCaptureService } from '../services/AudioCaptureService.js';
|
|
10
|
+
import { MIDIExportService } from '../services/MIDIExportService.js';
|
|
11
|
+
import { SessionManager } from '../services/SessionManager.js';
|
|
12
|
+
import { readFileSync, existsSync } from 'fs';
|
|
13
|
+
import { Logger } from '../utils/Logger.js';
|
|
14
|
+
import { PerformanceMonitor } from '../utils/PerformanceMonitor.js';
|
|
15
|
+
import { InputValidator } from '../utils/InputValidator.js';
|
|
16
|
+
import { StrudelEngine } from '../services/StrudelEngine.js';
|
|
17
|
+
const configPath = './config.json';
|
|
18
|
+
const config = existsSync(configPath)
|
|
19
|
+
? JSON.parse(readFileSync(configPath, 'utf-8'))
|
|
20
|
+
: { headless: false };
|
|
21
|
+
/** Energy level presets for pattern energy adjustments (#81) */
|
|
22
|
+
const ENERGY_LEVELS = {
|
|
23
|
+
0: { tempoAdjust: -20, roomAmount: 0.5, densityAdjust: '.slow(4)', description: 'minimal/ambient' },
|
|
24
|
+
1: { tempoAdjust: -15, roomAmount: 0.4, densityAdjust: '.slow(3)', description: 'very sparse' },
|
|
25
|
+
2: { tempoAdjust: -10, roomAmount: 0.3, densityAdjust: '.slow(2)', description: 'sparse' },
|
|
26
|
+
3: { tempoAdjust: -5, roomAmount: 0.2, densityAdjust: '.slow(1.5)', description: 'light' },
|
|
27
|
+
4: { tempoAdjust: 0, roomAmount: 0.15, densityAdjust: '', description: 'relaxed' },
|
|
28
|
+
5: { tempoAdjust: 0, roomAmount: 0.1, densityAdjust: '', description: 'normal' },
|
|
29
|
+
6: { tempoAdjust: 5, roomAmount: 0.08, densityAdjust: '', description: 'moderate' },
|
|
30
|
+
7: { tempoAdjust: 10, roomAmount: 0.05, densityAdjust: '.fast(1.25)', description: 'driving' },
|
|
31
|
+
8: { tempoAdjust: 15, roomAmount: 0.03, densityAdjust: '.fast(1.5)', description: 'intense' },
|
|
32
|
+
9: { tempoAdjust: 18, roomAmount: 0.02, densityAdjust: '.fast(1.75)', description: 'very intense' },
|
|
33
|
+
10: { tempoAdjust: 20, roomAmount: 0.01, densityAdjust: '.fast(2)', description: 'maximum' }
|
|
34
|
+
};
|
|
35
|
+
/** Mood profiles for emotional transformations (#80) */
|
|
36
|
+
const MOOD_PROFILES = {
|
|
37
|
+
dark: { preferMinor: true, tempoMod: -0.1, cutoffMod: -200, roomMod: 0.2, gainMod: -0.1, noteShift: -12 },
|
|
38
|
+
euphoric: { preferMinor: false, tempoMod: 0.1, cutoffMod: 400, roomMod: 0.1, gainMod: 0.1, noteShift: 12 },
|
|
39
|
+
melancholic: { preferMinor: true, tempoMod: -0.15, cutoffMod: -100, roomMod: 0.3, gainMod: -0.05, noteShift: 0 },
|
|
40
|
+
aggressive: { preferMinor: false, tempoMod: 0.15, cutoffMod: 600, roomMod: -0.1, gainMod: 0.15, noteShift: 0 },
|
|
41
|
+
dreamy: { preferMinor: false, tempoMod: -0.2, cutoffMod: -300, roomMod: 0.4, delayMod: 0.3, gainMod: -0.1, noteShift: 0 },
|
|
42
|
+
peaceful: { preferMinor: false, tempoMod: -0.25, cutoffMod: -200, roomMod: 0.25, gainMod: -0.15, noteShift: 0 },
|
|
43
|
+
energetic: { preferMinor: false, tempoMod: 0.2, cutoffMod: 300, roomMod: 0, gainMod: 0.1, noteShift: 0 }
|
|
44
|
+
};
|
|
45
|
+
export class EnhancedMCPServerFixed {
|
|
46
|
+
server;
|
|
47
|
+
controller;
|
|
48
|
+
store;
|
|
49
|
+
theory;
|
|
50
|
+
generator;
|
|
51
|
+
geminiService;
|
|
52
|
+
audioCaptureService = null;
|
|
53
|
+
midiExportService;
|
|
54
|
+
sessionManager;
|
|
55
|
+
logger;
|
|
56
|
+
perfMonitor;
|
|
57
|
+
strudelEngine;
|
|
58
|
+
sessionHistory = [];
|
|
59
|
+
undoStack = [];
|
|
60
|
+
redoStack = [];
|
|
61
|
+
/** Pattern history with metadata for browsing (#41) */
|
|
62
|
+
historyStack = [];
|
|
63
|
+
historyIdCounter = 0;
|
|
64
|
+
/** Maximum history entries to prevent memory leaks */
|
|
65
|
+
MAX_HISTORY = 100;
|
|
66
|
+
isInitialized = false;
|
|
67
|
+
generatedPatterns = new Map();
|
|
68
|
+
constructor() {
|
|
69
|
+
this.server = new Server({
|
|
70
|
+
name: 'strudel-mcp-enhanced',
|
|
71
|
+
version: '2.0.1',
|
|
72
|
+
}, {
|
|
73
|
+
capabilities: {
|
|
74
|
+
tools: {},
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
this.controller = new StrudelController(config.headless);
|
|
78
|
+
this.store = new PatternStore('./patterns');
|
|
79
|
+
this.theory = new MusicTheory();
|
|
80
|
+
this.generator = new PatternGenerator();
|
|
81
|
+
this.geminiService = new GeminiService();
|
|
82
|
+
this.midiExportService = new MIDIExportService();
|
|
83
|
+
this.sessionManager = new SessionManager(config.headless);
|
|
84
|
+
this.logger = new Logger();
|
|
85
|
+
this.perfMonitor = new PerformanceMonitor();
|
|
86
|
+
this.strudelEngine = new StrudelEngine();
|
|
87
|
+
this.setupHandlers();
|
|
88
|
+
}
|
|
89
|
+
getTools() {
|
|
90
|
+
// Same tools as before - keeping the same structure
|
|
91
|
+
return [
|
|
92
|
+
// Core Control Tools (10)
|
|
93
|
+
{
|
|
94
|
+
name: 'init',
|
|
95
|
+
description: 'Initialize Strudel in browser',
|
|
96
|
+
inputSchema: { type: 'object', properties: {} }
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: 'write',
|
|
100
|
+
description: 'Write pattern to editor with optional auto-play and validation',
|
|
101
|
+
inputSchema: {
|
|
102
|
+
type: 'object',
|
|
103
|
+
properties: {
|
|
104
|
+
pattern: { type: 'string', description: 'Pattern code' },
|
|
105
|
+
auto_play: { type: 'boolean', description: 'Start playback immediately after writing (default: false)' },
|
|
106
|
+
validate: { type: 'boolean', description: 'Validate pattern before writing (default: true)' }
|
|
107
|
+
},
|
|
108
|
+
required: ['pattern']
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
name: 'append',
|
|
113
|
+
description: 'Append code to current pattern',
|
|
114
|
+
inputSchema: {
|
|
115
|
+
type: 'object',
|
|
116
|
+
properties: {
|
|
117
|
+
code: { type: 'string', description: 'Code to append' }
|
|
118
|
+
},
|
|
119
|
+
required: ['code']
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
name: 'insert',
|
|
124
|
+
description: 'Insert code at specific line',
|
|
125
|
+
inputSchema: {
|
|
126
|
+
type: 'object',
|
|
127
|
+
properties: {
|
|
128
|
+
position: { type: 'number', description: 'Line number' },
|
|
129
|
+
code: { type: 'string', description: 'Code to insert' }
|
|
130
|
+
},
|
|
131
|
+
required: ['position', 'code']
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: 'replace',
|
|
136
|
+
description: 'Replace pattern section',
|
|
137
|
+
inputSchema: {
|
|
138
|
+
type: 'object',
|
|
139
|
+
properties: {
|
|
140
|
+
search: { type: 'string', description: 'Text to replace' },
|
|
141
|
+
replace: { type: 'string', description: 'Replacement text' }
|
|
142
|
+
},
|
|
143
|
+
required: ['search', 'replace']
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
name: 'play',
|
|
148
|
+
description: 'Start playing pattern',
|
|
149
|
+
inputSchema: { type: 'object', properties: {} }
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
name: 'pause',
|
|
153
|
+
description: 'Pause playback',
|
|
154
|
+
inputSchema: { type: 'object', properties: {} }
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
name: 'stop',
|
|
158
|
+
description: 'Stop playback',
|
|
159
|
+
inputSchema: { type: 'object', properties: {} }
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: 'clear',
|
|
163
|
+
description: 'Clear the editor',
|
|
164
|
+
inputSchema: { type: 'object', properties: {} }
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: 'get_pattern',
|
|
168
|
+
description: 'Get current pattern code',
|
|
169
|
+
inputSchema: { type: 'object', properties: {} }
|
|
170
|
+
},
|
|
171
|
+
// Pattern Manipulation (10)
|
|
172
|
+
{
|
|
173
|
+
name: 'transpose',
|
|
174
|
+
description: 'Transpose notes by semitones',
|
|
175
|
+
inputSchema: {
|
|
176
|
+
type: 'object',
|
|
177
|
+
properties: {
|
|
178
|
+
semitones: { type: 'number', description: 'Semitones to transpose' }
|
|
179
|
+
},
|
|
180
|
+
required: ['semitones']
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
name: 'reverse',
|
|
185
|
+
description: 'Reverse pattern',
|
|
186
|
+
inputSchema: { type: 'object', properties: {} }
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
name: 'stretch',
|
|
190
|
+
description: 'Time stretch pattern',
|
|
191
|
+
inputSchema: {
|
|
192
|
+
type: 'object',
|
|
193
|
+
properties: {
|
|
194
|
+
factor: { type: 'number', description: 'Stretch factor' }
|
|
195
|
+
},
|
|
196
|
+
required: ['factor']
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
name: 'quantize',
|
|
201
|
+
description: 'Quantize to grid',
|
|
202
|
+
inputSchema: {
|
|
203
|
+
type: 'object',
|
|
204
|
+
properties: {
|
|
205
|
+
grid: { type: 'string', description: 'Grid size (e.g., "1/16")' }
|
|
206
|
+
},
|
|
207
|
+
required: ['grid']
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
name: 'humanize',
|
|
212
|
+
description: 'Add human timing variation',
|
|
213
|
+
inputSchema: {
|
|
214
|
+
type: 'object',
|
|
215
|
+
properties: {
|
|
216
|
+
amount: { type: 'number', description: 'Humanization amount (0-1)' }
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
name: 'generate_variation',
|
|
222
|
+
description: 'Create pattern variations',
|
|
223
|
+
inputSchema: {
|
|
224
|
+
type: 'object',
|
|
225
|
+
properties: {
|
|
226
|
+
type: { type: 'string', description: 'Variation type (subtle/moderate/extreme/glitch/evolving)' }
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
name: 'generate_pattern',
|
|
232
|
+
description: 'Generate complete pattern from style with optional auto-play',
|
|
233
|
+
inputSchema: {
|
|
234
|
+
type: 'object',
|
|
235
|
+
properties: {
|
|
236
|
+
style: { type: 'string', description: 'Music style (techno/house/dnb/ambient/etc)' },
|
|
237
|
+
key: { type: 'string', description: 'Musical key' },
|
|
238
|
+
bpm: { type: 'number', description: 'Tempo in BPM' },
|
|
239
|
+
auto_play: { type: 'boolean', description: 'Start playback immediately (default: false)' }
|
|
240
|
+
},
|
|
241
|
+
required: ['style']
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
name: 'generate_drums',
|
|
246
|
+
description: 'Generate drum pattern',
|
|
247
|
+
inputSchema: {
|
|
248
|
+
type: 'object',
|
|
249
|
+
properties: {
|
|
250
|
+
style: { type: 'string', description: 'Drum style' },
|
|
251
|
+
complexity: { type: 'number', description: 'Complexity (0-1)' }
|
|
252
|
+
},
|
|
253
|
+
required: ['style']
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
name: 'generate_bassline',
|
|
258
|
+
description: 'Generate bassline',
|
|
259
|
+
inputSchema: {
|
|
260
|
+
type: 'object',
|
|
261
|
+
properties: {
|
|
262
|
+
key: { type: 'string', description: 'Musical key' },
|
|
263
|
+
style: { type: 'string', description: 'Bass style' }
|
|
264
|
+
},
|
|
265
|
+
required: ['key', 'style']
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
name: 'generate_melody',
|
|
270
|
+
description: 'Generate melody from scale',
|
|
271
|
+
inputSchema: {
|
|
272
|
+
type: 'object',
|
|
273
|
+
properties: {
|
|
274
|
+
scale: { type: 'string', description: 'Scale name' },
|
|
275
|
+
root: { type: 'string', description: 'Root note' },
|
|
276
|
+
length: { type: 'number', description: 'Number of notes' }
|
|
277
|
+
},
|
|
278
|
+
required: ['scale', 'root']
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
// Audio Analysis (5)
|
|
282
|
+
{
|
|
283
|
+
name: 'analyze',
|
|
284
|
+
description: 'Complete audio analysis',
|
|
285
|
+
inputSchema: { type: 'object', properties: {} }
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
name: 'analyze_spectrum',
|
|
289
|
+
description: 'FFT spectrum analysis',
|
|
290
|
+
inputSchema: { type: 'object', properties: {} }
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
name: 'analyze_rhythm',
|
|
294
|
+
description: 'Rhythm analysis',
|
|
295
|
+
inputSchema: { type: 'object', properties: {} }
|
|
296
|
+
},
|
|
297
|
+
{
|
|
298
|
+
name: 'detect_tempo',
|
|
299
|
+
description: 'BPM detection',
|
|
300
|
+
inputSchema: { type: 'object', properties: {} }
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
name: 'detect_key',
|
|
304
|
+
description: 'Key detection',
|
|
305
|
+
inputSchema: { type: 'object', properties: {} }
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
name: 'validate_pattern_runtime',
|
|
309
|
+
description: 'Validate pattern with runtime error checking (monitors Strudel console for errors)',
|
|
310
|
+
inputSchema: {
|
|
311
|
+
type: 'object',
|
|
312
|
+
properties: {
|
|
313
|
+
pattern: { type: 'string', description: 'Pattern code to validate' },
|
|
314
|
+
waitMs: { type: 'number', description: 'How long to wait for errors (default 500ms)' }
|
|
315
|
+
},
|
|
316
|
+
required: ['pattern']
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
// Effects & Processing (5)
|
|
320
|
+
{
|
|
321
|
+
name: 'add_effect',
|
|
322
|
+
description: 'Add effect to pattern',
|
|
323
|
+
inputSchema: {
|
|
324
|
+
type: 'object',
|
|
325
|
+
properties: {
|
|
326
|
+
effect: { type: 'string', description: 'Effect name' },
|
|
327
|
+
params: { type: 'string', description: 'Effect parameters' }
|
|
328
|
+
},
|
|
329
|
+
required: ['effect']
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
name: 'remove_effect',
|
|
334
|
+
description: 'Remove effect',
|
|
335
|
+
inputSchema: {
|
|
336
|
+
type: 'object',
|
|
337
|
+
properties: {
|
|
338
|
+
effect: { type: 'string', description: 'Effect to remove' }
|
|
339
|
+
},
|
|
340
|
+
required: ['effect']
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
name: 'set_tempo',
|
|
345
|
+
description: 'Set BPM',
|
|
346
|
+
inputSchema: {
|
|
347
|
+
type: 'object',
|
|
348
|
+
properties: {
|
|
349
|
+
bpm: { type: 'number', description: 'Tempo in BPM' }
|
|
350
|
+
},
|
|
351
|
+
required: ['bpm']
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
name: 'add_swing',
|
|
356
|
+
description: 'Add swing to pattern',
|
|
357
|
+
inputSchema: {
|
|
358
|
+
type: 'object',
|
|
359
|
+
properties: {
|
|
360
|
+
amount: { type: 'number', description: 'Swing amount (0-1)' }
|
|
361
|
+
},
|
|
362
|
+
required: ['amount']
|
|
363
|
+
}
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
name: 'apply_scale',
|
|
367
|
+
description: 'Apply scale to notes',
|
|
368
|
+
inputSchema: {
|
|
369
|
+
type: 'object',
|
|
370
|
+
properties: {
|
|
371
|
+
scale: { type: 'string', description: 'Scale name' },
|
|
372
|
+
root: { type: 'string', description: 'Root note' }
|
|
373
|
+
},
|
|
374
|
+
required: ['scale', 'root']
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
// Mood Transformation Tool (#80)
|
|
378
|
+
{
|
|
379
|
+
name: 'shift_mood',
|
|
380
|
+
description: 'Transform current pattern to match a different emotional mood by adjusting tempo, effects, and note choices. Moods: dark, euphoric, melancholic, aggressive, dreamy, peaceful, energetic.',
|
|
381
|
+
inputSchema: {
|
|
382
|
+
type: 'object',
|
|
383
|
+
properties: {
|
|
384
|
+
target_mood: {
|
|
385
|
+
type: 'string',
|
|
386
|
+
enum: ['dark', 'euphoric', 'melancholic', 'aggressive', 'dreamy', 'peaceful', 'energetic'],
|
|
387
|
+
description: 'Target mood'
|
|
388
|
+
},
|
|
389
|
+
intensity: {
|
|
390
|
+
type: 'number',
|
|
391
|
+
minimum: 0,
|
|
392
|
+
maximum: 1,
|
|
393
|
+
description: 'How strongly to apply the mood transformation (0-1, default: 0.5)'
|
|
394
|
+
},
|
|
395
|
+
auto_play: {
|
|
396
|
+
type: 'boolean',
|
|
397
|
+
description: 'Start playback after transformation (default: true)'
|
|
398
|
+
}
|
|
399
|
+
},
|
|
400
|
+
required: ['target_mood']
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
// Session Management (5)
|
|
404
|
+
{
|
|
405
|
+
name: 'save',
|
|
406
|
+
description: 'Save pattern with metadata',
|
|
407
|
+
inputSchema: {
|
|
408
|
+
type: 'object',
|
|
409
|
+
properties: {
|
|
410
|
+
name: { type: 'string', description: 'Pattern name' },
|
|
411
|
+
tags: { type: 'array', items: { type: 'string' } }
|
|
412
|
+
},
|
|
413
|
+
required: ['name']
|
|
414
|
+
}
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
name: 'load',
|
|
418
|
+
description: 'Load saved pattern',
|
|
419
|
+
inputSchema: {
|
|
420
|
+
type: 'object',
|
|
421
|
+
properties: {
|
|
422
|
+
name: { type: 'string', description: 'Pattern name' }
|
|
423
|
+
},
|
|
424
|
+
required: ['name']
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
name: 'list',
|
|
429
|
+
description: 'List saved patterns',
|
|
430
|
+
inputSchema: {
|
|
431
|
+
type: 'object',
|
|
432
|
+
properties: {
|
|
433
|
+
tag: { type: 'string', description: 'Filter by tag' }
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
},
|
|
437
|
+
{
|
|
438
|
+
name: 'undo',
|
|
439
|
+
description: 'Undo last action',
|
|
440
|
+
inputSchema: { type: 'object', properties: {} }
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
name: 'redo',
|
|
444
|
+
description: 'Redo action',
|
|
445
|
+
inputSchema: { type: 'object', properties: {} }
|
|
446
|
+
},
|
|
447
|
+
// Pattern History Tools (#41)
|
|
448
|
+
{
|
|
449
|
+
name: 'list_history',
|
|
450
|
+
description: 'List recent pattern history with timestamps and previews',
|
|
451
|
+
inputSchema: {
|
|
452
|
+
type: 'object',
|
|
453
|
+
properties: {
|
|
454
|
+
limit: { type: 'number', description: 'Maximum entries to return (default: 10)' }
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
},
|
|
458
|
+
{
|
|
459
|
+
name: 'restore_history',
|
|
460
|
+
description: 'Restore a previous pattern from history by ID',
|
|
461
|
+
inputSchema: {
|
|
462
|
+
type: 'object',
|
|
463
|
+
properties: {
|
|
464
|
+
id: { type: 'number', description: 'History entry ID to restore' }
|
|
465
|
+
},
|
|
466
|
+
required: ['id']
|
|
467
|
+
}
|
|
468
|
+
},
|
|
469
|
+
{
|
|
470
|
+
name: 'compare_patterns',
|
|
471
|
+
description: 'Compare two patterns from history showing differences',
|
|
472
|
+
inputSchema: {
|
|
473
|
+
type: 'object',
|
|
474
|
+
properties: {
|
|
475
|
+
id1: { type: 'number', description: 'First pattern ID' },
|
|
476
|
+
id2: { type: 'number', description: 'Second pattern ID (default: current pattern)' }
|
|
477
|
+
},
|
|
478
|
+
required: ['id1']
|
|
479
|
+
}
|
|
480
|
+
},
|
|
481
|
+
// Additional Music Theory Tools (5)
|
|
482
|
+
{
|
|
483
|
+
name: 'generate_scale',
|
|
484
|
+
description: 'Generate scale notes',
|
|
485
|
+
inputSchema: {
|
|
486
|
+
type: 'object',
|
|
487
|
+
properties: {
|
|
488
|
+
root: { type: 'string', description: 'Root note' },
|
|
489
|
+
scale: { type: 'string', description: 'Scale type' }
|
|
490
|
+
},
|
|
491
|
+
required: ['root', 'scale']
|
|
492
|
+
}
|
|
493
|
+
},
|
|
494
|
+
{
|
|
495
|
+
name: 'generate_chord_progression',
|
|
496
|
+
description: 'Generate chord progression',
|
|
497
|
+
inputSchema: {
|
|
498
|
+
type: 'object',
|
|
499
|
+
properties: {
|
|
500
|
+
key: { type: 'string', description: 'Key' },
|
|
501
|
+
style: { type: 'string', description: 'Style (pop/jazz/blues/etc)' }
|
|
502
|
+
},
|
|
503
|
+
required: ['key', 'style']
|
|
504
|
+
}
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
name: 'generate_euclidean',
|
|
508
|
+
description: 'Generate Euclidean rhythm',
|
|
509
|
+
inputSchema: {
|
|
510
|
+
type: 'object',
|
|
511
|
+
properties: {
|
|
512
|
+
hits: { type: 'number', description: 'Number of hits' },
|
|
513
|
+
steps: { type: 'number', description: 'Total steps' },
|
|
514
|
+
sound: { type: 'string', description: 'Sound to use' }
|
|
515
|
+
},
|
|
516
|
+
required: ['hits', 'steps']
|
|
517
|
+
}
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
name: 'generate_polyrhythm',
|
|
521
|
+
description: 'Generate polyrhythm',
|
|
522
|
+
inputSchema: {
|
|
523
|
+
type: 'object',
|
|
524
|
+
properties: {
|
|
525
|
+
sounds: { type: 'array', items: { type: 'string' }, description: 'Sounds to use' },
|
|
526
|
+
patterns: { type: 'array', items: { type: 'number' }, description: 'Pattern numbers' }
|
|
527
|
+
},
|
|
528
|
+
required: ['sounds', 'patterns']
|
|
529
|
+
}
|
|
530
|
+
},
|
|
531
|
+
{
|
|
532
|
+
name: 'generate_fill',
|
|
533
|
+
description: 'Generate drum fill',
|
|
534
|
+
inputSchema: {
|
|
535
|
+
type: 'object',
|
|
536
|
+
properties: {
|
|
537
|
+
style: { type: 'string', description: 'Fill style' },
|
|
538
|
+
bars: { type: 'number', description: 'Number of bars' }
|
|
539
|
+
},
|
|
540
|
+
required: ['style']
|
|
541
|
+
}
|
|
542
|
+
},
|
|
543
|
+
// Performance Monitoring (2)
|
|
544
|
+
{
|
|
545
|
+
name: 'performance_report',
|
|
546
|
+
description: 'Get performance metrics and bottlenecks',
|
|
547
|
+
inputSchema: { type: 'object', properties: {} }
|
|
548
|
+
},
|
|
549
|
+
{
|
|
550
|
+
name: 'memory_usage',
|
|
551
|
+
description: 'Get current memory usage statistics',
|
|
552
|
+
inputSchema: { type: 'object', properties: {} }
|
|
553
|
+
},
|
|
554
|
+
// UX Tools - Browser Control (#37)
|
|
555
|
+
{
|
|
556
|
+
name: 'show_browser',
|
|
557
|
+
description: 'Bring browser window to foreground for visual feedback',
|
|
558
|
+
inputSchema: { type: 'object', properties: {} }
|
|
559
|
+
},
|
|
560
|
+
{
|
|
561
|
+
name: 'screenshot',
|
|
562
|
+
description: 'Take a screenshot of the current Strudel editor state',
|
|
563
|
+
inputSchema: {
|
|
564
|
+
type: 'object',
|
|
565
|
+
properties: {
|
|
566
|
+
filename: { type: 'string', description: 'Optional filename for screenshot' }
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
},
|
|
570
|
+
// UX Tools - Status & Diagnostics (#39)
|
|
571
|
+
{
|
|
572
|
+
name: 'status',
|
|
573
|
+
description: 'Get current browser and playback status (quick state check)',
|
|
574
|
+
inputSchema: { type: 'object', properties: {} }
|
|
575
|
+
},
|
|
576
|
+
{
|
|
577
|
+
name: 'diagnostics',
|
|
578
|
+
description: 'Get detailed browser diagnostics including cache, errors, and performance',
|
|
579
|
+
inputSchema: { type: 'object', properties: {} }
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
name: 'show_errors',
|
|
583
|
+
description: 'Display captured console errors and warnings from Strudel',
|
|
584
|
+
inputSchema: { type: 'object', properties: {} }
|
|
585
|
+
},
|
|
586
|
+
// UX Tools - High-level Compose (#42, #73)
|
|
587
|
+
{
|
|
588
|
+
name: 'compose',
|
|
589
|
+
description: 'Generate, write, and play a complete pattern in one step. Auto-initializes browser if needed.',
|
|
590
|
+
inputSchema: {
|
|
591
|
+
type: 'object',
|
|
592
|
+
properties: {
|
|
593
|
+
style: { type: 'string', description: 'Genre: techno, house, dnb, ambient, trap, jungle, jazz, experimental' },
|
|
594
|
+
tempo: { type: 'number', description: 'BPM (default: genre-appropriate)' },
|
|
595
|
+
key: { type: 'string', description: 'Musical key (default: C)' },
|
|
596
|
+
auto_play: { type: 'boolean', description: 'Start playback immediately (default: true)' },
|
|
597
|
+
get_feedback: { type: 'boolean', description: 'Get AI feedback on the generated pattern (default: false)' }
|
|
598
|
+
},
|
|
599
|
+
required: ['style']
|
|
600
|
+
}
|
|
601
|
+
},
|
|
602
|
+
// AI Feedback Tools (#67)
|
|
603
|
+
{
|
|
604
|
+
name: 'get_pattern_feedback',
|
|
605
|
+
description: 'Get AI-powered creative feedback on the current pattern using Google Gemini. Analyzes pattern structure and optionally audio.',
|
|
606
|
+
inputSchema: {
|
|
607
|
+
type: 'object',
|
|
608
|
+
properties: {
|
|
609
|
+
includeAudio: { type: 'boolean', description: 'Include audio analysis (plays pattern briefly). Default: false' },
|
|
610
|
+
style: { type: 'string', description: 'Optional style hint for context (e.g., "techno", "ambient")' }
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
},
|
|
614
|
+
// Audio Capture Tools (#72)
|
|
615
|
+
{
|
|
616
|
+
name: 'start_audio_capture',
|
|
617
|
+
description: 'Start capturing audio from Strudel output. Audio must be playing for capture to work.',
|
|
618
|
+
inputSchema: {
|
|
619
|
+
type: 'object',
|
|
620
|
+
properties: {
|
|
621
|
+
format: { type: 'string', enum: ['webm', 'opus'], description: 'Audio format (default: webm)' },
|
|
622
|
+
maxDuration: { type: 'number', description: 'Maximum capture duration in milliseconds' }
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
},
|
|
626
|
+
{
|
|
627
|
+
name: 'stop_audio_capture',
|
|
628
|
+
description: 'Stop audio capture and return the recorded audio as base64-encoded data.',
|
|
629
|
+
inputSchema: { type: 'object', properties: {} }
|
|
630
|
+
},
|
|
631
|
+
{
|
|
632
|
+
name: 'capture_audio_sample',
|
|
633
|
+
description: 'Capture a fixed-duration audio sample from Strudel output. Audio must be playing.',
|
|
634
|
+
inputSchema: {
|
|
635
|
+
type: 'object',
|
|
636
|
+
properties: {
|
|
637
|
+
duration: { type: 'number', description: 'Duration in milliseconds (default: 5000)' }
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
},
|
|
641
|
+
// MIDI Export Tools (#74)
|
|
642
|
+
{
|
|
643
|
+
name: 'export_midi',
|
|
644
|
+
description: 'Export current pattern to MIDI file. Parses note(), n(), and chord() functions.',
|
|
645
|
+
inputSchema: {
|
|
646
|
+
type: 'object',
|
|
647
|
+
properties: {
|
|
648
|
+
filename: { type: 'string', description: 'Output filename (optional, default: pattern.mid)' },
|
|
649
|
+
duration: { type: 'number', description: 'Export duration in bars (default: 4)' },
|
|
650
|
+
bpm: { type: 'number', description: 'Tempo in BPM (default: 120)' },
|
|
651
|
+
format: { type: 'string', enum: ['file', 'base64'], description: 'Output format: file or base64 (default: base64)' }
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
},
|
|
655
|
+
// Multi-Session Management Tools (#75)
|
|
656
|
+
{
|
|
657
|
+
name: 'create_session',
|
|
658
|
+
description: 'Create a new isolated Strudel browser session. Sessions share one browser but have isolated contexts.',
|
|
659
|
+
inputSchema: {
|
|
660
|
+
type: 'object',
|
|
661
|
+
properties: {
|
|
662
|
+
session_id: { type: 'string', description: 'Unique identifier for the session' }
|
|
663
|
+
},
|
|
664
|
+
required: ['session_id']
|
|
665
|
+
}
|
|
666
|
+
},
|
|
667
|
+
{
|
|
668
|
+
name: 'destroy_session',
|
|
669
|
+
description: 'Close and destroy a Strudel session, releasing its resources.',
|
|
670
|
+
inputSchema: {
|
|
671
|
+
type: 'object',
|
|
672
|
+
properties: {
|
|
673
|
+
session_id: { type: 'string', description: 'Session identifier to destroy' }
|
|
674
|
+
},
|
|
675
|
+
required: ['session_id']
|
|
676
|
+
}
|
|
677
|
+
},
|
|
678
|
+
{
|
|
679
|
+
name: 'list_sessions',
|
|
680
|
+
description: 'List all active Strudel sessions with their metadata.',
|
|
681
|
+
inputSchema: { type: 'object', properties: {} }
|
|
682
|
+
},
|
|
683
|
+
{
|
|
684
|
+
name: 'switch_session',
|
|
685
|
+
description: 'Change the default session used by other tools.',
|
|
686
|
+
inputSchema: {
|
|
687
|
+
type: 'object',
|
|
688
|
+
properties: {
|
|
689
|
+
session_id: { type: 'string', description: 'Session identifier to set as default' }
|
|
690
|
+
},
|
|
691
|
+
required: ['session_id']
|
|
692
|
+
}
|
|
693
|
+
},
|
|
694
|
+
// Pattern Refinement Tools (#78, #81)
|
|
695
|
+
{
|
|
696
|
+
name: 'refine',
|
|
697
|
+
description: 'Incrementally refine the current pattern with simple directional commands. Supports: faster/slower (tempo), louder/quieter (gain), brighter/darker (filter cutoff), "more reverb"/drier (reverb). Auto-plays after applying refinement.',
|
|
698
|
+
inputSchema: {
|
|
699
|
+
type: 'object',
|
|
700
|
+
properties: {
|
|
701
|
+
direction: {
|
|
702
|
+
type: 'string',
|
|
703
|
+
description: 'Refinement direction: faster, slower, louder, quieter, brighter, darker, "more reverb", or drier'
|
|
704
|
+
}
|
|
705
|
+
},
|
|
706
|
+
required: ['direction']
|
|
707
|
+
}
|
|
708
|
+
},
|
|
709
|
+
{
|
|
710
|
+
name: 'set_energy',
|
|
711
|
+
description: 'Adjust the overall energy level of the current pattern on a 0-10 scale. 0: minimal/ambient, 1-2: sparse, 3-4: light/relaxed, 5-6: normal/moderate, 7-8: driving/intense, 9-10: maximum. Auto-plays after applying energy level.',
|
|
712
|
+
inputSchema: {
|
|
713
|
+
type: 'object',
|
|
714
|
+
properties: {
|
|
715
|
+
level: {
|
|
716
|
+
type: 'number',
|
|
717
|
+
description: 'Energy level from 0 (minimal) to 10 (maximum)'
|
|
718
|
+
}
|
|
719
|
+
},
|
|
720
|
+
required: ['level']
|
|
721
|
+
}
|
|
722
|
+
},
|
|
723
|
+
// AI Collaborative Jamming (#82)
|
|
724
|
+
{
|
|
725
|
+
name: 'jam_with',
|
|
726
|
+
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.',
|
|
727
|
+
inputSchema: {
|
|
728
|
+
type: 'object',
|
|
729
|
+
properties: {
|
|
730
|
+
layer: {
|
|
731
|
+
type: 'string',
|
|
732
|
+
enum: ['drums', 'bass', 'melody', 'pad', 'texture'],
|
|
733
|
+
description: 'Type of layer to add: drums, bass, melody, pad, or texture'
|
|
734
|
+
},
|
|
735
|
+
style_hint: {
|
|
736
|
+
type: 'string',
|
|
737
|
+
description: 'Optional style guidance (e.g., "funky", "minimal", "atmospheric")'
|
|
738
|
+
},
|
|
739
|
+
auto_play: {
|
|
740
|
+
type: 'boolean',
|
|
741
|
+
description: 'Start playback after adding layer (default: true)'
|
|
742
|
+
}
|
|
743
|
+
},
|
|
744
|
+
required: ['layer']
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
];
|
|
748
|
+
}
|
|
749
|
+
setupHandlers() {
|
|
750
|
+
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
751
|
+
tools: this.getTools()
|
|
752
|
+
}));
|
|
753
|
+
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
754
|
+
const { name, arguments: args } = request.params;
|
|
755
|
+
try {
|
|
756
|
+
this.logger.info(`Executing tool: ${name}`, args);
|
|
757
|
+
// Measure performance
|
|
758
|
+
const result = await this.perfMonitor.measureAsync(name, () => this.executeTool(name, args));
|
|
759
|
+
return {
|
|
760
|
+
content: [{
|
|
761
|
+
type: 'text',
|
|
762
|
+
text: typeof result === 'string' ? result : JSON.stringify(result, null, 2)
|
|
763
|
+
}],
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
catch (error) {
|
|
767
|
+
this.logger.error(`Tool execution failed: ${name}`, error);
|
|
768
|
+
return {
|
|
769
|
+
content: [{
|
|
770
|
+
type: 'text',
|
|
771
|
+
text: `Error: ${error.message}`
|
|
772
|
+
}],
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
requiresInitialization(toolName) {
|
|
778
|
+
const toolsRequiringInit = [
|
|
779
|
+
'write', 'append', 'insert', 'replace', 'play', 'pause', 'stop',
|
|
780
|
+
'clear', 'get_pattern', 'analyze', 'analyze_spectrum', 'analyze_rhythm',
|
|
781
|
+
'transpose', 'reverse', 'stretch', 'humanize', 'generate_variation',
|
|
782
|
+
'add_effect', 'add_swing', 'set_tempo', 'save', 'undo', 'redo',
|
|
783
|
+
'validate_pattern_runtime'
|
|
784
|
+
];
|
|
785
|
+
const toolsRequiringWrite = [
|
|
786
|
+
'generate_pattern', 'generate_drums', 'generate_bassline', 'generate_melody',
|
|
787
|
+
'generate_chord_progression', 'generate_euclidean', 'generate_polyrhythm',
|
|
788
|
+
'generate_fill'
|
|
789
|
+
];
|
|
790
|
+
return toolsRequiringInit.includes(toolName) || toolsRequiringWrite.includes(toolName);
|
|
791
|
+
}
|
|
792
|
+
async getCurrentPatternSafe() {
|
|
793
|
+
if (!this.isInitialized) {
|
|
794
|
+
// Return the last generated pattern if available
|
|
795
|
+
const lastPattern = Array.from(this.generatedPatterns.values()).pop();
|
|
796
|
+
return lastPattern || '';
|
|
797
|
+
}
|
|
798
|
+
try {
|
|
799
|
+
return await this.controller.getCurrentPattern();
|
|
800
|
+
}
|
|
801
|
+
catch (e) {
|
|
802
|
+
return '';
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
async writePatternSafe(pattern) {
|
|
806
|
+
if (!this.isInitialized) {
|
|
807
|
+
// Store the pattern for later use
|
|
808
|
+
const id = `pattern_${Date.now()}`;
|
|
809
|
+
this.generatedPatterns.set(id, pattern);
|
|
810
|
+
return `Pattern generated (initialize Strudel to use it): ${pattern.substring(0, 50)}...`;
|
|
811
|
+
}
|
|
812
|
+
return await this.controller.writePattern(pattern);
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Gets a StrudelController for the specified session, or the default session.
|
|
816
|
+
* Falls back to the legacy single controller if no sessions exist.
|
|
817
|
+
* @param sessionId - Optional session ID. Uses default session if not specified.
|
|
818
|
+
* @returns StrudelController for the session
|
|
819
|
+
* @throws {Error} If session doesn't exist
|
|
820
|
+
*/
|
|
821
|
+
getControllerForSession(sessionId) {
|
|
822
|
+
// If session_id is specified, use the SessionManager
|
|
823
|
+
if (sessionId) {
|
|
824
|
+
const controller = this.sessionManager.getSession(sessionId);
|
|
825
|
+
if (!controller) {
|
|
826
|
+
throw new Error(`Session '${sessionId}' not found. Create it first with create_session.`);
|
|
827
|
+
}
|
|
828
|
+
return controller;
|
|
829
|
+
}
|
|
830
|
+
// If sessions exist and there's a default, use it
|
|
831
|
+
const defaultController = this.sessionManager.getDefaultSession();
|
|
832
|
+
if (defaultController) {
|
|
833
|
+
return defaultController;
|
|
834
|
+
}
|
|
835
|
+
// Fall back to legacy single controller for backwards compatibility
|
|
836
|
+
return this.controller;
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Checks if a session exists (or default/legacy controller is initialized)
|
|
840
|
+
* @param sessionId - Optional session ID
|
|
841
|
+
* @returns True if controller is available
|
|
842
|
+
*/
|
|
843
|
+
hasSession(sessionId) {
|
|
844
|
+
if (sessionId) {
|
|
845
|
+
return this.sessionManager.getSession(sessionId) !== undefined;
|
|
846
|
+
}
|
|
847
|
+
// Check if we have a default session or the legacy controller is initialized
|
|
848
|
+
return this.sessionManager.getDefaultSession() !== undefined || this.isInitialized;
|
|
849
|
+
}
|
|
850
|
+
async executeTool(name, args) {
|
|
851
|
+
// Check if tool needs initialization
|
|
852
|
+
if (this.requiresInitialization(name) && !this.isInitialized && name !== 'init') {
|
|
853
|
+
// For generation tools that don't require browser, handle them specially
|
|
854
|
+
const generationTools = [
|
|
855
|
+
'generate_pattern', 'generate_drums', 'generate_bassline', 'generate_melody',
|
|
856
|
+
'generate_chord_progression', 'generate_euclidean', 'generate_polyrhythm', 'generate_fill'
|
|
857
|
+
];
|
|
858
|
+
if (!generationTools.includes(name)) {
|
|
859
|
+
return `Browser not initialized. Run 'init' first to use ${name}.`;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
// Save current state for undo and history (#41) (only if initialized)
|
|
863
|
+
if (['write', 'append', 'insert', 'replace', 'clear'].includes(name) && this.isInitialized) {
|
|
864
|
+
try {
|
|
865
|
+
const current = await this.controller.getCurrentPattern();
|
|
866
|
+
this.undoStack.push(current);
|
|
867
|
+
// Add to history stack with metadata (#41)
|
|
868
|
+
this.historyIdCounter++;
|
|
869
|
+
this.historyStack.push({
|
|
870
|
+
id: this.historyIdCounter,
|
|
871
|
+
pattern: current,
|
|
872
|
+
timestamp: new Date(),
|
|
873
|
+
action: name
|
|
874
|
+
});
|
|
875
|
+
// Enforce bounds to prevent memory leaks
|
|
876
|
+
if (this.undoStack.length > this.MAX_HISTORY) {
|
|
877
|
+
this.undoStack.shift();
|
|
878
|
+
}
|
|
879
|
+
if (this.historyStack.length > this.MAX_HISTORY) {
|
|
880
|
+
this.historyStack.shift();
|
|
881
|
+
}
|
|
882
|
+
this.redoStack = [];
|
|
883
|
+
}
|
|
884
|
+
catch (e) {
|
|
885
|
+
// Controller might not be initialized yet
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
switch (name) {
|
|
889
|
+
// Core Control
|
|
890
|
+
case 'init':
|
|
891
|
+
const initResult = await this.controller.initialize();
|
|
892
|
+
this.isInitialized = true;
|
|
893
|
+
// Write any pending patterns
|
|
894
|
+
if (this.generatedPatterns.size > 0) {
|
|
895
|
+
const lastPattern = Array.from(this.generatedPatterns.values()).pop();
|
|
896
|
+
if (lastPattern) {
|
|
897
|
+
await this.controller.writePattern(lastPattern);
|
|
898
|
+
return `${initResult}. Loaded generated pattern.`;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
return initResult;
|
|
902
|
+
case 'write':
|
|
903
|
+
InputValidator.validateStringLength(args.pattern, 'pattern', 10000, true);
|
|
904
|
+
// Validate pattern if requested (default: true) - Issue #40
|
|
905
|
+
if (args.validate !== false && this.isInitialized && typeof this.controller.validatePattern === 'function') {
|
|
906
|
+
try {
|
|
907
|
+
const validation = await this.controller.validatePattern(args.pattern);
|
|
908
|
+
if (validation && !validation.valid) {
|
|
909
|
+
return {
|
|
910
|
+
success: false,
|
|
911
|
+
errors: validation.errors,
|
|
912
|
+
warnings: validation.warnings,
|
|
913
|
+
suggestions: validation.suggestions,
|
|
914
|
+
message: `Pattern validation failed: ${validation.errors.join('; ')}`
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
catch (e) {
|
|
919
|
+
// Validation failed, but we can still try to write
|
|
920
|
+
this.logger.warn('Pattern validation threw error, continuing with write');
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
const writeResult = await this.writePatternSafe(args.pattern);
|
|
924
|
+
// Auto-play if requested - Issue #38
|
|
925
|
+
if (args.auto_play && this.isInitialized) {
|
|
926
|
+
await this.controller.play();
|
|
927
|
+
return `${writeResult}. Playing.`;
|
|
928
|
+
}
|
|
929
|
+
return writeResult;
|
|
930
|
+
case 'append':
|
|
931
|
+
InputValidator.validateStringLength(args.code, 'code', 10000, true);
|
|
932
|
+
const current = await this.getCurrentPatternSafe();
|
|
933
|
+
return await this.writePatternSafe(current + '\n' + args.code);
|
|
934
|
+
case 'insert':
|
|
935
|
+
InputValidator.validatePositiveInteger(args.position, 'position');
|
|
936
|
+
InputValidator.validateStringLength(args.code, 'code', 10000, true);
|
|
937
|
+
const lines = (await this.getCurrentPatternSafe()).split('\n');
|
|
938
|
+
lines.splice(args.position, 0, args.code);
|
|
939
|
+
return await this.writePatternSafe(lines.join('\n'));
|
|
940
|
+
case 'replace':
|
|
941
|
+
InputValidator.validateStringLength(args.search, 'search', 1000, true);
|
|
942
|
+
InputValidator.validateStringLength(args.replace, 'replace', 10000, true);
|
|
943
|
+
const pattern = await this.getCurrentPatternSafe();
|
|
944
|
+
// Escape $ in replacement to prevent special sequence injection ($&, $1, $', etc.)
|
|
945
|
+
const safeReplacement = args.replace.replace(/\$/g, '$$$$');
|
|
946
|
+
const replaced = pattern.replace(args.search, safeReplacement);
|
|
947
|
+
return await this.writePatternSafe(replaced);
|
|
948
|
+
case 'play':
|
|
949
|
+
return await this.controller.play();
|
|
950
|
+
case 'pause':
|
|
951
|
+
case 'stop':
|
|
952
|
+
return await this.controller.stop();
|
|
953
|
+
case 'clear':
|
|
954
|
+
return await this.writePatternSafe('');
|
|
955
|
+
case 'get_pattern':
|
|
956
|
+
return await this.getCurrentPatternSafe();
|
|
957
|
+
// Pattern Generation - These can work without browser
|
|
958
|
+
case 'generate_pattern':
|
|
959
|
+
InputValidator.validateStringLength(args.style, 'style', 100, false);
|
|
960
|
+
if (args.key) {
|
|
961
|
+
InputValidator.validateRootNote(args.key);
|
|
962
|
+
}
|
|
963
|
+
if (args.bpm !== undefined) {
|
|
964
|
+
InputValidator.validateBPM(args.bpm);
|
|
965
|
+
}
|
|
966
|
+
const generated = this.generator.generateCompletePattern(args.style, args.key || 'C', args.bpm || 120);
|
|
967
|
+
await this.writePatternSafe(generated);
|
|
968
|
+
// Auto-play if requested - Issue #38
|
|
969
|
+
if (args.auto_play && this.isInitialized) {
|
|
970
|
+
await this.controller.play();
|
|
971
|
+
return `Generated ${args.style} pattern. Playing.`;
|
|
972
|
+
}
|
|
973
|
+
return `Generated ${args.style} pattern`;
|
|
974
|
+
case 'generate_drums':
|
|
975
|
+
InputValidator.validateStringLength(args.style, 'style', 100, false);
|
|
976
|
+
if (args.complexity !== undefined) {
|
|
977
|
+
InputValidator.validateNormalizedValue(args.complexity, 'complexity');
|
|
978
|
+
}
|
|
979
|
+
const drums = this.generator.generateDrumPattern(args.style, args.complexity || 0.5);
|
|
980
|
+
const currentDrum = await this.getCurrentPatternSafe();
|
|
981
|
+
const newDrumPattern = currentDrum ? currentDrum + '\n' + drums : drums;
|
|
982
|
+
await this.writePatternSafe(newDrumPattern);
|
|
983
|
+
return `Generated ${args.style} drums`;
|
|
984
|
+
case 'generate_bassline':
|
|
985
|
+
InputValidator.validateRootNote(args.key);
|
|
986
|
+
InputValidator.validateStringLength(args.style, 'style', 100, false);
|
|
987
|
+
const bass = this.generator.generateBassline(args.key, args.style);
|
|
988
|
+
const currentBass = await this.getCurrentPatternSafe();
|
|
989
|
+
const newBassPattern = currentBass ? currentBass + '\n' + bass : bass;
|
|
990
|
+
await this.writePatternSafe(newBassPattern);
|
|
991
|
+
return `Generated ${args.style} bassline in ${args.key}`;
|
|
992
|
+
case 'generate_melody':
|
|
993
|
+
InputValidator.validateRootNote(args.root);
|
|
994
|
+
InputValidator.validateScaleName(args.scale);
|
|
995
|
+
if (args.length !== undefined) {
|
|
996
|
+
InputValidator.validatePositiveInteger(args.length, 'length');
|
|
997
|
+
}
|
|
998
|
+
const scale = this.theory.generateScale(args.root, args.scale);
|
|
999
|
+
const melody = this.generator.generateMelody(scale, args.length || 8);
|
|
1000
|
+
const currentMelody = await this.getCurrentPatternSafe();
|
|
1001
|
+
const newMelodyPattern = currentMelody ? currentMelody + '\n' + melody : melody;
|
|
1002
|
+
await this.writePatternSafe(newMelodyPattern);
|
|
1003
|
+
return `Generated melody in ${args.root} ${args.scale}`;
|
|
1004
|
+
// Music Theory - These don't require browser
|
|
1005
|
+
case 'generate_scale':
|
|
1006
|
+
InputValidator.validateRootNote(args.root);
|
|
1007
|
+
InputValidator.validateScaleName(args.scale);
|
|
1008
|
+
const scaleNotes = this.theory.generateScale(args.root, args.scale);
|
|
1009
|
+
return `${args.root} ${args.scale} scale: ${scaleNotes.join(', ')}`;
|
|
1010
|
+
case 'generate_chord_progression':
|
|
1011
|
+
InputValidator.validateRootNote(args.key);
|
|
1012
|
+
InputValidator.validateChordStyle(args.style);
|
|
1013
|
+
const progression = this.theory.generateChordProgression(args.key, args.style);
|
|
1014
|
+
const chordPattern = this.generator.generateChords(progression);
|
|
1015
|
+
const currentChords = await this.getCurrentPatternSafe();
|
|
1016
|
+
const newChordPattern = currentChords ? currentChords + '\n' + chordPattern : chordPattern;
|
|
1017
|
+
await this.writePatternSafe(newChordPattern);
|
|
1018
|
+
return `Generated ${args.style} progression in ${args.key}: ${progression}`;
|
|
1019
|
+
case 'generate_euclidean':
|
|
1020
|
+
InputValidator.validateEuclidean(args.hits, args.steps);
|
|
1021
|
+
if (args.sound) {
|
|
1022
|
+
InputValidator.validateStringLength(args.sound, 'sound', 100, false);
|
|
1023
|
+
}
|
|
1024
|
+
const euclidean = this.generator.generateEuclideanPattern(args.hits, args.steps, args.sound || 'bd');
|
|
1025
|
+
const currentEuc = await this.getCurrentPatternSafe();
|
|
1026
|
+
const newEucPattern = currentEuc ? currentEuc + '\n' + euclidean : euclidean;
|
|
1027
|
+
await this.writePatternSafe(newEucPattern);
|
|
1028
|
+
return `Generated Euclidean rhythm (${args.hits}/${args.steps})`;
|
|
1029
|
+
case 'generate_polyrhythm':
|
|
1030
|
+
args.sounds.forEach((sound) => {
|
|
1031
|
+
InputValidator.validateStringLength(sound, 'sound', 100, false);
|
|
1032
|
+
});
|
|
1033
|
+
args.patterns.forEach((pattern) => {
|
|
1034
|
+
InputValidator.validatePositiveInteger(pattern, 'pattern');
|
|
1035
|
+
});
|
|
1036
|
+
const poly = this.generator.generatePolyrhythm(args.sounds, args.patterns);
|
|
1037
|
+
const currentPoly = await this.getCurrentPatternSafe();
|
|
1038
|
+
const newPolyPattern = currentPoly ? currentPoly + '\n' + poly : poly;
|
|
1039
|
+
await this.writePatternSafe(newPolyPattern);
|
|
1040
|
+
return `Generated polyrhythm`;
|
|
1041
|
+
case 'generate_fill':
|
|
1042
|
+
InputValidator.validateStringLength(args.style, 'style', 100, false);
|
|
1043
|
+
if (args.bars !== undefined) {
|
|
1044
|
+
InputValidator.validatePositiveInteger(args.bars, 'bars');
|
|
1045
|
+
}
|
|
1046
|
+
const fill = this.generator.generateFill(args.style, args.bars || 1);
|
|
1047
|
+
const currentFill = await this.getCurrentPatternSafe();
|
|
1048
|
+
const newFillPattern = currentFill ? currentFill + '\n' + fill : fill;
|
|
1049
|
+
await this.writePatternSafe(newFillPattern);
|
|
1050
|
+
return `Generated ${args.bars || 1} bar fill`;
|
|
1051
|
+
// Pattern Manipulation - These require browser
|
|
1052
|
+
case 'transpose':
|
|
1053
|
+
// Semitones can be positive or negative, just validate it's a number
|
|
1054
|
+
if (typeof args.semitones !== 'number' || !Number.isInteger(args.semitones)) {
|
|
1055
|
+
throw new Error('Semitones must be an integer');
|
|
1056
|
+
}
|
|
1057
|
+
const toTranspose = await this.getCurrentPatternSafe();
|
|
1058
|
+
const transposed = this.transposePattern(toTranspose, args.semitones);
|
|
1059
|
+
await this.writePatternSafe(transposed);
|
|
1060
|
+
return `Transposed ${args.semitones} semitones`;
|
|
1061
|
+
case 'reverse':
|
|
1062
|
+
const toReverse = await this.getCurrentPatternSafe();
|
|
1063
|
+
const reversed = toReverse + '.rev';
|
|
1064
|
+
await this.writePatternSafe(reversed);
|
|
1065
|
+
return 'Pattern reversed';
|
|
1066
|
+
case 'stretch':
|
|
1067
|
+
InputValidator.validateGain(args.factor); // Positive number, use gain validator for simplicity
|
|
1068
|
+
const toStretch = await this.getCurrentPatternSafe();
|
|
1069
|
+
const stretched = toStretch + `.slow(${args.factor})`;
|
|
1070
|
+
await this.writePatternSafe(stretched);
|
|
1071
|
+
return `Stretched by factor of ${args.factor}`;
|
|
1072
|
+
case 'humanize':
|
|
1073
|
+
if (args.amount !== undefined) {
|
|
1074
|
+
InputValidator.validateNormalizedValue(args.amount, 'amount');
|
|
1075
|
+
}
|
|
1076
|
+
const toHumanize = await this.getCurrentPatternSafe();
|
|
1077
|
+
const humanized = toHumanize + `.nudge(rand.range(-${args.amount || 0.01}, ${args.amount || 0.01}))`;
|
|
1078
|
+
await this.writePatternSafe(humanized);
|
|
1079
|
+
return 'Added human timing';
|
|
1080
|
+
case 'generate_variation':
|
|
1081
|
+
const toVary = await this.getCurrentPatternSafe();
|
|
1082
|
+
const varied = this.generator.generateVariation(toVary, args.type || 'subtle');
|
|
1083
|
+
await this.writePatternSafe(varied);
|
|
1084
|
+
return `Added ${args.type || 'subtle'} variation`;
|
|
1085
|
+
// Effects - These require browser
|
|
1086
|
+
case 'add_effect':
|
|
1087
|
+
InputValidator.validateStringLength(args.effect, 'effect', 100, false);
|
|
1088
|
+
if (args.params) {
|
|
1089
|
+
InputValidator.validateStringLength(args.params, 'params', 1000, true);
|
|
1090
|
+
}
|
|
1091
|
+
const currentEffect = await this.getCurrentPatternSafe();
|
|
1092
|
+
const withEffect = args.params
|
|
1093
|
+
? currentEffect + `.${args.effect}(${args.params})`
|
|
1094
|
+
: currentEffect + `.${args.effect}()`;
|
|
1095
|
+
await this.writePatternSafe(withEffect);
|
|
1096
|
+
return `Added ${args.effect} effect`;
|
|
1097
|
+
case 'add_swing':
|
|
1098
|
+
InputValidator.validateNormalizedValue(args.amount, 'amount');
|
|
1099
|
+
const currentSwing = await this.getCurrentPatternSafe();
|
|
1100
|
+
const withSwing = currentSwing + `.swing(${args.amount})`;
|
|
1101
|
+
await this.writePatternSafe(withSwing);
|
|
1102
|
+
return `Added swing: ${args.amount}`;
|
|
1103
|
+
case 'set_tempo':
|
|
1104
|
+
InputValidator.validateBPM(args.bpm);
|
|
1105
|
+
const currentTempo = await this.getCurrentPatternSafe();
|
|
1106
|
+
const withTempo = `setcpm(${args.bpm})\n${currentTempo}`;
|
|
1107
|
+
await this.writePatternSafe(withTempo);
|
|
1108
|
+
return `Set tempo to ${args.bpm} BPM`;
|
|
1109
|
+
// Audio Analysis - Requires browser
|
|
1110
|
+
case 'analyze':
|
|
1111
|
+
if (!this.isInitialized) {
|
|
1112
|
+
return 'Browser not initialized. Run init first.';
|
|
1113
|
+
}
|
|
1114
|
+
return await this.controller.analyzeAudio();
|
|
1115
|
+
case 'analyze_spectrum':
|
|
1116
|
+
if (!this.isInitialized) {
|
|
1117
|
+
return 'Browser not initialized. Run init first.';
|
|
1118
|
+
}
|
|
1119
|
+
const spectrum = await this.controller.analyzeAudio();
|
|
1120
|
+
return spectrum.features || spectrum;
|
|
1121
|
+
case 'analyze_rhythm':
|
|
1122
|
+
if (!this.isInitialized) {
|
|
1123
|
+
return 'Browser not initialized. Run init first.';
|
|
1124
|
+
}
|
|
1125
|
+
const analysis = await this.controller.analyzeAudio();
|
|
1126
|
+
return {
|
|
1127
|
+
isPlaying: analysis.features?.isPlaying,
|
|
1128
|
+
tempo: 'Analysis pending implementation',
|
|
1129
|
+
pattern: 'Rhythm pattern analysis'
|
|
1130
|
+
};
|
|
1131
|
+
case 'detect_tempo':
|
|
1132
|
+
if (!this.isInitialized) {
|
|
1133
|
+
return 'Browser not initialized. Run init first.';
|
|
1134
|
+
}
|
|
1135
|
+
try {
|
|
1136
|
+
const tempoAnalysis = await this.controller.detectTempo();
|
|
1137
|
+
if (!tempoAnalysis || tempoAnalysis.bpm === 0) {
|
|
1138
|
+
return {
|
|
1139
|
+
bpm: 0,
|
|
1140
|
+
confidence: 0,
|
|
1141
|
+
message: 'No tempo detected. Ensure audio is playing and has a clear rhythmic pattern.'
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
return {
|
|
1145
|
+
bpm: tempoAnalysis.bpm,
|
|
1146
|
+
confidence: Math.round(tempoAnalysis.confidence * 100) / 100,
|
|
1147
|
+
method: tempoAnalysis.method,
|
|
1148
|
+
message: `Detected ${tempoAnalysis.bpm} BPM with ${Math.round(tempoAnalysis.confidence * 100)}% confidence`
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
catch (error) {
|
|
1152
|
+
return {
|
|
1153
|
+
bpm: 0,
|
|
1154
|
+
confidence: 0,
|
|
1155
|
+
error: error.message || 'Tempo detection failed'
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
case 'detect_key':
|
|
1159
|
+
if (!this.isInitialized) {
|
|
1160
|
+
return 'Browser not initialized. Run init first.';
|
|
1161
|
+
}
|
|
1162
|
+
try {
|
|
1163
|
+
const keyAnalysis = await this.controller.detectKey();
|
|
1164
|
+
if (!keyAnalysis || keyAnalysis.confidence < 0.1) {
|
|
1165
|
+
return {
|
|
1166
|
+
key: 'Unknown',
|
|
1167
|
+
scale: 'unknown',
|
|
1168
|
+
confidence: 0,
|
|
1169
|
+
message: 'No clear key detected. Ensure audio is playing and has tonal content.'
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
const result = {
|
|
1173
|
+
key: keyAnalysis.key,
|
|
1174
|
+
scale: keyAnalysis.scale,
|
|
1175
|
+
confidence: Math.round(keyAnalysis.confidence * 100) / 100,
|
|
1176
|
+
message: `Detected ${keyAnalysis.key} ${keyAnalysis.scale} with ${Math.round(keyAnalysis.confidence * 100)}% confidence`
|
|
1177
|
+
};
|
|
1178
|
+
// Include alternatives if available and confidence is moderate
|
|
1179
|
+
if (keyAnalysis.alternatives && keyAnalysis.alternatives.length > 0) {
|
|
1180
|
+
result.alternatives = keyAnalysis.alternatives.map((alt) => ({
|
|
1181
|
+
key: alt.key,
|
|
1182
|
+
scale: alt.scale,
|
|
1183
|
+
confidence: Math.round(alt.confidence * 100) / 100
|
|
1184
|
+
}));
|
|
1185
|
+
}
|
|
1186
|
+
return result;
|
|
1187
|
+
}
|
|
1188
|
+
catch (error) {
|
|
1189
|
+
return {
|
|
1190
|
+
key: 'Unknown',
|
|
1191
|
+
scale: 'unknown',
|
|
1192
|
+
confidence: 0,
|
|
1193
|
+
error: error.message || 'Key detection failed'
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
case 'validate_pattern_runtime':
|
|
1197
|
+
if (!this.isInitialized) {
|
|
1198
|
+
return 'Browser not initialized. Run init first.';
|
|
1199
|
+
}
|
|
1200
|
+
InputValidator.validateStringLength(args.pattern, 'pattern', 10000, false);
|
|
1201
|
+
const validation = await this.controller.validatePatternRuntime(args.pattern, args.waitMs || 500);
|
|
1202
|
+
if (validation.valid) {
|
|
1203
|
+
return `✅ Pattern valid - no runtime errors detected`;
|
|
1204
|
+
}
|
|
1205
|
+
else {
|
|
1206
|
+
return `❌ Pattern has runtime errors:\n${validation.errors.join('\n')}\n` +
|
|
1207
|
+
(validation.warnings.length > 0 ? `\nWarnings:\n${validation.warnings.join('\n')}` : '');
|
|
1208
|
+
}
|
|
1209
|
+
// Local Pattern Tools (#83) - No browser required
|
|
1210
|
+
case 'validate_pattern_local':
|
|
1211
|
+
InputValidator.validateStringLength(args.pattern, 'pattern', 10000, false);
|
|
1212
|
+
const localValidation = this.strudelEngine.validate(args.pattern);
|
|
1213
|
+
return {
|
|
1214
|
+
valid: localValidation.valid,
|
|
1215
|
+
errors: localValidation.errors,
|
|
1216
|
+
warnings: localValidation.warnings,
|
|
1217
|
+
suggestions: localValidation.suggestions,
|
|
1218
|
+
errorLocation: localValidation.errorLocation,
|
|
1219
|
+
message: localValidation.valid
|
|
1220
|
+
? '✅ Pattern is valid'
|
|
1221
|
+
: `❌ Pattern has ${localValidation.errors.length} error(s)`
|
|
1222
|
+
};
|
|
1223
|
+
case 'analyze_pattern_local':
|
|
1224
|
+
InputValidator.validateStringLength(args.pattern, 'pattern', 10000, false);
|
|
1225
|
+
const patternMetadata = this.strudelEngine.analyzePattern(args.pattern);
|
|
1226
|
+
return {
|
|
1227
|
+
...patternMetadata,
|
|
1228
|
+
message: `Pattern analysis: ${patternMetadata.eventsPerCycle} events/cycle, ` +
|
|
1229
|
+
`complexity ${(patternMetadata.complexity * 100).toFixed(0)}%` +
|
|
1230
|
+
(patternMetadata.bpm ? `, ${patternMetadata.bpm} BPM` : '')
|
|
1231
|
+
};
|
|
1232
|
+
case 'query_pattern_events':
|
|
1233
|
+
InputValidator.validateStringLength(args.pattern, 'pattern', 10000, false);
|
|
1234
|
+
const startCycle = args.start ?? 0;
|
|
1235
|
+
const endCycle = args.end ?? 1;
|
|
1236
|
+
if (startCycle >= endCycle) {
|
|
1237
|
+
return { error: 'Start must be less than end' };
|
|
1238
|
+
}
|
|
1239
|
+
if (endCycle - startCycle > 16) {
|
|
1240
|
+
return { error: 'Maximum range is 16 cycles to prevent excessive output' };
|
|
1241
|
+
}
|
|
1242
|
+
try {
|
|
1243
|
+
const events = this.strudelEngine.queryEvents(args.pattern, startCycle, endCycle);
|
|
1244
|
+
return {
|
|
1245
|
+
count: events.length,
|
|
1246
|
+
range: { start: startCycle, end: endCycle },
|
|
1247
|
+
events: events.map((e) => ({
|
|
1248
|
+
value: e.value,
|
|
1249
|
+
start: e.start,
|
|
1250
|
+
end: e.end,
|
|
1251
|
+
duration: e.end - e.start
|
|
1252
|
+
}))
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
catch (error) {
|
|
1256
|
+
return {
|
|
1257
|
+
error: error.message,
|
|
1258
|
+
suggestion: 'Check pattern syntax with validate_pattern_local first'
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
case 'transpile_pattern':
|
|
1262
|
+
InputValidator.validateStringLength(args.pattern, 'pattern', 10000, false);
|
|
1263
|
+
const transpileResult = this.strudelEngine.transpile(args.pattern);
|
|
1264
|
+
if (transpileResult.success) {
|
|
1265
|
+
return {
|
|
1266
|
+
success: true,
|
|
1267
|
+
transpiledCode: transpileResult.transpiledCode,
|
|
1268
|
+
message: 'Pattern transpiled successfully'
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
else {
|
|
1272
|
+
return {
|
|
1273
|
+
success: false,
|
|
1274
|
+
error: transpileResult.error,
|
|
1275
|
+
errorLocation: transpileResult.errorLocation,
|
|
1276
|
+
message: 'Transpilation failed'
|
|
1277
|
+
};
|
|
1278
|
+
}
|
|
1279
|
+
// Mood Transformation (#80)
|
|
1280
|
+
case 'shift_mood':
|
|
1281
|
+
const mood = args.target_mood?.toLowerCase()?.trim();
|
|
1282
|
+
const moodProfile = MOOD_PROFILES[mood];
|
|
1283
|
+
if (!moodProfile) {
|
|
1284
|
+
return {
|
|
1285
|
+
success: false,
|
|
1286
|
+
error: `Unknown mood: ${args.target_mood}. Valid moods: ${Object.keys(MOOD_PROFILES).join(', ')}.`
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
const moodPattern = await this.getCurrentPatternSafe();
|
|
1290
|
+
if (!moodPattern || moodPattern.trim().length === 0) {
|
|
1291
|
+
return {
|
|
1292
|
+
success: false,
|
|
1293
|
+
error: 'No pattern to transform. Write a pattern first.'
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
const moodIntensity = args.intensity ?? 0.5;
|
|
1297
|
+
if (moodIntensity < 0 || moodIntensity > 1) {
|
|
1298
|
+
return {
|
|
1299
|
+
success: false,
|
|
1300
|
+
error: 'Intensity must be between 0 and 1.'
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
const appliedEffects = [];
|
|
1304
|
+
let transformedPattern = moodPattern;
|
|
1305
|
+
// Apply tempo modification
|
|
1306
|
+
if (moodProfile.tempoMod !== 0) {
|
|
1307
|
+
const tempoAdjust = 1 + (moodProfile.tempoMod * moodIntensity);
|
|
1308
|
+
if (tempoAdjust > 1) {
|
|
1309
|
+
transformedPattern += `.fast(${tempoAdjust.toFixed(2)})`;
|
|
1310
|
+
appliedEffects.push(`tempo +${Math.round(moodProfile.tempoMod * 100 * moodIntensity)}%`);
|
|
1311
|
+
}
|
|
1312
|
+
else {
|
|
1313
|
+
transformedPattern += `.slow(${(1 / tempoAdjust).toFixed(2)})`;
|
|
1314
|
+
appliedEffects.push(`tempo ${Math.round(moodProfile.tempoMod * 100 * moodIntensity)}%`);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
// Apply cutoff modification
|
|
1318
|
+
if (moodProfile.cutoffMod !== 0) {
|
|
1319
|
+
const baseCutoff = 1000;
|
|
1320
|
+
const newCutoff = Math.max(200, baseCutoff + (moodProfile.cutoffMod * moodIntensity));
|
|
1321
|
+
transformedPattern += `.lpf(${Math.round(newCutoff)})`;
|
|
1322
|
+
appliedEffects.push(`cutoff ${moodProfile.cutoffMod > 0 ? '+' : ''}${Math.round(moodProfile.cutoffMod * moodIntensity)}Hz`);
|
|
1323
|
+
}
|
|
1324
|
+
// Apply reverb modification
|
|
1325
|
+
if (moodProfile.roomMod !== 0) {
|
|
1326
|
+
const roomAmount = Math.max(0, Math.min(1, moodProfile.roomMod * moodIntensity));
|
|
1327
|
+
transformedPattern += `.room(${roomAmount.toFixed(2)})`;
|
|
1328
|
+
appliedEffects.push(`reverb ${roomAmount.toFixed(2)}`);
|
|
1329
|
+
}
|
|
1330
|
+
// Apply delay if specified
|
|
1331
|
+
if (moodProfile.delayMod && moodProfile.delayMod > 0) {
|
|
1332
|
+
const delayAmount = moodProfile.delayMod * moodIntensity;
|
|
1333
|
+
transformedPattern += `.delay(${delayAmount.toFixed(2)})`;
|
|
1334
|
+
appliedEffects.push(`delay ${delayAmount.toFixed(2)}`);
|
|
1335
|
+
}
|
|
1336
|
+
// Apply gain modification
|
|
1337
|
+
if (moodProfile.gainMod !== 0) {
|
|
1338
|
+
const gainAdjust = 1 + (moodProfile.gainMod * moodIntensity);
|
|
1339
|
+
transformedPattern += `.gain(${gainAdjust.toFixed(2)})`;
|
|
1340
|
+
appliedEffects.push(`gain ${moodProfile.gainMod > 0 ? '+' : ''}${Math.round(moodProfile.gainMod * 100 * moodIntensity)}%`);
|
|
1341
|
+
}
|
|
1342
|
+
await this.writePatternSafe(transformedPattern);
|
|
1343
|
+
const shouldPlayMood = args.auto_play !== false;
|
|
1344
|
+
if (shouldPlayMood && this.isInitialized) {
|
|
1345
|
+
await this.controller.play();
|
|
1346
|
+
}
|
|
1347
|
+
return {
|
|
1348
|
+
success: true,
|
|
1349
|
+
target_mood: mood,
|
|
1350
|
+
intensity: moodIntensity,
|
|
1351
|
+
applied_effects: appliedEffects
|
|
1352
|
+
};
|
|
1353
|
+
// Session Management
|
|
1354
|
+
case 'save':
|
|
1355
|
+
InputValidator.validateStringLength(args.name, 'name', 255, false);
|
|
1356
|
+
const toSave = await this.getCurrentPatternSafe();
|
|
1357
|
+
if (!toSave) {
|
|
1358
|
+
return 'No pattern to save';
|
|
1359
|
+
}
|
|
1360
|
+
await this.store.save(args.name, toSave, args.tags || []);
|
|
1361
|
+
return `Pattern saved as "${args.name}"`;
|
|
1362
|
+
case 'load':
|
|
1363
|
+
InputValidator.validateStringLength(args.name, 'name', 255, false);
|
|
1364
|
+
const saved = await this.store.load(args.name);
|
|
1365
|
+
if (saved) {
|
|
1366
|
+
await this.writePatternSafe(saved.content);
|
|
1367
|
+
return `Loaded pattern "${args.name}"`;
|
|
1368
|
+
}
|
|
1369
|
+
return `Pattern "${args.name}" not found`;
|
|
1370
|
+
case 'list':
|
|
1371
|
+
if (args?.tag) {
|
|
1372
|
+
InputValidator.validateStringLength(args.tag, 'tag', 100, false);
|
|
1373
|
+
}
|
|
1374
|
+
const patterns = await this.store.list(args?.tag);
|
|
1375
|
+
return patterns.map(p => `• ${p.name} [${p.tags.join(', ')}] - ${p.timestamp}`).join('\n') || 'No patterns found';
|
|
1376
|
+
case 'undo':
|
|
1377
|
+
if (!this.isInitialized) {
|
|
1378
|
+
return 'Browser not initialized. Run init first.';
|
|
1379
|
+
}
|
|
1380
|
+
if (this.undoStack.length > 0) {
|
|
1381
|
+
const currentUndo = await this.controller.getCurrentPattern();
|
|
1382
|
+
this.redoStack.push(currentUndo);
|
|
1383
|
+
// Enforce bounds to prevent memory leaks
|
|
1384
|
+
if (this.redoStack.length > this.MAX_HISTORY) {
|
|
1385
|
+
this.redoStack.shift();
|
|
1386
|
+
}
|
|
1387
|
+
const previous = this.undoStack.pop();
|
|
1388
|
+
await this.controller.writePattern(previous);
|
|
1389
|
+
return 'Undone';
|
|
1390
|
+
}
|
|
1391
|
+
return 'Nothing to undo';
|
|
1392
|
+
case 'redo':
|
|
1393
|
+
if (!this.isInitialized) {
|
|
1394
|
+
return 'Browser not initialized. Run init first.';
|
|
1395
|
+
}
|
|
1396
|
+
if (this.redoStack.length > 0) {
|
|
1397
|
+
const currentRedo = await this.controller.getCurrentPattern();
|
|
1398
|
+
this.undoStack.push(currentRedo);
|
|
1399
|
+
// Enforce bounds to prevent memory leaks
|
|
1400
|
+
if (this.undoStack.length > this.MAX_HISTORY) {
|
|
1401
|
+
this.undoStack.shift();
|
|
1402
|
+
}
|
|
1403
|
+
const next = this.redoStack.pop();
|
|
1404
|
+
await this.controller.writePattern(next);
|
|
1405
|
+
return 'Redone';
|
|
1406
|
+
}
|
|
1407
|
+
return 'Nothing to redo';
|
|
1408
|
+
// Pattern History (#41)
|
|
1409
|
+
case 'list_history':
|
|
1410
|
+
if (this.historyStack.length === 0) {
|
|
1411
|
+
return 'No pattern history yet. Make some edits to build history.';
|
|
1412
|
+
}
|
|
1413
|
+
const limit = args?.limit || 10;
|
|
1414
|
+
const recentHistory = this.historyStack.slice(-limit).reverse();
|
|
1415
|
+
return {
|
|
1416
|
+
count: this.historyStack.length,
|
|
1417
|
+
showing: recentHistory.length,
|
|
1418
|
+
entries: recentHistory.map(entry => ({
|
|
1419
|
+
id: entry.id,
|
|
1420
|
+
preview: entry.pattern.substring(0, 60) + (entry.pattern.length > 60 ? '...' : ''),
|
|
1421
|
+
chars: entry.pattern.length,
|
|
1422
|
+
action: entry.action,
|
|
1423
|
+
timestamp: this.formatTimeAgo(entry.timestamp)
|
|
1424
|
+
}))
|
|
1425
|
+
};
|
|
1426
|
+
case 'restore_history':
|
|
1427
|
+
if (!this.isInitialized) {
|
|
1428
|
+
return 'Browser not initialized. Run init first.';
|
|
1429
|
+
}
|
|
1430
|
+
const entryToRestore = this.historyStack.find(e => e.id === args.id);
|
|
1431
|
+
if (!entryToRestore) {
|
|
1432
|
+
return `History entry #${args.id} not found. Use list_history to see available entries.`;
|
|
1433
|
+
}
|
|
1434
|
+
// Save current state before restoring
|
|
1435
|
+
const currentBeforeRestore = await this.controller.getCurrentPattern();
|
|
1436
|
+
this.undoStack.push(currentBeforeRestore);
|
|
1437
|
+
if (this.undoStack.length > this.MAX_HISTORY) {
|
|
1438
|
+
this.undoStack.shift();
|
|
1439
|
+
}
|
|
1440
|
+
await this.controller.writePattern(entryToRestore.pattern);
|
|
1441
|
+
return `Restored pattern from history #${args.id} (${this.formatTimeAgo(entryToRestore.timestamp)})`;
|
|
1442
|
+
case 'compare_patterns':
|
|
1443
|
+
const entry1 = this.historyStack.find(e => e.id === args.id1);
|
|
1444
|
+
if (!entry1) {
|
|
1445
|
+
return `History entry #${args.id1} not found.`;
|
|
1446
|
+
}
|
|
1447
|
+
let pattern2;
|
|
1448
|
+
let label2;
|
|
1449
|
+
if (args.id2) {
|
|
1450
|
+
const entry2 = this.historyStack.find(e => e.id === args.id2);
|
|
1451
|
+
if (!entry2) {
|
|
1452
|
+
return `History entry #${args.id2} not found.`;
|
|
1453
|
+
}
|
|
1454
|
+
pattern2 = entry2.pattern;
|
|
1455
|
+
label2 = `#${args.id2}`;
|
|
1456
|
+
}
|
|
1457
|
+
else {
|
|
1458
|
+
pattern2 = await this.getCurrentPatternSafe();
|
|
1459
|
+
label2 = 'current';
|
|
1460
|
+
}
|
|
1461
|
+
const diff = this.generateDiff(entry1.pattern, pattern2);
|
|
1462
|
+
return {
|
|
1463
|
+
pattern1: { id: args.id1, chars: entry1.pattern.length },
|
|
1464
|
+
pattern2: { id: label2, chars: pattern2.length },
|
|
1465
|
+
diff: diff,
|
|
1466
|
+
summary: this.summarizeDiff(entry1.pattern, pattern2)
|
|
1467
|
+
};
|
|
1468
|
+
// Performance Monitoring
|
|
1469
|
+
case 'performance_report':
|
|
1470
|
+
const report = this.perfMonitor.getReport();
|
|
1471
|
+
const bottlenecks = this.perfMonitor.getBottlenecks(5);
|
|
1472
|
+
return `${report}\n\nTop 5 Bottlenecks:\n${JSON.stringify(bottlenecks, null, 2)}`;
|
|
1473
|
+
case 'memory_usage':
|
|
1474
|
+
const memory = this.perfMonitor.getMemoryUsage();
|
|
1475
|
+
return memory ? JSON.stringify(memory, null, 2) : 'Memory usage not available';
|
|
1476
|
+
// UX Tools - Browser Control (#37)
|
|
1477
|
+
case 'show_browser':
|
|
1478
|
+
if (!this.isInitialized) {
|
|
1479
|
+
return 'Browser not initialized. Run init first.';
|
|
1480
|
+
}
|
|
1481
|
+
return await this.controller.showBrowser();
|
|
1482
|
+
case 'screenshot':
|
|
1483
|
+
if (!this.isInitialized) {
|
|
1484
|
+
return 'Browser not initialized. Run init first.';
|
|
1485
|
+
}
|
|
1486
|
+
return await this.controller.takeScreenshot(args?.filename);
|
|
1487
|
+
// UX Tools - Status & Diagnostics (#39)
|
|
1488
|
+
case 'status':
|
|
1489
|
+
return this.controller.getStatus();
|
|
1490
|
+
case 'diagnostics':
|
|
1491
|
+
if (!this.isInitialized) {
|
|
1492
|
+
return {
|
|
1493
|
+
initialized: false,
|
|
1494
|
+
message: 'Browser not initialized. Run init first for full diagnostics.'
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
return await this.controller.getDiagnostics();
|
|
1498
|
+
case 'show_errors':
|
|
1499
|
+
const errors = this.controller.getConsoleErrors();
|
|
1500
|
+
const warnings = this.controller.getConsoleWarnings();
|
|
1501
|
+
if (errors.length === 0 && warnings.length === 0) {
|
|
1502
|
+
return 'No errors or warnings captured.';
|
|
1503
|
+
}
|
|
1504
|
+
let result = '';
|
|
1505
|
+
if (errors.length > 0) {
|
|
1506
|
+
result += `❌ Errors (${errors.length}):\n${errors.map(e => ` • ${e}`).join('\n')}\n`;
|
|
1507
|
+
}
|
|
1508
|
+
if (warnings.length > 0) {
|
|
1509
|
+
result += `⚠️ Warnings (${warnings.length}):\n${warnings.map(w => ` • ${w}`).join('\n')}`;
|
|
1510
|
+
}
|
|
1511
|
+
return result.trim();
|
|
1512
|
+
// UX Tools - High-level Compose (#42, #73)
|
|
1513
|
+
case 'compose':
|
|
1514
|
+
InputValidator.validateStringLength(args.style, 'style', 100, false);
|
|
1515
|
+
if (args.key) {
|
|
1516
|
+
InputValidator.validateRootNote(args.key);
|
|
1517
|
+
}
|
|
1518
|
+
if (args.tempo !== undefined) {
|
|
1519
|
+
InputValidator.validateBPM(args.tempo);
|
|
1520
|
+
}
|
|
1521
|
+
// Auto-initialize if needed
|
|
1522
|
+
if (!this.isInitialized) {
|
|
1523
|
+
await this.controller.initialize();
|
|
1524
|
+
this.isInitialized = true;
|
|
1525
|
+
}
|
|
1526
|
+
// Generate pattern
|
|
1527
|
+
const composedPattern = this.generator.generateCompletePattern(args.style, args.key || 'C', args.tempo || this.getDefaultTempo(args.style));
|
|
1528
|
+
// Write pattern
|
|
1529
|
+
await this.controller.writePattern(composedPattern);
|
|
1530
|
+
// Auto-play by default (unless explicitly set to false)
|
|
1531
|
+
const shouldPlay = args.auto_play !== false;
|
|
1532
|
+
if (shouldPlay) {
|
|
1533
|
+
await this.controller.play();
|
|
1534
|
+
}
|
|
1535
|
+
// Build response
|
|
1536
|
+
const composeResponse = {
|
|
1537
|
+
success: true,
|
|
1538
|
+
pattern: composedPattern.substring(0, 200) + (composedPattern.length > 200 ? '...' : ''),
|
|
1539
|
+
metadata: {
|
|
1540
|
+
style: args.style,
|
|
1541
|
+
bpm: args.tempo || this.getDefaultTempo(args.style),
|
|
1542
|
+
key: args.key || 'C'
|
|
1543
|
+
},
|
|
1544
|
+
status: shouldPlay ? 'playing' : 'ready',
|
|
1545
|
+
message: `Created ${args.style} pattern in ${args.key || 'C'}${shouldPlay ? ' - now playing' : ''}`
|
|
1546
|
+
};
|
|
1547
|
+
// Get AI feedback if requested (#73)
|
|
1548
|
+
if (args.get_feedback) {
|
|
1549
|
+
if (this.geminiService.isAvailable()) {
|
|
1550
|
+
try {
|
|
1551
|
+
const feedback = await this.geminiService.getCreativeFeedback(composedPattern);
|
|
1552
|
+
composeResponse.feedback = feedback;
|
|
1553
|
+
composeResponse.message += ` (AI feedback: ${feedback.complexity} complexity, estimated ${feedback.estimatedStyle})`;
|
|
1554
|
+
}
|
|
1555
|
+
catch (error) {
|
|
1556
|
+
this.logger.warn('Failed to get AI feedback for compose', error);
|
|
1557
|
+
// Don't fail the whole operation, just note the feedback failure
|
|
1558
|
+
composeResponse.message += ' (AI feedback unavailable)';
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
else {
|
|
1562
|
+
composeResponse.message += ' (AI feedback requires GEMINI_API_KEY)';
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
return composeResponse;
|
|
1566
|
+
// AI Feedback Tools (#67)
|
|
1567
|
+
case 'get_pattern_feedback':
|
|
1568
|
+
return await this.getPatternFeedback(args?.includeAudio || false, args?.style);
|
|
1569
|
+
// Audio Capture Tools (#72)
|
|
1570
|
+
case 'start_audio_capture':
|
|
1571
|
+
return await this.startAudioCapture(args?.format, args?.maxDuration);
|
|
1572
|
+
case 'stop_audio_capture':
|
|
1573
|
+
return await this.stopAudioCapture();
|
|
1574
|
+
case 'capture_audio_sample':
|
|
1575
|
+
return await this.captureAudioSampleTool(args?.duration);
|
|
1576
|
+
// MIDI Export Tools (#74)
|
|
1577
|
+
case 'export_midi':
|
|
1578
|
+
return await this.exportMidi(args?.filename, args?.duration, args?.bpm, args?.format);
|
|
1579
|
+
// Multi-Session Management Tools (#75)
|
|
1580
|
+
case 'create_session':
|
|
1581
|
+
InputValidator.validateStringLength(args.session_id, 'session_id', 100, false);
|
|
1582
|
+
try {
|
|
1583
|
+
await this.sessionManager.createSession(args.session_id);
|
|
1584
|
+
return {
|
|
1585
|
+
success: true,
|
|
1586
|
+
session_id: args.session_id,
|
|
1587
|
+
message: `Session '${args.session_id}' created successfully`,
|
|
1588
|
+
total_sessions: this.sessionManager.getSessionCount(),
|
|
1589
|
+
max_sessions: this.sessionManager.getMaxSessions()
|
|
1590
|
+
};
|
|
1591
|
+
}
|
|
1592
|
+
catch (error) {
|
|
1593
|
+
return {
|
|
1594
|
+
success: false,
|
|
1595
|
+
error: error.message
|
|
1596
|
+
};
|
|
1597
|
+
}
|
|
1598
|
+
case 'destroy_session':
|
|
1599
|
+
InputValidator.validateStringLength(args.session_id, 'session_id', 100, false);
|
|
1600
|
+
try {
|
|
1601
|
+
await this.sessionManager.destroySession(args.session_id);
|
|
1602
|
+
return {
|
|
1603
|
+
success: true,
|
|
1604
|
+
session_id: args.session_id,
|
|
1605
|
+
message: `Session '${args.session_id}' destroyed`,
|
|
1606
|
+
remaining_sessions: this.sessionManager.getSessionCount()
|
|
1607
|
+
};
|
|
1608
|
+
}
|
|
1609
|
+
catch (error) {
|
|
1610
|
+
return {
|
|
1611
|
+
success: false,
|
|
1612
|
+
error: error.message
|
|
1613
|
+
};
|
|
1614
|
+
}
|
|
1615
|
+
case 'list_sessions':
|
|
1616
|
+
const sessionsInfo = this.sessionManager.getSessionsInfo();
|
|
1617
|
+
return {
|
|
1618
|
+
count: sessionsInfo.length,
|
|
1619
|
+
max_sessions: this.sessionManager.getMaxSessions(),
|
|
1620
|
+
default_session: this.sessionManager.getDefaultSessionId(),
|
|
1621
|
+
sessions: sessionsInfo.map(s => ({
|
|
1622
|
+
id: s.id,
|
|
1623
|
+
created: s.created.toISOString(),
|
|
1624
|
+
last_activity: s.lastActivity.toISOString(),
|
|
1625
|
+
is_playing: s.isPlaying,
|
|
1626
|
+
is_default: s.id === this.sessionManager.getDefaultSessionId()
|
|
1627
|
+
}))
|
|
1628
|
+
};
|
|
1629
|
+
case 'switch_session':
|
|
1630
|
+
InputValidator.validateStringLength(args.session_id, 'session_id', 100, false);
|
|
1631
|
+
try {
|
|
1632
|
+
this.sessionManager.setDefaultSession(args.session_id);
|
|
1633
|
+
return {
|
|
1634
|
+
success: true,
|
|
1635
|
+
default_session: args.session_id,
|
|
1636
|
+
message: `Default session switched to '${args.session_id}'`
|
|
1637
|
+
};
|
|
1638
|
+
}
|
|
1639
|
+
catch (error) {
|
|
1640
|
+
return {
|
|
1641
|
+
success: false,
|
|
1642
|
+
error: error.message
|
|
1643
|
+
};
|
|
1644
|
+
}
|
|
1645
|
+
// AI Collaborative Jamming (#82)
|
|
1646
|
+
case 'jam_with':
|
|
1647
|
+
return await this.jamWith(args.layer, args.style_hint, args.auto_play);
|
|
1648
|
+
// Pattern Refinement Tools (#78, #81)
|
|
1649
|
+
case 'refine':
|
|
1650
|
+
return await this.refinePattern(args.direction);
|
|
1651
|
+
case 'set_energy':
|
|
1652
|
+
return await this.setEnergyLevel(args.level);
|
|
1653
|
+
default:
|
|
1654
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
/**
|
|
1658
|
+
* Incrementally refines the current pattern with simple directional commands.
|
|
1659
|
+
* Supports: faster/slower, louder/quieter, brighter/darker, more reverb/drier.
|
|
1660
|
+
* @param direction - Refinement direction
|
|
1661
|
+
* @returns Result object with success status and applied refinement
|
|
1662
|
+
*/
|
|
1663
|
+
async refinePattern(direction) {
|
|
1664
|
+
const currentPattern = await this.getCurrentPatternSafe();
|
|
1665
|
+
if (!currentPattern || currentPattern.trim().length === 0) {
|
|
1666
|
+
return {
|
|
1667
|
+
success: false,
|
|
1668
|
+
error: 'No pattern to refine. Write a pattern first.'
|
|
1669
|
+
};
|
|
1670
|
+
}
|
|
1671
|
+
let modification = '';
|
|
1672
|
+
const dir = direction.toLowerCase().trim();
|
|
1673
|
+
switch (dir) {
|
|
1674
|
+
case 'faster':
|
|
1675
|
+
modification = '.fast(1.1)';
|
|
1676
|
+
break;
|
|
1677
|
+
case 'slower':
|
|
1678
|
+
modification = '.slow(1.1)';
|
|
1679
|
+
break;
|
|
1680
|
+
case 'louder':
|
|
1681
|
+
modification = '.gain(1.1)';
|
|
1682
|
+
break;
|
|
1683
|
+
case 'quieter':
|
|
1684
|
+
modification = '.gain(0.9)';
|
|
1685
|
+
break;
|
|
1686
|
+
case 'brighter':
|
|
1687
|
+
modification = '.lpf(2000)';
|
|
1688
|
+
break;
|
|
1689
|
+
case 'darker':
|
|
1690
|
+
modification = '.lpf(800)';
|
|
1691
|
+
break;
|
|
1692
|
+
case 'more reverb':
|
|
1693
|
+
modification = '.room(0.5)';
|
|
1694
|
+
break;
|
|
1695
|
+
case 'drier':
|
|
1696
|
+
modification = '.room(0.1)';
|
|
1697
|
+
break;
|
|
1698
|
+
default:
|
|
1699
|
+
return {
|
|
1700
|
+
success: false,
|
|
1701
|
+
error: `Unknown refinement direction: ${direction}. Supported: faster, slower, louder, quieter, brighter, darker, "more reverb", drier.`
|
|
1702
|
+
};
|
|
1703
|
+
}
|
|
1704
|
+
const newPattern = currentPattern + modification;
|
|
1705
|
+
await this.writePatternSafe(newPattern);
|
|
1706
|
+
if (this.isInitialized) {
|
|
1707
|
+
await this.controller.play();
|
|
1708
|
+
}
|
|
1709
|
+
return {
|
|
1710
|
+
success: true,
|
|
1711
|
+
direction: dir,
|
|
1712
|
+
applied: modification
|
|
1713
|
+
};
|
|
1714
|
+
}
|
|
1715
|
+
/**
|
|
1716
|
+
* Adjusts the overall energy level of the current pattern.
|
|
1717
|
+
* @param level - Energy level from 0 (minimal) to 10 (maximum)
|
|
1718
|
+
* @returns Result object with success status
|
|
1719
|
+
*/
|
|
1720
|
+
async setEnergyLevel(level) {
|
|
1721
|
+
if (level < 0 || level > 10 || !Number.isInteger(level)) {
|
|
1722
|
+
return {
|
|
1723
|
+
success: false,
|
|
1724
|
+
error: 'Energy level must be an integer from 0 to 10.'
|
|
1725
|
+
};
|
|
1726
|
+
}
|
|
1727
|
+
const currentPattern = await this.getCurrentPatternSafe();
|
|
1728
|
+
if (!currentPattern || currentPattern.trim().length === 0) {
|
|
1729
|
+
return {
|
|
1730
|
+
success: false,
|
|
1731
|
+
error: 'No pattern to adjust. Write a pattern first.'
|
|
1732
|
+
};
|
|
1733
|
+
}
|
|
1734
|
+
const config = ENERGY_LEVELS[level];
|
|
1735
|
+
let newPattern = currentPattern;
|
|
1736
|
+
// Apply density adjustment if specified
|
|
1737
|
+
if (config.densityAdjust) {
|
|
1738
|
+
newPattern += config.densityAdjust;
|
|
1739
|
+
}
|
|
1740
|
+
// Apply room/reverb
|
|
1741
|
+
newPattern += `.room(${config.roomAmount})`;
|
|
1742
|
+
await this.writePatternSafe(newPattern);
|
|
1743
|
+
if (this.isInitialized) {
|
|
1744
|
+
await this.controller.play();
|
|
1745
|
+
}
|
|
1746
|
+
return {
|
|
1747
|
+
success: true,
|
|
1748
|
+
level,
|
|
1749
|
+
description: config.description
|
|
1750
|
+
};
|
|
1751
|
+
}
|
|
1752
|
+
transposePattern(pattern, semitones) {
|
|
1753
|
+
// Simple transpose implementation - would need more sophisticated parsing
|
|
1754
|
+
return pattern.replace(/([a-g][#b]?)(\d)/gi, (match, note, octave) => {
|
|
1755
|
+
const noteMap = {
|
|
1756
|
+
'c': 0, 'c#': 1, 'd': 2, 'd#': 3, 'e': 4, 'f': 5,
|
|
1757
|
+
'f#': 6, 'g': 7, 'g#': 8, 'a': 9, 'a#': 10, 'b': 11
|
|
1758
|
+
};
|
|
1759
|
+
const currentNote = note.toLowerCase();
|
|
1760
|
+
const noteValue = noteMap[currentNote] || 0;
|
|
1761
|
+
const newNoteValue = (noteValue + semitones + 12) % 12;
|
|
1762
|
+
const noteNames = ['c', 'c#', 'd', 'd#', 'e', 'f', 'f#', 'g', 'g#', 'a', 'a#', 'b'];
|
|
1763
|
+
const newOctave = parseInt(octave) + Math.floor((noteValue + semitones) / 12);
|
|
1764
|
+
return noteNames[newNoteValue] + newOctave;
|
|
1765
|
+
});
|
|
1766
|
+
}
|
|
1767
|
+
/**
|
|
1768
|
+
* Gets the default tempo for a given music style
|
|
1769
|
+
* @param style - Music style/genre
|
|
1770
|
+
* @returns Default BPM for the style
|
|
1771
|
+
*/
|
|
1772
|
+
getDefaultTempo(style) {
|
|
1773
|
+
const tempoMap = {
|
|
1774
|
+
'techno': 130,
|
|
1775
|
+
'house': 125,
|
|
1776
|
+
'dnb': 174,
|
|
1777
|
+
'drum and bass': 174,
|
|
1778
|
+
'ambient': 80,
|
|
1779
|
+
'trap': 140,
|
|
1780
|
+
'jungle': 160,
|
|
1781
|
+
'jazz': 110,
|
|
1782
|
+
'experimental': 120,
|
|
1783
|
+
'dubstep': 140,
|
|
1784
|
+
'trance': 138,
|
|
1785
|
+
'breakbeat': 130,
|
|
1786
|
+
'garage': 130,
|
|
1787
|
+
'electro': 128,
|
|
1788
|
+
'downtempo': 90,
|
|
1789
|
+
'idm': 115
|
|
1790
|
+
};
|
|
1791
|
+
return tempoMap[style.toLowerCase()] || 120;
|
|
1792
|
+
}
|
|
1793
|
+
/**
|
|
1794
|
+
* Formats a timestamp as human-readable "time ago" string
|
|
1795
|
+
* @param date - Date to format
|
|
1796
|
+
* @returns Human-readable string like "2m ago" or "1h ago"
|
|
1797
|
+
*/
|
|
1798
|
+
formatTimeAgo(date) {
|
|
1799
|
+
const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000);
|
|
1800
|
+
if (seconds < 60)
|
|
1801
|
+
return `${seconds}s ago`;
|
|
1802
|
+
if (seconds < 3600)
|
|
1803
|
+
return `${Math.floor(seconds / 60)}m ago`;
|
|
1804
|
+
if (seconds < 86400)
|
|
1805
|
+
return `${Math.floor(seconds / 3600)}h ago`;
|
|
1806
|
+
return `${Math.floor(seconds / 86400)}d ago`;
|
|
1807
|
+
}
|
|
1808
|
+
/**
|
|
1809
|
+
* Generates a simple line-by-line diff between two patterns
|
|
1810
|
+
* @param pattern1 - First pattern
|
|
1811
|
+
* @param pattern2 - Second pattern
|
|
1812
|
+
* @returns Diff output showing additions and removals
|
|
1813
|
+
*/
|
|
1814
|
+
generateDiff(pattern1, pattern2) {
|
|
1815
|
+
const lines1 = pattern1.split('\n');
|
|
1816
|
+
const lines2 = pattern2.split('\n');
|
|
1817
|
+
const diff = [];
|
|
1818
|
+
const maxLines = Math.max(lines1.length, lines2.length);
|
|
1819
|
+
for (let i = 0; i < maxLines; i++) {
|
|
1820
|
+
const line1 = lines1[i] || '';
|
|
1821
|
+
const line2 = lines2[i] || '';
|
|
1822
|
+
if (line1 === line2) {
|
|
1823
|
+
diff.push(` ${line1}`);
|
|
1824
|
+
}
|
|
1825
|
+
else {
|
|
1826
|
+
if (line1)
|
|
1827
|
+
diff.push(`- ${line1}`);
|
|
1828
|
+
if (line2)
|
|
1829
|
+
diff.push(`+ ${line2}`);
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
return diff;
|
|
1833
|
+
}
|
|
1834
|
+
/**
|
|
1835
|
+
* Summarizes differences between two patterns
|
|
1836
|
+
* @param pattern1 - First pattern
|
|
1837
|
+
* @param pattern2 - Second pattern
|
|
1838
|
+
* @returns Summary of differences
|
|
1839
|
+
*/
|
|
1840
|
+
summarizeDiff(pattern1, pattern2) {
|
|
1841
|
+
const lines1 = pattern1.split('\n');
|
|
1842
|
+
const lines2 = pattern2.split('\n');
|
|
1843
|
+
let linesAdded = 0;
|
|
1844
|
+
let linesRemoved = 0;
|
|
1845
|
+
let linesChanged = 0;
|
|
1846
|
+
const maxLines = Math.max(lines1.length, lines2.length);
|
|
1847
|
+
for (let i = 0; i < maxLines; i++) {
|
|
1848
|
+
const line1 = lines1[i];
|
|
1849
|
+
const line2 = lines2[i];
|
|
1850
|
+
if (line1 === undefined && line2 !== undefined) {
|
|
1851
|
+
linesAdded++;
|
|
1852
|
+
}
|
|
1853
|
+
else if (line1 !== undefined && line2 === undefined) {
|
|
1854
|
+
linesRemoved++;
|
|
1855
|
+
}
|
|
1856
|
+
else if (line1 !== line2) {
|
|
1857
|
+
linesChanged++;
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
return {
|
|
1861
|
+
linesAdded,
|
|
1862
|
+
linesRemoved,
|
|
1863
|
+
linesChanged,
|
|
1864
|
+
charsDiff: pattern2.length - pattern1.length
|
|
1865
|
+
};
|
|
1866
|
+
}
|
|
1867
|
+
/**
|
|
1868
|
+
* Gets AI-powered creative feedback on the current pattern
|
|
1869
|
+
* Uses Google Gemini for pattern analysis and optionally audio analysis
|
|
1870
|
+
* @param includeAudio - Whether to include audio analysis (plays pattern briefly)
|
|
1871
|
+
* @param style - Optional style hint for context
|
|
1872
|
+
* @returns Feedback object with pattern_analysis and optionally audio_analysis
|
|
1873
|
+
*/
|
|
1874
|
+
async getPatternFeedback(includeAudio, style) {
|
|
1875
|
+
// Check if Gemini is available
|
|
1876
|
+
if (!this.geminiService.isAvailable()) {
|
|
1877
|
+
return {
|
|
1878
|
+
gemini_available: false,
|
|
1879
|
+
error: 'Gemini API not configured. Set GEMINI_API_KEY environment variable to enable AI feedback.'
|
|
1880
|
+
};
|
|
1881
|
+
}
|
|
1882
|
+
// Get current pattern
|
|
1883
|
+
const pattern = await this.getCurrentPatternSafe();
|
|
1884
|
+
if (!pattern || pattern.trim().length === 0) {
|
|
1885
|
+
return {
|
|
1886
|
+
gemini_available: true,
|
|
1887
|
+
error: 'No pattern to analyze. Write a pattern first.'
|
|
1888
|
+
};
|
|
1889
|
+
}
|
|
1890
|
+
const result = {
|
|
1891
|
+
gemini_available: true
|
|
1892
|
+
};
|
|
1893
|
+
// Get pattern analysis
|
|
1894
|
+
try {
|
|
1895
|
+
result.pattern_analysis = await this.geminiService.getCreativeFeedback(pattern);
|
|
1896
|
+
}
|
|
1897
|
+
catch (error) {
|
|
1898
|
+
this.logger.error('Pattern feedback failed', error);
|
|
1899
|
+
// Handle rate limit errors gracefully - pass through the detailed error message
|
|
1900
|
+
if (error.message?.includes('rate limit') || error.message?.includes('Rate limit')) {
|
|
1901
|
+
return {
|
|
1902
|
+
gemini_available: true,
|
|
1903
|
+
error: error.message // Pass through the detailed rate limit message with wait time
|
|
1904
|
+
};
|
|
1905
|
+
}
|
|
1906
|
+
result.error = `Pattern analysis failed: ${error.message}`;
|
|
1907
|
+
}
|
|
1908
|
+
// Get audio analysis if requested
|
|
1909
|
+
if (includeAudio && this.isInitialized) {
|
|
1910
|
+
try {
|
|
1911
|
+
// Capture audio by playing the pattern briefly
|
|
1912
|
+
const audioBlob = await this.captureAudioSample();
|
|
1913
|
+
if (audioBlob) {
|
|
1914
|
+
result.audio_analysis = await this.geminiService.analyzeAudio(audioBlob, {
|
|
1915
|
+
style: style,
|
|
1916
|
+
duration: 5
|
|
1917
|
+
});
|
|
1918
|
+
}
|
|
1919
|
+
else {
|
|
1920
|
+
this.logger.warn('Audio capture returned no data');
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
catch (error) {
|
|
1924
|
+
this.logger.error('Audio analysis failed', error);
|
|
1925
|
+
// Don't fail the whole request if audio analysis fails
|
|
1926
|
+
if (!result.error) {
|
|
1927
|
+
result.error = `Audio analysis failed: ${error.message}`;
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
else if (includeAudio && !this.isInitialized) {
|
|
1932
|
+
if (!result.error) {
|
|
1933
|
+
result.error = 'Audio analysis requires browser initialization. Run init first or set includeAudio to false.';
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
return result;
|
|
1937
|
+
}
|
|
1938
|
+
/**
|
|
1939
|
+
* Captures a brief audio sample from the playing pattern
|
|
1940
|
+
* Plays the pattern for ~5 seconds and captures audio using MediaRecorder
|
|
1941
|
+
* @returns Audio blob or null if capture failed
|
|
1942
|
+
*/
|
|
1943
|
+
async captureAudioSample() {
|
|
1944
|
+
if (!this._page) {
|
|
1945
|
+
this.logger.warn('Cannot capture audio: page not available');
|
|
1946
|
+
return null;
|
|
1947
|
+
}
|
|
1948
|
+
const page = this.controller.page;
|
|
1949
|
+
if (!page) {
|
|
1950
|
+
this.logger.warn('Cannot capture audio: controller page not available');
|
|
1951
|
+
return null;
|
|
1952
|
+
}
|
|
1953
|
+
try {
|
|
1954
|
+
// Inject audio capture code and start recording
|
|
1955
|
+
const audioData = await page.evaluate(async () => {
|
|
1956
|
+
return new Promise((resolve) => {
|
|
1957
|
+
const analyzer = window.strudelAudioAnalyzer;
|
|
1958
|
+
if (!analyzer || !analyzer.analyser) {
|
|
1959
|
+
resolve(null);
|
|
1960
|
+
return;
|
|
1961
|
+
}
|
|
1962
|
+
try {
|
|
1963
|
+
const audioCtx = analyzer.analyser.context;
|
|
1964
|
+
const destination = audioCtx.createMediaStreamDestination();
|
|
1965
|
+
// Connect analyzer to destination for recording
|
|
1966
|
+
analyzer.analyser.connect(destination);
|
|
1967
|
+
const mediaRecorder = new MediaRecorder(destination.stream, {
|
|
1968
|
+
mimeType: 'audio/webm;codecs=opus'
|
|
1969
|
+
});
|
|
1970
|
+
const chunks = [];
|
|
1971
|
+
mediaRecorder.ondataavailable = (e) => {
|
|
1972
|
+
if (e.data.size > 0) {
|
|
1973
|
+
chunks.push(e.data);
|
|
1974
|
+
}
|
|
1975
|
+
};
|
|
1976
|
+
mediaRecorder.onstop = async () => {
|
|
1977
|
+
// Disconnect to avoid audio routing issues
|
|
1978
|
+
try {
|
|
1979
|
+
analyzer.analyser.disconnect(destination);
|
|
1980
|
+
}
|
|
1981
|
+
catch (e) {
|
|
1982
|
+
// Ignore disconnect errors
|
|
1983
|
+
}
|
|
1984
|
+
if (chunks.length === 0) {
|
|
1985
|
+
resolve(null);
|
|
1986
|
+
return;
|
|
1987
|
+
}
|
|
1988
|
+
const blob = new Blob(chunks, { type: 'audio/webm' });
|
|
1989
|
+
// Convert to base64 for transport
|
|
1990
|
+
const reader = new FileReader();
|
|
1991
|
+
reader.onloadend = () => {
|
|
1992
|
+
const base64 = reader.result;
|
|
1993
|
+
resolve(base64.split(',')[1] || null);
|
|
1994
|
+
};
|
|
1995
|
+
reader.onerror = () => resolve(null);
|
|
1996
|
+
reader.readAsDataURL(blob);
|
|
1997
|
+
};
|
|
1998
|
+
// Start recording
|
|
1999
|
+
mediaRecorder.start();
|
|
2000
|
+
// Record for 5 seconds
|
|
2001
|
+
setTimeout(() => {
|
|
2002
|
+
if (mediaRecorder.state === 'recording') {
|
|
2003
|
+
mediaRecorder.stop();
|
|
2004
|
+
}
|
|
2005
|
+
}, 5000);
|
|
2006
|
+
}
|
|
2007
|
+
catch (e) {
|
|
2008
|
+
resolve(null);
|
|
2009
|
+
}
|
|
2010
|
+
});
|
|
2011
|
+
});
|
|
2012
|
+
if (!audioData) {
|
|
2013
|
+
return null;
|
|
2014
|
+
}
|
|
2015
|
+
// Convert base64 back to Blob
|
|
2016
|
+
const binaryString = atob(audioData);
|
|
2017
|
+
const bytes = new Uint8Array(binaryString.length);
|
|
2018
|
+
for (let i = 0; i < binaryString.length; i++) {
|
|
2019
|
+
bytes[i] = binaryString.charCodeAt(i);
|
|
2020
|
+
}
|
|
2021
|
+
return new Blob([bytes], { type: 'audio/webm' });
|
|
2022
|
+
}
|
|
2023
|
+
catch (error) {
|
|
2024
|
+
this.logger.error('Audio capture failed', error);
|
|
2025
|
+
return null;
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
/**
|
|
2029
|
+
* Getter for page access in audio capture
|
|
2030
|
+
*/
|
|
2031
|
+
get _page() {
|
|
2032
|
+
return this.controller.page;
|
|
2033
|
+
}
|
|
2034
|
+
/**
|
|
2035
|
+
* Lazily initializes and returns the AudioCaptureService
|
|
2036
|
+
* Injects recorder into page on first use
|
|
2037
|
+
*/
|
|
2038
|
+
async getAudioCaptureService() {
|
|
2039
|
+
if (!this.isInitialized || !this._page) {
|
|
2040
|
+
throw new Error('Browser not initialized. Run init first.');
|
|
2041
|
+
}
|
|
2042
|
+
if (!this.audioCaptureService) {
|
|
2043
|
+
this.audioCaptureService = new AudioCaptureService();
|
|
2044
|
+
await this.audioCaptureService.injectRecorder(this._page);
|
|
2045
|
+
}
|
|
2046
|
+
return this.audioCaptureService;
|
|
2047
|
+
}
|
|
2048
|
+
/**
|
|
2049
|
+
* Starts audio capture from Strudel output
|
|
2050
|
+
* @param format - Audio format ('webm' or 'opus')
|
|
2051
|
+
* @param maxDuration - Maximum capture duration in ms (optional)
|
|
2052
|
+
* @returns Status message
|
|
2053
|
+
*/
|
|
2054
|
+
async startAudioCapture(format, maxDuration) {
|
|
2055
|
+
try {
|
|
2056
|
+
const captureService = await this.getAudioCaptureService();
|
|
2057
|
+
if (captureService.isCapturing()) {
|
|
2058
|
+
return {
|
|
2059
|
+
success: false,
|
|
2060
|
+
message: 'Audio capture already in progress. Stop it first.'
|
|
2061
|
+
};
|
|
2062
|
+
}
|
|
2063
|
+
await captureService.startCapture(this._page, { format });
|
|
2064
|
+
return {
|
|
2065
|
+
success: true,
|
|
2066
|
+
message: 'Audio capture started. Use stop_audio_capture to get the recorded audio.',
|
|
2067
|
+
format: format || 'webm'
|
|
2068
|
+
};
|
|
2069
|
+
}
|
|
2070
|
+
catch (error) {
|
|
2071
|
+
return {
|
|
2072
|
+
success: false,
|
|
2073
|
+
message: `Failed to start audio capture: ${error.message}`
|
|
2074
|
+
};
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
/**
|
|
2078
|
+
* Stops audio capture and returns base64-encoded audio data
|
|
2079
|
+
* @returns Captured audio as base64 with metadata
|
|
2080
|
+
*/
|
|
2081
|
+
async stopAudioCapture() {
|
|
2082
|
+
try {
|
|
2083
|
+
const captureService = await this.getAudioCaptureService();
|
|
2084
|
+
if (!captureService.isCapturing()) {
|
|
2085
|
+
return {
|
|
2086
|
+
success: false,
|
|
2087
|
+
message: 'No audio capture in progress. Start capture first.'
|
|
2088
|
+
};
|
|
2089
|
+
}
|
|
2090
|
+
const result = await captureService.stopCapture(this._page);
|
|
2091
|
+
// Convert Blob to base64
|
|
2092
|
+
const arrayBuffer = await result.blob.arrayBuffer();
|
|
2093
|
+
const bytes = new Uint8Array(arrayBuffer);
|
|
2094
|
+
let binary = '';
|
|
2095
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
2096
|
+
binary += String.fromCharCode(bytes[i]);
|
|
2097
|
+
}
|
|
2098
|
+
const base64 = btoa(binary);
|
|
2099
|
+
return {
|
|
2100
|
+
success: true,
|
|
2101
|
+
message: `Captured ${result.duration}ms of audio`,
|
|
2102
|
+
audio: base64,
|
|
2103
|
+
duration: result.duration,
|
|
2104
|
+
format: result.format
|
|
2105
|
+
};
|
|
2106
|
+
}
|
|
2107
|
+
catch (error) {
|
|
2108
|
+
return {
|
|
2109
|
+
success: false,
|
|
2110
|
+
message: `Failed to stop audio capture: ${error.message}`
|
|
2111
|
+
};
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
/**
|
|
2115
|
+
* Captures a fixed-duration audio sample (MCP tool handler)
|
|
2116
|
+
* @param duration - Duration in milliseconds (default: 5000)
|
|
2117
|
+
* @returns Captured audio as base64 with metadata
|
|
2118
|
+
*/
|
|
2119
|
+
async captureAudioSampleTool(duration) {
|
|
2120
|
+
const durationMs = duration || 5000;
|
|
2121
|
+
// Validate duration
|
|
2122
|
+
if (durationMs < 100 || durationMs > 60000) {
|
|
2123
|
+
return {
|
|
2124
|
+
success: false,
|
|
2125
|
+
message: 'Duration must be between 100ms and 60000ms (1 minute)'
|
|
2126
|
+
};
|
|
2127
|
+
}
|
|
2128
|
+
try {
|
|
2129
|
+
const captureService = await this.getAudioCaptureService();
|
|
2130
|
+
if (captureService.isCapturing()) {
|
|
2131
|
+
return {
|
|
2132
|
+
success: false,
|
|
2133
|
+
message: 'Audio capture already in progress. Stop it first.'
|
|
2134
|
+
};
|
|
2135
|
+
}
|
|
2136
|
+
const result = await captureService.captureForDuration(this._page, durationMs);
|
|
2137
|
+
// Convert Blob to base64
|
|
2138
|
+
const arrayBuffer = await result.blob.arrayBuffer();
|
|
2139
|
+
const bytes = new Uint8Array(arrayBuffer);
|
|
2140
|
+
let binary = '';
|
|
2141
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
2142
|
+
binary += String.fromCharCode(bytes[i]);
|
|
2143
|
+
}
|
|
2144
|
+
const base64 = btoa(binary);
|
|
2145
|
+
return {
|
|
2146
|
+
success: true,
|
|
2147
|
+
message: `Captured ${result.duration}ms audio sample`,
|
|
2148
|
+
audio: base64,
|
|
2149
|
+
duration: result.duration,
|
|
2150
|
+
format: result.format
|
|
2151
|
+
};
|
|
2152
|
+
}
|
|
2153
|
+
catch (error) {
|
|
2154
|
+
return {
|
|
2155
|
+
success: false,
|
|
2156
|
+
message: `Failed to capture audio sample: ${error.message}`
|
|
2157
|
+
};
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
/**
|
|
2161
|
+
* Exports current pattern to MIDI format
|
|
2162
|
+
* @param filename - Output filename (optional)
|
|
2163
|
+
* @param bars - Duration in bars (default: 4)
|
|
2164
|
+
* @param bpm - Tempo in BPM (default: 120)
|
|
2165
|
+
* @param format - Output format: 'file' or 'base64' (default: 'base64')
|
|
2166
|
+
* @returns Export result with MIDI data or file path
|
|
2167
|
+
*/
|
|
2168
|
+
async exportMidi(filename, bars, bpm, format) {
|
|
2169
|
+
// Validate inputs
|
|
2170
|
+
if (bpm !== undefined) {
|
|
2171
|
+
InputValidator.validateBPM(bpm);
|
|
2172
|
+
}
|
|
2173
|
+
if (bars !== undefined && (bars < 1 || bars > 128)) {
|
|
2174
|
+
return {
|
|
2175
|
+
success: false,
|
|
2176
|
+
message: 'Bars must be between 1 and 128'
|
|
2177
|
+
};
|
|
2178
|
+
}
|
|
2179
|
+
// Get current pattern
|
|
2180
|
+
const pattern = await this.getCurrentPatternSafe();
|
|
2181
|
+
if (!pattern || pattern.trim().length === 0) {
|
|
2182
|
+
return {
|
|
2183
|
+
success: false,
|
|
2184
|
+
message: 'No pattern to export. Write a pattern first.'
|
|
2185
|
+
};
|
|
2186
|
+
}
|
|
2187
|
+
const exportOptions = {
|
|
2188
|
+
bpm: bpm || 120,
|
|
2189
|
+
bars: bars || 4
|
|
2190
|
+
};
|
|
2191
|
+
// Export based on format
|
|
2192
|
+
const outputFormat = format || 'base64';
|
|
2193
|
+
if (outputFormat === 'file') {
|
|
2194
|
+
const result = this.midiExportService.exportToFile(pattern, filename, exportOptions);
|
|
2195
|
+
return {
|
|
2196
|
+
success: result.success,
|
|
2197
|
+
message: result.success
|
|
2198
|
+
? `Exported ${result.noteCount} notes to ${result.output}`
|
|
2199
|
+
: result.error || 'Export failed',
|
|
2200
|
+
output: result.output,
|
|
2201
|
+
noteCount: result.noteCount,
|
|
2202
|
+
bars: result.bars,
|
|
2203
|
+
bpm: result.bpm,
|
|
2204
|
+
error: result.error
|
|
2205
|
+
};
|
|
2206
|
+
}
|
|
2207
|
+
else {
|
|
2208
|
+
const result = this.midiExportService.exportToBase64(pattern, exportOptions);
|
|
2209
|
+
return {
|
|
2210
|
+
success: result.success,
|
|
2211
|
+
message: result.success
|
|
2212
|
+
? `Exported ${result.noteCount} notes as base64 MIDI data`
|
|
2213
|
+
: result.error || 'Export failed',
|
|
2214
|
+
output: result.output,
|
|
2215
|
+
noteCount: result.noteCount,
|
|
2216
|
+
bars: result.bars,
|
|
2217
|
+
bpm: result.bpm,
|
|
2218
|
+
error: result.error
|
|
2219
|
+
};
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
/**
|
|
2223
|
+
* AI Collaborative Jamming - generates a complementary layer to jam with the current pattern (#82)
|
|
2224
|
+
* @param layer - Type of layer to add (drums, bass, melody, pad, texture)
|
|
2225
|
+
* @param styleHint - Optional style guidance
|
|
2226
|
+
* @param autoPlay - Whether to auto-play after adding layer (default: true)
|
|
2227
|
+
* @returns Result with merged pattern and analysis info
|
|
2228
|
+
*/
|
|
2229
|
+
async jamWith(layer, styleHint, autoPlay = true) {
|
|
2230
|
+
const validLayers = ['drums', 'bass', 'melody', 'pad', 'texture'];
|
|
2231
|
+
if (!validLayers.includes(layer)) {
|
|
2232
|
+
return {
|
|
2233
|
+
success: false,
|
|
2234
|
+
message: `Invalid layer type: ${layer}. Must be one of: ${validLayers.join(', ')}`,
|
|
2235
|
+
layer,
|
|
2236
|
+
detected: { tempo: 120, key: 'C', existingLayers: [] },
|
|
2237
|
+
newLayer: ''
|
|
2238
|
+
};
|
|
2239
|
+
}
|
|
2240
|
+
const currentPattern = await this.getCurrentPatternSafe();
|
|
2241
|
+
if (!currentPattern || currentPattern.trim().length === 0) {
|
|
2242
|
+
return {
|
|
2243
|
+
success: false,
|
|
2244
|
+
message: 'No pattern to jam with. Write a pattern first.',
|
|
2245
|
+
layer,
|
|
2246
|
+
detected: { tempo: 120, key: 'C', existingLayers: [] },
|
|
2247
|
+
newLayer: ''
|
|
2248
|
+
};
|
|
2249
|
+
}
|
|
2250
|
+
const tempo = this.detectTempoFromPattern(currentPattern);
|
|
2251
|
+
const key = this.detectKeyFromPattern(currentPattern);
|
|
2252
|
+
const existingLayers = this.detectExistingLayers(currentPattern);
|
|
2253
|
+
const detectedStyle = this.detectStyleFromPattern(currentPattern, styleHint);
|
|
2254
|
+
if (existingLayers.includes(layer) && layer !== 'texture') {
|
|
2255
|
+
this.logger.warn(`Pattern already contains ${layer} layer, adding anyway`);
|
|
2256
|
+
}
|
|
2257
|
+
let newLayer;
|
|
2258
|
+
try {
|
|
2259
|
+
newLayer = this.generateComplementaryLayer(layer, key, tempo, detectedStyle, existingLayers);
|
|
2260
|
+
}
|
|
2261
|
+
catch (error) {
|
|
2262
|
+
return {
|
|
2263
|
+
success: false,
|
|
2264
|
+
message: `Failed to generate ${layer} layer: ${error.message}`,
|
|
2265
|
+
layer,
|
|
2266
|
+
detected: { tempo, key, existingLayers },
|
|
2267
|
+
newLayer: '',
|
|
2268
|
+
error: error.message
|
|
2269
|
+
};
|
|
2270
|
+
}
|
|
2271
|
+
const mergedPattern = this.mergeLayerIntoPattern(currentPattern, newLayer, layer);
|
|
2272
|
+
try {
|
|
2273
|
+
await this.writePatternSafe(mergedPattern);
|
|
2274
|
+
if (autoPlay && this.isInitialized) {
|
|
2275
|
+
await this.controller.play();
|
|
2276
|
+
}
|
|
2277
|
+
return {
|
|
2278
|
+
success: true,
|
|
2279
|
+
message: `Added ${layer} layer${styleHint ? ` (${styleHint} style)` : ''} to jam with your pattern`,
|
|
2280
|
+
layer,
|
|
2281
|
+
detected: { tempo, key, existingLayers },
|
|
2282
|
+
newLayer,
|
|
2283
|
+
pattern: mergedPattern.substring(0, 300) + (mergedPattern.length > 300 ? '...' : '')
|
|
2284
|
+
};
|
|
2285
|
+
}
|
|
2286
|
+
catch (error) {
|
|
2287
|
+
return {
|
|
2288
|
+
success: false,
|
|
2289
|
+
message: `Failed to write merged pattern: ${error.message}`,
|
|
2290
|
+
layer,
|
|
2291
|
+
detected: { tempo, key, existingLayers },
|
|
2292
|
+
newLayer,
|
|
2293
|
+
error: error.message
|
|
2294
|
+
};
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
detectTempoFromPattern(pattern) {
|
|
2298
|
+
const cpmMatch = pattern.match(/setcpm\s*\(\s*(\d+(?:\.\d+)?)\s*\)/i);
|
|
2299
|
+
if (cpmMatch)
|
|
2300
|
+
return Math.round(parseFloat(cpmMatch[1]));
|
|
2301
|
+
const bpmMatch = pattern.match(/setbpm\s*\(\s*(\d+(?:\.\d+)?)\s*\)/i);
|
|
2302
|
+
if (bpmMatch)
|
|
2303
|
+
return Math.round(parseFloat(bpmMatch[1]));
|
|
2304
|
+
const cpsMatch = pattern.match(/setcps\s*\(\s*(\d+(?:\.\d+)?)\s*(?:\/\s*60)?\s*\)/i);
|
|
2305
|
+
if (cpsMatch) {
|
|
2306
|
+
const cps = parseFloat(cpsMatch[1]);
|
|
2307
|
+
if (pattern.includes(`setcps(${cpsMatch[1]}/60`))
|
|
2308
|
+
return Math.round(cps);
|
|
2309
|
+
return Math.round(cps * 60);
|
|
2310
|
+
}
|
|
2311
|
+
if (pattern.toLowerCase().includes('dnb'))
|
|
2312
|
+
return 174;
|
|
2313
|
+
if (pattern.toLowerCase().includes('techno'))
|
|
2314
|
+
return 130;
|
|
2315
|
+
if (pattern.toLowerCase().includes('house'))
|
|
2316
|
+
return 125;
|
|
2317
|
+
return 120;
|
|
2318
|
+
}
|
|
2319
|
+
detectKeyFromPattern(pattern) {
|
|
2320
|
+
const noteMatches = pattern.match(/note\s*\(\s*["']([^"']+)["']\s*\)/gi) || [];
|
|
2321
|
+
const nMatches = pattern.match(/\.n\s*\(\s*["']([^"']+)["']\s*\)/gi) || [];
|
|
2322
|
+
const allNotes = [];
|
|
2323
|
+
for (const match of noteMatches) {
|
|
2324
|
+
const notesInMatch = match.match(/[a-g][#b]?\d?/gi) || [];
|
|
2325
|
+
allNotes.push(...notesInMatch.map(n => n.toLowerCase().replace(/\d/g, '')));
|
|
2326
|
+
}
|
|
2327
|
+
for (const match of nMatches) {
|
|
2328
|
+
const notesInMatch = match.match(/[a-g][#b]?\d?/gi) || [];
|
|
2329
|
+
allNotes.push(...notesInMatch.map(n => n.toLowerCase().replace(/\d/g, '')));
|
|
2330
|
+
}
|
|
2331
|
+
const chordMatches = pattern.match(/chord\s*\(\s*["']<([^>]+)>/gi) || [];
|
|
2332
|
+
for (const match of chordMatches) {
|
|
2333
|
+
const rootMatch = match.match(/[a-g][#b]?/i);
|
|
2334
|
+
if (rootMatch)
|
|
2335
|
+
allNotes.push(rootMatch[0].toLowerCase());
|
|
2336
|
+
}
|
|
2337
|
+
if (allNotes.length === 0)
|
|
2338
|
+
return 'C';
|
|
2339
|
+
const noteCounts = {};
|
|
2340
|
+
for (const note of allNotes) {
|
|
2341
|
+
const normalizedNote = note.charAt(0).toUpperCase() + note.slice(1);
|
|
2342
|
+
noteCounts[normalizedNote] = (noteCounts[normalizedNote] || 0) + 1;
|
|
2343
|
+
}
|
|
2344
|
+
let mostCommonNote = 'C';
|
|
2345
|
+
let maxCount = 0;
|
|
2346
|
+
for (const [note, count] of Object.entries(noteCounts)) {
|
|
2347
|
+
if (count > maxCount) {
|
|
2348
|
+
maxCount = count;
|
|
2349
|
+
mostCommonNote = note;
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
return mostCommonNote;
|
|
2353
|
+
}
|
|
2354
|
+
detectExistingLayers(pattern) {
|
|
2355
|
+
const layers = [];
|
|
2356
|
+
const lowerPattern = pattern.toLowerCase();
|
|
2357
|
+
if (lowerPattern.includes('bd') || lowerPattern.includes('cp') ||
|
|
2358
|
+
lowerPattern.includes('hh') || lowerPattern.includes('sd') ||
|
|
2359
|
+
lowerPattern.includes('sn') || lowerPattern.includes('oh') ||
|
|
2360
|
+
lowerPattern.includes('breaks') || lowerPattern.includes('drum')) {
|
|
2361
|
+
layers.push('drums');
|
|
2362
|
+
}
|
|
2363
|
+
if (pattern.match(/note\s*\([^)]*[12]\s*["']/i) || lowerPattern.includes('bass')) {
|
|
2364
|
+
layers.push('bass');
|
|
2365
|
+
}
|
|
2366
|
+
if (pattern.match(/note\s*\([^)]*[34567]\s*["']/i) ||
|
|
2367
|
+
lowerPattern.includes('melody') || lowerPattern.includes('lead')) {
|
|
2368
|
+
layers.push('melody');
|
|
2369
|
+
}
|
|
2370
|
+
if (lowerPattern.includes('chord(') || lowerPattern.includes('pad') ||
|
|
2371
|
+
lowerPattern.includes('strings') || lowerPattern.includes('.voicing')) {
|
|
2372
|
+
layers.push('pad');
|
|
2373
|
+
}
|
|
2374
|
+
if (lowerPattern.includes('noise') || lowerPattern.includes('fx') ||
|
|
2375
|
+
lowerPattern.includes('perlin') || lowerPattern.includes('rand')) {
|
|
2376
|
+
layers.push('texture');
|
|
2377
|
+
}
|
|
2378
|
+
return layers;
|
|
2379
|
+
}
|
|
2380
|
+
detectStyleFromPattern(pattern, styleHint) {
|
|
2381
|
+
if (styleHint)
|
|
2382
|
+
return styleHint.toLowerCase();
|
|
2383
|
+
const lowerPattern = pattern.toLowerCase();
|
|
2384
|
+
const tempo = this.detectTempoFromPattern(pattern);
|
|
2385
|
+
if (tempo >= 160 && lowerPattern.includes('breaks'))
|
|
2386
|
+
return 'jungle';
|
|
2387
|
+
if (tempo >= 165 && tempo <= 180)
|
|
2388
|
+
return 'dnb';
|
|
2389
|
+
if (tempo >= 125 && tempo <= 135 && lowerPattern.includes('bd*4')) {
|
|
2390
|
+
return lowerPattern.includes('cp') ? 'techno' : 'house';
|
|
2391
|
+
}
|
|
2392
|
+
if (tempo <= 100 && lowerPattern.includes('room'))
|
|
2393
|
+
return 'ambient';
|
|
2394
|
+
if (lowerPattern.includes('trap'))
|
|
2395
|
+
return 'trap';
|
|
2396
|
+
return 'techno';
|
|
2397
|
+
}
|
|
2398
|
+
generateComplementaryLayer(layer, key, tempo, style, existingLayers) {
|
|
2399
|
+
switch (layer) {
|
|
2400
|
+
case 'drums':
|
|
2401
|
+
if (existingLayers.includes('drums')) {
|
|
2402
|
+
const percOptions = {
|
|
2403
|
+
'techno': 's("~ hh ~ hh, ~ ~ oh ~").gain(0.4).hpf(5000)',
|
|
2404
|
+
'house': 's("[~ hh]*4, ~ ~ oh ~").gain(0.35).room(0.2)',
|
|
2405
|
+
'dnb': 's("hh*16").gain(perlin.range(0.2, 0.4)).hpf(6000)',
|
|
2406
|
+
'ambient': 's("~ ~ ~ hh:8").room(0.8).gain(0.2).slow(2)',
|
|
2407
|
+
'trap': 's("hh*16").gain(perlin.range(0.15, 0.35)).hpf(5000)',
|
|
2408
|
+
'jungle': 's("hh*32").gain(perlin.range(0.2, 0.4)).hpf(4000)',
|
|
2409
|
+
'jazz': 's("~ ride ~ ride, ~ ~ ~ hh").gain(0.3).room(0.3)'
|
|
2410
|
+
};
|
|
2411
|
+
return percOptions[style] || percOptions['techno'];
|
|
2412
|
+
}
|
|
2413
|
+
return this.generator.generateDrumPattern(style, 0.6);
|
|
2414
|
+
case 'bass':
|
|
2415
|
+
return this.generator.generateBassline(key, style);
|
|
2416
|
+
case 'melody': {
|
|
2417
|
+
let scaleName = 'minor';
|
|
2418
|
+
let octaveRange = [4, 5];
|
|
2419
|
+
if (style === 'jazz') {
|
|
2420
|
+
scaleName = 'dorian';
|
|
2421
|
+
octaveRange = [3, 5];
|
|
2422
|
+
}
|
|
2423
|
+
if (style === 'ambient') {
|
|
2424
|
+
scaleName = 'major';
|
|
2425
|
+
octaveRange = [4, 6];
|
|
2426
|
+
}
|
|
2427
|
+
if (existingLayers.includes('bass'))
|
|
2428
|
+
octaveRange = [4, 6];
|
|
2429
|
+
const scale = this.theory.generateScale(key, scaleName);
|
|
2430
|
+
const effects = {
|
|
2431
|
+
'techno': '.delay(0.25).room(0.2)', 'house': '.room(0.3).gain(0.6)',
|
|
2432
|
+
'dnb': '.delay(0.125).room(0.2).gain(0.5)', 'ambient': '.room(0.7).delay(0.5).gain(0.4)',
|
|
2433
|
+
'trap': '.gain(0.5).room(0.15)', 'jungle': '.delay(0.125).room(0.25).gain(0.55)',
|
|
2434
|
+
'jazz': '.room(0.4).gain(0.5)'
|
|
2435
|
+
};
|
|
2436
|
+
return this.generator.generateMelody(scale, 8, octaveRange) + (effects[style] || '.room(0.3).gain(0.5)');
|
|
2437
|
+
}
|
|
2438
|
+
case 'pad': {
|
|
2439
|
+
const safeKey = key.toLowerCase();
|
|
2440
|
+
const fourth = this.theory.getNote(key, 5).toLowerCase();
|
|
2441
|
+
const fifth = this.theory.getNote(key, 7).toLowerCase();
|
|
2442
|
+
const padPatterns = {
|
|
2443
|
+
'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)`,
|
|
2444
|
+
'house': `chord("<${safeKey}m9 ${fourth}7 ${fifth}m7>/2").dict('ireal').voicing().s("gm_epiano1").gain(0.3).room(0.4)`,
|
|
2445
|
+
'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)`,
|
|
2446
|
+
'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)`,
|
|
2447
|
+
'trap': `chord("<${safeKey}m7>/4").dict('ireal').voicing().s("sawtooth").attack(0.1).release(0.5).lpf(1500).gain(0.25).room(0.3)`,
|
|
2448
|
+
'jungle': `chord("<${safeKey}m9 ${fourth}m9>/8").dict('ireal').voicing().s("gm_epiano1").gain(0.25).room(0.4).delay(0.25)`,
|
|
2449
|
+
'jazz': `chord("<${safeKey}m9 ${fourth}m9 ${fifth}7>/4").dict('ireal').voicing().s("gm_epiano1").gain(0.3).room(0.5)`
|
|
2450
|
+
};
|
|
2451
|
+
return padPatterns[style] || padPatterns['techno'];
|
|
2452
|
+
}
|
|
2453
|
+
case 'texture': {
|
|
2454
|
+
const texturePatterns = {
|
|
2455
|
+
'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))`,
|
|
2456
|
+
'house': `s("~ noise:2 ~ noise:2").gain(0.04).hpf(6000).room(0.4)`,
|
|
2457
|
+
'dnb': `s("~ ~ ~ noise:4").gain(perlin.range(0.02, 0.05)).hpf(7000).room(0.5).pan(perlin.range(0.3, 0.7))`,
|
|
2458
|
+
'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)`,
|
|
2459
|
+
'trap': `s("~ ~ noise:3 ~").gain(0.03).hpf(10000).room(0.3)`,
|
|
2460
|
+
'jungle': `s("breaks125:8").fit().chop(32).gain(0.05).hpf(5000).room(0.4).pan(perlin.range(0.2, 0.8))`,
|
|
2461
|
+
'jazz': `s("brush:1").struct("~ 1 ~ 1 ~ 1 ~ ~").gain(0.1).room(0.5)`
|
|
2462
|
+
};
|
|
2463
|
+
return texturePatterns[style] || texturePatterns['techno'];
|
|
2464
|
+
}
|
|
2465
|
+
default:
|
|
2466
|
+
throw new Error(`Unknown layer type: ${layer}`);
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
mergeLayerIntoPattern(currentPattern, newLayer, layerType) {
|
|
2470
|
+
const trimmedPattern = currentPattern.trim();
|
|
2471
|
+
const trimmedLayer = newLayer.trim();
|
|
2472
|
+
const stackMatch = trimmedPattern.match(/^([\s\S]*?)stack\s*\(\s*([\s\S]*?)\s*\)([\s\S]*)$/);
|
|
2473
|
+
if (stackMatch) {
|
|
2474
|
+
const prefix = stackMatch[1];
|
|
2475
|
+
const stackContents = stackMatch[2].trimEnd().replace(/,\s*$/, '');
|
|
2476
|
+
const suffix = stackMatch[3];
|
|
2477
|
+
return `${prefix}stack(
|
|
2478
|
+
${stackContents},
|
|
2479
|
+
|
|
2480
|
+
// Jam ${layerType} layer
|
|
2481
|
+
${trimmedLayer}
|
|
2482
|
+
)${suffix}`;
|
|
2483
|
+
}
|
|
2484
|
+
const tempoMatch = trimmedPattern.match(/^(\s*(?:setcp[ms]|setbpm)\s*\([^)]+\)\s*\n?)/);
|
|
2485
|
+
const tempoPrefix = tempoMatch ? tempoMatch[1] : '';
|
|
2486
|
+
const patternBody = tempoMatch ? trimmedPattern.slice(tempoMatch[0].length) : trimmedPattern;
|
|
2487
|
+
return `${tempoPrefix}stack(
|
|
2488
|
+
// Original pattern
|
|
2489
|
+
${patternBody},
|
|
2490
|
+
|
|
2491
|
+
// Jam ${layerType} layer
|
|
2492
|
+
${trimmedLayer}
|
|
2493
|
+
)`;
|
|
2494
|
+
}
|
|
2495
|
+
async run() {
|
|
2496
|
+
const transport = new StdioServerTransport();
|
|
2497
|
+
await this.server.connect(transport);
|
|
2498
|
+
this.logger.info('Enhanced Strudel MCP server v2.0.1 running (fixed)');
|
|
2499
|
+
process.on('SIGINT', async () => {
|
|
2500
|
+
this.logger.info('Shutting down...');
|
|
2501
|
+
await this.controller.cleanup();
|
|
2502
|
+
await this.sessionManager.destroyAll();
|
|
2503
|
+
process.exit(0);
|
|
2504
|
+
});
|
|
2505
|
+
}
|
|
2506
|
+
}
|