moontide-mcp 2.1.0 → 2.4.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 +54 -331
  2. package/dist/ask.js +59 -12
  3. package/dist/catalog.js +118 -9
  4. package/dist/instruments.js +83 -9
  5. package/dist/mcp.js +146 -20
  6. package/dist/midi-state.js +43 -1
  7. package/dist/protocol.js +123 -5
  8. package/dist/reference.js +12 -0
  9. package/dist/request-status.js +221 -0
  10. package/dist/session.js +203 -7
  11. package/dist/vocabulary.js +420 -0
  12. package/package.json +3 -2
  13. package/page/ask-view.js +118 -50
  14. package/page/ask.js +139 -169
  15. package/page/bar-model.js +44 -2
  16. package/page/change-timing.js +35 -0
  17. package/page/index.html +6 -2
  18. package/page/instruments-model.js +50 -3
  19. package/page/instruments-view.js +14 -2
  20. package/page/jam-analysis.js +68 -23
  21. package/page/jam-model.js +185 -29
  22. package/page/jam-resolve.js +271 -0
  23. package/page/jam-view.js +165 -10
  24. package/page/lights.js +29 -7
  25. package/page/main.js +807 -66
  26. package/page/midi-clock.js +22 -3
  27. package/page/midi-in.js +407 -0
  28. package/page/midi-out.js +557 -48
  29. package/page/midi-plan.js +42 -6
  30. package/page/midi-rig.js +286 -13
  31. package/page/midi-settings.js +507 -0
  32. package/page/mixer-view.js +7 -1
  33. package/page/note-panel.js +29 -8
  34. package/page/note-tag.js +19 -4
  35. package/page/page-rewrite.js +1 -1
  36. package/page/played-buffer.js +208 -0
  37. package/page/query-check.js +21 -2
  38. package/page/request-status-view.js +419 -0
  39. package/page/request-status.js +686 -0
  40. package/page/scratch-gesture.js +239 -0
  41. package/page/scratch-listen.js +260 -0
  42. package/page/scratch-model.js +1696 -0
  43. package/page/scratch-view.js +2176 -0
  44. package/page/song-grid-model.js +15 -0
  45. package/page/song-grid-view.js +1 -1
  46. package/page/structure.js +2 -1
  47. package/page/style.css +1243 -43
  48. package/page/tempo-glide.js +330 -0
  49. package/page/tempo-guard.js +62 -10
  50. package/page/timeline-edit-view.js +1 -1
  51. package/page/timeline.js +2 -1
  52. package/page/top-bar-view.js +18 -13
  53. package/page/views.js +20 -7
  54. package/reference/midi.md +2 -0
  55. package/reference/motif.md +83 -0
  56. package/reference/settings.md +161 -0
  57. package/reference/transitions.md +13 -1
@@ -5,7 +5,7 @@
5
5
  // CONNECTION_LABELS (read at use time, so the popover, the top bar and this view never disagree). The DOM
6
6
  // side is page/instruments-view.js; page/main.js feeds the inputs.
7
7
 
8
- import { ACCESS_STATES, MIDI_COPY, fill, listNames, readMidiCalls } from './midi-plan.js';
8
+ import { ACCESS_STATES, MIDI_COPY, fill, listNames, readMidiCalls, readMidiSetup } from './midi-plan.js';
9
9
  import { BUILD_MISMATCH_COPY, CONNECTION_LABELS } from './ui-state.js';
10
10
  import { midiTag, readMidiTracks } from './midi-controls.js';
11
11
  import { drumMapFor, soundKey } from './midi-rig.js';
@@ -59,6 +59,15 @@ export const INSTRUMENTS_COPY = Object.freeze({
59
59
  labelControlsHint: 'default values',
60
60
  labelCatalog: 'Catalog',
61
61
  labelInUse: 'In use',
62
+ // The Synth settings row of an instrument whose model describes settings: what the playing code writes, never stored.
63
+ labelSynthSettings: 'Synth settings',
64
+ synthTone: 'tone, ch {channel}: {values}',
65
+ synthPart: 'part, ch {channel}: {values}',
66
+ synthScene: 'scene: {values}, reaches every part',
67
+ synthValue: '{name} {value}',
68
+ synthValuesJoin: ', ',
69
+ synthNone: 'none in the code',
70
+ synthStays: 'A setting taken out of the code is not sent again: the instrument keeps its value until the instrument itself changes it, and a tone setting until the instrument loads the tone again. Moontide stores no setting.',
62
71
  clockFollows: 'follows the clock',
63
72
  controlsNone: 'none',
64
73
  catalogOne: '1 sound',
@@ -303,8 +312,9 @@ function readUse(code, parse, rig) {
303
312
  if (typeof parse !== 'function') return { kind: 'unreadable' };
304
313
  const tracks = readMidiTracks(code, { parse, rig });
305
314
  const calls = readMidiCalls(code, { parse });
306
- if (!tracks.ok || !calls.ok) return { kind: 'unreadable' };
307
- return { kind: 'read', tracks: [...tracks.tracks.values()], calls: calls.calls };
315
+ const setup = readMidiSetup(code, { parse });
316
+ if (!tracks.ok || !calls.ok || !setup.ok) return { kind: 'unreadable' };
317
+ return { kind: 'read', tracks: [...tracks.tracks.values()], calls: calls.calls, setups: setup.calls };
308
318
  }
309
319
 
310
320
  /**
@@ -337,6 +347,40 @@ function inUseOf(instrument, use) {
337
347
  return { lead: instrument.enabled === false ? INSTRUMENTS_COPY.silentWhileOff : null, lines, muted: false };
338
348
  }
339
349
 
350
+ /**
351
+ * The Synth settings row of one instrument: null when its model describes no settings, else `{ lines: [{ text, muted }] }`
352
+ * read from the playing code. The lines are the tone and part settings per channel ascending (tone first), then the scene
353
+ * settings, taken from the calls whose port is exactly the instrument's port; the values stay in the order written and a
354
+ * later equal value merges. Without any, or without readable code, one muted line says so. The standing rule about a
355
+ * setting taken out of the code comes last. Nothing is stored: the row is the code.
356
+ */
357
+ function synthSettingsOf(instrument, use) {
358
+ if (instrument.settings === null || instrument.settings === undefined) return null;
359
+ const staying = { text: INSTRUMENTS_COPY.synthStays, muted: true };
360
+ if (use.kind === 'unreadable') return { lines: [{ text: INSTRUMENTS_COPY.unreadableCode, muted: true }, staying] };
361
+ if (use.kind === 'empty') return { lines: [{ text: MIDI_COPY.rowNotUsed, muted: true }, staying] };
362
+ const channels = new Map();
363
+ const scene = new Map();
364
+ for (const call of use.setups) {
365
+ if (call.port !== instrument.port) continue;
366
+ if (!channels.has(call.channel)) channels.set(call.channel, { tone: new Map(), part: new Map() });
367
+ const level = channels.get(call.channel);
368
+ for (const [name, value] of call.settings.tone) level.tone.set(name, value);
369
+ for (const [name, value] of call.settings.part) level.part.set(name, value);
370
+ for (const [name, value] of call.settings.scene) scene.set(name, value);
371
+ }
372
+ const valuesOf = (map) => Array.from(map, ([name, value]) => fill(INSTRUMENTS_COPY.synthValue, { name, value: String(value) })).join(INSTRUMENTS_COPY.synthValuesJoin);
373
+ const lines = [];
374
+ for (const channel of [...channels.keys()].sort((a, b) => a - b)) {
375
+ const { tone, part } = channels.get(channel);
376
+ if (tone.size > 0) lines.push({ text: fill(INSTRUMENTS_COPY.synthTone, { channel: String(channel), values: valuesOf(tone) }), muted: false });
377
+ if (part.size > 0) lines.push({ text: fill(INSTRUMENTS_COPY.synthPart, { channel: String(channel), values: valuesOf(part) }), muted: false });
378
+ }
379
+ if (scene.size > 0) lines.push({ text: fill(INSTRUMENTS_COPY.synthScene, { values: valuesOf(scene) }), muted: false });
380
+ if (lines.length === 0) lines.push({ text: INSTRUMENTS_COPY.synthNone, muted: true });
381
+ return { lines: [...lines, staying] };
382
+ }
383
+
340
384
  /** True when the instrument declares at least one channel with the rhythm role (the Drum kit row and Drum map line only exist then). */
341
385
  function hasRhythmChannel(instrument) {
342
386
  return instrument.channels.some((entry) => entry.role === 'rhythm');
@@ -558,6 +602,7 @@ function cardOf(instrument, liveInstruments, use) {
558
602
  modelChannels: modelChannelsOf(instrument),
559
603
  usedChannels: usedChannelsOf(instrument, use),
560
604
  inUse: inUseOf(instrument, use),
605
+ synthSettings: synthSettingsOf(instrument, use),
561
606
  drumKit: drumKitOf(instrument, use),
562
607
  drumMaps: drumMapsOf(instrument, use),
563
608
  drumDeclared: drumDeclaredOf(instrument),
@@ -717,6 +762,8 @@ function declaredFacts(card) {
717
762
  drumDeclared: card.drumDeclared,
718
763
  settings: card.settings,
719
764
  modelChannels: card.modelChannels,
765
+ // Whether the model describes settings decides whether the Synth settings row exists; its lines are live.
766
+ hasSynthSettings: card.synthSettings !== null,
720
767
  });
721
768
  }
722
769
 
@@ -208,6 +208,11 @@ export function createInstrumentsView({ document, onInstrumentSwitch, onInstrume
208
208
  return lines;
209
209
  }
210
210
 
211
+ /** The children of the Synth settings value: one line element per model line, the muted ones with the muted class. */
212
+ function synthSettingsLines(synthSettings) {
213
+ return synthSettings.lines.map((line) => element('span', line.muted ? 'instrument-inuse-line instrument-muted' : 'instrument-inuse-line', line.text));
214
+ }
215
+
211
216
  /** The lines of the Drum kit row: one span per line, muted when the model says so, with its hover title when it has one. */
212
217
  function drumKitLines(drumKit) {
213
218
  return drumKit.lines.map((line) => {
@@ -262,7 +267,7 @@ export function createInstrumentsView({ document, onInstrumentSwitch, onInstrume
262
267
 
263
268
  /** One card and the nodes a live redraw updates. The generated ids come from the index, never from user text. */
264
269
  function cardNode(card, index) {
265
- const refs = { id: card.id, index, item: null, dot: null, words: null, inUse: null, drumKit: null, maps: null, opens: [], enabled: card.enabled, switchNodes: null, head: null, errorNode: null, editButton: null, editor: null };
270
+ const refs = { id: card.id, index, item: null, dot: null, words: null, inUse: null, synthSettings: null, drumKit: null, maps: null, opens: [], enabled: card.enabled, switchNodes: null, head: null, errorNode: null, editButton: null, editor: null };
266
271
  const item = element('li', 'instrument-card');
267
272
  refs.item = item;
268
273
  item.tabIndex = 0;
@@ -300,6 +305,12 @@ export function createInstrumentsView({ document, onInstrumentSwitch, onInstrume
300
305
  inUse.append(...inUseLines(card.inUse));
301
306
  refs.inUse = inUse;
302
307
  rows.append(row(INSTRUMENTS_COPY.labelInUse, inUse));
308
+ if (card.synthSettings !== null) {
309
+ const synth = element('div', 'instrument-lines');
310
+ synth.append(...synthSettingsLines(card.synthSettings));
311
+ refs.synthSettings = synth;
312
+ rows.append(row(INSTRUMENTS_COPY.labelSynthSettings, synth));
313
+ }
303
314
  item.append(head, rows, editButtonNode(card, refs));
304
315
  drawError(refs);
305
316
  // An editor that was open (a draft kept in memory) comes back with the card.
@@ -766,7 +777,7 @@ export function createInstrumentsView({ document, onInstrumentSwitch, onInstrume
766
777
  instrumentsBody.scrollTop = scrollTop;
767
778
  }
768
779
 
769
- /** Updates the changed parts of each card in place: the dot, the state words, the Drum kit lines, the In use lines and the Drum map lines. */
780
+ /** Updates the changed parts of each card in place: the dot, the state words, the Drum kit lines, the In use lines, the Synth settings lines and the Drum map lines. */
770
781
  function updateCards(previous, model, skip = []) {
771
782
  model.cards.forEach((card, index) => {
772
783
  if (skip.includes(index)) return;
@@ -786,6 +797,7 @@ export function createInstrumentsView({ document, onInstrumentSwitch, onInstrume
786
797
  setText(refs.words, card.port.words);
787
798
  if (card.port.attention !== before.port.attention) refs.words.dataset.attention = String(card.port.attention);
788
799
  if (JSON.stringify(card.inUse) !== JSON.stringify(before.inUse)) refs.inUse.replaceChildren(...inUseLines(card.inUse));
800
+ if (card.synthSettings !== null && JSON.stringify(card.synthSettings) !== JSON.stringify(before.synthSettings)) refs.synthSettings.replaceChildren(...synthSettingsLines(card.synthSettings));
789
801
  if (card.drumKit !== null && JSON.stringify(card.drumKit) !== JSON.stringify(before.drumKit)) refs.drumKit.replaceChildren(...drumKitLines(card.drumKit));
790
802
  if (card.drumMaps !== null && JSON.stringify(card.drumMaps) !== JSON.stringify(before.drumMaps)) {
791
803
  // The drawn flags are read first and the focus noted, because the nodes are replaced: a map that stays keeps its state.
@@ -376,15 +376,38 @@ export function warningsFor(solidSegments, scale) {
376
376
 
377
377
  // --- scale detection ---
378
378
 
379
+ /** The key of a candidate scale: its root pitch class and its mode id, '9:aeolian' for A minor. */
380
+ export const candidateKey = (root, mode) => `${root}:${mode.id}`;
381
+
382
+ /**
383
+ * The clear shape of one scale: `{ status: 'clear', root, mode, name, spell, notes, coverage }`, notes being the
384
+ * seven `{ pc, name, degree }` in scale order from the root. detectScale returns it for a scale that stands alone,
385
+ * and every proposal of an ambiguous reading has it, so a scale in use reads the same wherever it came from.
386
+ */
387
+ export function clearScale(root, mode, coverage) {
388
+ const spell = spellTable(root, mode);
389
+ return {
390
+ status: 'clear',
391
+ root,
392
+ mode,
393
+ name: scaleName(root, mode),
394
+ spell,
395
+ notes: scalePcs(root, mode).map((pc, i) => ({ pc, name: spell[pc], degree: mode.degrees[i] })),
396
+ coverage,
397
+ };
398
+ }
399
+
379
400
  /**
380
401
  * Reads the scale of a set of pitched events (brief ones too). `harmonicSegments` (segments of
381
402
  * the half-cycle segmentation) add tonic evidence: bass notes of segments with at least two
382
403
  * notes and chord roots, weighted 2, 2 and 1 with all notes, renormalized over the components
383
404
  * that have evidence. Returns
384
- * - `{ status: 'clear', root, mode, name, spell, notes, coverage }`, notes being the seven
385
- * `{ pc, name, degree }` in scale order from the root;
386
- * - `{ status: 'ambiguous', kind: 'tie' | 'near', candidates }`, candidates `{ root, mode, name, coverage }`
387
- * in score order (a tie means the candidates cover the same weight, near that they do not);
405
+ * - `clearScale(root, mode, coverage)` for a scale that stands alone;
406
+ * - `{ status: 'ambiguous', kind: 'tie' | 'near', candidates }`, candidates `{ root, mode, name, coverage, key,
407
+ * score, lean, heard }` in score order, the best evidence first (a tie means the candidates cover the same weight,
408
+ * near that they do not). `score` is the coverage plus TONIC_BONUS times the tonic emphasis of the root, and
409
+ * that emphasis is `lean` (the part from bass notes and chord roots) plus `heard` (the part from the weight of the
410
+ * notes), so a reader can say which evidence put a candidate first;
388
411
  * - `{ status: 'no-fit' }` when no scale covers MIN_COVERAGE of the weight;
389
412
  * - `{ status: 'none' }` when no event has any duration.
390
413
  */
@@ -410,8 +433,14 @@ export function detectScale(events, harmonicSegments = []) {
410
433
  .filter((component) => component[2] > 0);
411
434
  const kSum = components.reduce((x, component) => x + component[1], 0);
412
435
  const tonic = new Array(12).fill(0);
436
+ const lean = new Array(12).fill(0);
437
+ const heard = new Array(12).fill(0);
413
438
  for (const [values, k, sum] of components) {
414
- for (let pc = 0; pc < 12; pc++) tonic[pc] += (k / kSum) * (values[pc] / sum);
439
+ for (let pc = 0; pc < 12; pc++) {
440
+ const part = (k / kSum) * (values[pc] / sum);
441
+ tonic[pc] += part;
442
+ (values === weight ? heard : lean)[pc] += part;
443
+ }
415
444
  }
416
445
  const candidates = [];
417
446
  for (const mode of MODES) {
@@ -419,30 +448,28 @@ export function detectScale(events, harmonicSegments = []) {
419
448
  let inside = 0;
420
449
  for (const pc of scalePcs(root, mode)) inside += weight[pc];
421
450
  const coverage = inside / total;
422
- candidates.push({ root, mode, coverage, score: coverage + TONIC_BONUS * tonic[root] });
451
+ candidates.push({ root, mode, coverage, score: coverage + TONIC_BONUS * tonic[root], lean: lean[root], heard: heard[root] });
423
452
  }
424
453
  }
425
454
  if (Math.max(...candidates.map((c) => c.coverage)) < MIN_COVERAGE) return { status: 'no-fit' };
426
455
  candidates.sort((a, b) => b.score - a.score || b.coverage - a.coverage || a.mode.parent - b.mode.parent);
427
456
  const top = candidates[0];
428
457
  const unresolved = candidates.filter((c) => top.score - c.score < CLEAR_GAP);
429
- if (unresolved.length === 1) {
430
- const spell = spellTable(top.root, top.mode);
431
- return {
432
- status: 'clear',
433
- root: top.root,
434
- mode: top.mode,
435
- name: scaleName(top.root, top.mode),
436
- spell,
437
- notes: scalePcs(top.root, top.mode).map((pc, i) => ({ pc, name: spell[pc], degree: top.mode.degrees[i] })),
438
- coverage: top.coverage,
439
- };
440
- }
458
+ if (unresolved.length === 1) return clearScale(top.root, top.mode, top.coverage);
441
459
  const tie = unresolved.every((c) => Math.abs(c.coverage - top.coverage) < 1e-9);
442
460
  return {
443
461
  status: 'ambiguous',
444
462
  kind: tie ? 'tie' : 'near',
445
- candidates: unresolved.map((c) => ({ root: c.root, mode: c.mode, name: scaleName(c.root, c.mode), coverage: c.coverage })),
463
+ candidates: unresolved.map((c) => ({
464
+ root: c.root,
465
+ mode: c.mode,
466
+ name: scaleName(c.root, c.mode),
467
+ coverage: c.coverage,
468
+ key: candidateKey(c.root, c.mode),
469
+ score: c.score,
470
+ lean: c.lean,
471
+ heard: c.heard,
472
+ })),
446
473
  };
447
474
  }
448
475
 
@@ -482,7 +509,11 @@ function scaleOfPool(pool) {
482
509
  * Warnings are computed per span, from the solid segmentation of the events that begin in the
483
510
  * span, with the span's own scale and spelling (none when that scale is not clear). `warnings`
484
511
  * lists every span's warnings in begin order, each also carrying its `index` in that list, its
485
- * `spanIndex` and its `scaleName`. Each span also carries `harmonic`, the half-cycle segmentation of
512
+ * `spanIndex` and its `scaleName`. A span whose scale is ambiguous also carries `proposals`, one
513
+ * `{ key, scale, warnings }` for each candidate in candidate order: the candidate as a clear scale and
514
+ * the warnings that scale would have, computed once from the same segmentation, so choosing among the
515
+ * candidates later (page/jam-resolve.js) is a lookup and never a new reading; the base `warnings` stay empty
516
+ * for such a span. Every other span has `proposals: []`. Each span also carries `harmonic`, the half-cycle segmentation of
486
517
  * its own events, from which the chord sounding now is read. With two or more arrangements `partOwner` names the chosen
487
518
  * one the way the Parts view does (the owner track's name among `tracks`, else `arrange() N`).
488
519
  * `partial` (default false) says the window is shorter than the look-ahead because the page after
@@ -541,9 +572,23 @@ export function buildReading({ events, windowStart, windowLength, arrangements =
541
572
  const warnings = [];
542
573
  spans.forEach((span, spanIndex) => {
543
574
  span.harmonic = segments(markSolid(spanEvents[spanIndex].map((e) => ({ ...e })), HARMONIC_MIN), span.begin, span.end);
544
- span.warnings =
545
- span.scale.status === 'clear'
546
- ? warningsFor(segments(markSolid(spanEvents[spanIndex].map((e) => ({ ...e })), BRIEF), span.begin, span.end), span.scale)
575
+ // One brief segmentation per span serves the span's own scale and, when it is ambiguous, every candidate.
576
+ const brief =
577
+ span.scale.status === 'clear' || span.scale.status === 'ambiguous'
578
+ ? segments(markSolid(spanEvents[spanIndex].map((e) => ({ ...e })), BRIEF), span.begin, span.end)
579
+ : null;
580
+ span.warnings = span.scale.status === 'clear' ? warningsFor(brief, span.scale) : [];
581
+ span.proposals =
582
+ span.scale.status === 'ambiguous'
583
+ ? span.scale.candidates.map((candidate) => {
584
+ const scale = clearScale(candidate.root, candidate.mode, candidate.coverage);
585
+ const proposed = warningsFor(brief, scale);
586
+ for (const warning of proposed) {
587
+ warning.spanIndex = spanIndex;
588
+ warning.scaleName = scale.name;
589
+ }
590
+ return { key: candidate.key, scale, warnings: proposed };
591
+ })
547
592
  : [];
548
593
  for (const warning of span.warnings) {
549
594
  warning.spanIndex = spanIndex;
package/page/jam-model.js CHANGED
@@ -63,8 +63,109 @@ export const JAM_COPY = {
63
63
  outside: { word: 'Outside', title: 'Chord note outside the scale, fine to play' },
64
64
  chord: { word: 'Chord tone', title: 'Note of the chord sounding now' },
65
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
+ },
66
102
  };
67
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
+
68
169
  // --- the piano ---
69
170
 
70
171
  // The 14 white keys of two octaves and the pitch classes that have a black key to their right.
@@ -172,25 +273,56 @@ export function legendEntries(chordTones) {
172
273
  return roles.map((role) => ({ role, ...JAM_COPY.legend[role] }));
173
274
  }
174
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
+
175
293
  /**
176
294
  * The accessible name of an instrument ('piano' or 'guitar'): 'Piano, two octaves from C3.' or
177
- * 'Guitar fretboard, standard tuning, 12 frets.' and then, with a clear scale,
178
- * 'Scale notes: D, E, F. Root D. Avoid: C.' ('Avoid: none.' when nothing is to be avoided), or 'No
179
- * clear scale.' The marks repeat in every octave, so the label names pitch classes. With `declared` (the
180
- * code declares a harmony) an unclear scale reads 'The heard scale is not clear.' instead, and a reading with no
181
- * 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.
182
304
  */
183
- export function instrumentLabel(kind, position, { declared = false } = {}) {
305
+ export function instrumentLabel(kind, position, { declared = false, chordTones = false } = {}) {
184
306
  let lead;
185
307
  if (kind === 'piano') lead = 'Piano, two octaves from C3.';
186
308
  else if (kind === 'guitar') lead = 'Guitar fretboard, standard tuning, 12 frets.';
187
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(', ') })}` : '';
188
311
  const scale = position.scale;
189
312
  // With a declaration the label never says 'No clear scale'; a reading with no pitched note has nothing heard to call unclear.
190
- if (declared && scale.status === 'none') return `${lead} ${JAM_COPY.noPitched}`;
191
- 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}`;
192
316
  const avoid = position.warning === null || position.warning.avoid.length === 0 ? 'none' : position.warning.avoid.join(', ');
193
- 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}`;
194
326
  }
195
327
 
196
328
  // The 12 chord-tone flags: the pitch classes of the chord sounding now while the overlay is on.
@@ -202,15 +334,15 @@ function chordFlags(position, chordTones) {
202
334
 
203
335
  // The instrument part of a ready model: roles and names by pitch class, the root, the chord
204
336
  // tones and the labels.
205
- function instrumentOf(position, chordPcs, declared) {
337
+ function instrumentOf(position, chordPcs, declared, chordTones) {
206
338
  const clear = position.scale.status === 'clear';
207
339
  return {
208
340
  roles: rolesByPc(position),
209
341
  names: pcNames(position),
210
342
  rootPc: clear ? position.scale.root : null,
211
343
  chordPcs,
212
- pianoLabel: instrumentLabel('piano', position, { declared }),
213
- guitarLabel: instrumentLabel('guitar', position, { declared }),
344
+ pianoLabel: instrumentLabel('piano', position, { declared, chordTones }),
345
+ guitarLabel: instrumentLabel('guitar', position, { declared, chordTones }),
214
346
  };
215
347
  }
216
348
 
@@ -248,23 +380,14 @@ export function messageModel(text, sub = null) {
248
380
  }
249
381
 
250
382
  /**
251
- * The plain sentence for a scale that cannot be named: 'No clear scale: the notes fit C major and
252
- * A minor equally.' (two names joined by 'and', three as 'A, B and C', more as the first three then
253
- * 'and {N} more'; 'almost equally' for a near tie), or the no-fit sentence. With `declared` (the code
254
- * 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.
255
387
  */
256
388
  export function ambiguitySentence(scale, { declared = false } = {}) {
257
389
  if (scale.status === 'no-fit') return declared ? JAM_COPY.noFitDeclared : JAM_COPY.noFit;
258
- if (scale.status !== 'ambiguous') {
259
- throw new Error(`Moontide: no ambiguity sentence for a scale reading with status '${String(scale.status)}'`);
260
- }
261
- const names = scale.candidates.map((candidate) => candidate.name);
262
- let list;
263
- if (names.length <= 1) list = names.join('');
264
- else if (names.length === 2) list = `${names[0]} and ${names[1]}`;
265
- else if (names.length === 3) list = `${names[0]}, ${names[1]} and ${names[2]}`;
266
- else list = `${names[0]}, ${names[1]}, ${names[2]} and ${names.length - 3} more`;
267
- 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)}'`);
268
391
  }
269
392
 
270
393
  /** The longest declared text shown on the Declared line, in UTF-16 units; the title keeps the whole text. */
@@ -475,6 +598,14 @@ function statusOf(position, paused) {
475
598
  * (even an unreadable declaration) no sentence says 'No clear scale', and a reading with no pitched note is a ready
476
599
  * model of kind 'none' instead of the message model, so the Declared line stays on screen. The heard scale, the
477
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:'.
478
609
  * A piece with sections shows the harmony of the section that plays: `declaredOverride` (the same shapes as a reading's
479
610
  * `declared`) replaces the one in the reading, and `section` is that section's name as the page shows it, quoted; every
480
611
  * ready model carries `section`, the words 'section {name}' for a piece with sections and null otherwise.
@@ -488,10 +619,16 @@ export function buildJamModel({ position, chordTones = false, declaredOverride,
488
619
  return messageModel(JAM_COPY.noPitched, JAM_COPY.noPitchedSub);
489
620
  }
490
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);
491
628
  const paused = position.playing === false;
492
629
  const rows = rowsFor(position, paused);
493
630
  const chordPcs = chordFlags(position, chordTones);
494
- const instrument = instrumentOf(position, chordPcs, declaredMode);
631
+ const instrument = instrumentOf(position, chordPcs, declaredMode, chordTones);
495
632
  const chordLine = chordTones ? chordLineText(position.chord) : null;
496
633
  // The scale a note context names: only a clear scale, never a guess.
497
634
  const noteScaleName = position.scale.status === 'clear' ? position.scale.name : null;
@@ -514,6 +651,9 @@ export function buildJamModel({ position, chordTones = false, declaredOverride,
514
651
  noteScaleName,
515
652
  declared,
516
653
  section: sectionText,
654
+ reason,
655
+ scaleSource,
656
+ candidates,
517
657
  };
518
658
  }
519
659
  if (scale.status === 'clear') {
@@ -544,13 +684,26 @@ export function buildJamModel({ position, chordTones = false, declaredOverride,
544
684
  noteScaleName,
545
685
  declared,
546
686
  section: sectionText,
687
+ reason,
688
+ scaleSource,
689
+ candidates,
547
690
  };
548
691
  }
549
692
  if (scale.status === 'ambiguous' || scale.status === 'no-fit') {
550
- 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
+ }
551
704
  return {
552
705
  state: 'ready',
553
- scale: { kind: 'ambiguous', text: sentence, title: sentence, sub: JAM_COPY.warningsOff },
706
+ scale: { kind: 'ambiguous', text: sentence, title: sentence, sub },
554
707
  degrees: [],
555
708
  status: OFF_STATUS,
556
709
  paused,
@@ -560,6 +713,9 @@ export function buildJamModel({ position, chordTones = false, declaredOverride,
560
713
  noteScaleName,
561
714
  declared,
562
715
  section: sectionText,
716
+ reason,
717
+ scaleSource,
718
+ candidates,
563
719
  };
564
720
  }
565
721
  throw new Error(`Moontide: the Jam model cannot draw a scale reading with status '${String(scale.status)}'`);