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/main.js
CHANGED
|
@@ -108,6 +108,11 @@ import { createSongGridView, isAskKey } from './song-grid-view.js';
|
|
|
108
108
|
import { SONG_FOLDS_COPY, isRememberable, readSongFolds, writeSongFolds } from './song-prefs.js';
|
|
109
109
|
import { JAM_COPY, buildJamModel, jamStuckFor, jamIsStuck } from './jam-model.js';
|
|
110
110
|
import { createJamView } from './jam-view.js';
|
|
111
|
+
import { createScratchView } from './scratch-view.js';
|
|
112
|
+
import { createMidiIn } from './midi-in.js';
|
|
113
|
+
import { createPlayedBuffer } from './played-buffer.js';
|
|
114
|
+
import { cycleAtStamp, listenOptions, unitInMotif } from './scratch-model.js';
|
|
115
|
+
import { createScratchListen } from './scratch-listen.js';
|
|
111
116
|
import { createInstrumentsView } from './instruments-view.js';
|
|
112
117
|
import { instrumentsModel, readDataFolder, readInstrumentResult } from './instruments-model.js';
|
|
113
118
|
import { createMidiOut, installNoMidiShim, isKnownMidiRejection } from './midi-out.js';
|
|
@@ -201,6 +206,9 @@ let pageSocket = 'connecting';
|
|
|
201
206
|
let pageDataFolder = null;
|
|
202
207
|
// The one place the page asks whether this browser has Web MIDI; midi-out and the Instruments view share it.
|
|
203
208
|
const supportsMidi = () => typeof navigator.requestMIDIAccess === 'function';
|
|
209
|
+
// The listener for the MIDI inputs the setup file names (made below, next to the Scratchpad it feeds). Declared here so the MIDI facts'
|
|
210
|
+
// callback can tell it that the port list or the access just changed.
|
|
211
|
+
let midiIn = null;
|
|
204
212
|
const midiOut = createMidiOut({
|
|
205
213
|
getWebMidi: () => globalThis.WebMidi ?? null,
|
|
206
214
|
enableWebMidi: () => {
|
|
@@ -235,6 +243,8 @@ const midiOut = createMidiOut({
|
|
|
235
243
|
onClockState: () => renderMidiIndicator(),
|
|
236
244
|
now: () => Date.now(),
|
|
237
245
|
publish: (facts) => {
|
|
246
|
+
// The access may just have come up, or a port may have come or gone: the input listener reads the ports again.
|
|
247
|
+
midiIn?.refresh();
|
|
238
248
|
// The indicator shows the same facts the server hears, so the page and Claude never disagree; it
|
|
239
249
|
// renders even while the socket is closed, so it can hide.
|
|
240
250
|
renderMidiIndicator(facts);
|
|
@@ -693,6 +703,17 @@ function drawTopBar() {
|
|
|
693
703
|
syncGridQueued(now);
|
|
694
704
|
}
|
|
695
705
|
|
|
706
|
+
/**
|
|
707
|
+
* The music's cycle now, as the top bar reads it (the same condition that it plays here, the same clamp), or null while it does not
|
|
708
|
+
* play: the Scratchpad's Send to Claude takes the moment of a motif from it. It reads the scheduler and nothing else.
|
|
709
|
+
*/
|
|
710
|
+
function musicCycleNow() {
|
|
711
|
+
const plays = currentSoundState === 'playing' && !pageSuperseded && hasEvaluatedSuccessfully;
|
|
712
|
+
if (!plays) return null;
|
|
713
|
+
const raw = scheduler.now();
|
|
714
|
+
return Number.isFinite(raw) && raw > 0 ? raw : 0;
|
|
715
|
+
}
|
|
716
|
+
|
|
696
717
|
/**
|
|
697
718
|
* Hands the queued section header of the Song grid its countdown for this poll: the rest of the top bar's text
|
|
698
719
|
* (`in 3 bars`, `in 2 beats`, `in 1 beat`) from the same record and the same cycle `now` the bar just drew, so the two
|
|
@@ -1087,6 +1108,7 @@ const timelineEditUi = createTimelineEditView({
|
|
|
1087
1108
|
window.addEventListener('blur', () => timelineEditUi.cancelGesture());
|
|
1088
1109
|
const jamViewEl = document.getElementById('jam-view');
|
|
1089
1110
|
const instrumentsViewEl = document.getElementById('instruments-view');
|
|
1111
|
+
const scratchViewEl = document.getElementById('scratch-view');
|
|
1090
1112
|
const editorArea = document.getElementById('editor-area');
|
|
1091
1113
|
const viewButtonsEl = document.getElementById('view-buttons');
|
|
1092
1114
|
const layoutButtonsEl = document.getElementById('layout-buttons');
|
|
@@ -1140,6 +1162,258 @@ const jamUi = createJamView({
|
|
|
1140
1162
|
onScaleChoice: (choice) => chooseJamScale(choice),
|
|
1141
1163
|
});
|
|
1142
1164
|
|
|
1165
|
+
// The Scratchpad view's DOM and its motif, which lives in memory only: nothing it holds is stored or played, and the motif is sent only
|
|
1166
|
+
// when the user presses Send to Claude (the view hands the request to onSend; the page sends it, as any other request to Claude). Created
|
|
1167
|
+
// here, before any code path that can call into it.
|
|
1168
|
+
const scratchUi = createScratchView({
|
|
1169
|
+
document,
|
|
1170
|
+
rootFontPx: () => parseFloat(getComputedStyle(document.documentElement).fontSize),
|
|
1171
|
+
// Send to Claude: the motif request goes through the Ask channel like any other request, so the Requests panel shows it. askUi is
|
|
1172
|
+
// created further down and looked up when the user presses.
|
|
1173
|
+
onSend: (request) => {
|
|
1174
|
+
const result = askUi.sendMotif(request);
|
|
1175
|
+
syncScratchSend();
|
|
1176
|
+
return result;
|
|
1177
|
+
},
|
|
1178
|
+
musicCycle: () => musicCycleNow(),
|
|
1179
|
+
// The marker loop runs only while Tap is on: the view tells the page whenever Tap turns on or off.
|
|
1180
|
+
onTapChange: (on) => (on ? startTapMarker() : stopTapMarker()),
|
|
1181
|
+
// Record that: the keyboard's last cycles become the motif; the snapshot stays in memory and nothing is sent or written.
|
|
1182
|
+
onRecord: () => {
|
|
1183
|
+
// The live layer showed exactly the notes Record that writes (one layout), so once they are the motif the buffer is empty again and the
|
|
1184
|
+
// live view hands over to it. A press that took nothing leaves the buffer alone.
|
|
1185
|
+
if (scratchUi.record(midiIn.snapshot())) midiIn.clearBuffer();
|
|
1186
|
+
},
|
|
1187
|
+
// The Allow MIDI to listen press: the only moment the keyboard input asks the browser for MIDI, and it does so through the music's own enable.
|
|
1188
|
+
onAllow: () => void midiIn.allow(),
|
|
1189
|
+
// Listen, Stop and Loop: the view asks, the page's Listen player plays on the page's own MIDI layer (see startListen).
|
|
1190
|
+
onListen: (request) => startListen(request),
|
|
1191
|
+
onStop: () => stopListen('stop'),
|
|
1192
|
+
onLoop: (on) => scratchListen.setLoop(on),
|
|
1193
|
+
// The Heads up line: the unmuted tracks of the playing code on the chosen instrument and channel, read by the MIDI layer.
|
|
1194
|
+
playingTracks: (instrumentId, channel) => midiOut.playingTracksOn(instrumentId, channel),
|
|
1195
|
+
});
|
|
1196
|
+
// A drag on the roll ends with nothing committed when the window loses focus.
|
|
1197
|
+
window.addEventListener('blur', () => scratchUi.cancelGesture());
|
|
1198
|
+
|
|
1199
|
+
// Tap and the MIDI keyboard read the music's cycle-to-time map through the one page clock (midiOut.stampOfCycle), never a second clock and never the top
|
|
1200
|
+
// bar's cycle. The two anchors are the stamps of the cycle lines on both sides of the music's now; null when the music is not running,
|
|
1201
|
+
// the clock is still warming up or the scheduler cannot be read (the view then says so and places nothing).
|
|
1202
|
+
let cycleLinesErrorLogged = false;
|
|
1203
|
+
function readCycleLines() {
|
|
1204
|
+
let raw;
|
|
1205
|
+
try {
|
|
1206
|
+
raw = scheduler.now();
|
|
1207
|
+
} catch (err) {
|
|
1208
|
+
// The listening tick reads this every 50 ms: say it once.
|
|
1209
|
+
if (!cycleLinesErrorLogged) console.error('Moontide: the scheduler could not be read for the Scratchpad:', err);
|
|
1210
|
+
cycleLinesErrorLogged = true;
|
|
1211
|
+
return null;
|
|
1212
|
+
}
|
|
1213
|
+
if (!Number.isFinite(raw)) return null;
|
|
1214
|
+
const c0 = Math.floor(Math.max(raw, 0));
|
|
1215
|
+
const ta = midiOut.stampOfCycle(c0);
|
|
1216
|
+
const tb = midiOut.stampOfCycle(c0 + 1);
|
|
1217
|
+
if (ta === null || tb === null || ta === tb) return null;
|
|
1218
|
+
return { a: { c: c0, t: ta }, b: { c: c0 + 1, t: tb } };
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// The notes a MIDI keyboard plays, in memory only. The listener reads the Web MIDI access Strudel's shared enable already holds, whatever view
|
|
1222
|
+
// is open, and asks for it only from the Allow MIDI to listen press, through that same enable (one request, never a second prompt). It places
|
|
1223
|
+
// each note by its own time stamp on the music's cycle lines. Nothing it hears is sent to an instrument. Its state goes to the Scratchpad's
|
|
1224
|
+
// listening strip.
|
|
1225
|
+
const playedBuffer = createPlayedBuffer();
|
|
1226
|
+
midiIn = createMidiIn({
|
|
1227
|
+
getAccess: () => {
|
|
1228
|
+
const webMidi = globalThis.WebMidi;
|
|
1229
|
+
return webMidi && webMidi.enabled === true && webMidi.interface ? webMidi.interface : null;
|
|
1230
|
+
},
|
|
1231
|
+
accessState: () => midiOut.accessState(),
|
|
1232
|
+
accessFailure: () => midiOut.accessFailure(),
|
|
1233
|
+
enable: () => midiOut.enable(),
|
|
1234
|
+
// Only a visible tab that holds the music listens, so there are never two buffers.
|
|
1235
|
+
isVisible: () => document.visibilityState === 'visible',
|
|
1236
|
+
holdsMusic: () => !pageSuperseded,
|
|
1237
|
+
readCycleLines,
|
|
1238
|
+
// The scheduler keeps its tempo while stopped, which is what keeps the buffer on the beat then.
|
|
1239
|
+
readCps: () => scheduler.cps,
|
|
1240
|
+
isPlaying: () => transportPlaying,
|
|
1241
|
+
perfNow: () => performance.now(),
|
|
1242
|
+
setTimer: (fn, ms) => setTimeout(fn, ms),
|
|
1243
|
+
clearTimer: (timer) => clearTimeout(timer),
|
|
1244
|
+
buffer: playedBuffer,
|
|
1245
|
+
onChange: () => {
|
|
1246
|
+
const state = midiIn.state();
|
|
1247
|
+
scratchUi.setListening(state);
|
|
1248
|
+
syncLive(state);
|
|
1249
|
+
},
|
|
1250
|
+
onError: (message) => console.error(message),
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1253
|
+
// Listen: the motif played on the output the user chose, by the page's own MIDI layer (the same access, queue and refusals as the music's
|
|
1254
|
+
// controls). The player holds the clock, the timer and the output it is given and nothing else; it changes no code and no History, sends nothing to
|
|
1255
|
+
// Claude and never starts by itself. What became of a play goes to the hear strip.
|
|
1256
|
+
const scratchListen = createScratchListen({
|
|
1257
|
+
perfNow: () => performance.now(),
|
|
1258
|
+
setTimer: (fn, ms) => setTimeout(fn, ms),
|
|
1259
|
+
clearTimer: (timer) => clearTimeout(timer),
|
|
1260
|
+
emit: (note) => midiOut.sendListenNote(note),
|
|
1261
|
+
stampOfCycle: (cycle) => midiOut.stampOfCycle(cycle),
|
|
1262
|
+
musicCycle: () => musicCycleNow(),
|
|
1263
|
+
readCps: () => scheduler.cps,
|
|
1264
|
+
onState: (state) => {
|
|
1265
|
+
// The frame loop also runs while a play waits or plays, to hand the view the playhead's place.
|
|
1266
|
+
listenBusy = state.kind === 'waiting' || state.kind === 'playing';
|
|
1267
|
+
scratchUi.setHear(state);
|
|
1268
|
+
syncLive();
|
|
1269
|
+
},
|
|
1270
|
+
});
|
|
1271
|
+
// The menu of the hear strip lists the setup file's instruments; it is given again whenever the rig changes (see applyRig).
|
|
1272
|
+
scratchUi.setOutputs(listenOptions(pageRig), pageRig.setup);
|
|
1273
|
+
|
|
1274
|
+
let listenBusy = false;
|
|
1275
|
+
// The press that is waiting for the MIDI permission, or null, and a number that a Stop, a new press or a stop of the view moves on, so an answer that
|
|
1276
|
+
// comes late finds it is no longer wanted.
|
|
1277
|
+
let listenAsking = null;
|
|
1278
|
+
let listenTicket = 0;
|
|
1279
|
+
|
|
1280
|
+
// A press of Listen. MIDI is asked for only here, through the one shared enable, and only when the page has not asked yet; the play starts
|
|
1281
|
+
// when it is granted, unless Stop came meanwhile. Whatever cannot start is said in one sentence and nothing falls back to anything else.
|
|
1282
|
+
function startListen(request) {
|
|
1283
|
+
listenTicket += 1;
|
|
1284
|
+
const word = midiOut.accessState();
|
|
1285
|
+
if (word === 'enabled') {
|
|
1286
|
+
playListen(request);
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
const { instrumentId, channel } = request;
|
|
1290
|
+
if (word === 'denied' || word === 'unsupported' || word === 'failed') {
|
|
1291
|
+
refuseListen({ kind: 'refused', refused: word, failure: midiOut.accessFailure() ?? 'Error', instrumentId, channel, mid: false });
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
// 'idle' (not asked yet) or 'pending' (the browser's prompt is open): the same enable answers both.
|
|
1295
|
+
const ticket = listenTicket;
|
|
1296
|
+
listenAsking = { instrumentId, channel };
|
|
1297
|
+
scratchUi.setHear({ kind: 'asking' });
|
|
1298
|
+
void midiOut.enable().then(() => {
|
|
1299
|
+
if (ticket !== listenTicket) return;
|
|
1300
|
+
listenAsking = null;
|
|
1301
|
+
const answer = midiOut.accessState();
|
|
1302
|
+
if (answer === 'enabled') playListen(request);
|
|
1303
|
+
else refuseListen({ kind: 'refused', refused: answer === 'denied' || answer === 'unsupported' ? answer : 'failed', failure: midiOut.accessFailure() ?? 'Error', instrumentId, channel, mid: false });
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
// The output must be one the page could play on right now, else the reason is said and nothing starts.
|
|
1308
|
+
function playListen({ instrumentId, channel, start, loop }) {
|
|
1309
|
+
const target = midiOut.listenTarget(instrumentId);
|
|
1310
|
+
if (target.refused !== undefined) {
|
|
1311
|
+
refuseListen({ kind: 'refused', ...target, instrumentId, channel, mid: false });
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
scratchListen.play({ readMotif: () => scratchUi.motif(), instrumentId, channel, start, loop });
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// A refused press still replaces the play before it: the old play is silenced first, then the refusal is said. Its 'stopped' state comes
|
|
1318
|
+
// first, so the refusal is the sentence that stays, and Stop is never greyed out over a play that goes on.
|
|
1319
|
+
function refuseListen(state) {
|
|
1320
|
+
scratchListen.stop('stop');
|
|
1321
|
+
scratchUi.setHear(state);
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// Stop, the view closing, the tab hidden and the page left all end here: a wait for the permission is cancelled, and a play is silenced.
|
|
1325
|
+
function stopListen(reason) {
|
|
1326
|
+
listenTicket += 1;
|
|
1327
|
+
const asking = listenAsking;
|
|
1328
|
+
listenAsking = null;
|
|
1329
|
+
if (asking !== null) scratchUi.setHear({ kind: 'stopped', reason, ...asking });
|
|
1330
|
+
scratchListen.stop(reason);
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// One document keydown listener in the capture phase, because focus may be on the Play button or anywhere outside a text field: a
|
|
1334
|
+
// listener on the view would never hear it. It acts only with the Scratchpad open and Tap on, never in a text field or the code
|
|
1335
|
+
// editor, and never for a Ctrl, Cmd or Alt chord. The handler's clock is read first, then the event's own stamp: the stamp, not the
|
|
1336
|
+
// time this code runs, decides where the note lands. A held key's repeats are swallowed and place nothing.
|
|
1337
|
+
let tapSpaceHandled = false;
|
|
1338
|
+
document.addEventListener('keydown', (event) => {
|
|
1339
|
+
if (event.key !== ' ' || viewState.view !== 'scratchpad' || !scratchUi.tapOn()) return;
|
|
1340
|
+
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
|
1341
|
+
if (isTextEntryFocused() || editorArea.contains(document.activeElement)) return;
|
|
1342
|
+
const h = performance.now();
|
|
1343
|
+
const ts = event.timeStamp;
|
|
1344
|
+
event.preventDefault();
|
|
1345
|
+
tapSpaceHandled = true;
|
|
1346
|
+
if (event.repeat) return;
|
|
1347
|
+
scratchUi.tap({ ts, h, anchors: readCycleLines() });
|
|
1348
|
+
}, true);
|
|
1349
|
+
// The Space keyup of a handled tap never clicks a focused button.
|
|
1350
|
+
document.addEventListener('keyup', (event) => {
|
|
1351
|
+
if (event.key !== ' ' || !tapSpaceHandled) return;
|
|
1352
|
+
tapSpaceHandled = false;
|
|
1353
|
+
event.preventDefault();
|
|
1354
|
+
}, true);
|
|
1355
|
+
// A window that loses focus between the key down and the key up never hears the key up; without this the next Space release, which may
|
|
1356
|
+
// be the click of a focused button, would be swallowed.
|
|
1357
|
+
window.addEventListener('blur', () => {
|
|
1358
|
+
tapSpaceHandled = false;
|
|
1359
|
+
});
|
|
1360
|
+
|
|
1361
|
+
// The now-marker: while Tap is on, each frame reads the same anchors at the present moment and hands the motif position to the view.
|
|
1362
|
+
// The loop also keeps the page clock warm on a page with no MIDI instrument, so a first tap right after Tap turns on can still meet a
|
|
1363
|
+
// clock that gives nothing yet, and says so.
|
|
1364
|
+
let tapMarkerFrame = null;
|
|
1365
|
+
function tapMarkerStep() {
|
|
1366
|
+
tapMarkerFrame = null;
|
|
1367
|
+
if (!scratchUi.tapOn()) return;
|
|
1368
|
+
const anchors = readCycleLines();
|
|
1369
|
+
scratchUi.setNowUnit(anchors === null ? null : unitInMotif(cycleAtStamp(performance.now(), anchors.a, anchors.b), scratchUi.motifCycles()));
|
|
1370
|
+
tapMarkerFrame = requestAnimationFrame(tapMarkerStep);
|
|
1371
|
+
}
|
|
1372
|
+
function startTapMarker() {
|
|
1373
|
+
if (tapMarkerFrame === null) tapMarkerFrame = requestAnimationFrame(tapMarkerStep);
|
|
1374
|
+
}
|
|
1375
|
+
function stopTapMarker() {
|
|
1376
|
+
if (tapMarkerFrame !== null) cancelAnimationFrame(tapMarkerFrame);
|
|
1377
|
+
tapMarkerFrame = null;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// The live view: while the Scratchpad is open and an input is listened to, every animation frame hands the view the cycle axis now and the
|
|
1381
|
+
// keyboard buffer's snapshot (read again only when the buffer changed, else the same object, so the view knows nothing was rebuilt). The loop only
|
|
1382
|
+
// reads: it makes no sound and sends no message and no request. It stops when the view closes or the listening ends, and then the view is
|
|
1383
|
+
// told there is nothing live (Off). The frame time is the view's only clock.
|
|
1384
|
+
let scratchOpen = false;
|
|
1385
|
+
let liveFrame = null;
|
|
1386
|
+
let liveRevision = null;
|
|
1387
|
+
let liveSnapshot = null;
|
|
1388
|
+
function liveStep() {
|
|
1389
|
+
liveFrame = null;
|
|
1390
|
+
const live = midiIn.live();
|
|
1391
|
+
if (live === null) {
|
|
1392
|
+
scratchUi.setLive(null);
|
|
1393
|
+
} else {
|
|
1394
|
+
if (live.revision !== liveRevision) {
|
|
1395
|
+
liveRevision = live.revision;
|
|
1396
|
+
liveSnapshot = midiIn.snapshot();
|
|
1397
|
+
}
|
|
1398
|
+
scratchUi.setLive({ now: live.now, snapshot: liveSnapshot, frameMs: performance.now() });
|
|
1399
|
+
}
|
|
1400
|
+
// The Listen playhead's place in the motif (null while the play waits).
|
|
1401
|
+
if (listenBusy) scratchUi.setHearPosition(scratchListen.position(), performance.now());
|
|
1402
|
+
liveFrame = requestAnimationFrame(liveStep);
|
|
1403
|
+
}
|
|
1404
|
+
// Starts or stops the loop for the state of the view and of the listener (`state` is midiIn.state(), read here when left out).
|
|
1405
|
+
function syncLive(state = midiIn.state()) {
|
|
1406
|
+
if (scratchOpen && (state.listen === 'listening' || listenBusy)) {
|
|
1407
|
+
if (liveFrame === null) liveFrame = requestAnimationFrame(liveStep);
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
if (liveFrame !== null) cancelAnimationFrame(liveFrame);
|
|
1411
|
+
liveFrame = null;
|
|
1412
|
+
liveRevision = null;
|
|
1413
|
+
liveSnapshot = null;
|
|
1414
|
+
scratchUi.setLive(null);
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1143
1417
|
// The Instruments view's DOM. Created here, before any code path that can call into it (its redraw
|
|
1144
1418
|
// hooks stay shut while instrumentsOpen is false, which it is until renderViews() has run). The view
|
|
1145
1419
|
// never sends; a press on a card's switch comes back here, and requestInstrumentSwitch owns the send.
|
|
@@ -1288,8 +1562,24 @@ const askUi = createAskView({
|
|
|
1288
1562
|
setTimer: (fn, ms) => setTimeout(fn, ms),
|
|
1289
1563
|
clearTimer: (timer) => clearTimeout(timer),
|
|
1290
1564
|
now: () => Date.now(),
|
|
1565
|
+
// What became of the Scratchpad's motif request is said in its own strip.
|
|
1566
|
+
onMotifResult: (result) => {
|
|
1567
|
+
scratchUi.motifResult(result);
|
|
1568
|
+
syncScratchSend();
|
|
1569
|
+
},
|
|
1291
1570
|
});
|
|
1292
1571
|
|
|
1572
|
+
// The Scratchpad's Send button follows the connection and the last motif request: the page tells the view whenever either changes.
|
|
1573
|
+
function syncScratchSend() {
|
|
1574
|
+
scratchUi.setSendState({ connected: askUi.isConnected(), pending: askUi.motifPending() });
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
/** The connection state of the Ask channel ('connecting', 'connected' or 'disconnected'), told to the Ask box and to the Scratchpad's Send button. */
|
|
1578
|
+
function setAskConnected(state) {
|
|
1579
|
+
askUi.setConnected(state);
|
|
1580
|
+
syncScratchSend();
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1293
1583
|
// The segment a superseded tab shows, with its "Play in this tab" button. takeBackTimer is the
|
|
1294
1584
|
// 5 s wait for the server's hello after a click; it is null while no take-over is pending.
|
|
1295
1585
|
let takeBackTimer = null;
|
|
@@ -1383,7 +1673,13 @@ function refreshMidiHidden() {
|
|
|
1383
1673
|
midiOut.publishFacts();
|
|
1384
1674
|
}
|
|
1385
1675
|
|
|
1386
|
-
document.addEventListener('visibilitychange', () =>
|
|
1676
|
+
document.addEventListener('visibilitychange', () => {
|
|
1677
|
+
// A hidden tab throttles its timers, so a play could not keep time and would hold notes: it is stopped.
|
|
1678
|
+
if (document.visibilityState === 'hidden') stopListen('hidden');
|
|
1679
|
+
refreshMidiHidden();
|
|
1680
|
+
// Showing or hiding the tab starts or stops the keyboard listening.
|
|
1681
|
+
midiIn.refresh();
|
|
1682
|
+
});
|
|
1387
1683
|
|
|
1388
1684
|
/**
|
|
1389
1685
|
* Draws the top-bar MIDI indicator from the MIDI facts. midiPublishReady is true exactly while the
|
|
@@ -1817,6 +2113,10 @@ function applyRig(rig) {
|
|
|
1817
2113
|
const drumsBefore = jamOpen && timelineTracks !== null ? drumTrackIndexes(timelineTracks, laneTags().drums) : null;
|
|
1818
2114
|
pageRig = withBuildMismatch(rig, pageBuildMismatch);
|
|
1819
2115
|
midiOut.setRig(pageRig);
|
|
2116
|
+
// The input ports the setup file names (a valid rig its inputs, an absent or invalid one none) and the file's own word, so the page can say which.
|
|
2117
|
+
midiIn.setInputs(pageRig.setup === 'valid' ? pageRig.inputs ?? [] : [], pageRig.setup);
|
|
2118
|
+
midiIn.refresh();
|
|
2119
|
+
scratchUi.setOutputs(listenOptions(pageRig), pageRig.setup);
|
|
1820
2120
|
renderMidiIndicator();
|
|
1821
2121
|
// The Mixer reads its MIDI tracks against the rig: draw it again when the file changes while it is open.
|
|
1822
2122
|
if (viewState.view === 'mixer') renderMixer();
|
|
@@ -1866,6 +2166,7 @@ function renderVersionIndicator() {
|
|
|
1866
2166
|
// result changed. One writer for both: the sound-state event, the superseded and hello handlers, trackEvalSuccess and
|
|
1867
2167
|
// the 250 ms poll all end here (a second writer of "playing" would let the button and the word disagree).
|
|
1868
2168
|
let shownTransport = null;
|
|
2169
|
+
let transportPlaying = false;
|
|
1869
2170
|
|
|
1870
2171
|
/** The transport model for the page as it is now. The editor's text is trimmed: a blank editor has nothing to play. */
|
|
1871
2172
|
function currentTransport() {
|
|
@@ -1879,6 +2180,8 @@ function currentTransport() {
|
|
|
1879
2180
|
|
|
1880
2181
|
function renderTransport() {
|
|
1881
2182
|
const model = currentTransport();
|
|
2183
|
+
// The keyboard buffer's cycle axis reads this: the music plays, as the transport says it does.
|
|
2184
|
+
transportPlaying = model.playing;
|
|
1882
2185
|
const key = JSON.stringify(model);
|
|
1883
2186
|
if (key === shownTransport) return;
|
|
1884
2187
|
shownTransport = key;
|
|
@@ -1894,6 +2197,10 @@ function renderTransport() {
|
|
|
1894
2197
|
soundStateEl.setAttribute('aria-label', `${model.word}${model.here ? BAR_COPY.stateHere : ''}`);
|
|
1895
2198
|
// The accent means live now: the model's playing, not the sound state alone (a lost tab or an unevaluated page is not live).
|
|
1896
2199
|
soundStateEl.dataset.accent = String(model.playing);
|
|
2200
|
+
// Tap needs the music playing: the Scratchpad turns Tap off by itself when it stops.
|
|
2201
|
+
scratchUi.setMusicPlaying(model.playing);
|
|
2202
|
+
// The Heads up line names only tracks that play now, so it follows the playing state.
|
|
2203
|
+
scratchUi.refreshHear();
|
|
1897
2204
|
// The word, its ' here' part and the button's word change the bar's width: the fit step runs again.
|
|
1898
2205
|
topBarUi.fit();
|
|
1899
2206
|
}
|
|
@@ -1935,6 +2242,7 @@ function trackEvalSuccess(e) {
|
|
|
1935
2242
|
// The setup prepared at the lead is committed first, so the facts and the clock follow the code that plays.
|
|
1936
2243
|
waiting.midiPlan?.commit();
|
|
1937
2244
|
midiOut.recordSuccess(waiting.code);
|
|
2245
|
+
scratchUi.refreshHear();
|
|
1938
2246
|
refreshMidiHidden();
|
|
1939
2247
|
onVersionPlaying();
|
|
1940
2248
|
});
|
|
@@ -1945,6 +2253,7 @@ function trackEvalSuccess(e) {
|
|
|
1945
2253
|
// The MIDI facts of the code that plays now. This runs inside repl.evaluate, before
|
|
1946
2254
|
// handleEvaluate sends `evaluated` on the same socket, so the server has them first.
|
|
1947
2255
|
midiOut.recordSuccess(editor.repl.state.activeCode);
|
|
2256
|
+
scratchUi.refreshHear();
|
|
1948
2257
|
refreshMidiHidden();
|
|
1949
2258
|
onVersionPlaying();
|
|
1950
2259
|
}
|
|
@@ -2401,7 +2710,7 @@ ws.addEventListener('open', () => {
|
|
|
2401
2710
|
midiOut.publishFacts({ force: true });
|
|
2402
2711
|
historyUi.setConnected('connected');
|
|
2403
2712
|
mixerUi.setConnected('connected');
|
|
2404
|
-
|
|
2713
|
+
setAskConnected('connected');
|
|
2405
2714
|
// The requests of a page that went away (a reload) come back from the server; an empty answer changes nothing.
|
|
2406
2715
|
sendIfOpen(JSON.stringify({ type: 'ask-board-request' }));
|
|
2407
2716
|
// Always ask, whether or not the view is open, so live additions accumulate.
|
|
@@ -2425,7 +2734,7 @@ ws.addEventListener('close', () => {
|
|
|
2425
2734
|
historyUi.setConnected('disconnected');
|
|
2426
2735
|
mixerUi.setConnected('disconnected');
|
|
2427
2736
|
requestTracker.connectionLost();
|
|
2428
|
-
|
|
2737
|
+
setAskConnected('disconnected');
|
|
2429
2738
|
});
|
|
2430
2739
|
ws.addEventListener('error', () => {
|
|
2431
2740
|
pageSocket = 'closed';
|
|
@@ -2440,7 +2749,7 @@ ws.addEventListener('error', () => {
|
|
|
2440
2749
|
historyUi.setConnected('disconnected');
|
|
2441
2750
|
mixerUi.setConnected('disconnected');
|
|
2442
2751
|
requestTracker.connectionLost();
|
|
2443
|
-
|
|
2752
|
+
setAskConnected('disconnected');
|
|
2444
2753
|
});
|
|
2445
2754
|
|
|
2446
2755
|
// Evaluate requests are processed strictly one at a time through this promise chain — never
|
|
@@ -2496,10 +2805,12 @@ const mixerUi = createMixerView({
|
|
|
2496
2805
|
onGroupFold: (key) => toggleSongFold(key),
|
|
2497
2806
|
});
|
|
2498
2807
|
mixerUi.setConnected(ws.readyState === ws.OPEN ? 'connected' : 'connecting');
|
|
2499
|
-
|
|
2808
|
+
setAskConnected(ws.readyState === ws.OPEN ? 'connected' : 'connecting');
|
|
2500
2809
|
|
|
2501
2810
|
// pagehide: record a pending gesture while the socket is still open.
|
|
2502
2811
|
window.addEventListener('pagehide', () => mixerGesture.flushNow());
|
|
2812
|
+
// Listen first, so its notes are ended before the page's own MIDI silence goes out; its own listener, so a throw in one never skips the other.
|
|
2813
|
+
window.addEventListener('pagehide', () => stopListen('page'));
|
|
2503
2814
|
// A separate listener, so a throw in one never skips the other: MIDI silence on close or reload.
|
|
2504
2815
|
window.addEventListener('pagehide', () => midiOut.onPageHide());
|
|
2505
2816
|
// Its own listener too: a closing page drops its waiting change without a message (the server handles detach).
|
|
@@ -2507,6 +2818,7 @@ window.addEventListener('pagehide', () => takeover.cancel('unload'));
|
|
|
2507
2818
|
// A resize or a layout change moves the lines: the selected-strip light follows (ignored while the Mixer is closed).
|
|
2508
2819
|
window.addEventListener('resize', () => {
|
|
2509
2820
|
if (viewState.view === 'mixer') syncMixerLit();
|
|
2821
|
+
if (viewState.view === 'scratchpad') scratchUi.resize();
|
|
2510
2822
|
});
|
|
2511
2823
|
|
|
2512
2824
|
ws.addEventListener('message', (event) => {
|
|
@@ -2533,6 +2845,8 @@ ws.addEventListener('message', (event) => {
|
|
|
2533
2845
|
sessionUi.setTakeBackPending(false);
|
|
2534
2846
|
renderMidiIndicator();
|
|
2535
2847
|
refreshMidiHidden();
|
|
2848
|
+
// A tab that lost the music stops listening to the keyboard, so there is never a second buffer.
|
|
2849
|
+
midiIn.refresh();
|
|
2536
2850
|
drawTopBar();
|
|
2537
2851
|
// Said again when a tab plays again after none did: this tab is already stopped, only its text changes.
|
|
2538
2852
|
// No second panic: it could cut the notes of the tab that plays now.
|
|
@@ -2617,6 +2931,8 @@ ws.addEventListener('message', (event) => {
|
|
|
2617
2931
|
// The shadows are cleared with no reset (another tab may have changed the sounds); the replay sends them again.
|
|
2618
2932
|
midiOut.setActive(true);
|
|
2619
2933
|
refreshMidiHidden();
|
|
2934
|
+
// The tab holds the music again, so it listens to the keyboard again.
|
|
2935
|
+
midiIn.refresh();
|
|
2620
2936
|
}
|
|
2621
2937
|
renderInstrumentsIfOpen();
|
|
2622
2938
|
chain = chain
|
|
@@ -3490,7 +3806,7 @@ function markNarrowSpans() {
|
|
|
3490
3806
|
}
|
|
3491
3807
|
|
|
3492
3808
|
/** The things that listen only to the window follow a new size: the Mixer's lit strip once per animation
|
|
3493
|
-
* frame while a drag moves and on release, the Timeline's narrow-span marks on release. CodeMirror needs nothing: it
|
|
3809
|
+
* frame while a drag moves and on release, the Timeline's narrow-span marks on release, the Scratchpad's beat and row sizes. CodeMirror needs nothing: it
|
|
3494
3810
|
* measures itself (its own ResizeObserver), and no manual measure call is made here. */
|
|
3495
3811
|
function refreshAfterSize({ final }) {
|
|
3496
3812
|
if (final) {
|
|
@@ -3498,12 +3814,14 @@ function refreshAfterSize({ final }) {
|
|
|
3498
3814
|
splitFrame = 0;
|
|
3499
3815
|
if (viewState.view === 'mixer') syncMixerLit();
|
|
3500
3816
|
if (viewState.view === 'timeline') markNarrowSpans();
|
|
3817
|
+
if (viewState.view === 'scratchpad') scratchUi.resize();
|
|
3501
3818
|
return;
|
|
3502
3819
|
}
|
|
3503
3820
|
if (splitFrame !== 0) return;
|
|
3504
3821
|
splitFrame = requestAnimationFrame(() => {
|
|
3505
3822
|
splitFrame = 0;
|
|
3506
3823
|
if (viewState.view === 'mixer') syncMixerLit();
|
|
3824
|
+
if (viewState.view === 'scratchpad') scratchUi.resize();
|
|
3507
3825
|
});
|
|
3508
3826
|
}
|
|
3509
3827
|
|
|
@@ -3998,7 +4316,8 @@ document.addEventListener('keyup', (event) => {
|
|
|
3998
4316
|
// The one contextmenu listener of the page; no view module has its own. Shift + right-click keeps
|
|
3999
4317
|
// the browser's own menu, and so does a right-click inside the box (the user can paste) or while the
|
|
4000
4318
|
// audio-unlock overlay shows (it is a gate to start sound, not part of the page's work). The right-click
|
|
4001
|
-
// never evaluates, records or changes the editor's text, selection or scroll.
|
|
4319
|
+
// never evaluates, records or changes the editor's text, selection or scroll. The Scratchpad's roll takes
|
|
4320
|
+
// its own right-click to erase a note, so it opens no Ask box there; the rest of that view asks as any other.
|
|
4002
4321
|
document.addEventListener('contextmenu', (event) => {
|
|
4003
4322
|
// The key press already opened the box: only keep the browser's menu away.
|
|
4004
4323
|
if (keyAsk !== null && !keyAsk.openBefore && askUi.isOpen()) {
|
|
@@ -4012,6 +4331,12 @@ document.addEventListener('contextmenu', (event) => {
|
|
|
4012
4331
|
problemUi.openMenu({ x: event.clientX, y: event.clientY });
|
|
4013
4332
|
return;
|
|
4014
4333
|
}
|
|
4334
|
+
if (scratchUi.rollContains(event.target)) {
|
|
4335
|
+
// A Menu key press raises the same event with no pointer behind it, and its coordinates are the browser's pick: erasing the note
|
|
4336
|
+
// under them would lose a note the user never pointed at. Only the browser's menu is kept away.
|
|
4337
|
+
if (keyAsk === null) scratchUi.handleContextMenu(event);
|
|
4338
|
+
return;
|
|
4339
|
+
}
|
|
4015
4340
|
openAskFor(() => resolveAskTarget(event), { x: event.clientX, y: event.clientY });
|
|
4016
4341
|
});
|
|
4017
4342
|
|
|
@@ -4082,6 +4407,9 @@ function renderViews() {
|
|
|
4082
4407
|
const isMixerOpen = viewState.view === 'mixer';
|
|
4083
4408
|
const wasJamOpen = previousViewState !== null && previousViewState.view === 'jam';
|
|
4084
4409
|
const isJamOpen = viewState.view === 'jam';
|
|
4410
|
+
const isScratchOpen = viewState.view === 'scratchpad';
|
|
4411
|
+
// Read before the view hides: focus inside a view that closes goes to that view's own top-bar button, never to the page body.
|
|
4412
|
+
const scratchLostFocus = previousViewState !== null && previousViewState.view === 'scratchpad' && !isScratchOpen && scratchViewEl.contains(document.activeElement);
|
|
4085
4413
|
|
|
4086
4414
|
if ((wasTimelineOpen || wasJamOpen) && !isTimelineOpen && !isJamOpen) {
|
|
4087
4415
|
// The Timeline or the Jam view just closed and neither is open now (without a new version
|
|
@@ -4114,6 +4442,17 @@ function renderViews() {
|
|
|
4114
4442
|
const isInstrumentsOpen = viewState.view === 'instruments';
|
|
4115
4443
|
instrumentsViewEl.hidden = !isInstrumentsOpen;
|
|
4116
4444
|
instrumentsOpen = isInstrumentsOpen;
|
|
4445
|
+
scratchViewEl.hidden = !isScratchOpen;
|
|
4446
|
+
if (isScratchOpen) scratchUi.render();
|
|
4447
|
+
else scratchUi.closed();
|
|
4448
|
+
scratchOpen = isScratchOpen;
|
|
4449
|
+
// Listen does not outlast the view: another view opening, or the Scratchpad closing, silences it.
|
|
4450
|
+
if (!isScratchOpen) stopListen('closed');
|
|
4451
|
+
syncLive();
|
|
4452
|
+
if (scratchLostFocus) {
|
|
4453
|
+
const scratchButton = document.querySelector('#view-buttons button[data-key="7"]');
|
|
4454
|
+
if (scratchButton !== null) scratchButton.focus();
|
|
4455
|
+
}
|
|
4117
4456
|
jamUi.setPreferences({ instrument: viewState.jamInstrument, chordTones: viewState.jamChordTones });
|
|
4118
4457
|
historyUi.setViewContext({ open: isHistoryOpen, layout: viewState.layout, mode: viewState.historyMode });
|
|
4119
4458
|
|
|
@@ -4144,6 +4483,8 @@ function renderViews() {
|
|
|
4144
4483
|
}
|
|
4145
4484
|
previousViewState = viewState;
|
|
4146
4485
|
if (splitSizeChanged) refreshAfterSize({ final: true });
|
|
4486
|
+
// The panel is laid out by now, so the Scratchpad can read its room: on opening, and on a layout change while it stays open.
|
|
4487
|
+
if (isScratchOpen) scratchUi.resize();
|
|
4147
4488
|
|
|
4148
4489
|
// The animation loop runs while
|
|
4149
4490
|
// either the Timeline or the Parts view is open: a fresh Timeline open (including the very
|