moontide-mcp 2.1.0 → 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.
@@ -148,6 +148,9 @@ function defaultOnError(text, err) {
148
148
  * - onState(port, state): called when a port's state changes.
149
149
  * - onError(text, err).
150
150
  * - alignment: { ms }, default the page-wide one.
151
+ * - rampSeconds(fromCycle, toCycle): while a tempo ramp runs, `{ seconds, lagSeconds }`: the seconds of music between two cycles
152
+ * through the ramp's steps and how far that can be off when the tempo trails the plan by a step; null when no ramp runs
153
+ * (default: always null). Only stampOfCycle uses it: a line far enough ahead to lie behind several tempo steps.
151
154
  * Returns { want, stop, stateOf, holdsNotes, stamp, stampOfCycle, stats }.
152
155
  */
153
156
  export function createPageClock({
@@ -162,6 +165,7 @@ export function createPageClock({
162
165
  onState = () => {},
163
166
  onError = defaultOnError,
164
167
  alignment = pageAlignment,
168
+ rampSeconds = () => null,
165
169
  }) {
166
170
  // port -> { entry: { port, id }, nextTick, startAt, startCycle, stopOwedFrom, holdSince, holdExpired }
167
171
  const wanted = new Map();
@@ -202,6 +206,20 @@ export function createPageClock({
202
206
  return (c - seg.c) / seg.cps + seg.s + s.latency;
203
207
  }
204
208
 
209
+ /**
210
+ * AudioContext time of the cycle line `c` a change waits for. Like audioTimeOf, except that while a tempo ramp runs the
211
+ * seconds from the start of the latest tempo segment to the line go through the ramp's own steps: one segment at the
212
+ * tempo of the moment puts a line early on a slowing ramp and late on a rising one. With `early` the time is the
213
+ * earliest the line can be heard, the ramp's lag taken off, for a setup that must land before the line.
214
+ */
215
+ function lineAudioTimeOf(s, c, early) {
216
+ let seg = segments[0];
217
+ for (const candidate of segments) if (candidate.c <= c) seg = candidate;
218
+ const through = c > seg.c ? rampSeconds(seg.c, c) : null;
219
+ const seconds = through === null ? (c - seg.c) / seg.cps : through.seconds - (early ? through.lagSeconds : 0);
220
+ return seconds + seg.s + s.latency;
221
+ }
222
+
205
223
  /**
206
224
  * performance.now() time of an AudioContext time, from getOutputTimestamp pairs lined up with Strudel's own
207
225
  * mapping by the page-wide alignment. Null when nothing can be scheduled yet (no alignment, no usable pair).
@@ -275,16 +293,17 @@ export function createPageClock({
275
293
  * a tick and a stamped note of that position use), or null when none can be given: the cycle is not a finite
276
294
  * number, the scheduler cannot be read or does not run, its numbers cannot be used, no tempo segment is known yet,
277
295
  * or stamp() has none. Reads the scheduler and notes its segment as a pass does, so it also serves while no
278
- * instrument wants the clock. Never throws.
296
+ * instrument wants the clock. While a tempo ramp runs the line is worked out through the ramp, and with `early` it is the
297
+ * earliest time the line can be heard (see lineAudioTimeOf). Never throws.
279
298
  */
280
- function stampOfCycle(cycle) {
299
+ function stampOfCycle(cycle, { early = false } = {}) {
281
300
  if (typeof cycle !== 'number' || !Number.isFinite(cycle)) return null;
282
301
  try {
283
302
  const s = readCyclist(getScheduler());
284
303
  if (!s.started || unusableReason(s) !== null) return null;
285
304
  if (s.num_ticks_since_cps_change !== 0) noteSegment(s);
286
305
  if (segments.length === 0) return null;
287
- return stamp(audioTimeOf(s, cycle));
306
+ return stamp(lineAudioTimeOf(s, cycle, early));
288
307
  } catch {
289
308
  return null;
290
309
  }
package/page/midi-out.js CHANGED
@@ -193,6 +193,8 @@ export function createMidiOut({
193
193
  getScheduler,
194
194
  getAudioContext,
195
195
  perfNow,
196
+ // (fromCycle, toCycle) => { seconds, lagSeconds } through a running tempo ramp, or null: lines far ahead are stamped through it.
197
+ rampSeconds = () => null,
196
198
  onClockState = () => {},
197
199
  resetSettleMs = RESET_SETTLE_MS,
198
200
  now = () => Date.now(),
@@ -1550,6 +1552,7 @@ export function createMidiOut({
1550
1552
  getScheduler,
1551
1553
  getAudioContext,
1552
1554
  perfNow,
1555
+ rampSeconds,
1553
1556
  setTimer,
1554
1557
  clearTimer,
1555
1558
  isPlaying,
@@ -1899,7 +1902,7 @@ export function createMidiOut({
1899
1902
 
1900
1903
  /**
1901
1904
  * The timestamp for the setup of a change that waits for cycle `atCycle`: the page clock's stamp of that line minus
1902
- * SETUP_BEFORE_LINE_MS. When no stamp can be given or it is more than NOTE_STAMP_MAX_AHEAD_MS ahead (a far timestamp
1905
+ * SETUP_BEFORE_LINE_MS (while a tempo ramp runs, the earliest the line can be heard: the setup must land before it). When no stamp can be given or it is more than NOTE_STAMP_MAX_AHEAD_MS ahead (a far timestamp
1903
1906
  * would park bytes in the OS queue, which cannot take them back), says so in one Moontide: line, waits until the
1904
1907
  * line through the injected timer and resolves undefined: the setup then goes out without a timestamp. `track` gets
1905
1908
  * { cancel } while that wait runs, so a dropped plan ends it. Never rejects.
@@ -1907,7 +1910,7 @@ export function createMidiOut({
1907
1910
  async function timedSetupAt(atCycle, track) {
1908
1911
  let line = null;
1909
1912
  try {
1910
- line = pageClock === null ? null : pageClock.stampOfCycle(atCycle);
1913
+ line = pageClock === null ? null : pageClock.stampOfCycle(atCycle, { early: true });
1911
1914
  } catch (err) {
1912
1915
  onError('Moontide: the page clock could not give the timestamp of the line:', err);
1913
1916
  }
@@ -2048,7 +2048,7 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
2048
2048
  mixerStrips.replaceChildren();
2049
2049
  renderInspector();
2050
2050
  refreshNotice();
2051
- return;
2051
+ return { created: 0 };
2052
2052
  }
2053
2053
  mixerMessage.hidden = true;
2054
2054
  mixerBody.hidden = false;
@@ -2058,6 +2058,9 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
2058
2058
  if (noNotesText === null) mixerNoNotes.removeAttribute('title');
2059
2059
  else mixerNoNotes.title = MIXER_COPY.noNotesTitle;
2060
2060
  mixerNoNotes.hidden = noNotesText === null;
2061
+ // How many strips and group frames this render built new. A new element starts dark, so the caller makes the lights write
2062
+ // their lit values again when it is more than 0.
2063
+ let created = 0;
2061
2064
  const seen = new Set();
2062
2065
  // The strips placed in the DOM whose fader the hand holds: they are not moved or removed until it lets go.
2063
2066
  const held = new Set();
@@ -2081,6 +2084,7 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
2081
2084
  if (!ref) {
2082
2085
  ref = createStrip(strip);
2083
2086
  refs.set(strip.identity, ref);
2087
+ created += 1;
2084
2088
  }
2085
2089
  updateStrip(ref, strip);
2086
2090
  });
@@ -2124,6 +2128,7 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
2124
2128
  if (groupRef === undefined) {
2125
2129
  groupRef = createGroupFrame(item);
2126
2130
  groupRefs.set(item.key, groupRef);
2131
+ created += 1;
2127
2132
  }
2128
2133
  updateGroupFrame(groupRef, item);
2129
2134
  desired.push(groupRef.el);
@@ -2169,6 +2174,7 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
2169
2174
  refs.delete(identity);
2170
2175
  }
2171
2176
  refreshDisabled();
2177
+ return { created };
2172
2178
  }
2173
2179
 
2174
2180
  return {
@@ -267,19 +267,40 @@ export function rangeWhereText(track) {
267
267
  return fill(TIMELINE_COPY.noteRangeWhere, { lane: track });
268
268
  }
269
269
 
270
+ /** The longest section part of a range's identity line, the same 200 characters as every text of a request. */
271
+ const MAX_RANGE_SECTIONS_TEXT = 200;
272
+
273
+ /**
274
+ * The section part of a range's identity line for `sections` (names in playing order) and the `moreSections` names a list already
275
+ * left out. A list that fits shows whole; a longer one keeps the first names that, with ' and N more', fit 200 characters (at least
276
+ * the first), and N counts every name not shown.
277
+ */
278
+ function rangeSectionsText(sections, moreSections) {
279
+ const quoted = sections.map((name) => `'${displayName(name)}'`);
280
+ if (sections.length === 1 && moreSections === 0) return fill(TIMELINE_COPY.noteSection, { section: displayName(sections[0]) });
281
+ const build = (shown) => {
282
+ const left = sections.length - shown + moreSections;
283
+ return fill(TIMELINE_COPY.noteRangeSectionList, { list: quoted.slice(0, shown).join(', ') }) + (left > 0 ? fill(TIMELINE_COPY.noteRangeSectionsMore, { n: left }) : '');
284
+ };
285
+ let shown = sections.length;
286
+ while (shown > 1 && build(shown).length > MAX_RANGE_SECTIONS_TEXT) shown--;
287
+ return build(shown);
288
+ }
289
+
270
290
  /**
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.
291
+ * The muted part of the identity line of a range: ` · cycles 12 to 15` (` · cycle 12` for one), the sections it crosses when the
292
+ * piece has a song (` · section 'y'`, ` · sections 'z', 'x', 'y'` in playing order, a section that comes back named again) and the
293
+ * notes (` · 14 notes`, ` · 1 note`, ` · no notes`). `range` is `{ from, to, sections, moreSections, count }`; `sections` is a
294
+ * list of names or absent (a piece without a song) and `moreSections` counts the names the list left out.
274
295
  */
275
- export function rangePositionText({ from, to, section = null, sectionTo = null, count }) {
296
+ export function rangePositionText({ from, to, sections = null, moreSections = 0, count }) {
276
297
  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
298
  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.');
299
+ if (!Number.isInteger(moreSections) || moreSections < 0) throw new Error('Moontide: a range has a count of more sections of at least 0.');
300
+ if (sections !== null && sections.length === 0) throw new Error('Moontide: a range that names its sections names at least one section.');
301
+ if (sections === null && moreSections > 0) throw new Error('Moontide: a range cannot have more sections without a list of sections.');
279
302
  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
- }
303
+ if (sections !== null) text += rangeSectionsText(sections, moreSections);
283
304
  if (count === 0) return text + TIMELINE_COPY.noteRangeNoNotes;
284
305
  return text + (count === 1 ? TIMELINE_COPY.noteRangeOneNote : fill(TIMELINE_COPY.noteRangeNotes, { n: count }));
285
306
  }
package/page/note-tag.js CHANGED
@@ -24,6 +24,9 @@ import { labelKey } from './tracks.js';
24
24
 
25
25
  const subscribers = new Set();
26
26
 
27
+ /** How many different failure messages are printed before one closing line says the rest are not. */
28
+ export const MAX_REPORTED_FAILURES = 20;
29
+
27
30
  /**
28
31
  * Calls `listener(event)` for every note of a labeled track, where event is `{ key, hap, target,
29
32
  * stamp }`. The key is the track's Mixer identity, or `$n` for the n-th `$` statement of the
@@ -64,14 +67,26 @@ export function installNoteTag(PatternClass, { stampOf } = {}) {
64
67
  // Undefined at page start: the repl assigns p only when it evaluates code.
65
68
  let repl = PatternClass.prototype.p;
66
69
  let dollar = 0;
67
- let errorReported = false;
70
+ // The messages already printed. The tag is installed once per page load, so each different failure is said once per load,
71
+ // and the set cannot grow past MAX_REPORTED_FAILURES.
72
+ const reportedMessages = new Set();
73
+ let boundSaid = false;
68
74
  // A note reaches one observer per tag; this keeps a pattern tagged twice under one key to one report.
69
75
  const reported = new WeakSet();
70
76
 
77
+ // A listener that throws on every note would print on every note; one that fails differently each time would never stop.
78
+ // So a failure is printed once per message, and after MAX_REPORTED_FAILURES different messages one last line says so.
71
79
  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);
80
+ const message = err instanceof Error ? err.message : String(err);
81
+ if (reportedMessages.has(message)) return;
82
+ if (reportedMessages.size >= MAX_REPORTED_FAILURES) {
83
+ if (boundSaid) return;
84
+ boundSaid = true;
85
+ console.error('Moontide: more note listener failures happened; they are not reported.');
86
+ return;
87
+ }
88
+ reportedMessages.add(message);
89
+ console.error('Moontide: a note listener or the MIDI timestamp lookup failed:', err);
75
90
  }
76
91
 
77
92
  function observerFor(key) {
@@ -46,7 +46,7 @@ function refused(reason) {
46
46
  * evalError() -> the evaluation's error or null,
47
47
  * revertAndReport(error) -> { errorText, line }: sets the editor back to the committed code and shows the banner,
48
48
  * markPageOrigin() -> the playing code now has no Claude evaluate id,
49
- * whenFinished(record, fn) -> runs fn when the waiting change takes over (or is dropped),
49
+ * whenFinished(record, fn) -> runs fn when the waiting change takes over; never when it is canceled (replaced or stopped),
50
50
  * markPageOriginAtTakeover() -> the same mark, for the moment a waiting change takes over.
51
51
  */
52
52
  export function createPageRewrite(deps) {
@@ -80,8 +80,13 @@ export function captureQueryTypes(kept, globals) {
80
80
  * `onReached` when the gate first queries past the line, because the old music that keeps playing must
81
81
  * not have its errors forwarded a second time and the new pattern's first errors must still count.
82
82
  * `getPlayingCode()` gives the code that plays now, for the waiting record.
83
+ *
84
+ * `refuse`, when given, is asked first, for every value handed over, before the pattern is checked or installed: when it
85
+ * returns a text, setPattern throws an Error with that text. This is the one seam where a change can be refused for
86
+ * something only the finished program knows (the tempo it set) and still fail the evaluation before the new pattern or a
87
+ * waiting record exists: the scheduler's pattern and any waiting change stay as they were.
83
88
  */
84
- export function installQueryCheck(scheduler, { getTypes, queryErrors, schedulerQueries, takeover, getPlayingCode }) {
89
+ export function installQueryCheck(scheduler, { getTypes, queryErrors, schedulerQueries, takeover, getPlayingCode, refuse }) {
85
90
  const originalSetPattern = scheduler.setPattern;
86
91
  if (typeof originalSetPattern !== 'function') {
87
92
  throw new Error('Moontide: the scheduler of the pinned @strudel/repl 1.3.0 has no setPattern function.');
@@ -103,6 +108,10 @@ export function installQueryCheck(scheduler, { getTypes, queryErrors, schedulerQ
103
108
  return result;
104
109
  }
105
110
  scheduler.setPattern = function (pattern, autostart) {
111
+ if (typeof refuse === 'function') {
112
+ const refusal = refuse();
113
+ if (typeof refusal === 'string') throw new Error(refusal);
114
+ }
106
115
  // The repl's own handling of a value that is not a pattern stays as before (a waiting change is replaced too).
107
116
  if (pattern === null || pattern === undefined || typeof pattern.query !== 'function') {
108
117
  queryErrors.reset();
@@ -154,8 +163,11 @@ export function installQueryCheck(scheduler, { getTypes, queryErrors, schedulerQ
154
163
  * to `onError` as the bundle's log text (`[query] error: <message>`), then return no events as the
155
164
  * bundle does. Every other query keeps the bundle's own `queryArc`, whose log the page ignores.
156
165
  * `getTypes()` gives { State, TimeSpan }.
166
+ * `onSchedulerQuery`, when given, is called once on each of the scheduler's own queries, before the query runs (it is the
167
+ * tempo ramp's way to learn that the scheduler is playing). It is the scheduler's slice that calls it, so a throw is
168
+ * caught here, logged as one `Moontide:` line and never reaches the scheduler.
157
169
  */
158
- export function createSchedulerQueryScope({ getTypes, onError }) {
170
+ export function createSchedulerQueryScope({ getTypes, onError, onSchedulerQuery }) {
159
171
  const wrapped = new WeakSet();
160
172
  return {
161
173
  wrap(pattern) {
@@ -169,6 +181,13 @@ export function createSchedulerQueryScope({ getTypes, onError }) {
169
181
  if (controls === null || typeof controls !== 'object' || typeof controls.cyclist !== 'string') {
170
182
  return original.call(this, begin, end, controls);
171
183
  }
184
+ if (typeof onSchedulerQuery === 'function') {
185
+ try {
186
+ onSchedulerQuery();
187
+ } catch (err) {
188
+ console.error('Moontide: the hook on the scheduler query failed:', err);
189
+ }
190
+ }
172
191
  const { State, TimeSpan } = getTypes();
173
192
  try {
174
193
  return this.query(new State(new TimeSpan(begin, end), controls));