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
@@ -0,0 +1,279 @@
1
+ // Pure module for Moontide's browser page: the numbers behind a pointer on the Timeline lanes. Distances are written in rem,
2
+ // the unit the whole page is sized in; the caller multiplies them by the root font size it reads when the press starts, so a
3
+ // larger window or a larger root makes the targets larger too. Where a note is drawn is worked out from the same shapes the
4
+ // lanes are drawn from, so the note a press picks is the note the person sees. No DOM access and no timers here, so node:test
5
+ // and page/main.js import the module without a build step.
6
+
7
+ import { PAGE_CYCLES, cycleAt } from './timeline.js';
8
+
9
+ /** A press that moves less than this is a click; a press that moves more is a drag. */
10
+ export const DRAG_THRESHOLD_REM = 0.25;
11
+
12
+ /** How far from a drawn note a press still counts as on it, in both directions. */
13
+ export const HIT_TOLERANCE_REM = 0.5;
14
+
15
+ /** The share of a lane's height a pitched note mark takes (a tick spans the whole height). */
16
+ export const NOTE_MARK_HEIGHT = 0.14;
17
+
18
+ /** How far a dragged note travels up or down for one semitone. A fixed step: it does not follow the lane's own pitch scale. */
19
+ export const SEMITONE_REM = 0.5;
20
+
21
+ /**
22
+ * Which gesture a press that has moved turns into, decided once and kept for the rest of the press: null while the pointer is
23
+ * still within `thresholdPx` of where it went down (it may yet be a click), then `'pitch'` or `'range'`. On a note the larger
24
+ * of the two travels wins (up or down is a pitch drag, sideways a range) and equal travel is a pitch drag; on empty lane area
25
+ * any travel is a range. `dx` and `dy` are the pointer's distance from the press, in pixels.
26
+ */
27
+ export function lockGesture({ dx, dy, onNote, thresholdPx }) {
28
+ if (Math.hypot(dx, dy) < thresholdPx) return null;
29
+ if (!onNote) return 'range';
30
+ return Math.abs(dy) >= Math.abs(dx) ? 'pitch' : 'range';
31
+ }
32
+
33
+ /**
34
+ * The whole cycles a range drag covers: the cycle under the press and the cycle under the pointer, in either order, both
35
+ * included. `pageStart` is the first cycle of the page drawn, `pressFraction` and `pointerFraction` how far along the lane's event
36
+ * area the press and the pointer are (0 at its left edge, 1 at its right). Gives `{ from, to, cut }`. A pointer that left the
37
+ * event area (a fraction below 0 or above 1) is held at the page's first or last cycle, and `cut` is then `{ from, to }`, the
38
+ * cycles the range ended up with; otherwise `cut` is null. A range never leaves the page, so it never covers more than
39
+ * PAGE_CYCLES cycles.
40
+ */
41
+ export function rangeCycles({ pageStart, pressFraction, pointerFraction }) {
42
+ for (const [name, value] of Object.entries({ pressFraction, pointerFraction })) {
43
+ if (typeof value !== 'number' || !Number.isFinite(value)) throw new TypeError(`rangeCycles: ${name} must be a finite number, got ${String(value)}`);
44
+ }
45
+ const pressed = cycleAt({ pageStart, fraction: pressFraction });
46
+ const pointed = cycleAt({ pageStart, fraction: pointerFraction });
47
+ const from = Math.min(pressed, pointed);
48
+ const to = Math.max(pressed, pointed);
49
+ const left = pointerFraction < 0 || pointerFraction > 1;
50
+ return { from, to, cut: left ? { from, to } : null };
51
+ }
52
+
53
+ /**
54
+ * The whole semitones a pointer that moved `dy` pixels down (negative: up) stands for, `stepPx` pixels to a semitone. Up is
55
+ * higher, a part of a step counts for nothing, and no travel is 0, never negative zero.
56
+ */
57
+ export function semitonesFrom({ dy, stepPx }) {
58
+ const semitones = Math.trunc(-dy / stepPx);
59
+ return semitones === 0 ? 0 : semitones;
60
+ }
61
+
62
+ /**
63
+ * One pixel box `{ left, top, right, bottom }` for each drawn note of a lane, in the order of `shapes`, and null for a tick
64
+ * (it has no pitch and is not picked). `rect` is the lane's event area in the page (`left`, `top`, `width`, `height`),
65
+ * `shapes` what the lane was drawn from (`{ kind, x, width, y }` for a normal lane, `{ x, width, row }` for a drum lane),
66
+ * `rowCount` the number of drum rows of a drum lane or null, `markHeight` the share of the lane height a mark takes.
67
+ */
68
+ export function noteBoxes({ rect, shapes, rowCount, markHeight }) {
69
+ const perCycle = rect.width / PAGE_CYCLES;
70
+ return shapes.map((shape) => {
71
+ if (rowCount === null && shape.kind !== 'note') return null;
72
+ const left = rect.left + shape.x * perCycle;
73
+ let top;
74
+ let height;
75
+ if (rowCount === null) {
76
+ const y = Math.max(0, Math.min(1 - markHeight, shape.y - markHeight / 2));
77
+ top = rect.top + y * rect.height;
78
+ height = markHeight * rect.height;
79
+ } else {
80
+ top = rect.top + ((shape.row + 0.25) / rowCount) * rect.height;
81
+ height = (0.5 / rowCount) * rect.height;
82
+ }
83
+ return { left, top, right: left + shape.width * perCycle, bottom: top + height };
84
+ });
85
+ }
86
+
87
+ /** How far `at` is outside the span from `low` to `high` (0 inside it). */
88
+ function outside(at, low, high) {
89
+ return Math.max(low - at, 0, at - high);
90
+ }
91
+
92
+ /**
93
+ * The index of the drawn note a press at (`x`, `y`) is on, or null on empty lane area. A note counts when the press is within
94
+ * `tolerancePx` of its box in both directions; of several the nearest wins, and equal distances go to the lower pitch
95
+ * (`pitches[i]` is the pitch of box i, null boxes are skipped).
96
+ */
97
+ export function pickNote({ boxes, pitches, x, y, tolerancePx }) {
98
+ let best = null;
99
+ let bestDistance = Infinity;
100
+ for (let i = 0; i < boxes.length; i++) {
101
+ const box = boxes[i];
102
+ if (box === null) continue;
103
+ const dx = outside(x, box.left, box.right);
104
+ const dy = outside(y, box.top, box.bottom);
105
+ if (dx > tolerancePx || dy > tolerancePx) continue;
106
+ const distance = Math.hypot(dx, dy);
107
+ const nearer = distance < bestDistance - 1e-9;
108
+ const tied = Math.abs(distance - bestDistance) <= 1e-9 && best !== null && pitches[i] < pitches[best];
109
+ if (nearer || tied) {
110
+ best = i;
111
+ bestDistance = Math.min(distance, bestDistance);
112
+ }
113
+ }
114
+ return best;
115
+ }
116
+
117
+ /** How long the steps of a key or a button gather before the page writes them once, in milliseconds. */
118
+ export const STEP_GATHER_MS = 600;
119
+
120
+ function defaultStepError(error) {
121
+ console.error('Moontide: the steps of a Timeline note could not be written:', error);
122
+ }
123
+
124
+ /**
125
+ * Gathers the steps of one kind (pitch, velocity) into one write. `step(kind, amount)` adds `amount` (a whole number, negative
126
+ * for down) to the pending total of `kind` and starts the wait again; `delayMs` after the last step `onFlush(kind, total)`
127
+ * runs once. A step of another kind writes the pending one first and starts a burst of its own, so a burst only ever holds
128
+ * one kind and each kind is its own write. A total of 0 writes nothing. `drop()` forgets the pending steps without writing,
129
+ * `flush()` writes them now, `pending()` gives `{ kind, total }` or null. Once handed to `onFlush` a burst belongs to the
130
+ * caller: the gatherer never takes it back, and `onFlush` must not call `step`, `flush` or `drop` (that throws, and the
131
+ * error is reported through `onError`). Like the Mixer's recorder it has no timers of its own: `setTimer(fn, ms)` gives a
132
+ * handle for `clearTimer(handle)`, so node:test drives it with a fake clock. `onError(error)` hears an `onFlush` that threw,
133
+ * and the gatherer keeps working.
134
+ */
135
+ export function createStepGatherer({ setTimer, clearTimer, delayMs = STEP_GATHER_MS, onFlush, onError = defaultStepError }) {
136
+ for (const [name, fn] of Object.entries({ setTimer, clearTimer, onFlush })) {
137
+ if (typeof fn !== 'function') throw new Error(`Moontide: the step gatherer needs ${name}, a function.`);
138
+ }
139
+ let open = null;
140
+ let timer = null;
141
+ // True while a burst is being handed to onFlush: the gatherer is not entered again from there.
142
+ let flushing = false;
143
+
144
+ function stopTimer() {
145
+ if (timer !== null) clearTimer(timer);
146
+ timer = null;
147
+ }
148
+
149
+ function notInFlush(name) {
150
+ if (flushing) throw new Error(`Moontide: the step gatherer's ${name} was called from inside its own flush.`);
151
+ }
152
+
153
+ function run(kind, total) {
154
+ if (total === 0) return;
155
+ flushing = true;
156
+ try {
157
+ onFlush(kind, total);
158
+ } catch (error) {
159
+ onError(error);
160
+ } finally {
161
+ flushing = false;
162
+ }
163
+ }
164
+
165
+ function flush() {
166
+ notInFlush('flush');
167
+ stopTimer();
168
+ const gathered = open;
169
+ open = null;
170
+ if (gathered !== null) run(gathered.kind, gathered.total);
171
+ }
172
+
173
+ return {
174
+ step(kind, amount) {
175
+ notInFlush('step');
176
+ if (typeof kind !== 'string' || kind === '') throw new Error('Moontide: a step has the kind it belongs to.');
177
+ if (!Number.isInteger(amount) || amount === 0) throw new Error(`Moontide: a step is a whole number other than 0, got ${String(amount)}.`);
178
+ if (open !== null && open.kind !== kind) flush();
179
+ if (open === null) open = { kind, total: 0 };
180
+ open.total += amount;
181
+ stopTimer();
182
+ timer = setTimer(() => {
183
+ timer = null;
184
+ flush();
185
+ }, delayMs);
186
+ },
187
+ drop() {
188
+ notInFlush('drop');
189
+ stopTimer();
190
+ open = null;
191
+ },
192
+ flush,
193
+ pending() {
194
+ return open === null ? null : { kind: open.kind, total: open.total };
195
+ },
196
+ };
197
+ }
198
+
199
+ /** The keys that move along a lane or across lanes. */
200
+ export const LANE_KEYS = Object.freeze(['ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown']);
201
+
202
+ /** The cycle a start time falls in (a start just under a whole cycle counts as that cycle, like the lanes' own cycle numbers). */
203
+ function cycleOfStart(begin) {
204
+ return Math.floor(begin + 1e-9);
205
+ }
206
+
207
+ /**
208
+ * The notes of one lane in the order the keys walk them: by start, then pitch (lower first), then by position. `notes` has one
209
+ * entry for each drawn event, `{ begin, pitch }`, or null for an event the keys skip (a tick has no pitch and no mark to pick).
210
+ * Gives the positions in `notes`.
211
+ */
212
+ function walkOrder(notes) {
213
+ const order = [];
214
+ notes.forEach((entry, index) => {
215
+ if (entry !== null) order.push(index);
216
+ });
217
+ order.sort((a, b) => notes[a].begin - notes[b].begin || (notes[a].pitch ?? -1) - (notes[b].pitch ?? -1) || a - b);
218
+ return order;
219
+ }
220
+
221
+ /**
222
+ * Where a key of the keyboard model takes the person on the Timeline's lanes. `lanes` holds the drawn lanes, each the list of its
223
+ * events as `{ begin, pitch }` (null for an event the keys skip); `at` is the current place or null: `{ laneIndex, eventIndex }`
224
+ * on a note, `{ laneIndex, eventIndex: null, cycle }` on the cursor of a lane that has no notes on the page. `key` is one of
225
+ * LANE_KEYS; any other key throws. The answer is `{ laneIndex, eventIndex, cycle }` (`eventIndex` null for the cursor, `cycle`
226
+ * the whole cycle of the note or the cursor), the same place when the key goes nowhere (the first or last note, the first or
227
+ * last lane), or null when the key does not apply: nothing to start from and no note anywhere, or a key that needs a place and
228
+ * has none (Home, End, Page Up and Page Down with nothing selected, so they scroll as they always did).
229
+ *
230
+ * Left and Right walk the lane by start and then pitch, and stop at its first and last note (the page does not turn). With no
231
+ * place Right takes the first note of the first lane that has one and Left the last note of that lane. Home and End take the
232
+ * first and the last note of the lane. Page Up and Page Down take the lane before and after, keeping the cycle: the note whose
233
+ * start is nearest in time (the earlier one when two are as near, then the lower pitch), or the cursor when that lane has no
234
+ * notes on the page.
235
+ */
236
+ export function laneMove({ lanes, at, key }) {
237
+ if (!Array.isArray(lanes)) throw new TypeError('laneMove: lanes must be an array of lanes.');
238
+ if (!LANE_KEYS.includes(key)) throw new Error(`laneMove: unknown key '${String(key)}'.`);
239
+ const note = (laneIndex, eventIndex) => ({ laneIndex, eventIndex, cycle: cycleOfStart(lanes[laneIndex][eventIndex].begin) });
240
+ if (at === null || at === undefined) {
241
+ if (key !== 'ArrowLeft' && key !== 'ArrowRight') return null;
242
+ for (let laneIndex = 0; laneIndex < lanes.length; laneIndex++) {
243
+ const order = walkOrder(lanes[laneIndex]);
244
+ if (order.length > 0) return note(laneIndex, key === 'ArrowRight' ? order[0] : order[order.length - 1]);
245
+ }
246
+ return null;
247
+ }
248
+ const { laneIndex, eventIndex } = at;
249
+ if (!Number.isInteger(laneIndex) || laneIndex < 0 || laneIndex >= lanes.length) throw new RangeError(`laneMove: there is no lane ${String(laneIndex)}.`);
250
+ if (eventIndex === null) {
251
+ if (!Number.isInteger(at.cycle)) throw new TypeError('laneMove: the cursor of a lane without notes needs its cycle.');
252
+ } else if (lanes[laneIndex][eventIndex] === undefined || lanes[laneIndex][eventIndex] === null) {
253
+ throw new RangeError(`laneMove: lane ${laneIndex} has no note ${String(eventIndex)} to walk.`);
254
+ }
255
+ const here = eventIndex === null ? { laneIndex, eventIndex: null, cycle: at.cycle } : note(laneIndex, eventIndex);
256
+ if (key === 'PageUp' || key === 'PageDown') {
257
+ const target = Math.max(0, Math.min(lanes.length - 1, laneIndex + (key === 'PageDown' ? 1 : -1)));
258
+ if (target === laneIndex) return here;
259
+ const time = eventIndex === null ? at.cycle : lanes[laneIndex][eventIndex].begin;
260
+ let nearest = null;
261
+ let nearestGap = Infinity;
262
+ for (const index of walkOrder(lanes[target])) {
263
+ const gap = Math.abs(lanes[target][index].begin - time);
264
+ if (gap < nearestGap - 1e-9) {
265
+ nearest = index;
266
+ nearestGap = gap;
267
+ }
268
+ }
269
+ return nearest === null ? { laneIndex: target, eventIndex: null, cycle: eventIndex === null ? at.cycle : cycleOfStart(time) } : note(target, nearest);
270
+ }
271
+ // The cursor of a lane without notes has no note to walk along.
272
+ if (eventIndex === null) return here;
273
+ const order = walkOrder(lanes[laneIndex]);
274
+ const position = order.indexOf(eventIndex);
275
+ if (key === 'Home') return note(laneIndex, order[0]);
276
+ if (key === 'End') return note(laneIndex, order[order.length - 1]);
277
+ const next = Math.max(0, Math.min(order.length - 1, position + (key === 'ArrowRight' ? 1 : -1)));
278
+ return note(laneIndex, order[next]);
279
+ }
package/page/timeline.js CHANGED
@@ -33,6 +33,137 @@ export const TIMELINE_COPY = {
33
33
  drumNumbered: 'note {note}',
34
34
  drumMapUnknown: ' · drum map unknown',
35
35
  drumRowsMax: 8,
36
+ // The words of the note panel under the lanes: what it says about the selected note, what a change did and why a
37
+ // note cannot be edited. page/note-panel.js builds every sentence from these strings and nowhere else.
38
+ noteNone: 'No note selected',
39
+ noteEditable: 'Editable',
40
+ noteNotEditable: 'Not editable',
41
+ notePosition: ' · cycle {cycle}, beat {beat}',
42
+ noteSection: " · section '{section}'",
43
+ notePitchLabel: 'Pitch',
44
+ noteVelocityLabel: 'Velocity',
45
+ notePitchValue: '{note} · {midi}',
46
+ notePitchDrum: '{value} · {drum}',
47
+ noteVelocityUnknown: 'unknown',
48
+ notePitchDown: 'Pitch down',
49
+ notePitchDownName: 'Lower pitch by a semitone',
50
+ notePitchDownTitle: 'Lower pitch by a semitone (Down). Shift: an octave.',
51
+ notePitchUp: 'Pitch up',
52
+ notePitchUpName: 'Raise pitch by a semitone',
53
+ notePitchUpTitle: 'Raise pitch by a semitone (Up). Shift: an octave.',
54
+ noteVelocityDown: '−',
55
+ noteVelocityDownName: 'Lower velocity',
56
+ noteVelocityDownTitle: 'Lower velocity by 0.10',
57
+ noteVelocityUp: '+',
58
+ noteVelocityUpName: 'Raise velocity',
59
+ noteVelocityUpTitle: 'Raise velocity by 0.10',
60
+ noteVelocityFixed: 'fixed in the code',
61
+ noteMakeRest: 'Make a rest',
62
+ noteMakeRestName: 'Turn this note into a rest',
63
+ noteMakeRestTitle: 'Turn this note into a rest (Delete)',
64
+ noteAsk: 'Ask Claude',
65
+ noteAskName: 'Ask Claude about this note',
66
+ noteAskTitle: 'Opens the Ask box for this note.',
67
+ // What a gesture on a note the page cannot edit does: the sentence while it is dragged, and the one after it is released.
68
+ noteRefusedPreview: 'Release to ask Claude: {verb} this note by {amount}.',
69
+ noteRaise: 'raise',
70
+ noteLower: 'lower',
71
+ noteRequestReady: 'Request ready in the Ask box. Nothing is sent until you press Enter.',
72
+ noteHintNone: 'Click a note to edit it. Drag across an empty part of a lane to ask Claude about those cycles.',
73
+ noteHintEditable: 'Drag the note or press Up / Down: a semitone, with Shift an octave. Delete makes a rest. Each edit is one History entry.',
74
+ noteHintRefused: 'Up, Down, Make a rest, − and + prepare a request for Claude. Nothing is sent until you press Enter.',
75
+ noteHintRange: 'Press Enter or Ask Claude to ask about these cycles. Esc clears the range.',
76
+ // A range of cycles on one lane: the panel's identity line, built by page/note-panel.js, and what the Ask Claude button says then.
77
+ noteRangeWhere: 'Range · {lane}',
78
+ noteRangeCycles: ' · cycles {a} to {b}',
79
+ noteRangeCycle: ' · cycle {a}',
80
+ noteRangeSections: " · sections '{first}' to '{last}'",
81
+ noteRangeNotes: ' · {n} notes',
82
+ noteRangeOneNote: ' · 1 note',
83
+ noteRangeNoNotes: ' · no notes',
84
+ noteRangeAskName: 'Ask Claude about these cycles',
85
+ noteRangeAskTitle: 'Opens the Ask box for these cycles.',
86
+ // The keyboard cursor on a lane that has no note on the page: the panel's identity line, its hint and what Ask Claude says then.
87
+ noteCursorPosition: ' · no notes on this page',
88
+ noteHintCursor: 'Press Enter or Ask Claude to ask about this lane. Shift+Left or Shift+Right selects cycles.',
89
+ noteLaneAskName: 'Ask Claude about this lane',
90
+ noteLaneAskTitle: 'Opens the Ask box for this lane.',
91
+ // The name a screen reader gives the active note of the lanes (the lanes point at it with aria-activedescendant).
92
+ noteActiveName: '{lane}, cycle {cycle}, beat {beat}: note {note}, velocity {velocity}. {state}',
93
+ noteActiveEditable: 'Editable.',
94
+ noteActiveNotEditable: 'Not editable. {reason}',
95
+ noteActiveCursor: '{lane}, cycle {cycle}: no notes on this page.',
96
+ noteStanding: 'An edit rewrites one token in the code: {token}.',
97
+ // Where a token plays: the standing sentences, and the count the preview and the result repeat.
98
+ notePlaces: 'Plays in {n} places: sections {list}. An edit changes all of them.',
99
+ notePlacesOnPage:
100
+ 'Plays in {n} places on this page: sections {list}. An edit changes every place it plays, on this page and after it.',
101
+ notePlacesPerCycle: 'This token draws {n} notes in each cycle. An edit changes all of them.',
102
+ notePlacesWords: ' in {n} places',
103
+ notePlacesPageWords: ' in {n} places on this page',
104
+ noteVelocityNotes: ', for {n} notes on this page',
105
+ noteVelocityNotesShort: ', for {n} notes',
106
+ noteSectionName: "'{name}'",
107
+ noteListAnd: '{first} and {last}',
108
+ noteListMore: '{first} and {n} more',
109
+ // The steps of a key or a button that were not written because the note they were aimed at is gone.
110
+ noteStepsDropped: 'The steps you pressed were not written: the note changed in the code. Select it again.',
111
+ noteStepsNotWritten: 'The steps you pressed were not written. Select the note and press them again.',
112
+ noteChangeDropped: 'The change was not written: the note changed in the code. Select it again.',
113
+ noteChangeNotWritten: 'The change you made was not written. Select the note and make it again.',
114
+ noteEditTimeout: 'The page did not answer this edit in time, so check the code.',
115
+ noteNamed: '{note} on {lane}: {text}',
116
+ noteChanged: 'Changed {from} to {to}{places}.',
117
+ noteChangedRest: 'Changed {from} to a rest{places}.',
118
+ noteVelocityNow: 'Velocity of {note} is now {v}{places}{notes}.',
119
+ noteWaitingBar: 'Waiting for the next bar — {from} becomes {to}.',
120
+ noteWaitingPhrase: 'Waiting for the next phrase — {from} becomes {to}.',
121
+ noteWaitingBarRest: 'Waiting for the next bar — {from} becomes a rest.',
122
+ noteWaitingPhraseRest: 'Waiting for the next phrase — {from} becomes a rest.',
123
+ noteWaitingBarVelocity: 'Waiting for the next bar — {note} velocity becomes {v}.',
124
+ noteWaitingPhraseVelocity: 'Waiting for the next phrase — {note} velocity becomes {v}.',
125
+ // The sentence while a note is dragged, and the size of the change in it.
126
+ notePreview: '{from} → {to} ({delta}){places}. Release to write.',
127
+ noteDeltaSemitone: '{sign}1 semitone',
128
+ noteDeltaSemitones: '{sign}{n} semitones',
129
+ noteDeltaOctave: '{sign}1 octave',
130
+ noteDeltaOctaves: '{sign}{n} octaves',
131
+ // The sentence while steps of a key or a button wait for the pause before they are written.
132
+ noteStepPreview: '{from} → {to} ({delta}){places}. Writing in a moment.',
133
+ noteVelocityPreview: 'Velocity {from} → {to}{places}{notes}. Writing in a moment.',
134
+ noteLowest: 'Already the lowest note.',
135
+ noteHighest: 'Already the highest note.',
136
+ noteVelocityLowest: 'Velocity is already at the lowest (0.10).',
137
+ noteVelocityHighest: 'Velocity is already at the highest (1.00).',
138
+ noteVelocityRefusal:
139
+ "The velocity of this note comes from something the page can't change here, like a signal or an option of .midi(). Ask Claude to change it.",
140
+ noteStale: 'That note changed in the code since you selected it. Nothing was changed. Select it again, or ask Claude.',
141
+ noteReadBack: "The page couldn't confirm this edit, so the code is as it was. Ask Claude to make it.",
142
+ noteEditError: 'The page ran into a problem before it changed this note, so the code is as it was. Ask Claude to make the change.',
143
+ noteEditErrorWritten: 'The page ran into a problem after it wrote this change, so check the code.',
144
+ noteNotEvaluated: 'The change did not evaluate, so the code is as it was. Ask Claude to make it.',
145
+ noteNoticeDisconnected: 'Disconnected — editing is off until Moontide reconnects.',
146
+ noteNoticeWaiting: 'A change is waiting for its bar line. Editing works again when it takes over.',
147
+ noteNoticeUnevaluated: 'The editor has changes that are not playing yet. Evaluate them first — editing works on the playing code.',
148
+ // The history entry of one written edit.
149
+ noteRecord: 'changed {track}: {from} to {to}{places}',
150
+ noteRecordRest: 'changed {track}: {from} to a rest{places}',
151
+ noteRecordVelocity: 'changed {track}: velocity of {note} to {v}{places}',
152
+ // Why a note cannot be edited, one sentence for each reason page/note-locate.js gives (NOTE_REASONS).
153
+ noteReasons: {
154
+ 'no-token':
155
+ 'Made by a function or by chance, or written in a single-quoted string, so no single place in the code holds this note. Ask Claude to change it.',
156
+ 'no-pitch': 'This is a sample, not a pitched note, so there is no pitch to change. Ask Claude to change it.',
157
+ several:
158
+ 'Shaped by several places in the code (an added note or a factor like *2), so no one place can be edited. Ask Claude to change it.',
159
+ degrees: "Written in degrees. The page doesn't edit those directly yet. Ask Claude to change it.",
160
+ escape:
161
+ "The string that holds this note has an escape sequence, so the page can't find the note in the code. Ask Claude to change it.",
162
+ shifted: 'The pitch you hear is not the pitch written: something adds to it or copies it. Ask Claude to change it.',
163
+ outside:
164
+ "Written outside this track's own code (its statement, or its variations in a song), so the page doesn't edit it here. Ask Claude to change it.",
165
+ shared: 'Comes from code shared by several tracks, so an edit here would change them too. Ask Claude to change it.',
166
+ },
36
167
  };
37
168
 
38
169
  /** Cycle count from which the next page starts computing in the background, while the current
@@ -85,6 +216,13 @@ export function rulerMarks(pageStart) {
85
216
  export const TRACK_COLOR_COUNT = 8;
86
217
 
87
218
  const NOTE_BASE_SEMITONE = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
219
+
220
+ /**
221
+ * The spelling of a note name that Strudel itself plays: a letter in either case, lower-case accidentals (`#`, `s`, `b`, `f`) and an
222
+ * optional octave. noteNameToMidi reads a capital accidental too (`EB3`), which Strudel does not play, so a token is edited only when it
223
+ * matches this: the spelling the page edits and the one the sound engine plays are the same.
224
+ */
225
+ export const NOTE_NAME_TOKEN = /^([a-gA-G])([#sbf]*)(-?\d+)?$/;
88
226
  const DEFAULT_NOTE_OCTAVE = 3;
89
227
 
90
228
  /**
@@ -93,7 +231,7 @@ const DEFAULT_NOTE_OCTAVE = 3;
93
231
  * (default 3, matching Strudel's own default). `null` for anything that doesn't parse as a
94
232
  * plain note name (a mini-notation pattern string, for example).
95
233
  */
96
- function noteNameToMidi(note) {
234
+ export function noteNameToMidi(note) {
97
235
  const match = /^([a-g])((?:[#sbf])*)(-?\d+)?$/.exec(note.toLowerCase());
98
236
  if (!match) return null;
99
237
  const [, letter, accidentals, octaveText] = match;
@@ -340,6 +478,19 @@ function toEvent(hap) {
340
478
  return { begin: hap.whole.begin.valueOf(), end: hap.whole.end.valueOf(), value: hap.value };
341
479
  }
342
480
 
481
+ /**
482
+ * One event of a lane: the same fields as the real pattern's events, plus `locations`, plain copies of the places in the
483
+ * code the note was read from, `[{ start, end }]` offsets into the code that was evaluated, or null when the note has none
484
+ * (a value made by a function, a hap without a context such as a test double). The real pattern's events keep no
485
+ * locations, so the check that the lanes add up to the real pattern never looks at them. Exported so the page reads the
486
+ * edited track back into the same shape.
487
+ */
488
+ export function toLaneEvent(hap) {
489
+ const raw = hap.context && Array.isArray(hap.context.locations) ? hap.context.locations : null;
490
+ const locations = raw === null ? null : raw.map((location) => ({ start: location.start, end: location.end }));
491
+ return { begin: hap.whole.begin.valueOf(), end: hap.whole.end.valueOf(), value: hap.value, locations };
492
+ }
493
+
343
494
  /**
344
495
  * Queries the real pattern and every unmuted track's own solo pattern over `pageCycles`
345
496
  * cycles starting at `pageStart`, in slices of `sliceCycles` with a yield to the page between
@@ -385,7 +536,7 @@ export async function computeTimeline({
385
536
  const pattern = trackPatterns[index];
386
537
  if (!pattern) return;
387
538
  for (const hap of pattern.queryArc(from, to)) {
388
- if (hap.whole && hap.hasOnset()) laneEvents[index].push(toEvent(hap));
539
+ if (hap.whole && hap.hasOnset()) laneEvents[index].push(toLaneEvent(hap));
389
540
  }
390
541
  });
391
542
 
@@ -423,6 +574,12 @@ export const SIXTEENTHS_PER_CYCLE = BEATS_PER_CYCLE * 4;
423
574
  */
424
575
  export const MAX_MOMENT_NOTES = 32;
425
576
 
577
+ /**
578
+ * The most cycles one range covers: the cycles of a page. Mirrors src/protocol.ts's MAX_RANGE_CYCLES by hand
579
+ * (test/ask.test.js checks both are 32).
580
+ */
581
+ export const MAX_RANGE_CYCLES = 32;
582
+
426
583
  // Float noise allowed when an event edge is compared with the instant, in cycles.
427
584
  const MOMENT_EPSILON = 1e-9;
428
585
 
@@ -450,7 +607,7 @@ function round3(value) {
450
607
  * the event and is not seen here: such a note reads the default 0.9 (the reference documents
451
608
  * `.velocity()` on the pattern).
452
609
  */
453
- function velocityOf(value, midi) {
610
+ export function velocityOf(value, midi) {
454
611
  const defaults = midi ? MIDI_LEVEL : BROWSER_LEVEL;
455
612
  const set = value && typeof value === 'object' ? value : {};
456
613
  const gain = set.gain === undefined ? defaults.gain : set.gain;
@@ -461,6 +618,15 @@ function velocityOf(value, midi) {
461
618
  return product < 0 ? undefined : Math.round(product * 100) / 100;
462
619
  }
463
620
 
621
+ /**
622
+ * The gain of one event as the pinned bundle would apply it: the gain set on the event, else the default of a browser track
623
+ * (0.8) or a MIDI track (1). It may not be a number (a signal), which the caller must check.
624
+ */
625
+ export function gainOf(value, midi) {
626
+ const set = value && typeof value === 'object' ? value : {};
627
+ return set.gain === undefined ? (midi ? MIDI_LEVEL : BROWSER_LEVEL).gain : set.gain;
628
+ }
629
+
464
630
  function momentNote(lane, event, state) {
465
631
  const value = event.value && typeof event.value === 'object' ? event.value : {};
466
632
  const note = { track: lane.track };
@@ -559,3 +725,30 @@ export function momentAt({ pageStart, fraction, lanes, version, focusTrack }) {
559
725
  more: Math.max(0, notes.length - MAX_MOMENT_NOTES),
560
726
  };
561
727
  }
728
+
729
+ /**
730
+ * The notes of one lane that start in the whole cycles `from` to `to` (both included): `{ notes, more }`. A note counts when its
731
+ * start is at least `from` and before `to + 1`, so one that started earlier and still sounds is not in it, and one that starts
732
+ * exactly at `to + 1` is not either. `lane` is `{ track, midi, instrument, drumMap, events }` as `momentAt` takes it. Each
733
+ * note is shaped like a moment note (the same fields, built by the same code) without its state, in order of start and then of
734
+ * pitch, the lower first; at most MAX_MOMENT_NOTES are kept and `more` counts the rest. Bad input is a loud TypeError.
735
+ */
736
+ export function rangeNotes(lane, from, to) {
737
+ if (lane === null || typeof lane !== 'object' || !Array.isArray(lane.events)) {
738
+ throw new TypeError(`rangeNotes: lane must hold an events array, got ${String(lane)}`);
739
+ }
740
+ if (!Number.isInteger(from) || from < 0) throw new TypeError(`rangeNotes: from must be a non-negative integer, got ${String(from)}`);
741
+ if (!Number.isInteger(to) || to < from || to - from >= MAX_RANGE_CYCLES) {
742
+ throw new TypeError(`rangeNotes: to must be an integer from ${from} to ${from + MAX_RANGE_CYCLES - 1}, got ${String(to)}`);
743
+ }
744
+ const inside = lane.events.filter((event) => event.begin >= from - MOMENT_EPSILON && event.begin < to + 1 - MOMENT_EPSILON);
745
+ const keyed = inside.map((event) => ({ event, pitch: pitchOf(event.value) }));
746
+ // A note without a pitch (a sample, a tick) sorts after the pitched notes that start with it.
747
+ keyed.sort((a, b) => a.event.begin - b.event.begin || (a.pitch ?? Infinity) - (b.pitch ?? Infinity) || 0);
748
+ const notes = keyed.slice(0, MAX_MOMENT_NOTES).map(({ event }) => {
749
+ const note = momentNote(lane, event, 'sounding');
750
+ delete note.state;
751
+ return note;
752
+ });
753
+ return { notes, more: Math.max(0, keyed.length - MAX_MOMENT_NOTES) };
754
+ }
package/page/tracks.js CHANGED
@@ -364,6 +364,17 @@ export function parseLabel(label) {
364
364
  return { mutedByLabel, core, solo, soloInert: solo === 'marker' && mutedByLabel, base, safe };
365
365
  }
366
366
 
367
+ /**
368
+ * The identity of a labeled track that has no `$` in its label: `label:` and the label without
369
+ * its mute underscore and solo marker, or the raw label when the mixer cannot read it. The
370
+ * mixer and the note tag both use this one rule, so a note is always reported under the same
371
+ * key as the strip it belongs to.
372
+ */
373
+ export function labelKey(label) {
374
+ const parsed = parseLabel(label);
375
+ return `label:${parsed.safe ? parsed.base : label}`;
376
+ }
377
+
367
378
  /**
368
379
  * Every labeled statement in the code, in source order (after the last-statement-wins dedup
369
380
  * above). A track is `{ index, kind: 'label', label, name, identity, muted, start, end, ... }`:
@@ -407,7 +418,7 @@ function buildLabelTracks(statements, topLevelDecls) {
407
418
  kind: 'label',
408
419
  label,
409
420
  name: isDollar ? displayNameFromSound(expression) : label,
410
- identity: isDollar ? structuralIdentity(expression) : `label:${baseLabel}`,
421
+ identity: isDollar ? structuralIdentity(expression) : labelKey(label),
411
422
  baseLabel,
412
423
  hushed,
413
424
  ownMuted,
@@ -650,13 +661,10 @@ export function soloSource(code, trackIndex, { parse }) {
650
661
  const edits = [];
651
662
  collectStringEdits(ast, edits);
652
663
 
653
- const declarationTexts = [];
654
664
  const allCalls = [];
655
665
  let lastEachCall = null;
656
666
  for (const statement of ast.body) {
657
- if (statement.type === 'VariableDeclaration' || statement.type === 'FunctionDeclaration' || statement.type === 'ClassDeclaration') {
658
- declarationTexts.push(applyEdits(code, edits, statement.start, statement.end));
659
- } else if (topLevelCallNamed(statement, 'all')) {
667
+ if (topLevelCallNamed(statement, 'all')) {
660
668
  allCalls.push(statement.expression);
661
669
  } else if (topLevelCallNamed(statement, 'each')) {
662
670
  lastEachCall = statement.expression;
@@ -701,10 +709,31 @@ export function soloSource(code, trackIndex, { parse }) {
701
709
  wrapped = wrapEachAll(applyEdits(code, [...keptEdits, ...silenceEdits], programExpr.start, programExpr.end));
702
710
  }
703
711
 
712
+ return `${preludeOf(code, ast, edits)}return (${wrapped});`;
713
+ }
714
+
715
+ /** True for a top-level statement that declares a name: a variable, a function or a class. */
716
+ function isDeclaration(statement) {
717
+ return statement.type === 'VariableDeclaration' || statement.type === 'FunctionDeclaration' || statement.type === 'ClassDeclaration';
718
+ }
719
+
720
+ /** The prelude text for an already parsed program and its string edits. */
721
+ function preludeOf(code, ast, edits) {
704
722
  let body = '"use strict";\n';
705
- for (const declarationText of declarationTexts) {
706
- body += declarationText + '\n';
723
+ for (const statement of ast.body) {
724
+ if (isDeclaration(statement)) body += applyEdits(code, edits, statement.start, statement.end) + '\n';
707
725
  }
708
- body += `return (${wrapped});`;
709
726
  return body;
710
727
  }
728
+
729
+ /**
730
+ * The text every solo program starts with, and the declared-against-played check starts with too: "use strict"; and every
731
+ * top-level declaration in source order, each followed by a newline, with the same string rewrites soloSource applies.
732
+ * Evaluated, it defines the code's own constants and helper functions and nothing else runs.
733
+ */
734
+ export function soloPrelude(code, { parse }) {
735
+ const ast = parse(code, PARSE_OPTIONS);
736
+ const edits = [];
737
+ collectStringEdits(ast, edits);
738
+ return preludeOf(code, ast, edits);
739
+ }
package/page/ui-state.js CHANGED
@@ -61,8 +61,11 @@ export const VCSL_MANIFEST_COMMIT = '9eacfc86ec4393e68a463ff52b01c19cfaa77f38';
61
61
  export const VCSL_MANIFEST_URL = 'https://raw.githubusercontent.com/felixroos/dough-samples/' + VCSL_MANIFEST_COMMIT + '/vcsl.json';
62
62
  export const VCSL_SAMPLE_BASE = 'https://cdn.jsdelivr.net/gh/sgossner/VCSL@' + VCSL_COMMIT + '/';
63
63
 
64
+ /** What every sound-load failure text ends with; the reply adds the final period. */
65
+ const REPLACED_SOUND_DUTY = '; if you replace it, tell the user which one and what replaced it, never silently';
66
+
64
67
  /** How many candidate sound names soundLoadFailureText lists before "and N more". */
65
- export const MAX_NAMED_SOUNDS = 8;
68
+ export const MAX_NAMED_SOUNDS = 7;
66
69
 
67
70
  /**
68
71
  * How long the page waits for AudioContext.resume() to reach 'running' after
@@ -219,20 +222,22 @@ export function isFetchFailure(text) {
219
222
  * The page names a sound only when it is the single candidate — a wrong name misled
220
223
  * Claude in an earlier user test. With several it lists them, sorted and de-duplicated so the text is
221
224
  * stable for any arrival order, capped at MAX_NAMED_SOUNDS so the server's 500 code point
222
- * cleanErrorDetail cap never cuts a name in half. With none, it says the page could not tell.
225
+ * cleanErrorDetail cap never cuts a name in half. With none, it says the page could not tell. Every variant ends
226
+ * with the duty to say which sound was replaced and by what: it lives here, where Claude reads about the failure,
227
+ * and not in the server instructions, which have a fixed length.
223
228
  */
224
229
  export function soundLoadFailureText(detail, names) {
225
230
  const distinct = [...new Set(names)].sort();
226
231
  if (distinct.length === 1) {
227
- return `sound "${distinct[0]}" failed to load (${detail}); it plays nothing until the page is reloaded`;
232
+ return `sound "${distinct[0]}" failed to load (${detail}); it plays nothing until the page is reloaded${REPLACED_SOUND_DUTY}`;
228
233
  }
229
234
  if (distinct.length === 0) {
230
- return `a sound failed to load (${detail}), and the page could not tell which one`;
235
+ return `a sound failed to load (${detail}), and the page could not tell which one${REPLACED_SOUND_DUTY}`;
231
236
  }
232
237
  const shown = distinct.slice(0, MAX_NAMED_SOUNDS);
233
238
  const rest = distinct.length - shown.length;
234
239
  const list = rest > 0 ? `${shown.join(', ')} and ${rest} more` : shown.join(', ');
235
- return `one of these sounds failed to load (${detail}): ${list}; the one that failed plays nothing until the page is reloaded`;
240
+ return `one of these sounds failed to load (${detail}): ${list}; the one that failed plays nothing until the page is reloaded${REPLACED_SOUND_DUTY}`;
236
241
  }
237
242
 
238
243
  /**