@williamzujkowski/live-coding-music-mcp 4.0.0 → 4.1.1

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 +27 -16
  52. package/dist/services/__mocks__/StrudelEngine.js +0 -157
  53. package/dist/types/index.js +0 -1
@@ -0,0 +1,318 @@
1
+ /**
2
+ * AudioExportService - records live Strudel audio and writes it to disk
3
+ *
4
+ * `audio_capture` returns base64 inline in the MCP response. For a 30s
5
+ * capture that is roughly 1.5MB of response text — hundreds of thousands
6
+ * of tokens dropped into the calling agent's context, for data the agent
7
+ * cannot listen to anyway. And WebM/Opus is not what a DAW or a person
8
+ * wants at the other end (#223).
9
+ *
10
+ * This service exports instead: record a window, decode and encode it
11
+ * *in the browser*, and hand back a path.
12
+ *
13
+ * Everything that can happen in page context does. The bytes cross the
14
+ * CDP boundary once, as a base64 string, because that is the only shape
15
+ * Playwright serializes losslessly — returning a `Blob` yields `{}` on the
16
+ * Node side with no `arrayBuffer` method, which is the bug that made
17
+ * `audio_capture` silently broken since it was written.
18
+ *
19
+ * @module services/AudioExportService
20
+ * @nist si-10 "Information input validation"
21
+ * @nist ac-3 "Access enforcement"
22
+ */
23
+ import { mkdirSync, writeFileSync } from 'fs';
24
+ import { Logger } from '../utils/Logger.js';
25
+ import { resolveSafeOutputPath, resolveExportDirectory } from '../utils/SafePath.js';
26
+ /** Longest window a single export may record. */
27
+ export const MAX_EXPORT_DURATION_MS = 30_000;
28
+ /** Shortest window worth recording. */
29
+ export const MIN_EXPORT_DURATION_MS = 100;
30
+ /**
31
+ * Peak amplitude at or below which a capture counts as silent.
32
+ *
33
+ * Strudel's graph emits a low noise floor even with nothing playing, so
34
+ * an exact-zero test would never fire and every silent export would be
35
+ * reported as successful audio.
36
+ */
37
+ export const SILENCE_PEAK_THRESHOLD = 0.0005;
38
+ export class AudioExportService {
39
+ exportDir;
40
+ logger;
41
+ /**
42
+ * @param exportDir - Directory exports are confined to (default `./exports`)
43
+ */
44
+ constructor(exportDir) {
45
+ this.exportDir = resolveExportDirectory(exportDir);
46
+ this.logger = new Logger();
47
+ }
48
+ /** Directory this service writes to. */
49
+ getExportDirectory() {
50
+ return this.exportDir;
51
+ }
52
+ /**
53
+ * Records a window of live audio and exports it.
54
+ *
55
+ * @param page - Page with the Strudel audio graph, already playing
56
+ * @param options - Duration, format, filename, and output mode
57
+ * @returns Path or base64 payload, plus what was actually recorded
58
+ *
59
+ * @example
60
+ * const result = await service.exportAudio(page, { duration: 3000 });
61
+ * // -> { success: true, path: '<cwd>/exports/capture.wav', silent: false, ... }
62
+ */
63
+ async exportAudio(page, options = {}) {
64
+ const format = options.format ?? 'wav';
65
+ const output = options.output ?? 'file';
66
+ const duration = options.duration ?? 5000;
67
+ if (!Number.isFinite(duration) || duration < MIN_EXPORT_DURATION_MS || duration > MAX_EXPORT_DURATION_MS) {
68
+ return {
69
+ success: false,
70
+ bytes: 0,
71
+ duration: 0,
72
+ format,
73
+ error: `Invalid duration: ${String(options.duration)}. ` +
74
+ `Must be between ${MIN_EXPORT_DURATION_MS} and ${MAX_EXPORT_DURATION_MS} ms.`,
75
+ };
76
+ }
77
+ let payload;
78
+ try {
79
+ payload = await page.evaluate(AudioExportService.browserCapture, { durationMs: duration, wantWav: format === 'wav' });
80
+ }
81
+ catch (error) {
82
+ const message = error instanceof Error ? error.message : String(error);
83
+ return { success: false, bytes: 0, duration: 0, format, error: `Audio export failed: ${message}` };
84
+ }
85
+ if (!payload.success || payload.base64 === undefined) {
86
+ return {
87
+ success: false,
88
+ bytes: 0,
89
+ duration: 0,
90
+ format,
91
+ error: payload.error ?? 'Audio export failed for an unknown reason.',
92
+ };
93
+ }
94
+ const bytes = Buffer.from(payload.base64, 'base64');
95
+ const warnings = [];
96
+ // Report silence rather than writing a silent file and calling it a
97
+ // success — the caller has no way to hear the difference (#223).
98
+ const silent = payload.peak !== undefined && payload.peak <= SILENCE_PEAK_THRESHOLD;
99
+ if (silent) {
100
+ warnings.push('Captured audio is silent. Is a pattern playing? ' +
101
+ 'Call playback({ action: "play" }) before exporting.');
102
+ }
103
+ else if (payload.peak === undefined) {
104
+ // Not measured is not the same as not silent.
105
+ //
106
+ // A WebM capture the browser could not decode returns without a
107
+ // peak, so `silent` is false and the check above never fires. The
108
+ // tool promises it "reports silent captures instead of writing a
109
+ // silent file and claiming success" — and that promise was kept
110
+ // by staying quiet about a file it had not been able to look at
111
+ // (#437 item 2). Say so instead.
112
+ warnings.push('Captured audio could not be decoded, so it was not checked for silence. '
113
+ + 'The bytes are the recorder\'s own output and may be empty.');
114
+ }
115
+ const base = {
116
+ success: true,
117
+ bytes: bytes.length,
118
+ duration: payload.duration ?? duration,
119
+ format,
120
+ ...(payload.sampleRate !== undefined ? { sampleRate: payload.sampleRate } : {}),
121
+ ...(payload.channels !== undefined ? { channels: payload.channels } : {}),
122
+ ...(payload.peak !== undefined ? { peak: payload.peak, rms: payload.rms, silent } : {}),
123
+ ...(warnings.length > 0 ? { warnings } : {}),
124
+ };
125
+ if (output === 'base64') {
126
+ return { ...base, audio: payload.base64 };
127
+ }
128
+ try {
129
+ const target = resolveSafeOutputPath(options.filename, {
130
+ directory: this.exportDir,
131
+ extension: format === 'wav' ? '.wav' : '.webm',
132
+ defaultName: `strudel-capture-${String(Date.now())}.${format}`,
133
+ });
134
+ mkdirSync(this.exportDir, { recursive: true });
135
+ writeFileSync(target.path, bytes);
136
+ this.logger.debug(`Exported ${String(bytes.length)} bytes to ${target.path}`);
137
+ return {
138
+ ...base,
139
+ path: target.path,
140
+ ...(target.wasModified
141
+ ? { sanitizedFilename: target.filename, requestedFilename: target.requested }
142
+ : {}),
143
+ };
144
+ }
145
+ catch (error) {
146
+ const message = error instanceof Error ? error.message : String(error);
147
+ return { ...base, success: false, error: `Failed to write audio file: ${message}` };
148
+ }
149
+ }
150
+ /**
151
+ * Runs in page context: record, decode, measure, encode.
152
+ *
153
+ * Defined as a static so the function passed to `page.evaluate` is a
154
+ * plain closure over its argument with no `this` — Playwright
155
+ * serializes the source, so anything captured from the enclosing scope
156
+ * would be undefined at runtime.
157
+ *
158
+ * Returns base64 rather than a Blob or Uint8Array: a Blob does not
159
+ * survive the CDP boundary at all, and a large typed array round-trips
160
+ * as a slow object map.
161
+ */
162
+ /* istanbul ignore next -- runs in the browser; covered by npm run test:export-audio */
163
+ static browserCapture = async (args) => {
164
+ const capture = window.strudelAudioCapture;
165
+ if (!capture) {
166
+ return { success: false, error: 'Audio recorder not injected. Run init first.' };
167
+ }
168
+ if (!capture.isConnected) {
169
+ return {
170
+ success: false,
171
+ error: 'Audio recorder not connected to Strudel output. Play a pattern first.',
172
+ };
173
+ }
174
+ if (capture.isCapturing) {
175
+ return { success: false, error: 'A capture is already in progress.' };
176
+ }
177
+ const started = capture.startCapture();
178
+ if (started.success !== true) {
179
+ return { success: false, error: started.error ?? 'Failed to start capture.' };
180
+ }
181
+ await new Promise(resolve => setTimeout(resolve, args.durationMs));
182
+ const stopped = await capture.stopCapture();
183
+ if (stopped.success !== true || !stopped.blob) {
184
+ return { success: false, error: stopped.error ?? 'Capture produced no audio.' };
185
+ }
186
+ const recorded = await stopped.blob.arrayBuffer();
187
+ // Helpers live as object METHODS, not `const fn = () => {}`.
188
+ // esbuild (which tsx uses) rewrites a named inner function into
189
+ // `__name(fn, "fn")` to preserve `.name`, and `__name` does not exist
190
+ // in page context — so the whole evaluate throws
191
+ // `ReferenceError: __name is not defined` under `npm run dev`, while
192
+ // working fine from `dist/` where tsc emits it untouched. Method
193
+ // shorthand is the one form esbuild leaves alone.
194
+ const h = {
195
+ toBase64(buffer) {
196
+ const view = new Uint8Array(buffer);
197
+ let binary = '';
198
+ // Chunked: String.fromCharCode(...view) blows the stack on a
199
+ // multi-megabyte capture.
200
+ const CHUNK = 0x8000;
201
+ for (let i = 0; i < view.length; i += CHUNK) {
202
+ binary += String.fromCharCode(...view.subarray(i, i + CHUNK));
203
+ }
204
+ return btoa(binary);
205
+ },
206
+ writeAscii(view, offset, text) {
207
+ for (let i = 0; i < text.length; i++)
208
+ view.setUint8(offset + i, text.charCodeAt(i));
209
+ },
210
+ };
211
+ // Decode for BOTH formats.
212
+ //
213
+ // The webm path used to return here, before anything measured the
214
+ // signal — so `peak` was undefined, the silence check upstream
215
+ // (`payload.peak !== undefined && ...`) was false, and a silent webm
216
+ // was reported as a clean export. The tool description promises
217
+ // "Reports silent captures instead of writing a silent file and
218
+ // claiming success" with no format qualifier, and that promise held
219
+ // only for wav (#437).
220
+ //
221
+ // webm still ships the ORIGINAL Opus bytes; the decode is only to
222
+ // measure them. A decode failure is fatal for wav, which needs the
223
+ // samples, and merely means "no peak reported" for webm, which does
224
+ // not.
225
+ const AudioCtx = window.AudioContext ?? window.webkitAudioContext;
226
+ const ctx = new AudioCtx();
227
+ let decoded = null;
228
+ let decodeError = '';
229
+ try {
230
+ decoded = await ctx.decodeAudioData(recorded.slice(0));
231
+ }
232
+ catch (error) {
233
+ decodeError = String(error?.message ?? error);
234
+ }
235
+ if (decoded === null) {
236
+ await ctx.close();
237
+ if (args.wantWav) {
238
+ return { success: false, error: `Failed to decode captured audio: ${decodeError}` };
239
+ }
240
+ return { success: true, base64: h.toBase64(recorded), duration: stopped.duration };
241
+ }
242
+ const channels = decoded.numberOfChannels;
243
+ const frames = decoded.length;
244
+ const sampleRate = decoded.sampleRate;
245
+ const channelData = [];
246
+ for (let c = 0; c < channels; c++)
247
+ channelData.push(decoded.getChannelData(c));
248
+ // Measure before quantising, so the numbers describe the real signal.
249
+ let peak = 0;
250
+ let sumSquares = 0;
251
+ for (const data of channelData) {
252
+ for (let i = 0; i < data.length; i++) {
253
+ const v = Math.abs(data[i]);
254
+ if (v > peak)
255
+ peak = v;
256
+ sumSquares += data[i] * data[i];
257
+ }
258
+ }
259
+ const rms = frames > 0 ? Math.sqrt(sumSquares / (frames * channels)) : 0;
260
+ if (!args.wantWav) {
261
+ await ctx.close();
262
+ return {
263
+ success: true,
264
+ base64: h.toBase64(recorded),
265
+ // The audio's own length, not the wall clock. `stopped.duration`
266
+ // is `Date.now() - startTime`, which counts the time spent
267
+ // starting and stopping the recorder as if it were sound (#437).
268
+ duration: Math.round((frames / sampleRate) * 1000),
269
+ peak,
270
+ rms,
271
+ sampleRate,
272
+ channels,
273
+ };
274
+ }
275
+ // 16-bit PCM WAV: 44-byte RIFF header then interleaved samples.
276
+ const bytesPerSample = 2;
277
+ const blockAlign = channels * bytesPerSample;
278
+ const dataSize = frames * blockAlign;
279
+ const buffer = new ArrayBuffer(44 + dataSize);
280
+ const view = new DataView(buffer);
281
+ h.writeAscii(view, 0, 'RIFF');
282
+ view.setUint32(4, 36 + dataSize, true);
283
+ h.writeAscii(view, 8, 'WAVE');
284
+ h.writeAscii(view, 12, 'fmt ');
285
+ view.setUint32(16, 16, true); // PCM chunk size
286
+ view.setUint16(20, 1, true); // format: PCM
287
+ view.setUint16(22, channels, true);
288
+ view.setUint32(24, sampleRate, true);
289
+ view.setUint32(28, sampleRate * blockAlign, true);
290
+ view.setUint16(32, blockAlign, true);
291
+ view.setUint16(34, 16, true); // bits per sample
292
+ h.writeAscii(view, 36, 'data');
293
+ view.setUint32(40, dataSize, true);
294
+ let offset = 44;
295
+ for (let i = 0; i < frames; i++) {
296
+ for (let c = 0; c < channels; c++) {
297
+ const sample = Math.max(-1, Math.min(1, channelData[c][i]));
298
+ view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true);
299
+ offset += bytesPerSample;
300
+ }
301
+ }
302
+ await ctx.close();
303
+ return {
304
+ success: true,
305
+ base64: h.toBase64(buffer),
306
+ // The audio's own length. `stopped.duration` is
307
+ // `Date.now() - startTime`, so it counted the time spent starting
308
+ // and stopping the recorder as if it were sound — and a caller
309
+ // could compute (bytes - 44) / (sampleRate * channels * 2) from
310
+ // this very response and get a different answer (#437).
311
+ duration: Math.round((frames / sampleRate) * 1000),
312
+ sampleRate,
313
+ channels,
314
+ peak,
315
+ rms,
316
+ };
317
+ };
318
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Strudel functions the local engine cannot provide.
3
+ *
4
+ * `StrudelEngine` builds its context from @strudel/core, @strudel/mini and
5
+ * @strudel/tonal. strudel.cc loads considerably more: sample loaders that
6
+ * fetch over the network, transport controls that need a running
7
+ * scheduler, MIDI/OSC bridges, and the visualisers, which draw to a
8
+ * canvas.
9
+ *
10
+ * Without this list those all fail with errors that blame the pattern:
11
+ *
12
+ * setcpm(120) -> "references unknown identifier 'setcpm'"
13
+ * s("bd").pianoroll() -> "s(...).pianoroll is not a function"
14
+ * samples("github:..") -> "Invalid argument"
15
+ *
16
+ * `setcpm` is the one that shows why this matters — it is a core, very
17
+ * common Strudel function, and telling someone it is an unknown
18
+ * identifier reads as "you typo'd" when the truth is "this validator
19
+ * cannot see it". Local and browser validation disagreeing about the same
20
+ * pattern is bad; disagreeing *and* misattributing the cause is worse.
21
+ *
22
+ * Every name here was verified to fail locally before being listed (#232).
23
+ *
24
+ * @module services/BrowserOnlyFunctions
25
+ */
26
+ /** Functions that exist on strudel.cc but not in the local engine. */
27
+ export const BROWSER_ONLY_FUNCTIONS = new Map([
28
+ // Transport: need a running scheduler, which the local engine has no
29
+ // equivalent of — it builds patterns, it does not play them.
30
+ ['setcpm', 'transport'],
31
+ // Lowercase. It was listed as 'setCps', which Strudel does not have,
32
+ // so `setcps(170/60)` fell through to "references unknown identifier
33
+ // 'setcps'" — the reads-as-a-typo message #232 exists to remove. The
34
+ // mechanism was fine; the key was mis-cased, which is invisible on
35
+ // inspection because the entry looks present (#355).
36
+ ['setcps', 'transport'],
37
+ ['getcps', 'transport'],
38
+ ['hush', 'transport'],
39
+ // Siblings of hush and setcpm, and just as absent locally. Without
40
+ // them, panic() and getcpm() produced the "references unknown
41
+ // identifier — reads as a typo" message that #232 was filed to
42
+ // remove (#343).
43
+ ['panic', 'transport'],
44
+ ['getcpm', 'transport'],
45
+ ['all', 'transport'],
46
+ // Used by two of Strudel's own flagship tunes (flatrave, amensister),
47
+ // so anyone pasting a real pattern in hits it immediately.
48
+ ['useRNG', 'transport'],
49
+ // Loaders: fetch sample banks over the network at evaluation time.
50
+ ['samples', 'loader'],
51
+ // An instrument shortcut, not a free identifier — it fails as
52
+ // "note(...).piano is not a function" rather than as an unknown
53
+ // name, so the allowlist never saw it. findBrowserOnlyCall matches
54
+ // `.name(` as well as `name(`, so listing it is enough (#355).
55
+ ['piano', 'loader'],
56
+ // Visualisers: draw to a canvas.
57
+ ['pianoroll', 'visual'],
58
+ ['scope', 'visual'],
59
+ ['punchcard', 'visual'],
60
+ ['spiral', 'visual'],
61
+ ['wordfall', 'visual'],
62
+ ['animate', 'visual'],
63
+ ['initHydra', 'visual'],
64
+ ['H', 'visual'],
65
+ ['P5', 'visual'],
66
+ // Bridges to hardware or other processes.
67
+ ['midi', 'io'],
68
+ ['osc', 'io'],
69
+ ]);
70
+ const REASON_TEXT = {
71
+ transport: 'controls playback, which the local engine does not run',
72
+ loader: 'loads samples over the network',
73
+ visual: 'draws to a canvas',
74
+ io: 'bridges to MIDI/OSC hardware',
75
+ };
76
+ /**
77
+ * Explains that a function is real but unavailable locally.
78
+ *
79
+ * @param name - Function the pattern referenced
80
+ * @returns An explanation, or null when the name is not browser-only
81
+ */
82
+ export function explainBrowserOnly(name) {
83
+ const reason = BROWSER_ONLY_FUNCTIONS.get(name);
84
+ if (reason === undefined)
85
+ return null;
86
+ return (`'${name}' is a real Strudel function, but it ${REASON_TEXT[reason]}, ` +
87
+ 'so local validation cannot evaluate it. The pattern may be perfectly ' +
88
+ 'valid — check it in the browser with validate_pattern_runtime, or ' +
89
+ 'remove this call to validate the rest locally.');
90
+ }
91
+ /**
92
+ * Finds a browser-only function called in pattern source.
93
+ *
94
+ * Error-message matching is not enough on its own. `samples("github:...")`
95
+ * fails as "Invalid argument", because the transpiler rewrites every
96
+ * double-quoted string into a `mini()` call and a URL is not mini
97
+ * notation — so the failure never mentions `samples` at all. Scanning the
98
+ * source catches those too.
99
+ *
100
+ * @param code - Strudel pattern source
101
+ * @returns The first browser-only function called, or null
102
+ */
103
+ export function findBrowserOnlyCall(code) {
104
+ // Comments are not calls.
105
+ //
106
+ // The regex only guarded the single character before the name, so a
107
+ // mention inside a comment matched. This is consulted on the ERROR
108
+ // path, so the effect was misattribution rather than false
109
+ // rejection — but it fired ahead of the real diagnosis: an
110
+ // unterminated string with `samples("x")` in a trailing comment was
111
+ // reported as "samples loads over the network" instead of as the
112
+ // syntax error it is (#343).
113
+ const withoutComments = code
114
+ .replace(/\/\*[\s\S]*?\*\//g, ' ')
115
+ .replace(/\/\/[^\n]*/g, ' ');
116
+ for (const name of BROWSER_ONLY_FUNCTIONS.keys()) {
117
+ // Called as a function or chained as a method, not merely mentioned
118
+ // inside a string.
119
+ const called = new RegExp(`(^|[^\\w$."'])${name}\\s*\\(|\\.${name}\\s*\\(`);
120
+ if (called.test(withoutComments))
121
+ return name;
122
+ }
123
+ return null;
124
+ }
125
+ /**
126
+ * Rewrites an engine error when a browser-only function caused it.
127
+ *
128
+ * Handles both shapes the failure takes: an allowlist rejection for a
129
+ * bare call, and a "not a function" runtime error for a method call.
130
+ *
131
+ * @param message - The original error
132
+ * @returns A clearer message, or the original when nothing applies
133
+ */
134
+ export function clarifyEngineError(message) {
135
+ const patterns = [
136
+ /unknown identifier '([A-Za-z_$][\w$]*)'/,
137
+ /\.\.\.\)\.([A-Za-z_$][\w$]*) is not a function/,
138
+ /([A-Za-z_$][\w$]*) is not a function/,
139
+ ];
140
+ for (const pattern of patterns) {
141
+ const found = pattern.exec(message);
142
+ if (found === null)
143
+ continue;
144
+ const explanation = explainBrowserOnly(found[1]);
145
+ if (explanation !== null)
146
+ return explanation;
147
+ }
148
+ return message;
149
+ }