moontide-mcp 2.0.1 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -6
- package/dist/ask.js +30 -8
- package/dist/mcp.js +60 -14
- package/dist/protocol.js +89 -2
- package/dist/reference.js +22 -0
- package/dist/session.js +53 -0
- package/package.json +1 -1
- package/page/ask-view.js +21 -7
- package/page/ask.js +292 -34
- package/page/change-timing.js +25 -4
- package/page/harmony.js +13 -0
- package/page/index.html +4 -3
- package/page/jam-model.js +11 -2
- package/page/jam-view.js +17 -0
- package/page/lights.js +278 -0
- package/page/main.js +1723 -140
- package/page/midi-clock.js +1 -1
- package/page/midi-out.js +12 -0
- 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 +282 -13
- package/page/note-edit.js +267 -0
- package/page/note-locate.js +346 -0
- package/page/note-panel.js +381 -0
- package/page/note-tag.js +126 -0
- package/page/page-rewrite.js +184 -0
- package/page/song-edit.js +230 -0
- package/page/song-fit.js +250 -0
- package/page/song-grid-model.js +491 -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 +760 -0
- package/page/style.css +879 -84
- package/page/takeover.js +20 -5
- package/page/timeline-edit-view.js +1956 -0
- package/page/timeline-gesture.js +279 -0
- package/page/timeline.js +196 -3
- package/page/tracks.js +37 -8
- package/page/ui-state.js +10 -5
- package/page/views.js +25 -8
- package/reference/functions.md +1 -0
- package/reference/midi.md +2 -0
- package/reference/structure.md +131 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// Pure module for Moontide's browser page: the edit of one note on the Timeline. It turns the token a note leads back to
|
|
2
|
+
// into its neighbour a semitone away, builds the one-token edit list and writes it through the page's shared rewrite path
|
|
3
|
+
// after the page has read the edited track back. The text written is built from a closed grammar (a note letter, its
|
|
4
|
+
// accidentals, an octave digit, or a number) and never from free text, and the rest of the code stays byte for byte as it
|
|
5
|
+
// was. Nothing here touches the DOM or a global: the write goes through the dependencies it is given, so node:test drives
|
|
6
|
+
// it with fakes and page/main.js passes the real ones.
|
|
7
|
+
|
|
8
|
+
import { applyEdits } from './mixer-rewrite.js';
|
|
9
|
+
import { compareReadBack, sameTokenEvents } from './note-locate.js';
|
|
10
|
+
import { noteHistoryLabel } from './note-panel.js';
|
|
11
|
+
import { NOTE_NAME_TOKEN, noteNameToMidi } from './timeline.js';
|
|
12
|
+
|
|
13
|
+
/** The reasons an edit is refused by this module (the shared rewrite path has its own). */
|
|
14
|
+
export const NOTE_EDIT_REFUSALS = Object.freeze(['stale', 'read-back']);
|
|
15
|
+
|
|
16
|
+
/** Raised to stop an edit before anything is written; `reason` is one of NOTE_EDIT_REFUSALS. */
|
|
17
|
+
export class NoteEditStop extends Error {
|
|
18
|
+
constructor(reason) {
|
|
19
|
+
if (!NOTE_EDIT_REFUSALS.includes(reason)) throw new Error(`Moontide: unknown note edit refusal '${String(reason)}'.`);
|
|
20
|
+
super(`Moontide: the note edit stopped (${reason}).`);
|
|
21
|
+
this.name = 'NoteEditStop';
|
|
22
|
+
this.reason = reason;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The token that stands for a rest in a mini-notation string: a slot that plays nothing and keeps the step count. */
|
|
27
|
+
export const REST_TOKEN = '~';
|
|
28
|
+
|
|
29
|
+
/** The lowest and the highest note number. */
|
|
30
|
+
const LOWEST_NOTE = 0;
|
|
31
|
+
const HIGHEST_NOTE = 127;
|
|
32
|
+
|
|
33
|
+
/** The octave a note name is in when it has no digit: the default of the pinned Strudel. */
|
|
34
|
+
const DEFAULT_OCTAVE = 3;
|
|
35
|
+
|
|
36
|
+
const NUMBER_TOKEN = /^-?\d+(\.\d+)?$/;
|
|
37
|
+
|
|
38
|
+
const SHARP_NAMES = ['c', 'c#', 'd', 'd#', 'e', 'f', 'f#', 'g', 'g#', 'a', 'a#', 'b'];
|
|
39
|
+
const FLAT_NAMES = ['c', 'db', 'd', 'eb', 'e', 'f', 'gb', 'g', 'ab', 'a', 'bb', 'b'];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The token `delta` semitones from `text`, a note name (`c3`, `Eb`, `cs4`) or a number. Gives `{ text, midi }` or, at the end
|
|
43
|
+
* of the note range (0 to 127), `{ limit: 'lowest' | 'highest' }`. A name is respelled the way the old one was written: a
|
|
44
|
+
* flat becomes a flat, a sharp keeps its sign (`#` or `s`), the case of the letter stays, and an octave digit is left out only
|
|
45
|
+
* when the old token had none and the new note is in octave 3. A number stays a number. Text that is neither throws.
|
|
46
|
+
*/
|
|
47
|
+
export function stepPitch(text, delta) {
|
|
48
|
+
if (typeof text !== 'string') throw new Error(`Moontide: a note token is text, got ${String(text)}.`);
|
|
49
|
+
if (!Number.isInteger(delta)) throw new Error(`Moontide: a pitch step is a whole number of semitones, got ${String(delta)}.`);
|
|
50
|
+
if (NUMBER_TOKEN.test(text)) {
|
|
51
|
+
const next = Math.round((Number(text) + delta) * 1e6) / 1e6;
|
|
52
|
+
if (next < LOWEST_NOTE) return { limit: 'lowest' };
|
|
53
|
+
if (next > HIGHEST_NOTE) return { limit: 'highest' };
|
|
54
|
+
return { text: String(next), midi: next };
|
|
55
|
+
}
|
|
56
|
+
const match = NOTE_NAME_TOKEN.exec(text);
|
|
57
|
+
const midi = match === null ? null : noteNameToMidi(text);
|
|
58
|
+
if (match === null || midi === null) throw new Error(`Moontide: '${text}' is neither a note name nor a number, so its pitch cannot be stepped.`);
|
|
59
|
+
const next = midi + delta;
|
|
60
|
+
if (next < LOWEST_NOTE) return { limit: 'lowest' };
|
|
61
|
+
if (next > HIGHEST_NOTE) return { limit: 'highest' };
|
|
62
|
+
const [, letter, accidentals, digits] = match;
|
|
63
|
+
const flat = /[bf]/.test(accidentals);
|
|
64
|
+
const names = flat ? FLAT_NAMES : SHARP_NAMES;
|
|
65
|
+
let name = names[next % 12];
|
|
66
|
+
if (name.length === 2) {
|
|
67
|
+
const sign = flat ? (accidentals.includes('f') ? 'f' : 'b') : accidentals.includes('s') ? 's' : '#';
|
|
68
|
+
name = name[0] + sign;
|
|
69
|
+
}
|
|
70
|
+
if (letter === letter.toUpperCase()) name = name[0].toUpperCase() + name.slice(1);
|
|
71
|
+
const octave = Math.floor(next / 12) - 1;
|
|
72
|
+
const written = digits === undefined && octave === DEFAULT_OCTAVE ? name : `${name}${octave}`;
|
|
73
|
+
return { text: written, midi: next };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The lowest and the highest velocity a step reaches, and the size of a step. */
|
|
77
|
+
const LOWEST_VELOCITY = 0.1;
|
|
78
|
+
const HIGHEST_VELOCITY = 1;
|
|
79
|
+
const VELOCITY_STEP = 0.1;
|
|
80
|
+
|
|
81
|
+
const round2 = (value) => Math.round(value * 100) / 100;
|
|
82
|
+
const round3 = (value) => Math.round(value * 1000) / 1000;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The velocity `steps` steps of 0.10 from `shown`, kept between 0.10 and 1.00, in two decimals. A velocity already at an end
|
|
86
|
+
* stays there when the step goes further out, so a step never moves a velocity the wrong way.
|
|
87
|
+
*/
|
|
88
|
+
export function velocityTarget({ shown, steps }) {
|
|
89
|
+
if (!Number.isInteger(steps)) throw new Error(`Moontide: a velocity step is a whole number of steps, got ${String(steps)}.`);
|
|
90
|
+
const now = round2(shown);
|
|
91
|
+
if (steps > 0 && now >= HIGHEST_VELOCITY) return now;
|
|
92
|
+
if (steps < 0 && now <= LOWEST_VELOCITY) return now;
|
|
93
|
+
return Math.max(LOWEST_VELOCITY, Math.min(HIGHEST_VELOCITY, round2(now + steps * VELOCITY_STEP)));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The velocity token that makes a note play `steps` steps of 0.10 louder (softer when negative) than the `shown` velocity,
|
|
98
|
+
* which is gain times the velocity written. Gives `{ token, target }` (`token` is the number to write, `target` the velocity
|
|
99
|
+
* that will be shown: the token is `target / gain` in three decimals, so on a browser lane at the default gain 0.8 it may
|
|
100
|
+
* be above 1), `{ limit: 'lowest' | 'highest' }` when the velocity is already at that end, or `{ fixed: true }` when no token
|
|
101
|
+
* can give the target: the gain is 0 or not a finite number, or the rounded token does not play as the target.
|
|
102
|
+
*/
|
|
103
|
+
export function velocityStep({ shown, gain, steps }) {
|
|
104
|
+
if (typeof shown !== 'number' || !Number.isFinite(shown)) throw new Error(`Moontide: the velocity shown is a finite number, got ${String(shown)}.`);
|
|
105
|
+
const target = velocityTarget({ shown, steps });
|
|
106
|
+
if (target === round2(shown)) return { limit: steps > 0 ? 'highest' : 'lowest' };
|
|
107
|
+
if (typeof gain !== 'number' || !Number.isFinite(gain) || gain <= 0) return { fixed: true };
|
|
108
|
+
const token = round3(target / gain);
|
|
109
|
+
if (!(token > 0) || !Number.isFinite(token) || round2(gain * token) !== target) return { fixed: true };
|
|
110
|
+
return { token: String(token), target };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The edit list that replaces `token` (`{ start, end, text }`, read from `code`) by `insert`: one `{ from, to, insert }`. The
|
|
115
|
+
* code at the token's place must be the token's text; anything else throws, because the place no longer belongs to the code.
|
|
116
|
+
*/
|
|
117
|
+
export function tokenEdit(code, token, insert) {
|
|
118
|
+
if (typeof insert !== 'string' || insert === '') throw new Error('Moontide: a token edit needs the text to write.');
|
|
119
|
+
if (code.slice(token.start, token.end) !== token.text) {
|
|
120
|
+
throw new Error(`Moontide: the code at ${token.start} to ${token.end} is not the token '${token.text}' any more.`);
|
|
121
|
+
}
|
|
122
|
+
return [{ from: token.start, to: token.end, insert }];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Writes one edit of the note the person saw. `seen` is `{ code, trackIndex, track, event, events, token, pageStart,
|
|
127
|
+
* velocity, places, placesWindow }` as they saw it (`events` the lane as drawn, `event` the note, `token` its place in `code`,
|
|
128
|
+
* `perCycle` how many notes the token draws in each cycle in a piece without a structure, `velocity` what page/note-locate.js says of its velocity, `places` what the page worked out of where the token plays: a
|
|
129
|
+
* promise or a value of `{ count, sections, onPage, events }`, absent for a piece without a structure, and `placesWindow` the
|
|
130
|
+
* `{ from, to }` stretch of the structure that `events` are the notes of, or null; `velocityPlaces` and `velocityWindow` are the same for the
|
|
131
|
+
* number that holds the velocity, which a velocity edit counts and reads back over the whole stretch just as a pitch does), `change` is `{ kind: 'pitch', semitones }`,
|
|
132
|
+
* `{ kind: 'rest' }` or `{ kind: 'velocity', steps }` (a whole number of steps of 0.10) and `deps` is `{ readBack(code, span)
|
|
133
|
+
* -> promise of the track's events over the drawn page, or over `span` (`{ from, to }`) when one is given, apply(request)
|
|
134
|
+
* (the shared rewrite path), timing, autostart, record(code, label), whenFinished(record, fn), activeCode() }`.
|
|
135
|
+
*
|
|
136
|
+
* The count of places is awaited before anything is built, so an edit is never written without it, and the result and the
|
|
137
|
+
* history entry carry it when it is above 1 (a velocity also carries how many notes of the page share its number). The edited code
|
|
138
|
+
* is read back first: the edit is written only when the read-back shows the same notes at the same times
|
|
139
|
+
* with the new pitch on the notes of the token (without them for a rest, or playing the new velocity for a velocity) and
|
|
140
|
+
* nothing else changed, over the drawn page and over the whole stretch the places were counted on, so
|
|
141
|
+
* an edit that would change a note outside the places it named is refused (a velocity number that plays at another gain in another section is refused that way). The write itself refuses unless the code that plays is the code the note was read from. Resolves one
|
|
142
|
+
* outcome, each with `what` (`pitch`, `rest` or `velocity`): `{ kind: 'written', what, label, from, to }` (for a velocity
|
|
143
|
+
* `from` is the note and `to` the new velocity in two decimals), `{ kind: 'waiting', what, label, from, to, at, record }` (the
|
|
144
|
+
* change waits for its line and is recorded when it takes over), `{ kind: 'refused', reason }` (`stale`, `read-back`,
|
|
145
|
+
* `velocity-fixed` or a refusal of the rewrite path), `{ kind: 'refused', reason: 'error', afterWrite: true, error }` (an error
|
|
146
|
+
* the page did not plan for, thrown by the rewrite path or after it, so the code may have been written), `{ kind: 'refused',
|
|
147
|
+
* reason: 'error', error }` (the evaluation threw and the rewrite path put the editor text back: nothing was written), `{ kind: 'failed' }`
|
|
148
|
+
* (the edited code did not evaluate; the editor is set back), or `{ kind: 'unchanged' }` (`limit` says which end of the note
|
|
149
|
+
* range or of the velocities stopped it). An error before the write reaches the caller as it is: nothing was written then.
|
|
150
|
+
*/
|
|
151
|
+
export async function writeNoteEdit({ seen, change, deps }) {
|
|
152
|
+
const known = change !== null && typeof change === 'object' && (change.kind === 'pitch' || change.kind === 'rest' || change.kind === 'velocity');
|
|
153
|
+
if (!known) throw new Error(`Moontide: a note edit does not know the change ${JSON.stringify(change)}.`);
|
|
154
|
+
// What is replaced, by what, how the read-back judges it, and the words of the outcome and the history entry.
|
|
155
|
+
let place;
|
|
156
|
+
let insert;
|
|
157
|
+
let proofChange;
|
|
158
|
+
let to;
|
|
159
|
+
let label;
|
|
160
|
+
if (change.kind === 'pitch') {
|
|
161
|
+
const step = stepPitch(seen.token.text, change.semitones);
|
|
162
|
+
if (step.limit !== undefined) return { kind: 'unchanged', what: 'pitch', limit: step.limit };
|
|
163
|
+
place = seen.token;
|
|
164
|
+
insert = step.text;
|
|
165
|
+
proofChange = { kind: 'pitch', midi: step.midi };
|
|
166
|
+
to = step.text;
|
|
167
|
+
label = noteHistoryLabel({ track: seen.track, from: seen.token.text, to: step.text });
|
|
168
|
+
} else if (change.kind === 'rest') {
|
|
169
|
+
place = seen.token;
|
|
170
|
+
insert = REST_TOKEN;
|
|
171
|
+
proofChange = { kind: 'rest' };
|
|
172
|
+
to = REST_TOKEN;
|
|
173
|
+
label = noteHistoryLabel({ track: seen.track, from: seen.token.text, rest: true });
|
|
174
|
+
} else {
|
|
175
|
+
const velocity = seen.velocity;
|
|
176
|
+
if (velocity === undefined || velocity === null || velocity.writable !== true) return { kind: 'refused', reason: 'velocity-fixed' };
|
|
177
|
+
const step = velocityStep({ shown: velocity.shown, gain: velocity.gain, steps: change.steps });
|
|
178
|
+
if (step.limit !== undefined) return { kind: 'unchanged', what: 'velocity', limit: step.limit };
|
|
179
|
+
if (step.fixed === true) return { kind: 'refused', reason: 'velocity-fixed' };
|
|
180
|
+
place = velocity.token;
|
|
181
|
+
insert = step.token;
|
|
182
|
+
proofChange = { kind: 'velocity', target: step.target, gain: velocity.gain };
|
|
183
|
+
to = step.target.toFixed(2);
|
|
184
|
+
label = noteHistoryLabel({ track: seen.track, from: seen.token.text, velocity: step.target });
|
|
185
|
+
}
|
|
186
|
+
// Where the token plays is known before anything is written: a gesture made while it is still being counted waits for it here. A
|
|
187
|
+
// velocity edit is about the number that holds the velocity, which may play in other places than the pitch token does.
|
|
188
|
+
const counting = change.kind === 'velocity' ? seen.velocityPlaces : seen.places;
|
|
189
|
+
const stretch = change.kind === 'velocity' ? seen.velocityWindow : seen.placesWindow;
|
|
190
|
+
let places = null;
|
|
191
|
+
if (counting !== undefined && counting !== null) {
|
|
192
|
+
try {
|
|
193
|
+
places = await counting;
|
|
194
|
+
} catch (error) {
|
|
195
|
+
return { kind: 'refused', reason: 'read-back', error };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const edits = tokenEdit(seen.code, place, insert);
|
|
199
|
+
const code = applyEdits(seen.code, edits);
|
|
200
|
+
// Every kind of edit changes every note of its token, so the stretch the places were counted on is read back as well.
|
|
201
|
+
const span = places !== null && stretch !== undefined && stretch !== null ? stretch : null;
|
|
202
|
+
let after;
|
|
203
|
+
let afterWindow = null;
|
|
204
|
+
try {
|
|
205
|
+
after = await deps.readBack(code);
|
|
206
|
+
if (span !== null) afterWindow = await deps.readBack(code, span);
|
|
207
|
+
} catch (error) {
|
|
208
|
+
return { kind: 'refused', reason: 'read-back', error };
|
|
209
|
+
}
|
|
210
|
+
const proof = compareReadBack({ before: seen.events, after, token: place, change: proofChange });
|
|
211
|
+
if (!proof.ok) return { kind: 'refused', reason: 'read-back', why: proof.why };
|
|
212
|
+
if (span !== null) {
|
|
213
|
+
const wider = compareReadBack({ before: places.events, after: afterWindow, token: place, change: proofChange });
|
|
214
|
+
if (!wider.ok) return { kind: 'refused', reason: 'read-back', why: `places-${wider.why}` };
|
|
215
|
+
}
|
|
216
|
+
// What the result and the history entry add: where the token plays, or how many notes of the page share the velocity.
|
|
217
|
+
const count = places === null ? (change.kind === 'velocity' ? 0 : (seen.perCycle ?? 0)) : places.count;
|
|
218
|
+
const onPage = places !== null && places.onPage === true;
|
|
219
|
+
const notes = change.kind === 'velocity' ? sameTokenEvents({ events: seen.events, token: place }).length : 0;
|
|
220
|
+
if (change.kind === 'pitch') label = noteHistoryLabel({ track: seen.track, from: seen.token.text, to, places: count, onPage });
|
|
221
|
+
else if (change.kind === 'rest') label = noteHistoryLabel({ track: seen.track, from: seen.token.text, rest: true, places: count, onPage });
|
|
222
|
+
else label = noteHistoryLabel({ track: seen.track, from: seen.token.text, velocity: proofChange.target, places: count, onPage });
|
|
223
|
+
const told = (outcome) => {
|
|
224
|
+
if (count > 1) {
|
|
225
|
+
outcome.places = count;
|
|
226
|
+
if (onPage) outcome.onPage = true;
|
|
227
|
+
}
|
|
228
|
+
if (notes > 1) outcome.notes = notes;
|
|
229
|
+
return outcome;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
let result;
|
|
233
|
+
try {
|
|
234
|
+
result = await deps.apply({
|
|
235
|
+
timing: deps.timing,
|
|
236
|
+
autostart: deps.autostart,
|
|
237
|
+
flash: false,
|
|
238
|
+
build(base) {
|
|
239
|
+
// The place belongs to the code the note was read from and to no other.
|
|
240
|
+
if (base !== seen.code) throw new NoteEditStop('stale');
|
|
241
|
+
return { code, edits };
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
} catch (error) {
|
|
245
|
+
if (error instanceof NoteEditStop) return { kind: 'refused', reason: error.reason };
|
|
246
|
+
// An evaluation that threw after which the rewrite path put the editor text back: nothing was written or armed.
|
|
247
|
+
if (error !== null && typeof error === 'object' && error.restored === true) return { kind: 'refused', reason: 'error', error };
|
|
248
|
+
// The rewrite path may have set the editor text or evaluated it before it threw: the code may have changed.
|
|
249
|
+
return { kind: 'refused', reason: 'error', afterWrite: true, error };
|
|
250
|
+
}
|
|
251
|
+
if (result.status === 'refused') return { kind: 'refused', reason: result.reason };
|
|
252
|
+
if (result.status === 'unchanged') return { kind: 'unchanged', what: change.kind };
|
|
253
|
+
if (result.status === 'failed') return { kind: 'failed' };
|
|
254
|
+
|
|
255
|
+
// The code is written from here on: an error now is told as one that came after the write.
|
|
256
|
+
try {
|
|
257
|
+
const deferred = result.run.deferred;
|
|
258
|
+
if (deferred === null || deferred === undefined) {
|
|
259
|
+
deps.record(deps.activeCode(), label);
|
|
260
|
+
return told({ kind: 'written', what: change.kind, label, from: seen.token.text, to });
|
|
261
|
+
}
|
|
262
|
+
deps.whenFinished(deferred, () => deps.record(typeof deferred.code === 'string' ? deferred.code : deps.activeCode(), label));
|
|
263
|
+
return told({ kind: 'waiting', what: change.kind, label, from: seen.token.text, to, at: deps.timing.at, record: deferred });
|
|
264
|
+
} catch (error) {
|
|
265
|
+
return { kind: 'refused', reason: 'error', afterWrite: true, error };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
// Pure module for Moontide's browser page: how a note drawn on the Timeline leads back to one place in the code, and
|
|
2
|
+
// whether that place may be rewritten. The pinned Strudel keeps, for each note it plays, the offsets of the strings it was
|
|
3
|
+
// read from (`event.locations`, a list of { start, end }). Which of those offsets is the note's own pitch is found by
|
|
4
|
+
// parsing the playing code: the string that holds an offset, and the call the string is an argument of (`note`, `n`,
|
|
5
|
+
// `velocity`, ...), never the position of the offset in the list, which moves with the chain. A note may be edited only when
|
|
6
|
+
// it leads back to exactly one double-quoted `note("...")` token, the code at it is the bare token, the pitch heard is the
|
|
7
|
+
// token's own pitch and the token lies in the code of the track alone. Every other note is refused with one reason. The
|
|
8
|
+
// offsets are good only against the exact code they were measured on, so nothing here caches across versions.
|
|
9
|
+
// `parse` (acorn) is injected, as in page/tracks.js, and nothing here touches the DOM, so node:test and page/main.js import
|
|
10
|
+
// the module without a build step.
|
|
11
|
+
|
|
12
|
+
import { PARSE_OPTIONS } from './tracks.js';
|
|
13
|
+
import { NOTE_NAME_TOKEN, eventKey, gainOf, noteNameToMidi, pitchOf, sameEvents, velocityOf } from './timeline.js';
|
|
14
|
+
import { playingSection } from './song-grid-model.js';
|
|
15
|
+
|
|
16
|
+
/** The reasons a note cannot be edited, in the order they are tried: the first that applies is the one given. */
|
|
17
|
+
export const NOTE_REASONS = Object.freeze(['no-token', 'no-pitch', 'several', 'degrees', 'escape', 'shifted', 'outside', 'shared']);
|
|
18
|
+
|
|
19
|
+
/** The characters a bare note token is made of; a token is the whole run of them around its place. */
|
|
20
|
+
const TOKEN_CHAR = /[A-Za-z0-9#._-]/;
|
|
21
|
+
|
|
22
|
+
/** A note written as a number: a whole number or a decimal. */
|
|
23
|
+
const NUMBER_TOKEN = /^-?\d+(\.\d+)?$/;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The name of the call a string is a direct argument of: the name of an identifier callee (`note("...")`) or the property
|
|
27
|
+
* of a non-computed member callee (`s("x").note("...")`). A string that is the object of a member (`"c3 e3".note()`) or sits
|
|
28
|
+
* anywhere else has no call: null.
|
|
29
|
+
*/
|
|
30
|
+
function callOf(node, parent) {
|
|
31
|
+
if (parent === null || parent.type !== 'CallExpression' || !parent.arguments.includes(node)) return null;
|
|
32
|
+
const { callee } = parent;
|
|
33
|
+
if (callee.type === 'Identifier') return callee.name;
|
|
34
|
+
if (callee.type === 'MemberExpression' && !callee.computed && callee.property.type === 'Identifier') return callee.property.name;
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Reads the playing code once: `strings` are the string literals the pinned Strudel gives locations to (double-quoted
|
|
40
|
+
* strings and untagged template literals; a single-quoted string carries no location and is left out), sorted by start,
|
|
41
|
+
* each `{ start, end, raw, call }`; `declarations` are the top-level const, let and var declarators with an initializer, each
|
|
42
|
+
* `{ name, start, end }` over the initializer; `references` maps a name to the offsets of the identifiers that read it.
|
|
43
|
+
*/
|
|
44
|
+
export function indexCode(code, { parse }) {
|
|
45
|
+
const ast = parse(code, PARSE_OPTIONS);
|
|
46
|
+
const strings = [];
|
|
47
|
+
const declarations = [];
|
|
48
|
+
const references = new Map();
|
|
49
|
+
const visit = (node, parent) => {
|
|
50
|
+
if (!node || typeof node.type !== 'string') return;
|
|
51
|
+
if (node.type === 'Literal' && typeof node.value === 'string' && typeof node.raw === 'string' && node.raw.startsWith('"')) {
|
|
52
|
+
strings.push({ start: node.start, end: node.end, raw: node.raw, call: callOf(node, parent) });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (node.type === 'TemplateLiteral' && (parent === null || parent.type !== 'TaggedTemplateExpression')) {
|
|
56
|
+
strings.push({ start: node.start, end: node.end, raw: code.slice(node.start, node.end), call: callOf(node, parent) });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (node.type === 'Identifier') {
|
|
60
|
+
const isPropertyName = parent !== null && parent.type === 'MemberExpression' && parent.property === node && !parent.computed;
|
|
61
|
+
const isKey = parent !== null && parent.type === 'Property' && parent.key === node && !parent.computed && !parent.shorthand;
|
|
62
|
+
if (!isPropertyName && !isKey) {
|
|
63
|
+
if (!references.has(node.name)) references.set(node.name, []);
|
|
64
|
+
references.get(node.name).push(node.start);
|
|
65
|
+
}
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
for (const key in node) {
|
|
69
|
+
if (key === 'parent') continue;
|
|
70
|
+
const value = node[key];
|
|
71
|
+
if (Array.isArray(value)) {
|
|
72
|
+
for (const child of value) visit(child, node);
|
|
73
|
+
} else if (value && typeof value.type === 'string') {
|
|
74
|
+
visit(value, node);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
visit(ast, null);
|
|
79
|
+
strings.sort((a, b) => a.start - b.start);
|
|
80
|
+
for (const statement of ast.body) {
|
|
81
|
+
if (statement.type !== 'VariableDeclaration') continue;
|
|
82
|
+
for (const declarator of statement.declarations) {
|
|
83
|
+
if (declarator.id.type === 'Identifier' && declarator.init) {
|
|
84
|
+
declarations.push({ name: declarator.id.name, start: declarator.init.start, end: declarator.init.end });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { strings, declarations, references };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The string literal that holds the place `location`, or null (a place in no string, such as a value made by code). */
|
|
92
|
+
function stringHolding(index, location) {
|
|
93
|
+
const { strings } = index;
|
|
94
|
+
let low = 0;
|
|
95
|
+
let high = strings.length - 1;
|
|
96
|
+
let found = -1;
|
|
97
|
+
while (low <= high) {
|
|
98
|
+
const middle = (low + high) >> 1;
|
|
99
|
+
if (strings[middle].start <= location.start) {
|
|
100
|
+
found = middle;
|
|
101
|
+
low = middle + 1;
|
|
102
|
+
} else {
|
|
103
|
+
high = middle - 1;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (found === -1) return null;
|
|
107
|
+
const candidate = strings[found];
|
|
108
|
+
return location.end <= candidate.end ? candidate : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Where a track's own code lies: `{ index, kind, start, end }`. `kind` is 'variations' with the range of the track's own
|
|
113
|
+
* variation const (page/structure.js constRange) when the piece declares a song the page can read and the track is built by
|
|
114
|
+
* play(); for every other track it is 'statement' with the range of the track's own expression.
|
|
115
|
+
*/
|
|
116
|
+
export function trackScope({ track, reading }) {
|
|
117
|
+
if (reading !== null && reading !== undefined && reading.state === 'read') {
|
|
118
|
+
const play = reading.tracks.find((candidate) => candidate.index === track.index);
|
|
119
|
+
if (play !== undefined && play.constRange !== undefined && play.constRange !== null) {
|
|
120
|
+
return { index: track.index, kind: 'variations', start: play.constRange.start, end: play.constRange.end };
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { index: track.index, kind: 'statement', start: track.start, end: track.end };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Tells how many tracks read the top-level name `name`: a track reads it when an identifier with that name lies in its own
|
|
128
|
+
* code, or in the initializer of a top-level declaration that is read in turn. `scopes` is the scope of every track.
|
|
129
|
+
*/
|
|
130
|
+
export function readerCounter(index, scopes) {
|
|
131
|
+
const cache = new Map();
|
|
132
|
+
const tracksReading = (name, visiting) => {
|
|
133
|
+
const found = new Set();
|
|
134
|
+
if (visiting.has(name)) return found;
|
|
135
|
+
visiting.add(name);
|
|
136
|
+
const offsets = index.references.get(name) ?? [];
|
|
137
|
+
for (const scope of scopes) {
|
|
138
|
+
if (offsets.some((offset) => offset >= scope.start && offset < scope.end)) found.add(scope.index);
|
|
139
|
+
}
|
|
140
|
+
for (const declaration of index.declarations) {
|
|
141
|
+
if (declaration.name === name) continue;
|
|
142
|
+
if (offsets.some((offset) => offset >= declaration.start && offset < declaration.end)) {
|
|
143
|
+
for (const track of tracksReading(declaration.name, visiting)) found.add(track);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return found;
|
|
147
|
+
};
|
|
148
|
+
return (name) => {
|
|
149
|
+
if (!cache.has(name)) cache.set(name, tracksReading(name, new Set()).size);
|
|
150
|
+
return cache.get(name);
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** True when the code at `location` is a whole token: the run of token characters around it begins and ends there. */
|
|
155
|
+
function isBareToken(code, location) {
|
|
156
|
+
if (!(location.end > location.start) || location.end > code.length) return false;
|
|
157
|
+
let from = location.start;
|
|
158
|
+
while (from > 0 && TOKEN_CHAR.test(code[from - 1])) from -= 1;
|
|
159
|
+
let to = location.end;
|
|
160
|
+
while (to < code.length && TOKEN_CHAR.test(code[to])) to += 1;
|
|
161
|
+
return from === location.start && to === location.end;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The note number a token stands for (a number or a note name spelled the way Strudel plays it and the page steps it), or null for
|
|
166
|
+
* anything else. A name the page reads but Strudel does not play (a capital accidental, `EB3`) is null, so it is never offered for an edit.
|
|
167
|
+
*/
|
|
168
|
+
function tokenPitch(text) {
|
|
169
|
+
if (NUMBER_TOKEN.test(text)) return Number(text);
|
|
170
|
+
return NOTE_NAME_TOKEN.test(text) ? noteNameToMidi(text) : null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The same places once each, in the order they came. */
|
|
174
|
+
function distinctLocations(locations) {
|
|
175
|
+
const seen = new Set();
|
|
176
|
+
const out = [];
|
|
177
|
+
for (const location of locations) {
|
|
178
|
+
const key = `${location.start}:${location.end}`;
|
|
179
|
+
if (seen.has(key)) continue;
|
|
180
|
+
seen.add(key);
|
|
181
|
+
out.push(location);
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Says whether the note `event` (`{ begin, end, value, locations }`) can be edited. Gives `{ editable: true, token: { start,
|
|
188
|
+
* end, text }, velocity }`, the one place in `code` that holds the note and what the page can do about its velocity, or
|
|
189
|
+
* `{ editable: false, reason }` with the first of NOTE_REASONS that applies. `index` is indexCode(code), `scope` the scope of
|
|
190
|
+
* the track the note belongs to, `scopes` the scope of every track, `readers(name)` how many tracks read the top-level name
|
|
191
|
+
* and `midi` whether the note is on a MIDI lane (its default gain differs). `velocity` is `{ writable: true, token, gain,
|
|
192
|
+
* shown }` when the velocity of the note comes from exactly one bare number in a double-quoted `velocity("...")` string in the
|
|
193
|
+
* track's own code whose value is the note's velocity and the gain is a finite number other than 0 (`shown` is the velocity
|
|
194
|
+
* the Timeline shows, gain times velocity); otherwise `{ writable: false }`.
|
|
195
|
+
*/
|
|
196
|
+
export function classifyNote({ code, index, event, scope, scopes, readers, midi = false }) {
|
|
197
|
+
const refuse = (reason) => ({ editable: false, reason });
|
|
198
|
+
const heard = pitchOf(event.value);
|
|
199
|
+
if (heard === null) return refuse('no-pitch');
|
|
200
|
+
const placed = distinctLocations(Array.isArray(event.locations) ? event.locations : [])
|
|
201
|
+
.map((location) => ({ location, string: stringHolding(index, location) }))
|
|
202
|
+
.filter((entry) => entry.string !== null);
|
|
203
|
+
// A velocity string, a double-quoted scale and the weighted mini clock add places in other calls: only note and n count.
|
|
204
|
+
const pitchPlaces = placed.filter((entry) => entry.string.call === 'note' || entry.string.call === 'n');
|
|
205
|
+
if (pitchPlaces.length === 0) return refuse('no-token');
|
|
206
|
+
if (pitchPlaces.length > 1) return refuse('several');
|
|
207
|
+
const { location, string } = pitchPlaces[0];
|
|
208
|
+
if (string.call === 'n') return refuse('degrees');
|
|
209
|
+
if (string.raw.includes('\\') || !isBareToken(code, location)) return refuse('escape');
|
|
210
|
+
const text = code.slice(location.start, location.end);
|
|
211
|
+
const own = tokenPitch(text);
|
|
212
|
+
if (own === null || own !== heard) return refuse('shifted');
|
|
213
|
+
const token = { start: location.start, end: location.end, text };
|
|
214
|
+
const holds = (candidate) => token.start >= candidate.start && token.end <= candidate.end;
|
|
215
|
+
if (holds(scope)) {
|
|
216
|
+
if (scopes.some((other) => other.index !== scope.index && holds(other))) return refuse('shared');
|
|
217
|
+
return { editable: true, token, velocity: velocityOfNote({ code, event, placed, scope, scopes, midi }) };
|
|
218
|
+
}
|
|
219
|
+
const declaration = index.declarations.find(holds);
|
|
220
|
+
return declaration !== undefined && readers(declaration.name) >= 2 ? refuse('shared') : refuse('outside');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The velocity part of an editable note's classification: see classifyNote. */
|
|
224
|
+
function velocityOfNote({ code, event, placed, scope, scopes, midi }) {
|
|
225
|
+
const fixed = { writable: false };
|
|
226
|
+
const places = placed.filter((entry) => entry.string.call === 'velocity');
|
|
227
|
+
if (places.length !== 1) return fixed;
|
|
228
|
+
const { location } = places[0];
|
|
229
|
+
if (places[0].string.raw.includes('\\') || !isBareToken(code, location)) return fixed;
|
|
230
|
+
const text = code.slice(location.start, location.end);
|
|
231
|
+
if (!NUMBER_TOKEN.test(text)) return fixed;
|
|
232
|
+
const token = { start: location.start, end: location.end, text };
|
|
233
|
+
const holds = (candidate) => token.start >= candidate.start && token.end <= candidate.end;
|
|
234
|
+
if (!holds(scope) || scopes.some((other) => other.index !== scope.index && holds(other))) return fixed;
|
|
235
|
+
const value = event.value && typeof event.value === 'object' ? event.value : {};
|
|
236
|
+
if (typeof value.velocity !== 'number' || Number(text) !== value.velocity) return fixed;
|
|
237
|
+
const gain = gainOf(event.value, midi);
|
|
238
|
+
if (typeof gain !== 'number' || !Number.isFinite(gain) || gain === 0) return fixed;
|
|
239
|
+
const shown = velocityOf(event.value, midi);
|
|
240
|
+
if (shown === undefined) return fixed;
|
|
241
|
+
return { writable: true, token, gain, shown };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** An event's key with one field of its value left out: the time and every other field. */
|
|
245
|
+
function keyWithout(event, field) {
|
|
246
|
+
const value = event.value && typeof event.value === 'object' ? { ...event.value } : event.value;
|
|
247
|
+
if (value && typeof value === 'object') delete value[field];
|
|
248
|
+
return eventKey({ begin: event.begin, end: event.end, value });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Compares the lane's events before an edit with the same lane read back from the edited code. `token` is the token that
|
|
253
|
+
* was rewritten (the edit keeps its start) and `change` what was done to it: `{ kind: 'pitch', midi }`, `{ kind: 'rest' }`
|
|
254
|
+
* or `{ kind: 'velocity', target, gain }`. The edit is confirmed when the notes that carried the token are, at the same
|
|
255
|
+
* times, what the change says (a new pitch, gone, or a velocity that plays as `target`) with every other field unchanged, and
|
|
256
|
+
* every other note is the same in time, value and count. Gives `{ ok: true }` or `{ ok: false, why }`.
|
|
257
|
+
*/
|
|
258
|
+
export function compareReadBack({ before, after, token, change }) {
|
|
259
|
+
const known =
|
|
260
|
+
change !== null &&
|
|
261
|
+
typeof change === 'object' &&
|
|
262
|
+
((change.kind === 'pitch' && Number.isFinite(change.midi)) ||
|
|
263
|
+
change.kind === 'rest' ||
|
|
264
|
+
(change.kind === 'velocity' && Number.isFinite(change.target) && Number.isFinite(change.gain)));
|
|
265
|
+
if (!known) throw new Error(`Moontide: the read-back does not know the change ${JSON.stringify(change)}.`);
|
|
266
|
+
const carries = (event) => Array.isArray(event.locations) && event.locations.some((place) => place.start === token.start);
|
|
267
|
+
const editedBefore = before.filter(carries);
|
|
268
|
+
const editedAfter = after.filter(carries);
|
|
269
|
+
if (editedBefore.length === 0) return { ok: false, why: 'no-edited-note' };
|
|
270
|
+
if (change.kind === 'rest') {
|
|
271
|
+
// A rest draws no note and keeps no place: every note that carried the token is gone.
|
|
272
|
+
if (editedAfter.length !== 0) return { ok: false, why: 'edited-count' };
|
|
273
|
+
} else {
|
|
274
|
+
if (editedBefore.length !== editedAfter.length) return { ok: false, why: 'edited-count' };
|
|
275
|
+
const field = change.kind === 'pitch' ? 'note' : 'velocity';
|
|
276
|
+
const expected = new Map();
|
|
277
|
+
for (const event of editedBefore) {
|
|
278
|
+
const key = keyWithout(event, field);
|
|
279
|
+
expected.set(key, (expected.get(key) ?? 0) + 1);
|
|
280
|
+
}
|
|
281
|
+
for (const event of editedAfter) {
|
|
282
|
+
if (change.kind === 'pitch') {
|
|
283
|
+
if (pitchOf(event.value) !== change.midi) return { ok: false, why: 'edited-pitch' };
|
|
284
|
+
} else {
|
|
285
|
+
const velocity = event.value && typeof event.value === 'object' ? event.value.velocity : undefined;
|
|
286
|
+
if (typeof velocity !== 'number' || Math.round(change.gain * velocity * 100) / 100 !== change.target) return { ok: false, why: 'edited-velocity' };
|
|
287
|
+
}
|
|
288
|
+
const key = keyWithout(event, field);
|
|
289
|
+
const count = expected.get(key);
|
|
290
|
+
if (!count) return { ok: false, why: 'edited-differs' };
|
|
291
|
+
if (count === 1) expected.delete(key);
|
|
292
|
+
else expected.set(key, count - 1);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (!sameEvents(before.filter((event) => !carries(event)), after.filter((event) => !carries(event)))) {
|
|
296
|
+
return { ok: false, why: 'others-differ' };
|
|
297
|
+
}
|
|
298
|
+
return { ok: true };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** The most cycles one pass of a structure may have for the page to count the places of a token over all of it. */
|
|
302
|
+
export const PLACES_MAX_CYCLES = 1024;
|
|
303
|
+
|
|
304
|
+
/** True when the note `event` carries a place that starts where `token` starts: it was read from that token. */
|
|
305
|
+
function carriesToken(event, token) {
|
|
306
|
+
return Array.isArray(event.locations) && event.locations.some((place) => place.start === token.start);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Where a token plays in a song the page has read. `haps` are the notes of one track over a stretch of the structure, each
|
|
311
|
+
* `{ begin, locations }`, `token` the token `{ start }` and `reading` the read song. A place is a section entry (a column of the
|
|
312
|
+
* Song grid) holding a note that carries the token, found with the Song grid's own rule for the section that plays at a
|
|
313
|
+
* cycle, so a repeat of a variation inside one entry is not another place. Gives `{ count, sections }`: the number of
|
|
314
|
+
* distinct entries and the names of the sections they belong to, once each, in the order of the entries.
|
|
315
|
+
*/
|
|
316
|
+
export function placesOf({ haps, token, reading }) {
|
|
317
|
+
const entries = new Set();
|
|
318
|
+
for (const hap of haps) {
|
|
319
|
+
if (carriesToken(hap, token)) entries.add(playingSection(reading, hap.begin).index);
|
|
320
|
+
}
|
|
321
|
+
const ordered = [...entries].sort((a, b) => a - b);
|
|
322
|
+
const sections = [];
|
|
323
|
+
for (const index of ordered) {
|
|
324
|
+
const { name } = reading.sections[index];
|
|
325
|
+
if (!sections.includes(name)) sections.push(name);
|
|
326
|
+
}
|
|
327
|
+
return { count: ordered.length, sections };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** How many of the `events` that begin in the whole cycle `cycle` carry the token: a token that draws several notes in a cycle. */
|
|
331
|
+
export function perCycleCount({ events, token, cycle }) {
|
|
332
|
+
let count = 0;
|
|
333
|
+
for (const event of events) {
|
|
334
|
+
if (event.begin >= cycle - 1e-9 && event.begin < cycle + 1 - 1e-9 && carriesToken(event, token)) count += 1;
|
|
335
|
+
}
|
|
336
|
+
return count;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** The indexes of the `events` of the drawn lane that carry the token, the selected note among them. */
|
|
340
|
+
export function sameTokenEvents({ events, token }) {
|
|
341
|
+
const found = [];
|
|
342
|
+
events.forEach((event, index) => {
|
|
343
|
+
if (carriesToken(event, token)) found.push(index);
|
|
344
|
+
});
|
|
345
|
+
return found;
|
|
346
|
+
}
|