moontide-mcp 2.2.0 → 2.4.0

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