@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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1735 -0
  3. package/dist/AudioAnalyzer.js +678 -0
  4. package/dist/PatternStore.js +169 -0
  5. package/dist/StrudelController.js +676 -0
  6. package/dist/__tests__/MusicTheory.test.js +245 -0
  7. package/dist/__tests__/PatternGenerator.test.js +337 -0
  8. package/dist/__tests__/PatternStore.test.js +153 -0
  9. package/dist/__tests__/utils/MockPlaywright.js +130 -0
  10. package/dist/index.js +21 -0
  11. package/dist/server/EnhancedMCPServer.js +654 -0
  12. package/dist/server/EnhancedMCPServerFixed.js +2506 -0
  13. package/dist/server/server.js +580 -0
  14. package/dist/server/tools/ai.js +530 -0
  15. package/dist/server/tools/analysis.js +132 -0
  16. package/dist/server/tools/capture.js +184 -0
  17. package/dist/server/tools/diagnostics.js +96 -0
  18. package/dist/server/tools/editor.js +128 -0
  19. package/dist/server/tools/generate.js +218 -0
  20. package/dist/server/tools/history.js +196 -0
  21. package/dist/server/tools/playback.js +41 -0
  22. package/dist/server/tools/session.js +115 -0
  23. package/dist/server/tools/storage.js +77 -0
  24. package/dist/server/tools/transform.js +399 -0
  25. package/dist/server/tools/types.js +9 -0
  26. package/dist/services/AudioCaptureService.js +279 -0
  27. package/dist/services/GeminiService.js +790 -0
  28. package/dist/services/MIDIExportService.js +412 -0
  29. package/dist/services/MusicTheory.js +186 -0
  30. package/dist/services/PatternGenerator.js +647 -0
  31. package/dist/services/SessionManager.js +316 -0
  32. package/dist/services/StrudelEngine.js +374 -0
  33. package/dist/services/__mocks__/StrudelEngine.js +157 -0
  34. package/dist/types/AudioAnalysis.js +4 -0
  35. package/dist/types/index.js +1 -0
  36. package/dist/utils/ErrorRecovery.js +248 -0
  37. package/dist/utils/InputValidator.js +281 -0
  38. package/dist/utils/Logger.js +19 -0
  39. package/dist/utils/PatternValidator.js +241 -0
  40. package/dist/utils/PerformanceMonitor.js +128 -0
  41. 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
+ }