moontide-mcp 2.0.1 → 2.1.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 +44 -6
- package/dist/ask.js +30 -8
- package/dist/mcp.js +60 -14
- package/dist/protocol.js +89 -2
- package/dist/reference.js +22 -0
- package/dist/session.js +53 -0
- package/package.json +1 -1
- package/page/ask-view.js +21 -7
- package/page/ask.js +292 -34
- package/page/change-timing.js +25 -4
- package/page/harmony.js +13 -0
- package/page/index.html +4 -3
- package/page/jam-model.js +11 -2
- package/page/jam-view.js +17 -0
- package/page/lights.js +278 -0
- package/page/main.js +1723 -140
- package/page/midi-clock.js +1 -1
- package/page/midi-out.js +12 -0
- package/page/mixer-groups.js +114 -0
- package/page/mixer-model.js +172 -29
- package/page/mixer-rewrite.js +30 -1
- package/page/mixer-view.js +282 -13
- package/page/note-edit.js +267 -0
- package/page/note-locate.js +346 -0
- package/page/note-panel.js +381 -0
- package/page/note-tag.js +126 -0
- package/page/page-rewrite.js +184 -0
- package/page/song-edit.js +230 -0
- package/page/song-fit.js +250 -0
- package/page/song-grid-model.js +491 -0
- package/page/song-grid-view.js +710 -0
- package/page/song-prefs.js +100 -0
- package/page/structure-check.js +216 -0
- package/page/structure-view.js +38 -0
- package/page/structure.js +760 -0
- package/page/style.css +879 -84
- package/page/takeover.js +20 -5
- package/page/timeline-edit-view.js +1956 -0
- package/page/timeline-gesture.js +279 -0
- package/page/timeline.js +196 -3
- package/page/tracks.js +37 -8
- package/page/ui-state.js +10 -5
- package/page/views.js +25 -8
- package/reference/functions.md +1 -0
- package/reference/midi.md +2 -0
- package/reference/structure.md +131 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// The one path through which the page itself changes the code: a Mixer move, a part launch, a grid
|
|
2
|
+
// gesture. It runs the guards in a fixed order, writes the new text and checks the write, makes one
|
|
3
|
+
// evaluation and, when that fails, sets the editor back and shows the failed-change banner through the
|
|
4
|
+
// same helper every other failure uses. Each caller keeps its own wording for a refusal and sends its own
|
|
5
|
+
// message to the server; this module only decides whether the text may change and whether it played.
|
|
6
|
+
// No DOM access at import time and every dependency injected, so node:test can import it with fakes.
|
|
7
|
+
|
|
8
|
+
import { parseTiming } from './change-timing.js';
|
|
9
|
+
|
|
10
|
+
// The reasons a change is refused before anything is written. A refusal never touches the editor, except
|
|
11
|
+
// write-mismatch, which sets the editor back to the text it had before the write.
|
|
12
|
+
export const PAGE_REWRITE_REFUSALS = Object.freeze(['not-played', 'waiting', 'closed', 'no-parser', 'dirty', 'write-mismatch']);
|
|
13
|
+
|
|
14
|
+
// The guards, in the order they run. Each one is on unless the request turns it off.
|
|
15
|
+
const GUARD_KEYS = Object.freeze(['played', 'waiting', 'socket', 'parser', 'dirty']);
|
|
16
|
+
|
|
17
|
+
const WRITES = Object.freeze(['dispatch', 'replace']);
|
|
18
|
+
|
|
19
|
+
const DEP_FUNCTIONS = Object.freeze([
|
|
20
|
+
'hasEvaluated',
|
|
21
|
+
'pending',
|
|
22
|
+
'playingCode',
|
|
23
|
+
'socketOpen',
|
|
24
|
+
'parserReady',
|
|
25
|
+
'evaluate',
|
|
26
|
+
'evalError',
|
|
27
|
+
'revertAndReport',
|
|
28
|
+
'markPageOrigin',
|
|
29
|
+
'whenFinished',
|
|
30
|
+
'markPageOriginAtTakeover',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
function refused(reason) {
|
|
34
|
+
return Object.freeze({ status: 'refused', reason });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Builds the page's rewrite path around its dependencies:
|
|
39
|
+
* editor { code, setCode(text), dispatch(edits) }: the text the user sees; dispatch applies a list of
|
|
40
|
+
* { from, to, insert } edits in the coordinates of the text before the write, keeping selection and scroll,
|
|
41
|
+
* hasEvaluated() -> true once some code has played, pending() -> the waiting change or null,
|
|
42
|
+
* playingCode() -> the code that plays now, socketOpen() -> true while the server socket is open,
|
|
43
|
+
* parserReady() -> true when the code parser is loaded,
|
|
44
|
+
* evaluate({ code, timing, autostart, flash }) -> promise of the run ({ timing, deferred, ... }); flash true
|
|
45
|
+
* evaluates through the editor (it flashes), flash false through the repl directly,
|
|
46
|
+
* evalError() -> the evaluation's error or null,
|
|
47
|
+
* revertAndReport(error) -> { errorText, line }: sets the editor back to the committed code and shows the banner,
|
|
48
|
+
* markPageOrigin() -> the playing code now has no Claude evaluate id,
|
|
49
|
+
* whenFinished(record, fn) -> runs fn when the waiting change takes over (or is dropped),
|
|
50
|
+
* markPageOriginAtTakeover() -> the same mark, for the moment a waiting change takes over.
|
|
51
|
+
*/
|
|
52
|
+
export function createPageRewrite(deps) {
|
|
53
|
+
if (deps === null || typeof deps !== 'object') {
|
|
54
|
+
throw new Error('Moontide: createPageRewrite needs its dependencies.');
|
|
55
|
+
}
|
|
56
|
+
const editor = deps.editor;
|
|
57
|
+
if (
|
|
58
|
+
editor === null ||
|
|
59
|
+
typeof editor !== 'object' ||
|
|
60
|
+
typeof editor.setCode !== 'function' ||
|
|
61
|
+
typeof editor.dispatch !== 'function' ||
|
|
62
|
+
!('code' in editor)
|
|
63
|
+
) {
|
|
64
|
+
throw new Error('Moontide: createPageRewrite needs an editor with code, setCode and dispatch.');
|
|
65
|
+
}
|
|
66
|
+
for (const name of DEP_FUNCTIONS) {
|
|
67
|
+
if (typeof deps[name] !== 'function') {
|
|
68
|
+
throw new Error(`Moontide: createPageRewrite needs the function ${name}.`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readGuards(guards) {
|
|
73
|
+
const on = {};
|
|
74
|
+
for (const key of GUARD_KEYS) on[key] = true;
|
|
75
|
+
if (guards === undefined) return on;
|
|
76
|
+
if (guards === null || typeof guards !== 'object' || Array.isArray(guards)) {
|
|
77
|
+
throw new Error('Moontide: the guards of a page rewrite are an object.');
|
|
78
|
+
}
|
|
79
|
+
for (const [key, value] of Object.entries(guards)) {
|
|
80
|
+
if (!GUARD_KEYS.includes(key)) {
|
|
81
|
+
throw new Error(`Moontide: unknown guard '${key}' in a page rewrite (expected one of ${GUARD_KEYS.join(', ')}).`);
|
|
82
|
+
}
|
|
83
|
+
if (typeof value !== 'boolean') {
|
|
84
|
+
throw new Error(`Moontide: the guard '${key}' of a page rewrite is true or false.`);
|
|
85
|
+
}
|
|
86
|
+
on[key] = value;
|
|
87
|
+
}
|
|
88
|
+
return on;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Applies one change to the code. See the request fields below; resolves one of
|
|
93
|
+
* { status: 'played', code, run, edits } the text that was evaluated; run.deferred is the waiting change
|
|
94
|
+
* when the change waits for its line, else null,
|
|
95
|
+
* { status: 'unchanged', code } the build left the text as it was and the request did not ask to evaluate it,
|
|
96
|
+
* { status: 'refused', reason } one of PAGE_REWRITE_REFUSALS; nothing was written,
|
|
97
|
+
* { status: 'failed', errorText, line } the evaluation failed; the editor is already set back and the banner shown.
|
|
98
|
+
*
|
|
99
|
+
* Request:
|
|
100
|
+
* build(base) -> { code, edits }: the new text; edits is a list of { from, to, insert } in the coordinates of
|
|
101
|
+
* base for write 'dispatch' and may be null for write 'replace'. An error it throws reaches the caller as it is.
|
|
102
|
+
* base: the text the build starts from and the dirty guard compares with the editor (default: the playing code).
|
|
103
|
+
* timing: a parsed timing (default: at once). autostart: only used when flash is false (default true).
|
|
104
|
+
* flash: evaluate through the editor, which flashes (default false).
|
|
105
|
+
* write: 'dispatch' (default) changes the text by edits and checks the result; 'replace' sets the whole text.
|
|
106
|
+
* guards: any of played, waiting, socket, parser, dirty set to false to leave that guard out.
|
|
107
|
+
* evaluateUnchanged: also evaluate when the build left the text as it was (default false).
|
|
108
|
+
*/
|
|
109
|
+
async function apply(request) {
|
|
110
|
+
if (request === null || typeof request !== 'object' || typeof request.build !== 'function') {
|
|
111
|
+
throw new Error('Moontide: a page rewrite needs a build function.');
|
|
112
|
+
}
|
|
113
|
+
const write = request.write === undefined ? 'dispatch' : request.write;
|
|
114
|
+
if (!WRITES.includes(write)) {
|
|
115
|
+
throw new Error(`Moontide: unknown write '${String(request.write)}' in a page rewrite (expected one of ${WRITES.join(', ')}).`);
|
|
116
|
+
}
|
|
117
|
+
const guards = readGuards(request.guards);
|
|
118
|
+
const timing = request.timing === undefined ? parseTiming('now') : request.timing;
|
|
119
|
+
const autostart = request.autostart === undefined ? true : request.autostart;
|
|
120
|
+
const flash = request.flash === true;
|
|
121
|
+
const evaluateUnchanged = request.evaluateUnchanged === true;
|
|
122
|
+
|
|
123
|
+
if (guards.played && !deps.hasEvaluated()) return refused('not-played');
|
|
124
|
+
if (guards.waiting && deps.pending() !== null) return refused('waiting');
|
|
125
|
+
if (guards.socket && !deps.socketOpen()) return refused('closed');
|
|
126
|
+
if (guards.parser && !deps.parserReady()) return refused('no-parser');
|
|
127
|
+
const base = request.base === undefined ? deps.playingCode() : request.base;
|
|
128
|
+
if (guards.dirty && editor.code !== base) return refused('dirty');
|
|
129
|
+
|
|
130
|
+
const built = request.build(base);
|
|
131
|
+
if (built === null || typeof built !== 'object' || typeof built.code !== 'string') {
|
|
132
|
+
throw new Error('Moontide: the build of a page rewrite must return { code, edits }.');
|
|
133
|
+
}
|
|
134
|
+
const code = built.code;
|
|
135
|
+
const edits = Array.isArray(built.edits) ? built.edits : null;
|
|
136
|
+
if (code === base && !evaluateUnchanged) {
|
|
137
|
+
return Object.freeze({ status: 'unchanged', code });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (write === 'dispatch') {
|
|
141
|
+
if (edits === null && code !== base) {
|
|
142
|
+
throw new Error('Moontide: a page rewrite that writes by edits needs an edit list from its build.');
|
|
143
|
+
}
|
|
144
|
+
if (edits !== null && edits.length > 0) editor.dispatch(edits);
|
|
145
|
+
if (editor.code !== code) {
|
|
146
|
+
editor.setCode(base);
|
|
147
|
+
return refused('write-mismatch');
|
|
148
|
+
}
|
|
149
|
+
} else {
|
|
150
|
+
editor.setCode(code);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let run;
|
|
154
|
+
try {
|
|
155
|
+
run = await deps.evaluate({ code, timing, autostart, flash });
|
|
156
|
+
} catch (err) {
|
|
157
|
+
// The evaluation itself threw (not a program error, which is read from evalError below): nothing is armed, so the
|
|
158
|
+
// text written above would sit in the editor as typing that was never evaluated. It is taken back, unless the
|
|
159
|
+
// person typed over it meanwhile; the error goes on to the caller, marked `restored` when the editor holds the base
|
|
160
|
+
// text again, so the caller can say the code is as it was.
|
|
161
|
+
if (code !== base && editor.code === code) editor.setCode(base);
|
|
162
|
+
if (editor.code === base && err !== null && typeof err === 'object' && Object.isExtensible(err)) err.restored = true;
|
|
163
|
+
throw err;
|
|
164
|
+
}
|
|
165
|
+
if (run === null || typeof run !== 'object') {
|
|
166
|
+
throw new Error('Moontide: the evaluation of a page rewrite must give back its run.');
|
|
167
|
+
}
|
|
168
|
+
const evalError = deps.evalError();
|
|
169
|
+
if (evalError) {
|
|
170
|
+
const { errorText, line } = deps.revertAndReport(evalError);
|
|
171
|
+
return Object.freeze({ status: 'failed', errorText, line });
|
|
172
|
+
}
|
|
173
|
+
// A change the page makes by itself has no Claude evaluate id: at once when it plays at once, otherwise at
|
|
174
|
+
// the moment it takes over (the old code, and its id, keep playing until then).
|
|
175
|
+
if (run.deferred === null || run.deferred === undefined) {
|
|
176
|
+
deps.markPageOrigin();
|
|
177
|
+
} else {
|
|
178
|
+
deps.whenFinished(run.deferred, deps.markPageOriginAtTakeover);
|
|
179
|
+
}
|
|
180
|
+
return Object.freeze({ status: 'played', code, run, edits });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return Object.freeze({ apply });
|
|
184
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// Pure module for Moontide's browser page: the line maths and the edits of a section launch from the Song grid. A launch
|
|
2
|
+
// reads the scheduler's frontier, computes the line the section must start on (the end of the section that plays, the next
|
|
3
|
+
// phrase or the next bar), writes the shift that puts the section on that line into the code and evaluates it, all in one
|
|
4
|
+
// turn; the takeover is then pinned to the very line the shift was written for, so the two cannot disagree. This was
|
|
5
|
+
// measured in the real page before it was built: the line agreed with the landing in every launch of the page's own path.
|
|
6
|
+
// Nothing here touches the DOM or the editor: the functions take a reading of the code (page/structure.js) and give back
|
|
7
|
+
// numbers and edit lists, so node:test and page/main.js import them without a build step.
|
|
8
|
+
|
|
9
|
+
import { boundaryCycle, DEFAULT_PHRASE_CYCLES } from './change-timing.js';
|
|
10
|
+
import { launchOffset } from './parts.js';
|
|
11
|
+
import { SONG_LAUNCHES } from './views.js';
|
|
12
|
+
|
|
13
|
+
/** The decimals a shift literal is written with; a fraction needs no more than a cycle split into a thousand million. */
|
|
14
|
+
const SHIFT_DECIMALS = 9;
|
|
15
|
+
|
|
16
|
+
/** Two shifts closer than this give the same position (floating-point noise of a few additions). */
|
|
17
|
+
const SHIFT_TOLERANCE = 1e-9;
|
|
18
|
+
|
|
19
|
+
function finite(name, value) {
|
|
20
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
21
|
+
throw new Error(`Moontide: ${name} must be a finite number, got ${String(value)}.`);
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Where every section of the read song starts while its shift is 0, in entry order: the start the reader gives (the shift
|
|
28
|
+
* is already in it) minus the shift. Starts are whole numbers (lengths are whole), so a result within rounding noise of a
|
|
29
|
+
* whole number is that number.
|
|
30
|
+
*/
|
|
31
|
+
export function unshiftedStarts(reading) {
|
|
32
|
+
return reading.sections.map((section) => {
|
|
33
|
+
const value = section.start - reading.shift;
|
|
34
|
+
const whole = Math.round(value);
|
|
35
|
+
return Math.abs(value - whole) < SHIFT_TOLERANCE ? whole : value;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The line a launch at the end of the section that plays starts on: the first section start of the shifted structure that
|
|
41
|
+
* is strictly after the scheduler frontier and at least `minLeadSeconds` ahead of it at tempo `cps`. A request in the last
|
|
42
|
+
* half second of a section therefore lands on the end of the next section. `starts` are the unshifted section starts in
|
|
43
|
+
* ascending order and `total` the length of the whole structure; the search covers four passes of the structure and throws
|
|
44
|
+
* when it finds no line, and when an argument is not usable.
|
|
45
|
+
*/
|
|
46
|
+
export function sectionEndLine({ frontier, cps, shift, total, starts, minLeadSeconds }) {
|
|
47
|
+
finite('the frontier', frontier);
|
|
48
|
+
finite('the shift', shift);
|
|
49
|
+
finite('the minimum lead', minLeadSeconds);
|
|
50
|
+
if (finite('the tempo', cps) <= 0) throw new Error(`Moontide: the tempo must be above 0, got ${String(cps)}.`);
|
|
51
|
+
if (finite('the length of the structure', total) <= 0) throw new Error(`Moontide: the length of the structure must be above 0, got ${String(total)}.`);
|
|
52
|
+
if (!Array.isArray(starts) || starts.length === 0) throw new Error('Moontide: a section line needs the starts of the sections.');
|
|
53
|
+
starts.forEach((start, i) => {
|
|
54
|
+
finite('a section start', start);
|
|
55
|
+
if (i > 0 && start <= starts[i - 1]) throw new Error('Moontide: the section starts must ascend.');
|
|
56
|
+
});
|
|
57
|
+
const first = Math.floor((frontier - shift) / total) - 1;
|
|
58
|
+
for (let pass = first; pass <= first + 3; pass++) {
|
|
59
|
+
for (const start of starts) {
|
|
60
|
+
const cycle = shift + pass * total + start;
|
|
61
|
+
if (cycle > frontier && (cycle - frontier) / cps >= minLeadSeconds) return cycle;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
throw new Error('Moontide: no section line was found ahead of the scheduler.');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The line of a launch and the word the takeover knows it by: `kind` is one of the toolbar's choices ('section', 'phrase',
|
|
69
|
+
* 'bar'). Returns a frozen { line, at } with `at` 'section', 'phrase' or 'bar'. Bar and phrase lines are multiples of 1 and
|
|
70
|
+
* of the page's phrase length counted from Play, at least `minLeadSeconds` ahead of the frontier; the end of the section
|
|
71
|
+
* that plays comes from the read song. Any other kind throws.
|
|
72
|
+
*/
|
|
73
|
+
export function launchLine({ kind, frontier, cps, reading, minLeadSeconds }) {
|
|
74
|
+
if (!SONG_LAUNCHES.includes(kind)) {
|
|
75
|
+
throw new Error(`Moontide: unknown launch choice '${String(kind)}' (expected one of ${SONG_LAUNCHES.join(', ')}).`);
|
|
76
|
+
}
|
|
77
|
+
if (kind === 'section') {
|
|
78
|
+
const line = sectionEndLine({ frontier, cps, shift: reading.shift, total: reading.total, starts: unshiftedStarts(reading), minLeadSeconds });
|
|
79
|
+
return Object.freeze({ line, at: 'section' });
|
|
80
|
+
}
|
|
81
|
+
const lineLength = kind === 'bar' ? 1 : DEFAULT_PHRASE_CYCLES;
|
|
82
|
+
return Object.freeze({ line: boundaryCycle({ frontier, cps, lineLength, minLeadSeconds }), at: kind });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The shift that puts section `sectionIndex` of the read song on cycle `line`. */
|
|
86
|
+
export function launchShift(reading, sectionIndex, line) {
|
|
87
|
+
const starts = unshiftedStarts(reading);
|
|
88
|
+
if (!Number.isInteger(sectionIndex) || sectionIndex < 0 || sectionIndex >= starts.length) {
|
|
89
|
+
throw new Error(`Moontide: the song has no section ${String(sectionIndex)} to launch.`);
|
|
90
|
+
}
|
|
91
|
+
finite('the launch line', line);
|
|
92
|
+
return launchOffset(line, starts[sectionIndex], reading.total);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** True when shifts `a` and `b` give the same position in a structure of `total` cycles (they differ by a whole number of structures). */
|
|
96
|
+
export function sameShift(a, b, total) {
|
|
97
|
+
finite('a shift', a);
|
|
98
|
+
finite('a shift', b);
|
|
99
|
+
if (finite('the length of the structure', total) <= 0) throw new Error(`Moontide: the length of the structure must be above 0, got ${String(total)}.`);
|
|
100
|
+
const rest = (((a - b) % total) + total) % total;
|
|
101
|
+
return rest < SHIFT_TOLERANCE || total - rest < SHIFT_TOLERANCE;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The number literal to write for a shift: whole numbers as digits, a fraction rounded to nine decimals with no trailing
|
|
106
|
+
* zero, a negative zero as '0'. A value that is not finite throws.
|
|
107
|
+
*/
|
|
108
|
+
export function shiftText(value) {
|
|
109
|
+
finite('a shift', value);
|
|
110
|
+
const fixed = value.toFixed(SHIFT_DECIMALS);
|
|
111
|
+
const text = fixed.includes('.') ? fixed.replace(/0+$/, '').replace(/\.$/, '') : fixed;
|
|
112
|
+
return text === '-0' ? '0' : text;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The edit list that replaces the shift literal of the read song with `value`. */
|
|
116
|
+
export function shiftEdit(reading, value) {
|
|
117
|
+
const range = reading.shiftRange;
|
|
118
|
+
if (range === undefined || range === null) throw new Error('Moontide: the reading of the song has no range for its shift.');
|
|
119
|
+
return [{ from: range.start, to: range.end, insert: shiftText(value) }];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The edit that makes Play start a piece from its first section: the scheduler's cycle count restarts at 0 at every Play, so
|
|
124
|
+
* a shift left in the code starts the music inside the structure. null when the shift is 0 already (Play changes nothing);
|
|
125
|
+
* otherwise the one edit that writes 0 over the shift literal and touches nothing else.
|
|
126
|
+
*/
|
|
127
|
+
export function playResetEdit(reading) {
|
|
128
|
+
if (reading.shift === 0) return null;
|
|
129
|
+
const range = reading.shiftRange;
|
|
130
|
+
if (range === undefined || range === null) throw new Error('Moontide: the reading of the song has no range for its shift.');
|
|
131
|
+
return [{ from: range.start, to: range.end, insert: '0' }];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* What the editor holds again when a launch from the grid is over without having taken over (canceled, or the sound
|
|
136
|
+
* stopped while it waited). The launch wrote only the shift into the code, so the code that plays goes back, but only while
|
|
137
|
+
* the editor still holds exactly the text the launch wrote. Text the person typed since is never replaced: the answer is
|
|
138
|
+
* then null and the editor is left as it is. Also null when the launch never recorded its text.
|
|
139
|
+
*/
|
|
140
|
+
export function textAfterCanceledLaunch({ editorCode, launchCode, playingCode }) {
|
|
141
|
+
if (typeof editorCode !== 'string' || typeof playingCode !== 'string') {
|
|
142
|
+
throw new Error('Moontide: a canceled launch needs the text of the editor and the code that plays.');
|
|
143
|
+
}
|
|
144
|
+
return typeof launchCode === 'string' && editorCode === launchCode ? playingCode : null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** null when section `index` of the read song still has the name and length the person saw, else 'section-gone'. */
|
|
148
|
+
export function checkSection(reading, { index, name, length }) {
|
|
149
|
+
const section = reading.sections[index];
|
|
150
|
+
return section !== undefined && section.name === name && section.length === length ? null : 'section-gone';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---- changing a cell of the plan ----
|
|
154
|
+
|
|
155
|
+
/** The sentence each refusal id of the cell edits stands for, so an error says what was refused. */
|
|
156
|
+
const EDIT_REFUSALS = {
|
|
157
|
+
'name-unwritable': "Moontide: that variation's name cannot be written into the plan as it is.",
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/** Raised when an edit cannot be built from what the person chose; `reason` is the id the grid turns into a sentence. */
|
|
161
|
+
export class SongEditRefusal extends Error {
|
|
162
|
+
constructor(reason) {
|
|
163
|
+
const message = Object.hasOwn(EDIT_REFUSALS, reason) ? EDIT_REFUSALS[reason] : null;
|
|
164
|
+
if (message === null) throw new Error(`Moontide: unknown song edit refusal '${String(reason)}'.`);
|
|
165
|
+
super(message);
|
|
166
|
+
this.name = 'SongEditRefusal';
|
|
167
|
+
this.reason = reason;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** The plan reads this name as a rest, so a variation called so cannot be chosen by name. */
|
|
172
|
+
const REST_KEY = '-';
|
|
173
|
+
|
|
174
|
+
/** The literal the plan holds for a rest. */
|
|
175
|
+
const REST_LITERAL = "'-'";
|
|
176
|
+
|
|
177
|
+
/** Characters that cannot sit in a single-quoted literal on one line: controls, line separator, paragraph separator. */
|
|
178
|
+
const UNWRITABLE = /[\u0000-\u001F\u007F\u2028\u2029]/;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The text to write into the plan for the variation called `key`: a single-quoted literal with the backslash and the single
|
|
182
|
+
* quote escaped. A name that holds a control, line separator or paragraph separator character, or the name '-' (the plan reads
|
|
183
|
+
* it as a rest), is refused with SongEditRefusal('name-unwritable').
|
|
184
|
+
*/
|
|
185
|
+
export function quoteKey(key) {
|
|
186
|
+
if (typeof key !== 'string') throw new Error(`Moontide: a variation name must be text, got ${String(key)}.`);
|
|
187
|
+
if (key === REST_KEY || UNWRITABLE.test(key)) throw new SongEditRefusal('name-unwritable');
|
|
188
|
+
return `'${key.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The cell of `track` in section entry `index`, or undefined. */
|
|
192
|
+
function cellOf(track, index) {
|
|
193
|
+
return track.cells.find((cell) => cell.section === index);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* null when the cell the person clicked is still what they saw, else the id of what changed: `section-gone` (entry `index`
|
|
198
|
+
* has another name or length now), `cell-gone` (the track is gone, or its cell in that section no longer holds `key`; a
|
|
199
|
+
* `key` of null stands for a rest) or `variation-gone` (`choice.key` is no longer a variation of that track). `choice` is
|
|
200
|
+
* `{ key }` or `{ rest: true }`.
|
|
201
|
+
*/
|
|
202
|
+
export function checkCell(reading, { index, name, length, track, key }, choice) {
|
|
203
|
+
const section = reading.sections[index];
|
|
204
|
+
if (section === undefined || section.name !== name || section.length !== length) return 'section-gone';
|
|
205
|
+
const found = reading.tracks.find((candidate) => candidate.name === track);
|
|
206
|
+
if (found === undefined) return 'cell-gone';
|
|
207
|
+
const cell = cellOf(found, index);
|
|
208
|
+
if (cell === undefined || cell.key !== key) return 'cell-gone';
|
|
209
|
+
if (choice.rest !== true && !found.variations.some((variation) => variation.key === choice.key)) return 'variation-gone';
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The edit that puts `choice` (`{ key }` or `{ rest: true }`) into the cell of `track` in section entry `index`: one
|
|
215
|
+
* `{ from, to, insert }` over the plan string of that cell and nothing else (entries that share a section name share the
|
|
216
|
+
* string, so one edit changes every column of that name). `changed` is false, with no edit, when the choice is what the cell
|
|
217
|
+
* holds already. A name that cannot be written throws SongEditRefusal; a track or section the reading does not have throws.
|
|
218
|
+
*/
|
|
219
|
+
export function cellEdit(reading, { index, track }, choice) {
|
|
220
|
+
const found = reading.tracks.find((candidate) => candidate.name === track);
|
|
221
|
+
const cell = found === undefined ? undefined : cellOf(found, index);
|
|
222
|
+
if (cell === undefined) throw new Error(`Moontide: the song has no cell for track '${String(track)}' in section ${String(index)}.`);
|
|
223
|
+
const rest = choice !== null && typeof choice === 'object' && choice.rest === true;
|
|
224
|
+
if (!rest && (choice === null || typeof choice !== 'object' || typeof choice.key !== 'string')) {
|
|
225
|
+
throw new Error('Moontide: a cell choice is { key } or { rest: true }.');
|
|
226
|
+
}
|
|
227
|
+
if (rest ? cell.key === null : cell.key === choice.key) return { edits: [], changed: false };
|
|
228
|
+
const insert = rest ? REST_LITERAL : quoteKey(choice.key);
|
|
229
|
+
return { edits: [{ from: cell.range.start, to: cell.range.end, insert }], changed: true };
|
|
230
|
+
}
|
package/page/song-fit.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// Pure module for Moontide's browser page: whether the notes of a fixed variation fit the harmony of the section they play
|
|
2
|
+
// in. The page asks each fixed variation for its own pattern, by a query of the code's own constants (never through the
|
|
3
|
+
// code's play() helper), keeps which pitch classes it plays in each cycle, and compares those of the cycles a section covers
|
|
4
|
+
// with the scale of the section's harmony, over the nine scales the Jam view knows. It only says words: nothing here
|
|
5
|
+
// changes the code, the music or a launch.
|
|
6
|
+
//
|
|
7
|
+
// The query follows the same shape as the check of what a song declares: the code's top-level declarations are evaluated by
|
|
8
|
+
// the prelude the per-track programs start with, then each variation is read from its track's constant. The clock, the
|
|
9
|
+
// budget, the yield to the page and the cancel flag are injected, so this module has no DOM access at import time and
|
|
10
|
+
// node:test drives it with fake patterns.
|
|
11
|
+
|
|
12
|
+
import { soloPrelude } from './tracks.js';
|
|
13
|
+
import { pitchOf } from './timeline.js';
|
|
14
|
+
import { MICROTONE, MODES, scalePcs, spellTable } from './jam-analysis.js';
|
|
15
|
+
import { readHarmonyText } from './harmony.js';
|
|
16
|
+
import { SONG_COPY, displayHarmony, fillSong, quoted } from './structure.js';
|
|
17
|
+
|
|
18
|
+
/** Why a variation's fit cannot be checked, by id; each has one sentence in SONG_COPY.fitReasons. */
|
|
19
|
+
export const FIT_REASONS = Object.freeze(['no-harmony', 'harmony-unreadable', 'harmony-unknown', 'no-notes', 'not-read', 'too-slow']);
|
|
20
|
+
|
|
21
|
+
/** How many outside notes the line under a variation names before it says how many more there are. */
|
|
22
|
+
const NAMED_OUTSIDE = 3;
|
|
23
|
+
|
|
24
|
+
/** How many cycles are read between two looks at the clock and the cancel flag. */
|
|
25
|
+
const CHECK_EVERY_CYCLES = 4;
|
|
26
|
+
|
|
27
|
+
const AsyncFunctionConstructor = Object.getPrototypeOf(async function () {}).constructor;
|
|
28
|
+
|
|
29
|
+
const mod12 = (n) => ((n % 12) + 12) % 12;
|
|
30
|
+
|
|
31
|
+
/** The key of one variation of one track in the answer: the track's name and the variation's key, as JSON text. */
|
|
32
|
+
export function pairKey(track, key) {
|
|
33
|
+
return JSON.stringify([track, key]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The (track, variation) pairs the fit query holds a pattern for: every fixed variation of every play track, muted tracks
|
|
38
|
+
* included, used or not, tracks in code order and then variations in code order. Returns `[{ track, key }]` with the
|
|
39
|
+
* track's name and the variation's key.
|
|
40
|
+
*/
|
|
41
|
+
export function fitPairs(reading) {
|
|
42
|
+
const pairs = [];
|
|
43
|
+
for (const track of reading.tracks) {
|
|
44
|
+
for (const variation of track.variations) {
|
|
45
|
+
if (variation.kind === 'fixed') pairs.push({ track: track.name, key: variation.key });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return pairs;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The body of an async function that returns one entry per pair of fitPairs: the pattern of the variation, or null. It
|
|
53
|
+
* starts with the declarations and string rewrites the per-track programs start with (soloPrelude), then reads each
|
|
54
|
+
* variation from the track's constant by the key the reader found, through a helper called by `this` so no name in the code
|
|
55
|
+
* can shadow it: the second element of the pair when it can be queried, else null. Keys enter only through JSON.stringify
|
|
56
|
+
* and constant names are the identifier names the parser gave the reader.
|
|
57
|
+
*/
|
|
58
|
+
export function fitSource(code, reading, { parse }) {
|
|
59
|
+
const constants = new Map(reading.tracks.map((track) => [track.name, track.constName]));
|
|
60
|
+
const entries = fitPairs(reading).map(({ track, key }) => `this.pick(${constants.get(track)}[${JSON.stringify(key)}])`);
|
|
61
|
+
const pick = "(value) => (Array.isArray(value) && value[1] && typeof value[1].queryArc === 'function' ? value[1] : null)";
|
|
62
|
+
return `${soloPrelude(code, { parse })}return (function () {\nreturn [\n${entries.join(',\n')}\n];\n}).call({ pick: ${pick} });`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Builds and awaits an AsyncFunction from `body` (a fitSource result), never through the editor's evaluate, so it never
|
|
67
|
+
* touches the scheduler and never calls the code's play(). Throws a 'Moontide: ...' error when the result is not a list.
|
|
68
|
+
*/
|
|
69
|
+
export async function evaluateFit(body) {
|
|
70
|
+
const result = await new AsyncFunctionConstructor(body)();
|
|
71
|
+
if (!Array.isArray(result)) throw new Error('Moontide: the fit query did not come out as a list of patterns.');
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The 12-bit set of the pitch classes of the onsets that begin in one cycle of `pattern`. A note farther than MICROTONE from a semitone is left out. */
|
|
76
|
+
function maskOfCycle(pattern, cycle) {
|
|
77
|
+
let mask = 0;
|
|
78
|
+
for (const hap of pattern.queryArc(cycle, cycle + 1)) {
|
|
79
|
+
if (!hap.whole || !hap.hasOnset()) continue;
|
|
80
|
+
const pitch = pitchOf(hap.value);
|
|
81
|
+
if (pitch === null) continue;
|
|
82
|
+
const midi = Math.round(pitch);
|
|
83
|
+
if (Math.abs(pitch - midi) > MICROTONE) continue;
|
|
84
|
+
mask |= 1 << mod12(midi);
|
|
85
|
+
}
|
|
86
|
+
return mask;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Reads, for every pair, which pitch classes its pattern plays in each of the first `cycles` cycles (a variation restarts at
|
|
91
|
+
* its first cycle in every section, so cycle 0 is the section's first). `patterns[i]` belongs to `pairs[i]`; a null pattern
|
|
92
|
+
* is a variation the page could not read. Resolves to `{ state: 'ready', byPair, failures }` or `{ state: 'cancelled' }`:
|
|
93
|
+
* `byPair` maps pairKey(track, key) to `{ masks }` (one 12-bit set per cycle, a note counted in the cycle it begins in) or
|
|
94
|
+
* to `{ reason: 'not-read' | 'too-slow' }`; `failures` lists `{ track, key, message }` for each query that threw. The
|
|
95
|
+
* clock and the cancel flag are looked at before each variation and every few cycles; a variation the budget cuts short and
|
|
96
|
+
* every one after it are 'too-slow'. It yields to the page after each variation and never throws.
|
|
97
|
+
*/
|
|
98
|
+
export async function collectFit({ patterns, pairs, cycles, budgetMs, clock, yieldToPage, isCancelled }) {
|
|
99
|
+
const begun = clock();
|
|
100
|
+
const byPair = new Map();
|
|
101
|
+
const failures = [];
|
|
102
|
+
if (!Array.isArray(patterns) || patterns.length !== pairs.length) {
|
|
103
|
+
return { state: 'ready', byPair: new Map(pairs.map((pair) => [pairKey(pair.track, pair.key), { reason: 'not-read' }])), failures: [{ track: null, key: null, message: 'the fit query did not match the variations of the reading' }] };
|
|
104
|
+
}
|
|
105
|
+
let overBudget = false;
|
|
106
|
+
for (let at = 0; at < pairs.length; at++) {
|
|
107
|
+
const { track, key } = pairs[at];
|
|
108
|
+
const id = pairKey(track, key);
|
|
109
|
+
if (isCancelled()) return { state: 'cancelled' };
|
|
110
|
+
if (!overBudget && clock() - begun > budgetMs) overBudget = true;
|
|
111
|
+
if (overBudget) {
|
|
112
|
+
byPair.set(id, { reason: 'too-slow' });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const pattern = patterns[at];
|
|
116
|
+
if (pattern === null || pattern === undefined || typeof pattern.queryArc !== 'function') {
|
|
117
|
+
byPair.set(id, { reason: 'not-read' });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const masks = [];
|
|
121
|
+
try {
|
|
122
|
+
for (let cycle = 0; cycle < cycles; cycle++) {
|
|
123
|
+
masks.push(maskOfCycle(pattern, cycle));
|
|
124
|
+
if (cycle % CHECK_EVERY_CYCLES === CHECK_EVERY_CYCLES - 1 && clock() - begun > budgetMs) {
|
|
125
|
+
overBudget = true;
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
} catch (err) {
|
|
130
|
+
failures.push({ track, key, message: err instanceof Error ? err.message : String(err) });
|
|
131
|
+
byPair.set(id, { reason: 'not-read' });
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
byPair.set(id, overBudget ? { reason: 'too-slow' } : { masks });
|
|
135
|
+
await yieldToPage();
|
|
136
|
+
if (isCancelled()) return { state: 'cancelled' };
|
|
137
|
+
}
|
|
138
|
+
return { state: 'ready', byPair, failures };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The scale a section's harmony gives to compare with: `{ rootPc, modeId, text }`, or `{ reason }` with a reason of
|
|
143
|
+
* FIT_REASONS when there is none to compare with: no harmony anywhere, a piece harmony the page cannot read, text the reader
|
|
144
|
+
* rejects, chord text, or a scale outside the nine. `section` is an entry of a read song.
|
|
145
|
+
*/
|
|
146
|
+
export function sectionHarmony(section) {
|
|
147
|
+
const { harmony } = section;
|
|
148
|
+
if (harmony.from === 'none') return { reason: 'no-harmony' };
|
|
149
|
+
if (harmony.from === 'piece-unreadable') return { reason: 'harmony-unreadable' };
|
|
150
|
+
const read = readHarmonyText(harmony.text);
|
|
151
|
+
if (read === null) return { reason: 'harmony-unreadable' };
|
|
152
|
+
if (read.scale === null) return { reason: 'harmony-unknown' };
|
|
153
|
+
return { rootPc: read.scale.rootPc, modeId: read.scale.modeId, text: harmony.text };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Whether the notes of one variation fit one section. `masks` is what collectFit read for the variation (one set per cycle),
|
|
158
|
+
* `length` the section's length in cycles (only the first `length` cycles count; a note that begins exactly at the length
|
|
159
|
+
* belongs to the next cycle), `harmony` the sectionHarmony result, and `readReason` the reason collectFit gave when it could
|
|
160
|
+
* not read the variation. Returns `{ state: 'fits' }`, `{ state: 'does-not-fit', outside }` with the outside notes spelled the
|
|
161
|
+
* way the Jam view spells the section's scale and listed upward from its root, each once, or `{ state: 'cannot-check', reason }`.
|
|
162
|
+
* The harmony's reason comes first, then the reason the variation was not read, then a variation with no pitched note.
|
|
163
|
+
*/
|
|
164
|
+
export function fitOf({ masks, length, harmony, readReason = null }) {
|
|
165
|
+
if (harmony.reason !== undefined) return { state: 'cannot-check', reason: harmony.reason };
|
|
166
|
+
if (readReason !== null) {
|
|
167
|
+
if (!FIT_REASONS.includes(readReason)) throw new Error(`Moontide: unknown fit reason '${String(readReason)}'.`);
|
|
168
|
+
return { state: 'cannot-check', reason: readReason };
|
|
169
|
+
}
|
|
170
|
+
if (!Array.isArray(masks) || masks.length < length) {
|
|
171
|
+
throw new Error(`Moontide: the fit of a section of ${String(length)} cycles needs the notes of that many cycles.`);
|
|
172
|
+
}
|
|
173
|
+
let played = 0;
|
|
174
|
+
for (let cycle = 0; cycle < length; cycle++) played |= masks[cycle];
|
|
175
|
+
if (played === 0) return { state: 'cannot-check', reason: 'no-notes' };
|
|
176
|
+
const mode = MODES.find((candidate) => candidate.id === harmony.modeId);
|
|
177
|
+
if (mode === undefined) throw new Error(`Moontide: no scale called '${String(harmony.modeId)}' to compare with.`);
|
|
178
|
+
const inScale = new Set(scalePcs(harmony.rootPc, mode));
|
|
179
|
+
const outside = [];
|
|
180
|
+
for (let pc = 0; pc < 12; pc++) {
|
|
181
|
+
if ((played & (1 << pc)) !== 0 && !inScale.has(pc)) outside.push(pc);
|
|
182
|
+
}
|
|
183
|
+
if (outside.length === 0) return { state: 'fits' };
|
|
184
|
+
outside.sort((a, b) => mod12(a - harmony.rootPc) - mod12(b - harmony.rootPc));
|
|
185
|
+
const spelling = spellTable(harmony.rootPc, mode);
|
|
186
|
+
return { state: 'does-not-fit', outside: outside.map((pc) => spelling[pc]) };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The words for one fit. `fit` is a fitOf result; the rest names the cell: `track`, `section` and `variation` as written,
|
|
191
|
+
* `fixedHarmony` the harmony text of the variation, `sectionHarmonyText` the section's harmony text (needed for a fit and a
|
|
192
|
+
* non-fit). Returns `{ line, title, chip }`: `line` is `fits`, `does not fit (F#, A)` naming at most three notes and then
|
|
193
|
+
* ` and {n} more`, or `cannot check`; `title` holds every outside note or the one reason sentence; `chip` is the line with
|
|
194
|
+
* the separator the Ask chip of the cell ends with. An unknown state or reason throws.
|
|
195
|
+
*/
|
|
196
|
+
export function fitText(fit, { track, section, variation, fixedHarmony, sectionHarmonyText }) {
|
|
197
|
+
const values = { track: quoted(track), section: quoted(section), variation: quoted(variation), harmony: displayHarmony(fixedHarmony) };
|
|
198
|
+
const sectionText = () => displayHarmony(sectionHarmonyText);
|
|
199
|
+
if (fit.state === 'fits') {
|
|
200
|
+
const line = SONG_COPY.fitFits;
|
|
201
|
+
return { line, title: fillSong(SONG_COPY.fitTitleFits, { ...values, sectionHarmony: sectionText() }), chip: fillSong(SONG_COPY.fitChip, { fit: line }) };
|
|
202
|
+
}
|
|
203
|
+
if (fit.state === 'does-not-fit') {
|
|
204
|
+
const named = fit.outside.slice(0, NAMED_OUTSIDE).join(', ');
|
|
205
|
+
const more = fit.outside.length - NAMED_OUTSIDE;
|
|
206
|
+
const notes = more > 0 ? fillSong(SONG_COPY.fitMore, { notes: named, count: more }) : named;
|
|
207
|
+
const line = fillSong(SONG_COPY.fitNot, { notes });
|
|
208
|
+
const title = fillSong(SONG_COPY.fitTitleNot, { ...values, sectionHarmony: sectionText(), notes: fit.outside.join(', ') });
|
|
209
|
+
return { line, title, chip: fillSong(SONG_COPY.fitChip, { fit: line }) };
|
|
210
|
+
}
|
|
211
|
+
if (fit.state === 'cannot-check') {
|
|
212
|
+
if (!Object.hasOwn(SONG_COPY.fitReasons, fit.reason)) throw new Error(`Moontide: unknown fit reason '${String(fit.reason)}'.`);
|
|
213
|
+
const line = SONG_COPY.fitCannot;
|
|
214
|
+
return { line, title: fillSong(SONG_COPY.fitTitleCannot, { ...values, reason: SONG_COPY.fitReasons[fit.reason] }), chip: fillSong(SONG_COPY.fitChip, { fit: line }) };
|
|
215
|
+
}
|
|
216
|
+
throw new Error(`Moontide: unknown fit state '${String(fit.state)}'.`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The lookup the grid and the cell list use: `lookup(trackName, sectionIndex, key)` gives the fit of that fixed variation in
|
|
221
|
+
* that section entry as `{ ...fitOf result, ...fitText result }`, from the answer of collectFit, or null when the variation is
|
|
222
|
+
* not a fixed one of the reading. Each entry of a section that is used more than once gets its own length and harmony.
|
|
223
|
+
* Results are made once per variation and section entry.
|
|
224
|
+
*/
|
|
225
|
+
export function fitLookup({ reading, byPair }) {
|
|
226
|
+
const made = new Map();
|
|
227
|
+
const harmonies = reading.sections.map((section) => sectionHarmony(section));
|
|
228
|
+
return (trackName, sectionIndex, key) => {
|
|
229
|
+
const track = reading.tracks.find((candidate) => candidate.name === trackName);
|
|
230
|
+
const variation = track === undefined ? undefined : track.variations.find((candidate) => candidate.key === key);
|
|
231
|
+
const section = reading.sections[sectionIndex];
|
|
232
|
+
if (variation === undefined || variation.kind !== 'fixed' || section === undefined) return null;
|
|
233
|
+
const id = `${sectionIndex}\u0000${pairKey(trackName, key)}`;
|
|
234
|
+
if (!made.has(id)) {
|
|
235
|
+
const entry = byPair.get(pairKey(trackName, key));
|
|
236
|
+
if (entry === undefined) throw new Error(`Moontide: the fit answer has nothing for variation '${String(key)}' of track '${String(trackName)}'.`);
|
|
237
|
+
const harmony = harmonies[sectionIndex];
|
|
238
|
+
const fit = fitOf({ masks: entry.masks, length: section.length, harmony, readReason: entry.reason ?? null });
|
|
239
|
+
const words = fitText(fit, {
|
|
240
|
+
track: trackName,
|
|
241
|
+
section: section.name,
|
|
242
|
+
variation: key,
|
|
243
|
+
fixedHarmony: variation.fixedHarmony,
|
|
244
|
+
sectionHarmonyText: harmony.text ?? '',
|
|
245
|
+
});
|
|
246
|
+
made.set(id, { ...fit, ...words, outsideText: fit.state === 'does-not-fit' ? fit.outside.join(', ') : null });
|
|
247
|
+
}
|
|
248
|
+
return made.get(id);
|
|
249
|
+
};
|
|
250
|
+
}
|