moontide-mcp 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/catalog.js CHANGED
@@ -10,7 +10,7 @@ import path from 'node:path';
10
10
  import { z } from 'zod';
11
11
  import { drumMapById, kitDrumMap, unknownMapReason } from './drum-maps.js';
12
12
  import { renameWithRetry } from './history.js';
13
- import { MAX_RIG_CATALOG_KITS, MAX_RIG_CATALOG_SOUNDS, MAX_RIG_PROBLEMS } from './protocol.js';
13
+ import { MAX_RIG_CATALOG_KITS, MAX_RIG_CATALOG_SOUNDS, MAX_RIG_ENGINES, MAX_RIG_PROBLEMS } from './protocol.js';
14
14
  import { CONTROL_CHARS, cleanProblemText, dataByteSchema, errnoCode, idSchema, modelIdSchema, parseJsonText, problemsFromZod, soundKey, } from './rig-util.js';
15
15
  export const CATALOGS_DIR_NAME = 'catalogs';
16
16
  export const MAX_CATALOG_SOUNDS = MAX_RIG_CATALOG_SOUNDS;
@@ -28,14 +28,73 @@ const catalogSoundSchema = z.strictObject({
28
28
  });
29
29
  /** A drum kit of a catalog: a sound that also may name its drum map (null: no map is known for the kit). */
30
30
  const catalogKitSchema = catalogSoundSchema.extend({ noteMap: idSchema.nullable().optional() });
31
- /** A catalog file: the model it extends (equal to the file name), where the list came from, the sounds and the drum kits. */
31
+ /** The banks whose tones belong to one engine of the model; a bank is the exact MSB and LSB pair a sound is sent with. */
32
+ const catalogEngineSchema = z.strictObject({
33
+ engine: idSchema,
34
+ banks: z.array(z.strictObject({ msb: dataByteSchema, lsb: dataByteSchema.optional() })).min(1, 'at least one bank is required').max(128, 'at most 128 banks'),
35
+ });
36
+ /** A catalog file: the model it extends (equal to the file name), where the list came from, the engine table, the sounds and the drum kits. */
32
37
  export const catalogSchema = z.strictObject({
33
38
  model: modelIdSchema,
34
39
  source: z.string().min(1, 'must not be empty').max(200, 'at most 200 characters'),
40
+ // Optional, so earlier catalogs stay valid: which engine of the model each bank of melodic tones belongs to.
41
+ engines: z.array(catalogEngineSchema).max(MAX_RIG_ENGINES, 'at most 16 engines').optional(),
35
42
  // At least one sound or one kit is required: catalogProblems checks that, with the words of a catalog without sounds.
36
43
  sounds: z.array(catalogSoundSchema).max(MAX_CATALOG_SOUNDS, 'at most 5000 sounds'),
37
44
  kits: z.array(catalogKitSchema).max(MAX_CATALOG_KITS, 'at most 512 kits').optional(),
38
45
  });
46
+ /**
47
+ * The bank as the instrument gets it: an absent bank is 0/0 and an absent LSB is 0, as the sender in
48
+ * page/midi-out.js sends it, so `97` and `97/0` are one bank. A model that takes an MSB only never has an LSB here
49
+ * (a catalog with one is refused), so the key needs no bank select.
50
+ */
51
+ function bankKey(bank) {
52
+ return `${bank?.msb ?? 0}/${bank?.lsb ?? 0}`;
53
+ }
54
+ /** `97/64`, or `97` where the bank has no LSB. */
55
+ function bankText(bank) {
56
+ return bank.lsb === undefined ? String(bank.msb) : `${bank.msb}/${bank.lsb}`;
57
+ }
58
+ /**
59
+ * The checks of the engine table against the model: the model declares engines and has a bank select, each
60
+ * engine is declared and listed once, each bank is in one engine only and fits the model's bank select.
61
+ */
62
+ function engineProblems(catalog, model, add) {
63
+ if (catalog.engines === undefined)
64
+ return;
65
+ const declared = model.settings?.engines;
66
+ if (declared === undefined) {
67
+ add('engines', 'the model declares no engines');
68
+ return;
69
+ }
70
+ if (model.bankSelect === 'none') {
71
+ add('engines', 'this model has no bank select');
72
+ return;
73
+ }
74
+ const firstEngine = new Map();
75
+ const firstBank = new Map();
76
+ catalog.engines.forEach((entry, index) => {
77
+ if (!declared.some((engine) => engine.id === entry.engine)) {
78
+ add(`engines[${index}].engine`, `unknown engine "${entry.engine}", engines of model ${model.id}: ${declared.map((engine) => engine.id).join(', ')}`);
79
+ }
80
+ const earlier = firstEngine.get(entry.engine);
81
+ if (earlier === undefined)
82
+ firstEngine.set(entry.engine, index);
83
+ else
84
+ add(`engines[${index}].engine`, `same engine as engines[${earlier}]`);
85
+ entry.banks.forEach((bank, position) => {
86
+ const at = `engines[${index}].banks[${position}]`;
87
+ if (model.bankSelect === 'msb' && bank.lsb !== undefined)
88
+ add(`${at}.lsb`, 'this model takes a bank MSB only');
89
+ const key = bankKey(bank);
90
+ const seen = firstBank.get(key);
91
+ if (seen === undefined)
92
+ firstBank.set(key, index);
93
+ else
94
+ add(at, `bank ${bankText(bank)} is also in engines[${seen}]`);
95
+ });
96
+ });
97
+ }
39
98
  /** The checks that need more than one field, or the model; the shape is already valid. Paths are bare (no file prefix). */
40
99
  function catalogProblems(catalog, model, file) {
41
100
  const problems = [];
@@ -53,6 +112,7 @@ function catalogProblems(catalog, model, file) {
53
112
  const kits = catalog.kits ?? [];
54
113
  if (catalog.sounds.length === 0 && kits.length === 0)
55
114
  add('sounds', 'at least one sound is required');
115
+ engineProblems(catalog, model, add);
56
116
  // Sounds are melodic, so only the model's melodic names are taken; kits are checked below against its rhythm names.
57
117
  const modelKeys = new Map(model.sounds.melodic.map((own) => [soundKey(own.name), own.name]));
58
118
  const firstByKey = new Map();
@@ -113,6 +173,33 @@ function catalogProblems(catalog, model, file) {
113
173
  });
114
174
  return problems;
115
175
  }
176
+ /**
177
+ * The engine whose banks hold this bank as the instrument gets it (see bankKey: no bank is 0/0, no LSB is 0),
178
+ * or null when there is no catalog, no engine table, or no engine holds the bank.
179
+ */
180
+ export function engineOf(catalog, bank) {
181
+ if (catalog === null || catalog === undefined || catalog.engines === undefined)
182
+ return null;
183
+ const key = bankKey(bank);
184
+ const found = catalog.engines.find((entry) => entry.banks.some((candidate) => bankKey(candidate) === key));
185
+ return found === undefined ? null : found.engine;
186
+ }
187
+ /**
188
+ * What a reply says of the engine of a melodic sound: `engine "<name>"` when the catalog places the bank in one, `in no
189
+ * engine` when it places it in none (also when there is no catalog or no engine table, since the page then knows no
190
+ * engine for the sound), and null (say nothing) for a model that declares no engines. The name is the model's, quoted
191
+ * like a port name.
192
+ */
193
+ export function engineClause(catalog, model, bank) {
194
+ const declared = model.settings?.engines;
195
+ if (declared === undefined)
196
+ return null;
197
+ const id = engineOf(catalog, bank);
198
+ if (id === null)
199
+ return 'in no engine';
200
+ const engine = declared.find((entry) => entry.id === id);
201
+ return engine === undefined ? 'in no engine' : `engine ${quote(engine.name)}`;
202
+ }
116
203
  /**
117
204
  * Checks a catalog value against the model it extends: the shape with its limits, then the
118
205
  * cross-checks. `file` names the catalog file for the problems (`catalogs/<id>.json:<path>`).
@@ -245,12 +332,12 @@ export function categoriesSentence(catalog, prefix) {
245
332
  const counts = categoryCounts(catalog);
246
333
  return counts.length === 0 ? 'The catalog has no categories.' : `${prefix}${categoryList(counts, 30)}.`;
247
334
  }
248
- /** A match line: facts only, in the order name, program, bank, category; a drum kit is marked and ends with its drum map. */
249
- function matchLine(sound, kit) {
335
+ /** A match line: facts only, in the order name, program, bank, category, engine; a drum kit is marked and ends with its drum map. */
336
+ function matchLine(sound, kit, engine) {
250
337
  const bank = sound.bank === undefined ? '' : `, bank ${sound.bank.lsb === undefined ? sound.bank.msb : `${sound.bank.msb}/${sound.bank.lsb}`}`;
251
338
  const category = sound.category === undefined ? '' : `, category ${quote(sound.category)}`;
252
339
  if (kit === null)
253
- return `- ${quote(sound.name)}: program ${sound.program}${bank}${category}`;
340
+ return `- ${quote(sound.name)}: program ${sound.program}${bank}${category}${engine === null ? '' : `, ${engine}`}`;
254
341
  const use = kitDrumMap(kit.model, sound);
255
342
  const map = use.state === 'known' ? `, drum map ${quote(use.map.name)} (${use.map.notes.length} notes)` : ', drum map unknown';
256
343
  return `- ${quote(sound.name)} (drum kit): program ${sound.program}${bank}${category}${map}`;
@@ -269,7 +356,10 @@ export function searchReply(options) {
269
356
  lines.push('1 match:');
270
357
  else
271
358
  lines.push(result.total > MAX_SEARCH_RESULTS ? `${result.total} matches, first ${MAX_SEARCH_RESULTS}:` : `${result.total} matches:`);
272
- result.matches.forEach((sound, index) => lines.push(matchLine(sound, result.kits[index] === true ? { model } : null)));
359
+ result.matches.forEach((sound, index) => {
360
+ const isKit = result.kits[index] === true;
361
+ lines.push(matchLine(sound, isKit ? { model } : null, isKit ? null : engineClause(catalog, model, sound.bank)));
362
+ });
273
363
  if (result.total > MAX_SEARCH_RESULTS)
274
364
  lines.push(`${result.total - MAX_SEARCH_RESULTS} more, refine your search.`);
275
365
  if (result.matches.some((sound) => /['\\]/.test(sound.name))) {
@@ -286,7 +376,8 @@ export class CatalogSaveError extends Error {
286
376
  }
287
377
  }
288
378
  /**
289
- * The text of a catalog file: the model and source lines, then one sound per line (about 420 KB for
379
+ * The text of a catalog file: the model and source lines, then the engine table when there is one (one engine
380
+ * per line), then one sound per line (about 420 KB for
290
381
  * 5,000 sounds, where indented JSON is nearly twice that), then the kits list when there are kits,
291
382
  * ending with a newline.
292
383
  */
@@ -294,7 +385,8 @@ export function catalogFileText(catalog) {
294
385
  const list = (items) => `[\n${items.map((item) => ` ${JSON.stringify(item)}`).join(',\n')}\n ]`;
295
386
  const sounds = catalog.sounds.length === 0 ? '[]' : list(catalog.sounds);
296
387
  const kits = catalog.kits === undefined || catalog.kits.length === 0 ? '' : `,\n "kits": ${list(catalog.kits)}`;
297
- return `{\n "model": ${JSON.stringify(catalog.model)},\n "source": ${JSON.stringify(catalog.source)},\n "sounds": ${sounds}${kits}\n}\n`;
388
+ const engines = catalog.engines === undefined || catalog.engines.length === 0 ? '' : `,\n "engines": ${list(catalog.engines)}`;
389
+ return `{\n "model": ${JSON.stringify(catalog.model)},\n "source": ${JSON.stringify(catalog.source)}${engines},\n "sounds": ${sounds}${kits}\n}\n`;
298
390
  }
299
391
  /**
300
392
  * Writes `<data>/catalogs/<model id>.json`: the file is written whole beside its name and renamed over
@@ -396,6 +488,21 @@ function summaryLine(label, sound) {
396
488
  parts.push(`program ${sound.program} as sent, ${sound.program + 1} in a manual numbered from 1`);
397
489
  return `${label}: ${quote(sound.name)} (${parts.join(', ')}).`;
398
490
  }
491
+ /** `Engines: <id> (N banks, M sounds), ...; in no engine: K sounds.`, singular for one; kits are not counted. */
492
+ function engineSummaryLine(catalog) {
493
+ const count = (n, noun) => `${n} ${noun}${n === 1 ? '' : 's'}`;
494
+ const sounds = new Map();
495
+ let loose = 0;
496
+ for (const sound of catalog.sounds) {
497
+ const engine = engineOf(catalog, sound.bank);
498
+ if (engine === null)
499
+ loose++;
500
+ else
501
+ sounds.set(engine, (sounds.get(engine) ?? 0) + 1);
502
+ }
503
+ const parts = (catalog.engines ?? []).map((entry) => `${entry.engine} (${count(entry.banks.length, 'bank')}, ${count(sounds.get(entry.engine) ?? 0, 'sound')})`);
504
+ return `Engines: ${parts.join(', ')}; in no engine: ${count(loose, 'sound')}.`;
505
+ }
399
506
  /**
400
507
  * The summary the user is shown before a catalog drafted from a sound list is saved, one fact per line:
401
508
  * the model and the source, how the list numbers programs, the categories with counts, the bank select
@@ -419,9 +526,11 @@ export function summarizeCatalog(options) {
419
526
  lines.push('Bank select: none.');
420
527
  }
421
528
  else {
422
- const banks = new Set([...sounds, ...kits].map((sound) => `${sound.bank?.msb ?? 0}/${sound.bank?.lsb ?? 0}`)).size;
529
+ const banks = new Set([...sounds, ...kits].map((sound) => bankKey(sound.bank))).size;
423
530
  lines.push(`Bank select: ${model.bankSelect === 'msb' ? 'CC0' : 'CC0 then CC32'}; ${banks} ${banks === 1 ? 'bank' : 'banks'}.`);
424
531
  }
532
+ if (catalog.engines !== undefined && catalog.engines.length > 0)
533
+ lines.push(engineSummaryLine(catalog));
425
534
  if (total >= 1)
426
535
  lines.push(summaryLine('First', sounds[0]));
427
536
  if (total >= 3)
@@ -10,12 +10,13 @@ import { mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import path from 'node:path';
12
12
  import { z } from 'zod';
13
- import { catalogLine, categoriesSentence, readCatalogFile, searchCatalog, searchReply } from './catalog.js';
13
+ import { catalogLine, categoriesSentence, engineClause, engineOf, readCatalogFile, searchCatalog, searchReply } from './catalog.js';
14
14
  import { MAX_DRUM_MAPS, drumMapById, drumMapSchema, kitDrumMap, mapsToSend, unknownMapReason, BUILT_IN_DRUM_MAPS, } from './drum-maps.js';
15
15
  import { renameWithRetry } from './history.js';
16
16
  import { cleanMidiName, quoteMidiName } from './midi-state.js';
17
- import { MAX_MIDI_NAME_LENGTH, MAX_RIG_INSTRUMENTS, MAX_RIG_PROBLEMS, MAX_RIG_SOUNDS, } from './protocol.js';
17
+ import { MAX_MIDI_NAME_LENGTH, MAX_RIG_INPUTS, MAX_RIG_INSTRUMENTS, MAX_RIG_PROBLEMS, MAX_RIG_SOUNDS, } from './protocol.js';
18
18
  import { CONTROL_CHARS, MAX_RIG_FILE_BYTES, RIG_FILE_NAME, RIG_STATE_FILE_NAME, cleanProblemText, dataByteSchema, errnoCode, idSchema, modelIdSchema, parseJsonText, problemsFromZod, soundKey, } from './rig-util.js';
19
+ import { settingsMessage, settingsProblems, settingsSchema, settingsSummary } from './vocabulary.js';
19
20
  // The helpers other modules and tests already read from here live in src/rig-util.ts.
20
21
  export { MAX_RIG_FILE_BYTES, RIG_FILE_NAME, RIG_STATE_FILE_NAME, problemsFromZod, soundKey } from './rig-util.js';
21
22
  export const MODELS_DIR_NAME = 'models';
@@ -93,12 +94,18 @@ export const modelSchema = z.strictObject({
93
94
  notes: z.array(z.string().min(1).max(240)).max(10),
94
95
  // Whether the instrument follows MIDI clock. Optional, so a model saved before the field existed still loads.
95
96
  clock: z.boolean().optional(),
97
+ // Whether the instrument plays one note at a time, so a chord is heard as one note. Optional: absent means unknown (never polyphonic),
98
+ // and a model saved before the field existed still loads.
99
+ monophonic: z.boolean().optional(),
96
100
  // Whether the rhythm channels choose a kit by bank select and program. Optional, absent means off, so every earlier model loads unchanged.
97
101
  rhythmBank: z.boolean().optional(),
98
102
  // Which note plays which drum. The maps of the model, the id of the map its kits take unless they name
99
103
  // another, both optional, so every earlier model loads unchanged. The General MIDI map is built in.
100
104
  drumMaps: z.array(drumMapSchema).max(MAX_DRUM_MAPS).optional(),
101
105
  drumMap: idSchema.optional(),
106
+ // Which tone, part and scene settings the instrument has and the message each becomes. Optional, absent means
107
+ // none, so every earlier model loads unchanged.
108
+ settings: settingsSchema.optional(),
102
109
  });
103
110
  /** The setup file: one entry per instrument the user plays. */
104
111
  export const rigFileSchema = z.strictObject({
@@ -113,6 +120,14 @@ export const rigFileSchema = z.strictObject({
113
120
  }))
114
121
  .min(1, 'at least one instrument is required')
115
122
  .max(MAX_RIG_INSTRUMENTS, 'at most 16 instruments'),
123
+ // The MIDI input ports the page listens to, by exact full name. Optional: absent means no input. An entry has
124
+ // the shape of an instrument's output port, so a refusal names inputs[0].port. The file is never written for this.
125
+ inputs: z
126
+ .array(z.strictObject({
127
+ port: z.string().min(1, 'must not be empty').max(MAX_MIDI_NAME_LENGTH, `at most ${MAX_MIDI_NAME_LENGTH} characters`),
128
+ }))
129
+ .max(MAX_RIG_INPUTS, `at most ${MAX_RIG_INPUTS} inputs`)
130
+ .optional(),
116
131
  });
117
132
  /** The state file: the instruments the user switched off in the page, by id. A missing file means none. */
118
133
  export const rigStateSchema = z.strictObject({
@@ -298,6 +313,9 @@ function modelProblems(model) {
298
313
  checkSevenBit(bytes, 0, bytes.length, `${at}.${name}`, problems);
299
314
  }
300
315
  });
316
+ for (const problem of settingsProblems(model.settings)) {
317
+ add(problem.field === '' ? 'settings' : `settings.${problem.field}`, problem.reason);
318
+ }
301
319
  return problems;
302
320
  }
303
321
  /**
@@ -561,13 +579,23 @@ export class RigStore {
561
579
  }
562
580
  instruments.push({ id: entry.id, port: entry.port, model, channels, catalog: catalogs.get(model.id) ?? null, clock: entry.clock ?? model.clock ?? false, enabled: !off.has(entry.id) });
563
581
  });
582
+ // Inputs are matched exactly and in full, so the outputs' part-of-each-other rule does not apply to them, and an input may
583
+ // carry the name of an output (one device can be both). Only the same port twice is refused.
584
+ const firstInput = new Map();
585
+ (parsed.data.inputs ?? []).forEach((input, index) => {
586
+ const same = firstInput.get(input.port);
587
+ if (same === undefined)
588
+ firstInput.set(input.port, index);
589
+ else
590
+ problems.push({ field: `inputs[${index}].port`, reason: `same port as inputs[${same}]` });
591
+ });
564
592
  }
565
593
  if (!state.ok)
566
594
  problems.push(...state.problems);
567
595
  problems.push(...user.problems);
568
596
  if (problems.length > 0)
569
597
  return invalid(problems);
570
- return { setup: 'valid', instruments };
598
+ return { setup: 'valid', instruments, inputs: (parsed.success ? parsed.data.inputs ?? [] : []).map((input) => input.port) };
571
599
  }
572
600
  }
573
601
  /**
@@ -666,6 +694,11 @@ export function rigMessage(state) {
666
694
  // name like a model name. The category stays on the server. There is no cache: the catalog is read
667
695
  // at every use (about 10 ms for 5,000 sounds), and a push goes out only when its JSON changed.
668
696
  const catalogSounds = (instrument.catalog?.sounds ?? []).map((sound) => sound.bank === undefined ? { name: sound.name, program: sound.program } : { name: sound.name, program: sound.program, bank: sound.bank });
697
+ // Each melodic sound tells the page its engine when the catalog places its bank in one (the model's own sounds too).
698
+ const withEngine = (sound) => {
699
+ const engine = engineOf(instrument.catalog, sound.bank);
700
+ return engine === null ? sound : { ...sound, engine };
701
+ };
669
702
  // The catalog kits follow the model's own in the rhythm list, resolved by the same sound option.
670
703
  const catalogKits = (instrument.catalog?.kits ?? []).map((kit) => ({
671
704
  name: kit.name,
@@ -687,7 +720,7 @@ export function rigMessage(state) {
687
720
  reset,
688
721
  // The rhythm sounds are copied key by key, so no other key of the model rides along to the page.
689
722
  sounds: {
690
- melodic: [...model.sounds.melodic, ...catalogSounds],
723
+ melodic: [...model.sounds.melodic, ...catalogSounds].map(withEngine),
691
724
  rhythm: [
692
725
  ...model.sounds.rhythm.map((sound) => ({
693
726
  name: sound.name,
@@ -706,14 +739,18 @@ export function rigMessage(state) {
706
739
  catalogKits: catalogKits.length,
707
740
  // The fields the Instruments view's switch and settings editor read.
708
741
  enabled: instrument.enabled,
742
+ // True only when the model states that the instrument plays one note at a time; a model that says nothing sends false.
743
+ monophonic: model.monophonic === true,
709
744
  // Every channel of the model, so the editor can offer them all (the chosen ones are in `channels`).
710
745
  modelChannels: [
711
746
  ...model.channels.melodic.map((channel) => ({ channel, role: 'melodic' })),
712
747
  ...model.channels.rhythm.map((channel) => ({ channel, role: 'rhythm' })),
713
748
  ].sort((a, b) => a.channel - b.channel),
749
+ // The settings the model describes, as names and ranges only: nothing about how they are sent.
750
+ settings: settingsMessage(model.settings),
714
751
  };
715
752
  });
716
- return { type: 'midi-rig', setup: 'valid', instruments };
753
+ return { type: 'midi-rig', setup: 'valid', instruments, inputs: [...state.inputs] };
717
754
  }
718
755
  /**
719
756
  * Whether a port is connected right now, from the last MIDI facts of the active page: 'unknown' while
@@ -795,6 +832,34 @@ function drumMapLine(instrument) {
795
832
  }
796
833
  /** The line under an instrument the user switched off in the page (indented in the summary, bare in the single-instrument text). */
797
834
  const SWITCHED_OFF_LINE = ' Switched off: the user switched this instrument off in the page.';
835
+ /** Setting rows as Claude reads them: each name with its range, in the model's row order; no rows is "none.". */
836
+ function settingNamesText(rows) {
837
+ return rows.length === 0 ? 'none.' : `${rows.map((row) => `${row.name} ${row.min}-${row.max}`).join(', ')}.`;
838
+ }
839
+ /**
840
+ * The Settings lines of one instrument: the names and ranges of each level, the tone ones per engine the model
841
+ * declares. An instrument whose model describes no settings has none, so its reply stays as it was. An engine name
842
+ * comes from the user's model file and is quoted like a port name.
843
+ */
844
+ function settingsLines(model) {
845
+ const { settings } = model;
846
+ if (settings === undefined)
847
+ return [];
848
+ const lines = [' Settings: the tone, part and scene options of .midi(); the settings topic of get_strudel_reference has their rules.'];
849
+ const tone = settings.tone ?? [];
850
+ if (settings.engines === undefined) {
851
+ lines.push(` Tone: ${settingNamesText(tone)}`);
852
+ }
853
+ else {
854
+ for (const engine of settings.engines) {
855
+ lines.push(` Tone, for a sound of engine ${quotePort(engine.name)}: ${settingNamesText(tone.filter((row) => row.engines?.includes(engine.id) === true))}`);
856
+ }
857
+ lines.push(' Tone, for a sound in no engine: none.');
858
+ }
859
+ lines.push(` Part, reaches the part its channel plays: ${settingNamesText(settings.part ?? [])}`);
860
+ lines.push(` Scene, reaches every part of the instrument: ${settingNamesText(settings.scene ?? [])}`);
861
+ return lines;
862
+ }
798
863
  /** The summary Claude reads without an argument: each instrument of the setup file, as facts. */
799
864
  export function describeRig(state, options) {
800
865
  if (state.setup !== 'valid')
@@ -838,9 +903,13 @@ export function describeRig(state, options) {
838
903
  lines.push(instrument.clock
839
904
  ? ' Clock: follows MIDI clock; Moontide sends the clock, Start and Stop while the playing code names this instrument.'
840
905
  : ' Clock: none.');
906
+ // Only a model that states it: nothing is said about an instrument whose model is silent on it.
907
+ if (model.monophonic === true)
908
+ lines.push(' Notes at once: one; a chord is heard as one note.');
841
909
  lines.push(model.controls.length === 0
842
910
  ? ' Controls: none.'
843
911
  : ` Controls: ${model.controls.map((control) => `${control.id} CC${control.cc} ${control.min}-${control.max} (default ${control.default})`).join(', ')}`);
912
+ lines.push(...settingsLines(model));
844
913
  if (model.notes.length > 0)
845
914
  lines.push(` Notes: ${model.notes.join(' ')}`);
846
915
  }
@@ -849,11 +918,11 @@ export function describeRig(state, options) {
849
918
  `One instrument's full sound list: get_midi_instruments with instrument set to its id.`);
850
919
  return lines.join('\n');
851
920
  }
852
- function soundLine(sound) {
921
+ function soundLine(sound, engine = null) {
853
922
  if (sound.bank === undefined)
854
- return `${sound.program} ${sound.name}`;
923
+ return `${sound.program} ${sound.name}${engine === null ? '' : ` (${engine})`}`;
855
924
  const bank = sound.bank.lsb === undefined ? `${sound.bank.msb}` : `${sound.bank.msb}/${sound.bank.lsb}`;
856
- return `${sound.program} ${sound.name} (bank ${bank})`;
925
+ return `${sound.program} ${sound.name} (bank ${bank}${engine === null ? '' : `, ${engine}`})`;
857
926
  }
858
927
  /** A drum map as Claude reads it: its name, its count, what it is for when it is not the whole model's, and its notes in note order. */
859
928
  function mapListLine(map, scope) {
@@ -906,8 +975,9 @@ export function describeInstrument(state, id, options) {
906
975
  const sounds = [...instrument.model.sounds[role]].sort((a, b) => a.program - b.program || (a.bank?.msb ?? -1) - (b.bank?.msb ?? -1) || (a.bank?.lsb ?? -1) - (b.bank?.lsb ?? -1));
907
976
  if (sounds.length === 0)
908
977
  lines.push('no sounds to choose.');
978
+ // A drum kit never carries an engine; a melodic sound of a model that declares engines does.
909
979
  for (const sound of sounds)
910
- lines.push(soundLine(sound));
980
+ lines.push(soundLine(sound, role === 'melodic' ? engineClause(instrument.catalog, instrument.model, sound.bank) : null));
911
981
  }
912
982
  if (instrument.channels.some((entry) => entry.role === 'rhythm'))
913
983
  lines.push('', ...drumMapLines(instrument.model, instrument.catalog?.kits ?? []));
@@ -1003,6 +1073,10 @@ export function summarizeDraft(model) {
1003
1073
  lines.push(`Reset when it connects: ${messages}, then a ${model.reset.waitMs} ms wait.`);
1004
1074
  }
1005
1075
  lines.push(model.clock === true ? 'Clock: follows MIDI clock.' : 'Clock: none.');
1076
+ if (model.monophonic === true)
1077
+ lines.push('Notes at once: one.');
1078
+ if (model.settings !== undefined)
1079
+ lines.push(...settingsSummary(model.settings));
1006
1080
  if (model.notes.length > 0) {
1007
1081
  lines.push('Notes:');
1008
1082
  for (const note of model.notes)
package/dist/mcp.js CHANGED
@@ -779,7 +779,7 @@ export function createMcpServer(options) {
779
779
  }
780
780
  });
781
781
  server.registerTool('get_midi_instruments', {
782
- description: "When the user has described their instruments, call this before writing MIDI code. Lists the MIDI instruments the user described in instruments.json, as facts: for each one its id, model, exact port name, whether it is connected right now, its channels and their roles, how many sounds it has, its bank select, its reset, whether it follows MIDI clock and its controls. Pass instrument (an id from that list) to get that instrument's full sound list, with the program number sent for each sound and its bank when it has one. A sound is chosen in the code with the sound option of .midi(). An instrument with a sound catalog shows how many sounds the catalog holds and its categories; search_midi_sounds finds them. For an instrument with a rhythm channel it says how a kit goes out and which drum map applies; with instrument set it lists each drum map as note numbers and drum names, a kit's own map only when it differs from the model's, and says when a kit has no known map. Drum notes are written as note numbers in the code. It also says which instruments the user switched off in the page: their notes are silent, and .midi() code newly aimed at them is refused until the user switches them on.",
782
+ description: "When the user has described their instruments, call this before writing MIDI code. Lists the MIDI instruments the user described in instruments.json, as facts: for each one its id, model, exact port name, whether it is connected right now, its channels and their roles, how many sounds it has, its bank select, its reset, whether it follows MIDI clock and its controls. Pass instrument (an id from that list) to get that instrument's full sound list, with the program number sent for each sound and its bank when it has one. A sound is chosen in the code with the sound option of .midi(). An instrument with a sound catalog shows how many sounds the catalog holds and its categories; search_midi_sounds finds them. For an instrument whose model describes settings it lists the tone, part and scene setting names with their ranges, the tone ones per engine, and the settings topic of get_strudel_reference has their rules; with instrument set it gives the engine of each sound the catalog places in one. For an instrument with a rhythm channel it says how a kit goes out and which drum map applies; with instrument set it lists each drum map as note numbers and drum names, a kit's own map only when it differs from the model's, and says when a kit has no known map. Drum notes are written as note numbers in the code. It also says which instruments the user switched off in the page: their notes are silent, and .midi() code newly aimed at them is refused until the user switches them on.",
783
783
  annotations: { readOnlyHint: true },
784
784
  inputSchema: {
785
785
  instrument: z.string().min(1).max(32).optional().describe('An instrument id from instruments.json, to list its sounds'),
@@ -798,7 +798,7 @@ export function createMcpServer(options) {
798
798
  }
799
799
  });
800
800
  server.registerTool('search_midi_sounds', {
801
- description: "Searches the sound catalog of one instrument: a list of the model's sounds kept in the user's data folder, built from a patch-name file or a sound list the user owns. It finds sounds by words in the name or the category, in any order, with case and spaces ignored. It returns at most 25 matches: the exact name first, then names that start with the words, then names that contain them, then matches in the category only. Each match gives the name as the sound option of .midi() takes it, the program as sent (0-127), the bank when there is one and the category. It says how many more matched. A drum kit is marked (drum kit) and gives its drum map, or says the map is unknown.",
801
+ description: "Searches the sound catalog of one instrument: a list of the model's sounds kept in the user's data folder, built from a patch-name file or a sound list the user owns. It finds sounds by words in the name or the category, in any order, with case and spaces ignored. It returns at most 25 matches: the exact name first, then names that start with the words, then names that contain them, then matches in the category only. Each match gives the name as the sound option of .midi() takes it, the program as sent (0-127), the bank when there is one, the category and, for an instrument whose model declares engines, the engine of the sound or that it is in no engine. It says how many more matched. A drum kit is marked (drum kit) and gives its drum map, or says the map is unknown.",
802
802
  annotations: { readOnlyHint: true },
803
803
  inputSchema: {
804
804
  instrument: z.string().min(1).max(32).describe('An instrument id from instruments.json whose model has a sound catalog'),
@@ -819,7 +819,7 @@ export function createMcpServer(options) {
819
819
  }
820
820
  });
821
821
  server.registerTool('save_midi_model', {
822
- description: "Saves an instrument model that you drafted from a page of the instrument's manual the user pasted, into the user's Moontide data folder (models/<id>.json), never into Moontide itself. First call it with confirmed: false: it checks the model and returns a summary (channels, sounds with their program numbers as sent and as numbered in a manual from 1, controls, reset bytes, notes) and saves nothing. Show that summary to the user, and call it again with the same model and confirmed: true only after they say yes. It refuses an id of a model Moontide ships, and an existing model unless overwrite is true. Fields: id (lowercase letters, digits, dashes or underscores, at most 32), name, channels { melodic, rhythm } (lists of channels 1-16), bankSelect (none, msb or msb-lsb), rhythmBank (optional: true when the instrument chooses drum kits by bank select and program change; every rhythm sound then needs a bank), drumMaps (optional, at most 8: [{ id, name, notes: [{ note 0-127, name }] }] typed from the instrument's manual), drumMap (optional: the id of the map of kits without their own; general-midi is built in), a rhythm sound's noteMap (optional: a map id, or null when no map is known for that kit), defaultVolume (0-127), clock (optional: true when the instrument follows MIDI clock, so Moontide sends it the clock, Start and Stop), reset (optional: sysex with at most 4 messages, each { kind: roland-dt1, model, address, data } as hex bytes or { kind: raw, bytes } from F0 to F7 and at most 32 bytes, and waitMs), controls ([{ id, name, cc, min, max, default }]), sounds { melodic, rhythm } ([{ name, program 0-127 as sent, bank { msb, lsb } when the instrument needs one }]) and notes (facts about the instrument, at most 10).",
822
+ description: "Saves an instrument model that you drafted from a page of the instrument's manual the user pasted, into the user's Moontide data folder (models/<id>.json), never into Moontide itself. First call it with confirmed: false: it checks the model and returns a summary (channels, sounds with their program numbers as sent and as numbered in a manual from 1, controls, reset bytes, notes) and saves nothing. Show that summary to the user, and call it again with the same model and confirmed: true only after they say yes. It refuses an id of a model Moontide ships, and an existing model unless overwrite is true. Fields: id (lowercase letters, digits, dashes or underscores, at most 32), name, channels { melodic, rhythm } (lists of channels 1-16), bankSelect (none, msb or msb-lsb), rhythmBank (optional: true when the instrument chooses drum kits by bank select and program change; every rhythm sound then needs a bank), drumMaps (optional, at most 8: [{ id, name, notes: [{ note 0-127, name }] }] typed from the instrument's manual), drumMap (optional: the id of the map of kits without their own; general-midi is built in), a rhythm sound's noteMap (optional: a map id, or null when no map is known for that kit), defaultVolume (0-127), clock (optional: true when the instrument follows MIDI clock, so Moontide sends it the clock, Start and Stop), monophonic (optional: true when the instrument plays one note at a time), reset (optional: sysex with at most 4 messages, each { kind: roland-dt1, model, address, data } as hex bytes or { kind: raw, bytes } from F0 to F7 and at most 32 bytes, and waitMs), controls ([{ id, name, cc, min, max, default }]), sounds { melodic, rhythm } ([{ name, program 0-127 as sent, bank { msb, lsb } when the instrument needs one }]) and notes (facts about the instrument, at most 10).",
823
823
  inputSchema: {
824
824
  model: z.record(z.string(), z.unknown()).describe('The drafted model, as a JSON object'),
825
825
  confirmed: z.boolean().describe('false to check and summarize without saving; true only after the user said yes to the summary'),
@@ -104,6 +104,21 @@ export function midiFailedLine(failure) {
104
104
  const reason = failure === null ? '' : ` (${quoteMidiName(failure)})`;
105
105
  return `MIDI: Web MIDI could not start${reason}, so tracks using .midi() are silent. Other tracks keep playing.`;
106
106
  }
107
+ /** Setting names joined by ', ': the schema holds each to a letter and then letters and digits, and cleaning keeps it so. */
108
+ function settingNameList(names) {
109
+ return names.map(cleanMidiName).join(', ');
110
+ }
111
+ /** The words of each reason the memory guard gives (src/protocol.ts lists them). */
112
+ const GUARD_REASON_WORDS = {
113
+ 'not-bytes': 'it is not a list of numbers, so it cannot be checked',
114
+ 'too-long': 'it is too long to be checked',
115
+ incomplete: 'it is not a complete system exclusive message',
116
+ 'not-data-set': 'it is not a Roland data set (command 12) for the model of the instrument',
117
+ 'unknown-areas': 'Moontide does not know which addresses of this instrument are temporary',
118
+ outside: 'it would write outside the temporary scene and tones',
119
+ checksum: 'its checksum does not match',
120
+ nrpn: 'it is not one of the NRPN part offsets Moontide knows for this instrument',
121
+ };
107
122
  /**
108
123
  * The line of one event the page sent: a reset of an instrument, a sound change that may
109
124
  * cut a note, a sound kept after a failed evaluation, a send that failed, and clock or transport the
@@ -129,9 +144,36 @@ export function midiEventLine(event) {
129
144
  }
130
145
  case 'send-failed': {
131
146
  const id = quoteMidiName(event.instrument);
132
- const what = event.what === 'reset' ? 'the reset' : 'the sound setup';
147
+ const what = event.what === 'reset' ? 'the reset' : event.what === 'settings' ? 'the settings' : 'the sound setup';
133
148
  return `MIDI: ${what} could not be sent to instrument ${id} (${quoteMidiName(event.failure)}).`;
134
149
  }
150
+ case 'settings-scene': {
151
+ const id = quoteMidiName(event.instrument);
152
+ return `MIDI: the scene settings ${settingNameList(event.names)} of ${id} reach every part of the instrument, not only the channel of their .midi() call.`;
153
+ }
154
+ case 'settings-left': {
155
+ const id = quoteMidiName(event.instrument);
156
+ const names = settingNameList(event.names);
157
+ const nothing = 'are no longer in the code that plays. Nothing is sent for them, so the instrument keeps the last value';
158
+ if (event.level === 'scene')
159
+ return `MIDI: the scene settings ${names} of ${id} ${nothing} until the instrument itself changes it.`;
160
+ if (event.level === 'part')
161
+ return `MIDI: the part settings ${names} of ${id} channel ${event.channel} ${nothing} until the instrument itself changes it.`;
162
+ return `MIDI: the tone settings ${names} of ${id} channel ${event.channel} ${nothing} until it loads the tone again.`;
163
+ }
164
+ case 'sysex-refused': {
165
+ const id = quoteMidiName(event.instrument);
166
+ const unread = event.reason === 'not-bytes' || event.reason === 'too-long';
167
+ const what = event.source === 'reset'
168
+ ? `the reset of ${id} was`
169
+ : event.source === 'settings'
170
+ ? `the settings for ${id} were`
171
+ : `a ${unread ? 'MIDI' : 'system exclusive'} message from the code to ${id} was`;
172
+ const only = event.reason === 'nrpn'
173
+ ? 'only Roland data sets into its temporary scene and tones, and the NRPN part offsets it knows.'
174
+ : 'only Roland data sets into its temporary scene and tones.';
175
+ return `MIDI: ${what} not sent: ${GUARD_REASON_WORDS[event.reason]}. Moontide sends nothing to an instrument that could write to its memory, ${only}`;
176
+ }
135
177
  case 'clock-dropped':
136
178
  return MIDI_CLOCK_DROPPED_LINE;
137
179
  }