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/page/midi-plan.js CHANGED
@@ -167,6 +167,10 @@ function isChannelLiteral(node) {
167
167
 
168
168
  const problem = (key, hints = []) => ({ key, hints });
169
169
 
170
+ /** The option keys that carry settings, one per level; the level is always written, never deduced from a name. */
171
+ const SETTING_LEVEL_KEYS = ['tone', 'part', 'scene'];
172
+ const emptySettings = () => ({ tone: new Map(), part: new Map(), scene: new Map() });
173
+
170
174
  /** The value of a finite number literal, or of a negated one (`-N`); null for anything else. (A private copy of the
171
175
  * page/mixer-rewrite.js rule: this module stays a leaf, so there is no import cycle.) */
172
176
  function numberLiteral(node) {
@@ -245,7 +249,17 @@ function midiUpChain(node, parents) {
245
249
  function readSetupCall(node, midichan) {
246
250
  const [first, second] = node.arguments;
247
251
  const start = first ? first.start : node.callee.property.start;
248
- const result = { start, port: null, channel: 1, sound: null, controls: new Map(), problems: { port: null, channel: null, sound: null, controls: null } };
252
+ const result = {
253
+ start,
254
+ port: null,
255
+ channel: 1,
256
+ sound: null,
257
+ controls: new Map(),
258
+ settings: emptySettings(),
259
+ problems: { port: null, channel: null, sound: null, controls: null, settings: null },
260
+ };
261
+ // Whether the last `tone`, `part` or `scene` key written was a plain object, by level in the order first written.
262
+ const settingsPlain = new Map();
249
263
  const sources = [];
250
264
  if (first && isSingleQuoted(first)) {
251
265
  result.port = first.value;
@@ -301,9 +315,21 @@ function readSetupCall(node, midichan) {
301
315
  const read = readControlsObject(property.value);
302
316
  result.controls = read ?? new Map();
303
317
  result.problems.controls = read === null ? problem('controlsNotPlain') : null;
318
+ } else if (SETTING_LEVEL_KEYS.includes(key)) {
319
+ // Same rule for each level: the last key wins, anything but a plain object of number literals leaves
320
+ // that level empty and is a problem of the call (the first such level in written order).
321
+ const read = readControlsObject(property.value);
322
+ result.settings[key] = read ?? new Map();
323
+ settingsPlain.set(key, read !== null);
304
324
  }
305
325
  }
306
326
  }
327
+ for (const [level, plain] of settingsPlain) {
328
+ if (!plain) {
329
+ result.problems.settings = { ...problem('settingsNotPlain'), level };
330
+ break;
331
+ }
332
+ }
307
333
  // A .midichan(n) on the same chain overrides the options' channel (a hap's own midichan wins in Strudel).
308
334
  if (midichan !== undefined) {
309
335
  result.channel = midichan;
@@ -316,18 +342,28 @@ function readSetupCall(node, midichan) {
316
342
  function entryProblem(node, key, hints = []) {
317
343
  const first = node.arguments[0];
318
344
  const start = first ? first.start : node.callee.type === 'MemberExpression' ? node.callee.property.start : node.start;
319
- return { start, port: null, channel: 1, sound: null, controls: new Map(), problems: { port: problem(key, hints), channel: null, sound: null, controls: null } };
345
+ return {
346
+ start,
347
+ port: null,
348
+ channel: 1,
349
+ sound: null,
350
+ controls: new Map(),
351
+ settings: emptySettings(),
352
+ problems: { port: problem(key, hints), channel: null, sound: null, controls: null, settings: null },
353
+ };
320
354
  }
321
355
 
322
356
  /**
323
357
  * The port, channel and sound written in every `.midi(` call of `code`, in source order, for the
324
358
  * setup file's checks. Returns { ok: false } when the code does not parse (nothing is
325
359
  * refused then: Strudel reports its own syntax error), else { ok: true, calls }, each call
326
- * { start, port: string | null, channel, sound: string | null, controls: Map<string, number>, problems: { port,
327
- * channel, sound, controls } } where a problem is null or { key, hints }: `key` and each hint are RIG_COPY
328
- * keys. Only values written in plain form in the call are read; the port must be a single-quoted literal in
360
+ * { start, port: string | null, channel, sound: string | null, controls: Map<string, number>, settings: { tone,
361
+ * part, scene } (each a Map<string, number>), problems: { port, channel, sound, controls, settings } } where a
362
+ * problem is null or { key, hints }: `key` and each hint are RIG_COPY keys. Only values written in plain form in the call are read; the port must be a single-quoted literal in
329
363
  * the call, a channel a whole number from 1 to 16, a sound a single-quoted literal and `controls` a plain
330
- * object of number literals (read in written order; any other value gives the `controlsNotPlain` problem and no controls). A `.midichan(n)` with a whole-number literal
364
+ * object of number literals (read in written order; any other value gives the `controlsNotPlain` problem and no controls).
365
+ * The `tone`, `part` and `scene` options are read the same way, the last key of a level wins, and a level that is not a plain
366
+ * object of number literals stays empty and gives the `settingsNotPlain` problem ({ key, hints, level }, the first such level in written order). A `.midichan(n)` with a whole-number literal
331
367
  * on the member chain of one `.midi(` call sets that call's channel; a `.midichan(` that is not plain,
332
368
  * is a second one on a chain, or has no `.midi(` call on its chain, and every `.midiport(` call, become
333
369
  * entries of their own that carry a problem at their own position.
package/page/midi-rig.js CHANGED
@@ -8,12 +8,13 @@ import { controlKeyText, fill } from './midi-plan.js';
8
8
  import { cutText } from './ask.js';
9
9
 
10
10
  /**
11
- * The limits of the midi-rig message. src/protocol.ts holds the same numbers (MAX_RIG_INSTRUMENTS,
11
+ * The limits of the midi-rig message. src/protocol.ts holds the same numbers (MAX_RIG_INSTRUMENTS, MAX_RIG_INPUTS,
12
12
  * MAX_RIG_SOUNDS, MAX_RIG_CONTROLS, MAX_RIG_PROBLEMS, MAX_RIG_CATALOG_SOUNDS, MAX_RIG_CATALOG_KITS, MAX_RIG_DRUM_MAPS, MAX_RIG_DRUM_NOTES and
13
- * MAX_RIG_DRUM_NAME, and MAX_MIDI_NAME_LENGTH for names); test/midi-rig.test.js checks both sides.
13
+ * MAX_RIG_DRUM_NAME, MAX_RIG_SETTINGS, MAX_RIG_ENGINES, and MAX_MIDI_NAME_LENGTH for names); test/midi-rig.test.js checks both sides.
14
14
  */
15
15
  export const RIG_LIMITS = Object.freeze({
16
16
  instruments: 16,
17
+ inputs: 4,
17
18
  sounds: 512,
18
19
  controls: 32,
19
20
  problems: 20,
@@ -23,6 +24,8 @@ export const RIG_LIMITS = Object.freeze({
23
24
  drumMaps: 16,
24
25
  drumNotes: 128,
25
26
  drumName: 40,
27
+ settings: 512,
28
+ engines: 16,
26
29
  });
27
30
 
28
31
  /**
@@ -79,6 +82,23 @@ export const RIG_COPY = Object.freeze({
79
82
  offInstrumentsOn: 'Instruments that are on: {ids}.',
80
83
  offNoneOn: 'No instrument is switched on.',
81
84
  offHow: 'The user switches it on in the Instruments view (key 6).',
85
+ unknownSetting: 'Unknown {level} setting "{setting}" for {id}.',
86
+ settingsList: 'Settings in {level} for {id}: {names}.',
87
+ settingsNotPlain: 'The {level} option of .midi() must be a plain object of numbers.',
88
+ settingsNotPlainHint: "Write each setting as a name and a whole number: .midi('{port}', { midichannel: 3, {level}: { name: 1 } }).",
89
+ noLevelSettings: '{id} has no {level} settings.',
90
+ otherLevel: '"{setting}" is a {other} setting of {id}; write it in {other}.',
91
+ settingRange: 'Setting "{setting}" in {level} for {id} must be a whole number from {min} to {max}.',
92
+ settingConflict: '{id} channel {channel} is given two different {setting} values in {level}: {a} and {b}.',
93
+ sceneConflict: '{id} is given two different {setting} values in scene: {a} and {b}.',
94
+ oneValuePerSetting: 'Give each setting one value.',
95
+ engineNoSound: 'Tone settings for {id} need a sound named in the same .midi() call, so the engine of the tone is known.',
96
+ engineUnknown: 'The engine of "{sound}" on {id} is not known, so its tone settings are refused.',
97
+ engineUnknownWhy: 'The sound catalog does not place this sound in an engine.',
98
+ engineMismatch: '"{setting}" is a setting of {settingEngines} tones on {id}, and "{sound}" is a {engine} tone.',
99
+ engineConflict: '{id} channel {channel} is given tone settings for a {a} tone and for a {b} tone.',
100
+ settingsForEngine: 'Settings in tone for {id} on {engine} tones: {names}.',
101
+ noEngineSettings: '{id} has no tone settings for {engine} tones.',
82
102
  closingPlaying: 'The previous music keeps playing.',
83
103
  closingStopped: 'Nothing changed in the music.',
84
104
  });
@@ -134,6 +154,12 @@ function readSound(value, path, role) {
134
154
  sound.bank = { msb: readInt(value.bank.msb, `${path}.bank.msb`, 0, 127) };
135
155
  if (value.bank.lsb !== undefined) sound.bank.lsb = readInt(value.bank.lsb, `${path}.bank.lsb`, 0, 127);
136
156
  }
157
+ // A melodic sound may name the engine its bank is placed in; the instrument check below makes sure the settings declare it.
158
+ if (value.engine !== undefined) {
159
+ if (role !== 'melodic') fail(`${path}.engine`, 'is only for melodic sounds');
160
+ if (typeof value.engine !== 'string' || !CONTROL_ID.test(value.engine)) fail(`${path}.engine`, 'must be an engine id');
161
+ sound.engine = value.engine;
162
+ }
137
163
  // A rhythm sound may name its kit's drum map (an id) or say null, no map is known; absent stays absent.
138
164
  if (value.noteMap !== undefined) {
139
165
  if (role !== 'rhythm') fail(`${path}.noteMap`, 'is only for rhythm sounds');
@@ -203,12 +229,115 @@ function readControls(value, path) {
203
229
  });
204
230
  }
205
231
 
232
+ // The setting names of the vocabulary (src/vocabulary.ts): a letter, then letters and digits.
233
+ const SETTING_NAME = /^[a-z][A-Za-z0-9]{0,39}$/;
234
+ const SETTING_LEVELS = ['tone', 'part', 'scene'];
235
+
236
+ /** The model ID of the Roland data sets: null, or 1 to 4 whole numbers from 0 to 127. */
237
+ function readRolandModel(value, path) {
238
+ if (value === null) return null;
239
+ const bytes = readList(value, path, 4).map((byte, index) => readInt(byte, `${path}[${index}]`, 0, 127));
240
+ if (bytes.length < 1) fail(path, 'must have at least 1 item');
241
+ return bytes;
242
+ }
243
+
244
+ /** The NRPN family of the model: null, or 'gs'. A missing key is unreadable, so a message from an older server is noticed. */
245
+ function readNrpnFamily(value, path) {
246
+ if (value === null || value === 'gs') return value;
247
+ return fail(path, "must be null or 'gs'");
248
+ }
249
+
250
+ /** The message a setting becomes: a Roland data set, an NRPN or a control change. Anything else fails with its path. */
251
+ function readSettingMessage(value, path, rolandModel, rolandPath) {
252
+ readObject(value, path);
253
+ if (value.kind === 'roland-dt1') {
254
+ // The page cannot build a data set without the model ID.
255
+ if (rolandModel === null) fail(rolandPath, 'is required by a roland-dt1 setting');
256
+ const address = readList(value.address, `${path}.address`, 4).map((byte, index) => readInt(byte, `${path}.address[${index}]`, 0, 127));
257
+ // 3 or 4: a shorter address would make the instrument read data bytes as address bytes. Which of the two the instrument
258
+ // takes is the guard's to check when the bytes are built.
259
+ if (address.length < 3) fail(`${path}.address`, 'must have 3 or 4 items');
260
+ if (value.size !== 1 && value.size !== 2 && value.size !== 4) fail(`${path}.size`, 'must be 1, 2 or 4');
261
+ return { kind: 'roland-dt1', address, size: value.size };
262
+ }
263
+ if (value.kind === 'nrpn') return { kind: 'nrpn', msb: readInt(value.msb, `${path}.msb`, 0, 127), lsb: readInt(value.lsb, `${path}.lsb`, 0, 127) };
264
+ if (value.kind === 'cc') return { kind: 'cc', cc: readInt(value.cc, `${path}.cc`, 0, 127) };
265
+ return fail(`${path}.kind`, "must be 'roland-dt1', 'nrpn' or 'cc'");
266
+ }
267
+
268
+ /** The settings an instrument describes, or null: rows of a name, a range in the device's own units and the message, per level. */
269
+ function readSettings(value, path) {
270
+ if (value === null) return null;
271
+ readObject(value, path);
272
+ const engines = readEngines(value.engines, `${path}.engines`);
273
+ const rolandModel = readRolandModel(value.rolandModel, `${path}.rolandModel`);
274
+ const nrpnFamily = readNrpnFamily(value.nrpnFamily, `${path}.nrpnFamily`);
275
+ const levels = {};
276
+ for (const level of SETTING_LEVELS) {
277
+ // The rows read so far by name: a tone name may repeat once per engine, no other name may.
278
+ const seen = new Map();
279
+ levels[level] = readList(value[level], `${path}.${level}`, RIG_LIMITS.settings).map((entry, index) => {
280
+ const at = `${path}.${level}[${index}]`;
281
+ readObject(entry, `${at}`);
282
+ const name = readString(entry.name, `${at}.name`);
283
+ if (!SETTING_NAME.test(name)) fail(`${at}.name`, 'must be a setting name (a letter, then letters and digits)');
284
+ const earlier = seen.get(name) ?? [];
285
+ // A repeat is only possible between tone rows that both name engines; everything else is refused here.
286
+ if (earlier.length > 0 && (level !== 'tone' || entry.engines === undefined)) fail(`${at}.name`, `same name as ${level}[${earlier[0].index}]`);
287
+ const row = { name, min: readInt(entry.min, `${at}.min`, 0, 65535), max: readInt(entry.max, `${at}.max`, 0, 65535) };
288
+ if (row.max < row.min) fail(`${at}.max`, 'must not be below min');
289
+ row.message = readSettingMessage(entry.message, `${at}.message`, rolandModel, `${path}.rolandModel`);
290
+ // Only a tone row may name engines, and only declared ones; part and scene settings reach every engine.
291
+ if (entry.engines !== undefined) {
292
+ if (level !== 'tone') fail(`${at}.engines`, `is only for tone settings: ${level} settings reach every engine`);
293
+ row.engines = readList(entry.engines, `${at}.engines`, RIG_LIMITS.engines).map((id, position) => {
294
+ if (typeof id !== 'string' || !engines.some((engine) => engine.id === id)) fail(`${at}.engines[${position}]`, 'must be the id of a declared engine');
295
+ return id;
296
+ });
297
+ }
298
+ for (const other of earlier) {
299
+ if (other.engines === undefined) fail(`${at}.name`, `same name as ${level}[${other.index}]`);
300
+ const shared = row.engines.find((id) => other.engines.includes(id));
301
+ if (shared !== undefined) fail(`${at}.name`, `same name as ${level}[${other.index}], and both reach engine "${shared}"`);
302
+ }
303
+ seen.set(name, [...earlier, { index, engines: row.engines }]);
304
+ return row;
305
+ });
306
+ }
307
+ return { engines, rolandModel, nrpnFamily, ...levels };
308
+ }
309
+
310
+ /** The engines a model declares: ids unique, names unique and 1 to 40 code points. */
311
+ function readEngines(value, path) {
312
+ const seen = new Map();
313
+ const seenNames = new Map();
314
+ return readList(value, path, RIG_LIMITS.engines).map((entry, index) => {
315
+ const at = `${path}[${index}]`;
316
+ readObject(entry, at);
317
+ const id = readString(entry.id, `${at}.id`);
318
+ if (!CONTROL_ID.test(id)) fail(`${at}.id`, 'must be an id (lowercase letters, digits, - and _)');
319
+ if (seen.has(id)) fail(`${at}.id`, `same id as engines[${seen.get(id)}]`);
320
+ seen.set(id, index);
321
+ const name = readString(entry.name, `${at}.name`);
322
+ const length = Array.from(name).length;
323
+ if (length < 1 || length > 40) fail(`${at}.name`, 'must be 1 to 40 characters');
324
+ if (seenNames.has(name)) fail(`${at}.name`, `same name as engines[${seenNames.get(name)}]`);
325
+ seenNames.set(name, index);
326
+ return { id, name };
327
+ });
328
+ }
329
+
206
330
  function readInstrument(value, path) {
207
331
  readObject(value, path);
208
332
  const instrument = readInstrumentFields(value, path);
209
333
  // Every map id the instrument names must be a map of the message, or the whole rig reads unreadable.
210
334
  const hasMap = (id) => instrument.drumMaps.some((map) => map.id === id);
211
335
  if (instrument.drumMap !== null && !hasMap(instrument.drumMap)) fail(`${path}.drumMap`, `names drum map "${instrument.drumMap}", which the message does not hold`);
336
+ instrument.sounds.melodic.forEach((sound, index) => {
337
+ if (sound.engine !== undefined && !(instrument.settings?.engines ?? []).some((engine) => engine.id === sound.engine)) {
338
+ fail(`${path}.sounds.melodic[${index}].engine`, `names engine "${sound.engine}", which the message does not hold`);
339
+ }
340
+ });
212
341
  instrument.sounds.rhythm.forEach((sound, index) => {
213
342
  // Same rule as src/instruments.ts and src/catalog.ts: a kit with no bank on an opted-in model would go out as bank 0/0.
214
343
  if (instrument.rhythmBank && sound.bank === undefined) {
@@ -267,12 +396,16 @@ function readInstrumentFields(value, path) {
267
396
  // Instruments from the page: the fields the Instruments view's switch and settings editor read.
268
397
  // Required, so a message from an older server reads unreadable instead of silently on.
269
398
  enabled: readBoolean(value.enabled, `${path}.enabled`),
399
+ // Whether the model states that the instrument plays one note at a time; required like enabled, so the page never guesses it and an older server's message reads unreadable.
400
+ monophonic: readBoolean(value.monophonic, `${path}.monophonic`),
270
401
  // Every channel of the model, so the editor can offer them all; required like enabled.
271
402
  modelChannels: readList(value.modelChannels, `${path}.modelChannels`, 16).map((entry, index) => {
272
403
  readObject(entry, `${path}.modelChannels[${index}]`);
273
404
  if (!ROLES.includes(entry.role)) fail(`${path}.modelChannels[${index}].role`, `must be one of ${ROLES.join(', ')}`);
274
405
  return { channel: readInt(entry.channel, `${path}.modelChannels[${index}].channel`, 1, 16), role: entry.role };
275
406
  }),
407
+ // The settings the model describes (names and ranges), or null; required, so a message from an older server reads unreadable.
408
+ settings: readSettings(value.settings, `${path}.settings`),
276
409
  };
277
410
  }
278
411
 
@@ -322,7 +455,9 @@ export function parseRigMessage(msg) {
322
455
  const instruments = readList(msg.instruments, 'instruments', RIG_LIMITS.instruments).map((instrument, index) =>
323
456
  readInstrument(instrument, `instruments[${index}]`),
324
457
  );
325
- return { setup: 'valid', instruments };
458
+ // The input ports the page listens to. A message without the field (an older server) reads as no input.
459
+ const inputs = msg.inputs === undefined ? [] : readList(msg.inputs, 'inputs', RIG_LIMITS.inputs).map((name, index) => readString(name, `inputs[${index}]`));
460
+ return { setup: 'valid', instruments, inputs };
326
461
  }
327
462
  fail('setup', "must be 'absent', 'invalid' or 'valid'");
328
463
  return null;
@@ -428,7 +563,7 @@ export function closestSounds(query, sounds) {
428
563
  * The lines of one problem, as a function of how many list entries it may show: line 1 is one
429
564
  * self-contained sentence, the lines after it are the valid values.
430
565
  */
431
- function problemLines(call, rig, outputs, desired, written, controls, playingPorts) {
566
+ function problemLines(call, rig, outputs, desired, written, controls, settings, scenes, playingPorts) {
432
567
  const issues = call.problems;
433
568
  if (issues.port !== null) {
434
569
  return () => [RIG_COPY[issues.port.key], ...issues.port.hints.map((hint) => RIG_COPY[hint])];
@@ -464,7 +599,7 @@ function problemLines(call, rig, outputs, desired, written, controls, playingPor
464
599
  fill(RIG_COPY.declaredChannels, { id: instrument.id, channels: channelRanges(instrument.channels.map((entry) => entry.channel)) }),
465
600
  ];
466
601
  }
467
- return soundLines(call, instrument, declared, desired, written) ?? controlLines(call, instrument, declared, controls);
602
+ return soundLines(call, instrument, declared, desired, written) ?? controlLines(call, instrument, declared, controls) ?? settingLines(call, instrument, declared, settings, scenes);
468
603
  }
469
604
 
470
605
  /**
@@ -568,6 +703,139 @@ function controlLines(call, instrument, declared, controls) {
568
703
  return null;
569
704
  }
570
705
 
706
+ /** Every name of `names`, the ones that contain the query (or are contained in it) first in list order, then by edit distance. */
707
+ function closestNames(query, names) {
708
+ const queryKey = query.toLowerCase();
709
+ const near = [];
710
+ const rest = [];
711
+ names.forEach((name, index) => {
712
+ const key = name.toLowerCase();
713
+ if (queryKey !== '' && (key.includes(queryKey) || queryKey.includes(key))) near.push(name);
714
+ else rest.push({ name, index, distance: editDistance(queryKey, key) });
715
+ });
716
+ rest.sort((a, b) => a.distance - b.distance || a.index - b.index);
717
+ return [...near, ...rest.map((entry) => entry.name)];
718
+ }
719
+
720
+ /**
721
+ * How many names a list of settings shows at each list limit of composeRefusal: every name at the first try,
722
+ * fewer only when the whole refusal would pass the budget.
723
+ */
724
+ const NAMES_SHOWN = Object.freeze({ 5: Infinity, 4: 16, 3: 10, 2: 6, 1: 3 });
725
+
726
+ /**
727
+ * The settings part of one call's problems, after the controls: the lines of the first problem, or null when
728
+ * the settings are fine or absent. A level that is not a plain object comes first; then, for the levels in the
729
+ * order tone, part, scene and each written name in written order: a name the instrument lacks at that level (the
730
+ * level it belongs to, or the names it has, the closest first), a value that is not a whole number inside the
731
+ * setting's min to max, and a different value for a setting a call already wrote (tone and part per slot
732
+ * `<instrument id>:<channel>`, scene per instrument). A valid value is recorded under its level.
733
+ */
734
+ function settingLines(call, instrument, declared, settings, scenes) {
735
+ if (call.problems.settings !== null) {
736
+ const level = call.problems.settings.level;
737
+ return () => [fill(RIG_COPY[call.problems.settings.key], { level }), fill(RIG_COPY.settingsNotPlainHint, { port: cut(call.port), level })];
738
+ }
739
+ const described = instrument.settings ?? null;
740
+ const slot = `${instrument.id}:${call.channel}`;
741
+ // The engine rule: the engine of the tone comes from the sound catalog through the sound the call names, never from the code.
742
+ let sound = null;
743
+ let engine = null;
744
+ const engineRule = described !== null && described.engines.length > 0 && call.settings.tone.size > 0;
745
+ if (engineRule) {
746
+ if (call.sound === null) return () => [fill(RIG_COPY.engineNoSound, { id: instrument.id })];
747
+ const key = soundKey(call.sound);
748
+ sound = instrument.sounds[declared.role].find((candidate) => soundKey(candidate.name) === key) ?? null;
749
+ if (sound === null || sound.engine === undefined) {
750
+ return () => [fill(RIG_COPY.engineUnknown, { sound: cut(sound === null ? call.sound : sound.name), id: instrument.id }), RIG_COPY.engineUnknownWhy];
751
+ }
752
+ engine = sound.engine;
753
+ }
754
+ const engineName = (id) => described.engines.find((candidate) => candidate.id === id).name;
755
+ const reaches = (row) => row.engines === undefined || row.engines.includes(engine);
756
+ for (const level of SETTING_LEVELS) {
757
+ const written = call.settings[level];
758
+ if (written.size === 0) continue;
759
+ const allRows = described === null ? [] : described[level];
760
+ // A tone name list shows only the names of the sound's engine.
761
+ const rows = level === 'tone' && engine !== null ? allRows.filter(reaches) : allRows;
762
+ for (const [name, value] of written) {
763
+ // A name may have one row per engine: the row is the one that reaches the sound's engine, else the first of the name.
764
+ const sameName = allRows.filter((candidate) => candidate.name === name);
765
+ const row = level === 'tone' && engine !== null ? (sameName.find(reaches) ?? sameName[0]) : sameName[0];
766
+ if (row === undefined) {
767
+ const other = described === null ? undefined : SETTING_LEVELS.find((candidate) => candidate !== level && described[candidate].some((entry) => entry.name === name));
768
+ return (limit) => {
769
+ const lines = [fill(RIG_COPY.unknownSetting, { level, setting: cut(name), id: instrument.id })];
770
+ if (other !== undefined) lines.push(fill(RIG_COPY.otherLevel, { setting: cut(name), other, id: instrument.id }));
771
+ else if (allRows.length === 0) lines.push(fill(RIG_COPY.noLevelSettings, { id: instrument.id, level }));
772
+ else if (rows.length === 0) lines.push(fill(RIG_COPY.noEngineSettings, { id: instrument.id, engine: engineName(engine) }));
773
+ else if (rows !== allRows) lines.push(fill(RIG_COPY.settingsForEngine, { id: instrument.id, engine: engineName(engine), names: listItems(closestNames(name, rows.map((entry) => entry.name)), NAMES_SHOWN[limit]) }));
774
+ else lines.push(fill(RIG_COPY.settingsList, { level, id: instrument.id, names: listItems(closestNames(name, rows.map((entry) => entry.name)), NAMES_SHOWN[limit]) }));
775
+ return lines;
776
+ };
777
+ }
778
+ if (level === 'tone' && engine !== null && !reaches(row)) {
779
+ return (limit) => [
780
+ fill(RIG_COPY.engineMismatch, {
781
+ setting: name,
782
+ settingEngines: [...new Set(sameName.flatMap((candidate) => candidate.engines))].map(engineName).join(' or '),
783
+ id: instrument.id,
784
+ sound: cut(sound.name),
785
+ engine: engineName(engine),
786
+ }),
787
+ rows.length === 0
788
+ ? fill(RIG_COPY.noEngineSettings, { id: instrument.id, engine: engineName(engine) })
789
+ : fill(RIG_COPY.settingsForEngine, { id: instrument.id, engine: engineName(engine), names: listItems(closestNames(name, rows.map((entry) => entry.name)), NAMES_SHOWN[limit]) }),
790
+ ];
791
+ }
792
+ if (!Number.isInteger(value) || value < row.min || value > row.max) {
793
+ return () => [fill(RIG_COPY.settingRange, { setting: name, level, id: instrument.id, min: row.min, max: row.max })];
794
+ }
795
+ let entry;
796
+ let earlier;
797
+ if (level === 'scene') {
798
+ entry = scenes.get(instrument.id);
799
+ earlier = entry === undefined ? undefined : entry.values.get(name);
800
+ } else {
801
+ entry = settings.get(slot);
802
+ earlier = entry === undefined ? undefined : entry[level].get(name);
803
+ }
804
+ if (earlier !== undefined && earlier !== value) {
805
+ return () => [
806
+ level === 'scene'
807
+ ? fill(RIG_COPY.sceneConflict, { id: instrument.id, setting: name, a: earlier, b: value })
808
+ : fill(RIG_COPY.settingConflict, { id: instrument.id, channel: call.channel, setting: name, level, a: earlier, b: value }),
809
+ RIG_COPY.oneValuePerSetting,
810
+ ];
811
+ }
812
+ if (level === 'scene') {
813
+ if (entry === undefined) {
814
+ entry = { instrument: instrument.id, port: instrument.port, values: new Map() };
815
+ scenes.set(instrument.id, entry);
816
+ }
817
+ entry.values.set(name, value);
818
+ } else {
819
+ if (entry === undefined) {
820
+ entry = { instrument: instrument.id, port: instrument.port, channel: call.channel, sound: sound === null ? call.sound : sound.name, engine, tone: new Map(), part: new Map() };
821
+ settings.set(slot, entry);
822
+ }
823
+ // Two calls on one channel share its slot: the first may have written part settings only (no engine), so the engine
824
+ // of the tone is the one of the call that writes tone settings, and two calls whose sounds are in different engines are refused.
825
+ if (level === 'tone' && engine !== null) {
826
+ if (entry.engine === null) entry.engine = engine;
827
+ else if (entry.engine !== engine) {
828
+ const first = entry.engine;
829
+ return () => [fill(RIG_COPY.engineConflict, { id: instrument.id, channel: call.channel, a: engineName(first), b: engineName(engine) }), RIG_COPY.oneSoundPerChannel];
830
+ }
831
+ }
832
+ entry[level].set(name, value);
833
+ }
834
+ }
835
+ }
836
+ return null;
837
+ }
838
+
571
839
  /**
572
840
  * Composes the refusal from the problems in code order: the first problem's lines (its lists shrink
573
841
  * until the text fits), then 'Also:' lines with line 1 of the next problems while they fit, then one
@@ -620,11 +888,14 @@ function composeInvalid(rig, closing) {
620
888
  * Checks the `.midi(` calls of a piece of code against the rig. `read` is the result of
621
889
  * readMidiSetup, `rig` the object parseRigMessage returned, `outputs` the connected output names (they
622
890
  * only decide the wording of a port that is missing from the file) and `playing` whether the page plays
623
- * now. Returns { refusal, desired, controls }: `refusal` is the whole text of the failed evaluation (null
891
+ * now. Returns { refusal, desired, controls, settings, scenes }: `refusal` is the whole text of the failed evaluation (null
624
892
  * when the code may run), `desired` the sound each used instrument channel must have, keyed
625
893
  * `<instrument id>:<channel>`: { instrument, port, channel, role, sound, program, bank }, and `controls`
626
894
  * the controls the code writes, keyed the same way: { instrument, port, channel, values: Map<cc, { id, value }> }.
627
- * `desired` and `controls` are empty Maps on every refusal and for an absent or invalid setup.
895
+ * `settings` the tone and part settings the code writes, keyed the same way: { instrument, port, channel, sound, engine,
896
+ * tone: Map<name, value>, part: Map<name, value> } (`sound` is the name the call wrote or null), and `scenes` the scene
897
+ * settings keyed by instrument id: { instrument, port, values: Map<name, value> }.
898
+ * `desired`, `controls`, `settings` and `scenes` are empty Maps on every refusal and for an absent or invalid setup.
628
899
  * Every call is checked in source order and, within one call, port, channel, sound, then controls; a port
629
900
  * equals a file port only by exact string equality.
630
901
  * `playingPorts` is the set of ports the playing code already names with .midi(): a call to the port of an
@@ -635,10 +906,12 @@ export function checkSetup({ read, rig, outputs = [], playing, playingPorts = ne
635
906
  const closing = playing === true ? RIG_COPY.closingPlaying : RIG_COPY.closingStopped;
636
907
  const desired = new Map();
637
908
  const controls = new Map();
638
- if (rig.setup === 'absent') return { refusal: null, desired, controls };
909
+ const settings = new Map();
910
+ const scenes = new Map();
911
+ if (rig.setup === 'absent') return { refusal: null, desired, controls, settings, scenes };
639
912
  if (rig.setup === 'invalid') {
640
- if (read.calls.length === 0) return { refusal: null, desired, controls };
641
- return { refusal: composeInvalid(rig, closing), desired, controls };
913
+ if (read.calls.length === 0) return { refusal: null, desired, controls, settings, scenes };
914
+ return { refusal: composeInvalid(rig, closing), desired, controls, settings, scenes };
642
915
  }
643
916
  if (rig.setup !== 'valid') {
644
917
  throw new Error(`Moontide: the rig setup must be absent, invalid or valid, got ${JSON.stringify(rig.setup)}.`);
@@ -647,9 +920,9 @@ export function checkSetup({ read, rig, outputs = [], playing, playingPorts = ne
647
920
  // The sound as the code wrote it for each slot: a conflict names both spellings.
648
921
  const written = new Map();
649
922
  for (const call of read.calls) {
650
- const lines = problemLines(call, rig, outputs, desired, written, controls, playingPorts);
923
+ const lines = problemLines(call, rig, outputs, desired, written, controls, settings, scenes, playingPorts);
651
924
  if (lines !== null) problems.push(lines);
652
925
  }
653
- if (problems.length === 0) return { refusal: null, desired, controls };
654
- return { refusal: composeRefusal(problems, closing), desired: new Map(), controls: new Map() };
926
+ if (problems.length === 0) return { refusal: null, desired, controls, settings, scenes };
927
+ return { refusal: composeRefusal(problems, closing), desired: new Map(), controls: new Map(), settings: new Map(), scenes: new Map() };
655
928
  }