@williamzujkowski/live-coding-music-mcp 4.0.0 → 4.1.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 (53) hide show
  1. package/README.md +202 -705
  2. package/dist/AudioAnalyzer.js +1294 -91
  3. package/dist/PatternStore.js +276 -23
  4. package/dist/StrudelController.js +388 -56
  5. package/dist/server/resources.js +27 -20
  6. package/dist/server/server.js +384 -76
  7. package/dist/server/tools/ai.js +124 -91
  8. package/dist/server/tools/analysis.js +168 -59
  9. package/dist/server/tools/capture.js +135 -24
  10. package/dist/server/tools/compose.js +26 -26
  11. package/dist/server/tools/diagnostics.js +20 -3
  12. package/dist/server/tools/editor.js +60 -6
  13. package/dist/server/tools/generate.js +38 -15
  14. package/dist/server/tools/history.js +30 -8
  15. package/dist/server/tools/playback.js +2 -1
  16. package/dist/server/tools/session.js +38 -6
  17. package/dist/server/tools/storage.js +179 -8
  18. package/dist/server/tools/transform.js +127 -43
  19. package/dist/server/tools/types.js +209 -14
  20. package/dist/services/AudioCaptureService.js +302 -33
  21. package/dist/services/AudioExportService.js +318 -0
  22. package/dist/services/BrowserOnlyFunctions.js +149 -0
  23. package/dist/services/EventDensityProbe.js +324 -0
  24. package/dist/services/GeminiService.js +221 -36
  25. package/dist/services/IsolatedEngineRunner.js +308 -0
  26. package/dist/services/IsolatedStrudelEngine.js +135 -0
  27. package/dist/services/LocalPatternEngine.js +16 -0
  28. package/dist/services/MIDIExportService.js +765 -71
  29. package/dist/services/MIDIImportService.js +474 -0
  30. package/dist/services/MusicTheory.js +85 -13
  31. package/dist/services/PatternGenerator.js +168 -85
  32. package/dist/services/PatternSandbox.js +359 -0
  33. package/dist/services/SessionManager.js +338 -65
  34. package/dist/services/StrudelEngine.js +218 -12
  35. package/dist/services/StrudelEngineHelpers.js +24 -8
  36. package/dist/services/StyleRegistry.js +204 -0
  37. package/dist/services/Transferable.js +114 -0
  38. package/dist/services/ai/AiTransport.js +56 -0
  39. package/dist/services/ai/AudioMeasurements.js +155 -0
  40. package/dist/services/ai/CliTransport.js +256 -0
  41. package/dist/services/engineChild.js +83 -0
  42. package/dist/services/engineChildPath.js +28 -0
  43. package/dist/utils/CategorisedError.js +59 -0
  44. package/dist/utils/ErrorRecovery.js +130 -110
  45. package/dist/utils/InputValidator.js +55 -7
  46. package/dist/utils/PatternValidator.js +136 -17
  47. package/dist/utils/SafePath.js +112 -0
  48. package/dist/utils/ServerConfig.js +248 -0
  49. package/dist/utils/TableLookup.js +41 -0
  50. package/dist/utils/Tempo.js +143 -0
  51. package/package.json +17 -5
  52. package/dist/services/__mocks__/StrudelEngine.js +0 -157
  53. package/dist/types/index.js +0 -1
@@ -47,10 +47,81 @@ export class PatternValidator {
47
47
  suggestions
48
48
  };
49
49
  }
50
+ /**
51
+ * The pattern with comments and string bodies blanked out.
52
+ *
53
+ * Both are text, not code, and scanning them as code went wrong in
54
+ * both directions. Measured before this:
55
+ *
56
+ * s("bd*4") // sounds sad :( -> BLOCKED, "Unclosed '('"
57
+ * s("bd*4") // yay :) -> BLOCKED, "Unexpected closing ')'"
58
+ * s("bd*4") // try gain(10) later -> BLOCKED, "Dangerous gain value"
59
+ * s(" eval(x)") -> BLOCKED, "eval() is not allowed"
60
+ *
61
+ * Every one of those is a valid pattern, and `edit_pattern` refuses
62
+ * the write on a validation error — so a smiley in a comment stopped
63
+ * the music. #334 taught `checkBalancedQuotes` about comments for
64
+ * exactly this reason and stopped there; these are the halves it did
65
+ * not reach (#445).
66
+ *
67
+ * Lengths and offsets are preserved, so positions reported to the user
68
+ * still point at the right character.
69
+ *
70
+ * @param pattern - Pattern source
71
+ * @returns The same string with comment and string contents replaced
72
+ * by spaces, delimiters kept
73
+ */
74
+ codeOnly(pattern) {
75
+ const out = pattern.split('');
76
+ let quote = null;
77
+ for (let i = 0; i < pattern.length; i++) {
78
+ const char = pattern[i];
79
+ if (quote !== null) {
80
+ // Count the backslash run: an escaped backslash does not escape
81
+ // the quote that follows it.
82
+ if (char === '\\') {
83
+ out[i] = ' ';
84
+ if (i + 1 < pattern.length)
85
+ out[i + 1] = ' ';
86
+ i++;
87
+ continue;
88
+ }
89
+ if (char === quote) {
90
+ quote = null;
91
+ continue;
92
+ }
93
+ out[i] = ' ';
94
+ continue;
95
+ }
96
+ if (char === '"' || char === "'" || char === '`') {
97
+ quote = char;
98
+ continue;
99
+ }
100
+ if (char === '/' && pattern[i + 1] === '/') {
101
+ const newline = pattern.indexOf('\n', i);
102
+ const end = newline === -1 ? pattern.length : newline;
103
+ for (let j = i; j < end; j++)
104
+ out[j] = ' ';
105
+ i = end;
106
+ continue;
107
+ }
108
+ if (char === '/' && pattern[i + 1] === '*') {
109
+ const close = pattern.indexOf('*/', i + 2);
110
+ const end = close === -1 ? pattern.length : close + 2;
111
+ for (let j = i; j < end; j++)
112
+ out[j] = ' ';
113
+ i = end - 1;
114
+ continue;
115
+ }
116
+ }
117
+ return out.join('');
118
+ }
50
119
  /**
51
120
  * Checks if parentheses, brackets, and braces are balanced
52
121
  */
53
- checkBalancedParentheses(pattern) {
122
+ checkBalancedParentheses(source) {
123
+ // Brackets inside a comment or a sample name are not structure.
124
+ const pattern = this.codeOnly(source);
54
125
  const stack = [];
55
126
  const pairs = { '(': ')', '[': ']', '{': '}' };
56
127
  const opening = Object.keys(pairs);
@@ -89,6 +160,28 @@ export class PatternValidator {
89
160
  if (prevChar === '\\') {
90
161
  continue;
91
162
  }
163
+ // Comments are not code, and an apostrophe in one is not a quote.
164
+ // Without this, `s("bd*4") // don't stop the beat` was reported as
165
+ // having an unclosed single quote — and writePatternWithValidation
166
+ // refuses the write outright on a validation error, so a comment
167
+ // containing an ordinary English contraction blocked the pattern
168
+ // (#334).
169
+ if (!inSingleQuote && !inDoubleQuote && !inBacktick) {
170
+ if (char === '/' && pattern[i + 1] === '/') {
171
+ const newline = pattern.indexOf('\n', i);
172
+ if (newline === -1)
173
+ break;
174
+ i = newline;
175
+ continue;
176
+ }
177
+ if (char === '/' && pattern[i + 1] === '*') {
178
+ const end = pattern.indexOf('*/', i + 2);
179
+ if (end === -1)
180
+ break;
181
+ i = end + 1;
182
+ continue;
183
+ }
184
+ }
92
185
  if (char === "'" && !inDoubleQuote && !inBacktick) {
93
186
  inSingleQuote = !inSingleQuote;
94
187
  }
@@ -156,28 +249,54 @@ export class PatternValidator {
156
249
  /**
157
250
  * Checks for potentially dangerous operations
158
251
  */
159
- checkSafety(pattern) {
252
+ checkSafety(source) {
160
253
  const errors = [];
161
254
  const warnings = [];
162
- // Check for excessive gain that could damage speakers
163
- const gainMatches = pattern.match(/\.gain\(([0-9.]+)\)/g);
164
- if (gainMatches) {
165
- gainMatches.forEach(match => {
166
- const gainValue = parseFloat(match.match(/([0-9.]+)/)?.[0] || '0');
167
- if (gainValue > 2) {
168
- warnings.push(`High gain value detected: ${gainValue} - may be too loud`);
169
- }
170
- if (gainValue > 5) {
171
- errors.push(`Dangerous gain value: ${gainValue} - reduced to 2.0 for safety`);
172
- }
173
- });
255
+ // A comment mentioning gain(10), or a sample called " eval(x)", is
256
+ // not a dangerous pattern — and both used to be refused (#445).
257
+ const pattern = this.codeOnly(source);
258
+ // Check for excessive gain that could damage speakers.
259
+ //
260
+ // This never fired for any input. The outer match used /g, which
261
+ // discards capture groups and returns the whole ".gain(9)"; the
262
+ // inner /([0-9.]+)/ then matched the LEADING DOT of ".gain", and
263
+ // parseFloat(".") is NaN. Both NaN > 2 and NaN > 5 are false, so
264
+ // neither branch was reachable — gain(99) validated clean while
265
+ // CLAUDE.md advertised "prevents dangerous patterns (gain > 2.0)".
266
+ //
267
+ // Four tests in PatternValidator.test.ts documented the bug in
268
+ // detail and asserted the broken behaviour, for coverage. Coverage
269
+ // measured that these lines ran; nothing measured that they did
270
+ // anything (#334).
271
+ //
272
+ // Now matchAll with the capture, an optional leading dot so bare
273
+ // `gain(9)` is caught too, whitespace tolerance, and a number
274
+ // pattern that accepts exponent form.
275
+ // `\s*` before the paren, and an optional sign.
276
+ //
277
+ // One space defeated every rule below: `gain (10)`, `eval ("x")` and
278
+ // `while ( true )` all passed as safe while their unspaced forms
279
+ // were errors. #334 fixed this regex's capture group and left the
280
+ // shape of the match, so the advertised guarantee still did not hold
281
+ // (#445). `+10` is the same evasion by a different character.
282
+ const GAIN = /(?:^|[.\s(])gain\s*\(\s*\+?(\d*\.?\d+(?:e[+-]?\d+)?)\s*\)/gi;
283
+ for (const match of pattern.matchAll(GAIN)) {
284
+ const gainValue = Number.parseFloat(match[1]);
285
+ if (!Number.isFinite(gainValue))
286
+ continue;
287
+ if (gainValue > 5) {
288
+ errors.push(`Dangerous gain value: ${String(gainValue)} - above the safe maximum of 2.0`);
289
+ }
290
+ else if (gainValue > 2) {
291
+ warnings.push(`High gain value detected: ${String(gainValue)} - may be too loud`);
292
+ }
174
293
  }
175
294
  // Check for potentially infinite loops
176
- if (/while\s*\(true\)/.test(pattern) || /for\s*\(.*;;.*\)/.test(pattern)) {
295
+ if (/while\s*\(\s*true\s*\)/.test(pattern) || /for\s*\(.*;;.*\)/.test(pattern)) {
177
296
  errors.push('Potential infinite loop detected');
178
297
  }
179
298
  // Check for eval or similar dangerous functions
180
- if (/\beval\(/.test(pattern) || /Function\(/.test(pattern)) {
299
+ if (/\beval\s*\(/.test(pattern) || /\bFunction\s*\(/.test(pattern)) {
181
300
  errors.push('Use of eval() or Function() is not allowed');
182
301
  }
183
302
  return {
@@ -204,7 +323,7 @@ export class PatternValidator {
204
323
  return match;
205
324
  });
206
325
  // Add missing quotes around sound patterns
207
- fixed = fixed.replace(/s\(([a-z0-9*]+)\)/gi, (match, sound) => {
326
+ fixed = fixed.replace(/(?<![a-z])s\(([a-z0-9*]+)\)/gi, (match, sound) => {
208
327
  if (!sound.includes('"') && !sound.includes("'")) {
209
328
  fixes.push(`Added quotes around sound: ${sound}`);
210
329
  return `s("${sound}")`;
@@ -0,0 +1,112 @@
1
+ /**
2
+ * SafePath - filename sanitization and directory confinement for file writes
3
+ *
4
+ * Every filename this server writes to disk originates in MCP tool
5
+ * arguments, which are model-generated. Anything that reaches an agent's
6
+ * context — a web page it summarized, a pattern it loaded, a MIDI file it
7
+ * imported — can therefore steer a path. Confining writes is not
8
+ * defense against the user; it is defense against the user's agent being
9
+ * talked into something.
10
+ *
11
+ * `PatternStore.sanitizeFilename()` already did this correctly for saved
12
+ * patterns. The export paths did not: `MIDIExportService.exportToFile()`
13
+ * called bare `resolve(filename)` (#224) and `takeScreenshot()` had an
14
+ * inverted guard that passed any filename *containing* a separator
15
+ * through unsanitized (#234). This module is the shared implementation so
16
+ * there is one place to get it right.
17
+ *
18
+ * Behaviour is *neutralize, then report* rather than throw: a traversal
19
+ * attempt is reduced to a safe basename and `wasModified` is set, so the
20
+ * caller can tell the agent where the file actually landed. Failing
21
+ * silently would let a model believe it wrote to `../../secrets/x.mid`;
22
+ * failing loudly on every odd character would make ordinary filenames
23
+ * unusable. Reporting the real path does both jobs.
24
+ *
25
+ * @module utils/SafePath
26
+ * @nist si-10 "Information input validation"
27
+ * @nist ac-3 "Access enforcement"
28
+ */
29
+ import * as path from 'path';
30
+ /** Windows device names, which are unusable as files even on POSIX shares. */
31
+ const RESERVED_NAMES = new Set([
32
+ 'con', 'prn', 'aux', 'nul',
33
+ 'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7', 'com8', 'com9',
34
+ 'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9',
35
+ ]);
36
+ const MAX_STEM_LENGTH = 100;
37
+ /**
38
+ * Reduces an arbitrary string to a safe filename stem.
39
+ *
40
+ * Strips every path component, then replaces anything outside
41
+ * `[A-Za-z0-9._-]` so separators, null bytes, and shell metacharacters
42
+ * cannot survive in any encoding.
43
+ *
44
+ * @param requested - Caller-supplied name
45
+ * @param extension - Extension to strip before sanitizing
46
+ * @returns Safe stem, or empty string if nothing usable remained
47
+ */
48
+ function sanitizeStem(requested, extension) {
49
+ // basename first: strips `../`, absolute roots, and Windows separators
50
+ // once path.basename has normalized them.
51
+ let stem = path.basename(requested.replace(/\\/g, '/'));
52
+ if (stem.toLowerCase().endsWith(extension.toLowerCase())) {
53
+ stem = stem.slice(0, -extension.length);
54
+ }
55
+ stem = stem.replace(/[^A-Za-z0-9._-]/g, '_');
56
+ // A name that is only dots would resolve back to a directory.
57
+ stem = stem.replace(/^\.+/, '');
58
+ if (RESERVED_NAMES.has(stem.toLowerCase())) {
59
+ stem = `_${stem}`;
60
+ }
61
+ return stem.slice(0, MAX_STEM_LENGTH);
62
+ }
63
+ /**
64
+ * Sanitizes a requested filename and confines it to a directory.
65
+ *
66
+ * @param requested - Caller-supplied filename, possibly hostile or absent
67
+ * @param options - Target directory, required extension, and default name
68
+ * @returns The resolved path plus whether the request had to be altered
69
+ * @throws {Error} If the result would escape `directory` — a belt-and-braces
70
+ * check that should be unreachable after sanitization
71
+ *
72
+ * @example
73
+ * resolveSafeOutputPath('../../etc/passwd', {
74
+ * directory: './exports', extension: '.mid', defaultName: 'pattern.mid',
75
+ * });
76
+ * // -> { filename: 'passwd.mid', wasModified: true, path: '<cwd>/exports/passwd.mid' }
77
+ *
78
+ * @nist si-10 "Information input validation"
79
+ * @nist ac-3 "Access enforcement"
80
+ */
81
+ export function resolveSafeOutputPath(requested, options) {
82
+ const { directory, extension, defaultName } = options;
83
+ const root = path.resolve(directory);
84
+ const stem = requested ? sanitizeStem(requested, extension) : '';
85
+ const filename = stem.length > 0 ? `${stem}${extension}` : defaultName;
86
+ const resolved = path.resolve(root, filename);
87
+ // Should be unreachable — sanitizeStem removes every separator — but a
88
+ // containment check is cheap and this is the last line before a write.
89
+ if (path.dirname(resolved) !== root) {
90
+ throw new Error(`Refusing to write outside the export directory: ${String(requested)}`);
91
+ }
92
+ const wasModified = requested !== undefined && requested !== filename;
93
+ return {
94
+ filename,
95
+ path: resolved,
96
+ wasModified,
97
+ ...(wasModified ? { requested } : {}),
98
+ };
99
+ }
100
+ /**
101
+ * Directory exports are written to.
102
+ *
103
+ * Defaults to `./exports`, overridable via `exports_dir` in config.json.
104
+ *
105
+ * @param configuredDir - Value of `exports_dir`, if set
106
+ * @returns Absolute export directory path
107
+ */
108
+ export function resolveExportDirectory(configuredDir) {
109
+ return path.resolve(typeof configuredDir === 'string' && configuredDir.trim().length > 0
110
+ ? configuredDir
111
+ : './exports');
112
+ }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * ServerConfig - parsing and validation for config.json
3
+ *
4
+ * `config.json` shipped four keys and the code read two of them.
5
+ * `strudel_url` and `patterns_dir` were documented in the README as
6
+ * working and silently ignored (#227), so anyone pointing the server at a
7
+ * self-hosted Strudel build, or at an existing pattern library, got the
8
+ * defaults with no warning and went debugging the wrong thing.
9
+ *
10
+ * Silently-ignored configuration is worse than none: it actively misleads.
11
+ * So this module both reads the keys and warns about ones it does not
12
+ * recognise, which is what stops the next dead key from being silent.
13
+ *
14
+ * @module utils/ServerConfig
15
+ * @nist si-10 "Information input validation"
16
+ */
17
+ import * as path from 'path';
18
+ /** Default Strudel REPL, used when `strudel_url` is absent or unusable. */
19
+ export const DEFAULT_STRUDEL_URL = 'https://strudel.cc/';
20
+ /** Default on-disk pattern library. */
21
+ export const DEFAULT_PATTERNS_DIR = './patterns';
22
+ /** Every key config.json may contain. Anything else earns a warning. */
23
+ const KNOWN_KEYS = new Set([
24
+ 'headless',
25
+ 'strudel_url',
26
+ 'patterns_dir',
27
+ 'exports_dir',
28
+ 'audio_analysis',
29
+ 'viewport',
30
+ 'block_resources',
31
+ ]);
32
+ /**
33
+ * Default page setup, unchanged from the historic hardcoding (#514).
34
+ *
35
+ * Both values exist for headless CI: a pinned viewport keeps
36
+ * screenshots deterministic, and aborting fonts and images speeds up
37
+ * page load. In a headed window the same choices degrade the live
38
+ * editor (fallback-font button metrics, canvases that ignore window
39
+ * resizes), which is why both are now configurable.
40
+ */
41
+ export const DEFAULT_VIEWPORT = { width: 1280, height: 720 };
42
+ /** Resource types the page route aborts by default (#514). */
43
+ export const DEFAULT_BLOCK_RESOURCES = ['image', 'font', 'media'];
44
+ /**
45
+ * Resource types Playwright may report; `block_resources` values are
46
+ * validated against this set so a typo earns a warning instead of a
47
+ * silently unblocked resource class.
48
+ */
49
+ const KNOWN_RESOURCE_TYPES = new Set([
50
+ 'document',
51
+ 'stylesheet',
52
+ 'image',
53
+ 'media',
54
+ 'font',
55
+ 'script',
56
+ 'texttrack',
57
+ 'xhr',
58
+ 'fetch',
59
+ 'eventsource',
60
+ 'websocket',
61
+ 'manifest',
62
+ 'other',
63
+ ]);
64
+ /**
65
+ * Validates a Strudel URL.
66
+ *
67
+ * The value reaches `page.goto()`, so restrict it to http/https rather
68
+ * than letting `file:`, `javascript:` or similar through.
69
+ *
70
+ * @param value - Raw `strudel_url` from config.json
71
+ * @param warnings - Collector for problems found
72
+ * @returns A usable URL, falling back to the default
73
+ * @nist si-10 "Information input validation"
74
+ */
75
+ function parseStrudelUrl(value, warnings) {
76
+ if (value === undefined)
77
+ return DEFAULT_STRUDEL_URL;
78
+ if (typeof value !== 'string' || value.trim().length === 0) {
79
+ warnings.push(`config.strudel_url must be a non-empty string; using ${DEFAULT_STRUDEL_URL}`);
80
+ return DEFAULT_STRUDEL_URL;
81
+ }
82
+ let parsed;
83
+ try {
84
+ parsed = new URL(value);
85
+ }
86
+ catch {
87
+ warnings.push(`config.strudel_url is not a valid URL: ${value}; using ${DEFAULT_STRUDEL_URL}`);
88
+ return DEFAULT_STRUDEL_URL;
89
+ }
90
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
91
+ warnings.push(`config.strudel_url must use http or https, got '${parsed.protocol}'; ` +
92
+ `using ${DEFAULT_STRUDEL_URL}`);
93
+ return DEFAULT_STRUDEL_URL;
94
+ }
95
+ return value;
96
+ }
97
+ /**
98
+ * Validates a directory path from config.
99
+ *
100
+ * @param value - Raw value from config.json
101
+ * @param key - Key name, for the warning message
102
+ * @param fallback - Value to use when unusable
103
+ * @param warnings - Collector for problems found
104
+ * @returns A usable directory path
105
+ */
106
+ function parseDirectory(value, key, fallback, warnings) {
107
+ if (value === undefined)
108
+ return fallback;
109
+ if (typeof value !== 'string' || value.trim().length === 0) {
110
+ warnings.push(`config.${key} must be a non-empty string; using ${fallback}`);
111
+ return fallback;
112
+ }
113
+ return path.resolve(value);
114
+ }
115
+ /**
116
+ * Parses raw config.json contents into validated settings.
117
+ *
118
+ * Never throws: a bad value falls back to its default and records a
119
+ * warning, so a typo in config.json cannot stop the server from starting.
120
+ *
121
+ * @param raw - Parsed contents of config.json, or undefined if absent
122
+ * @returns Validated config plus any warnings to log
123
+ *
124
+ * @example
125
+ * const config = parseServerConfig({ strudel_url: 'file:///etc/passwd' });
126
+ * // -> strudelUrl: 'https://strudel.cc/', warnings: ['...must use http or https...']
127
+ */
128
+ /** Keys `audio_analysis` may contain. Anything else earns a warning. */
129
+ const KNOWN_AUDIO_KEYS = new Set(['fft_size', 'smoothing']);
130
+ /**
131
+ * Parses the `audio_analysis` block, saying so when it cannot.
132
+ *
133
+ * Three ways this used to go wrong in silence, all measured:
134
+ *
135
+ * audio_analysis: "enabled" -> undefined, indistinguishable from
136
+ * leaving the block out entirely
137
+ * audio_analysis: [] -> {} — an array passes a bare
138
+ * `typeof === 'object'` test
139
+ * audio_analysis: {fftSize: ...} -> {} — the natural camelCase
140
+ * spelling of `fft_size`, dropped
141
+ *
142
+ * The last is the sharpest: the same typo one level up IS warned about
143
+ * by the unknown-key loop above, so the file was strict about its own
144
+ * keys and silent about the nested ones (#442).
145
+ */
146
+ function parseAudioAnalysis(raw, warnings) {
147
+ if (raw === undefined)
148
+ return undefined;
149
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
150
+ warnings.push('config.audio_analysis must be an object with fft_size and/or smoothing; ignoring it');
151
+ return undefined;
152
+ }
153
+ const audio = raw;
154
+ for (const key of Object.keys(audio)) {
155
+ if (!KNOWN_AUDIO_KEYS.has(key)) {
156
+ warnings.push(`Unknown config key 'audio_analysis.${key}' — it will be ignored. ` +
157
+ `Known keys: ${[...KNOWN_AUDIO_KEYS].join(', ')}`);
158
+ }
159
+ }
160
+ if (audio.fft_size !== undefined && typeof audio.fft_size !== 'number') {
161
+ warnings.push('config.audio_analysis.fft_size must be a number; ignoring it');
162
+ }
163
+ if (audio.smoothing !== undefined && typeof audio.smoothing !== 'number') {
164
+ warnings.push('config.audio_analysis.smoothing must be a number; ignoring it');
165
+ }
166
+ return {
167
+ fftSize: typeof audio.fft_size === 'number' ? audio.fft_size : undefined,
168
+ smoothing: typeof audio.smoothing === 'number' ? audio.smoothing : undefined,
169
+ };
170
+ }
171
+ /**
172
+ * Parses the `viewport` block (#514).
173
+ *
174
+ * `null` is meaningful, not missing: Playwright treats a null viewport
175
+ * as "use the real window size", which is what makes window resizes
176
+ * reach the page. A malformed value falls back to the pinned default
177
+ * rather than to null, so a typo cannot silently unpin the layout in
178
+ * headless runs.
179
+ */
180
+ function parseViewport(raw, warnings) {
181
+ if (raw === undefined)
182
+ return DEFAULT_VIEWPORT;
183
+ if (raw === null)
184
+ return null;
185
+ if (typeof raw === 'object' && !Array.isArray(raw)) {
186
+ const vp = raw;
187
+ if (typeof vp.width === 'number' &&
188
+ typeof vp.height === 'number' &&
189
+ Number.isFinite(vp.width) &&
190
+ Number.isFinite(vp.height) &&
191
+ vp.width > 0 &&
192
+ vp.height > 0) {
193
+ return { width: vp.width, height: vp.height };
194
+ }
195
+ }
196
+ warnings.push('config.viewport must be null or an object with positive numeric width and height; ' +
197
+ `using ${DEFAULT_VIEWPORT.width}x${DEFAULT_VIEWPORT.height}`);
198
+ return DEFAULT_VIEWPORT;
199
+ }
200
+ /**
201
+ * Parses the `block_resources` array (#514).
202
+ *
203
+ * An empty array is valid and means "abort nothing" — the live-coding
204
+ * recommendation is `["media"]`, keeping large audio fetches out while
205
+ * letting fonts and images render.
206
+ */
207
+ function parseBlockResources(raw, warnings) {
208
+ if (raw === undefined)
209
+ return [...DEFAULT_BLOCK_RESOURCES];
210
+ const valid = Array.isArray(raw) &&
211
+ raw.every((t) => typeof t === 'string' && KNOWN_RESOURCE_TYPES.has(t));
212
+ if (valid)
213
+ return [...raw];
214
+ warnings.push('config.block_resources must be an array of Playwright resource types ' +
215
+ `(${[...KNOWN_RESOURCE_TYPES].join(', ')}); using ` +
216
+ `[${DEFAULT_BLOCK_RESOURCES.map((t) => `'${t}'`).join(', ')}]`);
217
+ return [...DEFAULT_BLOCK_RESOURCES];
218
+ }
219
+ export function parseServerConfig(raw) {
220
+ const warnings = [];
221
+ const obj = raw !== null && typeof raw === 'object' ? raw : {};
222
+ if (raw !== undefined && (raw === null || typeof raw !== 'object' || Array.isArray(raw))) {
223
+ warnings.push('config.json must contain a JSON object; using defaults');
224
+ }
225
+ // A key nothing reads is how #227 happened. Say so rather than ignore it.
226
+ for (const key of Object.keys(obj)) {
227
+ if (!KNOWN_KEYS.has(key)) {
228
+ warnings.push(`Unknown config key '${key}' — it will be ignored. ` +
229
+ `Known keys: ${[...KNOWN_KEYS].join(', ')}`);
230
+ }
231
+ }
232
+ if (obj.headless !== undefined && typeof obj.headless !== 'boolean') {
233
+ warnings.push(`config.headless must be a boolean; using false`);
234
+ }
235
+ const audioAnalysis = parseAudioAnalysis(obj.audio_analysis, warnings);
236
+ return {
237
+ headless: obj.headless === true,
238
+ strudelUrl: parseStrudelUrl(obj.strudel_url, warnings),
239
+ patternsDir: parseDirectory(obj.patterns_dir, 'patterns_dir', DEFAULT_PATTERNS_DIR, warnings),
240
+ exportsDir: obj.exports_dir === undefined
241
+ ? undefined
242
+ : parseDirectory(obj.exports_dir, 'exports_dir', './exports', warnings),
243
+ audioAnalysis,
244
+ viewport: parseViewport(obj.viewport, warnings),
245
+ blockResources: parseBlockResources(obj.block_resources, warnings),
246
+ warnings,
247
+ };
248
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Safe lookup in a plain-object table keyed by caller-supplied text.
3
+ *
4
+ * `TABLE[key] || fallback` returns an inherited value when `key` is
5
+ * 'constructor', '__proto__', 'toString' and friends — those are
6
+ * neither falsy nor nullish, so neither `||` nor `??` fires, and the
7
+ * inherited value flows on as if it were data.
8
+ *
9
+ * That bug has been found eight separate times in this codebase, one at
10
+ * a time, with symptoms ranging from a TypeError that failed a whole
11
+ * MIDI export (#308) to `s("bd*4").slow(NaN).lpf(NaN)` written into the
12
+ * editor and reported as success (#308) to wrong musical intervals
13
+ * (#318). Finding the ninth by hand is not a plan.
14
+ *
15
+ * @param table - A plain object literal used as a lookup table
16
+ * @param key - Caller-supplied key
17
+ * @param fallback - Value to use when the key is not an own property
18
+ * @returns The table's own value for `key`, or `fallback`
19
+ * @example
20
+ * lookup(FILLS, style, FILLS.techno);
21
+ * @nist si-10 "Information input validation"
22
+ */
23
+ export function lookup(table, key, fallback) {
24
+ if (typeof key !== 'string')
25
+ return fallback;
26
+ return Object.hasOwn(table, key) ? table[key] : fallback;
27
+ }
28
+ /**
29
+ * As `lookup`, but with no fallback — returns undefined for a key the
30
+ * table does not own, so the caller can distinguish "absent" from a
31
+ * legitimately falsy value.
32
+ *
33
+ * @param table - A plain object literal used as a lookup table
34
+ * @param key - Caller-supplied key
35
+ * @returns The table's own value for `key`, or undefined
36
+ */
37
+ export function lookupOrUndefined(table, key) {
38
+ if (typeof key !== 'string')
39
+ return undefined;
40
+ return Object.hasOwn(table, key) ? table[key] : undefined;
41
+ }