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
package/page/ask.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
// Pure module for the Ask Claude box: the copy, the length rules, the context
|
|
2
|
-
// descriptors
|
|
2
|
+
// descriptors and the box placement. No DOM access at import time,
|
|
3
3
|
// so node:test and page/main.js import it directly with no build step. The box's own DOM lives in
|
|
4
4
|
// page/ask-view.js.
|
|
5
5
|
|
|
6
6
|
import { BEATS_PER_CYCLE } from './change-timing.js';
|
|
7
7
|
import { MAX_MOMENT_NOTES, MAX_RANGE_CYCLES } from './timeline.js';
|
|
8
8
|
import { cleanText } from './structure.js';
|
|
9
|
+
import { GRIDS, MAX_CYCLES, MIN_CYCLES, MOTIF_SOURCES, assertPhraseCode, cyclesText, notesText } from './scratch-model.js';
|
|
9
10
|
|
|
10
11
|
/** Mirrors src/protocol.ts's MAX_ASK_TEXT_LENGTH by hand (test/ask.test.js checks both are 2000). */
|
|
11
12
|
export const MAX_ASK_TEXT_LENGTH = 2000;
|
|
@@ -13,6 +14,12 @@ export const MAX_ASK_TEXT_LENGTH = 2000;
|
|
|
13
14
|
/** Mirrors src/protocol.ts's MAX_REQUEST_LINE_BYTES by hand (test/ask.test.js checks both are 8192). */
|
|
14
15
|
export const MAX_REQUEST_LINE_BYTES = 8192;
|
|
15
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Mirrors src/protocol.ts's MAX_MOTIF_PHRASE_LENGTH by hand (test/ask.test.js checks both are 8000). The motif's sources, grid ids, cycle bounds and
|
|
19
|
+
* phrase shape come from page/scratch-model.js, which src/protocol.ts mirrors by hand (the same test checks them).
|
|
20
|
+
*/
|
|
21
|
+
export const MAX_MOTIF_PHRASE_LENGTH = 8000;
|
|
22
|
+
|
|
16
23
|
/** Mirrors src/ask.ts's REQUEST_LINE_PREFIX by hand (test/ask.test.js checks both). */
|
|
17
24
|
export const REQUEST_LINE_PREFIX = 'Request from the Moontide page:';
|
|
18
25
|
|
|
@@ -25,12 +32,6 @@ export const PAGE_TOKEN_PLACEHOLDER = '__MOONTIDE_PAGE_TOKEN__';
|
|
|
25
32
|
/** No answer from the server within this long: the request is reported as not confirmed. */
|
|
26
33
|
export const ASK_CONFIRM_TIMEOUT_MS = 5000;
|
|
27
34
|
|
|
28
|
-
/** A non-error status stays readable at least this long before the next one replaces it. */
|
|
29
|
-
export const STATUS_MIN_SHOWN_MS = 600;
|
|
30
|
-
|
|
31
|
-
/** "Claude received it" is shown this long, then the status hides. */
|
|
32
|
-
export const RECEIVED_SHOWN_MS = 4000;
|
|
33
|
-
|
|
34
35
|
/** From this many characters the box shows a counter, so the limit is seen before it is hit. */
|
|
35
36
|
export const COUNTER_FROM = 1800;
|
|
36
37
|
|
|
@@ -52,9 +53,6 @@ export const ASK_COPY = {
|
|
|
52
53
|
disconnected: 'Disconnected — asking Claude is off until Moontide reconnects.',
|
|
53
54
|
notSent: 'This request was not sent. Press Enter to try again.',
|
|
54
55
|
notSentToken: 'This request was not sent.',
|
|
55
|
-
sent: 'Sent to Claude',
|
|
56
|
-
received: 'Claude received it',
|
|
57
|
-
waiting: 'Claude will see it on its next action',
|
|
58
56
|
tooLong: 'request too long — 2000 characters at most.',
|
|
59
57
|
lineTooLong: 'request too long to send — some characters take extra room, so shorten the text.',
|
|
60
58
|
rateLimit: 'too many requests — wait a moment, then send again.',
|
|
@@ -121,33 +119,6 @@ export function pageRequestsText(live) {
|
|
|
121
119
|
throw new Error(`Moontide: the page-requests state must be true or false, got ${JSON.stringify(live)}.`);
|
|
122
120
|
}
|
|
123
121
|
|
|
124
|
-
/** The status text for requests handed to Claude: one, or a count. */
|
|
125
|
-
export function receivedText(count) {
|
|
126
|
-
return count === 1 ? ASK_COPY.received : `Claude received ${count} requests`;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** The status text for requests that wait on the server: one, or a count. */
|
|
130
|
-
export function waitingText(count) {
|
|
131
|
-
return count === 1 ? ASK_COPY.waiting : `Claude will see ${count} requests on its next action`;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* The short forms of the status texts, shown at fit step 3 of the top bar when the long
|
|
136
|
-
* sentence no longer fits. They are made here, next to the long texts, and travel with them: nothing shortens a long
|
|
137
|
-
* text by matching strings afterwards. The long text stays in the accessibility tree.
|
|
138
|
-
*/
|
|
139
|
-
export const ASK_SHORT_COPY = Object.freeze({ sent: 'Sent', received: 'Received', waiting: 'Waiting' });
|
|
140
|
-
|
|
141
|
-
/** The short form of receivedText(count). */
|
|
142
|
-
export function receivedShortText(count) {
|
|
143
|
-
return count === 1 ? ASK_SHORT_COPY.received : `${count} received`;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** The short form of waitingText(count). */
|
|
147
|
-
export function waitingShortText(count) {
|
|
148
|
-
return count === 1 ? ASK_SHORT_COPY.waiting : `${count} waiting`;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
122
|
/** 'empty' | 'too-long' | null for the text the user typed (counted on the trimmed text, in UTF-16 units,
|
|
152
123
|
* the same count the server uses). */
|
|
153
124
|
export function askTextProblem(text) {
|
|
@@ -190,8 +161,8 @@ export function askMessage({ connected, length, notSent, tokenRefused, lineTooLo
|
|
|
190
161
|
|
|
191
162
|
/**
|
|
192
163
|
* The Parts view's "Ask Claude to add sections" button: disabled while asking is off or its request is
|
|
193
|
-
* pending, with the line under it saying why. `statusText` is the
|
|
194
|
-
*
|
|
164
|
+
* pending, with the line under it saying why. `statusText` is the state phrase of that request's row in the
|
|
165
|
+
* Requests list, or null.
|
|
195
166
|
*/
|
|
196
167
|
export function partsAskState({ connected, pending, statusText }) {
|
|
197
168
|
if (!connected) return { disabled: true, line: ASK_COPY.disconnected };
|
|
@@ -243,6 +214,28 @@ function optionalText(descriptor, field) {
|
|
|
243
214
|
return cutText(value);
|
|
244
215
|
}
|
|
245
216
|
|
|
217
|
+
/**
|
|
218
|
+
* The section names of a range: undefined when the descriptor has none, else a list of 1 to MAX_RANGE_CYCLES non-empty strings
|
|
219
|
+
* (a range crosses at most one section per cycle). A name is not cut here.
|
|
220
|
+
*/
|
|
221
|
+
function rangeSectionNames(descriptor) {
|
|
222
|
+
const names = descriptor.sections;
|
|
223
|
+
if (names === undefined || names === null) return undefined;
|
|
224
|
+
if (!Array.isArray(names) || names.length < 1 || names.length > MAX_RANGE_CYCLES || names.some((name) => typeof name !== 'string' || name === '')) {
|
|
225
|
+
throw missing(descriptor.kind, `sections as 1 to ${MAX_RANGE_CYCLES} non-empty names`);
|
|
226
|
+
}
|
|
227
|
+
return names;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The count of sections a range leaves out: undefined when absent, else a whole number from 1 to MAX_RANGE_CYCLES - 1. */
|
|
231
|
+
function rangeMoreSections(descriptor, names) {
|
|
232
|
+
const more = descriptor.moreSections;
|
|
233
|
+
if (more === undefined || more === null) return undefined;
|
|
234
|
+
if (names === undefined) throw missing(descriptor.kind, 'moreSections only with sections');
|
|
235
|
+
if (!Number.isInteger(more) || more < 1 || more > MAX_RANGE_CYCLES - 1) throw missing(descriptor.kind, `moreSections as a whole number from 1 to ${MAX_RANGE_CYCLES - 1}`);
|
|
236
|
+
return more;
|
|
237
|
+
}
|
|
238
|
+
|
|
246
239
|
function requiredInteger(descriptor, field, min) {
|
|
247
240
|
const value = descriptor[field];
|
|
248
241
|
if (!Number.isInteger(value) || value < min) throw missing(descriptor.kind, `a whole number ${field} of at least ${min}`);
|
|
@@ -393,14 +386,50 @@ function checkedMoment(kind, moment) {
|
|
|
393
386
|
return { cycle: moment.cycle, beat: moment.beat, at: moment.at, version: moment.version, notes, more: moment.more };
|
|
394
387
|
}
|
|
395
388
|
|
|
389
|
+
/**
|
|
390
|
+
* A motif of a motif descriptor checked by the schema's rules (src/protocol.ts's askMotifShape) and copied field by field: the source, the cycle
|
|
391
|
+
* when there is one (a whole number of at least 0), the cycles, the grid, the notes count and the phrase, which must have the phrase shape. The
|
|
392
|
+
* phrase is never cut. A wrong value throws like every other context, so a bug is loud.
|
|
393
|
+
*/
|
|
394
|
+
function checkedMotif(motif) {
|
|
395
|
+
const bad = (field) => missing('motif', field);
|
|
396
|
+
if (motif === null || typeof motif !== 'object') throw bad('a motif');
|
|
397
|
+
const { source, cycle, cycles, grid, notes, phrase } = motif;
|
|
398
|
+
if (!MOTIF_SOURCES.includes(source)) throw bad(`a source of ${MOTIF_SOURCES.join(', ')}`);
|
|
399
|
+
if (cycle !== undefined && !isNonNegativeInteger(cycle)) throw bad('a whole number cycle of at least 0, or no cycle');
|
|
400
|
+
if (!Number.isInteger(cycles) || cycles < MIN_CYCLES || cycles > MAX_CYCLES) throw bad(`cycles as a whole number from ${MIN_CYCLES} to ${MAX_CYCLES}`);
|
|
401
|
+
if (!GRIDS.some((candidate) => candidate.id === grid)) throw bad(`a grid of ${GRIDS.map((candidate) => candidate.id).join(', ')}`);
|
|
402
|
+
if (!Number.isInteger(notes) || notes < 1) throw bad('a notes count of at least 1');
|
|
403
|
+
if (typeof phrase !== 'string' || phrase === '' || phrase.length > MAX_MOTIF_PHRASE_LENGTH) throw bad(`a phrase of 1 to ${MAX_MOTIF_PHRASE_LENGTH} characters`);
|
|
404
|
+
assertPhraseCode(phrase);
|
|
405
|
+
return { source, ...(cycle === undefined ? {} : { cycle }), cycles, grid, notes, phrase };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* The chip of a motif context: the source, the cycles, the notes and, when there is a moment, its cycle, cut to the context limit. The
|
|
410
|
+
* motif is not checked here (describeContext checks it); the Ask view also uses this to measure the line of a motif it refuses.
|
|
411
|
+
*/
|
|
412
|
+
export function motifChip(motif) {
|
|
413
|
+
return cutText(`Motif${SEPARATOR}${motif.source}${SEPARATOR}${cyclesText(motif.cycles)}${SEPARATOR}${notesText(motif.notes)}${motif.cycle === undefined ? '' : `${SEPARATOR}at cycle ${motif.cycle}`}`);
|
|
414
|
+
}
|
|
415
|
+
|
|
396
416
|
/**
|
|
397
417
|
* The size in bytes (UTF-8) of the line the server builds from one request: src/ask.ts's requestLine, the
|
|
398
418
|
* prefix, a space and the JSON of the fields in the same order (U+2028 and U+2029 escaped), so the page can
|
|
399
419
|
* tell before it sends whether the server will take the line.
|
|
400
420
|
*/
|
|
401
|
-
export function requestLineBytes({ id, text, context, chip, current, moment, gesture }) {
|
|
402
|
-
// The moment, then the gesture, each only when there is one (the order of src/ask.ts's requestLine).
|
|
403
|
-
const fields = {
|
|
421
|
+
export function requestLineBytes({ id, text, context, chip, current, moment, gesture, motif }) {
|
|
422
|
+
// The moment, then the gesture, then the motif, each only when there is one (the order of src/ask.ts's requestLine).
|
|
423
|
+
const fields = {
|
|
424
|
+
id,
|
|
425
|
+
text,
|
|
426
|
+
context,
|
|
427
|
+
chip,
|
|
428
|
+
current,
|
|
429
|
+
...(moment === undefined ? {} : { moment }),
|
|
430
|
+
...(gesture === undefined ? {} : { gesture }),
|
|
431
|
+
...(motif === undefined ? {} : { motif }),
|
|
432
|
+
};
|
|
404
433
|
const json = JSON.stringify(fields).replaceAll('\u2028', '\\u2028').replaceAll('\u2029', '\\u2029');
|
|
405
434
|
return new TextEncoder().encode(`${REQUEST_LINE_PREFIX} ${json}`).length;
|
|
406
435
|
}
|
|
@@ -465,6 +494,47 @@ function momentChipPart(moment) {
|
|
|
465
494
|
/** The longest section name the chip of a range shows; the context carries the whole name (cut at the context limit). */
|
|
466
495
|
const MAX_RANGE_CHIP_SECTION = 40;
|
|
467
496
|
|
|
497
|
+
/** The words of the sections part of a range chip: one section, a list of them, and the sections the chip does not show. */
|
|
498
|
+
const RANGE_SECTION_WORDS = Object.freeze({ one: 'section', list: 'sections', more: (n) => `and ${n} more` });
|
|
499
|
+
|
|
500
|
+
/** The shortest lane part a range chip keeps, so the tail never takes its room: 'Range', the separator and one character. */
|
|
501
|
+
const SHORTEST_RANGE_LANE_PART = `Range${SEPARATOR}x`;
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* The section names of a range cut to what the context holds: names from the start while they and their ', ' separators stay
|
|
505
|
+
* within MAX_ASK_CONTEXT_LENGTH characters. The first name is always kept (cut with cutText when it is longer). Gives
|
|
506
|
+
* `{ names, more }` with `more` the count of names left out. The server rejects a longer list and never trims.
|
|
507
|
+
*/
|
|
508
|
+
export function fitSectionNames(names) {
|
|
509
|
+
if (names.length === 0) throw new Error('Moontide: a section list to fit needs at least one name');
|
|
510
|
+
const kept = [cutText(names[0])];
|
|
511
|
+
let length = kept[0].length;
|
|
512
|
+
for (let at = 1; at < names.length; at++) {
|
|
513
|
+
length += 2 + names[at].length;
|
|
514
|
+
if (length > MAX_ASK_CONTEXT_LENGTH) break;
|
|
515
|
+
kept.push(names[at]);
|
|
516
|
+
}
|
|
517
|
+
return { names: kept, more: names.length - kept.length };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* The sections part of a range chip, built around `others` (the chip's other parts, as they will be joined): the most names
|
|
522
|
+
* that keep the tail within the room left after the shortest lane part, each cut to MAX_RANGE_CHIP_SECTION, and 'and N more' for
|
|
523
|
+
* every name not shown. `more` counts the names the context already leaves out. At least one name stays.
|
|
524
|
+
*/
|
|
525
|
+
function rangeSectionsPart(names, more, assemble) {
|
|
526
|
+
const total = names.length + more;
|
|
527
|
+
const room = MAX_ASK_CONTEXT_LENGTH - SEPARATOR.length - SHORTEST_RANGE_LANE_PART.length;
|
|
528
|
+
let part = '';
|
|
529
|
+
for (let shown = names.length; shown >= 1; shown--) {
|
|
530
|
+
const quoted = names.slice(0, shown).map((name) => `"${cutText(name, MAX_RANGE_CHIP_SECTION)}"`).join(', ');
|
|
531
|
+
const hidden = total - shown;
|
|
532
|
+
part = total === 1 ? `${RANGE_SECTION_WORDS.one} ${quoted}` : `${RANGE_SECTION_WORDS.list} ${quoted}${hidden > 0 ? ` ${RANGE_SECTION_WORDS.more(hidden)}` : ''}`;
|
|
533
|
+
if (assemble(part).length <= room) break;
|
|
534
|
+
}
|
|
535
|
+
return part;
|
|
536
|
+
}
|
|
537
|
+
|
|
468
538
|
/**
|
|
469
539
|
* What a right-click pointed at, as the context the server receives and the chip the user sees.
|
|
470
540
|
* `descriptor` is what a view reports about an element:
|
|
@@ -477,6 +547,8 @@ export function describeContext(descriptor) {
|
|
|
477
547
|
const kind = descriptor && descriptor.kind;
|
|
478
548
|
let moment = descriptor ? descriptor.moment : undefined;
|
|
479
549
|
let gesture = descriptor ? descriptor.gesture : undefined;
|
|
550
|
+
let motif;
|
|
551
|
+
if (kind !== 'motif' && descriptor && descriptor.motif !== undefined) throw new Error(`Moontide: ask context ${kind} cannot carry a motif`);
|
|
480
552
|
// A moment is the Timeline's facts at the pointer: a lane, a part header over the lanes and the rest of the
|
|
481
553
|
// Timeline (a session descriptor) carry one; nothing else does.
|
|
482
554
|
if (moment !== undefined && kind !== 'lane' && kind !== 'part' && kind !== 'session') throw new Error(`Moontide: ask context ${kind} cannot carry a moment`);
|
|
@@ -616,24 +688,29 @@ export function describeContext(descriptor) {
|
|
|
616
688
|
const from = requiredInteger(descriptor, 'from', 0);
|
|
617
689
|
const to = requiredInteger(descriptor, 'to', from);
|
|
618
690
|
if (to - from >= MAX_RANGE_CYCLES) throw missing(kind, `at most ${MAX_RANGE_CYCLES} cycles`);
|
|
619
|
-
const
|
|
620
|
-
const
|
|
621
|
-
if (sectionTo !== undefined && (section === undefined || sectionTo === section)) throw missing(kind, 'a sectionTo only when it differs from the section');
|
|
691
|
+
const sections = rangeSectionNames(descriptor);
|
|
692
|
+
const moreSections = rangeMoreSections(descriptor, sections);
|
|
622
693
|
if (gesture === undefined || gesture.kind !== 'range') throw missing(kind, 'a range gesture with its notes');
|
|
623
694
|
gesture = checkedGesture(gesture);
|
|
624
695
|
const cut = descriptor.cut === undefined || descriptor.cut === null ? null : { from: descriptor.cut.from, to: descriptor.cut.to };
|
|
625
696
|
if (cut !== null) rangeCutText(cut);
|
|
626
697
|
context = { kind, track, from, to };
|
|
627
|
-
|
|
628
|
-
if (
|
|
698
|
+
let fitted;
|
|
699
|
+
if (sections !== undefined) {
|
|
700
|
+
// The page cuts the list to what the context holds; the cut and a cut the descriptor already made are one count.
|
|
701
|
+
fitted = fitSectionNames(sections);
|
|
702
|
+
const left = fitted.more + (moreSections ?? 0);
|
|
703
|
+
if (left > MAX_RANGE_CYCLES - 1) throw missing(kind, `at most ${MAX_RANGE_CYCLES - 1} moreSections`);
|
|
704
|
+
context.sections = fitted.names;
|
|
705
|
+
if (left > 0) context.moreSections = left;
|
|
706
|
+
}
|
|
629
707
|
// The cycles, the section and the notes are built first and the lane name is cut to the room left, so a long name never
|
|
630
708
|
// costs the cycles.
|
|
631
|
-
const
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
parts.push(notesWords(gesture.notes.length + gesture.more));
|
|
709
|
+
const cycles = from === to ? `cycle ${from}` : `cycles ${from} to ${to}`;
|
|
710
|
+
const notes = notesWords(gesture.notes.length + gesture.more);
|
|
711
|
+
const parts = [cycles];
|
|
712
|
+
if (fitted !== undefined) parts.push(rangeSectionsPart(fitted.names, context.moreSections ?? 0, (part) => [cycles, part, notes].join(SEPARATOR)));
|
|
713
|
+
parts.push(notes);
|
|
637
714
|
const tail = parts.join(SEPARATOR);
|
|
638
715
|
chip = `${cutText(`Range${SEPARATOR}${track}`, MAX_ASK_CONTEXT_LENGTH - SEPARATOR.length - tail.length)}${SEPARATOR}${tail}`;
|
|
639
716
|
return { context, chip: cutText(chip), gesture, ...(cut === null ? {} : { rangeCut: cut }) };
|
|
@@ -711,9 +788,17 @@ export function describeContext(descriptor) {
|
|
|
711
788
|
chip = `Group "${name}"${parent === undefined ? '' : `${SEPARATOR}in "${parent}"`}`;
|
|
712
789
|
break;
|
|
713
790
|
}
|
|
791
|
+
case 'motif': {
|
|
792
|
+
// A motif sent from the Scratchpad: the context is only the kind, the motif rides next to it and carries its own moment.
|
|
793
|
+
motif = checkedMotif(descriptor.motif);
|
|
794
|
+
context = { kind };
|
|
795
|
+
chip = motifChip(motif);
|
|
796
|
+
break;
|
|
797
|
+
}
|
|
714
798
|
default:
|
|
715
799
|
throw new Error(`Moontide: unknown ask context kind ${kind}`);
|
|
716
800
|
}
|
|
801
|
+
|
|
717
802
|
// A gesture is about one note of a lane: it rides only on a lane that carries a moment (the range gesture is the range
|
|
718
803
|
// context's own and is checked in its case above).
|
|
719
804
|
if (gesture !== undefined) {
|
|
@@ -721,7 +806,7 @@ export function describeContext(descriptor) {
|
|
|
721
806
|
gesture = checkedGesture(gesture);
|
|
722
807
|
if (gesture.kind === 'range') throw new Error('Moontide: a range gesture goes only with a range context');
|
|
723
808
|
}
|
|
724
|
-
return { context, chip: cutText(chip), ...(moment === undefined ? {} : { moment }), ...(gesture === undefined ? {} : { gesture }) };
|
|
809
|
+
return { context, chip: cutText(chip), ...(moment === undefined ? {} : { moment }), ...(gesture === undefined ? {} : { gesture }), ...(motif === undefined ? {} : { motif }) };
|
|
725
810
|
}
|
|
726
811
|
|
|
727
812
|
/**
|
|
@@ -838,118 +923,3 @@ export function placeBox({ pointerX, pointerY, boxWidth, boxHeight, maxBoxHeight
|
|
|
838
923
|
export function newAskId(counter, nowMs) {
|
|
839
924
|
return `ask-${nowMs.toString(36)}-${counter}`;
|
|
840
925
|
}
|
|
841
|
-
|
|
842
|
-
/**
|
|
843
|
-
* The top-bar status of the latest request, as a small state machine with injected time (the
|
|
844
|
-
* page/mixer-gesture.js style). `render(text, short)` receives a string or null and, with a string, its short form (the
|
|
845
|
-
* second argument is left out when hiding). `onTimeout(id)` is called
|
|
846
|
-
* for a request the server did not answer in time, or whose connection dropped.
|
|
847
|
-
*
|
|
848
|
-
* Every non-error text becomes visible no sooner than `minShownMs` after the previous non-error text
|
|
849
|
-
* appeared (a later text is scheduled, never lost); hiding for an error or a refusal is immediate and
|
|
850
|
-
* cancels anything scheduled.
|
|
851
|
-
*/
|
|
852
|
-
export function createAskStatus({
|
|
853
|
-
render,
|
|
854
|
-
onTimeout,
|
|
855
|
-
setTimer,
|
|
856
|
-
clearTimer,
|
|
857
|
-
now,
|
|
858
|
-
minShownMs = STATUS_MIN_SHOWN_MS,
|
|
859
|
-
receivedShownMs = RECEIVED_SHOWN_MS,
|
|
860
|
-
confirmTimeoutMs = ASK_CONFIRM_TIMEOUT_MS,
|
|
861
|
-
}) {
|
|
862
|
-
// The request the status describes.
|
|
863
|
-
let latestId = null;
|
|
864
|
-
// Requests sent and not yet answered: id -> confirm timer.
|
|
865
|
-
const unconfirmed = new Map();
|
|
866
|
-
// When the text on screen appeared; null while nothing shows.
|
|
867
|
-
let shownAt = null;
|
|
868
|
-
let scheduled = null;
|
|
869
|
-
let hideTimer = null;
|
|
870
|
-
|
|
871
|
-
function cancelScheduled() {
|
|
872
|
-
if (scheduled !== null) clearTimer(scheduled);
|
|
873
|
-
scheduled = null;
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
function cancelHide() {
|
|
877
|
-
if (hideTimer !== null) clearTimer(hideTimer);
|
|
878
|
-
hideTimer = null;
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
function hideNow() {
|
|
882
|
-
cancelScheduled();
|
|
883
|
-
cancelHide();
|
|
884
|
-
shownAt = null;
|
|
885
|
-
render(null);
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
function reveal(text, short, hideAfterMs) {
|
|
889
|
-
scheduled = null;
|
|
890
|
-
render(text, short);
|
|
891
|
-
shownAt = now();
|
|
892
|
-
if (hideAfterMs !== null) {
|
|
893
|
-
hideTimer = setTimer(() => {
|
|
894
|
-
hideTimer = null;
|
|
895
|
-
hideNow();
|
|
896
|
-
}, hideAfterMs);
|
|
897
|
-
}
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
/** Shows a non-error text now, or as soon as the text on screen has been readable long enough. */
|
|
901
|
-
function present(text, short, hideAfterMs) {
|
|
902
|
-
cancelScheduled();
|
|
903
|
-
cancelHide();
|
|
904
|
-
const wait = shownAt === null ? 0 : Math.max(0, minShownMs - (now() - shownAt));
|
|
905
|
-
if (wait === 0) {
|
|
906
|
-
reveal(text, short, hideAfterMs);
|
|
907
|
-
} else {
|
|
908
|
-
scheduled = setTimer(() => reveal(text, short, hideAfterMs), wait);
|
|
909
|
-
}
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
function forget(id) {
|
|
913
|
-
const timer = unconfirmed.get(id);
|
|
914
|
-
if (timer === undefined) return;
|
|
915
|
-
clearTimer(timer);
|
|
916
|
-
unconfirmed.delete(id);
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
function expire(id) {
|
|
920
|
-
unconfirmed.delete(id);
|
|
921
|
-
onTimeout(id);
|
|
922
|
-
if (id === latestId) hideNow();
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
return {
|
|
926
|
-
/** A request was sent: it becomes the latest and waits for the server's answer. */
|
|
927
|
-
sent(id) {
|
|
928
|
-
latestId = id;
|
|
929
|
-
unconfirmed.set(id, setTimer(() => expire(id), confirmTimeoutMs));
|
|
930
|
-
present(ASK_COPY.sent, ASK_SHORT_COPY.sent, null);
|
|
931
|
-
},
|
|
932
|
-
/** The server's ask-result for a request. */
|
|
933
|
-
answered(msg) {
|
|
934
|
-
forget(msg.id);
|
|
935
|
-
if (msg.id !== latestId) return;
|
|
936
|
-
if (msg.status === 'received') present(receivedText(1), receivedShortText(1), receivedShownMs);
|
|
937
|
-
else if (msg.status === 'waiting') present(waitingText(msg.waiting), waitingShortText(msg.waiting), null);
|
|
938
|
-
else hideNow();
|
|
939
|
-
},
|
|
940
|
-
/** The server's ask-received: waiting requests were just handed to Claude. */
|
|
941
|
-
handedOver(ids) {
|
|
942
|
-
for (const id of ids) forget(id);
|
|
943
|
-
if (latestId !== null && ids.includes(latestId)) present(receivedText(ids.length), receivedShortText(ids.length), receivedShownMs);
|
|
944
|
-
},
|
|
945
|
-
/** The connection is gone: nothing sent and unanswered will be answered any more. */
|
|
946
|
-
dropAll() {
|
|
947
|
-
for (const [id, timer] of unconfirmed) {
|
|
948
|
-
clearTimer(timer);
|
|
949
|
-
onTimeout(id);
|
|
950
|
-
}
|
|
951
|
-
unconfirmed.clear();
|
|
952
|
-
hideNow();
|
|
953
|
-
},
|
|
954
|
-
};
|
|
955
|
-
}
|
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. */
|
|
@@ -30,6 +30,7 @@ export const BAR_COPY = Object.freeze({
|
|
|
30
30
|
cardChange: 'Last change',
|
|
31
31
|
rightClickHint: 'Right-click anything to ask Claude.',
|
|
32
32
|
rightClickHintTitle: ASK_COPY.shiftRightClickTitle,
|
|
33
|
+
earlyNote: 'Early: Moontide is young and has known bugs.',
|
|
33
34
|
midiHint: 'Instruments view: key 6.',
|
|
34
35
|
settingsLabel: 'Your edits take over',
|
|
35
36
|
timingNote: "Claude's changes say their own timing.",
|
|
@@ -60,6 +61,13 @@ export const BAR_COPY = Object.freeze({
|
|
|
60
61
|
tempoTitle: 'Tempo of the music that plays',
|
|
61
62
|
tempoTitleIdle: 'Nothing plays',
|
|
62
63
|
tempoNameIdle: 'No tempo, nothing plays',
|
|
64
|
+
// While a tempo ramp runs: the accessible name and the hover text say the direction, the target and the bars left.
|
|
65
|
+
tempoNameRising: 'Tempo {num} BPM, rising to {target} BPM',
|
|
66
|
+
tempoNameFalling: 'Tempo {num} BPM, falling to {target} BPM',
|
|
67
|
+
tempoTitleUp: 'Tempo moving up to {target} BPM. {left}',
|
|
68
|
+
tempoTitleDown: 'Tempo moving down to {target} BPM. {left}',
|
|
69
|
+
tempoBarsLeft: '{k} bars left.',
|
|
70
|
+
tempoOneBarLeft: '1 bar left.',
|
|
63
71
|
// The failed-change banner: the lead of each kind, and the three sentences about the music. The first two
|
|
64
72
|
// equal the server's NOTHING_PLAYING_TAIL and STILL_STOPPED_TAIL; the third is the user-facing wording of
|
|
65
73
|
// STILL_PLAYING_TAIL ('The previous code is still playing.' stays the server's text for Claude). A test pins all three.
|
|
@@ -218,8 +226,15 @@ export function cycleBeat(now) {
|
|
|
218
226
|
* the waiting tempo differs from the playing one after that rounding. Returns { idle, num, arrow, unit, text, name,
|
|
219
227
|
* title }: `arrow` is '→ 124' or '', `unit` is 'BPM' (the view puts the spaces), `text` the whole line and `name`
|
|
220
228
|
* the accessible name. A number bpmText cannot format throws; the caller logs it and shows the idle form.
|
|
229
|
+
*
|
|
230
|
+
* `ramp` is null or absent, or `{ targetBpm, barsLeft }` while a tempo ramp runs (`rising` is an optional true or false
|
|
231
|
+
* that says the direction when the live tempo already reads like the target). A ramp wins over `waitingBpm`. The number is then
|
|
232
|
+
* the live tempo rounded to a whole BPM (after bpmText has validated it), the arrow part is the target through bpmText
|
|
233
|
+
* and is empty when the number reads like the target, the name says 'rising to' or 'falling to' and the hover text says
|
|
234
|
+
* 'moving up' or 'moving down' with the whole bars left (a whole number from 1; '1 bar left.' for one). A call without
|
|
235
|
+
* a ramp returns exactly what it returned before the ramp existed. A bad ramp throws.
|
|
221
236
|
*/
|
|
222
|
-
export function tempoText({ playing, bpm, waitingBpm }) {
|
|
237
|
+
export function tempoText({ playing, bpm, waitingBpm, ramp }) {
|
|
223
238
|
requireBoolean('playing', playing);
|
|
224
239
|
if (!playing) {
|
|
225
240
|
return {
|
|
@@ -233,6 +248,7 @@ export function tempoText({ playing, bpm, waitingBpm }) {
|
|
|
233
248
|
};
|
|
234
249
|
}
|
|
235
250
|
const num = bpmText(bpm);
|
|
251
|
+
if (ramp !== null && ramp !== undefined) return rampTempoText(bpm, ramp);
|
|
236
252
|
const hasWaiting = waitingBpm !== null && waitingBpm !== undefined;
|
|
237
253
|
const waiting = hasWaiting ? bpmText(waitingBpm) : null;
|
|
238
254
|
const changing = waiting !== null && waiting !== num;
|
|
@@ -247,6 +263,32 @@ export function tempoText({ playing, bpm, waitingBpm }) {
|
|
|
247
263
|
};
|
|
248
264
|
}
|
|
249
265
|
|
|
266
|
+
/** The readout while a tempo ramp runs; `bpm` was validated by the caller. See tempoText. */
|
|
267
|
+
function rampTempoText(bpm, ramp) {
|
|
268
|
+
if (typeof ramp !== 'object') {
|
|
269
|
+
throw new Error(`Moontide: the tempo ramp must be an object with targetBpm and barsLeft, got ${JSON.stringify(ramp)}.`);
|
|
270
|
+
}
|
|
271
|
+
const { targetBpm, barsLeft, rising } = ramp;
|
|
272
|
+
const target = bpmText(targetBpm);
|
|
273
|
+
if (!Number.isInteger(barsLeft) || barsLeft < 1) {
|
|
274
|
+
throw new Error(`Moontide: the bars left of a tempo ramp must be a whole number from 1, got ${JSON.stringify(barsLeft)}.`);
|
|
275
|
+
}
|
|
276
|
+
if (rising !== undefined) requireBoolean('rising', rising);
|
|
277
|
+
const num = String(Math.round(bpm));
|
|
278
|
+
const up = rising !== undefined ? rising : targetBpm > bpm;
|
|
279
|
+
const arrow = num === target ? '' : `${BAR_COPY.tempoArrow} ${target}`;
|
|
280
|
+
const left = barsLeft === 1 ? BAR_COPY.tempoOneBarLeft : fillChange(BAR_COPY.tempoBarsLeft, { k: barsLeft });
|
|
281
|
+
return {
|
|
282
|
+
idle: false,
|
|
283
|
+
num,
|
|
284
|
+
arrow,
|
|
285
|
+
unit: BAR_COPY.tempoUnit,
|
|
286
|
+
text: arrow === '' ? `${num} ${BAR_COPY.tempoUnit}` : `${num} ${arrow} ${BAR_COPY.tempoUnit}`,
|
|
287
|
+
name: fillChange(up ? BAR_COPY.tempoNameRising : BAR_COPY.tempoNameFalling, { num, target }),
|
|
288
|
+
title: fillChange(up ? BAR_COPY.tempoTitleUp : BAR_COPY.tempoTitleDown, { target, left }),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
250
292
|
const BANNER_KINDS = Object.freeze(['change', 'notice']);
|
|
251
293
|
|
|
252
294
|
/**
|
package/page/change-timing.js
CHANGED
|
@@ -144,6 +144,13 @@ export const CHANGE_COPY = Object.freeze({
|
|
|
144
144
|
settingNow: 'Your edits now take over: now.',
|
|
145
145
|
settingBar: 'Your edits now take over: on the next bar.',
|
|
146
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.',
|
|
147
154
|
});
|
|
148
155
|
|
|
149
156
|
// The fit steps of the top bar, in the order text is given up: 0 nothing, 1 the decoration
|
|
@@ -223,3 +230,31 @@ export function settingLine(value) {
|
|
|
223
230
|
if (value === 'phrase') return CHANGE_COPY.settingPhrase;
|
|
224
231
|
throw new Error(`Moontide: unknown change timing '${String(value)}' (expected one of ${CHANGE_TIMINGS.join(', ')}).`);
|
|
225
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/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>
|
|
@@ -62,6 +63,7 @@
|
|
|
62
63
|
<span id="build-mismatch" class="bar-text" hidden></span>
|
|
63
64
|
<span id="midi-takeover" class="bar-text" hidden><span id="midi-takeover-text"></span><button type="button" id="take-back"></button></span>
|
|
64
65
|
<p class="session-hint" data-copy="rightClickHint"></p>
|
|
66
|
+
<p class="session-hint" data-copy="earlyNote"></p>
|
|
65
67
|
</section>
|
|
66
68
|
<section id="session-card-midi" class="session-card">
|
|
67
69
|
<h2 class="session-card-title" data-copy="cardMidi"></h2>
|
|
@@ -85,6 +87,7 @@
|
|
|
85
87
|
<p class="session-hint session-empty" data-copy="noChange"></p>
|
|
86
88
|
</section>
|
|
87
89
|
</div>
|
|
90
|
+
<span id="requests-live" role="status" aria-live="polite"></span>
|
|
88
91
|
</div>
|
|
89
92
|
<div id="workspace">
|
|
90
93
|
<main id="editor-area"><strudel-editor id="repl"></strudel-editor></main>
|
|
@@ -104,8 +107,9 @@
|
|
|
104
107
|
</div>
|
|
105
108
|
<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>
|
|
106
109
|
<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>
|
|
107
|
-
<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><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>
|
|
110
|
+
<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>
|
|
108
111
|
<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>
|
|
112
|
+
<div id="scratch-view" hidden role="region" aria-label="Scratchpad"><div id="scratch-controls" role="toolbar" aria-label="Scratchpad controls"><div id="scratch-grids" role="group" aria-label="Grid"><button type="button" id="scratch-grid-quarter" class="tl-btn" data-grid="quarter"></button><button type="button" id="scratch-grid-eighth" class="tl-btn" data-grid="eighth"></button><button type="button" id="scratch-grid-sixteenth" class="tl-btn" data-grid="sixteenth"></button><button type="button" id="scratch-grid-eighth-triplet" class="tl-btn" data-grid="eighth-triplet"></button><button type="button" id="scratch-grid-sixteenth-triplet" class="tl-btn" data-grid="sixteenth-triplet"></button></div><div id="scratch-length" role="group" aria-label="Length of the motif"><button type="button" id="scratch-fewer" class="tl-btn"></button><span id="scratch-cycles" class="sp-readout"></span><button type="button" id="scratch-more" class="tl-btn"></button></div><div id="scratch-pitch" role="group" aria-label="Pitch"><button type="button" id="scratch-lower" class="tl-btn"></button><span id="scratch-window" class="sp-readout" role="img"></span><button type="button" id="scratch-higher" class="tl-btn"></button></div><div id="scratch-mode" role="group" aria-label="Mode"><button type="button" id="scratch-draw" class="tl-btn" aria-pressed="false"></button><button type="button" id="scratch-tap" class="tl-btn" aria-pressed="false"></button></div><div id="scratch-edit" role="group" aria-label="Edit"><button type="button" id="scratch-earlier" class="tl-btn"></button><button type="button" id="scratch-later" class="tl-btn"></button><button type="button" id="scratch-align" class="tl-btn"></button><button type="button" id="scratch-clear" class="tl-btn"></button></div><div id="scratch-history" role="group" aria-label="Undo and redo"><button type="button" id="scratch-undo" class="tl-btn"></button><button type="button" id="scratch-redo" class="tl-btn"></button></div><div id="scratch-record-group" role="group" aria-label="Keyboard"><button type="button" id="scratch-record" class="tl-btn"></button></div><span id="scratch-early"></span></div><div id="scratch-scroll"><div id="scratch-ruler" aria-hidden="true"></div><div id="scratch-roll" tabindex="0" role="group" aria-label="Scratchpad piano roll" aria-describedby="scratch-hint"><div id="scratch-gutter" aria-hidden="true"></div><div id="scratch-grid"><div id="scratch-hover-cell" hidden></div><div id="scratch-select-rect" hidden aria-hidden="true"></div><div id="scratch-notes"></div><div id="scratch-live" aria-hidden="true"></div><div id="scratch-preview" hidden></div><div id="scratch-cursor" hidden></div><div id="scratch-now" hidden></div><div id="scratch-playhead" hidden aria-hidden="true"></div></div></div><div id="scratch-lane" tabindex="0" role="group" aria-label="Velocity of the notes" aria-describedby="scratch-hint"><div id="scratch-lane-label" aria-hidden="true"></div><div id="scratch-bars"></div></div></div><div id="scratch-note"><p id="scratch-note-line" role="status" aria-live="polite"><span id="scratch-note-shown" aria-hidden="true"><span id="scratch-note-name"></span><span id="scratch-note-rest"></span></span><span id="scratch-note-spoken"></span></p><p id="scratch-note-hover" hidden aria-hidden="true"></p><p id="scratch-hint"></p></div><div id="scratch-listen"><p id="scratch-listen-state" role="status" aria-live="polite"></p><button type="button" id="scratch-allow" class="tl-btn" hidden></button><p id="scratch-heard"></p></div><div id="scratch-play" role="group" aria-label="Hear the motif"><select id="scratch-play-output" aria-label="Instrument and channel"></select><div id="scratch-play-start" role="group" aria-label="Start"><button type="button" id="scratch-play-music" class="tl-btn" aria-pressed="true"></button><button type="button" id="scratch-play-now" class="tl-btn" aria-pressed="false"></button></div><button type="button" id="scratch-play-loop" class="tl-btn" aria-pressed="false"></button><button type="button" id="scratch-play-listen" class="tl-btn"></button><button type="button" id="scratch-play-stop" class="tl-btn"></button><p id="scratch-play-note" role="status" aria-live="polite"></p></div><div id="scratch-send"><code id="scratch-phrase" tabindex="0"></code><div id="scratch-send-row"><input type="text" id="scratch-words" autocomplete="off" spellcheck="false" /><button type="button" id="scratch-send-button" class="tl-btn"></button></div><p id="scratch-send-status" role="status" aria-live="polite"></p></div></div>
|
|
109
113
|
</section>
|
|
110
114
|
</div>
|
|
111
115
|
<div id="ask-box" role="dialog" hidden><div id="ask-chips"><span id="ask-context-chip"></span><span id="ask-current-chip" hidden></span></div><textarea id="ask-field" rows="1" aria-describedby="ask-context-chip ask-hint"></textarea><p id="ask-message" hidden></p><p id="ask-hint"></p></div>
|