moontide-mcp 2.2.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/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', () => refreshMidiHidden());
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
- askUi.setConnected('connected');
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
- askUi.setConnected('disconnected');
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
- askUi.setConnected('disconnected');
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
- askUi.setConnected(ws.readyState === ws.OPEN ? 'connected' : 'connecting');
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