moontide-mcp 2.2.0 → 2.4.1
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/page/midi-out.js
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
// It never keeps a webmidi Output or a native MIDIOutput in a variable across calls: a port can be
|
|
6
6
|
// replaced at any time, so the current list is read each time.
|
|
7
7
|
|
|
8
|
-
import { MIDI_LIMITS, buildMidiFacts, channelSilence, panicMessages, panicOrder, panicTargetsFrom, portState, readMidiCalls, readMidiSetup, usesMidi } from './midi-plan.js';
|
|
9
|
-
import { checkSetup as checkRigSetup } from './midi-rig.js';
|
|
8
|
+
import { MIDI_LIMITS, buildMidiFacts, channelSilence, fill, panicMessages, panicOrder, panicTargetsFrom, portState, readMidiCalls, readMidiSetup, usesMidi } from './midi-plan.js';
|
|
9
|
+
import { RIG_COPY, checkSetup as checkRigSetup } from './midi-rig.js';
|
|
10
|
+
import { SETTINGS_COPY, buildSettings, guardOf, ownMessageProblem, ownNrpnProblem, readSentBytes, sysexProblem } from './midi-settings.js';
|
|
10
11
|
import { controlsByCc, defaultOf, midiStops, notesToStop, readMidiSlots } from './midi-controls.js';
|
|
11
12
|
import { cutText } from './ask.js';
|
|
12
13
|
import { createPageClock } from './midi-clock.js';
|
|
@@ -88,6 +89,10 @@ export const RECONNECT_REPEAT_MS = 1000;
|
|
|
88
89
|
// evaluation, a send that failed) wait here until the next publish carries them to the server once.
|
|
89
90
|
// src/protocol.ts holds the same number (MAX_MIDI_EVENTS); test/protocol.test.js checks both sides.
|
|
90
91
|
export const MAX_PAGE_EVENTS = 20;
|
|
92
|
+
// The most names one settings event carries and the longest setting name (the server's schema holds the same limits;
|
|
93
|
+
// the limit of names equals MAX_RIG_SETTINGS, the most rows of a level).
|
|
94
|
+
export const MAX_SETTING_NAMES = 512;
|
|
95
|
+
const MAX_SETTING_NAME = 40;
|
|
91
96
|
|
|
92
97
|
// How long after a MIDI track's notes were ended (its channel silenced, or its own note-offs sent) the same
|
|
93
98
|
// bytes go out once more. Strudel queues the notes of the next 0.1 s ahead of time (latency 0.1 s and a 0.1 s
|
|
@@ -232,9 +237,18 @@ export function createMidiOut({
|
|
|
232
237
|
const controlShadows = new Map();
|
|
233
238
|
let activeControls = null;
|
|
234
239
|
let activePlan = null;
|
|
240
|
+
// The settings the code writes, only to know which code's settings a reset, a replug or a rollback sends again
|
|
241
|
+
// (like activePlan and activeControls for the sounds and the controls): the tone and part settings per channel slot
|
|
242
|
+
// and the scene settings per instrument, each as the finished data sets. No Map here remembers what was sent: only
|
|
243
|
+
// the code is the truth, so the same code evaluated again sends all its settings again.
|
|
244
|
+
let activeSettings = null;
|
|
245
|
+
let activeScenes = null;
|
|
235
246
|
// The last valid rig the page saw (null after the file went absent): a file that passes through an
|
|
236
247
|
// invalid state between two valid ones is compared against it, not against the invalid state.
|
|
237
248
|
let lastValid = null;
|
|
249
|
+
// The instruments of the last valid rig, kept while the file is invalid or gone: only to name the instrument behind
|
|
250
|
+
// a port in the memory guard's notice (the guard itself is keyed on the message and checks every port).
|
|
251
|
+
let guardInstruments = [];
|
|
238
252
|
// The desired sounds of the evaluation being prepared (a reset that runs meanwhile sends them),
|
|
239
253
|
// and what the page knows of each rig instrument's connection: { key, present, settleTimer, repeatTimer,
|
|
240
254
|
// holding, holdLogged, epoch, resetPromise, resets, resetAt } by instrument id. `holding` is true from the instant a
|
|
@@ -243,6 +257,9 @@ export function createMidiOut({
|
|
|
243
257
|
// active role is not `active`: it resets and sets up nothing.
|
|
244
258
|
let pendingPlan = null;
|
|
245
259
|
let pendingControls = null;
|
|
260
|
+
// The settings of the evaluation being prepared (like activeSettings and activeScenes, for the code that plays).
|
|
261
|
+
let pendingSettings = null;
|
|
262
|
+
let pendingScenes = null;
|
|
246
263
|
// The plan (prepareSetup call) that owns pendingPlan: the same code checked twice gives the same desired Map, so the
|
|
247
264
|
// Map cannot tell two plans apart.
|
|
248
265
|
let pendingOwner = null;
|
|
@@ -251,6 +268,10 @@ export function createMidiOut({
|
|
|
251
268
|
// The channels the evaluation being prepared changed, so a rollback can undo or report each one. A reset
|
|
252
269
|
// that sends the pending sounds adds its channels here too (it wiped them first: nothing to send back).
|
|
253
270
|
let pendingChanges = null;
|
|
271
|
+
// What the evaluation being prepared has sent of its settings, by itself or through a reset that sent them:
|
|
272
|
+
// { names: Set of settingKey, instruments: Set of instrument ids }. Its send skips what a reset already sent (once
|
|
273
|
+
// per evaluation), and a rollback tells and puts back only what was sent.
|
|
274
|
+
let pendingSent = null;
|
|
254
275
|
let active = true;
|
|
255
276
|
const states = new Map();
|
|
256
277
|
// The rig check of a code string depends on the rig, the connected outputs and the playing flag; it
|
|
@@ -308,6 +329,73 @@ export function createMidiOut({
|
|
|
308
329
|
onError(`Moontide: more than ${MAX_PAGE_EVENTS} MIDI events were waiting to be sent, so the oldest were dropped.`);
|
|
309
330
|
}
|
|
310
331
|
|
|
332
|
+
/** The key of one setting sent: the channel slot (`<instrument id>:<channel>`, the instrument id for a scene), the level and the name. */
|
|
333
|
+
const settingKey = (slot, level, name) => `${slot}\n${level}\n${name}`;
|
|
334
|
+
|
|
335
|
+
/** The scene values of one instrument as one text: every name with the bytes of its value, in the order written. */
|
|
336
|
+
function sceneSignature(scene) {
|
|
337
|
+
return JSON.stringify(scene.sceneBytes.map((item) => [item.name, item.bytes]));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Queues the events about the scene settings of a code that takes over: for each instrument whose scene names or
|
|
342
|
+
* values differ from those of the code that played, one settings-scene event with its new names, since a scene
|
|
343
|
+
* setting reaches every part and not only the channel of its .midi() call. Facts about what the page sends.
|
|
344
|
+
* Returns how many events it added.
|
|
345
|
+
*/
|
|
346
|
+
function addSceneEvents(scenes) {
|
|
347
|
+
let added = 0;
|
|
348
|
+
for (const [id, scene] of scenes) {
|
|
349
|
+
const before = activeScenes?.get(id);
|
|
350
|
+
if (before !== undefined && sceneSignature(before) === sceneSignature(scene)) continue;
|
|
351
|
+
addEvent({
|
|
352
|
+
kind: 'settings-scene',
|
|
353
|
+
instrument: cutText(id, MIDI_LIMITS.name),
|
|
354
|
+
names: scene.sceneBytes.slice(0, MAX_SETTING_NAMES).map((item) => cutText(item.name, MAX_SETTING_NAME)),
|
|
355
|
+
});
|
|
356
|
+
added++;
|
|
357
|
+
}
|
|
358
|
+
return added;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Publishes the facts when an event waits (events added after the facts of an evaluation went out). Never throws. */
|
|
362
|
+
function publishEvents() {
|
|
363
|
+
try {
|
|
364
|
+
if (events.length > 0) publishFacts();
|
|
365
|
+
} catch (err) {
|
|
366
|
+
onError('Moontide: MIDI state could not be read:', err);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Queues the events about settings that stay on the instrument: for each slot of `sent` (the settings of a code that was
|
|
372
|
+
* sent) the tone and part names that the `kept` slot of the same channel no longer writes, and for each scene the names
|
|
373
|
+
* the kept scene no longer writes, one settings-left event per instrument, channel and level. Nothing is sent for such a
|
|
374
|
+
* setting and the page keeps no value to restore, so the instrument holds the last one. `toneLoaded(slot)` says that
|
|
375
|
+
* the sound of the slot's channel was sent after those tone settings: the program change loaded the tone again, so
|
|
376
|
+
* no tone setting stays there. `sent` and `kept` may be null. Returns how many events it added.
|
|
377
|
+
*/
|
|
378
|
+
function addLeftEvents(sent, sentScenes, kept, keptScenes, toneLoaded) {
|
|
379
|
+
let added = 0;
|
|
380
|
+
const gone = (names, keptNames) => names.filter((name) => !keptNames.includes(name)).slice(0, MAX_SETTING_NAMES).map((name) => cutText(name, MAX_SETTING_NAME));
|
|
381
|
+
const tell = (instrumentId, level, channel, names) => {
|
|
382
|
+
if (names.length === 0) return;
|
|
383
|
+
addEvent({ kind: 'settings-left', instrument: cutText(instrumentId, MIDI_LIMITS.name), level, channel, names });
|
|
384
|
+
added++;
|
|
385
|
+
};
|
|
386
|
+
for (const [key, slot] of sent ?? []) {
|
|
387
|
+
const now = kept?.get(key);
|
|
388
|
+
if (!toneLoaded(slot)) {
|
|
389
|
+
tell(slot.instrument, 'tone', slot.channel, gone(slot.toneBytes.map((item) => item.name), now?.toneBytes.map((item) => item.name) ?? []));
|
|
390
|
+
}
|
|
391
|
+
tell(slot.instrument, 'part', slot.channel, gone(slot.partBytes.map((item) => item.name), now?.partBytes.map((item) => item.name) ?? []));
|
|
392
|
+
}
|
|
393
|
+
for (const [id, scene] of sentScenes ?? []) {
|
|
394
|
+
tell(id, 'scene', null, gone(scene.sceneBytes.map((item) => item.name), keptScenes?.get(id)?.sceneBytes.map((item) => item.name) ?? []));
|
|
395
|
+
}
|
|
396
|
+
return added;
|
|
397
|
+
}
|
|
398
|
+
|
|
311
399
|
// While held, only a forced publish goes out: the page holds the facts during a take-over replay, when
|
|
312
400
|
// the code this tab last evaluated is not the code that plays any more.
|
|
313
401
|
let factsHeld = false;
|
|
@@ -339,6 +427,8 @@ export function createMidiOut({
|
|
|
339
427
|
const clockHoldLogged = new Set();
|
|
340
428
|
// Rig instrument ids whose switched-off notes were logged: one line per switch-off, not one per dropped note.
|
|
341
429
|
const offLogged = new Set();
|
|
430
|
+
// Instrument ids (or port names) whose refused message from the code was logged: one line per name per page session.
|
|
431
|
+
const sysexLogged = new Set();
|
|
342
432
|
// Output -> channels (1 to 16) whose messages it drops (the stale-channel rule).
|
|
343
433
|
// Strudel picks the Output when it triggers a note, up to 0.3 s ahead, and plays on that same object
|
|
344
434
|
// later: the notes the old code triggered before a sound change hold the old Output, so the Output's
|
|
@@ -405,6 +495,47 @@ export function createMidiOut({
|
|
|
405
495
|
}, 0);
|
|
406
496
|
}
|
|
407
497
|
|
|
498
|
+
/**
|
|
499
|
+
* The memory guard on Strudel's path, on every port: the arguments of an Output.send call as they may go on, or null
|
|
500
|
+
* when the call is dropped. The bytes are read once as Web MIDI reads them (readSentBytes: the first argument and, in
|
|
501
|
+
* webmidi's older form, a list given as the second argument too), the whole buffer is checked (sysexProblem: keyed on
|
|
502
|
+
* the message, so a Roland message for a model whose temporary areas Moontide knows must be a data set inside them,
|
|
503
|
+
* and every other message passes as it is), and the call goes on with the bytes that were read, not the original, so
|
|
504
|
+
* what was checked is what is sent. A refusal drops the whole call; the first one per name logs one line with the
|
|
505
|
+
* reason. The name is the instrument of the last valid rig on that port (guardInstruments), else the port. Never
|
|
506
|
+
* throws: it runs inside Strudel's send.
|
|
507
|
+
*/
|
|
508
|
+
function memoryGuardedArgs(output, args) {
|
|
509
|
+
try {
|
|
510
|
+
const first = readSentBytes(args[0]);
|
|
511
|
+
const second = Array.isArray(args[1]) ? readSentBytes(args[1]) : null;
|
|
512
|
+
const problem = first.problem ?? second?.problem ?? sysexProblem([...first.bytes, ...(second?.bytes ?? [])]);
|
|
513
|
+
if (problem !== null) {
|
|
514
|
+
refuseCodeMessage(output, problem);
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
const handed = args.slice();
|
|
518
|
+
handed[0] = typeof args[0] === 'number' ? first.bytes[0] : first.bytes;
|
|
519
|
+
if (second !== null) handed[1] = second.bytes;
|
|
520
|
+
return handed;
|
|
521
|
+
} catch (err) {
|
|
522
|
+
sayOnce('memory-guard', 'Moontide: a MIDI message from the code could not be checked, so it was not sent:', err);
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Tells the first refusal of a message from the code per instrument (or port, for a port of no instrument). */
|
|
528
|
+
function refuseCodeMessage(output, problem) {
|
|
529
|
+
const port = typeof output.name === 'string' ? output.name : '';
|
|
530
|
+
const name = guardInstruments.find((instrument) => port.includes(instrument.port))?.id ?? (port === '' ? 'unnamed port' : port);
|
|
531
|
+
if (sysexLogged.has(name)) return;
|
|
532
|
+
sysexLogged.add(name);
|
|
533
|
+
const unread = problem.reason === 'not-bytes' || problem.reason === 'too-long';
|
|
534
|
+
onError(fill(unread ? SETTINGS_COPY.messageDropped : SETTINGS_COPY.sysexDropped, { id: name, reason: problem.text }));
|
|
535
|
+
addEvent({ kind: 'sysex-refused', instrument: cutText(name, MIDI_LIMITS.name), source: 'code', reason: problem.reason });
|
|
536
|
+
publishEvents();
|
|
537
|
+
}
|
|
538
|
+
|
|
408
539
|
/**
|
|
409
540
|
* True when `output` drops this message. Five rules, in order: the message is clock or transport (status 0xF8,
|
|
410
541
|
* 0xFA, 0xFB or 0xFC): Moontide sends these itself, so Strudel's are dropped on every port; the message is a channel
|
|
@@ -413,7 +544,7 @@ export function createMidiOut({
|
|
|
413
544
|
* the notes of code already playing included); the Output is the one of a port that is held (a rig instrument whose
|
|
414
545
|
* presence state has `holding`; both rules test whether the output's name contains the port: the substring rule of
|
|
415
546
|
* nativeFor, never broader); or the message is a note-on for a clock instrument that waits for its Start (see
|
|
416
|
-
* below).
|
|
547
|
+
* below). The memory guard runs before these rules (memoryGuardedArgs). `data` is untrusted: its type is checked before the
|
|
417
548
|
* status byte is masked. `ts` is the timestamp the message carries (the page clock's mapping) or undefined: a stamped note-on is
|
|
418
549
|
* held by its own timestamp, and the other rules apply to a stamped message as they do to any other.
|
|
419
550
|
*/
|
|
@@ -687,12 +818,15 @@ export function createMidiOut({
|
|
|
687
818
|
guardedOutputs.add(output);
|
|
688
819
|
const original = output.send;
|
|
689
820
|
output.send = function guardedSend(...args) {
|
|
821
|
+
// The memory guard first: the rules below read the bytes it checked and hands on.
|
|
822
|
+
const checked = memoryGuardedArgs(output, args);
|
|
823
|
+
if (checked === null) return output;
|
|
690
824
|
const intent = noteIntent;
|
|
691
|
-
if (dropsMessage(output,
|
|
692
|
-
if (intent === null) return original.apply(output,
|
|
693
|
-
const sendArgs = safeStampedArgs(
|
|
825
|
+
if (dropsMessage(output, checked[0], intent === null ? undefined : intent.ts)) return output;
|
|
826
|
+
if (intent === null) return original.apply(output, checked);
|
|
827
|
+
const sendArgs = safeStampedArgs(checked, intent);
|
|
694
828
|
const result = original.apply(output, sendArgs);
|
|
695
|
-
recordQueued(output,
|
|
829
|
+
recordQueued(output, checked[0], sendArgs, intent.triggerAt);
|
|
696
830
|
return result;
|
|
697
831
|
};
|
|
698
832
|
}
|
|
@@ -931,6 +1065,16 @@ export function createMidiOut({
|
|
|
931
1065
|
return pendingEnable;
|
|
932
1066
|
}
|
|
933
1067
|
|
|
1068
|
+
/**
|
|
1069
|
+
* The enable the page offers outside an evaluation. The keyboard input asks for MIDI through this same enable, so the page asks the
|
|
1070
|
+
* browser once and the two never prompt separately. Like an evaluation it leaves 'enabled', 'denied' and 'unsupported' as they are
|
|
1071
|
+
* for the page session; it never rejects, every outcome is an access state.
|
|
1072
|
+
*/
|
|
1073
|
+
function enable() {
|
|
1074
|
+
if (access === 'enabled' || access === 'denied' || access === 'unsupported') return Promise.resolve();
|
|
1075
|
+
return ensureEnabled();
|
|
1076
|
+
}
|
|
1077
|
+
|
|
934
1078
|
/**
|
|
935
1079
|
* Waits for the enable, but not longer than the ceiling. When the ceiling wins the enable keeps
|
|
936
1080
|
* running (its outcome still sets the access and publishes) and later evaluations do not wait again.
|
|
@@ -1488,30 +1632,34 @@ export function createMidiOut({
|
|
|
1488
1632
|
}
|
|
1489
1633
|
// The sounds of the playing code are no longer true when the file went away, or when an instrument it
|
|
1490
1634
|
// names left the file or changed its port or model: a later reset must not send them.
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
const kept = new Map();
|
|
1494
|
-
for (const [slot, entry] of activePlan) {
|
|
1495
|
-
const was = known(base, entry.instrument);
|
|
1496
|
-
const current = known(rig, entry.instrument);
|
|
1497
|
-
const gone = rig.setup === 'absent' || current === undefined || (was !== undefined && (was.port !== current.port || was.model !== current.model));
|
|
1498
|
-
if (!gone) kept.set(slot, entry);
|
|
1499
|
-
}
|
|
1500
|
-
activePlan = kept;
|
|
1501
|
-
}
|
|
1502
|
-
if (activeControls !== null && (rig.setup === 'absent' || rig.setup === 'valid')) {
|
|
1635
|
+
// The same goes for the controls and the settings of the playing code.
|
|
1636
|
+
if (rig.setup === 'absent' || rig.setup === 'valid') {
|
|
1503
1637
|
const base = previous.setup === 'valid' ? previous : (lastValid ?? previous);
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
const
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1638
|
+
// The settings are finished bytes built from the model's rows: an edit of the description under the same model id
|
|
1639
|
+
// (an address, a size, a row gone) makes them stale, so they go too.
|
|
1640
|
+
const keptOf = (map, { bytesOfSettings = false } = {}) => {
|
|
1641
|
+
if (map === null) return null;
|
|
1642
|
+
const kept = new Map();
|
|
1643
|
+
for (const [slot, entry] of map) {
|
|
1644
|
+
const was = known(base, entry.instrument);
|
|
1645
|
+
const current = known(rig, entry.instrument);
|
|
1646
|
+
const gone =
|
|
1647
|
+
rig.setup === 'absent' ||
|
|
1648
|
+
current === undefined ||
|
|
1649
|
+
(was !== undefined &&
|
|
1650
|
+
(was.port !== current.port || was.model !== current.model || (bytesOfSettings && JSON.stringify(was.settings) !== JSON.stringify(current.settings))));
|
|
1651
|
+
if (!gone) kept.set(slot, entry);
|
|
1652
|
+
}
|
|
1653
|
+
return kept;
|
|
1654
|
+
};
|
|
1655
|
+
activePlan = keptOf(activePlan);
|
|
1656
|
+
activeControls = keptOf(activeControls);
|
|
1657
|
+
activeSettings = keptOf(activeSettings, { bytesOfSettings: true });
|
|
1658
|
+
activeScenes = keptOf(activeScenes, { bytesOfSettings: true });
|
|
1512
1659
|
}
|
|
1513
1660
|
if (rig.setup === 'valid') lastValid = rig;
|
|
1514
1661
|
else if (rig.setup === 'absent') lastValid = null;
|
|
1662
|
+
if (rig.setup === 'valid') guardInstruments = rig.instruments;
|
|
1515
1663
|
// An instrument that is on again (or gone) logs its next switch-off.
|
|
1516
1664
|
for (const id of [...offLogged]) if (!known(rig, id) || known(rig, id).enabled !== false) offLogged.delete(id);
|
|
1517
1665
|
silenceSwitchedOff(offBase);
|
|
@@ -1670,6 +1818,14 @@ export function createMidiOut({
|
|
|
1670
1818
|
if (replayCode !== null) counted.push(replayCode);
|
|
1671
1819
|
const playingPorts = namedPorts(counted);
|
|
1672
1820
|
result = checkRigSetup({ read, rig, outputs, playing, playingPorts });
|
|
1821
|
+
// The bytes of the settings are built after the vocabulary check passes; a setting that cannot be sent safely
|
|
1822
|
+
// fails the evaluation with its reason, like any other refusal.
|
|
1823
|
+
const built = result.refusal === null ? buildSettings({ rig, settings: result.settings, scenes: result.scenes }) : null;
|
|
1824
|
+
if (built !== null && built.lines !== null) {
|
|
1825
|
+
const closing = playing ? RIG_COPY.closingPlaying : RIG_COPY.closingStopped;
|
|
1826
|
+
result = { refusal: [...built.lines, closing].join('\n'), desired: new Map(), controls: new Map(), settings: new Map(), scenes: new Map() };
|
|
1827
|
+
}
|
|
1828
|
+
result = { ...result, settingSlots: built?.slots ?? new Map(), sceneSettings: built?.scenes ?? new Map() };
|
|
1673
1829
|
}
|
|
1674
1830
|
}
|
|
1675
1831
|
setupMemo.set(key, result);
|
|
@@ -1721,6 +1877,58 @@ export function createMidiOut({
|
|
|
1721
1877
|
return true;
|
|
1722
1878
|
}
|
|
1723
1879
|
|
|
1880
|
+
/**
|
|
1881
|
+
* Sends one finished settings message (a Roland data set, or the fifteen bytes of an NRPN set) to a native port, untimed or
|
|
1882
|
+
* with the timestamp `at`: the one place settings bytes reach a port. The message is checked again here, against the
|
|
1883
|
+
* instrument as the rig holds it now (a data set against its temporary areas, an NRPN set against the NRPN family of its
|
|
1884
|
+
* model), so a rig that changed since the plan was built cannot let a write outside the temporary areas, or an NRPN
|
|
1885
|
+
* Moontide does not know, through. Its bytes count
|
|
1886
|
+
* toward the guard. When the check refuses it or the port throws, the answer is false after a log line and a send-failed
|
|
1887
|
+
* event; with `throws` (the setup after a reset, whose caller has its own failure path) the error is thrown instead,
|
|
1888
|
+
* unreported here, and a refusal says why in its message.
|
|
1889
|
+
*/
|
|
1890
|
+
function sendSetting(native, instrument, level, bytes, bytesSent, at, { throws = false } = {}) {
|
|
1891
|
+
// Settings go only to an instrument whose temporary areas Moontide knows, as data sets for exactly its model; the one
|
|
1892
|
+
// other message is the NRPN set of a part offset of a family Moontide knows (a message that does not start with F0).
|
|
1893
|
+
let problem;
|
|
1894
|
+
if (bytes[0] === 0xf0) {
|
|
1895
|
+
const guard = guardOf(instrument.settings);
|
|
1896
|
+
problem = guard === null ? { reason: 'unknown-areas', text: 'the temporary areas of this instrument are not known' } : ownMessageProblem(guard, bytes);
|
|
1897
|
+
} else {
|
|
1898
|
+
problem = ownNrpnProblem(instrument, level, bytes);
|
|
1899
|
+
}
|
|
1900
|
+
try {
|
|
1901
|
+
if (problem !== null) throw guardRefusal(fill(SETTINGS_COPY.sendRefused, { level, id: instrument.id, reason: problem.text }), 'settings', problem.reason);
|
|
1902
|
+
if (at === undefined) native.send(bytes);
|
|
1903
|
+
else native.send(bytes, at);
|
|
1904
|
+
} catch (err) {
|
|
1905
|
+
if (throws) throw err;
|
|
1906
|
+
if (problem !== null) onError(err.message, undefined);
|
|
1907
|
+
else onError(`Moontide: could not send a setting to MIDI port "${native.name}":`, err);
|
|
1908
|
+
addEvent(failureEvent(instrument, 'settings', err));
|
|
1909
|
+
return false;
|
|
1910
|
+
}
|
|
1911
|
+
bytesSent.count += bytes.length;
|
|
1912
|
+
return true;
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
/** An Error for a message the memory guard refused, carrying what was refused and why for failureEvent. */
|
|
1916
|
+
function guardRefusal(text, source, reason) {
|
|
1917
|
+
return Object.assign(new Error(text), { name: 'MemoryGuardRefused', guardRefusal: { source, reason } });
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
/**
|
|
1921
|
+
* The event for Claude about a send that failed with `err`: the memory guard's refusal with its source and reason
|
|
1922
|
+
* (a sysex-refused event), else a send-failed event about `what` with the error's name.
|
|
1923
|
+
*/
|
|
1924
|
+
function failureEvent(instrument, what, err) {
|
|
1925
|
+
const id = cutText(instrument.id, MIDI_LIMITS.name);
|
|
1926
|
+
const refused = err !== null && typeof err === 'object' ? err.guardRefusal : undefined;
|
|
1927
|
+
if (refused !== undefined) return { kind: 'sysex-refused', instrument: id, source: refused.source, reason: refused.reason };
|
|
1928
|
+
const name = err && typeof err.name === 'string' && err.name !== '' ? err.name : 'Error';
|
|
1929
|
+
return { kind: 'send-failed', instrument: id, what, failure: cutText(name, MIDI_LIMITS.failure) };
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1724
1932
|
/**
|
|
1725
1933
|
* The controls of one channel the page must send now, in the model's order (the first control of each cc):
|
|
1726
1934
|
* [{ cc, value }]. `slot` is the controls the code writes there (or undefined), `shadow` the Map from cc to the
|
|
@@ -1978,8 +2186,11 @@ export function createMidiOut({
|
|
|
1978
2186
|
const timed = atCycle !== undefined;
|
|
1979
2187
|
pendingPlan = null;
|
|
1980
2188
|
pendingControls = null;
|
|
2189
|
+
pendingSettings = null;
|
|
2190
|
+
pendingScenes = null;
|
|
1981
2191
|
pendingOwner = null;
|
|
1982
2192
|
pendingChanges = null;
|
|
2193
|
+
pendingSent = null;
|
|
1983
2194
|
if (rig.setup === 'absent') return null;
|
|
1984
2195
|
const checked = checkedSetup(code);
|
|
1985
2196
|
if (checked === null) {
|
|
@@ -1987,14 +2198,19 @@ export function createMidiOut({
|
|
|
1987
2198
|
refusal: null,
|
|
1988
2199
|
send: async () => {},
|
|
1989
2200
|
commit: () => {
|
|
2201
|
+
// Code that names no MIDI writes no setting: every setting of the code that played is gone from the code.
|
|
2202
|
+
const told = addLeftEvents(activeSettings, activeScenes, null, null, () => false);
|
|
1990
2203
|
activePlan = new Map();
|
|
1991
2204
|
activeControls = new Map();
|
|
2205
|
+
activeSettings = new Map();
|
|
2206
|
+
activeScenes = new Map();
|
|
2207
|
+
if (told > 0) publishEvents();
|
|
1992
2208
|
},
|
|
1993
2209
|
rollback: async () => {},
|
|
1994
2210
|
drop: () => {},
|
|
1995
2211
|
};
|
|
1996
2212
|
}
|
|
1997
|
-
const { refusal, desired, controls } = checked;
|
|
2213
|
+
const { refusal, desired, controls, settingSlots, sceneSettings } = checked;
|
|
1998
2214
|
if (refusal !== null) {
|
|
1999
2215
|
return { refusal, send: async () => {}, commit: () => {}, rollback: async () => {}, drop: () => {} };
|
|
2000
2216
|
}
|
|
@@ -2003,9 +2219,16 @@ export function createMidiOut({
|
|
|
2003
2219
|
const owner = {};
|
|
2004
2220
|
pendingPlan = desired;
|
|
2005
2221
|
pendingControls = controls;
|
|
2222
|
+
pendingSettings = settingSlots;
|
|
2223
|
+
pendingScenes = sceneSettings;
|
|
2006
2224
|
pendingOwner = owner;
|
|
2007
2225
|
const changes = [];
|
|
2008
2226
|
pendingChanges = changes;
|
|
2227
|
+
// The instruments this plan sent settings to, and each setting sent (settingKey), by its send or by a reset: a
|
|
2228
|
+
// failure puts back the settings of the code returned to there and tells only what was sent.
|
|
2229
|
+
const settingInstruments = new Set();
|
|
2230
|
+
const sentNames = new Set();
|
|
2231
|
+
pendingSent = { names: sentNames, instruments: settingInstruments };
|
|
2009
2232
|
// The { output, channel } pairs this evaluation filters (see filterStaleChannel).
|
|
2010
2233
|
const filtered = [];
|
|
2011
2234
|
// The latest timestamp this evaluation's timed setup carries, or null when nothing was timed (a rollback goes after it).
|
|
@@ -2024,8 +2247,11 @@ export function createMidiOut({
|
|
|
2024
2247
|
if (timed && pendingOwner !== owner) return;
|
|
2025
2248
|
pendingPlan = null;
|
|
2026
2249
|
pendingControls = null;
|
|
2250
|
+
pendingSettings = null;
|
|
2251
|
+
pendingScenes = null;
|
|
2027
2252
|
pendingOwner = null;
|
|
2028
2253
|
pendingChanges = null;
|
|
2254
|
+
pendingSent = null;
|
|
2029
2255
|
}
|
|
2030
2256
|
return {
|
|
2031
2257
|
refusal: null,
|
|
@@ -2053,9 +2279,11 @@ export function createMidiOut({
|
|
|
2053
2279
|
.filter((entry) => entry.instrument === instrument.id)
|
|
2054
2280
|
.sort((a, b) => a.channel - b.channel);
|
|
2055
2281
|
const slots = Array.from(controls.values()).filter((slot) => slot.instrument === instrument.id);
|
|
2282
|
+
const settingsOf = Array.from(settingSlots.values()).filter((slot) => slot.instrument === instrument.id);
|
|
2283
|
+
const sceneOf = sceneSettings.get(instrument.id);
|
|
2056
2284
|
// An instrument the code does not name is still brought back to its defaults for a control that left the code.
|
|
2057
2285
|
const leaving = controlChannels(instrument, []).some((channel) => controlsToSend(instrument, undefined, controlShadows.get(instrument.id)?.get(channel)).length > 0);
|
|
2058
|
-
if (wanted.length === 0 && slots.length === 0 && !leaving) continue;
|
|
2286
|
+
if (wanted.length === 0 && slots.length === 0 && !leaving && settingsOf.length === 0 && sceneOf === undefined) continue;
|
|
2059
2287
|
// A reset in its wait finishes first; it sends this evaluation's sounds itself, and the bytes it
|
|
2060
2288
|
// sent count toward the guard.
|
|
2061
2289
|
const pendingReset = states.get(instrument.id)?.resetPromise ?? null;
|
|
@@ -2065,11 +2293,28 @@ export function createMidiOut({
|
|
|
2065
2293
|
if (native === null) continue;
|
|
2066
2294
|
if (!shadows.has(instrument.id)) shadows.set(instrument.id, new Map());
|
|
2067
2295
|
const shadow = shadows.get(instrument.id);
|
|
2068
|
-
|
|
2296
|
+
/**
|
|
2297
|
+
* One setting of this instrument, unless a reset already sent it for this plan (and, for a tone setting, no
|
|
2298
|
+
* program change of this send came after it); remembers what went out.
|
|
2299
|
+
*/
|
|
2300
|
+
const sendWritten = (slot, level, item, at, soundSent = false) => {
|
|
2301
|
+
const key = settingKey(slot, level, item.name);
|
|
2302
|
+
if (sentNames.has(key) && !(level === 'tone' && soundSent)) return true;
|
|
2303
|
+
const sent = sendSetting(native, instrument, level, item.bytes, bytesSent, at);
|
|
2304
|
+
if (sent) {
|
|
2305
|
+
settingInstruments.add(instrument.id);
|
|
2306
|
+
sentNames.add(key);
|
|
2307
|
+
}
|
|
2308
|
+
return sent;
|
|
2309
|
+
};
|
|
2310
|
+
// Channels ascending; on each, the sound goes first, then the tone and part settings the code writes (right
|
|
2311
|
+
// after the program change, which would erase a tone setting sent before it), then the controls (after the
|
|
2069
2312
|
// program change, so a control never lands before the sound it belongs to).
|
|
2070
|
-
const channels = controlChannels(instrument, slots, wanted.map((entry) => entry.channel));
|
|
2313
|
+
const channels = controlChannels(instrument, slots, [...wanted.map((entry) => entry.channel), ...settingsOf.map((slot) => slot.channel)]);
|
|
2314
|
+
let stopped = false;
|
|
2071
2315
|
for (const channel of channels) {
|
|
2072
2316
|
const entry = wanted.find((candidate) => candidate.channel === channel);
|
|
2317
|
+
let soundSent = false;
|
|
2073
2318
|
// A timed plan stamps the whole channel with the line; otherwise `at` is set only for a channel whose sound
|
|
2074
2319
|
// changes while old code plays, and the other channels go out at once.
|
|
2075
2320
|
let at = lineAt;
|
|
@@ -2094,9 +2339,11 @@ export function createMidiOut({
|
|
|
2094
2339
|
liftStaleFilters(filtered, channel);
|
|
2095
2340
|
// A partly sent change leaves the channel in a state nobody knows: forget it, so it is sent whole next time.
|
|
2096
2341
|
if (sent.partial) shadow.delete(channel);
|
|
2342
|
+
stopped = true;
|
|
2097
2343
|
break;
|
|
2098
2344
|
}
|
|
2099
2345
|
shadow.set(channel, next);
|
|
2346
|
+
soundSent = true;
|
|
2100
2347
|
changes.push({ instrument, channel, previous, sound: entry.sound });
|
|
2101
2348
|
// A held note on this channel may be cut by the change: the playing code used the channel.
|
|
2102
2349
|
if (previous !== null && isPlaying() === true && activePlan !== null && activePlan.has(`${instrument.id}:${channel}`)) {
|
|
@@ -2104,8 +2351,26 @@ export function createMidiOut({
|
|
|
2104
2351
|
}
|
|
2105
2352
|
}
|
|
2106
2353
|
}
|
|
2354
|
+
const written = settingsOf.find((candidate) => candidate.channel === channel);
|
|
2355
|
+
if (written !== undefined) {
|
|
2356
|
+
const slotKey = `${instrument.id}:${channel}`;
|
|
2357
|
+
const toneOk = written.toneBytes.every((item) => sendWritten(slotKey, 'tone', item, at, soundSent));
|
|
2358
|
+
if (!toneOk || !written.partBytes.every((item) => sendWritten(slotKey, 'part', item, at))) {
|
|
2359
|
+
stopped = true;
|
|
2360
|
+
break;
|
|
2361
|
+
}
|
|
2362
|
+
}
|
|
2107
2363
|
const slot = slots.find((candidate) => candidate.channel === channel);
|
|
2108
|
-
if (!sendControls(native, instrument, channel, slot, bytesSent, at, { record: lineAt !== undefined ? stampedControls : undefined }))
|
|
2364
|
+
if (!sendControls(native, instrument, channel, slot, bytesSent, at, { record: lineAt !== undefined ? stampedControls : undefined })) {
|
|
2365
|
+
stopped = true;
|
|
2366
|
+
break;
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
// The scene settings come after the instrument's channels; a failed send ends the instrument's setup. On the
|
|
2370
|
+
// line they are a queued message of no channel, so a later setup on any channel goes after them.
|
|
2371
|
+
if (!stopped && sceneOf !== undefined) {
|
|
2372
|
+
if (lineAt !== undefined) recordSetupQueued(native, null, lineAt + SETUP_BEFORE_LINE_MS);
|
|
2373
|
+
for (const item of sceneOf.sceneBytes) if (!sendWritten(instrument.id, 'scene', item, lineAt)) break;
|
|
2109
2374
|
}
|
|
2110
2375
|
}
|
|
2111
2376
|
// A timed plan goes out at the lead and nothing waits for it, so there is no guard to wait out.
|
|
@@ -2118,10 +2383,16 @@ export function createMidiOut({
|
|
|
2118
2383
|
},
|
|
2119
2384
|
commit() {
|
|
2120
2385
|
committed = true;
|
|
2121
|
-
// The facts
|
|
2386
|
+
// The facts of the evaluation went out with recordSuccess inside it; the events about the settings that
|
|
2387
|
+
// changed are added here, before the code that plays is swapped, and published below.
|
|
2388
|
+
const soundSent = (slot) => changes.some((change) => change.instrument.id === slot.instrument && change.channel === slot.channel);
|
|
2389
|
+
const told = addSceneEvents(sceneSettings) + addLeftEvents(activeSettings, activeScenes, settingSlots, sceneSettings, soundSent);
|
|
2122
2390
|
activePlan = desired;
|
|
2123
2391
|
activeControls = controls;
|
|
2392
|
+
activeSettings = settingSlots;
|
|
2393
|
+
activeScenes = sceneSettings;
|
|
2124
2394
|
releasePending();
|
|
2395
|
+
if (told > 0) publishEvents();
|
|
2125
2396
|
try {
|
|
2126
2397
|
swapFilteredOutputs(filtered);
|
|
2127
2398
|
} catch (err) {
|
|
@@ -2134,7 +2405,10 @@ export function createMidiOut({
|
|
|
2134
2405
|
// An immediate change that replaces this one has prepared (and sent) its own setup before its pattern is
|
|
2135
2406
|
// installed, and that setup is still pending here: it is what the instrument must end on. A timed newer change
|
|
2136
2407
|
// prepares its setup later, at its own lead, so it finds nothing here and goes after the put-back.
|
|
2137
|
-
const successor =
|
|
2408
|
+
const successor =
|
|
2409
|
+
timed && pendingOwner !== null && pendingOwner !== owner
|
|
2410
|
+
? { plan: pendingPlan, controls: pendingControls, settings: pendingSettings, scenes: pendingScenes }
|
|
2411
|
+
: null;
|
|
2138
2412
|
releasePending();
|
|
2139
2413
|
// Bytes stamped on the line are already queued on the port and cannot be recalled: when the change is canceled
|
|
2140
2414
|
// after they went out, the instrument would switch to the canceled change's sounds and controls on the line
|
|
@@ -2158,8 +2432,11 @@ export function createMidiOut({
|
|
|
2158
2432
|
liftStaleFilters(filtered);
|
|
2159
2433
|
pendingPlan = null;
|
|
2160
2434
|
pendingControls = null;
|
|
2435
|
+
pendingSettings = null;
|
|
2436
|
+
pendingScenes = null;
|
|
2161
2437
|
pendingOwner = null;
|
|
2162
2438
|
pendingChanges = null;
|
|
2439
|
+
pendingSent = null;
|
|
2163
2440
|
await putBack();
|
|
2164
2441
|
// The evaluation failed, so nothing else will publish: the events (kept sounds, cut notes) go out here,
|
|
2165
2442
|
// before the failed reply that Claude reads.
|
|
@@ -2172,11 +2449,14 @@ export function createMidiOut({
|
|
|
2172
2449
|
};
|
|
2173
2450
|
/**
|
|
2174
2451
|
* Sends the sounds this plan changed back to what the channels held before it, then the controls the playing code
|
|
2175
|
-
* writes;
|
|
2176
|
-
* immediate change that replaces this plan: { plan, controls }) a
|
|
2177
|
-
* the controls it writes are sent again after this
|
|
2452
|
+
* writes, then its settings (putBackSettings); all after the plan's own timed change while it is still ahead, never
|
|
2453
|
+
* before it. With a `successor` (an immediate change that replaces this plan: { plan, controls, settings, scenes }) a
|
|
2454
|
+
* channel it sets up keeps the sound it holds, and the controls and settings it writes are sent again after this
|
|
2455
|
+
* plan's stamped ones. Never rejects.
|
|
2178
2456
|
*/
|
|
2179
2457
|
async function putBack(successor = null) {
|
|
2458
|
+
// The channels whose old sound went back: a program change erased their tone settings.
|
|
2459
|
+
const soundsBack = [];
|
|
2180
2460
|
try {
|
|
2181
2461
|
for (const { instrument, channel, previous, sound } of changes) {
|
|
2182
2462
|
// The successor's own setup went out after this plan's bytes (or the sound is the same one): the shadow is true.
|
|
@@ -2197,8 +2477,10 @@ export function createMidiOut({
|
|
|
2197
2477
|
const bytesSent = { count: 0 };
|
|
2198
2478
|
// After the timed change of this evaluation while it is still ahead, so no old note sounds with the refused sound.
|
|
2199
2479
|
const sent = sendSound(native, instrument, channel, previous, bytesSent, afterTimed(latestTimed));
|
|
2200
|
-
if (sent.ok)
|
|
2201
|
-
|
|
2480
|
+
if (sent.ok) {
|
|
2481
|
+
shadows.get(instrument.id)?.set(channel, previous);
|
|
2482
|
+
soundsBack.push({ instrument, channel });
|
|
2483
|
+
} else if (sent.partial) shadows.get(instrument.id)?.delete(channel);
|
|
2202
2484
|
}
|
|
2203
2485
|
} catch (err) {
|
|
2204
2486
|
onError('Moontide: the MIDI setup could not be rolled back:', err);
|
|
@@ -2210,6 +2492,61 @@ export function createMidiOut({
|
|
|
2210
2492
|
? { after: latestTimed ?? undefined }
|
|
2211
2493
|
: { after: latestTimed ?? undefined, controls: successor.controls ?? undefined, resend: stampedControls },
|
|
2212
2494
|
);
|
|
2495
|
+
await putBackSettings(successor, soundsBack);
|
|
2496
|
+
// The settings this plan sent that the code returned to does not write stay on the instrument: only those that
|
|
2497
|
+
// went out, by this plan's send or by a reset, never those of a channel the send did not reach.
|
|
2498
|
+
if (active && sentNames.size > 0) {
|
|
2499
|
+
const wentOut = (slot, level, items) => items.filter((item) => sentNames.has(settingKey(slot, level, item.name)));
|
|
2500
|
+
const sent = new Map(
|
|
2501
|
+
Array.from(settingSlots, ([key, slot]) => [key, { ...slot, toneBytes: wentOut(key, 'tone', slot.toneBytes), partBytes: wentOut(key, 'part', slot.partBytes) }]),
|
|
2502
|
+
);
|
|
2503
|
+
const sentScenes = new Map(Array.from(sceneSettings, ([id, scene]) => [id, { ...scene, sceneBytes: wentOut(id, 'scene', scene.sceneBytes) }]));
|
|
2504
|
+
const loaded = (slot) => soundsBack.some((item) => item.instrument.id === slot.instrument && item.channel === slot.channel);
|
|
2505
|
+
addLeftEvents(sent, sentScenes, successor === null ? activeSettings : successor.settings, successor === null ? activeScenes : successor.scenes, loaded);
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
/**
|
|
2510
|
+
* The settings of the code returned to (the successor's when an immediate change replaced this plan, else the code
|
|
2511
|
+
* that plays), sent after the sounds and controls that went back: for every instrument this plan sent settings to,
|
|
2512
|
+
* per channel the tone then the part settings, then the scene settings; for a channel whose old sound went back
|
|
2513
|
+
* without this plan having sent settings there, its tone and part settings too, since the program change erased the
|
|
2514
|
+
* tones. Timed just after this plan's own timed change while it is still ahead. Nothing is sent to an instrument this
|
|
2515
|
+
* plan sent no settings to and changed no sound of. Never rejects.
|
|
2516
|
+
*/
|
|
2517
|
+
async function putBackSettings(successor, soundsBack) {
|
|
2518
|
+
try {
|
|
2519
|
+
const slots = successor === null ? activeSettings : successor.settings;
|
|
2520
|
+
const scenes = successor === null ? activeScenes : successor.scenes;
|
|
2521
|
+
for (const instrument of rig.instruments) {
|
|
2522
|
+
const sentSettings = settingInstruments.has(instrument.id);
|
|
2523
|
+
const erased = soundsBack.filter((item) => item.instrument.id === instrument.id).map((item) => item.channel);
|
|
2524
|
+
if (!sentSettings && erased.length === 0) continue;
|
|
2525
|
+
const pendingReset = states.get(instrument.id)?.resetPromise ?? null;
|
|
2526
|
+
if (pendingReset !== null) await pendingReset;
|
|
2527
|
+
if (!active) return;
|
|
2528
|
+
const native = nativeFor(instrument);
|
|
2529
|
+
if (native === null) continue;
|
|
2530
|
+
const bytesSent = { count: 0 };
|
|
2531
|
+
const at = afterTimed(latestTimed);
|
|
2532
|
+
const written = slots === null || slots === undefined ? [] : Array.from(slots.values()).filter((slot) => slot.instrument === instrument.id);
|
|
2533
|
+
let stopped = false;
|
|
2534
|
+
for (const slot of written.sort((a, b) => a.channel - b.channel)) {
|
|
2535
|
+
if (!sentSettings && !erased.includes(slot.channel)) continue;
|
|
2536
|
+
const items = [...slot.toneBytes.map((item) => ['tone', item]), ...slot.partBytes.map((item) => ['part', item])];
|
|
2537
|
+
if (!items.every(([level, item]) => sendSetting(native, instrument, level, item.bytes, bytesSent, at))) {
|
|
2538
|
+
stopped = true;
|
|
2539
|
+
break;
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
const scene = scenes?.get(instrument.id);
|
|
2543
|
+
if (sentSettings && !stopped && scene !== undefined) {
|
|
2544
|
+
for (const item of scene.sceneBytes) if (!sendSetting(native, instrument, 'scene', item.bytes, bytesSent, at)) break;
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2547
|
+
} catch (err) {
|
|
2548
|
+
onError('Moontide: the MIDI settings could not be put back:', err);
|
|
2549
|
+
}
|
|
2213
2550
|
}
|
|
2214
2551
|
}
|
|
2215
2552
|
|
|
@@ -2342,14 +2679,29 @@ export function createMidiOut({
|
|
|
2342
2679
|
|
|
2343
2680
|
/**
|
|
2344
2681
|
* For each declared channel ascending: the sound the code declares there (the evaluation being prepared
|
|
2345
|
-
* first, else the code that plays), then the
|
|
2346
|
-
*
|
|
2682
|
+
* first, else the code that plays), then the tone and part settings the code writes there (same order), then the
|
|
2683
|
+
* volume the code writes (same order) else the model's default,
|
|
2684
|
+
* always, then every other control the code writes there, in the model's order; after the channels the scene
|
|
2685
|
+
* settings. The shadows take what was
|
|
2347
2686
|
* sent. `wiped` is true for a reset, which cleared the instrument first: a sound of the evaluation being
|
|
2348
2687
|
* prepared is then listed in the change list, so a rollback tells Claude the channel kept it. Returns the
|
|
2349
2688
|
* number of bytes sent; a send that throws throws out of here.
|
|
2350
2689
|
*/
|
|
2351
2690
|
function sendDeclaredSetup(instrument, native, { wiped = false } = {}) {
|
|
2352
2691
|
let setupBytes = 0;
|
|
2692
|
+
const settingBytes = { count: 0 };
|
|
2693
|
+
// The one record of settings a reset sends: the evaluation being prepared when there is one, for every slot and for
|
|
2694
|
+
// the scene (a setting it does not write has left the code, so nothing is sent for it), else the code that plays.
|
|
2695
|
+
// What it sends of the evaluation being prepared is noted for that evaluation (pendingSent).
|
|
2696
|
+
const record =
|
|
2697
|
+
pendingSettings !== null
|
|
2698
|
+
? { slots: pendingSettings, scenes: pendingScenes, sent: pendingSent }
|
|
2699
|
+
: { slots: activeSettings, scenes: activeScenes, sent: null };
|
|
2700
|
+
const sendOne = (slot, level, item) => {
|
|
2701
|
+
sendSetting(native, instrument, level, item.bytes, settingBytes, undefined, { throws: true });
|
|
2702
|
+
record.sent?.names.add(settingKey(slot, level, item.name));
|
|
2703
|
+
record.sent?.instruments.add(instrument.id);
|
|
2704
|
+
};
|
|
2353
2705
|
if (!shadows.has(instrument.id)) shadows.set(instrument.id, new Map());
|
|
2354
2706
|
const shadow = shadows.get(instrument.id);
|
|
2355
2707
|
const channels = instrument.channels.map((entry) => entry.channel).sort((a, b) => a - b);
|
|
@@ -2371,6 +2723,13 @@ export function createMidiOut({
|
|
|
2371
2723
|
}
|
|
2372
2724
|
shadow.set(channel, next);
|
|
2373
2725
|
}
|
|
2726
|
+
// The settings of the record come right after the sound. A program change erases tone settings, so they never go
|
|
2727
|
+
// before the sound.
|
|
2728
|
+
const written = record.slots?.get(slot);
|
|
2729
|
+
if (written !== undefined) {
|
|
2730
|
+
for (const item of written.toneBytes) sendOne(slot, 'tone', item);
|
|
2731
|
+
for (const item of written.partBytes) sendOne(slot, 'part', item);
|
|
2732
|
+
}
|
|
2374
2733
|
const controlSlot = pendingControls?.get(slot) ?? activeControls?.get(slot);
|
|
2375
2734
|
const volume = controlSlot?.values.get(7)?.value ?? defaultOf(instrument, { cc: 7 });
|
|
2376
2735
|
native.send([0xb0 | (channel - 1), 7, volume]);
|
|
@@ -2384,7 +2743,12 @@ export function createMidiOut({
|
|
|
2384
2743
|
setupBytes += 3;
|
|
2385
2744
|
}
|
|
2386
2745
|
}
|
|
2387
|
-
|
|
2746
|
+
// The scene settings reach every part, so they follow the channels.
|
|
2747
|
+
const scene = record.scenes?.get(instrument.id);
|
|
2748
|
+
if (scene !== undefined) {
|
|
2749
|
+
for (const item of scene.sceneBytes) sendOne(instrument.id, 'scene', item);
|
|
2750
|
+
}
|
|
2751
|
+
return setupBytes + settingBytes.count;
|
|
2388
2752
|
}
|
|
2389
2753
|
|
|
2390
2754
|
/**
|
|
@@ -2403,8 +2767,7 @@ export function createMidiOut({
|
|
|
2403
2767
|
} catch (err) {
|
|
2404
2768
|
forgetShadows(instrument.id);
|
|
2405
2769
|
onError(`Moontide: could not send the second setup to MIDI port "${native.name}":`, err);
|
|
2406
|
-
|
|
2407
|
-
addEvent({ kind: 'send-failed', instrument: cutText(instrument.id, MIDI_LIMITS.name), what: 'reset', failure: cutText(name, MIDI_LIMITS.failure) });
|
|
2770
|
+
addEvent(failureEvent(instrument, 'reset', err));
|
|
2408
2771
|
publishFacts();
|
|
2409
2772
|
return 0;
|
|
2410
2773
|
}
|
|
@@ -2439,6 +2802,15 @@ export function createMidiOut({
|
|
|
2439
2802
|
let failed = null;
|
|
2440
2803
|
try {
|
|
2441
2804
|
const reset = instrument.reset;
|
|
2805
|
+
// No reset byte goes out unless every message of the reset passes the same check as the code's messages: a
|
|
2806
|
+
// message for a model whose temporary areas Moontide knows must stay inside them, any other passes as it is. Only
|
|
2807
|
+
// here may a message be the model's own reset (the guard's exact message), which the code can never send.
|
|
2808
|
+
if (reset !== null) {
|
|
2809
|
+
for (const message of reset.messages) {
|
|
2810
|
+
const problem = sysexProblem(Array.from(message), { allowReset: true });
|
|
2811
|
+
if (problem !== null) throw guardRefusal(fill(SETTINGS_COPY.resetRefused, { id: instrument.id, reason: problem.text }), 'reset', problem.reason);
|
|
2812
|
+
}
|
|
2813
|
+
}
|
|
2442
2814
|
if (reset !== null) for (const message of reset.messages) native.send(message);
|
|
2443
2815
|
shadows.set(instrument.id, new Map());
|
|
2444
2816
|
controlShadows.set(instrument.id, new Map());
|
|
@@ -2467,8 +2839,7 @@ export function createMidiOut({
|
|
|
2467
2839
|
if (failed === null) {
|
|
2468
2840
|
addEvent({ kind: 'reset', instrument: id, reason });
|
|
2469
2841
|
} else {
|
|
2470
|
-
|
|
2471
|
-
addEvent({ kind: 'send-failed', instrument: id, what: 'reset', failure: cutText(name, MIDI_LIMITS.failure) });
|
|
2842
|
+
addEvent(failureEvent(instrument, 'reset', failed));
|
|
2472
2843
|
}
|
|
2473
2844
|
publishFacts();
|
|
2474
2845
|
} catch (err) {
|
|
@@ -2566,6 +2937,130 @@ export function createMidiOut({
|
|
|
2566
2937
|
}
|
|
2567
2938
|
}
|
|
2568
2939
|
|
|
2940
|
+
// Whether the last Listen send failed with an exception: one error is logged for a run of them, and the next success clears it.
|
|
2941
|
+
let listenFailure = false;
|
|
2942
|
+
function listenFailed(err) {
|
|
2943
|
+
if (!listenFailure) {
|
|
2944
|
+
listenFailure = true;
|
|
2945
|
+
onError('Moontide: a Listen note could not be sent:', err);
|
|
2946
|
+
}
|
|
2947
|
+
return { refused: 'send-failed' };
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
/**
|
|
2951
|
+
* The instrument and the one connected output Listen would play on, or the refusal word: 'superseded' (another tab plays), 'no-rig' (no valid
|
|
2952
|
+
* setup file), 'no-instrument', 'off' (switched off), 'resetting' (the instrument resets, or is held after a replug: it would drop a note),
|
|
2953
|
+
* 'ambiguous' (several connected outputs contain the port, with their `count`) and 'no-output' (none does, with the `names` of the connected
|
|
2954
|
+
* outputs). The output is read now and never kept. Never throws.
|
|
2955
|
+
*/
|
|
2956
|
+
function resolveListen(instrumentId) {
|
|
2957
|
+
if (!active) return { refused: 'superseded' };
|
|
2958
|
+
if (rig.setup !== 'valid') return { refused: 'no-rig' };
|
|
2959
|
+
const instrument = rig.instruments.find((candidate) => candidate.id === instrumentId);
|
|
2960
|
+
if (instrument === undefined) return { refused: 'no-instrument' };
|
|
2961
|
+
if (instrument.enabled === false) return { refused: 'off' };
|
|
2962
|
+
if (states.get(instrument.id)?.resetPromise != null || states.get(instrument.id)?.holding === true) return { refused: 'resetting' };
|
|
2963
|
+
const output = listenOutput(instrument.port);
|
|
2964
|
+
return output.refused === undefined ? { instrument, native: output.native } : output;
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
// The one connected output whose name contains `port`, as `{ native }`, or the refusal 'ambiguous' (with the `count`) or 'no-output' (with the
|
|
2968
|
+
// `names` of the connected outputs).
|
|
2969
|
+
function listenOutput(port) {
|
|
2970
|
+
const native = portNative({ port });
|
|
2971
|
+
if (native !== null) return { native };
|
|
2972
|
+
const webMidi = enabledWebMidi();
|
|
2973
|
+
const connected = webMidi === null ? [] : Array.from(webMidi.interface.outputs.values()).filter((candidate) => candidate.state === 'connected' && typeof candidate.name === 'string');
|
|
2974
|
+
const count = connected.filter((candidate) => candidate.name.includes(port)).length;
|
|
2975
|
+
if (count > 1) return { refused: 'ambiguous', port, count };
|
|
2976
|
+
return { refused: 'no-output', port, names: connected.map((candidate) => candidate.name) };
|
|
2977
|
+
}
|
|
2978
|
+
|
|
2979
|
+
// The port each instrument's last Listen note on went to, so its note off reaches the same device even after the setup file changed.
|
|
2980
|
+
const listenPorts = new Map();
|
|
2981
|
+
|
|
2982
|
+
// Where a Listen note off goes. An off only ends a note, so it skips the checks that keep a new note from sounding (another tab plays, the
|
|
2983
|
+
// setup file changed, the instrument is off or resets): a note left sounding is worse than an off the device ignores. It goes to the port
|
|
2984
|
+
// of the instrument's last note on, else to the instrument's port in the setup file now, and needs one connected output there.
|
|
2985
|
+
function resolveListenOff(instrumentId) {
|
|
2986
|
+
const instrument = rig.setup === 'valid' ? rig.instruments.find((candidate) => candidate.id === instrumentId) : undefined;
|
|
2987
|
+
const port = listenPorts.get(instrumentId) ?? instrument?.port;
|
|
2988
|
+
if (typeof port !== 'string') return { refused: 'no-instrument' };
|
|
2989
|
+
return listenOutput(port);
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2992
|
+
/** What Listen would play on for this instrument: `{ ok: true, id, port }`, or the refusal `{ refused, ... }` of resolveListen. Sends nothing, never throws. */
|
|
2993
|
+
function listenTarget(instrumentId) {
|
|
2994
|
+
try {
|
|
2995
|
+
const target = resolveListen(instrumentId);
|
|
2996
|
+
return target.refused === undefined ? { ok: true, id: target.instrument.id, port: target.instrument.port } : target;
|
|
2997
|
+
} catch (err) {
|
|
2998
|
+
return listenFailed(err);
|
|
2999
|
+
}
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
/**
|
|
3003
|
+
* Sends one note on or off for the Scratchpad's Listen: `{ instrumentId, channel, kind: 'on' | 'off', pitch, velocity, at }`, to the one
|
|
3004
|
+
* connected output of that instrument, stamped `at` (a performance.now() time; none sends at once). It builds the bytes itself from whole
|
|
3005
|
+
* numbers (status 0x9n or 0x8n, the pitch, and the velocity of an on, 0 for an off) and sends nothing else: no program change, no
|
|
3006
|
+
* controller, no SysEx. The output is read again at every call. A note on is refused with the words of listenTarget; a note off is not held by
|
|
3007
|
+
* them and goes to the port of the instrument's last note on (see resolveListenOff), so Stop can always end what Listen started. Returns
|
|
3008
|
+
* `{ ok: true }` or `{ refused: word, ... }` (those words, 'invalid' for a number or kind out of range or a stamp more than
|
|
3009
|
+
* NOTE_STAMP_MAX_AHEAD_MS ahead, and 'send-failed' for an output that threw).
|
|
3010
|
+
* It never throws, because the player that calls it would stop. The notes it sends are Listen's own to end: the music's queue knows nothing of them.
|
|
3011
|
+
*/
|
|
3012
|
+
function sendListenNote({ instrumentId, channel, kind, pitch, velocity, at }) {
|
|
3013
|
+
try {
|
|
3014
|
+
const whole = (value, low, high) => Number.isInteger(value) && value >= low && value <= high;
|
|
3015
|
+
if (!whole(channel, 1, 16) || !whole(pitch, 0, 127) || (kind !== 'on' && kind !== 'off') || (kind === 'on' && !whole(velocity, 1, 127))) return { refused: 'invalid' };
|
|
3016
|
+
if (at !== undefined && (typeof at !== 'number' || !Number.isFinite(at) || (typeof perfNow === 'function' && at - perfNow() > NOTE_STAMP_MAX_AHEAD_MS))) return { refused: 'invalid' };
|
|
3017
|
+
const target = kind === 'on' ? resolveListen(instrumentId) : resolveListenOff(instrumentId);
|
|
3018
|
+
if (target.refused !== undefined) return target;
|
|
3019
|
+
const message = [(kind === 'on' ? 0x90 : 0x80) | (channel - 1), pitch, kind === 'on' ? velocity : 0];
|
|
3020
|
+
if (at === undefined) target.native.send(message);
|
|
3021
|
+
else target.native.send(message, at);
|
|
3022
|
+
if (kind === 'on') listenPorts.set(instrumentId, target.instrument.port);
|
|
3023
|
+
listenFailure = false;
|
|
3024
|
+
return { ok: true };
|
|
3025
|
+
} catch (err) {
|
|
3026
|
+
return listenFailed(err);
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
|
|
3030
|
+
// The one playingTracksOn failure that has been logged, so a run of them logs one line.
|
|
3031
|
+
let playingTracksFailed = false;
|
|
3032
|
+
|
|
3033
|
+
/**
|
|
3034
|
+
* The display names (trackName) of the unmuted tracks of the code this tab plays that sit on one instrument's output and channel, in code order, for
|
|
3035
|
+
* the Scratchpad's Heads up line. A track is on it when it has a slot on that channel whose port the instrument's one connected output contains (the
|
|
3036
|
+
* way a track reaches an output); when the instrument resolves to no single output the two port strings are compared as written. Only while this
|
|
3037
|
+
* tab plays and the music plays; otherwise, and for a rig that is not valid, an instrument it does not name or code that does not parse, none.
|
|
3038
|
+
* Reads only; it never throws (an error gives none and one logged line).
|
|
3039
|
+
*/
|
|
3040
|
+
function playingTracksOn(instrumentId, channel) {
|
|
3041
|
+
try {
|
|
3042
|
+
if (!mayStop() || rig.setup !== 'valid') return [];
|
|
3043
|
+
const instrument = rig.instruments.find((candidate) => candidate.id === instrumentId);
|
|
3044
|
+
if (instrument === undefined || activeCode === '') return [];
|
|
3045
|
+
const read = slotsOf(activeCode);
|
|
3046
|
+
if (!read.ok) return [];
|
|
3047
|
+
const native = portNative(instrument);
|
|
3048
|
+
const onOutput = (port) => (native !== null ? native.name.includes(port) : port === instrument.port);
|
|
3049
|
+
const names = [];
|
|
3050
|
+
for (const track of read.tracks.values()) {
|
|
3051
|
+
if (!track.muted && track.slots.some((slot) => slot.channel === channel && onOutput(slot.port))) names.push(trackName(track.identity));
|
|
3052
|
+
}
|
|
3053
|
+
playingTracksFailed = false;
|
|
3054
|
+
return names;
|
|
3055
|
+
} catch (err) {
|
|
3056
|
+
if (!playingTracksFailed) {
|
|
3057
|
+
playingTracksFailed = true;
|
|
3058
|
+
onError('Moontide: the tracks on a Listen channel could not be read:', err);
|
|
3059
|
+
}
|
|
3060
|
+
return [];
|
|
3061
|
+
}
|
|
3062
|
+
}
|
|
3063
|
+
|
|
2569
3064
|
/**
|
|
2570
3065
|
* Tells the page whether it is the tab that plays. A superseded tab (false) resets and sets up nothing and
|
|
2571
3066
|
* its pending settle timers are cancelled. Taking the role back (true) is not a connection: the shadows
|
|
@@ -2602,6 +3097,10 @@ export function createMidiOut({
|
|
|
2602
3097
|
return {
|
|
2603
3098
|
beforeEvaluate,
|
|
2604
3099
|
enableIfGranted,
|
|
3100
|
+
// The access word now (one of ACCESS_STATES), the name of the failure when it is 'failed' (else null), and the shared enable.
|
|
3101
|
+
accessState: () => access,
|
|
3102
|
+
accessFailure: () => failure,
|
|
3103
|
+
enable,
|
|
2605
3104
|
setReplayBase,
|
|
2606
3105
|
recordSuccess,
|
|
2607
3106
|
publishFacts,
|
|
@@ -2619,9 +3118,16 @@ export function createMidiOut({
|
|
|
2619
3118
|
prepareTakeover,
|
|
2620
3119
|
abandonTakeover,
|
|
2621
3120
|
previewControl,
|
|
3121
|
+
listenTarget,
|
|
3122
|
+
sendListenNote,
|
|
3123
|
+
playingTracksOn,
|
|
2622
3124
|
resync,
|
|
2623
3125
|
setActive,
|
|
2624
3126
|
installNoteStamps,
|
|
2625
3127
|
noteStampOf,
|
|
3128
|
+
// The performance.now() stamp of a cycle line on the one page clock, or null when the page has no clock or it cannot give one.
|
|
3129
|
+
// The Scratchpad places its taps on this same cycle-to-time map as Moontide's own MIDI notes; a second clock would have its own
|
|
3130
|
+
// tempo segments and drift from it.
|
|
3131
|
+
stampOfCycle: (cycle) => (pageClock === null ? null : pageClock.stampOfCycle(cycle)),
|
|
2626
3132
|
};
|
|
2627
3133
|
}
|