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
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// Pure module for Moontide's browser page: the numbers behind a pointer on the Timeline lanes. Distances are written in rem,
|
|
2
|
+
// the unit the whole page is sized in; the caller multiplies them by the root font size it reads when the press starts, so a
|
|
3
|
+
// larger window or a larger root makes the targets larger too. Where a note is drawn is worked out from the same shapes the
|
|
4
|
+
// lanes are drawn from, so the note a press picks is the note the person sees. No DOM access and no timers here, so node:test
|
|
5
|
+
// and page/main.js import the module without a build step.
|
|
6
|
+
|
|
7
|
+
import { PAGE_CYCLES, cycleAt } from './timeline.js';
|
|
8
|
+
|
|
9
|
+
/** A press that moves less than this is a click; a press that moves more is a drag. */
|
|
10
|
+
export const DRAG_THRESHOLD_REM = 0.25;
|
|
11
|
+
|
|
12
|
+
/** How far from a drawn note a press still counts as on it, in both directions. */
|
|
13
|
+
export const HIT_TOLERANCE_REM = 0.5;
|
|
14
|
+
|
|
15
|
+
/** The share of a lane's height a pitched note mark takes (a tick spans the whole height). */
|
|
16
|
+
export const NOTE_MARK_HEIGHT = 0.14;
|
|
17
|
+
|
|
18
|
+
/** How far a dragged note travels up or down for one semitone. A fixed step: it does not follow the lane's own pitch scale. */
|
|
19
|
+
export const SEMITONE_REM = 0.5;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Which gesture a press that has moved turns into, decided once and kept for the rest of the press: null while the pointer is
|
|
23
|
+
* still within `thresholdPx` of where it went down (it may yet be a click), then `'pitch'` or `'range'`. On a note the larger
|
|
24
|
+
* of the two travels wins (up or down is a pitch drag, sideways a range) and equal travel is a pitch drag; on empty lane area
|
|
25
|
+
* any travel is a range. `dx` and `dy` are the pointer's distance from the press, in pixels.
|
|
26
|
+
*/
|
|
27
|
+
export function lockGesture({ dx, dy, onNote, thresholdPx }) {
|
|
28
|
+
if (Math.hypot(dx, dy) < thresholdPx) return null;
|
|
29
|
+
if (!onNote) return 'range';
|
|
30
|
+
return Math.abs(dy) >= Math.abs(dx) ? 'pitch' : 'range';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The whole cycles a range drag covers: the cycle under the press and the cycle under the pointer, in either order, both
|
|
35
|
+
* included. `pageStart` is the first cycle of the page drawn, `pressFraction` and `pointerFraction` how far along the lane's event
|
|
36
|
+
* area the press and the pointer are (0 at its left edge, 1 at its right). Gives `{ from, to, cut }`. A pointer that left the
|
|
37
|
+
* event area (a fraction below 0 or above 1) is held at the page's first or last cycle, and `cut` is then `{ from, to }`, the
|
|
38
|
+
* cycles the range ended up with; otherwise `cut` is null. A range never leaves the page, so it never covers more than
|
|
39
|
+
* PAGE_CYCLES cycles.
|
|
40
|
+
*/
|
|
41
|
+
export function rangeCycles({ pageStart, pressFraction, pointerFraction }) {
|
|
42
|
+
for (const [name, value] of Object.entries({ pressFraction, pointerFraction })) {
|
|
43
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) throw new TypeError(`rangeCycles: ${name} must be a finite number, got ${String(value)}`);
|
|
44
|
+
}
|
|
45
|
+
const pressed = cycleAt({ pageStart, fraction: pressFraction });
|
|
46
|
+
const pointed = cycleAt({ pageStart, fraction: pointerFraction });
|
|
47
|
+
const from = Math.min(pressed, pointed);
|
|
48
|
+
const to = Math.max(pressed, pointed);
|
|
49
|
+
const left = pointerFraction < 0 || pointerFraction > 1;
|
|
50
|
+
return { from, to, cut: left ? { from, to } : null };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The whole semitones a pointer that moved `dy` pixels down (negative: up) stands for, `stepPx` pixels to a semitone. Up is
|
|
55
|
+
* higher, a part of a step counts for nothing, and no travel is 0, never negative zero.
|
|
56
|
+
*/
|
|
57
|
+
export function semitonesFrom({ dy, stepPx }) {
|
|
58
|
+
const semitones = Math.trunc(-dy / stepPx);
|
|
59
|
+
return semitones === 0 ? 0 : semitones;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* One pixel box `{ left, top, right, bottom }` for each drawn note of a lane, in the order of `shapes`, and null for a tick
|
|
64
|
+
* (it has no pitch and is not picked). `rect` is the lane's event area in the page (`left`, `top`, `width`, `height`),
|
|
65
|
+
* `shapes` what the lane was drawn from (`{ kind, x, width, y }` for a normal lane, `{ x, width, row }` for a drum lane),
|
|
66
|
+
* `rowCount` the number of drum rows of a drum lane or null, `markHeight` the share of the lane height a mark takes.
|
|
67
|
+
*/
|
|
68
|
+
export function noteBoxes({ rect, shapes, rowCount, markHeight }) {
|
|
69
|
+
const perCycle = rect.width / PAGE_CYCLES;
|
|
70
|
+
return shapes.map((shape) => {
|
|
71
|
+
if (rowCount === null && shape.kind !== 'note') return null;
|
|
72
|
+
const left = rect.left + shape.x * perCycle;
|
|
73
|
+
let top;
|
|
74
|
+
let height;
|
|
75
|
+
if (rowCount === null) {
|
|
76
|
+
const y = Math.max(0, Math.min(1 - markHeight, shape.y - markHeight / 2));
|
|
77
|
+
top = rect.top + y * rect.height;
|
|
78
|
+
height = markHeight * rect.height;
|
|
79
|
+
} else {
|
|
80
|
+
top = rect.top + ((shape.row + 0.25) / rowCount) * rect.height;
|
|
81
|
+
height = (0.5 / rowCount) * rect.height;
|
|
82
|
+
}
|
|
83
|
+
return { left, top, right: left + shape.width * perCycle, bottom: top + height };
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** How far `at` is outside the span from `low` to `high` (0 inside it). */
|
|
88
|
+
function outside(at, low, high) {
|
|
89
|
+
return Math.max(low - at, 0, at - high);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The index of the drawn note a press at (`x`, `y`) is on, or null on empty lane area. A note counts when the press is within
|
|
94
|
+
* `tolerancePx` of its box in both directions; of several the nearest wins, and equal distances go to the lower pitch
|
|
95
|
+
* (`pitches[i]` is the pitch of box i, null boxes are skipped).
|
|
96
|
+
*/
|
|
97
|
+
export function pickNote({ boxes, pitches, x, y, tolerancePx }) {
|
|
98
|
+
let best = null;
|
|
99
|
+
let bestDistance = Infinity;
|
|
100
|
+
for (let i = 0; i < boxes.length; i++) {
|
|
101
|
+
const box = boxes[i];
|
|
102
|
+
if (box === null) continue;
|
|
103
|
+
const dx = outside(x, box.left, box.right);
|
|
104
|
+
const dy = outside(y, box.top, box.bottom);
|
|
105
|
+
if (dx > tolerancePx || dy > tolerancePx) continue;
|
|
106
|
+
const distance = Math.hypot(dx, dy);
|
|
107
|
+
const nearer = distance < bestDistance - 1e-9;
|
|
108
|
+
const tied = Math.abs(distance - bestDistance) <= 1e-9 && best !== null && pitches[i] < pitches[best];
|
|
109
|
+
if (nearer || tied) {
|
|
110
|
+
best = i;
|
|
111
|
+
bestDistance = Math.min(distance, bestDistance);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return best;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** How long the steps of a key or a button gather before the page writes them once, in milliseconds. */
|
|
118
|
+
export const STEP_GATHER_MS = 600;
|
|
119
|
+
|
|
120
|
+
function defaultStepError(error) {
|
|
121
|
+
console.error('Moontide: the steps of a Timeline note could not be written:', error);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Gathers the steps of one kind (pitch, velocity) into one write. `step(kind, amount)` adds `amount` (a whole number, negative
|
|
126
|
+
* for down) to the pending total of `kind` and starts the wait again; `delayMs` after the last step `onFlush(kind, total)`
|
|
127
|
+
* runs once. A step of another kind writes the pending one first and starts a burst of its own, so a burst only ever holds
|
|
128
|
+
* one kind and each kind is its own write. A total of 0 writes nothing. `drop()` forgets the pending steps without writing,
|
|
129
|
+
* `flush()` writes them now, `pending()` gives `{ kind, total }` or null. Once handed to `onFlush` a burst belongs to the
|
|
130
|
+
* caller: the gatherer never takes it back, and `onFlush` must not call `step`, `flush` or `drop` (that throws, and the
|
|
131
|
+
* error is reported through `onError`). Like the Mixer's recorder it has no timers of its own: `setTimer(fn, ms)` gives a
|
|
132
|
+
* handle for `clearTimer(handle)`, so node:test drives it with a fake clock. `onError(error)` hears an `onFlush` that threw,
|
|
133
|
+
* and the gatherer keeps working.
|
|
134
|
+
*/
|
|
135
|
+
export function createStepGatherer({ setTimer, clearTimer, delayMs = STEP_GATHER_MS, onFlush, onError = defaultStepError }) {
|
|
136
|
+
for (const [name, fn] of Object.entries({ setTimer, clearTimer, onFlush })) {
|
|
137
|
+
if (typeof fn !== 'function') throw new Error(`Moontide: the step gatherer needs ${name}, a function.`);
|
|
138
|
+
}
|
|
139
|
+
let open = null;
|
|
140
|
+
let timer = null;
|
|
141
|
+
// True while a burst is being handed to onFlush: the gatherer is not entered again from there.
|
|
142
|
+
let flushing = false;
|
|
143
|
+
|
|
144
|
+
function stopTimer() {
|
|
145
|
+
if (timer !== null) clearTimer(timer);
|
|
146
|
+
timer = null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function notInFlush(name) {
|
|
150
|
+
if (flushing) throw new Error(`Moontide: the step gatherer's ${name} was called from inside its own flush.`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function run(kind, total) {
|
|
154
|
+
if (total === 0) return;
|
|
155
|
+
flushing = true;
|
|
156
|
+
try {
|
|
157
|
+
onFlush(kind, total);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
onError(error);
|
|
160
|
+
} finally {
|
|
161
|
+
flushing = false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function flush() {
|
|
166
|
+
notInFlush('flush');
|
|
167
|
+
stopTimer();
|
|
168
|
+
const gathered = open;
|
|
169
|
+
open = null;
|
|
170
|
+
if (gathered !== null) run(gathered.kind, gathered.total);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
step(kind, amount) {
|
|
175
|
+
notInFlush('step');
|
|
176
|
+
if (typeof kind !== 'string' || kind === '') throw new Error('Moontide: a step has the kind it belongs to.');
|
|
177
|
+
if (!Number.isInteger(amount) || amount === 0) throw new Error(`Moontide: a step is a whole number other than 0, got ${String(amount)}.`);
|
|
178
|
+
if (open !== null && open.kind !== kind) flush();
|
|
179
|
+
if (open === null) open = { kind, total: 0 };
|
|
180
|
+
open.total += amount;
|
|
181
|
+
stopTimer();
|
|
182
|
+
timer = setTimer(() => {
|
|
183
|
+
timer = null;
|
|
184
|
+
flush();
|
|
185
|
+
}, delayMs);
|
|
186
|
+
},
|
|
187
|
+
drop() {
|
|
188
|
+
notInFlush('drop');
|
|
189
|
+
stopTimer();
|
|
190
|
+
open = null;
|
|
191
|
+
},
|
|
192
|
+
flush,
|
|
193
|
+
pending() {
|
|
194
|
+
return open === null ? null : { kind: open.kind, total: open.total };
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The keys that move along a lane or across lanes. */
|
|
200
|
+
export const LANE_KEYS = Object.freeze(['ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown']);
|
|
201
|
+
|
|
202
|
+
/** The cycle a start time falls in (a start just under a whole cycle counts as that cycle, like the lanes' own cycle numbers). */
|
|
203
|
+
function cycleOfStart(begin) {
|
|
204
|
+
return Math.floor(begin + 1e-9);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The notes of one lane in the order the keys walk them: by start, then pitch (lower first), then by position. `notes` has one
|
|
209
|
+
* entry for each drawn event, `{ begin, pitch }`, or null for an event the keys skip (a tick has no pitch and no mark to pick).
|
|
210
|
+
* Gives the positions in `notes`.
|
|
211
|
+
*/
|
|
212
|
+
function walkOrder(notes) {
|
|
213
|
+
const order = [];
|
|
214
|
+
notes.forEach((entry, index) => {
|
|
215
|
+
if (entry !== null) order.push(index);
|
|
216
|
+
});
|
|
217
|
+
order.sort((a, b) => notes[a].begin - notes[b].begin || (notes[a].pitch ?? -1) - (notes[b].pitch ?? -1) || a - b);
|
|
218
|
+
return order;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Where a key of the keyboard model takes the person on the Timeline's lanes. `lanes` holds the drawn lanes, each the list of its
|
|
223
|
+
* events as `{ begin, pitch }` (null for an event the keys skip); `at` is the current place or null: `{ laneIndex, eventIndex }`
|
|
224
|
+
* on a note, `{ laneIndex, eventIndex: null, cycle }` on the cursor of a lane that has no notes on the page. `key` is one of
|
|
225
|
+
* LANE_KEYS; any other key throws. The answer is `{ laneIndex, eventIndex, cycle }` (`eventIndex` null for the cursor, `cycle`
|
|
226
|
+
* the whole cycle of the note or the cursor), the same place when the key goes nowhere (the first or last note, the first or
|
|
227
|
+
* last lane), or null when the key does not apply: nothing to start from and no note anywhere, or a key that needs a place and
|
|
228
|
+
* has none (Home, End, Page Up and Page Down with nothing selected, so they scroll as they always did).
|
|
229
|
+
*
|
|
230
|
+
* Left and Right walk the lane by start and then pitch, and stop at its first and last note (the page does not turn). With no
|
|
231
|
+
* place Right takes the first note of the first lane that has one and Left the last note of that lane. Home and End take the
|
|
232
|
+
* first and the last note of the lane. Page Up and Page Down take the lane before and after, keeping the cycle: the note whose
|
|
233
|
+
* start is nearest in time (the earlier one when two are as near, then the lower pitch), or the cursor when that lane has no
|
|
234
|
+
* notes on the page.
|
|
235
|
+
*/
|
|
236
|
+
export function laneMove({ lanes, at, key }) {
|
|
237
|
+
if (!Array.isArray(lanes)) throw new TypeError('laneMove: lanes must be an array of lanes.');
|
|
238
|
+
if (!LANE_KEYS.includes(key)) throw new Error(`laneMove: unknown key '${String(key)}'.`);
|
|
239
|
+
const note = (laneIndex, eventIndex) => ({ laneIndex, eventIndex, cycle: cycleOfStart(lanes[laneIndex][eventIndex].begin) });
|
|
240
|
+
if (at === null || at === undefined) {
|
|
241
|
+
if (key !== 'ArrowLeft' && key !== 'ArrowRight') return null;
|
|
242
|
+
for (let laneIndex = 0; laneIndex < lanes.length; laneIndex++) {
|
|
243
|
+
const order = walkOrder(lanes[laneIndex]);
|
|
244
|
+
if (order.length > 0) return note(laneIndex, key === 'ArrowRight' ? order[0] : order[order.length - 1]);
|
|
245
|
+
}
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
const { laneIndex, eventIndex } = at;
|
|
249
|
+
if (!Number.isInteger(laneIndex) || laneIndex < 0 || laneIndex >= lanes.length) throw new RangeError(`laneMove: there is no lane ${String(laneIndex)}.`);
|
|
250
|
+
if (eventIndex === null) {
|
|
251
|
+
if (!Number.isInteger(at.cycle)) throw new TypeError('laneMove: the cursor of a lane without notes needs its cycle.');
|
|
252
|
+
} else if (lanes[laneIndex][eventIndex] === undefined || lanes[laneIndex][eventIndex] === null) {
|
|
253
|
+
throw new RangeError(`laneMove: lane ${laneIndex} has no note ${String(eventIndex)} to walk.`);
|
|
254
|
+
}
|
|
255
|
+
const here = eventIndex === null ? { laneIndex, eventIndex: null, cycle: at.cycle } : note(laneIndex, eventIndex);
|
|
256
|
+
if (key === 'PageUp' || key === 'PageDown') {
|
|
257
|
+
const target = Math.max(0, Math.min(lanes.length - 1, laneIndex + (key === 'PageDown' ? 1 : -1)));
|
|
258
|
+
if (target === laneIndex) return here;
|
|
259
|
+
const time = eventIndex === null ? at.cycle : lanes[laneIndex][eventIndex].begin;
|
|
260
|
+
let nearest = null;
|
|
261
|
+
let nearestGap = Infinity;
|
|
262
|
+
for (const index of walkOrder(lanes[target])) {
|
|
263
|
+
const gap = Math.abs(lanes[target][index].begin - time);
|
|
264
|
+
if (gap < nearestGap - 1e-9) {
|
|
265
|
+
nearest = index;
|
|
266
|
+
nearestGap = gap;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return nearest === null ? { laneIndex: target, eventIndex: null, cycle: eventIndex === null ? at.cycle : cycleOfStart(time) } : note(target, nearest);
|
|
270
|
+
}
|
|
271
|
+
// The cursor of a lane without notes has no note to walk along.
|
|
272
|
+
if (eventIndex === null) return here;
|
|
273
|
+
const order = walkOrder(lanes[laneIndex]);
|
|
274
|
+
const position = order.indexOf(eventIndex);
|
|
275
|
+
if (key === 'Home') return note(laneIndex, order[0]);
|
|
276
|
+
if (key === 'End') return note(laneIndex, order[order.length - 1]);
|
|
277
|
+
const next = Math.max(0, Math.min(order.length - 1, position + (key === 'ArrowRight' ? 1 : -1)));
|
|
278
|
+
return note(laneIndex, order[next]);
|
|
279
|
+
}
|
package/page/timeline.js
CHANGED
|
@@ -33,6 +33,138 @@ export const TIMELINE_COPY = {
|
|
|
33
33
|
drumNumbered: 'note {note}',
|
|
34
34
|
drumMapUnknown: ' · drum map unknown',
|
|
35
35
|
drumRowsMax: 8,
|
|
36
|
+
// The words of the note panel under the lanes: what it says about the selected note, what a change did and why a
|
|
37
|
+
// note cannot be edited. page/note-panel.js builds every sentence from these strings and nowhere else.
|
|
38
|
+
noteNone: 'No note selected',
|
|
39
|
+
noteEditable: 'Editable',
|
|
40
|
+
noteNotEditable: 'Not editable',
|
|
41
|
+
notePosition: ' · cycle {cycle}, beat {beat}',
|
|
42
|
+
noteSection: " · section '{section}'",
|
|
43
|
+
notePitchLabel: 'Pitch',
|
|
44
|
+
noteVelocityLabel: 'Velocity',
|
|
45
|
+
notePitchValue: '{note} · {midi}',
|
|
46
|
+
notePitchDrum: '{value} · {drum}',
|
|
47
|
+
noteVelocityUnknown: 'unknown',
|
|
48
|
+
notePitchDown: 'Pitch down',
|
|
49
|
+
notePitchDownName: 'Lower pitch by a semitone',
|
|
50
|
+
notePitchDownTitle: 'Lower pitch by a semitone (Down). Shift: an octave.',
|
|
51
|
+
notePitchUp: 'Pitch up',
|
|
52
|
+
notePitchUpName: 'Raise pitch by a semitone',
|
|
53
|
+
notePitchUpTitle: 'Raise pitch by a semitone (Up). Shift: an octave.',
|
|
54
|
+
noteVelocityDown: '−',
|
|
55
|
+
noteVelocityDownName: 'Lower velocity',
|
|
56
|
+
noteVelocityDownTitle: 'Lower velocity by 0.10',
|
|
57
|
+
noteVelocityUp: '+',
|
|
58
|
+
noteVelocityUpName: 'Raise velocity',
|
|
59
|
+
noteVelocityUpTitle: 'Raise velocity by 0.10',
|
|
60
|
+
noteVelocityFixed: 'fixed in the code',
|
|
61
|
+
noteMakeRest: 'Make a rest',
|
|
62
|
+
noteMakeRestName: 'Turn this note into a rest',
|
|
63
|
+
noteMakeRestTitle: 'Turn this note into a rest (Delete)',
|
|
64
|
+
noteAsk: 'Ask Claude',
|
|
65
|
+
noteAskName: 'Ask Claude about this note',
|
|
66
|
+
noteAskTitle: 'Opens the Ask box for this note.',
|
|
67
|
+
// What a gesture on a note the page cannot edit does: the sentence while it is dragged, and the one after it is released.
|
|
68
|
+
noteRefusedPreview: 'Release to ask Claude: {verb} this note by {amount}.',
|
|
69
|
+
noteRaise: 'raise',
|
|
70
|
+
noteLower: 'lower',
|
|
71
|
+
noteRequestReady: 'Request ready in the Ask box. Nothing is sent until you press Enter.',
|
|
72
|
+
noteHintNone: 'Click a note to edit it. Drag across an empty part of a lane to ask Claude about those cycles.',
|
|
73
|
+
noteHintEditable: 'Drag the note or press Up / Down: a semitone, with Shift an octave. Delete makes a rest. Each edit is one History entry.',
|
|
74
|
+
noteHintRefused: 'Up, Down, Make a rest, − and + prepare a request for Claude. Nothing is sent until you press Enter.',
|
|
75
|
+
noteHintRange: 'Press Enter or Ask Claude to ask about these cycles. Esc clears the range.',
|
|
76
|
+
// A range of cycles on one lane: the panel's identity line, built by page/note-panel.js, and what the Ask Claude button says then.
|
|
77
|
+
noteRangeWhere: 'Range · {lane}',
|
|
78
|
+
noteRangeCycles: ' · cycles {a} to {b}',
|
|
79
|
+
noteRangeCycle: ' · cycle {a}',
|
|
80
|
+
noteRangeSectionList: ' · sections {list}',
|
|
81
|
+
noteRangeSectionsMore: ' and {n} more',
|
|
82
|
+
noteRangeNotes: ' · {n} notes',
|
|
83
|
+
noteRangeOneNote: ' · 1 note',
|
|
84
|
+
noteRangeNoNotes: ' · no notes',
|
|
85
|
+
noteRangeAskName: 'Ask Claude about these cycles',
|
|
86
|
+
noteRangeAskTitle: 'Opens the Ask box for these cycles.',
|
|
87
|
+
// The keyboard cursor on a lane that has no note on the page: the panel's identity line, its hint and what Ask Claude says then.
|
|
88
|
+
noteCursorPosition: ' · no notes on this page',
|
|
89
|
+
noteHintCursor: 'Press Enter or Ask Claude to ask about this lane. Shift+Left or Shift+Right selects cycles.',
|
|
90
|
+
noteLaneAskName: 'Ask Claude about this lane',
|
|
91
|
+
noteLaneAskTitle: 'Opens the Ask box for this lane.',
|
|
92
|
+
// The name a screen reader gives the active note of the lanes (the lanes point at it with aria-activedescendant).
|
|
93
|
+
noteActiveName: '{lane}, cycle {cycle}, beat {beat}: note {note}, velocity {velocity}. {state}',
|
|
94
|
+
noteActiveEditable: 'Editable.',
|
|
95
|
+
noteActiveNotEditable: 'Not editable. {reason}',
|
|
96
|
+
noteActiveCursor: '{lane}, cycle {cycle}: no notes on this page.',
|
|
97
|
+
noteStanding: 'An edit rewrites one token in the code: {token}.',
|
|
98
|
+
// Where a token plays: the standing sentences, and the count the preview and the result repeat.
|
|
99
|
+
notePlaces: 'Plays in {n} places: sections {list}. An edit changes all of them.',
|
|
100
|
+
notePlacesOnPage:
|
|
101
|
+
'Plays in {n} places on this page: sections {list}. An edit changes every place it plays, on this page and after it.',
|
|
102
|
+
notePlacesPerCycle: 'This token draws {n} notes in each cycle. An edit changes all of them.',
|
|
103
|
+
notePlacesWords: ' in {n} places',
|
|
104
|
+
notePlacesPageWords: ' in {n} places on this page',
|
|
105
|
+
noteVelocityNotes: ', for {n} notes on this page',
|
|
106
|
+
noteVelocityNotesShort: ', for {n} notes',
|
|
107
|
+
noteSectionName: "'{name}'",
|
|
108
|
+
noteListAnd: '{first} and {last}',
|
|
109
|
+
noteListMore: '{first} and {n} more',
|
|
110
|
+
// The steps of a key or a button that were not written because the note they were aimed at is gone.
|
|
111
|
+
noteStepsDropped: 'The steps you pressed were not written: the note changed in the code. Select it again.',
|
|
112
|
+
noteStepsNotWritten: 'The steps you pressed were not written. Select the note and press them again.',
|
|
113
|
+
noteChangeDropped: 'The change was not written: the note changed in the code. Select it again.',
|
|
114
|
+
noteChangeNotWritten: 'The change you made was not written. Select the note and make it again.',
|
|
115
|
+
noteEditTimeout: 'The page did not answer this edit in time, so check the code.',
|
|
116
|
+
noteNamed: '{note} on {lane}: {text}',
|
|
117
|
+
noteChanged: 'Changed {from} to {to}{places}.',
|
|
118
|
+
noteChangedRest: 'Changed {from} to a rest{places}.',
|
|
119
|
+
noteVelocityNow: 'Velocity of {note} is now {v}{places}{notes}.',
|
|
120
|
+
noteWaitingBar: 'Waiting for the next bar — {from} becomes {to}.',
|
|
121
|
+
noteWaitingPhrase: 'Waiting for the next phrase — {from} becomes {to}.',
|
|
122
|
+
noteWaitingBarRest: 'Waiting for the next bar — {from} becomes a rest.',
|
|
123
|
+
noteWaitingPhraseRest: 'Waiting for the next phrase — {from} becomes a rest.',
|
|
124
|
+
noteWaitingBarVelocity: 'Waiting for the next bar — {note} velocity becomes {v}.',
|
|
125
|
+
noteWaitingPhraseVelocity: 'Waiting for the next phrase — {note} velocity becomes {v}.',
|
|
126
|
+
// The sentence while a note is dragged, and the size of the change in it.
|
|
127
|
+
notePreview: '{from} → {to} ({delta}){places}. Release to write.',
|
|
128
|
+
noteDeltaSemitone: '{sign}1 semitone',
|
|
129
|
+
noteDeltaSemitones: '{sign}{n} semitones',
|
|
130
|
+
noteDeltaOctave: '{sign}1 octave',
|
|
131
|
+
noteDeltaOctaves: '{sign}{n} octaves',
|
|
132
|
+
// The sentence while steps of a key or a button wait for the pause before they are written.
|
|
133
|
+
noteStepPreview: '{from} → {to} ({delta}){places}. Writing in a moment.',
|
|
134
|
+
noteVelocityPreview: 'Velocity {from} → {to}{places}{notes}. Writing in a moment.',
|
|
135
|
+
noteLowest: 'Already the lowest note.',
|
|
136
|
+
noteHighest: 'Already the highest note.',
|
|
137
|
+
noteVelocityLowest: 'Velocity is already at the lowest (0.10).',
|
|
138
|
+
noteVelocityHighest: 'Velocity is already at the highest (1.00).',
|
|
139
|
+
noteVelocityRefusal:
|
|
140
|
+
"The velocity of this note comes from something the page can't change here, like a signal or an option of .midi(). Ask Claude to change it.",
|
|
141
|
+
noteStale: 'That note changed in the code since you selected it. Nothing was changed. Select it again, or ask Claude.',
|
|
142
|
+
noteReadBack: "The page couldn't confirm this edit, so the code is as it was. Ask Claude to make it.",
|
|
143
|
+
noteEditError: 'The page ran into a problem before it changed this note, so the code is as it was. Ask Claude to make the change.',
|
|
144
|
+
noteEditErrorWritten: 'The page ran into a problem after it wrote this change, so check the code.',
|
|
145
|
+
noteNotEvaluated: 'The change did not evaluate, so the code is as it was. Ask Claude to make it.',
|
|
146
|
+
noteNoticeDisconnected: 'Disconnected — editing is off until Moontide reconnects.',
|
|
147
|
+
noteNoticeWaiting: 'A change is waiting for its bar line. Editing works again when it takes over.',
|
|
148
|
+
noteNoticeUnevaluated: 'The editor has changes that are not playing yet. Evaluate them first — editing works on the playing code.',
|
|
149
|
+
// The history entry of one written edit.
|
|
150
|
+
noteRecord: 'changed {track}: {from} to {to}{places}',
|
|
151
|
+
noteRecordRest: 'changed {track}: {from} to a rest{places}',
|
|
152
|
+
noteRecordVelocity: 'changed {track}: velocity of {note} to {v}{places}',
|
|
153
|
+
// Why a note cannot be edited, one sentence for each reason page/note-locate.js gives (NOTE_REASONS).
|
|
154
|
+
noteReasons: {
|
|
155
|
+
'no-token':
|
|
156
|
+
'Made by a function or by chance, or written in a single-quoted string, so no single place in the code holds this note. Ask Claude to change it.',
|
|
157
|
+
'no-pitch': 'This is a sample, not a pitched note, so there is no pitch to change. Ask Claude to change it.',
|
|
158
|
+
several:
|
|
159
|
+
'Shaped by several places in the code (an added note or a factor like *2), so no one place can be edited. Ask Claude to change it.',
|
|
160
|
+
degrees: "Written in degrees. The page doesn't edit those directly yet. Ask Claude to change it.",
|
|
161
|
+
escape:
|
|
162
|
+
"The string that holds this note has an escape sequence, so the page can't find the note in the code. Ask Claude to change it.",
|
|
163
|
+
shifted: 'The pitch you hear is not the pitch written: something adds to it or copies it. Ask Claude to change it.',
|
|
164
|
+
outside:
|
|
165
|
+
"Written outside this track's own code (its statement, or its variations in a song), so the page doesn't edit it here. Ask Claude to change it.",
|
|
166
|
+
shared: 'Comes from code shared by several tracks, so an edit here would change them too. Ask Claude to change it.',
|
|
167
|
+
},
|
|
36
168
|
};
|
|
37
169
|
|
|
38
170
|
/** Cycle count from which the next page starts computing in the background, while the current
|
|
@@ -85,6 +217,13 @@ export function rulerMarks(pageStart) {
|
|
|
85
217
|
export const TRACK_COLOR_COUNT = 8;
|
|
86
218
|
|
|
87
219
|
const NOTE_BASE_SEMITONE = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* The spelling of a note name that Strudel itself plays: a letter in either case, lower-case accidentals (`#`, `s`, `b`, `f`) and an
|
|
223
|
+
* optional octave. noteNameToMidi reads a capital accidental too (`EB3`), which Strudel does not play, so a token is edited only when it
|
|
224
|
+
* matches this: the spelling the page edits and the one the sound engine plays are the same.
|
|
225
|
+
*/
|
|
226
|
+
export const NOTE_NAME_TOKEN = /^([a-gA-G])([#sbf]*)(-?\d+)?$/;
|
|
88
227
|
const DEFAULT_NOTE_OCTAVE = 3;
|
|
89
228
|
|
|
90
229
|
/**
|
|
@@ -93,7 +232,7 @@ const DEFAULT_NOTE_OCTAVE = 3;
|
|
|
93
232
|
* (default 3, matching Strudel's own default). `null` for anything that doesn't parse as a
|
|
94
233
|
* plain note name (a mini-notation pattern string, for example).
|
|
95
234
|
*/
|
|
96
|
-
function noteNameToMidi(note) {
|
|
235
|
+
export function noteNameToMidi(note) {
|
|
97
236
|
const match = /^([a-g])((?:[#sbf])*)(-?\d+)?$/.exec(note.toLowerCase());
|
|
98
237
|
if (!match) return null;
|
|
99
238
|
const [, letter, accidentals, octaveText] = match;
|
|
@@ -340,6 +479,19 @@ function toEvent(hap) {
|
|
|
340
479
|
return { begin: hap.whole.begin.valueOf(), end: hap.whole.end.valueOf(), value: hap.value };
|
|
341
480
|
}
|
|
342
481
|
|
|
482
|
+
/**
|
|
483
|
+
* One event of a lane: the same fields as the real pattern's events, plus `locations`, plain copies of the places in the
|
|
484
|
+
* code the note was read from, `[{ start, end }]` offsets into the code that was evaluated, or null when the note has none
|
|
485
|
+
* (a value made by a function, a hap without a context such as a test double). The real pattern's events keep no
|
|
486
|
+
* locations, so the check that the lanes add up to the real pattern never looks at them. Exported so the page reads the
|
|
487
|
+
* edited track back into the same shape.
|
|
488
|
+
*/
|
|
489
|
+
export function toLaneEvent(hap) {
|
|
490
|
+
const raw = hap.context && Array.isArray(hap.context.locations) ? hap.context.locations : null;
|
|
491
|
+
const locations = raw === null ? null : raw.map((location) => ({ start: location.start, end: location.end }));
|
|
492
|
+
return { begin: hap.whole.begin.valueOf(), end: hap.whole.end.valueOf(), value: hap.value, locations };
|
|
493
|
+
}
|
|
494
|
+
|
|
343
495
|
/**
|
|
344
496
|
* Queries the real pattern and every unmuted track's own solo pattern over `pageCycles`
|
|
345
497
|
* cycles starting at `pageStart`, in slices of `sliceCycles` with a yield to the page between
|
|
@@ -385,7 +537,7 @@ export async function computeTimeline({
|
|
|
385
537
|
const pattern = trackPatterns[index];
|
|
386
538
|
if (!pattern) return;
|
|
387
539
|
for (const hap of pattern.queryArc(from, to)) {
|
|
388
|
-
if (hap.whole && hap.hasOnset()) laneEvents[index].push(
|
|
540
|
+
if (hap.whole && hap.hasOnset()) laneEvents[index].push(toLaneEvent(hap));
|
|
389
541
|
}
|
|
390
542
|
});
|
|
391
543
|
|
|
@@ -423,6 +575,12 @@ export const SIXTEENTHS_PER_CYCLE = BEATS_PER_CYCLE * 4;
|
|
|
423
575
|
*/
|
|
424
576
|
export const MAX_MOMENT_NOTES = 32;
|
|
425
577
|
|
|
578
|
+
/**
|
|
579
|
+
* The most cycles one range covers: the cycles of a page. Mirrors src/protocol.ts's MAX_RANGE_CYCLES by hand
|
|
580
|
+
* (test/ask.test.js checks both are 32).
|
|
581
|
+
*/
|
|
582
|
+
export const MAX_RANGE_CYCLES = 32;
|
|
583
|
+
|
|
426
584
|
// Float noise allowed when an event edge is compared with the instant, in cycles.
|
|
427
585
|
const MOMENT_EPSILON = 1e-9;
|
|
428
586
|
|
|
@@ -450,7 +608,7 @@ function round3(value) {
|
|
|
450
608
|
* the event and is not seen here: such a note reads the default 0.9 (the reference documents
|
|
451
609
|
* `.velocity()` on the pattern).
|
|
452
610
|
*/
|
|
453
|
-
function velocityOf(value, midi) {
|
|
611
|
+
export function velocityOf(value, midi) {
|
|
454
612
|
const defaults = midi ? MIDI_LEVEL : BROWSER_LEVEL;
|
|
455
613
|
const set = value && typeof value === 'object' ? value : {};
|
|
456
614
|
const gain = set.gain === undefined ? defaults.gain : set.gain;
|
|
@@ -461,6 +619,15 @@ function velocityOf(value, midi) {
|
|
|
461
619
|
return product < 0 ? undefined : Math.round(product * 100) / 100;
|
|
462
620
|
}
|
|
463
621
|
|
|
622
|
+
/**
|
|
623
|
+
* The gain of one event as the pinned bundle would apply it: the gain set on the event, else the default of a browser track
|
|
624
|
+
* (0.8) or a MIDI track (1). It may not be a number (a signal), which the caller must check.
|
|
625
|
+
*/
|
|
626
|
+
export function gainOf(value, midi) {
|
|
627
|
+
const set = value && typeof value === 'object' ? value : {};
|
|
628
|
+
return set.gain === undefined ? (midi ? MIDI_LEVEL : BROWSER_LEVEL).gain : set.gain;
|
|
629
|
+
}
|
|
630
|
+
|
|
464
631
|
function momentNote(lane, event, state) {
|
|
465
632
|
const value = event.value && typeof event.value === 'object' ? event.value : {};
|
|
466
633
|
const note = { track: lane.track };
|
|
@@ -559,3 +726,30 @@ export function momentAt({ pageStart, fraction, lanes, version, focusTrack }) {
|
|
|
559
726
|
more: Math.max(0, notes.length - MAX_MOMENT_NOTES),
|
|
560
727
|
};
|
|
561
728
|
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* The notes of one lane that start in the whole cycles `from` to `to` (both included): `{ notes, more }`. A note counts when its
|
|
732
|
+
* start is at least `from` and before `to + 1`, so one that started earlier and still sounds is not in it, and one that starts
|
|
733
|
+
* exactly at `to + 1` is not either. `lane` is `{ track, midi, instrument, drumMap, events }` as `momentAt` takes it. Each
|
|
734
|
+
* note is shaped like a moment note (the same fields, built by the same code) without its state, in order of start and then of
|
|
735
|
+
* pitch, the lower first; at most MAX_MOMENT_NOTES are kept and `more` counts the rest. Bad input is a loud TypeError.
|
|
736
|
+
*/
|
|
737
|
+
export function rangeNotes(lane, from, to) {
|
|
738
|
+
if (lane === null || typeof lane !== 'object' || !Array.isArray(lane.events)) {
|
|
739
|
+
throw new TypeError(`rangeNotes: lane must hold an events array, got ${String(lane)}`);
|
|
740
|
+
}
|
|
741
|
+
if (!Number.isInteger(from) || from < 0) throw new TypeError(`rangeNotes: from must be a non-negative integer, got ${String(from)}`);
|
|
742
|
+
if (!Number.isInteger(to) || to < from || to - from >= MAX_RANGE_CYCLES) {
|
|
743
|
+
throw new TypeError(`rangeNotes: to must be an integer from ${from} to ${from + MAX_RANGE_CYCLES - 1}, got ${String(to)}`);
|
|
744
|
+
}
|
|
745
|
+
const inside = lane.events.filter((event) => event.begin >= from - MOMENT_EPSILON && event.begin < to + 1 - MOMENT_EPSILON);
|
|
746
|
+
const keyed = inside.map((event) => ({ event, pitch: pitchOf(event.value) }));
|
|
747
|
+
// A note without a pitch (a sample, a tick) sorts after the pitched notes that start with it.
|
|
748
|
+
keyed.sort((a, b) => a.event.begin - b.event.begin || (a.pitch ?? Infinity) - (b.pitch ?? Infinity) || 0);
|
|
749
|
+
const notes = keyed.slice(0, MAX_MOMENT_NOTES).map(({ event }) => {
|
|
750
|
+
const note = momentNote(lane, event, 'sounding');
|
|
751
|
+
delete note.state;
|
|
752
|
+
return note;
|
|
753
|
+
});
|
|
754
|
+
return { notes, more: Math.max(0, keyed.length - MAX_MOMENT_NOTES) };
|
|
755
|
+
}
|
package/page/top-bar-view.js
CHANGED
|
@@ -32,7 +32,7 @@ export function createTopBarView({ document, ResizeObserver }) {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
// What each item shows now, so only what changed is written (no rebuild on every poll).
|
|
35
|
-
let shownTempo = null;
|
|
35
|
+
let shownTempo = null; // the parts of the tempo readout as last written, or null before the first write
|
|
36
36
|
let shownCountdown = null;
|
|
37
37
|
let shownStatus = '';
|
|
38
38
|
let shownCycleBeat = '';
|
|
@@ -55,20 +55,25 @@ export function createTopBarView({ document, ResizeObserver }) {
|
|
|
55
55
|
|
|
56
56
|
/**
|
|
57
57
|
* The tempo readout from the model (`tempoText` in page/bar-model.js). It is never hidden: nothing playing shows the
|
|
58
|
-
* dash in muted text. The number, the arrow part (
|
|
59
|
-
* accessible name and the title are written only when they changed
|
|
58
|
+
* dash in muted text. The number, the arrow part (while a change with a new tempo waits or a tempo ramp runs), the
|
|
59
|
+
* unit, the accessible name and the title are each written only when they changed, so a ramp that steps the number
|
|
60
|
+
* once per BPM rewrites the number alone. The spaces between the parts are written here. The bar is measured again
|
|
61
|
+
* (fit) only when the readout's text length or its idle state changed: the digits are mono, so a number of the same
|
|
62
|
+
* length never changes the bar's width.
|
|
60
63
|
*/
|
|
61
64
|
function showTempo(model) {
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
tempoReadout.
|
|
70
|
-
|
|
71
|
-
|
|
65
|
+
const arrow = model.arrow === '' ? '' : ` ${model.arrow}`;
|
|
66
|
+
const unit = ` ${model.unit}`;
|
|
67
|
+
const before = shownTempo;
|
|
68
|
+
if (before !== null && before.num === model.num && before.arrow === arrow && before.unit === unit && before.name === model.name && before.title === model.title && before.idle === model.idle) return;
|
|
69
|
+
if (before === null || before.num !== model.num) tempoNum.textContent = model.num;
|
|
70
|
+
if (before === null || before.arrow !== arrow) tempoArrow.textContent = arrow;
|
|
71
|
+
if (before === null || before.unit !== unit) tempoUnit.textContent = unit;
|
|
72
|
+
if (before === null || before.idle !== model.idle) tempoReadout.dataset.idle = String(model.idle);
|
|
73
|
+
if (before === null || before.name !== model.name) tempoReadout.setAttribute('aria-label', model.name);
|
|
74
|
+
if (before === null || before.title !== model.title) tempoReadout.setAttribute('title', model.title);
|
|
75
|
+
shownTempo = { num: model.num, arrow, unit, name: model.name, title: model.title, idle: model.idle, length: model.text.length };
|
|
76
|
+
if (before === null || before.length !== shownTempo.length || before.idle !== model.idle) fit();
|
|
72
77
|
}
|
|
73
78
|
|
|
74
79
|
/**
|
package/page/tracks.js
CHANGED
|
@@ -364,6 +364,17 @@ export function parseLabel(label) {
|
|
|
364
364
|
return { mutedByLabel, core, solo, soloInert: solo === 'marker' && mutedByLabel, base, safe };
|
|
365
365
|
}
|
|
366
366
|
|
|
367
|
+
/**
|
|
368
|
+
* The identity of a labeled track that has no `$` in its label: `label:` and the label without
|
|
369
|
+
* its mute underscore and solo marker, or the raw label when the mixer cannot read it. The
|
|
370
|
+
* mixer and the note tag both use this one rule, so a note is always reported under the same
|
|
371
|
+
* key as the strip it belongs to.
|
|
372
|
+
*/
|
|
373
|
+
export function labelKey(label) {
|
|
374
|
+
const parsed = parseLabel(label);
|
|
375
|
+
return `label:${parsed.safe ? parsed.base : label}`;
|
|
376
|
+
}
|
|
377
|
+
|
|
367
378
|
/**
|
|
368
379
|
* Every labeled statement in the code, in source order (after the last-statement-wins dedup
|
|
369
380
|
* above). A track is `{ index, kind: 'label', label, name, identity, muted, start, end, ... }`:
|
|
@@ -407,7 +418,7 @@ function buildLabelTracks(statements, topLevelDecls) {
|
|
|
407
418
|
kind: 'label',
|
|
408
419
|
label,
|
|
409
420
|
name: isDollar ? displayNameFromSound(expression) : label,
|
|
410
|
-
identity: isDollar ? structuralIdentity(expression) :
|
|
421
|
+
identity: isDollar ? structuralIdentity(expression) : labelKey(label),
|
|
411
422
|
baseLabel,
|
|
412
423
|
hushed,
|
|
413
424
|
ownMuted,
|
|
@@ -650,13 +661,10 @@ export function soloSource(code, trackIndex, { parse }) {
|
|
|
650
661
|
const edits = [];
|
|
651
662
|
collectStringEdits(ast, edits);
|
|
652
663
|
|
|
653
|
-
const declarationTexts = [];
|
|
654
664
|
const allCalls = [];
|
|
655
665
|
let lastEachCall = null;
|
|
656
666
|
for (const statement of ast.body) {
|
|
657
|
-
if (statement
|
|
658
|
-
declarationTexts.push(applyEdits(code, edits, statement.start, statement.end));
|
|
659
|
-
} else if (topLevelCallNamed(statement, 'all')) {
|
|
667
|
+
if (topLevelCallNamed(statement, 'all')) {
|
|
660
668
|
allCalls.push(statement.expression);
|
|
661
669
|
} else if (topLevelCallNamed(statement, 'each')) {
|
|
662
670
|
lastEachCall = statement.expression;
|
|
@@ -701,10 +709,31 @@ export function soloSource(code, trackIndex, { parse }) {
|
|
|
701
709
|
wrapped = wrapEachAll(applyEdits(code, [...keptEdits, ...silenceEdits], programExpr.start, programExpr.end));
|
|
702
710
|
}
|
|
703
711
|
|
|
712
|
+
return `${preludeOf(code, ast, edits)}return (${wrapped});`;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** True for a top-level statement that declares a name: a variable, a function or a class. */
|
|
716
|
+
function isDeclaration(statement) {
|
|
717
|
+
return statement.type === 'VariableDeclaration' || statement.type === 'FunctionDeclaration' || statement.type === 'ClassDeclaration';
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** The prelude text for an already parsed program and its string edits. */
|
|
721
|
+
function preludeOf(code, ast, edits) {
|
|
704
722
|
let body = '"use strict";\n';
|
|
705
|
-
for (const
|
|
706
|
-
body +=
|
|
723
|
+
for (const statement of ast.body) {
|
|
724
|
+
if (isDeclaration(statement)) body += applyEdits(code, edits, statement.start, statement.end) + '\n';
|
|
707
725
|
}
|
|
708
|
-
body += `return (${wrapped});`;
|
|
709
726
|
return body;
|
|
710
727
|
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* The text every solo program starts with, and the declared-against-played check starts with too: "use strict"; and every
|
|
731
|
+
* top-level declaration in source order, each followed by a newline, with the same string rewrites soloSource applies.
|
|
732
|
+
* Evaluated, it defines the code's own constants and helper functions and nothing else runs.
|
|
733
|
+
*/
|
|
734
|
+
export function soloPrelude(code, { parse }) {
|
|
735
|
+
const ast = parse(code, PARSE_OPTIONS);
|
|
736
|
+
const edits = [];
|
|
737
|
+
collectStringEdits(ast, edits);
|
|
738
|
+
return preludeOf(code, ast, edits);
|
|
739
|
+
}
|