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
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
// The settings vocabulary: the names code uses to set a tone, a part or a scene, and the optional
|
|
2
|
+
// `settings` description of an instrument model that maps those names to the messages the instrument
|
|
3
|
+
// takes. The names say nothing about how a setting is sent: a model maps the same name to a Roland data
|
|
4
|
+
// set, a control change or an NRPN, and the code does not change. A wrong description is a list of
|
|
5
|
+
// problems (a field and a reason), never an exception, so a hand edit in progress cannot crash the server.
|
|
6
|
+
//
|
|
7
|
+
// Nothing here sends a message: the page receives the names, the ranges and the message of each setting
|
|
8
|
+
// (settingsMessage) and builds the bytes itself, so the model file stays the one place that says where a setting goes.
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
import { MAX_RIG_ENGINES, MAX_RIG_SETTINGS } from './protocol.js';
|
|
11
|
+
import { CONTROL_CHARS, dataByteSchema, idSchema } from './rig-util.js';
|
|
12
|
+
export const SETTING_LEVELS = ['tone', 'part', 'scene'];
|
|
13
|
+
// The tone names of one oscillator, the filter, the two envelopes, the LFO and the FM engine's global
|
|
14
|
+
// settings, then the names of the engines whose settings are their own (the number of a partial sits inside
|
|
15
|
+
// the name); the six operators follow, built from one list so their names stay in step.
|
|
16
|
+
const TONE_NAMES = [
|
|
17
|
+
'osc1Wave', 'osc1Feet', 'osc1Tune', 'osc1Level',
|
|
18
|
+
'osc2Wave', 'osc2Feet', 'osc2Tune', 'osc2Level',
|
|
19
|
+
'filterType', 'filterCutoff', 'filterResonance', 'filterEnvDepth',
|
|
20
|
+
'env1Attack', 'env1Decay', 'env1Sustain', 'env1Release',
|
|
21
|
+
'env2Attack', 'env2Decay', 'env2Sustain', 'env2Release',
|
|
22
|
+
'lfoRate', 'lfoOscDepth', 'lfoFilterDepth',
|
|
23
|
+
'algorithm', 'feedback',
|
|
24
|
+
'partial1Cutoff', 'toneCoarseTune',
|
|
25
|
+
'partialMute', 'partial1Level',
|
|
26
|
+
];
|
|
27
|
+
// The part names that are offsets from the instrument's normal sound (an NRPN of the GS family): the lowest value moves the
|
|
28
|
+
// sound down, it does not close or silence anything.
|
|
29
|
+
const PART_OFFSET_NAMES = [
|
|
30
|
+
'vibratoRateOffset', 'vibratoDepthOffset', 'vibratoDelayOffset',
|
|
31
|
+
'cutoffOffset', 'resonanceOffset',
|
|
32
|
+
'attackOffset', 'decayOffset', 'releaseOffset',
|
|
33
|
+
];
|
|
34
|
+
const OPERATOR_SETTINGS = ['Level', 'Ratio', 'Detune', 'Attack', 'Decay', 'Sustain', 'Release'];
|
|
35
|
+
const OPERATOR_COUNT = 6;
|
|
36
|
+
const operatorNames = [];
|
|
37
|
+
for (let operator = 1; operator <= OPERATOR_COUNT; operator++) {
|
|
38
|
+
for (const setting of OPERATOR_SETTINGS)
|
|
39
|
+
operatorNames.push(`op${operator}${setting}`);
|
|
40
|
+
}
|
|
41
|
+
// The settings of one partial of a timbre with four partials, in the order of the instrument's own table (pitch, wave,
|
|
42
|
+
// pitch envelope, pitch LFO, filter, filter envelope, volume, volume envelope); the four partials are built from this one
|
|
43
|
+
// list so their names stay in step. Two names of partial 1 already stand in TONE_NAMES and are not added twice.
|
|
44
|
+
const PARTIAL_SETTINGS = [
|
|
45
|
+
'CoarseTune', 'FineTune', 'PitchKeyFollow', 'BendSwitch', 'Wave', 'PcmWave', 'PulseWidth', 'PulseWidthVelocity',
|
|
46
|
+
'PitchEnvDepth', 'PitchEnvVelocity', 'PitchEnvTimeKeyFollow',
|
|
47
|
+
'PitchEnvTime1', 'PitchEnvTime2', 'PitchEnvTime3', 'PitchEnvTime4',
|
|
48
|
+
'PitchEnvLevel0', 'PitchEnvLevel1', 'PitchEnvLevel2', 'PitchEnvSustain', 'PitchEnvEnd',
|
|
49
|
+
'PitchLfoRate', 'PitchLfoDepth', 'PitchLfoModSens',
|
|
50
|
+
'Cutoff', 'Resonance', 'CutoffKeyFollow', 'FilterBiasPoint', 'FilterBiasLevel',
|
|
51
|
+
'FilterEnvDepth', 'FilterEnvVelocity', 'FilterEnvDepthKeyFollow', 'FilterEnvTimeKeyFollow',
|
|
52
|
+
'FilterEnvTime1', 'FilterEnvTime2', 'FilterEnvTime3', 'FilterEnvTime4', 'FilterEnvTime5',
|
|
53
|
+
'FilterEnvLevel1', 'FilterEnvLevel2', 'FilterEnvLevel3', 'FilterEnvSustain',
|
|
54
|
+
'Level', 'LevelVelocity', 'AmpBiasPoint1', 'AmpBiasLevel1', 'AmpBiasPoint2', 'AmpBiasLevel2',
|
|
55
|
+
'AmpEnvTimeKeyFollow', 'AmpEnvTimeVelocity',
|
|
56
|
+
'AmpEnvTime1', 'AmpEnvTime2', 'AmpEnvTime3', 'AmpEnvTime4', 'AmpEnvTime5',
|
|
57
|
+
'AmpEnvLevel1', 'AmpEnvLevel2', 'AmpEnvLevel3', 'AmpEnvSustain',
|
|
58
|
+
];
|
|
59
|
+
const PARTIAL_COUNT = 4;
|
|
60
|
+
// The tone names of a timbre with four partials after the earlier names: how two partials are joined, the envelope mode,
|
|
61
|
+
// every partial, then the settings of the patch that a program change loads again.
|
|
62
|
+
const timbreNames = ['structure12', 'structure34', 'envMode'];
|
|
63
|
+
for (let partial = 1; partial <= PARTIAL_COUNT; partial++) {
|
|
64
|
+
for (const setting of PARTIAL_SETTINGS) {
|
|
65
|
+
const name = `partial${partial}${setting}`;
|
|
66
|
+
if (!TONE_NAMES.includes(name))
|
|
67
|
+
timbreNames.push(name);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
timbreNames.push('keyShift', 'toneFineTune', 'bendRange', 'assignMode', 'toneReverbSwitch');
|
|
71
|
+
/** The setting names of each level, in the order a list shows them. Room for the FM engine is already here. */
|
|
72
|
+
export const SETTING_VOCABULARY = Object.freeze({
|
|
73
|
+
tone: Object.freeze([...TONE_NAMES, ...operatorNames, ...timbreNames]),
|
|
74
|
+
part: Object.freeze(['level', 'pan', 'chorusSend', 'reverbSend', 'delaySend', ...PART_OFFSET_NAMES]),
|
|
75
|
+
scene: Object.freeze([
|
|
76
|
+
'level',
|
|
77
|
+
'chorusSwitch', 'chorusType', 'chorusLevel',
|
|
78
|
+
'delaySwitch', 'delayType', 'delayLevel',
|
|
79
|
+
'reverbSwitch', 'reverbType', 'reverbLevel',
|
|
80
|
+
'driveAmount', 'driveSwitch',
|
|
81
|
+
'masterTune', 'reverbTime',
|
|
82
|
+
]),
|
|
83
|
+
});
|
|
84
|
+
const settingValueSchema = z.number().int().min(0).max(65535);
|
|
85
|
+
// The message a setting becomes: a Roland data set at an address, an NRPN or a control change.
|
|
86
|
+
const messageSchema = z.discriminatedUnion('kind', [
|
|
87
|
+
z.strictObject({ kind: z.literal('roland-dt1'), address: z.string(), size: z.union([z.literal(1), z.literal(2), z.literal(4)]) }),
|
|
88
|
+
z.strictObject({ kind: z.literal('nrpn'), msb: dataByteSchema, lsb: dataByteSchema }),
|
|
89
|
+
z.strictObject({ kind: z.literal('cc'), cc: dataByteSchema }),
|
|
90
|
+
]);
|
|
91
|
+
const rowSchema = z.strictObject({
|
|
92
|
+
name: z.string().min(1).max(40),
|
|
93
|
+
min: settingValueSchema,
|
|
94
|
+
max: settingValueSchema,
|
|
95
|
+
// Tone rows only, and required when the model declares engines: the engines that have this setting.
|
|
96
|
+
engines: z.array(idSchema).min(1).max(MAX_RIG_ENGINES).optional(),
|
|
97
|
+
message: messageSchema,
|
|
98
|
+
});
|
|
99
|
+
/** The optional `settings` field of a model; every key is optional, and a model without it stays valid. */
|
|
100
|
+
export const settingsSchema = z.strictObject({
|
|
101
|
+
// The engines a tone can belong to (the sound catalog places a sound on one of them).
|
|
102
|
+
engines: z.array(z.strictObject({ id: idSchema, name: z.string().min(1).max(40) })).min(1).max(MAX_RIG_ENGINES).optional(),
|
|
103
|
+
// The model ID bytes of the Roland data sets, as hex text.
|
|
104
|
+
rolandModel: z.string().optional(),
|
|
105
|
+
// The NRPN family of the instrument's NRPN rows. Moontide knows one: 'gs', the part offsets of the Roland GS family.
|
|
106
|
+
nrpnFamily: z.literal('gs').optional(),
|
|
107
|
+
tone: z.array(rowSchema).max(MAX_RIG_SETTINGS).optional(),
|
|
108
|
+
part: z.array(rowSchema).max(MAX_RIG_SETTINGS).optional(),
|
|
109
|
+
scene: z.array(rowSchema).max(MAX_RIG_SETTINGS).optional(),
|
|
110
|
+
});
|
|
111
|
+
/**
|
|
112
|
+
* The largest value a message carries: a control change 7 bits, an NRPN 7 bits (the page sends the data entry MSB only),
|
|
113
|
+
* and a Roland data set cuts a value into 4-bit pieces, one per byte of its size.
|
|
114
|
+
*/
|
|
115
|
+
export function messageMax(message) {
|
|
116
|
+
if (message.kind === 'cc')
|
|
117
|
+
return 127;
|
|
118
|
+
if (message.kind === 'nrpn')
|
|
119
|
+
return 127;
|
|
120
|
+
if (message.size === 1)
|
|
121
|
+
return 127;
|
|
122
|
+
if (message.size === 2)
|
|
123
|
+
return 255;
|
|
124
|
+
return 65535;
|
|
125
|
+
}
|
|
126
|
+
function editDistance(a, b) {
|
|
127
|
+
let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
128
|
+
for (let row = 1; row <= a.length; row++) {
|
|
129
|
+
const current = [row];
|
|
130
|
+
for (let column = 1; column <= b.length; column++) {
|
|
131
|
+
const same = a[row - 1] === b[column - 1] ? 0 : 1;
|
|
132
|
+
current[column] = Math.min(previous[column] + 1, current[column - 1] + 1, previous[column - 1] + same);
|
|
133
|
+
}
|
|
134
|
+
previous = current;
|
|
135
|
+
}
|
|
136
|
+
return previous[b.length];
|
|
137
|
+
}
|
|
138
|
+
/** The vocabulary name of a level closest to `name`, when it is at most 3 edits away. */
|
|
139
|
+
function closestName(level, name) {
|
|
140
|
+
let best = null;
|
|
141
|
+
let bestDistance = 4;
|
|
142
|
+
for (const candidate of SETTING_VOCABULARY[level]) {
|
|
143
|
+
const distance = editDistance(name, candidate);
|
|
144
|
+
if (distance < bestDistance) {
|
|
145
|
+
best = candidate;
|
|
146
|
+
bestDistance = distance;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return best;
|
|
150
|
+
}
|
|
151
|
+
const hex2 = (byte) => byte.toString(16).toUpperCase().padStart(2, '0');
|
|
152
|
+
/**
|
|
153
|
+
* The data range of each NRPN family the page knows (KNOWN_NRPNS in page/midi-settings.js; a test pins both): a row of the
|
|
154
|
+
* family whose range leaves it would be refused at every use, so it is refused when the model loads.
|
|
155
|
+
*/
|
|
156
|
+
export const NRPN_FAMILY_RANGES = Object.freeze({
|
|
157
|
+
gs: Object.freeze({ min: 14, max: 114 }),
|
|
158
|
+
});
|
|
159
|
+
/**
|
|
160
|
+
* The Roland model IDs whose temporary areas the page knows, with the length of the addresses their data sets take
|
|
161
|
+
* (TEMPORARY_AREAS in page/midi-settings.js; a test pins both). A row of another length would be refused at every use.
|
|
162
|
+
*/
|
|
163
|
+
export const ROLAND_ADDRESS_SIZES = Object.freeze([
|
|
164
|
+
Object.freeze({ model: Object.freeze([0x00, 0x00, 0x00, 0x65]), size: 4 }),
|
|
165
|
+
Object.freeze({ model: Object.freeze([0x16]), size: 3 }),
|
|
166
|
+
]);
|
|
167
|
+
/**
|
|
168
|
+
* Why hex text is not 1 to 4 two-digit bytes of at most 7F (3 or 4 with `isAddress`: a data set takes a three-byte or a
|
|
169
|
+
* four-byte address, and a shorter one would make the instrument read data bytes as address bytes; which of the two the
|
|
170
|
+
* instrument takes is checked when the bytes are built), or null when it is.
|
|
171
|
+
*/
|
|
172
|
+
function hexProblem(text, isAddress = false) {
|
|
173
|
+
const parts = text.trim().split(/\s+/).filter((part) => part !== '');
|
|
174
|
+
if (parts.length === 0)
|
|
175
|
+
return 'must not be empty';
|
|
176
|
+
for (const part of parts) {
|
|
177
|
+
if (!/^[0-9a-fA-F]{2}$/.test(part))
|
|
178
|
+
return `"${part}" is not a two-digit hex byte`;
|
|
179
|
+
}
|
|
180
|
+
if (isAddress && (parts.length < 3 || parts.length > 4))
|
|
181
|
+
return 'must be 3 or 4 bytes';
|
|
182
|
+
if (parts.length > 4)
|
|
183
|
+
return 'must be 1 to 4 bytes';
|
|
184
|
+
for (let index = 0; index < parts.length; index++) {
|
|
185
|
+
const byte = Number.parseInt(parts[index], 16);
|
|
186
|
+
if (byte > 0x7f)
|
|
187
|
+
return `byte ${index} (${hex2(byte)}) is above 7F`;
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
/** The engine rules: ids and names unique, a tone row names declared engines when the model declares some, no other row names any. */
|
|
192
|
+
function checkEngines(settings, add) {
|
|
193
|
+
const declared = settings.engines;
|
|
194
|
+
const first = new Map();
|
|
195
|
+
const firstNamed = new Map();
|
|
196
|
+
(declared ?? []).forEach((engine, index) => {
|
|
197
|
+
if (CONTROL_CHARS.test(engine.name))
|
|
198
|
+
add(`engines[${index}].name`, 'contains a control character');
|
|
199
|
+
const earlier = first.get(engine.id);
|
|
200
|
+
if (earlier === undefined)
|
|
201
|
+
first.set(engine.id, index);
|
|
202
|
+
else
|
|
203
|
+
add(`engines[${index}].id`, `same id as engines[${earlier}]`);
|
|
204
|
+
const sameName = firstNamed.get(engine.name);
|
|
205
|
+
if (sameName === undefined)
|
|
206
|
+
firstNamed.set(engine.name, index);
|
|
207
|
+
else
|
|
208
|
+
add(`engines[${index}].name`, `same name as engines[${sameName}]`);
|
|
209
|
+
});
|
|
210
|
+
(settings.tone ?? []).forEach((row, index) => {
|
|
211
|
+
const at = `tone[${index}].engines`;
|
|
212
|
+
if (declared === undefined) {
|
|
213
|
+
if (row.engines !== undefined)
|
|
214
|
+
add(at, 'the model declares no engines');
|
|
215
|
+
}
|
|
216
|
+
else if (row.engines === undefined) {
|
|
217
|
+
add(at, 'required: the model declares engines');
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
row.engines.forEach((id, position) => {
|
|
221
|
+
if (!first.has(id))
|
|
222
|
+
add(`${at}[${position}]`, `unknown engine "${id}", engines: ${declared.map((engine) => engine.id).join(', ')}`);
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
for (const level of ['part', 'scene']) {
|
|
227
|
+
(settings[level] ?? []).forEach((row, index) => {
|
|
228
|
+
if (row.engines !== undefined)
|
|
229
|
+
add(`${level}[${index}].engines`, `${level} settings reach every engine`);
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Why a row repeats the name of earlier rows of its level, or null when it may. A tone name may have one row per engine
|
|
235
|
+
* (the same setting sits at another address on each engine), so a tone row may repeat a name only when the model declares
|
|
236
|
+
* engines and no engine appears in two rows of the name; every other repeat is refused.
|
|
237
|
+
*/
|
|
238
|
+
function repeatProblem(level, settings, row, earlier) {
|
|
239
|
+
const rows = settings[level] ?? [];
|
|
240
|
+
for (const index of earlier) {
|
|
241
|
+
const other = rows[index];
|
|
242
|
+
const perEngine = level === 'tone' && settings.engines !== undefined && row.engines !== undefined && other.engines !== undefined;
|
|
243
|
+
if (!perEngine)
|
|
244
|
+
return `same name as ${level}[${index}]`;
|
|
245
|
+
const shared = row.engines.find((id) => other.engines.includes(id));
|
|
246
|
+
if (shared !== undefined)
|
|
247
|
+
return `same name as ${level}[${index}], and both reach engine "${shared}"`;
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* The checks that need more than one field of a settings description; the shape is already valid.
|
|
253
|
+
* Paths are relative to the settings object (`tone[0].name`); the empty path is the object itself.
|
|
254
|
+
*/
|
|
255
|
+
export function settingsProblems(settings) {
|
|
256
|
+
const problems = [];
|
|
257
|
+
if (settings === undefined)
|
|
258
|
+
return problems;
|
|
259
|
+
const add = (field, reason) => {
|
|
260
|
+
problems.push({ field, reason });
|
|
261
|
+
};
|
|
262
|
+
if (SETTING_LEVELS.every((level) => (settings[level] ?? []).length === 0))
|
|
263
|
+
add('', 'describes no setting');
|
|
264
|
+
let needsRolandModel = false;
|
|
265
|
+
let needsNrpnFamily = false;
|
|
266
|
+
// The rows with a well-formed data set address, checked against the address length of the model ID below.
|
|
267
|
+
const addressed = [];
|
|
268
|
+
for (const level of SETTING_LEVELS) {
|
|
269
|
+
// The rows seen so far by name: a tone name may repeat once per engine (see repeatProblem), no other name may.
|
|
270
|
+
const seen = new Map();
|
|
271
|
+
(settings[level] ?? []).forEach((row, index) => {
|
|
272
|
+
const at = `${level}[${index}]`;
|
|
273
|
+
if (!SETTING_VOCABULARY[level].includes(row.name)) {
|
|
274
|
+
const other = SETTING_LEVELS.find((candidate) => candidate !== level && SETTING_VOCABULARY[candidate].includes(row.name));
|
|
275
|
+
const closest = closestName(level, row.name);
|
|
276
|
+
add(`${at}.name`, other !== undefined
|
|
277
|
+
? `"${row.name}" is a ${other} setting name, not a ${level} one`
|
|
278
|
+
: `"${row.name}" is not a ${level} setting name${closest === null ? '' : ` (closest: ${closest})`}`);
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
const earlier = seen.get(row.name) ?? [];
|
|
282
|
+
const problem = repeatProblem(level, settings, row, earlier);
|
|
283
|
+
if (problem !== null)
|
|
284
|
+
add(`${at}.name`, problem);
|
|
285
|
+
seen.set(row.name, [...earlier, index]);
|
|
286
|
+
}
|
|
287
|
+
const carried = messageMax(row.message);
|
|
288
|
+
if (row.max < row.min)
|
|
289
|
+
add(`${at}.max`, 'must not be below min');
|
|
290
|
+
else if (row.max > carried)
|
|
291
|
+
add(`${at}.max`, `${row.max} is more than this message carries (${carried})`);
|
|
292
|
+
if (row.message.kind === 'roland-dt1') {
|
|
293
|
+
needsRolandModel = true;
|
|
294
|
+
const address = hexProblem(row.message.address, true);
|
|
295
|
+
if (address !== null)
|
|
296
|
+
add(`${at}.message.address`, address);
|
|
297
|
+
else
|
|
298
|
+
addressed.push({ at, length: row.message.address.trim().split(/\s+/).length });
|
|
299
|
+
}
|
|
300
|
+
if (row.message.kind === 'nrpn') {
|
|
301
|
+
needsNrpnFamily = true;
|
|
302
|
+
if (level === 'scene')
|
|
303
|
+
add(`${at}.message`, 'an NRPN reaches one channel, so it is not a scene setting');
|
|
304
|
+
if (settings.nrpnFamily !== undefined) {
|
|
305
|
+
const family = NRPN_FAMILY_RANGES[settings.nrpnFamily];
|
|
306
|
+
if (row.min < family.min)
|
|
307
|
+
add(`${at}.min`, `${row.min} is below the lowest value of the ${settings.nrpnFamily} family (${family.min})`);
|
|
308
|
+
if (row.max > family.max)
|
|
309
|
+
add(`${at}.max`, `${row.max} is above the highest value of the ${settings.nrpnFamily} family (${family.max})`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
checkEngines(settings, add);
|
|
315
|
+
if (settings.rolandModel === undefined) {
|
|
316
|
+
if (needsRolandModel)
|
|
317
|
+
add('rolandModel', 'required: a roland-dt1 setting needs the model ID');
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
const bad = hexProblem(settings.rolandModel);
|
|
321
|
+
if (bad !== null)
|
|
322
|
+
add('rolandModel', bad);
|
|
323
|
+
else {
|
|
324
|
+
// A model ID whose temporary areas Moontide knows takes one address length; any other takes 3 or 4 bytes.
|
|
325
|
+
const model = hexToBytes(settings.rolandModel);
|
|
326
|
+
const known = ROLAND_ADDRESS_SIZES.find((entry) => entry.model.length === model.length && entry.model.every((byte, index) => byte === model[index]));
|
|
327
|
+
if (known !== undefined) {
|
|
328
|
+
const modelText = model.map(hex2).join(' ');
|
|
329
|
+
for (const row of addressed) {
|
|
330
|
+
if (row.length !== known.size)
|
|
331
|
+
add(`${row.at}.message.address`, `must be ${known.size} bytes: the data sets of model ID ${modelText} take ${known.size}-byte addresses`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (needsNrpnFamily && settings.nrpnFamily === undefined)
|
|
337
|
+
add('nrpnFamily', 'required: an nrpn setting needs the NRPN family');
|
|
338
|
+
return problems;
|
|
339
|
+
}
|
|
340
|
+
/** Hex text as bytes. The description was checked when it was read, so a bad byte here is a fault: it throws. */
|
|
341
|
+
function hexToBytes(text) {
|
|
342
|
+
return text
|
|
343
|
+
.trim()
|
|
344
|
+
.split(/\s+/)
|
|
345
|
+
.map((part) => {
|
|
346
|
+
if (!/^[0-9a-fA-F]{2}$/.test(part))
|
|
347
|
+
throw new Error(`Moontide: "${part}" is not a two-digit hex byte.`);
|
|
348
|
+
return Number.parseInt(part, 16);
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
/** The message of one row for the page, copied key by key so nothing else of the model rides along. */
|
|
352
|
+
function rowMessage(message) {
|
|
353
|
+
if (message.kind === 'roland-dt1')
|
|
354
|
+
return { kind: 'roland-dt1', address: hexToBytes(message.address), size: message.size };
|
|
355
|
+
if (message.kind === 'nrpn')
|
|
356
|
+
return { kind: 'nrpn', msb: message.msb, lsb: message.lsb };
|
|
357
|
+
return { kind: 'cc', cc: message.cc };
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* The settings as the page gets them: names, ranges and the message of each row, copied key by key so no other
|
|
361
|
+
* key of the model rides along. Null when the model describes no settings.
|
|
362
|
+
*/
|
|
363
|
+
export function settingsMessage(settings) {
|
|
364
|
+
if (settings === undefined)
|
|
365
|
+
return null;
|
|
366
|
+
const declared = settings.engines !== undefined;
|
|
367
|
+
const row = (entry, withEngines) => ({
|
|
368
|
+
name: entry.name,
|
|
369
|
+
min: entry.min,
|
|
370
|
+
max: entry.max,
|
|
371
|
+
...(withEngines && declared && entry.engines !== undefined ? { engines: [...entry.engines] } : {}),
|
|
372
|
+
message: rowMessage(entry.message),
|
|
373
|
+
});
|
|
374
|
+
return {
|
|
375
|
+
engines: (settings.engines ?? []).map((engine) => ({ id: engine.id, name: engine.name })),
|
|
376
|
+
rolandModel: settings.rolandModel === undefined ? null : hexToBytes(settings.rolandModel),
|
|
377
|
+
nrpnFamily: settings.nrpnFamily ?? null,
|
|
378
|
+
tone: (settings.tone ?? []).map((entry) => row(entry, true)),
|
|
379
|
+
part: (settings.part ?? []).map((entry) => row(entry, false)),
|
|
380
|
+
scene: (settings.scene ?? []).map((entry) => row(entry, false)),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
const plural = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`;
|
|
384
|
+
function messageText(message) {
|
|
385
|
+
if (message.kind === 'cc')
|
|
386
|
+
return `control change ${message.cc}`;
|
|
387
|
+
if (message.kind === 'nrpn')
|
|
388
|
+
return `NRPN ${message.msb} ${message.lsb}`;
|
|
389
|
+
return `Roland data set at ${hexBytesText(message.address)} over ${plural(message.size, 'byte')}`;
|
|
390
|
+
}
|
|
391
|
+
/** Hex text as two-digit upper-case bytes joined by single spaces. */
|
|
392
|
+
function hexBytesText(text) {
|
|
393
|
+
return text
|
|
394
|
+
.trim()
|
|
395
|
+
.split(/\s+/)
|
|
396
|
+
.map((part) => part.toUpperCase())
|
|
397
|
+
.join(' ');
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* The save summary lines of a settings description, one fact per line, so nothing that reaches hardware
|
|
401
|
+
* later is hidden behind the user's yes: the engines, the Roland model ID, then every setting row in level order.
|
|
402
|
+
*/
|
|
403
|
+
export function settingsSummary(settings) {
|
|
404
|
+
const lines = [];
|
|
405
|
+
if (settings.engines !== undefined) {
|
|
406
|
+
lines.push(`Settings engines: ${settings.engines.map((engine) => `${engine.id} (${engine.name})`).join(', ')}.`);
|
|
407
|
+
}
|
|
408
|
+
if (settings.rolandModel !== undefined)
|
|
409
|
+
lines.push(`Settings Roland model ID: ${hexBytesText(settings.rolandModel)}.`);
|
|
410
|
+
if (settings.nrpnFamily !== undefined)
|
|
411
|
+
lines.push(`Settings NRPN family: ${settings.nrpnFamily}.`);
|
|
412
|
+
for (const level of SETTING_LEVELS) {
|
|
413
|
+
const label = `${level.charAt(0).toUpperCase()}${level.slice(1)}`;
|
|
414
|
+
for (const row of settings[level] ?? []) {
|
|
415
|
+
const engines = row.engines === undefined ? '' : `, engines ${row.engines.join(', ')}`;
|
|
416
|
+
lines.push(`${label} setting ${row.name}: ${row.min}-${row.max}, ${messageText(row.message)}${engines}.`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return lines;
|
|
420
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "moontide-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"mcpName": "io.github.konni-arcadia/moontide",
|
|
5
|
-
"description": "Claude steers live music by writing Strudel code played in a browser page",
|
|
5
|
+
"description": "Early: Claude steers live music by writing Strudel code played in a browser page",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"mcp",
|
|
8
8
|
"mcp-server",
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"prepack": "npm run build",
|
|
47
47
|
"smoke:npx": "node scripts/smoke-npx.mjs",
|
|
48
48
|
"check:reference": "node scripts/check-reference.mjs",
|
|
49
|
+
"build:site": "node scripts/build-site.mjs",
|
|
49
50
|
"tint:report": "npm run build && node scripts/tint-report.mjs",
|
|
50
51
|
"probe:page": "npm run build && node scripts/probe-page.mjs",
|
|
51
52
|
"probe:midi": "npm run build && node scripts/probe-midi.mjs",
|