moontide-mcp 2.2.0 → 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/page/ask-view.js CHANGED
@@ -13,6 +13,8 @@ import {
13
13
  ASK_COPY,
14
14
  BOX_GAP_REM,
15
15
  MAX_ASK_TEXT_LENGTH,
16
+ MAX_MOTIF_PHRASE_LENGTH,
17
+ MAX_REQUEST_LINE_BYTES,
16
18
  PAGE_REQUESTS_COPY,
17
19
  pageRequestsText,
18
20
  askMessage,
@@ -21,11 +23,13 @@ import {
21
23
  describeContext,
22
24
  fitGestureToLine,
23
25
  fitMomentToLine,
26
+ motifChip,
24
27
  newAskId,
25
28
  partsAskState,
26
29
  placeBox,
27
30
  refusalText,
28
31
  requestFitsLine,
32
+ requestLineBytes,
29
33
  } from './ask.js';
30
34
 
31
35
  /**
@@ -34,9 +38,11 @@ import {
34
38
  * sends one JSON-able message to the server; `reportError` and `clearError` write and clear the
35
39
  * top-bar error line; `setTimer`, `clearTimer` and `now` are the page's clock. `requests` is the per-request tracker
36
40
  * of page/request-status.js: every request sent is handed to it, and so is every server answer to one. The tracker
37
- * tells the page (main.js) when a request was never confirmed; the page then calls `notConfirmed(id)` here.
41
+ * tells the page (main.js) when a request was never confirmed; the page then calls `notConfirmed(id)` here. `onMotifResult` is
42
+ * called with what became of the Scratchpad's motif request (`{ status: 'received' }` once Claude has it, `{ status: 'waiting' }` once the
43
+ * server holds it for Claude's next call); the motif request has its own pending id, so nothing it meets ever reaches the Ask box or its draft.
38
44
  */
39
- export function createAskView({ document, send, reportError, clearError, setTimer, clearTimer, now, requests }) {
45
+ export function createAskView({ document, send, reportError, clearError, setTimer, clearTimer, now, requests, onMotifResult = () => {} }) {
40
46
  const askBox = document.getElementById('ask-box');
41
47
  const askContextChip = document.getElementById('ask-context-chip');
42
48
  const askCurrentChip = document.getElementById('ask-current-chip');
@@ -136,6 +142,8 @@ export function createAskView({ document, send, reportError, clearError, setTime
136
142
  // request while it waits for Claude to take it.
137
143
  let partsAvailable = false;
138
144
  let partsPendingId = null;
145
+ // The Scratchpad's motif request, the same way: the id of the one on its way, so its refusal never arms the Ask box.
146
+ let motifPendingId = null;
139
147
 
140
148
  /** The fields of the request the box would send for `text` (trimmed), the id measured with the next one's shape. */
141
149
  function requestFields(text) {
@@ -430,6 +438,46 @@ export function createAskView({ document, send, reportError, clearError, setTime
430
438
  renderPartsAsk();
431
439
  });
432
440
 
441
+ /**
442
+ * Sends the Scratchpad's motif as a page request: `words` are the user's own (trimmed) and `motif` is the motif field of
443
+ * page/scratch-model.js's motifRequest. The request has its own pending id, goes to the tracker like any other and never
444
+ * touches the Ask box. Returns `{ id }` once it is sent, or `{ refused: word, ...numbers }` and sends nothing: 'disconnected',
445
+ * 'pending' (the last motif request has not reached the server: the server has not answered it yet), 'empty-words', 'words-too-long' (`length`, `max`) or 'too-big'
446
+ * (`bytes` and `max`, measured on the exact frame, id included; a phrase over MAX_MOTIF_PHRASE_LENGTH characters is refused the same way). The
447
+ * phrase is never cut: a motif that does not fit is refused whole.
448
+ */
449
+ function sendMotif({ words, motif }) {
450
+ if (!connected) return { refused: 'disconnected' };
451
+ if (motifPendingId !== null) return { refused: 'pending' };
452
+ const text = words.trim();
453
+ if (text === '') return { refused: 'empty-words' };
454
+ if (text.length > MAX_ASK_TEXT_LENGTH) return { refused: 'words-too-long', length: text.length, max: MAX_ASK_TEXT_LENGTH };
455
+ const id = newAskId(counter + 1, now());
456
+ // A phrase longer than the server takes is refused like any motif too big for the line, before describeContext (which throws on it)
457
+ // checks the motif. The bytes are those of the line it would make; the rest of the request always puts that line over its bound.
458
+ if (motif !== null && typeof motif === 'object' && typeof motif.phrase === 'string' && motif.phrase.length > MAX_MOTIF_PHRASE_LENGTH) {
459
+ const bytes = requestLineBytes({ id, text, context: { kind: 'motif' }, chip: motifChip(motif), current: currentVersion, motif });
460
+ return { refused: 'too-big', bytes, max: MAX_REQUEST_LINE_BYTES };
461
+ }
462
+ const described = describeContext({ kind: 'motif', motif });
463
+ const frame = { type: 'ask', id, text, context: described.context, chip: described.chip, current: currentVersion, motif: described.motif };
464
+ const bytes = requestLineBytes(frame);
465
+ if (bytes > MAX_REQUEST_LINE_BYTES) return { refused: 'too-big', bytes, max: MAX_REQUEST_LINE_BYTES };
466
+ counter += 1;
467
+ send(frame);
468
+ // While the token is refused the line stays: the server's 'token' refusal writes it again, so it never blinks off.
469
+ if (!authRefused) clearError();
470
+ requests.sent(frame.id, { text, chip: described.chip });
471
+ motifPendingId = frame.id;
472
+ return { id: frame.id };
473
+ }
474
+
475
+ /** The server has the motif request: it is no longer on its way, and the page is told. */
476
+ function arrivedMotif() {
477
+ motifPendingId = null;
478
+ onMotifResult({ status: 'received' });
479
+ }
480
+
433
481
  /** The server's answers: token result, one result per request, and hand-overs of waiting requests. */
434
482
  function onServerMessage(msg) {
435
483
  if (msg.type === 'ask-auth') {
@@ -455,11 +503,26 @@ export function createAskView({ document, send, reportError, clearError, setTime
455
503
  if (msg.type === 'ask-received') {
456
504
  requests.serverMessage(msg);
457
505
  if (partsPendingId !== null && msg.ids.includes(partsPendingId)) partsPendingId = null;
506
+ if (motifPendingId !== null && msg.ids.includes(motifPendingId)) arrivedMotif();
458
507
  renderPartsAsk();
459
508
  return;
460
509
  }
461
510
  if (msg.type !== 'ask-result') return;
462
511
  requests.serverMessage(msg);
512
+ if (msg.id === motifPendingId) {
513
+ // The Scratchpad's request is answered in its own strip: nothing of it reaches the Ask box, its draft or its not-sent message.
514
+ if (msg.status === 'received') {
515
+ arrivedMotif();
516
+ } else if (msg.status === 'waiting') {
517
+ // The server holds it until Claude's next Moontide call: it is no longer on its way, so the next motif may go, and the strip says it waits.
518
+ motifPendingId = null;
519
+ onMotifResult({ status: 'waiting' });
520
+ } else if (msg.status === 'refused') {
521
+ motifPendingId = null;
522
+ onMotifResult({ status: 'refused', text: refusalText(msg.reason, msg.detail) });
523
+ }
524
+ return;
525
+ }
463
526
  const fromParts = msg.id === partsPendingId;
464
527
  if (msg.status === 'refused') {
465
528
  reportError(refusalText(msg.reason, msg.detail));
@@ -485,6 +548,12 @@ export function createAskView({ document, send, reportError, clearError, setTime
485
548
  * says so, and the draft comes back (or, for the Parts button's canned request, the button is released).
486
549
  */
487
550
  function notConfirmed(id) {
551
+ if (id === motifPendingId) {
552
+ // The Scratchpad's strip says it; the Ask box and its draft are not involved.
553
+ motifPendingId = null;
554
+ onMotifResult({ status: 'not-confirmed' });
555
+ return;
556
+ }
488
557
  reportError(ASK_COPY.didNotConfirm);
489
558
  if (id === partsPendingId) {
490
559
  // The canned request needs no draft: the button just comes back.
@@ -500,6 +569,7 @@ export function createAskView({ document, send, reportError, clearError, setTime
500
569
  connected = state === 'connected';
501
570
  if (state === 'disconnected') {
502
571
  partsPendingId = null;
572
+ motifPendingId = null;
503
573
  // The state is unknown once the socket is gone (the page does not reconnect by itself).
504
574
  pageRequests.hidden = true;
505
575
  closeHelp();
@@ -531,6 +601,11 @@ export function createAskView({ document, send, reportError, clearError, setTime
531
601
  contains: (node) => askBox.contains(node),
532
602
  setConnected,
533
603
  setCurrentVersion,
604
+ sendMotif,
605
+ /** True while a motif request is on its way to the server. */
606
+ motifPending: () => motifPendingId !== null,
607
+ /** True while the page is connected to the server. */
608
+ isConnected: () => connected,
534
609
  setPartsAvailable,
535
610
  onServerMessage,
536
611
  notConfirmed,
package/page/ask.js CHANGED
@@ -6,6 +6,7 @@
6
6
  import { BEATS_PER_CYCLE } from './change-timing.js';
7
7
  import { MAX_MOMENT_NOTES, MAX_RANGE_CYCLES } from './timeline.js';
8
8
  import { cleanText } from './structure.js';
9
+ import { GRIDS, MAX_CYCLES, MIN_CYCLES, MOTIF_SOURCES, assertPhraseCode, cyclesText, notesText } from './scratch-model.js';
9
10
 
10
11
  /** Mirrors src/protocol.ts's MAX_ASK_TEXT_LENGTH by hand (test/ask.test.js checks both are 2000). */
11
12
  export const MAX_ASK_TEXT_LENGTH = 2000;
@@ -13,6 +14,12 @@ export const MAX_ASK_TEXT_LENGTH = 2000;
13
14
  /** Mirrors src/protocol.ts's MAX_REQUEST_LINE_BYTES by hand (test/ask.test.js checks both are 8192). */
14
15
  export const MAX_REQUEST_LINE_BYTES = 8192;
15
16
 
17
+ /**
18
+ * Mirrors src/protocol.ts's MAX_MOTIF_PHRASE_LENGTH by hand (test/ask.test.js checks both are 8000). The motif's sources, grid ids, cycle bounds and
19
+ * phrase shape come from page/scratch-model.js, which src/protocol.ts mirrors by hand (the same test checks them).
20
+ */
21
+ export const MAX_MOTIF_PHRASE_LENGTH = 8000;
22
+
16
23
  /** Mirrors src/ask.ts's REQUEST_LINE_PREFIX by hand (test/ask.test.js checks both). */
17
24
  export const REQUEST_LINE_PREFIX = 'Request from the Moontide page:';
18
25
 
@@ -379,14 +386,50 @@ function checkedMoment(kind, moment) {
379
386
  return { cycle: moment.cycle, beat: moment.beat, at: moment.at, version: moment.version, notes, more: moment.more };
380
387
  }
381
388
 
389
+ /**
390
+ * A motif of a motif descriptor checked by the schema's rules (src/protocol.ts's askMotifShape) and copied field by field: the source, the cycle
391
+ * when there is one (a whole number of at least 0), the cycles, the grid, the notes count and the phrase, which must have the phrase shape. The
392
+ * phrase is never cut. A wrong value throws like every other context, so a bug is loud.
393
+ */
394
+ function checkedMotif(motif) {
395
+ const bad = (field) => missing('motif', field);
396
+ if (motif === null || typeof motif !== 'object') throw bad('a motif');
397
+ const { source, cycle, cycles, grid, notes, phrase } = motif;
398
+ if (!MOTIF_SOURCES.includes(source)) throw bad(`a source of ${MOTIF_SOURCES.join(', ')}`);
399
+ if (cycle !== undefined && !isNonNegativeInteger(cycle)) throw bad('a whole number cycle of at least 0, or no cycle');
400
+ if (!Number.isInteger(cycles) || cycles < MIN_CYCLES || cycles > MAX_CYCLES) throw bad(`cycles as a whole number from ${MIN_CYCLES} to ${MAX_CYCLES}`);
401
+ if (!GRIDS.some((candidate) => candidate.id === grid)) throw bad(`a grid of ${GRIDS.map((candidate) => candidate.id).join(', ')}`);
402
+ if (!Number.isInteger(notes) || notes < 1) throw bad('a notes count of at least 1');
403
+ if (typeof phrase !== 'string' || phrase === '' || phrase.length > MAX_MOTIF_PHRASE_LENGTH) throw bad(`a phrase of 1 to ${MAX_MOTIF_PHRASE_LENGTH} characters`);
404
+ assertPhraseCode(phrase);
405
+ return { source, ...(cycle === undefined ? {} : { cycle }), cycles, grid, notes, phrase };
406
+ }
407
+
408
+ /**
409
+ * The chip of a motif context: the source, the cycles, the notes and, when there is a moment, its cycle, cut to the context limit. The
410
+ * motif is not checked here (describeContext checks it); the Ask view also uses this to measure the line of a motif it refuses.
411
+ */
412
+ export function motifChip(motif) {
413
+ return cutText(`Motif${SEPARATOR}${motif.source}${SEPARATOR}${cyclesText(motif.cycles)}${SEPARATOR}${notesText(motif.notes)}${motif.cycle === undefined ? '' : `${SEPARATOR}at cycle ${motif.cycle}`}`);
414
+ }
415
+
382
416
  /**
383
417
  * The size in bytes (UTF-8) of the line the server builds from one request: src/ask.ts's requestLine, the
384
418
  * prefix, a space and the JSON of the fields in the same order (U+2028 and U+2029 escaped), so the page can
385
419
  * tell before it sends whether the server will take the line.
386
420
  */
387
- export function requestLineBytes({ id, text, context, chip, current, moment, gesture }) {
388
- // The moment, then the gesture, each only when there is one (the order of src/ask.ts's requestLine).
389
- const fields = { id, text, context, chip, current, ...(moment === undefined ? {} : { moment }), ...(gesture === undefined ? {} : { gesture }) };
421
+ export function requestLineBytes({ id, text, context, chip, current, moment, gesture, motif }) {
422
+ // The moment, then the gesture, then the motif, each only when there is one (the order of src/ask.ts's requestLine).
423
+ const fields = {
424
+ id,
425
+ text,
426
+ context,
427
+ chip,
428
+ current,
429
+ ...(moment === undefined ? {} : { moment }),
430
+ ...(gesture === undefined ? {} : { gesture }),
431
+ ...(motif === undefined ? {} : { motif }),
432
+ };
390
433
  const json = JSON.stringify(fields).replaceAll('\u2028', '\\u2028').replaceAll('\u2029', '\\u2029');
391
434
  return new TextEncoder().encode(`${REQUEST_LINE_PREFIX} ${json}`).length;
392
435
  }
@@ -504,6 +547,8 @@ export function describeContext(descriptor) {
504
547
  const kind = descriptor && descriptor.kind;
505
548
  let moment = descriptor ? descriptor.moment : undefined;
506
549
  let gesture = descriptor ? descriptor.gesture : undefined;
550
+ let motif;
551
+ if (kind !== 'motif' && descriptor && descriptor.motif !== undefined) throw new Error(`Moontide: ask context ${kind} cannot carry a motif`);
507
552
  // A moment is the Timeline's facts at the pointer: a lane, a part header over the lanes and the rest of the
508
553
  // Timeline (a session descriptor) carry one; nothing else does.
509
554
  if (moment !== undefined && kind !== 'lane' && kind !== 'part' && kind !== 'session') throw new Error(`Moontide: ask context ${kind} cannot carry a moment`);
@@ -743,9 +788,17 @@ export function describeContext(descriptor) {
743
788
  chip = `Group "${name}"${parent === undefined ? '' : `${SEPARATOR}in "${parent}"`}`;
744
789
  break;
745
790
  }
791
+ case 'motif': {
792
+ // A motif sent from the Scratchpad: the context is only the kind, the motif rides next to it and carries its own moment.
793
+ motif = checkedMotif(descriptor.motif);
794
+ context = { kind };
795
+ chip = motifChip(motif);
796
+ break;
797
+ }
746
798
  default:
747
799
  throw new Error(`Moontide: unknown ask context kind ${kind}`);
748
800
  }
801
+
749
802
  // A gesture is about one note of a lane: it rides only on a lane that carries a moment (the range gesture is the range
750
803
  // context's own and is checked in its case above).
751
804
  if (gesture !== undefined) {
@@ -753,7 +806,7 @@ export function describeContext(descriptor) {
753
806
  gesture = checkedGesture(gesture);
754
807
  if (gesture.kind === 'range') throw new Error('Moontide: a range gesture goes only with a range context');
755
808
  }
756
- return { context, chip: cutText(chip), ...(moment === undefined ? {} : { moment }), ...(gesture === undefined ? {} : { gesture }) };
809
+ return { context, chip: cutText(chip), ...(moment === undefined ? {} : { moment }), ...(gesture === undefined ? {} : { gesture }), ...(motif === undefined ? {} : { motif }) };
757
810
  }
758
811
 
759
812
  /**
package/page/bar-model.js CHANGED
@@ -30,6 +30,7 @@ export const BAR_COPY = Object.freeze({
30
30
  cardChange: 'Last change',
31
31
  rightClickHint: 'Right-click anything to ask Claude.',
32
32
  rightClickHintTitle: ASK_COPY.shiftRightClickTitle,
33
+ earlyNote: 'Early: Moontide is young and has known bugs.',
33
34
  midiHint: 'Instruments view: key 6.',
34
35
  settingsLabel: 'Your edits take over',
35
36
  timingNote: "Claude's changes say their own timing.",
package/page/index.html CHANGED
@@ -63,6 +63,7 @@
63
63
  <span id="build-mismatch" class="bar-text" hidden></span>
64
64
  <span id="midi-takeover" class="bar-text" hidden><span id="midi-takeover-text"></span><button type="button" id="take-back"></button></span>
65
65
  <p class="session-hint" data-copy="rightClickHint"></p>
66
+ <p class="session-hint" data-copy="earlyNote"></p>
66
67
  </section>
67
68
  <section id="session-card-midi" class="session-card">
68
69
  <h2 class="session-card-title" data-copy="cardMidi"></h2>
@@ -108,6 +109,7 @@
108
109
  <div id="parts-view" hidden><p id="parts-message"></p><button type="button" id="parts-ask" hidden></button><p id="parts-ask-status" hidden></p><div id="song-notes" hidden></div><div id="song-bar" hidden><span id="song-launch-label"></span><div id="song-launch" role="group" aria-labelledby="song-launch-label"></div><p id="song-now" hidden></p></div><p id="song-line" hidden role="status" aria-live="polite"></p><div id="parts-rows"></div><div id="song-cell-list" hidden role="listbox" tabindex="-1"></div></div>
109
110
  <div id="jam-view" hidden><div id="jam-message" hidden><p id="jam-message-text"></p><p id="jam-message-sub" hidden></p></div><div id="jam-body" hidden><div id="jam-info"><p id="jam-declared" hidden><span id="jam-declared-label"></span><span id="jam-declared-value"></span><span id="jam-declared-section" hidden></span></p><div id="jam-scale"><span id="jam-scale-label" hidden></span><h2 id="jam-scale-name"></h2><p id="jam-scale-sub"></p></div><p id="jam-reason" aria-live="polite" hidden><strong id="jam-reason-lead"></strong><span id="jam-reason-rest"></span></p><div id="jam-candidates" role="group" aria-labelledby="jam-candidates-label" hidden><span id="jam-candidates-label"></span><div id="jam-candidate-list"></div><span id="jam-candidates-more" hidden></span></div><div id="jam-degrees" role="list"></div><div id="jam-status" role="status" aria-live="polite" hidden><p id="jam-status-text"><strong id="jam-status-lead"></strong><span id="jam-status-rest"></span></p><span id="jam-status-time" aria-hidden="true"></span><span id="jam-status-spoken"></span></div><p id="jam-paused" hidden></p><div id="jam-coming"><p id="jam-coming-caption"></p><div id="jam-rows"></div></div></div><div id="jam-instrument"><div id="jam-controls"><div id="jam-instrument-switch" role="group"><button type="button" id="jam-instrument-piano">Piano</button><button type="button" id="jam-instrument-guitar">Guitar</button></div><label id="jam-chord-tones-label"><input type="checkbox" id="jam-chord-tones"> <span id="jam-chord-tones-text"></span></label><div id="jam-legend"><span id="jam-legend-chord" class="jam-legend-entry" hidden></span></div></div><p id="jam-chord" hidden></p><div id="jam-board"><div id="jam-piano" role="img"></div><div id="jam-guitar" role="img" hidden></div></div></div></div></div>
110
111
  <div id="instruments-view" hidden tabindex="-1"><p id="instruments-notice" hidden></p><div id="instruments-message" hidden><p id="instruments-message-text"></p><code id="instruments-folder" hidden></code><p id="instruments-message-sub" hidden></p><ul id="instruments-problems" hidden></ul><p id="instruments-message-closing" hidden></p></div><div id="instruments-body" hidden><ul id="instruments-list" role="list" aria-label="Instruments"></ul><p id="instruments-footer"></p></div><span id="instruments-status" role="status" aria-live="polite"></span></div>
112
+ <div id="scratch-view" hidden role="region" aria-label="Scratchpad"><div id="scratch-controls" role="toolbar" aria-label="Scratchpad controls"><div id="scratch-grids" role="group" aria-label="Grid"><button type="button" id="scratch-grid-quarter" class="tl-btn" data-grid="quarter"></button><button type="button" id="scratch-grid-eighth" class="tl-btn" data-grid="eighth"></button><button type="button" id="scratch-grid-sixteenth" class="tl-btn" data-grid="sixteenth"></button><button type="button" id="scratch-grid-eighth-triplet" class="tl-btn" data-grid="eighth-triplet"></button><button type="button" id="scratch-grid-sixteenth-triplet" class="tl-btn" data-grid="sixteenth-triplet"></button></div><div id="scratch-length" role="group" aria-label="Length of the motif"><button type="button" id="scratch-fewer" class="tl-btn"></button><span id="scratch-cycles" class="sp-readout"></span><button type="button" id="scratch-more" class="tl-btn"></button></div><div id="scratch-pitch" role="group" aria-label="Pitch"><button type="button" id="scratch-lower" class="tl-btn"></button><span id="scratch-window" class="sp-readout" role="img"></span><button type="button" id="scratch-higher" class="tl-btn"></button></div><div id="scratch-mode" role="group" aria-label="Mode"><button type="button" id="scratch-draw" class="tl-btn" aria-pressed="false"></button><button type="button" id="scratch-tap" class="tl-btn" aria-pressed="false"></button></div><div id="scratch-edit" role="group" aria-label="Edit"><button type="button" id="scratch-earlier" class="tl-btn"></button><button type="button" id="scratch-later" class="tl-btn"></button><button type="button" id="scratch-align" class="tl-btn"></button><button type="button" id="scratch-clear" class="tl-btn"></button></div><div id="scratch-history" role="group" aria-label="Undo and redo"><button type="button" id="scratch-undo" class="tl-btn"></button><button type="button" id="scratch-redo" class="tl-btn"></button></div><div id="scratch-record-group" role="group" aria-label="Keyboard"><button type="button" id="scratch-record" class="tl-btn"></button></div><span id="scratch-early"></span></div><div id="scratch-scroll"><div id="scratch-ruler" aria-hidden="true"></div><div id="scratch-roll" tabindex="0" role="group" aria-label="Scratchpad piano roll" aria-describedby="scratch-hint"><div id="scratch-gutter" aria-hidden="true"></div><div id="scratch-grid"><div id="scratch-hover-cell" hidden></div><div id="scratch-select-rect" hidden aria-hidden="true"></div><div id="scratch-notes"></div><div id="scratch-live" aria-hidden="true"></div><div id="scratch-preview" hidden></div><div id="scratch-cursor" hidden></div><div id="scratch-now" hidden></div><div id="scratch-playhead" hidden aria-hidden="true"></div></div></div><div id="scratch-lane" tabindex="0" role="group" aria-label="Velocity of the notes" aria-describedby="scratch-hint"><div id="scratch-lane-label" aria-hidden="true"></div><div id="scratch-bars"></div></div></div><div id="scratch-note"><p id="scratch-note-line" role="status" aria-live="polite"><span id="scratch-note-shown" aria-hidden="true"><span id="scratch-note-name"></span><span id="scratch-note-rest"></span></span><span id="scratch-note-spoken"></span></p><p id="scratch-note-hover" hidden aria-hidden="true"></p><p id="scratch-hint"></p></div><div id="scratch-listen"><p id="scratch-listen-state" role="status" aria-live="polite"></p><button type="button" id="scratch-allow" class="tl-btn" hidden></button><p id="scratch-heard"></p></div><div id="scratch-play" role="group" aria-label="Hear the motif"><select id="scratch-play-output" aria-label="Instrument and channel"></select><div id="scratch-play-start" role="group" aria-label="Start"><button type="button" id="scratch-play-music" class="tl-btn" aria-pressed="true"></button><button type="button" id="scratch-play-now" class="tl-btn" aria-pressed="false"></button></div><button type="button" id="scratch-play-loop" class="tl-btn" aria-pressed="false"></button><button type="button" id="scratch-play-listen" class="tl-btn"></button><button type="button" id="scratch-play-stop" class="tl-btn"></button><p id="scratch-play-note" role="status" aria-live="polite"></p></div><div id="scratch-send"><code id="scratch-phrase" tabindex="0"></code><div id="scratch-send-row"><input type="text" id="scratch-words" autocomplete="off" spellcheck="false" /><button type="button" id="scratch-send-button" class="tl-btn"></button></div><p id="scratch-send-status" role="status" aria-live="polite"></p></div></div>
111
113
  </section>
112
114
  </div>
113
115
  <div id="ask-box" role="dialog" hidden><div id="ask-chips"><span id="ask-context-chip"></span><span id="ask-current-chip" hidden></span></div><textarea id="ask-field" rows="1" aria-describedby="ask-context-chip ask-hint"></textarea><p id="ask-message" hidden></p><p id="ask-hint"></p></div>
@@ -5,7 +5,7 @@
5
5
  // CONNECTION_LABELS (read at use time, so the popover, the top bar and this view never disagree). The DOM
6
6
  // side is page/instruments-view.js; page/main.js feeds the inputs.
7
7
 
8
- import { ACCESS_STATES, MIDI_COPY, fill, listNames, readMidiCalls } from './midi-plan.js';
8
+ import { ACCESS_STATES, MIDI_COPY, fill, listNames, readMidiCalls, readMidiSetup } from './midi-plan.js';
9
9
  import { BUILD_MISMATCH_COPY, CONNECTION_LABELS } from './ui-state.js';
10
10
  import { midiTag, readMidiTracks } from './midi-controls.js';
11
11
  import { drumMapFor, soundKey } from './midi-rig.js';
@@ -59,6 +59,15 @@ export const INSTRUMENTS_COPY = Object.freeze({
59
59
  labelControlsHint: 'default values',
60
60
  labelCatalog: 'Catalog',
61
61
  labelInUse: 'In use',
62
+ // The Synth settings row of an instrument whose model describes settings: what the playing code writes, never stored.
63
+ labelSynthSettings: 'Synth settings',
64
+ synthTone: 'tone, ch {channel}: {values}',
65
+ synthPart: 'part, ch {channel}: {values}',
66
+ synthScene: 'scene: {values}, reaches every part',
67
+ synthValue: '{name} {value}',
68
+ synthValuesJoin: ', ',
69
+ synthNone: 'none in the code',
70
+ synthStays: 'A setting taken out of the code is not sent again: the instrument keeps its value until the instrument itself changes it, and a tone setting until the instrument loads the tone again. Moontide stores no setting.',
62
71
  clockFollows: 'follows the clock',
63
72
  controlsNone: 'none',
64
73
  catalogOne: '1 sound',
@@ -303,8 +312,9 @@ function readUse(code, parse, rig) {
303
312
  if (typeof parse !== 'function') return { kind: 'unreadable' };
304
313
  const tracks = readMidiTracks(code, { parse, rig });
305
314
  const calls = readMidiCalls(code, { parse });
306
- if (!tracks.ok || !calls.ok) return { kind: 'unreadable' };
307
- return { kind: 'read', tracks: [...tracks.tracks.values()], calls: calls.calls };
315
+ const setup = readMidiSetup(code, { parse });
316
+ if (!tracks.ok || !calls.ok || !setup.ok) return { kind: 'unreadable' };
317
+ return { kind: 'read', tracks: [...tracks.tracks.values()], calls: calls.calls, setups: setup.calls };
308
318
  }
309
319
 
310
320
  /**
@@ -337,6 +347,40 @@ function inUseOf(instrument, use) {
337
347
  return { lead: instrument.enabled === false ? INSTRUMENTS_COPY.silentWhileOff : null, lines, muted: false };
338
348
  }
339
349
 
350
+ /**
351
+ * The Synth settings row of one instrument: null when its model describes no settings, else `{ lines: [{ text, muted }] }`
352
+ * read from the playing code. The lines are the tone and part settings per channel ascending (tone first), then the scene
353
+ * settings, taken from the calls whose port is exactly the instrument's port; the values stay in the order written and a
354
+ * later equal value merges. Without any, or without readable code, one muted line says so. The standing rule about a
355
+ * setting taken out of the code comes last. Nothing is stored: the row is the code.
356
+ */
357
+ function synthSettingsOf(instrument, use) {
358
+ if (instrument.settings === null || instrument.settings === undefined) return null;
359
+ const staying = { text: INSTRUMENTS_COPY.synthStays, muted: true };
360
+ if (use.kind === 'unreadable') return { lines: [{ text: INSTRUMENTS_COPY.unreadableCode, muted: true }, staying] };
361
+ if (use.kind === 'empty') return { lines: [{ text: MIDI_COPY.rowNotUsed, muted: true }, staying] };
362
+ const channels = new Map();
363
+ const scene = new Map();
364
+ for (const call of use.setups) {
365
+ if (call.port !== instrument.port) continue;
366
+ if (!channels.has(call.channel)) channels.set(call.channel, { tone: new Map(), part: new Map() });
367
+ const level = channels.get(call.channel);
368
+ for (const [name, value] of call.settings.tone) level.tone.set(name, value);
369
+ for (const [name, value] of call.settings.part) level.part.set(name, value);
370
+ for (const [name, value] of call.settings.scene) scene.set(name, value);
371
+ }
372
+ const valuesOf = (map) => Array.from(map, ([name, value]) => fill(INSTRUMENTS_COPY.synthValue, { name, value: String(value) })).join(INSTRUMENTS_COPY.synthValuesJoin);
373
+ const lines = [];
374
+ for (const channel of [...channels.keys()].sort((a, b) => a - b)) {
375
+ const { tone, part } = channels.get(channel);
376
+ if (tone.size > 0) lines.push({ text: fill(INSTRUMENTS_COPY.synthTone, { channel: String(channel), values: valuesOf(tone) }), muted: false });
377
+ if (part.size > 0) lines.push({ text: fill(INSTRUMENTS_COPY.synthPart, { channel: String(channel), values: valuesOf(part) }), muted: false });
378
+ }
379
+ if (scene.size > 0) lines.push({ text: fill(INSTRUMENTS_COPY.synthScene, { values: valuesOf(scene) }), muted: false });
380
+ if (lines.length === 0) lines.push({ text: INSTRUMENTS_COPY.synthNone, muted: true });
381
+ return { lines: [...lines, staying] };
382
+ }
383
+
340
384
  /** True when the instrument declares at least one channel with the rhythm role (the Drum kit row and Drum map line only exist then). */
341
385
  function hasRhythmChannel(instrument) {
342
386
  return instrument.channels.some((entry) => entry.role === 'rhythm');
@@ -558,6 +602,7 @@ function cardOf(instrument, liveInstruments, use) {
558
602
  modelChannels: modelChannelsOf(instrument),
559
603
  usedChannels: usedChannelsOf(instrument, use),
560
604
  inUse: inUseOf(instrument, use),
605
+ synthSettings: synthSettingsOf(instrument, use),
561
606
  drumKit: drumKitOf(instrument, use),
562
607
  drumMaps: drumMapsOf(instrument, use),
563
608
  drumDeclared: drumDeclaredOf(instrument),
@@ -717,6 +762,8 @@ function declaredFacts(card) {
717
762
  drumDeclared: card.drumDeclared,
718
763
  settings: card.settings,
719
764
  modelChannels: card.modelChannels,
765
+ // Whether the model describes settings decides whether the Synth settings row exists; its lines are live.
766
+ hasSynthSettings: card.synthSettings !== null,
720
767
  });
721
768
  }
722
769
 
@@ -208,6 +208,11 @@ export function createInstrumentsView({ document, onInstrumentSwitch, onInstrume
208
208
  return lines;
209
209
  }
210
210
 
211
+ /** The children of the Synth settings value: one line element per model line, the muted ones with the muted class. */
212
+ function synthSettingsLines(synthSettings) {
213
+ return synthSettings.lines.map((line) => element('span', line.muted ? 'instrument-inuse-line instrument-muted' : 'instrument-inuse-line', line.text));
214
+ }
215
+
211
216
  /** The lines of the Drum kit row: one span per line, muted when the model says so, with its hover title when it has one. */
212
217
  function drumKitLines(drumKit) {
213
218
  return drumKit.lines.map((line) => {
@@ -262,7 +267,7 @@ export function createInstrumentsView({ document, onInstrumentSwitch, onInstrume
262
267
 
263
268
  /** One card and the nodes a live redraw updates. The generated ids come from the index, never from user text. */
264
269
  function cardNode(card, index) {
265
- const refs = { id: card.id, index, item: null, dot: null, words: null, inUse: null, drumKit: null, maps: null, opens: [], enabled: card.enabled, switchNodes: null, head: null, errorNode: null, editButton: null, editor: null };
270
+ const refs = { id: card.id, index, item: null, dot: null, words: null, inUse: null, synthSettings: null, drumKit: null, maps: null, opens: [], enabled: card.enabled, switchNodes: null, head: null, errorNode: null, editButton: null, editor: null };
266
271
  const item = element('li', 'instrument-card');
267
272
  refs.item = item;
268
273
  item.tabIndex = 0;
@@ -300,6 +305,12 @@ export function createInstrumentsView({ document, onInstrumentSwitch, onInstrume
300
305
  inUse.append(...inUseLines(card.inUse));
301
306
  refs.inUse = inUse;
302
307
  rows.append(row(INSTRUMENTS_COPY.labelInUse, inUse));
308
+ if (card.synthSettings !== null) {
309
+ const synth = element('div', 'instrument-lines');
310
+ synth.append(...synthSettingsLines(card.synthSettings));
311
+ refs.synthSettings = synth;
312
+ rows.append(row(INSTRUMENTS_COPY.labelSynthSettings, synth));
313
+ }
303
314
  item.append(head, rows, editButtonNode(card, refs));
304
315
  drawError(refs);
305
316
  // An editor that was open (a draft kept in memory) comes back with the card.
@@ -766,7 +777,7 @@ export function createInstrumentsView({ document, onInstrumentSwitch, onInstrume
766
777
  instrumentsBody.scrollTop = scrollTop;
767
778
  }
768
779
 
769
- /** Updates the changed parts of each card in place: the dot, the state words, the Drum kit lines, the In use lines and the Drum map lines. */
780
+ /** Updates the changed parts of each card in place: the dot, the state words, the Drum kit lines, the In use lines, the Synth settings lines and the Drum map lines. */
770
781
  function updateCards(previous, model, skip = []) {
771
782
  model.cards.forEach((card, index) => {
772
783
  if (skip.includes(index)) return;
@@ -786,6 +797,7 @@ export function createInstrumentsView({ document, onInstrumentSwitch, onInstrume
786
797
  setText(refs.words, card.port.words);
787
798
  if (card.port.attention !== before.port.attention) refs.words.dataset.attention = String(card.port.attention);
788
799
  if (JSON.stringify(card.inUse) !== JSON.stringify(before.inUse)) refs.inUse.replaceChildren(...inUseLines(card.inUse));
800
+ if (card.synthSettings !== null && JSON.stringify(card.synthSettings) !== JSON.stringify(before.synthSettings)) refs.synthSettings.replaceChildren(...synthSettingsLines(card.synthSettings));
789
801
  if (card.drumKit !== null && JSON.stringify(card.drumKit) !== JSON.stringify(before.drumKit)) refs.drumKit.replaceChildren(...drumKitLines(card.drumKit));
790
802
  if (card.drumMaps !== null && JSON.stringify(card.drumMaps) !== JSON.stringify(before.drumMaps)) {
791
803
  // The drawn flags are read first and the focus noted, because the nodes are replaced: a map that stays keeps its state.