moontide-mcp 2.0.2 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -6
- package/dist/ask.js +76 -15
- package/dist/mcp.js +198 -26
- package/dist/protocol.js +130 -2
- package/dist/reference.js +22 -0
- package/dist/request-status.js +221 -0
- package/dist/session.js +256 -7
- package/package.json +1 -1
- package/page/ask-view.js +64 -57
- package/page/ask.js +363 -188
- package/page/bar-model.js +43 -2
- package/page/change-timing.js +60 -4
- package/page/harmony.js +13 -0
- package/page/index.html +7 -4
- package/page/jam-analysis.js +68 -23
- package/page/jam-model.js +196 -31
- package/page/jam-resolve.js +271 -0
- package/page/jam-view.js +182 -10
- package/page/lights.js +300 -0
- package/page/main.js +2171 -188
- package/page/midi-clock.js +23 -4
- package/page/midi-out.js +17 -2
- package/page/mixer-groups.js +114 -0
- package/page/mixer-model.js +172 -29
- package/page/mixer-rewrite.js +30 -1
- package/page/mixer-view.js +289 -14
- package/page/note-edit.js +267 -0
- package/page/note-locate.js +346 -0
- package/page/note-panel.js +402 -0
- package/page/note-tag.js +141 -0
- package/page/page-rewrite.js +184 -0
- package/page/query-check.js +21 -2
- package/page/request-status-view.js +419 -0
- package/page/request-status.js +686 -0
- package/page/song-edit.js +230 -0
- package/page/song-fit.js +250 -0
- package/page/song-grid-model.js +506 -0
- package/page/song-grid-view.js +710 -0
- package/page/song-prefs.js +100 -0
- package/page/structure-check.js +216 -0
- package/page/structure-view.js +38 -0
- package/page/structure.js +761 -0
- package/page/style.css +1218 -65
- package/page/takeover.js +20 -5
- package/page/tempo-glide.js +330 -0
- package/page/tempo-guard.js +62 -10
- package/page/timeline-edit-view.js +1956 -0
- package/page/timeline-gesture.js +279 -0
- package/page/timeline.js +197 -3
- package/page/top-bar-view.js +18 -13
- package/page/tracks.js +37 -8
- package/page/ui-state.js +10 -5
- package/page/views.js +39 -9
- package/reference/functions.md +1 -0
- package/reference/midi.md +2 -0
- package/reference/structure.md +131 -0
- package/reference/transitions.md +13 -1
package/page/jam-model.js
CHANGED
|
@@ -44,6 +44,8 @@ export const JAM_COPY = {
|
|
|
44
44
|
heardLabel: 'Heard:',
|
|
45
45
|
noFitDeclared: 'The notes fit none of the nine scales.',
|
|
46
46
|
heardUnclearLabel: 'The heard scale is not clear.',
|
|
47
|
+
// The muted words after the Declared value of a piece with sections: the section whose harmony the line shows.
|
|
48
|
+
declaredSection: 'section {section}',
|
|
47
49
|
declaredReasons: {
|
|
48
50
|
'not-const': 'Not readable: harmony is declared with let or var; the page reads only const.',
|
|
49
51
|
computed: 'Not readable: harmony is computed ({source}); the page reads only written text.',
|
|
@@ -61,8 +63,109 @@ export const JAM_COPY = {
|
|
|
61
63
|
outside: { word: 'Outside', title: 'Chord note outside the scale, fine to play' },
|
|
62
64
|
chord: { word: 'Chord tone', title: 'Note of the chord sounding now' },
|
|
63
65
|
},
|
|
66
|
+
// The line that says why a scale is in use when several fit: a bold lead and a sentence that always names the scale and
|
|
67
|
+
// the reason. {scale} is the page's own name for it ('D Dorian'), {root} its spelled root ('D').
|
|
68
|
+
reason: {
|
|
69
|
+
proposedLead: 'Proposed:',
|
|
70
|
+
chosenLead: 'Your pick:',
|
|
71
|
+
section: ' {scale}, declared by the section.',
|
|
72
|
+
code: ' {scale}, declared by the code.',
|
|
73
|
+
'bass-chords': ' {scale}, the bass and chords lean on {root}.',
|
|
74
|
+
'most-heard': ' {scale}, {root} is the note heard most.',
|
|
75
|
+
nothing: ' {scale}, nothing in the notes says which scale is the key.',
|
|
76
|
+
chosen: ' {scale}. It holds while these same scales fit.',
|
|
77
|
+
},
|
|
78
|
+
// The scales that fit, listed under the scale in use: a caption, then one item per scale (buttons while one can be chosen,
|
|
79
|
+
// plain text when none can) and, past the eighth, the count of the rest.
|
|
80
|
+
candidates: {
|
|
81
|
+
pickCaption: 'Scales that fit, click to switch:',
|
|
82
|
+
listCaption: 'Scales that fit:',
|
|
83
|
+
more: 'and {n} more',
|
|
84
|
+
},
|
|
85
|
+
// The sentences an instrument's accessible name adds to its lead: where the scale in use comes from, the chord notes
|
|
86
|
+
// outside it, the notes of the chord sounding now, and why nothing is coloured when no scale can be chosen. The role words
|
|
87
|
+
// are the ones a piano key's hover title says.
|
|
88
|
+
instrument: {
|
|
89
|
+
proposedScale: 'Proposed scale: {scale}.',
|
|
90
|
+
chosenScale: 'Chosen scale: {scale}.',
|
|
91
|
+
outside: 'Outside: {notes}.',
|
|
92
|
+
chordTones: 'Chord tones: {notes}.',
|
|
93
|
+
cannotChoose: 'Several scales fit with different notes, so none is coloured.',
|
|
94
|
+
},
|
|
95
|
+
roleWords: { scale: 'scale', root: 'root', avoid: 'avoid', outside: 'outside', chord: 'chord tone' },
|
|
96
|
+
// Several scales fit with different notes and none can be used: the sentence for each cause, and the line under it.
|
|
97
|
+
cannotChoose: {
|
|
98
|
+
'nothing-declared': 'Several scales fit, with different notes, and nothing is declared.',
|
|
99
|
+
'declared-not-among': 'Several scales fit, with different notes, and none of them is the declared scale.',
|
|
100
|
+
sub: 'Warnings are off until a scale is declared or the notes settle on one.',
|
|
101
|
+
},
|
|
64
102
|
};
|
|
65
103
|
|
|
104
|
+
/**
|
|
105
|
+
* A sentence of the copy with each `{name}` replaced by its value in `values`. A placeholder with no value throws, so a
|
|
106
|
+
* sentence is never shown with a hole in it. The values are put in as plain text.
|
|
107
|
+
*/
|
|
108
|
+
export function fillJam(template, values) {
|
|
109
|
+
return template.replace(/\{(\w+)\}/g, (match, name) => {
|
|
110
|
+
if (!Object.hasOwn(values, name)) throw new Error(`Moontide: the Jam copy has no value for {${name}} in '${template}'`);
|
|
111
|
+
return String(values[name]);
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The line that says why a scale is in use, `{ lead, rest }`, or null for a scale that was simply heard. A proposed scale
|
|
117
|
+
* reads 'Proposed:' and a sentence naming the scale and what decided (its reason code: 'declared' is the section's or the
|
|
118
|
+
* code's declaration, `section` being the section's words or null; 'bass-chords', 'most-heard' or 'nothing'); a scale the
|
|
119
|
+
* user picked reads 'Your pick:'. The word alone is never returned. An unknown source or reason throws.
|
|
120
|
+
*/
|
|
121
|
+
export function reasonLine(scale, { section = null } = {}) {
|
|
122
|
+
const source = scale.source ?? 'heard';
|
|
123
|
+
if (source === 'heard') return null;
|
|
124
|
+
const values = { scale: scale.name, root: scale.spell[scale.root] };
|
|
125
|
+
if (source === 'chosen') return { lead: JAM_COPY.reason.chosenLead, rest: fillJam(JAM_COPY.reason.chosen, values) };
|
|
126
|
+
if (source !== 'proposed') throw new Error(`Moontide: no reason line for a scale whose source is '${String(source)}'`);
|
|
127
|
+
const key = scale.reason === 'declared' ? (section === null || section === undefined ? 'code' : 'section') : scale.reason;
|
|
128
|
+
if (!['section', 'code', 'bass-chords', 'most-heard', 'nothing'].includes(key)) {
|
|
129
|
+
throw new Error(`Moontide: no reason line for the reason '${String(scale.reason)}'`);
|
|
130
|
+
}
|
|
131
|
+
return { lead: JAM_COPY.reason.proposedLead, rest: fillJam(JAM_COPY.reason[key], values) };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** How many of the scales that fit are drawn as items; the rest are only counted. */
|
|
135
|
+
export const MAX_CANDIDATES_SHOWN = 8;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The list of the scales that fit, or null when no list is due. A scale that was heard (clear, no fit, none) has none. A
|
|
139
|
+
* proposed or chosen scale gives `mode: 'pick'` (buttons: the user can switch) and an unresolved one `mode: 'list'` (plain
|
|
140
|
+
* text: nothing can be chosen). `items` are the first MAX_CANDIDATES_SHOWN candidates in the order the resolver gave them
|
|
141
|
+
* (the order never changes with a pick), each `{ key, name, pressed }`; `pressed` is true only for the scale in use in pick
|
|
142
|
+
* mode, which takes the last place drawn when it comes later in the order. `more` says how many candidates are not drawn,
|
|
143
|
+
* or is null. `setKey` names the set of candidates a choice is made for.
|
|
144
|
+
*/
|
|
145
|
+
function candidatesOf(scale) {
|
|
146
|
+
const source = scale.source ?? 'heard';
|
|
147
|
+
if (source === 'heard') return null;
|
|
148
|
+
const pick = source === 'proposed' || source === 'chosen';
|
|
149
|
+
const all = scale.candidates;
|
|
150
|
+
if (!Array.isArray(all) || typeof scale.setKey !== 'string') {
|
|
151
|
+
throw new Error(`Moontide: the Jam model needs the candidates and the set key of a scale whose source is '${source}'`);
|
|
152
|
+
}
|
|
153
|
+
let shown = all.slice(0, MAX_CANDIDATES_SHOWN);
|
|
154
|
+
if (pick) {
|
|
155
|
+
const inUse = all.findIndex((candidate) => candidate.key === scale.key);
|
|
156
|
+
if (inUse === -1) throw new Error(`Moontide: the scale in use '${String(scale.key)}' is not among the candidates`);
|
|
157
|
+
if (inUse >= MAX_CANDIDATES_SHOWN) shown = [...all.slice(0, MAX_CANDIDATES_SHOWN - 1), all[inUse]];
|
|
158
|
+
}
|
|
159
|
+
const rest = all.length - shown.length;
|
|
160
|
+
return {
|
|
161
|
+
mode: pick ? 'pick' : 'list',
|
|
162
|
+
caption: pick ? JAM_COPY.candidates.pickCaption : JAM_COPY.candidates.listCaption,
|
|
163
|
+
setKey: scale.setKey,
|
|
164
|
+
items: shown.map((candidate) => ({ key: candidate.key, name: candidate.name, pressed: pick && candidate.key === scale.key })),
|
|
165
|
+
more: rest > 0 ? fillJam(JAM_COPY.candidates.more, { n: rest }) : null,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
66
169
|
// --- the piano ---
|
|
67
170
|
|
|
68
171
|
// The 14 white keys of two octaves and the pitch classes that have a black key to their right.
|
|
@@ -170,25 +273,56 @@ export function legendEntries(chordTones) {
|
|
|
170
273
|
return roles.map((role) => ({ role, ...JAM_COPY.legend[role] }));
|
|
171
274
|
}
|
|
172
275
|
|
|
276
|
+
/**
|
|
277
|
+
* The hover title of one piano key: the spelled note and its role word ('D, root', 'C#, outside', 'B, scale'), with ', root'
|
|
278
|
+
* added for an avoided root and ', chord tone' for a chord tone. An 'off' key that is no chord tone has no title (''), and an
|
|
279
|
+
* 'off' chord tone reads 'F#, chord tone'. `names` are the position's spelled names, `chordPcs` the optional 12 chord-tone
|
|
280
|
+
* flags, `rootPc` the scale's root or null. The title only repeats what the marks and the instrument's name already say.
|
|
281
|
+
*/
|
|
282
|
+
export function keyTitle(key, roles, names, chordPcs = null, rootPc = null) {
|
|
283
|
+
const role = roles[key.pc];
|
|
284
|
+
const chord = chordPcs !== null && chordPcs[key.pc] === true;
|
|
285
|
+
if (role === 'off' && !chord) return '';
|
|
286
|
+
const words = [names[key.pc]];
|
|
287
|
+
if (role !== 'off') words.push(JAM_COPY.roleWords[role]);
|
|
288
|
+
if (role === 'avoid' && key.pc === rootPc) words.push(JAM_COPY.roleWords.root);
|
|
289
|
+
if (chord) words.push(JAM_COPY.roleWords.chord);
|
|
290
|
+
return words.join(', ');
|
|
291
|
+
}
|
|
292
|
+
|
|
173
293
|
/**
|
|
174
294
|
* The accessible name of an instrument ('piano' or 'guitar'): 'Piano, two octaves from C3.' or
|
|
175
|
-
* 'Guitar fretboard, standard tuning, 12 frets.' and then, with a clear scale,
|
|
176
|
-
* '
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
295
|
+
* 'Guitar fretboard, standard tuning, 12 frets.' and then, with a clear scale, in this order and each sentence only when it
|
|
296
|
+
* applies: 'Proposed scale: C major.' or 'Chosen scale: C major.' (none for a scale that was simply heard),
|
|
297
|
+
* 'Scale notes: D, E, F. Root D. Avoid: C.' ('Avoid: none.' when nothing is to be avoided), 'Outside: C#.' while the current
|
|
298
|
+
* warning has chord notes outside the scale. A scale that cannot be chosen reads that several scales fit with different notes,
|
|
299
|
+
* so none is coloured; no clear scale reads 'No clear scale.' The marks repeat in every octave, so the label names pitch
|
|
300
|
+
* classes. With `declared` (the code declares a harmony) an unclear scale reads 'The heard scale is not clear.' instead, and a
|
|
301
|
+
* reading with no pitched note reads the nothing-pitched sentence. With `chordTones` on and a chord sounding, 'Chord tones:
|
|
302
|
+
* D, F, A, C.' (from the bass up) comes last, in every state the overlay draws chord marks. A label with none of these facts is
|
|
303
|
+
* the label of a heard scale, unchanged.
|
|
180
304
|
*/
|
|
181
|
-
export function instrumentLabel(kind, position, { declared = false } = {}) {
|
|
305
|
+
export function instrumentLabel(kind, position, { declared = false, chordTones = false } = {}) {
|
|
182
306
|
let lead;
|
|
183
307
|
if (kind === 'piano') lead = 'Piano, two octaves from C3.';
|
|
184
308
|
else if (kind === 'guitar') lead = 'Guitar fretboard, standard tuning, 12 frets.';
|
|
185
309
|
else throw new Error(`Moontide: no accessible name for an instrument called '${String(kind)}'`);
|
|
310
|
+
const chordPart = chordTones && position.chord !== null ? ` ${fillJam(JAM_COPY.instrument.chordTones, { notes: position.chord.notes.join(', ') })}` : '';
|
|
186
311
|
const scale = position.scale;
|
|
187
312
|
// With a declaration the label never says 'No clear scale'; a reading with no pitched note has nothing heard to call unclear.
|
|
188
|
-
if (declared && scale.status === 'none') return `${lead} ${JAM_COPY.noPitched}`;
|
|
189
|
-
if (scale.status
|
|
313
|
+
if (declared && scale.status === 'none') return `${lead} ${JAM_COPY.noPitched}${chordPart}`;
|
|
314
|
+
if (scale.status === 'ambiguous' && scale.source === 'unresolved') return `${lead} ${JAM_COPY.instrument.cannotChoose}${chordPart}`;
|
|
315
|
+
if (scale.status !== 'clear') return `${lead} ${declared ? JAM_COPY.heardUnclearLabel : 'No clear scale.'}${chordPart}`;
|
|
190
316
|
const avoid = position.warning === null || position.warning.avoid.length === 0 ? 'none' : position.warning.avoid.join(', ');
|
|
191
|
-
|
|
317
|
+
const source =
|
|
318
|
+
scale.source === 'proposed' ? ` ${fillJam(JAM_COPY.instrument.proposedScale, { scale: scale.name })}`
|
|
319
|
+
: scale.source === 'chosen' ? ` ${fillJam(JAM_COPY.instrument.chosenScale, { scale: scale.name })}`
|
|
320
|
+
: '';
|
|
321
|
+
const outside =
|
|
322
|
+
position.warning !== null && position.warning.outside.length > 0
|
|
323
|
+
? ` ${fillJam(JAM_COPY.instrument.outside, { notes: position.warning.outside.join(', ') })}`
|
|
324
|
+
: '';
|
|
325
|
+
return `${lead}${source} Scale notes: ${scale.notes.map((note) => note.name).join(', ')}. Root ${scale.spell[scale.root]}. Avoid: ${avoid}.${outside}${chordPart}`;
|
|
192
326
|
}
|
|
193
327
|
|
|
194
328
|
// The 12 chord-tone flags: the pitch classes of the chord sounding now while the overlay is on.
|
|
@@ -200,15 +334,15 @@ function chordFlags(position, chordTones) {
|
|
|
200
334
|
|
|
201
335
|
// The instrument part of a ready model: roles and names by pitch class, the root, the chord
|
|
202
336
|
// tones and the labels.
|
|
203
|
-
function instrumentOf(position, chordPcs, declared) {
|
|
337
|
+
function instrumentOf(position, chordPcs, declared, chordTones) {
|
|
204
338
|
const clear = position.scale.status === 'clear';
|
|
205
339
|
return {
|
|
206
340
|
roles: rolesByPc(position),
|
|
207
341
|
names: pcNames(position),
|
|
208
342
|
rootPc: clear ? position.scale.root : null,
|
|
209
343
|
chordPcs,
|
|
210
|
-
pianoLabel: instrumentLabel('piano', position, { declared }),
|
|
211
|
-
guitarLabel: instrumentLabel('guitar', position, { declared }),
|
|
344
|
+
pianoLabel: instrumentLabel('piano', position, { declared, chordTones }),
|
|
345
|
+
guitarLabel: instrumentLabel('guitar', position, { declared, chordTones }),
|
|
212
346
|
};
|
|
213
347
|
}
|
|
214
348
|
|
|
@@ -246,23 +380,14 @@ export function messageModel(text, sub = null) {
|
|
|
246
380
|
}
|
|
247
381
|
|
|
248
382
|
/**
|
|
249
|
-
* The plain sentence for
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
383
|
+
* The plain sentence for notes that fit none of the nine scales: 'No clear scale: the notes fit none of the nine scales.',
|
|
384
|
+
* or, with `declared` (the code declares a harmony), 'The notes fit none of the nine scales.' without the verdict prefix.
|
|
385
|
+
* Several scales that fit are never said here: the resolver (page/jam-resolve.js) uses one of them, or says why it cannot.
|
|
386
|
+
* Any other reading throws.
|
|
253
387
|
*/
|
|
254
388
|
export function ambiguitySentence(scale, { declared = false } = {}) {
|
|
255
389
|
if (scale.status === 'no-fit') return declared ? JAM_COPY.noFitDeclared : JAM_COPY.noFit;
|
|
256
|
-
|
|
257
|
-
throw new Error(`Moontide: no ambiguity sentence for a scale reading with status '${String(scale.status)}'`);
|
|
258
|
-
}
|
|
259
|
-
const names = scale.candidates.map((candidate) => candidate.name);
|
|
260
|
-
let list;
|
|
261
|
-
if (names.length <= 1) list = names.join('');
|
|
262
|
-
else if (names.length === 2) list = `${names[0]} and ${names[1]}`;
|
|
263
|
-
else if (names.length === 3) list = `${names[0]}, ${names[1]} and ${names[2]}`;
|
|
264
|
-
else list = `${names[0]}, ${names[1]}, ${names[2]} and ${names.length - 3} more`;
|
|
265
|
-
return `${declared ? 'The' : 'No clear scale: the'} notes fit ${list} ${scale.kind === 'near' ? 'almost equally' : 'equally'}.`;
|
|
390
|
+
throw new Error(`Moontide: no ambiguity sentence for a scale reading with status '${String(scale.status)}'`);
|
|
266
391
|
}
|
|
267
392
|
|
|
268
393
|
/** The longest declared text shown on the Declared line, in UTF-16 units; the title keeps the whole text. */
|
|
@@ -473,19 +598,37 @@ function statusOf(position, paused) {
|
|
|
473
598
|
* (even an unreadable declaration) no sentence says 'No clear scale', and a reading with no pitched note is a ready
|
|
474
599
|
* model of kind 'none' instead of the message model, so the Declared line stays on screen. The heard scale, the
|
|
475
600
|
* degrees, the status, the rows and the instrument roles are the ones of the same notes without a declaration.
|
|
601
|
+
* The position comes from a resolved reading (page/jam-resolve.js resolveReading): when several scales fit, a proposed
|
|
602
|
+
* scale is a clear scale carrying its `source` ('proposed') and `reason`, and the model draws it like a heard one (kind 'clear',
|
|
603
|
+
* seven degrees, status, rows, roles, warnings on) and adds `reason` (`{ lead, rest }` of reasonLine, null for a heard scale) and
|
|
604
|
+
* `scaleSource` ('heard', 'proposed' or 'chosen'; 'heard' for none, no-fit and cannot-choose); scales with different notes that
|
|
605
|
+
* cannot be chosen give kind 'ambiguous', the sentence for their `why` and the sub-line that says warnings are off. An
|
|
606
|
+
* ambiguous scale with no `why` was never resolved and throws. A scale that was not heard (proposed, chosen or cannot be chosen)
|
|
607
|
+
* also gives `candidates`: `{ mode, caption, setKey, items, more }`, the list of the scales that fit (see candidatesOf; 'pick'
|
|
608
|
+
* mode for a scale in use, 'list' mode when none can be chosen), and null for a heard scale. A chosen scale reads 'Your pick:'.
|
|
609
|
+
* A piece with sections shows the harmony of the section that plays: `declaredOverride` (the same shapes as a reading's
|
|
610
|
+
* `declared`) replaces the one in the reading, and `section` is that section's name as the page shows it, quoted; every
|
|
611
|
+
* ready model carries `section`, the words 'section {name}' for a piece with sections and null otherwise.
|
|
476
612
|
*/
|
|
477
|
-
export function buildJamModel({ position, chordTones = false }) {
|
|
478
|
-
const declaredState = position.reading.declared ?? { state: 'none' };
|
|
613
|
+
export function buildJamModel({ position, chordTones = false, declaredOverride, section = null }) {
|
|
614
|
+
const declaredState = declaredOverride ?? position.reading.declared ?? { state: 'none' };
|
|
615
|
+
const sectionText = section === null || section === undefined ? null : JAM_COPY.declaredSection.replace('{section}', () => section);
|
|
479
616
|
const declaredMode = declaredState.state !== 'none';
|
|
480
617
|
const declared = declaredLine(declaredState);
|
|
481
618
|
if (position.reading.status === 'none' && !declaredMode) {
|
|
482
619
|
return messageModel(JAM_COPY.noPitched, JAM_COPY.noPitchedSub);
|
|
483
620
|
}
|
|
484
621
|
const scale = position.scale;
|
|
622
|
+
const scaleSource = scale.status === 'clear' ? (scale.source ?? 'heard') : 'heard';
|
|
623
|
+
if (!['heard', 'proposed', 'chosen'].includes(scaleSource)) {
|
|
624
|
+
throw new Error(`Moontide: the Jam model cannot draw a scale whose source is '${String(scaleSource)}'`);
|
|
625
|
+
}
|
|
626
|
+
const reason = scale.status === 'clear' ? reasonLine(scale, { section: sectionText }) : null;
|
|
627
|
+
const candidates = candidatesOf(scale);
|
|
485
628
|
const paused = position.playing === false;
|
|
486
629
|
const rows = rowsFor(position, paused);
|
|
487
630
|
const chordPcs = chordFlags(position, chordTones);
|
|
488
|
-
const instrument = instrumentOf(position, chordPcs, declaredMode);
|
|
631
|
+
const instrument = instrumentOf(position, chordPcs, declaredMode, chordTones);
|
|
489
632
|
const chordLine = chordTones ? chordLineText(position.chord) : null;
|
|
490
633
|
// The scale a note context names: only a clear scale, never a guess.
|
|
491
634
|
const noteScaleName = position.scale.status === 'clear' ? position.scale.name : null;
|
|
@@ -507,6 +650,10 @@ export function buildJamModel({ position, chordTones = false }) {
|
|
|
507
650
|
chordLine,
|
|
508
651
|
noteScaleName,
|
|
509
652
|
declared,
|
|
653
|
+
section: sectionText,
|
|
654
|
+
reason,
|
|
655
|
+
scaleSource,
|
|
656
|
+
candidates,
|
|
510
657
|
};
|
|
511
658
|
}
|
|
512
659
|
if (scale.status === 'clear') {
|
|
@@ -536,13 +683,27 @@ export function buildJamModel({ position, chordTones = false }) {
|
|
|
536
683
|
chordLine,
|
|
537
684
|
noteScaleName,
|
|
538
685
|
declared,
|
|
686
|
+
section: sectionText,
|
|
687
|
+
reason,
|
|
688
|
+
scaleSource,
|
|
689
|
+
candidates,
|
|
539
690
|
};
|
|
540
691
|
}
|
|
541
692
|
if (scale.status === 'ambiguous' || scale.status === 'no-fit') {
|
|
542
|
-
|
|
693
|
+
let sentence;
|
|
694
|
+
let sub = JAM_COPY.warningsOff;
|
|
695
|
+
if (scale.status === 'no-fit') {
|
|
696
|
+
sentence = ambiguitySentence(scale, { declared: declaredMode });
|
|
697
|
+
} else {
|
|
698
|
+
if (scale.why !== 'nothing-declared' && scale.why !== 'declared-not-among') {
|
|
699
|
+
throw new Error(`Moontide: the Jam model needs a resolved scale: several scales fit and the reading says no reason it cannot choose (why: '${String(scale.why)}')`);
|
|
700
|
+
}
|
|
701
|
+
sentence = JAM_COPY.cannotChoose[scale.why];
|
|
702
|
+
sub = JAM_COPY.cannotChoose.sub;
|
|
703
|
+
}
|
|
543
704
|
return {
|
|
544
705
|
state: 'ready',
|
|
545
|
-
scale: { kind: 'ambiguous', text: sentence, title: sentence, sub
|
|
706
|
+
scale: { kind: 'ambiguous', text: sentence, title: sentence, sub },
|
|
546
707
|
degrees: [],
|
|
547
708
|
status: OFF_STATUS,
|
|
548
709
|
paused,
|
|
@@ -551,6 +712,10 @@ export function buildJamModel({ position, chordTones = false }) {
|
|
|
551
712
|
chordLine,
|
|
552
713
|
noteScaleName,
|
|
553
714
|
declared,
|
|
715
|
+
section: sectionText,
|
|
716
|
+
reason,
|
|
717
|
+
scaleSource,
|
|
718
|
+
candidates,
|
|
554
719
|
};
|
|
555
720
|
}
|
|
556
721
|
throw new Error(`Moontide: the Jam model cannot draw a scale reading with status '${String(scale.status)}'`);
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// Pure module for Moontide's browser page: decides which scale the Jam view uses when the heard notes fit several of
|
|
2
|
+
// the nine. Several scales can hold the very same notes (C major, A minor and G Mixolydian are one set of seven notes),
|
|
3
|
+
// and the view should help instead of switching its warnings off: it uses one of them, says why, and every path that
|
|
4
|
+
// needs a clear scale (the piano and the guitar, the degree chips, the status, the coming-up rows, the notes and warnings
|
|
5
|
+
// a right-click asks about) reads that one scale, resolved here. The warnings of every candidate were computed with the
|
|
6
|
+
// reading (page/jam-analysis.js buildReading, `proposals`), so choosing among them is a lookup and never a new reading.
|
|
7
|
+
//
|
|
8
|
+
// The scale in use follows one fixed order, the first rule that applies:
|
|
9
|
+
// 1. the user's pick, held for this exact set of candidates (source 'chosen');
|
|
10
|
+
// 2. the declared scale of the playing section, or of the piece without sections, when it is among the candidates;
|
|
11
|
+
// 3. the kept proposal, the one last chosen from the evidence for this same set, so near-equal readings do not make the
|
|
12
|
+
// proposal flicker (it applies to scales that share their notes only, and never over a declaration);
|
|
13
|
+
// 4. the strongest evidence of the notes.
|
|
14
|
+
// The kept proposal steadies only choices made from the evidence: a section that declares another scale among the same
|
|
15
|
+
// candidates moves the proposal, because the declaration is what the code states. Picks and the kept proposal are held
|
|
16
|
+
// per set of candidates in a small page memory (createScaleMemory) that holds no reading and no DOM; both are capped, and
|
|
17
|
+
// gone when the page reloads. No DOM access at import time, so this module can be imported directly by node:test and by
|
|
18
|
+
// page/main.js without a build step.
|
|
19
|
+
|
|
20
|
+
import { MODES, scalePcs, clearScale } from './jam-analysis.js';
|
|
21
|
+
|
|
22
|
+
/** Two evidence values closer than this are the same evidence. */
|
|
23
|
+
const EVIDENCE_TOLERANCE = 1e-9;
|
|
24
|
+
|
|
25
|
+
/** The reasons a proposal can give, in the words of the reason codes: what decided between the candidates. */
|
|
26
|
+
export const REASONS = Object.freeze(['declared', 'bass-chords', 'most-heard', 'nothing']);
|
|
27
|
+
|
|
28
|
+
/** Why a scale cannot be chosen: no declaration to break the tie, or a declared scale that is none of the candidates. */
|
|
29
|
+
export const UNRESOLVED_REASONS = Object.freeze(['nothing-declared', 'declared-not-among']);
|
|
30
|
+
|
|
31
|
+
function candidateKeyOf(candidate) {
|
|
32
|
+
if (candidate === null || typeof candidate !== 'object' || typeof candidate.key !== 'string') {
|
|
33
|
+
throw new Error(`Moontide: a candidate scale needs its key, got ${JSON.stringify(candidate)}`);
|
|
34
|
+
}
|
|
35
|
+
return candidate.key;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The key of a set of candidates: their keys (page/jam-analysis.js candidateKey) sorted as strings and joined by
|
|
40
|
+
* commas, so the same candidates in another order share one key.
|
|
41
|
+
*/
|
|
42
|
+
export function candidateSetKey(candidates) {
|
|
43
|
+
return candidates.map(candidateKeyOf).sort().join(',');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** How many sets of candidates the page remembers a pick for, and a kept proposal for; the oldest is dropped first. */
|
|
47
|
+
export const MAX_REMEMBERED_SETS = 32;
|
|
48
|
+
|
|
49
|
+
const pcsKey = (candidate) => [...scalePcs(candidate.root, candidate.mode)].sort((a, b) => a - b).join(',');
|
|
50
|
+
|
|
51
|
+
/** True when every candidate holds the same seven pitch classes as the first one. */
|
|
52
|
+
export function sameNotes(candidates) {
|
|
53
|
+
if (candidates.length === 0) throw new Error('Moontide: no candidate scales to compare');
|
|
54
|
+
const first = pcsKey(candidates[0]);
|
|
55
|
+
return candidates.every((candidate) => pcsKey(candidate) === first);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Which evidence put `chosen` ahead of the other `candidates`: 'bass-chords' when the bass and the chord roots lean on
|
|
60
|
+
* its root more than on every other candidate's, else 'most-heard' when the weight of its root among the notes exceeds
|
|
61
|
+
* every other's, else 'nothing' (the evidence does not tell them apart). The evidence is the one the detection scored
|
|
62
|
+
* (`lean` and `heard` of each candidate); nothing new is added.
|
|
63
|
+
*/
|
|
64
|
+
export function evidenceReason(chosen, candidates) {
|
|
65
|
+
const others = candidates.filter((candidate) => candidateKeyOf(candidate) !== candidateKeyOf(chosen));
|
|
66
|
+
if (others.length === 0) return 'nothing';
|
|
67
|
+
if (others.every((other) => chosen.lean - other.lean > EVIDENCE_TOLERANCE)) return 'bass-chords';
|
|
68
|
+
if (others.every((other) => chosen.heard - other.heard > EVIDENCE_TOLERANCE)) return 'most-heard';
|
|
69
|
+
return 'nothing';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function declaredScaleOf(declared) {
|
|
73
|
+
if (declared === null || typeof declared !== 'object') {
|
|
74
|
+
throw new Error(`Moontide: resolving a scale needs the declared harmony, got ${String(declared)}`);
|
|
75
|
+
}
|
|
76
|
+
if (declared.state !== 'declared' || declared.scale === null || declared.scale === undefined) return null;
|
|
77
|
+
const { rootPc, modeId } = declared.scale;
|
|
78
|
+
if (!Number.isInteger(rootPc) || rootPc < 0 || rootPc > 11) {
|
|
79
|
+
throw new Error(`Moontide: a declared scale needs a root pitch class from 0 to 11, got ${String(rootPc)}`);
|
|
80
|
+
}
|
|
81
|
+
if (!MODES.some((mode) => mode.id === modeId)) {
|
|
82
|
+
throw new Error(`Moontide: no scale called '${String(modeId)}' among the nine the Jam view knows`);
|
|
83
|
+
}
|
|
84
|
+
return declared.scale;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function inputsOf(inputs) {
|
|
88
|
+
if (inputs === null || typeof inputs !== 'object' || !('declared' in inputs)) {
|
|
89
|
+
throw new Error('Moontide: resolving a scale needs its inputs, { declared } (the declared harmony of the playing section).');
|
|
90
|
+
}
|
|
91
|
+
return inputs;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const nonEmptyString = (value) => typeof value === 'string' && value !== '';
|
|
95
|
+
|
|
96
|
+
function needString(value, what) {
|
|
97
|
+
if (!nonEmptyString(value)) throw new Error(`Moontide: the scale memory needs ${what} as a non-empty string, got ${JSON.stringify(value)}`);
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The page's memory of the user's choices, `{ pick, pickFor, keep, keptFor, size }`; it holds keys only, never a reading and
|
|
103
|
+
* never a DOM node, and it lives in a variable of the page, so a reload forgets it.
|
|
104
|
+
* - `pick(setKey, key)` remembers that the user chose the candidate `key` while the set of candidates was `setKey`;
|
|
105
|
+
* `pickFor(setKey)` gives it back, or null. Picks are kept in recency order and at most `cap` sets are remembered (the
|
|
106
|
+
* oldest dropped first; picking an old set again makes it the newest), so a long session cannot grow the memory.
|
|
107
|
+
* - `keep(setKey, key)` records a proposal in view that came from the evidence for the set of candidates `setKey`;
|
|
108
|
+
* `keptFor(setKey)` gives its key, or null. Kept proposals are held per set like picks, in recency order and at most
|
|
109
|
+
* `cap` sets (the oldest dropped first), so going back to an earlier ambiguous span finds the proposal it had. A kept
|
|
110
|
+
* proposal is not a pick and does not count in `size()`.
|
|
111
|
+
* A missing or empty argument throws a Moontide error.
|
|
112
|
+
*/
|
|
113
|
+
export function createScaleMemory({ cap = MAX_REMEMBERED_SETS } = {}) {
|
|
114
|
+
if (!Number.isInteger(cap) || cap < 1) throw new Error(`Moontide: the scale memory needs a cap of at least 1, got ${String(cap)}`);
|
|
115
|
+
const picks = new Map();
|
|
116
|
+
const kept = new Map();
|
|
117
|
+
return {
|
|
118
|
+
pick(setKey, key) {
|
|
119
|
+
needString(setKey, 'a set key');
|
|
120
|
+
needString(key, 'a scale key');
|
|
121
|
+
picks.delete(setKey);
|
|
122
|
+
picks.set(setKey, key);
|
|
123
|
+
while (picks.size > cap) picks.delete(picks.keys().next().value);
|
|
124
|
+
},
|
|
125
|
+
pickFor(setKey) {
|
|
126
|
+
needString(setKey, 'a set key');
|
|
127
|
+
return picks.get(setKey) ?? null;
|
|
128
|
+
},
|
|
129
|
+
keep(setKey, key) {
|
|
130
|
+
needString(setKey, 'a set key');
|
|
131
|
+
needString(key, 'a scale key');
|
|
132
|
+
kept.delete(setKey);
|
|
133
|
+
kept.set(setKey, key);
|
|
134
|
+
while (kept.size > cap) kept.delete(kept.keys().next().value);
|
|
135
|
+
},
|
|
136
|
+
keptFor(setKey) {
|
|
137
|
+
needString(setKey, 'a set key');
|
|
138
|
+
return kept.get(setKey) ?? null;
|
|
139
|
+
},
|
|
140
|
+
size: () => picks.size,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const HEARD = Object.freeze({ source: 'heard', key: null, reason: null, setKey: null, sameNotes: null, why: null });
|
|
145
|
+
|
|
146
|
+
// A key held in memory must be one of the candidates it was looked up for (by the set key); anything else is a caller bug.
|
|
147
|
+
function candidateOf(candidates, key, what) {
|
|
148
|
+
const hit = candidates.find((candidate) => candidate.key === key);
|
|
149
|
+
if (hit === undefined) throw new Error(`Moontide: the ${what} '${key}' is not one of the scales that fit now`);
|
|
150
|
+
return hit;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Which scale a detected `scale` stands for, as `{ source, key, reason, setKey, sameNotes, why }`. A clear, no-fit or
|
|
155
|
+
* none scale is the heard one (source 'heard', nothing else set). For an ambiguous scale `inputs` is `{ declared, pick,
|
|
156
|
+
* kept }`: `declared` is the harmony the code declares for what plays now (page/harmony.js: a declared scale is
|
|
157
|
+
* `{ rootPc, modeId }` or null), `pick` the key the user chose for this set of candidates (or null) and `kept` the key of
|
|
158
|
+
* the proposal in view that came from the evidence for this set (or null). The first rule that applies wins:
|
|
159
|
+
* - source 'chosen' (reason null): the user's `pick`, also when the candidates have different notes;
|
|
160
|
+
* - source 'proposed', reason 'declared': the declared scale is one of the candidates, matched on its root and mode (never
|
|
161
|
+
* on its name text), also when the candidates have different notes;
|
|
162
|
+
* - source 'proposed': the candidates share their notes and `kept` is one of them, with the reason evidenceReason gives
|
|
163
|
+
* for it; a kept key is never used for candidates with different notes;
|
|
164
|
+
* - source 'proposed': the candidates share their notes, so the first one (the detection puts the best evidence first) is
|
|
165
|
+
* used, with the reason evidenceReason gives;
|
|
166
|
+
* - source 'unresolved': the candidates have different notes and nothing above names one of them; `why` is
|
|
167
|
+
* 'declared-not-among' when a declared scale exists and is none of the candidates, else 'nothing-declared' (no
|
|
168
|
+
* declaration, a chords declaration, an unreadable one).
|
|
169
|
+
* `key` is the scale in use, `setKey` the key of the set of candidates (a memory of a choice hangs on it). A `pick` or
|
|
170
|
+
* `kept` that is not one of the candidates throws, as anything else a Moontide error can name.
|
|
171
|
+
*/
|
|
172
|
+
export function resolveScale(scale, inputs) {
|
|
173
|
+
if (scale === null || typeof scale !== 'object') throw new Error(`Moontide: cannot resolve ${String(scale)} as a scale`);
|
|
174
|
+
if (scale.status === 'clear' || scale.status === 'no-fit' || scale.status === 'none') return HEARD;
|
|
175
|
+
if (scale.status !== 'ambiguous') throw new Error(`Moontide: cannot resolve a scale reading with status '${String(scale.status)}'`);
|
|
176
|
+
const { declared, pick = null, kept = null } = inputsOf(inputs);
|
|
177
|
+
const declaredScale = declaredScaleOf(declared);
|
|
178
|
+
const { candidates } = scale;
|
|
179
|
+
const setKey = candidateSetKey(candidates);
|
|
180
|
+
const same = sameNotes(candidates);
|
|
181
|
+
if (pick !== null) candidateOf(candidates, pick, 'picked scale');
|
|
182
|
+
const keptCandidate = kept === null ? null : candidateOf(candidates, kept, 'kept scale');
|
|
183
|
+
if (pick !== null) return { source: 'chosen', key: pick, reason: null, setKey, sameNotes: same, why: null };
|
|
184
|
+
if (declaredScale !== null) {
|
|
185
|
+
const hit = candidates.find((candidate) => candidate.root === declaredScale.rootPc && candidate.mode.id === declaredScale.modeId);
|
|
186
|
+
if (hit !== undefined) return { source: 'proposed', key: hit.key, reason: 'declared', setKey, sameNotes: same, why: null };
|
|
187
|
+
}
|
|
188
|
+
if (same) {
|
|
189
|
+
const used = keptCandidate ?? candidates[0];
|
|
190
|
+
return { source: 'proposed', key: used.key, reason: evidenceReason(used, candidates), setKey, sameNotes: true, why: null };
|
|
191
|
+
}
|
|
192
|
+
return { source: 'unresolved', key: null, reason: null, setKey, sameNotes: false, why: declaredScale === null ? 'nothing-declared' : 'declared-not-among' };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// The names of a list of candidates, in the order the detection gave them (the best evidence first).
|
|
196
|
+
const namesOf = (candidates) => candidates.map((candidate) => ({ key: candidate.key, name: candidate.name }));
|
|
197
|
+
|
|
198
|
+
// A scale of the reading as the resolution says: the scale in use (proposed or chosen) as a clear scale with the fields the
|
|
199
|
+
// model reads, the ambiguous scale with why it cannot be chosen, or the heard scale itself.
|
|
200
|
+
function resolvedScale(scale, resolution) {
|
|
201
|
+
if (resolution.source === 'heard') return scale;
|
|
202
|
+
const { setKey, sameNotes: same } = resolution;
|
|
203
|
+
const candidates = namesOf(scale.candidates);
|
|
204
|
+
if (resolution.source === 'unresolved') {
|
|
205
|
+
return { ...scale, source: 'unresolved', why: resolution.why, setKey, sameNotes: same, candidates };
|
|
206
|
+
}
|
|
207
|
+
const hit = scale.candidates.find((candidate) => candidate.key === resolution.key);
|
|
208
|
+
return { ...clearScale(hit.root, hit.mode, hit.coverage), source: resolution.source, reason: resolution.reason, key: resolution.key, setKey, sameNotes: same, candidates };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function memoryOf(inputs) {
|
|
212
|
+
const { memory } = inputs;
|
|
213
|
+
if (memory === null || typeof memory !== 'object' || typeof memory.pickFor !== 'function' || typeof memory.keptFor !== 'function') {
|
|
214
|
+
throw new Error('Moontide: resolving a reading needs the scale memory, { declared, memory } (page/jam-resolve.js createScaleMemory).');
|
|
215
|
+
}
|
|
216
|
+
return memory;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// What a span's scale resolves to, with the pick and the kept proposal the memory holds for its set of candidates.
|
|
220
|
+
function resolveWithMemory(scale, { declared }, memory) {
|
|
221
|
+
if (scale.status !== 'ambiguous') return resolveScale(scale, { declared });
|
|
222
|
+
const setKey = candidateSetKey(scale.candidates);
|
|
223
|
+
return resolveScale(scale, { declared, pick: memory.pickFor(setKey), kept: memory.keptFor(setKey) });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The reading with every scale resolved, as a new object (the reading given is never changed): what every path that needs
|
|
228
|
+
* a clear scale reads, `readingAt` first. `inputs` is `{ declared, memory }`, the declared harmony in effect at the playing
|
|
229
|
+
* position and the page memory (createScaleMemory) that holds the user's picks and the kept proposal. Each span whose scale
|
|
230
|
+
* is ambiguous resolves on its own scale with the pick and the kept proposal the memory holds for its set of candidates,
|
|
231
|
+
* so the whole window follows what plays now.
|
|
232
|
+
* - a proposed or chosen span gets its scale as the clear scale of the chosen candidate, plus `source`, `reason`, `key`,
|
|
233
|
+
* `setKey`, `sameNotes` and `candidates` (`[{ key, name }]` in evidence order), and the warnings that candidate's proposal
|
|
234
|
+
* computed;
|
|
235
|
+
* - an unresolved span keeps its ambiguous scale, plus `source: 'unresolved'`, `why`, `setKey`, `sameNotes` and
|
|
236
|
+
* `candidates`, and has no warning;
|
|
237
|
+
* - a heard span is returned unchanged, and `whole` is resolved the same way (it carries no warnings);
|
|
238
|
+
* - `warnings` is rebuilt in begin order from the warnings of every span, each a copy with a fresh `index`, its
|
|
239
|
+
* `spanIndex` and its `scaleName`. A resolved span's own `warnings` are the same copies; a heard span keeps its own list
|
|
240
|
+
* as it was, and `warnings` of the reading is the list that holds the current indexes.
|
|
241
|
+
* A reading of a status other than 'ready' or 'none', or inputs without a memory, throws.
|
|
242
|
+
*/
|
|
243
|
+
export function resolveReading(reading, inputs) {
|
|
244
|
+
if (reading === null || typeof reading !== 'object' || (reading.status !== 'ready' && reading.status !== 'none')) {
|
|
245
|
+
throw new Error(`Moontide: cannot resolve a reading with status '${String(reading?.status)}'`);
|
|
246
|
+
}
|
|
247
|
+
inputsOf(inputs);
|
|
248
|
+
const memory = memoryOf(inputs);
|
|
249
|
+
const whole = resolvedScale(reading.whole, resolveWithMemory(reading.whole, inputs, memory));
|
|
250
|
+
const warnings = [];
|
|
251
|
+
const spans = reading.spans.map((span, spanIndex) => {
|
|
252
|
+
const resolution = resolveWithMemory(span.scale, inputs, memory);
|
|
253
|
+
const inUse = resolution.source === 'proposed' || resolution.source === 'chosen';
|
|
254
|
+
let resolved = span;
|
|
255
|
+
if (resolution.source !== 'heard') {
|
|
256
|
+
const scale = resolvedScale(span.scale, resolution);
|
|
257
|
+
const proposal = inUse ? span.proposals.find((entry) => entry.key === resolution.key) : undefined;
|
|
258
|
+
if (inUse && proposal === undefined) {
|
|
259
|
+
throw new Error(`Moontide: the reading has no proposal for the scale '${resolution.key}'`);
|
|
260
|
+
}
|
|
261
|
+
resolved = { ...span, scale, warnings: proposal === undefined ? [] : proposal.warnings };
|
|
262
|
+
}
|
|
263
|
+
const own = resolved.warnings.map((warning) => ({ ...warning, spanIndex, scaleName: resolved.scale.name }));
|
|
264
|
+
for (const warning of own) {
|
|
265
|
+
warning.index = warnings.length;
|
|
266
|
+
warnings.push(warning);
|
|
267
|
+
}
|
|
268
|
+
return inUse ? { ...resolved, warnings: own } : resolved;
|
|
269
|
+
});
|
|
270
|
+
return { ...reading, whole, spans, warnings };
|
|
271
|
+
}
|