moontide-mcp 2.0.2 → 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 +41 -5
  2. package/dist/ask.js +30 -8
  3. package/dist/mcp.js +60 -14
  4. package/dist/protocol.js +89 -2
  5. package/dist/reference.js +22 -0
  6. package/dist/session.js +53 -0
  7. package/package.json +1 -1
  8. package/page/ask-view.js +21 -7
  9. package/page/ask.js +292 -34
  10. package/page/change-timing.js +25 -4
  11. package/page/harmony.js +13 -0
  12. package/page/index.html +4 -3
  13. package/page/jam-model.js +11 -2
  14. package/page/jam-view.js +17 -0
  15. package/page/lights.js +278 -0
  16. package/page/main.js +1723 -140
  17. package/page/midi-clock.js +1 -1
  18. package/page/midi-out.js +12 -0
  19. package/page/mixer-groups.js +114 -0
  20. package/page/mixer-model.js +172 -29
  21. package/page/mixer-rewrite.js +30 -1
  22. package/page/mixer-view.js +282 -13
  23. package/page/note-edit.js +267 -0
  24. package/page/note-locate.js +346 -0
  25. package/page/note-panel.js +381 -0
  26. package/page/note-tag.js +126 -0
  27. package/page/page-rewrite.js +184 -0
  28. package/page/song-edit.js +230 -0
  29. package/page/song-fit.js +250 -0
  30. package/page/song-grid-model.js +491 -0
  31. package/page/song-grid-view.js +710 -0
  32. package/page/song-prefs.js +100 -0
  33. package/page/structure-check.js +216 -0
  34. package/page/structure-view.js +38 -0
  35. package/page/structure.js +760 -0
  36. package/page/style.css +879 -84
  37. package/page/takeover.js +20 -5
  38. package/page/timeline-edit-view.js +1956 -0
  39. package/page/timeline-gesture.js +279 -0
  40. package/page/timeline.js +196 -3
  41. package/page/tracks.js +37 -8
  42. package/page/ui-state.js +10 -5
  43. package/page/views.js +25 -8
  44. package/reference/functions.md +1 -0
  45. package/reference/midi.md +2 -0
  46. package/reference/structure.md +131 -0
package/page/index.html CHANGED
@@ -97,13 +97,14 @@
97
97
  <div id="timeline-body" hidden>
98
98
  <div id="timeline-ruler"></div>
99
99
  <div id="timeline-sections"></div>
100
- <div id="timeline-lanes"></div>
100
+ <div id="timeline-lanes" tabindex="0" role="group" aria-label="Timeline notes"></div>
101
101
  <div id="timeline-playhead"><span class="playhead-cycle"></span></div>
102
102
  </div>
103
+ <section id="timeline-note" aria-label="Note" hidden><p id="timeline-note-line" role="status" aria-live="polite"></p></section>
103
104
  </div>
104
105
  <div id="mixer-view" hidden><p id="mixer-notice" hidden></p><p id="mixer-message" hidden></p><div id="mixer-body" hidden><div id="mixer-strips"></div><div id="mixer-inspector" hidden></div></div><p id="mixer-nonotes" hidden></p></div>
105
- <div id="parts-view" hidden><p id="parts-message"></p><button type="button" id="parts-ask" hidden></button><p id="parts-ask-status" hidden></p><div id="parts-rows"></div></div>
106
- <div id="jam-view" hidden><div id="jam-message" hidden><p id="jam-message-text"></p><p id="jam-message-sub" hidden></p></div><div id="jam-body" hidden><div id="jam-info"><p id="jam-declared" hidden><span id="jam-declared-label"></span><span id="jam-declared-value"></span></p><div id="jam-scale"><span id="jam-scale-label" hidden></span><h2 id="jam-scale-name"></h2><p id="jam-scale-sub"></p></div><div id="jam-degrees" role="list"></div><div id="jam-status" role="status" aria-live="polite" hidden><p id="jam-status-text"><strong id="jam-status-lead"></strong><span id="jam-status-rest"></span></p><span id="jam-status-time" aria-hidden="true"></span><span id="jam-status-spoken"></span></div><p id="jam-paused" hidden></p><div id="jam-coming"><p id="jam-coming-caption"></p><div id="jam-rows"></div></div></div><div id="jam-instrument"><div id="jam-controls"><div id="jam-instrument-switch" role="group"><button type="button" id="jam-instrument-piano">Piano</button><button type="button" id="jam-instrument-guitar">Guitar</button></div><label id="jam-chord-tones-label"><input type="checkbox" id="jam-chord-tones"> <span id="jam-chord-tones-text"></span></label><div id="jam-legend"><span id="jam-legend-chord" class="jam-legend-entry" hidden></span></div></div><p id="jam-chord" hidden></p><div id="jam-board"><div id="jam-piano" role="img"></div><div id="jam-guitar" role="img" hidden></div></div></div></div></div>
106
+ <div id="parts-view" hidden><p id="parts-message"></p><button type="button" id="parts-ask" hidden></button><p id="parts-ask-status" hidden></p><div id="song-notes" hidden></div><div id="song-bar" hidden><span id="song-launch-label"></span><div id="song-launch" role="group" aria-labelledby="song-launch-label"></div><p id="song-now" hidden></p></div><p id="song-line" hidden role="status" aria-live="polite"></p><div id="parts-rows"></div><div id="song-cell-list" hidden role="listbox" tabindex="-1"></div></div>
107
+ <div id="jam-view" hidden><div id="jam-message" hidden><p id="jam-message-text"></p><p id="jam-message-sub" hidden></p></div><div id="jam-body" hidden><div id="jam-info"><p id="jam-declared" hidden><span id="jam-declared-label"></span><span id="jam-declared-value"></span><span id="jam-declared-section" hidden></span></p><div id="jam-scale"><span id="jam-scale-label" hidden></span><h2 id="jam-scale-name"></h2><p id="jam-scale-sub"></p></div><div id="jam-degrees" role="list"></div><div id="jam-status" role="status" aria-live="polite" hidden><p id="jam-status-text"><strong id="jam-status-lead"></strong><span id="jam-status-rest"></span></p><span id="jam-status-time" aria-hidden="true"></span><span id="jam-status-spoken"></span></div><p id="jam-paused" hidden></p><div id="jam-coming"><p id="jam-coming-caption"></p><div id="jam-rows"></div></div></div><div id="jam-instrument"><div id="jam-controls"><div id="jam-instrument-switch" role="group"><button type="button" id="jam-instrument-piano">Piano</button><button type="button" id="jam-instrument-guitar">Guitar</button></div><label id="jam-chord-tones-label"><input type="checkbox" id="jam-chord-tones"> <span id="jam-chord-tones-text"></span></label><div id="jam-legend"><span id="jam-legend-chord" class="jam-legend-entry" hidden></span></div></div><p id="jam-chord" hidden></p><div id="jam-board"><div id="jam-piano" role="img"></div><div id="jam-guitar" role="img" hidden></div></div></div></div></div>
107
108
  <div id="instruments-view" hidden tabindex="-1"><p id="instruments-notice" hidden></p><div id="instruments-message" hidden><p id="instruments-message-text"></p><code id="instruments-folder" hidden></code><p id="instruments-message-sub" hidden></p><ul id="instruments-problems" hidden></ul><p id="instruments-message-closing" hidden></p></div><div id="instruments-body" hidden><ul id="instruments-list" role="list" aria-label="Instruments"></ul><p id="instruments-footer"></p></div><span id="instruments-status" role="status" aria-live="polite"></span></div>
108
109
  </section>
109
110
  </div>
package/page/jam-model.js CHANGED
@@ -44,6 +44,8 @@ export const JAM_COPY = {
44
44
  heardLabel: 'Heard:',
45
45
  noFitDeclared: 'The notes fit none of the nine scales.',
46
46
  heardUnclearLabel: 'The heard scale is not clear.',
47
+ // The muted words after the Declared value of a piece with sections: the section whose harmony the line shows.
48
+ declaredSection: 'section {section}',
47
49
  declaredReasons: {
48
50
  'not-const': 'Not readable: harmony is declared with let or var; the page reads only const.',
49
51
  computed: 'Not readable: harmony is computed ({source}); the page reads only written text.',
@@ -473,9 +475,13 @@ function statusOf(position, paused) {
473
475
  * (even an unreadable declaration) no sentence says 'No clear scale', and a reading with no pitched note is a ready
474
476
  * model of kind 'none' instead of the message model, so the Declared line stays on screen. The heard scale, the
475
477
  * degrees, the status, the rows and the instrument roles are the ones of the same notes without a declaration.
478
+ * A piece with sections shows the harmony of the section that plays: `declaredOverride` (the same shapes as a reading's
479
+ * `declared`) replaces the one in the reading, and `section` is that section's name as the page shows it, quoted; every
480
+ * ready model carries `section`, the words 'section {name}' for a piece with sections and null otherwise.
476
481
  */
477
- export function buildJamModel({ position, chordTones = false }) {
478
- const declaredState = position.reading.declared ?? { state: 'none' };
482
+ export function buildJamModel({ position, chordTones = false, declaredOverride, section = null }) {
483
+ const declaredState = declaredOverride ?? position.reading.declared ?? { state: 'none' };
484
+ const sectionText = section === null || section === undefined ? null : JAM_COPY.declaredSection.replace('{section}', () => section);
479
485
  const declaredMode = declaredState.state !== 'none';
480
486
  const declared = declaredLine(declaredState);
481
487
  if (position.reading.status === 'none' && !declaredMode) {
@@ -507,6 +513,7 @@ export function buildJamModel({ position, chordTones = false }) {
507
513
  chordLine,
508
514
  noteScaleName,
509
515
  declared,
516
+ section: sectionText,
510
517
  };
511
518
  }
512
519
  if (scale.status === 'clear') {
@@ -536,6 +543,7 @@ export function buildJamModel({ position, chordTones = false }) {
536
543
  chordLine,
537
544
  noteScaleName,
538
545
  declared,
546
+ section: sectionText,
539
547
  };
540
548
  }
541
549
  if (scale.status === 'ambiguous' || scale.status === 'no-fit') {
@@ -551,6 +559,7 @@ export function buildJamModel({ position, chordTones = false }) {
551
559
  chordLine,
552
560
  noteScaleName,
553
561
  declared,
562
+ section: sectionText,
554
563
  };
555
564
  }
556
565
  throw new Error(`Moontide: the Jam model cannot draw a scale reading with status '${String(scale.status)}'`);
package/page/jam-view.js CHANGED
@@ -24,6 +24,7 @@ export function createJamView({ document, onInstrumentChange, onChordTonesChange
24
24
  const jamDeclared = document.getElementById('jam-declared');
25
25
  const jamDeclaredLabel = document.getElementById('jam-declared-label');
26
26
  const jamDeclaredValue = document.getElementById('jam-declared-value');
27
+ const jamDeclaredSection = document.getElementById('jam-declared-section');
27
28
  const jamScaleLabel = document.getElementById('jam-scale-label');
28
29
  const jamScaleName = document.getElementById('jam-scale-name');
29
30
  const jamScaleSub = document.getElementById('jam-scale-sub');
@@ -165,6 +166,7 @@ export function createJamView({ document, onInstrumentChange, onChordTonesChange
165
166
  let shownDeclaredKind = null;
166
167
  let shownDeclaredText = null;
167
168
  let shownDeclaredTitle = null;
169
+ let shownDeclaredSection = null;
168
170
  let shownName = null;
169
171
  let shownKind = null;
170
172
  let shownSub = null;
@@ -222,6 +224,20 @@ export function createJamView({ document, onInstrumentChange, onChordTonesChange
222
224
  }
223
225
  }
224
226
 
227
+ // The section the Declared line speaks for: its words after the value, hidden for a piece without sections.
228
+ function renderDeclaredSection(section) {
229
+ if (section === undefined) throw new Error('Moontide: a ready Jam model needs its section field (null without sections).');
230
+ if (section === shownDeclaredSection) return;
231
+ if (section === null) {
232
+ jamDeclaredSection.textContent = '';
233
+ jamDeclaredSection.hidden = true;
234
+ } else {
235
+ jamDeclaredSection.textContent = section;
236
+ jamDeclaredSection.hidden = false;
237
+ }
238
+ shownDeclaredSection = section;
239
+ }
240
+
225
241
  function renderScale(scale, degrees) {
226
242
  if (scale.text !== shownName) {
227
243
  jamScaleName.textContent = scale.text;
@@ -477,6 +493,7 @@ export function createJamView({ document, onInstrumentChange, onChordTonesChange
477
493
  }
478
494
  lastModel = model;
479
495
  renderDeclared(model.declared);
496
+ renderDeclaredSection(model.section);
480
497
  renderScale(model.scale, model.degrees);
481
498
  renderStatus(model.status);
482
499
  if (model.paused !== shownPaused) {
package/page/lights.js ADDED
@@ -0,0 +1,278 @@
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 }` 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). 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) });
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.
112
+ */
113
+ export function createLights() {
114
+ // key -> { id, sounding }
115
+ let tracks = new Map();
116
+ // strip id -> keys of the groups above it
117
+ let ancestors = new Map();
118
+ // key -> the notes waiting to be heard, each { at } or { target }
119
+ const pending = new Map();
120
+ const pendingKeys = new Set();
121
+ // key -> { litAt, lastHeard }: when a track last lit in a frame, and the latest heard time among its notes
122
+ const states = new Map();
123
+ // The keys whose light may be lit now: the only ones a frame looks at.
124
+ const active = new Set();
125
+ // What the last frame returned that is not dark: key -> { id, value } for tracks, group key -> value for groups.
126
+ const shownTracks = new Map();
127
+ const shownGroups = new Map();
128
+ // Scratch maps of one frame, kept to write nothing new on the common path.
129
+ const trackValues = new Map();
130
+ const groupValues = new Map();
131
+
132
+ function forget(key) {
133
+ pending.delete(key);
134
+ pendingKeys.delete(key);
135
+ states.delete(key);
136
+ active.delete(key);
137
+ }
138
+
139
+ return {
140
+ setLayout(layout) {
141
+ if (layout === null || typeof layout !== 'object' || !Array.isArray(layout.tracks) || !(layout.ancestors instanceof Map)) {
142
+ throw new Error('Moontide: the lights need a layout of tracks and ancestors.');
143
+ }
144
+ const next = new Map();
145
+ // Strips can share a key (a muted copy `hats_` kept next to `hats`): the one that sounds owns the key, so the
146
+ // copy stays dark and the audible track blinks.
147
+ for (const track of layout.tracks) {
148
+ const known = next.get(track.key);
149
+ if (known !== undefined && known.sounding) continue;
150
+ next.set(track.key, { id: track.id, sounding: track.sounding === true });
151
+ }
152
+ tracks = next;
153
+ ancestors = layout.ancestors;
154
+ // A track that is gone or silent drops its notes and reads dark from the next frame on.
155
+ for (const key of [...pending.keys(), ...states.keys()]) {
156
+ const track = tracks.get(key);
157
+ if (track === undefined || !track.sounding) forget(key);
158
+ }
159
+ },
160
+
161
+ noteSeen(key, heard) {
162
+ const hasAt = heard !== null && typeof heard === 'object' && isFiniteNumber(heard.at);
163
+ const hasTarget = heard !== null && typeof heard === 'object' && isFiniteNumber(heard.target);
164
+ if (!hasAt && !hasTarget) {
165
+ throw new Error('Moontide: a note for the lights needs a heard time (at) or an audio time (target) that is a number.');
166
+ }
167
+ const track = tracks.get(key);
168
+ if (track === undefined || !track.sounding) return;
169
+ let queue = pending.get(key);
170
+ if (queue === undefined) {
171
+ queue = [];
172
+ pending.set(key, queue);
173
+ }
174
+ queue.push(hasAt ? { at: heard.at } : { target: heard.target });
175
+ if (queue.length > MAX_PENDING_NOTES) queue.shift();
176
+ pendingKeys.add(key);
177
+ },
178
+
179
+ frame(ts, { motion, cps, toPerf } = {}) {
180
+ if (!isFiniteNumber(ts)) throw new Error(`Moontide: a light frame needs a time that is a number, got ${String(ts)}.`);
181
+ if (motion !== 'on' && motion !== 'off') throw new Error(`Moontide: a light frame needs motion 'on' or 'off', got ${String(motion)}.`);
182
+ if (!isFiniteNumber(cps) || cps <= 0) throw new Error(`Moontide: a light frame needs a tempo above 0, got ${String(cps)}.`);
183
+ if (typeof toPerf !== 'function') throw new Error('Moontide: a light frame needs toPerf, the function that turns an audio time into the page clock.');
184
+
185
+ // Notes heard by now light their track in this frame. The whole queue is read: a note that arrives out of order must
186
+ // not wait behind one that is heard later.
187
+ for (const key of pendingKeys) {
188
+ const queue = pending.get(key);
189
+ let latest = -Infinity;
190
+ let kept = 0;
191
+ for (let i = 0; i < queue.length; i++) {
192
+ const note = queue[i];
193
+ const heard = note.at !== undefined ? note.at : toPerf(note.target);
194
+ if (!isFiniteNumber(heard)) throw new Error(`Moontide: toPerf gave ${String(heard)} for an audio time, not a number.`);
195
+ if (heard <= ts) {
196
+ if (heard > latest) latest = heard;
197
+ } else {
198
+ queue[kept++] = note;
199
+ }
200
+ }
201
+ queue.length = kept;
202
+ if (kept === 0) pendingKeys.delete(key);
203
+ if (latest === -Infinity) continue;
204
+ let state = states.get(key);
205
+ if (state === undefined) {
206
+ state = { litAt: ts, lastHeard: latest };
207
+ states.set(key, state);
208
+ } else {
209
+ state.litAt = ts;
210
+ if (latest > state.lastHeard) state.lastHeard = latest;
211
+ }
212
+ active.add(key);
213
+ }
214
+
215
+ // The value of every light that may be lit; a track whose value reached 0 leaves the active set.
216
+ trackValues.clear();
217
+ groupValues.clear();
218
+ const cycleMs = 1000 / cps;
219
+ for (const key of active) {
220
+ const state = states.get(key);
221
+ const track = tracks.get(key);
222
+ let value = 0;
223
+ if (state !== undefined && track !== undefined && track.sounding) {
224
+ if (motion === 'on') {
225
+ value = Math.round(Math.min(1, Math.max(0, 1 - (ts - state.litAt) / LIGHT_FADE_MS)) * LIGHT_STEPS) / LIGHT_STEPS;
226
+ } else {
227
+ value = ts - state.lastHeard <= cycleMs ? 1 : 0;
228
+ }
229
+ }
230
+ if (value <= 0) {
231
+ active.delete(key);
232
+ continue;
233
+ }
234
+ trackValues.set(key, value);
235
+ const above = ancestors.get(track.id);
236
+ if (above === undefined) continue;
237
+ for (const group of above) {
238
+ if (value > (groupValues.get(group) ?? 0)) groupValues.set(group, value);
239
+ }
240
+ }
241
+
242
+ const changes = [];
243
+ for (const [key, value] of trackValues) {
244
+ const shown = shownTracks.get(key);
245
+ if (shown !== undefined && shown.value === value) continue;
246
+ const id = tracks.get(key).id;
247
+ if (shown === undefined) shownTracks.set(key, { id, value });
248
+ else shown.value = value;
249
+ changes.push({ kind: 'track', id, value });
250
+ }
251
+ for (const [key, shown] of shownTracks) {
252
+ if (trackValues.has(key)) continue;
253
+ shownTracks.delete(key);
254
+ changes.push({ kind: 'track', id: shown.id, value: 0 });
255
+ }
256
+ for (const [group, value] of groupValues) {
257
+ if (shownGroups.get(group) === value) continue;
258
+ shownGroups.set(group, value);
259
+ changes.push({ kind: 'group', id: group, value });
260
+ }
261
+ for (const group of shownGroups.keys()) {
262
+ if (groupValues.has(group)) continue;
263
+ shownGroups.delete(group);
264
+ changes.push({ kind: 'group', id: group, value: 0 });
265
+ }
266
+ return changes;
267
+ },
268
+
269
+ reset() {
270
+ pending.clear();
271
+ pendingKeys.clear();
272
+ states.clear();
273
+ active.clear();
274
+ shownTracks.clear();
275
+ shownGroups.clear();
276
+ },
277
+ };
278
+ }