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.
- package/README.md +94 -6
- package/dist/ask.js +76 -15
- package/dist/mcp.js +198 -26
- package/dist/protocol.js +130 -2
- package/dist/reference.js +22 -0
- package/dist/request-status.js +221 -0
- package/dist/session.js +256 -7
- package/package.json +1 -1
- package/page/ask-view.js +64 -57
- package/page/ask.js +363 -188
- package/page/bar-model.js +43 -2
- package/page/change-timing.js +60 -4
- package/page/harmony.js +13 -0
- package/page/index.html +7 -4
- package/page/jam-analysis.js +68 -23
- package/page/jam-model.js +196 -31
- package/page/jam-resolve.js +271 -0
- package/page/jam-view.js +182 -10
- package/page/lights.js +300 -0
- package/page/main.js +2171 -188
- package/page/midi-clock.js +23 -4
- package/page/midi-out.js +17 -2
- package/page/mixer-groups.js +114 -0
- package/page/mixer-model.js +172 -29
- package/page/mixer-rewrite.js +30 -1
- package/page/mixer-view.js +289 -14
- package/page/note-edit.js +267 -0
- package/page/note-locate.js +346 -0
- package/page/note-panel.js +402 -0
- package/page/note-tag.js +141 -0
- package/page/page-rewrite.js +184 -0
- 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-edit.js +230 -0
- package/page/song-fit.js +250 -0
- package/page/song-grid-model.js +506 -0
- package/page/song-grid-view.js +710 -0
- package/page/song-prefs.js +100 -0
- package/page/structure-check.js +216 -0
- package/page/structure-view.js +38 -0
- package/page/structure.js +761 -0
- package/page/style.css +1218 -65
- package/page/takeover.js +20 -5
- package/page/tempo-glide.js +330 -0
- package/page/tempo-guard.js +62 -10
- package/page/timeline-edit-view.js +1956 -0
- package/page/timeline-gesture.js +279 -0
- package/page/timeline.js +197 -3
- package/page/top-bar-view.js +18 -13
- package/page/tracks.js +37 -8
- package/page/ui-state.js +10 -5
- package/page/views.js +39 -9
- package/reference/functions.md +1 -0
- package/reference/midi.md +2 -0
- package/reference/structure.md +131 -0
- package/reference/transitions.md +13 -1
package/page/takeover.js
CHANGED
|
@@ -64,7 +64,7 @@ export function gatePattern(oldPattern, newPattern, boundary, { TimeSpan, leadCy
|
|
|
64
64
|
* The waiting change of the page: one record at most (a newer change replaces it, last wins). The factory
|
|
65
65
|
* only keeps the record and calls the injected hooks; the page decides what follows the takeover.
|
|
66
66
|
* getScheduler() -> the scheduler (started, pattern, cps, lastEnd), getTypes() -> { TimeSpan },
|
|
67
|
-
* getTiming() -> the parsed timing of the change being evaluated ({ at, lineLength }),
|
|
67
|
+
* getTiming() -> the parsed timing of the change being evaluated ({ at, lineLength }, plus atCycle when the change is written for one given line),
|
|
68
68
|
* getCps() -> the tempo that plays now (cycles per second), for the line and the lead hook; the scheduler's own cps
|
|
69
69
|
* by default, but a program's setcps has already reached the scheduler when a change is armed, so the page gives the
|
|
70
70
|
* tempo from before the evaluation,
|
|
@@ -138,18 +138,29 @@ export function createTakeover({
|
|
|
138
138
|
}
|
|
139
139
|
|
|
140
140
|
return {
|
|
141
|
-
/**
|
|
141
|
+
/**
|
|
142
|
+
* null when the change takes over at once; otherwise { boundary }. A timing that carries `atCycle` (a change written
|
|
143
|
+
* for one given line, see pinnedTiming) takes that line while it is still at least MIN_TAKEOVER_LEAD_SECONDS ahead
|
|
144
|
+
* of the scheduler frontier: { boundary: atCycle, pinned: true }; once it is closer, the next line of the timing's
|
|
145
|
+
* own length is taken and the result says { pinned: false }, so the caller can write its change again for that line.
|
|
146
|
+
* Changes no state.
|
|
147
|
+
*/
|
|
142
148
|
plan() {
|
|
143
149
|
const timing = getTiming();
|
|
144
150
|
if (timing.at === 'now' || !isPlaying()) return null;
|
|
145
151
|
const scheduler = getScheduler();
|
|
152
|
+
const cps = getCps();
|
|
153
|
+
const pinnedLine = timing.atCycle;
|
|
154
|
+
if (pinnedLine !== undefined && (pinnedLine - scheduler.lastEnd) / cps >= MIN_TAKEOVER_LEAD_SECONDS) {
|
|
155
|
+
return { boundary: pinnedLine, pinned: true };
|
|
156
|
+
}
|
|
146
157
|
const boundary = boundaryCycle({
|
|
147
158
|
frontier: scheduler.lastEnd,
|
|
148
|
-
cps
|
|
159
|
+
cps,
|
|
149
160
|
lineLength: timing.lineLength,
|
|
150
161
|
minLeadSeconds: MIN_TAKEOVER_LEAD_SECONDS,
|
|
151
162
|
});
|
|
152
|
-
return { boundary };
|
|
163
|
+
return pinnedLine === undefined ? { boundary } : { boundary, pinned: false };
|
|
153
164
|
},
|
|
154
165
|
|
|
155
166
|
/**
|
|
@@ -242,7 +253,11 @@ export function createTakeover({
|
|
|
242
253
|
record.finishers.push(fn);
|
|
243
254
|
},
|
|
244
255
|
|
|
245
|
-
/**
|
|
256
|
+
/**
|
|
257
|
+
* Drops the waiting record (no-op without one); its continuations never run. `reason` says why: 'stop' (the sound
|
|
258
|
+
* stopped), 'replaced' (a newer change took its place), 'superseded' and 'unload' (this tab lost its role or closes) and
|
|
259
|
+
* 'canceled', the person's own cancel of a waiting section launch.
|
|
260
|
+
*/
|
|
246
261
|
cancel,
|
|
247
262
|
|
|
248
263
|
/** The poll fallback: finishes the waiting record when the scheduler's cycle has reached its boundary. */
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// A tempo ramp for Moontide's browser page: the tempo moves in small steps from the tempo that plays to the tempo
|
|
2
|
+
// the code sets, over a number of bars. No DOM access at import time, so node:test can import it.
|
|
3
|
+
|
|
4
|
+
// Why the page owns the ramp. setcps and setcpm read their value once, when the code runs, so a ramp cannot live in
|
|
5
|
+
// the code; it has to be a series of tempo steps made by the page.
|
|
6
|
+
//
|
|
7
|
+
// Why the plan is in cycles. The steps follow the music's own position (the cycle the scheduler has queried up to),
|
|
8
|
+
// never the wall clock, so a scheduler that stalls or is throttled stalls the ramp with it, and the ramp always
|
|
9
|
+
// ends exactly on the cycle it was planned for.
|
|
10
|
+
//
|
|
11
|
+
// How a step is taken. The driver hooks the scheduler's own queries: every query the scheduler makes asks it to look at
|
|
12
|
+
// the music's position, and about 25 ms later it sets the step of that position. The wait matters. The scheduler takes a
|
|
13
|
+
// new tempo over at its next tick, and the page's MIDI clock reads the takeover on its own 10 ms passes; a step set in
|
|
14
|
+
// the very task of the tick that took the previous step over would replace that takeover before the clock could read
|
|
15
|
+
// it, and the clock would drift from the music. A step 25 ms after the tick leaves every takeover in place for at
|
|
16
|
+
// least two clock passes. At most one step is set for each pair of scheduler ticks (about 100 ms), which is also the
|
|
17
|
+
// finest the scheduler can follow.
|
|
18
|
+
//
|
|
19
|
+
// Steps reach the scheduler only through the injected `apply`, which the page backs with the tempo guard, so a step is
|
|
20
|
+
// never recorded as the tempo a program asked for.
|
|
21
|
+
|
|
22
|
+
import { BEATS_PER_CYCLE } from './change-timing.js';
|
|
23
|
+
|
|
24
|
+
// The shortest and longest ramp, in whole bars (one bar is one cycle). src/protocol.ts mirrors both by hand, and a test
|
|
25
|
+
// checks both sides.
|
|
26
|
+
export const MIN_GLIDE_BARS = 1;
|
|
27
|
+
export const MAX_GLIDE_BARS = 64;
|
|
28
|
+
// No step lasts less than this at the fastest tempo of the ramp.
|
|
29
|
+
export const GLIDE_STEP_SECONDS = 0.1;
|
|
30
|
+
// No step is finer than this in BPM, the one decimal the tempo readout shows.
|
|
31
|
+
export const GLIDE_FINEST_STEP_BPM = 0.1;
|
|
32
|
+
// How long after the scheduler query that found a step the step is set.
|
|
33
|
+
export const GLIDE_APPLY_DELAY_MS = 25;
|
|
34
|
+
|
|
35
|
+
// Cycle positions and tempo distances are floats; a boundary or a count within this of a whole number counts as it.
|
|
36
|
+
const TOLERANCE = 1e-9;
|
|
37
|
+
|
|
38
|
+
const bpmOfCps = (cps) => cps * 60 * BEATS_PER_CYCLE;
|
|
39
|
+
|
|
40
|
+
function requireCps(name, value) {
|
|
41
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
|
42
|
+
throw new Error(`Moontide: the ramp's ${name} must be a finite tempo above 0, got ${String(value)}.`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The number of bars of a ramp: undefined (no ramp asked for) gives null, a whole number from MIN_GLIDE_BARS to
|
|
48
|
+
* MAX_GLIDE_BARS gives itself, and anything else throws a `Moontide:` Error naming the value.
|
|
49
|
+
*/
|
|
50
|
+
export function parseGlideBars(value) {
|
|
51
|
+
if (value === undefined) return null;
|
|
52
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < MIN_GLIDE_BARS || value > MAX_GLIDE_BARS) {
|
|
53
|
+
const shown = typeof value === 'string' ? JSON.stringify(value) : String(value);
|
|
54
|
+
throw new Error(`Moontide: glideBars ${shown} is not a whole number from ${MIN_GLIDE_BARS} to ${MAX_GLIDE_BARS}.`);
|
|
55
|
+
}
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The plan of a ramp from `fromCps` to `toCps` over `bars` bars, starting at the cycle `startCycle`. Returns a frozen
|
|
61
|
+
* { fromCps, toCps, bars, startCycle, endCycle, steps } (endCycle is startCycle + bars). The number of steps follows
|
|
62
|
+
* from the distance and the length, never from a timer: the most that keeps every step at least GLIDE_STEP_SECONDS
|
|
63
|
+
* long at the fastest tempo of the ramp, but no more than keeps every step at least GLIDE_FINEST_STEP_BPM wide, and
|
|
64
|
+
* at least one. Throws a `Moontide:` Error for a tempo that is not finite and above 0, equal tempos, a number of bars
|
|
65
|
+
* parseGlideBars refuses, or a start cycle that is not finite.
|
|
66
|
+
*/
|
|
67
|
+
export function planGlide({ fromCps, toCps, bars, startCycle }) {
|
|
68
|
+
requireCps('start tempo', fromCps);
|
|
69
|
+
requireCps('end tempo', toCps);
|
|
70
|
+
if (fromCps === toCps) {
|
|
71
|
+
throw new Error('Moontide: a ramp needs two different tempos, but the start and the end tempo are equal.');
|
|
72
|
+
}
|
|
73
|
+
if (bars === undefined || parseGlideBars(bars) === null) {
|
|
74
|
+
throw new Error('Moontide: a ramp needs a number of bars.');
|
|
75
|
+
}
|
|
76
|
+
if (typeof startCycle !== 'number' || !Number.isFinite(startCycle)) {
|
|
77
|
+
throw new Error(`Moontide: a ramp needs a finite start cycle, got ${String(startCycle)}.`);
|
|
78
|
+
}
|
|
79
|
+
const distanceBpm = Math.abs(bpmOfCps(toCps) - bpmOfCps(fromCps));
|
|
80
|
+
const byLength = Math.floor(bars / (GLIDE_STEP_SECONDS * Math.max(fromCps, toCps)) + TOLERANCE);
|
|
81
|
+
const byDistance = Math.floor(distanceBpm / GLIDE_FINEST_STEP_BPM + TOLERANCE);
|
|
82
|
+
const steps = Math.max(1, Math.min(byLength, byDistance));
|
|
83
|
+
return Object.freeze({ fromCps, toCps, bars, startCycle, endCycle: startCycle + bars, steps });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The whole step number, 0 to `plan.steps`, that plays at `cycle`: 0 before the first boundary, `steps` from the end
|
|
88
|
+
* of the ramp on. Step k begins at startCycle + bars * k / steps; a cycle exactly on a boundary counts as that step.
|
|
89
|
+
*/
|
|
90
|
+
export function glideStepAt(plan, cycle) {
|
|
91
|
+
if (typeof cycle !== 'number' || !Number.isFinite(cycle)) {
|
|
92
|
+
throw new Error(`Moontide: a ramp step is read at a finite cycle, got ${String(cycle)}.`);
|
|
93
|
+
}
|
|
94
|
+
const reached = Math.floor(((cycle - plan.startCycle + TOLERANCE) * plan.steps) / plan.bars);
|
|
95
|
+
return Math.min(plan.steps, Math.max(0, reached));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The tempo of step `k` (whole, 0 to `plan.steps`): the start tempo for 0, the end tempo itself for the last step, so
|
|
100
|
+
* no float drift is left after the ramp, and linear in BPM between.
|
|
101
|
+
*/
|
|
102
|
+
export function glideCpsOf(plan, k) {
|
|
103
|
+
if (typeof k !== 'number' || !Number.isInteger(k) || k < 0 || k > plan.steps) {
|
|
104
|
+
throw new Error(`Moontide: a ramp of ${plan.steps} steps has no step ${String(k)}.`);
|
|
105
|
+
}
|
|
106
|
+
if (k === 0) return plan.fromCps;
|
|
107
|
+
if (k === plan.steps) return plan.toCps;
|
|
108
|
+
return plan.fromCps + ((plan.toCps - plan.fromCps) * k) / plan.steps;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The seconds the music takes to play the cycles from `fromCycle` to `toCycle` while the ramp of `plan` moves the tempo: 0
|
|
113
|
+
* when `toCycle` is not above `fromCycle`, the span divided by the start tempo before the ramp starts and by the end tempo
|
|
114
|
+
* after it ends, and in between the sum over the steps of the cycles of each divided by the tempo of that step (the same steps
|
|
115
|
+
* glideStepAt and glideCpsOf name). It is pure and grows with `toCycle`; a span split in two sums to the whole. A cycle that
|
|
116
|
+
* is not finite throws a `Moontide:` Error.
|
|
117
|
+
*/
|
|
118
|
+
export function glideSeconds(plan, fromCycle, toCycle) {
|
|
119
|
+
for (const [name, value] of [['first', fromCycle], ['last', toCycle]]) {
|
|
120
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
121
|
+
throw new Error(`Moontide: the ${name} cycle of a ramp's time must be finite, got ${String(value)}.`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (toCycle <= fromCycle) return 0;
|
|
125
|
+
const stepCycles = plan.bars / plan.steps;
|
|
126
|
+
// Step k plays from startCycle + k * stepCycles to the next boundary; step 0 reaches back and the last step on.
|
|
127
|
+
const first = Math.max(0, glideStepAt(plan, fromCycle) - 1);
|
|
128
|
+
const last = Math.min(plan.steps, glideStepAt(plan, toCycle) + 1);
|
|
129
|
+
let seconds = 0;
|
|
130
|
+
for (let k = first; k <= last; k++) {
|
|
131
|
+
const begin = k === 0 ? Number.NEGATIVE_INFINITY : plan.startCycle + k * stepCycles;
|
|
132
|
+
const end = k === plan.steps ? Number.POSITIVE_INFINITY : plan.startCycle + (k + 1) * stepCycles;
|
|
133
|
+
const cycles = Math.min(toCycle, end) - Math.max(fromCycle, begin);
|
|
134
|
+
if (cycles > 0) seconds += cycles / glideCpsOf(plan, k);
|
|
135
|
+
}
|
|
136
|
+
return seconds;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The most that the seconds of `glideSeconds` can be wrong because the tempo that plays trails the plan by one step: a step
|
|
141
|
+
* is set a little after its cycle (a timer after the scheduler's query) and the scheduler takes it over at its next tick, so
|
|
142
|
+
* on a rising ramp the music runs a little slower than the plan and on a falling ramp a little faster. It is the sum, over
|
|
143
|
+
* the cycles from `fromCycle` to `toCycle`, of the cycles of each step times how much its seconds per cycle differ from the
|
|
144
|
+
* step before. 0 when `toCycle` is not above `fromCycle`. Pure; a cycle that is not finite throws a `Moontide:` Error.
|
|
145
|
+
*/
|
|
146
|
+
export function glideLagSeconds(plan, fromCycle, toCycle) {
|
|
147
|
+
for (const [name, value] of [['first', fromCycle], ['last', toCycle]]) {
|
|
148
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
149
|
+
throw new Error(`Moontide: the ${name} cycle of a ramp's lag must be finite, got ${String(value)}.`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (toCycle <= fromCycle) return 0;
|
|
153
|
+
const stepCycles = plan.bars / plan.steps;
|
|
154
|
+
const first = Math.max(1, glideStepAt(plan, fromCycle) - 1);
|
|
155
|
+
const last = Math.min(plan.steps, glideStepAt(plan, toCycle) + 1);
|
|
156
|
+
let seconds = 0;
|
|
157
|
+
// The lag is one step long: the end tempo is reached one step late, then the lag is over.
|
|
158
|
+
for (let k = first; k <= last; k++) {
|
|
159
|
+
const begin = plan.startCycle + k * stepCycles;
|
|
160
|
+
const end = plan.startCycle + (k + 1) * stepCycles;
|
|
161
|
+
const cycles = Math.min(toCycle, end) - Math.max(fromCycle, begin);
|
|
162
|
+
if (cycles > 0) seconds += cycles * Math.abs(1 / glideCpsOf(plan, k - 1) - 1 / glideCpsOf(plan, k));
|
|
163
|
+
}
|
|
164
|
+
return seconds;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* What a change that waited for its bar or phrase line does to the tempo when its line is reached, as one word:
|
|
169
|
+
* - a change that asks for a ramp: 'start-glide' (the ramp starts at the line from the tempo that plays then);
|
|
170
|
+
* - a ramp runs and the change states its target: 'keep' (nothing is applied, the step that plays stays);
|
|
171
|
+
* - a ramp runs and the change states no tempo: 'cancel-no-tempo' (the ramp ends, the tempo stays);
|
|
172
|
+
* - a ramp runs and the change states another tempo: 'cancel-and-apply' (that tempo applies at once, the ramp ends);
|
|
173
|
+
* - no ramp runs: 'apply' for a stated tempo and 'nothing' for none (the usual path of a waiting change).
|
|
174
|
+
* `newCps` is the tempo the waiting code stated, or null. The target is compared with `===`: it came from the same
|
|
175
|
+
* program text as the target of the ramp, so a program that restates it yields the same number, and no rounding to
|
|
176
|
+
* a BPM enters the rule. A change that asks for a ramp must state a tempo (a finite number above 0), or this throws.
|
|
177
|
+
*/
|
|
178
|
+
export function lineTempoAction({ running, targetCps, newCps, startsGlide }) {
|
|
179
|
+
if (startsGlide) {
|
|
180
|
+
requireCps('target tempo', newCps);
|
|
181
|
+
return 'start-glide';
|
|
182
|
+
}
|
|
183
|
+
if (running) {
|
|
184
|
+
if (newCps === null) return 'cancel-no-tempo';
|
|
185
|
+
return newCps === targetCps ? 'keep' : 'cancel-and-apply';
|
|
186
|
+
}
|
|
187
|
+
return newCps === null ? 'nothing' : 'apply';
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The words of the three refusals of a change that asks for a ramp it cannot make. Each says that nothing was changed.
|
|
192
|
+
*/
|
|
193
|
+
export const GLIDE_REFUSALS = Object.freeze({
|
|
194
|
+
nothingPlays:
|
|
195
|
+
'Nothing plays, so there is no tempo to ramp from. Nothing was changed. Send the change again without glideBars.',
|
|
196
|
+
noTempo:
|
|
197
|
+
'The code sets no tempo with setcps or setcpm, so there is nothing to ramp to. Nothing was changed.',
|
|
198
|
+
sameTempo:
|
|
199
|
+
'The code sets the tempo that already plays, so there is nothing to ramp. Nothing was changed.',
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The driver of one ramp at a time. `scheduler` is read for `cps`, `lastEnd` (the cycle a new tempo starts at) and
|
|
204
|
+
* `started`; `apply(cps)` sets a tempo (the page's tempo guard); `setTimer` and `clearTimer` are the page's timers.
|
|
205
|
+
* `onEvent` hears { kind: 'started', plan }, { kind: 'finished', plan } and { kind: 'canceled', reason, plan, cps }
|
|
206
|
+
* (`cps` is the scheduler's tempo when the ramp was canceled); `onError(text, err)` hears an error once for each text.
|
|
207
|
+
* Returns:
|
|
208
|
+
* - `start({ fromCps, toCps, bars, startCycle })`: plans the ramp (a refusal throws and changes nothing), cancels a
|
|
209
|
+
* running one with the reason 'replaced' first, and returns the plan;
|
|
210
|
+
* - `onSchedulerQuery()`: called on every query the scheduler itself makes; it never throws;
|
|
211
|
+
* - `cancel(reason)`: ends the ramp where it is and returns true, or returns false when none runs;
|
|
212
|
+
* - `running()`, `current()` (null or a frozen { ...plan, applied, stepCps }) and `targetCps()` (null or the end tempo);
|
|
213
|
+
* - `runs(plan)`: whether `plan` (as `start` returned it) is the ramp that runs now; false once it finished, was canceled
|
|
214
|
+
* or was replaced, and for anything that is not such a plan. A report of a ramp goes to the server only while this is
|
|
215
|
+
* true, so every end of that ramp is told after the report and the server's record of it can always be cleared;
|
|
216
|
+
* - `secondsBetween(fromCycle, toCycle)`: the seconds of the music between two cycles through the ramp's own tempo
|
|
217
|
+
* (glideSeconds on the running plan), null with no ramp;
|
|
218
|
+
* - `lagSecondsBetween(fromCycle, toCycle)`: how far that can be off when the tempo trails the plan by a step
|
|
219
|
+
* (glideLagSeconds on the running plan), null with no ramp;
|
|
220
|
+
* - `fastestCps()`: the fastest tempo the ramp still reaches, the larger of the step that plays and the end tempo, null with
|
|
221
|
+
* no ramp;
|
|
222
|
+
* - `barsLeft(cycle)`: the bars to the end of the ramp at `cycle`, rounded up, never below 1 and never above the ramp's
|
|
223
|
+
* length, null with no ramp.
|
|
224
|
+
*/
|
|
225
|
+
export function createTempoGlide({ scheduler, apply, setTimer, clearTimer, onEvent = () => {}, onError = (text, err) => console.error(text, err) }) {
|
|
226
|
+
if (scheduler === null || typeof scheduler !== 'object' || typeof scheduler.cps !== 'number') {
|
|
227
|
+
throw new Error('Moontide: the tempo ramp needs a scheduler with a numeric cps.');
|
|
228
|
+
}
|
|
229
|
+
for (const [name, fn] of [['apply', apply], ['setTimer', setTimer], ['clearTimer', clearTimer]]) {
|
|
230
|
+
if (typeof fn !== 'function') throw new Error(`Moontide: the tempo ramp needs a ${name} function.`);
|
|
231
|
+
}
|
|
232
|
+
let ramp = null; // { plan, applied } while a ramp runs
|
|
233
|
+
let pending = false;
|
|
234
|
+
let timer = null;
|
|
235
|
+
const reported = new Set();
|
|
236
|
+
|
|
237
|
+
const report = (text, err) => {
|
|
238
|
+
if (reported.has(text)) return;
|
|
239
|
+
reported.add(text);
|
|
240
|
+
try {
|
|
241
|
+
onError(text, err);
|
|
242
|
+
} catch {
|
|
243
|
+
// Reporting must not be able to break the scheduler either.
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
const emit = (event) => {
|
|
247
|
+
try {
|
|
248
|
+
onEvent(event);
|
|
249
|
+
} catch (err) {
|
|
250
|
+
report('Moontide: a tempo ramp listener failed:', err);
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
const clearPending = () => {
|
|
254
|
+
if (pending) clearTimer(timer);
|
|
255
|
+
pending = false;
|
|
256
|
+
timer = null;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
function cancel(reason) {
|
|
260
|
+
if (ramp === null) return false;
|
|
261
|
+
const { plan } = ramp;
|
|
262
|
+
ramp = null;
|
|
263
|
+
clearPending();
|
|
264
|
+
emit({ kind: 'canceled', reason, plan, cps: scheduler.cps });
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function fire(mine) {
|
|
269
|
+
pending = false;
|
|
270
|
+
timer = null;
|
|
271
|
+
try {
|
|
272
|
+
if (ramp !== mine) return;
|
|
273
|
+
if (scheduler.started === false) return;
|
|
274
|
+
if (typeof scheduler.lastEnd !== 'number' || !Number.isFinite(scheduler.lastEnd)) return;
|
|
275
|
+
const k = glideStepAt(mine.plan, scheduler.lastEnd);
|
|
276
|
+
if (k > mine.applied) {
|
|
277
|
+
apply(glideCpsOf(mine.plan, k));
|
|
278
|
+
mine.applied = k;
|
|
279
|
+
}
|
|
280
|
+
if (mine.applied >= mine.plan.steps) {
|
|
281
|
+
ramp = null;
|
|
282
|
+
emit({ kind: 'finished', plan: mine.plan });
|
|
283
|
+
}
|
|
284
|
+
} catch (err) {
|
|
285
|
+
cancel('error');
|
|
286
|
+
report(`Moontide: a tempo ramp step failed, so the ramp was canceled: ${err instanceof Error ? err.message : String(err)}`, err);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
start(input) {
|
|
292
|
+
const plan = planGlide(input);
|
|
293
|
+
cancel('replaced');
|
|
294
|
+
ramp = { plan, applied: 0 };
|
|
295
|
+
emit({ kind: 'started', plan });
|
|
296
|
+
return plan;
|
|
297
|
+
},
|
|
298
|
+
onSchedulerQuery() {
|
|
299
|
+
try {
|
|
300
|
+
if (ramp === null || pending) return;
|
|
301
|
+
const mine = ramp;
|
|
302
|
+
pending = true;
|
|
303
|
+
timer = setTimer(() => fire(mine), GLIDE_APPLY_DELAY_MS);
|
|
304
|
+
} catch (err) {
|
|
305
|
+
pending = false;
|
|
306
|
+
timer = null;
|
|
307
|
+
report('Moontide: the tempo ramp could not follow the scheduler:', err);
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
cancel,
|
|
311
|
+
running: () => ramp !== null,
|
|
312
|
+
runs: (plan) => ramp !== null && ramp.plan === plan,
|
|
313
|
+
current() {
|
|
314
|
+
if (ramp === null) return null;
|
|
315
|
+
return Object.freeze({ ...ramp.plan, applied: ramp.applied, stepCps: glideCpsOf(ramp.plan, ramp.applied) });
|
|
316
|
+
},
|
|
317
|
+
targetCps: () => (ramp === null ? null : ramp.plan.toCps),
|
|
318
|
+
secondsBetween: (fromCycle, toCycle) => (ramp === null ? null : glideSeconds(ramp.plan, fromCycle, toCycle)),
|
|
319
|
+
lagSecondsBetween: (fromCycle, toCycle) => (ramp === null ? null : glideLagSeconds(ramp.plan, fromCycle, toCycle)),
|
|
320
|
+
fastestCps: () => (ramp === null ? null : Math.max(glideCpsOf(ramp.plan, ramp.applied), ramp.plan.toCps)),
|
|
321
|
+
barsLeft(cycle) {
|
|
322
|
+
if (typeof cycle !== 'number' || !Number.isFinite(cycle)) {
|
|
323
|
+
throw new Error(`Moontide: the bars left of a ramp are read at a finite cycle, got ${String(cycle)}.`);
|
|
324
|
+
}
|
|
325
|
+
if (ramp === null) return null;
|
|
326
|
+
const { plan } = ramp;
|
|
327
|
+
return Math.min(plan.bars, Math.max(1, Math.ceil(plan.endCycle - cycle - TOLERANCE)));
|
|
328
|
+
},
|
|
329
|
+
};
|
|
330
|
+
}
|
package/page/tempo-guard.js
CHANGED
|
@@ -19,10 +19,11 @@
|
|
|
19
19
|
// scheduler.cps while it builds the pattern).
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* Wraps `scheduler.setCps`. Call `begin()` before an evaluation and `end(failed, { hold })` after it: when
|
|
23
|
-
* `failed` is true, or `hold` is true, and the evaluation changed the tempo, the tempo from before it is
|
|
24
|
-
* asked of the scheduler again. `end` returns `{ requested }`: the tempo the program asked for (the
|
|
25
|
-
* scheduler's cps read before any restore), or undefined when it failed or never called `setCps
|
|
22
|
+
* Wraps `scheduler.setCps`. Call `begin()` before an evaluation and `end(failed, { hold, startsGlide })` after it: when
|
|
23
|
+
* `failed` is true, or `hold` or `startsGlide` is true, and the evaluation changed the tempo, the tempo from before it is
|
|
24
|
+
* asked of the scheduler again. `end` returns `{ requested, glide }`: `requested` is the tempo the program asked for (the
|
|
25
|
+
* scheduler's cps read before any restore), or undefined when it failed or never called `setCps`; `glide` says what the
|
|
26
|
+
* evaluation did to a running tempo ramp: 'none' (no ramp ran), 'kept', 'other-tempo' or 'no-tempo' (the ramp was canceled).
|
|
26
27
|
* `apply(cps)` sets a tempo through the raw setter without touching an open evaluation's requested value; the applied
|
|
27
28
|
* tempo becomes the tempo that evaluation restores on a failure or a hold, and an immediate success whose program set
|
|
28
29
|
* its own tempo before the apply keeps the program's tempo.
|
|
@@ -30,8 +31,23 @@
|
|
|
30
31
|
* `cps` when the evaluation begins, because that is the tempo that plays: the page's scheduler is the
|
|
31
32
|
* pinned Cyclist, whose `setCps` takes effect at once, and whose tick also sets `cps` itself when a hap
|
|
32
33
|
* carries a `cps` control, a change that never went through `setCps`.
|
|
34
|
+
*
|
|
35
|
+
* With a `glide` (the tempo ramp: `running()`, `targetCps()`, `cancel(reason)`), the end of every evaluation decides what
|
|
36
|
+
* happens to a ramp that runs, in this order of who wins:
|
|
37
|
+
* - a failed evaluation changes nothing: the step that played comes back and the ramp goes on;
|
|
38
|
+
* - a change that waits for its line (`hold`) leaves the ramp alone and the step stays; its line decides later;
|
|
39
|
+
* - a change that asks for a new ramp (`startsGlide`) gets the step that played back, and the caller replaces the ramp
|
|
40
|
+
* with one that starts from it;
|
|
41
|
+
* - a program that states the ramp's own target (the same number, since both came from the same program text) keeps
|
|
42
|
+
* the ramp, and the step that played is put back in the same task, so each of several evaluations in a row (a Mixer
|
|
43
|
+
* drag) ends on the step;
|
|
44
|
+
* - a program that states another tempo wins at once: its tempo stands and the ramp is canceled;
|
|
45
|
+
* - a program that states no tempo cancels the ramp, and the tempo stays where the ramp was.
|
|
46
|
+
* The program's tempo is still forwarded to the scheduler while the program runs: the pinned bundle's `.cpm(n)` reads the
|
|
47
|
+
* scheduler's cps once, while the program builds its patterns, so a program that sets its tempo first must see it.
|
|
48
|
+
* The guard takes it back at the end of the same evaluation, in the same task, which a scheduler tick cannot see.
|
|
33
49
|
*/
|
|
34
|
-
export function createTempoGuard(scheduler) {
|
|
50
|
+
export function createTempoGuard(scheduler, { glide = null } = {}) {
|
|
35
51
|
const originalSetCps = scheduler.setCps;
|
|
36
52
|
if (typeof originalSetCps !== 'function') {
|
|
37
53
|
throw new Error('Moontide: the scheduler of the pinned @strudel/repl 1.3.0 has no setCps function.');
|
|
@@ -39,6 +55,11 @@ export function createTempoGuard(scheduler) {
|
|
|
39
55
|
if (typeof scheduler.cps !== 'number') {
|
|
40
56
|
throw new Error('Moontide: the scheduler of the pinned @strudel/repl 1.3.0 has no numeric cps, so the tempo that plays cannot be read.');
|
|
41
57
|
}
|
|
58
|
+
if (glide !== null) {
|
|
59
|
+
for (const name of ['running', 'targetCps', 'cancel']) {
|
|
60
|
+
if (typeof glide[name] !== 'function') throw new Error(`Moontide: the tempo ramp given to the tempo guard has no ${name} function.`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
42
63
|
let open = null; // { before, requested } while an evaluation runs; requested stays undefined until setCps is called
|
|
43
64
|
scheduler.setCps = function (cps) {
|
|
44
65
|
if (open !== null) open.requested = cps;
|
|
@@ -48,20 +69,51 @@ export function createTempoGuard(scheduler) {
|
|
|
48
69
|
begin() {
|
|
49
70
|
open = { before: scheduler.cps, requested: undefined };
|
|
50
71
|
},
|
|
51
|
-
end(failed, { hold = false } = {}) {
|
|
72
|
+
end(failed, { hold = false, startsGlide = false } = {}) {
|
|
52
73
|
const finished = open;
|
|
53
74
|
open = null;
|
|
54
|
-
if (finished === null
|
|
75
|
+
if (finished === null) return { requested: undefined, glide: 'none' };
|
|
76
|
+
const rampRuns = glide !== null && glide.running();
|
|
77
|
+
// The ramp's target is read before anything is restored or canceled.
|
|
78
|
+
const target = rampRuns ? glide.targetCps() : null;
|
|
79
|
+
if (finished.requested === undefined) {
|
|
80
|
+
// No setCps call: nothing to restore. A step applied during the evaluation already stands.
|
|
81
|
+
if (!rampRuns) return { requested: undefined, glide: 'none' };
|
|
82
|
+
if (failed || hold || startsGlide) return { requested: undefined, glide: 'kept' };
|
|
83
|
+
glide.cancel('no-tempo');
|
|
84
|
+
return { requested: undefined, glide: 'no-tempo' };
|
|
85
|
+
}
|
|
55
86
|
// The tempo the program asked for is read before any restore; it also covers a tempo set another way.
|
|
56
87
|
// An apply during the evaluation is not the program's own tempo, so then the last setCps value stands.
|
|
57
88
|
const requested = failed ? undefined : finished.applied ? finished.requested : scheduler.cps;
|
|
58
89
|
// `before` is the tempo that plays when the evaluation ends: an apply during it (a waiting change reaching its
|
|
59
90
|
// line) moved it to the applied tempo, so a failed or held evaluation restores that one, never the older one.
|
|
60
|
-
if (failed || hold
|
|
91
|
+
if (failed || hold || startsGlide) {
|
|
92
|
+
originalSetCps.call(scheduler, finished.before);
|
|
93
|
+
return { requested, glide: rampRuns ? 'kept' : 'none' };
|
|
94
|
+
}
|
|
95
|
+
if (rampRuns && requested === target) {
|
|
96
|
+
// The same target again: the ramp goes on and the step that played is put back in this task.
|
|
97
|
+
if (scheduler.cps !== finished.before) originalSetCps.call(scheduler, finished.before);
|
|
98
|
+
return { requested, glide: 'kept' };
|
|
99
|
+
}
|
|
61
100
|
// An immediate success whose program set its own tempo before an apply: the program's tempo is the one that
|
|
62
101
|
// plays and the one the reply reports, so it is put back over the applied one.
|
|
63
|
-
|
|
64
|
-
|
|
102
|
+
if (finished.applied && scheduler.cps !== finished.requested) originalSetCps.call(scheduler, finished.requested);
|
|
103
|
+
if (rampRuns) {
|
|
104
|
+
// Another tempo written in the code always wins; the cancel reads the tempo that plays after it.
|
|
105
|
+
glide.cancel('other-tempo');
|
|
106
|
+
return { requested, glide: 'other-tempo' };
|
|
107
|
+
}
|
|
108
|
+
return { requested, glide: 'none' };
|
|
109
|
+
},
|
|
110
|
+
/**
|
|
111
|
+
* The tempo the open evaluation's program has asked for so far, or undefined when no evaluation is open or the program
|
|
112
|
+
* has not called setCps yet. The page reads it at the moment a pattern is handed over, to refuse a ramp with no tempo
|
|
113
|
+
* to ramp to or one that would not move.
|
|
114
|
+
*/
|
|
115
|
+
requestedSoFar() {
|
|
116
|
+
return open !== null ? open.requested : undefined;
|
|
65
117
|
},
|
|
66
118
|
/**
|
|
67
119
|
* The tempo that plays: while an evaluation is open the scheduler's cps already holds the tempo the program set
|