moontide-mcp 2.1.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +54 -331
  2. package/dist/ask.js +59 -12
  3. package/dist/catalog.js +118 -9
  4. package/dist/instruments.js +83 -9
  5. package/dist/mcp.js +146 -20
  6. package/dist/midi-state.js +43 -1
  7. package/dist/protocol.js +123 -5
  8. package/dist/reference.js +12 -0
  9. package/dist/request-status.js +221 -0
  10. package/dist/session.js +203 -7
  11. package/dist/vocabulary.js +420 -0
  12. package/package.json +3 -2
  13. package/page/ask-view.js +118 -50
  14. package/page/ask.js +139 -169
  15. package/page/bar-model.js +44 -2
  16. package/page/change-timing.js +35 -0
  17. package/page/index.html +6 -2
  18. package/page/instruments-model.js +50 -3
  19. package/page/instruments-view.js +14 -2
  20. package/page/jam-analysis.js +68 -23
  21. package/page/jam-model.js +185 -29
  22. package/page/jam-resolve.js +271 -0
  23. package/page/jam-view.js +165 -10
  24. package/page/lights.js +29 -7
  25. package/page/main.js +807 -66
  26. package/page/midi-clock.js +22 -3
  27. package/page/midi-in.js +407 -0
  28. package/page/midi-out.js +557 -48
  29. package/page/midi-plan.js +42 -6
  30. package/page/midi-rig.js +286 -13
  31. package/page/midi-settings.js +507 -0
  32. package/page/mixer-view.js +7 -1
  33. package/page/note-panel.js +29 -8
  34. package/page/note-tag.js +19 -4
  35. package/page/page-rewrite.js +1 -1
  36. package/page/played-buffer.js +208 -0
  37. package/page/query-check.js +21 -2
  38. package/page/request-status-view.js +419 -0
  39. package/page/request-status.js +686 -0
  40. package/page/scratch-gesture.js +239 -0
  41. package/page/scratch-listen.js +260 -0
  42. package/page/scratch-model.js +1696 -0
  43. package/page/scratch-view.js +2176 -0
  44. package/page/song-grid-model.js +15 -0
  45. package/page/song-grid-view.js +1 -1
  46. package/page/structure.js +2 -1
  47. package/page/style.css +1243 -43
  48. package/page/tempo-glide.js +330 -0
  49. package/page/tempo-guard.js +62 -10
  50. package/page/timeline-edit-view.js +1 -1
  51. package/page/timeline.js +2 -1
  52. package/page/top-bar-view.js +18 -13
  53. package/page/views.js +20 -7
  54. package/reference/midi.md +2 -0
  55. package/reference/motif.md +83 -0
  56. package/reference/settings.md +161 -0
  57. package/reference/transitions.md +13 -1
package/page/main.js CHANGED
@@ -65,6 +65,7 @@ import {
65
65
  motionAfterOsChange,
66
66
  setChangeTiming,
67
67
  setSongLaunch,
68
+ VIEW_PREFS_COPY,
68
69
  BUILT_VIEWS,
69
70
  VIEW_KEYS,
70
71
  VIEW_TITLES,
@@ -90,20 +91,28 @@ import {
90
91
  sectionSpans,
91
92
  } from './parts.js';
92
93
  import { baseTitle, readSessionLabel, renderSessionIdentity } from './session-identity.js';
93
- import { ASK_COPY, PAGE_TOKEN_PLACEHOLDER, cycleAt, describeContext, errorAskPrefill, lineAtY, timelineEditPrefill } from './ask.js';
94
+ import { ASK_COPY, PAGE_TOKEN_PLACEHOLDER, cycleAt, describeContext, errorAskPrefill, fitSectionNames, lineAtY, timelineEditPrefill } from './ask.js';
94
95
  import { createAskView } from './ask-view.js';
96
+ import { createRequestTracker } from './request-status.js';
97
+ import { createRequestStatusView } from './request-status-view.js';
95
98
  import { buildReading, readingAt, flattenLanes } from './jam-analysis.js';
99
+ import { resolveReading, createScaleMemory } from './jam-resolve.js';
96
100
  import { readHarmony } from './harmony.js';
97
101
  import { SONG_COPY, differsLine, quoted, readStructure, songMessageText, songNoteLines } from './structure.js';
98
102
  import { checkSections, evaluateDeclared, expectedSource, sectionStarts } from './structure-check.js';
99
103
  import { collectFit, evaluateFit, fitLookup, fitPairs, fitSource, pairKey } from './song-fit.js';
100
104
  import { createSongView } from './structure-view.js';
101
- import { buildSongGrid, cellRecordLabel, playingSection, sectionDeclared, songLine } from './song-grid-model.js';
105
+ import { buildSongGrid, cellRecordLabel, playingSection, sectionDeclared, sectionsCrossed, songLine } from './song-grid-model.js';
102
106
  import { SongEditRefusal, cellEdit, checkCell, checkSection, launchLine, launchShift, playResetEdit, sameShift, shiftEdit, textAfterCanceledLaunch } from './song-edit.js';
103
- import { createSongGridView } from './song-grid-view.js';
107
+ import { createSongGridView, isAskKey } from './song-grid-view.js';
104
108
  import { SONG_FOLDS_COPY, isRememberable, readSongFolds, writeSongFolds } from './song-prefs.js';
105
109
  import { JAM_COPY, buildJamModel, jamStuckFor, jamIsStuck } from './jam-model.js';
106
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';
107
116
  import { createInstrumentsView } from './instruments-view.js';
108
117
  import { instrumentsModel, readDataFolder, readInstrumentResult } from './instruments-model.js';
109
118
  import { createMidiOut, installNoMidiShim, isKnownMidiRejection } from './midi-out.js';
@@ -124,6 +133,7 @@ import { checkFailureText, drumMapFor, parseRigMessage, rigFromReadFailure, with
124
133
  import { drumLane } from './drum-lane.js';
125
134
  import { captureQueryTypes, createSchedulerQueryScope, installQueryCheck } from './query-check.js';
126
135
  import { createTempoGuard } from './tempo-guard.js';
136
+ import { GLIDE_REFUSALS, createTempoGlide, lineTempoAction, parseGlideBars } from './tempo-glide.js';
127
137
  import {
128
138
  BEATS_PER_CYCLE,
129
139
  CHANGE_COPY,
@@ -137,6 +147,7 @@ import {
137
147
  parseTiming,
138
148
  pinnedTiming,
139
149
  queuedLine,
150
+ rampLine,
140
151
  settingLine,
141
152
  } from './change-timing.js';
142
153
  import { createTopBarView } from './top-bar-view.js';
@@ -195,6 +206,9 @@ let pageSocket = 'connecting';
195
206
  let pageDataFolder = null;
196
207
  // The one place the page asks whether this browser has Web MIDI; midi-out and the Instruments view share it.
197
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;
198
212
  const midiOut = createMidiOut({
199
213
  getWebMidi: () => globalThis.WebMidi ?? null,
200
214
  enableWebMidi: () => {
@@ -220,10 +234,17 @@ const midiOut = createMidiOut({
220
234
  getScheduler: () => editor.repl.scheduler,
221
235
  getAudioContext: () => (typeof globalThis.getAudioContext === 'function' ? globalThis.getAudioContext() : null),
222
236
  perfNow: () => performance.now(),
237
+ // A line far ahead during a tempo ramp is stamped through the ramp's own steps (tempoGlide is created below, long before a line is asked for).
238
+ rampSeconds: (fromCycle, toCycle) => {
239
+ const seconds = tempoGlide.secondsBetween(fromCycle, toCycle);
240
+ return seconds === null ? null : { seconds, lagSeconds: tempoGlide.lagSecondsBetween(fromCycle, toCycle) };
241
+ },
223
242
  // A clock state change (a Start waiting, a Start reached, a Stop) re-renders the indicator and its popover rows.
224
243
  onClockState: () => renderMidiIndicator(),
225
244
  now: () => Date.now(),
226
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();
227
248
  // The indicator shows the same facts the server hears, so the page and Claude never disagree; it
228
249
  // renders even while the socket is closed, so it can hide.
229
250
  renderMidiIndicator(facts);
@@ -257,6 +278,71 @@ let evalQueue = Promise.resolve();
257
278
  // launch all pass here too, so a hand evaluation follows the same rule. Checking after evaluate returns
258
279
  // would be too late: the failing pattern would already be the scheduler's pattern.
259
280
  const scheduler = editor.repl.scheduler;
281
+ // The tempo ramp (page/tempo-glide.js): it moves the tempo in steps toward the tempo a change set, and every step goes
282
+ // through the tempo guard's apply. The closure reads tempoGuard only when a step fires, long after the page has loaded.
283
+ const tempoGlide = createTempoGlide({
284
+ scheduler,
285
+ apply: (cps) => tempoGuard.apply(cps),
286
+ setTimer: (fn, ms) => setTimeout(fn, ms),
287
+ clearTimer: (timer) => clearTimeout(timer),
288
+ onEvent(event) {
289
+ // The server is told when a ramp ends on its own, synchronously, so the message goes out before the evaluated reply
290
+ // of the change that caused it and that reply carries the cancel line. Stop, a lost role, a page reload and a ramp that
291
+ // a new ramp replaced send nothing: the server learns the first three itself and the new ramp comes in its own reply.
292
+ // Mirrors src/protocol.ts's glide-ended by hand.
293
+ if (event.kind === 'finished') {
294
+ sendIfOpen(JSON.stringify({ type: 'glide-ended', reason: 'finished' }));
295
+ } else if (event.kind === 'canceled' && (event.reason === 'other-tempo' || event.reason === 'no-tempo' || event.reason === 'error')) {
296
+ const bpm = bpmForReply(event.cps);
297
+ sendIfOpen(JSON.stringify({ type: 'glide-ended', reason: event.reason, ...(bpm !== undefined ? { bpm } : {}) }));
298
+ }
299
+ if (event.kind === 'canceled' && event.reason === 'error') {
300
+ console.error(`Moontide: the tempo ramp was canceled because a step failed; the tempo stays where it was (${String(event.cps)} cps).`);
301
+ }
302
+ // The live region speaks a ramp when it starts, when it finishes and when the code cancels it, and says nothing for a step.
303
+ // Stop, a lost role, a reload, a failed step and a replaced ramp are not spoken (a replaced ramp's new start is).
304
+ const { plan } = event;
305
+ if (event.kind === 'started') {
306
+ const stillRuns = () => {
307
+ const running = tempoGlide.current();
308
+ return running !== null && running.startCycle === plan.startCycle && running.toCps === plan.toCps;
309
+ };
310
+ sayRamp(() => ({ kind: 'started', fromBpm: bpmOf(plan.fromCps), toBpm: bpmOf(plan.toCps), bars: plan.bars }), stillRuns);
311
+ } else if (event.kind === 'finished') {
312
+ sayRamp(() => ({ kind: 'finished', toBpm: bpmOf(plan.toCps) }));
313
+ } else if (event.kind === 'canceled' && (event.reason === 'other-tempo' || event.reason === 'no-tempo')) {
314
+ sayRamp(() => ({ kind: 'canceled', reason: event.reason, bpm: bpmOf(event.cps) }));
315
+ }
316
+ },
317
+ onError: (text, err) => console.error(text, err),
318
+ });
319
+
320
+ // The waiting change whose line onReached is handling now, or null. A ramp that starts or ends at a line is spoken only once
321
+ // that change has finished, after the silent clear of its countdown line, so the sentence is the one that stays in the live region.
322
+ let reachedRecord = null;
323
+ const rampSayLogged = new Set();
324
+
325
+ /**
326
+ * Says the sentence of a ramp event through the top bar's live region. `makeEvent` builds the event for rampLine and runs now;
327
+ * the sentence is spoken now, or after the finish of the waiting change whose line is being handled, and (with `stillCurrent`)
328
+ * only if it is still true then. A failure is logged once for each text and never reaches the ramp.
329
+ */
330
+ function sayRamp(makeEvent, stillCurrent = () => true) {
331
+ try {
332
+ const line = rampLine(makeEvent());
333
+ const speak = () => {
334
+ if (stillCurrent()) topBarUi.say(line);
335
+ };
336
+ if (reachedRecord !== null) takeover.whenFinished(reachedRecord, speak);
337
+ else speak();
338
+ } catch (err) {
339
+ const text = err instanceof Error ? err.message : String(err);
340
+ if (!rampSayLogged.has(text)) {
341
+ rampSayLogged.add(text);
342
+ console.error('Moontide: the tempo ramp could not be spoken:', err);
343
+ }
344
+ }
345
+ }
260
346
  // State and TimeSpan are kept from the first evaluation on (captureQueryTypes, called before the user code
261
347
  // of each evaluation runs), so a hand edit that assigns to those globals cannot change the check.
262
348
  let queryTypes = { State: undefined, TimeSpan: undefined };
@@ -274,6 +360,7 @@ const queryErrors = createOncePerChange();
274
360
  // 1 s debounce lets a page query hide the scheduler's report. The log listener drops every [query] text.
275
361
  const schedulerQueries = createSchedulerQueryScope({
276
362
  getTypes: () => queryTypes,
363
+ onSchedulerQuery: () => tempoGlide.onSchedulerQuery(),
277
364
  onError(logText) {
278
365
  const text = runtimeErrorFromLog(logText);
279
366
  if (text !== null && queryErrors.first(text)) sendRuntimeError(text);
@@ -281,7 +368,11 @@ const schedulerQueries = createSchedulerQueryScope({
281
368
  });
282
369
 
283
370
  // The timing of one evaluation run, bound at the editor.repl.evaluate call (see the wrapper below):
284
- // { timing, deferred }. `deferred` is the waiting change the run created (set by the takeover's onArmed), or null.
371
+ // { timing, deferred, bpm, midiDeferred, glideBars, glide, glidePlan, glideRefusal }. `deferred` is the waiting change the run
372
+ // created (set by the takeover's onArmed), or null. `glideBars` is the length of the tempo ramp the change asked for, or null;
373
+ // `glide` is what the reply says about the ramp the run started or let go on, or undefined; `glidePlan` is the plan of the ramp
374
+ // the run started, or null; `glideRefusal` is the text of the refusal of a ramp that cannot be made (set at the setPattern seam),
375
+ // or null.
285
376
  const NOW_TIMING = parseTiming('now');
286
377
  let armedRun = null; // set right before a programmatic evaluation and consumed by the wrapper, synchronously
287
378
  let activeRun = null; // the run whose evaluation is executing now (evaluations are serialized)
@@ -321,16 +412,33 @@ function playingPattern() {
321
412
  return editor.repl.state.pattern;
322
413
  }
323
414
 
324
- /** Arms the timing of the next editor.repl.evaluate call; the wrapper takes it synchronously. */
325
- function armRun(timing) {
326
- const run = { timing, deferred: null, bpm: undefined, midiDeferred: false };
415
+ /** Arms the timing (and the tempo ramp) of the next editor.repl.evaluate call; the wrapper takes it synchronously. */
416
+ function armRun(timing, glideBars = null) {
417
+ const run = { timing, deferred: null, bpm: undefined, midiDeferred: false, glideBars, glide: undefined, glidePlan: null, glideRefusal: null };
327
418
  armedRun = run;
328
419
  return run;
329
420
  }
330
421
 
331
- /** The AudioContext time of a cycle line, from the scheduler's current tempo segment (as a hap's targetTime). */
422
+ /**
423
+ * The AudioContext time of a cycle line, from the scheduler's current tempo segment (as a hap's targetTime). While a tempo ramp
424
+ * runs, the seconds from the start of that segment to a line later than it go through the ramp's own tempo: the tempo changes
425
+ * between now and the line, so one segment at the tempo of the moment would put the line early on a slowing ramp and late on
426
+ * a rising one.
427
+ */
332
428
  function audioTimeOfCycle(cycle) {
333
- return (cycle - scheduler.num_cycles_at_cps_change) / scheduler.cps + scheduler.seconds_at_cps_change + scheduler.latency;
429
+ const anchor = scheduler.num_cycles_at_cps_change;
430
+ const throughRamp = cycle > anchor ? tempoGlide.secondsBetween(anchor, cycle) : null;
431
+ const seconds = throughRamp !== null ? throughRamp : (cycle - anchor) / scheduler.cps;
432
+ return seconds + scheduler.seconds_at_cps_change + scheduler.latency;
433
+ }
434
+
435
+ /**
436
+ * The tempo that sizes the lead of a change. The minimum lead of a line and the lead hook of a waiting change are fixed in
437
+ * cycles when the change is armed, so during a tempo ramp they are sized at the fastest tempo the ramp reaches and are never
438
+ * shorter in seconds than intended.
439
+ */
440
+ function takeoverCps() {
441
+ return Math.max(tempoGuard.playing(), tempoGlide.fastestCps() ?? 0);
334
442
  }
335
443
 
336
444
  /** Milliseconds until a cycle line is audible (0 when it already is). */
@@ -349,13 +457,17 @@ const takeover = createTakeover({
349
457
  getTypes: () => queryTypes,
350
458
  getTiming: () => (activeRun !== null ? activeRun.timing : NOW_TIMING),
351
459
  // The old music covers the way to the line at the tempo that plays, not at the tempo the waiting code just set.
352
- getCps: () => tempoGuard.playing(),
460
+ getCps: () => takeoverCps(),
353
461
  isPlaying: () => scheduler.started === true && !pageStopped && hasEvaluatedSuccessfully && !!scheduler.pattern,
354
462
  msUntil: msUntilCycle,
355
463
  setTimer: (fn, ms) => setTimeout(fn, ms),
356
464
  clearTimer: (timer) => clearTimeout(timer),
357
465
  onArmed(record) {
358
466
  if (activeRun !== null) activeRun.deferred = record;
467
+ // Whether the waiting change also asks for a ramp: it starts at the line (onReached), never before.
468
+ record.glideBars = activeRun !== null ? activeRun.glideBars : null;
469
+ // The plan of the ramp it started at its line, or null: the takeover message says whether that ramp still runs.
470
+ record.glidePlan = null;
359
471
  // The first finisher of the record: the Mixer and Parts stop saying "waiting" and read the new code at the
360
472
  // takeover, before the finishers that report it.
361
473
  takeover.whenFinished(record, () => {
@@ -400,14 +512,42 @@ const takeover = createTakeover({
400
512
  },
401
513
  onReached(record) {
402
514
  // A microtask after the gate's first query past the line: the downbeat is already timestamped on the line
403
- // and the old bar is untouched. A canceled record never gets here (the takeover skips it).
404
- if (takeover.pending() !== record || record.newCps === null) return;
515
+ // and the old bar is untouched. A canceled record never gets here (the takeover skips it), so a waiting change that
516
+ // asks for a ramp and is replaced or canceled before its line never starts one.
517
+ if (takeover.pending() !== record) return;
518
+ reachedRecord = record;
405
519
  try {
406
- // Kept for a cancel after this point (Stop before the line is audible): the tempo that played goes back with it.
407
- record.cpsBefore = tempoGuard.playing();
408
- tempoGuard.apply(record.newCps);
520
+ // The line decides what the change does to the tempo and to a ramp that runs (page/tempo-glide.js lineTempoAction).
521
+ const action = lineTempoAction({
522
+ running: tempoGlide.running(),
523
+ targetCps: tempoGlide.targetCps(),
524
+ newCps: record.newCps,
525
+ startsGlide: typeof record.glideBars === 'number',
526
+ });
527
+ if (action === 'start-glide') {
528
+ // The ramp starts at the line from the tempo that plays then; nothing is applied here.
529
+ const fromCps = tempoGuard.playing();
530
+ if (fromCps !== record.newCps) {
531
+ record.glidePlan = tempoGlide.start({ fromCps, toCps: record.newCps, bars: record.glideBars, startCycle: record.boundary });
532
+ } else {
533
+ // Nothing to ramp between: the tempo that plays is the target already. An older ramp that passes through it must
534
+ // not go on toward its own target, so it ends here as replaced (no message): the takeover message says no ramp
535
+ // runs, and the server clears its record then.
536
+ tempoGlide.cancel('replaced');
537
+ }
538
+ } else if (action === 'cancel-no-tempo') {
539
+ tempoGlide.cancel('no-tempo');
540
+ } else if (action === 'cancel-and-apply' || action === 'apply') {
541
+ // Kept for a cancel after this point (Stop before the line is audible): the tempo that played goes back with it.
542
+ record.cpsBefore = tempoGuard.playing();
543
+ tempoGuard.apply(record.newCps);
544
+ if (action === 'cancel-and-apply') tempoGlide.cancel('other-tempo');
545
+ }
546
+ // 'keep' and 'nothing' apply nothing: the step that plays stays.
409
547
  } catch (err) {
410
548
  console.error('Moontide: the tempo of the waiting change could not be applied:', err);
549
+ } finally {
550
+ reachedRecord = null;
411
551
  }
412
552
  },
413
553
  onCancel(record, reason) {
@@ -481,6 +621,7 @@ installQueryCheck(scheduler, {
481
621
  schedulerQueries,
482
622
  takeover,
483
623
  getPlayingCode: () => (hasEvaluatedSuccessfully ? playingCode() : ''),
624
+ refuse: glideRefusal,
484
625
  });
485
626
 
486
627
  // The top bar's tempo readout and countdown. The view only writes what changed.
@@ -499,7 +640,9 @@ let badWaitingTempoLogged;
499
640
  * the scheduler's cps, which keeps the old value until a waiting change's line, and the countdown is the distance
500
641
  * to the waiting change's line in beats of the music. The tempo readout is always shown: while this tab does not
501
642
  * play (before the first evaluation, after Stop, with sound off, in a superseded tab) it shows the dash, and a
502
- * waiting change that carries a new tempo adds it after an arrow ('118 → 124 BPM'). The countdown is hidden while
643
+ * waiting change that carries a new tempo adds it after an arrow ('118 → 124 BPM'). While a tempo ramp runs the number is the
644
+ * live tempo to a whole BPM and the arrow shows the ramp's target ('112 → 120 BPM'), with the bars left in the hover text; the
645
+ * ramp wins over a waiting change's tempo, and the same 250 ms poll draws it. The countdown is hidden while
503
646
  * nothing plays. The view redraws only when a text changed.
504
647
  */
505
648
  function drawTopBar() {
@@ -515,6 +658,9 @@ function drawTopBar() {
515
658
  }
516
659
  const record = takeover.pending();
517
660
  const cps = scheduler.cps;
661
+ // The cycle is read before the tempo: the bars left of a ramp count from it.
662
+ const raw = scheduler.now();
663
+ const now = Number.isFinite(raw) && raw > 0 ? raw : 0;
518
664
  try {
519
665
  const bpm = bpmOf(cps);
520
666
  // The tempo a waiting change brings, when it carries one; a bad number is logged once and the arrow is left out.
@@ -532,7 +678,14 @@ function drawTopBar() {
532
678
  }
533
679
  }
534
680
  }
535
- topBarUi.showTempo(tempoText({ playing: true, bpm, waitingBpm }));
681
+ // A running ramp shows its target and the bars left (the ramp wins over a waiting change's tempo); the live tempo is the
682
+ // scheduler's own cps, the step that plays. A bad number falls into the catch below like a bad steady tempo.
683
+ const glide = tempoGlide.current();
684
+ topBarUi.showTempo(
685
+ glide === null
686
+ ? tempoText({ playing: true, bpm, waitingBpm })
687
+ : tempoText({ playing: true, bpm, waitingBpm, ramp: { targetBpm: bpmOf(glide.toCps), barsLeft: tempoGlide.barsLeft(now), rising: glide.toCps > glide.fromCps } }),
688
+ );
536
689
  } catch (err) {
537
690
  topBarUi.showTempo(idleTempo);
538
691
  if (!Object.is(badTempoLogged, cps)) {
@@ -540,8 +693,6 @@ function drawTopBar() {
540
693
  console.error(`Moontide: the top bar cannot show the tempo, the scheduler reports cps ${String(cps)}:`, err);
541
694
  }
542
695
  }
543
- const raw = scheduler.now();
544
- const now = Number.isFinite(raw) && raw > 0 ? raw : 0;
545
696
  topBarUi.showCycleBeat(cycleBeat(now));
546
697
  if (record === null || record.at === undefined || record.boundary - now <= 0) {
547
698
  topBarUi.showCountdown(null);
@@ -552,6 +703,17 @@ function drawTopBar() {
552
703
  syncGridQueued(now);
553
704
  }
554
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
+
555
717
  /**
556
718
  * Hands the queued section header of the Song grid its countdown for this poll: the rest of the top bar's text
557
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
@@ -602,7 +764,38 @@ setInterval(() => {
602
764
  // The program has already run when the check refuses it (or when evaluate fails later), so a
603
765
  // top-level setcps/setcpm of the refused code has already reached the scheduler while the previous music
604
766
  // keeps playing. The guard puts the previous tempo back when an evaluation fails.
605
- const tempoGuard = createTempoGuard(scheduler);
767
+ const tempoGuard = createTempoGuard(scheduler, { glide: tempoGlide });
768
+
769
+ /** True while a tempo ramp has a tempo to leave: the top bar's plays condition, and some version makes sound (playsNow). */
770
+ function glideCanStart() {
771
+ return currentSoundState === 'playing' && !pageSuperseded && hasEvaluatedSuccessfully && playsNow();
772
+ }
773
+
774
+ /**
775
+ * The refusal of a change that asks for a ramp it cannot make, asked at the setPattern seam (installQueryCheck's refuse
776
+ * option): the program's tempo is known only once the program ran, and a throw here fails the evaluation before the new
777
+ * pattern or a waiting record exists, so nothing has changed. Returns the text, or null when the change may ramp. The
778
+ * text is also kept on the run, so the page can tell this refusal from any other failure and show no banner for it.
779
+ */
780
+ function glideRefusal() {
781
+ const run = activeRun;
782
+ if (run === null || run.glideBars === null) return null;
783
+ const requested = tempoGuard.requestedSoFar();
784
+ let text = null;
785
+ if (requested === undefined) {
786
+ text = GLIDE_REFUSALS.noTempo;
787
+ } else {
788
+ try {
789
+ // The reply states a tempo to one decimal of a BPM, so two tempos that read alike have nothing to ramp between.
790
+ if (bpmOf(requested) === bpmOf(tempoGuard.playing())) text = GLIDE_REFUSALS.sameTempo;
791
+ } catch (err) {
792
+ console.error('Moontide: the tempo of the code is not usable for a ramp:', err);
793
+ text = GLIDE_REFUSALS.noTempo;
794
+ }
795
+ }
796
+ if (text !== null) run.glideRefusal = text;
797
+ return text;
798
+ }
606
799
 
607
800
  /** The tempo in BPM for a reply, or undefined (with one console line) when the cps is not a usable number. */
608
801
  function bpmForReply(cps) {
@@ -709,9 +902,18 @@ async function evaluateWithSetup(code, rest) {
709
902
  }
710
903
  const failed = !!editor.repl.state.evalError;
711
904
  const waitingRecord = activeRun !== null ? activeRun.deferred : null;
712
- // A waiting change must not touch the music that still plays, so its tempo is taken back now and
713
- // applied at the line (onReached); the reply states it as the tempo from that cycle.
714
- const { requested } = tempoGuard.end(failed, { hold: !failed && waitingRecord !== null });
905
+ // A change that asks for a ramp and takes over at once keeps the tempo that plays: the program's tempo is taken back in
906
+ // this task and a ramp moves toward it (startGlide). A waiting change must not touch the music that still plays either, so
907
+ // its tempo is taken back now and applied at the line (onReached); the reply states it as the tempo from that cycle.
908
+ const startsGlide = !failed && run !== null && run.glideBars !== null && waitingRecord === null;
909
+ const { requested, glide: glideResult } = tempoGuard.end(failed, { hold: !failed && waitingRecord !== null, startsGlide });
910
+ if (startsGlide) startGlide(run, requested);
911
+ // A change that asked for no ramp but restated the target of the one that runs lets it go on: the reply says so.
912
+ if (!failed && run !== null && run.glideBars === null && waitingRecord === null && glideResult === 'kept') {
913
+ const nowBpm = bpmForReply(scheduler.cps);
914
+ const toBpm = bpmForReply(tempoGlide.targetCps());
915
+ if (nowBpm !== undefined && toBpm !== undefined) run.glide = { state: 'running', nowBpm, toBpm };
916
+ }
715
917
  if (!failed && activeRun !== null) {
716
918
  if (waitingRecord !== null) {
717
919
  waitingRecord.newCps = typeof requested === 'number' ? requested : null;
@@ -731,6 +933,47 @@ async function evaluateWithSetup(code, rest) {
731
933
  return result;
732
934
  }
733
935
 
936
+ /**
937
+ * Starts the tempo ramp a change asked for, right after its evaluation (the tempo guard took the program's tempo back in the
938
+ * same task). A ramp that cannot be made was refused before this point (glideRefusal), so a change that gets here has a tempo
939
+ * to ramp to. When the page stopped or lost its role during the evaluation nothing starts and nothing is applied: Stop ends
940
+ * every ramp, and the next Play takes the code's tempo. Never throws.
941
+ */
942
+ function startGlide(run, requested) {
943
+ try {
944
+ if (typeof requested !== 'number') {
945
+ console.error('Moontide: glideBars was asked for, but the evaluation stated no tempo, so no ramp started.');
946
+ return;
947
+ }
948
+ if (!glideCanStart()) return;
949
+ const fromCps = scheduler.cps;
950
+ try {
951
+ run.glidePlan = tempoGlide.start({ fromCps, toCps: requested, bars: run.glideBars, startCycle: scheduler.lastEnd });
952
+ } catch (err) {
953
+ // The program's tempo was taken back for the ramp, so the change keeps its own tempo instead.
954
+ console.error('Moontide: the tempo ramp could not start, so the tempo applied at once:', err);
955
+ tempoGuard.apply(requested);
956
+ return;
957
+ }
958
+ const fromBpm = bpmForReply(fromCps);
959
+ const toBpm = bpmForReply(requested);
960
+ if (fromBpm !== undefined && toBpm !== undefined) run.glide = { state: 'started', fromBpm, toBpm, bars: run.glideBars };
961
+ } catch (err) {
962
+ console.error('Moontide: the tempo ramp of the change could not be set up:', err);
963
+ }
964
+ }
965
+
966
+ /**
967
+ * The glide report of a waiting change that asked for a ramp: it starts at the line, from the tempo that plays then, so the
968
+ * reply names only the tempo it ends on and the bars. An empty object when the change asked for none or the tempo is not
969
+ * one a reply can state. Mirrors src/protocol.ts's at-line report by hand.
970
+ */
971
+ function atLineGlide(record) {
972
+ if (typeof record.glideBars !== 'number' || typeof record.newCps !== 'number') return {};
973
+ const toBpm = bpmForReply(record.newCps);
974
+ return toBpm === undefined ? {} : { glide: { state: 'at-line', toBpm, bars: record.glideBars } };
975
+ }
976
+
734
977
  /** The MIDI setup of a change that took over at once although it was checked as a waiting one. Never throws. */
735
978
  async function sendLateSetup(code) {
736
979
  try {
@@ -755,7 +998,7 @@ editor.repl.evaluate = (code, ...rest) => {
755
998
  const evalRun =
756
999
  armedRun !== null
757
1000
  ? armedRun
758
- : { timing: parseTiming(viewState.changeTiming), deferred: null, bpm: undefined, midiDeferred: false };
1001
+ : { timing: parseTiming(viewState.changeTiming), deferred: null, bpm: undefined, midiDeferred: false, glideBars: null, glide: undefined, glidePlan: null, glideRefusal: null };
759
1002
  armedRun = null;
760
1003
  const run = evalQueue.then(async () => {
761
1004
  activeRun = evalRun;
@@ -865,6 +1108,7 @@ const timelineEditUi = createTimelineEditView({
865
1108
  window.addEventListener('blur', () => timelineEditUi.cancelGesture());
866
1109
  const jamViewEl = document.getElementById('jam-view');
867
1110
  const instrumentsViewEl = document.getElementById('instruments-view');
1111
+ const scratchViewEl = document.getElementById('scratch-view');
868
1112
  const editorArea = document.getElementById('editor-area');
869
1113
  const viewButtonsEl = document.getElementById('view-buttons');
870
1114
  const layoutButtonsEl = document.getElementById('layout-buttons');
@@ -915,8 +1159,261 @@ const jamUi = createJamView({
915
1159
  // Draw the overlay at once; the 250 ms tick would make the checkbox feel slow.
916
1160
  renderJam();
917
1161
  },
1162
+ onScaleChoice: (choice) => chooseJamScale(choice),
918
1163
  });
919
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
+
920
1417
  // The Instruments view's DOM. Created here, before any code path that can call into it (its redraw
921
1418
  // hooks stay shut while instrumentsOpen is false, which it is until renderViews() has run). The view
922
1419
  // never sends; a press on a card's switch comes back here, and requestInstrumentSwitch owns the send.
@@ -1023,11 +1520,40 @@ const sessionLabel = readSessionLabel(document, (text) => console.error(text));
1023
1520
  const pageBaseTitle = baseTitle(sessionLabel);
1024
1521
  renderSessionIdentity(document, sessionLabel, (text) => console.error(text));
1025
1522
 
1523
+ // What became of each request this page sent: the tracker holds a row per request and the view draws the chip in
1524
+ // the top bar and the panel under it. The Session drawer and the panel never show together; each is looked up at
1525
+ // call time because the drawer is created further down.
1526
+ const requestTracker = createRequestTracker({
1527
+ now: () => Date.now(),
1528
+ setTimer: (fn, ms) => setTimeout(fn, ms),
1529
+ clearTimer: (timer) => clearTimeout(timer),
1530
+ onChange: () => {
1531
+ requestsUi.render(requestTracker.model());
1532
+ askUi.refreshPartsLine();
1533
+ },
1534
+ onNotConfirmed: (id) => askUi.notConfirmed(id),
1535
+ });
1536
+ const requestsUi = createRequestStatusView({
1537
+ document,
1538
+ now: () => Date.now(),
1539
+ setTimer: (fn, ms) => setTimeout(fn, ms),
1540
+ clearTimer: (timer) => clearTimeout(timer),
1541
+ // The panel opening closes the Session drawer, without moving focus.
1542
+ onOpen: () => sessionUi.close(),
1543
+ // A row under the pointer, with focus or with its detail open stays where it is, even when it has finished.
1544
+ onHold: (id, held) => requestTracker.setHeld(id, held),
1545
+ takeLive: () => requestTracker.takeLive(),
1546
+ getModel: () => requestTracker.model(),
1547
+ });
1548
+
1026
1549
  // The Ask Claude box. Created before any code path that can call into it.
1027
1550
  const askUi = createAskView({
1028
1551
  document,
1552
+ requests: requestTracker,
1029
1553
  send: (message) => {
1030
1554
  sendIfOpen(JSON.stringify(message));
1555
+ // The panel opens by itself when the user sends, and focus stays where the Ask box gave it back.
1556
+ if (message.type === 'ask') requestsUi.noteSend();
1031
1557
  // A sent request about a range of the Timeline is done with it: the wash goes.
1032
1558
  if (message.type === 'ask' && message.context.kind === 'range') timelineEditUi.clearRange();
1033
1559
  },
@@ -1036,10 +1562,24 @@ const askUi = createAskView({
1036
1562
  setTimer: (fn, ms) => setTimeout(fn, ms),
1037
1563
  clearTimer: (timer) => clearTimeout(timer),
1038
1564
  now: () => Date.now(),
1039
- // The status text changes the bar's width: the fit step runs again.
1040
- fit: () => topBarUi.fit(),
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
+ },
1041
1570
  });
1042
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
+
1043
1583
  // The segment a superseded tab shows, with its "Play in this tab" button. takeBackTimer is the
1044
1584
  // 5 s wait for the server's hello after a click; it is null while no take-over is pending.
1045
1585
  let takeBackTimer = null;
@@ -1085,6 +1625,7 @@ const sessionUi = createSessionView({
1085
1625
  onOpen() {
1086
1626
  midiUi.closeHelp();
1087
1627
  askUi.closeHelp();
1628
+ requestsUi.close();
1088
1629
  },
1089
1630
  setTimer: (fn, ms) => setTimeout(fn, ms),
1090
1631
  // One of the three Edits timing buttons. The setting is stored by applyViewState (through the closed-set
@@ -1132,7 +1673,13 @@ function refreshMidiHidden() {
1132
1673
  midiOut.publishFacts();
1133
1674
  }
1134
1675
 
1135
- 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
+ });
1136
1683
 
1137
1684
  /**
1138
1685
  * Draws the top-bar MIDI indicator from the MIDI facts. midiPublishReady is true exactly while the
@@ -1394,8 +1941,8 @@ let mixerLitMemo = { code: null, identity: null, range: null };
1394
1941
  * or a pushed change for a hand evaluation. (runMixerEvaluate below is the single place that
1395
1942
  * calls the repl directly.)
1396
1943
  */
1397
- async function runProgrammaticEvaluate(timing = NOW_TIMING) {
1398
- const run = armRun(timing);
1944
+ async function runProgrammaticEvaluate(timing = NOW_TIMING, { glideBars = null } = {}) {
1945
+ const run = armRun(timing, glideBars);
1399
1946
  programmaticEvalInFlight = true;
1400
1947
  try {
1401
1948
  const evaluation = editor.evaluate();
@@ -1566,6 +2113,10 @@ function applyRig(rig) {
1566
2113
  const drumsBefore = jamOpen && timelineTracks !== null ? drumTrackIndexes(timelineTracks, laneTags().drums) : null;
1567
2114
  pageRig = withBuildMismatch(rig, pageBuildMismatch);
1568
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);
1569
2120
  renderMidiIndicator();
1570
2121
  // The Mixer reads its MIDI tracks against the rig: draw it again when the file changes while it is open.
1571
2122
  if (viewState.view === 'mixer') renderMixer();
@@ -1615,6 +2166,7 @@ function renderVersionIndicator() {
1615
2166
  // result changed. One writer for both: the sound-state event, the superseded and hello handlers, trackEvalSuccess and
1616
2167
  // the 250 ms poll all end here (a second writer of "playing" would let the button and the word disagree).
1617
2168
  let shownTransport = null;
2169
+ let transportPlaying = false;
1618
2170
 
1619
2171
  /** The transport model for the page as it is now. The editor's text is trimmed: a blank editor has nothing to play. */
1620
2172
  function currentTransport() {
@@ -1628,6 +2180,8 @@ function currentTransport() {
1628
2180
 
1629
2181
  function renderTransport() {
1630
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;
1631
2185
  const key = JSON.stringify(model);
1632
2186
  if (key === shownTransport) return;
1633
2187
  shownTransport = key;
@@ -1643,6 +2197,10 @@ function renderTransport() {
1643
2197
  soundStateEl.setAttribute('aria-label', `${model.word}${model.here ? BAR_COPY.stateHere : ''}`);
1644
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).
1645
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();
1646
2204
  // The word, its ' here' part and the button's word change the bar's width: the fit step runs again.
1647
2205
  topBarUi.fit();
1648
2206
  }
@@ -1684,6 +2242,7 @@ function trackEvalSuccess(e) {
1684
2242
  // The setup prepared at the lead is committed first, so the facts and the clock follow the code that plays.
1685
2243
  waiting.midiPlan?.commit();
1686
2244
  midiOut.recordSuccess(waiting.code);
2245
+ scratchUi.refreshHear();
1687
2246
  refreshMidiHidden();
1688
2247
  onVersionPlaying();
1689
2248
  });
@@ -1694,6 +2253,7 @@ function trackEvalSuccess(e) {
1694
2253
  // The MIDI facts of the code that plays now. This runs inside repl.evaluate, before
1695
2254
  // handleEvaluate sends `evaluated` on the same socket, so the server has them first.
1696
2255
  midiOut.recordSuccess(editor.repl.state.activeCode);
2256
+ scratchUi.refreshHear();
1697
2257
  refreshMidiHidden();
1698
2258
  onVersionPlaying();
1699
2259
  }
@@ -1788,6 +2348,8 @@ async function stopSoundLocally({ panicFirst = false } = {}) {
1788
2348
  // A change that waits for its line is canceled first: its finish must never run after Stop, and the
1789
2349
  // next Play starts from cycle 0 without the canceled gate.
1790
2350
  takeover.cancel('stop');
2351
+ // A tempo ramp ends with Stop, and no ramp state survives it: the next Play starts at the tempo the code declares.
2352
+ tempoGlide.cancel('stop');
1791
2353
  // Retire the webmidi Outputs first (synchronous), so no callback frozen from the stopped
1792
2354
  // code can send between scheduler.stop() and the suspend, or after the next Play.
1793
2355
  midiOut.retireOutputs({ timedRelease: !panicFirst });
@@ -1873,6 +2435,9 @@ async function reportHandEval(state, code = editor.repl.state.activeCode) {
1873
2435
  sendIfOpen(JSON.stringify({ type: 'hand-eval', ok: true, code }));
1874
2436
  }
1875
2437
 
2438
+ // Whether the sentence for a Play that could not reset the shift (no code parser) was already logged in this page session.
2439
+ let playShiftNoParserSaid = false;
2440
+
1876
2441
  /**
1877
2442
  * Play after Stop on a piece that declares a song: the scheduler's cycle count restarts at 0 at every Play, so a shift a
1878
2443
  * launch (or a person) left in the code would start the music inside the structure. The shift literal of the editor text is
@@ -1882,7 +2447,13 @@ async function reportHandEval(state, code = editor.repl.state.activeCode) {
1882
2447
  * reported on the console and Play goes on. Ctrl+Enter in the editor is an evaluation, not a Play, and does not come here.
1883
2448
  */
1884
2449
  function resetSongShiftForPlay() {
1885
- if (!window.acorn || typeof window.acorn.parse !== 'function') return;
2450
+ if (!window.acorn || typeof window.acorn.parse !== 'function') {
2451
+ if (!playShiftNoParserSaid) {
2452
+ playShiftNoParserSaid = true;
2453
+ console.error('Moontide: Play could not reset the shift of the song because the code parser is not loaded; the code plays as written.');
2454
+ }
2455
+ return;
2456
+ }
1886
2457
  const text = editor.code;
1887
2458
  try {
1888
2459
  const reading = readSongOf(text);
@@ -2024,12 +2595,34 @@ try {
2024
2595
  console.error(err);
2025
2596
  }
2026
2597
 
2598
+ // A MIDI note with neither a port timestamp nor the audio clock is timed as a browser sound, which is later than a MIDI
2599
+ // message is heard. That is said once per page load, never on every note, and nothing is thrown into the scheduler.
2600
+ let midiNoteNoClockSaid = false;
2601
+ function sayMidiNoteWithoutClock() {
2602
+ if (midiNoteNoClockSaid) return;
2603
+ midiNoteNoClockSaid = true;
2604
+ console.error('Moontide: a MIDI note reached the lights with no port timestamp and the audio clock is not available, so the lights time it as a browser sound; this is said once.');
2605
+ }
2606
+
2027
2607
  // Every note of a labeled track goes to the lights while they are active. Otherwise it is dropped at once, so notes played
2028
2608
  // while the Mixer is closed, the tab is hidden or the music is stopped never wait to be drawn later. A MIDI note carries the
2029
2609
  // time its port got; a browser sound carries the audio time it is due, which the lights map to when it is heard.
2030
2610
  subscribeNotes((event) => {
2031
2611
  if (!lightsActive) return;
2032
- lights.noteSeen(event.key, event.stamp !== null ? { at: event.stamp + MIDI_LIGHT_OFFSET_MS } : { target: event.target });
2612
+ if (event.stamp !== null) {
2613
+ lights.noteSeen(event.key, { at: event.stamp + MIDI_LIGHT_OFFSET_MS });
2614
+ return;
2615
+ }
2616
+ // A MIDI note that reached the lights without a port timestamp is sent at its due time on the page clock, with no audio output
2617
+ // latency (a MIDI message never has any), so it is timed there and not as a browser sound.
2618
+ if (lights.playsMidi(event.key)) {
2619
+ if (typeof globalThis.getAudioContext === 'function') {
2620
+ lights.noteSeen(event.key, { at: performance.now() + (event.target - globalThis.getAudioContext().currentTime) * 1000 + MIDI_LIGHT_OFFSET_MS });
2621
+ return;
2622
+ }
2623
+ sayMidiNoteWithoutClock();
2624
+ }
2625
+ lights.noteSeen(event.key, { target: event.target });
2033
2626
  });
2034
2627
 
2035
2628
  // Start enabling Web MIDI at page start only when the browser already says the permission is
@@ -2117,7 +2710,9 @@ ws.addEventListener('open', () => {
2117
2710
  midiOut.publishFacts({ force: true });
2118
2711
  historyUi.setConnected('connected');
2119
2712
  mixerUi.setConnected('connected');
2120
- askUi.setConnected('connected');
2713
+ setAskConnected('connected');
2714
+ // The requests of a page that went away (a reload) come back from the server; an empty answer changes nothing.
2715
+ sendIfOpen(JSON.stringify({ type: 'ask-board-request' }));
2121
2716
  // Always ask, whether or not the view is open, so live additions accumulate.
2122
2717
  sendIfOpen(JSON.stringify({ type: 'history-request' }));
2123
2718
  // The data folder is asked once per connection, after page-auth (the server answers only a page that
@@ -2138,7 +2733,8 @@ ws.addEventListener('close', () => {
2138
2733
  renderConnectionState('disconnected');
2139
2734
  historyUi.setConnected('disconnected');
2140
2735
  mixerUi.setConnected('disconnected');
2141
- askUi.setConnected('disconnected');
2736
+ requestTracker.connectionLost();
2737
+ setAskConnected('disconnected');
2142
2738
  });
2143
2739
  ws.addEventListener('error', () => {
2144
2740
  pageSocket = 'closed';
@@ -2152,7 +2748,8 @@ ws.addEventListener('error', () => {
2152
2748
  renderConnectionState('disconnected');
2153
2749
  historyUi.setConnected('disconnected');
2154
2750
  mixerUi.setConnected('disconnected');
2155
- askUi.setConnected('disconnected');
2751
+ requestTracker.connectionLost();
2752
+ setAskConnected('disconnected');
2156
2753
  });
2157
2754
 
2158
2755
  // Evaluate requests are processed strictly one at a time through this promise chain — never
@@ -2208,10 +2805,12 @@ const mixerUi = createMixerView({
2208
2805
  onGroupFold: (key) => toggleSongFold(key),
2209
2806
  });
2210
2807
  mixerUi.setConnected(ws.readyState === ws.OPEN ? 'connected' : 'connecting');
2211
- askUi.setConnected(ws.readyState === ws.OPEN ? 'connected' : 'connecting');
2808
+ setAskConnected(ws.readyState === ws.OPEN ? 'connected' : 'connecting');
2212
2809
 
2213
2810
  // pagehide: record a pending gesture while the socket is still open.
2214
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'));
2215
2814
  // A separate listener, so a throw in one never skips the other: MIDI silence on close or reload.
2216
2815
  window.addEventListener('pagehide', () => midiOut.onPageHide());
2217
2816
  // Its own listener too: a closing page drops its waiting change without a message (the server handles detach).
@@ -2219,6 +2818,7 @@ window.addEventListener('pagehide', () => takeover.cancel('unload'));
2219
2818
  // A resize or a layout change moves the lines: the selected-strip light follows (ignored while the Mixer is closed).
2220
2819
  window.addEventListener('resize', () => {
2221
2820
  if (viewState.view === 'mixer') syncMixerLit();
2821
+ if (viewState.view === 'scratchpad') scratchUi.resize();
2222
2822
  });
2223
2823
 
2224
2824
  ws.addEventListener('message', (event) => {
@@ -2245,6 +2845,8 @@ ws.addEventListener('message', (event) => {
2245
2845
  sessionUi.setTakeBackPending(false);
2246
2846
  renderMidiIndicator();
2247
2847
  refreshMidiHidden();
2848
+ // A tab that lost the music stops listening to the keyboard, so there is never a second buffer.
2849
+ midiIn.refresh();
2248
2850
  drawTopBar();
2249
2851
  // Said again when a tab plays again after none did: this tab is already stopped, only its text changes.
2250
2852
  // No second panic: it could cut the notes of the tab that plays now.
@@ -2305,6 +2907,9 @@ ws.addEventListener('message', (event) => {
2305
2907
  }
2306
2908
 
2307
2909
  if (msg.type === 'hello') {
2910
+ // The server treats every attach as the end of a ramp, so the page agrees at once, before the replay is queued: a
2911
+ // reload, a reconnect or a tab taking the role back never carries a ramp into its replay.
2912
+ tempoGlide.cancel('page');
2308
2913
  // The hello is the first moment the page can know there is no instruments.json, so the
2309
2914
  // Instruments view says "Connecting..." until now. Set synchronously, before anything is queued.
2310
2915
  pageHelloSeen = true;
@@ -2326,6 +2931,8 @@ ws.addEventListener('message', (event) => {
2326
2931
  // The shadows are cleared with no reset (another tab may have changed the sounds); the replay sends them again.
2327
2932
  midiOut.setActive(true);
2328
2933
  refreshMidiHidden();
2934
+ // The tab holds the music again, so it listens to the keyboard again.
2935
+ midiIn.refresh();
2329
2936
  }
2330
2937
  renderInstrumentsIfOpen();
2331
2938
  chain = chain
@@ -2479,6 +3086,13 @@ ws.addEventListener('message', (event) => {
2479
3086
  return;
2480
3087
  }
2481
3088
 
3089
+ if (msg.type === 'ask-status' || msg.type === 'ask-board') {
3090
+ // Mirrors src/protocol.ts by hand: what became of a request after Claude received it, and the requests a
3091
+ // reloaded page gets back. Outside the serial `chain`; the tracker moves each row only forward.
3092
+ requestTracker.serverMessage(msg);
3093
+ return;
3094
+ }
3095
+
2482
3096
  if (msg.type === 'instrument-result') {
2483
3097
  // Mirrors src/protocol.ts by hand: the answer to one instrument-switch of this page. Outside the serial
2484
3098
  // `chain` like the other plain updates; the rig push it follows has already been applied.
@@ -2515,26 +3129,36 @@ function buildEvalErrorReport(evalError) {
2515
3129
  * that is audible), or to nothing before any code has played, and shows the failed-change banner. Returns the
2516
3130
  * report, so a caller that tells the server about the failure sends the same text and line. Every failed
2517
3131
  * evaluation of code the page wrote or received ends here: Claude's change, the reconnect replay and the page's own
2518
- * rewrites.
3132
+ * rewrites. `banner` false takes the code back without the banner (a refused tempo ramp, which is not a failure of the code).
2519
3133
  */
2520
- function revertAndReport(evalError) {
3134
+ function revertAndReport(evalError, { banner = true } = {}) {
2521
3135
  editor.setCode(hasEvaluatedSuccessfully ? committedCode() : '');
2522
3136
  const report = buildEvalErrorReport(evalError);
2523
- showEvalError(report.errorText);
3137
+ if (banner) showEvalError(report.errorText);
2524
3138
  return report;
2525
3139
  }
2526
3140
 
2527
3141
  async function handleEvaluate(msg) {
2528
3142
  // An unknown timing is refused before anything changes (the page mirrors src/protocol.ts by hand).
2529
3143
  let timing;
3144
+ let glideBars;
2530
3145
  try {
2531
3146
  timing = parseTiming(msg.at, msg.phraseCycles);
3147
+ glideBars = parseGlideBars(msg.glideBars);
2532
3148
  } catch (err) {
2533
3149
  sendIfOpen(
2534
3150
  JSON.stringify({ type: 'evaluated', id: msg.id, ok: false, code: msg.code, error: err instanceof Error ? err.message : String(err) }),
2535
3151
  );
2536
3152
  return;
2537
3153
  }
3154
+ // A ramp needs music to leave: with nothing playing the change is refused whole, before anything changes and with no
3155
+ // banner (the server tells Claude to send it again without glideBars).
3156
+ if (glideBars !== null && !glideCanStart()) {
3157
+ sendIfOpen(
3158
+ JSON.stringify({ type: 'evaluated', id: msg.id, ok: false, code: msg.code, error: GLIDE_REFUSALS.nothingPlays, glideRefused: true }),
3159
+ );
3160
+ return;
3161
+ }
2538
3162
  // A pending Mixer gesture is recorded (with the code that played) before Claude's code
2539
3163
  // applies, so the history reads gesture then Claude; the touched lines no longer describe the text.
2540
3164
  await mixerGesture.flushDirect();
@@ -2553,7 +3177,7 @@ async function handleEvaluate(msg) {
2553
3177
  evaluateTag.begin(msg.id);
2554
3178
  let run;
2555
3179
  try {
2556
- run = await runProgrammaticEvaluate(timing);
3180
+ run = await runProgrammaticEvaluate(timing, { glideBars });
2557
3181
  } finally {
2558
3182
  evaluateTag.end();
2559
3183
  }
@@ -2566,15 +3190,19 @@ async function handleEvaluate(msg) {
2566
3190
  // still audible. Never call editor.evaluate() here: the previous pattern is still
2567
3191
  // playing, re-running it is unnecessary and risks re-triggering audio. A failed
2568
3192
  // evaluate leaves pageStopped and the silence unchanged.
2569
- const { errorText, line } = revertAndReport(evalError);
3193
+ // A ramp the code cannot make was refused at the setPattern seam (glideRefusal): the editor goes back all the same, but no
3194
+ // banner shows, because nothing the person wrote failed.
3195
+ const refusal = typeof run.glideRefusal === 'string' ? run.glideRefusal : null;
3196
+ const { errorText, line } = revertAndReport(evalError, { banner: refusal === null });
2570
3197
  const payload = {
2571
3198
  type: 'evaluated',
2572
3199
  id: msg.id,
2573
3200
  ok: false,
2574
3201
  code: msg.code,
2575
- error: errorText,
3202
+ error: refusal ?? errorText,
3203
+ ...(refusal !== null ? { glideRefused: true } : {}),
2576
3204
  };
2577
- if (line !== undefined) {
3205
+ if (refusal === null && line !== undefined) {
2578
3206
  payload.line = line;
2579
3207
  }
2580
3208
  sendIfOpen(JSON.stringify(payload));
@@ -2612,7 +3240,9 @@ async function handleEvaluate(msg) {
2612
3240
  if (!replacedBeforeReply) {
2613
3241
  takeover.whenFinished(waiting, () => {
2614
3242
  evaluateTag.fromTakeover(msg.id);
2615
- sendIfOpen(JSON.stringify({ type: 'takeover', id: msg.id }));
3243
+ // The server records the ramp this change started at its line only if it still runs now (mirrors src/protocol.ts).
3244
+ const glideRuns = tempoGlide.runs(waiting.glidePlan);
3245
+ sendIfOpen(JSON.stringify({ type: 'takeover', id: msg.id, ...(glideRuns ? { glideRuns: true } : {}) }));
2616
3246
  });
2617
3247
  }
2618
3248
  sendIfOpen(
@@ -2624,6 +3254,7 @@ async function handleEvaluate(msg) {
2624
3254
  audio,
2625
3255
  pending: { cycle: waiting.boundary, seconds: pendingSecondsForReply(waiting.boundary) },
2626
3256
  ...(typeof run.bpm === 'number' ? { bpm: run.bpm } : {}),
3257
+ ...(!replacedBeforeReply ? atLineGlide(waiting) : {}),
2627
3258
  }),
2628
3259
  );
2629
3260
  waiting.replied = true;
@@ -2646,6 +3277,16 @@ async function handleEvaluate(msg) {
2646
3277
  // A change that already took over (a newer change settled it at its line before this reply) is reported with its
2647
3278
  // own code; the editor may already hold the newer change's text.
2648
3279
  const finishedBeforeReply = waiting !== null && waiting.finished === true && typeof waiting.code === 'string';
3280
+ // The ramp this change started is reported only while it still runs: a Stop during the watch above, or a program that
3281
+ // ended it meanwhile, already ended it, and the server must never record a ramp that no longer runs. A waiting change
3282
+ // that already took over has no takeover message (this reply records it), so the reply carries the ramp it started at
3283
+ // its line, by the same rule.
3284
+ let glide;
3285
+ if (run.glide !== undefined) {
3286
+ glide = run.glide.state !== 'started' || tempoGlide.runs(run.glidePlan) ? run.glide : undefined;
3287
+ } else if (finishedBeforeReply && tempoGlide.runs(waiting.glidePlan)) {
3288
+ glide = atLineGlide(waiting).glide;
3289
+ }
2649
3290
  sendIfOpen(
2650
3291
  JSON.stringify({
2651
3292
  type: 'evaluated',
@@ -2654,8 +3295,15 @@ async function handleEvaluate(msg) {
2654
3295
  code: finishedBeforeReply ? waiting.code : editor.repl.state.activeCode,
2655
3296
  audio,
2656
3297
  ...(typeof run.bpm === 'number' ? { bpm: run.bpm } : {}),
3298
+ ...(glide !== undefined ? { glide } : {}),
2657
3299
  }),
2658
3300
  );
3301
+ // A waiting change that asked for a ramp and already took over ended any older ramp at its line, even when it started
3302
+ // none (nothing to ramp between). It has no takeover message to say so, so when no ramp runs now the server hears a
3303
+ // silent end and clears its record. While a newer ramp runs, its own report sets the record, so nothing is sent.
3304
+ if (finishedBeforeReply && typeof waiting.glideBars === 'number' && !tempoGlide.running()) {
3305
+ sendIfOpen(JSON.stringify({ type: 'glide-ended', reason: 'finished' }));
3306
+ }
2659
3307
  }
2660
3308
 
2661
3309
  // --- the Mixer's writes ---
@@ -3070,9 +3718,11 @@ function renderMixer() {
3070
3718
  for (const { identity, effect } of model.staleRecords) mixerBypassRecords = forgetBypass(mixerBypassRecords, identity, effect);
3071
3719
  if (!writeBypassRecords(storage, mixerBypassRecords)) reportStorageUnavailableOnce();
3072
3720
  }
3073
- mixerUi.render(model);
3721
+ const { created } = mixerUi.render(model);
3074
3722
  // The lights learn which tracks the Mixer shows, which of them sound, and which groups hold them.
3075
3723
  lights.setLayout(lightLayout(model, { webMidi: typeof navigator.requestMIDIAccess === 'function' }));
3724
+ // A strip this render built starts dark, so the lights write every lit value again from the next frame.
3725
+ if (created > 0) lights.forgetShown();
3076
3726
  // Every render moves the one light onto the selected strip's block (or clears it): the strip may have left the model.
3077
3727
  syncMixerLit();
3078
3728
  }
@@ -3124,6 +3774,9 @@ const splitSizes = { a: initialPrefs.splitA ?? SPLIT_DEFAULTS.a, b: initialPrefs
3124
3774
  for (const key of initialPrefs.rejected) {
3125
3775
  console.error(SPLIT_COPY.badStored(key, key === STORAGE_KEYS.splitA ? SPLIT_DEFAULTS.a : SPLIT_DEFAULTS.b));
3126
3776
  }
3777
+ // A stored song launch choice outside the list was refused by readViewPrefs: say so once per page load (top-level code),
3778
+ // and use the default launch.
3779
+ if (initialPrefs.songLaunchRejected) console.error(VIEW_PREFS_COPY.badSongLaunch);
3127
3780
  const splitStorageKeys = { a: STORAGE_KEYS.splitA, b: STORAGE_KEYS.splitB };
3128
3781
 
3129
3782
  // The border between the editor and the view. The view only draws and listens; this is the one place that applies a size
@@ -3153,7 +3806,7 @@ function markNarrowSpans() {
3153
3806
  }
3154
3807
 
3155
3808
  /** The things that listen only to the window follow a new size: the Mixer's lit strip once per animation
3156
- * 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
3157
3810
  * measures itself (its own ResizeObserver), and no manual measure call is made here. */
3158
3811
  function refreshAfterSize({ final }) {
3159
3812
  if (final) {
@@ -3161,12 +3814,14 @@ function refreshAfterSize({ final }) {
3161
3814
  splitFrame = 0;
3162
3815
  if (viewState.view === 'mixer') syncMixerLit();
3163
3816
  if (viewState.view === 'timeline') markNarrowSpans();
3817
+ if (viewState.view === 'scratchpad') scratchUi.resize();
3164
3818
  return;
3165
3819
  }
3166
3820
  if (splitFrame !== 0) return;
3167
3821
  splitFrame = requestAnimationFrame(() => {
3168
3822
  splitFrame = 0;
3169
3823
  if (viewState.view === 'mixer') syncMixerLit();
3824
+ if (viewState.view === 'scratchpad') scratchUi.resize();
3170
3825
  });
3171
3826
  }
3172
3827
 
@@ -3307,6 +3962,11 @@ let timelineRafId = null;
3307
3962
  // cannot change a handled failure until the version or the page changes, so the 250 ms tick does not
3308
3963
  // retry it four times a second; only a too-slow query is retried, once, after a pause.
3309
3964
  let jamReading = null;
3965
+ // What the user picked among the scales that fit, and the proposal in view, per set of candidates: page memory only, gone
3966
+ // on reload (no browser storage), capped at a few dozen sets (page/jam-resolve.js createScaleMemory).
3967
+ const jamScaleMemory = createScaleMemory();
3968
+ // The scale the view shows now (the playing span's, as resolved), so a choice is checked against what is on screen.
3969
+ let jamShownScale = null;
3310
3970
  let jamTimer = null;
3311
3971
  let jamRefreshRunning = false;
3312
3972
  let jamRefreshAgain = false;
@@ -3562,17 +4222,13 @@ function askAboutTimelineLane(laneIndex, cycle, point) {
3562
4222
  }
3563
4223
 
3564
4224
  /**
3565
- * The section of the first and of the last cycle of a range as `{ section, sectionTo }`, for a piece with a song: `section` is
3566
- * the section of the first cycle and `sectionTo` the section of the last only when it is another one. A piece without a song (or
3567
- * with only arrange()) gives `{}`.
4225
+ * The sections a range crosses as `{ sections }`, in playing order (a section played twice in a row once), for a piece with a
4226
+ * song. A piece without a song (or with only arrange()) gives `{}`.
3568
4227
  */
3569
- function timelineRangeSections(from, to) {
4228
+ function timelineRangeSectionList(from, to) {
3570
4229
  const reading = timelineSongReading();
3571
4230
  if (reading.state !== 'read') return {};
3572
- const sectionAt = (cycle) => String(reading.sections[playingSection(reading, cycle).index].name);
3573
- const first = sectionAt(from);
3574
- const last = sectionAt(to);
3575
- return last === first ? { section: first } : { section: first, sectionTo: last };
4231
+ return { sections: sectionsCrossed(reading, from, to) };
3576
4232
  }
3577
4233
 
3578
4234
  /** The lane of a range of the lanes drawn, or a loud error when the range names none. */
@@ -3584,20 +4240,27 @@ function timelineRangeLane(selection) {
3584
4240
  }
3585
4241
 
3586
4242
  /**
3587
- * What the note panel says of a range: `{ track, section, sectionTo, count }` (the sections only for a piece with a song, the
3588
- * count of the notes that start in the cycles). Reads the lanes and the code only.
4243
+ * What the note panel says of a range: `{ track, sections, moreSections, count }` (the sections the range crosses in playing
4244
+ * order, cut like the request's, only for a piece with a song, and `moreSections` only when names were left out; the count of the
4245
+ * notes that start in the cycles). Reads the lanes and the code only.
3589
4246
  */
3590
4247
  function describeTimelineRange(selection) {
3591
4248
  const lane = timelineRangeLane(selection);
3592
4249
  const { notes, more } = rangeNotes(lane, selection.from, selection.to);
3593
- const { section = null, sectionTo = null } = timelineRangeSections(selection.from, selection.to);
3594
- return { track: lane.track, section, sectionTo, count: notes.length + more };
4250
+ const info = { track: lane.track, count: notes.length + more };
4251
+ const { sections } = timelineRangeSectionList(selection.from, selection.to);
4252
+ if (sections !== undefined) {
4253
+ const fitted = fitSectionNames(sections);
4254
+ info.sections = fitted.names;
4255
+ if (fitted.more > 0) info.moreSections = fitted.more;
4256
+ }
4257
+ return info;
3595
4258
  }
3596
4259
 
3597
4260
  /**
3598
- * The descriptor of a range of cycles on one lane of the lanes drawn: the lane's shown name, the first and last cycle, the section
3599
- * of each end when the playing code is a song (the second only when it differs), the notes of the lane that start in those cycles
3600
- * with the version drawn, and the cut when the pointer left the page. Reads the lanes and the code only.
4261
+ * The descriptor of a range of cycles on one lane of the lanes drawn: the lane's shown name, the first and last cycle, the sections
4262
+ * the range crosses in playing order when the playing code is a song, the notes of the lane that start in those cycles with the
4263
+ * version drawn, and the cut when the pointer left the page. Reads the lanes and the code only.
3601
4264
  */
3602
4265
  function timelineRangeDescriptor(selection) {
3603
4266
  const lane = timelineRangeLane(selection);
@@ -3607,7 +4270,7 @@ function timelineRangeDescriptor(selection) {
3607
4270
  track: lane.track,
3608
4271
  from: selection.from,
3609
4272
  to: selection.to,
3610
- ...timelineRangeSections(selection.from, selection.to),
4273
+ ...timelineRangeSectionList(selection.from, selection.to),
3611
4274
  gesture: { kind: 'range', version: timelineDrawn.version, notes, more },
3612
4275
  };
3613
4276
  if (selection.cut !== null) descriptor.cut = selection.cut;
@@ -3631,11 +4294,36 @@ songGridHandlers.ask = (descriptor, point) => {
3631
4294
  openAskFor(() => descriptor, point);
3632
4295
  };
3633
4296
 
4297
+ // The Menu key and Shift+F10 are answered by the control that has focus (the grid and the Timeline open the box on the key
4298
+ // itself), and the browser raises a contextmenu for the same press. That contextmenu must neither open the box a second time nor
4299
+ // let the browser's own menu show over it. `keyAsk` remembers an ask-key press from its key down until one task after its key up,
4300
+ // with whether the box was open when the key went down.
4301
+ let keyAsk = null;
4302
+ document.addEventListener('keydown', (event) => {
4303
+ // A held key repeats its keydown; the repeats find the box open and must not change what the first press recorded.
4304
+ if (isAskKey(event) && !event.repeat) keyAsk = { key: event.key, openBefore: askUi.isOpen() };
4305
+ }, true);
4306
+ document.addEventListener('keyup', (event) => {
4307
+ if (keyAsk === null || event.key !== keyAsk.key) return;
4308
+ // One task later, so a contextmenu the browser raises right after the key up is still recognised. On a busy page that task
4309
+ // can run after the next press went down, so it forgets only the press it was set for.
4310
+ const released = keyAsk;
4311
+ setTimeout(() => {
4312
+ if (keyAsk === released) keyAsk = null;
4313
+ }, 0);
4314
+ }, true);
4315
+
3634
4316
  // The one contextmenu listener of the page; no view module has its own. Shift + right-click keeps
3635
4317
  // the browser's own menu, and so does a right-click inside the box (the user can paste) or while the
3636
4318
  // audio-unlock overlay shows (it is a gate to start sound, not part of the page's work). The right-click
3637
- // 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.
3638
4321
  document.addEventListener('contextmenu', (event) => {
4322
+ // The key press already opened the box: only keep the browser's menu away.
4323
+ if (keyAsk !== null && !keyAsk.openBefore && askUi.isOpen()) {
4324
+ event.preventDefault();
4325
+ return;
4326
+ }
3639
4327
  if (event.shiftKey || !overlay.hidden || askUi.contains(event.target)) return;
3640
4328
  event.preventDefault();
3641
4329
  // A right-click on the failed-change banner (or on its own popover) offers the banner's actions.
@@ -3643,6 +4331,12 @@ document.addEventListener('contextmenu', (event) => {
3643
4331
  problemUi.openMenu({ x: event.clientX, y: event.clientY });
3644
4332
  return;
3645
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
+ }
3646
4340
  openAskFor(() => resolveAskTarget(event), { x: event.clientX, y: event.clientY });
3647
4341
  });
3648
4342
 
@@ -3713,6 +4407,9 @@ function renderViews() {
3713
4407
  const isMixerOpen = viewState.view === 'mixer';
3714
4408
  const wasJamOpen = previousViewState !== null && previousViewState.view === 'jam';
3715
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);
3716
4413
 
3717
4414
  if ((wasTimelineOpen || wasJamOpen) && !isTimelineOpen && !isJamOpen) {
3718
4415
  // The Timeline or the Jam view just closed and neither is open now (without a new version
@@ -3745,6 +4442,17 @@ function renderViews() {
3745
4442
  const isInstrumentsOpen = viewState.view === 'instruments';
3746
4443
  instrumentsViewEl.hidden = !isInstrumentsOpen;
3747
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
+ }
3748
4456
  jamUi.setPreferences({ instrument: viewState.jamInstrument, chordTones: viewState.jamChordTones });
3749
4457
  historyUi.setViewContext({ open: isHistoryOpen, layout: viewState.layout, mode: viewState.historyMode });
3750
4458
 
@@ -3775,6 +4483,8 @@ function renderViews() {
3775
4483
  }
3776
4484
  previousViewState = viewState;
3777
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();
3778
4488
 
3779
4489
  // The animation loop runs while
3780
4490
  // either the Timeline or the Parts view is open: a fresh Timeline open (including the very
@@ -3936,7 +4646,8 @@ function songNoteEntries(reading) {
3936
4646
  /** The line a finished check gives, or null when what plays agrees with the declaration. */
3937
4647
  function songCheckEntry(answer) {
3938
4648
  if (answer.state === 'differs') return differsLine({ ...answer.first, more: answer.more });
3939
- if (answer.state === 'failed' || answer.state === 'too-slow') return { text: SONG_COPY.checkFailed, kind: 'check-failed' };
4649
+ if (answer.state === 'failed') return { text: SONG_COPY.checkFailed, kind: 'check-failed' };
4650
+ if (answer.state === 'too-slow') return { text: SONG_COPY.checkTooSlow, kind: 'check-failed' };
3940
4651
  return null;
3941
4652
  }
3942
4653
 
@@ -3970,7 +4681,7 @@ async function runSongCheck(generation, code, reading, evaluateId) {
3970
4681
  if (answer.state === 'too-slow') console.error(`Moontide: the check of the song declaration ran over its ${TIMELINE_BUDGET_MS} ms budget (${Math.round(answer.ms)} ms).`);
3971
4682
  const entry = songCheckEntry(answer);
3972
4683
  songCheck = { generation, entry };
3973
- songNotesEl.dataset.check = answer.state === 'agrees' || answer.state === 'differs' ? answer.state : 'failed';
4684
+ songNotesEl.dataset.check = answer.state === 'agrees' || answer.state === 'differs' || answer.state === 'too-slow' ? answer.state : 'failed';
3974
4685
  songNotesEl.dataset.checkMs = String(Math.round(answer.ms));
3975
4686
  songUi.showNotes(songNoteEntries(reading));
3976
4687
  if (entry !== null) {
@@ -4857,14 +5568,28 @@ function renderJam() {
4857
5568
  options.declaredOverride = sectionDeclared(song.sections[index], jamReading.reading.declared ?? { state: 'none' });
4858
5569
  options.section = quoted(song.sections[index].name);
4859
5570
  }
5571
+ // When several scales fit the notes, the one in use is resolved here, from the harmony the playing section declares;
5572
+ // every path below (the status, the rows, the degrees, the piano and the guitar, what a right-click asks about) then
5573
+ // reads that one scale. The warnings of every candidate came with the reading, so this only looks them up.
5574
+ const declared = options.declaredOverride ?? jamReading.reading.declared ?? { state: 'none' };
5575
+ const resolved = resolveReading(jamReading.reading, { declared, memory: jamScaleMemory });
5576
+ const position = readingAt(resolved, { cycle, cps, playing });
4860
5577
  jamUi.render(
4861
5578
  buildJamModel({
4862
- position: readingAt(jamReading.reading, { cycle, cps, playing }),
5579
+ position,
4863
5580
  chordTones: viewState.jamChordTones === 'on',
4864
5581
  ...options,
4865
5582
  }),
4866
5583
  );
5584
+ // After the draw: remember what is shown. A proposal that came from the evidence is kept for its set of candidates, so
5585
+ // a later reading that orders the same candidates otherwise does not move it; a declared scale is not kept, because the
5586
+ // declaration moves the proposal on its own and the next section may declare another.
5587
+ jamShownScale = position.scale;
5588
+ if (position.scale.source === 'proposed' && position.scale.reason !== 'declared') {
5589
+ jamScaleMemory.keep(position.scale.setKey, position.scale.key);
5590
+ }
4867
5591
  } catch (err) {
5592
+ jamShownScale = null;
4868
5593
  if (jamDrawErrorLoggedFor !== jamReading) {
4869
5594
  jamDrawErrorLoggedFor = jamReading;
4870
5595
  console.error('Moontide: the Jam view could not draw the reading:', err);
@@ -4873,6 +5598,22 @@ function renderJam() {
4873
5598
  }
4874
5599
  }
4875
5600
 
5601
+ /** Takes the user's choice among the scales that fit: `{ setKey, key }` from a candidate button. It is accepted only while the
5602
+ * scale on screen is a proposed or a chosen one, for the set of candidates on screen, and `key` is one of them; anything
5603
+ * else is a caller bug and throws. The pick is remembered for that set (page memory) and the view is drawn again at once from
5604
+ * the reading it already holds: no new reading, the warnings of every candidate came with it. */
5605
+ function chooseJamScale({ setKey, key }) {
5606
+ const shown = jamShownScale;
5607
+ const usable =
5608
+ shown !== null &&
5609
+ (shown.source === 'proposed' || shown.source === 'chosen') &&
5610
+ shown.setKey === setKey &&
5611
+ shown.candidates.some((candidate) => candidate.key === key);
5612
+ if (!usable) throw new Error(`Moontide: the Jam view cannot use the scale '${String(key)}': it is not one of the scales that fit now.`);
5613
+ jamScaleMemory.pick(setKey, key);
5614
+ renderJam();
5615
+ }
5616
+
4876
5617
  /** The Jam view's 250 ms tick: asks for a rebuild when the reading is missing, belongs to an older
4877
5618
  * version or does not cover the playing page (also backwards, after Stop), otherwise only looks
4878
5619
  * the position up. Skips while the page is hidden and stops with the view. */
@@ -5672,7 +6413,7 @@ async function launchSectionNow(seen) {
5672
6413
  for (;;) {
5673
6414
  attempt += 1;
5674
6415
  // Read the frontier, compute the line and the shift, write and evaluate: no await until the call has started.
5675
- const { line, at } = launchLine({ kind: seen.kind, frontier: scheduler.lastEnd, cps: tempoGuard.playing(), reading, minLeadSeconds: MIN_TAKEOVER_LEAD_SECONDS });
6416
+ const { line, at } = launchLine({ kind: seen.kind, frontier: scheduler.lastEnd, cps: takeoverCps(), reading, minLeadSeconds: MIN_TAKEOVER_LEAD_SECONDS });
5676
6417
  const shift = launchShift(reading, seen.index, line);
5677
6418
  if (sameShift(shift, reading.shift, reading.total)) {
5678
6419
  // The section already starts on that line: nothing is written. A launch of ours that waits (or the first try of