moontide-mcp 2.1.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -331
- package/dist/ask.js +59 -12
- package/dist/catalog.js +118 -9
- package/dist/instruments.js +83 -9
- package/dist/mcp.js +146 -20
- package/dist/midi-state.js +43 -1
- package/dist/protocol.js +123 -5
- package/dist/reference.js +12 -0
- package/dist/request-status.js +221 -0
- package/dist/session.js +203 -7
- package/dist/vocabulary.js +420 -0
- package/package.json +3 -2
- package/page/ask-view.js +118 -50
- package/page/ask.js +139 -169
- package/page/bar-model.js +44 -2
- package/page/change-timing.js +35 -0
- package/page/index.html +6 -2
- package/page/instruments-model.js +50 -3
- package/page/instruments-view.js +14 -2
- package/page/jam-analysis.js +68 -23
- package/page/jam-model.js +185 -29
- package/page/jam-resolve.js +271 -0
- package/page/jam-view.js +165 -10
- package/page/lights.js +29 -7
- package/page/main.js +807 -66
- package/page/midi-clock.js +22 -3
- package/page/midi-in.js +407 -0
- package/page/midi-out.js +557 -48
- package/page/midi-plan.js +42 -6
- package/page/midi-rig.js +286 -13
- package/page/midi-settings.js +507 -0
- package/page/mixer-view.js +7 -1
- package/page/note-panel.js +29 -8
- package/page/note-tag.js +19 -4
- package/page/page-rewrite.js +1 -1
- package/page/played-buffer.js +208 -0
- package/page/query-check.js +21 -2
- package/page/request-status-view.js +419 -0
- package/page/request-status.js +686 -0
- package/page/scratch-gesture.js +239 -0
- package/page/scratch-listen.js +260 -0
- package/page/scratch-model.js +1696 -0
- package/page/scratch-view.js +2176 -0
- package/page/song-grid-model.js +15 -0
- package/page/song-grid-view.js +1 -1
- package/page/structure.js +2 -1
- package/page/style.css +1243 -43
- package/page/tempo-glide.js +330 -0
- package/page/tempo-guard.js +62 -10
- package/page/timeline-edit-view.js +1 -1
- package/page/timeline.js +2 -1
- package/page/top-bar-view.js +18 -13
- package/page/views.js +20 -7
- package/reference/midi.md +2 -0
- package/reference/motif.md +83 -0
- package/reference/settings.md +161 -0
- package/reference/transitions.md +13 -1
package/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
|
|
@@ -193,6 +198,8 @@ export function createMidiOut({
|
|
|
193
198
|
getScheduler,
|
|
194
199
|
getAudioContext,
|
|
195
200
|
perfNow,
|
|
201
|
+
// (fromCycle, toCycle) => { seconds, lagSeconds } through a running tempo ramp, or null: lines far ahead are stamped through it.
|
|
202
|
+
rampSeconds = () => null,
|
|
196
203
|
onClockState = () => {},
|
|
197
204
|
resetSettleMs = RESET_SETTLE_MS,
|
|
198
205
|
now = () => Date.now(),
|
|
@@ -230,9 +237,18 @@ export function createMidiOut({
|
|
|
230
237
|
const controlShadows = new Map();
|
|
231
238
|
let activeControls = null;
|
|
232
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;
|
|
233
246
|
// The last valid rig the page saw (null after the file went absent): a file that passes through an
|
|
234
247
|
// invalid state between two valid ones is compared against it, not against the invalid state.
|
|
235
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 = [];
|
|
236
252
|
// The desired sounds of the evaluation being prepared (a reset that runs meanwhile sends them),
|
|
237
253
|
// and what the page knows of each rig instrument's connection: { key, present, settleTimer, repeatTimer,
|
|
238
254
|
// holding, holdLogged, epoch, resetPromise, resets, resetAt } by instrument id. `holding` is true from the instant a
|
|
@@ -241,6 +257,9 @@ export function createMidiOut({
|
|
|
241
257
|
// active role is not `active`: it resets and sets up nothing.
|
|
242
258
|
let pendingPlan = null;
|
|
243
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;
|
|
244
263
|
// The plan (prepareSetup call) that owns pendingPlan: the same code checked twice gives the same desired Map, so the
|
|
245
264
|
// Map cannot tell two plans apart.
|
|
246
265
|
let pendingOwner = null;
|
|
@@ -249,6 +268,10 @@ export function createMidiOut({
|
|
|
249
268
|
// The channels the evaluation being prepared changed, so a rollback can undo or report each one. A reset
|
|
250
269
|
// that sends the pending sounds adds its channels here too (it wiped them first: nothing to send back).
|
|
251
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;
|
|
252
275
|
let active = true;
|
|
253
276
|
const states = new Map();
|
|
254
277
|
// The rig check of a code string depends on the rig, the connected outputs and the playing flag; it
|
|
@@ -306,6 +329,73 @@ export function createMidiOut({
|
|
|
306
329
|
onError(`Moontide: more than ${MAX_PAGE_EVENTS} MIDI events were waiting to be sent, so the oldest were dropped.`);
|
|
307
330
|
}
|
|
308
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
|
+
|
|
309
399
|
// While held, only a forced publish goes out: the page holds the facts during a take-over replay, when
|
|
310
400
|
// the code this tab last evaluated is not the code that plays any more.
|
|
311
401
|
let factsHeld = false;
|
|
@@ -337,6 +427,8 @@ export function createMidiOut({
|
|
|
337
427
|
const clockHoldLogged = new Set();
|
|
338
428
|
// Rig instrument ids whose switched-off notes were logged: one line per switch-off, not one per dropped note.
|
|
339
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();
|
|
340
432
|
// Output -> channels (1 to 16) whose messages it drops (the stale-channel rule).
|
|
341
433
|
// Strudel picks the Output when it triggers a note, up to 0.3 s ahead, and plays on that same object
|
|
342
434
|
// later: the notes the old code triggered before a sound change hold the old Output, so the Output's
|
|
@@ -403,6 +495,47 @@ export function createMidiOut({
|
|
|
403
495
|
}, 0);
|
|
404
496
|
}
|
|
405
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
|
+
|
|
406
539
|
/**
|
|
407
540
|
* True when `output` drops this message. Five rules, in order: the message is clock or transport (status 0xF8,
|
|
408
541
|
* 0xFA, 0xFB or 0xFC): Moontide sends these itself, so Strudel's are dropped on every port; the message is a channel
|
|
@@ -411,7 +544,7 @@ export function createMidiOut({
|
|
|
411
544
|
* the notes of code already playing included); the Output is the one of a port that is held (a rig instrument whose
|
|
412
545
|
* presence state has `holding`; both rules test whether the output's name contains the port: the substring rule of
|
|
413
546
|
* nativeFor, never broader); or the message is a note-on for a clock instrument that waits for its Start (see
|
|
414
|
-
* below).
|
|
547
|
+
* below). The memory guard runs before these rules (memoryGuardedArgs). `data` is untrusted: its type is checked before the
|
|
415
548
|
* status byte is masked. `ts` is the timestamp the message carries (the page clock's mapping) or undefined: a stamped note-on is
|
|
416
549
|
* held by its own timestamp, and the other rules apply to a stamped message as they do to any other.
|
|
417
550
|
*/
|
|
@@ -685,12 +818,15 @@ export function createMidiOut({
|
|
|
685
818
|
guardedOutputs.add(output);
|
|
686
819
|
const original = output.send;
|
|
687
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;
|
|
688
824
|
const intent = noteIntent;
|
|
689
|
-
if (dropsMessage(output,
|
|
690
|
-
if (intent === null) return original.apply(output,
|
|
691
|
-
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);
|
|
692
828
|
const result = original.apply(output, sendArgs);
|
|
693
|
-
recordQueued(output,
|
|
829
|
+
recordQueued(output, checked[0], sendArgs, intent.triggerAt);
|
|
694
830
|
return result;
|
|
695
831
|
};
|
|
696
832
|
}
|
|
@@ -929,6 +1065,16 @@ export function createMidiOut({
|
|
|
929
1065
|
return pendingEnable;
|
|
930
1066
|
}
|
|
931
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
|
+
|
|
932
1078
|
/**
|
|
933
1079
|
* Waits for the enable, but not longer than the ceiling. When the ceiling wins the enable keeps
|
|
934
1080
|
* running (its outcome still sets the access and publishes) and later evaluations do not wait again.
|
|
@@ -1486,30 +1632,34 @@ export function createMidiOut({
|
|
|
1486
1632
|
}
|
|
1487
1633
|
// The sounds of the playing code are no longer true when the file went away, or when an instrument it
|
|
1488
1634
|
// names left the file or changed its port or model: a later reset must not send them.
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
const kept = new Map();
|
|
1492
|
-
for (const [slot, entry] of activePlan) {
|
|
1493
|
-
const was = known(base, entry.instrument);
|
|
1494
|
-
const current = known(rig, entry.instrument);
|
|
1495
|
-
const gone = rig.setup === 'absent' || current === undefined || (was !== undefined && (was.port !== current.port || was.model !== current.model));
|
|
1496
|
-
if (!gone) kept.set(slot, entry);
|
|
1497
|
-
}
|
|
1498
|
-
activePlan = kept;
|
|
1499
|
-
}
|
|
1500
|
-
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') {
|
|
1501
1637
|
const base = previous.setup === 'valid' ? previous : (lastValid ?? previous);
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
const
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
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 });
|
|
1510
1659
|
}
|
|
1511
1660
|
if (rig.setup === 'valid') lastValid = rig;
|
|
1512
1661
|
else if (rig.setup === 'absent') lastValid = null;
|
|
1662
|
+
if (rig.setup === 'valid') guardInstruments = rig.instruments;
|
|
1513
1663
|
// An instrument that is on again (or gone) logs its next switch-off.
|
|
1514
1664
|
for (const id of [...offLogged]) if (!known(rig, id) || known(rig, id).enabled !== false) offLogged.delete(id);
|
|
1515
1665
|
silenceSwitchedOff(offBase);
|
|
@@ -1550,6 +1700,7 @@ export function createMidiOut({
|
|
|
1550
1700
|
getScheduler,
|
|
1551
1701
|
getAudioContext,
|
|
1552
1702
|
perfNow,
|
|
1703
|
+
rampSeconds,
|
|
1553
1704
|
setTimer,
|
|
1554
1705
|
clearTimer,
|
|
1555
1706
|
isPlaying,
|
|
@@ -1667,6 +1818,14 @@ export function createMidiOut({
|
|
|
1667
1818
|
if (replayCode !== null) counted.push(replayCode);
|
|
1668
1819
|
const playingPorts = namedPorts(counted);
|
|
1669
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() };
|
|
1670
1829
|
}
|
|
1671
1830
|
}
|
|
1672
1831
|
setupMemo.set(key, result);
|
|
@@ -1718,6 +1877,58 @@ export function createMidiOut({
|
|
|
1718
1877
|
return true;
|
|
1719
1878
|
}
|
|
1720
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
|
+
|
|
1721
1932
|
/**
|
|
1722
1933
|
* The controls of one channel the page must send now, in the model's order (the first control of each cc):
|
|
1723
1934
|
* [{ cc, value }]. `slot` is the controls the code writes there (or undefined), `shadow` the Map from cc to the
|
|
@@ -1899,7 +2110,7 @@ export function createMidiOut({
|
|
|
1899
2110
|
|
|
1900
2111
|
/**
|
|
1901
2112
|
* The timestamp for the setup of a change that waits for cycle `atCycle`: the page clock's stamp of that line minus
|
|
1902
|
-
* SETUP_BEFORE_LINE_MS. When no stamp can be given or it is more than NOTE_STAMP_MAX_AHEAD_MS ahead (a far timestamp
|
|
2113
|
+
* SETUP_BEFORE_LINE_MS (while a tempo ramp runs, the earliest the line can be heard: the setup must land before it). When no stamp can be given or it is more than NOTE_STAMP_MAX_AHEAD_MS ahead (a far timestamp
|
|
1903
2114
|
* would park bytes in the OS queue, which cannot take them back), says so in one Moontide: line, waits until the
|
|
1904
2115
|
* line through the injected timer and resolves undefined: the setup then goes out without a timestamp. `track` gets
|
|
1905
2116
|
* { cancel } while that wait runs, so a dropped plan ends it. Never rejects.
|
|
@@ -1907,7 +2118,7 @@ export function createMidiOut({
|
|
|
1907
2118
|
async function timedSetupAt(atCycle, track) {
|
|
1908
2119
|
let line = null;
|
|
1909
2120
|
try {
|
|
1910
|
-
line = pageClock === null ? null : pageClock.stampOfCycle(atCycle);
|
|
2121
|
+
line = pageClock === null ? null : pageClock.stampOfCycle(atCycle, { early: true });
|
|
1911
2122
|
} catch (err) {
|
|
1912
2123
|
onError('Moontide: the page clock could not give the timestamp of the line:', err);
|
|
1913
2124
|
}
|
|
@@ -1975,8 +2186,11 @@ export function createMidiOut({
|
|
|
1975
2186
|
const timed = atCycle !== undefined;
|
|
1976
2187
|
pendingPlan = null;
|
|
1977
2188
|
pendingControls = null;
|
|
2189
|
+
pendingSettings = null;
|
|
2190
|
+
pendingScenes = null;
|
|
1978
2191
|
pendingOwner = null;
|
|
1979
2192
|
pendingChanges = null;
|
|
2193
|
+
pendingSent = null;
|
|
1980
2194
|
if (rig.setup === 'absent') return null;
|
|
1981
2195
|
const checked = checkedSetup(code);
|
|
1982
2196
|
if (checked === null) {
|
|
@@ -1984,14 +2198,19 @@ export function createMidiOut({
|
|
|
1984
2198
|
refusal: null,
|
|
1985
2199
|
send: async () => {},
|
|
1986
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);
|
|
1987
2203
|
activePlan = new Map();
|
|
1988
2204
|
activeControls = new Map();
|
|
2205
|
+
activeSettings = new Map();
|
|
2206
|
+
activeScenes = new Map();
|
|
2207
|
+
if (told > 0) publishEvents();
|
|
1989
2208
|
},
|
|
1990
2209
|
rollback: async () => {},
|
|
1991
2210
|
drop: () => {},
|
|
1992
2211
|
};
|
|
1993
2212
|
}
|
|
1994
|
-
const { refusal, desired, controls } = checked;
|
|
2213
|
+
const { refusal, desired, controls, settingSlots, sceneSettings } = checked;
|
|
1995
2214
|
if (refusal !== null) {
|
|
1996
2215
|
return { refusal, send: async () => {}, commit: () => {}, rollback: async () => {}, drop: () => {} };
|
|
1997
2216
|
}
|
|
@@ -2000,9 +2219,16 @@ export function createMidiOut({
|
|
|
2000
2219
|
const owner = {};
|
|
2001
2220
|
pendingPlan = desired;
|
|
2002
2221
|
pendingControls = controls;
|
|
2222
|
+
pendingSettings = settingSlots;
|
|
2223
|
+
pendingScenes = sceneSettings;
|
|
2003
2224
|
pendingOwner = owner;
|
|
2004
2225
|
const changes = [];
|
|
2005
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 };
|
|
2006
2232
|
// The { output, channel } pairs this evaluation filters (see filterStaleChannel).
|
|
2007
2233
|
const filtered = [];
|
|
2008
2234
|
// The latest timestamp this evaluation's timed setup carries, or null when nothing was timed (a rollback goes after it).
|
|
@@ -2021,8 +2247,11 @@ export function createMidiOut({
|
|
|
2021
2247
|
if (timed && pendingOwner !== owner) return;
|
|
2022
2248
|
pendingPlan = null;
|
|
2023
2249
|
pendingControls = null;
|
|
2250
|
+
pendingSettings = null;
|
|
2251
|
+
pendingScenes = null;
|
|
2024
2252
|
pendingOwner = null;
|
|
2025
2253
|
pendingChanges = null;
|
|
2254
|
+
pendingSent = null;
|
|
2026
2255
|
}
|
|
2027
2256
|
return {
|
|
2028
2257
|
refusal: null,
|
|
@@ -2050,9 +2279,11 @@ export function createMidiOut({
|
|
|
2050
2279
|
.filter((entry) => entry.instrument === instrument.id)
|
|
2051
2280
|
.sort((a, b) => a.channel - b.channel);
|
|
2052
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);
|
|
2053
2284
|
// An instrument the code does not name is still brought back to its defaults for a control that left the code.
|
|
2054
2285
|
const leaving = controlChannels(instrument, []).some((channel) => controlsToSend(instrument, undefined, controlShadows.get(instrument.id)?.get(channel)).length > 0);
|
|
2055
|
-
if (wanted.length === 0 && slots.length === 0 && !leaving) continue;
|
|
2286
|
+
if (wanted.length === 0 && slots.length === 0 && !leaving && settingsOf.length === 0 && sceneOf === undefined) continue;
|
|
2056
2287
|
// A reset in its wait finishes first; it sends this evaluation's sounds itself, and the bytes it
|
|
2057
2288
|
// sent count toward the guard.
|
|
2058
2289
|
const pendingReset = states.get(instrument.id)?.resetPromise ?? null;
|
|
@@ -2062,11 +2293,28 @@ export function createMidiOut({
|
|
|
2062
2293
|
if (native === null) continue;
|
|
2063
2294
|
if (!shadows.has(instrument.id)) shadows.set(instrument.id, new Map());
|
|
2064
2295
|
const shadow = shadows.get(instrument.id);
|
|
2065
|
-
|
|
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
|
|
2066
2312
|
// program change, so a control never lands before the sound it belongs to).
|
|
2067
|
-
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;
|
|
2068
2315
|
for (const channel of channels) {
|
|
2069
2316
|
const entry = wanted.find((candidate) => candidate.channel === channel);
|
|
2317
|
+
let soundSent = false;
|
|
2070
2318
|
// A timed plan stamps the whole channel with the line; otherwise `at` is set only for a channel whose sound
|
|
2071
2319
|
// changes while old code plays, and the other channels go out at once.
|
|
2072
2320
|
let at = lineAt;
|
|
@@ -2091,9 +2339,11 @@ export function createMidiOut({
|
|
|
2091
2339
|
liftStaleFilters(filtered, channel);
|
|
2092
2340
|
// A partly sent change leaves the channel in a state nobody knows: forget it, so it is sent whole next time.
|
|
2093
2341
|
if (sent.partial) shadow.delete(channel);
|
|
2342
|
+
stopped = true;
|
|
2094
2343
|
break;
|
|
2095
2344
|
}
|
|
2096
2345
|
shadow.set(channel, next);
|
|
2346
|
+
soundSent = true;
|
|
2097
2347
|
changes.push({ instrument, channel, previous, sound: entry.sound });
|
|
2098
2348
|
// A held note on this channel may be cut by the change: the playing code used the channel.
|
|
2099
2349
|
if (previous !== null && isPlaying() === true && activePlan !== null && activePlan.has(`${instrument.id}:${channel}`)) {
|
|
@@ -2101,8 +2351,26 @@ export function createMidiOut({
|
|
|
2101
2351
|
}
|
|
2102
2352
|
}
|
|
2103
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
|
+
}
|
|
2104
2363
|
const slot = slots.find((candidate) => candidate.channel === channel);
|
|
2105
|
-
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;
|
|
2106
2374
|
}
|
|
2107
2375
|
}
|
|
2108
2376
|
// A timed plan goes out at the lead and nothing waits for it, so there is no guard to wait out.
|
|
@@ -2115,10 +2383,16 @@ export function createMidiOut({
|
|
|
2115
2383
|
},
|
|
2116
2384
|
commit() {
|
|
2117
2385
|
committed = true;
|
|
2118
|
-
// 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);
|
|
2119
2390
|
activePlan = desired;
|
|
2120
2391
|
activeControls = controls;
|
|
2392
|
+
activeSettings = settingSlots;
|
|
2393
|
+
activeScenes = sceneSettings;
|
|
2121
2394
|
releasePending();
|
|
2395
|
+
if (told > 0) publishEvents();
|
|
2122
2396
|
try {
|
|
2123
2397
|
swapFilteredOutputs(filtered);
|
|
2124
2398
|
} catch (err) {
|
|
@@ -2131,7 +2405,10 @@ export function createMidiOut({
|
|
|
2131
2405
|
// An immediate change that replaces this one has prepared (and sent) its own setup before its pattern is
|
|
2132
2406
|
// installed, and that setup is still pending here: it is what the instrument must end on. A timed newer change
|
|
2133
2407
|
// prepares its setup later, at its own lead, so it finds nothing here and goes after the put-back.
|
|
2134
|
-
const successor =
|
|
2408
|
+
const successor =
|
|
2409
|
+
timed && pendingOwner !== null && pendingOwner !== owner
|
|
2410
|
+
? { plan: pendingPlan, controls: pendingControls, settings: pendingSettings, scenes: pendingScenes }
|
|
2411
|
+
: null;
|
|
2135
2412
|
releasePending();
|
|
2136
2413
|
// Bytes stamped on the line are already queued on the port and cannot be recalled: when the change is canceled
|
|
2137
2414
|
// after they went out, the instrument would switch to the canceled change's sounds and controls on the line
|
|
@@ -2155,8 +2432,11 @@ export function createMidiOut({
|
|
|
2155
2432
|
liftStaleFilters(filtered);
|
|
2156
2433
|
pendingPlan = null;
|
|
2157
2434
|
pendingControls = null;
|
|
2435
|
+
pendingSettings = null;
|
|
2436
|
+
pendingScenes = null;
|
|
2158
2437
|
pendingOwner = null;
|
|
2159
2438
|
pendingChanges = null;
|
|
2439
|
+
pendingSent = null;
|
|
2160
2440
|
await putBack();
|
|
2161
2441
|
// The evaluation failed, so nothing else will publish: the events (kept sounds, cut notes) go out here,
|
|
2162
2442
|
// before the failed reply that Claude reads.
|
|
@@ -2169,11 +2449,14 @@ export function createMidiOut({
|
|
|
2169
2449
|
};
|
|
2170
2450
|
/**
|
|
2171
2451
|
* Sends the sounds this plan changed back to what the channels held before it, then the controls the playing code
|
|
2172
|
-
* writes;
|
|
2173
|
-
* immediate change that replaces this plan: { plan, controls }) a
|
|
2174
|
-
* 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.
|
|
2175
2456
|
*/
|
|
2176
2457
|
async function putBack(successor = null) {
|
|
2458
|
+
// The channels whose old sound went back: a program change erased their tone settings.
|
|
2459
|
+
const soundsBack = [];
|
|
2177
2460
|
try {
|
|
2178
2461
|
for (const { instrument, channel, previous, sound } of changes) {
|
|
2179
2462
|
// The successor's own setup went out after this plan's bytes (or the sound is the same one): the shadow is true.
|
|
@@ -2194,8 +2477,10 @@ export function createMidiOut({
|
|
|
2194
2477
|
const bytesSent = { count: 0 };
|
|
2195
2478
|
// After the timed change of this evaluation while it is still ahead, so no old note sounds with the refused sound.
|
|
2196
2479
|
const sent = sendSound(native, instrument, channel, previous, bytesSent, afterTimed(latestTimed));
|
|
2197
|
-
if (sent.ok)
|
|
2198
|
-
|
|
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);
|
|
2199
2484
|
}
|
|
2200
2485
|
} catch (err) {
|
|
2201
2486
|
onError('Moontide: the MIDI setup could not be rolled back:', err);
|
|
@@ -2207,6 +2492,61 @@ export function createMidiOut({
|
|
|
2207
2492
|
? { after: latestTimed ?? undefined }
|
|
2208
2493
|
: { after: latestTimed ?? undefined, controls: successor.controls ?? undefined, resend: stampedControls },
|
|
2209
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
|
+
}
|
|
2210
2550
|
}
|
|
2211
2551
|
}
|
|
2212
2552
|
|
|
@@ -2339,14 +2679,29 @@ export function createMidiOut({
|
|
|
2339
2679
|
|
|
2340
2680
|
/**
|
|
2341
2681
|
* For each declared channel ascending: the sound the code declares there (the evaluation being prepared
|
|
2342
|
-
* first, else the code that plays), then the
|
|
2343
|
-
*
|
|
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
|
|
2344
2686
|
* sent. `wiped` is true for a reset, which cleared the instrument first: a sound of the evaluation being
|
|
2345
2687
|
* prepared is then listed in the change list, so a rollback tells Claude the channel kept it. Returns the
|
|
2346
2688
|
* number of bytes sent; a send that throws throws out of here.
|
|
2347
2689
|
*/
|
|
2348
2690
|
function sendDeclaredSetup(instrument, native, { wiped = false } = {}) {
|
|
2349
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
|
+
};
|
|
2350
2705
|
if (!shadows.has(instrument.id)) shadows.set(instrument.id, new Map());
|
|
2351
2706
|
const shadow = shadows.get(instrument.id);
|
|
2352
2707
|
const channels = instrument.channels.map((entry) => entry.channel).sort((a, b) => a - b);
|
|
@@ -2368,6 +2723,13 @@ export function createMidiOut({
|
|
|
2368
2723
|
}
|
|
2369
2724
|
shadow.set(channel, next);
|
|
2370
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
|
+
}
|
|
2371
2733
|
const controlSlot = pendingControls?.get(slot) ?? activeControls?.get(slot);
|
|
2372
2734
|
const volume = controlSlot?.values.get(7)?.value ?? defaultOf(instrument, { cc: 7 });
|
|
2373
2735
|
native.send([0xb0 | (channel - 1), 7, volume]);
|
|
@@ -2381,7 +2743,12 @@ export function createMidiOut({
|
|
|
2381
2743
|
setupBytes += 3;
|
|
2382
2744
|
}
|
|
2383
2745
|
}
|
|
2384
|
-
|
|
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;
|
|
2385
2752
|
}
|
|
2386
2753
|
|
|
2387
2754
|
/**
|
|
@@ -2400,8 +2767,7 @@ export function createMidiOut({
|
|
|
2400
2767
|
} catch (err) {
|
|
2401
2768
|
forgetShadows(instrument.id);
|
|
2402
2769
|
onError(`Moontide: could not send the second setup to MIDI port "${native.name}":`, err);
|
|
2403
|
-
|
|
2404
|
-
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));
|
|
2405
2771
|
publishFacts();
|
|
2406
2772
|
return 0;
|
|
2407
2773
|
}
|
|
@@ -2436,6 +2802,15 @@ export function createMidiOut({
|
|
|
2436
2802
|
let failed = null;
|
|
2437
2803
|
try {
|
|
2438
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
|
+
}
|
|
2439
2814
|
if (reset !== null) for (const message of reset.messages) native.send(message);
|
|
2440
2815
|
shadows.set(instrument.id, new Map());
|
|
2441
2816
|
controlShadows.set(instrument.id, new Map());
|
|
@@ -2464,8 +2839,7 @@ export function createMidiOut({
|
|
|
2464
2839
|
if (failed === null) {
|
|
2465
2840
|
addEvent({ kind: 'reset', instrument: id, reason });
|
|
2466
2841
|
} else {
|
|
2467
|
-
|
|
2468
|
-
addEvent({ kind: 'send-failed', instrument: id, what: 'reset', failure: cutText(name, MIDI_LIMITS.failure) });
|
|
2842
|
+
addEvent(failureEvent(instrument, 'reset', failed));
|
|
2469
2843
|
}
|
|
2470
2844
|
publishFacts();
|
|
2471
2845
|
} catch (err) {
|
|
@@ -2563,6 +2937,130 @@ export function createMidiOut({
|
|
|
2563
2937
|
}
|
|
2564
2938
|
}
|
|
2565
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
|
+
|
|
2566
3064
|
/**
|
|
2567
3065
|
* Tells the page whether it is the tab that plays. A superseded tab (false) resets and sets up nothing and
|
|
2568
3066
|
* its pending settle timers are cancelled. Taking the role back (true) is not a connection: the shadows
|
|
@@ -2599,6 +3097,10 @@ export function createMidiOut({
|
|
|
2599
3097
|
return {
|
|
2600
3098
|
beforeEvaluate,
|
|
2601
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,
|
|
2602
3104
|
setReplayBase,
|
|
2603
3105
|
recordSuccess,
|
|
2604
3106
|
publishFacts,
|
|
@@ -2616,9 +3118,16 @@ export function createMidiOut({
|
|
|
2616
3118
|
prepareTakeover,
|
|
2617
3119
|
abandonTakeover,
|
|
2618
3120
|
previewControl,
|
|
3121
|
+
listenTarget,
|
|
3122
|
+
sendListenNote,
|
|
3123
|
+
playingTracksOn,
|
|
2619
3124
|
resync,
|
|
2620
3125
|
setActive,
|
|
2621
3126
|
installNoteStamps,
|
|
2622
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)),
|
|
2623
3132
|
};
|
|
2624
3133
|
}
|