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/README.md +54 -383
- package/dist/ask.js +12 -4
- package/dist/catalog.js +118 -9
- package/dist/instruments.js +83 -9
- package/dist/mcp.js +3 -3
- package/dist/midi-state.js +43 -1
- package/dist/protocol.js +80 -3
- package/dist/reference.js +12 -0
- package/dist/vocabulary.js +420 -0
- package/package.json +3 -2
- package/page/ask-view.js +77 -2
- package/page/ask.js +57 -4
- package/page/bar-model.js +1 -0
- package/page/index.html +2 -0
- package/page/instruments-model.js +50 -3
- package/page/instruments-view.js +14 -2
- package/page/main.js +348 -7
- package/page/midi-in.js +407 -0
- package/page/midi-out.js +552 -46
- package/page/midi-plan.js +42 -6
- package/page/midi-rig.js +286 -13
- package/page/midi-settings.js +507 -0
- package/page/played-buffer.js +208 -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/style.css +842 -0
- package/page/views.js +5 -5
- package/reference/midi.md +2 -0
- package/reference/motif.md +83 -0
- package/reference/settings.md +161 -0
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
|
-
/**
|
|
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) =>
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
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)
|
package/dist/instruments.js
CHANGED
|
@@ -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
|
|
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'),
|
package/dist/midi-state.js
CHANGED
|
@@ -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
|
}
|