import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
Tool,
} from '@modelcontextprotocol/sdk/types.js';
import { StrudelController } from '../StrudelController.js';
import { PatternStore } from '../PatternStore.js';
import { MusicTheory } from '../services/MusicTheory.js';
import { PatternGenerator } from '../services/PatternGenerator.js';
import { GeminiService, CreativeFeedback, AudioFeedback } from '../services/GeminiService.js';
import { AudioCaptureService } from '../services/AudioCaptureService.js';
import { MIDIExportService } from '../services/MIDIExportService.js';
import { SessionManager } from '../services/SessionManager.js';
import { readFileSync, existsSync } from 'fs';
import { Logger } from '../utils/Logger.js';
import { PerformanceMonitor } from '../utils/PerformanceMonitor.js';
import { InputValidator } from '../utils/InputValidator.js';
import { StrudelEngine } from '../services/StrudelEngine.js';
const configPath = './config.json';
const config = existsSync(configPath)
? JSON.parse(readFileSync(configPath, 'utf-8'))
: { headless: false };
/** History entry with metadata for pattern browsing */
interface HistoryEntry {
id: number;
pattern: string;
timestamp: Date;
action: string;
}
/** Energy level configuration for set_energy tool (#81) */
interface EnergyConfig {
tempoAdjust: number; // Percentage adjustment (-20 to +20)
roomAmount: number; // Reverb amount (0-1)
densityAdjust: string; // fast(), slow(), or empty
description: string;
}
/** Energy level presets for pattern energy adjustments (#81) */
const ENERGY_LEVELS: Record<number, EnergyConfig> = {
0: { tempoAdjust: -20, roomAmount: 0.5, densityAdjust: '.slow(4)', description: 'minimal/ambient' },
1: { tempoAdjust: -15, roomAmount: 0.4, densityAdjust: '.slow(3)', description: 'very sparse' },
2: { tempoAdjust: -10, roomAmount: 0.3, densityAdjust: '.slow(2)', description: 'sparse' },
3: { tempoAdjust: -5, roomAmount: 0.2, densityAdjust: '.slow(1.5)', description: 'light' },
4: { tempoAdjust: 0, roomAmount: 0.15, densityAdjust: '', description: 'relaxed' },
5: { tempoAdjust: 0, roomAmount: 0.1, densityAdjust: '', description: 'normal' },
6: { tempoAdjust: 5, roomAmount: 0.08, densityAdjust: '', description: 'moderate' },
7: { tempoAdjust: 10, roomAmount: 0.05, densityAdjust: '.fast(1.25)', description: 'driving' },
8: { tempoAdjust: 15, roomAmount: 0.03, densityAdjust: '.fast(1.5)', description: 'intense' },
9: { tempoAdjust: 18, roomAmount: 0.02, densityAdjust: '.fast(1.75)', description: 'very intense' },
10: { tempoAdjust: 20, roomAmount: 0.01, densityAdjust: '.fast(2)', description: 'maximum' }
};
/** Mood profile for emotional pattern transformations (#80) */
interface MoodProfile {
preferMinor: boolean;
tempoMod: number;
cutoffMod: number;
roomMod: number;
gainMod: number;
noteShift: number;
delayMod?: number;
}
/** Mood profiles for emotional transformations (#80) */
const MOOD_PROFILES: Record<string, MoodProfile> = {
dark: { preferMinor: true, tempoMod: -0.1, cutoffMod: -200, roomMod: 0.2, gainMod: -0.1, noteShift: -12 },
euphoric: { preferMinor: false, tempoMod: 0.1, cutoffMod: 400, roomMod: 0.1, gainMod: 0.1, noteShift: 12 },
melancholic: { preferMinor: true, tempoMod: -0.15, cutoffMod: -100, roomMod: 0.3, gainMod: -0.05, noteShift: 0 },
aggressive: { preferMinor: false, tempoMod: 0.15, cutoffMod: 600, roomMod: -0.1, gainMod: 0.15, noteShift: 0 },
dreamy: { preferMinor: false, tempoMod: -0.2, cutoffMod: -300, roomMod: 0.4, delayMod: 0.3, gainMod: -0.1, noteShift: 0 },
peaceful: { preferMinor: false, tempoMod: -0.25, cutoffMod: -200, roomMod: 0.25, gainMod: -0.15, noteShift: 0 },
energetic: { preferMinor: false, tempoMod: 0.2, cutoffMod: 300, roomMod: 0, gainMod: 0.1, noteShift: 0 }
};
export class EnhancedMCPServerFixed {
private server: Server;
private controller: StrudelController;
private store: PatternStore;
private theory: MusicTheory;
private generator: PatternGenerator;
private geminiService: GeminiService;
private audioCaptureService: AudioCaptureService | null = null;
private midiExportService: MIDIExportService;
private sessionManager: SessionManager;
private logger: Logger;
private perfMonitor: PerformanceMonitor;
private strudelEngine: StrudelEngine;
private sessionHistory: string[] = [];
private undoStack: string[] = [];
private redoStack: string[] = [];
/** Pattern history with metadata for browsing (#41) */
private historyStack: HistoryEntry[] = [];
private historyIdCounter: number = 0;
/** Maximum history entries to prevent memory leaks */
private readonly MAX_HISTORY = 100;
private isInitialized: boolean = false;
private generatedPatterns: Map<string, string> = new Map();
constructor() {
this.server = new Server(
{
name: 'strudel-mcp-enhanced',
version: '2.0.1',
},
{
capabilities: {
tools: {},
},
}
);
this.controller = new StrudelController(config.headless);
this.store = new PatternStore('./patterns');
this.theory = new MusicTheory();
this.generator = new PatternGenerator();
this.geminiService = new GeminiService();
this.midiExportService = new MIDIExportService();
this.sessionManager = new SessionManager(config.headless);
this.logger = new Logger();
this.perfMonitor = new PerformanceMonitor();
this.strudelEngine = new StrudelEngine();
this.setupHandlers();
}
private getTools(): Tool[] {
// Same tools as before - keeping the same structure
return [
// Core Control Tools (10)
{
name: 'init',
description: 'Initialize Strudel in browser',
inputSchema: { type: 'object', properties: {} }
},
{
name: 'write',
description: 'Write pattern to editor with optional auto-play and validation',
inputSchema: {
type: 'object',
properties: {
pattern: { type: 'string', description: 'Pattern code' },
auto_play: { type: 'boolean', description: 'Start playback immediately after writing (default: false)' },
validate: { type: 'boolean', description: 'Validate pattern before writing (default: true)' }
},
required: ['pattern']
}
},
{
name: 'append',
description: 'Append code to current pattern',
inputSchema: {
type: 'object',
properties: {
code: { type: 'string', description: 'Code to append' }
},
required: ['code']
}
},
{
name: 'insert',
description: 'Insert code at specific line',
inputSchema: {
type: 'object',
properties: {
position: { type: 'number', description: 'Line number' },
code: { type: 'string', description: 'Code to insert' }
},
required: ['position', 'code']
}
},
{
name: 'replace',
description: 'Replace pattern section',
inputSchema: {
type: 'object',
properties: {
search: { type: 'string', description: 'Text to replace' },
replace: { type: 'string', description: 'Replacement text' }
},
required: ['search', 'replace']
}
},
{
name: 'play',
description: 'Start playing pattern',
inputSchema: { type: 'object', properties: {} }
},
{
name: 'pause',
description: 'Pause playback',
inputSchema: { type: 'object', properties: {} }
},
{
name: 'stop',
description: 'Stop playback',
inputSchema: { type: 'object', properties: {} }
},
{
name: 'clear',
description: 'Clear the editor',
inputSchema: { type: 'object', properties: {} }
},
{
name: 'get_pattern',
description: 'Get current pattern code',
inputSchema: { type: 'object', properties: {} }
},
// Pattern Manipulation (10)
{
name: 'transpose',
description: 'Transpose notes by semitones',
inputSchema: {
type: 'object',
properties: {
semitones: { type: 'number', description: 'Semitones to transpose' }
},
required: ['semitones']
}
},
{
name: 'reverse',
description: 'Reverse pattern',
inputSchema: { type: 'object', properties: {} }
},
{
name: 'stretch',
description: 'Time stretch pattern',
inputSchema: {
type: 'object',
properties: {
factor: { type: 'number', description: 'Stretch factor' }
},
required: ['factor']
}
},
{
name: 'quantize',
description: 'Quantize to grid',
inputSchema: {
type: 'object',
properties: {
grid: { type: 'string', description: 'Grid size (e.g., "1/16")' }
},
required: ['grid']
}
},
{
name: 'humanize',
description: 'Add human timing variation',
inputSchema: {
type: 'object',
properties: {
amount: { type: 'number', description: 'Humanization amount (0-1)' }
}
}
},
{
name: 'generate_variation',
description: 'Create pattern variations',
inputSchema: {
type: 'object',
properties: {
type: { type: 'string', description: 'Variation type (subtle/moderate/extreme/glitch/evolving)' }
}
}
},
{
name: 'generate_pattern',
description: 'Generate complete pattern from style with optional auto-play',
inputSchema: {
type: 'object',
properties: {
style: { type: 'string', description: 'Music style (techno/house/dnb/ambient/etc)' },
key: { type: 'string', description: 'Musical key' },
bpm: { type: 'number', description: 'Tempo in BPM' },
auto_play: { type: 'boolean', description: 'Start playback immediately (default: false)' }
},
required: ['style']
}
},
{
name: 'generate_drums',
description: 'Generate drum pattern',
inputSchema: {
type: 'object',
properties: {
style: { type: 'string', description: 'Drum style' },
complexity: { type: 'number', description: 'Complexity (0-1)' }
},
required: ['style']
}
},
{
name: 'generate_bassline',
description: 'Generate bassline',
inputSchema: {
type: 'object',
properties: {
key: { type: 'string', description: 'Musical key' },
style: { type: 'string', description: 'Bass style' }
},
required: ['key', 'style']
}
},
{
name: 'generate_melody',
description: 'Generate melody from scale',
inputSchema: {
type: 'object',
properties: {
scale: { type: 'string', description: 'Scale name' },
root: { type: 'string', description: 'Root note' },
length: { type: 'number', description: 'Number of notes' }
},
required: ['scale', 'root']
}
},
// Audio Analysis (5)
{
name: 'analyze',
description: 'Complete audio analysis',
inputSchema: { type: 'object', properties: {} }
},
{
name: 'analyze_spectrum',
description: 'FFT spectrum analysis',
inputSchema: { type: 'object', properties: {} }
},
{
name: 'analyze_rhythm',
description: 'Rhythm analysis',
inputSchema: { type: 'object', properties: {} }
},
{
name: 'detect_tempo',
description: 'BPM detection',
inputSchema: { type: 'object', properties: {} }
},
{
name: 'detect_key',
description: 'Key detection',
inputSchema: { type: 'object', properties: {} }
},
{
name: 'validate_pattern_runtime',
description: 'Validate pattern with runtime error checking (monitors Strudel console for errors)',
inputSchema: {
type: 'object',
properties: {
pattern: { type: 'string', description: 'Pattern code to validate' },
waitMs: { type: 'number', description: 'How long to wait for errors (default 500ms)' }
},
required: ['pattern']
}
},
// Effects & Processing (5)
{
name: 'add_effect',
description: 'Add effect to pattern',
inputSchema: {
type: 'object',
properties: {
effect: { type: 'string', description: 'Effect name' },
params: { type: 'string', description: 'Effect parameters' }
},
required: ['effect']
}
},
{
name: 'remove_effect',
description: 'Remove effect',
inputSchema: {
type: 'object',
properties: {
effect: { type: 'string', description: 'Effect to remove' }
},
required: ['effect']
}
},
{
name: 'set_tempo',
description: 'Set BPM',
inputSchema: {
type: 'object',
properties: {
bpm: { type: 'number', description: 'Tempo in BPM' }
},
required: ['bpm']
}
},
{
name: 'add_swing',
description: 'Add swing to pattern',
inputSchema: {
type: 'object',
properties: {
amount: { type: 'number', description: 'Swing amount (0-1)' }
},
required: ['amount']
}
},
{
name: 'apply_scale',
description: 'Apply scale to notes',
inputSchema: {
type: 'object',
properties: {
scale: { type: 'string', description: 'Scale name' },
root: { type: 'string', description: 'Root note' }
},
required: ['scale', 'root']
}
},
// Mood Transformation Tool (#80)
{
name: 'shift_mood',
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.',
inputSchema: {
type: 'object',
properties: {
target_mood: {
type: 'string',
enum: ['dark', 'euphoric', 'melancholic', 'aggressive', 'dreamy', 'peaceful', 'energetic'],
description: 'Target mood'
},
intensity: {
type: 'number',
minimum: 0,
maximum: 1,
description: 'How strongly to apply the mood transformation (0-1, default: 0.5)'
},
auto_play: {
type: 'boolean',
description: 'Start playback after transformation (default: true)'
}
},
required: ['target_mood']
}
},
// Session Management (5)
{
name: 'save',
description: 'Save pattern with metadata',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: 'Pattern name' },
tags: { type: 'array', items: { type: 'string' } }
},
required: ['name']
}
},
{
name: 'load',
description: 'Load saved pattern',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: 'Pattern name' }
},
required: ['name']
}
},
{
name: 'list',
description: 'List saved patterns',
inputSchema: {
type: 'object',
properties: {
tag: { type: 'string', description: 'Filter by tag' }
}
}
},
{
name: 'undo',
description: 'Undo last action',
inputSchema: { type: 'object', properties: {} }
},
{
name: 'redo',
description: 'Redo action',
inputSchema: { type: 'object', properties: {} }
},
// Pattern History Tools (#41)
{
name: 'list_history',
description: 'List recent pattern history with timestamps and previews',
inputSchema: {
type: 'object',
properties: {
limit: { type: 'number', description: 'Maximum entries to return (default: 10)' }
}
}
},
{
name: 'restore_history',
description: 'Restore a previous pattern from history by ID',
inputSchema: {
type: 'object',
properties: {
id: { type: 'number', description: 'History entry ID to restore' }
},
required: ['id']
}
},
{
name: 'compare_patterns',
description: 'Compare two patterns from history showing differences',
inputSchema: {
type: 'object',
properties: {
id1: { type: 'number', description: 'First pattern ID' },
id2: { type: 'number', description: 'Second pattern ID (default: current pattern)' }
},
required: ['id1']
}
},
// Additional Music Theory Tools (5)
{
name: 'generate_scale',
description: 'Generate scale notes',
inputSchema: {
type: 'object',
properties: {
root: { type: 'string', description: 'Root note' },
scale: { type: 'string', description: 'Scale type' }
},
required: ['root', 'scale']
}
},
{
name: 'generate_chord_progression',
description: 'Generate chord progression',
inputSchema: {
type: 'object',
properties: {
key: { type: 'string', description: 'Key' },
style: { type: 'string', description: 'Style (pop/jazz/blues/etc)' }
},
required: ['key', 'style']
}
},
{
name: 'generate_euclidean',
description: 'Generate Euclidean rhythm',
inputSchema: {
type: 'object',
properties: {
hits: { type: 'number', description: 'Number of hits' },
steps: { type: 'number', description: 'Total steps' },
sound: { type: 'string', description: 'Sound to use' }
},
required: ['hits', 'steps']
}
},
{
name: 'generate_polyrhythm',
description: 'Generate polyrhythm',
inputSchema: {
type: 'object',
properties: {
sounds: { type: 'array', items: { type: 'string' }, description: 'Sounds to use' },
patterns: { type: 'array', items: { type: 'number' }, description: 'Pattern numbers' }
},
required: ['sounds', 'patterns']
}
},
{
name: 'generate_fill',
description: 'Generate drum fill',
inputSchema: {
type: 'object',
properties: {
style: { type: 'string', description: 'Fill style' },
bars: { type: 'number', description: 'Number of bars' }
},
required: ['style']
}
},
// Performance Monitoring (2)
{
name: 'performance_report',
description: 'Get performance metrics and bottlenecks',
inputSchema: { type: 'object', properties: {} }
},
{
name: 'memory_usage',
description: 'Get current memory usage statistics',
inputSchema: { type: 'object', properties: {} }
},
// UX Tools - Browser Control (#37)
{
name: 'show_browser',
description: 'Bring browser window to foreground for visual feedback',
inputSchema: { type: 'object', properties: {} }
},
{
name: 'screenshot',
description: 'Take a screenshot of the current Strudel editor state',
inputSchema: {
type: 'object',
properties: {
filename: { type: 'string', description: 'Optional filename for screenshot' }
}
}
},
// UX Tools - Status & Diagnostics (#39)
{
name: 'status',
description: 'Get current browser and playback status (quick state check)',
inputSchema: { type: 'object', properties: {} }
},
{
name: 'diagnostics',
description: 'Get detailed browser diagnostics including cache, errors, and performance',
inputSchema: { type: 'object', properties: {} }
},
{
name: 'show_errors',
description: 'Display captured console errors and warnings from Strudel',
inputSchema: { type: 'object', properties: {} }
},
// UX Tools - High-level Compose (#42, #73)
{
name: 'compose',
description: 'Generate, write, and play a complete pattern in one step. Auto-initializes browser if needed.',
inputSchema: {
type: 'object',
properties: {
style: { type: 'string', description: 'Genre: techno, house, dnb, ambient, trap, jungle, jazz, experimental' },
tempo: { type: 'number', description: 'BPM (default: genre-appropriate)' },
key: { type: 'string', description: 'Musical key (default: C)' },
auto_play: { type: 'boolean', description: 'Start playback immediately (default: true)' },
get_feedback: { type: 'boolean', description: 'Get AI feedback on the generated pattern (default: false)' }
},
required: ['style']
}
},
// AI Feedback Tools (#67)
{
name: 'get_pattern_feedback',
description: 'Get AI-powered creative feedback on the current pattern using Google Gemini. Analyzes pattern structure and optionally audio.',
inputSchema: {
type: 'object',
properties: {
includeAudio: { type: 'boolean', description: 'Include audio analysis (plays pattern briefly). Default: false' },
style: { type: 'string', description: 'Optional style hint for context (e.g., "techno", "ambient")' }
}
}
},
// Audio Capture Tools (#72)
{
name: 'start_audio_capture',
description: 'Start capturing audio from Strudel output. Audio must be playing for capture to work.',
inputSchema: {
type: 'object',
properties: {
format: { type: 'string', enum: ['webm', 'opus'], description: 'Audio format (default: webm)' },
maxDuration: { type: 'number', description: 'Maximum capture duration in milliseconds' }
}
}
},
{
name: 'stop_audio_capture',
description: 'Stop audio capture and return the recorded audio as base64-encoded data.',
inputSchema: { type: 'object', properties: {} }
},
{
name: 'capture_audio_sample',
description: 'Capture a fixed-duration audio sample from Strudel output. Audio must be playing.',
inputSchema: {
type: 'object',
properties: {
duration: { type: 'number', description: 'Duration in milliseconds (default: 5000)' }
}
}
},
// MIDI Export Tools (#74)
{
name: 'export_midi',
description: 'Export current pattern to MIDI file. Parses note(), n(), and chord() functions.',
inputSchema: {
type: 'object',
properties: {
filename: { type: 'string', description: 'Output filename (optional, default: pattern.mid)' },
duration: { type: 'number', description: 'Export duration in bars (default: 4)' },
bpm: { type: 'number', description: 'Tempo in BPM (default: 120)' },
format: { type: 'string', enum: ['file', 'base64'], description: 'Output format: file or base64 (default: base64)' }
}
}
},
// Multi-Session Management Tools (#75)
{
name: 'create_session',
description: 'Create a new isolated Strudel browser session. Sessions share one browser but have isolated contexts.',
inputSchema: {
type: 'object',
properties: {
session_id: { type: 'string', description: 'Unique identifier for the session' }
},
required: ['session_id']
}
},
{
name: 'destroy_session',
description: 'Close and destroy a Strudel session, releasing its resources.',
inputSchema: {
type: 'object',
properties: {
session_id: { type: 'string', description: 'Session identifier to destroy' }
},
required: ['session_id']
}
},
{
name: 'list_sessions',
description: 'List all active Strudel sessions with their metadata.',
inputSchema: { type: 'object', properties: {} }
},
{
name: 'switch_session',
description: 'Change the default session used by other tools.',
inputSchema: {
type: 'object',
properties: {
session_id: { type: 'string', description: 'Session identifier to set as default' }
},
required: ['session_id']
}
},
// Pattern Refinement Tools (#78, #81)
{
name: 'refine',
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.',
inputSchema: {
type: 'object',
properties: {
direction: {
type: 'string',
description: 'Refinement direction: faster, slower, louder, quieter, brighter, darker, "more reverb", or drier'
}
},
required: ['direction']
}
},
{
name: 'set_energy',
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.',
inputSchema: {
type: 'object',
properties: {
level: {
type: 'number',
description: 'Energy level from 0 (minimal) to 10 (maximum)'
}
},
required: ['level']
}
},
// AI Collaborative Jamming (#82)
{
name: 'jam_with',
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.',
inputSchema: {
type: 'object',
properties: {
layer: {
type: 'string',
enum: ['drums', 'bass', 'melody', 'pad', 'texture'],
description: 'Type of layer to add: drums, bass, melody, pad, or texture'
},
style_hint: {
type: 'string',
description: 'Optional style guidance (e.g., "funky", "minimal", "atmospheric")'
},
auto_play: {
type: 'boolean',
description: 'Start playback after adding layer (default: true)'
}
},
required: ['layer']
}
}
];
}
private setupHandlers() {
this.server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: this.getTools()
}));
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
this.logger.info(`Executing tool: ${name}`, args);
// Measure performance
const result = await this.perfMonitor.measureAsync(
name,
() => this.executeTool(name, args)
);
return {
content: [{
type: 'text',
text: typeof result === 'string' ? result : JSON.stringify(result, null, 2)
}],
};
} catch (error: any) {
this.logger.error(`Tool execution failed: ${name}`, error);
return {
content: [{
type: 'text',
text: `Error: ${error.message}`
}],
};
}
});
}
private requiresInitialization(toolName: string): boolean {
const toolsRequiringInit = [
'write', 'append', 'insert', 'replace', 'play', 'pause', 'stop',
'clear', 'get_pattern', 'analyze', 'analyze_spectrum', 'analyze_rhythm',
'transpose', 'reverse', 'stretch', 'humanize', 'generate_variation',
'add_effect', 'add_swing', 'set_tempo', 'save', 'undo', 'redo',
'validate_pattern_runtime'
];
const toolsRequiringWrite = [
'generate_pattern', 'generate_drums', 'generate_bassline', 'generate_melody',
'generate_chord_progression', 'generate_euclidean', 'generate_polyrhythm',
'generate_fill'
];
return toolsRequiringInit.includes(toolName) || toolsRequiringWrite.includes(toolName);
}
private async getCurrentPatternSafe(): Promise<string> {
if (!this.isInitialized) {
// Return the last generated pattern if available
const lastPattern = Array.from(this.generatedPatterns.values()).pop();
return lastPattern || '';
}
try {
return await this.controller.getCurrentPattern();
} catch (e) {
return '';
}
}
private async writePatternSafe(pattern: string): Promise<string> {
if (!this.isInitialized) {
// Store the pattern for later use
const id = `pattern_${Date.now()}`;
this.generatedPatterns.set(id, pattern);
return `Pattern generated (initialize Strudel to use it): ${pattern.substring(0, 50)}...`;
}
return await this.controller.writePattern(pattern);
}
/**
* Gets a StrudelController for the specified session, or the default session.
* Falls back to the legacy single controller if no sessions exist.
* @param sessionId - Optional session ID. Uses default session if not specified.
* @returns StrudelController for the session
* @throws {Error} If session doesn't exist
*/
private getControllerForSession(sessionId?: string): StrudelController {
// If session_id is specified, use the SessionManager
if (sessionId) {
const controller = this.sessionManager.getSession(sessionId);
if (!controller) {
throw new Error(`Session '${sessionId}' not found. Create it first with create_session.`);
}
return controller;
}
// If sessions exist and there's a default, use it
const defaultController = this.sessionManager.getDefaultSession();
if (defaultController) {
return defaultController;
}
// Fall back to legacy single controller for backwards compatibility
return this.controller;
}
/**
* Checks if a session exists (or default/legacy controller is initialized)
* @param sessionId - Optional session ID
* @returns True if controller is available
*/
private hasSession(sessionId?: string): boolean {
if (sessionId) {
return this.sessionManager.getSession(sessionId) !== undefined;
}
// Check if we have a default session or the legacy controller is initialized
return this.sessionManager.getDefaultSession() !== undefined || this.isInitialized;
}
private async executeTool(name: string, args: any): Promise<any> {
// Check if tool needs initialization
if (this.requiresInitialization(name) && !this.isInitialized && name !== 'init') {
// For generation tools that don't require browser, handle them specially
const generationTools = [
'generate_pattern', 'generate_drums', 'generate_bassline', 'generate_melody',
'generate_chord_progression', 'generate_euclidean', 'generate_polyrhythm', 'generate_fill'
];
if (!generationTools.includes(name)) {
return `Browser not initialized. Run 'init' first to use ${name}.`;
}
}
// Save current state for undo and history (#41) (only if initialized)
if (['write', 'append', 'insert', 'replace', 'clear'].includes(name) && this.isInitialized) {
try {
const current = await this.controller.getCurrentPattern();
this.undoStack.push(current);
// Add to history stack with metadata (#41)
this.historyIdCounter++;
this.historyStack.push({
id: this.historyIdCounter,
pattern: current,
timestamp: new Date(),
action: name
});
// Enforce bounds to prevent memory leaks
if (this.undoStack.length > this.MAX_HISTORY) {
this.undoStack.shift();
}
if (this.historyStack.length > this.MAX_HISTORY) {
this.historyStack.shift();
}
this.redoStack = [];
} catch (e) {
// Controller might not be initialized yet
}
}
switch (name) {
// Core Control
case 'init':
const initResult = await this.controller.initialize();
this.isInitialized = true;
// Write any pending patterns
if (this.generatedPatterns.size > 0) {
const lastPattern = Array.from(this.generatedPatterns.values()).pop();
if (lastPattern) {
await this.controller.writePattern(lastPattern);
return `${initResult}. Loaded generated pattern.`;
}
}
return initResult;
case 'write':
InputValidator.validateStringLength(args.pattern, 'pattern', 10000, true);
// Validate pattern if requested (default: true) - Issue #40
if (args.validate !== false && this.isInitialized && typeof this.controller.validatePattern === 'function') {
try {
const validation = await this.controller.validatePattern(args.pattern);
if (validation && !validation.valid) {
return {
success: false,
errors: validation.errors,
warnings: validation.warnings,
suggestions: validation.suggestions,
message: `Pattern validation failed: ${validation.errors.join('; ')}`
};
}
} catch (e) {
// Validation failed, but we can still try to write
this.logger.warn('Pattern validation threw error, continuing with write');
}
}
const writeResult = await this.writePatternSafe(args.pattern);
// Auto-play if requested - Issue #38
if (args.auto_play && this.isInitialized) {
await this.controller.play();
return `${writeResult}. Playing.`;
}
return writeResult;
case 'append':
InputValidator.validateStringLength(args.code, 'code', 10000, true);
const current = await this.getCurrentPatternSafe();
return await this.writePatternSafe(current + '\n' + args.code);
case 'insert':
InputValidator.validatePositiveInteger(args.position, 'position');
InputValidator.validateStringLength(args.code, 'code', 10000, true);
const lines = (await this.getCurrentPatternSafe()).split('\n');
lines.splice(args.position, 0, args.code);
return await this.writePatternSafe(lines.join('\n'));
case 'replace':
InputValidator.validateStringLength(args.search, 'search', 1000, true);
InputValidator.validateStringLength(args.replace, 'replace', 10000, true);
const pattern = await this.getCurrentPatternSafe();
// Escape $ in replacement to prevent special sequence injection ($&, $1, $', etc.)
const safeReplacement = args.replace.replace(/\$/g, '$$$$');
const replaced = pattern.replace(args.search, safeReplacement);
return await this.writePatternSafe(replaced);
case 'play':
return await this.controller.play();
case 'pause':
case 'stop':
return await this.controller.stop();
case 'clear':
return await this.writePatternSafe('');
case 'get_pattern':
return await this.getCurrentPatternSafe();
// Pattern Generation - These can work without browser
case 'generate_pattern':
InputValidator.validateStringLength(args.style, 'style', 100, false);
if (args.key) {
InputValidator.validateRootNote(args.key);
}
if (args.bpm !== undefined) {
InputValidator.validateBPM(args.bpm);
}
const generated = this.generator.generateCompletePattern(
args.style,
args.key || 'C',
args.bpm || 120
);
await this.writePatternSafe(generated);
// Auto-play if requested - Issue #38
if (args.auto_play && this.isInitialized) {
await this.controller.play();
return `Generated ${args.style} pattern. Playing.`;
}
return `Generated ${args.style} pattern`;
case 'generate_drums':
InputValidator.validateStringLength(args.style, 'style', 100, false);
if (args.complexity !== undefined) {
InputValidator.validateNormalizedValue(args.complexity, 'complexity');
}
const drums = this.generator.generateDrumPattern(args.style, args.complexity || 0.5);
const currentDrum = await this.getCurrentPatternSafe();
const newDrumPattern = currentDrum ? currentDrum + '\n' + drums : drums;
await this.writePatternSafe(newDrumPattern);
return `Generated ${args.style} drums`;
case 'generate_bassline':
InputValidator.validateRootNote(args.key);
InputValidator.validateStringLength(args.style, 'style', 100, false);
const bass = this.generator.generateBassline(args.key, args.style);
const currentBass = await this.getCurrentPatternSafe();
const newBassPattern = currentBass ? currentBass + '\n' + bass : bass;
await this.writePatternSafe(newBassPattern);
return `Generated ${args.style} bassline in ${args.key}`;
case 'generate_melody':
InputValidator.validateRootNote(args.root);
InputValidator.validateScaleName(args.scale);
if (args.length !== undefined) {
InputValidator.validatePositiveInteger(args.length, 'length');
}
const scale = this.theory.generateScale(args.root, args.scale);
const melody = this.generator.generateMelody(scale, args.length || 8);
const currentMelody = await this.getCurrentPatternSafe();
const newMelodyPattern = currentMelody ? currentMelody + '\n' + melody : melody;
await this.writePatternSafe(newMelodyPattern);
return `Generated melody in ${args.root} ${args.scale}`;
// Music Theory - These don't require browser
case 'generate_scale':
InputValidator.validateRootNote(args.root);
InputValidator.validateScaleName(args.scale);
const scaleNotes = this.theory.generateScale(args.root, args.scale);
return `${args.root} ${args.scale} scale: ${scaleNotes.join(', ')}`;
case 'generate_chord_progression':
InputValidator.validateRootNote(args.key);
InputValidator.validateChordStyle(args.style);
const progression = this.theory.generateChordProgression(args.key, args.style);
const chordPattern = this.generator.generateChords(progression);
const currentChords = await this.getCurrentPatternSafe();
const newChordPattern = currentChords ? currentChords + '\n' + chordPattern : chordPattern;
await this.writePatternSafe(newChordPattern);
return `Generated ${args.style} progression in ${args.key}: ${progression}`;
case 'generate_euclidean':
InputValidator.validateEuclidean(args.hits, args.steps);
if (args.sound) {
InputValidator.validateStringLength(args.sound, 'sound', 100, false);
}
const euclidean = this.generator.generateEuclideanPattern(
args.hits,
args.steps,
args.sound || 'bd'
);
const currentEuc = await this.getCurrentPatternSafe();
const newEucPattern = currentEuc ? currentEuc + '\n' + euclidean : euclidean;
await this.writePatternSafe(newEucPattern);
return `Generated Euclidean rhythm (${args.hits}/${args.steps})`;
case 'generate_polyrhythm':
args.sounds.forEach((sound: string) => {
InputValidator.validateStringLength(sound, 'sound', 100, false);
});
args.patterns.forEach((pattern: number) => {
InputValidator.validatePositiveInteger(pattern, 'pattern');
});
const poly = this.generator.generatePolyrhythm(args.sounds, args.patterns);
const currentPoly = await this.getCurrentPatternSafe();
const newPolyPattern = currentPoly ? currentPoly + '\n' + poly : poly;
await this.writePatternSafe(newPolyPattern);
return `Generated polyrhythm`;
case 'generate_fill':
InputValidator.validateStringLength(args.style, 'style', 100, false);
if (args.bars !== undefined) {
InputValidator.validatePositiveInteger(args.bars, 'bars');
}
const fill = this.generator.generateFill(args.style, args.bars || 1);
const currentFill = await this.getCurrentPatternSafe();
const newFillPattern = currentFill ? currentFill + '\n' + fill : fill;
await this.writePatternSafe(newFillPattern);
return `Generated ${args.bars || 1} bar fill`;
// Pattern Manipulation - These require browser
case 'transpose':
// Semitones can be positive or negative, just validate it's a number
if (typeof args.semitones !== 'number' || !Number.isInteger(args.semitones)) {
throw new Error('Semitones must be an integer');
}
const toTranspose = await this.getCurrentPatternSafe();
const transposed = this.transposePattern(toTranspose, args.semitones);
await this.writePatternSafe(transposed);
return `Transposed ${args.semitones} semitones`;
case 'reverse':
const toReverse = await this.getCurrentPatternSafe();
const reversed = toReverse + '.rev';
await this.writePatternSafe(reversed);
return 'Pattern reversed';
case 'stretch':
InputValidator.validateGain(args.factor); // Positive number, use gain validator for simplicity
const toStretch = await this.getCurrentPatternSafe();
const stretched = toStretch + `.slow(${args.factor})`;
await this.writePatternSafe(stretched);
return `Stretched by factor of ${args.factor}`;
case 'humanize':
if (args.amount !== undefined) {
InputValidator.validateNormalizedValue(args.amount, 'amount');
}
const toHumanize = await this.getCurrentPatternSafe();
const humanized = toHumanize + `.nudge(rand.range(-${args.amount || 0.01}, ${args.amount || 0.01}))`;
await this.writePatternSafe(humanized);
return 'Added human timing';
case 'generate_variation':
const toVary = await this.getCurrentPatternSafe();
const varied = this.generator.generateVariation(toVary, args.type || 'subtle');
await this.writePatternSafe(varied);
return `Added ${args.type || 'subtle'} variation`;
// Effects - These require browser
case 'add_effect':
InputValidator.validateStringLength(args.effect, 'effect', 100, false);
if (args.params) {
InputValidator.validateStringLength(args.params, 'params', 1000, true);
}
const currentEffect = await this.getCurrentPatternSafe();
const withEffect = args.params
? currentEffect + `.${args.effect}(${args.params})`
: currentEffect + `.${args.effect}()`;
await this.writePatternSafe(withEffect);
return `Added ${args.effect} effect`;
case 'add_swing':
InputValidator.validateNormalizedValue(args.amount, 'amount');
const currentSwing = await this.getCurrentPatternSafe();
const withSwing = currentSwing + `.swing(${args.amount})`;
await this.writePatternSafe(withSwing);
return `Added swing: ${args.amount}`;
case 'set_tempo':
InputValidator.validateBPM(args.bpm);
const currentTempo = await this.getCurrentPatternSafe();
const withTempo = `setcpm(${args.bpm})\n${currentTempo}`;
await this.writePatternSafe(withTempo);
return `Set tempo to ${args.bpm} BPM`;
// Audio Analysis - Requires browser
case 'analyze':
if (!this.isInitialized) {
return 'Browser not initialized. Run init first.';
}
return await this.controller.analyzeAudio();
case 'analyze_spectrum':
if (!this.isInitialized) {
return 'Browser not initialized. Run init first.';
}
const spectrum = await this.controller.analyzeAudio();
return spectrum.features || spectrum;
case 'analyze_rhythm':
if (!this.isInitialized) {
return 'Browser not initialized. Run init first.';
}
const analysis = await this.controller.analyzeAudio();
return {
isPlaying: analysis.features?.isPlaying,
tempo: 'Analysis pending implementation',
pattern: 'Rhythm pattern analysis'
};
case 'detect_tempo':
if (!this.isInitialized) {
return 'Browser not initialized. Run init first.';
}
try {
const tempoAnalysis = await this.controller.detectTempo();
if (!tempoAnalysis || tempoAnalysis.bpm === 0) {
return {
bpm: 0,
confidence: 0,
message: 'No tempo detected. Ensure audio is playing and has a clear rhythmic pattern.'
};
}
return {
bpm: tempoAnalysis.bpm,
confidence: Math.round(tempoAnalysis.confidence * 100) / 100,
method: tempoAnalysis.method,
message: `Detected ${tempoAnalysis.bpm} BPM with ${Math.round(tempoAnalysis.confidence * 100)}% confidence`
};
} catch (error: any) {
return {
bpm: 0,
confidence: 0,
error: error.message || 'Tempo detection failed'
};
}
case 'detect_key':
if (!this.isInitialized) {
return 'Browser not initialized. Run init first.';
}
try {
const keyAnalysis = await this.controller.detectKey();
if (!keyAnalysis || keyAnalysis.confidence < 0.1) {
return {
key: 'Unknown',
scale: 'unknown',
confidence: 0,
message: 'No clear key detected. Ensure audio is playing and has tonal content.'
};
}
const result: any = {
key: keyAnalysis.key,
scale: keyAnalysis.scale,
confidence: Math.round(keyAnalysis.confidence * 100) / 100,
message: `Detected ${keyAnalysis.key} ${keyAnalysis.scale} with ${Math.round(keyAnalysis.confidence * 100)}% confidence`
};
// Include alternatives if available and confidence is moderate
if (keyAnalysis.alternatives && keyAnalysis.alternatives.length > 0) {
result.alternatives = keyAnalysis.alternatives.map((alt: any) => ({
key: alt.key,
scale: alt.scale,
confidence: Math.round(alt.confidence * 100) / 100
}));
}
return result;
} catch (error: any) {
return {
key: 'Unknown',
scale: 'unknown',
confidence: 0,
error: error.message || 'Key detection failed'
};
}
case 'validate_pattern_runtime':
if (!this.isInitialized) {
return 'Browser not initialized. Run init first.';
}
InputValidator.validateStringLength(args.pattern, 'pattern', 10000, false);
const validation = await this.controller.validatePatternRuntime(
args.pattern,
args.waitMs || 500
);
if (validation.valid) {
return `✅ Pattern valid - no runtime errors detected`;
} else {
return `❌ Pattern has runtime errors:\n${validation.errors.join('\n')}\n` +
(validation.warnings.length > 0 ? `\nWarnings:\n${validation.warnings.join('\n')}` : '');
}
// Local Pattern Tools (#83) - No browser required
case 'validate_pattern_local':
InputValidator.validateStringLength(args.pattern, 'pattern', 10000, false);
const localValidation = this.strudelEngine.validate(args.pattern);
return {
valid: localValidation.valid,
errors: localValidation.errors,
warnings: localValidation.warnings,
suggestions: localValidation.suggestions,
errorLocation: localValidation.errorLocation,
message: localValidation.valid
? '✅ Pattern is valid'
: `❌ Pattern has ${localValidation.errors.length} error(s)`
};
case 'analyze_pattern_local':
InputValidator.validateStringLength(args.pattern, 'pattern', 10000, false);
const patternMetadata = this.strudelEngine.analyzePattern(args.pattern);
return {
...patternMetadata,
message: `Pattern analysis: ${patternMetadata.eventsPerCycle} events/cycle, ` +
`complexity ${(patternMetadata.complexity * 100).toFixed(0)}%` +
(patternMetadata.bpm ? `, ${patternMetadata.bpm} BPM` : '')
};
case 'query_pattern_events':
InputValidator.validateStringLength(args.pattern, 'pattern', 10000, false);
const startCycle = args.start ?? 0;
const endCycle = args.end ?? 1;
if (startCycle >= endCycle) {
return { error: 'Start must be less than end' };
}
if (endCycle - startCycle > 16) {
return { error: 'Maximum range is 16 cycles to prevent excessive output' };
}
try {
const events = this.strudelEngine.queryEvents(args.pattern, startCycle, endCycle);
return {
count: events.length,
range: { start: startCycle, end: endCycle },
events: events.map((e: any) => ({
value: e.value,
start: e.start,
end: e.end,
duration: e.end - e.start
}))
};
} catch (error: any) {
return {
error: error.message,
suggestion: 'Check pattern syntax with validate_pattern_local first'
};
}
case 'transpile_pattern':
InputValidator.validateStringLength(args.pattern, 'pattern', 10000, false);
const transpileResult = this.strudelEngine.transpile(args.pattern);
if (transpileResult.success) {
return {
success: true,
transpiledCode: transpileResult.transpiledCode,
message: 'Pattern transpiled successfully'
};
} else {
return {
success: false,
error: transpileResult.error,
errorLocation: transpileResult.errorLocation,
message: 'Transpilation failed'
};
}
// Mood Transformation (#80)
case 'shift_mood':
const mood = args.target_mood?.toLowerCase()?.trim();
const moodProfile = MOOD_PROFILES[mood];
if (!moodProfile) {
return {
success: false,
error: `Unknown mood: ${args.target_mood}. Valid moods: ${Object.keys(MOOD_PROFILES).join(', ')}.`
};
}
const moodPattern = await this.getCurrentPatternSafe();
if (!moodPattern || moodPattern.trim().length === 0) {
return {
success: false,
error: 'No pattern to transform. Write a pattern first.'
};
}
const moodIntensity = args.intensity ?? 0.5;
if (moodIntensity < 0 || moodIntensity > 1) {
return {
success: false,
error: 'Intensity must be between 0 and 1.'
};
}
const appliedEffects: string[] = [];
let transformedPattern = moodPattern;
// Apply tempo modification
if (moodProfile.tempoMod !== 0) {
const tempoAdjust = 1 + (moodProfile.tempoMod * moodIntensity);
if (tempoAdjust > 1) {
transformedPattern += `.fast(${tempoAdjust.toFixed(2)})`;
appliedEffects.push(`tempo +${Math.round(moodProfile.tempoMod * 100 * moodIntensity)}%`);
} else {
transformedPattern += `.slow(${(1 / tempoAdjust).toFixed(2)})`;
appliedEffects.push(`tempo ${Math.round(moodProfile.tempoMod * 100 * moodIntensity)}%`);
}
}
// Apply cutoff modification
if (moodProfile.cutoffMod !== 0) {
const baseCutoff = 1000;
const newCutoff = Math.max(200, baseCutoff + (moodProfile.cutoffMod * moodIntensity));
transformedPattern += `.lpf(${Math.round(newCutoff)})`;
appliedEffects.push(`cutoff ${moodProfile.cutoffMod > 0 ? '+' : ''}${Math.round(moodProfile.cutoffMod * moodIntensity)}Hz`);
}
// Apply reverb modification
if (moodProfile.roomMod !== 0) {
const roomAmount = Math.max(0, Math.min(1, moodProfile.roomMod * moodIntensity));
transformedPattern += `.room(${roomAmount.toFixed(2)})`;
appliedEffects.push(`reverb ${roomAmount.toFixed(2)}`);
}
// Apply delay if specified
if (moodProfile.delayMod && moodProfile.delayMod > 0) {
const delayAmount = moodProfile.delayMod * moodIntensity;
transformedPattern += `.delay(${delayAmount.toFixed(2)})`;
appliedEffects.push(`delay ${delayAmount.toFixed(2)}`);
}
// Apply gain modification
if (moodProfile.gainMod !== 0) {
const gainAdjust = 1 + (moodProfile.gainMod * moodIntensity);
transformedPattern += `.gain(${gainAdjust.toFixed(2)})`;
appliedEffects.push(`gain ${moodProfile.gainMod > 0 ? '+' : ''}${Math.round(moodProfile.gainMod * 100 * moodIntensity)}%`);
}
await this.writePatternSafe(transformedPattern);
const shouldPlayMood = args.auto_play !== false;
if (shouldPlayMood && this.isInitialized) {
await this.controller.play();
}
return {
success: true,
target_mood: mood,
intensity: moodIntensity,
applied_effects: appliedEffects
};
// Session Management
case 'save':
InputValidator.validateStringLength(args.name, 'name', 255, false);
const toSave = await this.getCurrentPatternSafe();
if (!toSave) {
return 'No pattern to save';
}
await this.store.save(args.name, toSave, args.tags || []);
return `Pattern saved as "${args.name}"`;
case 'load':
InputValidator.validateStringLength(args.name, 'name', 255, false);
const saved = await this.store.load(args.name);
if (saved) {
await this.writePatternSafe(saved.content);
return `Loaded pattern "${args.name}"`;
}
return `Pattern "${args.name}" not found`;
case 'list':
if (args?.tag) {
InputValidator.validateStringLength(args.tag, 'tag', 100, false);
}
const patterns = await this.store.list(args?.tag);
return patterns.map(p =>
`• ${p.name} [${p.tags.join(', ')}] - ${p.timestamp}`
).join('\n') || 'No patterns found';
case 'undo':
if (!this.isInitialized) {
return 'Browser not initialized. Run init first.';
}
if (this.undoStack.length > 0) {
const currentUndo = await this.controller.getCurrentPattern();
this.redoStack.push(currentUndo);
// Enforce bounds to prevent memory leaks
if (this.redoStack.length > this.MAX_HISTORY) {
this.redoStack.shift();
}
const previous = this.undoStack.pop()!;
await this.controller.writePattern(previous);
return 'Undone';
}
return 'Nothing to undo';
case 'redo':
if (!this.isInitialized) {
return 'Browser not initialized. Run init first.';
}
if (this.redoStack.length > 0) {
const currentRedo = await this.controller.getCurrentPattern();
this.undoStack.push(currentRedo);
// Enforce bounds to prevent memory leaks
if (this.undoStack.length > this.MAX_HISTORY) {
this.undoStack.shift();
}
const next = this.redoStack.pop()!;
await this.controller.writePattern(next);
return 'Redone';
}
return 'Nothing to redo';
// Pattern History (#41)
case 'list_history':
if (this.historyStack.length === 0) {
return 'No pattern history yet. Make some edits to build history.';
}
const limit = args?.limit || 10;
const recentHistory = this.historyStack.slice(-limit).reverse();
return {
count: this.historyStack.length,
showing: recentHistory.length,
entries: recentHistory.map(entry => ({
id: entry.id,
preview: entry.pattern.substring(0, 60) + (entry.pattern.length > 60 ? '...' : ''),
chars: entry.pattern.length,
action: entry.action,
timestamp: this.formatTimeAgo(entry.timestamp)
}))
};
case 'restore_history':
if (!this.isInitialized) {
return 'Browser not initialized. Run init first.';
}
const entryToRestore = this.historyStack.find(e => e.id === args.id);
if (!entryToRestore) {
return `History entry #${args.id} not found. Use list_history to see available entries.`;
}
// Save current state before restoring
const currentBeforeRestore = await this.controller.getCurrentPattern();
this.undoStack.push(currentBeforeRestore);
if (this.undoStack.length > this.MAX_HISTORY) {
this.undoStack.shift();
}
await this.controller.writePattern(entryToRestore.pattern);
return `Restored pattern from history #${args.id} (${this.formatTimeAgo(entryToRestore.timestamp)})`;
case 'compare_patterns':
const entry1 = this.historyStack.find(e => e.id === args.id1);
if (!entry1) {
return `History entry #${args.id1} not found.`;
}
let pattern2: string;
let label2: string;
if (args.id2) {
const entry2 = this.historyStack.find(e => e.id === args.id2);
if (!entry2) {
return `History entry #${args.id2} not found.`;
}
pattern2 = entry2.pattern;
label2 = `#${args.id2}`;
} else {
pattern2 = await this.getCurrentPatternSafe();
label2 = 'current';
}
const diff = this.generateDiff(entry1.pattern, pattern2);
return {
pattern1: { id: args.id1, chars: entry1.pattern.length },
pattern2: { id: label2, chars: pattern2.length },
diff: diff,
summary: this.summarizeDiff(entry1.pattern, pattern2)
};
// Performance Monitoring
case 'performance_report':
const report = this.perfMonitor.getReport();
const bottlenecks = this.perfMonitor.getBottlenecks(5);
return `${report}\n\nTop 5 Bottlenecks:\n${JSON.stringify(bottlenecks, null, 2)}`;
case 'memory_usage':
const memory = this.perfMonitor.getMemoryUsage();
return memory ? JSON.stringify(memory, null, 2) : 'Memory usage not available';
// UX Tools - Browser Control (#37)
case 'show_browser':
if (!this.isInitialized) {
return 'Browser not initialized. Run init first.';
}
return await this.controller.showBrowser();
case 'screenshot':
if (!this.isInitialized) {
return 'Browser not initialized. Run init first.';
}
return await this.controller.takeScreenshot(args?.filename);
// UX Tools - Status & Diagnostics (#39)
case 'status':
return this.controller.getStatus();
case 'diagnostics':
if (!this.isInitialized) {
return {
initialized: false,
message: 'Browser not initialized. Run init first for full diagnostics.'
};
}
return await this.controller.getDiagnostics();
case 'show_errors':
const errors = this.controller.getConsoleErrors();
const warnings = this.controller.getConsoleWarnings();
if (errors.length === 0 && warnings.length === 0) {
return 'No errors or warnings captured.';
}
let result = '';
if (errors.length > 0) {
result += `❌ Errors (${errors.length}):\n${errors.map(e => ` • ${e}`).join('\n')}\n`;
}
if (warnings.length > 0) {
result += `⚠️ Warnings (${warnings.length}):\n${warnings.map(w => ` • ${w}`).join('\n')}`;
}
return result.trim();
// UX Tools - High-level Compose (#42, #73)
case 'compose':
InputValidator.validateStringLength(args.style, 'style', 100, false);
if (args.key) {
InputValidator.validateRootNote(args.key);
}
if (args.tempo !== undefined) {
InputValidator.validateBPM(args.tempo);
}
// Auto-initialize if needed
if (!this.isInitialized) {
await this.controller.initialize();
this.isInitialized = true;
}
// Generate pattern
const composedPattern = this.generator.generateCompletePattern(
args.style,
args.key || 'C',
args.tempo || this.getDefaultTempo(args.style)
);
// Write pattern
await this.controller.writePattern(composedPattern);
// Auto-play by default (unless explicitly set to false)
const shouldPlay = args.auto_play !== false;
if (shouldPlay) {
await this.controller.play();
}
// Build response
const composeResponse: {
success: boolean;
pattern: string;
metadata: { style: string; bpm: number; key: string };
status: string;
message: string;
feedback?: CreativeFeedback;
} = {
success: true,
pattern: composedPattern.substring(0, 200) + (composedPattern.length > 200 ? '...' : ''),
metadata: {
style: args.style,
bpm: args.tempo || this.getDefaultTempo(args.style),
key: args.key || 'C'
},
status: shouldPlay ? 'playing' : 'ready',
message: `Created ${args.style} pattern in ${args.key || 'C'}${shouldPlay ? ' - now playing' : ''}`
};
// Get AI feedback if requested (#73)
if (args.get_feedback) {
if (this.geminiService.isAvailable()) {
try {
const feedback = await this.geminiService.getCreativeFeedback(composedPattern);
composeResponse.feedback = feedback;
composeResponse.message += ` (AI feedback: ${feedback.complexity} complexity, estimated ${feedback.estimatedStyle})`;
} catch (error: any) {
this.logger.warn('Failed to get AI feedback for compose', error);
// Don't fail the whole operation, just note the feedback failure
composeResponse.message += ' (AI feedback unavailable)';
}
} else {
composeResponse.message += ' (AI feedback requires GEMINI_API_KEY)';
}
}
return composeResponse;
// AI Feedback Tools (#67)
case 'get_pattern_feedback':
return await this.getPatternFeedback(args?.includeAudio || false, args?.style);
// Audio Capture Tools (#72)
case 'start_audio_capture':
return await this.startAudioCapture(args?.format, args?.maxDuration);
case 'stop_audio_capture':
return await this.stopAudioCapture();
case 'capture_audio_sample':
return await this.captureAudioSampleTool(args?.duration);
// MIDI Export Tools (#74)
case 'export_midi':
return await this.exportMidi(args?.filename, args?.duration, args?.bpm, args?.format);
// Multi-Session Management Tools (#75)
case 'create_session':
InputValidator.validateStringLength(args.session_id, 'session_id', 100, false);
try {
await this.sessionManager.createSession(args.session_id);
return {
success: true,
session_id: args.session_id,
message: `Session '${args.session_id}' created successfully`,
total_sessions: this.sessionManager.getSessionCount(),
max_sessions: this.sessionManager.getMaxSessions()
};
} catch (error: any) {
return {
success: false,
error: error.message
};
}
case 'destroy_session':
InputValidator.validateStringLength(args.session_id, 'session_id', 100, false);
try {
await this.sessionManager.destroySession(args.session_id);
return {
success: true,
session_id: args.session_id,
message: `Session '${args.session_id}' destroyed`,
remaining_sessions: this.sessionManager.getSessionCount()
};
} catch (error: any) {
return {
success: false,
error: error.message
};
}
case 'list_sessions':
const sessionsInfo = this.sessionManager.getSessionsInfo();
return {
count: sessionsInfo.length,
max_sessions: this.sessionManager.getMaxSessions(),
default_session: this.sessionManager.getDefaultSessionId(),
sessions: sessionsInfo.map(s => ({
id: s.id,
created: s.created.toISOString(),
last_activity: s.lastActivity.toISOString(),
is_playing: s.isPlaying,
is_default: s.id === this.sessionManager.getDefaultSessionId()
}))
};
case 'switch_session':
InputValidator.validateStringLength(args.session_id, 'session_id', 100, false);
try {
this.sessionManager.setDefaultSession(args.session_id);
return {
success: true,
default_session: args.session_id,
message: `Default session switched to '${args.session_id}'`
};
} catch (error: any) {
return {
success: false,
error: error.message
};
}
// AI Collaborative Jamming (#82)
case 'jam_with':
return await this.jamWith(args.layer, args.style_hint, args.auto_play);
// Pattern Refinement Tools (#78, #81)
case 'refine':
return await this.refinePattern(args.direction);
case 'set_energy':
return await this.setEnergyLevel(args.level);
default:
throw new Error(`Unknown tool: ${name}`);
}
}
/**
* Incrementally refines the current pattern with simple directional commands.
* Supports: faster/slower, louder/quieter, brighter/darker, more reverb/drier.
* @param direction - Refinement direction
* @returns Result object with success status and applied refinement
*/
private async refinePattern(direction: string): Promise<{
success: boolean;
direction?: string;
applied?: string;
error?: string;
}> {
const currentPattern = await this.getCurrentPatternSafe();
if (!currentPattern || currentPattern.trim().length === 0) {
return {
success: false,
error: 'No pattern to refine. Write a pattern first.'
};
}
let modification = '';
const dir = direction.toLowerCase().trim();
switch (dir) {
case 'faster':
modification = '.fast(1.1)';
break;
case 'slower':
modification = '.slow(1.1)';
break;
case 'louder':
modification = '.gain(1.1)';
break;
case 'quieter':
modification = '.gain(0.9)';
break;
case 'brighter':
modification = '.lpf(2000)';
break;
case 'darker':
modification = '.lpf(800)';
break;
case 'more reverb':
modification = '.room(0.5)';
break;
case 'drier':
modification = '.room(0.1)';
break;
default:
return {
success: false,
error: `Unknown refinement direction: ${direction}. Supported: faster, slower, louder, quieter, brighter, darker, "more reverb", drier.`
};
}
const newPattern = currentPattern + modification;
await this.writePatternSafe(newPattern);
if (this.isInitialized) {
await this.controller.play();
}
return {
success: true,
direction: dir,
applied: modification
};
}
/**
* Adjusts the overall energy level of the current pattern.
* @param level - Energy level from 0 (minimal) to 10 (maximum)
* @returns Result object with success status
*/
private async setEnergyLevel(level: number): Promise<{
success: boolean;
level?: number;
description?: string;
error?: string;
}> {
if (level < 0 || level > 10 || !Number.isInteger(level)) {
return {
success: false,
error: 'Energy level must be an integer from 0 to 10.'
};
}
const currentPattern = await this.getCurrentPatternSafe();
if (!currentPattern || currentPattern.trim().length === 0) {
return {
success: false,
error: 'No pattern to adjust. Write a pattern first.'
};
}
const config = ENERGY_LEVELS[level];
let newPattern = currentPattern;
// Apply density adjustment if specified
if (config.densityAdjust) {
newPattern += config.densityAdjust;
}
// Apply room/reverb
newPattern += `.room(${config.roomAmount})`;
await this.writePatternSafe(newPattern);
if (this.isInitialized) {
await this.controller.play();
}
return {
success: true,
level,
description: config.description
};
}
private transposePattern(pattern: string, semitones: number): string {
// Simple transpose implementation - would need more sophisticated parsing
return pattern.replace(/([a-g][#b]?)(\d)/gi, (match, note, octave) => {
const noteMap: Record<string, number> = {
'c': 0, 'c#': 1, 'd': 2, 'd#': 3, 'e': 4, 'f': 5,
'f#': 6, 'g': 7, 'g#': 8, 'a': 9, 'a#': 10, 'b': 11
};
const currentNote = note.toLowerCase();
const noteValue = noteMap[currentNote] || 0;
const newNoteValue = (noteValue + semitones + 12) % 12;
const noteNames = ['c', 'c#', 'd', 'd#', 'e', 'f', 'f#', 'g', 'g#', 'a', 'a#', 'b'];
const newOctave = parseInt(octave) + Math.floor((noteValue + semitones) / 12);
return noteNames[newNoteValue] + newOctave;
});
}
/**
* Gets the default tempo for a given music style
* @param style - Music style/genre
* @returns Default BPM for the style
*/
private getDefaultTempo(style: string): number {
const tempoMap: Record<string, number> = {
'techno': 130,
'house': 125,
'dnb': 174,
'drum and bass': 174,
'ambient': 80,
'trap': 140,
'jungle': 160,
'jazz': 110,
'experimental': 120,
'dubstep': 140,
'trance': 138,
'breakbeat': 130,
'garage': 130,
'electro': 128,
'downtempo': 90,
'idm': 115
};
return tempoMap[style.toLowerCase()] || 120;
}
/**
* Formats a timestamp as human-readable "time ago" string
* @param date - Date to format
* @returns Human-readable string like "2m ago" or "1h ago"
*/
private formatTimeAgo(date: Date): string {
const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000);
if (seconds < 60) return `${seconds}s ago`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return `${Math.floor(seconds / 86400)}d ago`;
}
/**
* Generates a simple line-by-line diff between two patterns
* @param pattern1 - First pattern
* @param pattern2 - Second pattern
* @returns Diff output showing additions and removals
*/
private generateDiff(pattern1: string, pattern2: string): string[] {
const lines1 = pattern1.split('\n');
const lines2 = pattern2.split('\n');
const diff: string[] = [];
const maxLines = Math.max(lines1.length, lines2.length);
for (let i = 0; i < maxLines; i++) {
const line1 = lines1[i] || '';
const line2 = lines2[i] || '';
if (line1 === line2) {
diff.push(` ${line1}`);
} else {
if (line1) diff.push(`- ${line1}`);
if (line2) diff.push(`+ ${line2}`);
}
}
return diff;
}
/**
* Summarizes differences between two patterns
* @param pattern1 - First pattern
* @param pattern2 - Second pattern
* @returns Summary of differences
*/
private summarizeDiff(pattern1: string, pattern2: string): {
linesAdded: number;
linesRemoved: number;
linesChanged: number;
charsDiff: number;
} {
const lines1 = pattern1.split('\n');
const lines2 = pattern2.split('\n');
let linesAdded = 0;
let linesRemoved = 0;
let linesChanged = 0;
const maxLines = Math.max(lines1.length, lines2.length);
for (let i = 0; i < maxLines; i++) {
const line1 = lines1[i];
const line2 = lines2[i];
if (line1 === undefined && line2 !== undefined) {
linesAdded++;
} else if (line1 !== undefined && line2 === undefined) {
linesRemoved++;
} else if (line1 !== line2) {
linesChanged++;
}
}
return {
linesAdded,
linesRemoved,
linesChanged,
charsDiff: pattern2.length - pattern1.length
};
}
/**
* Gets AI-powered creative feedback on the current pattern
* Uses Google Gemini for pattern analysis and optionally audio analysis
* @param includeAudio - Whether to include audio analysis (plays pattern briefly)
* @param style - Optional style hint for context
* @returns Feedback object with pattern_analysis and optionally audio_analysis
*/
private async getPatternFeedback(
includeAudio: boolean,
style?: string
): Promise<{
pattern_analysis?: CreativeFeedback;
audio_analysis?: AudioFeedback;
error?: string;
gemini_available: boolean;
}> {
// Check if Gemini is available
if (!this.geminiService.isAvailable()) {
return {
gemini_available: false,
error: 'Gemini API not configured. Set GEMINI_API_KEY environment variable to enable AI feedback.'
};
}
// Get current pattern
const pattern = await this.getCurrentPatternSafe();
if (!pattern || pattern.trim().length === 0) {
return {
gemini_available: true,
error: 'No pattern to analyze. Write a pattern first.'
};
}
const result: {
pattern_analysis?: CreativeFeedback;
audio_analysis?: AudioFeedback;
error?: string;
gemini_available: boolean;
} = {
gemini_available: true
};
// Get pattern analysis
try {
result.pattern_analysis = await this.geminiService.getCreativeFeedback(pattern);
} catch (error: any) {
this.logger.error('Pattern feedback failed', error);
// Handle rate limit errors gracefully - pass through the detailed error message
if (error.message?.includes('rate limit') || error.message?.includes('Rate limit')) {
return {
gemini_available: true,
error: error.message // Pass through the detailed rate limit message with wait time
};
}
result.error = `Pattern analysis failed: ${error.message}`;
}
// Get audio analysis if requested
if (includeAudio && this.isInitialized) {
try {
// Capture audio by playing the pattern briefly
const audioBlob = await this.captureAudioSample();
if (audioBlob) {
result.audio_analysis = await this.geminiService.analyzeAudio(audioBlob, {
style: style,
duration: 5
});
} else {
this.logger.warn('Audio capture returned no data');
}
} catch (error: any) {
this.logger.error('Audio analysis failed', error);
// Don't fail the whole request if audio analysis fails
if (!result.error) {
result.error = `Audio analysis failed: ${error.message}`;
}
}
} else if (includeAudio && !this.isInitialized) {
if (!result.error) {
result.error = 'Audio analysis requires browser initialization. Run init first or set includeAudio to false.';
}
}
return result;
}
/**
* Captures a brief audio sample from the playing pattern
* Plays the pattern for ~5 seconds and captures audio using MediaRecorder
* @returns Audio blob or null if capture failed
*/
private async captureAudioSample(): Promise<Blob | null> {
if (!this._page) {
this.logger.warn('Cannot capture audio: page not available');
return null;
}
const page = this.controller.page;
if (!page) {
this.logger.warn('Cannot capture audio: controller page not available');
return null;
}
try {
// Inject audio capture code and start recording
const audioData = await page.evaluate(async () => {
return new Promise<string | null>((resolve) => {
const analyzer = (window as any).strudelAudioAnalyzer;
if (!analyzer || !analyzer.analyser) {
resolve(null);
return;
}
try {
const audioCtx = analyzer.analyser.context as AudioContext;
const destination = audioCtx.createMediaStreamDestination();
// Connect analyzer to destination for recording
analyzer.analyser.connect(destination);
const mediaRecorder = new MediaRecorder(destination.stream, {
mimeType: 'audio/webm;codecs=opus'
});
const chunks: Blob[] = [];
mediaRecorder.ondataavailable = (e) => {
if (e.data.size > 0) {
chunks.push(e.data);
}
};
mediaRecorder.onstop = async () => {
// Disconnect to avoid audio routing issues
try {
analyzer.analyser.disconnect(destination);
} catch (e) {
// Ignore disconnect errors
}
if (chunks.length === 0) {
resolve(null);
return;
}
const blob = new Blob(chunks, { type: 'audio/webm' });
// Convert to base64 for transport
const reader = new FileReader();
reader.onloadend = () => {
const base64 = reader.result as string;
resolve(base64.split(',')[1] || null);
};
reader.onerror = () => resolve(null);
reader.readAsDataURL(blob);
};
// Start recording
mediaRecorder.start();
// Record for 5 seconds
setTimeout(() => {
if (mediaRecorder.state === 'recording') {
mediaRecorder.stop();
}
}, 5000);
} catch (e) {
resolve(null);
}
});
});
if (!audioData) {
return null;
}
// Convert base64 back to Blob
const binaryString = atob(audioData);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return new Blob([bytes], { type: 'audio/webm' });
} catch (error: any) {
this.logger.error('Audio capture failed', error);
return null;
}
}
/**
* Getter for page access in audio capture
*/
private get _page() {
return this.controller.page;
}
/**
* Lazily initializes and returns the AudioCaptureService
* Injects recorder into page on first use
*/
private async getAudioCaptureService(): Promise<AudioCaptureService> {
if (!this.isInitialized || !this._page) {
throw new Error('Browser not initialized. Run init first.');
}
if (!this.audioCaptureService) {
this.audioCaptureService = new AudioCaptureService();
await this.audioCaptureService.injectRecorder(this._page);
}
return this.audioCaptureService;
}
/**
* Starts audio capture from Strudel output
* @param format - Audio format ('webm' or 'opus')
* @param maxDuration - Maximum capture duration in ms (optional)
* @returns Status message
*/
private async startAudioCapture(
format?: 'webm' | 'opus',
maxDuration?: number
): Promise<{ success: boolean; message: string; format?: string }> {
try {
const captureService = await this.getAudioCaptureService();
if (captureService.isCapturing()) {
return {
success: false,
message: 'Audio capture already in progress. Stop it first.'
};
}
await captureService.startCapture(this._page!, { format });
return {
success: true,
message: 'Audio capture started. Use stop_audio_capture to get the recorded audio.',
format: format || 'webm'
};
} catch (error: any) {
return {
success: false,
message: `Failed to start audio capture: ${error.message}`
};
}
}
/**
* Stops audio capture and returns base64-encoded audio data
* @returns Captured audio as base64 with metadata
*/
private async stopAudioCapture(): Promise<{
success: boolean;
message: string;
audio?: string;
duration?: number;
format?: string;
}> {
try {
const captureService = await this.getAudioCaptureService();
if (!captureService.isCapturing()) {
return {
success: false,
message: 'No audio capture in progress. Start capture first.'
};
}
const result = await captureService.stopCapture(this._page!);
// Convert Blob to base64
const arrayBuffer = await result.blob.arrayBuffer();
const bytes = new Uint8Array(arrayBuffer);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
const base64 = btoa(binary);
return {
success: true,
message: `Captured ${result.duration}ms of audio`,
audio: base64,
duration: result.duration,
format: result.format
};
} catch (error: any) {
return {
success: false,
message: `Failed to stop audio capture: ${error.message}`
};
}
}
/**
* Captures a fixed-duration audio sample (MCP tool handler)
* @param duration - Duration in milliseconds (default: 5000)
* @returns Captured audio as base64 with metadata
*/
private async captureAudioSampleTool(duration?: number): Promise<{
success: boolean;
message: string;
audio?: string;
duration?: number;
format?: string;
}> {
const durationMs = duration || 5000;
// Validate duration
if (durationMs < 100 || durationMs > 60000) {
return {
success: false,
message: 'Duration must be between 100ms and 60000ms (1 minute)'
};
}
try {
const captureService = await this.getAudioCaptureService();
if (captureService.isCapturing()) {
return {
success: false,
message: 'Audio capture already in progress. Stop it first.'
};
}
const result = await captureService.captureForDuration(this._page!, durationMs);
// Convert Blob to base64
const arrayBuffer = await result.blob.arrayBuffer();
const bytes = new Uint8Array(arrayBuffer);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
const base64 = btoa(binary);
return {
success: true,
message: `Captured ${result.duration}ms audio sample`,
audio: base64,
duration: result.duration,
format: result.format
};
} catch (error: any) {
return {
success: false,
message: `Failed to capture audio sample: ${error.message}`
};
}
}
/**
* Exports current pattern to MIDI format
* @param filename - Output filename (optional)
* @param bars - Duration in bars (default: 4)
* @param bpm - Tempo in BPM (default: 120)
* @param format - Output format: 'file' or 'base64' (default: 'base64')
* @returns Export result with MIDI data or file path
*/
private async exportMidi(
filename?: string,
bars?: number,
bpm?: number,
format?: 'file' | 'base64'
): Promise<{
success: boolean;
message: string;
output?: string;
noteCount?: number;
bars?: number;
bpm?: number;
error?: string;
}> {
// Validate inputs
if (bpm !== undefined) {
InputValidator.validateBPM(bpm);
}
if (bars !== undefined && (bars < 1 || bars > 128)) {
return {
success: false,
message: 'Bars must be between 1 and 128'
};
}
// Get current pattern
const pattern = await this.getCurrentPatternSafe();
if (!pattern || pattern.trim().length === 0) {
return {
success: false,
message: 'No pattern to export. Write a pattern first.'
};
}
const exportOptions = {
bpm: bpm || 120,
bars: bars || 4
};
// Export based on format
const outputFormat = format || 'base64';
if (outputFormat === 'file') {
const result = this.midiExportService.exportToFile(pattern, filename, exportOptions);
return {
success: result.success,
message: result.success
? `Exported ${result.noteCount} notes to ${result.output}`
: result.error || 'Export failed',
output: result.output,
noteCount: result.noteCount,
bars: result.bars,
bpm: result.bpm,
error: result.error
};
} else {
const result = this.midiExportService.exportToBase64(pattern, exportOptions);
return {
success: result.success,
message: result.success
? `Exported ${result.noteCount} notes as base64 MIDI data`
: result.error || 'Export failed',
output: result.output,
noteCount: result.noteCount,
bars: result.bars,
bpm: result.bpm,
error: result.error
};
}
}
/**
* AI Collaborative Jamming - generates a complementary layer to jam with the current pattern (#82)
* @param layer - Type of layer to add (drums, bass, melody, pad, texture)
* @param styleHint - Optional style guidance
* @param autoPlay - Whether to auto-play after adding layer (default: true)
* @returns Result with merged pattern and analysis info
*/
private async jamWith(
layer: 'drums' | 'bass' | 'melody' | 'pad' | 'texture',
styleHint?: string,
autoPlay: boolean = true
): Promise<{
success: boolean;
message: string;
layer: string;
detected: { tempo: number; key: string; existingLayers: string[] };
newLayer: string;
pattern?: string;
error?: string;
}> {
const validLayers = ['drums', 'bass', 'melody', 'pad', 'texture'];
if (!validLayers.includes(layer)) {
return {
success: false,
message: `Invalid layer type: ${layer}. Must be one of: ${validLayers.join(', ')}`,
layer,
detected: { tempo: 120, key: 'C', existingLayers: [] },
newLayer: ''
};
}
const currentPattern = await this.getCurrentPatternSafe();
if (!currentPattern || currentPattern.trim().length === 0) {
return {
success: false,
message: 'No pattern to jam with. Write a pattern first.',
layer,
detected: { tempo: 120, key: 'C', existingLayers: [] },
newLayer: ''
};
}
const tempo = this.detectTempoFromPattern(currentPattern);
const key = this.detectKeyFromPattern(currentPattern);
const existingLayers = this.detectExistingLayers(currentPattern);
const detectedStyle = this.detectStyleFromPattern(currentPattern, styleHint);
if (existingLayers.includes(layer) && layer !== 'texture') {
this.logger.warn(`Pattern already contains ${layer} layer, adding anyway`);
}
let newLayer: string;
try {
newLayer = this.generateComplementaryLayer(layer, key, tempo, detectedStyle, existingLayers);
} catch (error: any) {
return {
success: false,
message: `Failed to generate ${layer} layer: ${error.message}`,
layer,
detected: { tempo, key, existingLayers },
newLayer: '',
error: error.message
};
}
const mergedPattern = this.mergeLayerIntoPattern(currentPattern, newLayer, layer);
try {
await this.writePatternSafe(mergedPattern);
if (autoPlay && this.isInitialized) {
await this.controller.play();
}
return {
success: true,
message: `Added ${layer} layer${styleHint ? ` (${styleHint} style)` : ''} to jam with your pattern`,
layer,
detected: { tempo, key, existingLayers },
newLayer,
pattern: mergedPattern.substring(0, 300) + (mergedPattern.length > 300 ? '...' : '')
};
} catch (error: any) {
return {
success: false,
message: `Failed to write merged pattern: ${error.message}`,
layer,
detected: { tempo, key, existingLayers },
newLayer,
error: error.message
};
}
}
private detectTempoFromPattern(pattern: string): number {
const cpmMatch = pattern.match(/setcpm\s*\(\s*(\d+(?:\.\d+)?)\s*\)/i);
if (cpmMatch) return Math.round(parseFloat(cpmMatch[1]));
const bpmMatch = pattern.match(/setbpm\s*\(\s*(\d+(?:\.\d+)?)\s*\)/i);
if (bpmMatch) return Math.round(parseFloat(bpmMatch[1]));
const cpsMatch = pattern.match(/setcps\s*\(\s*(\d+(?:\.\d+)?)\s*(?:\/\s*60)?\s*\)/i);
if (cpsMatch) {
const cps = parseFloat(cpsMatch[1]);
if (pattern.includes(`setcps(${cpsMatch[1]}/60`)) return Math.round(cps);
return Math.round(cps * 60);
}
if (pattern.toLowerCase().includes('dnb')) return 174;
if (pattern.toLowerCase().includes('techno')) return 130;
if (pattern.toLowerCase().includes('house')) return 125;
return 120;
}
private detectKeyFromPattern(pattern: string): string {
const noteMatches = pattern.match(/note\s*\(\s*["']([^"']+)["']\s*\)/gi) || [];
const nMatches = pattern.match(/\.n\s*\(\s*["']([^"']+)["']\s*\)/gi) || [];
const allNotes: string[] = [];
for (const match of noteMatches) {
const notesInMatch = match.match(/[a-g][#b]?\d?/gi) || [];
allNotes.push(...notesInMatch.map(n => n.toLowerCase().replace(/\d/g, '')));
}
for (const match of nMatches) {
const notesInMatch = match.match(/[a-g][#b]?\d?/gi) || [];
allNotes.push(...notesInMatch.map(n => n.toLowerCase().replace(/\d/g, '')));
}
const chordMatches = pattern.match(/chord\s*\(\s*["']<([^>]+)>/gi) || [];
for (const match of chordMatches) {
const rootMatch = match.match(/[a-g][#b]?/i);
if (rootMatch) allNotes.push(rootMatch[0].toLowerCase());
}
if (allNotes.length === 0) return 'C';
const noteCounts: Record<string, number> = {};
for (const note of allNotes) {
const normalizedNote = note.charAt(0).toUpperCase() + note.slice(1);
noteCounts[normalizedNote] = (noteCounts[normalizedNote] || 0) + 1;
}
let mostCommonNote = 'C';
let maxCount = 0;
for (const [note, count] of Object.entries(noteCounts)) {
if (count > maxCount) { maxCount = count; mostCommonNote = note; }
}
return mostCommonNote;
}
private detectExistingLayers(pattern: string): string[] {
const layers: string[] = [];
const lowerPattern = pattern.toLowerCase();
if (lowerPattern.includes('bd') || lowerPattern.includes('cp') ||
lowerPattern.includes('hh') || lowerPattern.includes('sd') ||
lowerPattern.includes('sn') || lowerPattern.includes('oh') ||
lowerPattern.includes('breaks') || lowerPattern.includes('drum')) {
layers.push('drums');
}
if (pattern.match(/note\s*\([^)]*[12]\s*["']/i) || lowerPattern.includes('bass')) {
layers.push('bass');
}
if (pattern.match(/note\s*\([^)]*[34567]\s*["']/i) ||
lowerPattern.includes('melody') || lowerPattern.includes('lead')) {
layers.push('melody');
}
if (lowerPattern.includes('chord(') || lowerPattern.includes('pad') ||
lowerPattern.includes('strings') || lowerPattern.includes('.voicing')) {
layers.push('pad');
}
if (lowerPattern.includes('noise') || lowerPattern.includes('fx') ||
lowerPattern.includes('perlin') || lowerPattern.includes('rand')) {
layers.push('texture');
}
return layers;
}
private detectStyleFromPattern(pattern: string, styleHint?: string): string {
if (styleHint) return styleHint.toLowerCase();
const lowerPattern = pattern.toLowerCase();
const tempo = this.detectTempoFromPattern(pattern);
if (tempo >= 160 && lowerPattern.includes('breaks')) return 'jungle';
if (tempo >= 165 && tempo <= 180) return 'dnb';
if (tempo >= 125 && tempo <= 135 && lowerPattern.includes('bd*4')) {
return lowerPattern.includes('cp') ? 'techno' : 'house';
}
if (tempo <= 100 && lowerPattern.includes('room')) return 'ambient';
if (lowerPattern.includes('trap')) return 'trap';
return 'techno';
}
private generateComplementaryLayer(
layer: string, key: string, tempo: number, style: string, existingLayers: string[]
): string {
switch (layer) {
case 'drums':
if (existingLayers.includes('drums')) {
const percOptions: Record<string, string> = {
'techno': 's("~ hh ~ hh, ~ ~ oh ~").gain(0.4).hpf(5000)',
'house': 's("[~ hh]*4, ~ ~ oh ~").gain(0.35).room(0.2)',
'dnb': 's("hh*16").gain(perlin.range(0.2, 0.4)).hpf(6000)',
'ambient': 's("~ ~ ~ hh:8").room(0.8).gain(0.2).slow(2)',
'trap': 's("hh*16").gain(perlin.range(0.15, 0.35)).hpf(5000)',
'jungle': 's("hh*32").gain(perlin.range(0.2, 0.4)).hpf(4000)',
'jazz': 's("~ ride ~ ride, ~ ~ ~ hh").gain(0.3).room(0.3)'
};
return percOptions[style] || percOptions['techno'];
}
return this.generator.generateDrumPattern(style, 0.6);
case 'bass':
return this.generator.generateBassline(key, style);
case 'melody': {
let scaleName: 'minor' | 'major' | 'dorian' | 'pentatonic' = 'minor';
let octaveRange: [number, number] = [4, 5];
if (style === 'jazz') { scaleName = 'dorian'; octaveRange = [3, 5]; }
if (style === 'ambient') { scaleName = 'major'; octaveRange = [4, 6]; }
if (existingLayers.includes('bass')) octaveRange = [4, 6];
const scale = this.theory.generateScale(key, scaleName);
const effects: Record<string, string> = {
'techno': '.delay(0.25).room(0.2)', 'house': '.room(0.3).gain(0.6)',
'dnb': '.delay(0.125).room(0.2).gain(0.5)', 'ambient': '.room(0.7).delay(0.5).gain(0.4)',
'trap': '.gain(0.5).room(0.15)', 'jungle': '.delay(0.125).room(0.25).gain(0.55)',
'jazz': '.room(0.4).gain(0.5)'
};
return this.generator.generateMelody(scale, 8, octaveRange) + (effects[style] || '.room(0.3).gain(0.5)');
}
case 'pad': {
const safeKey = key.toLowerCase();
const fourth = this.theory.getNote(key, 5).toLowerCase();
const fifth = this.theory.getNote(key, 7).toLowerCase();
const padPatterns: Record<string, string> = {
'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)`,
'house': `chord("<${safeKey}m9 ${fourth}7 ${fifth}m7>/2").dict('ireal').voicing().s("gm_epiano1").gain(0.3).room(0.4)`,
'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)`,
'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)`,
'trap': `chord("<${safeKey}m7>/4").dict('ireal').voicing().s("sawtooth").attack(0.1).release(0.5).lpf(1500).gain(0.25).room(0.3)`,
'jungle': `chord("<${safeKey}m9 ${fourth}m9>/8").dict('ireal').voicing().s("gm_epiano1").gain(0.25).room(0.4).delay(0.25)`,
'jazz': `chord("<${safeKey}m9 ${fourth}m9 ${fifth}7>/4").dict('ireal').voicing().s("gm_epiano1").gain(0.3).room(0.5)`
};
return padPatterns[style] || padPatterns['techno'];
}
case 'texture': {
const texturePatterns: Record<string, string> = {
'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))`,
'house': `s("~ noise:2 ~ noise:2").gain(0.04).hpf(6000).room(0.4)`,
'dnb': `s("~ ~ ~ noise:4").gain(perlin.range(0.02, 0.05)).hpf(7000).room(0.5).pan(perlin.range(0.3, 0.7))`,
'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)`,
'trap': `s("~ ~ noise:3 ~").gain(0.03).hpf(10000).room(0.3)`,
'jungle': `s("breaks125:8").fit().chop(32).gain(0.05).hpf(5000).room(0.4).pan(perlin.range(0.2, 0.8))`,
'jazz': `s("brush:1").struct("~ 1 ~ 1 ~ 1 ~ ~").gain(0.1).room(0.5)`
};
return texturePatterns[style] || texturePatterns['techno'];
}
default:
throw new Error(`Unknown layer type: ${layer}`);
}
}
private mergeLayerIntoPattern(currentPattern: string, newLayer: string, layerType: string): string {
const trimmedPattern = currentPattern.trim();
const trimmedLayer = newLayer.trim();
const stackMatch = trimmedPattern.match(/^([\s\S]*?)stack\s*\(\s*([\s\S]*?)\s*\)([\s\S]*)$/);
if (stackMatch) {
const prefix = stackMatch[1];
const stackContents = stackMatch[2].trimEnd().replace(/,\s*$/, '');
const suffix = stackMatch[3];
return `${prefix}stack(
${stackContents},
// Jam ${layerType} layer
${trimmedLayer}
)${suffix}`;
}
const tempoMatch = trimmedPattern.match(/^(\s*(?:setcp[ms]|setbpm)\s*\([^)]+\)\s*\n?)/);
const tempoPrefix = tempoMatch ? tempoMatch[1] : '';
const patternBody = tempoMatch ? trimmedPattern.slice(tempoMatch[0].length) : trimmedPattern;
return `${tempoPrefix}stack(
// Original pattern
${patternBody},
// Jam ${layerType} layer
${trimmedLayer}
)`;
}
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
this.logger.info('Enhanced Strudel MCP server v2.0.1 running (fixed)');
process.on('SIGINT', async () => {
this.logger.info('Shutting down...');
await this.controller.cleanup();
await this.sessionManager.destroyAll();
process.exit(0);
});
}
}