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.
- package/README.md +54 -331
- package/dist/ask.js +59 -12
- package/dist/catalog.js +118 -9
- package/dist/instruments.js +83 -9
- package/dist/mcp.js +146 -20
- package/dist/midi-state.js +43 -1
- package/dist/protocol.js +123 -5
- package/dist/reference.js +12 -0
- package/dist/request-status.js +221 -0
- package/dist/session.js +203 -7
- package/dist/vocabulary.js +420 -0
- package/package.json +3 -2
- package/page/ask-view.js +118 -50
- package/page/ask.js +139 -169
- package/page/bar-model.js +44 -2
- package/page/change-timing.js +35 -0
- package/page/index.html +6 -2
- package/page/instruments-model.js +50 -3
- package/page/instruments-view.js +14 -2
- package/page/jam-analysis.js +68 -23
- package/page/jam-model.js +185 -29
- package/page/jam-resolve.js +271 -0
- package/page/jam-view.js +165 -10
- package/page/lights.js +29 -7
- package/page/main.js +807 -66
- package/page/midi-clock.js +22 -3
- package/page/midi-in.js +407 -0
- package/page/midi-out.js +557 -48
- package/page/midi-plan.js +42 -6
- package/page/midi-rig.js +286 -13
- package/page/midi-settings.js +507 -0
- package/page/mixer-view.js +7 -1
- package/page/note-panel.js +29 -8
- package/page/note-tag.js +19 -4
- package/page/page-rewrite.js +1 -1
- package/page/played-buffer.js +208 -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/scratch-gesture.js +239 -0
- package/page/scratch-listen.js +260 -0
- package/page/scratch-model.js +1696 -0
- package/page/scratch-view.js +2176 -0
- package/page/song-grid-model.js +15 -0
- package/page/song-grid-view.js +1 -1
- package/page/structure.js +2 -1
- package/page/style.css +1243 -43
- package/page/tempo-glide.js +330 -0
- package/page/tempo-guard.js +62 -10
- package/page/timeline-edit-view.js +1 -1
- package/page/timeline.js +2 -1
- package/page/top-bar-view.js +18 -13
- package/page/views.js +20 -7
- package/reference/midi.md +2 -0
- package/reference/motif.md +83 -0
- package/reference/settings.md +161 -0
- 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
|
-
|
|
307
|
-
|
|
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
|
|
package/page/instruments-view.js
CHANGED
|
@@ -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.
|
package/page/jam-analysis.js
CHANGED
|
@@ -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
|
-
* - `
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
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++)
|
|
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) => ({
|
|
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`.
|
|
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.
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
* '
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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)}'`);
|