moontide-mcp 2.1.0 → 2.2.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
@@ -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,17 +91,20 @@ 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';
@@ -124,6 +128,7 @@ import { checkFailureText, drumMapFor, parseRigMessage, rigFromReadFailure, with
124
128
  import { drumLane } from './drum-lane.js';
125
129
  import { captureQueryTypes, createSchedulerQueryScope, installQueryCheck } from './query-check.js';
126
130
  import { createTempoGuard } from './tempo-guard.js';
131
+ import { GLIDE_REFUSALS, createTempoGlide, lineTempoAction, parseGlideBars } from './tempo-glide.js';
127
132
  import {
128
133
  BEATS_PER_CYCLE,
129
134
  CHANGE_COPY,
@@ -137,6 +142,7 @@ import {
137
142
  parseTiming,
138
143
  pinnedTiming,
139
144
  queuedLine,
145
+ rampLine,
140
146
  settingLine,
141
147
  } from './change-timing.js';
142
148
  import { createTopBarView } from './top-bar-view.js';
@@ -220,6 +226,11 @@ const midiOut = createMidiOut({
220
226
  getScheduler: () => editor.repl.scheduler,
221
227
  getAudioContext: () => (typeof globalThis.getAudioContext === 'function' ? globalThis.getAudioContext() : null),
222
228
  perfNow: () => performance.now(),
229
+ // 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).
230
+ rampSeconds: (fromCycle, toCycle) => {
231
+ const seconds = tempoGlide.secondsBetween(fromCycle, toCycle);
232
+ return seconds === null ? null : { seconds, lagSeconds: tempoGlide.lagSecondsBetween(fromCycle, toCycle) };
233
+ },
223
234
  // A clock state change (a Start waiting, a Start reached, a Stop) re-renders the indicator and its popover rows.
224
235
  onClockState: () => renderMidiIndicator(),
225
236
  now: () => Date.now(),
@@ -257,6 +268,71 @@ let evalQueue = Promise.resolve();
257
268
  // launch all pass here too, so a hand evaluation follows the same rule. Checking after evaluate returns
258
269
  // would be too late: the failing pattern would already be the scheduler's pattern.
259
270
  const scheduler = editor.repl.scheduler;
271
+ // The tempo ramp (page/tempo-glide.js): it moves the tempo in steps toward the tempo a change set, and every step goes
272
+ // through the tempo guard's apply. The closure reads tempoGuard only when a step fires, long after the page has loaded.
273
+ const tempoGlide = createTempoGlide({
274
+ scheduler,
275
+ apply: (cps) => tempoGuard.apply(cps),
276
+ setTimer: (fn, ms) => setTimeout(fn, ms),
277
+ clearTimer: (timer) => clearTimeout(timer),
278
+ onEvent(event) {
279
+ // The server is told when a ramp ends on its own, synchronously, so the message goes out before the evaluated reply
280
+ // of the change that caused it and that reply carries the cancel line. Stop, a lost role, a page reload and a ramp that
281
+ // a new ramp replaced send nothing: the server learns the first three itself and the new ramp comes in its own reply.
282
+ // Mirrors src/protocol.ts's glide-ended by hand.
283
+ if (event.kind === 'finished') {
284
+ sendIfOpen(JSON.stringify({ type: 'glide-ended', reason: 'finished' }));
285
+ } else if (event.kind === 'canceled' && (event.reason === 'other-tempo' || event.reason === 'no-tempo' || event.reason === 'error')) {
286
+ const bpm = bpmForReply(event.cps);
287
+ sendIfOpen(JSON.stringify({ type: 'glide-ended', reason: event.reason, ...(bpm !== undefined ? { bpm } : {}) }));
288
+ }
289
+ if (event.kind === 'canceled' && event.reason === 'error') {
290
+ console.error(`Moontide: the tempo ramp was canceled because a step failed; the tempo stays where it was (${String(event.cps)} cps).`);
291
+ }
292
+ // The live region speaks a ramp when it starts, when it finishes and when the code cancels it, and says nothing for a step.
293
+ // Stop, a lost role, a reload, a failed step and a replaced ramp are not spoken (a replaced ramp's new start is).
294
+ const { plan } = event;
295
+ if (event.kind === 'started') {
296
+ const stillRuns = () => {
297
+ const running = tempoGlide.current();
298
+ return running !== null && running.startCycle === plan.startCycle && running.toCps === plan.toCps;
299
+ };
300
+ sayRamp(() => ({ kind: 'started', fromBpm: bpmOf(plan.fromCps), toBpm: bpmOf(plan.toCps), bars: plan.bars }), stillRuns);
301
+ } else if (event.kind === 'finished') {
302
+ sayRamp(() => ({ kind: 'finished', toBpm: bpmOf(plan.toCps) }));
303
+ } else if (event.kind === 'canceled' && (event.reason === 'other-tempo' || event.reason === 'no-tempo')) {
304
+ sayRamp(() => ({ kind: 'canceled', reason: event.reason, bpm: bpmOf(event.cps) }));
305
+ }
306
+ },
307
+ onError: (text, err) => console.error(text, err),
308
+ });
309
+
310
+ // The waiting change whose line onReached is handling now, or null. A ramp that starts or ends at a line is spoken only once
311
+ // that change has finished, after the silent clear of its countdown line, so the sentence is the one that stays in the live region.
312
+ let reachedRecord = null;
313
+ const rampSayLogged = new Set();
314
+
315
+ /**
316
+ * Says the sentence of a ramp event through the top bar's live region. `makeEvent` builds the event for rampLine and runs now;
317
+ * the sentence is spoken now, or after the finish of the waiting change whose line is being handled, and (with `stillCurrent`)
318
+ * only if it is still true then. A failure is logged once for each text and never reaches the ramp.
319
+ */
320
+ function sayRamp(makeEvent, stillCurrent = () => true) {
321
+ try {
322
+ const line = rampLine(makeEvent());
323
+ const speak = () => {
324
+ if (stillCurrent()) topBarUi.say(line);
325
+ };
326
+ if (reachedRecord !== null) takeover.whenFinished(reachedRecord, speak);
327
+ else speak();
328
+ } catch (err) {
329
+ const text = err instanceof Error ? err.message : String(err);
330
+ if (!rampSayLogged.has(text)) {
331
+ rampSayLogged.add(text);
332
+ console.error('Moontide: the tempo ramp could not be spoken:', err);
333
+ }
334
+ }
335
+ }
260
336
  // State and TimeSpan are kept from the first evaluation on (captureQueryTypes, called before the user code
261
337
  // of each evaluation runs), so a hand edit that assigns to those globals cannot change the check.
262
338
  let queryTypes = { State: undefined, TimeSpan: undefined };
@@ -274,6 +350,7 @@ const queryErrors = createOncePerChange();
274
350
  // 1 s debounce lets a page query hide the scheduler's report. The log listener drops every [query] text.
275
351
  const schedulerQueries = createSchedulerQueryScope({
276
352
  getTypes: () => queryTypes,
353
+ onSchedulerQuery: () => tempoGlide.onSchedulerQuery(),
277
354
  onError(logText) {
278
355
  const text = runtimeErrorFromLog(logText);
279
356
  if (text !== null && queryErrors.first(text)) sendRuntimeError(text);
@@ -281,7 +358,11 @@ const schedulerQueries = createSchedulerQueryScope({
281
358
  });
282
359
 
283
360
  // 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.
361
+ // { timing, deferred, bpm, midiDeferred, glideBars, glide, glidePlan, glideRefusal }. `deferred` is the waiting change the run
362
+ // created (set by the takeover's onArmed), or null. `glideBars` is the length of the tempo ramp the change asked for, or null;
363
+ // `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
364
+ // the run started, or null; `glideRefusal` is the text of the refusal of a ramp that cannot be made (set at the setPattern seam),
365
+ // or null.
285
366
  const NOW_TIMING = parseTiming('now');
286
367
  let armedRun = null; // set right before a programmatic evaluation and consumed by the wrapper, synchronously
287
368
  let activeRun = null; // the run whose evaluation is executing now (evaluations are serialized)
@@ -321,16 +402,33 @@ function playingPattern() {
321
402
  return editor.repl.state.pattern;
322
403
  }
323
404
 
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 };
405
+ /** Arms the timing (and the tempo ramp) of the next editor.repl.evaluate call; the wrapper takes it synchronously. */
406
+ function armRun(timing, glideBars = null) {
407
+ const run = { timing, deferred: null, bpm: undefined, midiDeferred: false, glideBars, glide: undefined, glidePlan: null, glideRefusal: null };
327
408
  armedRun = run;
328
409
  return run;
329
410
  }
330
411
 
331
- /** The AudioContext time of a cycle line, from the scheduler's current tempo segment (as a hap's targetTime). */
412
+ /**
413
+ * The AudioContext time of a cycle line, from the scheduler's current tempo segment (as a hap's targetTime). While a tempo ramp
414
+ * 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
415
+ * 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
416
+ * a rising one.
417
+ */
332
418
  function audioTimeOfCycle(cycle) {
333
- return (cycle - scheduler.num_cycles_at_cps_change) / scheduler.cps + scheduler.seconds_at_cps_change + scheduler.latency;
419
+ const anchor = scheduler.num_cycles_at_cps_change;
420
+ const throughRamp = cycle > anchor ? tempoGlide.secondsBetween(anchor, cycle) : null;
421
+ const seconds = throughRamp !== null ? throughRamp : (cycle - anchor) / scheduler.cps;
422
+ return seconds + scheduler.seconds_at_cps_change + scheduler.latency;
423
+ }
424
+
425
+ /**
426
+ * 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
427
+ * cycles when the change is armed, so during a tempo ramp they are sized at the fastest tempo the ramp reaches and are never
428
+ * shorter in seconds than intended.
429
+ */
430
+ function takeoverCps() {
431
+ return Math.max(tempoGuard.playing(), tempoGlide.fastestCps() ?? 0);
334
432
  }
335
433
 
336
434
  /** Milliseconds until a cycle line is audible (0 when it already is). */
@@ -349,13 +447,17 @@ const takeover = createTakeover({
349
447
  getTypes: () => queryTypes,
350
448
  getTiming: () => (activeRun !== null ? activeRun.timing : NOW_TIMING),
351
449
  // 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(),
450
+ getCps: () => takeoverCps(),
353
451
  isPlaying: () => scheduler.started === true && !pageStopped && hasEvaluatedSuccessfully && !!scheduler.pattern,
354
452
  msUntil: msUntilCycle,
355
453
  setTimer: (fn, ms) => setTimeout(fn, ms),
356
454
  clearTimer: (timer) => clearTimeout(timer),
357
455
  onArmed(record) {
358
456
  if (activeRun !== null) activeRun.deferred = record;
457
+ // Whether the waiting change also asks for a ramp: it starts at the line (onReached), never before.
458
+ record.glideBars = activeRun !== null ? activeRun.glideBars : null;
459
+ // The plan of the ramp it started at its line, or null: the takeover message says whether that ramp still runs.
460
+ record.glidePlan = null;
359
461
  // The first finisher of the record: the Mixer and Parts stop saying "waiting" and read the new code at the
360
462
  // takeover, before the finishers that report it.
361
463
  takeover.whenFinished(record, () => {
@@ -400,14 +502,42 @@ const takeover = createTakeover({
400
502
  },
401
503
  onReached(record) {
402
504
  // 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;
505
+ // and the old bar is untouched. A canceled record never gets here (the takeover skips it), so a waiting change that
506
+ // asks for a ramp and is replaced or canceled before its line never starts one.
507
+ if (takeover.pending() !== record) return;
508
+ reachedRecord = record;
405
509
  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);
510
+ // The line decides what the change does to the tempo and to a ramp that runs (page/tempo-glide.js lineTempoAction).
511
+ const action = lineTempoAction({
512
+ running: tempoGlide.running(),
513
+ targetCps: tempoGlide.targetCps(),
514
+ newCps: record.newCps,
515
+ startsGlide: typeof record.glideBars === 'number',
516
+ });
517
+ if (action === 'start-glide') {
518
+ // The ramp starts at the line from the tempo that plays then; nothing is applied here.
519
+ const fromCps = tempoGuard.playing();
520
+ if (fromCps !== record.newCps) {
521
+ record.glidePlan = tempoGlide.start({ fromCps, toCps: record.newCps, bars: record.glideBars, startCycle: record.boundary });
522
+ } else {
523
+ // Nothing to ramp between: the tempo that plays is the target already. An older ramp that passes through it must
524
+ // not go on toward its own target, so it ends here as replaced (no message): the takeover message says no ramp
525
+ // runs, and the server clears its record then.
526
+ tempoGlide.cancel('replaced');
527
+ }
528
+ } else if (action === 'cancel-no-tempo') {
529
+ tempoGlide.cancel('no-tempo');
530
+ } else if (action === 'cancel-and-apply' || action === 'apply') {
531
+ // Kept for a cancel after this point (Stop before the line is audible): the tempo that played goes back with it.
532
+ record.cpsBefore = tempoGuard.playing();
533
+ tempoGuard.apply(record.newCps);
534
+ if (action === 'cancel-and-apply') tempoGlide.cancel('other-tempo');
535
+ }
536
+ // 'keep' and 'nothing' apply nothing: the step that plays stays.
409
537
  } catch (err) {
410
538
  console.error('Moontide: the tempo of the waiting change could not be applied:', err);
539
+ } finally {
540
+ reachedRecord = null;
411
541
  }
412
542
  },
413
543
  onCancel(record, reason) {
@@ -481,6 +611,7 @@ installQueryCheck(scheduler, {
481
611
  schedulerQueries,
482
612
  takeover,
483
613
  getPlayingCode: () => (hasEvaluatedSuccessfully ? playingCode() : ''),
614
+ refuse: glideRefusal,
484
615
  });
485
616
 
486
617
  // The top bar's tempo readout and countdown. The view only writes what changed.
@@ -499,7 +630,9 @@ let badWaitingTempoLogged;
499
630
  * the scheduler's cps, which keeps the old value until a waiting change's line, and the countdown is the distance
500
631
  * to the waiting change's line in beats of the music. The tempo readout is always shown: while this tab does not
501
632
  * 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
633
+ * waiting change that carries a new tempo adds it after an arrow ('118 → 124 BPM'). While a tempo ramp runs the number is the
634
+ * 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
635
+ * ramp wins over a waiting change's tempo, and the same 250 ms poll draws it. The countdown is hidden while
503
636
  * nothing plays. The view redraws only when a text changed.
504
637
  */
505
638
  function drawTopBar() {
@@ -515,6 +648,9 @@ function drawTopBar() {
515
648
  }
516
649
  const record = takeover.pending();
517
650
  const cps = scheduler.cps;
651
+ // The cycle is read before the tempo: the bars left of a ramp count from it.
652
+ const raw = scheduler.now();
653
+ const now = Number.isFinite(raw) && raw > 0 ? raw : 0;
518
654
  try {
519
655
  const bpm = bpmOf(cps);
520
656
  // The tempo a waiting change brings, when it carries one; a bad number is logged once and the arrow is left out.
@@ -532,7 +668,14 @@ function drawTopBar() {
532
668
  }
533
669
  }
534
670
  }
535
- topBarUi.showTempo(tempoText({ playing: true, bpm, waitingBpm }));
671
+ // A running ramp shows its target and the bars left (the ramp wins over a waiting change's tempo); the live tempo is the
672
+ // scheduler's own cps, the step that plays. A bad number falls into the catch below like a bad steady tempo.
673
+ const glide = tempoGlide.current();
674
+ topBarUi.showTempo(
675
+ glide === null
676
+ ? tempoText({ playing: true, bpm, waitingBpm })
677
+ : tempoText({ playing: true, bpm, waitingBpm, ramp: { targetBpm: bpmOf(glide.toCps), barsLeft: tempoGlide.barsLeft(now), rising: glide.toCps > glide.fromCps } }),
678
+ );
536
679
  } catch (err) {
537
680
  topBarUi.showTempo(idleTempo);
538
681
  if (!Object.is(badTempoLogged, cps)) {
@@ -540,8 +683,6 @@ function drawTopBar() {
540
683
  console.error(`Moontide: the top bar cannot show the tempo, the scheduler reports cps ${String(cps)}:`, err);
541
684
  }
542
685
  }
543
- const raw = scheduler.now();
544
- const now = Number.isFinite(raw) && raw > 0 ? raw : 0;
545
686
  topBarUi.showCycleBeat(cycleBeat(now));
546
687
  if (record === null || record.at === undefined || record.boundary - now <= 0) {
547
688
  topBarUi.showCountdown(null);
@@ -602,7 +743,38 @@ setInterval(() => {
602
743
  // The program has already run when the check refuses it (or when evaluate fails later), so a
603
744
  // top-level setcps/setcpm of the refused code has already reached the scheduler while the previous music
604
745
  // keeps playing. The guard puts the previous tempo back when an evaluation fails.
605
- const tempoGuard = createTempoGuard(scheduler);
746
+ const tempoGuard = createTempoGuard(scheduler, { glide: tempoGlide });
747
+
748
+ /** True while a tempo ramp has a tempo to leave: the top bar's plays condition, and some version makes sound (playsNow). */
749
+ function glideCanStart() {
750
+ return currentSoundState === 'playing' && !pageSuperseded && hasEvaluatedSuccessfully && playsNow();
751
+ }
752
+
753
+ /**
754
+ * The refusal of a change that asks for a ramp it cannot make, asked at the setPattern seam (installQueryCheck's refuse
755
+ * option): the program's tempo is known only once the program ran, and a throw here fails the evaluation before the new
756
+ * pattern or a waiting record exists, so nothing has changed. Returns the text, or null when the change may ramp. The
757
+ * text is also kept on the run, so the page can tell this refusal from any other failure and show no banner for it.
758
+ */
759
+ function glideRefusal() {
760
+ const run = activeRun;
761
+ if (run === null || run.glideBars === null) return null;
762
+ const requested = tempoGuard.requestedSoFar();
763
+ let text = null;
764
+ if (requested === undefined) {
765
+ text = GLIDE_REFUSALS.noTempo;
766
+ } else {
767
+ try {
768
+ // The reply states a tempo to one decimal of a BPM, so two tempos that read alike have nothing to ramp between.
769
+ if (bpmOf(requested) === bpmOf(tempoGuard.playing())) text = GLIDE_REFUSALS.sameTempo;
770
+ } catch (err) {
771
+ console.error('Moontide: the tempo of the code is not usable for a ramp:', err);
772
+ text = GLIDE_REFUSALS.noTempo;
773
+ }
774
+ }
775
+ if (text !== null) run.glideRefusal = text;
776
+ return text;
777
+ }
606
778
 
607
779
  /** The tempo in BPM for a reply, or undefined (with one console line) when the cps is not a usable number. */
608
780
  function bpmForReply(cps) {
@@ -709,9 +881,18 @@ async function evaluateWithSetup(code, rest) {
709
881
  }
710
882
  const failed = !!editor.repl.state.evalError;
711
883
  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 });
884
+ // 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
885
+ // this task and a ramp moves toward it (startGlide). A waiting change must not touch the music that still plays either, so
886
+ // its tempo is taken back now and applied at the line (onReached); the reply states it as the tempo from that cycle.
887
+ const startsGlide = !failed && run !== null && run.glideBars !== null && waitingRecord === null;
888
+ const { requested, glide: glideResult } = tempoGuard.end(failed, { hold: !failed && waitingRecord !== null, startsGlide });
889
+ if (startsGlide) startGlide(run, requested);
890
+ // A change that asked for no ramp but restated the target of the one that runs lets it go on: the reply says so.
891
+ if (!failed && run !== null && run.glideBars === null && waitingRecord === null && glideResult === 'kept') {
892
+ const nowBpm = bpmForReply(scheduler.cps);
893
+ const toBpm = bpmForReply(tempoGlide.targetCps());
894
+ if (nowBpm !== undefined && toBpm !== undefined) run.glide = { state: 'running', nowBpm, toBpm };
895
+ }
715
896
  if (!failed && activeRun !== null) {
716
897
  if (waitingRecord !== null) {
717
898
  waitingRecord.newCps = typeof requested === 'number' ? requested : null;
@@ -731,6 +912,47 @@ async function evaluateWithSetup(code, rest) {
731
912
  return result;
732
913
  }
733
914
 
915
+ /**
916
+ * Starts the tempo ramp a change asked for, right after its evaluation (the tempo guard took the program's tempo back in the
917
+ * same task). A ramp that cannot be made was refused before this point (glideRefusal), so a change that gets here has a tempo
918
+ * to ramp to. When the page stopped or lost its role during the evaluation nothing starts and nothing is applied: Stop ends
919
+ * every ramp, and the next Play takes the code's tempo. Never throws.
920
+ */
921
+ function startGlide(run, requested) {
922
+ try {
923
+ if (typeof requested !== 'number') {
924
+ console.error('Moontide: glideBars was asked for, but the evaluation stated no tempo, so no ramp started.');
925
+ return;
926
+ }
927
+ if (!glideCanStart()) return;
928
+ const fromCps = scheduler.cps;
929
+ try {
930
+ run.glidePlan = tempoGlide.start({ fromCps, toCps: requested, bars: run.glideBars, startCycle: scheduler.lastEnd });
931
+ } catch (err) {
932
+ // The program's tempo was taken back for the ramp, so the change keeps its own tempo instead.
933
+ console.error('Moontide: the tempo ramp could not start, so the tempo applied at once:', err);
934
+ tempoGuard.apply(requested);
935
+ return;
936
+ }
937
+ const fromBpm = bpmForReply(fromCps);
938
+ const toBpm = bpmForReply(requested);
939
+ if (fromBpm !== undefined && toBpm !== undefined) run.glide = { state: 'started', fromBpm, toBpm, bars: run.glideBars };
940
+ } catch (err) {
941
+ console.error('Moontide: the tempo ramp of the change could not be set up:', err);
942
+ }
943
+ }
944
+
945
+ /**
946
+ * 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
947
+ * 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
948
+ * one a reply can state. Mirrors src/protocol.ts's at-line report by hand.
949
+ */
950
+ function atLineGlide(record) {
951
+ if (typeof record.glideBars !== 'number' || typeof record.newCps !== 'number') return {};
952
+ const toBpm = bpmForReply(record.newCps);
953
+ return toBpm === undefined ? {} : { glide: { state: 'at-line', toBpm, bars: record.glideBars } };
954
+ }
955
+
734
956
  /** The MIDI setup of a change that took over at once although it was checked as a waiting one. Never throws. */
735
957
  async function sendLateSetup(code) {
736
958
  try {
@@ -755,7 +977,7 @@ editor.repl.evaluate = (code, ...rest) => {
755
977
  const evalRun =
756
978
  armedRun !== null
757
979
  ? armedRun
758
- : { timing: parseTiming(viewState.changeTiming), deferred: null, bpm: undefined, midiDeferred: false };
980
+ : { timing: parseTiming(viewState.changeTiming), deferred: null, bpm: undefined, midiDeferred: false, glideBars: null, glide: undefined, glidePlan: null, glideRefusal: null };
759
981
  armedRun = null;
760
982
  const run = evalQueue.then(async () => {
761
983
  activeRun = evalRun;
@@ -915,6 +1137,7 @@ const jamUi = createJamView({
915
1137
  // Draw the overlay at once; the 250 ms tick would make the checkbox feel slow.
916
1138
  renderJam();
917
1139
  },
1140
+ onScaleChoice: (choice) => chooseJamScale(choice),
918
1141
  });
919
1142
 
920
1143
  // The Instruments view's DOM. Created here, before any code path that can call into it (its redraw
@@ -1023,11 +1246,40 @@ const sessionLabel = readSessionLabel(document, (text) => console.error(text));
1023
1246
  const pageBaseTitle = baseTitle(sessionLabel);
1024
1247
  renderSessionIdentity(document, sessionLabel, (text) => console.error(text));
1025
1248
 
1249
+ // What became of each request this page sent: the tracker holds a row per request and the view draws the chip in
1250
+ // the top bar and the panel under it. The Session drawer and the panel never show together; each is looked up at
1251
+ // call time because the drawer is created further down.
1252
+ const requestTracker = createRequestTracker({
1253
+ now: () => Date.now(),
1254
+ setTimer: (fn, ms) => setTimeout(fn, ms),
1255
+ clearTimer: (timer) => clearTimeout(timer),
1256
+ onChange: () => {
1257
+ requestsUi.render(requestTracker.model());
1258
+ askUi.refreshPartsLine();
1259
+ },
1260
+ onNotConfirmed: (id) => askUi.notConfirmed(id),
1261
+ });
1262
+ const requestsUi = createRequestStatusView({
1263
+ document,
1264
+ now: () => Date.now(),
1265
+ setTimer: (fn, ms) => setTimeout(fn, ms),
1266
+ clearTimer: (timer) => clearTimeout(timer),
1267
+ // The panel opening closes the Session drawer, without moving focus.
1268
+ onOpen: () => sessionUi.close(),
1269
+ // A row under the pointer, with focus or with its detail open stays where it is, even when it has finished.
1270
+ onHold: (id, held) => requestTracker.setHeld(id, held),
1271
+ takeLive: () => requestTracker.takeLive(),
1272
+ getModel: () => requestTracker.model(),
1273
+ });
1274
+
1026
1275
  // The Ask Claude box. Created before any code path that can call into it.
1027
1276
  const askUi = createAskView({
1028
1277
  document,
1278
+ requests: requestTracker,
1029
1279
  send: (message) => {
1030
1280
  sendIfOpen(JSON.stringify(message));
1281
+ // The panel opens by itself when the user sends, and focus stays where the Ask box gave it back.
1282
+ if (message.type === 'ask') requestsUi.noteSend();
1031
1283
  // A sent request about a range of the Timeline is done with it: the wash goes.
1032
1284
  if (message.type === 'ask' && message.context.kind === 'range') timelineEditUi.clearRange();
1033
1285
  },
@@ -1036,8 +1288,6 @@ const askUi = createAskView({
1036
1288
  setTimer: (fn, ms) => setTimeout(fn, ms),
1037
1289
  clearTimer: (timer) => clearTimeout(timer),
1038
1290
  now: () => Date.now(),
1039
- // The status text changes the bar's width: the fit step runs again.
1040
- fit: () => topBarUi.fit(),
1041
1291
  });
1042
1292
 
1043
1293
  // The segment a superseded tab shows, with its "Play in this tab" button. takeBackTimer is the
@@ -1085,6 +1335,7 @@ const sessionUi = createSessionView({
1085
1335
  onOpen() {
1086
1336
  midiUi.closeHelp();
1087
1337
  askUi.closeHelp();
1338
+ requestsUi.close();
1088
1339
  },
1089
1340
  setTimer: (fn, ms) => setTimeout(fn, ms),
1090
1341
  // One of the three Edits timing buttons. The setting is stored by applyViewState (through the closed-set
@@ -1394,8 +1645,8 @@ let mixerLitMemo = { code: null, identity: null, range: null };
1394
1645
  * or a pushed change for a hand evaluation. (runMixerEvaluate below is the single place that
1395
1646
  * calls the repl directly.)
1396
1647
  */
1397
- async function runProgrammaticEvaluate(timing = NOW_TIMING) {
1398
- const run = armRun(timing);
1648
+ async function runProgrammaticEvaluate(timing = NOW_TIMING, { glideBars = null } = {}) {
1649
+ const run = armRun(timing, glideBars);
1399
1650
  programmaticEvalInFlight = true;
1400
1651
  try {
1401
1652
  const evaluation = editor.evaluate();
@@ -1788,6 +2039,8 @@ async function stopSoundLocally({ panicFirst = false } = {}) {
1788
2039
  // A change that waits for its line is canceled first: its finish must never run after Stop, and the
1789
2040
  // next Play starts from cycle 0 without the canceled gate.
1790
2041
  takeover.cancel('stop');
2042
+ // A tempo ramp ends with Stop, and no ramp state survives it: the next Play starts at the tempo the code declares.
2043
+ tempoGlide.cancel('stop');
1791
2044
  // Retire the webmidi Outputs first (synchronous), so no callback frozen from the stopped
1792
2045
  // code can send between scheduler.stop() and the suspend, or after the next Play.
1793
2046
  midiOut.retireOutputs({ timedRelease: !panicFirst });
@@ -1873,6 +2126,9 @@ async function reportHandEval(state, code = editor.repl.state.activeCode) {
1873
2126
  sendIfOpen(JSON.stringify({ type: 'hand-eval', ok: true, code }));
1874
2127
  }
1875
2128
 
2129
+ // Whether the sentence for a Play that could not reset the shift (no code parser) was already logged in this page session.
2130
+ let playShiftNoParserSaid = false;
2131
+
1876
2132
  /**
1877
2133
  * 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
2134
  * 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 +2138,13 @@ async function reportHandEval(state, code = editor.repl.state.activeCode) {
1882
2138
  * 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
2139
  */
1884
2140
  function resetSongShiftForPlay() {
1885
- if (!window.acorn || typeof window.acorn.parse !== 'function') return;
2141
+ if (!window.acorn || typeof window.acorn.parse !== 'function') {
2142
+ if (!playShiftNoParserSaid) {
2143
+ playShiftNoParserSaid = true;
2144
+ console.error('Moontide: Play could not reset the shift of the song because the code parser is not loaded; the code plays as written.');
2145
+ }
2146
+ return;
2147
+ }
1886
2148
  const text = editor.code;
1887
2149
  try {
1888
2150
  const reading = readSongOf(text);
@@ -2024,12 +2286,34 @@ try {
2024
2286
  console.error(err);
2025
2287
  }
2026
2288
 
2289
+ // A MIDI note with neither a port timestamp nor the audio clock is timed as a browser sound, which is later than a MIDI
2290
+ // message is heard. That is said once per page load, never on every note, and nothing is thrown into the scheduler.
2291
+ let midiNoteNoClockSaid = false;
2292
+ function sayMidiNoteWithoutClock() {
2293
+ if (midiNoteNoClockSaid) return;
2294
+ midiNoteNoClockSaid = true;
2295
+ 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.');
2296
+ }
2297
+
2027
2298
  // Every note of a labeled track goes to the lights while they are active. Otherwise it is dropped at once, so notes played
2028
2299
  // 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
2300
  // time its port got; a browser sound carries the audio time it is due, which the lights map to when it is heard.
2030
2301
  subscribeNotes((event) => {
2031
2302
  if (!lightsActive) return;
2032
- lights.noteSeen(event.key, event.stamp !== null ? { at: event.stamp + MIDI_LIGHT_OFFSET_MS } : { target: event.target });
2303
+ if (event.stamp !== null) {
2304
+ lights.noteSeen(event.key, { at: event.stamp + MIDI_LIGHT_OFFSET_MS });
2305
+ return;
2306
+ }
2307
+ // 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
2308
+ // latency (a MIDI message never has any), so it is timed there and not as a browser sound.
2309
+ if (lights.playsMidi(event.key)) {
2310
+ if (typeof globalThis.getAudioContext === 'function') {
2311
+ lights.noteSeen(event.key, { at: performance.now() + (event.target - globalThis.getAudioContext().currentTime) * 1000 + MIDI_LIGHT_OFFSET_MS });
2312
+ return;
2313
+ }
2314
+ sayMidiNoteWithoutClock();
2315
+ }
2316
+ lights.noteSeen(event.key, { target: event.target });
2033
2317
  });
2034
2318
 
2035
2319
  // Start enabling Web MIDI at page start only when the browser already says the permission is
@@ -2118,6 +2402,8 @@ ws.addEventListener('open', () => {
2118
2402
  historyUi.setConnected('connected');
2119
2403
  mixerUi.setConnected('connected');
2120
2404
  askUi.setConnected('connected');
2405
+ // The requests of a page that went away (a reload) come back from the server; an empty answer changes nothing.
2406
+ sendIfOpen(JSON.stringify({ type: 'ask-board-request' }));
2121
2407
  // Always ask, whether or not the view is open, so live additions accumulate.
2122
2408
  sendIfOpen(JSON.stringify({ type: 'history-request' }));
2123
2409
  // The data folder is asked once per connection, after page-auth (the server answers only a page that
@@ -2138,6 +2424,7 @@ ws.addEventListener('close', () => {
2138
2424
  renderConnectionState('disconnected');
2139
2425
  historyUi.setConnected('disconnected');
2140
2426
  mixerUi.setConnected('disconnected');
2427
+ requestTracker.connectionLost();
2141
2428
  askUi.setConnected('disconnected');
2142
2429
  });
2143
2430
  ws.addEventListener('error', () => {
@@ -2152,6 +2439,7 @@ ws.addEventListener('error', () => {
2152
2439
  renderConnectionState('disconnected');
2153
2440
  historyUi.setConnected('disconnected');
2154
2441
  mixerUi.setConnected('disconnected');
2442
+ requestTracker.connectionLost();
2155
2443
  askUi.setConnected('disconnected');
2156
2444
  });
2157
2445
 
@@ -2305,6 +2593,9 @@ ws.addEventListener('message', (event) => {
2305
2593
  }
2306
2594
 
2307
2595
  if (msg.type === 'hello') {
2596
+ // The server treats every attach as the end of a ramp, so the page agrees at once, before the replay is queued: a
2597
+ // reload, a reconnect or a tab taking the role back never carries a ramp into its replay.
2598
+ tempoGlide.cancel('page');
2308
2599
  // The hello is the first moment the page can know there is no instruments.json, so the
2309
2600
  // Instruments view says "Connecting..." until now. Set synchronously, before anything is queued.
2310
2601
  pageHelloSeen = true;
@@ -2479,6 +2770,13 @@ ws.addEventListener('message', (event) => {
2479
2770
  return;
2480
2771
  }
2481
2772
 
2773
+ if (msg.type === 'ask-status' || msg.type === 'ask-board') {
2774
+ // Mirrors src/protocol.ts by hand: what became of a request after Claude received it, and the requests a
2775
+ // reloaded page gets back. Outside the serial `chain`; the tracker moves each row only forward.
2776
+ requestTracker.serverMessage(msg);
2777
+ return;
2778
+ }
2779
+
2482
2780
  if (msg.type === 'instrument-result') {
2483
2781
  // Mirrors src/protocol.ts by hand: the answer to one instrument-switch of this page. Outside the serial
2484
2782
  // `chain` like the other plain updates; the rig push it follows has already been applied.
@@ -2515,26 +2813,36 @@ function buildEvalErrorReport(evalError) {
2515
2813
  * that is audible), or to nothing before any code has played, and shows the failed-change banner. Returns the
2516
2814
  * report, so a caller that tells the server about the failure sends the same text and line. Every failed
2517
2815
  * evaluation of code the page wrote or received ends here: Claude's change, the reconnect replay and the page's own
2518
- * rewrites.
2816
+ * rewrites. `banner` false takes the code back without the banner (a refused tempo ramp, which is not a failure of the code).
2519
2817
  */
2520
- function revertAndReport(evalError) {
2818
+ function revertAndReport(evalError, { banner = true } = {}) {
2521
2819
  editor.setCode(hasEvaluatedSuccessfully ? committedCode() : '');
2522
2820
  const report = buildEvalErrorReport(evalError);
2523
- showEvalError(report.errorText);
2821
+ if (banner) showEvalError(report.errorText);
2524
2822
  return report;
2525
2823
  }
2526
2824
 
2527
2825
  async function handleEvaluate(msg) {
2528
2826
  // An unknown timing is refused before anything changes (the page mirrors src/protocol.ts by hand).
2529
2827
  let timing;
2828
+ let glideBars;
2530
2829
  try {
2531
2830
  timing = parseTiming(msg.at, msg.phraseCycles);
2831
+ glideBars = parseGlideBars(msg.glideBars);
2532
2832
  } catch (err) {
2533
2833
  sendIfOpen(
2534
2834
  JSON.stringify({ type: 'evaluated', id: msg.id, ok: false, code: msg.code, error: err instanceof Error ? err.message : String(err) }),
2535
2835
  );
2536
2836
  return;
2537
2837
  }
2838
+ // A ramp needs music to leave: with nothing playing the change is refused whole, before anything changes and with no
2839
+ // banner (the server tells Claude to send it again without glideBars).
2840
+ if (glideBars !== null && !glideCanStart()) {
2841
+ sendIfOpen(
2842
+ JSON.stringify({ type: 'evaluated', id: msg.id, ok: false, code: msg.code, error: GLIDE_REFUSALS.nothingPlays, glideRefused: true }),
2843
+ );
2844
+ return;
2845
+ }
2538
2846
  // A pending Mixer gesture is recorded (with the code that played) before Claude's code
2539
2847
  // applies, so the history reads gesture then Claude; the touched lines no longer describe the text.
2540
2848
  await mixerGesture.flushDirect();
@@ -2553,7 +2861,7 @@ async function handleEvaluate(msg) {
2553
2861
  evaluateTag.begin(msg.id);
2554
2862
  let run;
2555
2863
  try {
2556
- run = await runProgrammaticEvaluate(timing);
2864
+ run = await runProgrammaticEvaluate(timing, { glideBars });
2557
2865
  } finally {
2558
2866
  evaluateTag.end();
2559
2867
  }
@@ -2566,15 +2874,19 @@ async function handleEvaluate(msg) {
2566
2874
  // still audible. Never call editor.evaluate() here: the previous pattern is still
2567
2875
  // playing, re-running it is unnecessary and risks re-triggering audio. A failed
2568
2876
  // evaluate leaves pageStopped and the silence unchanged.
2569
- const { errorText, line } = revertAndReport(evalError);
2877
+ // A ramp the code cannot make was refused at the setPattern seam (glideRefusal): the editor goes back all the same, but no
2878
+ // banner shows, because nothing the person wrote failed.
2879
+ const refusal = typeof run.glideRefusal === 'string' ? run.glideRefusal : null;
2880
+ const { errorText, line } = revertAndReport(evalError, { banner: refusal === null });
2570
2881
  const payload = {
2571
2882
  type: 'evaluated',
2572
2883
  id: msg.id,
2573
2884
  ok: false,
2574
2885
  code: msg.code,
2575
- error: errorText,
2886
+ error: refusal ?? errorText,
2887
+ ...(refusal !== null ? { glideRefused: true } : {}),
2576
2888
  };
2577
- if (line !== undefined) {
2889
+ if (refusal === null && line !== undefined) {
2578
2890
  payload.line = line;
2579
2891
  }
2580
2892
  sendIfOpen(JSON.stringify(payload));
@@ -2612,7 +2924,9 @@ async function handleEvaluate(msg) {
2612
2924
  if (!replacedBeforeReply) {
2613
2925
  takeover.whenFinished(waiting, () => {
2614
2926
  evaluateTag.fromTakeover(msg.id);
2615
- sendIfOpen(JSON.stringify({ type: 'takeover', id: msg.id }));
2927
+ // The server records the ramp this change started at its line only if it still runs now (mirrors src/protocol.ts).
2928
+ const glideRuns = tempoGlide.runs(waiting.glidePlan);
2929
+ sendIfOpen(JSON.stringify({ type: 'takeover', id: msg.id, ...(glideRuns ? { glideRuns: true } : {}) }));
2616
2930
  });
2617
2931
  }
2618
2932
  sendIfOpen(
@@ -2624,6 +2938,7 @@ async function handleEvaluate(msg) {
2624
2938
  audio,
2625
2939
  pending: { cycle: waiting.boundary, seconds: pendingSecondsForReply(waiting.boundary) },
2626
2940
  ...(typeof run.bpm === 'number' ? { bpm: run.bpm } : {}),
2941
+ ...(!replacedBeforeReply ? atLineGlide(waiting) : {}),
2627
2942
  }),
2628
2943
  );
2629
2944
  waiting.replied = true;
@@ -2646,6 +2961,16 @@ async function handleEvaluate(msg) {
2646
2961
  // A change that already took over (a newer change settled it at its line before this reply) is reported with its
2647
2962
  // own code; the editor may already hold the newer change's text.
2648
2963
  const finishedBeforeReply = waiting !== null && waiting.finished === true && typeof waiting.code === 'string';
2964
+ // The ramp this change started is reported only while it still runs: a Stop during the watch above, or a program that
2965
+ // ended it meanwhile, already ended it, and the server must never record a ramp that no longer runs. A waiting change
2966
+ // that already took over has no takeover message (this reply records it), so the reply carries the ramp it started at
2967
+ // its line, by the same rule.
2968
+ let glide;
2969
+ if (run.glide !== undefined) {
2970
+ glide = run.glide.state !== 'started' || tempoGlide.runs(run.glidePlan) ? run.glide : undefined;
2971
+ } else if (finishedBeforeReply && tempoGlide.runs(waiting.glidePlan)) {
2972
+ glide = atLineGlide(waiting).glide;
2973
+ }
2649
2974
  sendIfOpen(
2650
2975
  JSON.stringify({
2651
2976
  type: 'evaluated',
@@ -2654,8 +2979,15 @@ async function handleEvaluate(msg) {
2654
2979
  code: finishedBeforeReply ? waiting.code : editor.repl.state.activeCode,
2655
2980
  audio,
2656
2981
  ...(typeof run.bpm === 'number' ? { bpm: run.bpm } : {}),
2982
+ ...(glide !== undefined ? { glide } : {}),
2657
2983
  }),
2658
2984
  );
2985
+ // A waiting change that asked for a ramp and already took over ended any older ramp at its line, even when it started
2986
+ // none (nothing to ramp between). It has no takeover message to say so, so when no ramp runs now the server hears a
2987
+ // silent end and clears its record. While a newer ramp runs, its own report sets the record, so nothing is sent.
2988
+ if (finishedBeforeReply && typeof waiting.glideBars === 'number' && !tempoGlide.running()) {
2989
+ sendIfOpen(JSON.stringify({ type: 'glide-ended', reason: 'finished' }));
2990
+ }
2659
2991
  }
2660
2992
 
2661
2993
  // --- the Mixer's writes ---
@@ -3070,9 +3402,11 @@ function renderMixer() {
3070
3402
  for (const { identity, effect } of model.staleRecords) mixerBypassRecords = forgetBypass(mixerBypassRecords, identity, effect);
3071
3403
  if (!writeBypassRecords(storage, mixerBypassRecords)) reportStorageUnavailableOnce();
3072
3404
  }
3073
- mixerUi.render(model);
3405
+ const { created } = mixerUi.render(model);
3074
3406
  // The lights learn which tracks the Mixer shows, which of them sound, and which groups hold them.
3075
3407
  lights.setLayout(lightLayout(model, { webMidi: typeof navigator.requestMIDIAccess === 'function' }));
3408
+ // A strip this render built starts dark, so the lights write every lit value again from the next frame.
3409
+ if (created > 0) lights.forgetShown();
3076
3410
  // Every render moves the one light onto the selected strip's block (or clears it): the strip may have left the model.
3077
3411
  syncMixerLit();
3078
3412
  }
@@ -3124,6 +3458,9 @@ const splitSizes = { a: initialPrefs.splitA ?? SPLIT_DEFAULTS.a, b: initialPrefs
3124
3458
  for (const key of initialPrefs.rejected) {
3125
3459
  console.error(SPLIT_COPY.badStored(key, key === STORAGE_KEYS.splitA ? SPLIT_DEFAULTS.a : SPLIT_DEFAULTS.b));
3126
3460
  }
3461
+ // A stored song launch choice outside the list was refused by readViewPrefs: say so once per page load (top-level code),
3462
+ // and use the default launch.
3463
+ if (initialPrefs.songLaunchRejected) console.error(VIEW_PREFS_COPY.badSongLaunch);
3127
3464
  const splitStorageKeys = { a: STORAGE_KEYS.splitA, b: STORAGE_KEYS.splitB };
3128
3465
 
3129
3466
  // The border between the editor and the view. The view only draws and listens; this is the one place that applies a size
@@ -3307,6 +3644,11 @@ let timelineRafId = null;
3307
3644
  // cannot change a handled failure until the version or the page changes, so the 250 ms tick does not
3308
3645
  // retry it four times a second; only a too-slow query is retried, once, after a pause.
3309
3646
  let jamReading = null;
3647
+ // What the user picked among the scales that fit, and the proposal in view, per set of candidates: page memory only, gone
3648
+ // on reload (no browser storage), capped at a few dozen sets (page/jam-resolve.js createScaleMemory).
3649
+ const jamScaleMemory = createScaleMemory();
3650
+ // The scale the view shows now (the playing span's, as resolved), so a choice is checked against what is on screen.
3651
+ let jamShownScale = null;
3310
3652
  let jamTimer = null;
3311
3653
  let jamRefreshRunning = false;
3312
3654
  let jamRefreshAgain = false;
@@ -3562,17 +3904,13 @@ function askAboutTimelineLane(laneIndex, cycle, point) {
3562
3904
  }
3563
3905
 
3564
3906
  /**
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 `{}`.
3907
+ * The sections a range crosses as `{ sections }`, in playing order (a section played twice in a row once), for a piece with a
3908
+ * song. A piece without a song (or with only arrange()) gives `{}`.
3568
3909
  */
3569
- function timelineRangeSections(from, to) {
3910
+ function timelineRangeSectionList(from, to) {
3570
3911
  const reading = timelineSongReading();
3571
3912
  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 };
3913
+ return { sections: sectionsCrossed(reading, from, to) };
3576
3914
  }
3577
3915
 
3578
3916
  /** The lane of a range of the lanes drawn, or a loud error when the range names none. */
@@ -3584,20 +3922,27 @@ function timelineRangeLane(selection) {
3584
3922
  }
3585
3923
 
3586
3924
  /**
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.
3925
+ * What the note panel says of a range: `{ track, sections, moreSections, count }` (the sections the range crosses in playing
3926
+ * 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
3927
+ * notes that start in the cycles). Reads the lanes and the code only.
3589
3928
  */
3590
3929
  function describeTimelineRange(selection) {
3591
3930
  const lane = timelineRangeLane(selection);
3592
3931
  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 };
3932
+ const info = { track: lane.track, count: notes.length + more };
3933
+ const { sections } = timelineRangeSectionList(selection.from, selection.to);
3934
+ if (sections !== undefined) {
3935
+ const fitted = fitSectionNames(sections);
3936
+ info.sections = fitted.names;
3937
+ if (fitted.more > 0) info.moreSections = fitted.more;
3938
+ }
3939
+ return info;
3595
3940
  }
3596
3941
 
3597
3942
  /**
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.
3943
+ * 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
3944
+ * 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
3945
+ * version drawn, and the cut when the pointer left the page. Reads the lanes and the code only.
3601
3946
  */
3602
3947
  function timelineRangeDescriptor(selection) {
3603
3948
  const lane = timelineRangeLane(selection);
@@ -3607,7 +3952,7 @@ function timelineRangeDescriptor(selection) {
3607
3952
  track: lane.track,
3608
3953
  from: selection.from,
3609
3954
  to: selection.to,
3610
- ...timelineRangeSections(selection.from, selection.to),
3955
+ ...timelineRangeSectionList(selection.from, selection.to),
3611
3956
  gesture: { kind: 'range', version: timelineDrawn.version, notes, more },
3612
3957
  };
3613
3958
  if (selection.cut !== null) descriptor.cut = selection.cut;
@@ -3631,11 +3976,35 @@ songGridHandlers.ask = (descriptor, point) => {
3631
3976
  openAskFor(() => descriptor, point);
3632
3977
  };
3633
3978
 
3979
+ // 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
3980
+ // itself), and the browser raises a contextmenu for the same press. That contextmenu must neither open the box a second time nor
3981
+ // 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,
3982
+ // with whether the box was open when the key went down.
3983
+ let keyAsk = null;
3984
+ document.addEventListener('keydown', (event) => {
3985
+ // A held key repeats its keydown; the repeats find the box open and must not change what the first press recorded.
3986
+ if (isAskKey(event) && !event.repeat) keyAsk = { key: event.key, openBefore: askUi.isOpen() };
3987
+ }, true);
3988
+ document.addEventListener('keyup', (event) => {
3989
+ if (keyAsk === null || event.key !== keyAsk.key) return;
3990
+ // One task later, so a contextmenu the browser raises right after the key up is still recognised. On a busy page that task
3991
+ // can run after the next press went down, so it forgets only the press it was set for.
3992
+ const released = keyAsk;
3993
+ setTimeout(() => {
3994
+ if (keyAsk === released) keyAsk = null;
3995
+ }, 0);
3996
+ }, true);
3997
+
3634
3998
  // The one contextmenu listener of the page; no view module has its own. Shift + right-click keeps
3635
3999
  // the browser's own menu, and so does a right-click inside the box (the user can paste) or while the
3636
4000
  // audio-unlock overlay shows (it is a gate to start sound, not part of the page's work). The right-click
3637
4001
  // never evaluates, records or changes the editor's text, selection or scroll.
3638
4002
  document.addEventListener('contextmenu', (event) => {
4003
+ // The key press already opened the box: only keep the browser's menu away.
4004
+ if (keyAsk !== null && !keyAsk.openBefore && askUi.isOpen()) {
4005
+ event.preventDefault();
4006
+ return;
4007
+ }
3639
4008
  if (event.shiftKey || !overlay.hidden || askUi.contains(event.target)) return;
3640
4009
  event.preventDefault();
3641
4010
  // A right-click on the failed-change banner (or on its own popover) offers the banner's actions.
@@ -3936,7 +4305,8 @@ function songNoteEntries(reading) {
3936
4305
  /** The line a finished check gives, or null when what plays agrees with the declaration. */
3937
4306
  function songCheckEntry(answer) {
3938
4307
  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' };
4308
+ if (answer.state === 'failed') return { text: SONG_COPY.checkFailed, kind: 'check-failed' };
4309
+ if (answer.state === 'too-slow') return { text: SONG_COPY.checkTooSlow, kind: 'check-failed' };
3940
4310
  return null;
3941
4311
  }
3942
4312
 
@@ -3970,7 +4340,7 @@ async function runSongCheck(generation, code, reading, evaluateId) {
3970
4340
  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
4341
  const entry = songCheckEntry(answer);
3972
4342
  songCheck = { generation, entry };
3973
- songNotesEl.dataset.check = answer.state === 'agrees' || answer.state === 'differs' ? answer.state : 'failed';
4343
+ songNotesEl.dataset.check = answer.state === 'agrees' || answer.state === 'differs' || answer.state === 'too-slow' ? answer.state : 'failed';
3974
4344
  songNotesEl.dataset.checkMs = String(Math.round(answer.ms));
3975
4345
  songUi.showNotes(songNoteEntries(reading));
3976
4346
  if (entry !== null) {
@@ -4857,14 +5227,28 @@ function renderJam() {
4857
5227
  options.declaredOverride = sectionDeclared(song.sections[index], jamReading.reading.declared ?? { state: 'none' });
4858
5228
  options.section = quoted(song.sections[index].name);
4859
5229
  }
5230
+ // When several scales fit the notes, the one in use is resolved here, from the harmony the playing section declares;
5231
+ // every path below (the status, the rows, the degrees, the piano and the guitar, what a right-click asks about) then
5232
+ // reads that one scale. The warnings of every candidate came with the reading, so this only looks them up.
5233
+ const declared = options.declaredOverride ?? jamReading.reading.declared ?? { state: 'none' };
5234
+ const resolved = resolveReading(jamReading.reading, { declared, memory: jamScaleMemory });
5235
+ const position = readingAt(resolved, { cycle, cps, playing });
4860
5236
  jamUi.render(
4861
5237
  buildJamModel({
4862
- position: readingAt(jamReading.reading, { cycle, cps, playing }),
5238
+ position,
4863
5239
  chordTones: viewState.jamChordTones === 'on',
4864
5240
  ...options,
4865
5241
  }),
4866
5242
  );
5243
+ // After the draw: remember what is shown. A proposal that came from the evidence is kept for its set of candidates, so
5244
+ // a later reading that orders the same candidates otherwise does not move it; a declared scale is not kept, because the
5245
+ // declaration moves the proposal on its own and the next section may declare another.
5246
+ jamShownScale = position.scale;
5247
+ if (position.scale.source === 'proposed' && position.scale.reason !== 'declared') {
5248
+ jamScaleMemory.keep(position.scale.setKey, position.scale.key);
5249
+ }
4867
5250
  } catch (err) {
5251
+ jamShownScale = null;
4868
5252
  if (jamDrawErrorLoggedFor !== jamReading) {
4869
5253
  jamDrawErrorLoggedFor = jamReading;
4870
5254
  console.error('Moontide: the Jam view could not draw the reading:', err);
@@ -4873,6 +5257,22 @@ function renderJam() {
4873
5257
  }
4874
5258
  }
4875
5259
 
5260
+ /** Takes the user's choice among the scales that fit: `{ setKey, key }` from a candidate button. It is accepted only while the
5261
+ * scale on screen is a proposed or a chosen one, for the set of candidates on screen, and `key` is one of them; anything
5262
+ * 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
5263
+ * the reading it already holds: no new reading, the warnings of every candidate came with it. */
5264
+ function chooseJamScale({ setKey, key }) {
5265
+ const shown = jamShownScale;
5266
+ const usable =
5267
+ shown !== null &&
5268
+ (shown.source === 'proposed' || shown.source === 'chosen') &&
5269
+ shown.setKey === setKey &&
5270
+ shown.candidates.some((candidate) => candidate.key === key);
5271
+ 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.`);
5272
+ jamScaleMemory.pick(setKey, key);
5273
+ renderJam();
5274
+ }
5275
+
4876
5276
  /** The Jam view's 250 ms tick: asks for a rebuild when the reading is missing, belongs to an older
4877
5277
  * version or does not cover the playing page (also backwards, after Stop), otherwise only looks
4878
5278
  * the position up. Skips while the page is hidden and stops with the view. */
@@ -5672,7 +6072,7 @@ async function launchSectionNow(seen) {
5672
6072
  for (;;) {
5673
6073
  attempt += 1;
5674
6074
  // 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 });
6075
+ const { line, at } = launchLine({ kind: seen.kind, frontier: scheduler.lastEnd, cps: takeoverCps(), reading, minLeadSeconds: MIN_TAKEOVER_LEAD_SECONDS });
5676
6076
  const shift = launchShift(reading, seen.index, line);
5677
6077
  if (sameShift(shift, reading.shift, reading.total)) {
5678
6078
  // The section already starts on that line: nothing is written. A launch of ours that waits (or the first try of