moontide-mcp 2.0.2 → 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 +41 -5
- 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
package/page/ask-view.js
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
createAskStatus,
|
|
20
20
|
cutText,
|
|
21
21
|
describeContext,
|
|
22
|
+
fitGestureToLine,
|
|
22
23
|
fitMomentToLine,
|
|
23
24
|
newAskId,
|
|
24
25
|
partsAskState,
|
|
@@ -119,7 +120,8 @@ export function createAskView({ document, send, reportError, clearError, setTime
|
|
|
119
120
|
// The version id the top bar shows, or null before any version.
|
|
120
121
|
let currentVersion = null;
|
|
121
122
|
// What the right-click pointed at, snapshotted when the box opened: { context, chip } and, when the
|
|
122
|
-
// Timeline showed the music there, the moment captured at the click
|
|
123
|
+
// Timeline showed the music there, the moment captured at the click, and, when a gesture on a note opened the box,
|
|
124
|
+
// that gesture; for a range, the cycles it was cut to when the pointer left the page (`rangeCut`).
|
|
123
125
|
let snapshot = null;
|
|
124
126
|
// The last request sent: { id, text }, so an accepted request clears its own draft.
|
|
125
127
|
let lastSent = null;
|
|
@@ -177,6 +179,7 @@ export function createAskView({ document, send, reportError, clearError, setTime
|
|
|
177
179
|
chip: snapshot.chip,
|
|
178
180
|
current: currentVersion,
|
|
179
181
|
moment: snapshot.moment,
|
|
182
|
+
gesture: snapshot.gesture,
|
|
180
183
|
};
|
|
181
184
|
}
|
|
182
185
|
|
|
@@ -189,7 +192,7 @@ export function createAskView({ document, send, reportError, clearError, setTime
|
|
|
189
192
|
function renderMessage() {
|
|
190
193
|
const length = askField.value.trim().length;
|
|
191
194
|
const tooLongLine = lineTooLong();
|
|
192
|
-
const text = askMessage({ connected, length, notSent, tokenRefused, lineTooLong: tooLongLine });
|
|
195
|
+
const text = askMessage({ connected, length, notSent, tokenRefused, lineTooLong: tooLongLine, rangeCut: snapshot === null ? null : (snapshot.rangeCut ?? null) });
|
|
193
196
|
askMessageEl.textContent = text ?? '';
|
|
194
197
|
askMessageEl.hidden = text === null;
|
|
195
198
|
askField.setAttribute('aria-describedby', askMessageEl.hidden ? DESCRIBED_WITHOUT_MESSAGE : DESCRIBED_WITH_MESSAGE);
|
|
@@ -264,13 +267,17 @@ export function createAskView({ document, send, reportError, clearError, setTime
|
|
|
264
267
|
/**
|
|
265
268
|
* Puts a prefill in the field (a failed change sent to Claude). An empty field is simply filled. A field that
|
|
266
269
|
* already holds a draft keeps it: the prefill follows after a blank line, cut so the whole field stays within
|
|
267
|
-
* MAX_ASK_TEXT_LENGTH
|
|
268
|
-
* room at all, the draft stays as it is (the chip still
|
|
270
|
+
* MAX_ASK_TEXT_LENGTH, and is not added again when the draft already ends with it. The caret goes to the end so the
|
|
271
|
+
* user types the question after it. When the draft leaves no room at all, the draft stays as it is (the chip still
|
|
272
|
+
* names the error).
|
|
269
273
|
*/
|
|
270
274
|
function applyPrefill(prefill) {
|
|
271
275
|
const draft = askField.value;
|
|
272
276
|
if (draft === '') {
|
|
273
277
|
askField.value = prefill;
|
|
278
|
+
} else if (draft.endsWith(prefill)) {
|
|
279
|
+
// The request is already there (the same target was asked about twice, or the browser raised a right-click after
|
|
280
|
+
// a key press that had already opened the box): it is not added a second time.
|
|
274
281
|
} else {
|
|
275
282
|
const room = MAX_ASK_TEXT_LENGTH - draft.length - 2;
|
|
276
283
|
if (room < 1) return;
|
|
@@ -284,8 +291,8 @@ export function createAskView({ document, send, reportError, clearError, setTime
|
|
|
284
291
|
* `{ prefill }` the field also receives that text (see applyPrefill); without it nothing about the field changes.
|
|
285
292
|
*/
|
|
286
293
|
function open(descriptor, point, { prefill } = {}) {
|
|
287
|
-
const { context, chip, moment } = describeContext(descriptor);
|
|
288
|
-
snapshot = moment === undefined ? {
|
|
294
|
+
const { context, chip, moment, gesture, rangeCut } = describeContext(descriptor);
|
|
295
|
+
snapshot = { context, chip, ...(moment === undefined ? {} : { moment }), ...(gesture === undefined ? {} : { gesture }), ...(rangeCut === undefined ? {} : { rangeCut }) };
|
|
289
296
|
askContextChip.textContent = chip;
|
|
290
297
|
askContextChip.setAttribute('title', chip);
|
|
291
298
|
const wasHidden = askBox.hidden;
|
|
@@ -327,8 +334,13 @@ export function createAskView({ document, send, reportError, clearError, setTime
|
|
|
327
334
|
const frame = { type: 'ask', id, text, context: describedContext.context, chip: describedContext.chip, current: currentVersion };
|
|
328
335
|
// The moment rides after `current`, and only when there is one: a frame without it is as it always was.
|
|
329
336
|
// Notes come off its end until the whole line fits what the server takes (the rest counts in `more`).
|
|
330
|
-
const
|
|
337
|
+
const fields = { ...frame, moment: describedContext.moment, gesture: describedContext.gesture };
|
|
338
|
+
const moment = fitMomentToLine(fields);
|
|
331
339
|
if (moment !== undefined) frame.moment = moment;
|
|
340
|
+
// The gesture follows the moment, and only when the box was opened by one. The notes of a range gesture come off its end the
|
|
341
|
+
// same way; any other gesture is sent as it is.
|
|
342
|
+
const gesture = fitGestureToLine({ ...fields, moment });
|
|
343
|
+
if (gesture !== undefined) frame.gesture = gesture;
|
|
332
344
|
send(frame);
|
|
333
345
|
// While the token is refused the line stays: the server's 'token' refusal writes it again, so it never blinks off.
|
|
334
346
|
if (!authRefused) clearError();
|
|
@@ -358,6 +370,8 @@ export function createAskView({ document, send, reportError, clearError, setTime
|
|
|
358
370
|
// The History view must never see this Enter, even after focus moves back to a row.
|
|
359
371
|
event.preventDefault();
|
|
360
372
|
event.stopPropagation();
|
|
373
|
+
// A held key that opened the box (the auto-repeat lands here once focus has moved) must not also send what is in it.
|
|
374
|
+
if (event.repeat) return;
|
|
361
375
|
submit();
|
|
362
376
|
} else if (event.key === 'Escape') {
|
|
363
377
|
// Esc closes the box only, never an open view.
|
package/page/ask.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// page/ask-view.js.
|
|
5
5
|
|
|
6
6
|
import { BEATS_PER_CYCLE } from './change-timing.js';
|
|
7
|
-
import { MAX_MOMENT_NOTES } from './timeline.js';
|
|
7
|
+
import { MAX_MOMENT_NOTES, MAX_RANGE_CYCLES } from './timeline.js';
|
|
8
|
+
import { cleanText } from './structure.js';
|
|
8
9
|
|
|
9
10
|
/** Mirrors src/protocol.ts's MAX_ASK_TEXT_LENGTH by hand (test/ask.test.js checks both are 2000). */
|
|
10
11
|
export const MAX_ASK_TEXT_LENGTH = 2000;
|
|
@@ -67,6 +68,24 @@ export const ASK_COPY = {
|
|
|
67
68
|
// A failed change sent to Claude from the banner (the chip's lead and the first words of the prefill).
|
|
68
69
|
errorChipLead: 'Failed change',
|
|
69
70
|
errorPrefillLead: 'The last change failed with this error:',
|
|
71
|
+
// The ready-made request on a cell whose fixed variation has notes outside its section's harmony. The words in braces
|
|
72
|
+
// are filled by cellFitPrefill; the user reads the text, may change it and presses Enter.
|
|
73
|
+
cellFitRequest: "Fit variation '{variation}' of track '{track}' to the harmony of section '{section}' ({harmony}). It has notes outside it: {notes}.",
|
|
74
|
+
// The ready-made requests of a gesture on a Timeline note the page cannot edit. timelineEditPrefill fills the words in
|
|
75
|
+
// braces; the user reads the text, may change it and presses Enter.
|
|
76
|
+
raiseRequest: "Raise note {note} at cycle {cycle}, beat {beat} of '{track}' by {amount}.",
|
|
77
|
+
lowerRequest: "Lower note {note} at cycle {cycle}, beat {beat} of '{track}' by {amount}.",
|
|
78
|
+
restRequest: "Turn note {note} at cycle {cycle}, beat {beat} of '{track}' into a rest.",
|
|
79
|
+
softerRequest: "Play note {note} at cycle {cycle}, beat {beat} of '{track}' softer.",
|
|
80
|
+
louderRequest: "Play note {note} at cycle {cycle}, beat {beat} of '{track}' louder.",
|
|
81
|
+
semitoneAmount: '1 semitone',
|
|
82
|
+
semitonesAmount: '{n} semitones',
|
|
83
|
+
octaveAmount: '1 octave',
|
|
84
|
+
octavesAmount: '{n} octaves',
|
|
85
|
+
// The line the box shows when a range was cut to the page shown (the pointer left the lanes while the range was drawn). The
|
|
86
|
+
// words in braces are filled by rangeCutText.
|
|
87
|
+
rangeCut: 'Cut to the page shown — cycles {a} to {b}.',
|
|
88
|
+
rangeCutOne: 'Cut to the page shown — cycle {a}.',
|
|
70
89
|
};
|
|
71
90
|
|
|
72
91
|
/**
|
|
@@ -145,14 +164,27 @@ export function counterText(length) {
|
|
|
145
164
|
return `${length} / ${MAX_ASK_TEXT_LENGTH} — too long. Shorten it to send.`;
|
|
146
165
|
}
|
|
147
166
|
|
|
148
|
-
/** The
|
|
149
|
-
export function
|
|
167
|
+
/** The line that says a range was cut to the page shown, for `cut` = `{ from, to }` (the cycles the range ended up with). */
|
|
168
|
+
export function rangeCutText(cut) {
|
|
169
|
+
if (cut === null || typeof cut !== 'object' || !isNonNegativeInteger(cut.from) || !isNonNegativeInteger(cut.to) || cut.to < cut.from) {
|
|
170
|
+
throw new Error('Moontide: a cut range needs whole cycles from and to, to not before from');
|
|
171
|
+
}
|
|
172
|
+
const template = cut.from === cut.to ? ASK_COPY.rangeCutOne : ASK_COPY.rangeCut;
|
|
173
|
+
return template.replace('{a}', String(cut.from)).replace('{b}', String(cut.to));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The one muted message line of the box, in the spec's priority order, or null when none applies. `rangeCut` (`{ from, to }`
|
|
178
|
+
* or null) is last: it shows only when nothing above it applies.
|
|
179
|
+
*/
|
|
180
|
+
export function askMessage({ connected, length, notSent, tokenRefused, lineTooLong = false, rangeCut = null }) {
|
|
150
181
|
if (!connected) return ASK_COPY.disconnected;
|
|
151
182
|
const counter = counterText(length);
|
|
152
183
|
if (counter !== null && !(lineTooLong && length <= MAX_ASK_TEXT_LENGTH)) return counter;
|
|
153
184
|
if (lineTooLong) return ASK_COPY.lineTooLong;
|
|
154
185
|
if (notSent && tokenRefused) return ASK_COPY.notSentToken;
|
|
155
186
|
if (notSent) return ASK_COPY.notSent;
|
|
187
|
+
if (rangeCut !== null) return rangeCutText(rangeCut);
|
|
156
188
|
return null;
|
|
157
189
|
}
|
|
158
190
|
|
|
@@ -224,6 +256,12 @@ function requiredBus(descriptor) {
|
|
|
224
256
|
|
|
225
257
|
const SEPARATOR = ' \u00B7 ';
|
|
226
258
|
|
|
259
|
+
/** The words after a cell's name in its chip, by the grid's fit state. */
|
|
260
|
+
const CELL_FIT_WORDS = Object.freeze({ fits: 'fits', 'does-not-fit': 'does not fit', 'cannot-check': 'cannot check' });
|
|
261
|
+
|
|
262
|
+
/** The most characters the fit part of a cell's chip may take, so the base always keeps room. */
|
|
263
|
+
const MAX_CELL_SUFFIX = 100;
|
|
264
|
+
|
|
227
265
|
/** The bus name a bus chip shows: 'Reverb bus', with the orbit while several orbits are in use. */
|
|
228
266
|
function busTitle(bus, orbit, multipleOrbits) {
|
|
229
267
|
const name = `${bus === 'reverb' ? 'Reverb' : 'Delay'} bus`;
|
|
@@ -246,29 +284,63 @@ function isNonNegativeNumber(value) {
|
|
|
246
284
|
export const MOMENT_NOTE_FIELDS = Object.freeze(['track', 'instrument', 'sound', 'note', 'drum', 'velocity', 'start', 'end', 'state']);
|
|
247
285
|
|
|
248
286
|
/**
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
* wrong is a loud error here instead of a request that is refused later. The input is never mutated.
|
|
287
|
+
* The kinds of gesture a request can carry, equal to GESTURE_KINDS in src/protocol.ts (test/ask.test.js pins the two
|
|
288
|
+
* lists equal). The server rejects a kind it does not know.
|
|
252
289
|
*/
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
290
|
+
export const GESTURE_KINDS = Object.freeze(['pitch', 'rest', 'velocity', 'range']);
|
|
291
|
+
|
|
292
|
+
/** The gestures that change one note of the Timeline (the ones a ready-made request is written for). */
|
|
293
|
+
const NOTE_GESTURE_KINDS = Object.freeze(GESTURE_KINDS.filter((kind) => kind !== 'range'));
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The keys checkedGesture may copy into a note of a range: the moment note's keys without the state. The server's range note
|
|
297
|
+
* shape (askRangeNoteShape in src/protocol.ts) names the same keys; test/ask.test.js pins the two lists equal.
|
|
298
|
+
*/
|
|
299
|
+
export const RANGE_NOTE_FIELDS = Object.freeze(MOMENT_NOTE_FIELDS.filter((field) => field !== 'state'));
|
|
300
|
+
|
|
301
|
+
/** The most semitones one pitch gesture may carry, up or down; equals MAX_GESTURE_SEMITONES in src/protocol.ts. */
|
|
302
|
+
export const MAX_GESTURE_SEMITONES = 127;
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* A frozen copy of the gesture a descriptor carries, with only the keys its kind names. The server rejects a gesture out
|
|
306
|
+
* of bounds and never trims it, so a gesture the page got wrong is a loud error here.
|
|
307
|
+
*/
|
|
308
|
+
function checkedGesture(gesture) {
|
|
309
|
+
if (gesture === null || typeof gesture !== 'object') throw new Error('Moontide: an ask gesture is an object');
|
|
310
|
+
switch (gesture.kind) {
|
|
311
|
+
case 'pitch':
|
|
312
|
+
if (!Number.isInteger(gesture.semitones) || gesture.semitones === 0 || Math.abs(gesture.semitones) > MAX_GESTURE_SEMITONES) {
|
|
313
|
+
throw new Error(`Moontide: a pitch gesture needs whole semitones from -${MAX_GESTURE_SEMITONES} to ${MAX_GESTURE_SEMITONES} other than 0`);
|
|
314
|
+
}
|
|
315
|
+
return Object.freeze({ kind: 'pitch', semitones: gesture.semitones });
|
|
316
|
+
case 'rest':
|
|
317
|
+
return Object.freeze({ kind: 'rest' });
|
|
318
|
+
case 'velocity':
|
|
319
|
+
if (gesture.direction !== 'softer' && gesture.direction !== 'louder') throw new Error('Moontide: a velocity gesture needs a direction of softer or louder');
|
|
320
|
+
return Object.freeze({ kind: 'velocity', direction: gesture.direction });
|
|
321
|
+
case 'range': {
|
|
322
|
+
if (gesture.version !== null && !(Number.isInteger(gesture.version) && gesture.version > 0)) {
|
|
323
|
+
throw missing('range', 'a gesture version that is null or a positive whole number');
|
|
324
|
+
}
|
|
325
|
+
if (!Array.isArray(gesture.notes) || gesture.notes.length > MAX_MOMENT_NOTES) throw missing('range', `at most ${MAX_MOMENT_NOTES} range notes`);
|
|
326
|
+
if (!isNonNegativeInteger(gesture.more)) throw missing('range', 'a whole number count of more notes of at least 0');
|
|
327
|
+
return Object.freeze({ kind: 'range', version: gesture.version, notes: checkedNotes('range', gesture.notes, false), more: gesture.more });
|
|
328
|
+
}
|
|
329
|
+
default:
|
|
330
|
+
throw new Error(`Moontide: unknown ask gesture kind ${String(gesture.kind)}`);
|
|
265
331
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* A checked copy of the notes of a moment (`withState` true) or of a range (no state), every string cut to the context limit.
|
|
336
|
+
* The input is never mutated; a note that is not as the server takes it is a loud error.
|
|
337
|
+
*/
|
|
338
|
+
function checkedNotes(kind, notes, withState) {
|
|
339
|
+
return notes.map((note, index) => {
|
|
340
|
+
const where = `${withState ? 'moment' : 'range'} note ${index + 1}`;
|
|
269
341
|
if (note === null || typeof note !== 'object') throw missing(kind, `${where} as an object`);
|
|
270
342
|
if (typeof note.track !== 'string' || note.track === '') throw missing(kind, `a non-empty track in ${where}`);
|
|
271
|
-
if (note.state !== 'sounding' && note.state !== 'starts') throw missing(kind, `a state of sounding or starts in ${where}`);
|
|
343
|
+
if (withState && note.state !== 'sounding' && note.state !== 'starts') throw missing(kind, `a state of sounding or starts in ${where}`);
|
|
272
344
|
if (!isNonNegativeNumber(note.start) || !isNonNegativeNumber(note.end)) {
|
|
273
345
|
throw missing(kind, `a finite start and end of at least 0 in ${where}`);
|
|
274
346
|
}
|
|
@@ -293,9 +365,31 @@ function checkedMoment(kind, moment) {
|
|
|
293
365
|
}
|
|
294
366
|
copy.start = note.start;
|
|
295
367
|
copy.end = note.end;
|
|
296
|
-
copy.state = note.state;
|
|
368
|
+
if (withState) copy.state = note.state;
|
|
297
369
|
return copy;
|
|
298
370
|
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* A copy of the moment the Timeline described, checked and with every string cut to the context limit
|
|
375
|
+
* (numbers are untouched). The server refuses what is out of bounds and never trims, so a moment the page got
|
|
376
|
+
* wrong is a loud error here instead of a request that is refused later. The input is never mutated.
|
|
377
|
+
*/
|
|
378
|
+
function checkedMoment(kind, moment) {
|
|
379
|
+
if (moment === null || typeof moment !== 'object') throw missing(kind, 'a moment that is an object');
|
|
380
|
+
if (!isNonNegativeInteger(moment.cycle)) throw missing(kind, 'a whole number moment cycle of at least 0');
|
|
381
|
+
if (!Number.isInteger(moment.beat) || moment.beat < 1 || moment.beat > BEATS_PER_CYCLE) {
|
|
382
|
+
throw missing(kind, `a whole number moment beat from 1 to ${BEATS_PER_CYCLE}`);
|
|
383
|
+
}
|
|
384
|
+
if (!isNonNegativeNumber(moment.at)) throw missing(kind, 'a finite moment instant of at least 0');
|
|
385
|
+
if (moment.version !== null && !(Number.isInteger(moment.version) && moment.version > 0)) {
|
|
386
|
+
throw missing(kind, 'a moment version that is null or a positive whole number');
|
|
387
|
+
}
|
|
388
|
+
if (!Array.isArray(moment.notes) || moment.notes.length > MAX_MOMENT_NOTES) {
|
|
389
|
+
throw missing(kind, `at most ${MAX_MOMENT_NOTES} moment notes`);
|
|
390
|
+
}
|
|
391
|
+
if (!isNonNegativeInteger(moment.more)) throw missing(kind, 'a whole number moment count of more notes of at least 0');
|
|
392
|
+
const notes = checkedNotes(kind, moment.notes, true);
|
|
299
393
|
return { cycle: moment.cycle, beat: moment.beat, at: moment.at, version: moment.version, notes, more: moment.more };
|
|
300
394
|
}
|
|
301
395
|
|
|
@@ -304,8 +398,9 @@ function checkedMoment(kind, moment) {
|
|
|
304
398
|
* prefix, a space and the JSON of the fields in the same order (U+2028 and U+2029 escaped), so the page can
|
|
305
399
|
* tell before it sends whether the server will take the line.
|
|
306
400
|
*/
|
|
307
|
-
export function requestLineBytes({ id, text, context, chip, current, moment }) {
|
|
308
|
-
|
|
401
|
+
export function requestLineBytes({ id, text, context, chip, current, moment, gesture }) {
|
|
402
|
+
// The moment, then the gesture, each only when there is one (the order of src/ask.ts's requestLine).
|
|
403
|
+
const fields = { id, text, context, chip, current, ...(moment === undefined ? {} : { moment }), ...(gesture === undefined ? {} : { gesture }) };
|
|
309
404
|
const json = JSON.stringify(fields).replaceAll('\u2028', '\\u2028').replaceAll('\u2029', '\\u2029');
|
|
310
405
|
return new TextEncoder().encode(`${REQUEST_LINE_PREFIX} ${json}`).length;
|
|
311
406
|
}
|
|
@@ -329,22 +424,47 @@ export function fitMomentToLine(fields) {
|
|
|
329
424
|
}
|
|
330
425
|
|
|
331
426
|
/**
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
427
|
+
* The gesture of a request cut to what the line holds. A range gesture loses notes from its end, one at a time, counted in
|
|
428
|
+
* `more`, until the whole line is at most MAX_REQUEST_LINE_BYTES (or no note is left); any other gesture, and undefined, come
|
|
429
|
+
* back as they are. The input is never mutated, and a gesture that fits is returned as it is. The chip counts notes and
|
|
430
|
+
* `more` together, so it needs no change.
|
|
431
|
+
*/
|
|
432
|
+
export function fitGestureToLine(fields) {
|
|
433
|
+
const { gesture } = fields;
|
|
434
|
+
if (gesture === undefined || gesture.kind !== 'range') return gesture;
|
|
435
|
+
let notes = gesture.notes;
|
|
436
|
+
let more = gesture.more;
|
|
437
|
+
while (notes.length > 0 && requestLineBytes({ ...fields, gesture: { ...gesture, notes, more } }) > MAX_REQUEST_LINE_BYTES) {
|
|
438
|
+
notes = notes.slice(0, -1);
|
|
439
|
+
more += 1;
|
|
440
|
+
}
|
|
441
|
+
return notes === gesture.notes ? gesture : { ...gesture, notes, more };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Whether the request fits one line once its moment and its range notes are cut as far as `fitMomentToLine` and
|
|
446
|
+
* `fitGestureToLine` cut them. False means the text, the context and the chip alone are over MAX_REQUEST_LINE_BYTES: a text
|
|
447
|
+
* within the character limit can still get here (JSON writes a control character as six bytes, and some scripts take three),
|
|
448
|
+
* so the box does not send it and says so, instead of letting the server refuse it with the wrong reason.
|
|
336
449
|
*/
|
|
337
450
|
export function requestFitsLine(fields) {
|
|
338
|
-
|
|
451
|
+
const moment = fitMomentToLine(fields);
|
|
452
|
+
return requestLineBytes({ ...fields, moment, gesture: fitGestureToLine({ ...fields, moment }) }) <= MAX_REQUEST_LINE_BYTES;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** '3 notes', '1 note' or 'no notes' for a count. */
|
|
456
|
+
function notesWords(count) {
|
|
457
|
+
return count === 0 ? 'no notes' : count === 1 ? '1 note' : `${count} notes`;
|
|
339
458
|
}
|
|
340
459
|
|
|
341
460
|
/** The part of a chip that says where and what: 'cycle 34, beat 3 · 3 notes'. */
|
|
342
461
|
function momentChipPart(moment) {
|
|
343
|
-
|
|
344
|
-
const notes = count === 0 ? 'no notes' : count === 1 ? '1 note' : `${count} notes`;
|
|
345
|
-
return `cycle ${moment.cycle}, beat ${moment.beat}${SEPARATOR}${notes}`;
|
|
462
|
+
return `cycle ${moment.cycle}, beat ${moment.beat}${SEPARATOR}${notesWords(moment.notes.length + moment.more)}`;
|
|
346
463
|
}
|
|
347
464
|
|
|
465
|
+
/** The longest section name the chip of a range shows; the context carries the whole name (cut at the context limit). */
|
|
466
|
+
const MAX_RANGE_CHIP_SECTION = 40;
|
|
467
|
+
|
|
348
468
|
/**
|
|
349
469
|
* What a right-click pointed at, as the context the server receives and the chip the user sees.
|
|
350
470
|
* `descriptor` is what a view reports about an element:
|
|
@@ -356,6 +476,7 @@ function momentChipPart(moment) {
|
|
|
356
476
|
export function describeContext(descriptor) {
|
|
357
477
|
const kind = descriptor && descriptor.kind;
|
|
358
478
|
let moment = descriptor ? descriptor.moment : undefined;
|
|
479
|
+
let gesture = descriptor ? descriptor.gesture : undefined;
|
|
359
480
|
// A moment is the Timeline's facts at the pointer: a lane, a part header over the lanes and the rest of the
|
|
360
481
|
// Timeline (a session descriptor) carry one; nothing else does.
|
|
361
482
|
if (moment !== undefined && kind !== 'lane' && kind !== 'part' && kind !== 'session') throw new Error(`Moontide: ask context ${kind} cannot carry a moment`);
|
|
@@ -490,6 +611,33 @@ export function describeContext(descriptor) {
|
|
|
490
611
|
}
|
|
491
612
|
break;
|
|
492
613
|
}
|
|
614
|
+
case 'range': {
|
|
615
|
+
const track = requiredText(descriptor, 'track');
|
|
616
|
+
const from = requiredInteger(descriptor, 'from', 0);
|
|
617
|
+
const to = requiredInteger(descriptor, 'to', from);
|
|
618
|
+
if (to - from >= MAX_RANGE_CYCLES) throw missing(kind, `at most ${MAX_RANGE_CYCLES} cycles`);
|
|
619
|
+
const section = optionalText(descriptor, 'section');
|
|
620
|
+
const sectionTo = optionalText(descriptor, 'sectionTo');
|
|
621
|
+
if (sectionTo !== undefined && (section === undefined || sectionTo === section)) throw missing(kind, 'a sectionTo only when it differs from the section');
|
|
622
|
+
if (gesture === undefined || gesture.kind !== 'range') throw missing(kind, 'a range gesture with its notes');
|
|
623
|
+
gesture = checkedGesture(gesture);
|
|
624
|
+
const cut = descriptor.cut === undefined || descriptor.cut === null ? null : { from: descriptor.cut.from, to: descriptor.cut.to };
|
|
625
|
+
if (cut !== null) rangeCutText(cut);
|
|
626
|
+
context = { kind, track, from, to };
|
|
627
|
+
if (section !== undefined) context.section = section;
|
|
628
|
+
if (sectionTo !== undefined) context.sectionTo = sectionTo;
|
|
629
|
+
// The cycles, the section and the notes are built first and the lane name is cut to the room left, so a long name never
|
|
630
|
+
// costs the cycles.
|
|
631
|
+
const parts = [from === to ? `cycle ${from}` : `cycles ${from} to ${to}`];
|
|
632
|
+
if (section !== undefined) {
|
|
633
|
+
const shown = (name) => `"${cutText(name, MAX_RANGE_CHIP_SECTION)}"`;
|
|
634
|
+
parts.push(sectionTo === undefined ? `section ${shown(section)}` : `sections ${shown(section)} to ${shown(sectionTo)}`);
|
|
635
|
+
}
|
|
636
|
+
parts.push(notesWords(gesture.notes.length + gesture.more));
|
|
637
|
+
const tail = parts.join(SEPARATOR);
|
|
638
|
+
chip = `${cutText(`Range${SEPARATOR}${track}`, MAX_ASK_CONTEXT_LENGTH - SEPARATOR.length - tail.length)}${SEPARATOR}${tail}`;
|
|
639
|
+
return { context, chip: cutText(chip), gesture, ...(cut === null ? {} : { rangeCut: cut }) };
|
|
640
|
+
}
|
|
493
641
|
case 'note': {
|
|
494
642
|
const note = requiredText(descriptor, 'note');
|
|
495
643
|
const role = descriptor.role === null ? undefined : descriptor.role;
|
|
@@ -523,10 +671,57 @@ export function describeContext(descriptor) {
|
|
|
523
671
|
chip = `Warning${SEPARATOR}${chord} brings ${outside}${avoid === undefined ? '' : `, avoid ${avoid}`}, ${seconds === 0 ? 'now' : `in ${seconds} s`}`;
|
|
524
672
|
break;
|
|
525
673
|
}
|
|
674
|
+
case 'cell': {
|
|
675
|
+
const section = requiredText(descriptor, 'section');
|
|
676
|
+
const track = requiredText(descriptor, 'track');
|
|
677
|
+
const variation = optionalText(descriptor, 'variation');
|
|
678
|
+
const rest = descriptor.rest;
|
|
679
|
+
if (rest !== undefined && rest !== true) throw missing(kind, 'rest as true, or no rest');
|
|
680
|
+
// A cell holds one variation or is a rest, never both and never neither.
|
|
681
|
+
if ((variation === undefined) === (rest === undefined)) throw missing(kind, 'a variation or rest true, not both');
|
|
682
|
+
const fit = descriptor.fit === null ? undefined : descriptor.fit;
|
|
683
|
+
let outside;
|
|
684
|
+
let suffix = rest === undefined ? '' : `${SEPARATOR}rest`;
|
|
685
|
+
if (fit !== undefined) {
|
|
686
|
+
// The fit is the grid's word for a fixed variation; a rest has none.
|
|
687
|
+
if (rest !== undefined) throw missing(kind, 'no fit on a rest');
|
|
688
|
+
if (!Object.hasOwn(CELL_FIT_WORDS, fit)) throw missing(kind, 'a fit of fits, does-not-fit or cannot-check');
|
|
689
|
+
outside = optionalText(descriptor, 'outside');
|
|
690
|
+
if (outside !== undefined && fit !== 'does-not-fit') throw missing(kind, 'outside only with a fit of does-not-fit');
|
|
691
|
+
suffix = `${SEPARATOR}${CELL_FIT_WORDS[fit]}${outside === undefined ? '' : ` (${outside})`}`;
|
|
692
|
+
} else if (descriptor.outside !== undefined && descriptor.outside !== null) {
|
|
693
|
+
throw missing(kind, 'outside only with a fit of does-not-fit');
|
|
694
|
+
}
|
|
695
|
+
context = { kind, section, track };
|
|
696
|
+
if (variation !== undefined) context.variation = variation;
|
|
697
|
+
if (rest !== undefined) context.rest = true;
|
|
698
|
+
if (fit !== undefined) context.fit = fit;
|
|
699
|
+
if (outside !== undefined) context.outside = outside;
|
|
700
|
+
// The fit part is built first and the base (section, track, variation) is cut to the room left, so a long name
|
|
701
|
+
// never costs the words that say whether the notes fit.
|
|
702
|
+
const fitPart = cutText(suffix, MAX_CELL_SUFFIX);
|
|
703
|
+
const base = `Cell "${section}"${SEPARATOR}${track}${variation === undefined ? '' : `${SEPARATOR}${variation}`}`;
|
|
704
|
+
chip = `${cutText(base, MAX_ASK_CONTEXT_LENGTH - fitPart.length)}${fitPart}`;
|
|
705
|
+
break;
|
|
706
|
+
}
|
|
707
|
+
case 'group': {
|
|
708
|
+
const name = requiredText(descriptor, 'name');
|
|
709
|
+
const parent = optionalText(descriptor, 'parent');
|
|
710
|
+
context = parent === undefined ? { kind, name } : { kind, name, parent };
|
|
711
|
+
chip = `Group "${name}"${parent === undefined ? '' : `${SEPARATOR}in "${parent}"`}`;
|
|
712
|
+
break;
|
|
713
|
+
}
|
|
526
714
|
default:
|
|
527
715
|
throw new Error(`Moontide: unknown ask context kind ${kind}`);
|
|
528
716
|
}
|
|
529
|
-
|
|
717
|
+
// A gesture is about one note of a lane: it rides only on a lane that carries a moment (the range gesture is the range
|
|
718
|
+
// context's own and is checked in its case above).
|
|
719
|
+
if (gesture !== undefined) {
|
|
720
|
+
if (kind !== 'lane' || moment === undefined) throw new Error(`Moontide: ask context ${kind} cannot carry a gesture without a lane and its moment`);
|
|
721
|
+
gesture = checkedGesture(gesture);
|
|
722
|
+
if (gesture.kind === 'range') throw new Error('Moontide: a range gesture goes only with a range context');
|
|
723
|
+
}
|
|
724
|
+
return { context, chip: cutText(chip), ...(moment === undefined ? {} : { moment }), ...(gesture === undefined ? {} : { gesture }) };
|
|
530
725
|
}
|
|
531
726
|
|
|
532
727
|
/**
|
|
@@ -539,6 +734,69 @@ export function errorAskPrefill(fullText) {
|
|
|
539
734
|
return `${ASK_COPY.errorPrefillLead}\n\n${cutText(fullText, MAX_ERROR_PREFILL)}\n\n`;
|
|
540
735
|
}
|
|
541
736
|
|
|
737
|
+
/**
|
|
738
|
+
* The text the Ask field starts with on a cell whose fixed variation does not fit its section: ASK_COPY.cellFitRequest with
|
|
739
|
+
* the variation, the track, the section, the section's harmony text and the outside notes. Every value is a non-empty string,
|
|
740
|
+
* cleaned of hidden characters and cut at the context limit, so the text stays within the field. A missing value throws.
|
|
741
|
+
*/
|
|
742
|
+
export function cellFitPrefill(values) {
|
|
743
|
+
const filled = {};
|
|
744
|
+
for (const field of ['variation', 'track', 'section', 'harmony', 'notes']) {
|
|
745
|
+
const value = values === null || typeof values !== 'object' ? undefined : values[field];
|
|
746
|
+
if (typeof value !== 'string' || value === '') throw new Error(`Moontide: the fit request needs a non-empty ${field}`);
|
|
747
|
+
filled[field] = cutText(cleanText(value));
|
|
748
|
+
}
|
|
749
|
+
const text = ASK_COPY.cellFitRequest.replace(/\{(\w+)\}/g, (_, name) => filled[name]);
|
|
750
|
+
if (text.length > MAX_ASK_TEXT_LENGTH) throw new Error('Moontide: the fit request is longer than the Ask box takes.');
|
|
751
|
+
return text;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** A pitch change in words: `1 semitone`, `5 semitones`, `1 octave`, `2 octaves` (a multiple of 12 reads in octaves). */
|
|
755
|
+
function amountWords(size) {
|
|
756
|
+
if (size % 12 === 0) return size === 12 ? ASK_COPY.octaveAmount : ASK_COPY.octavesAmount.replace('{n}', String(size / 12));
|
|
757
|
+
return size === 1 ? ASK_COPY.semitoneAmount : ASK_COPY.semitonesAmount.replace('{n}', String(size));
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* The text the Ask field starts with when the user does something to a Timeline note the page cannot edit: raise, lower,
|
|
762
|
+
* rest or softer / louder, with the note as the Timeline shows it, its cycle and beat and its track. `values` is `{ kind,
|
|
763
|
+
* note, cycle, beat, track }` and, for a pitch gesture, `semitones` (a whole number from -127 to 127 other than 0), for a
|
|
764
|
+
* velocity gesture `direction` ('softer' or 'louder'). The note and the track are cleaned of hidden characters and cut at the
|
|
765
|
+
* context limit (this is the user's own request, so it keeps the name Claude needs to find the track); anything else wrong
|
|
766
|
+
* throws.
|
|
767
|
+
*/
|
|
768
|
+
export function timelineEditPrefill(values) {
|
|
769
|
+
const from = (field) => (values === null || typeof values !== 'object' ? undefined : values[field]);
|
|
770
|
+
const kind = from('kind');
|
|
771
|
+
if (!NOTE_GESTURE_KINDS.includes(kind)) throw new Error(`Moontide: the Timeline request needs a kind of ${NOTE_GESTURE_KINDS.join(', ')}`);
|
|
772
|
+
const note = from('note');
|
|
773
|
+
if (!((typeof note === 'string' && note !== '') || (typeof note === 'number' && Number.isFinite(note)))) {
|
|
774
|
+
throw new Error('Moontide: the Timeline request needs the note as a non-empty string or a finite number');
|
|
775
|
+
}
|
|
776
|
+
const cycle = from('cycle');
|
|
777
|
+
if (!isNonNegativeInteger(cycle)) throw new Error('Moontide: the Timeline request needs a whole number cycle of at least 0');
|
|
778
|
+
const beat = from('beat');
|
|
779
|
+
if (!Number.isInteger(beat) || beat < 1 || beat > BEATS_PER_CYCLE) throw new Error(`Moontide: the Timeline request needs a whole number beat from 1 to ${BEATS_PER_CYCLE}`);
|
|
780
|
+
const track = from('track');
|
|
781
|
+
if (typeof track !== 'string' || track === '') throw new Error('Moontide: the Timeline request needs a non-empty track');
|
|
782
|
+
const filled = { note: cutText(cleanText(String(note))), cycle: String(cycle), beat: String(beat), track: cutText(cleanText(track)) };
|
|
783
|
+
let template;
|
|
784
|
+
if (kind === 'pitch') {
|
|
785
|
+
const semitones = from('semitones');
|
|
786
|
+
checkedGesture({ kind, semitones });
|
|
787
|
+
template = semitones > 0 ? ASK_COPY.raiseRequest : ASK_COPY.lowerRequest;
|
|
788
|
+
filled.amount = amountWords(Math.abs(semitones));
|
|
789
|
+
} else if (kind === 'velocity') {
|
|
790
|
+
const direction = checkedGesture({ kind, direction: from('direction') }).direction;
|
|
791
|
+
template = direction === 'softer' ? ASK_COPY.softerRequest : ASK_COPY.louderRequest;
|
|
792
|
+
} else {
|
|
793
|
+
template = ASK_COPY.restRequest;
|
|
794
|
+
}
|
|
795
|
+
const text = template.replace(/\{(\w+)\}/g, (_, name) => filled[name]);
|
|
796
|
+
if (text.length > MAX_ASK_TEXT_LENGTH) throw new Error('Moontide: the Timeline request is longer than the Ask box takes.');
|
|
797
|
+
return text;
|
|
798
|
+
}
|
|
799
|
+
|
|
542
800
|
// The cycle under the pointer on a Timeline lane lives in page/timeline.js next to momentAt, so a lane's
|
|
543
801
|
// context cycle and a moment's cycle share one rule; it stays importable from here.
|
|
544
802
|
export { cycleAt } from './timeline.js';
|
package/page/change-timing.js
CHANGED
|
@@ -76,6 +76,23 @@ export function bpmOf(cps) {
|
|
|
76
76
|
return Math.round(cps * 60 * BEATS_PER_CYCLE * 10) / 10;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* The timing of a change that must take over on one given line: a section launch writes a shift for that very line, so
|
|
81
|
+
* the takeover has to take the same one. `at` is 'section' (the end of the section that plays), 'bar' or 'phrase', and
|
|
82
|
+
* `atCycle` is the line, a finite cycle number. Returns a frozen { at, lineLength, atCycle }: `lineLength` (1 for a
|
|
83
|
+
* section end and a bar, the phrase length for a phrase) is the line the takeover falls back to when the pinned one has
|
|
84
|
+
* come too close. CHANGE_TIMINGS is not extended: it is the closed set of the stored setting and of the server's schema.
|
|
85
|
+
*/
|
|
86
|
+
export function pinnedTiming(at, atCycle) {
|
|
87
|
+
if (at !== 'section' && at !== 'bar' && at !== 'phrase') {
|
|
88
|
+
throw new Error(`Moontide: a pinned change timing is 'section', 'bar' or 'phrase', got '${String(at)}'.`);
|
|
89
|
+
}
|
|
90
|
+
if (typeof atCycle !== 'number' || !Number.isFinite(atCycle)) {
|
|
91
|
+
throw new Error(`Moontide: a pinned change timing needs a finite line, got ${String(atCycle)}.`);
|
|
92
|
+
}
|
|
93
|
+
return Object.freeze({ at, lineLength: at === 'phrase' ? DEFAULT_PHRASE_CYCLES : 1, atCycle });
|
|
94
|
+
}
|
|
95
|
+
|
|
79
96
|
/**
|
|
80
97
|
* The cycle a timed change takes over on: the first multiple of `lineLength` strictly after the scheduler
|
|
81
98
|
* frontier `frontier` (the cycle the scheduler has already queried up to), moved one line further while
|
|
@@ -111,6 +128,7 @@ export const CHANGE_COPY = Object.freeze({
|
|
|
111
128
|
countdownOneBeat: 'in 1 beat',
|
|
112
129
|
countdownTitleBar: 'A code change is waiting. It takes over on the next bar.',
|
|
113
130
|
countdownTitlePhrase: 'A code change is waiting. It takes over on the next phrase.',
|
|
131
|
+
countdownTitleSection: 'A code change is waiting. It takes over at the end of the section.',
|
|
114
132
|
// The Edits timing group of the Settings card: three buttons. The visible word of each, and its accessible name.
|
|
115
133
|
timingNow: 'now',
|
|
116
134
|
timingBar: 'next bar',
|
|
@@ -121,6 +139,7 @@ export const CHANGE_COPY = Object.freeze({
|
|
|
121
139
|
timingTitle: "When your edits (Ctrl+Enter) take over: now, on the next bar or on the next phrase. Claude's changes choose their own timing.",
|
|
122
140
|
queuedBar: 'Change waiting: it takes over on the next bar.',
|
|
123
141
|
queuedPhrase: 'Change waiting: it takes over on the next phrase.',
|
|
142
|
+
queuedSection: 'Change waiting: it takes over at the end of the section.',
|
|
124
143
|
canceled: 'Change canceled.',
|
|
125
144
|
settingNow: 'Your edits now take over: now.',
|
|
126
145
|
settingBar: 'Your edits now take over: on the next bar.',
|
|
@@ -167,11 +186,12 @@ export function countdownParts(beatsLeft) {
|
|
|
167
186
|
return { lead, rest: CHANGE_COPY.countdownOneBeat };
|
|
168
187
|
}
|
|
169
188
|
|
|
170
|
-
/** The hover title of the countdown for a change waiting on 'bar' or '
|
|
189
|
+
/** The hover title of the countdown for a change waiting on 'bar', 'phrase' or (a section launch) 'section'. */
|
|
171
190
|
export function countdownTitle(at) {
|
|
172
191
|
if (at === 'bar') return CHANGE_COPY.countdownTitleBar;
|
|
173
192
|
if (at === 'phrase') return CHANGE_COPY.countdownTitlePhrase;
|
|
174
|
-
|
|
193
|
+
if (at === 'section') return CHANGE_COPY.countdownTitleSection;
|
|
194
|
+
throw new Error(`Moontide: a waiting change has the timing 'bar', 'phrase' or 'section', got '${String(at)}'.`);
|
|
175
195
|
}
|
|
176
196
|
|
|
177
197
|
/**
|
|
@@ -188,11 +208,12 @@ export function timingOptions() {
|
|
|
188
208
|
return CHANGE_TIMINGS.map((value) => Object.freeze({ value, label: copy[value][0], name: copy[value][1] }));
|
|
189
209
|
}
|
|
190
210
|
|
|
191
|
-
/** The spoken line for a change that just started to wait, for 'bar' or '
|
|
211
|
+
/** The spoken line for a change that just started to wait, for 'bar', 'phrase' or 'section'. */
|
|
192
212
|
export function queuedLine(at) {
|
|
193
213
|
if (at === 'bar') return CHANGE_COPY.queuedBar;
|
|
194
214
|
if (at === 'phrase') return CHANGE_COPY.queuedPhrase;
|
|
195
|
-
|
|
215
|
+
if (at === 'section') return CHANGE_COPY.queuedSection;
|
|
216
|
+
throw new Error(`Moontide: a waiting change has the timing 'bar', 'phrase' or 'section', got '${String(at)}'.`);
|
|
196
217
|
}
|
|
197
218
|
|
|
198
219
|
/** The spoken line after a press of the setting button, for the value it now has. */
|
package/page/harmony.js
CHANGED
|
@@ -157,6 +157,19 @@ export function readHarmony(code, { parse }) {
|
|
|
157
157
|
return { state: 'none' };
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Reads one harmony text, as written in a section of a song or in a fixed variation: `{ kind: 'scale' | 'chords', scale }`
|
|
162
|
+
* with `scale` `{ rootPc, modeId }` only when the whole text is one scale token whose name is one of the nine scales (else
|
|
163
|
+
* null), or null when the text is neither a scale nor chords (nothing left to read, too long, a plain word). The same
|
|
164
|
+
* reading as a top-level declaration: readHarmony calls the same checks.
|
|
165
|
+
*/
|
|
166
|
+
export function readHarmonyText(text) {
|
|
167
|
+
if (text.length > MAX_HARMONY_TEXT) return null;
|
|
168
|
+
const found = classify(text);
|
|
169
|
+
if (found === null || found === 'empty') return null;
|
|
170
|
+
return { kind: found, scale: found === 'scale' ? scaleOf(text.trim()) : null };
|
|
171
|
+
}
|
|
172
|
+
|
|
160
173
|
function unreadable(reason, source = null) {
|
|
161
174
|
return { state: 'unreadable', reason, source };
|
|
162
175
|
}
|