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
|
@@ -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
|
+
}
|
package/page/jam-view.js
CHANGED
|
@@ -1,21 +1,25 @@
|
|
|
1
|
-
// The Jam view's DOM: the message, the scale block
|
|
1
|
+
// The Jam view's DOM: the message, the scale block with the line that says why a scale is proposed and the list of the scales that
|
|
2
|
+
// fit (buttons that switch while one can be chosen, plain text when none can), the seven degree chips, the
|
|
2
3
|
// status box, the coming-up rows and the piano, built with DOM methods only. Every text reaches
|
|
3
4
|
// the page through textContent, title or setAttribute, never as markup: a part name comes from
|
|
4
5
|
// the code and is not trusted. The view attaches no right-click listener: page/main.js owns the
|
|
5
6
|
// only one. No DOM access at import time: main.js calls createJamView() with its document, so
|
|
6
7
|
// this module can be loaded anywhere. The words and the model come from page/jam-model.js.
|
|
7
8
|
|
|
8
|
-
import { JAM_COPY, statusAnnounceKey, noteDescriptor, pianoKeys, keyLabel, fretCells, fretUnits, legendEntries, STRING_NAMES, FRET_NUMBERS } from './jam-model.js';
|
|
9
|
+
import { JAM_COPY, statusAnnounceKey, noteDescriptor, pianoKeys, keyLabel, keyTitle, fretCells, fretUnits, legendEntries, STRING_NAMES, FRET_NUMBERS } from './jam-model.js';
|
|
9
10
|
|
|
10
11
|
const DEGREE_COUNT = 7;
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
14
|
* Builds the Jam view's DOM once and returns `{ showMessage, render, setPreferences }`.
|
|
14
15
|
* `onInstrumentChange(name)` is called with 'piano' or 'guitar' when the user picks the other
|
|
15
|
-
* segment, `onChordTonesChange(checked)` when the user flips the Chord tones checkbox
|
|
16
|
-
*
|
|
16
|
+
* segment, `onChordTonesChange(checked)` when the user flips the Chord tones checkbox,
|
|
17
|
+
* `onScaleChoice({ setKey, key })` when the user picks a scale that is not the one in use (a click, or Enter or Space on
|
|
18
|
+
* the focused button); a handler that throws is caught and said once per set of candidates. The view never touches
|
|
19
|
+
* browser storage and never chooses: main.js owns the preferences and the choice.
|
|
17
20
|
*/
|
|
18
|
-
export function createJamView({ document, onInstrumentChange, onChordTonesChange }) {
|
|
21
|
+
export function createJamView({ document, onInstrumentChange, onChordTonesChange, onScaleChoice }) {
|
|
22
|
+
if (typeof onScaleChoice !== 'function') throw new Error('Moontide: createJamView needs onScaleChoice, a function that takes { setKey, key }.');
|
|
19
23
|
const jamView = document.getElementById('jam-view');
|
|
20
24
|
const jamMessage = document.getElementById('jam-message');
|
|
21
25
|
const jamMessageText = document.getElementById('jam-message-text');
|
|
@@ -25,9 +29,17 @@ export function createJamView({ document, onInstrumentChange, onChordTonesChange
|
|
|
25
29
|
const jamDeclaredLabel = document.getElementById('jam-declared-label');
|
|
26
30
|
const jamDeclaredValue = document.getElementById('jam-declared-value');
|
|
27
31
|
const jamDeclaredSection = document.getElementById('jam-declared-section');
|
|
32
|
+
const jamScale = document.getElementById('jam-scale');
|
|
28
33
|
const jamScaleLabel = document.getElementById('jam-scale-label');
|
|
29
34
|
const jamScaleName = document.getElementById('jam-scale-name');
|
|
30
35
|
const jamScaleSub = document.getElementById('jam-scale-sub');
|
|
36
|
+
const jamReason = document.getElementById('jam-reason');
|
|
37
|
+
const jamReasonLead = document.getElementById('jam-reason-lead');
|
|
38
|
+
const jamReasonRest = document.getElementById('jam-reason-rest');
|
|
39
|
+
const jamCandidates = document.getElementById('jam-candidates');
|
|
40
|
+
const jamCandidatesLabel = document.getElementById('jam-candidates-label');
|
|
41
|
+
const jamCandidateList = document.getElementById('jam-candidate-list');
|
|
42
|
+
const jamCandidatesMore = document.getElementById('jam-candidates-more');
|
|
31
43
|
const jamDegrees = document.getElementById('jam-degrees');
|
|
32
44
|
const jamStatus = document.getElementById('jam-status');
|
|
33
45
|
const jamStatusLead = document.getElementById('jam-status-lead');
|
|
@@ -105,7 +117,7 @@ export function createJamView({ document, onInstrumentChange, onChordTonesChange
|
|
|
105
117
|
labelEl.className = 'jam-key-label';
|
|
106
118
|
el.append(labelEl);
|
|
107
119
|
jamPiano.append(el);
|
|
108
|
-
return { key, el, labelEl, shown: { note: null, role: null, root: null, chord: null, label: null } };
|
|
120
|
+
return { key, el, labelEl, shown: { note: null, role: null, root: null, chord: null, label: null, title: '' } };
|
|
109
121
|
});
|
|
110
122
|
let shownPianoLabel = null;
|
|
111
123
|
|
|
@@ -163,6 +175,11 @@ export function createJamView({ document, onInstrumentChange, onChordTonesChange
|
|
|
163
175
|
}
|
|
164
176
|
|
|
165
177
|
let shownDeclaredOn = false;
|
|
178
|
+
let shownLabelOn = null;
|
|
179
|
+
let shownSource = null;
|
|
180
|
+
let shownReasonOn = null;
|
|
181
|
+
let shownReasonLead = null;
|
|
182
|
+
let shownReasonRest = null;
|
|
166
183
|
let shownDeclaredKind = null;
|
|
167
184
|
let shownDeclaredText = null;
|
|
168
185
|
let shownDeclaredTitle = null;
|
|
@@ -199,14 +216,13 @@ export function createJamView({ document, onInstrumentChange, onChordTonesChange
|
|
|
199
216
|
lastModel = null;
|
|
200
217
|
}
|
|
201
218
|
|
|
202
|
-
// The Declared line
|
|
203
|
-
//
|
|
219
|
+
// The Declared line: shown with a declaration, hidden without one. The text comes from the code, so it goes
|
|
220
|
+
// in as text only, and only when it changed.
|
|
204
221
|
function renderDeclared(declared) {
|
|
205
222
|
if (declared === undefined) throw new Error('Moontide: a ready Jam model needs its declared field (null without a declaration).');
|
|
206
223
|
const on = declared !== null;
|
|
207
224
|
if (on !== shownDeclaredOn) {
|
|
208
225
|
jamDeclared.hidden = !on;
|
|
209
|
-
jamScaleLabel.hidden = !on;
|
|
210
226
|
shownDeclaredOn = on;
|
|
211
227
|
}
|
|
212
228
|
if (!on) return;
|
|
@@ -224,6 +240,136 @@ export function createJamView({ document, onInstrumentChange, onChordTonesChange
|
|
|
224
240
|
}
|
|
225
241
|
}
|
|
226
242
|
|
|
243
|
+
// Where the scale in use came from. The Heard label shows only with a declaration and only for a scale that was heard: it
|
|
244
|
+
// would be false for a proposed one. The source rides on #jam-scale for tests and the probe.
|
|
245
|
+
function renderSource(declared, scaleSource) {
|
|
246
|
+
if (scaleSource !== 'heard' && scaleSource !== 'proposed' && scaleSource !== 'chosen') {
|
|
247
|
+
throw new Error(`Moontide: a ready Jam model needs a scale source of heard, proposed or chosen, got '${String(scaleSource)}'.`);
|
|
248
|
+
}
|
|
249
|
+
const labelOn = declared !== null && scaleSource === 'heard';
|
|
250
|
+
if (labelOn !== shownLabelOn) {
|
|
251
|
+
jamScaleLabel.hidden = !labelOn;
|
|
252
|
+
shownLabelOn = labelOn;
|
|
253
|
+
}
|
|
254
|
+
if (scaleSource !== shownSource) {
|
|
255
|
+
jamScale.dataset.source = scaleSource;
|
|
256
|
+
shownSource = scaleSource;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// The line that says why the scale is proposed: a lead word and one sentence, written only when they changed so the
|
|
261
|
+
// polite live region reads a proposal once and never per tick. Hiding it clears both texts, so a proposal that comes back
|
|
262
|
+
// later is read again.
|
|
263
|
+
function renderReason(reason) {
|
|
264
|
+
if (reason === undefined) throw new Error('Moontide: a ready Jam model needs its reason field (null for a scale that was heard).');
|
|
265
|
+
const on = reason !== null;
|
|
266
|
+
const lead = on ? reason.lead : '';
|
|
267
|
+
const rest = on ? reason.rest : '';
|
|
268
|
+
if (lead !== shownReasonLead) {
|
|
269
|
+
jamReasonLead.textContent = lead;
|
|
270
|
+
shownReasonLead = lead;
|
|
271
|
+
}
|
|
272
|
+
if (rest !== shownReasonRest) {
|
|
273
|
+
jamReasonRest.textContent = rest;
|
|
274
|
+
shownReasonRest = rest;
|
|
275
|
+
}
|
|
276
|
+
if (on !== shownReasonOn) {
|
|
277
|
+
jamReason.hidden = !on;
|
|
278
|
+
shownReasonOn = on;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// The scales that fit. Pick mode draws one button per scale and exactly one is pressed; list mode draws plain items in a
|
|
283
|
+
// list. The items are rebuilt only when the mode, the set of candidates or the names drawn changed; a switch of the
|
|
284
|
+
// pressed scale rewrites aria-pressed in place, so the button just clicked keeps focus (and a rebuild gives focus back to
|
|
285
|
+
// the button of the same scale). Each button reads its own key
|
|
286
|
+
// from its closure, never from the markup, and a click on the pressed one does nothing. A choice the page refuses is said
|
|
287
|
+
// once per set of candidates, not once per click.
|
|
288
|
+
let shownCandidatesOn = null;
|
|
289
|
+
let shownCandidatesMode = null;
|
|
290
|
+
let shownCandidatesCaption = null;
|
|
291
|
+
let shownCandidatesMore = null;
|
|
292
|
+
let shownCandidatesKey = null;
|
|
293
|
+
let candidateRefs = [];
|
|
294
|
+
const choiceReportedFor = new Set();
|
|
295
|
+
|
|
296
|
+
function chooseCandidate(setKey, ref) {
|
|
297
|
+
if (ref.pressed) return;
|
|
298
|
+
try {
|
|
299
|
+
onScaleChoice({ setKey, key: ref.key });
|
|
300
|
+
} catch (err) {
|
|
301
|
+
if (!choiceReportedFor.has(setKey)) {
|
|
302
|
+
choiceReportedFor.add(setKey);
|
|
303
|
+
console.error('Moontide: the Jam view could not switch the scale:', err);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function buildCandidates(candidates) {
|
|
309
|
+
// The buttons are replaced, so a focused one would be lost: note its key now and give focus to the new button of the
|
|
310
|
+
// same scale (a pick can change which scale sits in the last place drawn when more than eight fit).
|
|
311
|
+
const focused = candidateRefs.find((ref) => ref.el === document.activeElement);
|
|
312
|
+
candidateRefs = candidates.items.map((item) => {
|
|
313
|
+
const pick = candidates.mode === 'pick';
|
|
314
|
+
const el = document.createElement(pick ? 'button' : 'span');
|
|
315
|
+
el.className = 'jam-candidate';
|
|
316
|
+
el.textContent = item.name;
|
|
317
|
+
const ref = { el, key: item.key, pressed: pick && item.pressed };
|
|
318
|
+
if (pick) {
|
|
319
|
+
el.type = 'button';
|
|
320
|
+
el.setAttribute('aria-pressed', String(item.pressed));
|
|
321
|
+
el.addEventListener('click', () => chooseCandidate(candidates.setKey, ref));
|
|
322
|
+
} else {
|
|
323
|
+
el.setAttribute('role', 'listitem');
|
|
324
|
+
}
|
|
325
|
+
return ref;
|
|
326
|
+
});
|
|
327
|
+
if (candidates.mode === 'list') jamCandidateList.setAttribute('role', 'list');
|
|
328
|
+
else jamCandidateList.removeAttribute('role');
|
|
329
|
+
jamCandidateList.replaceChildren(...candidateRefs.map((ref) => ref.el));
|
|
330
|
+
if (focused !== undefined) candidateRefs.find((ref) => ref.key === focused.key)?.el.focus();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function renderCandidates(candidates) {
|
|
334
|
+
if (candidates === undefined) throw new Error('Moontide: a ready Jam model needs its candidates field (null for a scale that was heard).');
|
|
335
|
+
const on = candidates !== null;
|
|
336
|
+
if (on !== shownCandidatesOn) {
|
|
337
|
+
jamCandidates.hidden = !on;
|
|
338
|
+
shownCandidatesOn = on;
|
|
339
|
+
}
|
|
340
|
+
if (!on) return;
|
|
341
|
+
if (candidates.mode !== 'pick' && candidates.mode !== 'list') {
|
|
342
|
+
throw new Error(`Moontide: the Jam view cannot draw scales that fit in the mode '${String(candidates.mode)}'.`);
|
|
343
|
+
}
|
|
344
|
+
if (candidates.mode !== shownCandidatesMode) {
|
|
345
|
+
jamCandidates.dataset.mode = candidates.mode;
|
|
346
|
+
shownCandidatesMode = candidates.mode;
|
|
347
|
+
}
|
|
348
|
+
if (candidates.caption !== shownCandidatesCaption) {
|
|
349
|
+
jamCandidatesLabel.textContent = candidates.caption;
|
|
350
|
+
shownCandidatesCaption = candidates.caption;
|
|
351
|
+
}
|
|
352
|
+
if (candidates.more !== shownCandidatesMore) {
|
|
353
|
+
jamCandidatesMore.textContent = candidates.more ?? '';
|
|
354
|
+
jamCandidatesMore.hidden = candidates.more === null;
|
|
355
|
+
shownCandidatesMore = candidates.more;
|
|
356
|
+
}
|
|
357
|
+
const listKey = [candidates.mode, candidates.setKey, ...candidates.items.map((item) => item.key)].join('\n');
|
|
358
|
+
if (listKey !== shownCandidatesKey) {
|
|
359
|
+
buildCandidates(candidates);
|
|
360
|
+
shownCandidatesKey = listKey;
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (candidates.mode !== 'pick') return;
|
|
364
|
+
candidates.items.forEach((item, i) => {
|
|
365
|
+
const ref = candidateRefs[i];
|
|
366
|
+
if (ref.pressed !== item.pressed) {
|
|
367
|
+
ref.el.setAttribute('aria-pressed', String(item.pressed));
|
|
368
|
+
ref.pressed = item.pressed;
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
227
373
|
// The section the Declared line speaks for: its words after the value, hidden for a piece without sections.
|
|
228
374
|
function renderDeclaredSection(section) {
|
|
229
375
|
if (section === undefined) throw new Error('Moontide: a ready Jam model needs its section field (null without sections).');
|
|
@@ -376,7 +522,7 @@ export function createJamView({ document, onInstrumentChange, onChordTonesChange
|
|
|
376
522
|
});
|
|
377
523
|
}
|
|
378
524
|
|
|
379
|
-
// The piano: each key's note, role, avoided-root ring and
|
|
525
|
+
// The piano: each key's note, role, avoided-root ring, label and hover title, written only when changed.
|
|
380
526
|
function renderInstrument(instrument) {
|
|
381
527
|
const { roles, names, rootPc, chordPcs } = instrument;
|
|
382
528
|
for (const ref of keys) {
|
|
@@ -408,6 +554,12 @@ export function createJamView({ document, onInstrumentChange, onChordTonesChange
|
|
|
408
554
|
ref.labelEl.textContent = label;
|
|
409
555
|
ref.shown.label = label;
|
|
410
556
|
}
|
|
557
|
+
const title = keyTitle(ref.key, roles, names, chordPcs, rootPc);
|
|
558
|
+
if (ref.shown.title !== title) {
|
|
559
|
+
if (title === '') ref.el.removeAttribute('title');
|
|
560
|
+
else ref.el.setAttribute('title', title);
|
|
561
|
+
ref.shown.title = title;
|
|
562
|
+
}
|
|
411
563
|
}
|
|
412
564
|
if (instrument.pianoLabel !== shownPianoLabel) {
|
|
413
565
|
jamPiano.setAttribute('aria-label', instrument.pianoLabel);
|
|
@@ -494,6 +646,9 @@ export function createJamView({ document, onInstrumentChange, onChordTonesChange
|
|
|
494
646
|
lastModel = model;
|
|
495
647
|
renderDeclared(model.declared);
|
|
496
648
|
renderDeclaredSection(model.section);
|
|
649
|
+
renderSource(model.declared, model.scaleSource);
|
|
650
|
+
renderReason(model.reason);
|
|
651
|
+
renderCandidates(model.candidates);
|
|
497
652
|
renderScale(model.scale, model.degrees);
|
|
498
653
|
renderStatus(model.status);
|
|
499
654
|
if (model.paused !== shownPaused) {
|
package/page/lights.js
CHANGED
|
@@ -78,8 +78,8 @@ export function readOutputPair(audio) {
|
|
|
78
78
|
|
|
79
79
|
/**
|
|
80
80
|
* What the lights need to know about a Mixer model: `{ tracks, ancestors }`. `tracks` has one entry
|
|
81
|
-
* `{ key, id, sounding }` for every strip that has a light key (`key` is what a note names, `id` the strip's identity,
|
|
82
|
-
* which the view finds the light by). A strip that plays MIDI cannot sound in a browser without Web MIDI, so it counts as
|
|
81
|
+
* `{ key, id, sounding, midi }` for every strip that has a light key (`key` is what a note names, `id` the strip's identity,
|
|
82
|
+
* which the view finds the light by, `midi` whether the strip plays a MIDI instrument). A strip that plays MIDI cannot sound in a browser without Web MIDI, so it counts as
|
|
83
83
|
* not sounding there. `ancestors` maps a strip's id to the keys of the groups above it, outermost first. A model that
|
|
84
84
|
* is not ready gives an empty layout.
|
|
85
85
|
*/
|
|
@@ -90,7 +90,7 @@ export function lightLayout(model, { webMidi }) {
|
|
|
90
90
|
for (const strip of model.strips) {
|
|
91
91
|
if (strip.lightKey === null || strip.lightKey === undefined) continue;
|
|
92
92
|
const playsMidi = (strip.midi !== null && strip.midi !== undefined) || strip.playsMidi === true;
|
|
93
|
-
tracks.push({ key: strip.lightKey, id: strip.identity, sounding: strip.sounding === true && (webMidi === true || !playsMidi) });
|
|
93
|
+
tracks.push({ key: strip.lightKey, id: strip.identity, sounding: strip.sounding === true && (webMidi === true || !playsMidi), midi: playsMidi });
|
|
94
94
|
}
|
|
95
95
|
if (model.layout !== null && model.layout !== undefined) {
|
|
96
96
|
for (const group of model.layout.groups) {
|
|
@@ -108,10 +108,13 @@ export function lightLayout(model, { webMidi }) {
|
|
|
108
108
|
* The light state. `setLayout(layout)` says which tracks and groups exist (see lightLayout); `noteSeen(key, heard)` queues
|
|
109
109
|
* a note, `heard` being `{ at }` (a time on the page clock, in ms) or `{ target }` (an audio time, in seconds);
|
|
110
110
|
* `frame(ts, { motion, cps, toPerf })` lights what is due at `ts` and returns `[{ kind, id, value }]` for the lights whose
|
|
111
|
-
* value changed since the last frame; `reset()` forgets every note and every lit light
|
|
111
|
+
* value changed since the last frame; `reset()` forgets every note and every lit light; `forgetShown()` forgets only which
|
|
112
|
+
* values were handed out, so the next frame hands out every lit value again and still darkens a light that has gone out
|
|
113
|
+
* meanwhile (after the view rebuilt some of its lights);
|
|
114
|
+
* `playsMidi(key)` tells whether the strip that owns `key` plays a MIDI instrument (false for an unknown key).
|
|
112
115
|
*/
|
|
113
116
|
export function createLights() {
|
|
114
|
-
// key -> { id, sounding }
|
|
117
|
+
// key -> { id, sounding, midi }
|
|
115
118
|
let tracks = new Map();
|
|
116
119
|
// strip id -> keys of the groups above it
|
|
117
120
|
let ancestors = new Map();
|
|
@@ -147,7 +150,7 @@ export function createLights() {
|
|
|
147
150
|
for (const track of layout.tracks) {
|
|
148
151
|
const known = next.get(track.key);
|
|
149
152
|
if (known !== undefined && known.sounding) continue;
|
|
150
|
-
next.set(track.key, { id: track.id, sounding: track.sounding === true });
|
|
153
|
+
next.set(track.key, { id: track.id, sounding: track.sounding === true, midi: track.midi === true });
|
|
151
154
|
}
|
|
152
155
|
tracks = next;
|
|
153
156
|
ancestors = layout.ancestors;
|
|
@@ -242,8 +245,15 @@ export function createLights() {
|
|
|
242
245
|
const changes = [];
|
|
243
246
|
for (const [key, value] of trackValues) {
|
|
244
247
|
const shown = shownTracks.get(key);
|
|
245
|
-
if (shown !== undefined && shown.value === value) continue;
|
|
246
248
|
const id = tracks.get(key).id;
|
|
249
|
+
// The strip that owns a shared key can change while its light is lit: the old strip goes dark and the new one shows
|
|
250
|
+
// the current value in this same frame.
|
|
251
|
+
if (shown !== undefined && shown.id !== id) {
|
|
252
|
+
changes.push({ kind: 'track', id: shown.id, value: 0 });
|
|
253
|
+
shown.id = id;
|
|
254
|
+
shown.value = -1;
|
|
255
|
+
}
|
|
256
|
+
if (shown !== undefined && shown.value === value) continue;
|
|
247
257
|
if (shown === undefined) shownTracks.set(key, { id, value });
|
|
248
258
|
else shown.value = value;
|
|
249
259
|
changes.push({ kind: 'track', id, value });
|
|
@@ -266,6 +276,18 @@ export function createLights() {
|
|
|
266
276
|
return changes;
|
|
267
277
|
},
|
|
268
278
|
|
|
279
|
+
forgetShown() {
|
|
280
|
+
// Keep the entries but mark their values unknown: a light that is lit gets its value handed out again, and one that
|
|
281
|
+
// has gone dark by the next frame still falls into the leaving loops and gets its dark change.
|
|
282
|
+
for (const shown of shownTracks.values()) shown.value = -1;
|
|
283
|
+
for (const group of shownGroups.keys()) shownGroups.set(group, -1);
|
|
284
|
+
},
|
|
285
|
+
|
|
286
|
+
playsMidi(key) {
|
|
287
|
+
const track = tracks.get(key);
|
|
288
|
+
return track !== undefined && track.midi === true;
|
|
289
|
+
},
|
|
290
|
+
|
|
269
291
|
reset() {
|
|
270
292
|
pending.clear();
|
|
271
293
|
pendingKeys.clear();
|