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,760 @@
|
|
|
1
|
+
// Pure module for Moontide's browser page: reads the song structure a piece declares in its own code. The structure
|
|
2
|
+
// is one top-level constant named song (sections with lengths, a cell for every track in every section, optional
|
|
3
|
+
// nested groups) that a few helper lines in the same code turn into one section clock and one lookup per track. The
|
|
4
|
+
// page reads only the text of the code: nothing is evaluated, and a shape it cannot read says why, in one sentence.
|
|
5
|
+
// `parse` (acorn) and `tracks` (from page/tracks.js extractTracks) are injected, so this module has no DOM access at
|
|
6
|
+
// import time and can be imported directly by node:test and by page/main.js without a build step. Its one import is
|
|
7
|
+
// the harmony reader, which has no DOM access either.
|
|
8
|
+
|
|
9
|
+
import { readHarmony, readHarmonyText } from './harmony.js';
|
|
10
|
+
|
|
11
|
+
export const PARSE_OPTIONS = { ecmaVersion: 'latest', sourceType: 'script', allowAwaitOutsideFunction: true };
|
|
12
|
+
|
|
13
|
+
/** The longest source quote or name a sentence carries, in code points. */
|
|
14
|
+
export const MAX_SOURCE_LENGTH = 40;
|
|
15
|
+
/** The longest harmony text the list shows, in code points; the whole text stays in the element's title. */
|
|
16
|
+
export const MAX_HARMONY_SHOWN = 60;
|
|
17
|
+
|
|
18
|
+
/** The three helper statements, by the name each one binds. */
|
|
19
|
+
export const HELPER_NAMES = ['total', 'clock', 'play'];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The helper a structured piece carries, written as is at the top of the piece. The page compares the three
|
|
23
|
+
* statements of the code with these, whitespace runs counting as one space, and says when they differ.
|
|
24
|
+
* Names are single-quoted strings and the messages are joined with + (a double-quoted string or a template literal
|
|
25
|
+
* is a mini-notation pattern in the page's transpiler).
|
|
26
|
+
*/
|
|
27
|
+
export const HELPER_TEXT = [
|
|
28
|
+
'const total = song.sections.reduce((a, [, n]) => a + n, 0);',
|
|
29
|
+
'const clock = timeCat(...song.sections.map(([name, n]) => [n, pure(name)])).slow(total).late(song.shift);',
|
|
30
|
+
'const play = (track, variations) => {',
|
|
31
|
+
' const cells = {};',
|
|
32
|
+
' for (const [name, , own] of song.sections) {',
|
|
33
|
+
" const key = (song.plan[name] || {})[track];",
|
|
34
|
+
" if (key === undefined) throw new Error('song: section ' + name + ' has no cell for track ' + track);",
|
|
35
|
+
" const v = key === '-' ? silence : variations[key];",
|
|
36
|
+
" if (v === undefined) throw new Error('song: section ' + name + ', track ' + track + ': no variation ' + key + ' (it has ' + Object.keys(variations).join(', ') + ')');",
|
|
37
|
+
" cells[name] = typeof v === 'function' ? v(own || harmony) : Array.isArray(v) ? v[1] : v;",
|
|
38
|
+
' }',
|
|
39
|
+
' return clock.pickRestart(cells);',
|
|
40
|
+
'};',
|
|
41
|
+
].join('\n');
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Page-visible copy for the song list and its lines. `{name}` placeholders are filled by fillSong(). `reasons` holds
|
|
45
|
+
* one clause for every shape the page cannot read, by reason id; the clauses state a fact about what the page reads and
|
|
46
|
+
* never what to write.
|
|
47
|
+
*/
|
|
48
|
+
export const SONG_COPY = {
|
|
49
|
+
sectionOne: '{count} section',
|
|
50
|
+
sectionMany: '{count} sections',
|
|
51
|
+
cycleOne: '{count} cycle',
|
|
52
|
+
cycleMany: '{count} cycles',
|
|
53
|
+
summary: '{sections}, {cycles}',
|
|
54
|
+
summaryShifted: '{sections}, {cycles}, shifted by {shift}',
|
|
55
|
+
cellRest: 'rest',
|
|
56
|
+
cellFollows: 'follows the harmony',
|
|
57
|
+
cellFixed: 'fixed to {harmony}',
|
|
58
|
+
harmonyOwn: 'harmony {text}',
|
|
59
|
+
harmonyPiece: 'harmony {text} (from the piece)',
|
|
60
|
+
harmonyNone: 'no harmony declared',
|
|
61
|
+
harmonyUnreadable: 'harmony not readable',
|
|
62
|
+
cannotRead: "Parts can't read this song: {reason}.",
|
|
63
|
+
helperChanged: 'The play() helper was changed, so the page cannot vouch for what plays.',
|
|
64
|
+
helperMissing: 'The play() helper is missing, so the page cannot vouch for what plays.',
|
|
65
|
+
// The Song grid that replaces the list for a readable song.
|
|
66
|
+
viewTitle: 'Song',
|
|
67
|
+
launchLabel: 'Launch at',
|
|
68
|
+
launchGroupTitle: 'When a launched section starts: at the end of the section that plays, on the next phrase or on the next bar.',
|
|
69
|
+
launchSection: 'end of section',
|
|
70
|
+
launchSectionName: 'Sections start at the end of the section that plays',
|
|
71
|
+
launchPhrase: 'next phrase',
|
|
72
|
+
launchPhraseName: 'Sections start on the next phrase',
|
|
73
|
+
launchBar: 'next bar',
|
|
74
|
+
launchBarName: 'Sections start on the next bar',
|
|
75
|
+
launchWord: 'Launch',
|
|
76
|
+
playingState: 'Playing {n}/{length}',
|
|
77
|
+
playingTitle: 'Playing, cycle {n} of {length}',
|
|
78
|
+
idleTitleSection: 'Starts at the end of the section that plays. Click to queue it.',
|
|
79
|
+
idleTitlePhrase: 'Starts on the next phrase. Click to queue it.',
|
|
80
|
+
idleTitleBar: 'Starts on the next bar. Click to queue it.',
|
|
81
|
+
stoppedTitle: 'Starts the piece at this section.',
|
|
82
|
+
queuedState: 'Queued — {countdown}',
|
|
83
|
+
cancelWord: 'Cancel',
|
|
84
|
+
queuedTitle: 'Queued. Click to cancel.',
|
|
85
|
+
// The sentence under the toolbar: why the grid is off, what waits, or why a gesture changed nothing.
|
|
86
|
+
lineDisconnected: 'Disconnected — the grid is off until Moontide reconnects.',
|
|
87
|
+
lineQueued: "Section {section} is queued — cells can't change until it starts. Click it again to cancel.",
|
|
88
|
+
lineAlready: 'Section {section} already starts on that line, so the code stays as it is. Click it again to hide the countdown.',
|
|
89
|
+
lineWaiting: 'A change is waiting for its bar line. The grid works again when it takes over.',
|
|
90
|
+
lineDirty: 'The editor has changes that are not playing yet. Evaluate them first — the grid works on the playing code.',
|
|
91
|
+
// One sentence for each reason a gesture on the grid is refused, by id; an id that is not here throws.
|
|
92
|
+
refusals: {
|
|
93
|
+
'section-gone': 'That section changed in the code since you clicked. Nothing was changed.',
|
|
94
|
+
'cell-gone': 'That cell changed in the code since you clicked. Nothing was changed.',
|
|
95
|
+
'variation-gone': 'That variation is no longer in the code. Nothing was changed.',
|
|
96
|
+
'name-unwritable': "That variation's name cannot be written into the plan as it is. Nothing was changed.",
|
|
97
|
+
'not-evaluated': 'The change did not evaluate, so the code is as it was.',
|
|
98
|
+
'line-moved': 'The launch could not be placed on its line, so nothing changed.',
|
|
99
|
+
'not-written': 'The page could not write that change into the editor, so the code is as it was.',
|
|
100
|
+
},
|
|
101
|
+
now: 'Playing section {section}, cycle {n} of {length}.',
|
|
102
|
+
// The list that opens on a cell, the cell while its change waits for a line, and the history label of a cell change.
|
|
103
|
+
listLabel: 'Variation for {track} in section {section}',
|
|
104
|
+
listCurrent: 'current',
|
|
105
|
+
listShared: 'Section {section} is used {n} times; the change applies to each use.',
|
|
106
|
+
waitBar: 'waiting for the next bar',
|
|
107
|
+
waitPhrase: 'waiting for the next phrase',
|
|
108
|
+
cellRecord: 'changed {track} in {section}: {choice}',
|
|
109
|
+
foldTitle: 'Fold {group}',
|
|
110
|
+
openTitle: 'Open {group}',
|
|
111
|
+
trackOne: '1 track',
|
|
112
|
+
trackMany: '{count} tracks',
|
|
113
|
+
usedTimes: 'used {n} times',
|
|
114
|
+
usedTitle: 'This section is used {n} times (columns {columns}); its cells are shared.',
|
|
115
|
+
cellTitle: '{track}, section {section}: {variation}. Click to change.',
|
|
116
|
+
cellTitleRest: '{track}, section {section}: rest. Click to change.',
|
|
117
|
+
cellTitleDegrees: '{track}, section {section}: {variation}, follows the harmony',
|
|
118
|
+
cellTitleFixed: '{track}, section {section}: {variation}, fixed to {harmony}',
|
|
119
|
+
outside: 'outside the song plan: plays in every section',
|
|
120
|
+
differs: 'What plays differs from the declaration at the start of section {section}, track {track}.',
|
|
121
|
+
differsMore: 'What plays differs from the declaration at the start of section {section}, track {track} (and {count} more places).',
|
|
122
|
+
differsMoreOne: 'What plays differs from the declaration at the start of section {section}, track {track} (and 1 more place).',
|
|
123
|
+
checkFailed: 'The page could not check what plays against the declaration.',
|
|
124
|
+
// Under a fixed variation: whether its notes are in the scale of the section's harmony (words only, nothing is changed).
|
|
125
|
+
fitFits: 'fits',
|
|
126
|
+
fitNot: 'does not fit ({notes})',
|
|
127
|
+
fitMore: '{notes} and {count} more',
|
|
128
|
+
fitCannot: 'cannot check',
|
|
129
|
+
fitChip: ' \u00b7 {fit}',
|
|
130
|
+
fitTitleFits: '{track}, section {section}: {variation}, fixed to {harmony}. Its notes are all in {sectionHarmony}.',
|
|
131
|
+
fitTitleNot: '{track}, section {section}: {variation}, fixed to {harmony}. These notes are outside {sectionHarmony}: {notes}. Nothing is changed.',
|
|
132
|
+
fitTitleCannot: '{track}, section {section}: {variation}, fixed to {harmony}. {reason}',
|
|
133
|
+
// One sentence for each reason the fit of a variation cannot be checked, by id; an id that is not here throws.
|
|
134
|
+
fitReasons: {
|
|
135
|
+
'no-harmony': 'This section declares no harmony.',
|
|
136
|
+
'harmony-unreadable': 'The harmony of this section is not readable.',
|
|
137
|
+
'harmony-unknown': 'The harmony is not one of the nine scales the page knows.',
|
|
138
|
+
'no-notes': 'The page found no pitched notes to compare.',
|
|
139
|
+
'not-read': 'The page could not read the notes of this variation.',
|
|
140
|
+
'too-slow': 'Reading the notes of this variation took too long.',
|
|
141
|
+
},
|
|
142
|
+
reasons: {
|
|
143
|
+
'song-not-const': 'song is declared with let or var; the page reads only const',
|
|
144
|
+
'song-not-object': 'song is {source}, not an object written out in the code',
|
|
145
|
+
'no-sections': 'song has no sections',
|
|
146
|
+
'section-not-entry': 'section {section} is not a name and a length written out in the code ({source})',
|
|
147
|
+
'section-name-not-text': 'section {section} has a name that is not a single-quoted string ({source})',
|
|
148
|
+
'section-name-empty': 'section {section} has an empty name; a section needs a name of at least one character',
|
|
149
|
+
'length-not-plain-number': 'the length of section {section} is not a plain number ({source})',
|
|
150
|
+
'length-not-whole': 'the length of section {section} is not a whole number ({source})',
|
|
151
|
+
'length-not-above-zero': 'the length of section {section} is not above zero ({source})',
|
|
152
|
+
'total-too-long': 'the lengths of the sections add up to more than the page can count, at section {section}',
|
|
153
|
+
'section-harmony-unreadable': 'the harmony of section {section} is not scale or chord text in single quotes ({source})',
|
|
154
|
+
'section-repeated-harmony': 'section {section} is listed more than once with different harmonies; every use plays the variations made for its last entry',
|
|
155
|
+
'shift-missing': 'song has no shift',
|
|
156
|
+
'shift-not-number': 'song.shift is {source}, not a number written in the code',
|
|
157
|
+
'plan-missing': 'song.plan is missing or not written out as names',
|
|
158
|
+
'plan-unknown-section': 'song.plan has a section {source} that song does not list',
|
|
159
|
+
'plan-unknown-track': 'song.plan names track {track}, which has no labeled statement',
|
|
160
|
+
'cell-missing': 'section {section} has no cell for track {track}',
|
|
161
|
+
'cell-not-text': 'the cell of section {section}, track {track} is not a single-quoted name ({source})',
|
|
162
|
+
'cell-unknown-variation': 'section {section}, track {track} names variation {source}, which {track} does not define',
|
|
163
|
+
'variations-unreadable': 'the variations of {track} are not written out as a top-level const object ({source})',
|
|
164
|
+
'label-mismatch': 'the statement labeled {track} passes {source} to play(); the label and that name must be the same',
|
|
165
|
+
'play-call-unreadable': 'the statement labeled {track} does not give play() a single-quoted name and a const ({source})',
|
|
166
|
+
'track-repeated': 'more than one statement is labeled {track}',
|
|
167
|
+
'groups-not-data': 'song.groups is not written out as nested lists of track names ({source})',
|
|
168
|
+
'group-unknown-track': 'group {group} lists {track}, which has no labeled statement',
|
|
169
|
+
'groups-duplicate-track': 'song.groups lists {track} more than once',
|
|
170
|
+
'groups-duplicate-group': 'song.groups has two groups named {group} side by side; the page cannot tell them apart',
|
|
171
|
+
'key-not-plain': '{source} is a computed or double-quoted name; the page reads only plain and single-quoted names in song and in a track\'s variations',
|
|
172
|
+
'duplicate-key': 'the name {source} is written twice in one object; JavaScript keeps only the last one',
|
|
173
|
+
'name-inherited': '{source} is a name every JavaScript object already has, so the page cannot tell it from a missing one',
|
|
174
|
+
'fixed-not-pair': 'variation {variation} of {track} is a list but not a harmony and a pattern ({source})',
|
|
175
|
+
'fixed-harmony-unreadable': 'variation {variation} of {track} declares the harmony {source}, which is not scale or chord text in single quotes',
|
|
176
|
+
'variation-kind-unknown': 'variation {variation} of {track} is a name or an expression, so the page cannot tell a pattern from a function or a pair ({source})',
|
|
177
|
+
'degrees-without-harmony': 'variation {variation} of {track} follows the harmony, but section {section} declares none and the code has no harmony constant',
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/** The ids of every shape the page cannot read. */
|
|
182
|
+
export const SONG_REASONS = Object.freeze(Object.keys(SONG_COPY.reasons));
|
|
183
|
+
|
|
184
|
+
/** The ids of every reason a gesture on the Song grid is refused. */
|
|
185
|
+
export const SONG_REFUSALS = Object.freeze(Object.keys(SONG_COPY.refusals));
|
|
186
|
+
|
|
187
|
+
/** Replaces each `{name}` in `template` with `values[name]`; a missing value throws (loud). */
|
|
188
|
+
export function fillSong(template, values) {
|
|
189
|
+
return template.replace(/\{(\w+)\}/g, (_, name) => {
|
|
190
|
+
if (values[name] === undefined || values[name] === null) {
|
|
191
|
+
throw new Error(`Moontide: the song copy needs a value for '${name}'.`);
|
|
192
|
+
}
|
|
193
|
+
return String(values[name]);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function reasonClause(reason) {
|
|
198
|
+
const clause = SONG_COPY.reasons[reason];
|
|
199
|
+
if (typeof clause !== 'string') throw new Error(`Moontide: unknown song reason '${String(reason)}'.`);
|
|
200
|
+
return clause;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---- text that comes from the code ----
|
|
204
|
+
|
|
205
|
+
/** Control, format, line separator and paragraph separator characters: none of them may reach a line of text. */
|
|
206
|
+
const HIDDEN_CHARACTERS = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu;
|
|
207
|
+
|
|
208
|
+
/** `text` with every hidden character replaced by a space. */
|
|
209
|
+
export function cleanText(text) {
|
|
210
|
+
return text.replace(HIDDEN_CHARACTERS, ' ');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** `text` cut at `max` code points, an ellipsis as the last one. */
|
|
214
|
+
export function cutText(text, max) {
|
|
215
|
+
const points = [...text];
|
|
216
|
+
if (points.length <= max) return text;
|
|
217
|
+
return `${points.slice(0, max - 1).join('')}…`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** A name from the code as the page shows it: hidden characters replaced by a space, cut at 40 code points. */
|
|
221
|
+
export function displayName(text) {
|
|
222
|
+
return cutText(cleanText(String(text)), MAX_SOURCE_LENGTH);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** A harmony text as the list shows it: hidden characters replaced by a space, cut at 60 code points. */
|
|
226
|
+
export function displayHarmony(text) {
|
|
227
|
+
return cutText(cleanText(String(text)), MAX_HARMONY_SHOWN);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** A name inside a sentence: in single quotes, or the 1-based position number of an entry whose name cannot be read. */
|
|
231
|
+
export function quoted(value) {
|
|
232
|
+
return typeof value === 'number' ? String(value) : `'${displayName(value)}'`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** `n` and its noun in the singular or the plural form. */
|
|
236
|
+
export function countText(n, one, many) {
|
|
237
|
+
return fillSong(n === 1 ? one : many, { count: n });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The corner line of the grid: how many sections, how many cycles, and the shift when there is one. */
|
|
241
|
+
export function summaryText(reading) {
|
|
242
|
+
const sections = countText(reading.sections.length, SONG_COPY.sectionOne, SONG_COPY.sectionMany);
|
|
243
|
+
const cycles = countText(reading.total, SONG_COPY.cycleOne, SONG_COPY.cycleMany);
|
|
244
|
+
if (reading.shift === 0) return fillSong(SONG_COPY.summary, { sections, cycles });
|
|
245
|
+
const shift = countText(reading.shift, SONG_COPY.cycleOne, SONG_COPY.cycleMany);
|
|
246
|
+
return fillSong(SONG_COPY.summaryShifted, { sections, cycles, shift });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The text of a section's harmony in the grid, and the whole harmony text when the shown one was cut (else null). */
|
|
250
|
+
export function harmonyParts(harmony) {
|
|
251
|
+
if (harmony.from === 'none') return { text: SONG_COPY.harmonyNone, whole: null };
|
|
252
|
+
if (harmony.from === 'piece-unreadable') return { text: SONG_COPY.harmonyUnreadable, whole: null };
|
|
253
|
+
const shown = displayHarmony(harmony.text);
|
|
254
|
+
const whole = shown === cleanText(harmony.text) ? null : cleanText(harmony.text);
|
|
255
|
+
const template = harmony.from === 'own' ? SONG_COPY.harmonyOwn : SONG_COPY.harmonyPiece;
|
|
256
|
+
return { text: fillSong(template, { text: shown }), whole };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---- sentences ----
|
|
260
|
+
|
|
261
|
+
/** The sentence for a song the page cannot read. Throws for any other reading. */
|
|
262
|
+
export function songMessageText(reading) {
|
|
263
|
+
if (reading === null || typeof reading !== 'object' || reading.state !== 'unreadable') {
|
|
264
|
+
throw new Error('Moontide: songMessageText needs an unreadable song reading.');
|
|
265
|
+
}
|
|
266
|
+
const pick = (value) => (value === null || value === undefined ? null : quoted(value));
|
|
267
|
+
const clause = fillSong(reasonClause(reading.reason), {
|
|
268
|
+
section: pick(reading.section),
|
|
269
|
+
track: pick(reading.track),
|
|
270
|
+
group: pick(reading.group),
|
|
271
|
+
variation: pick(reading.variation),
|
|
272
|
+
source: reading.source,
|
|
273
|
+
});
|
|
274
|
+
return fillSong(SONG_COPY.cannotRead, { reason: clause });
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** The sentence for a helper that is not the canonical text, or null when it is. */
|
|
278
|
+
export function helperLine(helper) {
|
|
279
|
+
if (helper === 'canonical') return null;
|
|
280
|
+
if (helper === 'changed') return SONG_COPY.helperChanged;
|
|
281
|
+
if (helper === 'missing') return SONG_COPY.helperMissing;
|
|
282
|
+
throw new Error(`Moontide: unknown helper state '${String(helper)}'.`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** The sentence for what plays differing from the declaration: the first place, and how many more there are. */
|
|
286
|
+
export function differsLine({ section, track, more }) {
|
|
287
|
+
const values = { section: quoted(section), track: quoted(track), count: more };
|
|
288
|
+
if (more === 0) return fillSong(SONG_COPY.differs, values);
|
|
289
|
+
return fillSong(more === 1 ? SONG_COPY.differsMoreOne : SONG_COPY.differsMore, values);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** The lines the page knows right after reading the code, the same sentences it shows and sends to Claude. */
|
|
293
|
+
export function songNoteLines(reading) {
|
|
294
|
+
if (reading.state === 'unreadable') return [songMessageText(reading)];
|
|
295
|
+
if (reading.state === 'read') {
|
|
296
|
+
const line = helperLine(reading.helper);
|
|
297
|
+
return line === null ? [] : [line];
|
|
298
|
+
}
|
|
299
|
+
return [];
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ---- reading the code ----
|
|
303
|
+
|
|
304
|
+
/** The names of Object.prototype: a section, track or variation with one of them cannot be told from a missing one. */
|
|
305
|
+
const INHERITED_NAMES = new Set(Object.getOwnPropertyNames(Object.prototype));
|
|
306
|
+
|
|
307
|
+
/** Mirrors collectPatternNames in page/harmony.js; keep the two in step. The names a binding pattern binds, pushed to `names`. */
|
|
308
|
+
function collectPatternNames(pattern, names) {
|
|
309
|
+
if (!pattern) return;
|
|
310
|
+
if (pattern.type === 'Identifier') names.push(pattern.name);
|
|
311
|
+
else if (pattern.type === 'ObjectPattern') {
|
|
312
|
+
for (const property of pattern.properties) {
|
|
313
|
+
collectPatternNames(property.type === 'RestElement' ? property.argument : property.value, names);
|
|
314
|
+
}
|
|
315
|
+
} else if (pattern.type === 'ArrayPattern') {
|
|
316
|
+
for (const element of pattern.elements) collectPatternNames(element, names);
|
|
317
|
+
} else if (pattern.type === 'AssignmentPattern') collectPatternNames(pattern.left, names);
|
|
318
|
+
else if (pattern.type === 'RestElement') collectPatternNames(pattern.argument, names);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** The source of `node`, hidden characters replaced and cut at MAX_SOURCE_LENGTH code points. */
|
|
322
|
+
function sourceOf(code, node) {
|
|
323
|
+
return cutText(cleanText(code.slice(node.start, node.end)), MAX_SOURCE_LENGTH);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** True for a string literal written with single quotes (a double-quoted string is a pattern in the page's transpiler). */
|
|
327
|
+
function singleQuoted(node) {
|
|
328
|
+
return node !== null && node !== undefined && node.type === 'Literal' && typeof node.value === 'string' && node.raw.startsWith("'");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** The value of a number literal, with a minus sign allowed in front; null for anything else. */
|
|
332
|
+
function numberLiteral(node) {
|
|
333
|
+
if (node.type === 'Literal' && typeof node.value === 'number') return node.value;
|
|
334
|
+
if (node.type === 'UnaryExpression' && node.operator === '-' && node.argument.type === 'Literal' && typeof node.argument.value === 'number') {
|
|
335
|
+
return -node.argument.value;
|
|
336
|
+
}
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Every top-level binding of `name`: `{ statement, kind, declarator, init }`, in code order. */
|
|
341
|
+
function bindingsOf(ast, name) {
|
|
342
|
+
const found = [];
|
|
343
|
+
for (const statement of ast.body) {
|
|
344
|
+
if (statement.type === 'VariableDeclaration') {
|
|
345
|
+
for (const declarator of statement.declarations) {
|
|
346
|
+
const names = [];
|
|
347
|
+
collectPatternNames(declarator.id, names);
|
|
348
|
+
if (names.includes(name)) found.push({ statement, kind: statement.kind, declarator, init: declarator.init ?? null });
|
|
349
|
+
}
|
|
350
|
+
} else if ((statement.type === 'FunctionDeclaration' || statement.type === 'ClassDeclaration') && statement.id && statement.id.name === name) {
|
|
351
|
+
found.push({ statement, kind: statement.type === 'FunctionDeclaration' ? 'function' : 'class', declarator: null, init: null });
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return found;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* The entries of an object literal, `[{ key, keyNode, value }]`, or `{ problem }` for the first entry that is not a
|
|
359
|
+
* plain name: a spread, a method kind that is not a value, a computed or double-quoted name, a name written twice, a name
|
|
360
|
+
* every object already has.
|
|
361
|
+
*/
|
|
362
|
+
function readObject(code, node) {
|
|
363
|
+
const entries = [];
|
|
364
|
+
const seen = new Set();
|
|
365
|
+
for (const property of node.properties) {
|
|
366
|
+
if (property.type !== 'Property' || property.computed || property.kind !== 'init') {
|
|
367
|
+
return { problem: { reason: 'key-not-plain', source: sourceOf(code, property) } };
|
|
368
|
+
}
|
|
369
|
+
const key = property.key.type === 'Identifier' ? property.key.name : singleQuoted(property.key) ? property.key.value : null;
|
|
370
|
+
if (key === null) return { problem: { reason: 'key-not-plain', source: sourceOf(code, property.key) } };
|
|
371
|
+
if (INHERITED_NAMES.has(key)) return { problem: { reason: 'name-inherited', source: sourceOf(code, property.key) } };
|
|
372
|
+
if (seen.has(key)) return { problem: { reason: 'duplicate-key', source: sourceOf(code, property.key) } };
|
|
373
|
+
seen.add(key);
|
|
374
|
+
entries.push({ key, keyNode: property.key, value: property.value, method: property.method === true });
|
|
375
|
+
}
|
|
376
|
+
return { entries };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const normalizeSpace = (text) => text.replace(/\s+/g, ' ').trim();
|
|
380
|
+
|
|
381
|
+
let helperReference = null;
|
|
382
|
+
|
|
383
|
+
/** The normalized text of each canonical helper statement, by the name it binds. Parsed once. */
|
|
384
|
+
function canonicalHelper(parse) {
|
|
385
|
+
if (helperReference === null) {
|
|
386
|
+
const reference = new Map();
|
|
387
|
+
for (const statement of parse(HELPER_TEXT, PARSE_OPTIONS).body) {
|
|
388
|
+
reference.set(statement.declarations[0].id.name, normalizeSpace(HELPER_TEXT.slice(statement.start, statement.end)));
|
|
389
|
+
}
|
|
390
|
+
helperReference = reference;
|
|
391
|
+
}
|
|
392
|
+
return helperReference;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** 'canonical' when the three helper statements are the canonical text, 'missing' when none is bound, else 'changed'. */
|
|
396
|
+
function helperState(code, ast, parse) {
|
|
397
|
+
const reference = canonicalHelper(parse);
|
|
398
|
+
let bound = 0;
|
|
399
|
+
let equal = 0;
|
|
400
|
+
for (const name of HELPER_NAMES) {
|
|
401
|
+
const [binding] = bindingsOf(ast, name);
|
|
402
|
+
if (binding === undefined) continue;
|
|
403
|
+
bound += 1;
|
|
404
|
+
if (normalizeSpace(code.slice(binding.statement.start, binding.statement.end)) === reference.get(name)) equal += 1;
|
|
405
|
+
}
|
|
406
|
+
if (bound === 0) return 'missing';
|
|
407
|
+
return equal === HELPER_NAMES.length ? 'canonical' : 'changed';
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** The innermost call of a chain of `.method(args)` calls: `a.b(1).c(2)` gives the node of `a.b(1)`'s root. */
|
|
411
|
+
function chainRoot(node) {
|
|
412
|
+
let current = node;
|
|
413
|
+
while (current && current.type === 'CallExpression' && current.callee.type === 'MemberExpression' && !current.callee.computed) {
|
|
414
|
+
current = current.callee.object;
|
|
415
|
+
}
|
|
416
|
+
return current;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* The song groups as written, walked without recursion: `{ groups, pairs, grouped }` with `groups` the top-level groups
|
|
421
|
+
* (`{ name, items: [{ track } | { group }] }`), `pairs` every `{ group, track }` in written order and `grouped` the set of
|
|
422
|
+
* track names; or `{ problem }`. A group is an array of track names (and, in an array, one-key group objects) or an
|
|
423
|
+
* object of groups.
|
|
424
|
+
*/
|
|
425
|
+
function readGroups(code, node) {
|
|
426
|
+
const root = { name: null, items: [] };
|
|
427
|
+
const pairs = [];
|
|
428
|
+
const grouped = new Set();
|
|
429
|
+
const top = readObject(code, node);
|
|
430
|
+
if (top.problem) return { problem: top.problem };
|
|
431
|
+
const stack = [{ kind: 'entries', list: top.entries, index: 0, parent: root }];
|
|
432
|
+
while (stack.length > 0) {
|
|
433
|
+
const frame = stack[stack.length - 1];
|
|
434
|
+
if (frame.index >= frame.list.length) {
|
|
435
|
+
stack.pop();
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
const element = frame.list[frame.index];
|
|
439
|
+
frame.index += 1;
|
|
440
|
+
if (frame.kind === 'entries') {
|
|
441
|
+
// Two groups with one name side by side would share one fold state and one Mixer frame, so refuse them.
|
|
442
|
+
if (frame.parent.items.some((item) => item.group !== undefined && item.group.name === element.key)) {
|
|
443
|
+
return { problem: { reason: 'groups-duplicate-group', group: element.key } };
|
|
444
|
+
}
|
|
445
|
+
const group = { name: element.key, items: [] };
|
|
446
|
+
frame.parent.items.push({ group });
|
|
447
|
+
const body = element.value;
|
|
448
|
+
if (body.type === 'ArrayExpression') {
|
|
449
|
+
stack.push({ kind: 'items', list: body.elements, index: 0, parent: group });
|
|
450
|
+
} else if (body.type === 'ObjectExpression') {
|
|
451
|
+
const inner = readObject(code, body);
|
|
452
|
+
if (inner.problem) return { problem: inner.problem };
|
|
453
|
+
stack.push({ kind: 'entries', list: inner.entries, index: 0, parent: group });
|
|
454
|
+
} else {
|
|
455
|
+
return { problem: { reason: 'groups-not-data', source: sourceOf(code, body) } };
|
|
456
|
+
}
|
|
457
|
+
} else if (singleQuoted(element)) {
|
|
458
|
+
if (grouped.has(element.value)) return { problem: { reason: 'groups-duplicate-track', track: element.value } };
|
|
459
|
+
grouped.add(element.value);
|
|
460
|
+
frame.parent.items.push({ track: element.value });
|
|
461
|
+
pairs.push({ group: frame.parent.name, track: element.value });
|
|
462
|
+
} else if (element !== null && element.type === 'ObjectExpression') {
|
|
463
|
+
const inner = readObject(code, element);
|
|
464
|
+
if (inner.problem) return { problem: inner.problem };
|
|
465
|
+
stack.push({ kind: 'entries', list: inner.entries, index: 0, parent: frame.parent });
|
|
466
|
+
} else {
|
|
467
|
+
return { problem: { reason: 'groups-not-data', source: element === null ? sourceOf(code, node) : sourceOf(code, element) } };
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return { groups: root.items.map((item) => item.group), pairs, grouped };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* The kinds of expression that are a pattern as far as the text says: a call (note(...), s(...).slow(2)), a quoted text, a
|
|
475
|
+
* template or an object, none of which is a function or a pair. A name, a member or a condition could be either at run
|
|
476
|
+
* time, and the page cannot tell which from the text.
|
|
477
|
+
*/
|
|
478
|
+
const PATTERN_NODES = new Set(['CallExpression', 'Literal', 'TemplateLiteral', 'TaggedTemplateExpression', 'ObjectExpression']);
|
|
479
|
+
|
|
480
|
+
/** The keys a song declaration is made of; an object holding none of them is just a constant named song. */
|
|
481
|
+
const SONG_KEYS = ['sections', 'plan', 'shift'];
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Whether the piece declares a song structure: a top-level play binding says so, and so does a song object that holds one
|
|
485
|
+
* of the keys of a declaration. A constant named song that is neither (an older piece may name any constant song) is not one.
|
|
486
|
+
*/
|
|
487
|
+
function declaresStructure(ast, songInit, songIsObject) {
|
|
488
|
+
if (bindingsOf(ast, 'play').length > 0) return true;
|
|
489
|
+
if (!songIsObject) return false;
|
|
490
|
+
return songInit.properties.some((property) => {
|
|
491
|
+
if (property.type !== 'Property' || property.computed) return false;
|
|
492
|
+
const key = property.key.type === 'Identifier' ? property.key.name : property.key.value;
|
|
493
|
+
return SONG_KEYS.includes(key);
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Reads the song structure of `code`. `tracks` is extractTracks(code, { parse }). Returns plain data:
|
|
499
|
+
* - `{ state: 'none' }` for a piece without a structure: no top-level song, or a song that holds none of the keys sections,
|
|
500
|
+
* plan and shift while the code binds no top-level play (an older piece may name any constant song);
|
|
501
|
+
* - `{ state: 'unreadable', reason, section, track, group, variation, source, helper }`: the first problem in a fixed
|
|
502
|
+
* order (the song, the sections, the shift, the plan, the groups, the statements, the variations, the cells), a reason
|
|
503
|
+
* id of SONG_REASONS with the names and the quoted source its sentence needs (unused fields null);
|
|
504
|
+
* - `{ state: 'read', helper, shift, shiftRange, total, pieceHarmony, sections, tracks, groups, ungrouped }`.
|
|
505
|
+
* `helper` is 'canonical' | 'changed' | 'missing'. Nothing is evaluated. Every range is `{ start, end }` offsets into the
|
|
506
|
+
* exact `code` that was parsed: `shiftRange` is the shift value (a leading minus included), a cell's `range` the plan string
|
|
507
|
+
* of its section's name for its track (a rest included; entries that share a name share the range). A section also keeps
|
|
508
|
+
* its `ownHarmony` text (or null), and a track every variation of its constant, `variations: [{ key, kind, fixedHarmony }]`
|
|
509
|
+
* in code order, used or not.
|
|
510
|
+
*/
|
|
511
|
+
export function readStructure(code, { parse, tracks }) {
|
|
512
|
+
const ast = parse(code, PARSE_OPTIONS);
|
|
513
|
+
const [songBinding] = bindingsOf(ast, 'song');
|
|
514
|
+
if (songBinding === undefined) return { state: 'none' };
|
|
515
|
+
const songInit = songBinding.init;
|
|
516
|
+
const songIsObject = songInit !== null && songInit.type === 'ObjectExpression';
|
|
517
|
+
if (!declaresStructure(ast, songInit, songIsObject)) return { state: 'none' };
|
|
518
|
+
|
|
519
|
+
const helper = helperState(code, ast, parse);
|
|
520
|
+
const unreadable = (reason, fields = {}) => {
|
|
521
|
+
reasonClause(reason);
|
|
522
|
+
return { state: 'unreadable', reason, section: null, track: null, group: null, variation: null, source: null, helper, ...fields };
|
|
523
|
+
};
|
|
524
|
+
const problemOf = ({ reason, ...fields }) => unreadable(reason, fields);
|
|
525
|
+
|
|
526
|
+
// The song.
|
|
527
|
+
if (songBinding.kind !== 'const') return unreadable('song-not-const');
|
|
528
|
+
if (!songIsObject || songBinding.declarator.id.type !== 'Identifier') {
|
|
529
|
+
return unreadable('song-not-object', { source: sourceOf(code, songInit ?? songBinding.statement) });
|
|
530
|
+
}
|
|
531
|
+
const songObject = readObject(code, songInit);
|
|
532
|
+
if (songObject.problem) return problemOf(songObject.problem);
|
|
533
|
+
const songEntry = (name) => songObject.entries.find((entry) => entry.key === name);
|
|
534
|
+
|
|
535
|
+
// The sections.
|
|
536
|
+
const sectionsEntry = songEntry('sections');
|
|
537
|
+
if (sectionsEntry === undefined || sectionsEntry.value.type !== 'ArrayExpression' || sectionsEntry.value.elements.length === 0) {
|
|
538
|
+
return unreadable('no-sections');
|
|
539
|
+
}
|
|
540
|
+
const sections = [];
|
|
541
|
+
const firstHarmony = new Map();
|
|
542
|
+
let total = 0;
|
|
543
|
+
for (let i = 0; i < sectionsEntry.value.elements.length; i++) {
|
|
544
|
+
const position = i + 1;
|
|
545
|
+
const element = sectionsEntry.value.elements[i];
|
|
546
|
+
if (
|
|
547
|
+
element === null ||
|
|
548
|
+
element.type !== 'ArrayExpression' ||
|
|
549
|
+
element.elements.length < 2 ||
|
|
550
|
+
element.elements.length > 3 ||
|
|
551
|
+
element.elements.some((part) => part === null || part.type === 'SpreadElement')
|
|
552
|
+
) {
|
|
553
|
+
return unreadable('section-not-entry', { section: position, source: sourceOf(code, element ?? sectionsEntry.value) });
|
|
554
|
+
}
|
|
555
|
+
const [nameNode, lengthNode, harmonyNode] = element.elements;
|
|
556
|
+
if (!singleQuoted(nameNode)) return unreadable('section-name-not-text', { section: position, source: sourceOf(code, nameNode) });
|
|
557
|
+
const name = nameNode.value;
|
|
558
|
+
if (name === '') return unreadable('section-name-empty', { section: position });
|
|
559
|
+
if (INHERITED_NAMES.has(name)) return unreadable('name-inherited', { source: sourceOf(code, nameNode) });
|
|
560
|
+
const length = numberLiteral(lengthNode);
|
|
561
|
+
if (length === null) return unreadable('length-not-plain-number', { section: name, source: sourceOf(code, lengthNode) });
|
|
562
|
+
if (!Number.isSafeInteger(length)) return unreadable('length-not-whole', { section: name, source: sourceOf(code, lengthNode) });
|
|
563
|
+
if (length <= 0) return unreadable('length-not-above-zero', { section: name, source: sourceOf(code, lengthNode) });
|
|
564
|
+
const start = total;
|
|
565
|
+
total += length;
|
|
566
|
+
if (!Number.isSafeInteger(total)) return unreadable('total-too-long', { section: name });
|
|
567
|
+
let ownHarmony = null;
|
|
568
|
+
if (harmonyNode !== undefined) {
|
|
569
|
+
if (!singleQuoted(harmonyNode) || readHarmonyText(harmonyNode.value) === null) {
|
|
570
|
+
return unreadable('section-harmony-unreadable', { section: name, source: sourceOf(code, harmonyNode) });
|
|
571
|
+
}
|
|
572
|
+
ownHarmony = harmonyNode.value;
|
|
573
|
+
}
|
|
574
|
+
sections.push({ name, length, start, ownHarmony });
|
|
575
|
+
}
|
|
576
|
+
for (const section of sections) {
|
|
577
|
+
if (!firstHarmony.has(section.name)) firstHarmony.set(section.name, section.ownHarmony);
|
|
578
|
+
else if (firstHarmony.get(section.name) !== section.ownHarmony) return unreadable('section-repeated-harmony', { section: section.name });
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// The shift.
|
|
582
|
+
const shiftEntry = songEntry('shift');
|
|
583
|
+
if (shiftEntry === undefined) return unreadable('shift-missing');
|
|
584
|
+
const shift = numberLiteral(shiftEntry.value);
|
|
585
|
+
if (shift === null || !Number.isFinite(shift)) return unreadable('shift-not-number', { source: sourceOf(code, shiftEntry.value) });
|
|
586
|
+
for (const section of sections) section.start += shift;
|
|
587
|
+
// The source range of the shift value (a leading minus included), so an edit can replace exactly that text.
|
|
588
|
+
const shiftRange = { start: shiftEntry.value.start, end: shiftEntry.value.end };
|
|
589
|
+
|
|
590
|
+
// The plan: the cells by section, then by track.
|
|
591
|
+
const planEntry = songEntry('plan');
|
|
592
|
+
if (planEntry === undefined || planEntry.value.type !== 'ObjectExpression') return unreadable('plan-missing');
|
|
593
|
+
const planObject = readObject(code, planEntry.value);
|
|
594
|
+
if (planObject.problem) return problemOf(planObject.problem);
|
|
595
|
+
const sectionNames = new Set(sections.map((section) => section.name));
|
|
596
|
+
const planCells = new Map();
|
|
597
|
+
for (const planned of planObject.entries) {
|
|
598
|
+
if (!sectionNames.has(planned.key)) return unreadable('plan-unknown-section', { source: sourceOf(code, planned.keyNode) });
|
|
599
|
+
if (planned.value.type !== 'ObjectExpression') return unreadable('plan-missing');
|
|
600
|
+
const cellObject = readObject(code, planned.value);
|
|
601
|
+
if (cellObject.problem) return problemOf(cellObject.problem);
|
|
602
|
+
const cells = new Map();
|
|
603
|
+
for (const cell of cellObject.entries) {
|
|
604
|
+
if (!singleQuoted(cell.value)) {
|
|
605
|
+
return unreadable('cell-not-text', { section: planned.key, track: cell.key, source: sourceOf(code, cell.value) });
|
|
606
|
+
}
|
|
607
|
+
cells.set(cell.key, { key: cell.value.value, node: cell.value });
|
|
608
|
+
}
|
|
609
|
+
planCells.set(planned.key, cells);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// The groups.
|
|
613
|
+
let groups = [];
|
|
614
|
+
let groupPairs = [];
|
|
615
|
+
let grouped = new Set();
|
|
616
|
+
const groupsEntry = songEntry('groups');
|
|
617
|
+
if (groupsEntry !== undefined) {
|
|
618
|
+
if (groupsEntry.value.type !== 'ObjectExpression') return unreadable('groups-not-data', { source: sourceOf(code, groupsEntry.value) });
|
|
619
|
+
const read = readGroups(code, groupsEntry.value);
|
|
620
|
+
if (read.problem) return problemOf(read.problem);
|
|
621
|
+
({ groups, pairs: groupPairs, grouped } = read);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// The statements: the tracks whose statement is a play() call, in code order.
|
|
625
|
+
const expressionByStart = new Map();
|
|
626
|
+
for (const statement of ast.body) {
|
|
627
|
+
if (statement.type === 'LabeledStatement' && statement.body.type === 'ExpressionStatement') {
|
|
628
|
+
expressionByStart.set(statement.body.expression.start, statement.body.expression);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
const playTracks = [];
|
|
632
|
+
const statements = [];
|
|
633
|
+
const byName = new Map();
|
|
634
|
+
for (const track of tracks) {
|
|
635
|
+
if (track.kind !== 'label') continue;
|
|
636
|
+
const expression = expressionByStart.get(track.start);
|
|
637
|
+
const root = expression === undefined ? null : chainRoot(expression);
|
|
638
|
+
if (root === null || root.type !== 'CallExpression' || root.callee.type !== 'Identifier' || root.callee.name !== 'play') {
|
|
639
|
+
statements.push({ track, play: null });
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
const [nameNode, constNode] = root.arguments;
|
|
643
|
+
if (root.arguments.length !== 2 || !singleQuoted(nameNode) || constNode.type !== 'Identifier') {
|
|
644
|
+
return unreadable('play-call-unreadable', { track: track.label, source: sourceOf(code, root) });
|
|
645
|
+
}
|
|
646
|
+
const name = nameNode.value;
|
|
647
|
+
if (!track.label.includes('$') && track.baseLabel !== name) {
|
|
648
|
+
return unreadable('label-mismatch', { track: track.label, source: sourceOf(code, nameNode) });
|
|
649
|
+
}
|
|
650
|
+
if (byName.has(name)) return unreadable('track-repeated', { track: name });
|
|
651
|
+
const playTrack = { name, label: track.label, index: track.index, constName: constNode.name, muted: track.muted, constNode, cells: [] };
|
|
652
|
+
byName.set(name, playTrack);
|
|
653
|
+
playTracks.push(playTrack);
|
|
654
|
+
statements.push({ track, play: playTrack });
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Every track the plan or a group names has a statement.
|
|
658
|
+
for (const cells of planCells.values()) {
|
|
659
|
+
for (const trackName of cells.keys()) {
|
|
660
|
+
if (!byName.has(trackName)) return unreadable('plan-unknown-track', { track: trackName });
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
for (const pair of groupPairs) {
|
|
664
|
+
if (!byName.has(pair.track)) return unreadable('group-unknown-track', { group: pair.group, track: pair.track });
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// The variations of each track: one top-level const object.
|
|
668
|
+
const variationsOf = new Map();
|
|
669
|
+
for (const playTrack of playTracks) {
|
|
670
|
+
const bindings = bindingsOf(ast, playTrack.constName);
|
|
671
|
+
const [binding] = bindings;
|
|
672
|
+
if (bindings.length !== 1 || binding.kind !== 'const' || binding.declarator === null || binding.declarator.id.type !== 'Identifier' || binding.init === null || binding.init.type !== 'ObjectExpression') {
|
|
673
|
+
return unreadable('variations-unreadable', { track: playTrack.name, source: sourceOf(code, binding?.init ?? playTrack.constNode) });
|
|
674
|
+
}
|
|
675
|
+
const object = readObject(code, binding.init);
|
|
676
|
+
if (object.problem) return problemOf(object.problem);
|
|
677
|
+
const variations = new Map();
|
|
678
|
+
for (const entry of object.entries) {
|
|
679
|
+
const value = entry.value;
|
|
680
|
+
let kind = 'other';
|
|
681
|
+
let fixedHarmony = null;
|
|
682
|
+
if (entry.method || value.type === 'ArrowFunctionExpression' || value.type === 'FunctionExpression') {
|
|
683
|
+
kind = 'degrees';
|
|
684
|
+
} else if (value.type === 'ArrayExpression') {
|
|
685
|
+
const pair = value.elements.length === 2 && value.elements.every((part) => part !== null && part.type !== 'SpreadElement');
|
|
686
|
+
if (!pair) {
|
|
687
|
+
return unreadable('fixed-not-pair', { variation: entry.key, track: playTrack.name, source: sourceOf(code, value) });
|
|
688
|
+
}
|
|
689
|
+
if (!singleQuoted(value.elements[0]) || readHarmonyText(value.elements[0].value) === null) {
|
|
690
|
+
return unreadable('fixed-harmony-unreadable', { variation: entry.key, track: playTrack.name, source: sourceOf(code, value.elements[0]) });
|
|
691
|
+
}
|
|
692
|
+
kind = 'fixed';
|
|
693
|
+
fixedHarmony = value.elements[0].value;
|
|
694
|
+
} else if (!PATTERN_NODES.has(value.type)) {
|
|
695
|
+
return unreadable('variation-kind-unknown', { variation: entry.key, track: playTrack.name, source: sourceOf(code, value) });
|
|
696
|
+
}
|
|
697
|
+
variations.set(entry.key, { kind, fixedHarmony });
|
|
698
|
+
}
|
|
699
|
+
variationsOf.set(playTrack.name, variations);
|
|
700
|
+
// Where the const object of the variations lies in the code: a note inside it belongs to this track's own variations.
|
|
701
|
+
playTrack.constRange = { start: binding.init.start, end: binding.init.end };
|
|
702
|
+
playTrack.variations = [...variations].map(([key, variation]) => ({ key, kind: variation.kind, fixedHarmony: variation.fixedHarmony }));
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// The harmony of each section: its own, else the piece's.
|
|
706
|
+
const piece = readHarmony(code, { parse });
|
|
707
|
+
const pieceHarmony = { state: piece.state, text: piece.state === 'declared' ? piece.text : null };
|
|
708
|
+
const harmonyOf = (ownHarmony) => {
|
|
709
|
+
if (ownHarmony !== null) return { from: 'own', text: ownHarmony };
|
|
710
|
+
if (piece.state === 'declared') return { from: 'piece', text: piece.text };
|
|
711
|
+
return { from: piece.state === 'none' ? 'none' : 'piece-unreadable', text: null };
|
|
712
|
+
};
|
|
713
|
+
|
|
714
|
+
// The cells: one for every section and every track.
|
|
715
|
+
const readSections = sections.map((section) => ({
|
|
716
|
+
name: section.name,
|
|
717
|
+
length: section.length,
|
|
718
|
+
start: section.start,
|
|
719
|
+
ownHarmony: section.ownHarmony,
|
|
720
|
+
harmony: harmonyOf(section.ownHarmony),
|
|
721
|
+
}));
|
|
722
|
+
for (let i = 0; i < readSections.length; i++) {
|
|
723
|
+
for (const playTrack of playTracks) {
|
|
724
|
+
const planned = planCells.get(readSections[i].name)?.get(playTrack.name);
|
|
725
|
+
if (planned === undefined) return unreadable('cell-missing', { section: readSections[i].name, track: playTrack.name });
|
|
726
|
+
// The range is the plan string of the section's name for this track; entries of one name share it.
|
|
727
|
+
const range = { start: planned.node.start, end: planned.node.end };
|
|
728
|
+
if (planned.key === '-') {
|
|
729
|
+
playTrack.cells.push({ section: i, key: null, kind: 'rest', fixedHarmony: null, range });
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
const variation = variationsOf.get(playTrack.name).get(planned.key);
|
|
733
|
+
if (variation === undefined) {
|
|
734
|
+
return unreadable('cell-unknown-variation', { section: readSections[i].name, track: playTrack.name, source: sourceOf(code, planned.node) });
|
|
735
|
+
}
|
|
736
|
+
if (variation.kind === 'degrees' && readSections[i].harmony.from === 'none') {
|
|
737
|
+
return unreadable('degrees-without-harmony', { section: readSections[i].name, track: playTrack.name, variation: planned.key });
|
|
738
|
+
}
|
|
739
|
+
playTrack.cells.push({ section: i, key: planned.key, kind: variation.kind, fixedHarmony: variation.fixedHarmony, range });
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const ungrouped = [];
|
|
744
|
+
for (const { track, play } of statements) {
|
|
745
|
+
if (play === null) ungrouped.push({ name: track.displayName, index: track.index, structured: false });
|
|
746
|
+
else if (!grouped.has(play.name)) ungrouped.push({ name: play.name, index: track.index, structured: true });
|
|
747
|
+
}
|
|
748
|
+
return {
|
|
749
|
+
state: 'read',
|
|
750
|
+
helper,
|
|
751
|
+
shift,
|
|
752
|
+
shiftRange,
|
|
753
|
+
total,
|
|
754
|
+
pieceHarmony,
|
|
755
|
+
sections: readSections,
|
|
756
|
+
tracks: playTracks.map(({ name, label, index, constName, muted, variations, cells, constRange }) => ({ name, label, index, constName, muted, variations, cells, constRange })),
|
|
757
|
+
groups,
|
|
758
|
+
ungrouped,
|
|
759
|
+
};
|
|
760
|
+
}
|