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.
Files changed (57) hide show
  1. package/README.md +54 -331
  2. package/dist/ask.js +59 -12
  3. package/dist/catalog.js +118 -9
  4. package/dist/instruments.js +83 -9
  5. package/dist/mcp.js +146 -20
  6. package/dist/midi-state.js +43 -1
  7. package/dist/protocol.js +123 -5
  8. package/dist/reference.js +12 -0
  9. package/dist/request-status.js +221 -0
  10. package/dist/session.js +203 -7
  11. package/dist/vocabulary.js +420 -0
  12. package/package.json +3 -2
  13. package/page/ask-view.js +118 -50
  14. package/page/ask.js +139 -169
  15. package/page/bar-model.js +44 -2
  16. package/page/change-timing.js +35 -0
  17. package/page/index.html +6 -2
  18. package/page/instruments-model.js +50 -3
  19. package/page/instruments-view.js +14 -2
  20. package/page/jam-analysis.js +68 -23
  21. package/page/jam-model.js +185 -29
  22. package/page/jam-resolve.js +271 -0
  23. package/page/jam-view.js +165 -10
  24. package/page/lights.js +29 -7
  25. package/page/main.js +807 -66
  26. package/page/midi-clock.js +22 -3
  27. package/page/midi-in.js +407 -0
  28. package/page/midi-out.js +557 -48
  29. package/page/midi-plan.js +42 -6
  30. package/page/midi-rig.js +286 -13
  31. package/page/midi-settings.js +507 -0
  32. package/page/mixer-view.js +7 -1
  33. package/page/note-panel.js +29 -8
  34. package/page/note-tag.js +19 -4
  35. package/page/page-rewrite.js +1 -1
  36. package/page/played-buffer.js +208 -0
  37. package/page/query-check.js +21 -2
  38. package/page/request-status-view.js +419 -0
  39. package/page/request-status.js +686 -0
  40. package/page/scratch-gesture.js +239 -0
  41. package/page/scratch-listen.js +260 -0
  42. package/page/scratch-model.js +1696 -0
  43. package/page/scratch-view.js +2176 -0
  44. package/page/song-grid-model.js +15 -0
  45. package/page/song-grid-view.js +1 -1
  46. package/page/structure.js +2 -1
  47. package/page/style.css +1243 -43
  48. package/page/tempo-glide.js +330 -0
  49. package/page/tempo-guard.js +62 -10
  50. package/page/timeline-edit-view.js +1 -1
  51. package/page/timeline.js +2 -1
  52. package/page/top-bar-view.js +18 -13
  53. package/page/views.js +20 -7
  54. package/reference/midi.md +2 -0
  55. package/reference/motif.md +83 -0
  56. package/reference/settings.md +161 -0
  57. 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). SysEx and the like pass unless the instrument is off. `data` is untrusted: its type is checked before the
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, args[0], intent === null ? undefined : intent.ts)) return output;
690
- if (intent === null) return original.apply(output, args);
691
- const sendArgs = safeStampedArgs(args, intent);
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, args[0], sendArgs, intent.triggerAt);
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
- if (activePlan !== null && (rig.setup === 'absent' || rig.setup === 'valid')) {
1490
- const base = previous.setup === 'valid' ? previous : (lastValid ?? previous);
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
- const kept = new Map();
1503
- for (const [slot, entry] of activeControls) {
1504
- const was = known(base, entry.instrument);
1505
- const current = known(rig, entry.instrument);
1506
- const gone = rig.setup === 'absent' || current === undefined || (was !== undefined && (was.port !== current.port || was.model !== current.model));
1507
- if (!gone) kept.set(slot, entry);
1508
- }
1509
- activeControls = kept;
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
- // Channels ascending; on each, the sound goes first, then the controls the code writes (after the
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 })) break;
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, events included, went out with recordSuccess inside the evaluation.
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 = timed && pendingOwner !== null && pendingOwner !== owner ? { plan: pendingPlan, controls: pendingControls } : null;
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; both after the plan's own timed change while it is still ahead, never before it. With a `successor` (an
2173
- * immediate change that replaces this plan: { plan, controls }) a channel it sets up keeps the sound it holds, and
2174
- * the controls it writes are sent again after this plan's stamped ones. Never rejects.
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) shadows.get(instrument.id)?.set(channel, previous);
2198
- else if (sent.partial) shadows.get(instrument.id)?.delete(channel);
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 volume the code writes (same order) else the model's default,
2343
- * always, then every other control the code writes there, in the model's order. The shadows take what was
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
- return setupBytes;
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
- const name = err && typeof err.name === 'string' && err.name !== '' ? err.name : 'Error';
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
- const name = failed && typeof failed.name === 'string' && failed.name !== '' ? failed.name : 'Error';
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
  }