moontide-mcp 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +44 -6
  2. package/dist/ask.js +30 -8
  3. package/dist/mcp.js +60 -14
  4. package/dist/protocol.js +89 -2
  5. package/dist/reference.js +22 -0
  6. package/dist/session.js +53 -0
  7. package/package.json +1 -1
  8. package/page/ask-view.js +21 -7
  9. package/page/ask.js +292 -34
  10. package/page/change-timing.js +25 -4
  11. package/page/harmony.js +13 -0
  12. package/page/index.html +4 -3
  13. package/page/jam-model.js +11 -2
  14. package/page/jam-view.js +17 -0
  15. package/page/lights.js +278 -0
  16. package/page/main.js +1723 -140
  17. package/page/midi-clock.js +1 -1
  18. package/page/midi-out.js +12 -0
  19. package/page/mixer-groups.js +114 -0
  20. package/page/mixer-model.js +172 -29
  21. package/page/mixer-rewrite.js +30 -1
  22. package/page/mixer-view.js +282 -13
  23. package/page/note-edit.js +267 -0
  24. package/page/note-locate.js +346 -0
  25. package/page/note-panel.js +381 -0
  26. package/page/note-tag.js +126 -0
  27. package/page/page-rewrite.js +184 -0
  28. package/page/song-edit.js +230 -0
  29. package/page/song-fit.js +250 -0
  30. package/page/song-grid-model.js +491 -0
  31. package/page/song-grid-view.js +710 -0
  32. package/page/song-prefs.js +100 -0
  33. package/page/structure-check.js +216 -0
  34. package/page/structure-view.js +38 -0
  35. package/page/structure.js +760 -0
  36. package/page/style.css +879 -84
  37. package/page/takeover.js +20 -5
  38. package/page/timeline-edit-view.js +1956 -0
  39. package/page/timeline-gesture.js +279 -0
  40. package/page/timeline.js +196 -3
  41. package/page/tracks.js +37 -8
  42. package/page/ui-state.js +10 -5
  43. package/page/views.js +25 -8
  44. package/reference/functions.md +1 -0
  45. package/reference/midi.md +2 -0
  46. package/reference/structure.md +131 -0
package/page/ask-view.js CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  createAskStatus,
20
20
  cutText,
21
21
  describeContext,
22
+ fitGestureToLine,
22
23
  fitMomentToLine,
23
24
  newAskId,
24
25
  partsAskState,
@@ -119,7 +120,8 @@ export function createAskView({ document, send, reportError, clearError, setTime
119
120
  // The version id the top bar shows, or null before any version.
120
121
  let currentVersion = null;
121
122
  // What the right-click pointed at, snapshotted when the box opened: { context, chip } and, when the
122
- // Timeline showed the music there, the moment captured at the click.
123
+ // Timeline showed the music there, the moment captured at the click, and, when a gesture on a note opened the box,
124
+ // that gesture; for a range, the cycles it was cut to when the pointer left the page (`rangeCut`).
123
125
  let snapshot = null;
124
126
  // The last request sent: { id, text }, so an accepted request clears its own draft.
125
127
  let lastSent = null;
@@ -177,6 +179,7 @@ export function createAskView({ document, send, reportError, clearError, setTime
177
179
  chip: snapshot.chip,
178
180
  current: currentVersion,
179
181
  moment: snapshot.moment,
182
+ gesture: snapshot.gesture,
180
183
  };
181
184
  }
182
185
 
@@ -189,7 +192,7 @@ export function createAskView({ document, send, reportError, clearError, setTime
189
192
  function renderMessage() {
190
193
  const length = askField.value.trim().length;
191
194
  const tooLongLine = lineTooLong();
192
- const text = askMessage({ connected, length, notSent, tokenRefused, lineTooLong: tooLongLine });
195
+ const text = askMessage({ connected, length, notSent, tokenRefused, lineTooLong: tooLongLine, rangeCut: snapshot === null ? null : (snapshot.rangeCut ?? null) });
193
196
  askMessageEl.textContent = text ?? '';
194
197
  askMessageEl.hidden = text === null;
195
198
  askField.setAttribute('aria-describedby', askMessageEl.hidden ? DESCRIBED_WITHOUT_MESSAGE : DESCRIBED_WITH_MESSAGE);
@@ -264,13 +267,17 @@ export function createAskView({ document, send, reportError, clearError, setTime
264
267
  /**
265
268
  * Puts a prefill in the field (a failed change sent to Claude). An empty field is simply filled. A field that
266
269
  * already holds a draft keeps it: the prefill follows after a blank line, cut so the whole field stays within
267
- * MAX_ASK_TEXT_LENGTH. The caret goes to the end so the user types the question after it. When the draft leaves no
268
- * room at all, the draft stays as it is (the chip still names the error).
270
+ * MAX_ASK_TEXT_LENGTH, and is not added again when the draft already ends with it. The caret goes to the end so the
271
+ * user types the question after it. When the draft leaves no room at all, the draft stays as it is (the chip still
272
+ * names the error).
269
273
  */
270
274
  function applyPrefill(prefill) {
271
275
  const draft = askField.value;
272
276
  if (draft === '') {
273
277
  askField.value = prefill;
278
+ } else if (draft.endsWith(prefill)) {
279
+ // The request is already there (the same target was asked about twice, or the browser raised a right-click after
280
+ // a key press that had already opened the box): it is not added a second time.
274
281
  } else {
275
282
  const room = MAX_ASK_TEXT_LENGTH - draft.length - 2;
276
283
  if (room < 1) return;
@@ -284,8 +291,8 @@ export function createAskView({ document, send, reportError, clearError, setTime
284
291
  * `{ prefill }` the field also receives that text (see applyPrefill); without it nothing about the field changes.
285
292
  */
286
293
  function open(descriptor, point, { prefill } = {}) {
287
- const { context, chip, moment } = describeContext(descriptor);
288
- snapshot = moment === undefined ? { context, chip } : { context, chip, moment };
294
+ const { context, chip, moment, gesture, rangeCut } = describeContext(descriptor);
295
+ snapshot = { context, chip, ...(moment === undefined ? {} : { moment }), ...(gesture === undefined ? {} : { gesture }), ...(rangeCut === undefined ? {} : { rangeCut }) };
289
296
  askContextChip.textContent = chip;
290
297
  askContextChip.setAttribute('title', chip);
291
298
  const wasHidden = askBox.hidden;
@@ -327,8 +334,13 @@ export function createAskView({ document, send, reportError, clearError, setTime
327
334
  const frame = { type: 'ask', id, text, context: describedContext.context, chip: describedContext.chip, current: currentVersion };
328
335
  // The moment rides after `current`, and only when there is one: a frame without it is as it always was.
329
336
  // Notes come off its end until the whole line fits what the server takes (the rest counts in `more`).
330
- const moment = fitMomentToLine({ ...frame, moment: describedContext.moment });
337
+ const fields = { ...frame, moment: describedContext.moment, gesture: describedContext.gesture };
338
+ const moment = fitMomentToLine(fields);
331
339
  if (moment !== undefined) frame.moment = moment;
340
+ // The gesture follows the moment, and only when the box was opened by one. The notes of a range gesture come off its end the
341
+ // same way; any other gesture is sent as it is.
342
+ const gesture = fitGestureToLine({ ...fields, moment });
343
+ if (gesture !== undefined) frame.gesture = gesture;
332
344
  send(frame);
333
345
  // While the token is refused the line stays: the server's 'token' refusal writes it again, so it never blinks off.
334
346
  if (!authRefused) clearError();
@@ -358,6 +370,8 @@ export function createAskView({ document, send, reportError, clearError, setTime
358
370
  // The History view must never see this Enter, even after focus moves back to a row.
359
371
  event.preventDefault();
360
372
  event.stopPropagation();
373
+ // A held key that opened the box (the auto-repeat lands here once focus has moved) must not also send what is in it.
374
+ if (event.repeat) return;
361
375
  submit();
362
376
  } else if (event.key === 'Escape') {
363
377
  // Esc closes the box only, never an open view.
package/page/ask.js CHANGED
@@ -4,7 +4,8 @@
4
4
  // page/ask-view.js.
5
5
 
6
6
  import { BEATS_PER_CYCLE } from './change-timing.js';
7
- import { MAX_MOMENT_NOTES } from './timeline.js';
7
+ import { MAX_MOMENT_NOTES, MAX_RANGE_CYCLES } from './timeline.js';
8
+ import { cleanText } from './structure.js';
8
9
 
9
10
  /** Mirrors src/protocol.ts's MAX_ASK_TEXT_LENGTH by hand (test/ask.test.js checks both are 2000). */
10
11
  export const MAX_ASK_TEXT_LENGTH = 2000;
@@ -67,6 +68,24 @@ export const ASK_COPY = {
67
68
  // A failed change sent to Claude from the banner (the chip's lead and the first words of the prefill).
68
69
  errorChipLead: 'Failed change',
69
70
  errorPrefillLead: 'The last change failed with this error:',
71
+ // The ready-made request on a cell whose fixed variation has notes outside its section's harmony. The words in braces
72
+ // are filled by cellFitPrefill; the user reads the text, may change it and presses Enter.
73
+ cellFitRequest: "Fit variation '{variation}' of track '{track}' to the harmony of section '{section}' ({harmony}). It has notes outside it: {notes}.",
74
+ // The ready-made requests of a gesture on a Timeline note the page cannot edit. timelineEditPrefill fills the words in
75
+ // braces; the user reads the text, may change it and presses Enter.
76
+ raiseRequest: "Raise note {note} at cycle {cycle}, beat {beat} of '{track}' by {amount}.",
77
+ lowerRequest: "Lower note {note} at cycle {cycle}, beat {beat} of '{track}' by {amount}.",
78
+ restRequest: "Turn note {note} at cycle {cycle}, beat {beat} of '{track}' into a rest.",
79
+ softerRequest: "Play note {note} at cycle {cycle}, beat {beat} of '{track}' softer.",
80
+ louderRequest: "Play note {note} at cycle {cycle}, beat {beat} of '{track}' louder.",
81
+ semitoneAmount: '1 semitone',
82
+ semitonesAmount: '{n} semitones',
83
+ octaveAmount: '1 octave',
84
+ octavesAmount: '{n} octaves',
85
+ // The line the box shows when a range was cut to the page shown (the pointer left the lanes while the range was drawn). The
86
+ // words in braces are filled by rangeCutText.
87
+ rangeCut: 'Cut to the page shown — cycles {a} to {b}.',
88
+ rangeCutOne: 'Cut to the page shown — cycle {a}.',
70
89
  };
71
90
 
72
91
  /**
@@ -145,14 +164,27 @@ export function counterText(length) {
145
164
  return `${length} / ${MAX_ASK_TEXT_LENGTH} — too long. Shorten it to send.`;
146
165
  }
147
166
 
148
- /** The one muted message line of the box, in the spec's priority order, or null when none applies. */
149
- export function askMessage({ connected, length, notSent, tokenRefused, lineTooLong = false }) {
167
+ /** The line that says a range was cut to the page shown, for `cut` = `{ from, to }` (the cycles the range ended up with). */
168
+ export function rangeCutText(cut) {
169
+ if (cut === null || typeof cut !== 'object' || !isNonNegativeInteger(cut.from) || !isNonNegativeInteger(cut.to) || cut.to < cut.from) {
170
+ throw new Error('Moontide: a cut range needs whole cycles from and to, to not before from');
171
+ }
172
+ const template = cut.from === cut.to ? ASK_COPY.rangeCutOne : ASK_COPY.rangeCut;
173
+ return template.replace('{a}', String(cut.from)).replace('{b}', String(cut.to));
174
+ }
175
+
176
+ /**
177
+ * The one muted message line of the box, in the spec's priority order, or null when none applies. `rangeCut` (`{ from, to }`
178
+ * or null) is last: it shows only when nothing above it applies.
179
+ */
180
+ export function askMessage({ connected, length, notSent, tokenRefused, lineTooLong = false, rangeCut = null }) {
150
181
  if (!connected) return ASK_COPY.disconnected;
151
182
  const counter = counterText(length);
152
183
  if (counter !== null && !(lineTooLong && length <= MAX_ASK_TEXT_LENGTH)) return counter;
153
184
  if (lineTooLong) return ASK_COPY.lineTooLong;
154
185
  if (notSent && tokenRefused) return ASK_COPY.notSentToken;
155
186
  if (notSent) return ASK_COPY.notSent;
187
+ if (rangeCut !== null) return rangeCutText(rangeCut);
156
188
  return null;
157
189
  }
158
190
 
@@ -224,6 +256,12 @@ function requiredBus(descriptor) {
224
256
 
225
257
  const SEPARATOR = ' \u00B7 ';
226
258
 
259
+ /** The words after a cell's name in its chip, by the grid's fit state. */
260
+ const CELL_FIT_WORDS = Object.freeze({ fits: 'fits', 'does-not-fit': 'does not fit', 'cannot-check': 'cannot check' });
261
+
262
+ /** The most characters the fit part of a cell's chip may take, so the base always keeps room. */
263
+ const MAX_CELL_SUFFIX = 100;
264
+
227
265
  /** The bus name a bus chip shows: 'Reverb bus', with the orbit while several orbits are in use. */
228
266
  function busTitle(bus, orbit, multipleOrbits) {
229
267
  const name = `${bus === 'reverb' ? 'Reverb' : 'Delay'} bus`;
@@ -246,29 +284,63 @@ function isNonNegativeNumber(value) {
246
284
  export const MOMENT_NOTE_FIELDS = Object.freeze(['track', 'instrument', 'sound', 'note', 'drum', 'velocity', 'start', 'end', 'state']);
247
285
 
248
286
  /**
249
- * A copy of the moment the Timeline described, checked and with every string cut to the context limit
250
- * (numbers are untouched). The server refuses what is out of bounds and never trims, so a moment the page got
251
- * wrong is a loud error here instead of a request that is refused later. The input is never mutated.
287
+ * The kinds of gesture a request can carry, equal to GESTURE_KINDS in src/protocol.ts (test/ask.test.js pins the two
288
+ * lists equal). The server rejects a kind it does not know.
252
289
  */
253
- function checkedMoment(kind, moment) {
254
- if (moment === null || typeof moment !== 'object') throw missing(kind, 'a moment that is an object');
255
- if (!isNonNegativeInteger(moment.cycle)) throw missing(kind, 'a whole number moment cycle of at least 0');
256
- if (!Number.isInteger(moment.beat) || moment.beat < 1 || moment.beat > BEATS_PER_CYCLE) {
257
- throw missing(kind, `a whole number moment beat from 1 to ${BEATS_PER_CYCLE}`);
258
- }
259
- if (!isNonNegativeNumber(moment.at)) throw missing(kind, 'a finite moment instant of at least 0');
260
- if (moment.version !== null && !(Number.isInteger(moment.version) && moment.version > 0)) {
261
- throw missing(kind, 'a moment version that is null or a positive whole number');
262
- }
263
- if (!Array.isArray(moment.notes) || moment.notes.length > MAX_MOMENT_NOTES) {
264
- throw missing(kind, `at most ${MAX_MOMENT_NOTES} moment notes`);
290
+ export const GESTURE_KINDS = Object.freeze(['pitch', 'rest', 'velocity', 'range']);
291
+
292
+ /** The gestures that change one note of the Timeline (the ones a ready-made request is written for). */
293
+ const NOTE_GESTURE_KINDS = Object.freeze(GESTURE_KINDS.filter((kind) => kind !== 'range'));
294
+
295
+ /**
296
+ * The keys checkedGesture may copy into a note of a range: the moment note's keys without the state. The server's range note
297
+ * shape (askRangeNoteShape in src/protocol.ts) names the same keys; test/ask.test.js pins the two lists equal.
298
+ */
299
+ export const RANGE_NOTE_FIELDS = Object.freeze(MOMENT_NOTE_FIELDS.filter((field) => field !== 'state'));
300
+
301
+ /** The most semitones one pitch gesture may carry, up or down; equals MAX_GESTURE_SEMITONES in src/protocol.ts. */
302
+ export const MAX_GESTURE_SEMITONES = 127;
303
+
304
+ /**
305
+ * A frozen copy of the gesture a descriptor carries, with only the keys its kind names. The server rejects a gesture out
306
+ * of bounds and never trims it, so a gesture the page got wrong is a loud error here.
307
+ */
308
+ function checkedGesture(gesture) {
309
+ if (gesture === null || typeof gesture !== 'object') throw new Error('Moontide: an ask gesture is an object');
310
+ switch (gesture.kind) {
311
+ case 'pitch':
312
+ if (!Number.isInteger(gesture.semitones) || gesture.semitones === 0 || Math.abs(gesture.semitones) > MAX_GESTURE_SEMITONES) {
313
+ throw new Error(`Moontide: a pitch gesture needs whole semitones from -${MAX_GESTURE_SEMITONES} to ${MAX_GESTURE_SEMITONES} other than 0`);
314
+ }
315
+ return Object.freeze({ kind: 'pitch', semitones: gesture.semitones });
316
+ case 'rest':
317
+ return Object.freeze({ kind: 'rest' });
318
+ case 'velocity':
319
+ if (gesture.direction !== 'softer' && gesture.direction !== 'louder') throw new Error('Moontide: a velocity gesture needs a direction of softer or louder');
320
+ return Object.freeze({ kind: 'velocity', direction: gesture.direction });
321
+ case 'range': {
322
+ if (gesture.version !== null && !(Number.isInteger(gesture.version) && gesture.version > 0)) {
323
+ throw missing('range', 'a gesture version that is null or a positive whole number');
324
+ }
325
+ if (!Array.isArray(gesture.notes) || gesture.notes.length > MAX_MOMENT_NOTES) throw missing('range', `at most ${MAX_MOMENT_NOTES} range notes`);
326
+ if (!isNonNegativeInteger(gesture.more)) throw missing('range', 'a whole number count of more notes of at least 0');
327
+ return Object.freeze({ kind: 'range', version: gesture.version, notes: checkedNotes('range', gesture.notes, false), more: gesture.more });
328
+ }
329
+ default:
330
+ throw new Error(`Moontide: unknown ask gesture kind ${String(gesture.kind)}`);
265
331
  }
266
- if (!isNonNegativeInteger(moment.more)) throw missing(kind, 'a whole number moment count of more notes of at least 0');
267
- const notes = moment.notes.map((note, index) => {
268
- const where = `moment note ${index + 1}`;
332
+ }
333
+
334
+ /**
335
+ * A checked copy of the notes of a moment (`withState` true) or of a range (no state), every string cut to the context limit.
336
+ * The input is never mutated; a note that is not as the server takes it is a loud error.
337
+ */
338
+ function checkedNotes(kind, notes, withState) {
339
+ return notes.map((note, index) => {
340
+ const where = `${withState ? 'moment' : 'range'} note ${index + 1}`;
269
341
  if (note === null || typeof note !== 'object') throw missing(kind, `${where} as an object`);
270
342
  if (typeof note.track !== 'string' || note.track === '') throw missing(kind, `a non-empty track in ${where}`);
271
- if (note.state !== 'sounding' && note.state !== 'starts') throw missing(kind, `a state of sounding or starts in ${where}`);
343
+ if (withState && note.state !== 'sounding' && note.state !== 'starts') throw missing(kind, `a state of sounding or starts in ${where}`);
272
344
  if (!isNonNegativeNumber(note.start) || !isNonNegativeNumber(note.end)) {
273
345
  throw missing(kind, `a finite start and end of at least 0 in ${where}`);
274
346
  }
@@ -293,9 +365,31 @@ function checkedMoment(kind, moment) {
293
365
  }
294
366
  copy.start = note.start;
295
367
  copy.end = note.end;
296
- copy.state = note.state;
368
+ if (withState) copy.state = note.state;
297
369
  return copy;
298
370
  });
371
+ }
372
+
373
+ /**
374
+ * A copy of the moment the Timeline described, checked and with every string cut to the context limit
375
+ * (numbers are untouched). The server refuses what is out of bounds and never trims, so a moment the page got
376
+ * wrong is a loud error here instead of a request that is refused later. The input is never mutated.
377
+ */
378
+ function checkedMoment(kind, moment) {
379
+ if (moment === null || typeof moment !== 'object') throw missing(kind, 'a moment that is an object');
380
+ if (!isNonNegativeInteger(moment.cycle)) throw missing(kind, 'a whole number moment cycle of at least 0');
381
+ if (!Number.isInteger(moment.beat) || moment.beat < 1 || moment.beat > BEATS_PER_CYCLE) {
382
+ throw missing(kind, `a whole number moment beat from 1 to ${BEATS_PER_CYCLE}`);
383
+ }
384
+ if (!isNonNegativeNumber(moment.at)) throw missing(kind, 'a finite moment instant of at least 0');
385
+ if (moment.version !== null && !(Number.isInteger(moment.version) && moment.version > 0)) {
386
+ throw missing(kind, 'a moment version that is null or a positive whole number');
387
+ }
388
+ if (!Array.isArray(moment.notes) || moment.notes.length > MAX_MOMENT_NOTES) {
389
+ throw missing(kind, `at most ${MAX_MOMENT_NOTES} moment notes`);
390
+ }
391
+ if (!isNonNegativeInteger(moment.more)) throw missing(kind, 'a whole number moment count of more notes of at least 0');
392
+ const notes = checkedNotes(kind, moment.notes, true);
299
393
  return { cycle: moment.cycle, beat: moment.beat, at: moment.at, version: moment.version, notes, more: moment.more };
300
394
  }
301
395
 
@@ -304,8 +398,9 @@ function checkedMoment(kind, moment) {
304
398
  * prefix, a space and the JSON of the fields in the same order (U+2028 and U+2029 escaped), so the page can
305
399
  * tell before it sends whether the server will take the line.
306
400
  */
307
- export function requestLineBytes({ id, text, context, chip, current, moment }) {
308
- const fields = moment === undefined ? { id, text, context, chip, current } : { id, text, context, chip, current, moment };
401
+ export function requestLineBytes({ id, text, context, chip, current, moment, gesture }) {
402
+ // The moment, then the gesture, each only when there is one (the order of src/ask.ts's requestLine).
403
+ const fields = { id, text, context, chip, current, ...(moment === undefined ? {} : { moment }), ...(gesture === undefined ? {} : { gesture }) };
309
404
  const json = JSON.stringify(fields).replaceAll('\u2028', '\\u2028').replaceAll('\u2029', '\\u2029');
310
405
  return new TextEncoder().encode(`${REQUEST_LINE_PREFIX} ${json}`).length;
311
406
  }
@@ -329,22 +424,47 @@ export function fitMomentToLine(fields) {
329
424
  }
330
425
 
331
426
  /**
332
- * Whether the request fits one line once its moment is cut as far as `fitMomentToLine` cuts it. False means the
333
- * text, the context and the chip alone are over MAX_REQUEST_LINE_BYTES: a text within the character limit can
334
- * still get here (JSON writes a control character as six bytes, and some scripts take three), so the box does
335
- * not send it and says so, instead of letting the server refuse it with the wrong reason.
427
+ * The gesture of a request cut to what the line holds. A range gesture loses notes from its end, one at a time, counted in
428
+ * `more`, until the whole line is at most MAX_REQUEST_LINE_BYTES (or no note is left); any other gesture, and undefined, come
429
+ * back as they are. The input is never mutated, and a gesture that fits is returned as it is. The chip counts notes and
430
+ * `more` together, so it needs no change.
431
+ */
432
+ export function fitGestureToLine(fields) {
433
+ const { gesture } = fields;
434
+ if (gesture === undefined || gesture.kind !== 'range') return gesture;
435
+ let notes = gesture.notes;
436
+ let more = gesture.more;
437
+ while (notes.length > 0 && requestLineBytes({ ...fields, gesture: { ...gesture, notes, more } }) > MAX_REQUEST_LINE_BYTES) {
438
+ notes = notes.slice(0, -1);
439
+ more += 1;
440
+ }
441
+ return notes === gesture.notes ? gesture : { ...gesture, notes, more };
442
+ }
443
+
444
+ /**
445
+ * Whether the request fits one line once its moment and its range notes are cut as far as `fitMomentToLine` and
446
+ * `fitGestureToLine` cut them. False means the text, the context and the chip alone are over MAX_REQUEST_LINE_BYTES: a text
447
+ * within the character limit can still get here (JSON writes a control character as six bytes, and some scripts take three),
448
+ * so the box does not send it and says so, instead of letting the server refuse it with the wrong reason.
336
449
  */
337
450
  export function requestFitsLine(fields) {
338
- return requestLineBytes({ ...fields, moment: fitMomentToLine(fields) }) <= MAX_REQUEST_LINE_BYTES;
451
+ const moment = fitMomentToLine(fields);
452
+ return requestLineBytes({ ...fields, moment, gesture: fitGestureToLine({ ...fields, moment }) }) <= MAX_REQUEST_LINE_BYTES;
453
+ }
454
+
455
+ /** '3 notes', '1 note' or 'no notes' for a count. */
456
+ function notesWords(count) {
457
+ return count === 0 ? 'no notes' : count === 1 ? '1 note' : `${count} notes`;
339
458
  }
340
459
 
341
460
  /** The part of a chip that says where and what: 'cycle 34, beat 3 · 3 notes'. */
342
461
  function momentChipPart(moment) {
343
- const count = moment.notes.length + moment.more;
344
- const notes = count === 0 ? 'no notes' : count === 1 ? '1 note' : `${count} notes`;
345
- return `cycle ${moment.cycle}, beat ${moment.beat}${SEPARATOR}${notes}`;
462
+ return `cycle ${moment.cycle}, beat ${moment.beat}${SEPARATOR}${notesWords(moment.notes.length + moment.more)}`;
346
463
  }
347
464
 
465
+ /** The longest section name the chip of a range shows; the context carries the whole name (cut at the context limit). */
466
+ const MAX_RANGE_CHIP_SECTION = 40;
467
+
348
468
  /**
349
469
  * What a right-click pointed at, as the context the server receives and the chip the user sees.
350
470
  * `descriptor` is what a view reports about an element:
@@ -356,6 +476,7 @@ function momentChipPart(moment) {
356
476
  export function describeContext(descriptor) {
357
477
  const kind = descriptor && descriptor.kind;
358
478
  let moment = descriptor ? descriptor.moment : undefined;
479
+ let gesture = descriptor ? descriptor.gesture : undefined;
359
480
  // A moment is the Timeline's facts at the pointer: a lane, a part header over the lanes and the rest of the
360
481
  // Timeline (a session descriptor) carry one; nothing else does.
361
482
  if (moment !== undefined && kind !== 'lane' && kind !== 'part' && kind !== 'session') throw new Error(`Moontide: ask context ${kind} cannot carry a moment`);
@@ -490,6 +611,33 @@ export function describeContext(descriptor) {
490
611
  }
491
612
  break;
492
613
  }
614
+ case 'range': {
615
+ const track = requiredText(descriptor, 'track');
616
+ const from = requiredInteger(descriptor, 'from', 0);
617
+ const to = requiredInteger(descriptor, 'to', from);
618
+ 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');
622
+ if (gesture === undefined || gesture.kind !== 'range') throw missing(kind, 'a range gesture with its notes');
623
+ gesture = checkedGesture(gesture);
624
+ const cut = descriptor.cut === undefined || descriptor.cut === null ? null : { from: descriptor.cut.from, to: descriptor.cut.to };
625
+ if (cut !== null) rangeCutText(cut);
626
+ context = { kind, track, from, to };
627
+ if (section !== undefined) context.section = section;
628
+ if (sectionTo !== undefined) context.sectionTo = sectionTo;
629
+ // 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
+ // 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));
637
+ const tail = parts.join(SEPARATOR);
638
+ chip = `${cutText(`Range${SEPARATOR}${track}`, MAX_ASK_CONTEXT_LENGTH - SEPARATOR.length - tail.length)}${SEPARATOR}${tail}`;
639
+ return { context, chip: cutText(chip), gesture, ...(cut === null ? {} : { rangeCut: cut }) };
640
+ }
493
641
  case 'note': {
494
642
  const note = requiredText(descriptor, 'note');
495
643
  const role = descriptor.role === null ? undefined : descriptor.role;
@@ -523,10 +671,57 @@ export function describeContext(descriptor) {
523
671
  chip = `Warning${SEPARATOR}${chord} brings ${outside}${avoid === undefined ? '' : `, avoid ${avoid}`}, ${seconds === 0 ? 'now' : `in ${seconds} s`}`;
524
672
  break;
525
673
  }
674
+ case 'cell': {
675
+ const section = requiredText(descriptor, 'section');
676
+ const track = requiredText(descriptor, 'track');
677
+ const variation = optionalText(descriptor, 'variation');
678
+ const rest = descriptor.rest;
679
+ if (rest !== undefined && rest !== true) throw missing(kind, 'rest as true, or no rest');
680
+ // A cell holds one variation or is a rest, never both and never neither.
681
+ if ((variation === undefined) === (rest === undefined)) throw missing(kind, 'a variation or rest true, not both');
682
+ const fit = descriptor.fit === null ? undefined : descriptor.fit;
683
+ let outside;
684
+ let suffix = rest === undefined ? '' : `${SEPARATOR}rest`;
685
+ if (fit !== undefined) {
686
+ // The fit is the grid's word for a fixed variation; a rest has none.
687
+ if (rest !== undefined) throw missing(kind, 'no fit on a rest');
688
+ if (!Object.hasOwn(CELL_FIT_WORDS, fit)) throw missing(kind, 'a fit of fits, does-not-fit or cannot-check');
689
+ outside = optionalText(descriptor, 'outside');
690
+ if (outside !== undefined && fit !== 'does-not-fit') throw missing(kind, 'outside only with a fit of does-not-fit');
691
+ suffix = `${SEPARATOR}${CELL_FIT_WORDS[fit]}${outside === undefined ? '' : ` (${outside})`}`;
692
+ } else if (descriptor.outside !== undefined && descriptor.outside !== null) {
693
+ throw missing(kind, 'outside only with a fit of does-not-fit');
694
+ }
695
+ context = { kind, section, track };
696
+ if (variation !== undefined) context.variation = variation;
697
+ if (rest !== undefined) context.rest = true;
698
+ if (fit !== undefined) context.fit = fit;
699
+ if (outside !== undefined) context.outside = outside;
700
+ // The fit part is built first and the base (section, track, variation) is cut to the room left, so a long name
701
+ // never costs the words that say whether the notes fit.
702
+ const fitPart = cutText(suffix, MAX_CELL_SUFFIX);
703
+ const base = `Cell "${section}"${SEPARATOR}${track}${variation === undefined ? '' : `${SEPARATOR}${variation}`}`;
704
+ chip = `${cutText(base, MAX_ASK_CONTEXT_LENGTH - fitPart.length)}${fitPart}`;
705
+ break;
706
+ }
707
+ case 'group': {
708
+ const name = requiredText(descriptor, 'name');
709
+ const parent = optionalText(descriptor, 'parent');
710
+ context = parent === undefined ? { kind, name } : { kind, name, parent };
711
+ chip = `Group "${name}"${parent === undefined ? '' : `${SEPARATOR}in "${parent}"`}`;
712
+ break;
713
+ }
526
714
  default:
527
715
  throw new Error(`Moontide: unknown ask context kind ${kind}`);
528
716
  }
529
- return moment === undefined ? { context, chip: cutText(chip) } : { context, chip: cutText(chip), moment };
717
+ // 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
718
+ // context's own and is checked in its case above).
719
+ if (gesture !== undefined) {
720
+ if (kind !== 'lane' || moment === undefined) throw new Error(`Moontide: ask context ${kind} cannot carry a gesture without a lane and its moment`);
721
+ gesture = checkedGesture(gesture);
722
+ if (gesture.kind === 'range') throw new Error('Moontide: a range gesture goes only with a range context');
723
+ }
724
+ return { context, chip: cutText(chip), ...(moment === undefined ? {} : { moment }), ...(gesture === undefined ? {} : { gesture }) };
530
725
  }
531
726
 
532
727
  /**
@@ -539,6 +734,69 @@ export function errorAskPrefill(fullText) {
539
734
  return `${ASK_COPY.errorPrefillLead}\n\n${cutText(fullText, MAX_ERROR_PREFILL)}\n\n`;
540
735
  }
541
736
 
737
+ /**
738
+ * The text the Ask field starts with on a cell whose fixed variation does not fit its section: ASK_COPY.cellFitRequest with
739
+ * the variation, the track, the section, the section's harmony text and the outside notes. Every value is a non-empty string,
740
+ * cleaned of hidden characters and cut at the context limit, so the text stays within the field. A missing value throws.
741
+ */
742
+ export function cellFitPrefill(values) {
743
+ const filled = {};
744
+ for (const field of ['variation', 'track', 'section', 'harmony', 'notes']) {
745
+ const value = values === null || typeof values !== 'object' ? undefined : values[field];
746
+ if (typeof value !== 'string' || value === '') throw new Error(`Moontide: the fit request needs a non-empty ${field}`);
747
+ filled[field] = cutText(cleanText(value));
748
+ }
749
+ const text = ASK_COPY.cellFitRequest.replace(/\{(\w+)\}/g, (_, name) => filled[name]);
750
+ if (text.length > MAX_ASK_TEXT_LENGTH) throw new Error('Moontide: the fit request is longer than the Ask box takes.');
751
+ return text;
752
+ }
753
+
754
+ /** A pitch change in words: `1 semitone`, `5 semitones`, `1 octave`, `2 octaves` (a multiple of 12 reads in octaves). */
755
+ function amountWords(size) {
756
+ if (size % 12 === 0) return size === 12 ? ASK_COPY.octaveAmount : ASK_COPY.octavesAmount.replace('{n}', String(size / 12));
757
+ return size === 1 ? ASK_COPY.semitoneAmount : ASK_COPY.semitonesAmount.replace('{n}', String(size));
758
+ }
759
+
760
+ /**
761
+ * The text the Ask field starts with when the user does something to a Timeline note the page cannot edit: raise, lower,
762
+ * rest or softer / louder, with the note as the Timeline shows it, its cycle and beat and its track. `values` is `{ kind,
763
+ * note, cycle, beat, track }` and, for a pitch gesture, `semitones` (a whole number from -127 to 127 other than 0), for a
764
+ * velocity gesture `direction` ('softer' or 'louder'). The note and the track are cleaned of hidden characters and cut at the
765
+ * context limit (this is the user's own request, so it keeps the name Claude needs to find the track); anything else wrong
766
+ * throws.
767
+ */
768
+ export function timelineEditPrefill(values) {
769
+ const from = (field) => (values === null || typeof values !== 'object' ? undefined : values[field]);
770
+ const kind = from('kind');
771
+ if (!NOTE_GESTURE_KINDS.includes(kind)) throw new Error(`Moontide: the Timeline request needs a kind of ${NOTE_GESTURE_KINDS.join(', ')}`);
772
+ const note = from('note');
773
+ if (!((typeof note === 'string' && note !== '') || (typeof note === 'number' && Number.isFinite(note)))) {
774
+ throw new Error('Moontide: the Timeline request needs the note as a non-empty string or a finite number');
775
+ }
776
+ const cycle = from('cycle');
777
+ if (!isNonNegativeInteger(cycle)) throw new Error('Moontide: the Timeline request needs a whole number cycle of at least 0');
778
+ const beat = from('beat');
779
+ if (!Number.isInteger(beat) || beat < 1 || beat > BEATS_PER_CYCLE) throw new Error(`Moontide: the Timeline request needs a whole number beat from 1 to ${BEATS_PER_CYCLE}`);
780
+ const track = from('track');
781
+ if (typeof track !== 'string' || track === '') throw new Error('Moontide: the Timeline request needs a non-empty track');
782
+ const filled = { note: cutText(cleanText(String(note))), cycle: String(cycle), beat: String(beat), track: cutText(cleanText(track)) };
783
+ let template;
784
+ if (kind === 'pitch') {
785
+ const semitones = from('semitones');
786
+ checkedGesture({ kind, semitones });
787
+ template = semitones > 0 ? ASK_COPY.raiseRequest : ASK_COPY.lowerRequest;
788
+ filled.amount = amountWords(Math.abs(semitones));
789
+ } else if (kind === 'velocity') {
790
+ const direction = checkedGesture({ kind, direction: from('direction') }).direction;
791
+ template = direction === 'softer' ? ASK_COPY.softerRequest : ASK_COPY.louderRequest;
792
+ } else {
793
+ template = ASK_COPY.restRequest;
794
+ }
795
+ const text = template.replace(/\{(\w+)\}/g, (_, name) => filled[name]);
796
+ if (text.length > MAX_ASK_TEXT_LENGTH) throw new Error('Moontide: the Timeline request is longer than the Ask box takes.');
797
+ return text;
798
+ }
799
+
542
800
  // The cycle under the pointer on a Timeline lane lives in page/timeline.js next to momentAt, so a lane's
543
801
  // context cycle and a moment's cycle share one rule; it stays importable from here.
544
802
  export { cycleAt } from './timeline.js';
@@ -76,6 +76,23 @@ export function bpmOf(cps) {
76
76
  return Math.round(cps * 60 * BEATS_PER_CYCLE * 10) / 10;
77
77
  }
78
78
 
79
+ /**
80
+ * The timing of a change that must take over on one given line: a section launch writes a shift for that very line, so
81
+ * the takeover has to take the same one. `at` is 'section' (the end of the section that plays), 'bar' or 'phrase', and
82
+ * `atCycle` is the line, a finite cycle number. Returns a frozen { at, lineLength, atCycle }: `lineLength` (1 for a
83
+ * section end and a bar, the phrase length for a phrase) is the line the takeover falls back to when the pinned one has
84
+ * come too close. CHANGE_TIMINGS is not extended: it is the closed set of the stored setting and of the server's schema.
85
+ */
86
+ export function pinnedTiming(at, atCycle) {
87
+ if (at !== 'section' && at !== 'bar' && at !== 'phrase') {
88
+ throw new Error(`Moontide: a pinned change timing is 'section', 'bar' or 'phrase', got '${String(at)}'.`);
89
+ }
90
+ if (typeof atCycle !== 'number' || !Number.isFinite(atCycle)) {
91
+ throw new Error(`Moontide: a pinned change timing needs a finite line, got ${String(atCycle)}.`);
92
+ }
93
+ return Object.freeze({ at, lineLength: at === 'phrase' ? DEFAULT_PHRASE_CYCLES : 1, atCycle });
94
+ }
95
+
79
96
  /**
80
97
  * The cycle a timed change takes over on: the first multiple of `lineLength` strictly after the scheduler
81
98
  * frontier `frontier` (the cycle the scheduler has already queried up to), moved one line further while
@@ -111,6 +128,7 @@ export const CHANGE_COPY = Object.freeze({
111
128
  countdownOneBeat: 'in 1 beat',
112
129
  countdownTitleBar: 'A code change is waiting. It takes over on the next bar.',
113
130
  countdownTitlePhrase: 'A code change is waiting. It takes over on the next phrase.',
131
+ countdownTitleSection: 'A code change is waiting. It takes over at the end of the section.',
114
132
  // The Edits timing group of the Settings card: three buttons. The visible word of each, and its accessible name.
115
133
  timingNow: 'now',
116
134
  timingBar: 'next bar',
@@ -121,6 +139,7 @@ export const CHANGE_COPY = Object.freeze({
121
139
  timingTitle: "When your edits (Ctrl+Enter) take over: now, on the next bar or on the next phrase. Claude's changes choose their own timing.",
122
140
  queuedBar: 'Change waiting: it takes over on the next bar.',
123
141
  queuedPhrase: 'Change waiting: it takes over on the next phrase.',
142
+ queuedSection: 'Change waiting: it takes over at the end of the section.',
124
143
  canceled: 'Change canceled.',
125
144
  settingNow: 'Your edits now take over: now.',
126
145
  settingBar: 'Your edits now take over: on the next bar.',
@@ -167,11 +186,12 @@ export function countdownParts(beatsLeft) {
167
186
  return { lead, rest: CHANGE_COPY.countdownOneBeat };
168
187
  }
169
188
 
170
- /** The hover title of the countdown for a change waiting on 'bar' or 'phrase'. */
189
+ /** The hover title of the countdown for a change waiting on 'bar', 'phrase' or (a section launch) 'section'. */
171
190
  export function countdownTitle(at) {
172
191
  if (at === 'bar') return CHANGE_COPY.countdownTitleBar;
173
192
  if (at === 'phrase') return CHANGE_COPY.countdownTitlePhrase;
174
- throw new Error(`Moontide: a waiting change has the timing 'bar' or 'phrase', got '${String(at)}'.`);
193
+ if (at === 'section') return CHANGE_COPY.countdownTitleSection;
194
+ throw new Error(`Moontide: a waiting change has the timing 'bar', 'phrase' or 'section', got '${String(at)}'.`);
175
195
  }
176
196
 
177
197
  /**
@@ -188,11 +208,12 @@ export function timingOptions() {
188
208
  return CHANGE_TIMINGS.map((value) => Object.freeze({ value, label: copy[value][0], name: copy[value][1] }));
189
209
  }
190
210
 
191
- /** The spoken line for a change that just started to wait, for 'bar' or 'phrase'. */
211
+ /** The spoken line for a change that just started to wait, for 'bar', 'phrase' or 'section'. */
192
212
  export function queuedLine(at) {
193
213
  if (at === 'bar') return CHANGE_COPY.queuedBar;
194
214
  if (at === 'phrase') return CHANGE_COPY.queuedPhrase;
195
- throw new Error(`Moontide: a waiting change has the timing 'bar' or 'phrase', got '${String(at)}'.`);
215
+ if (at === 'section') return CHANGE_COPY.queuedSection;
216
+ throw new Error(`Moontide: a waiting change has the timing 'bar', 'phrase' or 'section', got '${String(at)}'.`);
196
217
  }
197
218
 
198
219
  /** The spoken line after a press of the setting button, for the value it now has. */
package/page/harmony.js CHANGED
@@ -157,6 +157,19 @@ export function readHarmony(code, { parse }) {
157
157
  return { state: 'none' };
158
158
  }
159
159
 
160
+ /**
161
+ * Reads one harmony text, as written in a section of a song or in a fixed variation: `{ kind: 'scale' | 'chords', scale }`
162
+ * with `scale` `{ rootPc, modeId }` only when the whole text is one scale token whose name is one of the nine scales (else
163
+ * null), or null when the text is neither a scale nor chords (nothing left to read, too long, a plain word). The same
164
+ * reading as a top-level declaration: readHarmony calls the same checks.
165
+ */
166
+ export function readHarmonyText(text) {
167
+ if (text.length > MAX_HARMONY_TEXT) return null;
168
+ const found = classify(text);
169
+ if (found === null || found === 'empty') return null;
170
+ return { kind: found, scale: found === 'scale' ? scaleOf(text.trim()) : null };
171
+ }
172
+
160
173
  function unreadable(reason, source = null) {
161
174
  return { state: 'unreadable', reason, source };
162
175
  }