moontide-mcp 2.2.0 → 2.4.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.
package/dist/protocol.js CHANGED
@@ -3,6 +3,13 @@
3
3
  import { z } from 'zod';
4
4
  /** How many of the newest versions the History view loads first. */
5
5
  export const HISTORY_WINDOW_SIZE = 100;
6
+ /**
7
+ * Why the memory guard refused a message: not readable as bytes, longer than it reads, a system exclusive message that
8
+ * does not end, not a Roland data set for the instrument's model, an instrument whose temporary areas are not known,
9
+ * an address outside the temporary scene and tones, a wrong checksum, or an NRPN message that is not one of the part offsets of
10
+ * the NRPN family of the instrument. Worded in src/midi-state.ts.
11
+ */
12
+ export const MEMORY_GUARD_REASONS = ['not-bytes', 'too-long', 'incomplete', 'not-data-set', 'unknown-areas', 'outside', 'checksum', 'nrpn'];
6
13
  // Every string in a midi-state is at most MAX_MIDI_NAME_LENGTH characters (the page cuts longer
7
14
  // names with an ellipsis, the server rejects them and never trims); the arrays and the failure
8
15
  // name are bounded too.
@@ -16,6 +23,8 @@ export const MAX_MIDI_EVENTS = 20;
16
23
  // message. page/midi-rig.js mirrors the limits below (RIG_LIMITS) and the message shape
17
24
  // by hand; test/midi-rig.test.js checks both sides.
18
25
  export const MAX_RIG_INSTRUMENTS = 16;
26
+ // The most MIDI input ports the setup file may name (its inputs list); mirrored by RIG_LIMITS.inputs in page/midi-rig.js.
27
+ export const MAX_RIG_INPUTS = 4;
19
28
  export const MAX_RIG_SOUNDS = 512;
20
29
  // The most controls a model may declare: the model schema's own `.max(32)` of `controls` in
21
30
  // src/instruments.ts; mirrored by RIG_LIMITS.controls in page/midi-rig.js.
@@ -30,6 +39,12 @@ export const MAX_RIG_PROBLEMS = 20;
30
39
  export const MAX_RIG_DRUM_MAPS = 16;
31
40
  export const MAX_RIG_DRUM_NOTES = 128;
32
41
  export const MAX_RIG_DRUM_NAME = 40;
42
+ // The most setting rows one level (tone, part or scene) of a model may describe, and the most engines a model may
43
+ // declare; mirrored by RIG_LIMITS.settings and RIG_LIMITS.engines in page/midi-rig.js. The page cannot import the
44
+ // vocabulary, so this is a fixed number; test/vocabulary.test.js checks that the whole vocabulary fits in it. It is 512
45
+ // because the MT-32 map holds about 250 tone settings, and a settings event may name every one of them.
46
+ export const MAX_RIG_SETTINGS = 512;
47
+ export const MAX_RIG_ENGINES = 16;
33
48
  /**
34
49
  * The shortest and longest phrase, in cycles, that set_code accepts with at: 'phrase'. page/change-timing.js
35
50
  * mirrors both by hand (test/protocol.test.js checks both sides).
@@ -82,6 +97,24 @@ export const MAX_ASK_ID_LENGTH = 64;
82
97
  // The longest sentence Claude can close a page request with. The page shows it with an ellipsis in a
83
98
  // row and whole in the detail.
84
99
  export const MAX_ANSWER_TEXT_LENGTH = 200;
100
+ // How the notes of a motif sent from the Scratchpad got on its roll, in the order the request lists them; `mixed` is more than one way.
101
+ // page/scratch-model.js mirrors it by hand (test/ask.test.js checks both sides).
102
+ export const MOTIF_SOURCES = ['played', 'drawn', 'tapped', 'mixed'];
103
+ // The ids of the five grids of the Scratchpad, in the page's order. page/scratch-model.js mirrors them by hand
104
+ // (test/ask.test.js checks both sides).
105
+ export const MOTIF_GRIDS = ['quarter', 'eighth', 'sixteenth', 'eighth-triplet', 'sixteenth-triplet'];
106
+ // The fewest and the most cycles one motif has. page/scratch-model.js mirrors them by hand (test/ask.test.js checks both sides).
107
+ export const MIN_MOTIF_CYCLES = 1;
108
+ export const MAX_MOTIF_CYCLES = 8;
109
+ // The most characters the phrase of a motif has. A bound below MAX_REQUEST_LINE_BYTES so the schema is bounded; the line's own
110
+ // bound stays the real limit, and a phrase is refused whole and never cut. page/ask.js mirrors it by hand (test/ask.test.js checks
111
+ // both sides).
112
+ export const MAX_MOTIF_PHRASE_LENGTH = 8000;
113
+ // The lane of a phrase: note("...").velocity("..."), with only the characters of the phrase inside the quotes.
114
+ const MOTIF_PHRASE_LANE = 'note\\("[a-g0-9#.~_\\[\\],/ <>]*"\\)\\.velocity\\("[a-g0-9#.~_\\[\\],/ <>]*"\\)';
115
+ // The shape of a phrase: one lane, or stack(...) of two or more. Nothing but notes and velocities can travel in the phrase field.
116
+ // page/scratch-model.js's PHRASE_SHAPE is the same pattern, written by hand (test/ask.test.js checks both sides).
117
+ export const MOTIF_PHRASE_SHAPE = new RegExp(`^(${MOTIF_PHRASE_LANE}|stack\\(${MOTIF_PHRASE_LANE}(, ${MOTIF_PHRASE_LANE})+\\))$`);
85
118
  // The kinds of thing a right-click can point at. The Jam view is
86
119
  // the source of 'note' (a pitch class with its role in the scale) and 'warning' (a chord that leaves it).
87
120
  export const ASK_CONTEXT_KINDS = [
@@ -104,6 +137,7 @@ export const ASK_CONTEXT_KINDS = [
104
137
  'cell',
105
138
  'group',
106
139
  'range',
140
+ 'motif',
107
141
  ];
108
142
  // The kinds of gesture a request can carry: what the user did to a note of the Timeline that the page could not
109
143
  // apply itself. page/ask.js mirrors the list by hand (test/ask.test.js checks both sides).
@@ -113,6 +147,12 @@ export const MAX_GESTURE_SEMITONES = 127;
113
147
  const audioStateSchema = z.enum(['playing', 'waiting-for-click', 'stopped']);
114
148
  const midiName = z.string().max(MAX_MIDI_NAME_LENGTH);
115
149
  const midiChannel = z.number().int().min(1).max(16);
150
+ // The names of the settings an event is about: a setting name is a lowercase letter, then letters and digits
151
+ // (page/midi-rig.js reads the same pattern), and a level holds at most MAX_RIG_SETTINGS of them.
152
+ const settingNames = z
153
+ .array(z.string().regex(/^[a-z][A-Za-z0-9]{0,39}$/))
154
+ .min(1)
155
+ .max(MAX_RIG_SETTINGS);
116
156
  // A longer string is rejected, never trimmed (the page cuts before it sends).
117
157
  const askString = z.string().max(MAX_ASK_CONTEXT_LENGTH);
118
158
  const askName = askString.min(1);
@@ -168,6 +208,18 @@ export const askGestureSchema = z.discriminatedUnion('kind', [
168
208
  more: z.number().int().nonnegative(),
169
209
  }),
170
210
  ]);
211
+ // The keys of a motif. z.object drops a key it does not name, so test/ask.test.js pins these keys equal to the ones page/scratch-model.js's
212
+ // motifRequest can give. A cycle, a count or a phrase out of bounds is rejected, never trimmed, and a phrase that is not in the phrase form
213
+ // is rejected whole.
214
+ export const askMotifShape = {
215
+ source: z.enum(MOTIF_SOURCES),
216
+ cycle: z.number().int().nonnegative().optional(),
217
+ cycles: z.number().int().min(MIN_MOTIF_CYCLES).max(MAX_MOTIF_CYCLES),
218
+ grid: z.enum(MOTIF_GRIDS),
219
+ notes: z.number().int().min(1),
220
+ phrase: z.string().min(1).max(MAX_MOTIF_PHRASE_LENGTH).regex(MOTIF_PHRASE_SHAPE),
221
+ };
222
+ const askMotifSchema = z.object(askMotifShape);
171
223
  const askContextSchema = z.discriminatedUnion('kind', [
172
224
  z.object({ kind: z.literal('session') }),
173
225
  z.object({ kind: z.literal('version'), id: z.number().int().positive(), label: askName.optional() }),
@@ -246,6 +298,7 @@ const askContextSchema = z.discriminatedUnion('kind', [
246
298
  message: 'outside notes come only with a fit of does-not-fit',
247
299
  }),
248
300
  z.object({ kind: z.literal('group'), name: askName, parent: askName.optional() }),
301
+ z.object({ kind: z.literal('motif') }),
249
302
  ]);
250
303
  /** A structure note holds at most this many lines, each at most this many characters. */
251
304
  export const MAX_STRUCTURE_NOTE_LINES = 3;
@@ -382,9 +435,10 @@ const pageMessageSchema = z.union([
382
435
  context: askContextSchema,
383
436
  chip: z.string().min(1).max(MAX_ASK_CONTEXT_LENGTH),
384
437
  current: z.number().int().positive().nullable(),
385
- // z.object drops a key it does not name, so the moment and the gesture must be named here to reach Claude.
438
+ // z.object drops a key it does not name, so the moment, the gesture and the motif must be named here to reach Claude.
386
439
  moment: askMomentSchema.optional(),
387
440
  gesture: askGestureSchema.optional(),
441
+ motif: askMotifSchema.optional(),
388
442
  })
389
443
  // A gesture about one note of a lane rides only on a lane context that carries a moment; the range gesture and the range
390
444
  // context come together and carry no moment (the notes of the range are in the gesture).
@@ -393,7 +447,11 @@ const pageMessageSchema = z.union([
393
447
  if (isRange || ask.context.kind === 'range')
394
448
  return isRange && ask.context.kind === 'range' && ask.moment === undefined;
395
449
  return ask.gesture === undefined || (ask.context.kind === 'lane' && ask.moment !== undefined);
396
- }, { message: 'a gesture needs a lane context and a moment, and a range gesture goes only with a range context' }),
450
+ }, { message: 'a gesture needs a lane context and a moment, and a range gesture goes only with a range context' })
451
+ // A motif goes only with the motif context and the motif context needs one; a motif request carries no moment and no gesture.
452
+ .refine((ask) => (ask.motif !== undefined) === (ask.context.kind === 'motif') && (ask.motif === undefined || (ask.moment === undefined && ask.gesture === undefined)), {
453
+ message: 'a motif goes only with a motif context, which needs it, and carries no moment and no gesture',
454
+ }),
397
455
  z.object({
398
456
  type: z.literal('midi-state'),
399
457
  access: z.enum(['idle', 'pending', 'enabled', 'denied', 'unsupported', 'failed']),
@@ -421,9 +479,28 @@ const pageMessageSchema = z.union([
421
479
  z.object({
422
480
  kind: z.literal('send-failed'),
423
481
  instrument: midiName,
424
- what: z.enum(['reset', 'setup']),
482
+ what: z.enum(['reset', 'setup', 'settings']),
425
483
  failure: z.string().max(MAX_MIDI_FAILURE_LENGTH),
426
484
  }),
485
+ z.object({ kind: z.literal('settings-scene'), instrument: midiName, names: settingNames }),
486
+ z
487
+ .object({
488
+ kind: z.literal('settings-left'),
489
+ instrument: midiName,
490
+ level: z.enum(['tone', 'part', 'scene']),
491
+ channel: midiChannel.nullable(),
492
+ names: settingNames,
493
+ })
494
+ // A tone or part setting belongs to a channel; a scene setting reaches every part and has none.
495
+ .refine((event) => (event.level === 'scene') === (event.channel === null), {
496
+ message: 'a scene setting has no channel, a tone or part setting has one',
497
+ }),
498
+ z.object({
499
+ kind: z.literal('sysex-refused'),
500
+ instrument: midiName,
501
+ source: z.enum(['code', 'settings', 'reset']),
502
+ reason: z.enum(MEMORY_GUARD_REASONS),
503
+ }),
427
504
  z.object({ kind: z.literal('clock-dropped') }),
428
505
  ]))
429
506
  .max(MAX_MIDI_EVENTS)
package/dist/reference.js CHANGED
@@ -65,6 +65,18 @@ export const REFERENCE_TOPICS = [
65
65
  title: 'MIDI output',
66
66
  summary: 'sending a track to a MIDI port with .midi(): port names, channels, notes, control and program changes, the MIDI clock Moontide sends, and what Stop sends',
67
67
  },
68
+ {
69
+ id: 'settings',
70
+ file: 'settings.md',
71
+ title: 'Synth settings',
72
+ summary: 'the tone, part and scene settings of a MIDI instrument written in .midi(): what each level reaches, the setting names, what is refused, when they are sent and what undoes them',
73
+ },
74
+ {
75
+ id: 'motif',
76
+ file: 'motif.md',
77
+ title: 'Motif requests',
78
+ summary: 'how to read a motif sent from the Scratchpad: its fields, the moment, the grid, the phrase form, the velocities, the source words, what the words can ask and what the page did',
79
+ },
68
80
  ];
69
81
  export const REFERENCE_TOPIC_IDS = REFERENCE_TOPICS.map((topic) => topic.id);
70
82
  /** Module-relative, not cwd-relative (works wherever npm installs the package). */
@@ -0,0 +1,420 @@
1
+ // The settings vocabulary: the names code uses to set a tone, a part or a scene, and the optional
2
+ // `settings` description of an instrument model that maps those names to the messages the instrument
3
+ // takes. The names say nothing about how a setting is sent: a model maps the same name to a Roland data
4
+ // set, a control change or an NRPN, and the code does not change. A wrong description is a list of
5
+ // problems (a field and a reason), never an exception, so a hand edit in progress cannot crash the server.
6
+ //
7
+ // Nothing here sends a message: the page receives the names, the ranges and the message of each setting
8
+ // (settingsMessage) and builds the bytes itself, so the model file stays the one place that says where a setting goes.
9
+ import { z } from 'zod';
10
+ import { MAX_RIG_ENGINES, MAX_RIG_SETTINGS } from './protocol.js';
11
+ import { CONTROL_CHARS, dataByteSchema, idSchema } from './rig-util.js';
12
+ export const SETTING_LEVELS = ['tone', 'part', 'scene'];
13
+ // The tone names of one oscillator, the filter, the two envelopes, the LFO and the FM engine's global
14
+ // settings, then the names of the engines whose settings are their own (the number of a partial sits inside
15
+ // the name); the six operators follow, built from one list so their names stay in step.
16
+ const TONE_NAMES = [
17
+ 'osc1Wave', 'osc1Feet', 'osc1Tune', 'osc1Level',
18
+ 'osc2Wave', 'osc2Feet', 'osc2Tune', 'osc2Level',
19
+ 'filterType', 'filterCutoff', 'filterResonance', 'filterEnvDepth',
20
+ 'env1Attack', 'env1Decay', 'env1Sustain', 'env1Release',
21
+ 'env2Attack', 'env2Decay', 'env2Sustain', 'env2Release',
22
+ 'lfoRate', 'lfoOscDepth', 'lfoFilterDepth',
23
+ 'algorithm', 'feedback',
24
+ 'partial1Cutoff', 'toneCoarseTune',
25
+ 'partialMute', 'partial1Level',
26
+ ];
27
+ // The part names that are offsets from the instrument's normal sound (an NRPN of the GS family): the lowest value moves the
28
+ // sound down, it does not close or silence anything.
29
+ const PART_OFFSET_NAMES = [
30
+ 'vibratoRateOffset', 'vibratoDepthOffset', 'vibratoDelayOffset',
31
+ 'cutoffOffset', 'resonanceOffset',
32
+ 'attackOffset', 'decayOffset', 'releaseOffset',
33
+ ];
34
+ const OPERATOR_SETTINGS = ['Level', 'Ratio', 'Detune', 'Attack', 'Decay', 'Sustain', 'Release'];
35
+ const OPERATOR_COUNT = 6;
36
+ const operatorNames = [];
37
+ for (let operator = 1; operator <= OPERATOR_COUNT; operator++) {
38
+ for (const setting of OPERATOR_SETTINGS)
39
+ operatorNames.push(`op${operator}${setting}`);
40
+ }
41
+ // The settings of one partial of a timbre with four partials, in the order of the instrument's own table (pitch, wave,
42
+ // pitch envelope, pitch LFO, filter, filter envelope, volume, volume envelope); the four partials are built from this one
43
+ // list so their names stay in step. Two names of partial 1 already stand in TONE_NAMES and are not added twice.
44
+ const PARTIAL_SETTINGS = [
45
+ 'CoarseTune', 'FineTune', 'PitchKeyFollow', 'BendSwitch', 'Wave', 'PcmWave', 'PulseWidth', 'PulseWidthVelocity',
46
+ 'PitchEnvDepth', 'PitchEnvVelocity', 'PitchEnvTimeKeyFollow',
47
+ 'PitchEnvTime1', 'PitchEnvTime2', 'PitchEnvTime3', 'PitchEnvTime4',
48
+ 'PitchEnvLevel0', 'PitchEnvLevel1', 'PitchEnvLevel2', 'PitchEnvSustain', 'PitchEnvEnd',
49
+ 'PitchLfoRate', 'PitchLfoDepth', 'PitchLfoModSens',
50
+ 'Cutoff', 'Resonance', 'CutoffKeyFollow', 'FilterBiasPoint', 'FilterBiasLevel',
51
+ 'FilterEnvDepth', 'FilterEnvVelocity', 'FilterEnvDepthKeyFollow', 'FilterEnvTimeKeyFollow',
52
+ 'FilterEnvTime1', 'FilterEnvTime2', 'FilterEnvTime3', 'FilterEnvTime4', 'FilterEnvTime5',
53
+ 'FilterEnvLevel1', 'FilterEnvLevel2', 'FilterEnvLevel3', 'FilterEnvSustain',
54
+ 'Level', 'LevelVelocity', 'AmpBiasPoint1', 'AmpBiasLevel1', 'AmpBiasPoint2', 'AmpBiasLevel2',
55
+ 'AmpEnvTimeKeyFollow', 'AmpEnvTimeVelocity',
56
+ 'AmpEnvTime1', 'AmpEnvTime2', 'AmpEnvTime3', 'AmpEnvTime4', 'AmpEnvTime5',
57
+ 'AmpEnvLevel1', 'AmpEnvLevel2', 'AmpEnvLevel3', 'AmpEnvSustain',
58
+ ];
59
+ const PARTIAL_COUNT = 4;
60
+ // The tone names of a timbre with four partials after the earlier names: how two partials are joined, the envelope mode,
61
+ // every partial, then the settings of the patch that a program change loads again.
62
+ const timbreNames = ['structure12', 'structure34', 'envMode'];
63
+ for (let partial = 1; partial <= PARTIAL_COUNT; partial++) {
64
+ for (const setting of PARTIAL_SETTINGS) {
65
+ const name = `partial${partial}${setting}`;
66
+ if (!TONE_NAMES.includes(name))
67
+ timbreNames.push(name);
68
+ }
69
+ }
70
+ timbreNames.push('keyShift', 'toneFineTune', 'bendRange', 'assignMode', 'toneReverbSwitch');
71
+ /** The setting names of each level, in the order a list shows them. Room for the FM engine is already here. */
72
+ export const SETTING_VOCABULARY = Object.freeze({
73
+ tone: Object.freeze([...TONE_NAMES, ...operatorNames, ...timbreNames]),
74
+ part: Object.freeze(['level', 'pan', 'chorusSend', 'reverbSend', 'delaySend', ...PART_OFFSET_NAMES]),
75
+ scene: Object.freeze([
76
+ 'level',
77
+ 'chorusSwitch', 'chorusType', 'chorusLevel',
78
+ 'delaySwitch', 'delayType', 'delayLevel',
79
+ 'reverbSwitch', 'reverbType', 'reverbLevel',
80
+ 'driveAmount', 'driveSwitch',
81
+ 'masterTune', 'reverbTime',
82
+ ]),
83
+ });
84
+ const settingValueSchema = z.number().int().min(0).max(65535);
85
+ // The message a setting becomes: a Roland data set at an address, an NRPN or a control change.
86
+ const messageSchema = z.discriminatedUnion('kind', [
87
+ z.strictObject({ kind: z.literal('roland-dt1'), address: z.string(), size: z.union([z.literal(1), z.literal(2), z.literal(4)]) }),
88
+ z.strictObject({ kind: z.literal('nrpn'), msb: dataByteSchema, lsb: dataByteSchema }),
89
+ z.strictObject({ kind: z.literal('cc'), cc: dataByteSchema }),
90
+ ]);
91
+ const rowSchema = z.strictObject({
92
+ name: z.string().min(1).max(40),
93
+ min: settingValueSchema,
94
+ max: settingValueSchema,
95
+ // Tone rows only, and required when the model declares engines: the engines that have this setting.
96
+ engines: z.array(idSchema).min(1).max(MAX_RIG_ENGINES).optional(),
97
+ message: messageSchema,
98
+ });
99
+ /** The optional `settings` field of a model; every key is optional, and a model without it stays valid. */
100
+ export const settingsSchema = z.strictObject({
101
+ // The engines a tone can belong to (the sound catalog places a sound on one of them).
102
+ engines: z.array(z.strictObject({ id: idSchema, name: z.string().min(1).max(40) })).min(1).max(MAX_RIG_ENGINES).optional(),
103
+ // The model ID bytes of the Roland data sets, as hex text.
104
+ rolandModel: z.string().optional(),
105
+ // The NRPN family of the instrument's NRPN rows. Moontide knows one: 'gs', the part offsets of the Roland GS family.
106
+ nrpnFamily: z.literal('gs').optional(),
107
+ tone: z.array(rowSchema).max(MAX_RIG_SETTINGS).optional(),
108
+ part: z.array(rowSchema).max(MAX_RIG_SETTINGS).optional(),
109
+ scene: z.array(rowSchema).max(MAX_RIG_SETTINGS).optional(),
110
+ });
111
+ /**
112
+ * The largest value a message carries: a control change 7 bits, an NRPN 7 bits (the page sends the data entry MSB only),
113
+ * and a Roland data set cuts a value into 4-bit pieces, one per byte of its size.
114
+ */
115
+ export function messageMax(message) {
116
+ if (message.kind === 'cc')
117
+ return 127;
118
+ if (message.kind === 'nrpn')
119
+ return 127;
120
+ if (message.size === 1)
121
+ return 127;
122
+ if (message.size === 2)
123
+ return 255;
124
+ return 65535;
125
+ }
126
+ function editDistance(a, b) {
127
+ let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
128
+ for (let row = 1; row <= a.length; row++) {
129
+ const current = [row];
130
+ for (let column = 1; column <= b.length; column++) {
131
+ const same = a[row - 1] === b[column - 1] ? 0 : 1;
132
+ current[column] = Math.min(previous[column] + 1, current[column - 1] + 1, previous[column - 1] + same);
133
+ }
134
+ previous = current;
135
+ }
136
+ return previous[b.length];
137
+ }
138
+ /** The vocabulary name of a level closest to `name`, when it is at most 3 edits away. */
139
+ function closestName(level, name) {
140
+ let best = null;
141
+ let bestDistance = 4;
142
+ for (const candidate of SETTING_VOCABULARY[level]) {
143
+ const distance = editDistance(name, candidate);
144
+ if (distance < bestDistance) {
145
+ best = candidate;
146
+ bestDistance = distance;
147
+ }
148
+ }
149
+ return best;
150
+ }
151
+ const hex2 = (byte) => byte.toString(16).toUpperCase().padStart(2, '0');
152
+ /**
153
+ * The data range of each NRPN family the page knows (KNOWN_NRPNS in page/midi-settings.js; a test pins both): a row of the
154
+ * family whose range leaves it would be refused at every use, so it is refused when the model loads.
155
+ */
156
+ export const NRPN_FAMILY_RANGES = Object.freeze({
157
+ gs: Object.freeze({ min: 14, max: 114 }),
158
+ });
159
+ /**
160
+ * The Roland model IDs whose temporary areas the page knows, with the length of the addresses their data sets take
161
+ * (TEMPORARY_AREAS in page/midi-settings.js; a test pins both). A row of another length would be refused at every use.
162
+ */
163
+ export const ROLAND_ADDRESS_SIZES = Object.freeze([
164
+ Object.freeze({ model: Object.freeze([0x00, 0x00, 0x00, 0x65]), size: 4 }),
165
+ Object.freeze({ model: Object.freeze([0x16]), size: 3 }),
166
+ ]);
167
+ /**
168
+ * Why hex text is not 1 to 4 two-digit bytes of at most 7F (3 or 4 with `isAddress`: a data set takes a three-byte or a
169
+ * four-byte address, and a shorter one would make the instrument read data bytes as address bytes; which of the two the
170
+ * instrument takes is checked when the bytes are built), or null when it is.
171
+ */
172
+ function hexProblem(text, isAddress = false) {
173
+ const parts = text.trim().split(/\s+/).filter((part) => part !== '');
174
+ if (parts.length === 0)
175
+ return 'must not be empty';
176
+ for (const part of parts) {
177
+ if (!/^[0-9a-fA-F]{2}$/.test(part))
178
+ return `"${part}" is not a two-digit hex byte`;
179
+ }
180
+ if (isAddress && (parts.length < 3 || parts.length > 4))
181
+ return 'must be 3 or 4 bytes';
182
+ if (parts.length > 4)
183
+ return 'must be 1 to 4 bytes';
184
+ for (let index = 0; index < parts.length; index++) {
185
+ const byte = Number.parseInt(parts[index], 16);
186
+ if (byte > 0x7f)
187
+ return `byte ${index} (${hex2(byte)}) is above 7F`;
188
+ }
189
+ return null;
190
+ }
191
+ /** The engine rules: ids and names unique, a tone row names declared engines when the model declares some, no other row names any. */
192
+ function checkEngines(settings, add) {
193
+ const declared = settings.engines;
194
+ const first = new Map();
195
+ const firstNamed = new Map();
196
+ (declared ?? []).forEach((engine, index) => {
197
+ if (CONTROL_CHARS.test(engine.name))
198
+ add(`engines[${index}].name`, 'contains a control character');
199
+ const earlier = first.get(engine.id);
200
+ if (earlier === undefined)
201
+ first.set(engine.id, index);
202
+ else
203
+ add(`engines[${index}].id`, `same id as engines[${earlier}]`);
204
+ const sameName = firstNamed.get(engine.name);
205
+ if (sameName === undefined)
206
+ firstNamed.set(engine.name, index);
207
+ else
208
+ add(`engines[${index}].name`, `same name as engines[${sameName}]`);
209
+ });
210
+ (settings.tone ?? []).forEach((row, index) => {
211
+ const at = `tone[${index}].engines`;
212
+ if (declared === undefined) {
213
+ if (row.engines !== undefined)
214
+ add(at, 'the model declares no engines');
215
+ }
216
+ else if (row.engines === undefined) {
217
+ add(at, 'required: the model declares engines');
218
+ }
219
+ else {
220
+ row.engines.forEach((id, position) => {
221
+ if (!first.has(id))
222
+ add(`${at}[${position}]`, `unknown engine "${id}", engines: ${declared.map((engine) => engine.id).join(', ')}`);
223
+ });
224
+ }
225
+ });
226
+ for (const level of ['part', 'scene']) {
227
+ (settings[level] ?? []).forEach((row, index) => {
228
+ if (row.engines !== undefined)
229
+ add(`${level}[${index}].engines`, `${level} settings reach every engine`);
230
+ });
231
+ }
232
+ }
233
+ /**
234
+ * Why a row repeats the name of earlier rows of its level, or null when it may. A tone name may have one row per engine
235
+ * (the same setting sits at another address on each engine), so a tone row may repeat a name only when the model declares
236
+ * engines and no engine appears in two rows of the name; every other repeat is refused.
237
+ */
238
+ function repeatProblem(level, settings, row, earlier) {
239
+ const rows = settings[level] ?? [];
240
+ for (const index of earlier) {
241
+ const other = rows[index];
242
+ const perEngine = level === 'tone' && settings.engines !== undefined && row.engines !== undefined && other.engines !== undefined;
243
+ if (!perEngine)
244
+ return `same name as ${level}[${index}]`;
245
+ const shared = row.engines.find((id) => other.engines.includes(id));
246
+ if (shared !== undefined)
247
+ return `same name as ${level}[${index}], and both reach engine "${shared}"`;
248
+ }
249
+ return null;
250
+ }
251
+ /**
252
+ * The checks that need more than one field of a settings description; the shape is already valid.
253
+ * Paths are relative to the settings object (`tone[0].name`); the empty path is the object itself.
254
+ */
255
+ export function settingsProblems(settings) {
256
+ const problems = [];
257
+ if (settings === undefined)
258
+ return problems;
259
+ const add = (field, reason) => {
260
+ problems.push({ field, reason });
261
+ };
262
+ if (SETTING_LEVELS.every((level) => (settings[level] ?? []).length === 0))
263
+ add('', 'describes no setting');
264
+ let needsRolandModel = false;
265
+ let needsNrpnFamily = false;
266
+ // The rows with a well-formed data set address, checked against the address length of the model ID below.
267
+ const addressed = [];
268
+ for (const level of SETTING_LEVELS) {
269
+ // The rows seen so far by name: a tone name may repeat once per engine (see repeatProblem), no other name may.
270
+ const seen = new Map();
271
+ (settings[level] ?? []).forEach((row, index) => {
272
+ const at = `${level}[${index}]`;
273
+ if (!SETTING_VOCABULARY[level].includes(row.name)) {
274
+ const other = SETTING_LEVELS.find((candidate) => candidate !== level && SETTING_VOCABULARY[candidate].includes(row.name));
275
+ const closest = closestName(level, row.name);
276
+ add(`${at}.name`, other !== undefined
277
+ ? `"${row.name}" is a ${other} setting name, not a ${level} one`
278
+ : `"${row.name}" is not a ${level} setting name${closest === null ? '' : ` (closest: ${closest})`}`);
279
+ }
280
+ else {
281
+ const earlier = seen.get(row.name) ?? [];
282
+ const problem = repeatProblem(level, settings, row, earlier);
283
+ if (problem !== null)
284
+ add(`${at}.name`, problem);
285
+ seen.set(row.name, [...earlier, index]);
286
+ }
287
+ const carried = messageMax(row.message);
288
+ if (row.max < row.min)
289
+ add(`${at}.max`, 'must not be below min');
290
+ else if (row.max > carried)
291
+ add(`${at}.max`, `${row.max} is more than this message carries (${carried})`);
292
+ if (row.message.kind === 'roland-dt1') {
293
+ needsRolandModel = true;
294
+ const address = hexProblem(row.message.address, true);
295
+ if (address !== null)
296
+ add(`${at}.message.address`, address);
297
+ else
298
+ addressed.push({ at, length: row.message.address.trim().split(/\s+/).length });
299
+ }
300
+ if (row.message.kind === 'nrpn') {
301
+ needsNrpnFamily = true;
302
+ if (level === 'scene')
303
+ add(`${at}.message`, 'an NRPN reaches one channel, so it is not a scene setting');
304
+ if (settings.nrpnFamily !== undefined) {
305
+ const family = NRPN_FAMILY_RANGES[settings.nrpnFamily];
306
+ if (row.min < family.min)
307
+ add(`${at}.min`, `${row.min} is below the lowest value of the ${settings.nrpnFamily} family (${family.min})`);
308
+ if (row.max > family.max)
309
+ add(`${at}.max`, `${row.max} is above the highest value of the ${settings.nrpnFamily} family (${family.max})`);
310
+ }
311
+ }
312
+ });
313
+ }
314
+ checkEngines(settings, add);
315
+ if (settings.rolandModel === undefined) {
316
+ if (needsRolandModel)
317
+ add('rolandModel', 'required: a roland-dt1 setting needs the model ID');
318
+ }
319
+ else {
320
+ const bad = hexProblem(settings.rolandModel);
321
+ if (bad !== null)
322
+ add('rolandModel', bad);
323
+ else {
324
+ // A model ID whose temporary areas Moontide knows takes one address length; any other takes 3 or 4 bytes.
325
+ const model = hexToBytes(settings.rolandModel);
326
+ const known = ROLAND_ADDRESS_SIZES.find((entry) => entry.model.length === model.length && entry.model.every((byte, index) => byte === model[index]));
327
+ if (known !== undefined) {
328
+ const modelText = model.map(hex2).join(' ');
329
+ for (const row of addressed) {
330
+ if (row.length !== known.size)
331
+ add(`${row.at}.message.address`, `must be ${known.size} bytes: the data sets of model ID ${modelText} take ${known.size}-byte addresses`);
332
+ }
333
+ }
334
+ }
335
+ }
336
+ if (needsNrpnFamily && settings.nrpnFamily === undefined)
337
+ add('nrpnFamily', 'required: an nrpn setting needs the NRPN family');
338
+ return problems;
339
+ }
340
+ /** Hex text as bytes. The description was checked when it was read, so a bad byte here is a fault: it throws. */
341
+ function hexToBytes(text) {
342
+ return text
343
+ .trim()
344
+ .split(/\s+/)
345
+ .map((part) => {
346
+ if (!/^[0-9a-fA-F]{2}$/.test(part))
347
+ throw new Error(`Moontide: "${part}" is not a two-digit hex byte.`);
348
+ return Number.parseInt(part, 16);
349
+ });
350
+ }
351
+ /** The message of one row for the page, copied key by key so nothing else of the model rides along. */
352
+ function rowMessage(message) {
353
+ if (message.kind === 'roland-dt1')
354
+ return { kind: 'roland-dt1', address: hexToBytes(message.address), size: message.size };
355
+ if (message.kind === 'nrpn')
356
+ return { kind: 'nrpn', msb: message.msb, lsb: message.lsb };
357
+ return { kind: 'cc', cc: message.cc };
358
+ }
359
+ /**
360
+ * The settings as the page gets them: names, ranges and the message of each row, copied key by key so no other
361
+ * key of the model rides along. Null when the model describes no settings.
362
+ */
363
+ export function settingsMessage(settings) {
364
+ if (settings === undefined)
365
+ return null;
366
+ const declared = settings.engines !== undefined;
367
+ const row = (entry, withEngines) => ({
368
+ name: entry.name,
369
+ min: entry.min,
370
+ max: entry.max,
371
+ ...(withEngines && declared && entry.engines !== undefined ? { engines: [...entry.engines] } : {}),
372
+ message: rowMessage(entry.message),
373
+ });
374
+ return {
375
+ engines: (settings.engines ?? []).map((engine) => ({ id: engine.id, name: engine.name })),
376
+ rolandModel: settings.rolandModel === undefined ? null : hexToBytes(settings.rolandModel),
377
+ nrpnFamily: settings.nrpnFamily ?? null,
378
+ tone: (settings.tone ?? []).map((entry) => row(entry, true)),
379
+ part: (settings.part ?? []).map((entry) => row(entry, false)),
380
+ scene: (settings.scene ?? []).map((entry) => row(entry, false)),
381
+ };
382
+ }
383
+ const plural = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`;
384
+ function messageText(message) {
385
+ if (message.kind === 'cc')
386
+ return `control change ${message.cc}`;
387
+ if (message.kind === 'nrpn')
388
+ return `NRPN ${message.msb} ${message.lsb}`;
389
+ return `Roland data set at ${hexBytesText(message.address)} over ${plural(message.size, 'byte')}`;
390
+ }
391
+ /** Hex text as two-digit upper-case bytes joined by single spaces. */
392
+ function hexBytesText(text) {
393
+ return text
394
+ .trim()
395
+ .split(/\s+/)
396
+ .map((part) => part.toUpperCase())
397
+ .join(' ');
398
+ }
399
+ /**
400
+ * The save summary lines of a settings description, one fact per line, so nothing that reaches hardware
401
+ * later is hidden behind the user's yes: the engines, the Roland model ID, then every setting row in level order.
402
+ */
403
+ export function settingsSummary(settings) {
404
+ const lines = [];
405
+ if (settings.engines !== undefined) {
406
+ lines.push(`Settings engines: ${settings.engines.map((engine) => `${engine.id} (${engine.name})`).join(', ')}.`);
407
+ }
408
+ if (settings.rolandModel !== undefined)
409
+ lines.push(`Settings Roland model ID: ${hexBytesText(settings.rolandModel)}.`);
410
+ if (settings.nrpnFamily !== undefined)
411
+ lines.push(`Settings NRPN family: ${settings.nrpnFamily}.`);
412
+ for (const level of SETTING_LEVELS) {
413
+ const label = `${level.charAt(0).toUpperCase()}${level.slice(1)}`;
414
+ for (const row of settings[level] ?? []) {
415
+ const engines = row.engines === undefined ? '' : `, engines ${row.engines.join(', ')}`;
416
+ lines.push(`${label} setting ${row.name}: ${row.min}-${row.max}, ${messageText(row.message)}${engines}.`);
417
+ }
418
+ }
419
+ return lines;
420
+ }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "moontide-mcp",
3
- "version": "2.2.0",
3
+ "version": "2.4.1",
4
4
  "mcpName": "io.github.konni-arcadia/moontide",
5
- "description": "Claude steers live music by writing Strudel code played in a browser page",
5
+ "description": "Early: Claude steers live music by writing Strudel code played in a browser page",
6
6
  "keywords": [
7
7
  "mcp",
8
8
  "mcp-server",
@@ -46,6 +46,7 @@
46
46
  "prepack": "npm run build",
47
47
  "smoke:npx": "node scripts/smoke-npx.mjs",
48
48
  "check:reference": "node scripts/check-reference.mjs",
49
+ "build:site": "node scripts/build-site.mjs",
49
50
  "tint:report": "npm run build && node scripts/tint-report.mjs",
50
51
  "probe:page": "npm run build && node scripts/probe-page.mjs",
51
52
  "probe:midi": "npm run build && node scripts/probe-midi.mjs",