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,381 @@
1
+ // Pure module for Moontide's browser page: the words of the note panel under the Timeline lanes. Every sentence is built
2
+ // from TIMELINE_COPY (page/timeline.js), so the panel, the history entry and the tests read the same text. Names that come
3
+ // from the code (a lane, a section, a token) are cut to 40 code points in a sentence. An id this module does not know
4
+ // (a reason a note cannot be edited, a refusal, a notice, a hint) throws, so a new one cannot be shown as a blank.
5
+ // Nothing here touches the DOM, so node:test and page/main.js import the module without a build step.
6
+
7
+ import { TIMELINE_COPY } from './timeline.js';
8
+ import { displayName } from './structure.js';
9
+ import { clipMixerLabel } from './mixer-model.js';
10
+
11
+ /** Fills the `{name}` places of a TIMELINE_COPY string; a place without a value throws (never a blank). */
12
+ function fill(template, values) {
13
+ return template.replace(/\{(\w+)\}/g, (_, key) => {
14
+ if (!(key in values)) throw new Error(`Moontide: the note panel copy needs a value for {${key}}.`);
15
+ return String(values[key]);
16
+ });
17
+ }
18
+
19
+ /** The sentence that says why a note cannot be edited. An unknown reason throws. */
20
+ export function reasonText(reason) {
21
+ const text = Object.hasOwn(TIMELINE_COPY.noteReasons, reason) ? TIMELINE_COPY.noteReasons[reason] : undefined;
22
+ if (typeof text !== 'string') throw new Error(`Moontide: unknown reason '${String(reason)}' for a note that cannot be edited.`);
23
+ return text;
24
+ }
25
+
26
+ /** The word at the right of the identity line: Editable or Not editable. */
27
+ export function stateWord(info) {
28
+ return info.editable ? TIMELINE_COPY.noteEditable : TIMELINE_COPY.noteNotEditable;
29
+ }
30
+
31
+ /** The muted part of the identity line after the lane name: the cycle and beat, and the section when the piece has one. */
32
+ export function positionText(info) {
33
+ const where = fill(TIMELINE_COPY.notePosition, { cycle: info.cycle, beat: info.beat });
34
+ return info.section === null || info.section === undefined ? where : where + fill(TIMELINE_COPY.noteSection, { section: displayName(info.section) });
35
+ }
36
+
37
+ /** What the panel says with no note selected. */
38
+ export function noneText() {
39
+ return TIMELINE_COPY.noteNone;
40
+ }
41
+
42
+ /** The pitch as written, then its note number (`c3 · 48`); a note written as a number shows the number alone; a drum lane
43
+ * adds the name of the drum. */
44
+ export function pitchValueText(info) {
45
+ const written = String(info.noteText);
46
+ const isNumber = /^-?\d+(\.\d+)?$/.test(written);
47
+ const value = isNumber ? written : fill(TIMELINE_COPY.notePitchValue, { note: written, midi: info.midi });
48
+ return typeof info.drum === 'string' && info.drum !== '' ? fill(TIMELINE_COPY.notePitchDrum, { value, drum: info.drum }) : value;
49
+ }
50
+
51
+ /** The velocity the Timeline computes (gain times velocity), two decimals; the word for a velocity it cannot compute. */
52
+ export function velocityValueText(velocity) {
53
+ return typeof velocity === 'number' && Number.isFinite(velocity) ? velocity.toFixed(2) : TIMELINE_COPY.noteVelocityUnknown;
54
+ }
55
+
56
+ /**
57
+ * The names of the sections a token plays in, as the sentence lists them: `'x'`, `'x' and 'z'`, `'x', 'y' and 'z'`, and from
58
+ * five names on the first three and the number that are left (`'a', 'b', 'c' and 2 more`). At least one name.
59
+ */
60
+ export function sectionList(names) {
61
+ if (!Array.isArray(names) || names.length === 0) throw new Error('Moontide: a list of sections needs at least one name.');
62
+ const quoted = names.map((name) => fill(TIMELINE_COPY.noteSectionName, { name: displayName(name) }));
63
+ if (quoted.length === 1) return quoted[0];
64
+ if (quoted.length <= 4) return fill(TIMELINE_COPY.noteListAnd, { first: quoted.slice(0, -1).join(', '), last: quoted[quoted.length - 1] });
65
+ return fill(TIMELINE_COPY.noteListMore, { first: quoted.slice(0, 3).join(', '), n: quoted.length - 3 });
66
+ }
67
+
68
+ /**
69
+ * The sentence that says a token plays in several places, or null when it plays in one. `perCycle` is how many notes the
70
+ * token draws in each cycle (a piece without a structure); `count` and `sections` are the places of a structured piece, and
71
+ * `onPage` says they were counted on the drawn page alone.
72
+ */
73
+ export function placesText({ count = 0, sections = [], perCycle = 0, onPage = false }) {
74
+ if (perCycle > 1) return fill(TIMELINE_COPY.notePlacesPerCycle, { n: perCycle });
75
+ if (count > 1) return fill(onPage ? TIMELINE_COPY.notePlacesOnPage : TIMELINE_COPY.notePlaces, { n: count, list: sectionList(sections) });
76
+ return null;
77
+ }
78
+
79
+ /** The words that repeat the count after a change: ` in 3 places` (` in 3 places on this page`), nothing for one place. */
80
+ export function placesWords(count, onPage = false) {
81
+ if (!(count > 1)) return '';
82
+ return fill(onPage ? TIMELINE_COPY.notePlacesPageWords : TIMELINE_COPY.notePlacesWords, { n: count });
83
+ }
84
+
85
+ /**
86
+ * The words that repeat the count of notes sharing a velocity number: `, for 4 notes on this page`, nothing for one note. With
87
+ * `onPage` the places words before them already say "on this page", so they are not said twice: `, for 4 notes`.
88
+ */
89
+ export function notesWords(count, onPage = false) {
90
+ if (!(count > 1)) return '';
91
+ return fill(onPage ? TIMELINE_COPY.noteVelocityNotesShort : TIMELINE_COPY.noteVelocityNotes, { n: count });
92
+ }
93
+
94
+ /**
95
+ * The sentence that stands while nothing else is said: what the token's places are told as soon as they are known, or the
96
+ * token an edit rewrites, or the reason the note is refused. `places` is `{ count, sections, onPage }` once the places of a
97
+ * structured piece are counted, null before that; a piece without a structure carries its count in `info.perCycle`.
98
+ */
99
+ export function standingText(info, places = null) {
100
+ if (!info.editable) return reasonText(info.reason);
101
+ const told = placesText(places === null ? { perCycle: info.perCycle ?? 0 } : places);
102
+ if (told !== null) return told;
103
+ return fill(TIMELINE_COPY.noteStanding, { token: displayName(info.seen.token.text) });
104
+ }
105
+
106
+ /** The size of a pitch change in words: `+1 semitone`, `-3 semitones`, `+1 octave`, `+2 octaves` (a multiple of 12 reads in octaves). */
107
+ export function deltaText(semitones) {
108
+ if (!Number.isInteger(semitones) || semitones === 0) throw new Error(`Moontide: a pitch change is a whole number of semitones other than 0, got ${String(semitones)}.`);
109
+ const sign = semitones > 0 ? '+' : '-';
110
+ const size = Math.abs(semitones);
111
+ if (size % 12 === 0) {
112
+ const octaves = size / 12;
113
+ return octaves === 1 ? fill(TIMELINE_COPY.noteDeltaOctave, { sign }) : fill(TIMELINE_COPY.noteDeltaOctaves, { sign, n: octaves });
114
+ }
115
+ return size === 1 ? fill(TIMELINE_COPY.noteDeltaSemitone, { sign }) : fill(TIMELINE_COPY.noteDeltaSemitones, { sign, n: size });
116
+ }
117
+
118
+ /**
119
+ * The sentence while a note the page cannot edit is dragged: `Release to ask Claude: raise this note by 2 semitones.` or
120
+ * `lower this note by 1 octave.` The change is never 0; nothing is written, the release prepares a request.
121
+ */
122
+ export function refusedPreviewText(semitones) {
123
+ // deltaText checks the change and writes it with its sign (`+2 semitones`); the sentence has the verb instead.
124
+ const amount = deltaText(Math.abs(semitones)).slice(1);
125
+ return fill(TIMELINE_COPY.noteRefusedPreview, { verb: semitones > 0 ? TIMELINE_COPY.noteRaise : TIMELINE_COPY.noteLower, amount });
126
+ }
127
+
128
+ /** The sentence after a request was put in the Ask box: nothing is sent until the person presses Enter. */
129
+ export function requestReadyText() {
130
+ return TIMELINE_COPY.noteRequestReady;
131
+ }
132
+
133
+ /** One end of the preview: the note as written, then what a drum lane calls it. `drum` is null (not a drum lane), the drum's
134
+ * name, or `{ map }` for a note the lane's map does not name (it reads like the drum rows do: `d2 · not in GM`). */
135
+ function previewEnd(written, drum) {
136
+ const name = displayName(written);
137
+ if (drum === null || drum === undefined) return name;
138
+ if (typeof drum === 'string') return `${name} ${displayName(drum)}`;
139
+ return fill(TIMELINE_COPY.drumNotInMap, { note: name, map: displayName(drum.map) });
140
+ }
141
+
142
+ /**
143
+ * The sentence while a note is dragged: `c3 → d3 (+2 semitones). Release to write.` With `waiting` it is the sentence of steps
144
+ * that wait for the pause before they are written: `c3 → d3 (+2 semitones). Writing in a moment.` The change is never 0. With
145
+ * `places` above 1 the sentence repeats the count: `c3 → d3 (+2 semitones), in 3 places. Release to write.`
146
+ */
147
+ export function previewText({ from, to, semitones, drumFrom = null, drumTo = null, waiting = false, places = 0, onPage = false }) {
148
+ const template = waiting ? TIMELINE_COPY.noteStepPreview : TIMELINE_COPY.notePreview;
149
+ const count = placesWords(places, onPage);
150
+ return fill(template, { from: previewEnd(from, drumFrom), to: previewEnd(to, drumTo), delta: deltaText(semitones), places: count === '' ? '' : `,${count}` });
151
+ }
152
+
153
+ /** A velocity as the panel writes it: two decimals. */
154
+ function velocityWord(value) {
155
+ if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error(`Moontide: a velocity is a finite number, got ${String(value)}.`);
156
+ return value.toFixed(2);
157
+ }
158
+
159
+ /**
160
+ * The sentence while velocity steps wait for the pause: `Velocity 0.80 → 0.90. Writing in a moment.` With `notes` above 1
161
+ * (the notes of the page that share the number) it says so: `Velocity 0.80 → 0.90, for 4 notes on this page. ...`, and with `places`
162
+ * above 1 (where the number plays in a piece with a structure) it names them the way the result does: `Velocity 0.80 → 0.90
163
+ * in 3 places, for 4 notes on this page. ...`
164
+ */
165
+ export function velocityPreviewText({ from, to, notes = 0, places = 0, onPage = false }) {
166
+ return fill(TIMELINE_COPY.noteVelocityPreview, { from: velocityWord(from), to: velocityWord(to), places: placesWords(places, onPage), notes: notesWords(notes, onPage && places > 1) });
167
+ }
168
+
169
+ /** The sentence of steps that were aimed at a note the lanes no longer hold as it was. */
170
+ export function stepsDroppedText() {
171
+ return TIMELINE_COPY.noteStepsDropped;
172
+ }
173
+
174
+ /** The sentence of a rest or a drag that was aimed at a note the lanes no longer hold as it was. */
175
+ export function changeDroppedText() {
176
+ return TIMELINE_COPY.noteChangeDropped;
177
+ }
178
+
179
+ /** The sentence of a rest or a drag the page could not write when it was due: the person makes it again. */
180
+ export function changeNotWrittenText() {
181
+ return TIMELINE_COPY.noteChangeNotWritten;
182
+ }
183
+
184
+ /** The sentence of an edit the page gave no answer to in time: it may or may not have been written. */
185
+ export function editTimeoutText() {
186
+ return TIMELINE_COPY.noteEditTimeout;
187
+ }
188
+
189
+ /**
190
+ * A sentence about the edit of a note that is not the one the panel shows, named after that note: `c3 on t1: Changed c3 to
191
+ * c#3.` The note and the lane are cut like every name in a sentence.
192
+ */
193
+ export function namedText({ note, lane, text }) {
194
+ if (typeof text !== 'string' || text === '') throw new Error('Moontide: a sentence about another note needs its words.');
195
+ return fill(TIMELINE_COPY.noteNamed, { note: displayName(String(note)), lane: displayName(String(lane)), text });
196
+ }
197
+
198
+ /** The sentence of steps the page could not write when they were due: the person presses them again. */
199
+ export function stepsNotWrittenText() {
200
+ return TIMELINE_COPY.noteStepsNotWritten;
201
+ }
202
+
203
+ /** The sentence when the velocity is at the end of its range: `lowest` or `highest`. */
204
+ export function velocityLimitText(kind) {
205
+ if (kind === 'lowest') return TIMELINE_COPY.noteVelocityLowest;
206
+ if (kind === 'highest') return TIMELINE_COPY.noteVelocityHighest;
207
+ throw new Error(`Moontide: unknown limit '${String(kind)}' of a velocity.`);
208
+ }
209
+
210
+ /** The muted words after a velocity the page cannot write. */
211
+ export function velocityFixedText() {
212
+ return TIMELINE_COPY.noteVelocityFixed;
213
+ }
214
+
215
+ /** The sentence when a velocity button is pressed for a velocity the page cannot write. */
216
+ export function velocityRefusalText() {
217
+ return TIMELINE_COPY.noteVelocityRefusal;
218
+ }
219
+
220
+ /** The sentence when a drag is held at the end of the note range: `lowest` or `highest`. */
221
+ export function limitText(kind) {
222
+ if (kind === 'lowest') return TIMELINE_COPY.noteLowest;
223
+ if (kind === 'highest') return TIMELINE_COPY.noteHighest;
224
+ throw new Error(`Moontide: unknown limit '${String(kind)}' of a note.`);
225
+ }
226
+
227
+ /** The sentence of a notice: `disconnected`, `waiting` or `unevaluated`. */
228
+ export function noticeText(kind) {
229
+ const text = { disconnected: TIMELINE_COPY.noteNoticeDisconnected, waiting: TIMELINE_COPY.noteNoticeWaiting, unevaluated: TIMELINE_COPY.noteNoticeUnevaluated }[kind];
230
+ if (typeof text !== 'string') throw new Error(`Moontide: unknown note panel notice '${String(kind)}'.`);
231
+ return text;
232
+ }
233
+
234
+ /** The sentence of a refused edit: the id is one of the edit's own (`stale`, `read-back`) or one of the shared rewrite path. */
235
+ export function refusalText(reason) {
236
+ switch (reason) {
237
+ case 'stale':
238
+ return TIMELINE_COPY.noteStale;
239
+ case 'velocity-fixed':
240
+ return TIMELINE_COPY.noteVelocityRefusal;
241
+ case 'closed':
242
+ return noticeText('disconnected');
243
+ case 'waiting':
244
+ return noticeText('waiting');
245
+ case 'dirty':
246
+ return noticeText('unevaluated');
247
+ // The page hit an error it did not expect before it wrote the edit.
248
+ case 'error':
249
+ return TIMELINE_COPY.noteEditError;
250
+ // The edit could not be proven, or the editor did not take it: the code is as it was and Claude can make the change.
251
+ case 'read-back':
252
+ case 'write-mismatch':
253
+ case 'not-played':
254
+ case 'no-parser':
255
+ return TIMELINE_COPY.noteReadBack;
256
+ default:
257
+ throw new Error(`Moontide: unknown refusal '${String(reason)}' of a note edit.`);
258
+ }
259
+ }
260
+
261
+ /**
262
+ * The bold part of the identity line of a range: `Range · t1`. The lane name is as the Timeline shows it; the panel's element
263
+ * carries it through textContent only.
264
+ */
265
+ export function rangeWhereText(track) {
266
+ if (typeof track !== 'string' || track === '') throw new Error('Moontide: a range needs the name of its lane.');
267
+ return fill(TIMELINE_COPY.noteRangeWhere, { lane: track });
268
+ }
269
+
270
+ /**
271
+ * The muted part of the identity line of a range: ` · cycles 12 to 15` (` · cycle 12` for one), the section when the piece has a
272
+ * song (` · section 'y'`, ` · sections 'x' to 'z'` when the range crosses a boundary) and the notes (` · 14 notes`, ` · 1 note`,
273
+ * ` · no notes`). `range` is `{ from, to, section, sectionTo, count }`; `section` and `sectionTo` are names or absent.
274
+ */
275
+ export function rangePositionText({ from, to, section = null, sectionTo = null, count }) {
276
+ if (!Number.isInteger(from) || !Number.isInteger(to) || to < from) throw new Error('Moontide: a range has whole cycles, the last not before the first.');
277
+ if (!Number.isInteger(count) || count < 0) throw new Error('Moontide: a range has a count of notes of at least 0.');
278
+ if (sectionTo !== null && section === null) throw new Error('Moontide: a range that ends in a second section starts in a first one.');
279
+ let text = from === to ? fill(TIMELINE_COPY.noteRangeCycle, { a: from }) : fill(TIMELINE_COPY.noteRangeCycles, { a: from, b: to });
280
+ if (section !== null) {
281
+ text += sectionTo === null ? fill(TIMELINE_COPY.noteSection, { section: displayName(section) }) : fill(TIMELINE_COPY.noteRangeSections, { first: displayName(section), last: displayName(sectionTo) });
282
+ }
283
+ if (count === 0) return text + TIMELINE_COPY.noteRangeNoNotes;
284
+ return text + (count === 1 ? TIMELINE_COPY.noteRangeOneNote : fill(TIMELINE_COPY.noteRangeNotes, { n: count }));
285
+ }
286
+
287
+ /** The muted part of the identity line of the keyboard cursor on a lane that has no note on the page. */
288
+ export function cursorPositionText() {
289
+ return TIMELINE_COPY.noteCursorPosition;
290
+ }
291
+
292
+ /**
293
+ * The name a screen reader gives the active note: `t1, cycle 0, beat 1: note c3, velocity 0.80. Editable.`, or `... Not editable.`
294
+ * and the sentence of the reason. The lane is named whole.
295
+ */
296
+ export function activeNoteName(info) {
297
+ const state = info.editable ? TIMELINE_COPY.noteActiveEditable : fill(TIMELINE_COPY.noteActiveNotEditable, { reason: reasonText(info.reason) });
298
+ return fill(TIMELINE_COPY.noteActiveName, {
299
+ lane: info.track,
300
+ cycle: info.cycle,
301
+ beat: info.beat,
302
+ note: info.noteText,
303
+ velocity: velocityValueText(info.velocity),
304
+ state,
305
+ });
306
+ }
307
+
308
+ /** The name a screen reader gives the keyboard cursor on a lane with no note on the page: `bass, cycle 12: no notes on this page.` */
309
+ export function activeCursorName({ lane, cycle }) {
310
+ if (typeof lane !== 'string' || lane === '') throw new Error('Moontide: the cursor of a lane needs the name of its lane.');
311
+ if (!Number.isInteger(cycle)) throw new Error('Moontide: the cursor of a lane needs a whole cycle.');
312
+ return fill(TIMELINE_COPY.noteActiveCursor, { lane, cycle });
313
+ }
314
+
315
+ /** The sentence of one hint: `none`, `editable`, `refused`, `range` or `cursor`. */
316
+ export function hintText(kind) {
317
+ const text = {
318
+ none: TIMELINE_COPY.noteHintNone,
319
+ editable: TIMELINE_COPY.noteHintEditable,
320
+ refused: TIMELINE_COPY.noteHintRefused,
321
+ range: TIMELINE_COPY.noteHintRange,
322
+ cursor: TIMELINE_COPY.noteHintCursor,
323
+ }[kind];
324
+ if (typeof text !== 'string') throw new Error(`Moontide: unknown note panel hint '${String(kind)}'.`);
325
+ return text;
326
+ }
327
+
328
+ /**
329
+ * What the sentence slot says about the outcome of one edit, `{ text, kind }` (`kind` is `result` or `refused`), or null
330
+ * when the edit changed nothing and there is nothing to say. `outcome.what` is the kind of change, `pitch` (the default),
331
+ * `rest` or `velocity`. An outcome the panel does not know throws.
332
+ */
333
+ export function resultText(outcome) {
334
+ const what = outcome.what === undefined ? 'pitch' : outcome.what;
335
+ if (what !== 'pitch' && what !== 'rest' && what !== 'velocity') {
336
+ throw new Error(`Moontide: unknown kind of change '${String(what)}' in the outcome of a note edit.`);
337
+ }
338
+ switch (outcome.kind) {
339
+ case 'written':
340
+ if (what === 'rest') return { kind: 'result', text: fill(TIMELINE_COPY.noteChangedRest, { from: displayName(outcome.from), places: placesWords(outcome.places, outcome.onPage === true) }) };
341
+ if (what === 'velocity') return { kind: 'result', text: fill(TIMELINE_COPY.noteVelocityNow, { note: displayName(outcome.from), v: outcome.to, places: placesWords(outcome.places, outcome.onPage === true), notes: notesWords(outcome.notes, outcome.onPage === true && outcome.places > 1) }) };
342
+ return { kind: 'result', text: fill(TIMELINE_COPY.noteChanged, { from: displayName(outcome.from), to: displayName(outcome.to), places: placesWords(outcome.places, outcome.onPage === true) }) };
343
+ case 'waiting': {
344
+ if (outcome.at !== 'bar' && outcome.at !== 'phrase') {
345
+ throw new Error(`Moontide: a waiting note edit has the timing 'bar' or 'phrase', got '${String(outcome.at)}'.`);
346
+ }
347
+ const bar = outcome.at === 'bar';
348
+ if (what === 'rest') {
349
+ return { kind: 'result', text: fill(bar ? TIMELINE_COPY.noteWaitingBarRest : TIMELINE_COPY.noteWaitingPhraseRest, { from: displayName(outcome.from) }) };
350
+ }
351
+ if (what === 'velocity') {
352
+ return { kind: 'result', text: fill(bar ? TIMELINE_COPY.noteWaitingBarVelocity : TIMELINE_COPY.noteWaitingPhraseVelocity, { note: displayName(outcome.from), v: outcome.to }) };
353
+ }
354
+ return { kind: 'result', text: fill(bar ? TIMELINE_COPY.noteWaitingBar : TIMELINE_COPY.noteWaitingPhrase, { from: displayName(outcome.from), to: displayName(outcome.to) }) };
355
+ }
356
+ case 'unchanged':
357
+ if (outcome.limit === 'lowest') return { kind: 'result', text: what === 'velocity' ? TIMELINE_COPY.noteVelocityLowest : TIMELINE_COPY.noteLowest };
358
+ if (outcome.limit === 'highest') return { kind: 'result', text: what === 'velocity' ? TIMELINE_COPY.noteVelocityHighest : TIMELINE_COPY.noteHighest };
359
+ return null;
360
+ case 'refused':
361
+ // An error that came at or after the write leaves code that may have changed: the sentence does not say it is as it was.
362
+ if (outcome.reason === 'error' && outcome.afterWrite === true) return { kind: 'refused', text: TIMELINE_COPY.noteEditErrorWritten };
363
+ return { kind: 'refused', text: refusalText(outcome.reason) };
364
+ case 'failed':
365
+ return { kind: 'refused', text: TIMELINE_COPY.noteNotEvaluated };
366
+ default:
367
+ throw new Error(`Moontide: unknown outcome '${String(outcome.kind)}' of a note edit.`);
368
+ }
369
+ }
370
+
371
+ /**
372
+ * The history entry of one written edit, cut to the length the server keeps: `changed t1: c3 to c#3`, with `rest: true`
373
+ * `changed t1: c3 to a rest`, and with `velocity` (the new velocity, a number) `changed t1: velocity of c3 to 0.90` (`from` is
374
+ * the note). With `places` above 1 an entry ends with the count: `changed t1: c3 to c#3 in 3 places`, `changed t1: velocity of c3 to 0.90 in 3 places`.
375
+ */
376
+ export function noteHistoryLabel({ track, from, to, rest = false, velocity = undefined, places = 0, onPage = false }) {
377
+ const where = placesWords(places, onPage);
378
+ if (rest) return clipMixerLabel(fill(TIMELINE_COPY.noteRecordRest, { track: displayName(track), from: displayName(from), places: where }));
379
+ if (velocity !== undefined) return clipMixerLabel(fill(TIMELINE_COPY.noteRecordVelocity, { track: displayName(track), note: displayName(from), v: velocityWord(velocity), places: where }));
380
+ return clipMixerLabel(fill(TIMELINE_COPY.noteRecord, { track: displayName(track), from: displayName(from), to: displayName(to), places: where }));
381
+ }
@@ -0,0 +1,126 @@
1
+ // Which track played a note.
2
+ //
3
+ // Strudel runs every labeled statement (`name: pattern`) through `Pattern.prototype.p`. This module
4
+ // wraps that method so each note of a labeled pattern carries the key of its track, and adds an
5
+ // observer that tells every subscriber when such a note is triggered: `{ key, hap, target, stamp }`
6
+ // (the hap, the audio time it is due, and for a MIDI note the timestamp its port got, else null).
7
+ // It changes no sound and no MIDI message; nothing is drawn here.
8
+ //
9
+ // Why `p` is an accessor: the repl assigns a brand new `p` at the start of every evaluation, so a
10
+ // plain wrapper would be replaced by the first run. The accessor hands out one wrapper for good and
11
+ // its setter keeps the repl's newest function, which the wrapper calls. The setter also runs once at
12
+ // the start of each evaluation, which is where the count of `$` statements starts again.
13
+ //
14
+ // Why the observer is not dominant: a trigger that is dominant replaces the browser sound. A track
15
+ // that ends in `.midi()` already carries a dominant trigger and one without any plays the browser
16
+ // sound; passing `false` keeps whichever the note had. The earlier trigger runs first, so by the time
17
+ // the observer runs, `.midi()` has recorded the timestamp its port got and `stampOf` can read it.
18
+ //
19
+ // Why an outer tag wins: a pattern labeled inside another labeled pattern is tagged twice. The
20
+ // outer tag sets the key last, every observer compares the key on the note with its own, and only
21
+ // the outer one reports it.
22
+
23
+ import { labelKey } from './tracks.js';
24
+
25
+ const subscribers = new Set();
26
+
27
+ /**
28
+ * Calls `listener(event)` for every note of a labeled track, where event is `{ key, hap, target,
29
+ * stamp }`. The key is the track's Mixer identity, or `$n` for the n-th `$` statement of the
30
+ * evaluation (counted from 0, in code order). Returns the function that stops the calls.
31
+ */
32
+ export function subscribe(listener) {
33
+ if (typeof listener !== 'function') {
34
+ throw new Error('Moontide: a note listener must be a function.');
35
+ }
36
+ subscribers.add(listener);
37
+ return () => {
38
+ subscribers.delete(listener);
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Tags every labeled statement of Strudel's `Pattern` with its track's key and reports each note to
44
+ * the subscribers. `stampOf(hap)` gives the timestamp a MIDI note was sent with, or null. Install
45
+ * it once, at page start, before any code is evaluated; a second install throws.
46
+ */
47
+ export function installNoteTag(PatternClass, { stampOf } = {}) {
48
+ const lost = 'so lights cannot learn which track played a note';
49
+ if (typeof PatternClass !== 'function') {
50
+ throw new Error(`Moontide: Strudel's Pattern is not available, ${lost}.`);
51
+ }
52
+ for (const name of ['withHap', 'onTrigger']) {
53
+ if (typeof PatternClass.prototype[name] !== 'function') {
54
+ throw new Error(`Moontide: Strudel's Pattern has no ${name}, ${lost}.`);
55
+ }
56
+ }
57
+ if (typeof stampOf !== 'function') {
58
+ throw new Error(`Moontide: the note tag needs a way to read a note's MIDI timestamp, ${lost}.`);
59
+ }
60
+ if (Object.getOwnPropertyDescriptor(PatternClass.prototype, 'p')?.get) {
61
+ throw new Error("Moontide: the note tag is already installed on Strudel's Pattern.");
62
+ }
63
+
64
+ // Undefined at page start: the repl assigns p only when it evaluates code.
65
+ let repl = PatternClass.prototype.p;
66
+ let dollar = 0;
67
+ let errorReported = false;
68
+ // A note reaches one observer per tag; this keeps a pattern tagged twice under one key to one report.
69
+ const reported = new WeakSet();
70
+
71
+ function reportOnce(err) {
72
+ if (errorReported) return;
73
+ errorReported = true;
74
+ console.error('Moontide: a note listener or the MIDI timestamp lookup failed; later failures are not repeated:', err);
75
+ }
76
+
77
+ function observerFor(key) {
78
+ return function noteObserver(hap, now, cps, target) {
79
+ if (hap === null || typeof hap !== 'object' || hap.context?.noteTrack !== key || reported.has(hap)) return;
80
+ reported.add(hap);
81
+ let stamp = null;
82
+ try {
83
+ const got = stampOf(hap);
84
+ stamp = typeof got === 'number' && Number.isFinite(got) ? got : null;
85
+ } catch (err) {
86
+ reportOnce(err);
87
+ }
88
+ const event = { key, hap, target, stamp };
89
+ for (const listener of [...subscribers]) {
90
+ try {
91
+ listener(event);
92
+ } catch (err) {
93
+ reportOnce(err);
94
+ }
95
+ }
96
+ };
97
+ }
98
+
99
+ function tag(pattern, key) {
100
+ return pattern
101
+ .withHap((hap) => hap.setContext({ ...hap.context, noteTrack: key }))
102
+ .onTrigger(observerFor(key), false);
103
+ }
104
+
105
+ const wrapper = function taggedP(id) {
106
+ if (typeof repl !== 'function') {
107
+ throw new Error("Moontide: Strudel has not set up labeled tracks yet, so a labeled statement ran before the note tag could reach it.");
108
+ }
109
+ // A label that starts or ends with an underscore is silenced by the repl: nothing to report.
110
+ if (typeof id === 'string' && !id.startsWith('_') && !id.endsWith('_')) {
111
+ const key = id.includes('$') ? `$${dollar++}` : labelKey(id);
112
+ return repl.call(tag(this, key), id);
113
+ }
114
+ return repl.call(this, id);
115
+ };
116
+
117
+ Object.defineProperty(PatternClass.prototype, 'p', {
118
+ configurable: true,
119
+ enumerable: true,
120
+ get: () => wrapper,
121
+ set: (fn) => {
122
+ repl = fn;
123
+ dollar = 0;
124
+ },
125
+ });
126
+ }