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.
@@ -0,0 +1,407 @@
1
+ // Listening to the MIDI inputs the setup file names, for Moontide's browser page. It reads the notes a keyboard plays, places each one on the
2
+ // music's cycles by the packet's own time stamp (the rule of page/scratch-model.js: checkStamp, then cycleAtStamp) and hands it to the
3
+ // played-note buffer. Every cycle it uses comes from one cycle axis (page/played-buffer.js): the music's own cycle lines while the music
4
+ // plays, the scheduler's tempo while it is stopped, so the buffer keeps the last cycles either way. No DOM access and no browser global: the page gives it everything it needs, like page/midi-out.js, so node:test
5
+ // imports it directly. It never sends, never touches an output and stores nothing.
6
+ //
7
+ // The ports belong to Web MIDI, and Strudel's own enable already holds the one access: this module reads that access through
8
+ // `getAccess`, and when the user presses the button that offers it, asks for it through `enable`, the very enable the music's MIDI output uses, so
9
+ // the browser is asked once. It never asks on its own. webmidi.js owns the `onmidimessage` and `onstatechange` properties of every port and
10
+ // of the access, and keeps Strudel's port list up to date with them, so they are never assigned here; the standard event API listens beside
11
+ // them, and an added listener does not open a port, so the port is opened here too.
12
+
13
+ import { checkStamp, cycleAtStamp } from './scratch-model.js';
14
+ import { BUFFER_CYCLES, createCycleAxis, parseNoteMessage } from './played-buffer.js';
15
+
16
+ /** How often the listening tick reads the music's cycle lines while a port is listened to, in milliseconds. */
17
+ export const LISTEN_TICK_MS = 50;
18
+
19
+ /** The words of the setup file's state the page hands to `setInputs`. */
20
+ const SETUP_WORDS = ['absent', 'invalid', 'valid'];
21
+
22
+ /**
23
+ * Builds the listener and returns `{ setInputs, refresh, allow, state, snapshot, live, clearBuffer }`.
24
+ *
25
+ * - `getAccess()` gives the MIDIAccess Strudel's enable created, or null while there is none.
26
+ * - `accessState()` gives the access word of page/midi-out.js ('idle', 'pending', 'enabled', 'denied', 'unsupported' or 'failed'),
27
+ * `accessFailure()` the name of the failure when it is 'failed' (optional), and `enable()` the one shared enable that asks the browser; it
28
+ * never rejects. Only `allow()` calls it.
29
+ * - `isVisible()` tells whether this tab is shown and `holdsMusic()` whether this tab is the one that holds the music (not another Moontide tab).
30
+ * Only a visible tab that holds the music listens, so two tabs never keep two buffers; a tab that does not listen removes every listener,
31
+ * ends every held note at that moment and stops the tick, and keeps its buffer, so Record that still works on what it holds.
32
+ * - `readCycleLines()` gives the stamps of the two cycle lines around the music's now, `{ a: { c, t }, b: { c, t } }`, or null when the
33
+ * music's clock cannot give them (the music is stopped, or the clock is warming up). `readCps()` gives the scheduler's cycles per second,
34
+ * which it keeps while stopped, and `isPlaying()` whether the music plays. All three are read once per kept note, never for a message
35
+ * that is not a note, and go through the cycle axis.
36
+ * - `perfNow()` is the page clock in milliseconds, the clock event time stamps are on.
37
+ * - `setTimer(fn, ms)` and `clearTimer(handle)` run the listening tick; `buffer` is a played-note buffer (page/played-buffer.js).
38
+ * - `onChange()` is called whenever the state changed; `onError(message)` is called once per kind of refusal and for a port that failed.
39
+ *
40
+ * `setInputs(ports, setup)` takes the exact port names the setup file names and the file's own word ('absent', 'invalid' or 'valid'; a file
41
+ * that is not valid names no port). `refresh()` reads the access again and listens to what is named, and is safe at any time: the page calls
42
+ * it when the access, the rig or the ports change, and the access's own statechange event calls it too, so a plug or an unplug is seen with no
43
+ * reload. A listened port that is gone loses its listener, and every held note is ended at that moment (the buffer itself is kept).
44
+ * `allow()` is the press of the Allow MIDI to listen button: it asks through `enable`, then reads the access again.
45
+ *
46
+ * `state()` gives `{ listen, ports, listened, notOpened, missing, unplugged, several, available, failure, heard, refused, count }`. `listen` is one of
47
+ * 'absent' (no instruments.json), 'invalid' (the file has a problem), 'none' (a valid file with no input), 'needs-permission' (inputs named,
48
+ * MIDI not asked yet), 'pending' (the browser's prompt is open), 'denied', 'unsupported', 'failed' (see `failure`), 'other-tab' (another
49
+ * tab holds the music), 'hidden' (this tab is hidden: the page cannot tell a background tab from a window covered by another), 'not-opened'
50
+ * (a named input is connected but refused to open, and no other is heard), 'ambiguous' (several
51
+ * connected inputs carry exactly a named name, so none of them is listened to), 'unplugged' (a named input seen earlier in this page
52
+ * session is gone), 'missing' (a named input was never seen, it may be misspelled) or 'listening' (at least one named input is heard; the
53
+ * others are in `notOpened`, `missing` or `several`). `listened` holds the names heard (open or opening), `notOpened` those whose port
54
+ * refused to open (asked again at each refresh), `missing` every named name not connected once, `unplugged` those of them seen earlier, `several`
55
+ * those carried by several inputs, and `available` the names of the inputs connected now. `heard` is `{ pitch, velocity, cycle, source }`
56
+ * for the last note-on or null, `source` being 'music' (the music's own cycle lines) or 'free' (the tempo, with the music stopped);
57
+ * `refused` is the reason word of the last note-on that was not kept (a note-off is never refused: see offNow) ('zero', 'not-a-number', 'ahead', 'stale', 'clock' when there are no
58
+ * cycle lines and no tempo above 0, or 'full', the buffer's cap) until the next note is kept. `count` is the notes the buffer holds at the
59
+ * latest cycle. `snapshot()` reads the axis cycle at the present moment and gives the buffer's snapshot at that cycle
60
+ * (page/played-buffer.js), or null when there are no cycle lines and no tempo above 0.
61
+ *
62
+ * `live()` is what the Scratchpad's live view reads on every animation frame: `{ now, revision }`, the axis cycle now and a whole number that
63
+ * grows whenever the buffer's snapshot would read differently apart from time passing: a note is kept, a held note ends, notes leave the
64
+ * window, held notes are closed, the buffer is emptied, or the window starts at a new whole cycle (which changes the cut of a note that
65
+ * reaches into it). It is null when there are no cycle lines and no tempo above 0. `clearBuffer()` empties the buffer, the count and the last
66
+ * heard note, as the press of Record that does once it has taken the notes, and tells the page; a key still held loses its held mark and its
67
+ * later note-off keeps nothing.
68
+ */
69
+ export function createMidiIn({
70
+ getAccess,
71
+ accessState,
72
+ accessFailure = () => null,
73
+ enable,
74
+ isVisible,
75
+ holdsMusic,
76
+ readCycleLines,
77
+ readCps,
78
+ isPlaying,
79
+ perfNow,
80
+ setTimer,
81
+ clearTimer,
82
+ buffer,
83
+ onChange = () => {},
84
+ onError = () => {},
85
+ }) {
86
+ for (const [name, value] of Object.entries({ getAccess, accessState, accessFailure, enable, isVisible, holdsMusic, readCycleLines, readCps, isPlaying, perfNow, setTimer, clearTimer })) {
87
+ if (typeof value !== 'function') throw new TypeError(`Moontide: the MIDI input listener needs ${name}, a function.`);
88
+ }
89
+ if (buffer === null || typeof buffer !== 'object') throw new TypeError('Moontide: the MIDI input listener needs a played-note buffer.');
90
+
91
+ let ports = [];
92
+ let setup = 'absent';
93
+ // The native ports that carry the handler now, by name, and the ports whose open() was asked for (once each).
94
+ const listened = new Map();
95
+ const opening = new WeakSet();
96
+ // The listened names whose open() was refused: they carry the handler but cannot be heard, so they do not count as listening.
97
+ const notOpened = new Set();
98
+ // The access objects that carry the statechange listener (one each), and the names seen connected at any time in this page session.
99
+ const watched = new WeakSet();
100
+ const seen = new Set();
101
+ let missing = [];
102
+ let unplugged = [];
103
+ let several = [];
104
+ let available = [];
105
+ let hasAccess = false;
106
+ let heard = null;
107
+ let refused = null;
108
+ let timer = null;
109
+ let lastCount = 0;
110
+ // Grows whenever the buffer reads differently (see `live`), and the first cycle of the window the last live read saw.
111
+ let revision = 0;
112
+ let lastWindow = null;
113
+ // The latest cycle the axis gave, to end held notes when the axis cannot be read any more.
114
+ let lastCycle = null;
115
+ const axis = createCycleAxis();
116
+ const reported = new Set();
117
+
118
+ const reportOnce = (key, message) => {
119
+ if (reported.has(key)) return;
120
+ reported.add(key);
121
+ onError(message);
122
+ };
123
+
124
+ function refuse(reason, message) {
125
+ refused = reason;
126
+ reportOnce(reason, message);
127
+ }
128
+
129
+ // The axis cycle at the moment `h`, with the anchors it was placed on, or null when there are no cycle lines and no tempo above 0.
130
+ const readAxis = (h) => {
131
+ const placed = axis.at(h, { music: readCycleLines(), cps: readCps(), playing: isPlaying() });
132
+ if (placed !== null) lastCycle = placed.now;
133
+ return placed;
134
+ };
135
+
136
+ // A note-off is never thrown away: one whose stamp is refused or that the axis cannot place still ends its held note, at the present
137
+ // cycle (or the last cycle the axis gave), so a late off does not leave a key held to the end of the window. It is not a refusal.
138
+ function offNow(h, pitch) {
139
+ const placed = readAxis(h);
140
+ const at = placed === null ? lastCycle : placed.now;
141
+ if (at !== null) endHeld(at, pitch);
142
+ lastCount = buffer.count();
143
+ }
144
+
145
+ // Ends the held note of a pitch, and moves the revision on only when there was one.
146
+ function endHeld(cycle, pitch) {
147
+ if (buffer.notes().some((note) => note.pitch === pitch && note.end === null)) revision += 1;
148
+ buffer.noteOff(cycle, pitch);
149
+ }
150
+
151
+ function onMessage(event) {
152
+ // The handler's own clock first, then the bytes: a message that is not a note (clock, active sensing, a controller) ends here, before
153
+ // any cycle line is read.
154
+ const h = perfNow();
155
+ const note = parseNoteMessage(event.data);
156
+ if (note === null) return;
157
+ // The packet's own stamp decides where the note lands, never the time this code runs.
158
+ const ts = event.timeStamp;
159
+ const reason = checkStamp(ts, h);
160
+ if (reason !== null) {
161
+ if (note.kind === 'off') offNow(h, note.pitch);
162
+ else refuse(reason, `Moontide: a note from a MIDI input was not kept, its time stamp was refused (${reason}).`);
163
+ onChange();
164
+ return;
165
+ }
166
+ const placed = readAxis(h);
167
+ if (placed === null) {
168
+ if (note.kind === 'off') offNow(h, note.pitch);
169
+ else refuse('clock', 'Moontide: a note from a MIDI input was not kept, the music gives no cycle and the tempo is not above 0.');
170
+ onChange();
171
+ return;
172
+ }
173
+ const cycle = cycleAtStamp(ts, placed.anchors.a, placed.anchors.b);
174
+ // A full buffer keeps saying so until a note is kept again; a note-off does not wipe the sentence.
175
+ if (note.kind === 'on' || refused !== 'full') refused = null;
176
+ if (note.kind === 'on') {
177
+ const result = buffer.noteOn(cycle, note.pitch, note.velocity, placed.source === 'music' ? placed.offset : null);
178
+ if (result.kept === false) {
179
+ refuse('full', 'Moontide: the keyboard buffer is full (2000 notes), so the newest notes were not kept.');
180
+ } else {
181
+ heard = { pitch: note.pitch, velocity: note.velocity, cycle, source: placed.source };
182
+ revision += 1;
183
+ }
184
+ } else {
185
+ endHeld(cycle, note.pitch);
186
+ }
187
+ lastCount = buffer.count();
188
+ onChange();
189
+ }
190
+
191
+ // Ends every held note at this moment. The buffer keeps its notes: only the ones still held get an end, so a key held down when the cable
192
+ // goes does not stay held for ever.
193
+ function closeHeld() {
194
+ if (!buffer.notes().some((note) => note.end === null)) return;
195
+ const placed = readAxis(perfNow());
196
+ const at = placed === null ? lastCycle : placed.now;
197
+ if (at !== null) {
198
+ buffer.closeAll(at);
199
+ revision += 1;
200
+ }
201
+ lastCount = buffer.count();
202
+ }
203
+
204
+ // Takes the handler off a port; true when the port carried it. The port is only unlistened, never closed: Strudel may use it.
205
+ function detach(name) {
206
+ const native = listened.get(name);
207
+ listened.delete(name);
208
+ notOpened.delete(name);
209
+ if (native === undefined) return false;
210
+ native.removeEventListener('midimessage', onMessage);
211
+ return true;
212
+ }
213
+
214
+ function attach(name, native) {
215
+ if (listened.get(name) === native) {
216
+ // A port whose open() failed is asked again at each refresh (a plug, a rig change, the tab shown again).
217
+ if (!opening.has(native)) startOpen(name, native);
218
+ return false;
219
+ }
220
+ const replaced = detach(name);
221
+ native.addEventListener('midimessage', onMessage);
222
+ listened.set(name, native);
223
+ if (!opening.has(native)) startOpen(name, native);
224
+ return replaced;
225
+ }
226
+
227
+ // Opens a listened port. A refusal (another program may hold the input) is not listening: the name goes to `notOpened` until a later
228
+ // open succeeds, and the console says it once per name.
229
+ function startOpen(name, native) {
230
+ opening.add(native);
231
+ notOpened.delete(name);
232
+ Promise.resolve(native.open()).catch((err) => {
233
+ opening.delete(native);
234
+ if (listened.get(name) !== native) return;
235
+ notOpened.add(name);
236
+ reportOnce(`open:${name}`, `Moontide: the MIDI input "${name}" could not be opened: ${err instanceof Error ? err.message : String(err)}`);
237
+ onChange();
238
+ });
239
+ }
240
+
241
+ function snapshot() {
242
+ const placed = readAxis(perfNow());
243
+ return placed === null ? null : buffer.snapshot(placed.now);
244
+ }
245
+
246
+ // The axis cycle now and the revision, for the live view's frame. A new first cycle of the window moves the revision on, because the
247
+ // window cuts the notes that reach into it.
248
+ function live() {
249
+ const placed = readAxis(perfNow());
250
+ if (placed === null) return null;
251
+ const window = Math.floor(placed.now) + 1 - BUFFER_CYCLES;
252
+ if (lastWindow !== null && window !== lastWindow) revision += 1;
253
+ lastWindow = window;
254
+ return { now: placed.now, revision };
255
+ }
256
+
257
+ // Empties the buffer after Record that took what it held. The heard note and the count go with it, so the strip does not speak of notes
258
+ // that are gone.
259
+ function clearBuffer() {
260
+ buffer.clear();
261
+ heard = null;
262
+ lastCount = 0;
263
+ revision += 1;
264
+ onChange();
265
+ }
266
+
267
+ // The tick runs whenever a port is listened to, with the music playing or not. It reads the axis, which keeps the page clock warm on a page
268
+ // with no MIDI track (without it the first notes would meet a cold clock) and lets a Stop or a Play be seen within one tick, and tells
269
+ // the page when the count of kept notes changed.
270
+ function tick() {
271
+ timer = null;
272
+ if (listened.size === 0) return;
273
+ const placed = readAxis(perfNow());
274
+ // The count is taken at the present cycle, so notes that left the window are gone from it.
275
+ const count = placed === null ? buffer.count() : buffer.count(placed.now);
276
+ if (count !== lastCount) {
277
+ lastCount = count;
278
+ revision += 1;
279
+ onChange();
280
+ }
281
+ timer = setTimer(tick, LISTEN_TICK_MS);
282
+ }
283
+
284
+ function syncTick() {
285
+ if (listened.size > 0 && timer === null) timer = setTimer(tick, LISTEN_TICK_MS);
286
+ if (listened.size === 0 && timer !== null) {
287
+ clearTimer(timer);
288
+ timer = null;
289
+ }
290
+ }
291
+
292
+ // One listener on the access, by the standard event API: webmidi.js keeps the access's own handler property. Chrome fires several events for
293
+ // one plug; refresh is idempotent, so the extra ones change nothing.
294
+ function watch(access) {
295
+ if (watched.has(access) || typeof access.addEventListener !== 'function') return;
296
+ watched.add(access);
297
+ access.addEventListener('statechange', refresh);
298
+ }
299
+
300
+ function refresh() {
301
+ const access = getAccess();
302
+ hasAccess = access !== null && access !== undefined && typeof access === 'object' && access.inputs !== undefined && access.inputs !== null;
303
+ missing = [];
304
+ unplugged = [];
305
+ several = [];
306
+ available = [];
307
+ let lost = false;
308
+ const drop = (name) => {
309
+ if (detach(name)) lost = true;
310
+ };
311
+ if (hasAccess) watch(access);
312
+ if (!hasAccess || ports.length === 0) {
313
+ for (const name of [...listened.keys()]) drop(name);
314
+ } else {
315
+ const connected = [...access.inputs.values()].filter((input) => input !== null && typeof input === 'object' && input.state === 'connected');
316
+ available = connected.map((input) => String(input.name));
317
+ for (const name of available) seen.add(name);
318
+ for (const name of [...listened.keys()]) if (!ports.includes(name)) drop(name);
319
+ const away = !holdsMusic() || !isVisible();
320
+ // A tab that is hidden or has lost the music listens to nothing, so no note is kept twice.
321
+ if (away) for (const name of [...listened.keys()]) drop(name);
322
+ const toMatch = away ? [] : ports;
323
+ for (const name of toMatch) {
324
+ // A name is matched exactly and in full, never by a part of it.
325
+ const matches = connected.filter((input) => input.name === name);
326
+ if (matches.length === 1) {
327
+ if (attach(name, matches[0])) lost = true;
328
+ } else {
329
+ drop(name);
330
+ missing.push(name);
331
+ if (matches.length === 0) {
332
+ if (seen.has(name)) unplugged.push(name);
333
+ } else {
334
+ several.push(name);
335
+ reportOnce(`several:${name}`, `Moontide: several MIDI inputs are named exactly "${name}", so none is listened to.`);
336
+ }
337
+ }
338
+ }
339
+ }
340
+ if (lost) closeHeld();
341
+ syncTick();
342
+ onChange();
343
+ }
344
+
345
+ function setInputs(names, setupWord) {
346
+ if (!Array.isArray(names) || names.some((name) => typeof name !== 'string')) throw new TypeError('Moontide: the MIDI inputs must be a list of port names.');
347
+ if (!SETUP_WORDS.includes(setupWord)) throw new TypeError(`Moontide: the setup file's state must be one of ${SETUP_WORDS.join(', ')}, got ${JSON.stringify(setupWord)}.`);
348
+ const next = setupWord === 'valid' ? [...names] : [];
349
+ const changed = setupWord !== setup || next.length !== ports.length || next.some((name, index) => name !== ports[index]);
350
+ setup = setupWord;
351
+ ports = next;
352
+ // A rig change ends the held notes at that moment, whatever port they came from.
353
+ if (changed) closeHeld();
354
+ }
355
+
356
+ async function allow() {
357
+ try {
358
+ await enable();
359
+ } catch (err) {
360
+ onError(`Moontide: the MIDI permission could not be asked for: ${err instanceof Error ? err.message : String(err)}`);
361
+ }
362
+ refresh();
363
+ }
364
+
365
+ // The listened names whose port is open or still opening: the ones a note can come from.
366
+ const heardNames = () => [...listened.keys()].filter((name) => !notOpened.has(name));
367
+
368
+ function listenWord() {
369
+ if (setup === 'absent') return 'absent';
370
+ if (setup === 'invalid') return 'invalid';
371
+ if (ports.length === 0) return 'none';
372
+ const access = accessState();
373
+ if (access === 'idle') return 'needs-permission';
374
+ if (access !== 'enabled') return access;
375
+ // The access is said to be on but the page cannot read it: that is a failure to start, not a wait.
376
+ if (!hasAccess) return 'failed';
377
+ if (!holdsMusic()) return 'other-tab';
378
+ if (!isVisible()) return 'hidden';
379
+ if (heardNames().length > 0) return 'listening';
380
+ if (notOpened.size > 0) return 'not-opened';
381
+ if (several.length > 0) return 'ambiguous';
382
+ if (unplugged.length > 0) return 'unplugged';
383
+ return 'missing';
384
+ }
385
+
386
+ function state() {
387
+ const listen = listenWord();
388
+ let failure = null;
389
+ if (listen === 'failed') failure = accessState() === 'failed' ? accessFailure() ?? 'Error' : 'NoAccess';
390
+ return {
391
+ listen,
392
+ ports: [...ports],
393
+ listened: heardNames(),
394
+ notOpened: [...listened.keys()].filter((name) => notOpened.has(name)),
395
+ missing: [...missing],
396
+ unplugged: [...unplugged],
397
+ several: [...several],
398
+ available: [...available],
399
+ failure,
400
+ heard: heard === null ? null : { ...heard },
401
+ refused,
402
+ count: buffer.count(),
403
+ };
404
+ }
405
+
406
+ return { setInputs, refresh, allow, state, snapshot, live, clearBuffer };
407
+ }