@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,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transform domain — mutate the current pattern.
|
|
3
|
+
*
|
|
4
|
+
* Owns (14 tools): transpose, reverse, stretch, quantize, humanize,
|
|
5
|
+
* add_swing, apply_scale, generate_variation, add_effect, remove_effect,
|
|
6
|
+
* set_tempo, shift_mood, set_energy, refine.
|
|
7
|
+
*
|
|
8
|
+
* Per the #110 audit this eventually collapses into:
|
|
9
|
+
* - `transform` (op enum) — 8 tools
|
|
10
|
+
* - `effect` (add/remove enum) — 2 tools
|
|
11
|
+
* - `shape` (mood/energy/refine enum) — 3 tools
|
|
12
|
+
* - `set_tempo` stays — high-traffic verb
|
|
13
|
+
*
|
|
14
|
+
* For now we keep the individual verbs and just move them out of server.ts.
|
|
15
|
+
*/
|
|
16
|
+
import { InputValidator } from '../../utils/InputValidator.js';
|
|
17
|
+
const ENERGY_LEVELS = {
|
|
18
|
+
0: { tempoAdjust: -20, roomAmount: 0.5, densityAdjust: '.slow(4)', description: 'minimal/ambient' },
|
|
19
|
+
1: { tempoAdjust: -15, roomAmount: 0.4, densityAdjust: '.slow(3)', description: 'very sparse' },
|
|
20
|
+
2: { tempoAdjust: -10, roomAmount: 0.3, densityAdjust: '.slow(2)', description: 'sparse' },
|
|
21
|
+
3: { tempoAdjust: -5, roomAmount: 0.2, densityAdjust: '.slow(1.5)', description: 'light' },
|
|
22
|
+
4: { tempoAdjust: 0, roomAmount: 0.15, densityAdjust: '', description: 'relaxed' },
|
|
23
|
+
5: { tempoAdjust: 0, roomAmount: 0.1, densityAdjust: '', description: 'normal' },
|
|
24
|
+
6: { tempoAdjust: 5, roomAmount: 0.08, densityAdjust: '', description: 'moderate' },
|
|
25
|
+
7: { tempoAdjust: 10, roomAmount: 0.05, densityAdjust: '.fast(1.25)', description: 'driving' },
|
|
26
|
+
8: { tempoAdjust: 15, roomAmount: 0.03, densityAdjust: '.fast(1.5)', description: 'intense' },
|
|
27
|
+
9: { tempoAdjust: 18, roomAmount: 0.02, densityAdjust: '.fast(1.75)', description: 'very intense' },
|
|
28
|
+
10: { tempoAdjust: 20, roomAmount: 0.01, densityAdjust: '.fast(2)', description: 'maximum' },
|
|
29
|
+
};
|
|
30
|
+
const MOOD_PROFILES = {
|
|
31
|
+
dark: { preferMinor: true, tempoMod: -0.1, cutoffMod: -200, roomMod: 0.2, gainMod: -0.1, noteShift: -12 },
|
|
32
|
+
euphoric: { preferMinor: false, tempoMod: 0.1, cutoffMod: 400, roomMod: 0.1, gainMod: 0.1, noteShift: 12 },
|
|
33
|
+
melancholic: { preferMinor: true, tempoMod: -0.15, cutoffMod: -100, roomMod: 0.3, gainMod: -0.05, noteShift: 0 },
|
|
34
|
+
aggressive: { preferMinor: false, tempoMod: 0.15, cutoffMod: 600, roomMod: -0.1, gainMod: 0.15, noteShift: 0 },
|
|
35
|
+
dreamy: { preferMinor: false, tempoMod: -0.2, cutoffMod: -300, roomMod: 0.4, delayMod: 0.3, gainMod: -0.1, noteShift: 0 },
|
|
36
|
+
peaceful: { preferMinor: false, tempoMod: -0.25, cutoffMod: -200, roomMod: 0.25, gainMod: -0.15, noteShift: 0 },
|
|
37
|
+
energetic: { preferMinor: false, tempoMod: 0.2, cutoffMod: 300, roomMod: 0, gainMod: 0.1, noteShift: 0 },
|
|
38
|
+
};
|
|
39
|
+
function transposePattern(pattern, semitones) {
|
|
40
|
+
return pattern.replace(/([a-g][#b]?)(\d)/gi, (_match, note, octave) => {
|
|
41
|
+
const noteMap = {
|
|
42
|
+
'c': 0, 'c#': 1, 'd': 2, 'd#': 3, 'e': 4, 'f': 5,
|
|
43
|
+
'f#': 6, 'g': 7, 'g#': 8, 'a': 9, 'a#': 10, 'b': 11,
|
|
44
|
+
};
|
|
45
|
+
const currentNote = note.toLowerCase();
|
|
46
|
+
const noteValue = noteMap[currentNote] || 0;
|
|
47
|
+
const newNoteValue = (noteValue + semitones + 12) % 12;
|
|
48
|
+
const noteNames = ['c', 'c#', 'd', 'd#', 'e', 'f', 'f#', 'g', 'g#', 'a', 'a#', 'b'];
|
|
49
|
+
const newOctave = parseInt(octave) + Math.floor((noteValue + semitones) / 12);
|
|
50
|
+
return noteNames[newNoteValue] + newOctave;
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
export const tools = [
|
|
54
|
+
{
|
|
55
|
+
name: 'transpose',
|
|
56
|
+
description: 'Transpose notes by semitones',
|
|
57
|
+
inputSchema: {
|
|
58
|
+
type: 'object',
|
|
59
|
+
properties: { semitones: { type: 'number', description: 'Semitones to transpose' } },
|
|
60
|
+
required: ['semitones'],
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: 'reverse',
|
|
65
|
+
description: 'Reverse pattern',
|
|
66
|
+
inputSchema: { type: 'object', properties: {} },
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: 'stretch',
|
|
70
|
+
description: 'Time stretch pattern',
|
|
71
|
+
inputSchema: {
|
|
72
|
+
type: 'object',
|
|
73
|
+
properties: { factor: { type: 'number', description: 'Stretch factor' } },
|
|
74
|
+
required: ['factor'],
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: 'quantize',
|
|
79
|
+
description: 'Quantize to grid',
|
|
80
|
+
inputSchema: {
|
|
81
|
+
type: 'object',
|
|
82
|
+
properties: { grid: { type: 'string', description: 'Grid size (e.g., "1/16")' } },
|
|
83
|
+
required: ['grid'],
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: 'humanize',
|
|
88
|
+
description: 'Add human timing variation',
|
|
89
|
+
inputSchema: {
|
|
90
|
+
type: 'object',
|
|
91
|
+
properties: { amount: { type: 'number', description: 'Humanization amount (0-1)' } },
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: 'generate_variation',
|
|
96
|
+
description: 'Create pattern variations',
|
|
97
|
+
inputSchema: {
|
|
98
|
+
type: 'object',
|
|
99
|
+
properties: { type: { type: 'string', description: 'Variation type (subtle/moderate/extreme/glitch/evolving)' } },
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: 'add_effect',
|
|
104
|
+
description: 'Add effect to pattern',
|
|
105
|
+
inputSchema: {
|
|
106
|
+
type: 'object',
|
|
107
|
+
properties: {
|
|
108
|
+
effect: { type: 'string', description: 'Effect name' },
|
|
109
|
+
params: { type: 'string', description: 'Effect parameters' },
|
|
110
|
+
},
|
|
111
|
+
required: ['effect'],
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: 'remove_effect',
|
|
116
|
+
description: 'Remove effect',
|
|
117
|
+
inputSchema: {
|
|
118
|
+
type: 'object',
|
|
119
|
+
properties: { effect: { type: 'string', description: 'Effect to remove' } },
|
|
120
|
+
required: ['effect'],
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: 'set_tempo',
|
|
125
|
+
description: 'Set BPM',
|
|
126
|
+
inputSchema: {
|
|
127
|
+
type: 'object',
|
|
128
|
+
properties: { bpm: { type: 'number', description: 'Tempo in BPM' } },
|
|
129
|
+
required: ['bpm'],
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
name: 'add_swing',
|
|
134
|
+
description: 'Add swing to pattern',
|
|
135
|
+
inputSchema: {
|
|
136
|
+
type: 'object',
|
|
137
|
+
properties: { amount: { type: 'number', description: 'Swing amount (0-1)' } },
|
|
138
|
+
required: ['amount'],
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
name: 'apply_scale',
|
|
143
|
+
description: 'Apply scale to notes',
|
|
144
|
+
inputSchema: {
|
|
145
|
+
type: 'object',
|
|
146
|
+
properties: {
|
|
147
|
+
scale: { type: 'string', description: 'Scale name' },
|
|
148
|
+
root: { type: 'string', description: 'Root note' },
|
|
149
|
+
},
|
|
150
|
+
required: ['scale', 'root'],
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
name: 'shift_mood',
|
|
155
|
+
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.',
|
|
156
|
+
inputSchema: {
|
|
157
|
+
type: 'object',
|
|
158
|
+
properties: {
|
|
159
|
+
target_mood: {
|
|
160
|
+
type: 'string',
|
|
161
|
+
enum: ['dark', 'euphoric', 'melancholic', 'aggressive', 'dreamy', 'peaceful', 'energetic'],
|
|
162
|
+
description: 'Target mood',
|
|
163
|
+
},
|
|
164
|
+
intensity: { type: 'number', minimum: 0, maximum: 1, description: 'How strongly to apply the mood transformation (0-1, default: 0.5)' },
|
|
165
|
+
auto_play: { type: 'boolean', description: 'Start playback after transformation (default: true)' },
|
|
166
|
+
},
|
|
167
|
+
required: ['target_mood'],
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
name: 'set_energy',
|
|
172
|
+
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.',
|
|
173
|
+
inputSchema: {
|
|
174
|
+
type: 'object',
|
|
175
|
+
properties: { level: { type: 'number', description: 'Energy level from 0 (minimal) to 10 (maximum)' } },
|
|
176
|
+
required: ['level'],
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
name: 'refine',
|
|
181
|
+
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.',
|
|
182
|
+
inputSchema: {
|
|
183
|
+
type: 'object',
|
|
184
|
+
properties: { direction: { type: 'string', description: 'Refinement direction: faster, slower, louder, quieter, brighter, darker, "more reverb", or drier' } },
|
|
185
|
+
required: ['direction'],
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
];
|
|
189
|
+
export const toolNames = new Set(tools.map(t => t.name));
|
|
190
|
+
export async function execute(name, args, ctx) {
|
|
191
|
+
switch (name) {
|
|
192
|
+
case 'transpose': {
|
|
193
|
+
if (typeof args.semitones !== 'number' || !Number.isInteger(args.semitones)) {
|
|
194
|
+
throw new Error('Semitones must be an integer');
|
|
195
|
+
}
|
|
196
|
+
const p = await ctx.getCurrentPatternSafe();
|
|
197
|
+
await ctx.writePatternSafe(transposePattern(p, args.semitones));
|
|
198
|
+
return `Transposed ${args.semitones} semitones`;
|
|
199
|
+
}
|
|
200
|
+
case 'reverse': {
|
|
201
|
+
const p = await ctx.getCurrentPatternSafe();
|
|
202
|
+
await ctx.writePatternSafe(p + '.rev');
|
|
203
|
+
return 'Pattern reversed';
|
|
204
|
+
}
|
|
205
|
+
case 'stretch': {
|
|
206
|
+
InputValidator.validateGain(args.factor);
|
|
207
|
+
const p = await ctx.getCurrentPatternSafe();
|
|
208
|
+
await ctx.writePatternSafe(p + `.slow(${args.factor})`);
|
|
209
|
+
return `Stretched by factor of ${args.factor}`;
|
|
210
|
+
}
|
|
211
|
+
case 'quantize': {
|
|
212
|
+
InputValidator.validateStringLength(args.grid, 'grid', 50, false);
|
|
213
|
+
const p = await ctx.getCurrentPatternSafe();
|
|
214
|
+
await ctx.writePatternSafe(p + `.struct("${args.grid}")`);
|
|
215
|
+
return `Quantized to ${args.grid} grid`;
|
|
216
|
+
}
|
|
217
|
+
case 'humanize': {
|
|
218
|
+
if (args.amount !== undefined)
|
|
219
|
+
InputValidator.validateNormalizedValue(args.amount, 'amount');
|
|
220
|
+
const p = await ctx.getCurrentPatternSafe();
|
|
221
|
+
const amt = args.amount || 0.01;
|
|
222
|
+
await ctx.writePatternSafe(p + `.nudge(rand.range(-${amt}, ${amt}))`);
|
|
223
|
+
return 'Added human timing';
|
|
224
|
+
}
|
|
225
|
+
case 'generate_variation': {
|
|
226
|
+
const p = await ctx.getCurrentPatternSafe();
|
|
227
|
+
const varied = ctx.generator.generateVariation(p, args.type || 'subtle');
|
|
228
|
+
await ctx.writePatternSafe(varied);
|
|
229
|
+
return `Added ${args.type || 'subtle'} variation`;
|
|
230
|
+
}
|
|
231
|
+
case 'add_effect': {
|
|
232
|
+
InputValidator.validateStringLength(args.effect, 'effect', 100, false);
|
|
233
|
+
if (args.params)
|
|
234
|
+
InputValidator.validateStringLength(args.params, 'params', 1000, true);
|
|
235
|
+
const p = await ctx.getCurrentPatternSafe();
|
|
236
|
+
const withEffect = args.params
|
|
237
|
+
? p + `.${args.effect}(${args.params})`
|
|
238
|
+
: p + `.${args.effect}()`;
|
|
239
|
+
await ctx.writePatternSafe(withEffect);
|
|
240
|
+
return `Added ${args.effect} effect`;
|
|
241
|
+
}
|
|
242
|
+
case 'remove_effect': {
|
|
243
|
+
InputValidator.validateStringLength(args.effect, 'effect', 100, false);
|
|
244
|
+
const p = await ctx.getCurrentPatternSafe();
|
|
245
|
+
const regex = new RegExp(`\\.${args.effect}\\([^)]*\\)`, 'g');
|
|
246
|
+
const stripped = p.replace(regex, '');
|
|
247
|
+
if (stripped === p)
|
|
248
|
+
return `No ${args.effect} effect found to remove`;
|
|
249
|
+
await ctx.writePatternSafe(stripped);
|
|
250
|
+
return `Removed ${args.effect} effect`;
|
|
251
|
+
}
|
|
252
|
+
case 'set_tempo': {
|
|
253
|
+
InputValidator.validateBPM(args.bpm);
|
|
254
|
+
const p = await ctx.getCurrentPatternSafe();
|
|
255
|
+
await ctx.writePatternSafe(`setcpm(${args.bpm})\n${p}`);
|
|
256
|
+
return `Set tempo to ${args.bpm} BPM`;
|
|
257
|
+
}
|
|
258
|
+
case 'add_swing': {
|
|
259
|
+
InputValidator.validateNormalizedValue(args.amount, 'amount');
|
|
260
|
+
const p = await ctx.getCurrentPatternSafe();
|
|
261
|
+
await ctx.writePatternSafe(p + `.swing(${args.amount})`);
|
|
262
|
+
return `Added swing: ${args.amount}`;
|
|
263
|
+
}
|
|
264
|
+
case 'apply_scale': {
|
|
265
|
+
InputValidator.validateStringLength(args.scale, 'scale', 50, false);
|
|
266
|
+
InputValidator.validateRootNote(args.root);
|
|
267
|
+
const p = await ctx.getCurrentPatternSafe();
|
|
268
|
+
await ctx.writePatternSafe(p + `.scale("${args.root}:${args.scale}")`);
|
|
269
|
+
return `Applied ${args.root} ${args.scale} scale`;
|
|
270
|
+
}
|
|
271
|
+
case 'shift_mood':
|
|
272
|
+
return await shiftMood(args, ctx);
|
|
273
|
+
case 'set_energy':
|
|
274
|
+
return await setEnergyLevel(args.level, ctx);
|
|
275
|
+
case 'refine':
|
|
276
|
+
return await refinePattern(args.direction, ctx);
|
|
277
|
+
default:
|
|
278
|
+
throw new Error(`transform module does not handle tool: ${name}`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
async function shiftMood(args, ctx) {
|
|
282
|
+
const mood = args.target_mood?.toLowerCase()?.trim();
|
|
283
|
+
const profile = MOOD_PROFILES[mood];
|
|
284
|
+
if (!profile) {
|
|
285
|
+
return {
|
|
286
|
+
success: false,
|
|
287
|
+
error: `Unknown mood: ${args.target_mood}. Valid moods: ${Object.keys(MOOD_PROFILES).join(', ')}.`,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
const pattern = await ctx.getCurrentPatternSafe();
|
|
291
|
+
if (!pattern || pattern.trim().length === 0) {
|
|
292
|
+
return { success: false, error: 'No pattern to transform. Write a pattern first.' };
|
|
293
|
+
}
|
|
294
|
+
const intensity = args.intensity ?? 0.5;
|
|
295
|
+
if (intensity < 0 || intensity > 1) {
|
|
296
|
+
return { success: false, error: 'Intensity must be between 0 and 1.' };
|
|
297
|
+
}
|
|
298
|
+
const applied = [];
|
|
299
|
+
let result = pattern;
|
|
300
|
+
if (profile.tempoMod !== 0) {
|
|
301
|
+
const adjust = 1 + (profile.tempoMod * intensity);
|
|
302
|
+
if (adjust > 1) {
|
|
303
|
+
result += `.fast(${adjust.toFixed(2)})`;
|
|
304
|
+
applied.push(`tempo +${Math.round(profile.tempoMod * 100 * intensity)}%`);
|
|
305
|
+
}
|
|
306
|
+
else {
|
|
307
|
+
result += `.slow(${(1 / adjust).toFixed(2)})`;
|
|
308
|
+
applied.push(`tempo ${Math.round(profile.tempoMod * 100 * intensity)}%`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (profile.cutoffMod !== 0) {
|
|
312
|
+
const base = 1000;
|
|
313
|
+
const cutoff = Math.max(200, base + (profile.cutoffMod * intensity));
|
|
314
|
+
result += `.lpf(${Math.round(cutoff)})`;
|
|
315
|
+
applied.push(`cutoff ${profile.cutoffMod > 0 ? '+' : ''}${Math.round(profile.cutoffMod * intensity)}Hz`);
|
|
316
|
+
}
|
|
317
|
+
if (profile.roomMod !== 0) {
|
|
318
|
+
const room = Math.max(0, Math.min(1, profile.roomMod * intensity));
|
|
319
|
+
result += `.room(${room.toFixed(2)})`;
|
|
320
|
+
applied.push(`reverb ${room.toFixed(2)}`);
|
|
321
|
+
}
|
|
322
|
+
if (profile.delayMod && profile.delayMod > 0) {
|
|
323
|
+
const d = profile.delayMod * intensity;
|
|
324
|
+
result += `.delay(${d.toFixed(2)})`;
|
|
325
|
+
applied.push(`delay ${d.toFixed(2)}`);
|
|
326
|
+
}
|
|
327
|
+
if (profile.gainMod !== 0) {
|
|
328
|
+
const g = 1 + (profile.gainMod * intensity);
|
|
329
|
+
result += `.gain(${g.toFixed(2)})`;
|
|
330
|
+
applied.push(`gain ${profile.gainMod > 0 ? '+' : ''}${Math.round(profile.gainMod * 100 * intensity)}%`);
|
|
331
|
+
}
|
|
332
|
+
await ctx.writePatternSafe(result);
|
|
333
|
+
if (args.auto_play !== false && ctx.isInitialized()) {
|
|
334
|
+
await ctx.controller.play();
|
|
335
|
+
}
|
|
336
|
+
return { success: true, target_mood: mood, intensity, applied_effects: applied };
|
|
337
|
+
}
|
|
338
|
+
async function setEnergyLevel(level, ctx) {
|
|
339
|
+
if (level < 0 || level > 10 || !Number.isInteger(level)) {
|
|
340
|
+
return { success: false, error: 'Energy level must be an integer from 0 to 10.' };
|
|
341
|
+
}
|
|
342
|
+
const pattern = await ctx.getCurrentPatternSafe();
|
|
343
|
+
if (!pattern || pattern.trim().length === 0) {
|
|
344
|
+
return { success: false, error: 'No pattern to adjust. Write a pattern first.' };
|
|
345
|
+
}
|
|
346
|
+
const config = ENERGY_LEVELS[level];
|
|
347
|
+
let result = pattern;
|
|
348
|
+
if (config.densityAdjust)
|
|
349
|
+
result += config.densityAdjust;
|
|
350
|
+
result += `.room(${config.roomAmount})`;
|
|
351
|
+
await ctx.writePatternSafe(result);
|
|
352
|
+
if (ctx.isInitialized())
|
|
353
|
+
await ctx.controller.play();
|
|
354
|
+
return { success: true, level, description: config.description };
|
|
355
|
+
}
|
|
356
|
+
async function refinePattern(direction, ctx) {
|
|
357
|
+
const pattern = await ctx.getCurrentPatternSafe();
|
|
358
|
+
if (!pattern || pattern.trim().length === 0) {
|
|
359
|
+
return { success: false, error: 'No pattern to refine. Write a pattern first.' };
|
|
360
|
+
}
|
|
361
|
+
const dir = direction.toLowerCase().trim();
|
|
362
|
+
let modification = '';
|
|
363
|
+
switch (dir) {
|
|
364
|
+
case 'faster':
|
|
365
|
+
modification = '.fast(1.1)';
|
|
366
|
+
break;
|
|
367
|
+
case 'slower':
|
|
368
|
+
modification = '.slow(1.1)';
|
|
369
|
+
break;
|
|
370
|
+
case 'louder':
|
|
371
|
+
modification = '.gain(1.1)';
|
|
372
|
+
break;
|
|
373
|
+
case 'quieter':
|
|
374
|
+
modification = '.gain(0.9)';
|
|
375
|
+
break;
|
|
376
|
+
case 'brighter':
|
|
377
|
+
modification = '.lpf(2000)';
|
|
378
|
+
break;
|
|
379
|
+
case 'darker':
|
|
380
|
+
modification = '.lpf(800)';
|
|
381
|
+
break;
|
|
382
|
+
case 'more reverb':
|
|
383
|
+
modification = '.room(0.5)';
|
|
384
|
+
break;
|
|
385
|
+
case 'drier':
|
|
386
|
+
modification = '.room(0.1)';
|
|
387
|
+
break;
|
|
388
|
+
default:
|
|
389
|
+
return {
|
|
390
|
+
success: false,
|
|
391
|
+
error: `Unknown refinement direction: ${direction}. Supported: faster, slower, louder, quieter, brighter, darker, "more reverb", drier.`,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
await ctx.writePatternSafe(pattern + modification);
|
|
395
|
+
if (ctx.isInitialized())
|
|
396
|
+
await ctx.controller.play();
|
|
397
|
+
return { success: true, direction: dir, applied: modification };
|
|
398
|
+
}
|
|
399
|
+
export const transformModule = { tools, toolNames, execute };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for the per-domain tool modules under src/server/tools/.
|
|
3
|
+
*
|
|
4
|
+
* Part of the file-split work tracked in #104. Each domain file exports a
|
|
5
|
+
* `tools` array of MCP tool definitions and an `execute(name, args, ctx)`
|
|
6
|
+
* dispatcher. The server keeps the MCP protocol layer thin and delegates
|
|
7
|
+
* to these modules.
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { Logger } from '../utils/Logger.js';
|
|
2
|
+
/**
|
|
3
|
+
* AudioCaptureService captures audio from Strudel's output using MediaRecorder API.
|
|
4
|
+
*
|
|
5
|
+
* Uses the same GainNode interception pattern as AudioAnalyzer to connect
|
|
6
|
+
* a MediaStreamDestination for recording without interfering with playback.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* const capture = new AudioCaptureService();
|
|
10
|
+
* await capture.injectRecorder(page);
|
|
11
|
+
* const result = await capture.captureForDuration(page, 5000);
|
|
12
|
+
* // result.blob contains WebM/Opus audio data
|
|
13
|
+
*/
|
|
14
|
+
export class AudioCaptureService {
|
|
15
|
+
logger = new Logger();
|
|
16
|
+
_isCapturing = false;
|
|
17
|
+
_startTime = 0;
|
|
18
|
+
// Default configuration
|
|
19
|
+
DEFAULT_FORMAT = 'webm';
|
|
20
|
+
DEFAULT_SAMPLE_RATE = 48000;
|
|
21
|
+
DEFAULT_DURATION_MS = 5000;
|
|
22
|
+
MIME_TYPE = 'audio/webm;codecs=opus';
|
|
23
|
+
/**
|
|
24
|
+
* Injects audio capture code into the Strudel page.
|
|
25
|
+
* Must be called before any capture operations.
|
|
26
|
+
*
|
|
27
|
+
* @param page - Playwright page instance to inject into
|
|
28
|
+
* @throws {Error} When injection fails
|
|
29
|
+
*/
|
|
30
|
+
async injectRecorder(page) {
|
|
31
|
+
/* istanbul ignore next -- browser-injected IIFE, covered by integration tests */
|
|
32
|
+
await page.evaluate(() => {
|
|
33
|
+
window.strudelAudioCapture = {
|
|
34
|
+
mediaStreamDest: null,
|
|
35
|
+
recorder: null,
|
|
36
|
+
isConnected: false,
|
|
37
|
+
isCapturing: false,
|
|
38
|
+
startTime: 0,
|
|
39
|
+
chunks: [],
|
|
40
|
+
error: null,
|
|
41
|
+
/**
|
|
42
|
+
* Connects to Strudel's audio output via GainNode interception
|
|
43
|
+
*/
|
|
44
|
+
connect() {
|
|
45
|
+
const originalGainConnect = GainNode.prototype.connect;
|
|
46
|
+
let intercepted = false;
|
|
47
|
+
GainNode.prototype.connect = function (...args) {
|
|
48
|
+
if (!intercepted && args[0] && args[0].context) {
|
|
49
|
+
intercepted = true;
|
|
50
|
+
const ctx = args[0].context;
|
|
51
|
+
const capture = window.strudelAudioCapture;
|
|
52
|
+
// Create MediaStreamDestination for recording
|
|
53
|
+
capture.mediaStreamDest = ctx.createMediaStreamDestination();
|
|
54
|
+
// Connect GainNode to both original destination and our capture node
|
|
55
|
+
const result = originalGainConnect.apply(this, args);
|
|
56
|
+
originalGainConnect.call(this, capture.mediaStreamDest);
|
|
57
|
+
capture.isConnected = true;
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
return originalGainConnect.apply(this, args);
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
/**
|
|
64
|
+
* Starts audio capture
|
|
65
|
+
* @returns Success status and any error message
|
|
66
|
+
*/
|
|
67
|
+
startCapture() {
|
|
68
|
+
const capture = window.strudelAudioCapture;
|
|
69
|
+
if (!capture.isConnected || !capture.mediaStreamDest) {
|
|
70
|
+
return {
|
|
71
|
+
success: false,
|
|
72
|
+
error: 'Audio capture not connected. Play a pattern first to initialize audio.'
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
if (capture.isCapturing) {
|
|
76
|
+
return { success: false, error: 'Capture already in progress.' };
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
// Reset state
|
|
80
|
+
capture.chunks = [];
|
|
81
|
+
capture.error = null;
|
|
82
|
+
// Create MediaRecorder with WebM/Opus codec (Gemini compatible)
|
|
83
|
+
const options = {
|
|
84
|
+
mimeType: 'audio/webm;codecs=opus'
|
|
85
|
+
};
|
|
86
|
+
capture.recorder = new MediaRecorder(capture.mediaStreamDest.stream, options);
|
|
87
|
+
capture.recorder.ondataavailable = (event) => {
|
|
88
|
+
if (event.data.size > 0) {
|
|
89
|
+
capture.chunks.push(event.data);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
capture.recorder.onerror = (event) => {
|
|
93
|
+
capture.error = 'MediaRecorder error occurred';
|
|
94
|
+
capture.isCapturing = false;
|
|
95
|
+
};
|
|
96
|
+
capture.recorder.onstop = () => {
|
|
97
|
+
capture.isCapturing = false;
|
|
98
|
+
};
|
|
99
|
+
capture.startTime = Date.now();
|
|
100
|
+
capture.isCapturing = true;
|
|
101
|
+
capture.recorder.start(100); // Collect data every 100ms
|
|
102
|
+
return { success: true };
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
return { success: false, error: err.message || 'Failed to start capture' };
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
/**
|
|
109
|
+
* Stops audio capture and returns recorded data
|
|
110
|
+
* @returns Recorded audio data or error
|
|
111
|
+
*/
|
|
112
|
+
async stopCapture() {
|
|
113
|
+
const capture = window.strudelAudioCapture;
|
|
114
|
+
if (!capture.recorder || !capture.isCapturing) {
|
|
115
|
+
return { success: false, error: 'No capture in progress.' };
|
|
116
|
+
}
|
|
117
|
+
return new Promise((resolve) => {
|
|
118
|
+
const recorder = capture.recorder;
|
|
119
|
+
const startTime = capture.startTime;
|
|
120
|
+
recorder.onstop = () => {
|
|
121
|
+
capture.isCapturing = false;
|
|
122
|
+
const duration = Date.now() - startTime;
|
|
123
|
+
if (capture.chunks.length === 0) {
|
|
124
|
+
resolve({ success: false, error: 'No audio data captured.' });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const blob = new Blob(capture.chunks, { type: 'audio/webm;codecs=opus' });
|
|
128
|
+
capture.chunks = [];
|
|
129
|
+
resolve({
|
|
130
|
+
success: true,
|
|
131
|
+
blob,
|
|
132
|
+
duration,
|
|
133
|
+
format: 'audio/webm;codecs=opus'
|
|
134
|
+
});
|
|
135
|
+
};
|
|
136
|
+
recorder.stop();
|
|
137
|
+
});
|
|
138
|
+
},
|
|
139
|
+
/**
|
|
140
|
+
* Returns current capture state
|
|
141
|
+
*/
|
|
142
|
+
getState() {
|
|
143
|
+
const capture = window.strudelAudioCapture;
|
|
144
|
+
return {
|
|
145
|
+
isCapturing: capture.isCapturing,
|
|
146
|
+
startTime: capture.startTime,
|
|
147
|
+
chunks: capture.chunks
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
// Initialize connection interception
|
|
152
|
+
window.strudelAudioCapture.connect();
|
|
153
|
+
});
|
|
154
|
+
this.logger.debug('Audio capture injected');
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Starts audio capture on the page.
|
|
158
|
+
*
|
|
159
|
+
* @param page - Playwright page instance
|
|
160
|
+
* @param config - Optional capture configuration
|
|
161
|
+
* @throws {Error} When capture fails to start
|
|
162
|
+
*/
|
|
163
|
+
async startCapture(page, config) {
|
|
164
|
+
const result = await page.evaluate(/* istanbul ignore next */ () => {
|
|
165
|
+
const capture = window.strudelAudioCapture;
|
|
166
|
+
if (!capture) {
|
|
167
|
+
return { success: false, error: 'Audio capture not initialized. Call injectRecorder first.' };
|
|
168
|
+
}
|
|
169
|
+
return capture.startCapture();
|
|
170
|
+
});
|
|
171
|
+
if (!result.success) {
|
|
172
|
+
throw new Error(result.error || 'Failed to start capture');
|
|
173
|
+
}
|
|
174
|
+
this._isCapturing = true;
|
|
175
|
+
this._startTime = Date.now();
|
|
176
|
+
this.logger.debug('Audio capture started');
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Stops audio capture and returns the recorded audio.
|
|
180
|
+
*
|
|
181
|
+
* @param page - Playwright page instance
|
|
182
|
+
* @returns Captured audio result with blob, duration, and format
|
|
183
|
+
* @throws {Error} When capture fails or no data was recorded
|
|
184
|
+
*/
|
|
185
|
+
async stopCapture(page) {
|
|
186
|
+
const result = await page.evaluate(/* istanbul ignore next */ async () => {
|
|
187
|
+
const capture = window.strudelAudioCapture;
|
|
188
|
+
if (!capture) {
|
|
189
|
+
return { success: false, error: 'Audio capture not initialized.' };
|
|
190
|
+
}
|
|
191
|
+
return await capture.stopCapture();
|
|
192
|
+
});
|
|
193
|
+
this._isCapturing = false;
|
|
194
|
+
if (!result.success) {
|
|
195
|
+
throw new Error(result.error || 'Failed to stop capture');
|
|
196
|
+
}
|
|
197
|
+
this.logger.debug(`Audio capture stopped: ${result.duration}ms`);
|
|
198
|
+
return {
|
|
199
|
+
blob: result.blob,
|
|
200
|
+
duration: result.duration,
|
|
201
|
+
format: result.format,
|
|
202
|
+
timestamp: Date.now()
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Captures audio for a specified duration.
|
|
207
|
+
* Convenience method that handles start, wait, and stop.
|
|
208
|
+
*
|
|
209
|
+
* @param page - Playwright page instance
|
|
210
|
+
* @param durationMs - Duration to record in milliseconds (default: 5000)
|
|
211
|
+
* @returns Captured audio result
|
|
212
|
+
* @throws {Error} When capture fails
|
|
213
|
+
*
|
|
214
|
+
* @example
|
|
215
|
+
* const result = await capture.captureForDuration(page, 5000);
|
|
216
|
+
* console.log(`Captured ${result.duration}ms of audio`);
|
|
217
|
+
*/
|
|
218
|
+
async captureForDuration(page, durationMs) {
|
|
219
|
+
const duration = durationMs ?? this.DEFAULT_DURATION_MS;
|
|
220
|
+
await this.startCapture(page);
|
|
221
|
+
// Wait for the specified duration
|
|
222
|
+
await new Promise(resolve => setTimeout(resolve, duration));
|
|
223
|
+
return await this.stopCapture(page);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Returns whether audio capture is currently in progress.
|
|
227
|
+
*
|
|
228
|
+
* @returns True if capturing, false otherwise
|
|
229
|
+
*/
|
|
230
|
+
isCapturing() {
|
|
231
|
+
return this._isCapturing;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Returns the elapsed capture time in milliseconds.
|
|
235
|
+
* Returns 0 if not currently capturing.
|
|
236
|
+
*
|
|
237
|
+
* @returns Elapsed time in milliseconds
|
|
238
|
+
*/
|
|
239
|
+
getElapsedTime() {
|
|
240
|
+
if (!this._isCapturing) {
|
|
241
|
+
return 0;
|
|
242
|
+
}
|
|
243
|
+
return Date.now() - this._startTime;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Checks if audio capture is connected to Strudel's audio output.
|
|
247
|
+
*
|
|
248
|
+
* @param page - Playwright page instance
|
|
249
|
+
* @returns Connection status
|
|
250
|
+
*/
|
|
251
|
+
async isConnected(page) {
|
|
252
|
+
return await page.evaluate(/* istanbul ignore next */ () => {
|
|
253
|
+
const capture = window.strudelAudioCapture;
|
|
254
|
+
return capture?.isConnected ?? false;
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Clears any recorded chunks without stopping capture.
|
|
259
|
+
* Useful for discarding unwanted audio.
|
|
260
|
+
*
|
|
261
|
+
* @param page - Playwright page instance
|
|
262
|
+
*/
|
|
263
|
+
async clearChunks(page) {
|
|
264
|
+
await page.evaluate(/* istanbul ignore next */ () => {
|
|
265
|
+
const capture = window.strudelAudioCapture;
|
|
266
|
+
if (capture) {
|
|
267
|
+
capture.chunks = [];
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Gets the MIME type used for recording.
|
|
273
|
+
*
|
|
274
|
+
* @returns MIME type string
|
|
275
|
+
*/
|
|
276
|
+
getMimeType() {
|
|
277
|
+
return this.MIME_TYPE;
|
|
278
|
+
}
|
|
279
|
+
}
|