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/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)
|