@williamzujkowski/live-coding-music-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1735 -0
  3. package/dist/AudioAnalyzer.js +678 -0
  4. package/dist/PatternStore.js +169 -0
  5. package/dist/StrudelController.js +676 -0
  6. package/dist/__tests__/MusicTheory.test.js +245 -0
  7. package/dist/__tests__/PatternGenerator.test.js +337 -0
  8. package/dist/__tests__/PatternStore.test.js +153 -0
  9. package/dist/__tests__/utils/MockPlaywright.js +130 -0
  10. package/dist/index.js +21 -0
  11. package/dist/server/EnhancedMCPServer.js +654 -0
  12. package/dist/server/EnhancedMCPServerFixed.js +2506 -0
  13. package/dist/server/server.js +580 -0
  14. package/dist/server/tools/ai.js +530 -0
  15. package/dist/server/tools/analysis.js +132 -0
  16. package/dist/server/tools/capture.js +184 -0
  17. package/dist/server/tools/diagnostics.js +96 -0
  18. package/dist/server/tools/editor.js +128 -0
  19. package/dist/server/tools/generate.js +218 -0
  20. package/dist/server/tools/history.js +196 -0
  21. package/dist/server/tools/playback.js +41 -0
  22. package/dist/server/tools/session.js +115 -0
  23. package/dist/server/tools/storage.js +77 -0
  24. package/dist/server/tools/transform.js +399 -0
  25. package/dist/server/tools/types.js +9 -0
  26. package/dist/services/AudioCaptureService.js +279 -0
  27. package/dist/services/GeminiService.js +790 -0
  28. package/dist/services/MIDIExportService.js +412 -0
  29. package/dist/services/MusicTheory.js +186 -0
  30. package/dist/services/PatternGenerator.js +647 -0
  31. package/dist/services/SessionManager.js +316 -0
  32. package/dist/services/StrudelEngine.js +374 -0
  33. package/dist/services/__mocks__/StrudelEngine.js +157 -0
  34. package/dist/types/AudioAnalysis.js +4 -0
  35. package/dist/types/index.js +1 -0
  36. package/dist/utils/ErrorRecovery.js +248 -0
  37. package/dist/utils/InputValidator.js +281 -0
  38. package/dist/utils/Logger.js +19 -0
  39. package/dist/utils/PatternValidator.js +241 -0
  40. package/dist/utils/PerformanceMonitor.js +128 -0
  41. package/package.json +86 -0
@@ -0,0 +1,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,4 @@
1
+ /**
2
+ * Type definitions for advanced audio analysis
3
+ */
4
+ export {};
@@ -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
+ }