moontide-mcp 2.0.2 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +94 -6
  2. package/dist/ask.js +76 -15
  3. package/dist/mcp.js +198 -26
  4. package/dist/protocol.js +130 -2
  5. package/dist/reference.js +22 -0
  6. package/dist/request-status.js +221 -0
  7. package/dist/session.js +256 -7
  8. package/package.json +1 -1
  9. package/page/ask-view.js +64 -57
  10. package/page/ask.js +363 -188
  11. package/page/bar-model.js +43 -2
  12. package/page/change-timing.js +60 -4
  13. package/page/harmony.js +13 -0
  14. package/page/index.html +7 -4
  15. package/page/jam-analysis.js +68 -23
  16. package/page/jam-model.js +196 -31
  17. package/page/jam-resolve.js +271 -0
  18. package/page/jam-view.js +182 -10
  19. package/page/lights.js +300 -0
  20. package/page/main.js +2171 -188
  21. package/page/midi-clock.js +23 -4
  22. package/page/midi-out.js +17 -2
  23. package/page/mixer-groups.js +114 -0
  24. package/page/mixer-model.js +172 -29
  25. package/page/mixer-rewrite.js +30 -1
  26. package/page/mixer-view.js +289 -14
  27. package/page/note-edit.js +267 -0
  28. package/page/note-locate.js +346 -0
  29. package/page/note-panel.js +402 -0
  30. package/page/note-tag.js +141 -0
  31. package/page/page-rewrite.js +184 -0
  32. package/page/query-check.js +21 -2
  33. package/page/request-status-view.js +419 -0
  34. package/page/request-status.js +686 -0
  35. package/page/song-edit.js +230 -0
  36. package/page/song-fit.js +250 -0
  37. package/page/song-grid-model.js +506 -0
  38. package/page/song-grid-view.js +710 -0
  39. package/page/song-prefs.js +100 -0
  40. package/page/structure-check.js +216 -0
  41. package/page/structure-view.js +38 -0
  42. package/page/structure.js +761 -0
  43. package/page/style.css +1218 -65
  44. package/page/takeover.js +20 -5
  45. package/page/tempo-glide.js +330 -0
  46. package/page/tempo-guard.js +62 -10
  47. package/page/timeline-edit-view.js +1956 -0
  48. package/page/timeline-gesture.js +279 -0
  49. package/page/timeline.js +197 -3
  50. package/page/top-bar-view.js +18 -13
  51. package/page/tracks.js +37 -8
  52. package/page/ui-state.js +10 -5
  53. package/page/views.js +39 -9
  54. package/reference/functions.md +1 -0
  55. package/reference/midi.md +2 -0
  56. package/reference/structure.md +131 -0
  57. package/reference/transitions.md +13 -1
package/page/jam-view.js CHANGED
@@ -1,21 +1,25 @@
1
- // The Jam view's DOM: the message, the scale block, the seven degree chips, the
1
+ // The Jam view's DOM: the message, the scale block with the line that says why a scale is proposed and the list of the scales that
2
+ // fit (buttons that switch while one can be chosen, plain text when none can), the seven degree chips, the
2
3
  // status box, the coming-up rows and the piano, built with DOM methods only. Every text reaches
3
4
  // the page through textContent, title or setAttribute, never as markup: a part name comes from
4
5
  // the code and is not trusted. The view attaches no right-click listener: page/main.js owns the
5
6
  // only one. No DOM access at import time: main.js calls createJamView() with its document, so
6
7
  // this module can be loaded anywhere. The words and the model come from page/jam-model.js.
7
8
 
8
- import { JAM_COPY, statusAnnounceKey, noteDescriptor, pianoKeys, keyLabel, fretCells, fretUnits, legendEntries, STRING_NAMES, FRET_NUMBERS } from './jam-model.js';
9
+ import { JAM_COPY, statusAnnounceKey, noteDescriptor, pianoKeys, keyLabel, keyTitle, fretCells, fretUnits, legendEntries, STRING_NAMES, FRET_NUMBERS } from './jam-model.js';
9
10
 
10
11
  const DEGREE_COUNT = 7;
11
12
 
12
13
  /**
13
14
  * Builds the Jam view's DOM once and returns `{ showMessage, render, setPreferences }`.
14
15
  * `onInstrumentChange(name)` is called with 'piano' or 'guitar' when the user picks the other
15
- * segment, `onChordTonesChange(checked)` when the user flips the Chord tones checkbox; the view
16
- * never touches browser storage, main.js owns the preferences.
16
+ * segment, `onChordTonesChange(checked)` when the user flips the Chord tones checkbox,
17
+ * `onScaleChoice({ setKey, key })` when the user picks a scale that is not the one in use (a click, or Enter or Space on
18
+ * the focused button); a handler that throws is caught and said once per set of candidates. The view never touches
19
+ * browser storage and never chooses: main.js owns the preferences and the choice.
17
20
  */
18
- export function createJamView({ document, onInstrumentChange, onChordTonesChange }) {
21
+ export function createJamView({ document, onInstrumentChange, onChordTonesChange, onScaleChoice }) {
22
+ if (typeof onScaleChoice !== 'function') throw new Error('Moontide: createJamView needs onScaleChoice, a function that takes { setKey, key }.');
19
23
  const jamView = document.getElementById('jam-view');
20
24
  const jamMessage = document.getElementById('jam-message');
21
25
  const jamMessageText = document.getElementById('jam-message-text');
@@ -24,9 +28,18 @@ export function createJamView({ document, onInstrumentChange, onChordTonesChange
24
28
  const jamDeclared = document.getElementById('jam-declared');
25
29
  const jamDeclaredLabel = document.getElementById('jam-declared-label');
26
30
  const jamDeclaredValue = document.getElementById('jam-declared-value');
31
+ const jamDeclaredSection = document.getElementById('jam-declared-section');
32
+ const jamScale = document.getElementById('jam-scale');
27
33
  const jamScaleLabel = document.getElementById('jam-scale-label');
28
34
  const jamScaleName = document.getElementById('jam-scale-name');
29
35
  const jamScaleSub = document.getElementById('jam-scale-sub');
36
+ const jamReason = document.getElementById('jam-reason');
37
+ const jamReasonLead = document.getElementById('jam-reason-lead');
38
+ const jamReasonRest = document.getElementById('jam-reason-rest');
39
+ const jamCandidates = document.getElementById('jam-candidates');
40
+ const jamCandidatesLabel = document.getElementById('jam-candidates-label');
41
+ const jamCandidateList = document.getElementById('jam-candidate-list');
42
+ const jamCandidatesMore = document.getElementById('jam-candidates-more');
30
43
  const jamDegrees = document.getElementById('jam-degrees');
31
44
  const jamStatus = document.getElementById('jam-status');
32
45
  const jamStatusLead = document.getElementById('jam-status-lead');
@@ -104,7 +117,7 @@ export function createJamView({ document, onInstrumentChange, onChordTonesChange
104
117
  labelEl.className = 'jam-key-label';
105
118
  el.append(labelEl);
106
119
  jamPiano.append(el);
107
- return { key, el, labelEl, shown: { note: null, role: null, root: null, chord: null, label: null } };
120
+ return { key, el, labelEl, shown: { note: null, role: null, root: null, chord: null, label: null, title: '' } };
108
121
  });
109
122
  let shownPianoLabel = null;
110
123
 
@@ -162,9 +175,15 @@ export function createJamView({ document, onInstrumentChange, onChordTonesChange
162
175
  }
163
176
 
164
177
  let shownDeclaredOn = false;
178
+ let shownLabelOn = null;
179
+ let shownSource = null;
180
+ let shownReasonOn = null;
181
+ let shownReasonLead = null;
182
+ let shownReasonRest = null;
165
183
  let shownDeclaredKind = null;
166
184
  let shownDeclaredText = null;
167
185
  let shownDeclaredTitle = null;
186
+ let shownDeclaredSection = null;
168
187
  let shownName = null;
169
188
  let shownKind = null;
170
189
  let shownSub = null;
@@ -197,14 +216,13 @@ export function createJamView({ document, onInstrumentChange, onChordTonesChange
197
216
  lastModel = null;
198
217
  }
199
218
 
200
- // The Declared line and the Heard label: both shown with a declaration, both hidden without one. The
201
- // text comes from the code, so it goes in as text only, and only when it changed.
219
+ // The Declared line: shown with a declaration, hidden without one. The text comes from the code, so it goes
220
+ // in as text only, and only when it changed.
202
221
  function renderDeclared(declared) {
203
222
  if (declared === undefined) throw new Error('Moontide: a ready Jam model needs its declared field (null without a declaration).');
204
223
  const on = declared !== null;
205
224
  if (on !== shownDeclaredOn) {
206
225
  jamDeclared.hidden = !on;
207
- jamScaleLabel.hidden = !on;
208
226
  shownDeclaredOn = on;
209
227
  }
210
228
  if (!on) return;
@@ -222,6 +240,150 @@ export function createJamView({ document, onInstrumentChange, onChordTonesChange
222
240
  }
223
241
  }
224
242
 
243
+ // Where the scale in use came from. The Heard label shows only with a declaration and only for a scale that was heard: it
244
+ // would be false for a proposed one. The source rides on #jam-scale for tests and the probe.
245
+ function renderSource(declared, scaleSource) {
246
+ if (scaleSource !== 'heard' && scaleSource !== 'proposed' && scaleSource !== 'chosen') {
247
+ throw new Error(`Moontide: a ready Jam model needs a scale source of heard, proposed or chosen, got '${String(scaleSource)}'.`);
248
+ }
249
+ const labelOn = declared !== null && scaleSource === 'heard';
250
+ if (labelOn !== shownLabelOn) {
251
+ jamScaleLabel.hidden = !labelOn;
252
+ shownLabelOn = labelOn;
253
+ }
254
+ if (scaleSource !== shownSource) {
255
+ jamScale.dataset.source = scaleSource;
256
+ shownSource = scaleSource;
257
+ }
258
+ }
259
+
260
+ // The line that says why the scale is proposed: a lead word and one sentence, written only when they changed so the
261
+ // polite live region reads a proposal once and never per tick. Hiding it clears both texts, so a proposal that comes back
262
+ // later is read again.
263
+ function renderReason(reason) {
264
+ if (reason === undefined) throw new Error('Moontide: a ready Jam model needs its reason field (null for a scale that was heard).');
265
+ const on = reason !== null;
266
+ const lead = on ? reason.lead : '';
267
+ const rest = on ? reason.rest : '';
268
+ if (lead !== shownReasonLead) {
269
+ jamReasonLead.textContent = lead;
270
+ shownReasonLead = lead;
271
+ }
272
+ if (rest !== shownReasonRest) {
273
+ jamReasonRest.textContent = rest;
274
+ shownReasonRest = rest;
275
+ }
276
+ if (on !== shownReasonOn) {
277
+ jamReason.hidden = !on;
278
+ shownReasonOn = on;
279
+ }
280
+ }
281
+
282
+ // The scales that fit. Pick mode draws one button per scale and exactly one is pressed; list mode draws plain items in a
283
+ // list. The items are rebuilt only when the mode, the set of candidates or the names drawn changed; a switch of the
284
+ // pressed scale rewrites aria-pressed in place, so the button just clicked keeps focus (and a rebuild gives focus back to
285
+ // the button of the same scale). Each button reads its own key
286
+ // from its closure, never from the markup, and a click on the pressed one does nothing. A choice the page refuses is said
287
+ // once per set of candidates, not once per click.
288
+ let shownCandidatesOn = null;
289
+ let shownCandidatesMode = null;
290
+ let shownCandidatesCaption = null;
291
+ let shownCandidatesMore = null;
292
+ let shownCandidatesKey = null;
293
+ let candidateRefs = [];
294
+ const choiceReportedFor = new Set();
295
+
296
+ function chooseCandidate(setKey, ref) {
297
+ if (ref.pressed) return;
298
+ try {
299
+ onScaleChoice({ setKey, key: ref.key });
300
+ } catch (err) {
301
+ if (!choiceReportedFor.has(setKey)) {
302
+ choiceReportedFor.add(setKey);
303
+ console.error('Moontide: the Jam view could not switch the scale:', err);
304
+ }
305
+ }
306
+ }
307
+
308
+ function buildCandidates(candidates) {
309
+ // The buttons are replaced, so a focused one would be lost: note its key now and give focus to the new button of the
310
+ // same scale (a pick can change which scale sits in the last place drawn when more than eight fit).
311
+ const focused = candidateRefs.find((ref) => ref.el === document.activeElement);
312
+ candidateRefs = candidates.items.map((item) => {
313
+ const pick = candidates.mode === 'pick';
314
+ const el = document.createElement(pick ? 'button' : 'span');
315
+ el.className = 'jam-candidate';
316
+ el.textContent = item.name;
317
+ const ref = { el, key: item.key, pressed: pick && item.pressed };
318
+ if (pick) {
319
+ el.type = 'button';
320
+ el.setAttribute('aria-pressed', String(item.pressed));
321
+ el.addEventListener('click', () => chooseCandidate(candidates.setKey, ref));
322
+ } else {
323
+ el.setAttribute('role', 'listitem');
324
+ }
325
+ return ref;
326
+ });
327
+ if (candidates.mode === 'list') jamCandidateList.setAttribute('role', 'list');
328
+ else jamCandidateList.removeAttribute('role');
329
+ jamCandidateList.replaceChildren(...candidateRefs.map((ref) => ref.el));
330
+ if (focused !== undefined) candidateRefs.find((ref) => ref.key === focused.key)?.el.focus();
331
+ }
332
+
333
+ function renderCandidates(candidates) {
334
+ if (candidates === undefined) throw new Error('Moontide: a ready Jam model needs its candidates field (null for a scale that was heard).');
335
+ const on = candidates !== null;
336
+ if (on !== shownCandidatesOn) {
337
+ jamCandidates.hidden = !on;
338
+ shownCandidatesOn = on;
339
+ }
340
+ if (!on) return;
341
+ if (candidates.mode !== 'pick' && candidates.mode !== 'list') {
342
+ throw new Error(`Moontide: the Jam view cannot draw scales that fit in the mode '${String(candidates.mode)}'.`);
343
+ }
344
+ if (candidates.mode !== shownCandidatesMode) {
345
+ jamCandidates.dataset.mode = candidates.mode;
346
+ shownCandidatesMode = candidates.mode;
347
+ }
348
+ if (candidates.caption !== shownCandidatesCaption) {
349
+ jamCandidatesLabel.textContent = candidates.caption;
350
+ shownCandidatesCaption = candidates.caption;
351
+ }
352
+ if (candidates.more !== shownCandidatesMore) {
353
+ jamCandidatesMore.textContent = candidates.more ?? '';
354
+ jamCandidatesMore.hidden = candidates.more === null;
355
+ shownCandidatesMore = candidates.more;
356
+ }
357
+ const listKey = [candidates.mode, candidates.setKey, ...candidates.items.map((item) => item.key)].join('\n');
358
+ if (listKey !== shownCandidatesKey) {
359
+ buildCandidates(candidates);
360
+ shownCandidatesKey = listKey;
361
+ return;
362
+ }
363
+ if (candidates.mode !== 'pick') return;
364
+ candidates.items.forEach((item, i) => {
365
+ const ref = candidateRefs[i];
366
+ if (ref.pressed !== item.pressed) {
367
+ ref.el.setAttribute('aria-pressed', String(item.pressed));
368
+ ref.pressed = item.pressed;
369
+ }
370
+ });
371
+ }
372
+
373
+ // The section the Declared line speaks for: its words after the value, hidden for a piece without sections.
374
+ function renderDeclaredSection(section) {
375
+ if (section === undefined) throw new Error('Moontide: a ready Jam model needs its section field (null without sections).');
376
+ if (section === shownDeclaredSection) return;
377
+ if (section === null) {
378
+ jamDeclaredSection.textContent = '';
379
+ jamDeclaredSection.hidden = true;
380
+ } else {
381
+ jamDeclaredSection.textContent = section;
382
+ jamDeclaredSection.hidden = false;
383
+ }
384
+ shownDeclaredSection = section;
385
+ }
386
+
225
387
  function renderScale(scale, degrees) {
226
388
  if (scale.text !== shownName) {
227
389
  jamScaleName.textContent = scale.text;
@@ -360,7 +522,7 @@ export function createJamView({ document, onInstrumentChange, onChordTonesChange
360
522
  });
361
523
  }
362
524
 
363
- // The piano: each key's note, role, avoided-root ring and label, written only when changed.
525
+ // The piano: each key's note, role, avoided-root ring, label and hover title, written only when changed.
364
526
  function renderInstrument(instrument) {
365
527
  const { roles, names, rootPc, chordPcs } = instrument;
366
528
  for (const ref of keys) {
@@ -392,6 +554,12 @@ export function createJamView({ document, onInstrumentChange, onChordTonesChange
392
554
  ref.labelEl.textContent = label;
393
555
  ref.shown.label = label;
394
556
  }
557
+ const title = keyTitle(ref.key, roles, names, chordPcs, rootPc);
558
+ if (ref.shown.title !== title) {
559
+ if (title === '') ref.el.removeAttribute('title');
560
+ else ref.el.setAttribute('title', title);
561
+ ref.shown.title = title;
562
+ }
395
563
  }
396
564
  if (instrument.pianoLabel !== shownPianoLabel) {
397
565
  jamPiano.setAttribute('aria-label', instrument.pianoLabel);
@@ -477,6 +645,10 @@ export function createJamView({ document, onInstrumentChange, onChordTonesChange
477
645
  }
478
646
  lastModel = model;
479
647
  renderDeclared(model.declared);
648
+ renderDeclaredSection(model.section);
649
+ renderSource(model.declared, model.scaleSource);
650
+ renderReason(model.reason);
651
+ renderCandidates(model.candidates);
480
652
  renderScale(model.scale, model.degrees);
481
653
  renderStatus(model.status);
482
654
  if (model.paused !== shownPaused) {
package/page/lights.js ADDED
@@ -0,0 +1,300 @@
1
+ // The lights of the Mixer: the state behind the small dot on every track strip and every group.
2
+ //
3
+ // Pure: no DOM, and the clocks are handed in, so node:test drives it with fake time. The page tells it which tracks
4
+ // the Mixer shows (setLayout), which notes were played (noteSeen) and, once per animation frame, asks which lights
5
+ // changed (frame). It never draws: the Mixer view writes what frame returns.
6
+ //
7
+ // What a light shows. With motion on, a light goes to full in the first frame at or after the moment its note is
8
+ // heard and falls in a straight line to nothing over LIGHT_FADE_MS; a note that comes while it fades starts it over.
9
+ // With motion off nothing blinks: a light stays lit while a note was heard within the last cycle. A group's light
10
+ // shows the brightest of the tracks below it, at any depth, folded or not.
11
+ //
12
+ // Why a note is kept as a time to convert, not as a converted time. A browser sound is due at a time on the audio
13
+ // clock; it is heard later, after the output delay, and the page maps one to the other from the audio context's
14
+ // output timestamp. That timestamp is read once per frame (not once per note: at 40 tracks that would be hundreds of
15
+ // reads a second), so the note waits here as its audio time and `toPerf` turns it into the page clock when a frame runs.
16
+ // A MIDI note already has the time its port got and needs no conversion.
17
+ //
18
+ // Why frame returns only what changed: the page writes to the document only for those lights, in steps of 1/64 of
19
+ // the brightness, so a fading light does not rewrite the same value and 40 tracks stay far under a frame's budget.
20
+ //
21
+ // The two offsets start at 0 and the fade at 180 ms. They are meant to be tuned by ear on real hardware.
22
+
23
+ import { FRESH_PAIR_MAX_AGE_MS } from './midi-clock.js';
24
+
25
+ /** How long a light takes to fall from full to dark, with motion on. */
26
+ export const LIGHT_FADE_MS = 180;
27
+ /** Added to the moment a browser sound is heard (positive: later). */
28
+ export const BROWSER_LIGHT_OFFSET_MS = 0;
29
+ /** Added to the timestamp a MIDI note got (positive: later). */
30
+ export const MIDI_LIGHT_OFFSET_MS = 0;
31
+ /** A light's brightness is rounded to this many steps from dark to full. */
32
+ export const LIGHT_STEPS = 64;
33
+ /** The most notes a track keeps waiting; the oldest goes first. */
34
+ export const MAX_PENDING_NOTES = 256;
35
+
36
+ const isFiniteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
37
+
38
+ /**
39
+ * Maps the audio time a note is due to the moment it is heard, on the page clock (performance.now()).
40
+ * `pair` is the audio context's output timestamp `{ contextTime, performanceTime }`; while it is fresh the mapping goes
41
+ * through it. Otherwise (right after the context starts, or while it is suspended) the time is shifted by the context's
42
+ * own latencies, a missing latency counting as 0.
43
+ */
44
+ export function heardMapper({ pair, currentTime, baseLatency, outputLatency, perfNow }) {
45
+ if (!isFiniteNumber(perfNow)) {
46
+ throw new Error(`Moontide: the lights need the page clock as a number, got ${String(perfNow)}.`);
47
+ }
48
+ const fresh =
49
+ pair !== null &&
50
+ typeof pair === 'object' &&
51
+ isFiniteNumber(pair.contextTime) &&
52
+ isFiniteNumber(pair.performanceTime) &&
53
+ pair.contextTime > 0 &&
54
+ pair.performanceTime > 0 &&
55
+ perfNow - pair.performanceTime < FRESH_PAIR_MAX_AGE_MS;
56
+ if (fresh) {
57
+ return (target) => pair.performanceTime + (target - pair.contextTime) * 1000 + BROWSER_LIGHT_OFFSET_MS;
58
+ }
59
+ if (!isFiniteNumber(currentTime)) {
60
+ throw new Error(`Moontide: the lights need the audio clock as a number, got ${String(currentTime)}.`);
61
+ }
62
+ const latencyMs = ((isFiniteNumber(baseLatency) ? baseLatency : 0) + (isFiniteNumber(outputLatency) ? outputLatency : 0)) * 1000;
63
+ return (target) => perfNow + (target - currentTime) * 1000 + latencyMs + BROWSER_LIGHT_OFFSET_MS;
64
+ }
65
+
66
+ /** Whether `cps` can drive the lights: a number above 0. A piece can set the tempo to 0 and set it back later. */
67
+ export function usableTempo(cps) {
68
+ return isFiniteNumber(cps) && cps > 0;
69
+ }
70
+
71
+ /**
72
+ * The audio context's output timestamp for `heardMapper`, or null when this browser has no such method; the mapper then
73
+ * shifts a time by the context's own latencies, as it does while the pair is stale.
74
+ */
75
+ export function readOutputPair(audio) {
76
+ return typeof audio.getOutputTimestamp === 'function' ? audio.getOutputTimestamp() : null;
77
+ }
78
+
79
+ /**
80
+ * What the lights need to know about a Mixer model: `{ tracks, ancestors }`. `tracks` has one entry
81
+ * `{ key, id, sounding, midi }` for every strip that has a light key (`key` is what a note names, `id` the strip's identity,
82
+ * which the view finds the light by, `midi` whether the strip plays a MIDI instrument). A strip that plays MIDI cannot sound in a browser without Web MIDI, so it counts as
83
+ * not sounding there. `ancestors` maps a strip's id to the keys of the groups above it, outermost first. A model that
84
+ * is not ready gives an empty layout.
85
+ */
86
+ export function lightLayout(model, { webMidi }) {
87
+ const tracks = [];
88
+ const ancestors = new Map();
89
+ if (model === null || typeof model !== 'object' || model.status !== 'ready') return { tracks, ancestors };
90
+ for (const strip of model.strips) {
91
+ if (strip.lightKey === null || strip.lightKey === undefined) continue;
92
+ const playsMidi = (strip.midi !== null && strip.midi !== undefined) || strip.playsMidi === true;
93
+ tracks.push({ key: strip.lightKey, id: strip.identity, sounding: strip.sounding === true && (webMidi === true || !playsMidi), midi: playsMidi });
94
+ }
95
+ if (model.layout !== null && model.layout !== undefined) {
96
+ for (const group of model.layout.groups) {
97
+ for (const id of group.members) {
98
+ const above = ancestors.get(id);
99
+ if (above === undefined) ancestors.set(id, [group.key]);
100
+ else above.push(group.key);
101
+ }
102
+ }
103
+ }
104
+ return { tracks, ancestors };
105
+ }
106
+
107
+ /**
108
+ * The light state. `setLayout(layout)` says which tracks and groups exist (see lightLayout); `noteSeen(key, heard)` queues
109
+ * a note, `heard` being `{ at }` (a time on the page clock, in ms) or `{ target }` (an audio time, in seconds);
110
+ * `frame(ts, { motion, cps, toPerf })` lights what is due at `ts` and returns `[{ kind, id, value }]` for the lights whose
111
+ * value changed since the last frame; `reset()` forgets every note and every lit light; `forgetShown()` forgets only which
112
+ * values were handed out, so the next frame hands out every lit value again and still darkens a light that has gone out
113
+ * meanwhile (after the view rebuilt some of its lights);
114
+ * `playsMidi(key)` tells whether the strip that owns `key` plays a MIDI instrument (false for an unknown key).
115
+ */
116
+ export function createLights() {
117
+ // key -> { id, sounding, midi }
118
+ let tracks = new Map();
119
+ // strip id -> keys of the groups above it
120
+ let ancestors = new Map();
121
+ // key -> the notes waiting to be heard, each { at } or { target }
122
+ const pending = new Map();
123
+ const pendingKeys = new Set();
124
+ // key -> { litAt, lastHeard }: when a track last lit in a frame, and the latest heard time among its notes
125
+ const states = new Map();
126
+ // The keys whose light may be lit now: the only ones a frame looks at.
127
+ const active = new Set();
128
+ // What the last frame returned that is not dark: key -> { id, value } for tracks, group key -> value for groups.
129
+ const shownTracks = new Map();
130
+ const shownGroups = new Map();
131
+ // Scratch maps of one frame, kept to write nothing new on the common path.
132
+ const trackValues = new Map();
133
+ const groupValues = new Map();
134
+
135
+ function forget(key) {
136
+ pending.delete(key);
137
+ pendingKeys.delete(key);
138
+ states.delete(key);
139
+ active.delete(key);
140
+ }
141
+
142
+ return {
143
+ setLayout(layout) {
144
+ if (layout === null || typeof layout !== 'object' || !Array.isArray(layout.tracks) || !(layout.ancestors instanceof Map)) {
145
+ throw new Error('Moontide: the lights need a layout of tracks and ancestors.');
146
+ }
147
+ const next = new Map();
148
+ // Strips can share a key (a muted copy `hats_` kept next to `hats`): the one that sounds owns the key, so the
149
+ // copy stays dark and the audible track blinks.
150
+ for (const track of layout.tracks) {
151
+ const known = next.get(track.key);
152
+ if (known !== undefined && known.sounding) continue;
153
+ next.set(track.key, { id: track.id, sounding: track.sounding === true, midi: track.midi === true });
154
+ }
155
+ tracks = next;
156
+ ancestors = layout.ancestors;
157
+ // A track that is gone or silent drops its notes and reads dark from the next frame on.
158
+ for (const key of [...pending.keys(), ...states.keys()]) {
159
+ const track = tracks.get(key);
160
+ if (track === undefined || !track.sounding) forget(key);
161
+ }
162
+ },
163
+
164
+ noteSeen(key, heard) {
165
+ const hasAt = heard !== null && typeof heard === 'object' && isFiniteNumber(heard.at);
166
+ const hasTarget = heard !== null && typeof heard === 'object' && isFiniteNumber(heard.target);
167
+ if (!hasAt && !hasTarget) {
168
+ throw new Error('Moontide: a note for the lights needs a heard time (at) or an audio time (target) that is a number.');
169
+ }
170
+ const track = tracks.get(key);
171
+ if (track === undefined || !track.sounding) return;
172
+ let queue = pending.get(key);
173
+ if (queue === undefined) {
174
+ queue = [];
175
+ pending.set(key, queue);
176
+ }
177
+ queue.push(hasAt ? { at: heard.at } : { target: heard.target });
178
+ if (queue.length > MAX_PENDING_NOTES) queue.shift();
179
+ pendingKeys.add(key);
180
+ },
181
+
182
+ frame(ts, { motion, cps, toPerf } = {}) {
183
+ if (!isFiniteNumber(ts)) throw new Error(`Moontide: a light frame needs a time that is a number, got ${String(ts)}.`);
184
+ if (motion !== 'on' && motion !== 'off') throw new Error(`Moontide: a light frame needs motion 'on' or 'off', got ${String(motion)}.`);
185
+ if (!isFiniteNumber(cps) || cps <= 0) throw new Error(`Moontide: a light frame needs a tempo above 0, got ${String(cps)}.`);
186
+ if (typeof toPerf !== 'function') throw new Error('Moontide: a light frame needs toPerf, the function that turns an audio time into the page clock.');
187
+
188
+ // Notes heard by now light their track in this frame. The whole queue is read: a note that arrives out of order must
189
+ // not wait behind one that is heard later.
190
+ for (const key of pendingKeys) {
191
+ const queue = pending.get(key);
192
+ let latest = -Infinity;
193
+ let kept = 0;
194
+ for (let i = 0; i < queue.length; i++) {
195
+ const note = queue[i];
196
+ const heard = note.at !== undefined ? note.at : toPerf(note.target);
197
+ if (!isFiniteNumber(heard)) throw new Error(`Moontide: toPerf gave ${String(heard)} for an audio time, not a number.`);
198
+ if (heard <= ts) {
199
+ if (heard > latest) latest = heard;
200
+ } else {
201
+ queue[kept++] = note;
202
+ }
203
+ }
204
+ queue.length = kept;
205
+ if (kept === 0) pendingKeys.delete(key);
206
+ if (latest === -Infinity) continue;
207
+ let state = states.get(key);
208
+ if (state === undefined) {
209
+ state = { litAt: ts, lastHeard: latest };
210
+ states.set(key, state);
211
+ } else {
212
+ state.litAt = ts;
213
+ if (latest > state.lastHeard) state.lastHeard = latest;
214
+ }
215
+ active.add(key);
216
+ }
217
+
218
+ // The value of every light that may be lit; a track whose value reached 0 leaves the active set.
219
+ trackValues.clear();
220
+ groupValues.clear();
221
+ const cycleMs = 1000 / cps;
222
+ for (const key of active) {
223
+ const state = states.get(key);
224
+ const track = tracks.get(key);
225
+ let value = 0;
226
+ if (state !== undefined && track !== undefined && track.sounding) {
227
+ if (motion === 'on') {
228
+ value = Math.round(Math.min(1, Math.max(0, 1 - (ts - state.litAt) / LIGHT_FADE_MS)) * LIGHT_STEPS) / LIGHT_STEPS;
229
+ } else {
230
+ value = ts - state.lastHeard <= cycleMs ? 1 : 0;
231
+ }
232
+ }
233
+ if (value <= 0) {
234
+ active.delete(key);
235
+ continue;
236
+ }
237
+ trackValues.set(key, value);
238
+ const above = ancestors.get(track.id);
239
+ if (above === undefined) continue;
240
+ for (const group of above) {
241
+ if (value > (groupValues.get(group) ?? 0)) groupValues.set(group, value);
242
+ }
243
+ }
244
+
245
+ const changes = [];
246
+ for (const [key, value] of trackValues) {
247
+ const shown = shownTracks.get(key);
248
+ const id = tracks.get(key).id;
249
+ // The strip that owns a shared key can change while its light is lit: the old strip goes dark and the new one shows
250
+ // the current value in this same frame.
251
+ if (shown !== undefined && shown.id !== id) {
252
+ changes.push({ kind: 'track', id: shown.id, value: 0 });
253
+ shown.id = id;
254
+ shown.value = -1;
255
+ }
256
+ if (shown !== undefined && shown.value === value) continue;
257
+ if (shown === undefined) shownTracks.set(key, { id, value });
258
+ else shown.value = value;
259
+ changes.push({ kind: 'track', id, value });
260
+ }
261
+ for (const [key, shown] of shownTracks) {
262
+ if (trackValues.has(key)) continue;
263
+ shownTracks.delete(key);
264
+ changes.push({ kind: 'track', id: shown.id, value: 0 });
265
+ }
266
+ for (const [group, value] of groupValues) {
267
+ if (shownGroups.get(group) === value) continue;
268
+ shownGroups.set(group, value);
269
+ changes.push({ kind: 'group', id: group, value });
270
+ }
271
+ for (const group of shownGroups.keys()) {
272
+ if (groupValues.has(group)) continue;
273
+ shownGroups.delete(group);
274
+ changes.push({ kind: 'group', id: group, value: 0 });
275
+ }
276
+ return changes;
277
+ },
278
+
279
+ forgetShown() {
280
+ // Keep the entries but mark their values unknown: a light that is lit gets its value handed out again, and one that
281
+ // has gone dark by the next frame still falls into the leaving loops and gets its dark change.
282
+ for (const shown of shownTracks.values()) shown.value = -1;
283
+ for (const group of shownGroups.keys()) shownGroups.set(group, -1);
284
+ },
285
+
286
+ playsMidi(key) {
287
+ const track = tracks.get(key);
288
+ return track !== undefined && track.midi === true;
289
+ },
290
+
291
+ reset() {
292
+ pending.clear();
293
+ pendingKeys.clear();
294
+ states.clear();
295
+ active.clear();
296
+ shownTracks.clear();
297
+ shownGroups.clear();
298
+ },
299
+ };
300
+ }