moontide-mcp 2.2.0 → 2.4.1
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 -383
- package/dist/ask.js +12 -4
- package/dist/catalog.js +118 -9
- package/dist/instruments.js +83 -9
- package/dist/mcp.js +3 -3
- package/dist/midi-state.js +43 -1
- package/dist/protocol.js +80 -3
- package/dist/reference.js +12 -0
- package/dist/vocabulary.js +420 -0
- package/package.json +3 -2
- package/page/ask-view.js +77 -2
- package/page/ask.js +57 -4
- package/page/bar-model.js +1 -0
- package/page/index.html +2 -0
- package/page/instruments-model.js +50 -3
- package/page/instruments-view.js +14 -2
- package/page/main.js +348 -7
- package/page/midi-in.js +407 -0
- package/page/midi-out.js +552 -46
- package/page/midi-plan.js +42 -6
- package/page/midi-rig.js +286 -13
- package/page/midi-settings.js +507 -0
- package/page/played-buffer.js +208 -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/style.css +842 -0
- package/page/views.js +5 -5
- package/reference/midi.md +2 -0
- package/reference/motif.md +83 -0
- package/reference/settings.md +161 -0
package/page/ask-view.js
CHANGED
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
ASK_COPY,
|
|
14
14
|
BOX_GAP_REM,
|
|
15
15
|
MAX_ASK_TEXT_LENGTH,
|
|
16
|
+
MAX_MOTIF_PHRASE_LENGTH,
|
|
17
|
+
MAX_REQUEST_LINE_BYTES,
|
|
16
18
|
PAGE_REQUESTS_COPY,
|
|
17
19
|
pageRequestsText,
|
|
18
20
|
askMessage,
|
|
@@ -21,11 +23,13 @@ import {
|
|
|
21
23
|
describeContext,
|
|
22
24
|
fitGestureToLine,
|
|
23
25
|
fitMomentToLine,
|
|
26
|
+
motifChip,
|
|
24
27
|
newAskId,
|
|
25
28
|
partsAskState,
|
|
26
29
|
placeBox,
|
|
27
30
|
refusalText,
|
|
28
31
|
requestFitsLine,
|
|
32
|
+
requestLineBytes,
|
|
29
33
|
} from './ask.js';
|
|
30
34
|
|
|
31
35
|
/**
|
|
@@ -34,9 +38,11 @@ import {
|
|
|
34
38
|
* sends one JSON-able message to the server; `reportError` and `clearError` write and clear the
|
|
35
39
|
* top-bar error line; `setTimer`, `clearTimer` and `now` are the page's clock. `requests` is the per-request tracker
|
|
36
40
|
* of page/request-status.js: every request sent is handed to it, and so is every server answer to one. The tracker
|
|
37
|
-
* tells the page (main.js) when a request was never confirmed; the page then calls `notConfirmed(id)` here.
|
|
41
|
+
* tells the page (main.js) when a request was never confirmed; the page then calls `notConfirmed(id)` here. `onMotifResult` is
|
|
42
|
+
* called with what became of the Scratchpad's motif request (`{ status: 'received' }` once Claude has it, `{ status: 'waiting' }` once the
|
|
43
|
+
* server holds it for Claude's next call); the motif request has its own pending id, so nothing it meets ever reaches the Ask box or its draft.
|
|
38
44
|
*/
|
|
39
|
-
export function createAskView({ document, send, reportError, clearError, setTimer, clearTimer, now, requests }) {
|
|
45
|
+
export function createAskView({ document, send, reportError, clearError, setTimer, clearTimer, now, requests, onMotifResult = () => {} }) {
|
|
40
46
|
const askBox = document.getElementById('ask-box');
|
|
41
47
|
const askContextChip = document.getElementById('ask-context-chip');
|
|
42
48
|
const askCurrentChip = document.getElementById('ask-current-chip');
|
|
@@ -136,6 +142,8 @@ export function createAskView({ document, send, reportError, clearError, setTime
|
|
|
136
142
|
// request while it waits for Claude to take it.
|
|
137
143
|
let partsAvailable = false;
|
|
138
144
|
let partsPendingId = null;
|
|
145
|
+
// The Scratchpad's motif request, the same way: the id of the one on its way, so its refusal never arms the Ask box.
|
|
146
|
+
let motifPendingId = null;
|
|
139
147
|
|
|
140
148
|
/** The fields of the request the box would send for `text` (trimmed), the id measured with the next one's shape. */
|
|
141
149
|
function requestFields(text) {
|
|
@@ -430,6 +438,46 @@ export function createAskView({ document, send, reportError, clearError, setTime
|
|
|
430
438
|
renderPartsAsk();
|
|
431
439
|
});
|
|
432
440
|
|
|
441
|
+
/**
|
|
442
|
+
* Sends the Scratchpad's motif as a page request: `words` are the user's own (trimmed) and `motif` is the motif field of
|
|
443
|
+
* page/scratch-model.js's motifRequest. The request has its own pending id, goes to the tracker like any other and never
|
|
444
|
+
* touches the Ask box. Returns `{ id }` once it is sent, or `{ refused: word, ...numbers }` and sends nothing: 'disconnected',
|
|
445
|
+
* 'pending' (the last motif request has not reached the server: the server has not answered it yet), 'empty-words', 'words-too-long' (`length`, `max`) or 'too-big'
|
|
446
|
+
* (`bytes` and `max`, measured on the exact frame, id included; a phrase over MAX_MOTIF_PHRASE_LENGTH characters is refused the same way). The
|
|
447
|
+
* phrase is never cut: a motif that does not fit is refused whole.
|
|
448
|
+
*/
|
|
449
|
+
function sendMotif({ words, motif }) {
|
|
450
|
+
if (!connected) return { refused: 'disconnected' };
|
|
451
|
+
if (motifPendingId !== null) return { refused: 'pending' };
|
|
452
|
+
const text = words.trim();
|
|
453
|
+
if (text === '') return { refused: 'empty-words' };
|
|
454
|
+
if (text.length > MAX_ASK_TEXT_LENGTH) return { refused: 'words-too-long', length: text.length, max: MAX_ASK_TEXT_LENGTH };
|
|
455
|
+
const id = newAskId(counter + 1, now());
|
|
456
|
+
// A phrase longer than the server takes is refused like any motif too big for the line, before describeContext (which throws on it)
|
|
457
|
+
// checks the motif. The bytes are those of the line it would make; the rest of the request always puts that line over its bound.
|
|
458
|
+
if (motif !== null && typeof motif === 'object' && typeof motif.phrase === 'string' && motif.phrase.length > MAX_MOTIF_PHRASE_LENGTH) {
|
|
459
|
+
const bytes = requestLineBytes({ id, text, context: { kind: 'motif' }, chip: motifChip(motif), current: currentVersion, motif });
|
|
460
|
+
return { refused: 'too-big', bytes, max: MAX_REQUEST_LINE_BYTES };
|
|
461
|
+
}
|
|
462
|
+
const described = describeContext({ kind: 'motif', motif });
|
|
463
|
+
const frame = { type: 'ask', id, text, context: described.context, chip: described.chip, current: currentVersion, motif: described.motif };
|
|
464
|
+
const bytes = requestLineBytes(frame);
|
|
465
|
+
if (bytes > MAX_REQUEST_LINE_BYTES) return { refused: 'too-big', bytes, max: MAX_REQUEST_LINE_BYTES };
|
|
466
|
+
counter += 1;
|
|
467
|
+
send(frame);
|
|
468
|
+
// While the token is refused the line stays: the server's 'token' refusal writes it again, so it never blinks off.
|
|
469
|
+
if (!authRefused) clearError();
|
|
470
|
+
requests.sent(frame.id, { text, chip: described.chip });
|
|
471
|
+
motifPendingId = frame.id;
|
|
472
|
+
return { id: frame.id };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** The server has the motif request: it is no longer on its way, and the page is told. */
|
|
476
|
+
function arrivedMotif() {
|
|
477
|
+
motifPendingId = null;
|
|
478
|
+
onMotifResult({ status: 'received' });
|
|
479
|
+
}
|
|
480
|
+
|
|
433
481
|
/** The server's answers: token result, one result per request, and hand-overs of waiting requests. */
|
|
434
482
|
function onServerMessage(msg) {
|
|
435
483
|
if (msg.type === 'ask-auth') {
|
|
@@ -455,11 +503,26 @@ export function createAskView({ document, send, reportError, clearError, setTime
|
|
|
455
503
|
if (msg.type === 'ask-received') {
|
|
456
504
|
requests.serverMessage(msg);
|
|
457
505
|
if (partsPendingId !== null && msg.ids.includes(partsPendingId)) partsPendingId = null;
|
|
506
|
+
if (motifPendingId !== null && msg.ids.includes(motifPendingId)) arrivedMotif();
|
|
458
507
|
renderPartsAsk();
|
|
459
508
|
return;
|
|
460
509
|
}
|
|
461
510
|
if (msg.type !== 'ask-result') return;
|
|
462
511
|
requests.serverMessage(msg);
|
|
512
|
+
if (msg.id === motifPendingId) {
|
|
513
|
+
// The Scratchpad's request is answered in its own strip: nothing of it reaches the Ask box, its draft or its not-sent message.
|
|
514
|
+
if (msg.status === 'received') {
|
|
515
|
+
arrivedMotif();
|
|
516
|
+
} else if (msg.status === 'waiting') {
|
|
517
|
+
// The server holds it until Claude's next Moontide call: it is no longer on its way, so the next motif may go, and the strip says it waits.
|
|
518
|
+
motifPendingId = null;
|
|
519
|
+
onMotifResult({ status: 'waiting' });
|
|
520
|
+
} else if (msg.status === 'refused') {
|
|
521
|
+
motifPendingId = null;
|
|
522
|
+
onMotifResult({ status: 'refused', text: refusalText(msg.reason, msg.detail) });
|
|
523
|
+
}
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
463
526
|
const fromParts = msg.id === partsPendingId;
|
|
464
527
|
if (msg.status === 'refused') {
|
|
465
528
|
reportError(refusalText(msg.reason, msg.detail));
|
|
@@ -485,6 +548,12 @@ export function createAskView({ document, send, reportError, clearError, setTime
|
|
|
485
548
|
* says so, and the draft comes back (or, for the Parts button's canned request, the button is released).
|
|
486
549
|
*/
|
|
487
550
|
function notConfirmed(id) {
|
|
551
|
+
if (id === motifPendingId) {
|
|
552
|
+
// The Scratchpad's strip says it; the Ask box and its draft are not involved.
|
|
553
|
+
motifPendingId = null;
|
|
554
|
+
onMotifResult({ status: 'not-confirmed' });
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
488
557
|
reportError(ASK_COPY.didNotConfirm);
|
|
489
558
|
if (id === partsPendingId) {
|
|
490
559
|
// The canned request needs no draft: the button just comes back.
|
|
@@ -500,6 +569,7 @@ export function createAskView({ document, send, reportError, clearError, setTime
|
|
|
500
569
|
connected = state === 'connected';
|
|
501
570
|
if (state === 'disconnected') {
|
|
502
571
|
partsPendingId = null;
|
|
572
|
+
motifPendingId = null;
|
|
503
573
|
// The state is unknown once the socket is gone (the page does not reconnect by itself).
|
|
504
574
|
pageRequests.hidden = true;
|
|
505
575
|
closeHelp();
|
|
@@ -531,6 +601,11 @@ export function createAskView({ document, send, reportError, clearError, setTime
|
|
|
531
601
|
contains: (node) => askBox.contains(node),
|
|
532
602
|
setConnected,
|
|
533
603
|
setCurrentVersion,
|
|
604
|
+
sendMotif,
|
|
605
|
+
/** True while a motif request is on its way to the server. */
|
|
606
|
+
motifPending: () => motifPendingId !== null,
|
|
607
|
+
/** True while the page is connected to the server. */
|
|
608
|
+
isConnected: () => connected,
|
|
534
609
|
setPartsAvailable,
|
|
535
610
|
onServerMessage,
|
|
536
611
|
notConfirmed,
|
package/page/ask.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
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
|
|
|
@@ -379,14 +386,50 @@ function checkedMoment(kind, moment) {
|
|
|
379
386
|
return { cycle: moment.cycle, beat: moment.beat, at: moment.at, version: moment.version, notes, more: moment.more };
|
|
380
387
|
}
|
|
381
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
|
+
|
|
382
416
|
/**
|
|
383
417
|
* The size in bytes (UTF-8) of the line the server builds from one request: src/ask.ts's requestLine, the
|
|
384
418
|
* prefix, a space and the JSON of the fields in the same order (U+2028 and U+2029 escaped), so the page can
|
|
385
419
|
* tell before it sends whether the server will take the line.
|
|
386
420
|
*/
|
|
387
|
-
export function requestLineBytes({ id, text, context, chip, current, moment, gesture }) {
|
|
388
|
-
// The moment, then the gesture, each only when there is one (the order of src/ask.ts's requestLine).
|
|
389
|
-
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
|
+
};
|
|
390
433
|
const json = JSON.stringify(fields).replaceAll('\u2028', '\\u2028').replaceAll('\u2029', '\\u2029');
|
|
391
434
|
return new TextEncoder().encode(`${REQUEST_LINE_PREFIX} ${json}`).length;
|
|
392
435
|
}
|
|
@@ -504,6 +547,8 @@ export function describeContext(descriptor) {
|
|
|
504
547
|
const kind = descriptor && descriptor.kind;
|
|
505
548
|
let moment = descriptor ? descriptor.moment : undefined;
|
|
506
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`);
|
|
507
552
|
// A moment is the Timeline's facts at the pointer: a lane, a part header over the lanes and the rest of the
|
|
508
553
|
// Timeline (a session descriptor) carry one; nothing else does.
|
|
509
554
|
if (moment !== undefined && kind !== 'lane' && kind !== 'part' && kind !== 'session') throw new Error(`Moontide: ask context ${kind} cannot carry a moment`);
|
|
@@ -743,9 +788,17 @@ export function describeContext(descriptor) {
|
|
|
743
788
|
chip = `Group "${name}"${parent === undefined ? '' : `${SEPARATOR}in "${parent}"`}`;
|
|
744
789
|
break;
|
|
745
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
|
+
}
|
|
746
798
|
default:
|
|
747
799
|
throw new Error(`Moontide: unknown ask context kind ${kind}`);
|
|
748
800
|
}
|
|
801
|
+
|
|
749
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
|
|
750
803
|
// context's own and is checked in its case above).
|
|
751
804
|
if (gesture !== undefined) {
|
|
@@ -753,7 +806,7 @@ export function describeContext(descriptor) {
|
|
|
753
806
|
gesture = checkedGesture(gesture);
|
|
754
807
|
if (gesture.kind === 'range') throw new Error('Moontide: a range gesture goes only with a range context');
|
|
755
808
|
}
|
|
756
|
-
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 }) };
|
|
757
810
|
}
|
|
758
811
|
|
|
759
812
|
/**
|
package/page/bar-model.js
CHANGED
|
@@ -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.",
|
package/page/index.html
CHANGED
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
<span id="build-mismatch" class="bar-text" hidden></span>
|
|
64
64
|
<span id="midi-takeover" class="bar-text" hidden><span id="midi-takeover-text"></span><button type="button" id="take-back"></button></span>
|
|
65
65
|
<p class="session-hint" data-copy="rightClickHint"></p>
|
|
66
|
+
<p class="session-hint" data-copy="earlyNote"></p>
|
|
66
67
|
</section>
|
|
67
68
|
<section id="session-card-midi" class="session-card">
|
|
68
69
|
<h2 class="session-card-title" data-copy="cardMidi"></h2>
|
|
@@ -108,6 +109,7 @@
|
|
|
108
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>
|
|
109
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>
|
|
110
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>
|
|
111
113
|
</section>
|
|
112
114
|
</div>
|
|
113
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>
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// CONNECTION_LABELS (read at use time, so the popover, the top bar and this view never disagree). The DOM
|
|
6
6
|
// side is page/instruments-view.js; page/main.js feeds the inputs.
|
|
7
7
|
|
|
8
|
-
import { ACCESS_STATES, MIDI_COPY, fill, listNames, readMidiCalls } from './midi-plan.js';
|
|
8
|
+
import { ACCESS_STATES, MIDI_COPY, fill, listNames, readMidiCalls, readMidiSetup } from './midi-plan.js';
|
|
9
9
|
import { BUILD_MISMATCH_COPY, CONNECTION_LABELS } from './ui-state.js';
|
|
10
10
|
import { midiTag, readMidiTracks } from './midi-controls.js';
|
|
11
11
|
import { drumMapFor, soundKey } from './midi-rig.js';
|
|
@@ -59,6 +59,15 @@ export const INSTRUMENTS_COPY = Object.freeze({
|
|
|
59
59
|
labelControlsHint: 'default values',
|
|
60
60
|
labelCatalog: 'Catalog',
|
|
61
61
|
labelInUse: 'In use',
|
|
62
|
+
// The Synth settings row of an instrument whose model describes settings: what the playing code writes, never stored.
|
|
63
|
+
labelSynthSettings: 'Synth settings',
|
|
64
|
+
synthTone: 'tone, ch {channel}: {values}',
|
|
65
|
+
synthPart: 'part, ch {channel}: {values}',
|
|
66
|
+
synthScene: 'scene: {values}, reaches every part',
|
|
67
|
+
synthValue: '{name} {value}',
|
|
68
|
+
synthValuesJoin: ', ',
|
|
69
|
+
synthNone: 'none in the code',
|
|
70
|
+
synthStays: 'A setting taken out of the code is not sent again: the instrument keeps its value until the instrument itself changes it, and a tone setting until the instrument loads the tone again. Moontide stores no setting.',
|
|
62
71
|
clockFollows: 'follows the clock',
|
|
63
72
|
controlsNone: 'none',
|
|
64
73
|
catalogOne: '1 sound',
|
|
@@ -303,8 +312,9 @@ function readUse(code, parse, rig) {
|
|
|
303
312
|
if (typeof parse !== 'function') return { kind: 'unreadable' };
|
|
304
313
|
const tracks = readMidiTracks(code, { parse, rig });
|
|
305
314
|
const calls = readMidiCalls(code, { parse });
|
|
306
|
-
|
|
307
|
-
|
|
315
|
+
const setup = readMidiSetup(code, { parse });
|
|
316
|
+
if (!tracks.ok || !calls.ok || !setup.ok) return { kind: 'unreadable' };
|
|
317
|
+
return { kind: 'read', tracks: [...tracks.tracks.values()], calls: calls.calls, setups: setup.calls };
|
|
308
318
|
}
|
|
309
319
|
|
|
310
320
|
/**
|
|
@@ -337,6 +347,40 @@ function inUseOf(instrument, use) {
|
|
|
337
347
|
return { lead: instrument.enabled === false ? INSTRUMENTS_COPY.silentWhileOff : null, lines, muted: false };
|
|
338
348
|
}
|
|
339
349
|
|
|
350
|
+
/**
|
|
351
|
+
* The Synth settings row of one instrument: null when its model describes no settings, else `{ lines: [{ text, muted }] }`
|
|
352
|
+
* read from the playing code. The lines are the tone and part settings per channel ascending (tone first), then the scene
|
|
353
|
+
* settings, taken from the calls whose port is exactly the instrument's port; the values stay in the order written and a
|
|
354
|
+
* later equal value merges. Without any, or without readable code, one muted line says so. The standing rule about a
|
|
355
|
+
* setting taken out of the code comes last. Nothing is stored: the row is the code.
|
|
356
|
+
*/
|
|
357
|
+
function synthSettingsOf(instrument, use) {
|
|
358
|
+
if (instrument.settings === null || instrument.settings === undefined) return null;
|
|
359
|
+
const staying = { text: INSTRUMENTS_COPY.synthStays, muted: true };
|
|
360
|
+
if (use.kind === 'unreadable') return { lines: [{ text: INSTRUMENTS_COPY.unreadableCode, muted: true }, staying] };
|
|
361
|
+
if (use.kind === 'empty') return { lines: [{ text: MIDI_COPY.rowNotUsed, muted: true }, staying] };
|
|
362
|
+
const channels = new Map();
|
|
363
|
+
const scene = new Map();
|
|
364
|
+
for (const call of use.setups) {
|
|
365
|
+
if (call.port !== instrument.port) continue;
|
|
366
|
+
if (!channels.has(call.channel)) channels.set(call.channel, { tone: new Map(), part: new Map() });
|
|
367
|
+
const level = channels.get(call.channel);
|
|
368
|
+
for (const [name, value] of call.settings.tone) level.tone.set(name, value);
|
|
369
|
+
for (const [name, value] of call.settings.part) level.part.set(name, value);
|
|
370
|
+
for (const [name, value] of call.settings.scene) scene.set(name, value);
|
|
371
|
+
}
|
|
372
|
+
const valuesOf = (map) => Array.from(map, ([name, value]) => fill(INSTRUMENTS_COPY.synthValue, { name, value: String(value) })).join(INSTRUMENTS_COPY.synthValuesJoin);
|
|
373
|
+
const lines = [];
|
|
374
|
+
for (const channel of [...channels.keys()].sort((a, b) => a - b)) {
|
|
375
|
+
const { tone, part } = channels.get(channel);
|
|
376
|
+
if (tone.size > 0) lines.push({ text: fill(INSTRUMENTS_COPY.synthTone, { channel: String(channel), values: valuesOf(tone) }), muted: false });
|
|
377
|
+
if (part.size > 0) lines.push({ text: fill(INSTRUMENTS_COPY.synthPart, { channel: String(channel), values: valuesOf(part) }), muted: false });
|
|
378
|
+
}
|
|
379
|
+
if (scene.size > 0) lines.push({ text: fill(INSTRUMENTS_COPY.synthScene, { values: valuesOf(scene) }), muted: false });
|
|
380
|
+
if (lines.length === 0) lines.push({ text: INSTRUMENTS_COPY.synthNone, muted: true });
|
|
381
|
+
return { lines: [...lines, staying] };
|
|
382
|
+
}
|
|
383
|
+
|
|
340
384
|
/** True when the instrument declares at least one channel with the rhythm role (the Drum kit row and Drum map line only exist then). */
|
|
341
385
|
function hasRhythmChannel(instrument) {
|
|
342
386
|
return instrument.channels.some((entry) => entry.role === 'rhythm');
|
|
@@ -558,6 +602,7 @@ function cardOf(instrument, liveInstruments, use) {
|
|
|
558
602
|
modelChannels: modelChannelsOf(instrument),
|
|
559
603
|
usedChannels: usedChannelsOf(instrument, use),
|
|
560
604
|
inUse: inUseOf(instrument, use),
|
|
605
|
+
synthSettings: synthSettingsOf(instrument, use),
|
|
561
606
|
drumKit: drumKitOf(instrument, use),
|
|
562
607
|
drumMaps: drumMapsOf(instrument, use),
|
|
563
608
|
drumDeclared: drumDeclaredOf(instrument),
|
|
@@ -717,6 +762,8 @@ function declaredFacts(card) {
|
|
|
717
762
|
drumDeclared: card.drumDeclared,
|
|
718
763
|
settings: card.settings,
|
|
719
764
|
modelChannels: card.modelChannels,
|
|
765
|
+
// Whether the model describes settings decides whether the Synth settings row exists; its lines are live.
|
|
766
|
+
hasSynthSettings: card.synthSettings !== null,
|
|
720
767
|
});
|
|
721
768
|
}
|
|
722
769
|
|
package/page/instruments-view.js
CHANGED
|
@@ -208,6 +208,11 @@ export function createInstrumentsView({ document, onInstrumentSwitch, onInstrume
|
|
|
208
208
|
return lines;
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
/** The children of the Synth settings value: one line element per model line, the muted ones with the muted class. */
|
|
212
|
+
function synthSettingsLines(synthSettings) {
|
|
213
|
+
return synthSettings.lines.map((line) => element('span', line.muted ? 'instrument-inuse-line instrument-muted' : 'instrument-inuse-line', line.text));
|
|
214
|
+
}
|
|
215
|
+
|
|
211
216
|
/** The lines of the Drum kit row: one span per line, muted when the model says so, with its hover title when it has one. */
|
|
212
217
|
function drumKitLines(drumKit) {
|
|
213
218
|
return drumKit.lines.map((line) => {
|
|
@@ -262,7 +267,7 @@ export function createInstrumentsView({ document, onInstrumentSwitch, onInstrume
|
|
|
262
267
|
|
|
263
268
|
/** One card and the nodes a live redraw updates. The generated ids come from the index, never from user text. */
|
|
264
269
|
function cardNode(card, index) {
|
|
265
|
-
const refs = { id: card.id, index, item: null, dot: null, words: null, inUse: null, drumKit: null, maps: null, opens: [], enabled: card.enabled, switchNodes: null, head: null, errorNode: null, editButton: null, editor: null };
|
|
270
|
+
const refs = { id: card.id, index, item: null, dot: null, words: null, inUse: null, synthSettings: null, drumKit: null, maps: null, opens: [], enabled: card.enabled, switchNodes: null, head: null, errorNode: null, editButton: null, editor: null };
|
|
266
271
|
const item = element('li', 'instrument-card');
|
|
267
272
|
refs.item = item;
|
|
268
273
|
item.tabIndex = 0;
|
|
@@ -300,6 +305,12 @@ export function createInstrumentsView({ document, onInstrumentSwitch, onInstrume
|
|
|
300
305
|
inUse.append(...inUseLines(card.inUse));
|
|
301
306
|
refs.inUse = inUse;
|
|
302
307
|
rows.append(row(INSTRUMENTS_COPY.labelInUse, inUse));
|
|
308
|
+
if (card.synthSettings !== null) {
|
|
309
|
+
const synth = element('div', 'instrument-lines');
|
|
310
|
+
synth.append(...synthSettingsLines(card.synthSettings));
|
|
311
|
+
refs.synthSettings = synth;
|
|
312
|
+
rows.append(row(INSTRUMENTS_COPY.labelSynthSettings, synth));
|
|
313
|
+
}
|
|
303
314
|
item.append(head, rows, editButtonNode(card, refs));
|
|
304
315
|
drawError(refs);
|
|
305
316
|
// An editor that was open (a draft kept in memory) comes back with the card.
|
|
@@ -766,7 +777,7 @@ export function createInstrumentsView({ document, onInstrumentSwitch, onInstrume
|
|
|
766
777
|
instrumentsBody.scrollTop = scrollTop;
|
|
767
778
|
}
|
|
768
779
|
|
|
769
|
-
/** Updates the changed parts of each card in place: the dot, the state words, the Drum kit lines, the In use lines and the Drum map lines. */
|
|
780
|
+
/** Updates the changed parts of each card in place: the dot, the state words, the Drum kit lines, the In use lines, the Synth settings lines and the Drum map lines. */
|
|
770
781
|
function updateCards(previous, model, skip = []) {
|
|
771
782
|
model.cards.forEach((card, index) => {
|
|
772
783
|
if (skip.includes(index)) return;
|
|
@@ -786,6 +797,7 @@ export function createInstrumentsView({ document, onInstrumentSwitch, onInstrume
|
|
|
786
797
|
setText(refs.words, card.port.words);
|
|
787
798
|
if (card.port.attention !== before.port.attention) refs.words.dataset.attention = String(card.port.attention);
|
|
788
799
|
if (JSON.stringify(card.inUse) !== JSON.stringify(before.inUse)) refs.inUse.replaceChildren(...inUseLines(card.inUse));
|
|
800
|
+
if (card.synthSettings !== null && JSON.stringify(card.synthSettings) !== JSON.stringify(before.synthSettings)) refs.synthSettings.replaceChildren(...synthSettingsLines(card.synthSettings));
|
|
789
801
|
if (card.drumKit !== null && JSON.stringify(card.drumKit) !== JSON.stringify(before.drumKit)) refs.drumKit.replaceChildren(...drumKitLines(card.drumKit));
|
|
790
802
|
if (card.drumMaps !== null && JSON.stringify(card.drumMaps) !== JSON.stringify(before.drumMaps)) {
|
|
791
803
|
// The drawn flags are read first and the focus noted, because the nodes are replaced: a map that stays keeps its state.
|