moontide-mcp 2.1.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/README.md +54 -331
- package/dist/ask.js +59 -12
- package/dist/catalog.js +118 -9
- package/dist/instruments.js +83 -9
- package/dist/mcp.js +146 -20
- package/dist/midi-state.js +43 -1
- package/dist/protocol.js +123 -5
- package/dist/reference.js +12 -0
- package/dist/request-status.js +221 -0
- package/dist/session.js +203 -7
- package/dist/vocabulary.js +420 -0
- package/package.json +3 -2
- package/page/ask-view.js +118 -50
- package/page/ask.js +139 -169
- package/page/bar-model.js +44 -2
- package/page/change-timing.js +35 -0
- package/page/index.html +6 -2
- package/page/instruments-model.js +50 -3
- package/page/instruments-view.js +14 -2
- package/page/jam-analysis.js +68 -23
- package/page/jam-model.js +185 -29
- package/page/jam-resolve.js +271 -0
- package/page/jam-view.js +165 -10
- package/page/lights.js +29 -7
- package/page/main.js +807 -66
- package/page/midi-clock.js +22 -3
- package/page/midi-in.js +407 -0
- package/page/midi-out.js +557 -48
- package/page/midi-plan.js +42 -6
- package/page/midi-rig.js +286 -13
- package/page/midi-settings.js +507 -0
- package/page/mixer-view.js +7 -1
- package/page/note-panel.js +29 -8
- package/page/note-tag.js +19 -4
- package/page/page-rewrite.js +1 -1
- package/page/played-buffer.js +208 -0
- package/page/query-check.js +21 -2
- package/page/request-status-view.js +419 -0
- package/page/request-status.js +686 -0
- package/page/scratch-gesture.js +239 -0
- package/page/scratch-listen.js +260 -0
- package/page/scratch-model.js +1696 -0
- package/page/scratch-view.js +2176 -0
- package/page/song-grid-model.js +15 -0
- package/page/song-grid-view.js +1 -1
- package/page/structure.js +2 -1
- package/page/style.css +1243 -43
- package/page/tempo-glide.js +330 -0
- package/page/tempo-guard.js +62 -10
- package/page/timeline-edit-view.js +1 -1
- package/page/timeline.js +2 -1
- package/page/top-bar-view.js +18 -13
- package/page/views.js +20 -7
- package/reference/midi.md +2 -0
- package/reference/motif.md +83 -0
- package/reference/settings.md +161 -0
- package/reference/transitions.md +13 -1
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 = {
|
|
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 {
|
|
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>,
|
|
327
|
-
*
|
|
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).
|
|
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
|
-
|
|
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
|
-
* `
|
|
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
|
-
|
|
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
|
}
|