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.
Files changed (57) hide show
  1. package/README.md +94 -6
  2. package/dist/ask.js +76 -15
  3. package/dist/mcp.js +198 -26
  4. package/dist/protocol.js +130 -2
  5. package/dist/reference.js +22 -0
  6. package/dist/request-status.js +221 -0
  7. package/dist/session.js +256 -7
  8. package/package.json +1 -1
  9. package/page/ask-view.js +64 -57
  10. package/page/ask.js +363 -188
  11. package/page/bar-model.js +43 -2
  12. package/page/change-timing.js +60 -4
  13. package/page/harmony.js +13 -0
  14. package/page/index.html +7 -4
  15. package/page/jam-analysis.js +68 -23
  16. package/page/jam-model.js +196 -31
  17. package/page/jam-resolve.js +271 -0
  18. package/page/jam-view.js +182 -10
  19. package/page/lights.js +300 -0
  20. package/page/main.js +2171 -188
  21. package/page/midi-clock.js +23 -4
  22. package/page/midi-out.js +17 -2
  23. package/page/mixer-groups.js +114 -0
  24. package/page/mixer-model.js +172 -29
  25. package/page/mixer-rewrite.js +30 -1
  26. package/page/mixer-view.js +289 -14
  27. package/page/note-edit.js +267 -0
  28. package/page/note-locate.js +346 -0
  29. package/page/note-panel.js +402 -0
  30. package/page/note-tag.js +141 -0
  31. package/page/page-rewrite.js +184 -0
  32. package/page/query-check.js +21 -2
  33. package/page/request-status-view.js +419 -0
  34. package/page/request-status.js +686 -0
  35. package/page/song-edit.js +230 -0
  36. package/page/song-fit.js +250 -0
  37. package/page/song-grid-model.js +506 -0
  38. package/page/song-grid-view.js +710 -0
  39. package/page/song-prefs.js +100 -0
  40. package/page/structure-check.js +216 -0
  41. package/page/structure-view.js +38 -0
  42. package/page/structure.js +761 -0
  43. package/page/style.css +1218 -65
  44. package/page/takeover.js +20 -5
  45. package/page/tempo-glide.js +330 -0
  46. package/page/tempo-guard.js +62 -10
  47. package/page/timeline-edit-view.js +1956 -0
  48. package/page/timeline-gesture.js +279 -0
  49. package/page/timeline.js +197 -3
  50. package/page/top-bar-view.js +18 -13
  51. package/page/tracks.js +37 -8
  52. package/page/ui-state.js +10 -5
  53. package/page/views.js +39 -9
  54. package/reference/functions.md +1 -0
  55. package/reference/midi.md +2 -0
  56. package/reference/structure.md +131 -0
  57. 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
- * 'Scale notes: D, E, F. Root D. Avoid: C.' ('Avoid: none.' when nothing is to be avoided), or 'No
177
- * clear scale.' The marks repeat in every octave, so the label names pitch classes. With `declared` (the
178
- * code declares a harmony) an unclear scale reads 'The heard scale is not clear.' instead, and a reading with no
179
- * pitched note reads the nothing-pitched sentence.
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 !== 'clear') return `${lead} ${declared ? JAM_COPY.heardUnclearLabel : 'No clear scale.'}`;
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
- return `${lead} Scale notes: ${scale.notes.map((note) => note.name).join(', ')}. Root ${scale.spell[scale.root]}. Avoid: ${avoid}.`;
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 a scale that cannot be named: 'No clear scale: the notes fit C major and
250
- * A minor equally.' (two names joined by 'and', three as 'A, B and C', more as the first three then
251
- * 'and {N} more'; 'almost equally' for a near tie), or the no-fit sentence. With `declared` (the code
252
- * declares a harmony) the 'No clear scale: ' prefix is dropped and the sentence starts 'The notes fit'.
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
- if (scale.status !== 'ambiguous') {
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
- const sentence = ambiguitySentence(scale, { declared: declaredMode });
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: JAM_COPY.warningsOff },
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
+ }