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.
- package/README.md +54 -2
- package/dist/ask.js +47 -8
- package/dist/mcp.js +143 -17
- package/dist/protocol.js +43 -2
- package/dist/request-status.js +221 -0
- package/dist/session.js +203 -7
- package/package.json +1 -1
- package/page/ask-view.js +43 -50
- package/page/ask.js +82 -165
- package/page/bar-model.js +43 -2
- package/page/change-timing.js +35 -0
- package/page/index.html +4 -2
- package/page/jam-analysis.js +68 -23
- package/page/jam-model.js +185 -29
- package/page/jam-resolve.js +271 -0
- package/page/jam-view.js +165 -10
- package/page/lights.js +29 -7
- package/page/main.js +459 -59
- package/page/midi-clock.js +22 -3
- package/page/midi-out.js +5 -2
- package/page/mixer-view.js +7 -1
- package/page/note-panel.js +29 -8
- package/page/note-tag.js +19 -4
- package/page/page-rewrite.js +1 -1
- package/page/query-check.js +21 -2
- package/page/request-status-view.js +419 -0
- package/page/request-status.js +686 -0
- package/page/song-grid-model.js +15 -0
- package/page/song-grid-view.js +1 -1
- package/page/structure.js +2 -1
- package/page/style.css +389 -31
- package/page/tempo-glide.js +330 -0
- package/page/tempo-guard.js +62 -10
- package/page/timeline-edit-view.js +1 -1
- package/page/timeline.js +2 -1
- package/page/top-bar-view.js +18 -13
- package/page/views.js +15 -2
- package/reference/transitions.md +13 -1
package/page/midi-clock.js
CHANGED
|
@@ -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.
|
|
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(
|
|
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
|
}
|
package/page/mixer-view.js
CHANGED
|
@@ -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 {
|
package/page/note-panel.js
CHANGED
|
@@ -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
|
|
272
|
-
* song (` · section 'y'`, ` · sections 'x'
|
|
273
|
-
* ` · no notes`). `range` is `{ from, to,
|
|
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,
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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) {
|
package/page/page-rewrite.js
CHANGED
|
@@ -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
|
|
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) {
|
package/page/query-check.js
CHANGED
|
@@ -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));
|