moontide-mcp 2.1.0 → 2.2.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/ask.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // Pure module for the Ask Claude box: the copy, the length rules, the context
2
- // descriptors, the box placement and the request status sequencing. No DOM access at import time,
2
+ // descriptors and the box placement. No DOM access at import time,
3
3
  // so node:test and page/main.js import it directly with no build step. The box's own DOM lives in
4
4
  // page/ask-view.js.
5
5
 
@@ -25,12 +25,6 @@ export const PAGE_TOKEN_PLACEHOLDER = '__MOONTIDE_PAGE_TOKEN__';
25
25
  /** No answer from the server within this long: the request is reported as not confirmed. */
26
26
  export const ASK_CONFIRM_TIMEOUT_MS = 5000;
27
27
 
28
- /** A non-error status stays readable at least this long before the next one replaces it. */
29
- export const STATUS_MIN_SHOWN_MS = 600;
30
-
31
- /** "Claude received it" is shown this long, then the status hides. */
32
- export const RECEIVED_SHOWN_MS = 4000;
33
-
34
28
  /** From this many characters the box shows a counter, so the limit is seen before it is hit. */
35
29
  export const COUNTER_FROM = 1800;
36
30
 
@@ -52,9 +46,6 @@ export const ASK_COPY = {
52
46
  disconnected: 'Disconnected — asking Claude is off until Moontide reconnects.',
53
47
  notSent: 'This request was not sent. Press Enter to try again.',
54
48
  notSentToken: 'This request was not sent.',
55
- sent: 'Sent to Claude',
56
- received: 'Claude received it',
57
- waiting: 'Claude will see it on its next action',
58
49
  tooLong: 'request too long — 2000 characters at most.',
59
50
  lineTooLong: 'request too long to send — some characters take extra room, so shorten the text.',
60
51
  rateLimit: 'too many requests — wait a moment, then send again.',
@@ -121,33 +112,6 @@ export function pageRequestsText(live) {
121
112
  throw new Error(`Moontide: the page-requests state must be true or false, got ${JSON.stringify(live)}.`);
122
113
  }
123
114
 
124
- /** The status text for requests handed to Claude: one, or a count. */
125
- export function receivedText(count) {
126
- return count === 1 ? ASK_COPY.received : `Claude received ${count} requests`;
127
- }
128
-
129
- /** The status text for requests that wait on the server: one, or a count. */
130
- export function waitingText(count) {
131
- return count === 1 ? ASK_COPY.waiting : `Claude will see ${count} requests on its next action`;
132
- }
133
-
134
- /**
135
- * The short forms of the status texts, shown at fit step 3 of the top bar when the long
136
- * sentence no longer fits. They are made here, next to the long texts, and travel with them: nothing shortens a long
137
- * text by matching strings afterwards. The long text stays in the accessibility tree.
138
- */
139
- export const ASK_SHORT_COPY = Object.freeze({ sent: 'Sent', received: 'Received', waiting: 'Waiting' });
140
-
141
- /** The short form of receivedText(count). */
142
- export function receivedShortText(count) {
143
- return count === 1 ? ASK_SHORT_COPY.received : `${count} received`;
144
- }
145
-
146
- /** The short form of waitingText(count). */
147
- export function waitingShortText(count) {
148
- return count === 1 ? ASK_SHORT_COPY.waiting : `${count} waiting`;
149
- }
150
-
151
115
  /** 'empty' | 'too-long' | null for the text the user typed (counted on the trimmed text, in UTF-16 units,
152
116
  * the same count the server uses). */
153
117
  export function askTextProblem(text) {
@@ -190,8 +154,8 @@ export function askMessage({ connected, length, notSent, tokenRefused, lineTooLo
190
154
 
191
155
  /**
192
156
  * The Parts view's "Ask Claude to add sections" button: disabled while asking is off or its request is
193
- * pending, with the line under it saying why. `statusText` is the top-bar
194
- * status text of that request, or null.
157
+ * pending, with the line under it saying why. `statusText` is the state phrase of that request's row in the
158
+ * Requests list, or null.
195
159
  */
196
160
  export function partsAskState({ connected, pending, statusText }) {
197
161
  if (!connected) return { disabled: true, line: ASK_COPY.disconnected };
@@ -243,6 +207,28 @@ function optionalText(descriptor, field) {
243
207
  return cutText(value);
244
208
  }
245
209
 
210
+ /**
211
+ * The section names of a range: undefined when the descriptor has none, else a list of 1 to MAX_RANGE_CYCLES non-empty strings
212
+ * (a range crosses at most one section per cycle). A name is not cut here.
213
+ */
214
+ function rangeSectionNames(descriptor) {
215
+ const names = descriptor.sections;
216
+ if (names === undefined || names === null) return undefined;
217
+ if (!Array.isArray(names) || names.length < 1 || names.length > MAX_RANGE_CYCLES || names.some((name) => typeof name !== 'string' || name === '')) {
218
+ throw missing(descriptor.kind, `sections as 1 to ${MAX_RANGE_CYCLES} non-empty names`);
219
+ }
220
+ return names;
221
+ }
222
+
223
+ /** The count of sections a range leaves out: undefined when absent, else a whole number from 1 to MAX_RANGE_CYCLES - 1. */
224
+ function rangeMoreSections(descriptor, names) {
225
+ const more = descriptor.moreSections;
226
+ if (more === undefined || more === null) return undefined;
227
+ if (names === undefined) throw missing(descriptor.kind, 'moreSections only with sections');
228
+ if (!Number.isInteger(more) || more < 1 || more > MAX_RANGE_CYCLES - 1) throw missing(descriptor.kind, `moreSections as a whole number from 1 to ${MAX_RANGE_CYCLES - 1}`);
229
+ return more;
230
+ }
231
+
246
232
  function requiredInteger(descriptor, field, min) {
247
233
  const value = descriptor[field];
248
234
  if (!Number.isInteger(value) || value < min) throw missing(descriptor.kind, `a whole number ${field} of at least ${min}`);
@@ -465,6 +451,47 @@ function momentChipPart(moment) {
465
451
  /** The longest section name the chip of a range shows; the context carries the whole name (cut at the context limit). */
466
452
  const MAX_RANGE_CHIP_SECTION = 40;
467
453
 
454
+ /** The words of the sections part of a range chip: one section, a list of them, and the sections the chip does not show. */
455
+ const RANGE_SECTION_WORDS = Object.freeze({ one: 'section', list: 'sections', more: (n) => `and ${n} more` });
456
+
457
+ /** The shortest lane part a range chip keeps, so the tail never takes its room: 'Range', the separator and one character. */
458
+ const SHORTEST_RANGE_LANE_PART = `Range${SEPARATOR}x`;
459
+
460
+ /**
461
+ * The section names of a range cut to what the context holds: names from the start while they and their ', ' separators stay
462
+ * within MAX_ASK_CONTEXT_LENGTH characters. The first name is always kept (cut with cutText when it is longer). Gives
463
+ * `{ names, more }` with `more` the count of names left out. The server rejects a longer list and never trims.
464
+ */
465
+ export function fitSectionNames(names) {
466
+ if (names.length === 0) throw new Error('Moontide: a section list to fit needs at least one name');
467
+ const kept = [cutText(names[0])];
468
+ let length = kept[0].length;
469
+ for (let at = 1; at < names.length; at++) {
470
+ length += 2 + names[at].length;
471
+ if (length > MAX_ASK_CONTEXT_LENGTH) break;
472
+ kept.push(names[at]);
473
+ }
474
+ return { names: kept, more: names.length - kept.length };
475
+ }
476
+
477
+ /**
478
+ * The sections part of a range chip, built around `others` (the chip's other parts, as they will be joined): the most names
479
+ * that keep the tail within the room left after the shortest lane part, each cut to MAX_RANGE_CHIP_SECTION, and 'and N more' for
480
+ * every name not shown. `more` counts the names the context already leaves out. At least one name stays.
481
+ */
482
+ function rangeSectionsPart(names, more, assemble) {
483
+ const total = names.length + more;
484
+ const room = MAX_ASK_CONTEXT_LENGTH - SEPARATOR.length - SHORTEST_RANGE_LANE_PART.length;
485
+ let part = '';
486
+ for (let shown = names.length; shown >= 1; shown--) {
487
+ const quoted = names.slice(0, shown).map((name) => `"${cutText(name, MAX_RANGE_CHIP_SECTION)}"`).join(', ');
488
+ const hidden = total - shown;
489
+ part = total === 1 ? `${RANGE_SECTION_WORDS.one} ${quoted}` : `${RANGE_SECTION_WORDS.list} ${quoted}${hidden > 0 ? ` ${RANGE_SECTION_WORDS.more(hidden)}` : ''}`;
490
+ if (assemble(part).length <= room) break;
491
+ }
492
+ return part;
493
+ }
494
+
468
495
  /**
469
496
  * What a right-click pointed at, as the context the server receives and the chip the user sees.
470
497
  * `descriptor` is what a view reports about an element:
@@ -616,24 +643,29 @@ export function describeContext(descriptor) {
616
643
  const from = requiredInteger(descriptor, 'from', 0);
617
644
  const to = requiredInteger(descriptor, 'to', from);
618
645
  if (to - from >= MAX_RANGE_CYCLES) throw missing(kind, `at most ${MAX_RANGE_CYCLES} cycles`);
619
- const section = optionalText(descriptor, 'section');
620
- const sectionTo = optionalText(descriptor, 'sectionTo');
621
- if (sectionTo !== undefined && (section === undefined || sectionTo === section)) throw missing(kind, 'a sectionTo only when it differs from the section');
646
+ const sections = rangeSectionNames(descriptor);
647
+ const moreSections = rangeMoreSections(descriptor, sections);
622
648
  if (gesture === undefined || gesture.kind !== 'range') throw missing(kind, 'a range gesture with its notes');
623
649
  gesture = checkedGesture(gesture);
624
650
  const cut = descriptor.cut === undefined || descriptor.cut === null ? null : { from: descriptor.cut.from, to: descriptor.cut.to };
625
651
  if (cut !== null) rangeCutText(cut);
626
652
  context = { kind, track, from, to };
627
- if (section !== undefined) context.section = section;
628
- if (sectionTo !== undefined) context.sectionTo = sectionTo;
653
+ let fitted;
654
+ if (sections !== undefined) {
655
+ // The page cuts the list to what the context holds; the cut and a cut the descriptor already made are one count.
656
+ fitted = fitSectionNames(sections);
657
+ const left = fitted.more + (moreSections ?? 0);
658
+ if (left > MAX_RANGE_CYCLES - 1) throw missing(kind, `at most ${MAX_RANGE_CYCLES - 1} moreSections`);
659
+ context.sections = fitted.names;
660
+ if (left > 0) context.moreSections = left;
661
+ }
629
662
  // The cycles, the section and the notes are built first and the lane name is cut to the room left, so a long name never
630
663
  // costs the cycles.
631
- const parts = [from === to ? `cycle ${from}` : `cycles ${from} to ${to}`];
632
- if (section !== undefined) {
633
- const shown = (name) => `"${cutText(name, MAX_RANGE_CHIP_SECTION)}"`;
634
- parts.push(sectionTo === undefined ? `section ${shown(section)}` : `sections ${shown(section)} to ${shown(sectionTo)}`);
635
- }
636
- parts.push(notesWords(gesture.notes.length + gesture.more));
664
+ const cycles = from === to ? `cycle ${from}` : `cycles ${from} to ${to}`;
665
+ const notes = notesWords(gesture.notes.length + gesture.more);
666
+ const parts = [cycles];
667
+ if (fitted !== undefined) parts.push(rangeSectionsPart(fitted.names, context.moreSections ?? 0, (part) => [cycles, part, notes].join(SEPARATOR)));
668
+ parts.push(notes);
637
669
  const tail = parts.join(SEPARATOR);
638
670
  chip = `${cutText(`Range${SEPARATOR}${track}`, MAX_ASK_CONTEXT_LENGTH - SEPARATOR.length - tail.length)}${SEPARATOR}${tail}`;
639
671
  return { context, chip: cutText(chip), gesture, ...(cut === null ? {} : { rangeCut: cut }) };
@@ -838,118 +870,3 @@ export function placeBox({ pointerX, pointerY, boxWidth, boxHeight, maxBoxHeight
838
870
  export function newAskId(counter, nowMs) {
839
871
  return `ask-${nowMs.toString(36)}-${counter}`;
840
872
  }
841
-
842
- /**
843
- * The top-bar status of the latest request, as a small state machine with injected time (the
844
- * page/mixer-gesture.js style). `render(text, short)` receives a string or null and, with a string, its short form (the
845
- * second argument is left out when hiding). `onTimeout(id)` is called
846
- * for a request the server did not answer in time, or whose connection dropped.
847
- *
848
- * Every non-error text becomes visible no sooner than `minShownMs` after the previous non-error text
849
- * appeared (a later text is scheduled, never lost); hiding for an error or a refusal is immediate and
850
- * cancels anything scheduled.
851
- */
852
- export function createAskStatus({
853
- render,
854
- onTimeout,
855
- setTimer,
856
- clearTimer,
857
- now,
858
- minShownMs = STATUS_MIN_SHOWN_MS,
859
- receivedShownMs = RECEIVED_SHOWN_MS,
860
- confirmTimeoutMs = ASK_CONFIRM_TIMEOUT_MS,
861
- }) {
862
- // The request the status describes.
863
- let latestId = null;
864
- // Requests sent and not yet answered: id -> confirm timer.
865
- const unconfirmed = new Map();
866
- // When the text on screen appeared; null while nothing shows.
867
- let shownAt = null;
868
- let scheduled = null;
869
- let hideTimer = null;
870
-
871
- function cancelScheduled() {
872
- if (scheduled !== null) clearTimer(scheduled);
873
- scheduled = null;
874
- }
875
-
876
- function cancelHide() {
877
- if (hideTimer !== null) clearTimer(hideTimer);
878
- hideTimer = null;
879
- }
880
-
881
- function hideNow() {
882
- cancelScheduled();
883
- cancelHide();
884
- shownAt = null;
885
- render(null);
886
- }
887
-
888
- function reveal(text, short, hideAfterMs) {
889
- scheduled = null;
890
- render(text, short);
891
- shownAt = now();
892
- if (hideAfterMs !== null) {
893
- hideTimer = setTimer(() => {
894
- hideTimer = null;
895
- hideNow();
896
- }, hideAfterMs);
897
- }
898
- }
899
-
900
- /** Shows a non-error text now, or as soon as the text on screen has been readable long enough. */
901
- function present(text, short, hideAfterMs) {
902
- cancelScheduled();
903
- cancelHide();
904
- const wait = shownAt === null ? 0 : Math.max(0, minShownMs - (now() - shownAt));
905
- if (wait === 0) {
906
- reveal(text, short, hideAfterMs);
907
- } else {
908
- scheduled = setTimer(() => reveal(text, short, hideAfterMs), wait);
909
- }
910
- }
911
-
912
- function forget(id) {
913
- const timer = unconfirmed.get(id);
914
- if (timer === undefined) return;
915
- clearTimer(timer);
916
- unconfirmed.delete(id);
917
- }
918
-
919
- function expire(id) {
920
- unconfirmed.delete(id);
921
- onTimeout(id);
922
- if (id === latestId) hideNow();
923
- }
924
-
925
- return {
926
- /** A request was sent: it becomes the latest and waits for the server's answer. */
927
- sent(id) {
928
- latestId = id;
929
- unconfirmed.set(id, setTimer(() => expire(id), confirmTimeoutMs));
930
- present(ASK_COPY.sent, ASK_SHORT_COPY.sent, null);
931
- },
932
- /** The server's ask-result for a request. */
933
- answered(msg) {
934
- forget(msg.id);
935
- if (msg.id !== latestId) return;
936
- if (msg.status === 'received') present(receivedText(1), receivedShortText(1), receivedShownMs);
937
- else if (msg.status === 'waiting') present(waitingText(msg.waiting), waitingShortText(msg.waiting), null);
938
- else hideNow();
939
- },
940
- /** The server's ask-received: waiting requests were just handed to Claude. */
941
- handedOver(ids) {
942
- for (const id of ids) forget(id);
943
- if (latestId !== null && ids.includes(latestId)) present(receivedText(ids.length), receivedShortText(ids.length), receivedShownMs);
944
- },
945
- /** The connection is gone: nothing sent and unanswered will be answered any more. */
946
- dropAll() {
947
- for (const [id, timer] of unconfirmed) {
948
- clearTimer(timer);
949
- onTimeout(id);
950
- }
951
- unconfirmed.clear();
952
- hideNow();
953
- },
954
- };
955
- }
package/page/bar-model.js CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { ASK_COPY } from './ask.js';
8
8
  import { MIDI_COPY } from './midi-plan.js';
9
- import { BEATS_PER_CYCLE, bpmText } from './change-timing.js';
9
+ import { BEATS_PER_CYCLE, bpmText, fillChange } from './change-timing.js';
10
10
  import { BUILD_MISMATCH_COPY, CONNECTION_LABELS, SERVER_UPDATED_COPY, SOUND_LABELS, errorLineText } from './ui-state.js';
11
11
 
12
12
  /** The copy the bar and the Session drawer add. */
@@ -60,6 +60,13 @@ export const BAR_COPY = Object.freeze({
60
60
  tempoTitle: 'Tempo of the music that plays',
61
61
  tempoTitleIdle: 'Nothing plays',
62
62
  tempoNameIdle: 'No tempo, nothing plays',
63
+ // While a tempo ramp runs: the accessible name and the hover text say the direction, the target and the bars left.
64
+ tempoNameRising: 'Tempo {num} BPM, rising to {target} BPM',
65
+ tempoNameFalling: 'Tempo {num} BPM, falling to {target} BPM',
66
+ tempoTitleUp: 'Tempo moving up to {target} BPM. {left}',
67
+ tempoTitleDown: 'Tempo moving down to {target} BPM. {left}',
68
+ tempoBarsLeft: '{k} bars left.',
69
+ tempoOneBarLeft: '1 bar left.',
63
70
  // The failed-change banner: the lead of each kind, and the three sentences about the music. The first two
64
71
  // equal the server's NOTHING_PLAYING_TAIL and STILL_STOPPED_TAIL; the third is the user-facing wording of
65
72
  // STILL_PLAYING_TAIL ('The previous code is still playing.' stays the server's text for Claude). A test pins all three.
@@ -218,8 +225,15 @@ export function cycleBeat(now) {
218
225
  * the waiting tempo differs from the playing one after that rounding. Returns { idle, num, arrow, unit, text, name,
219
226
  * title }: `arrow` is '→ 124' or '', `unit` is 'BPM' (the view puts the spaces), `text` the whole line and `name`
220
227
  * the accessible name. A number bpmText cannot format throws; the caller logs it and shows the idle form.
228
+ *
229
+ * `ramp` is null or absent, or `{ targetBpm, barsLeft }` while a tempo ramp runs (`rising` is an optional true or false
230
+ * that says the direction when the live tempo already reads like the target). A ramp wins over `waitingBpm`. The number is then
231
+ * the live tempo rounded to a whole BPM (after bpmText has validated it), the arrow part is the target through bpmText
232
+ * and is empty when the number reads like the target, the name says 'rising to' or 'falling to' and the hover text says
233
+ * 'moving up' or 'moving down' with the whole bars left (a whole number from 1; '1 bar left.' for one). A call without
234
+ * a ramp returns exactly what it returned before the ramp existed. A bad ramp throws.
221
235
  */
222
- export function tempoText({ playing, bpm, waitingBpm }) {
236
+ export function tempoText({ playing, bpm, waitingBpm, ramp }) {
223
237
  requireBoolean('playing', playing);
224
238
  if (!playing) {
225
239
  return {
@@ -233,6 +247,7 @@ export function tempoText({ playing, bpm, waitingBpm }) {
233
247
  };
234
248
  }
235
249
  const num = bpmText(bpm);
250
+ if (ramp !== null && ramp !== undefined) return rampTempoText(bpm, ramp);
236
251
  const hasWaiting = waitingBpm !== null && waitingBpm !== undefined;
237
252
  const waiting = hasWaiting ? bpmText(waitingBpm) : null;
238
253
  const changing = waiting !== null && waiting !== num;
@@ -247,6 +262,32 @@ export function tempoText({ playing, bpm, waitingBpm }) {
247
262
  };
248
263
  }
249
264
 
265
+ /** The readout while a tempo ramp runs; `bpm` was validated by the caller. See tempoText. */
266
+ function rampTempoText(bpm, ramp) {
267
+ if (typeof ramp !== 'object') {
268
+ throw new Error(`Moontide: the tempo ramp must be an object with targetBpm and barsLeft, got ${JSON.stringify(ramp)}.`);
269
+ }
270
+ const { targetBpm, barsLeft, rising } = ramp;
271
+ const target = bpmText(targetBpm);
272
+ if (!Number.isInteger(barsLeft) || barsLeft < 1) {
273
+ throw new Error(`Moontide: the bars left of a tempo ramp must be a whole number from 1, got ${JSON.stringify(barsLeft)}.`);
274
+ }
275
+ if (rising !== undefined) requireBoolean('rising', rising);
276
+ const num = String(Math.round(bpm));
277
+ const up = rising !== undefined ? rising : targetBpm > bpm;
278
+ const arrow = num === target ? '' : `${BAR_COPY.tempoArrow} ${target}`;
279
+ const left = barsLeft === 1 ? BAR_COPY.tempoOneBarLeft : fillChange(BAR_COPY.tempoBarsLeft, { k: barsLeft });
280
+ return {
281
+ idle: false,
282
+ num,
283
+ arrow,
284
+ unit: BAR_COPY.tempoUnit,
285
+ text: arrow === '' ? `${num} ${BAR_COPY.tempoUnit}` : `${num} ${arrow} ${BAR_COPY.tempoUnit}`,
286
+ name: fillChange(up ? BAR_COPY.tempoNameRising : BAR_COPY.tempoNameFalling, { num, target }),
287
+ title: fillChange(up ? BAR_COPY.tempoTitleUp : BAR_COPY.tempoTitleDown, { target, left }),
288
+ };
289
+ }
290
+
250
291
  const BANNER_KINDS = Object.freeze(['change', 'notice']);
251
292
 
252
293
  /**
@@ -144,6 +144,13 @@ export const CHANGE_COPY = Object.freeze({
144
144
  settingNow: 'Your edits now take over: now.',
145
145
  settingBar: 'Your edits now take over: on the next bar.',
146
146
  settingPhrase: 'Your edits now take over: on the next phrase.',
147
+ // The four sentences a tempo ramp speaks in the live region: at its start, at its end and when the code cancels it.
148
+ // {from}, {to} and {bpm} go through bpmText and {n} is a whole number of bars. Nothing is spoken per step.
149
+ rampStarted: 'Tempo ramp started: {from} to {to} BPM over {n} bars.',
150
+ rampStartedOneBar: 'Tempo ramp started: {from} to {to} BPM over 1 bar.',
151
+ rampFinished: 'Tempo ramp finished at {to} BPM.',
152
+ rampCanceledOtherTempo: 'Tempo ramp canceled. The code sets {bpm} BPM.',
153
+ rampCanceledNoTempo: 'Tempo ramp canceled. Tempo stays at {bpm} BPM.',
147
154
  });
148
155
 
149
156
  // The fit steps of the top bar, in the order text is given up: 0 nothing, 1 the decoration
@@ -223,3 +230,31 @@ export function settingLine(value) {
223
230
  if (value === 'phrase') return CHANGE_COPY.settingPhrase;
224
231
  throw new Error(`Moontide: unknown change timing '${String(value)}' (expected one of ${CHANGE_TIMINGS.join(', ')}).`);
225
232
  }
233
+
234
+ /**
235
+ * The spoken line of a tempo ramp, for the three events that are told: `{ kind: 'started', fromBpm, toBpm, bars }`,
236
+ * `{ kind: 'finished', toBpm }` and `{ kind: 'canceled', reason, bpm }` with the reason 'other-tempo' (the code states
237
+ * another tempo, which plays now) or 'no-tempo' (the code states none, the tempo stays). The tempos go through bpmText and
238
+ * `bars` is a whole number from 1; anything else throws a `Moontide:` Error. Stop, a reload and a replaced ramp are not told.
239
+ */
240
+ export function rampLine(event) {
241
+ if (event === null || typeof event !== 'object') {
242
+ throw new Error(`Moontide: a ramp line needs an event, got ${String(event)}.`);
243
+ }
244
+ if (event.kind === 'started') {
245
+ if (!Number.isInteger(event.bars) || event.bars < 1) {
246
+ throw new Error(`Moontide: a ramp line needs a whole number of bars from 1, got ${String(event.bars)}.`);
247
+ }
248
+ const template = event.bars === 1 ? CHANGE_COPY.rampStartedOneBar : CHANGE_COPY.rampStarted;
249
+ return fillChange(template, { from: bpmText(event.fromBpm), to: bpmText(event.toBpm), n: event.bars });
250
+ }
251
+ if (event.kind === 'finished') {
252
+ return fillChange(CHANGE_COPY.rampFinished, { to: bpmText(event.toBpm) });
253
+ }
254
+ if (event.kind === 'canceled') {
255
+ if (event.reason === 'other-tempo') return fillChange(CHANGE_COPY.rampCanceledOtherTempo, { bpm: bpmText(event.bpm) });
256
+ if (event.reason === 'no-tempo') return fillChange(CHANGE_COPY.rampCanceledNoTempo, { bpm: bpmText(event.bpm) });
257
+ throw new Error(`Moontide: a canceled ramp is told for 'other-tempo' or 'no-tempo', not '${String(event.reason)}'.`);
258
+ }
259
+ throw new Error(`Moontide: a ramp line is told for 'started', 'finished' or 'canceled', not '${String(event.kind)}'.`);
260
+ }
package/page/index.html CHANGED
@@ -47,9 +47,10 @@
47
47
  <span class="layout-word"></span>
48
48
  <div id="layout-buttons" role="group" aria-label="Layout"></div>
49
49
  <span class="bar-sep" aria-hidden="true"></span>
50
- <span id="ask-status" role="status" aria-live="polite" hidden><span class="ask-long"></span><span class="ask-short" aria-hidden="true"></span></span>
50
+ <button type="button" id="requests-toggle" aria-expanded="false" aria-controls="requests-panel" hidden><span class="rq-word"></span><span class="rq-count" hidden></span></button>
51
51
  <button type="button" id="session-button" aria-expanded="false" aria-controls="session-drawer"><span class="session-label"></span><span id="session-count" hidden></span><span class="session-chevron" aria-hidden="true"></span></button>
52
52
  </header>
53
+ <div id="requests-panel" role="region" tabindex="-1" hidden></div>
53
54
  <div id="problem-banner" data-kind="change" tabindex="-1" hidden><span class="problem-lead"></span><span class="problem-consequence"></span><span id="eval-error" hidden></span><span class="problem-actions"><button type="button" id="problem-details-toggle" aria-expanded="false" aria-controls="problem-details"></button><button type="button" id="problem-copy"></button><button type="button" id="problem-ask" hidden></button><button type="button" id="problem-dismiss"></button></span><p id="problem-copy-failure" hidden></p><pre id="problem-details" hidden></pre></div>
54
55
  <div id="problem-alert" role="alert"></div>
55
56
  <div id="problem-menu" role="group" hidden><button type="button" id="problem-menu-copy"></button><button type="button" id="problem-menu-ask"></button><button type="button" id="problem-menu-dismiss"></button></div>
@@ -85,6 +86,7 @@
85
86
  <p class="session-hint session-empty" data-copy="noChange"></p>
86
87
  </section>
87
88
  </div>
89
+ <span id="requests-live" role="status" aria-live="polite"></span>
88
90
  </div>
89
91
  <div id="workspace">
90
92
  <main id="editor-area"><strudel-editor id="repl"></strudel-editor></main>
@@ -104,7 +106,7 @@
104
106
  </div>
105
107
  <div id="mixer-view" hidden><p id="mixer-notice" hidden></p><p id="mixer-message" hidden></p><div id="mixer-body" hidden><div id="mixer-strips"></div><div id="mixer-inspector" hidden></div></div><p id="mixer-nonotes" hidden></p></div>
106
108
  <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>
107
- <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><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>
109
+ <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>
108
110
  <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>
109
111
  </section>
110
112
  </div>
@@ -376,15 +376,38 @@ export function warningsFor(solidSegments, scale) {
376
376
 
377
377
  // --- scale detection ---
378
378
 
379
+ /** The key of a candidate scale: its root pitch class and its mode id, '9:aeolian' for A minor. */
380
+ export const candidateKey = (root, mode) => `${root}:${mode.id}`;
381
+
382
+ /**
383
+ * The clear shape of one scale: `{ status: 'clear', root, mode, name, spell, notes, coverage }`, notes being the
384
+ * seven `{ pc, name, degree }` in scale order from the root. detectScale returns it for a scale that stands alone,
385
+ * and every proposal of an ambiguous reading has it, so a scale in use reads the same wherever it came from.
386
+ */
387
+ export function clearScale(root, mode, coverage) {
388
+ const spell = spellTable(root, mode);
389
+ return {
390
+ status: 'clear',
391
+ root,
392
+ mode,
393
+ name: scaleName(root, mode),
394
+ spell,
395
+ notes: scalePcs(root, mode).map((pc, i) => ({ pc, name: spell[pc], degree: mode.degrees[i] })),
396
+ coverage,
397
+ };
398
+ }
399
+
379
400
  /**
380
401
  * Reads the scale of a set of pitched events (brief ones too). `harmonicSegments` (segments of
381
402
  * the half-cycle segmentation) add tonic evidence: bass notes of segments with at least two
382
403
  * notes and chord roots, weighted 2, 2 and 1 with all notes, renormalized over the components
383
404
  * that have evidence. Returns
384
- * - `{ status: 'clear', root, mode, name, spell, notes, coverage }`, notes being the seven
385
- * `{ pc, name, degree }` in scale order from the root;
386
- * - `{ status: 'ambiguous', kind: 'tie' | 'near', candidates }`, candidates `{ root, mode, name, coverage }`
387
- * in score order (a tie means the candidates cover the same weight, near that they do not);
405
+ * - `clearScale(root, mode, coverage)` for a scale that stands alone;
406
+ * - `{ status: 'ambiguous', kind: 'tie' | 'near', candidates }`, candidates `{ root, mode, name, coverage, key,
407
+ * score, lean, heard }` in score order, the best evidence first (a tie means the candidates cover the same weight,
408
+ * near that they do not). `score` is the coverage plus TONIC_BONUS times the tonic emphasis of the root, and
409
+ * that emphasis is `lean` (the part from bass notes and chord roots) plus `heard` (the part from the weight of the
410
+ * notes), so a reader can say which evidence put a candidate first;
388
411
  * - `{ status: 'no-fit' }` when no scale covers MIN_COVERAGE of the weight;
389
412
  * - `{ status: 'none' }` when no event has any duration.
390
413
  */
@@ -410,8 +433,14 @@ export function detectScale(events, harmonicSegments = []) {
410
433
  .filter((component) => component[2] > 0);
411
434
  const kSum = components.reduce((x, component) => x + component[1], 0);
412
435
  const tonic = new Array(12).fill(0);
436
+ const lean = new Array(12).fill(0);
437
+ const heard = new Array(12).fill(0);
413
438
  for (const [values, k, sum] of components) {
414
- for (let pc = 0; pc < 12; pc++) tonic[pc] += (k / kSum) * (values[pc] / sum);
439
+ for (let pc = 0; pc < 12; pc++) {
440
+ const part = (k / kSum) * (values[pc] / sum);
441
+ tonic[pc] += part;
442
+ (values === weight ? heard : lean)[pc] += part;
443
+ }
415
444
  }
416
445
  const candidates = [];
417
446
  for (const mode of MODES) {
@@ -419,30 +448,28 @@ export function detectScale(events, harmonicSegments = []) {
419
448
  let inside = 0;
420
449
  for (const pc of scalePcs(root, mode)) inside += weight[pc];
421
450
  const coverage = inside / total;
422
- candidates.push({ root, mode, coverage, score: coverage + TONIC_BONUS * tonic[root] });
451
+ candidates.push({ root, mode, coverage, score: coverage + TONIC_BONUS * tonic[root], lean: lean[root], heard: heard[root] });
423
452
  }
424
453
  }
425
454
  if (Math.max(...candidates.map((c) => c.coverage)) < MIN_COVERAGE) return { status: 'no-fit' };
426
455
  candidates.sort((a, b) => b.score - a.score || b.coverage - a.coverage || a.mode.parent - b.mode.parent);
427
456
  const top = candidates[0];
428
457
  const unresolved = candidates.filter((c) => top.score - c.score < CLEAR_GAP);
429
- if (unresolved.length === 1) {
430
- const spell = spellTable(top.root, top.mode);
431
- return {
432
- status: 'clear',
433
- root: top.root,
434
- mode: top.mode,
435
- name: scaleName(top.root, top.mode),
436
- spell,
437
- notes: scalePcs(top.root, top.mode).map((pc, i) => ({ pc, name: spell[pc], degree: top.mode.degrees[i] })),
438
- coverage: top.coverage,
439
- };
440
- }
458
+ if (unresolved.length === 1) return clearScale(top.root, top.mode, top.coverage);
441
459
  const tie = unresolved.every((c) => Math.abs(c.coverage - top.coverage) < 1e-9);
442
460
  return {
443
461
  status: 'ambiguous',
444
462
  kind: tie ? 'tie' : 'near',
445
- candidates: unresolved.map((c) => ({ root: c.root, mode: c.mode, name: scaleName(c.root, c.mode), coverage: c.coverage })),
463
+ candidates: unresolved.map((c) => ({
464
+ root: c.root,
465
+ mode: c.mode,
466
+ name: scaleName(c.root, c.mode),
467
+ coverage: c.coverage,
468
+ key: candidateKey(c.root, c.mode),
469
+ score: c.score,
470
+ lean: c.lean,
471
+ heard: c.heard,
472
+ })),
446
473
  };
447
474
  }
448
475
 
@@ -482,7 +509,11 @@ function scaleOfPool(pool) {
482
509
  * Warnings are computed per span, from the solid segmentation of the events that begin in the
483
510
  * span, with the span's own scale and spelling (none when that scale is not clear). `warnings`
484
511
  * lists every span's warnings in begin order, each also carrying its `index` in that list, its
485
- * `spanIndex` and its `scaleName`. Each span also carries `harmonic`, the half-cycle segmentation of
512
+ * `spanIndex` and its `scaleName`. A span whose scale is ambiguous also carries `proposals`, one
513
+ * `{ key, scale, warnings }` for each candidate in candidate order: the candidate as a clear scale and
514
+ * the warnings that scale would have, computed once from the same segmentation, so choosing among the
515
+ * candidates later (page/jam-resolve.js) is a lookup and never a new reading; the base `warnings` stay empty
516
+ * for such a span. Every other span has `proposals: []`. Each span also carries `harmonic`, the half-cycle segmentation of
486
517
  * its own events, from which the chord sounding now is read. With two or more arrangements `partOwner` names the chosen
487
518
  * one the way the Parts view does (the owner track's name among `tracks`, else `arrange() N`).
488
519
  * `partial` (default false) says the window is shorter than the look-ahead because the page after
@@ -541,9 +572,23 @@ export function buildReading({ events, windowStart, windowLength, arrangements =
541
572
  const warnings = [];
542
573
  spans.forEach((span, spanIndex) => {
543
574
  span.harmonic = segments(markSolid(spanEvents[spanIndex].map((e) => ({ ...e })), HARMONIC_MIN), span.begin, span.end);
544
- span.warnings =
545
- span.scale.status === 'clear'
546
- ? warningsFor(segments(markSolid(spanEvents[spanIndex].map((e) => ({ ...e })), BRIEF), span.begin, span.end), span.scale)
575
+ // One brief segmentation per span serves the span's own scale and, when it is ambiguous, every candidate.
576
+ const brief =
577
+ span.scale.status === 'clear' || span.scale.status === 'ambiguous'
578
+ ? segments(markSolid(spanEvents[spanIndex].map((e) => ({ ...e })), BRIEF), span.begin, span.end)
579
+ : null;
580
+ span.warnings = span.scale.status === 'clear' ? warningsFor(brief, span.scale) : [];
581
+ span.proposals =
582
+ span.scale.status === 'ambiguous'
583
+ ? span.scale.candidates.map((candidate) => {
584
+ const scale = clearScale(candidate.root, candidate.mode, candidate.coverage);
585
+ const proposed = warningsFor(brief, scale);
586
+ for (const warning of proposed) {
587
+ warning.spanIndex = spanIndex;
588
+ warning.scaleName = scale.name;
589
+ }
590
+ return { key: candidate.key, scale, warnings: proposed };
591
+ })
547
592
  : [];
548
593
  for (const warning of span.warnings) {
549
594
  warning.spanIndex = spanIndex;