@williamzujkowski/live-coding-music-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1735 -0
- package/dist/AudioAnalyzer.js +678 -0
- package/dist/PatternStore.js +169 -0
- package/dist/StrudelController.js +676 -0
- package/dist/__tests__/MusicTheory.test.js +245 -0
- package/dist/__tests__/PatternGenerator.test.js +337 -0
- package/dist/__tests__/PatternStore.test.js +153 -0
- package/dist/__tests__/utils/MockPlaywright.js +130 -0
- package/dist/index.js +21 -0
- package/dist/server/EnhancedMCPServer.js +654 -0
- package/dist/server/EnhancedMCPServerFixed.js +2506 -0
- package/dist/server/server.js +580 -0
- package/dist/server/tools/ai.js +530 -0
- package/dist/server/tools/analysis.js +132 -0
- package/dist/server/tools/capture.js +184 -0
- package/dist/server/tools/diagnostics.js +96 -0
- package/dist/server/tools/editor.js +128 -0
- package/dist/server/tools/generate.js +218 -0
- package/dist/server/tools/history.js +196 -0
- package/dist/server/tools/playback.js +41 -0
- package/dist/server/tools/session.js +115 -0
- package/dist/server/tools/storage.js +77 -0
- package/dist/server/tools/transform.js +399 -0
- package/dist/server/tools/types.js +9 -0
- package/dist/services/AudioCaptureService.js +279 -0
- package/dist/services/GeminiService.js +790 -0
- package/dist/services/MIDIExportService.js +412 -0
- package/dist/services/MusicTheory.js +186 -0
- package/dist/services/PatternGenerator.js +647 -0
- package/dist/services/SessionManager.js +316 -0
- package/dist/services/StrudelEngine.js +374 -0
- package/dist/services/__mocks__/StrudelEngine.js +157 -0
- package/dist/types/AudioAnalysis.js +4 -0
- package/dist/types/index.js +1 -0
- package/dist/utils/ErrorRecovery.js +248 -0
- package/dist/utils/InputValidator.js +281 -0
- package/dist/utils/Logger.js +19 -0
- package/dist/utils/PatternValidator.js +241 -0
- package/dist/utils/PerformanceMonitor.js +128 -0
- package/package.json +86 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mock StrudelEngine for Jest testing
|
|
3
|
+
* Provides stub implementations for tests that don't need actual Strudel execution
|
|
4
|
+
*/
|
|
5
|
+
export class StrudelEngine {
|
|
6
|
+
transpile(code) {
|
|
7
|
+
if (!code || code.trim().length === 0) {
|
|
8
|
+
return {
|
|
9
|
+
success: false,
|
|
10
|
+
error: 'Empty pattern',
|
|
11
|
+
errorLocation: { line: 1, column: 1, offset: 0 },
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
// Simple validation - check for basic syntax issues
|
|
15
|
+
if (code.includes('(') && !code.includes(')')) {
|
|
16
|
+
return {
|
|
17
|
+
success: false,
|
|
18
|
+
error: 'Unexpected end of input',
|
|
19
|
+
errorLocation: { line: 1, column: code.length, offset: code.length },
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
success: true,
|
|
24
|
+
transpiledCode: `return /* mock transpiled */ ${code};`,
|
|
25
|
+
locations: [],
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
validate(code) {
|
|
29
|
+
const errors = [];
|
|
30
|
+
const warnings = [];
|
|
31
|
+
const suggestions = [];
|
|
32
|
+
if (!code || code.trim().length === 0) {
|
|
33
|
+
return {
|
|
34
|
+
valid: false,
|
|
35
|
+
errors: ['Pattern is empty'],
|
|
36
|
+
warnings: [],
|
|
37
|
+
suggestions: ['Add a pattern like: s("bd hh sd hh")'],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
const transpileResult = this.transpile(code);
|
|
41
|
+
if (!transpileResult.success) {
|
|
42
|
+
return {
|
|
43
|
+
valid: false,
|
|
44
|
+
errors: [transpileResult.error || 'Syntax error'],
|
|
45
|
+
warnings,
|
|
46
|
+
suggestions: ['Check pattern syntax'],
|
|
47
|
+
errorLocation: transpileResult.errorLocation
|
|
48
|
+
? { line: transpileResult.errorLocation.line, column: transpileResult.errorLocation.column }
|
|
49
|
+
: undefined,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
// Check for common issues
|
|
53
|
+
const gainMatches = code.match(/\.gain\s*\(\s*(\d+(?:\.\d+)?)\s*\)/g);
|
|
54
|
+
if (gainMatches) {
|
|
55
|
+
for (const match of gainMatches) {
|
|
56
|
+
const value = parseFloat(match.match(/(\d+(?:\.\d+)?)/)?.[1] || '0');
|
|
57
|
+
if (value > 2) {
|
|
58
|
+
warnings.push(`High gain value (${value}) may cause distortion`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (!/setcpm|setbpm|cpm|bpm/.test(code)) {
|
|
63
|
+
suggestions.push('Consider setting tempo with setcpm(120)');
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
valid: errors.length === 0,
|
|
67
|
+
errors,
|
|
68
|
+
warnings,
|
|
69
|
+
suggestions,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
queryEvents(code, start, end) {
|
|
73
|
+
const transpileResult = this.transpile(code);
|
|
74
|
+
if (!transpileResult.success) {
|
|
75
|
+
throw new Error(`Transpilation failed: ${transpileResult.error}`);
|
|
76
|
+
}
|
|
77
|
+
// Return mock events based on pattern content
|
|
78
|
+
const events = [];
|
|
79
|
+
const cycles = end - start;
|
|
80
|
+
// Simple mock - return 4 events per cycle for patterns containing s()
|
|
81
|
+
if (code.includes('s(')) {
|
|
82
|
+
const matches = code.match(/s\("([^"]+)"\)/);
|
|
83
|
+
if (matches) {
|
|
84
|
+
const sounds = matches[1].split(' ');
|
|
85
|
+
const eventsPerCycle = sounds.length;
|
|
86
|
+
const duration = 1 / eventsPerCycle;
|
|
87
|
+
for (let cycle = 0; cycle < cycles; cycle++) {
|
|
88
|
+
for (let i = 0; i < sounds.length; i++) {
|
|
89
|
+
events.push({
|
|
90
|
+
value: { s: sounds[i] },
|
|
91
|
+
start: start + cycle + i * duration,
|
|
92
|
+
end: start + cycle + (i + 1) * duration,
|
|
93
|
+
isWhole: true,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return events;
|
|
100
|
+
}
|
|
101
|
+
compile(code) {
|
|
102
|
+
const transpileResult = this.transpile(code);
|
|
103
|
+
if (!transpileResult.success) {
|
|
104
|
+
throw new Error(`Transpilation failed: ${transpileResult.error}`);
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
queryArc: (start, end) => this.queryEvents(code, start, end),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
analyzePattern(code) {
|
|
111
|
+
const metadata = {
|
|
112
|
+
eventsPerCycle: 0,
|
|
113
|
+
uniqueValues: [],
|
|
114
|
+
usesSound: false,
|
|
115
|
+
usesNote: false,
|
|
116
|
+
isStack: false,
|
|
117
|
+
functionsUsed: [],
|
|
118
|
+
complexity: 0,
|
|
119
|
+
};
|
|
120
|
+
// Extract BPM from setcpm
|
|
121
|
+
const bpmMatch = code.match(/setcpm\s*\(\s*(\d+(?:\.\d+)?)\s*\)/);
|
|
122
|
+
if (bpmMatch) {
|
|
123
|
+
metadata.bpm = parseFloat(bpmMatch[1]);
|
|
124
|
+
}
|
|
125
|
+
metadata.usesSound = /\bs\s*\(/.test(code) || /\bsound\s*\(/.test(code);
|
|
126
|
+
metadata.usesNote = /\bnote\s*\(/.test(code);
|
|
127
|
+
metadata.isStack = /\bstack\s*\(/.test(code);
|
|
128
|
+
// Extract function names
|
|
129
|
+
const funcMatches = code.match(/\b([a-z][a-zA-Z0-9_]*)\s*\(/g);
|
|
130
|
+
if (funcMatches) {
|
|
131
|
+
const funcNames = funcMatches.map(m => m.replace(/\s*\($/, ''));
|
|
132
|
+
metadata.functionsUsed = [...new Set(funcNames)];
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
const events = this.queryEvents(code, 0, 1);
|
|
136
|
+
metadata.eventsPerCycle = events.length;
|
|
137
|
+
const values = events.map(e => {
|
|
138
|
+
if (typeof e.value === 'object' && e.value !== null) {
|
|
139
|
+
return e.value.s || e.value.note || e.value.n || JSON.stringify(e.value);
|
|
140
|
+
}
|
|
141
|
+
return String(e.value);
|
|
142
|
+
});
|
|
143
|
+
metadata.uniqueValues = [...new Set(values)];
|
|
144
|
+
metadata.complexity = Math.min((events.length / 16) * 0.3 +
|
|
145
|
+
(metadata.uniqueValues.length / 8) * 0.2 +
|
|
146
|
+
(metadata.functionsUsed.length / 10) * 0.3 +
|
|
147
|
+
(metadata.isStack ? 0.1 : 0) +
|
|
148
|
+
(code.length / 500) * 0.1, 1);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
metadata.complexity = Math.min((metadata.functionsUsed.length / 10) * 0.5 +
|
|
152
|
+
(code.length / 500) * 0.3 +
|
|
153
|
+
(metadata.isStack ? 0.2 : 0), 1);
|
|
154
|
+
}
|
|
155
|
+
return metadata;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ErrorRecovery - Provides graceful error handling and recovery mechanisms
|
|
3
|
+
* Ensures the MCP server can recover from browser crashes, network issues, and pattern errors
|
|
4
|
+
*/
|
|
5
|
+
import { Logger } from './Logger.js';
|
|
6
|
+
export class ErrorRecovery {
|
|
7
|
+
logger;
|
|
8
|
+
errorHistory = new Map();
|
|
9
|
+
ERROR_WINDOW = 60000; // 1 minute window for error tracking
|
|
10
|
+
constructor() {
|
|
11
|
+
this.logger = new Logger();
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Executes an operation with automatic retry logic
|
|
15
|
+
* @param operation - Async function to execute
|
|
16
|
+
* @param operationName - Name for logging
|
|
17
|
+
* @param strategy - Recovery strategy configuration
|
|
18
|
+
* @returns Result of the operation
|
|
19
|
+
*/
|
|
20
|
+
async executeWithRetry(operation, operationName, strategy = {
|
|
21
|
+
maxRetries: 3,
|
|
22
|
+
retryDelay: 1000,
|
|
23
|
+
exponentialBackoff: true
|
|
24
|
+
}) {
|
|
25
|
+
let lastError = null;
|
|
26
|
+
for (let attempt = 0; attempt <= strategy.maxRetries; attempt++) {
|
|
27
|
+
try {
|
|
28
|
+
this.logger.debug(`Executing ${operationName} (attempt ${attempt + 1}/${strategy.maxRetries + 1})`);
|
|
29
|
+
const result = await operation();
|
|
30
|
+
// Success - clear error history for this operation
|
|
31
|
+
this.clearErrorHistory(operationName);
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
lastError = error;
|
|
36
|
+
this.recordError(operationName);
|
|
37
|
+
this.logger.warn(`${operationName} failed (attempt ${attempt + 1}/${strategy.maxRetries + 1})`, { error: error.message });
|
|
38
|
+
// Don't retry on last attempt
|
|
39
|
+
if (attempt < strategy.maxRetries) {
|
|
40
|
+
const delay = strategy.exponentialBackoff
|
|
41
|
+
? strategy.retryDelay * Math.pow(2, attempt)
|
|
42
|
+
: strategy.retryDelay;
|
|
43
|
+
this.logger.debug(`Waiting ${delay}ms before retry`);
|
|
44
|
+
await this.sleep(delay);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// All retries failed
|
|
49
|
+
this.logger.error(`${operationName} failed after ${strategy.maxRetries + 1} attempts`);
|
|
50
|
+
// Try fallback if available
|
|
51
|
+
if (strategy.fallbackAction) {
|
|
52
|
+
this.logger.info(`Executing fallback for ${operationName}`);
|
|
53
|
+
try {
|
|
54
|
+
return await strategy.fallbackAction();
|
|
55
|
+
}
|
|
56
|
+
catch (fallbackError) {
|
|
57
|
+
this.logger.error(`Fallback failed for ${operationName}`, fallbackError);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
throw new Error(`${operationName} failed after ${strategy.maxRetries + 1} attempts: ${lastError?.message}`);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Wraps an operation with timeout protection
|
|
64
|
+
* @param operation - Async function to execute
|
|
65
|
+
* @param timeout - Timeout in milliseconds
|
|
66
|
+
* @param operationName - Name for error messages
|
|
67
|
+
* @returns Result of the operation
|
|
68
|
+
*/
|
|
69
|
+
async executeWithTimeout(operation, timeout, operationName) {
|
|
70
|
+
return Promise.race([
|
|
71
|
+
operation(),
|
|
72
|
+
this.createTimeout(timeout, operationName)
|
|
73
|
+
]);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Combines retry logic with timeout protection
|
|
77
|
+
* @param operation - Async function to execute
|
|
78
|
+
* @param operationName - Name for logging
|
|
79
|
+
* @param timeout - Timeout per attempt in milliseconds
|
|
80
|
+
* @param strategy - Recovery strategy
|
|
81
|
+
* @returns Result of the operation
|
|
82
|
+
*/
|
|
83
|
+
async executeWithRetryAndTimeout(operation, operationName, timeout, strategy) {
|
|
84
|
+
return this.executeWithRetry(() => this.executeWithTimeout(operation, timeout, operationName), operationName, strategy);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Gracefully handles browser initialization errors
|
|
88
|
+
* @param initFunction - Browser initialization function
|
|
89
|
+
* @returns Initialization result
|
|
90
|
+
*/
|
|
91
|
+
async handleBrowserInit(initFunction) {
|
|
92
|
+
return this.executeWithRetryAndTimeout(initFunction, 'Browser Initialization', 30000, // 30 second timeout
|
|
93
|
+
{
|
|
94
|
+
maxRetries: 2,
|
|
95
|
+
retryDelay: 2000,
|
|
96
|
+
exponentialBackoff: true,
|
|
97
|
+
fallbackAction: async () => {
|
|
98
|
+
this.logger.warn('Browser initialization failed, suggesting headless mode');
|
|
99
|
+
return 'Browser initialization failed. Try setting headless:true in config.json';
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Handles pattern write errors with validation
|
|
105
|
+
* @param writeFunction - Pattern write function
|
|
106
|
+
* @param pattern - Pattern to write
|
|
107
|
+
* @returns Write result
|
|
108
|
+
*/
|
|
109
|
+
async handlePatternWrite(writeFunction, pattern) {
|
|
110
|
+
return this.executeWithRetry(() => writeFunction(pattern), 'Pattern Write', {
|
|
111
|
+
maxRetries: 2,
|
|
112
|
+
retryDelay: 500,
|
|
113
|
+
exponentialBackoff: false,
|
|
114
|
+
fallbackAction: async () => {
|
|
115
|
+
// Try writing a simplified version
|
|
116
|
+
const simplified = this.simplifyPattern(pattern);
|
|
117
|
+
this.logger.info('Attempting to write simplified pattern');
|
|
118
|
+
return writeFunction(simplified);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Checks if an operation is experiencing repeated failures
|
|
124
|
+
* @param operationName - Name of operation to check
|
|
125
|
+
* @param threshold - Number of errors to consider "frequent"
|
|
126
|
+
* @returns True if operation is failing frequently
|
|
127
|
+
*/
|
|
128
|
+
isFrequentlyFailing(operationName, threshold = 3) {
|
|
129
|
+
const errors = this.errorHistory.get(operationName) || [];
|
|
130
|
+
const now = Date.now();
|
|
131
|
+
// Count errors within the time window
|
|
132
|
+
const recentErrors = errors.filter(timestamp => now - timestamp < this.ERROR_WINDOW);
|
|
133
|
+
return recentErrors.length >= threshold;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Gets error statistics for monitoring
|
|
137
|
+
* @returns Error statistics by operation
|
|
138
|
+
*/
|
|
139
|
+
getErrorStats() {
|
|
140
|
+
const stats = {};
|
|
141
|
+
const now = Date.now();
|
|
142
|
+
this.errorHistory.forEach((timestamps, operation) => {
|
|
143
|
+
const recentErrors = timestamps.filter(ts => now - ts < this.ERROR_WINDOW);
|
|
144
|
+
stats[operation] = {
|
|
145
|
+
count: recentErrors.length,
|
|
146
|
+
lastError: recentErrors.length > 0 ? new Date(Math.max(...recentErrors)) : null
|
|
147
|
+
};
|
|
148
|
+
});
|
|
149
|
+
return stats;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Clears error history for a specific operation
|
|
153
|
+
* @param operationName - Operation to clear
|
|
154
|
+
*/
|
|
155
|
+
clearErrorHistory(operationName) {
|
|
156
|
+
this.errorHistory.delete(operationName);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Clears all error history
|
|
160
|
+
*/
|
|
161
|
+
clearAllErrorHistory() {
|
|
162
|
+
this.errorHistory.clear();
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Records an error occurrence
|
|
166
|
+
* @param operationName - Name of the operation
|
|
167
|
+
*/
|
|
168
|
+
recordError(operationName) {
|
|
169
|
+
const errors = this.errorHistory.get(operationName) || [];
|
|
170
|
+
errors.push(Date.now());
|
|
171
|
+
// Keep only recent errors
|
|
172
|
+
const now = Date.now();
|
|
173
|
+
const recentErrors = errors.filter(ts => now - ts < this.ERROR_WINDOW);
|
|
174
|
+
this.errorHistory.set(operationName, recentErrors);
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Creates a timeout promise
|
|
178
|
+
* @param ms - Timeout in milliseconds
|
|
179
|
+
* @param operationName - Name for error message
|
|
180
|
+
* @returns Promise that rejects after timeout
|
|
181
|
+
*/
|
|
182
|
+
createTimeout(ms, operationName) {
|
|
183
|
+
return new Promise((_, reject) => {
|
|
184
|
+
setTimeout(() => {
|
|
185
|
+
reject(new Error(`${operationName} timed out after ${ms}ms`));
|
|
186
|
+
}, ms);
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Sleep utility
|
|
191
|
+
* @param ms - Milliseconds to sleep
|
|
192
|
+
* @returns Promise that resolves after delay
|
|
193
|
+
*/
|
|
194
|
+
sleep(ms) {
|
|
195
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Simplifies a pattern by removing complex modifiers
|
|
199
|
+
* @param pattern - Original pattern
|
|
200
|
+
* @returns Simplified pattern
|
|
201
|
+
*/
|
|
202
|
+
simplifyPattern(pattern) {
|
|
203
|
+
// Remove complex modifiers but keep core structure
|
|
204
|
+
let simplified = pattern;
|
|
205
|
+
// Remove effect chains but keep basic sound/note calls
|
|
206
|
+
simplified = simplified.replace(/\.(delay|reverb|room|lpf|hpf|bpf)\([^)]*\)/g, '');
|
|
207
|
+
// Remove complex transformations
|
|
208
|
+
simplified = simplified.replace(/\.(jux|iter|chop|striate|scramble)\([^)]*\)/g, '');
|
|
209
|
+
// Remove conditional modifications
|
|
210
|
+
simplified = simplified.replace(/\.(sometimes|often|rarely|every)\([^)]*\)/g, '');
|
|
211
|
+
this.logger.debug('Pattern simplified', {
|
|
212
|
+
original: pattern.substring(0, 50) + '...',
|
|
213
|
+
simplified: simplified.substring(0, 50) + '...'
|
|
214
|
+
});
|
|
215
|
+
return simplified;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Handles network-related errors
|
|
219
|
+
* @param operation - Operation that may fail due to network
|
|
220
|
+
* @param operationName - Name for logging
|
|
221
|
+
* @returns Operation result
|
|
222
|
+
*/
|
|
223
|
+
async handleNetworkOperation(operation, operationName) {
|
|
224
|
+
return this.executeWithRetry(operation, operationName, {
|
|
225
|
+
maxRetries: 5,
|
|
226
|
+
retryDelay: 2000,
|
|
227
|
+
exponentialBackoff: true,
|
|
228
|
+
fallbackAction: async () => {
|
|
229
|
+
throw new Error(`Network operation ${operationName} failed - check internet connection`);
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Creates a circuit breaker for operations that fail frequently
|
|
235
|
+
* @param operation - Operation to protect
|
|
236
|
+
* @param operationName - Name for tracking
|
|
237
|
+
* @param threshold - Number of failures before circuit opens
|
|
238
|
+
* @returns Protected operation
|
|
239
|
+
*/
|
|
240
|
+
createCircuitBreaker(operation, operationName, threshold = 5) {
|
|
241
|
+
return async () => {
|
|
242
|
+
if (this.isFrequentlyFailing(operationName, threshold)) {
|
|
243
|
+
throw new Error(`Circuit breaker open for ${operationName} - operation disabled due to repeated failures`);
|
|
244
|
+
}
|
|
245
|
+
return operation();
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* InputValidator - Comprehensive parameter validation for Strudel MCP Server
|
|
3
|
+
*
|
|
4
|
+
* Provides robust validation for all user inputs to prevent:
|
|
5
|
+
* - Invalid musical parameters (BPM, gain, etc.)
|
|
6
|
+
* - Speaker damage from excessive gain values
|
|
7
|
+
* - Mathematically invalid patterns (e.g., Euclidean rhythms)
|
|
8
|
+
* - Security issues from malformed inputs
|
|
9
|
+
*
|
|
10
|
+
* All methods throw descriptive errors for invalid inputs.
|
|
11
|
+
*/
|
|
12
|
+
export class InputValidator {
|
|
13
|
+
// Valid scale names from MusicTheory service
|
|
14
|
+
static VALID_SCALES = [
|
|
15
|
+
'major', 'minor', 'dorian', 'phrygian', 'lydian', 'mixolydian',
|
|
16
|
+
'aeolian', 'locrian', 'pentatonic', 'blues', 'chromatic',
|
|
17
|
+
'wholetone', 'harmonic_minor', 'melodic_minor'
|
|
18
|
+
];
|
|
19
|
+
// Valid chord progression styles from MusicTheory service
|
|
20
|
+
static VALID_CHORD_STYLES = [
|
|
21
|
+
'pop', 'jazz', 'blues', 'folk', 'rock', 'classical', 'modal', 'edm'
|
|
22
|
+
];
|
|
23
|
+
// Valid root notes (12-tone chromatic scale)
|
|
24
|
+
static VALID_ROOT_NOTES = [
|
|
25
|
+
'C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'
|
|
26
|
+
];
|
|
27
|
+
/**
|
|
28
|
+
* Validates BPM (Beats Per Minute) is within acceptable range
|
|
29
|
+
*
|
|
30
|
+
* @param bpm - Tempo in beats per minute
|
|
31
|
+
* @throws {Error} When BPM is not a number, not finite, or outside 20-300 range
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* InputValidator.validateBPM(120); // ✓ Valid
|
|
35
|
+
* InputValidator.validateBPM(19); // ✗ Throws: BPM must be between 20 and 300
|
|
36
|
+
*/
|
|
37
|
+
static validateBPM(bpm) {
|
|
38
|
+
if (typeof bpm !== 'number') {
|
|
39
|
+
throw new Error('BPM must be a number');
|
|
40
|
+
}
|
|
41
|
+
if (isNaN(bpm) || !isFinite(bpm)) {
|
|
42
|
+
throw new Error('BPM must be a valid number');
|
|
43
|
+
}
|
|
44
|
+
if (bpm < 20 || bpm > 300) {
|
|
45
|
+
throw new Error(`BPM must be between 20 and 300, got ${bpm}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Validates gain is within safe range to prevent speaker damage
|
|
50
|
+
*
|
|
51
|
+
* @param gain - Audio gain multiplier (0 = silence, 1 = unity, 2 = max safe)
|
|
52
|
+
* @throws {Error} When gain is not a number, not finite, or outside 0-2.0 range
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* InputValidator.validateGain(1.0); // ✓ Valid (unity gain)
|
|
56
|
+
* InputValidator.validateGain(10); // ✗ Throws: Gain must be between 0 and 2.0
|
|
57
|
+
*/
|
|
58
|
+
static validateGain(gain) {
|
|
59
|
+
if (typeof gain !== 'number') {
|
|
60
|
+
throw new Error('Gain must be a number');
|
|
61
|
+
}
|
|
62
|
+
if (isNaN(gain) || !isFinite(gain)) {
|
|
63
|
+
throw new Error('Gain must be a valid number');
|
|
64
|
+
}
|
|
65
|
+
if (gain < 0 || gain > 2.0) {
|
|
66
|
+
throw new Error(`Gain must be between 0 and 2.0, got ${gain}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Validates Euclidean rhythm parameters are mathematically valid
|
|
71
|
+
*
|
|
72
|
+
* Euclidean rhythms distribute hits evenly across steps.
|
|
73
|
+
* Example: 3 hits in 8 steps = "X..X..X." pattern
|
|
74
|
+
*
|
|
75
|
+
* @param hits - Number of beats/hits (must be <= steps)
|
|
76
|
+
* @param steps - Total number of steps in the pattern
|
|
77
|
+
* @throws {Error} When hits > steps or parameters are not valid integers
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* InputValidator.validateEuclidean(3, 8); // ✓ Valid
|
|
81
|
+
* InputValidator.validateEuclidean(10, 8); // ✗ Throws: Hits cannot exceed steps
|
|
82
|
+
*/
|
|
83
|
+
static validateEuclidean(hits, steps) {
|
|
84
|
+
// Validate hits
|
|
85
|
+
if (typeof hits !== 'number') {
|
|
86
|
+
throw new Error('Hits must be a number');
|
|
87
|
+
}
|
|
88
|
+
if (isNaN(hits) || !isFinite(hits)) {
|
|
89
|
+
throw new Error('Hits must be a valid number');
|
|
90
|
+
}
|
|
91
|
+
if (!Number.isInteger(hits)) {
|
|
92
|
+
throw new Error('Hits must be an integer');
|
|
93
|
+
}
|
|
94
|
+
if (hits < 0) {
|
|
95
|
+
throw new Error('Hits must be a non-negative integer');
|
|
96
|
+
}
|
|
97
|
+
// Validate steps
|
|
98
|
+
if (typeof steps !== 'number') {
|
|
99
|
+
throw new Error('Steps must be a number');
|
|
100
|
+
}
|
|
101
|
+
if (isNaN(steps) || !isFinite(steps)) {
|
|
102
|
+
throw new Error('Steps must be a valid number');
|
|
103
|
+
}
|
|
104
|
+
if (!Number.isInteger(steps)) {
|
|
105
|
+
throw new Error('Steps must be an integer');
|
|
106
|
+
}
|
|
107
|
+
if (steps <= 0) {
|
|
108
|
+
throw new Error('Steps must be a positive integer');
|
|
109
|
+
}
|
|
110
|
+
if (steps > 256) {
|
|
111
|
+
throw new Error(`Steps cannot exceed 256, got ${steps}`);
|
|
112
|
+
}
|
|
113
|
+
// Validate relationship
|
|
114
|
+
if (hits > steps) {
|
|
115
|
+
throw new Error(`Hits (${hits}) cannot exceed steps (${steps})`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Validates musical scale name is supported by MusicTheory service
|
|
120
|
+
*
|
|
121
|
+
* @param name - Scale name (must be lowercase)
|
|
122
|
+
* @throws {Error} When scale name is invalid or not a string
|
|
123
|
+
*
|
|
124
|
+
* Supported scales:
|
|
125
|
+
* - major, minor, pentatonic, blues
|
|
126
|
+
* - Modes: dorian, phrygian, lydian, mixolydian, aeolian, locrian
|
|
127
|
+
* - Special: chromatic, wholetone, harmonic_minor, melodic_minor
|
|
128
|
+
*
|
|
129
|
+
* @example
|
|
130
|
+
* InputValidator.validateScaleName('major'); // ✓ Valid
|
|
131
|
+
* InputValidator.validateScaleName('Major'); // ✗ Throws: Invalid scale (case-sensitive)
|
|
132
|
+
*/
|
|
133
|
+
static validateScaleName(name) {
|
|
134
|
+
if (typeof name !== 'string') {
|
|
135
|
+
throw new Error('Scale name must be a string');
|
|
136
|
+
}
|
|
137
|
+
const trimmed = name.trim();
|
|
138
|
+
if (trimmed.length === 0) {
|
|
139
|
+
throw new Error('Scale name cannot be empty');
|
|
140
|
+
}
|
|
141
|
+
if (trimmed.length > 100) {
|
|
142
|
+
throw new Error('Scale name too long (max 100 characters)');
|
|
143
|
+
}
|
|
144
|
+
if (!this.VALID_SCALES.includes(trimmed)) {
|
|
145
|
+
throw new Error(`Invalid scale name: ${name}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Validates chord progression style is supported by MusicTheory service
|
|
150
|
+
*
|
|
151
|
+
* @param style - Chord style (must be lowercase)
|
|
152
|
+
* @throws {Error} When chord style is invalid or not a string
|
|
153
|
+
*
|
|
154
|
+
* Supported styles: pop, jazz, blues, folk, rock, classical, modal, edm
|
|
155
|
+
*
|
|
156
|
+
* @example
|
|
157
|
+
* InputValidator.validateChordStyle('jazz'); // ✓ Valid
|
|
158
|
+
* InputValidator.validateChordStyle('Jazz'); // ✗ Throws: Invalid chord style
|
|
159
|
+
*/
|
|
160
|
+
static validateChordStyle(style) {
|
|
161
|
+
if (typeof style !== 'string') {
|
|
162
|
+
throw new Error('Chord style must be a string');
|
|
163
|
+
}
|
|
164
|
+
const trimmed = style.trim();
|
|
165
|
+
if (trimmed.length === 0) {
|
|
166
|
+
throw new Error('Chord style cannot be empty');
|
|
167
|
+
}
|
|
168
|
+
if (trimmed.length > 100) {
|
|
169
|
+
throw new Error('Chord style too long (max 100 characters)');
|
|
170
|
+
}
|
|
171
|
+
if (!this.VALID_CHORD_STYLES.includes(trimmed)) {
|
|
172
|
+
throw new Error(`Invalid chord style: ${style}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Validates musical root note is a valid chromatic note
|
|
177
|
+
*
|
|
178
|
+
* @param note - Root note (A-G with optional #, case insensitive)
|
|
179
|
+
* @throws {Error} When note is invalid or not a string
|
|
180
|
+
*
|
|
181
|
+
* Valid notes: C, C#, D, D#, E, F, F#, G, G#, A, A#, B
|
|
182
|
+
*
|
|
183
|
+
* @example
|
|
184
|
+
* InputValidator.validateRootNote('C'); // ✓ Valid
|
|
185
|
+
* InputValidator.validateRootNote('f#'); // ✓ Valid (normalized to F#)
|
|
186
|
+
* InputValidator.validateRootNote('X'); // ✗ Throws: Invalid root note
|
|
187
|
+
*/
|
|
188
|
+
static validateRootNote(note) {
|
|
189
|
+
if (typeof note !== 'string') {
|
|
190
|
+
throw new Error('Root note must be a string');
|
|
191
|
+
}
|
|
192
|
+
const trimmed = note.trim();
|
|
193
|
+
if (trimmed.length === 0) {
|
|
194
|
+
throw new Error('Root note cannot be empty');
|
|
195
|
+
}
|
|
196
|
+
if (trimmed.length > 10) {
|
|
197
|
+
throw new Error(`Root note too long (max 10 characters, got ${trimmed.length})`);
|
|
198
|
+
}
|
|
199
|
+
const normalized = trimmed.toUpperCase();
|
|
200
|
+
if (!this.VALID_ROOT_NOTES.includes(normalized)) {
|
|
201
|
+
throw new Error(`Invalid root note: ${note}. Valid notes: ${this.VALID_ROOT_NOTES.join(', ')}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Validates string length to prevent resource exhaustion
|
|
206
|
+
*
|
|
207
|
+
* @param str - String to validate
|
|
208
|
+
* @param fieldName - Field name for error messages
|
|
209
|
+
* @param maxLength - Maximum allowed length (default: 1000)
|
|
210
|
+
* @param allowEmpty - Whether to allow empty strings (default: false for identifiers, true for content)
|
|
211
|
+
* @throws {Error} When string exceeds maximum length or is not a string
|
|
212
|
+
*
|
|
213
|
+
* @example
|
|
214
|
+
* InputValidator.validateStringLength('hello', 'pattern', 100); // ✓ Valid
|
|
215
|
+
* InputValidator.validateStringLength('x'.repeat(1001), 'pattern'); // ✗ Throws
|
|
216
|
+
* InputValidator.validateStringLength('', 'pattern', 1000, true); // ✓ Valid (clearing pattern)
|
|
217
|
+
* InputValidator.validateStringLength('', 'name', 1000, false); // ✗ Throws
|
|
218
|
+
*/
|
|
219
|
+
static validateStringLength(str, fieldName, maxLength = 1000, allowEmpty = true) {
|
|
220
|
+
if (typeof str !== 'string') {
|
|
221
|
+
throw new Error(`${fieldName} must be a string`);
|
|
222
|
+
}
|
|
223
|
+
if (!allowEmpty && str.trim().length === 0) {
|
|
224
|
+
throw new Error(`${fieldName} cannot be empty`);
|
|
225
|
+
}
|
|
226
|
+
if (str.length > maxLength) {
|
|
227
|
+
throw new Error(`${fieldName} too long (max ${maxLength} characters, got ${str.length})`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Validates a value is within 0-1 range (normalized/ratio value)
|
|
232
|
+
*
|
|
233
|
+
* Used for parameters like complexity, swing amount, humanize amount, etc.
|
|
234
|
+
* that represent normalized ratios or percentages.
|
|
235
|
+
*
|
|
236
|
+
* @param value - Number to validate
|
|
237
|
+
* @param fieldName - Field name for error messages
|
|
238
|
+
* @throws {Error} When value is not in 0-1 range
|
|
239
|
+
*
|
|
240
|
+
* @example
|
|
241
|
+
* InputValidator.validateNormalizedValue(0.5, 'complexity'); // ✓ Valid
|
|
242
|
+
* InputValidator.validateNormalizedValue(1.5, 'complexity'); // ✗ Throws
|
|
243
|
+
*/
|
|
244
|
+
static validateNormalizedValue(value, fieldName) {
|
|
245
|
+
if (typeof value !== 'number') {
|
|
246
|
+
throw new Error(`${fieldName} must be a number`);
|
|
247
|
+
}
|
|
248
|
+
if (isNaN(value) || !isFinite(value)) {
|
|
249
|
+
throw new Error(`${fieldName} must be a valid number`);
|
|
250
|
+
}
|
|
251
|
+
if (value < 0 || value > 1.0) {
|
|
252
|
+
throw new Error(`${fieldName} must be between 0 and 1.0, got ${value}`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Validates a value is a positive integer (> 0)
|
|
257
|
+
*
|
|
258
|
+
* @param value - Number to validate
|
|
259
|
+
* @param fieldName - Field name for error messages
|
|
260
|
+
* @throws {Error} When value is not a positive integer
|
|
261
|
+
*
|
|
262
|
+
* @example
|
|
263
|
+
* InputValidator.validatePositiveInteger(5, 'count'); // ✓ Valid
|
|
264
|
+
* InputValidator.validatePositiveInteger(0, 'count'); // ✗ Throws: must be positive
|
|
265
|
+
* InputValidator.validatePositiveInteger(1.5, 'count'); // ✗ Throws: must be integer
|
|
266
|
+
*/
|
|
267
|
+
static validatePositiveInteger(value, fieldName) {
|
|
268
|
+
if (typeof value !== 'number') {
|
|
269
|
+
throw new Error(`${fieldName} must be a number`);
|
|
270
|
+
}
|
|
271
|
+
if (isNaN(value) || !isFinite(value)) {
|
|
272
|
+
throw new Error(`${fieldName} must be a valid number`);
|
|
273
|
+
}
|
|
274
|
+
if (!Number.isInteger(value)) {
|
|
275
|
+
throw new Error(`${fieldName} must be an integer`);
|
|
276
|
+
}
|
|
277
|
+
if (value <= 0) {
|
|
278
|
+
throw new Error(`${fieldName} must be a positive integer`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|