moontide-mcp 2.0.2 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -6
- package/dist/ask.js +76 -15
- package/dist/mcp.js +198 -26
- package/dist/protocol.js +130 -2
- package/dist/reference.js +22 -0
- package/dist/request-status.js +221 -0
- package/dist/session.js +256 -7
- package/package.json +1 -1
- package/page/ask-view.js +64 -57
- package/page/ask.js +363 -188
- package/page/bar-model.js +43 -2
- package/page/change-timing.js +60 -4
- package/page/harmony.js +13 -0
- package/page/index.html +7 -4
- package/page/jam-analysis.js +68 -23
- package/page/jam-model.js +196 -31
- package/page/jam-resolve.js +271 -0
- package/page/jam-view.js +182 -10
- package/page/lights.js +300 -0
- package/page/main.js +2171 -188
- package/page/midi-clock.js +23 -4
- package/page/midi-out.js +17 -2
- 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 +289 -14
- package/page/note-edit.js +267 -0
- package/page/note-locate.js +346 -0
- package/page/note-panel.js +402 -0
- package/page/note-tag.js +141 -0
- package/page/page-rewrite.js +184 -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/song-edit.js +230 -0
- package/page/song-fit.js +250 -0
- package/page/song-grid-model.js +506 -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 +761 -0
- package/page/style.css +1218 -65
- package/page/takeover.js +20 -5
- package/page/tempo-glide.js +330 -0
- package/page/tempo-guard.js +62 -10
- package/page/timeline-edit-view.js +1956 -0
- package/page/timeline-gesture.js +279 -0
- package/page/timeline.js +197 -3
- package/page/top-bar-view.js +18 -13
- package/page/tracks.js +37 -8
- package/page/ui-state.js +10 -5
- package/page/views.js +39 -9
- package/reference/functions.md +1 -0
- package/reference/midi.md +2 -0
- package/reference/structure.md +131 -0
- package/reference/transitions.md +13 -1
package/page/bar-model.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { ASK_COPY } from './ask.js';
|
|
8
8
|
import { MIDI_COPY } from './midi-plan.js';
|
|
9
|
-
import { BEATS_PER_CYCLE, bpmText } from './change-timing.js';
|
|
9
|
+
import { BEATS_PER_CYCLE, bpmText, fillChange } from './change-timing.js';
|
|
10
10
|
import { BUILD_MISMATCH_COPY, CONNECTION_LABELS, SERVER_UPDATED_COPY, SOUND_LABELS, errorLineText } from './ui-state.js';
|
|
11
11
|
|
|
12
12
|
/** The copy the bar and the Session drawer add. */
|
|
@@ -60,6 +60,13 @@ export const BAR_COPY = Object.freeze({
|
|
|
60
60
|
tempoTitle: 'Tempo of the music that plays',
|
|
61
61
|
tempoTitleIdle: 'Nothing plays',
|
|
62
62
|
tempoNameIdle: 'No tempo, nothing plays',
|
|
63
|
+
// While a tempo ramp runs: the accessible name and the hover text say the direction, the target and the bars left.
|
|
64
|
+
tempoNameRising: 'Tempo {num} BPM, rising to {target} BPM',
|
|
65
|
+
tempoNameFalling: 'Tempo {num} BPM, falling to {target} BPM',
|
|
66
|
+
tempoTitleUp: 'Tempo moving up to {target} BPM. {left}',
|
|
67
|
+
tempoTitleDown: 'Tempo moving down to {target} BPM. {left}',
|
|
68
|
+
tempoBarsLeft: '{k} bars left.',
|
|
69
|
+
tempoOneBarLeft: '1 bar left.',
|
|
63
70
|
// The failed-change banner: the lead of each kind, and the three sentences about the music. The first two
|
|
64
71
|
// equal the server's NOTHING_PLAYING_TAIL and STILL_STOPPED_TAIL; the third is the user-facing wording of
|
|
65
72
|
// STILL_PLAYING_TAIL ('The previous code is still playing.' stays the server's text for Claude). A test pins all three.
|
|
@@ -218,8 +225,15 @@ export function cycleBeat(now) {
|
|
|
218
225
|
* the waiting tempo differs from the playing one after that rounding. Returns { idle, num, arrow, unit, text, name,
|
|
219
226
|
* title }: `arrow` is '→ 124' or '', `unit` is 'BPM' (the view puts the spaces), `text` the whole line and `name`
|
|
220
227
|
* the accessible name. A number bpmText cannot format throws; the caller logs it and shows the idle form.
|
|
228
|
+
*
|
|
229
|
+
* `ramp` is null or absent, or `{ targetBpm, barsLeft }` while a tempo ramp runs (`rising` is an optional true or false
|
|
230
|
+
* that says the direction when the live tempo already reads like the target). A ramp wins over `waitingBpm`. The number is then
|
|
231
|
+
* the live tempo rounded to a whole BPM (after bpmText has validated it), the arrow part is the target through bpmText
|
|
232
|
+
* and is empty when the number reads like the target, the name says 'rising to' or 'falling to' and the hover text says
|
|
233
|
+
* 'moving up' or 'moving down' with the whole bars left (a whole number from 1; '1 bar left.' for one). A call without
|
|
234
|
+
* a ramp returns exactly what it returned before the ramp existed. A bad ramp throws.
|
|
221
235
|
*/
|
|
222
|
-
export function tempoText({ playing, bpm, waitingBpm }) {
|
|
236
|
+
export function tempoText({ playing, bpm, waitingBpm, ramp }) {
|
|
223
237
|
requireBoolean('playing', playing);
|
|
224
238
|
if (!playing) {
|
|
225
239
|
return {
|
|
@@ -233,6 +247,7 @@ export function tempoText({ playing, bpm, waitingBpm }) {
|
|
|
233
247
|
};
|
|
234
248
|
}
|
|
235
249
|
const num = bpmText(bpm);
|
|
250
|
+
if (ramp !== null && ramp !== undefined) return rampTempoText(bpm, ramp);
|
|
236
251
|
const hasWaiting = waitingBpm !== null && waitingBpm !== undefined;
|
|
237
252
|
const waiting = hasWaiting ? bpmText(waitingBpm) : null;
|
|
238
253
|
const changing = waiting !== null && waiting !== num;
|
|
@@ -247,6 +262,32 @@ export function tempoText({ playing, bpm, waitingBpm }) {
|
|
|
247
262
|
};
|
|
248
263
|
}
|
|
249
264
|
|
|
265
|
+
/** The readout while a tempo ramp runs; `bpm` was validated by the caller. See tempoText. */
|
|
266
|
+
function rampTempoText(bpm, ramp) {
|
|
267
|
+
if (typeof ramp !== 'object') {
|
|
268
|
+
throw new Error(`Moontide: the tempo ramp must be an object with targetBpm and barsLeft, got ${JSON.stringify(ramp)}.`);
|
|
269
|
+
}
|
|
270
|
+
const { targetBpm, barsLeft, rising } = ramp;
|
|
271
|
+
const target = bpmText(targetBpm);
|
|
272
|
+
if (!Number.isInteger(barsLeft) || barsLeft < 1) {
|
|
273
|
+
throw new Error(`Moontide: the bars left of a tempo ramp must be a whole number from 1, got ${JSON.stringify(barsLeft)}.`);
|
|
274
|
+
}
|
|
275
|
+
if (rising !== undefined) requireBoolean('rising', rising);
|
|
276
|
+
const num = String(Math.round(bpm));
|
|
277
|
+
const up = rising !== undefined ? rising : targetBpm > bpm;
|
|
278
|
+
const arrow = num === target ? '' : `${BAR_COPY.tempoArrow} ${target}`;
|
|
279
|
+
const left = barsLeft === 1 ? BAR_COPY.tempoOneBarLeft : fillChange(BAR_COPY.tempoBarsLeft, { k: barsLeft });
|
|
280
|
+
return {
|
|
281
|
+
idle: false,
|
|
282
|
+
num,
|
|
283
|
+
arrow,
|
|
284
|
+
unit: BAR_COPY.tempoUnit,
|
|
285
|
+
text: arrow === '' ? `${num} ${BAR_COPY.tempoUnit}` : `${num} ${arrow} ${BAR_COPY.tempoUnit}`,
|
|
286
|
+
name: fillChange(up ? BAR_COPY.tempoNameRising : BAR_COPY.tempoNameFalling, { num, target }),
|
|
287
|
+
title: fillChange(up ? BAR_COPY.tempoTitleUp : BAR_COPY.tempoTitleDown, { target, left }),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
250
291
|
const BANNER_KINDS = Object.freeze(['change', 'notice']);
|
|
251
292
|
|
|
252
293
|
/**
|
package/page/change-timing.js
CHANGED
|
@@ -76,6 +76,23 @@ export function bpmOf(cps) {
|
|
|
76
76
|
return Math.round(cps * 60 * BEATS_PER_CYCLE * 10) / 10;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* The timing of a change that must take over on one given line: a section launch writes a shift for that very line, so
|
|
81
|
+
* the takeover has to take the same one. `at` is 'section' (the end of the section that plays), 'bar' or 'phrase', and
|
|
82
|
+
* `atCycle` is the line, a finite cycle number. Returns a frozen { at, lineLength, atCycle }: `lineLength` (1 for a
|
|
83
|
+
* section end and a bar, the phrase length for a phrase) is the line the takeover falls back to when the pinned one has
|
|
84
|
+
* come too close. CHANGE_TIMINGS is not extended: it is the closed set of the stored setting and of the server's schema.
|
|
85
|
+
*/
|
|
86
|
+
export function pinnedTiming(at, atCycle) {
|
|
87
|
+
if (at !== 'section' && at !== 'bar' && at !== 'phrase') {
|
|
88
|
+
throw new Error(`Moontide: a pinned change timing is 'section', 'bar' or 'phrase', got '${String(at)}'.`);
|
|
89
|
+
}
|
|
90
|
+
if (typeof atCycle !== 'number' || !Number.isFinite(atCycle)) {
|
|
91
|
+
throw new Error(`Moontide: a pinned change timing needs a finite line, got ${String(atCycle)}.`);
|
|
92
|
+
}
|
|
93
|
+
return Object.freeze({ at, lineLength: at === 'phrase' ? DEFAULT_PHRASE_CYCLES : 1, atCycle });
|
|
94
|
+
}
|
|
95
|
+
|
|
79
96
|
/**
|
|
80
97
|
* The cycle a timed change takes over on: the first multiple of `lineLength` strictly after the scheduler
|
|
81
98
|
* frontier `frontier` (the cycle the scheduler has already queried up to), moved one line further while
|
|
@@ -111,6 +128,7 @@ export const CHANGE_COPY = Object.freeze({
|
|
|
111
128
|
countdownOneBeat: 'in 1 beat',
|
|
112
129
|
countdownTitleBar: 'A code change is waiting. It takes over on the next bar.',
|
|
113
130
|
countdownTitlePhrase: 'A code change is waiting. It takes over on the next phrase.',
|
|
131
|
+
countdownTitleSection: 'A code change is waiting. It takes over at the end of the section.',
|
|
114
132
|
// The Edits timing group of the Settings card: three buttons. The visible word of each, and its accessible name.
|
|
115
133
|
timingNow: 'now',
|
|
116
134
|
timingBar: 'next bar',
|
|
@@ -121,10 +139,18 @@ export const CHANGE_COPY = Object.freeze({
|
|
|
121
139
|
timingTitle: "When your edits (Ctrl+Enter) take over: now, on the next bar or on the next phrase. Claude's changes choose their own timing.",
|
|
122
140
|
queuedBar: 'Change waiting: it takes over on the next bar.',
|
|
123
141
|
queuedPhrase: 'Change waiting: it takes over on the next phrase.',
|
|
142
|
+
queuedSection: 'Change waiting: it takes over at the end of the section.',
|
|
124
143
|
canceled: 'Change canceled.',
|
|
125
144
|
settingNow: 'Your edits now take over: now.',
|
|
126
145
|
settingBar: 'Your edits now take over: on the next bar.',
|
|
127
146
|
settingPhrase: 'Your edits now take over: on the next phrase.',
|
|
147
|
+
// The four sentences a tempo ramp speaks in the live region: at its start, at its end and when the code cancels it.
|
|
148
|
+
// {from}, {to} and {bpm} go through bpmText and {n} is a whole number of bars. Nothing is spoken per step.
|
|
149
|
+
rampStarted: 'Tempo ramp started: {from} to {to} BPM over {n} bars.',
|
|
150
|
+
rampStartedOneBar: 'Tempo ramp started: {from} to {to} BPM over 1 bar.',
|
|
151
|
+
rampFinished: 'Tempo ramp finished at {to} BPM.',
|
|
152
|
+
rampCanceledOtherTempo: 'Tempo ramp canceled. The code sets {bpm} BPM.',
|
|
153
|
+
rampCanceledNoTempo: 'Tempo ramp canceled. Tempo stays at {bpm} BPM.',
|
|
128
154
|
});
|
|
129
155
|
|
|
130
156
|
// The fit steps of the top bar, in the order text is given up: 0 nothing, 1 the decoration
|
|
@@ -167,11 +193,12 @@ export function countdownParts(beatsLeft) {
|
|
|
167
193
|
return { lead, rest: CHANGE_COPY.countdownOneBeat };
|
|
168
194
|
}
|
|
169
195
|
|
|
170
|
-
/** The hover title of the countdown for a change waiting on 'bar' or '
|
|
196
|
+
/** The hover title of the countdown for a change waiting on 'bar', 'phrase' or (a section launch) 'section'. */
|
|
171
197
|
export function countdownTitle(at) {
|
|
172
198
|
if (at === 'bar') return CHANGE_COPY.countdownTitleBar;
|
|
173
199
|
if (at === 'phrase') return CHANGE_COPY.countdownTitlePhrase;
|
|
174
|
-
|
|
200
|
+
if (at === 'section') return CHANGE_COPY.countdownTitleSection;
|
|
201
|
+
throw new Error(`Moontide: a waiting change has the timing 'bar', 'phrase' or 'section', got '${String(at)}'.`);
|
|
175
202
|
}
|
|
176
203
|
|
|
177
204
|
/**
|
|
@@ -188,11 +215,12 @@ export function timingOptions() {
|
|
|
188
215
|
return CHANGE_TIMINGS.map((value) => Object.freeze({ value, label: copy[value][0], name: copy[value][1] }));
|
|
189
216
|
}
|
|
190
217
|
|
|
191
|
-
/** The spoken line for a change that just started to wait, for 'bar' or '
|
|
218
|
+
/** The spoken line for a change that just started to wait, for 'bar', 'phrase' or 'section'. */
|
|
192
219
|
export function queuedLine(at) {
|
|
193
220
|
if (at === 'bar') return CHANGE_COPY.queuedBar;
|
|
194
221
|
if (at === 'phrase') return CHANGE_COPY.queuedPhrase;
|
|
195
|
-
|
|
222
|
+
if (at === 'section') return CHANGE_COPY.queuedSection;
|
|
223
|
+
throw new Error(`Moontide: a waiting change has the timing 'bar', 'phrase' or 'section', got '${String(at)}'.`);
|
|
196
224
|
}
|
|
197
225
|
|
|
198
226
|
/** The spoken line after a press of the setting button, for the value it now has. */
|
|
@@ -202,3 +230,31 @@ export function settingLine(value) {
|
|
|
202
230
|
if (value === 'phrase') return CHANGE_COPY.settingPhrase;
|
|
203
231
|
throw new Error(`Moontide: unknown change timing '${String(value)}' (expected one of ${CHANGE_TIMINGS.join(', ')}).`);
|
|
204
232
|
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The spoken line of a tempo ramp, for the three events that are told: `{ kind: 'started', fromBpm, toBpm, bars }`,
|
|
236
|
+
* `{ kind: 'finished', toBpm }` and `{ kind: 'canceled', reason, bpm }` with the reason 'other-tempo' (the code states
|
|
237
|
+
* another tempo, which plays now) or 'no-tempo' (the code states none, the tempo stays). The tempos go through bpmText and
|
|
238
|
+
* `bars` is a whole number from 1; anything else throws a `Moontide:` Error. Stop, a reload and a replaced ramp are not told.
|
|
239
|
+
*/
|
|
240
|
+
export function rampLine(event) {
|
|
241
|
+
if (event === null || typeof event !== 'object') {
|
|
242
|
+
throw new Error(`Moontide: a ramp line needs an event, got ${String(event)}.`);
|
|
243
|
+
}
|
|
244
|
+
if (event.kind === 'started') {
|
|
245
|
+
if (!Number.isInteger(event.bars) || event.bars < 1) {
|
|
246
|
+
throw new Error(`Moontide: a ramp line needs a whole number of bars from 1, got ${String(event.bars)}.`);
|
|
247
|
+
}
|
|
248
|
+
const template = event.bars === 1 ? CHANGE_COPY.rampStartedOneBar : CHANGE_COPY.rampStarted;
|
|
249
|
+
return fillChange(template, { from: bpmText(event.fromBpm), to: bpmText(event.toBpm), n: event.bars });
|
|
250
|
+
}
|
|
251
|
+
if (event.kind === 'finished') {
|
|
252
|
+
return fillChange(CHANGE_COPY.rampFinished, { to: bpmText(event.toBpm) });
|
|
253
|
+
}
|
|
254
|
+
if (event.kind === 'canceled') {
|
|
255
|
+
if (event.reason === 'other-tempo') return fillChange(CHANGE_COPY.rampCanceledOtherTempo, { bpm: bpmText(event.bpm) });
|
|
256
|
+
if (event.reason === 'no-tempo') return fillChange(CHANGE_COPY.rampCanceledNoTempo, { bpm: bpmText(event.bpm) });
|
|
257
|
+
throw new Error(`Moontide: a canceled ramp is told for 'other-tempo' or 'no-tempo', not '${String(event.reason)}'.`);
|
|
258
|
+
}
|
|
259
|
+
throw new Error(`Moontide: a ramp line is told for 'started', 'finished' or 'canceled', not '${String(event.kind)}'.`);
|
|
260
|
+
}
|
package/page/harmony.js
CHANGED
|
@@ -157,6 +157,19 @@ export function readHarmony(code, { parse }) {
|
|
|
157
157
|
return { state: 'none' };
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Reads one harmony text, as written in a section of a song or in a fixed variation: `{ kind: 'scale' | 'chords', scale }`
|
|
162
|
+
* with `scale` `{ rootPc, modeId }` only when the whole text is one scale token whose name is one of the nine scales (else
|
|
163
|
+
* null), or null when the text is neither a scale nor chords (nothing left to read, too long, a plain word). The same
|
|
164
|
+
* reading as a top-level declaration: readHarmony calls the same checks.
|
|
165
|
+
*/
|
|
166
|
+
export function readHarmonyText(text) {
|
|
167
|
+
if (text.length > MAX_HARMONY_TEXT) return null;
|
|
168
|
+
const found = classify(text);
|
|
169
|
+
if (found === null || found === 'empty') return null;
|
|
170
|
+
return { kind: found, scale: found === 'scale' ? scaleOf(text.trim()) : null };
|
|
171
|
+
}
|
|
172
|
+
|
|
160
173
|
function unreadable(reason, source = null) {
|
|
161
174
|
return { state: 'unreadable', reason, source };
|
|
162
175
|
}
|
package/page/index.html
CHANGED
|
@@ -47,9 +47,10 @@
|
|
|
47
47
|
<span class="layout-word"></span>
|
|
48
48
|
<div id="layout-buttons" role="group" aria-label="Layout"></div>
|
|
49
49
|
<span class="bar-sep" aria-hidden="true"></span>
|
|
50
|
-
<
|
|
50
|
+
<button type="button" id="requests-toggle" aria-expanded="false" aria-controls="requests-panel" hidden><span class="rq-word"></span><span class="rq-count" hidden></span></button>
|
|
51
51
|
<button type="button" id="session-button" aria-expanded="false" aria-controls="session-drawer"><span class="session-label"></span><span id="session-count" hidden></span><span class="session-chevron" aria-hidden="true"></span></button>
|
|
52
52
|
</header>
|
|
53
|
+
<div id="requests-panel" role="region" tabindex="-1" hidden></div>
|
|
53
54
|
<div id="problem-banner" data-kind="change" tabindex="-1" hidden><span class="problem-lead"></span><span class="problem-consequence"></span><span id="eval-error" hidden></span><span class="problem-actions"><button type="button" id="problem-details-toggle" aria-expanded="false" aria-controls="problem-details"></button><button type="button" id="problem-copy"></button><button type="button" id="problem-ask" hidden></button><button type="button" id="problem-dismiss"></button></span><p id="problem-copy-failure" hidden></p><pre id="problem-details" hidden></pre></div>
|
|
54
55
|
<div id="problem-alert" role="alert"></div>
|
|
55
56
|
<div id="problem-menu" role="group" hidden><button type="button" id="problem-menu-copy"></button><button type="button" id="problem-menu-ask"></button><button type="button" id="problem-menu-dismiss"></button></div>
|
|
@@ -85,6 +86,7 @@
|
|
|
85
86
|
<p class="session-hint session-empty" data-copy="noChange"></p>
|
|
86
87
|
</section>
|
|
87
88
|
</div>
|
|
89
|
+
<span id="requests-live" role="status" aria-live="polite"></span>
|
|
88
90
|
</div>
|
|
89
91
|
<div id="workspace">
|
|
90
92
|
<main id="editor-area"><strudel-editor id="repl"></strudel-editor></main>
|
|
@@ -97,13 +99,14 @@
|
|
|
97
99
|
<div id="timeline-body" hidden>
|
|
98
100
|
<div id="timeline-ruler"></div>
|
|
99
101
|
<div id="timeline-sections"></div>
|
|
100
|
-
<div id="timeline-lanes"></div>
|
|
102
|
+
<div id="timeline-lanes" tabindex="0" role="group" aria-label="Timeline notes"></div>
|
|
101
103
|
<div id="timeline-playhead"><span class="playhead-cycle"></span></div>
|
|
102
104
|
</div>
|
|
105
|
+
<section id="timeline-note" aria-label="Note" hidden><p id="timeline-note-line" role="status" aria-live="polite"></p></section>
|
|
103
106
|
</div>
|
|
104
107
|
<div id="mixer-view" hidden><p id="mixer-notice" hidden></p><p id="mixer-message" hidden></p><div id="mixer-body" hidden><div id="mixer-strips"></div><div id="mixer-inspector" hidden></div></div><p id="mixer-nonotes" hidden></p></div>
|
|
105
|
-
<div id="parts-view" hidden><p id="parts-message"></p><button type="button" id="parts-ask" hidden></button><p id="parts-ask-status" hidden></p><div id="parts-rows"></div></div>
|
|
106
|
-
<div id="jam-view" hidden><div id="jam-message" hidden><p id="jam-message-text"></p><p id="jam-message-sub" hidden></p></div><div id="jam-body" hidden><div id="jam-info"><p id="jam-declared" hidden><span id="jam-declared-label"></span><span id="jam-declared-value"></span></p><div id="jam-scale"><span id="jam-scale-label" hidden></span><h2 id="jam-scale-name"></h2><p id="jam-scale-sub"></p></div><div id="jam-degrees" role="list"></div><div id="jam-status" role="status" aria-live="polite" hidden><p id="jam-status-text"><strong id="jam-status-lead"></strong><span id="jam-status-rest"></span></p><span id="jam-status-time" aria-hidden="true"></span><span id="jam-status-spoken"></span></div><p id="jam-paused" hidden></p><div id="jam-coming"><p id="jam-coming-caption"></p><div id="jam-rows"></div></div></div><div id="jam-instrument"><div id="jam-controls"><div id="jam-instrument-switch" role="group"><button type="button" id="jam-instrument-piano">Piano</button><button type="button" id="jam-instrument-guitar">Guitar</button></div><label id="jam-chord-tones-label"><input type="checkbox" id="jam-chord-tones"> <span id="jam-chord-tones-text"></span></label><div id="jam-legend"><span id="jam-legend-chord" class="jam-legend-entry" hidden></span></div></div><p id="jam-chord" hidden></p><div id="jam-board"><div id="jam-piano" role="img"></div><div id="jam-guitar" role="img" hidden></div></div></div></div></div>
|
|
108
|
+
<div id="parts-view" hidden><p id="parts-message"></p><button type="button" id="parts-ask" hidden></button><p id="parts-ask-status" hidden></p><div id="song-notes" hidden></div><div id="song-bar" hidden><span id="song-launch-label"></span><div id="song-launch" role="group" aria-labelledby="song-launch-label"></div><p id="song-now" hidden></p></div><p id="song-line" hidden role="status" aria-live="polite"></p><div id="parts-rows"></div><div id="song-cell-list" hidden role="listbox" tabindex="-1"></div></div>
|
|
109
|
+
<div id="jam-view" hidden><div id="jam-message" hidden><p id="jam-message-text"></p><p id="jam-message-sub" hidden></p></div><div id="jam-body" hidden><div id="jam-info"><p id="jam-declared" hidden><span id="jam-declared-label"></span><span id="jam-declared-value"></span><span id="jam-declared-section" hidden></span></p><div id="jam-scale"><span id="jam-scale-label" hidden></span><h2 id="jam-scale-name"></h2><p id="jam-scale-sub"></p></div><p id="jam-reason" aria-live="polite" hidden><strong id="jam-reason-lead"></strong><span id="jam-reason-rest"></span></p><div id="jam-candidates" role="group" aria-labelledby="jam-candidates-label" hidden><span id="jam-candidates-label"></span><div id="jam-candidate-list"></div><span id="jam-candidates-more" hidden></span></div><div id="jam-degrees" role="list"></div><div id="jam-status" role="status" aria-live="polite" hidden><p id="jam-status-text"><strong id="jam-status-lead"></strong><span id="jam-status-rest"></span></p><span id="jam-status-time" aria-hidden="true"></span><span id="jam-status-spoken"></span></div><p id="jam-paused" hidden></p><div id="jam-coming"><p id="jam-coming-caption"></p><div id="jam-rows"></div></div></div><div id="jam-instrument"><div id="jam-controls"><div id="jam-instrument-switch" role="group"><button type="button" id="jam-instrument-piano">Piano</button><button type="button" id="jam-instrument-guitar">Guitar</button></div><label id="jam-chord-tones-label"><input type="checkbox" id="jam-chord-tones"> <span id="jam-chord-tones-text"></span></label><div id="jam-legend"><span id="jam-legend-chord" class="jam-legend-entry" hidden></span></div></div><p id="jam-chord" hidden></p><div id="jam-board"><div id="jam-piano" role="img"></div><div id="jam-guitar" role="img" hidden></div></div></div></div></div>
|
|
107
110
|
<div id="instruments-view" hidden tabindex="-1"><p id="instruments-notice" hidden></p><div id="instruments-message" hidden><p id="instruments-message-text"></p><code id="instruments-folder" hidden></code><p id="instruments-message-sub" hidden></p><ul id="instruments-problems" hidden></ul><p id="instruments-message-closing" hidden></p></div><div id="instruments-body" hidden><ul id="instruments-list" role="list" aria-label="Instruments"></ul><p id="instruments-footer"></p></div><span id="instruments-status" role="status" aria-live="polite"></span></div>
|
|
108
111
|
</section>
|
|
109
112
|
</div>
|
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;
|