moontide-mcp 2.0.2 → 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.
Files changed (57) hide show
  1. package/README.md +94 -6
  2. package/dist/ask.js +76 -15
  3. package/dist/mcp.js +198 -26
  4. package/dist/protocol.js +130 -2
  5. package/dist/reference.js +22 -0
  6. package/dist/request-status.js +221 -0
  7. package/dist/session.js +256 -7
  8. package/package.json +1 -1
  9. package/page/ask-view.js +64 -57
  10. package/page/ask.js +363 -188
  11. package/page/bar-model.js +43 -2
  12. package/page/change-timing.js +60 -4
  13. package/page/harmony.js +13 -0
  14. package/page/index.html +7 -4
  15. package/page/jam-analysis.js +68 -23
  16. package/page/jam-model.js +196 -31
  17. package/page/jam-resolve.js +271 -0
  18. package/page/jam-view.js +182 -10
  19. package/page/lights.js +300 -0
  20. package/page/main.js +2171 -188
  21. package/page/midi-clock.js +23 -4
  22. package/page/midi-out.js +17 -2
  23. package/page/mixer-groups.js +114 -0
  24. package/page/mixer-model.js +172 -29
  25. package/page/mixer-rewrite.js +30 -1
  26. package/page/mixer-view.js +289 -14
  27. package/page/note-edit.js +267 -0
  28. package/page/note-locate.js +346 -0
  29. package/page/note-panel.js +402 -0
  30. package/page/note-tag.js +141 -0
  31. package/page/page-rewrite.js +184 -0
  32. package/page/query-check.js +21 -2
  33. package/page/request-status-view.js +419 -0
  34. package/page/request-status.js +686 -0
  35. package/page/song-edit.js +230 -0
  36. package/page/song-fit.js +250 -0
  37. package/page/song-grid-model.js +506 -0
  38. package/page/song-grid-view.js +710 -0
  39. package/page/song-prefs.js +100 -0
  40. package/page/structure-check.js +216 -0
  41. package/page/structure-view.js +38 -0
  42. package/page/structure.js +761 -0
  43. package/page/style.css +1218 -65
  44. package/page/takeover.js +20 -5
  45. package/page/tempo-glide.js +330 -0
  46. package/page/tempo-guard.js +62 -10
  47. package/page/timeline-edit-view.js +1956 -0
  48. package/page/timeline-gesture.js +279 -0
  49. package/page/timeline.js +197 -3
  50. package/page/top-bar-view.js +18 -13
  51. package/page/tracks.js +37 -8
  52. package/page/ui-state.js +10 -5
  53. package/page/views.js +39 -9
  54. package/reference/functions.md +1 -0
  55. package/reference/midi.md +2 -0
  56. package/reference/structure.md +131 -0
  57. package/reference/transitions.md +13 -1
package/page/main.js CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  rulerMarks,
35
35
  laneShapes,
36
36
  momentAt,
37
+ rangeNotes,
37
38
  drumTrackIndexes,
38
39
  sameIndexSet,
39
40
  loopPeriod,
@@ -42,6 +43,10 @@ import {
42
43
  curvePoints,
43
44
  evaluateSolo,
44
45
  computeTimeline,
46
+ drumNoteOf,
47
+ pitchOf,
48
+ toLaneEvent,
49
+ velocityOf,
45
50
  } from './timeline.js';
46
51
  import {
47
52
  applyKey,
@@ -59,10 +64,14 @@ import {
59
64
  setJamChordTones,
60
65
  motionAfterOsChange,
61
66
  setChangeTiming,
67
+ setSongLaunch,
68
+ VIEW_PREFS_COPY,
69
+ BUILT_VIEWS,
62
70
  VIEW_KEYS,
71
+ VIEW_TITLES,
63
72
  } from './views.js';
64
73
  import { createHistoryView } from './history-view.js';
65
- import { MixerRewriteError, touchedRanges, writeBus, writeTrack } from './mixer-rewrite.js';
74
+ import { MixerRewriteError, applyEdits, touchedRanges, writeBus, writeTrack, writeTracks } from './mixer-rewrite.js';
66
75
  import { MIXER_COPY, buildMixerModel, clipMixerLabel, forgetBypass, readBypassRecords, rememberBypass, writeBypassRecords } from './mixer-model.js';
67
76
  import { createGestureRecorder } from './mixer-gesture.js';
68
77
  import { createPreviewSender, midiTag, readMidiTracks, writeMidiControl } from './midi-controls.js';
@@ -82,15 +91,28 @@ import {
82
91
  sectionSpans,
83
92
  } from './parts.js';
84
93
  import { baseTitle, readSessionLabel, renderSessionIdentity } from './session-identity.js';
85
- import { ASK_COPY, PAGE_TOKEN_PLACEHOLDER, cycleAt, describeContext, errorAskPrefill, lineAtY } from './ask.js';
94
+ import { ASK_COPY, PAGE_TOKEN_PLACEHOLDER, cycleAt, describeContext, errorAskPrefill, fitSectionNames, lineAtY, timelineEditPrefill } from './ask.js';
86
95
  import { createAskView } from './ask-view.js';
96
+ import { createRequestTracker } from './request-status.js';
97
+ import { createRequestStatusView } from './request-status-view.js';
87
98
  import { buildReading, readingAt, flattenLanes } from './jam-analysis.js';
99
+ import { resolveReading, createScaleMemory } from './jam-resolve.js';
88
100
  import { readHarmony } from './harmony.js';
101
+ import { SONG_COPY, differsLine, quoted, readStructure, songMessageText, songNoteLines } from './structure.js';
102
+ import { checkSections, evaluateDeclared, expectedSource, sectionStarts } from './structure-check.js';
103
+ import { collectFit, evaluateFit, fitLookup, fitPairs, fitSource, pairKey } from './song-fit.js';
104
+ import { createSongView } from './structure-view.js';
105
+ import { buildSongGrid, cellRecordLabel, playingSection, sectionDeclared, sectionsCrossed, songLine } from './song-grid-model.js';
106
+ import { SongEditRefusal, cellEdit, checkCell, checkSection, launchLine, launchShift, playResetEdit, sameShift, shiftEdit, textAfterCanceledLaunch } from './song-edit.js';
107
+ import { createSongGridView, isAskKey } from './song-grid-view.js';
108
+ import { SONG_FOLDS_COPY, isRememberable, readSongFolds, writeSongFolds } from './song-prefs.js';
89
109
  import { JAM_COPY, buildJamModel, jamStuckFor, jamIsStuck } from './jam-model.js';
90
110
  import { createJamView } from './jam-view.js';
91
111
  import { createInstrumentsView } from './instruments-view.js';
92
112
  import { instrumentsModel, readDataFolder, readInstrumentResult } from './instruments-model.js';
93
113
  import { createMidiOut, installNoMidiShim, isKnownMidiRejection } from './midi-out.js';
114
+ import { installNoteTag, subscribe as subscribeNotes } from './note-tag.js';
115
+ import { MIDI_LIGHT_OFFSET_MS, createLights, heardMapper, lightLayout, readOutputPair, usableTempo } from './lights.js';
94
116
  import {
95
117
  hhmm,
96
118
  hiddenTransition,
@@ -106,16 +128,21 @@ import { checkFailureText, drumMapFor, parseRigMessage, rigFromReadFailure, with
106
128
  import { drumLane } from './drum-lane.js';
107
129
  import { captureQueryTypes, createSchedulerQueryScope, installQueryCheck } from './query-check.js';
108
130
  import { createTempoGuard } from './tempo-guard.js';
131
+ import { GLIDE_REFUSALS, createTempoGlide, lineTempoAction, parseGlideBars } from './tempo-glide.js';
109
132
  import {
110
133
  BEATS_PER_CYCLE,
111
134
  CHANGE_COPY,
112
135
  MAX_REPORTED_BPM,
136
+ MIN_TAKEOVER_LEAD_SECONDS,
113
137
  bpmOf,
114
138
  bpmText,
115
139
  clampPendingSeconds,
140
+ countdownParts,
116
141
  isReportableBpm,
117
142
  parseTiming,
143
+ pinnedTiming,
118
144
  queuedLine,
145
+ rampLine,
119
146
  settingLine,
120
147
  } from './change-timing.js';
121
148
  import { createTopBarView } from './top-bar-view.js';
@@ -125,6 +152,11 @@ import { createSessionView } from './session-view.js';
125
152
  import { createSplitView } from './split-view.js';
126
153
  import { SPLIT_COPY, SPLIT_DEFAULTS, clampSplit } from './split-model.js';
127
154
  import { createTakeover } from './takeover.js';
155
+ import { createPageRewrite } from './page-rewrite.js';
156
+ import { PLACES_MAX_CYCLES, classifyNote, indexCode, perCycleCount, placesOf, readerCounter, trackScope } from './note-locate.js';
157
+ import { NoteEditStop, writeNoteEdit } from './note-edit.js';
158
+ import { NOTE_MARK_HEIGHT } from './timeline-gesture.js';
159
+ import { createTimelineEditView } from './timeline-edit-view.js';
128
160
  import { createEvaluateTag } from './evaluate-tag.js';
129
161
 
130
162
  await customElements.whenDefined('strudel-editor');
@@ -194,6 +226,11 @@ const midiOut = createMidiOut({
194
226
  getScheduler: () => editor.repl.scheduler,
195
227
  getAudioContext: () => (typeof globalThis.getAudioContext === 'function' ? globalThis.getAudioContext() : null),
196
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
+ },
197
234
  // A clock state change (a Start waiting, a Start reached, a Stop) re-renders the indicator and its popover rows.
198
235
  onClockState: () => renderMidiIndicator(),
199
236
  now: () => Date.now(),
@@ -231,6 +268,71 @@ let evalQueue = Promise.resolve();
231
268
  // launch all pass here too, so a hand evaluation follows the same rule. Checking after evaluate returns
232
269
  // would be too late: the failing pattern would already be the scheduler's pattern.
233
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
+ }
234
336
  // State and TimeSpan are kept from the first evaluation on (captureQueryTypes, called before the user code
235
337
  // of each evaluation runs), so a hand edit that assigns to those globals cannot change the check.
236
338
  let queryTypes = { State: undefined, TimeSpan: undefined };
@@ -248,6 +350,7 @@ const queryErrors = createOncePerChange();
248
350
  // 1 s debounce lets a page query hide the scheduler's report. The log listener drops every [query] text.
249
351
  const schedulerQueries = createSchedulerQueryScope({
250
352
  getTypes: () => queryTypes,
353
+ onSchedulerQuery: () => tempoGlide.onSchedulerQuery(),
251
354
  onError(logText) {
252
355
  const text = runtimeErrorFromLog(logText);
253
356
  if (text !== null && queryErrors.first(text)) sendRuntimeError(text);
@@ -255,7 +358,11 @@ const schedulerQueries = createSchedulerQueryScope({
255
358
  });
256
359
 
257
360
  // The timing of one evaluation run, bound at the editor.repl.evaluate call (see the wrapper below):
258
- // { 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.
259
366
  const NOW_TIMING = parseTiming('now');
260
367
  let armedRun = null; // set right before a programmatic evaluation and consumed by the wrapper, synchronously
261
368
  let activeRun = null; // the run whose evaluation is executing now (evaluations are serialized)
@@ -295,16 +402,33 @@ function playingPattern() {
295
402
  return editor.repl.state.pattern;
296
403
  }
297
404
 
298
- /** Arms the timing of the next editor.repl.evaluate call; the wrapper takes it synchronously. */
299
- function armRun(timing) {
300
- 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 };
301
408
  armedRun = run;
302
409
  return run;
303
410
  }
304
411
 
305
- /** 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
+ */
306
418
  function audioTimeOfCycle(cycle) {
307
- 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);
308
432
  }
309
433
 
310
434
  /** Milliseconds until a cycle line is audible (0 when it already is). */
@@ -323,13 +447,17 @@ const takeover = createTakeover({
323
447
  getTypes: () => queryTypes,
324
448
  getTiming: () => (activeRun !== null ? activeRun.timing : NOW_TIMING),
325
449
  // The old music covers the way to the line at the tempo that plays, not at the tempo the waiting code just set.
326
- getCps: () => tempoGuard.playing(),
450
+ getCps: () => takeoverCps(),
327
451
  isPlaying: () => scheduler.started === true && !pageStopped && hasEvaluatedSuccessfully && !!scheduler.pattern,
328
452
  msUntil: msUntilCycle,
329
453
  setTimer: (fn, ms) => setTimeout(fn, ms),
330
454
  clearTimer: (timer) => clearTimeout(timer),
331
455
  onArmed(record) {
332
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;
333
461
  // The first finisher of the record: the Mixer and Parts stop saying "waiting" and read the new code at the
334
462
  // takeover, before the finishers that report it.
335
463
  takeover.whenFinished(record, () => {
@@ -374,17 +502,53 @@ const takeover = createTakeover({
374
502
  },
375
503
  onReached(record) {
376
504
  // A microtask after the gate's first query past the line: the downbeat is already timestamped on the line
377
- // and the old bar is untouched. A canceled record never gets here (the takeover skips it).
378
- 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;
379
509
  try {
380
- // Kept for a cancel after this point (Stop before the line is audible): the tempo that played goes back with it.
381
- record.cpsBefore = tempoGuard.playing();
382
- 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.
383
537
  } catch (err) {
384
538
  console.error('Moontide: the tempo of the waiting change could not be applied:', err);
539
+ } finally {
540
+ reachedRecord = null;
385
541
  }
386
542
  },
387
543
  onCancel(record, reason) {
544
+ // A section launch that waited for this record is over, whatever the reason.
545
+ const ownLaunch = gridLaunch !== null && gridLaunch.record === record;
546
+ if (ownLaunch) {
547
+ gridLaunch = null;
548
+ gridQueued = null;
549
+ }
550
+ // A cell change that waited for this record is over too: nothing was recorded for it.
551
+ if (gridCellWait !== null && gridCellWait.record === record) gridCellWait = null;
388
552
  // The setup that did not go out is forgotten; one that went out (stamped on the line, it cannot be recalled) is
389
553
  // answered by the playing code's sounds and controls sent just after it. The clock serves the playing code's
390
554
  // instruments only again.
@@ -406,7 +570,7 @@ const takeover = createTakeover({
406
570
  // The server learns about a waiting change that was stopped or replaced, but only when it already heard the
407
571
  // reply that announced it (a change canceled before its reply says so in the reply itself). Sent here,
408
572
  // synchronously, so it goes out before user-stopped and stop-result (sent after stopSoundLocally resolves)
409
- // and before the reply of the newer change. Any other reason (superseded, unload) sends nothing: the
573
+ // and before the reply of the newer change. Any other reason (superseded, unload, canceled) sends nothing: the
410
574
  // server drops the change itself on detach and attach.
411
575
  if ((reason === 'stop' || reason === 'replaced') && record.evaluateId !== null && record.replied === true) {
412
576
  sendIfOpen(JSON.stringify({ type: 'takeover-canceled', id: record.evaluateId, reason }));
@@ -418,10 +582,16 @@ const takeover = createTakeover({
418
582
  if (reason !== 'replaced' && scheduler.pattern === record.gate) scheduler.pattern = record.playingPattern;
419
583
  // The repl still holds the canceled change as committed: the readers keep the music that played. A replaced
420
584
  // record has a successor that is armed (or takes over at once) in the same turn and clears or keeps this.
421
- if (reason === 'stop' || reason === 'superseded' || reason === 'unload') {
585
+ if (reason === 'stop' || reason === 'superseded' || reason === 'unload' || reason === 'canceled') {
422
586
  canceledPlayingCode = record.playingCode;
423
587
  canceledPlayingPattern = record.playingPattern;
424
588
  }
589
+ // The person's own cancel of a waiting section launch: the music keeps playing, and the editor goes back to the code
590
+ // that plays (the launch wrote only the shift into it), unless the person typed over that text meanwhile: typing that
591
+ // was never evaluated is never replaced. Nothing is sent to the server: a launch has no evaluate id and was never
592
+ // recorded. A Stop while the launch of the grid waits does the same: the shift it wrote would never play (Play writes
593
+ // shift 0), and left in the editor it would read as typing that was never evaluated and lock the grid.
594
+ if (reason === 'canceled' || (reason === 'stop' && ownLaunch)) putBackCanceledLaunch(record);
425
595
  // A replaced record has a successor that is armed (its onArmed shows the wait again) or takes over at once
426
596
  // (its takeover redraws the views): here the Mixer only stops saying "waiting", with no redraw from the code
427
597
  // that is being evaluated.
@@ -430,7 +600,7 @@ const takeover = createTakeover({
430
600
  // The countdown always ends with the wait. Stop says so once; any other reason clears the line silently (a
431
601
  // replacing change says its own line right after, an immediate one needs none).
432
602
  topBarUi.showCountdown(null);
433
- if (reason === 'stop') topBarUi.say(CHANGE_COPY.canceled);
603
+ if (reason === 'stop' || reason === 'canceled') topBarUi.say(CHANGE_COPY.canceled);
434
604
  else topBarUi.clearStatus();
435
605
  },
436
606
  onError: (text, err) => console.error(text, err),
@@ -441,6 +611,7 @@ installQueryCheck(scheduler, {
441
611
  schedulerQueries,
442
612
  takeover,
443
613
  getPlayingCode: () => (hasEvaluatedSuccessfully ? playingCode() : ''),
614
+ refuse: glideRefusal,
444
615
  });
445
616
 
446
617
  // The top bar's tempo readout and countdown. The view only writes what changed.
@@ -459,7 +630,9 @@ let badWaitingTempoLogged;
459
630
  * the scheduler's cps, which keeps the old value until a waiting change's line, and the countdown is the distance
460
631
  * to the waiting change's line in beats of the music. The tempo readout is always shown: while this tab does not
461
632
  * play (before the first evaluation, after Stop, with sound off, in a superseded tab) it shows the dash, and a
462
- * 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
463
636
  * nothing plays. The view redraws only when a text changed.
464
637
  */
465
638
  function drawTopBar() {
@@ -470,10 +643,14 @@ function drawTopBar() {
470
643
  topBarUi.showTempo(idleTempo);
471
644
  topBarUi.showCycleBeat(null);
472
645
  topBarUi.showCountdown(null);
646
+ syncGridQueued(null);
473
647
  return;
474
648
  }
475
649
  const record = takeover.pending();
476
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;
477
654
  try {
478
655
  const bpm = bpmOf(cps);
479
656
  // The tempo a waiting change brings, when it carries one; a bad number is logged once and the arrow is left out.
@@ -491,7 +668,14 @@ function drawTopBar() {
491
668
  }
492
669
  }
493
670
  }
494
- 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
+ );
495
679
  } catch (err) {
496
680
  topBarUi.showTempo(idleTempo);
497
681
  if (!Object.is(badTempoLogged, cps)) {
@@ -499,14 +683,37 @@ function drawTopBar() {
499
683
  console.error(`Moontide: the top bar cannot show the tempo, the scheduler reports cps ${String(cps)}:`, err);
500
684
  }
501
685
  }
502
- const raw = scheduler.now();
503
- const now = Number.isFinite(raw) && raw > 0 ? raw : 0;
504
686
  topBarUi.showCycleBeat(cycleBeat(now));
505
687
  if (record === null || record.at === undefined || record.boundary - now <= 0) {
506
688
  topBarUi.showCountdown(null);
689
+ syncGridQueued(now);
507
690
  return;
508
691
  }
509
692
  topBarUi.showCountdown({ beatsLeft: (record.boundary - now) * BEATS_PER_CYCLE, at: record.at });
693
+ syncGridQueued(now);
694
+ }
695
+
696
+ /**
697
+ * Hands the queued section header of the Song grid its countdown for this poll: the rest of the top bar's text
698
+ * (`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
699
+ * never disagree. `now` is null while nothing plays. The grid is written in the same turn when the text changed.
700
+ */
701
+ function syncGridQueued(now) {
702
+ let next = null;
703
+ if (now !== null && gridLaunch !== null && takeover.pending() === gridLaunch.record && gridLaunch.record.boundary - now > 0) {
704
+ next = {
705
+ index: gridLaunch.index,
706
+ countdownText: countdownParts((gridLaunch.record.boundary - now) * BEATS_PER_CYCLE).rest,
707
+ line: gridLaunch.record.boundary,
708
+ };
709
+ } else if (now !== null && gridAlready !== null && gridAlready.line - now > 0) {
710
+ // A launch that needs no code change counts down to its natural arrival with the same words.
711
+ next = { index: gridAlready.index, countdownText: countdownParts((gridAlready.line - now) * BEATS_PER_CYCLE).rest, line: gridAlready.line };
712
+ }
713
+ const same = gridQueued === next || (gridQueued !== null && next !== null && gridQueued.index === next.index && gridQueued.countdownText === next.countdownText && gridQueued.line === next.line);
714
+ if (same) return;
715
+ gridQueued = next;
716
+ if (songGridReading !== null) updateSongGridLive();
510
717
  }
511
718
 
512
719
  // The always-on page poll (every 250 ms, for the life of the page). It finishes a waiting change whose line the
@@ -521,12 +728,53 @@ setInterval(() => {
521
728
  } catch (err) {
522
729
  console.error('Moontide: the top bar could not be drawn:', err);
523
730
  }
731
+ try {
732
+ pollSongGrid();
733
+ } catch (err) {
734
+ console.error('Moontide: the song grid could not follow the page:', err);
735
+ }
736
+ try {
737
+ pollTimelineEdit();
738
+ } catch (err) {
739
+ console.error('Moontide: the Timeline note panel could not follow the page:', err);
740
+ }
524
741
  }, 250);
525
742
 
526
743
  // The program has already run when the check refuses it (or when evaluate fails later), so a
527
744
  // top-level setcps/setcpm of the refused code has already reached the scheduler while the previous music
528
745
  // keeps playing. The guard puts the previous tempo back when an evaluation fails.
529
- 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
+ }
530
778
 
531
779
  /** The tempo in BPM for a reply, or undefined (with one console line) when the cps is not a usable number. */
532
780
  function bpmForReply(cps) {
@@ -633,9 +881,18 @@ async function evaluateWithSetup(code, rest) {
633
881
  }
634
882
  const failed = !!editor.repl.state.evalError;
635
883
  const waitingRecord = activeRun !== null ? activeRun.deferred : null;
636
- // A waiting change must not touch the music that still plays, so its tempo is taken back now and
637
- // applied at the line (onReached); the reply states it as the tempo from that cycle.
638
- 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
+ }
639
896
  if (!failed && activeRun !== null) {
640
897
  if (waitingRecord !== null) {
641
898
  waitingRecord.newCps = typeof requested === 'number' ? requested : null;
@@ -655,6 +912,47 @@ async function evaluateWithSetup(code, rest) {
655
912
  return result;
656
913
  }
657
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
+
658
956
  /** The MIDI setup of a change that took over at once although it was checked as a waiting one. Never throws. */
659
957
  async function sendLateSetup(code) {
660
958
  try {
@@ -679,7 +977,7 @@ editor.repl.evaluate = (code, ...rest) => {
679
977
  const evalRun =
680
978
  armedRun !== null
681
979
  ? armedRun
682
- : { 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 };
683
981
  armedRun = null;
684
982
  const run = evalQueue.then(async () => {
685
983
  activeRun = evalRun;
@@ -737,6 +1035,56 @@ const mixerViewEl = document.getElementById('mixer-view');
737
1035
  const partsView = document.getElementById('parts-view');
738
1036
  const partsMessage = document.getElementById('parts-message');
739
1037
  const partsRows = document.getElementById('parts-rows');
1038
+ const songNotesEl = document.getElementById('song-notes');
1039
+ if (!songNotesEl) throw new Error('Moontide: the song notes element is missing from the page.');
1040
+ // The lines above the song grid in the Parts view; they draw what page/structure.js read from the code.
1041
+ const songUi = createSongView({ document, notes: songNotesEl });
1042
+ // The Song grid that replaces the Parts rows for a piece that declares a readable song. Its handlers are added as each
1043
+ // gesture gets its behaviour; a gesture without one throws, so nothing is ignored silently.
1044
+ const songGridHandlers = {};
1045
+ const songBarEl = document.getElementById('song-bar');
1046
+ const songNowEl = document.getElementById('song-now');
1047
+ const songLaunchEl = document.getElementById('song-launch');
1048
+ const songLaunchLabelEl = document.getElementById('song-launch-label');
1049
+ const songLineEl = document.getElementById('song-line');
1050
+ const songCellListEl = document.getElementById('song-cell-list');
1051
+ if (!songBarEl || !songNowEl || !songLaunchEl || !songLaunchLabelEl || !songLineEl || !songCellListEl) throw new Error('Moontide: the song toolbar elements are missing from the page.');
1052
+ songLaunchLabelEl.textContent = SONG_COPY.launchLabel;
1053
+ const songGridUi = createSongGridView({ document, rows: partsRows, bar: songBarEl, now: songNowEl, line: songLineEl, launchGroup: songLaunchEl, list: songCellListEl, handlers: songGridHandlers });
1054
+ // The note panel under the Timeline lanes and the selection on the lanes. A click picks a note, the panel says whether the page
1055
+ // can edit it, and keys and buttons change its pitch, turn it into a rest or change its velocity when it can. What the panel
1056
+ // needs to know is read in describeTimelineNote; the edit itself runs on the serial chain (editTimelineNote), further down.
1057
+ const timelineNotePanel = document.getElementById('timeline-note');
1058
+ const timelineNoteLine = document.getElementById('timeline-note-line');
1059
+ if (!timelineNotePanel || !timelineNoteLine) throw new Error('Moontide: the Timeline note panel is missing from the page.');
1060
+ const timelineEditUi = createTimelineEditView({
1061
+ document,
1062
+ lanes: timelineLanes,
1063
+ panel: timelineNotePanel,
1064
+ line: timelineNoteLine,
1065
+ rootFontPx: () => parseFloat(getComputedStyle(document.documentElement).fontSize),
1066
+ // The pause that gathers the steps of a key or a button runs on the page's own timers.
1067
+ setTimer: (fn, ms) => setTimeout(fn, ms),
1068
+ clearTimer: (handle) => clearTimeout(handle),
1069
+ handlers: {
1070
+ describe: (laneIndex, eventIndex) => describeTimelineNote(laneIndex, eventIndex),
1071
+ // The view hands over one edit at a time and waits for its outcome, which is given back with the same id.
1072
+ edit: (info, change, id) => {
1073
+ chain = chain.then(() => editTimelineNote(info, change, id)).catch((err) => reportChainError('timeline edit', err));
1074
+ },
1075
+ // A gesture on a note the page cannot edit becomes a ready-made request in the Ask box; nothing is written or sent.
1076
+ prepare: (info, change, point) => askAboutTimelineNote(info, timelineGestureOf(change), point),
1077
+ ask: (info, point) => askAboutTimelineNote(info, undefined, point),
1078
+ // A range of cycles on one lane opens the Ask box with the lane, the cycles and the notes in them.
1079
+ range: (selection, point) => askAboutTimelineRange(selection, point),
1080
+ // What the panel says of a range: the lane, the sections of its ends and how many notes start in it.
1081
+ describeRange: (selection) => describeTimelineRange(selection),
1082
+ // The keyboard cursor on a lane with no note on the page: the lane's own context at the cursor's cycle.
1083
+ askLane: (laneIndex, cycle, point) => askAboutTimelineLane(laneIndex, cycle, point),
1084
+ },
1085
+ });
1086
+ // A drag on the lanes ends with no write when the window loses focus.
1087
+ window.addEventListener('blur', () => timelineEditUi.cancelGesture());
740
1088
  const jamViewEl = document.getElementById('jam-view');
741
1089
  const instrumentsViewEl = document.getElementById('instruments-view');
742
1090
  const editorArea = document.getElementById('editor-area');
@@ -789,6 +1137,7 @@ const jamUi = createJamView({
789
1137
  // Draw the overlay at once; the 250 ms tick would make the checkbox feel slow.
790
1138
  renderJam();
791
1139
  },
1140
+ onScaleChoice: (choice) => chooseJamScale(choice),
792
1141
  });
793
1142
 
794
1143
  // The Instruments view's DOM. Created here, before any code path that can call into it (its redraw
@@ -897,17 +1246,48 @@ const sessionLabel = readSessionLabel(document, (text) => console.error(text));
897
1246
  const pageBaseTitle = baseTitle(sessionLabel);
898
1247
  renderSessionIdentity(document, sessionLabel, (text) => console.error(text));
899
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
+
900
1275
  // The Ask Claude box. Created before any code path that can call into it.
901
1276
  const askUi = createAskView({
902
1277
  document,
903
- send: (message) => sendIfOpen(JSON.stringify(message)),
1278
+ requests: requestTracker,
1279
+ send: (message) => {
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();
1283
+ // A sent request about a range of the Timeline is done with it: the wash goes.
1284
+ if (message.type === 'ask' && message.context.kind === 'range') timelineEditUi.clearRange();
1285
+ },
904
1286
  reportError: showAskProblem,
905
1287
  clearError: clearAskProblem,
906
1288
  setTimer: (fn, ms) => setTimeout(fn, ms),
907
1289
  clearTimer: (timer) => clearTimeout(timer),
908
1290
  now: () => Date.now(),
909
- // The status text changes the bar's width: the fit step runs again.
910
- fit: () => topBarUi.fit(),
911
1291
  });
912
1292
 
913
1293
  // The segment a superseded tab shows, with its "Play in this tab" button. takeBackTimer is the
@@ -955,6 +1335,7 @@ const sessionUi = createSessionView({
955
1335
  onOpen() {
956
1336
  midiUi.closeHelp();
957
1337
  askUi.closeHelp();
1338
+ requestsUi.close();
958
1339
  },
959
1340
  setTimer: (fn, ms) => setTimeout(fn, ms),
960
1341
  // One of the three Edits timing buttons. The setting is stored by applyViewState (through the closed-set
@@ -1114,6 +1495,23 @@ function buildViewButtons() {
1114
1495
  }
1115
1496
  buildViewButtons();
1116
1497
 
1498
+ // The names the top-bar view buttons show now: the Parts view is called Song while the code declares a song.
1499
+ const viewTitles = { ...VIEW_TITLES };
1500
+
1501
+ /** Calls a view by `name` on its top-bar button (the visible name, the accessible name and the tooltip) and lets the bar
1502
+ * fit again, since the name may have another width. The key and the view itself do not change. */
1503
+ function setViewTitle(view, name) {
1504
+ if (viewTitles[view] === name) return;
1505
+ viewTitles[view] = name;
1506
+ const part = viewButtonParts(BUILT_VIEWS, viewTitles).find((candidate) => VIEW_KEYS[candidate.key] === view);
1507
+ const button = viewButtonsEl.querySelector(`button[data-key="${part.key}"]`);
1508
+ if (button === null) throw new Error(`Moontide: the top-bar button for the '${view}' view is missing.`);
1509
+ button.querySelector('.view-name').textContent = part.name;
1510
+ button.setAttribute('aria-label', part.label);
1511
+ button.title = part.title;
1512
+ topBarUi.fit();
1513
+ }
1514
+
1117
1515
  // True once stopSoundLocally() has run and no successful evaluate has
1118
1516
  // restarted playback since. stopCount lets handleEvaluate detect a Stop that
1119
1517
  // landed while a set_code was still in flight. Declared here, ahead of refreshSoundState's
@@ -1140,6 +1538,47 @@ let pendingLaunch = null;
1140
1538
  // or width changes). Rebuilt on every renderParts() call (a new version, or a fresh view open).
1141
1539
  let partsCardRefs = [];
1142
1540
 
1541
+ // The reading the Song grid on screen was drawn from, or null while no grid is drawn: the live tick needs its shift and
1542
+ // lengths to tell which section plays.
1543
+ let songGridReading = null;
1544
+ // Whether the last live update found sound playing, so the grid scrolls the playing section into view once at Play.
1545
+ let songGridWasPlaying = false;
1546
+ // The section launch that waits for its line, `{ record, index, name, line, at }`: `record` is the takeover's waiting change,
1547
+ // `line` the cycle it takes over on. Null when none waits. Cleared when the record takes over or is dropped.
1548
+ let gridLaunch = null;
1549
+ // The change of a cell that waits for its line, `{ record, track, section, choice, at }`: `section` is the section's name (every
1550
+ // column of that name shows it), `choice` is `{ key }` or `{ rest: true }`, `at` the timing it waits on. Null when none waits.
1551
+ // Cleared when the record takes over or is dropped.
1552
+ let gridCellWait = null;
1553
+ // What the queued section header shows on this poll, `{ index, countdownText, line }`, or null. Computed once per poll of the
1554
+ // top bar from the same record and the same cycle as the bar's countdown, so the header and the bar always agree.
1555
+ let gridQueued = null;
1556
+ // True while a launch is being written and evaluated: nothing else reads the editor as "unevaluated typing" meanwhile.
1557
+ let gridBusy = false;
1558
+ // Why the last gesture on the Song grid was refused, as an id, or null (nothing was changed).
1559
+ let gridRefused = null;
1560
+ // A launch that needs no code change, `{ index, name, line }`: the section already starts on the line the person chose, so
1561
+ // nothing is written or waits in the takeover; the header counts down to the line until the scheduler reaches it.
1562
+ let gridAlready = null;
1563
+ // The sentence under the toolbar for this poll (the result of songLine), and whether the editor held text that was not
1564
+ // evaluated at the last poll that could tell.
1565
+ let gridLine = null;
1566
+ let gridDirty = false;
1567
+
1568
+ // The song structure read from the code of the version that plays, `{ generation, code, reading }`, or null: read once
1569
+ // per version in onVersionPlaying (the Parts view uses it, the 250 ms tick never parses). structureNoteSent is true
1570
+ // while the last structure note sent to the server held lines, so a note that clears them is sent too.
1571
+ let songReading = null;
1572
+ let structureNoteSent = false;
1573
+ // The line the declared-against-played check gave for the version that plays, `{ generation, entry }` (entry is a line for
1574
+ // #song-notes, or null when what plays agrees with the declaration), or null while the check has not answered.
1575
+ let songCheck = null;
1576
+ // Whether the notes of each fixed variation fit the harmony of the sections it plays in, for the version that plays:
1577
+ // `{ generation, reading, answer }` with the answer of collectFit (variation to masks or reason) once the query has
1578
+ // answered. It is null while the query is pending, and for a version with no fixed variation: the grid then shows no fit
1579
+ // words at all.
1580
+ let songFit = null;
1581
+
1143
1582
  // The history id of the code this page has loaded, and whether a version has ever
1144
1583
  // played in this page session — both feed versionIndicator(). Declared here, ahead of
1145
1584
  // refreshSoundState's first call below, for the same reason as pageStopped above.
@@ -1206,8 +1645,8 @@ let mixerLitMemo = { code: null, identity: null, range: null };
1206
1645
  * or a pushed change for a hand evaluation. (runMixerEvaluate below is the single place that
1207
1646
  * calls the repl directly.)
1208
1647
  */
1209
- async function runProgrammaticEvaluate(timing = NOW_TIMING) {
1210
- const run = armRun(timing);
1648
+ async function runProgrammaticEvaluate(timing = NOW_TIMING, { glideBars = null } = {}) {
1649
+ const run = armRun(timing, glideBars);
1211
1650
  programmaticEvalInFlight = true;
1212
1651
  try {
1213
1652
  const evaluation = editor.evaluate();
@@ -1231,14 +1670,15 @@ async function runProgrammaticEvaluate(timing = NOW_TIMING) {
1231
1670
  * keeps trackHandEval from reporting each tick as a hand evaluation, which would record one
1232
1671
  * 'hand' version per tick.
1233
1672
  */
1234
- async function runMixerEvaluate(code, autostart) {
1673
+ async function runMixerEvaluate(code, autostart, timing = NOW_TIMING) {
1235
1674
  programmaticEvalInFlight = true;
1236
- armRun(NOW_TIMING);
1675
+ const run = armRun(timing);
1237
1676
  try {
1238
1677
  await editor.repl.evaluate(code, autostart);
1239
1678
  } finally {
1240
1679
  programmaticEvalInFlight = false;
1241
1680
  }
1681
+ return run;
1242
1682
  }
1243
1683
 
1244
1684
  /**
@@ -1396,6 +1836,20 @@ function applyRig(rig) {
1396
1836
  // Tracks the current sound-state slot ('off' | 'silent' | 'playing').
1397
1837
  let currentSoundState = 'off';
1398
1838
 
1839
+ // The lights of the Mixer (page/lights.js holds their state, the Mixer view draws them). They are declared here, before
1840
+ // refreshSoundState first runs, because that function ends in syncLights. `lightsActive` is true while the loop runs: the Mixer
1841
+ // is open, the tab is visible and sound is playing. `lightsViewReady` stays false until the view state and the Mixer exist,
1842
+ // so nothing below touches them too early. `lightsMotion` is the motion the running lights were started with.
1843
+ const lights = createLights();
1844
+ let lightsActive = false;
1845
+ let lightsRafId = null;
1846
+ let lightsMotion = 'on';
1847
+ let lightsViewReady = false;
1848
+ let lightsBroken = false;
1849
+ // True while the tempo is not above 0: the loop runs and draws nothing. The warning about a missing output timestamp is said once.
1850
+ let lightsPaused = false;
1851
+ let lightsPairWarned = false;
1852
+
1399
1853
  /** Applies versionIndicator()'s result — textContent and the hidden property only, never a data-accent (the indicator is never accented). */
1400
1854
  function renderVersionIndicator() {
1401
1855
  const { hidden, text } = versionIndicator({
@@ -1459,6 +1913,7 @@ function refreshSoundState() {
1459
1913
  const started = !!(editor.repl && editor.repl.state && editor.repl.state.started);
1460
1914
  currentSoundState = soundState({ audioRunning, started, stopped: pageStopped });
1461
1915
  renderSoundState();
1916
+ syncLights();
1462
1917
  }
1463
1918
 
1464
1919
  renderConnectionState('connecting');
@@ -1584,6 +2039,8 @@ async function stopSoundLocally({ panicFirst = false } = {}) {
1584
2039
  // A change that waits for its line is canceled first: its finish must never run after Stop, and the
1585
2040
  // next Play starts from cycle 0 without the canceled gate.
1586
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');
1587
2044
  // Retire the webmidi Outputs first (synchronous), so no callback frozen from the stopped
1588
2045
  // code can send between scheduler.stop() and the suspend, or after the next Play.
1589
2046
  midiOut.retireOutputs({ timedRelease: !panicFirst });
@@ -1593,6 +2050,7 @@ async function stopSoundLocally({ panicFirst = false } = {}) {
1593
2050
  pageStopped = true;
1594
2051
  stopCount++;
1595
2052
  pendingLaunch = null;
2053
+ gridAlready = null;
1596
2054
  editor.repl.scheduler.stop();
1597
2055
  refreshSoundState();
1598
2056
  try {
@@ -1668,6 +2126,43 @@ async function reportHandEval(state, code = editor.repl.state.activeCode) {
1668
2126
  sendIfOpen(JSON.stringify({ type: 'hand-eval', ok: true, code }));
1669
2127
  }
1670
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
+
2132
+ /**
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
2134
+ * launch (or a person) left in the code would start the music inside the structure. The shift literal of the editor text is
2135
+ * written back to 0 before Play evaluates the text, which then records one hand edit like any other; with the shift already
2136
+ * 0 nothing is written and Play records nothing new. Only the shift literal is ever replaced. Text that does not parse or
2137
+ * does not read as a song plays as written (the evaluation says what is wrong with it); anything else that throws is
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.
2139
+ */
2140
+ function resetSongShiftForPlay() {
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
+ }
2148
+ const text = editor.code;
2149
+ try {
2150
+ const reading = readSongOf(text);
2151
+ if (reading.state !== 'read') return;
2152
+ const edits = playResetEdit(reading);
2153
+ if (edits === null) return;
2154
+ const expected = applyEdits(text, edits);
2155
+ editor.editor.dispatch({ changes: edits });
2156
+ if (editor.code !== expected) {
2157
+ editor.setCode(text);
2158
+ console.error('Moontide: Play could not write shift 0 into the code; the code plays as written.');
2159
+ }
2160
+ } catch (err) {
2161
+ if (err instanceof SyntaxError) return;
2162
+ console.error('Moontide: Play could not reset the shift of the song; the code plays as written:', err);
2163
+ }
2164
+ }
2165
+
1671
2166
  // UI Consideration loading/transport-button: Stop is a synchronous local action — the sound
1672
2167
  // state flips to 'Stopped' the instant stopSoundLocally() sets pageStopped and calls
1673
2168
  // refreshSoundState(), with no network round trip awaited first. Not routed through the
@@ -1701,6 +2196,8 @@ transportButton.addEventListener('click', (event) => {
1701
2196
  if (typeof globalThis.getAudioContext === 'function') {
1702
2197
  globalThis.getAudioContext().resume().catch((err) => console.error('Moontide: AudioContext.resume() failed:', err));
1703
2198
  }
2199
+ // Play after Stop starts a piece that declares a song from its first section.
2200
+ if (pageStopped) resetSongShiftForPlay();
1704
2201
  Promise.resolve(editor.evaluate())
1705
2202
  .then(() => {
1706
2203
  // A failed evaluation leaves its error on the repl state (the error line shows it) and the word stays Stopped.
@@ -1778,6 +2275,47 @@ if (typeof navigator.requestMIDIAccess !== 'function') {
1778
2275
  }
1779
2276
  }
1780
2277
 
2278
+ // Every note learns which labeled track played it, and the note tag tells whoever subscribes (the lights).
2279
+ // Installed once, here, because the tag is added when a labeled statement is evaluated: it cannot be switched
2280
+ // on later for code that already ran. Without Web MIDI no note has a port timestamp.
2281
+ try {
2282
+ installNoteTag(globalThis.Pattern, {
2283
+ stampOf: typeof navigator.requestMIDIAccess === 'function' ? (hap) => midiOut.noteStampOf(hap) : () => null,
2284
+ });
2285
+ } catch (err) {
2286
+ console.error(err);
2287
+ }
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
+
2298
+ // Every note of a labeled track goes to the lights while they are active. Otherwise it is dropped at once, so notes played
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
2300
+ // time its port got; a browser sound carries the audio time it is due, which the lights map to when it is heard.
2301
+ subscribeNotes((event) => {
2302
+ if (!lightsActive) return;
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 });
2317
+ });
2318
+
1781
2319
  // Start enabling Web MIDI at page start only when the browser already says the permission is
1782
2320
  // granted (no prompt can appear then). Otherwise the first evaluation whose code has a .midi( call
1783
2321
  // asks. Never awaited here: requestMIDIAccess can hang with some device drivers, and the WebSocket
@@ -1825,6 +2363,21 @@ document.addEventListener('strudel.log', (event) => {
1825
2363
  sendRuntimeError(text);
1826
2364
  });
1827
2365
 
2366
+ /**
2367
+ * Sends the lines the page read from the song structure of the code that plays (an empty list clears the last note),
2368
+ * tagged with the evaluate id whose code was playing (`evaluateId`, which a late caller captured when its version started).
2369
+ * The message shape is mirrored by hand from src/protocol.ts. Called from onVersionPlaying, which runs inside repl.evaluate,
2370
+ * so the server has the note before the evaluated reply; the check of the declaration sends the version's full lines later.
2371
+ */
2372
+ function sendStructureNote(lines, evaluateId = playingEvaluateId) {
2373
+ const payload = { type: 'structure-note', lines };
2374
+ if (evaluateId !== null) {
2375
+ payload.evaluateId = evaluateId;
2376
+ }
2377
+ sendIfOpen(JSON.stringify(payload));
2378
+ structureNoteSent = lines.length > 0;
2379
+ }
2380
+
1828
2381
  /** Sends one runtime error to the server, tagged with the evaluate id whose code was playing. */
1829
2382
  function sendRuntimeError(text) {
1830
2383
  const payload = { type: 'runtime-error', message: text };
@@ -1849,6 +2402,8 @@ ws.addEventListener('open', () => {
1849
2402
  historyUi.setConnected('connected');
1850
2403
  mixerUi.setConnected('connected');
1851
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' }));
1852
2407
  // Always ask, whether or not the view is open, so live additions accumulate.
1853
2408
  sendIfOpen(JSON.stringify({ type: 'history-request' }));
1854
2409
  // The data folder is asked once per connection, after page-auth (the server answers only a page that
@@ -1869,6 +2424,7 @@ ws.addEventListener('close', () => {
1869
2424
  renderConnectionState('disconnected');
1870
2425
  historyUi.setConnected('disconnected');
1871
2426
  mixerUi.setConnected('disconnected');
2427
+ requestTracker.connectionLost();
1872
2428
  askUi.setConnected('disconnected');
1873
2429
  });
1874
2430
  ws.addEventListener('error', () => {
@@ -1883,6 +2439,7 @@ ws.addEventListener('error', () => {
1883
2439
  renderConnectionState('disconnected');
1884
2440
  historyUi.setConnected('disconnected');
1885
2441
  mixerUi.setConnected('disconnected');
2442
+ requestTracker.connectionLost();
1886
2443
  askUi.setConnected('disconnected');
1887
2444
  });
1888
2445
 
@@ -1931,7 +2488,13 @@ const midiPreview = createPreviewSender({
1931
2488
  clearTimer: (timer) => clearTimeout(timer),
1932
2489
  now: () => performance.now(),
1933
2490
  });
1934
- const mixerUi = createMixerView({ document, gesture: mixerGesture, midiPreview, onStripSelect: () => syncMixerLit({ reveal: true }) });
2491
+ const mixerUi = createMixerView({
2492
+ document,
2493
+ gesture: mixerGesture,
2494
+ midiPreview,
2495
+ onStripSelect: () => syncMixerLit({ reveal: true }),
2496
+ onGroupFold: (key) => toggleSongFold(key),
2497
+ });
1935
2498
  mixerUi.setConnected(ws.readyState === ws.OPEN ? 'connected' : 'connecting');
1936
2499
  askUi.setConnected(ws.readyState === ws.OPEN ? 'connected' : 'connecting');
1937
2500
 
@@ -2030,6 +2593,9 @@ ws.addEventListener('message', (event) => {
2030
2593
  }
2031
2594
 
2032
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');
2033
2599
  // The hello is the first moment the page can know there is no instruments.json, so the
2034
2600
  // Instruments view says "Connecting..." until now. Set synchronously, before anything is queued.
2035
2601
  pageHelloSeen = true;
@@ -2081,9 +2647,7 @@ ws.addEventListener('message', (event) => {
2081
2647
  // does (this is the page's first evaluation attempt since (re)load, so
2082
2648
  // hasEvaluatedSuccessfully is still false and the revert target is always '').
2083
2649
  // Report it so the server (and Claude) learn playback is actually silent.
2084
- editor.setCode(hasEvaluatedSuccessfully ? committedCode() : '');
2085
- const { errorText, line } = buildEvalErrorReport(evalError);
2086
- showEvalError(errorText);
2650
+ const { errorText, line } = revertAndReport(evalError);
2087
2651
  const payload = { type: 'reconnect-eval-failed', error: errorText };
2088
2652
  if (line !== undefined) {
2089
2653
  payload.line = line;
@@ -2206,6 +2770,13 @@ ws.addEventListener('message', (event) => {
2206
2770
  return;
2207
2771
  }
2208
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
+
2209
2780
  if (msg.type === 'instrument-result') {
2210
2781
  // Mirrors src/protocol.ts by hand: the answer to one instrument-switch of this page. Outside the serial
2211
2782
  // `chain` like the other plain updates; the rig push it follows has already been applied.
@@ -2237,17 +2808,41 @@ function buildEvalErrorReport(evalError) {
2237
2808
  return { errorText: String(evalError.message || evalError), line: evalErrorLine(evalError) };
2238
2809
  }
2239
2810
 
2811
+ /**
2812
+ * The one place that takes a failed change back out of the editor: sets the text to the committed code (the music
2813
+ * that is audible), or to nothing before any code has played, and shows the failed-change banner. Returns the
2814
+ * report, so a caller that tells the server about the failure sends the same text and line. Every failed
2815
+ * evaluation of code the page wrote or received ends here: Claude's change, the reconnect replay and the page's own
2816
+ * rewrites. `banner` false takes the code back without the banner (a refused tempo ramp, which is not a failure of the code).
2817
+ */
2818
+ function revertAndReport(evalError, { banner = true } = {}) {
2819
+ editor.setCode(hasEvaluatedSuccessfully ? committedCode() : '');
2820
+ const report = buildEvalErrorReport(evalError);
2821
+ if (banner) showEvalError(report.errorText);
2822
+ return report;
2823
+ }
2824
+
2240
2825
  async function handleEvaluate(msg) {
2241
2826
  // An unknown timing is refused before anything changes (the page mirrors src/protocol.ts by hand).
2242
2827
  let timing;
2828
+ let glideBars;
2243
2829
  try {
2244
2830
  timing = parseTiming(msg.at, msg.phraseCycles);
2831
+ glideBars = parseGlideBars(msg.glideBars);
2245
2832
  } catch (err) {
2246
2833
  sendIfOpen(
2247
2834
  JSON.stringify({ type: 'evaluated', id: msg.id, ok: false, code: msg.code, error: err instanceof Error ? err.message : String(err) }),
2248
2835
  );
2249
2836
  return;
2250
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
+ }
2251
2846
  // A pending Mixer gesture is recorded (with the code that played) before Claude's code
2252
2847
  // applies, so the history reads gesture then Claude; the touched lines no longer describe the text.
2253
2848
  await mixerGesture.flushDirect();
@@ -2266,7 +2861,7 @@ async function handleEvaluate(msg) {
2266
2861
  evaluateTag.begin(msg.id);
2267
2862
  let run;
2268
2863
  try {
2269
- run = await runProgrammaticEvaluate(timing);
2864
+ run = await runProgrammaticEvaluate(timing, { glideBars });
2270
2865
  } finally {
2271
2866
  evaluateTag.end();
2272
2867
  }
@@ -2279,17 +2874,19 @@ async function handleEvaluate(msg) {
2279
2874
  // still audible. Never call editor.evaluate() here: the previous pattern is still
2280
2875
  // playing, re-running it is unnecessary and risks re-triggering audio. A failed
2281
2876
  // evaluate leaves pageStopped and the silence unchanged.
2282
- editor.setCode(hasEvaluatedSuccessfully ? committedCode() : '');
2283
- const { errorText, line } = buildEvalErrorReport(evalError);
2284
- showEvalError(errorText);
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 });
2285
2881
  const payload = {
2286
2882
  type: 'evaluated',
2287
2883
  id: msg.id,
2288
2884
  ok: false,
2289
2885
  code: msg.code,
2290
- error: errorText,
2886
+ error: refusal ?? errorText,
2887
+ ...(refusal !== null ? { glideRefused: true } : {}),
2291
2888
  };
2292
- if (line !== undefined) {
2889
+ if (refusal === null && line !== undefined) {
2293
2890
  payload.line = line;
2294
2891
  }
2295
2892
  sendIfOpen(JSON.stringify(payload));
@@ -2327,7 +2924,9 @@ async function handleEvaluate(msg) {
2327
2924
  if (!replacedBeforeReply) {
2328
2925
  takeover.whenFinished(waiting, () => {
2329
2926
  evaluateTag.fromTakeover(msg.id);
2330
- 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 } : {}) }));
2331
2930
  });
2332
2931
  }
2333
2932
  sendIfOpen(
@@ -2339,6 +2938,7 @@ async function handleEvaluate(msg) {
2339
2938
  audio,
2340
2939
  pending: { cycle: waiting.boundary, seconds: pendingSecondsForReply(waiting.boundary) },
2341
2940
  ...(typeof run.bpm === 'number' ? { bpm: run.bpm } : {}),
2941
+ ...(!replacedBeforeReply ? atLineGlide(waiting) : {}),
2342
2942
  }),
2343
2943
  );
2344
2944
  waiting.replied = true;
@@ -2361,6 +2961,16 @@ async function handleEvaluate(msg) {
2361
2961
  // A change that already took over (a newer change settled it at its line before this reply) is reported with its
2362
2962
  // own code; the editor may already hold the newer change's text.
2363
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
+ }
2364
2974
  sendIfOpen(
2365
2975
  JSON.stringify({
2366
2976
  type: 'evaluated',
@@ -2369,8 +2979,15 @@ async function handleEvaluate(msg) {
2369
2979
  code: finishedBeforeReply ? waiting.code : editor.repl.state.activeCode,
2370
2980
  audio,
2371
2981
  ...(typeof run.bpm === 'number' ? { bpm: run.bpm } : {}),
2982
+ ...(glide !== undefined ? { glide } : {}),
2372
2983
  }),
2373
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
+ }
2374
2991
  }
2375
2992
 
2376
2993
  // --- the Mixer's writes ---
@@ -2386,6 +3003,41 @@ function sendMixerRecord(code, label) {
2386
3003
  ws.send(JSON.stringify({ type: 'mixer-record', code, label: clipMixerLabel(label) }));
2387
3004
  }
2388
3005
 
3006
+ // The one path through which the page changes the code by itself (page/page-rewrite.js): the Mixer and the
3007
+ // part launch call it, and each keeps its own wording for a refusal and its own message to the server.
3008
+ const pageRewrite = createPageRewrite({
3009
+ editor: {
3010
+ get code() {
3011
+ return editor.code;
3012
+ },
3013
+ setCode(text) {
3014
+ editor.setCode(text);
3015
+ },
3016
+ // The edit list changes the text in place, keeping the selection and the scroll.
3017
+ dispatch(edits) {
3018
+ editor.editor.dispatch({ changes: edits });
3019
+ },
3020
+ },
3021
+ hasEvaluated: () => hasEvaluatedSuccessfully,
3022
+ pending: () => takeover.pending(),
3023
+ playingCode,
3024
+ socketOpen: () => ws.readyState === ws.OPEN,
3025
+ parserReady: () => Boolean(window.acorn) && typeof window.acorn.parse === 'function',
3026
+ evaluate({ code, timing, autostart, flash }) {
3027
+ return flash ? runProgrammaticEvaluate(timing) : runMixerEvaluate(code, autostart, timing);
3028
+ },
3029
+ evalError: () => editor.repl.state.evalError,
3030
+ revertAndReport,
3031
+ // A page-originated evaluation has no Claude evaluate id.
3032
+ markPageOrigin() {
3033
+ playingEvaluateId = null;
3034
+ },
3035
+ whenFinished: (record, fn) => takeover.whenFinished(record, fn),
3036
+ markPageOriginAtTakeover() {
3037
+ evaluateTag.fromTakeover(null);
3038
+ },
3039
+ });
3040
+
2389
3041
  /**
2390
3042
  * Applies ONE Mixer change to the playing code, immediately. Runs only inside the serial `chain`
2391
3043
  * (or from mixerGesture.flushDirect, itself called from inside it), so the editor cannot change
@@ -2395,40 +3047,29 @@ function sendMixerRecord(code, label) {
2395
3047
  * (the user's typing is never overwritten). A rewrite the mixer refuses is logged with its reason.
2396
3048
  */
2397
3049
  async function applyMixerChangeNow(change) {
2398
- if (!hasEvaluatedSuccessfully) {
2399
- console.error('Moontide: the mixer has nothing to change yet: no code has played.');
2400
- return null;
2401
- }
2402
- if (takeover.pending() !== null) {
2403
- // The waiting change must not be played or recorded by a move; the Mixer says so in its notice.
2404
- console.error('Moontide: the mixer change was not applied: a change is waiting for its bar line.');
2405
- return null;
2406
- }
2407
- if (ws.readyState !== ws.OPEN) {
2408
- console.error('Moontide: the mixer change was not applied: the socket is not open.');
2409
- return null;
2410
- }
2411
- if (!window.acorn || typeof window.acorn.parse !== 'function') {
2412
- console.error('Moontide: window.acorn is not available — cannot change the mixer.');
2413
- return null;
2414
- }
2415
- const activeCode = playingCode();
2416
- if (editor.code !== activeCode) {
2417
- // The authoritative guard against overwriting a hand edit that was not evaluated.
2418
- console.error('Moontide: the mixer change was not applied: the editor holds a hand edit that was not evaluated.');
2419
- // The poll only explains; this is the authoritative check, so it turns the notice on itself.
2420
- mixerUi.setDirty(true);
2421
- if (viewState.view === 'mixer') renderMixer();
2422
- return null;
2423
- }
2424
- let result;
3050
+ // onVersionPlaying clears pendingLaunch on every live tick, but a mixer write never moves a
3051
+ // part's offset: a launch still counting down must survive it (set back after the evaluation).
3052
+ const launchBefore = pendingLaunch;
3053
+ mixerTickInFlight = true;
3054
+ let outcome;
3055
+ let removed;
2425
3056
  try {
2426
- // A bus parameter is written on every track that sends to the bus, and a MIDI control on every track of its
2427
- // port and channel, in one edit list; every other change is one track's.
2428
- const parse = window.acorn.parse;
2429
- if (change.type === 'bus-param') result = writeBus(activeCode, change, { parse });
2430
- else if (change.type === 'midi-control') result = writeMidiControl(activeCode, change, { parse });
2431
- else result = writeTrack(activeCode, change.identity, change, { parse });
3057
+ // A mixer move is not a request to play: never resumeAfterStop(), and no autostart while stopped.
3058
+ outcome = await pageRewrite.apply({
3059
+ autostart: !pageStopped,
3060
+ build(activeCode) {
3061
+ // A bus parameter is written on every track that sends to the bus, a MIDI control on every track of its
3062
+ // port and channel, and a group's mute or solo on every track it reaches, in one edit list; every other change is one track's.
3063
+ const parse = window.acorn.parse;
3064
+ let result;
3065
+ if (change.type === 'bus-param') result = writeBus(activeCode, change, { parse });
3066
+ else if (change.type === 'midi-control') result = writeMidiControl(activeCode, change, { parse });
3067
+ else if (change.type === 'group') result = writeTracks(activeCode, change.identities, { type: change.kind, on: change.on }, { parse });
3068
+ else result = writeTrack(activeCode, change.identity, change, { parse });
3069
+ removed = result.removed;
3070
+ return result;
3071
+ },
3072
+ });
2432
3073
  } catch (err) {
2433
3074
  if (!(err instanceof MixerRewriteError)) {
2434
3075
  console.error('Moontide: the mixer could not read the playing code:', err);
@@ -2438,43 +3079,49 @@ async function applyMixerChangeNow(change) {
2438
3079
  showMixerProblem(err.message);
2439
3080
  if (viewState.view === 'mixer') renderMixer();
2440
3081
  return null;
3082
+ } finally {
3083
+ mixerTickInFlight = false;
2441
3084
  }
2442
- if (result.code === activeCode) {
2443
- return { played: false, code: activeCode };
2444
- }
2445
- // The same edit list changes the text (keeping the selection and scroll) and lights the line.
2446
- editor.editor.dispatch({ changes: result.edits });
2447
- if (editor.code !== result.code) {
2448
- console.error('Moontide: the editor text does not match the mixer rewrite; reverting.');
2449
- editor.setCode(activeCode);
3085
+ if (outcome.status === 'refused') {
3086
+ switch (outcome.reason) {
3087
+ case 'not-played':
3088
+ console.error('Moontide: the mixer has nothing to change yet: no code has played.');
3089
+ break;
3090
+ case 'waiting':
3091
+ // The waiting change must not be played or recorded by a move; the Mixer says so in its notice.
3092
+ console.error('Moontide: the mixer change was not applied: a change is waiting for its bar line.');
3093
+ break;
3094
+ case 'closed':
3095
+ console.error('Moontide: the mixer change was not applied: the socket is not open.');
3096
+ break;
3097
+ case 'no-parser':
3098
+ console.error('Moontide: window.acorn is not available — cannot change the mixer.');
3099
+ break;
3100
+ case 'dirty':
3101
+ // The authoritative guard against overwriting a hand edit that was not evaluated.
3102
+ console.error('Moontide: the mixer change was not applied: the editor holds a hand edit that was not evaluated.');
3103
+ // The poll only explains; this is the authoritative check, so it turns the notice on itself.
3104
+ mixerUi.setDirty(true);
3105
+ if (viewState.view === 'mixer') renderMixer();
3106
+ break;
3107
+ default:
3108
+ console.error('Moontide: the editor text does not match the mixer rewrite; reverting.');
3109
+ }
2450
3110
  return null;
2451
3111
  }
2452
- // onVersionPlaying clears pendingLaunch on every live tick, but a mixer write never moves a
2453
- // part's offset: a launch still counting down must survive it (set back after the evaluation).
2454
- const launchBefore = pendingLaunch;
2455
- mixerTickInFlight = true;
2456
- try {
2457
- // A mixer move is not a request to play: never resumeAfterStop(), and no autostart while stopped.
2458
- await runMixerEvaluate(result.code, !pageStopped);
2459
- } finally {
2460
- mixerTickInFlight = false;
3112
+ if (outcome.status === 'unchanged') {
3113
+ return { played: false, code: outcome.code };
2461
3114
  }
2462
- const evalError = editor.repl.state.evalError;
2463
- if (evalError) {
2464
- // launchPart's own revert-on-failure branch: the old code is still what plays.
2465
- editor.setCode(hasEvaluatedSuccessfully ? committedCode() : '');
2466
- const { errorText } = buildEvalErrorReport(evalError);
2467
- showEvalError(errorText);
3115
+ if (outcome.status === 'failed') {
3116
+ // The old code is still what plays; the shared path already set the editor back and showed the banner.
2468
3117
  return null;
2469
3118
  }
2470
- // A page-originated evaluation has no Claude evaluate id.
2471
- playingEvaluateId = null;
2472
3119
  pendingLaunch = launchBefore;
2473
- updateBypassRecords(change, result.removed);
3120
+ updateBypassRecords(change, removed);
2474
3121
  const playing = editor.repl.state.activeCode;
2475
3122
  // Mirrors src/protocol.ts's mixer-live by hand: get_code returns the code that plays now.
2476
3123
  sendIfOpen(JSON.stringify({ type: 'mixer-live', code: playing }));
2477
- flashMixerLines(result.edits);
3124
+ flashMixerLines(outcome.edits);
2478
3125
  return { played: true, code: playing };
2479
3126
  }
2480
3127
 
@@ -2710,23 +3357,43 @@ function syncWaitingViews() {
2710
3357
  }
2711
3358
  }
2712
3359
 
3360
+ /**
3361
+ * The song structure the Mixer draws groups from, for `code`: the reading made when this version took over when it belongs
3362
+ * to the same text, else a new reading. Code the page cannot read counts as a piece without a song, so the Mixer stays flat.
3363
+ */
3364
+ function mixerSongReading(code) {
3365
+ if (songReading !== null && songReading.code === code) return songReading.reading;
3366
+ if (code === '') return { state: 'none' };
3367
+ try {
3368
+ const parse = window.acorn.parse;
3369
+ return readStructure(code, { parse, tracks: extractTracks(code, { parse }) });
3370
+ } catch (err) {
3371
+ console.error('Moontide: the mixer could not read the song structure:', err);
3372
+ return { state: 'none' };
3373
+ }
3374
+ }
3375
+
2713
3376
  /** Draws the Mixer for the code that is playing now. */
2714
3377
  function renderMixer() {
2715
3378
  checkMixerDirty();
2716
3379
  if (!window.acorn || typeof window.acorn.parse !== 'function') {
2717
3380
  console.error('Moontide: window.acorn is not available — the mixer cannot read the code.');
2718
3381
  mixerUi.render({ status: 'unreadable', strips: [] });
3382
+ lights.setLayout(lightLayout({ status: 'unreadable', strips: [] }, { webMidi: false }));
2719
3383
  syncMixerLit();
2720
3384
  return;
2721
3385
  }
3386
+ const code = hasEvaluatedSuccessfully ? playingCode() : '';
2722
3387
  const model = buildMixerModel({
2723
- code: hasEvaluatedSuccessfully ? playingCode() : '',
3388
+ code,
2724
3389
  parse: window.acorn.parse,
2725
3390
  colorOf: (identity) => trackColors.colorOf(identity),
2726
3391
  bypassRecords: mixerBypassRecords,
2727
3392
  busMemory: mixerBusMemory,
2728
3393
  keepBus: mixerUi.selectedBus(),
2729
3394
  rig: pageRig,
3395
+ song: mixerSongReading(code),
3396
+ folds: songFolds,
2730
3397
  });
2731
3398
  mixerBusMemory = model.busMemory;
2732
3399
  if (model.error) console.error('Moontide: the mixer could not read the playing code:', model.error);
@@ -2735,7 +3402,11 @@ function renderMixer() {
2735
3402
  for (const { identity, effect } of model.staleRecords) mixerBypassRecords = forgetBypass(mixerBypassRecords, identity, effect);
2736
3403
  if (!writeBypassRecords(storage, mixerBypassRecords)) reportStorageUnavailableOnce();
2737
3404
  }
2738
- mixerUi.render(model);
3405
+ const { created } = mixerUi.render(model);
3406
+ // The lights learn which tracks the Mixer shows, which of them sound, and which groups hold them.
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();
2739
3410
  // Every render moves the one light onto the selected strip's block (or clears it): the strip may have left the model.
2740
3411
  syncMixerLit();
2741
3412
  }
@@ -2761,7 +3432,7 @@ let storageUnavailableLogged = false;
2761
3432
  function reportStorageUnavailableOnce() {
2762
3433
  if (storageUnavailableLogged) return;
2763
3434
  storageUnavailableLogged = true;
2764
- console.error('Moontide: browser storage is unavailable; layout, view, motion, History display, Jam instrument, Jam chord tones and change timing will not be remembered.');
3435
+ console.error('Moontide: browser storage is unavailable; layout, view, motion, History display, Jam instrument, Jam chord tones, change timing, the song launch choice and folded song groups will not be remembered.');
2765
3436
  }
2766
3437
 
2767
3438
  const initialPrefs = readViewPrefs(storage);
@@ -2770,6 +3441,14 @@ if (!initialPrefs.available) reportStorageUnavailableOnce();
2770
3441
  const initialBypass = readBypassRecords(storage);
2771
3442
  mixerBypassRecords = initialBypass.records;
2772
3443
  if (!initialBypass.available) reportStorageUnavailableOnce();
3444
+ // The groups of the Song grid that are folded, as group keys. A stored value that cannot be read is reported once and
3445
+ // ignored, so every group starts open.
3446
+ const initialFolds = readSongFolds(storage);
3447
+ if (!initialFolds.available) reportStorageUnavailableOnce();
3448
+ else if (initialFolds.rejected) console.error(SONG_FOLDS_COPY.badStored);
3449
+ let songFolds = new Set(initialFolds.value);
3450
+ // Whether the sentence for a fold that cannot be remembered was already logged in this page session.
3451
+ let notRememberedSaid = false;
2773
3452
  const reduceMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
2774
3453
  let viewState = initialViewState({ prefs: initialPrefs, prefersReducedMotion: reduceMotionQuery.matches });
2775
3454
 
@@ -2779,6 +3458,9 @@ const splitSizes = { a: initialPrefs.splitA ?? SPLIT_DEFAULTS.a, b: initialPrefs
2779
3458
  for (const key of initialPrefs.rejected) {
2780
3459
  console.error(SPLIT_COPY.badStored(key, key === STORAGE_KEYS.splitA ? SPLIT_DEFAULTS.a : SPLIT_DEFAULTS.b));
2781
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);
2782
3464
  const splitStorageKeys = { a: STORAGE_KEYS.splitA, b: STORAGE_KEYS.splitB };
2783
3465
 
2784
3466
  // The border between the editor and the view. The view only draws and listens; this is the one place that applies a size
@@ -2906,6 +3588,9 @@ layoutButtonsEl.addEventListener('click', onViewButtonClick);
2906
3588
  // version started playing (performance.now()), so refreshTimeline can wait out
2907
3589
  // TIMELINE_SETTLE_MS only when it is actually racing a version that just started.
2908
3590
  let timelineGeneration = 0;
3591
+ // The song check and the song reading belong to one version of the music, so they use their own token: only a new
3592
+ // version that plays bumps it, and closing the Timeline or Jam view (which bumps timelineGeneration) leaves them alone.
3593
+ let songVersion = 0;
2909
3594
  let versionPlayingAt = 0;
2910
3595
 
2911
3596
  // The current version's own tracks/code/parser, set once per refreshTimeline() call and read by
@@ -2913,6 +3598,11 @@ let versionPlayingAt = 0;
2913
3598
  let timelineTracks = null;
2914
3599
  let timelineCode = null;
2915
3600
  let timelineParse = null;
3601
+ // The strings of timelineCode with the scope of every track, made on the first note picked on the version and dropped with it.
3602
+ let timelineIndex = null;
3603
+ // Where tokens play, worked out on demand for the version drawn: the evaluated pattern of each track and the answer for each
3604
+ // token, both as promises, dropped with the version.
3605
+ let timelinePlaces = { patterns: new Map(), answers: new Map() };
2916
3606
  // One color per track identity, kept for the whole page session — never reset per version or
2917
3607
  // per Timeline open/close.
2918
3608
  const trackColors = createTrackColors();
@@ -2928,6 +3618,12 @@ let timelineCache = new Map();
2928
3618
  // pageStart -> { generation, promise } for every computePage() call running, so the Timeline, the
2929
3619
  // Jam view and a prefetch never start two queries for the same page.
2930
3620
  let timelinePending = new Map();
3621
+ // True while the lanes had the keyboard focus when the Timeline hid its body to compute again; any other element taking the focus
3622
+ // clears it.
3623
+ let timelineLanesWantFocus = false;
3624
+ document.addEventListener('focusin', (event) => {
3625
+ if (event.target !== timelineLanes) timelineLanesWantFocus = false;
3626
+ });
2931
3627
  // The pageStart currently drawn in #timeline-lanes, or null while nothing has drawn yet.
2932
3628
  let timelineDrawnPageStart = null;
2933
3629
  // What the lanes on screen were drawn from: { pageStart, lanes: [{ track, midi, instrument, events }], version,
@@ -2948,6 +3644,11 @@ let timelineRafId = null;
2948
3644
  // cannot change a handled failure until the version or the page changes, so the 250 ms tick does not
2949
3645
  // retry it four times a second; only a too-slow query is retried, once, after a pause.
2950
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;
2951
3652
  let jamTimer = null;
2952
3653
  let jamRefreshRunning = false;
2953
3654
  let jamRefreshAgain = false;
@@ -3068,6 +3769,12 @@ function resolveAskTarget(event) {
3068
3769
  if (target.closest('#history-view') !== null) return historyUi.askTarget(target) ?? session;
3069
3770
  if (target.closest('#mixer-view') !== null) return mixerUi.askTarget(target) ?? session;
3070
3771
  if (target.closest('#instruments-view') !== null) return instrumentsUi.askTarget(target) ?? session;
3772
+ // The Song grid knows its own headers, cells, group rows and track names, and a cell is a more specific target than the
3773
+ // header-like card around it, so it answers before the generic part match below. Anything else in the grid falls through.
3774
+ if (target.closest('table.song-grid') !== null) {
3775
+ const inGrid = songGridUi.askTarget(target);
3776
+ if (inGrid !== null) return inGrid;
3777
+ }
3071
3778
  const partEl = target.closest('.part-card[data-part], .section-span[data-part], .jam-row[data-part]');
3072
3779
  if (partEl !== null) {
3073
3780
  const owner = partEl.dataset.owner;
@@ -3100,22 +3807,19 @@ function resolveAskTarget(event) {
3100
3807
  return session;
3101
3808
  }
3102
3809
 
3103
- // The one contextmenu listener of the page; no view module has its own. Shift + right-click keeps
3104
- // the browser's own menu, and so does a right-click inside the box (the user can paste) or while the
3105
- // audio-unlock overlay shows (it is a gate to start sound, not part of the page's work). The right-click
3106
- // never evaluates, records or changes the editor's text, selection or scroll.
3107
- document.addEventListener('contextmenu', (event) => {
3108
- if (event.shiftKey || !overlay.hidden || askUi.contains(event.target)) return;
3109
- event.preventDefault();
3110
- // A right-click on the failed-change banner (or on its own popover) offers the banner's actions.
3111
- if (event.target instanceof Node && (problemUi.contains(event.target) || problemUi.menuContains(event.target))) {
3112
- problemUi.openMenu({ x: event.clientX, y: event.clientY });
3113
- return;
3114
- }
3810
+ /**
3811
+ * Opens the Ask box for what `describeTarget()` finds, at `point`: the one place both the right-click and the grid's
3812
+ * keyboard route go through. A target that cannot be resolved or described is a bug: it is said on the error line and in the
3813
+ * console, and the box opens on the whole session. A descriptor with a `prefill` (a fixed variation whose notes do not fit)
3814
+ * puts that request in the field; an existing draft is kept and the request follows it. Nothing is sent, evaluated or
3815
+ * recorded here, and an open cell list is closed first. Gives false when the target could not be described (the box then holds the
3816
+ * whole session and no request), true otherwise.
3817
+ */
3818
+ function openAskFor(describeTarget, point) {
3115
3819
  let descriptor;
3116
3820
  let readable = true;
3117
3821
  try {
3118
- descriptor = resolveAskTarget(event);
3822
+ descriptor = describeTarget();
3119
3823
  describeContext(descriptor);
3120
3824
  } catch (err) {
3121
3825
  // A view could not resolve or describe what was right-clicked: say so on the error line and in the
@@ -3127,27 +3831,208 @@ document.addEventListener('contextmenu', (event) => {
3127
3831
  }
3128
3832
  // This context reads fine: a stale "could not read" line would contradict the chip.
3129
3833
  if (readable && errorLineIsAskContext) clearEvalError();
3130
- askUi.open(descriptor, { x: event.clientX, y: event.clientY });
3131
- });
3834
+ if (songGridUi.isListOpen()) songGridUi.closeList();
3835
+ if (typeof descriptor.prefill === 'string') askUi.open(descriptor, point, { prefill: descriptor.prefill });
3836
+ else askUi.open(descriptor, point);
3837
+ return readable;
3838
+ }
3132
3839
 
3133
- // A right press outside the open box must not move focus out of its field: the contextmenu that follows
3134
- // then moves the box and swaps its chip while the text and the caret stay. Shift keeps the browser's own
3135
- // behavior (and its menu), so it is left alone.
3136
- document.addEventListener('mousedown', (event) => {
3137
- if (event.button !== 2 || event.shiftKey || !askUi.isOpen()) return;
3138
- if (event.target instanceof Node && askUi.contains(event.target)) return;
3139
- event.preventDefault();
3140
- }, true);
3840
+ /** The gesture field of a request for a change of the Timeline's note panel: a pitch change, a rest or a velocity step. */
3841
+ function timelineGestureOf(change) {
3842
+ switch (change.kind) {
3843
+ case 'pitch':
3844
+ return { kind: 'pitch', semitones: change.semitones };
3845
+ case 'rest':
3846
+ return { kind: 'rest' };
3847
+ case 'velocity':
3848
+ return { kind: 'velocity', direction: change.steps > 0 ? 'louder' : 'softer' };
3849
+ default:
3850
+ throw new Error(`Moontide: unknown kind of Timeline change ${String(change.kind)}`);
3851
+ }
3852
+ }
3141
3853
 
3142
- // A Space keyup never clicks a focused button when the History path handled its keydown.
3143
- document.addEventListener('keyup', (event) => {
3144
- historyUi.handleKeyup(event);
3145
- });
3854
+ /**
3855
+ * Opens the Ask box for one note of the Timeline, at `point`: the lane context with the moment taken where the note starts (so the
3856
+ * note is among the moment's notes and the cycle of the text and of the moment are one number). With a `gesture` the box also
3857
+ * holds the ready-made request for it and the gesture rides on the message; without one the field stays as it is. Nothing is
3858
+ * written, evaluated or sent: only the person's Enter in the box sends. Gives false when nothing opened (the audio-unlock overlay
3859
+ * shows, or the note could not be described), so the panel does not say a request is ready.
3860
+ */
3861
+ function askAboutTimelineNote(info, gesture, point) {
3862
+ if (!overlay.hidden) return false;
3863
+ return openAskFor(() => {
3864
+ if (timelineDrawn === null) throw new Error('Moontide: a note of the Timeline was asked about while no lanes are drawn.');
3865
+ const lane = timelineDrawn.lanes[info.laneIndex];
3866
+ const event = lane.events[info.eventIndex];
3867
+ const moment = momentAt({
3868
+ pageStart: timelineDrawn.pageStart,
3869
+ fraction: (event.begin - timelineDrawn.pageStart) / PAGE_CYCLES,
3870
+ lanes: timelineDrawn.lanes,
3871
+ version: timelineDrawn.version,
3872
+ focusTrack: lane.track,
3873
+ });
3874
+ const descriptor = { kind: 'lane', track: lane.track, cycle: moment.cycle, moment };
3875
+ if (gesture === undefined) return descriptor;
3876
+ return {
3877
+ ...descriptor,
3878
+ gesture,
3879
+ prefill: timelineEditPrefill({ ...gesture, note: info.noteText, cycle: moment.cycle, beat: moment.beat, track: lane.track }),
3880
+ };
3881
+ }, point);
3882
+ }
3146
3883
 
3147
- // Set once renderViews has run at least once, so the very first render reflects the restored
3148
- // state plainly (no fade-in on load) instead of treating "no prior state" as a close-to-open
3149
- // transition. Read/written only by renderViews below.
3150
- let previousViewState = null;
3884
+ /**
3885
+ * Opens the Ask box for a lane of the Timeline that has no note on the page, at `point`: the lane context with the moment taken at
3886
+ * the start of `cycle`, as a right-click there gives it. The field stays as it is. Nothing is written, evaluated or sent: only the
3887
+ * person's Enter in the box sends. Gives false when nothing opened.
3888
+ */
3889
+ function askAboutTimelineLane(laneIndex, cycle, point) {
3890
+ if (!overlay.hidden) return false;
3891
+ return openAskFor(() => {
3892
+ if (timelineDrawn === null) throw new Error('Moontide: a lane of the Timeline was asked about while no lanes are drawn.');
3893
+ const lane = timelineDrawn.lanes[laneIndex];
3894
+ if (lane === undefined) throw new Error(`Moontide: the Timeline draws no lane ${laneIndex} to ask about.`);
3895
+ const moment = momentAt({
3896
+ pageStart: timelineDrawn.pageStart,
3897
+ fraction: (cycle - timelineDrawn.pageStart) / PAGE_CYCLES,
3898
+ lanes: timelineDrawn.lanes,
3899
+ version: timelineDrawn.version,
3900
+ focusTrack: lane.track,
3901
+ });
3902
+ return { kind: 'lane', track: lane.track, cycle: moment.cycle, moment };
3903
+ }, point);
3904
+ }
3905
+
3906
+ /**
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 `{}`.
3909
+ */
3910
+ function timelineRangeSectionList(from, to) {
3911
+ const reading = timelineSongReading();
3912
+ if (reading.state !== 'read') return {};
3913
+ return { sections: sectionsCrossed(reading, from, to) };
3914
+ }
3915
+
3916
+ /** The lane of a range of the lanes drawn, or a loud error when the range names none. */
3917
+ function timelineRangeLane(selection) {
3918
+ if (timelineDrawn === null) throw new Error('Moontide: a range of the Timeline was asked about while no lanes are drawn.');
3919
+ const lane = timelineDrawn.lanes[selection.laneIndex];
3920
+ if (lane === undefined) throw new Error(`Moontide: the range names lane ${selection.laneIndex}, which is not drawn.`);
3921
+ return lane;
3922
+ }
3923
+
3924
+ /**
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.
3928
+ */
3929
+ function describeTimelineRange(selection) {
3930
+ const lane = timelineRangeLane(selection);
3931
+ const { notes, more } = rangeNotes(lane, selection.from, selection.to);
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;
3940
+ }
3941
+
3942
+ /**
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.
3946
+ */
3947
+ function timelineRangeDescriptor(selection) {
3948
+ const lane = timelineRangeLane(selection);
3949
+ const { notes, more } = rangeNotes(lane, selection.from, selection.to);
3950
+ const descriptor = {
3951
+ kind: 'range',
3952
+ track: lane.track,
3953
+ from: selection.from,
3954
+ to: selection.to,
3955
+ ...timelineRangeSectionList(selection.from, selection.to),
3956
+ gesture: { kind: 'range', version: timelineDrawn.version, notes, more },
3957
+ };
3958
+ if (selection.cut !== null) descriptor.cut = selection.cut;
3959
+ return descriptor;
3960
+ }
3961
+
3962
+ /**
3963
+ * Opens the Ask box for a range of cycles of the Timeline, at `point`, with an empty field (a draft in it is kept). Nothing is
3964
+ * written, evaluated or sent: only the person's Enter in the box sends. Gives false when nothing opened.
3965
+ */
3966
+ function askAboutTimelineRange(selection, point) {
3967
+ if (!overlay.hidden) return false;
3968
+ return openAskFor(() => timelineRangeDescriptor(selection), point);
3969
+ }
3970
+
3971
+ // The Menu key and Shift+F10 on a control of the Song grid: the same box as a right-click, at the centre of the control. The
3972
+ // page's contextmenu listener leaves a Shift press to the browser, so the grid reports the key itself. The audio-unlock overlay
3973
+ // is a gate to start sound, not part of the page's work, so the key does nothing while it shows.
3974
+ songGridHandlers.ask = (descriptor, point) => {
3975
+ if (!overlay.hidden) return;
3976
+ openAskFor(() => descriptor, point);
3977
+ };
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
+
3998
+ // The one contextmenu listener of the page; no view module has its own. Shift + right-click keeps
3999
+ // the browser's own menu, and so does a right-click inside the box (the user can paste) or while the
4000
+ // audio-unlock overlay shows (it is a gate to start sound, not part of the page's work). The right-click
4001
+ // never evaluates, records or changes the editor's text, selection or scroll.
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
+ }
4008
+ if (event.shiftKey || !overlay.hidden || askUi.contains(event.target)) return;
4009
+ event.preventDefault();
4010
+ // A right-click on the failed-change banner (or on its own popover) offers the banner's actions.
4011
+ if (event.target instanceof Node && (problemUi.contains(event.target) || problemUi.menuContains(event.target))) {
4012
+ problemUi.openMenu({ x: event.clientX, y: event.clientY });
4013
+ return;
4014
+ }
4015
+ openAskFor(() => resolveAskTarget(event), { x: event.clientX, y: event.clientY });
4016
+ });
4017
+
4018
+ // A right press outside the open box must not move focus out of its field: the contextmenu that follows
4019
+ // then moves the box and swaps its chip while the text and the caret stay. Shift keeps the browser's own
4020
+ // behavior (and its menu), so it is left alone.
4021
+ document.addEventListener('mousedown', (event) => {
4022
+ if (event.button !== 2 || event.shiftKey || !askUi.isOpen()) return;
4023
+ if (event.target instanceof Node && askUi.contains(event.target)) return;
4024
+ event.preventDefault();
4025
+ }, true);
4026
+
4027
+ // A Space keyup never clicks a focused button when the History path handled its keydown.
4028
+ document.addEventListener('keyup', (event) => {
4029
+ historyUi.handleKeyup(event);
4030
+ });
4031
+
4032
+ // Set once renderViews has run at least once, so the very first render reflects the restored
4033
+ // state plainly (no fade-in on load) instead of treating "no prior state" as a close-to-open
4034
+ // transition. Read/written only by renderViews below.
4035
+ let previousViewState = null;
3151
4036
 
3152
4037
  /**
3153
4038
  * The border for the current state: its orientation, the stored size of the layout just switched to, and whether
@@ -3266,12 +4151,16 @@ function renderViews() {
3266
4151
  // which starts the loop itself; a layout or motion change while it stays open must never
3267
4152
  // restart it (the loop already tracks the live clock on its own, every frame — see
3268
4153
  // timelineTick below). Parts has no async computation, so opening it starts the loop directly.
4154
+ // A closed Timeline hands no edit over: steps and edits that wait are dropped, and the panel says so when it shows again.
4155
+ if (wasTimelineOpen && !isTimelineOpen) timelineEditUi.close();
3269
4156
  if (isTimelineOpen && !wasTimelineOpen) {
3270
4157
  void refreshTimeline().catch((err) => {
3271
4158
  console.error('Moontide: failed to refresh the Timeline:', err);
3272
4159
  renderTimelineMessage(TIMELINE_COPY.failed, 'failed');
3273
4160
  });
3274
4161
  }
4162
+ // A refusal ends when the view closes.
4163
+ if (wasPartsOpen && !isPartsOpen) gridRefused = null;
3275
4164
  if (isPartsOpen) {
3276
4165
  renderParts();
3277
4166
  startTimelineLoop();
@@ -3306,17 +4195,239 @@ function renderViews() {
3306
4195
  // No timer: the view follows the events that already redraw the MIDI indicator (see renderMidiIndicator).
3307
4196
  if (isInstrumentsOpen) renderInstruments();
3308
4197
  else instrumentsUi.closed();
4198
+ // Opening the Mixer, closing it and switching motion all change whether the lights run, and how.
4199
+ syncLights();
4200
+ }
4201
+
4202
+ /**
4203
+ * Starts, stops or restarts the lights to match the page: they run only while the Mixer is open, the tab is visible and sound
4204
+ * is playing. Starting (and a switch of motion while running) forgets every note and takes every light to unlit, so lights
4205
+ * follow only the notes that come after; stopping does the same and ends the loop.
4206
+ */
4207
+ function syncLights() {
4208
+ if (!lightsViewReady) return;
4209
+ // A failure ends the lights only until the music stops: the next Start tries again.
4210
+ if (currentSoundState !== 'playing') lightsBroken = false;
4211
+ const wanted = !lightsBroken && viewState.view === 'mixer' && document.visibilityState === 'visible' && currentSoundState === 'playing';
4212
+ if (!wanted) {
4213
+ stopLights();
4214
+ return;
4215
+ }
4216
+ if (lightsActive && lightsMotion === viewState.motion) return;
4217
+ lights.reset();
4218
+ mixerUi.clearLights();
4219
+ lightsMotion = viewState.motion;
4220
+ lightsActive = true;
4221
+ lightsPaused = false;
4222
+ if (lightsRafId === null) lightsRafId = requestAnimationFrame(lightsTick);
4223
+ }
4224
+
4225
+ /** Ends the loop and takes every light to unlit. Does nothing to the document when the lights were not running. */
4226
+ function stopLights() {
4227
+ if (lightsRafId !== null) {
4228
+ cancelAnimationFrame(lightsRafId);
4229
+ lightsRafId = null;
4230
+ }
4231
+ if (!lightsActive) return;
4232
+ lightsActive = false;
4233
+ lights.reset();
4234
+ mixerUi.clearLights();
4235
+ }
4236
+
4237
+ /** One animation frame of the lights: the notes heard by now light their tracks, and only the lights that changed are written. */
4238
+ function lightsTick(ts) {
4239
+ lightsRafId = null;
4240
+ if (!lightsActive) return;
4241
+ try {
4242
+ const cps = editor.repl.scheduler.cps;
4243
+ if (!usableTempo(cps)) {
4244
+ // The tempo is 0 (or not a number) for now: draw nothing, take lit lights dark once and keep the loop for when it returns.
4245
+ if (!lightsPaused) {
4246
+ lightsPaused = true;
4247
+ lights.reset();
4248
+ mixerUi.clearLights();
4249
+ }
4250
+ lightsRafId = requestAnimationFrame(lightsTick);
4251
+ return;
4252
+ }
4253
+ if (lightsPaused) {
4254
+ // The tempo is back: notes queued while it was 0 are not worth a blink.
4255
+ lightsPaused = false;
4256
+ lights.reset();
4257
+ }
4258
+ // One read of the audio context's output timestamp serves every note of this frame.
4259
+ const audio = globalThis.getAudioContext();
4260
+ const pair = readOutputPair(audio);
4261
+ if (pair === null && !lightsPairWarned) {
4262
+ lightsPairWarned = true;
4263
+ console.warn("Moontide: this browser has no getOutputTimestamp, so the Mixer lights time a note from the audio clock and the context's reported latencies.");
4264
+ }
4265
+ const toPerf = heardMapper({
4266
+ pair,
4267
+ currentTime: audio.currentTime,
4268
+ baseLatency: audio.baseLatency,
4269
+ outputLatency: audio.outputLatency,
4270
+ perfNow: performance.now(),
4271
+ });
4272
+ const changes = lights.frame(ts, { motion: lightsMotion, cps, toPerf });
4273
+ if (changes.length > 0) mixerUi.setLights(changes, lightsMotion);
4274
+ } catch (err) {
4275
+ // Once: the lights end here, and the music is not touched.
4276
+ lightsBroken = true;
4277
+ console.error('Moontide: the Mixer lights stopped:', err);
4278
+ stopLights();
4279
+ return;
4280
+ }
4281
+ lightsRafId = requestAnimationFrame(lightsTick);
3309
4282
  }
3310
4283
 
4284
+ // A hidden tab draws nothing and drops its notes; coming back, every light starts unlit.
4285
+ document.addEventListener('visibilitychange', () => syncLights());
4286
+
3311
4287
  // The first render never came from a keydown (nothing changed relative to a prior viewState),
3312
4288
  // so it must be triggered explicitly — sets the initial data-layout/data-view/data-motion
3313
4289
  // attributes and opens the Timeline if a view was remembered from a previous session.
4290
+ // The lights may touch the view state and the Mixer from here on: both exist.
4291
+ lightsViewReady = true;
3314
4292
  renderViews();
3315
4293
 
4294
+ /**
4295
+ * The lines #song-notes shows for a reading: none unless the song is read, else the helper line (when the helper is not the
4296
+ * canonical text) and then the line of the check when this version has one. A line is a string, or `{ text, kind }`.
4297
+ */
4298
+ function songNoteEntries(reading) {
4299
+ if (reading.state !== 'read') return [];
4300
+ const entries = [...songNoteLines(reading)];
4301
+ if (songCheck !== null && songCheck.generation === songVersion && songCheck.entry !== null) entries.push(songCheck.entry);
4302
+ return entries;
4303
+ }
4304
+
4305
+ /** The line a finished check gives, or null when what plays agrees with the declaration. */
4306
+ function songCheckEntry(answer) {
4307
+ if (answer.state === 'differs') return differsLine({ ...answer.first, more: answer.more });
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' };
4310
+ return null;
4311
+ }
4312
+
4313
+ /**
4314
+ * Checks, after the version has settled, what its tracks really play at the start of every section entry against what the
4315
+ * song declares, without blocking the music and whether or not the Parts view is open. The answer of an overtaken version is
4316
+ * dropped. A difference or a failed check is shown in #song-notes and sent to Claude with the helper line of the version;
4317
+ * agreement says nothing. `evaluateId` is the evaluate id captured when the version started.
4318
+ */
4319
+ async function runSongCheck(generation, code, reading, evaluateId) {
4320
+ await sleep(TIMELINE_SETTLE_MS);
4321
+ if (songVersion !== generation) return;
4322
+ let answer;
4323
+ try {
4324
+ const parse = window.acorn.parse;
4325
+ answer = await checkSections({
4326
+ reading,
4327
+ starts: sectionStarts(reading),
4328
+ playedPattern: (track) => evaluateSolo(soloSource(code, track.index, { parse })),
4329
+ declared: () => evaluateDeclared(expectedSource(code, reading, { parse })),
4330
+ budgetMs: TIMELINE_BUDGET_MS,
4331
+ clock: () => performance.now(),
4332
+ yieldToPage: () => new Promise((resolve) => setTimeout(resolve, 0)),
4333
+ isCancelled: () => songVersion !== generation,
4334
+ });
4335
+ } catch (err) {
4336
+ answer = { state: 'failed', message: err instanceof Error ? err.message : String(err), ms: 0 };
4337
+ }
4338
+ if (answer.state === 'cancelled' || songVersion !== generation) return;
4339
+ if (answer.state === 'failed') console.error('Moontide: the check of the song declaration failed:', answer.message);
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).`);
4341
+ const entry = songCheckEntry(answer);
4342
+ songCheck = { generation, entry };
4343
+ songNotesEl.dataset.check = answer.state === 'agrees' || answer.state === 'differs' || answer.state === 'too-slow' ? answer.state : 'failed';
4344
+ songNotesEl.dataset.checkMs = String(Math.round(answer.ms));
4345
+ songUi.showNotes(songNoteEntries(reading));
4346
+ if (entry !== null) {
4347
+ sendStructureNote([...songNoteLines(reading), typeof entry === 'string' ? entry : entry.text], evaluateId);
4348
+ }
4349
+ }
4350
+
4351
+ /**
4352
+ * Asks, after the song check of the same version has answered (so the two queries never run at once), which pitch classes
4353
+ * each fixed variation plays in each of its first cycles, and keeps the answer for the grid. It reads the code's own
4354
+ * constants and never calls its play() helper; a variation it cannot read, or reads too slowly, is a visible reason in the
4355
+ * grid. The answer of an overtaken version is dropped. It changes nothing in the code or the music.
4356
+ */
4357
+ async function runSongFit(generation, code, reading) {
4358
+ if (songVersion !== generation) return;
4359
+ const isCancelled = () => songVersion !== generation;
4360
+ const pairs = fitPairs(reading);
4361
+ let answer;
4362
+ try {
4363
+ const patterns = await evaluateFit(fitSource(code, reading, { parse: window.acorn.parse }));
4364
+ if (isCancelled()) return;
4365
+ answer = await collectFit({
4366
+ patterns,
4367
+ pairs,
4368
+ cycles: Math.max(...reading.sections.map((section) => section.length)),
4369
+ budgetMs: TIMELINE_BUDGET_MS,
4370
+ clock: () => performance.now(),
4371
+ yieldToPage: () => new Promise((resolve) => setTimeout(resolve, 0)),
4372
+ isCancelled,
4373
+ });
4374
+ } catch (err) {
4375
+ console.error('Moontide: the page could not read the notes of the fixed variations:', err);
4376
+ answer = { state: 'ready', byPair: new Map(pairs.map((pair) => [pairKey(pair.track, pair.key), { reason: 'not-read' }])), failures: [] };
4377
+ }
4378
+ if (answer.state === 'cancelled' || isCancelled()) return;
4379
+ for (const failure of answer.failures) {
4380
+ console.error(`Moontide: the page could not read the notes of variation '${String(failure.key)}' of track '${String(failure.track)}': ${failure.message}`);
4381
+ }
4382
+ songFit = { generation, reading, answer: answer.byPair };
4383
+ // The grid is drawn again once with the answer, when the Parts view shows the grid of this very reading.
4384
+ if (viewState.view === 'parts' && songGridReading === reading) drawSongGrid();
4385
+ }
4386
+
3316
4387
  /** Called from trackEvalSuccess right where hasEvaluatedSuccessfully becomes true —
3317
4388
  * every new version that plays bumps the generation and, if the Timeline is open, recomputes it. */
3318
4389
  function onVersionPlaying() {
3319
4390
  timelineGeneration++;
4391
+ songVersion++;
4392
+ // The song structure of the version that plays, read once; a code the parser cannot read counts as a piece without one.
4393
+ songReading = null;
4394
+ if (window.acorn && typeof window.acorn.parse === 'function') {
4395
+ const code = playingCode();
4396
+ try {
4397
+ const parse = window.acorn.parse;
4398
+ songReading = { generation: songVersion, code, reading: readStructure(code, { parse, tracks: extractTracks(code, { parse }) }) };
4399
+ } catch (err) {
4400
+ console.error('Moontide: failed to read the song structure of the playing code:', err);
4401
+ }
4402
+ }
4403
+ const reading = songReading === null ? { state: 'none' } : songReading.reading;
4404
+ // The Parts view is called Song while the code declares a song (readable or not), and Parts otherwise.
4405
+ setViewTitle('parts', reading.state === 'none' ? VIEW_TITLES.parts : SONG_COPY.viewTitle);
4406
+ if (reading.state !== 'none' || structureNoteSent) sendStructureNote(songNoteLines(reading));
4407
+ // The old answer of the check goes with the old version; the new one is shown when it arrives.
4408
+ songCheck = null;
4409
+ if (reading.state === 'read') {
4410
+ songNotesEl.dataset.check = 'pending';
4411
+ delete songNotesEl.dataset.checkMs;
4412
+ } else {
4413
+ delete songNotesEl.dataset.check;
4414
+ delete songNotesEl.dataset.checkMs;
4415
+ }
4416
+ songUi.showNotes(songNoteEntries(reading));
4417
+ songFit = null;
4418
+ if (reading.state === 'read') {
4419
+ const generation = songVersion;
4420
+ const { code } = songReading;
4421
+ // The fit words come after the check of the declaration has answered, so the two queries never run at once.
4422
+ const checked = runSongCheck(songVersion, songReading.code, reading, playingEvaluateId).catch((err) => {
4423
+ console.error('Moontide: the check of the song declaration failed:', err);
4424
+ });
4425
+ if (fitPairs(reading).length > 0) {
4426
+ void checked.then(() => runSongFit(generation, code, reading)).catch((err) => {
4427
+ console.error('Moontide: the fit of the fixed variations failed:', err);
4428
+ });
4429
+ }
4430
+ }
3320
4431
  // The shared reading belongs to one version: a new one empties the preparation, the page cache
3321
4432
  // and the pages in flight (the Jam view drops its reading and shows the reading message until the new one is built).
3322
4433
  readingVersion++;
@@ -3324,10 +4435,15 @@ function onVersionPlaying() {
3324
4435
  timelineTracks = null;
3325
4436
  timelineCode = null;
3326
4437
  timelineParse = null;
4438
+ timelineIndex = null;
4439
+ timelinePlaces = { patterns: new Map(), answers: new Map() };
3327
4440
  timelineCache = new Map();
3328
4441
  timelinePending = new Map();
3329
4442
  versionPlayingAt = performance.now();
3330
4443
  pendingLaunch = null;
4444
+ // A new version can move the sections a countdown or a refusal counted on.
4445
+ gridAlready = null;
4446
+ gridRefused = null;
3331
4447
  if (viewState.view === 'timeline') {
3332
4448
  void refreshTimeline().catch((err) => {
3333
4449
  console.error('Moontide: failed to refresh the Timeline:', err);
@@ -3356,6 +4472,9 @@ function onVersionPlaying() {
3356
4472
  * playhead body — the shared shape for "nothing playing", "computing" and "failed" (never a
3357
4473
  * partial lane draw). */
3358
4474
  function renderTimelineMessage(text, state) {
4475
+ // Hiding the body takes the keyboard focus away from the lanes; the lanes get it back when they are drawn again, so a person
4476
+ // who edits a note with the keys can go on with the keys.
4477
+ if (document.activeElement === timelineLanes) timelineLanesWantFocus = true;
3359
4478
  timelineView.dataset.state = state;
3360
4479
  delete timelineView.dataset.computeMs;
3361
4480
  delete timelineView.dataset.longestSliceMs;
@@ -3366,6 +4485,7 @@ function renderTimelineMessage(text, state) {
3366
4485
  timelineLanes.replaceChildren();
3367
4486
  timelineRuler.replaceChildren();
3368
4487
  timelineSections.replaceChildren();
4488
+ timelineEditUi.onRedraw(null);
3369
4489
  }
3370
4490
 
3371
4491
  /** Builds the ruler's labeled cycle-number marks (every 4 cycles) for the page starting at
@@ -3450,10 +4570,8 @@ function renderSections(pageStart) {
3450
4570
  * `rect.event` per event, positioned/sized in cycle units against the 32-cycle page. Never
3451
4571
  * builds a node from an HTML string (track/part names can be Claude- or user-authored text). */
3452
4572
  const SVG_NS = 'http://www.w3.org/2000/svg';
3453
- // Fraction of a lane's own SVG height (viewBox 0 0 32 1) a note mark occupies — Claude's
3454
- // choice; a tick (no pitch)
3455
- // always spans the full lane height instead.
3456
- const NOTE_MARK_HEIGHT = 0.14;
4573
+ // A note mark occupies NOTE_MARK_HEIGHT of a lane's own SVG height (viewBox 0 0 32 1), the share page/timeline-gesture.js
4574
+ // also uses to find the note under a press; a tick (no pitch) always spans the full lane height instead.
3457
4575
 
3458
4576
  /** The MIDI tracks of the code the lanes were read from: `tags`, identity -> midiTag (the same formatter as
3459
4577
  * the Mixer strip, so the two views always agree), `midi`, the identities of every MIDI track even when its
@@ -3551,13 +4669,20 @@ function renderLanes(lanes, pageStart, tracks) {
3551
4669
  const known = drumUse !== undefined && drumUse.use.state === 'known' ? drumUse.use.map : null;
3552
4670
  const plan = known === null ? null : drumLane(lane.events, pageStart, known);
3553
4671
  // What a moment reads: the lane as shown (the name equals the right-click's lane context).
3554
- drawnLanes.push({
4672
+ const drawnLane = {
3555
4673
  track: track.displayName ?? track.name,
3556
4674
  midi: midiIdentities.has(track.identity),
3557
4675
  instrument: tag === undefined ? null : tag.text,
3558
4676
  events: lane.events,
3559
4677
  drumMap: known === null ? null : { name: known.name, byNote: new Map(known.notes.map((entry) => [entry.note, entry.name])) },
3560
- });
4678
+ // What the editable Timeline needs beside what a moment reads: the track the lane belongs to, the shapes the lane is drawn
4679
+ // from (one for each event, in the order of the events) and the number of drum rows (null for a lane that is not drums).
4680
+ index: track.index,
4681
+ identity: track.identity,
4682
+ shapes: plan === null ? laneShapes(lane.events, pageStart) : plan.shapes,
4683
+ rowCount: plan === null ? null : plan.rows.length,
4684
+ };
4685
+ drawnLanes.push(drawnLane);
3561
4686
 
3562
4687
  const svg = document.createElementNS(SVG_NS, 'svg');
3563
4688
  svg.setAttribute('class', 'lane-events');
@@ -3581,7 +4706,7 @@ function renderLanes(lanes, pageStart, tracks) {
3581
4706
  svg.appendChild(rect);
3582
4707
  }
3583
4708
  }
3584
- for (const shape of plan === null ? laneShapes(lane.events, pageStart) : []) {
4709
+ for (const shape of plan === null ? drawnLane.shapes : []) {
3585
4710
  const rect = document.createElementNS(SVG_NS, 'rect');
3586
4711
  rect.setAttribute('class', 'event');
3587
4712
  rect.setAttribute('data-kind', shape.kind);
@@ -3655,6 +4780,7 @@ function renderLanes(lanes, pageStart, tracks) {
3655
4780
  timelineLanes.dataset.pageStart = String(pageStart);
3656
4781
  timelineDrawn = { pageStart, lanes: drawnLanes, version: currentVersionId, generation: timelineGeneration };
3657
4782
  timelineLanes.replaceChildren(...laneNodes);
4783
+ timelineEditUi.onRedraw(timelineDrawn);
3658
4784
  }
3659
4785
 
3660
4786
  /** Applies a computeTimeline() result: draws the lanes on 'ok'; shows the too-slow or failed
@@ -3670,6 +4796,10 @@ function renderTimelineResult(result, pageStart, tracks) {
3670
4796
  renderRuler(pageStart);
3671
4797
  renderSections(pageStart);
3672
4798
  renderLanes(result.lanes, pageStart, tracks);
4799
+ if (timelineLanesWantFocus) {
4800
+ timelineLanesWantFocus = false;
4801
+ timelineLanes.focus({ preventScroll: true });
4802
+ }
3673
4803
  return;
3674
4804
  }
3675
4805
  if (result.state === 'too-slow') {
@@ -3821,6 +4951,7 @@ function timelineTick() {
3821
4951
  }
3822
4952
  if (viewState.view === 'parts') {
3823
4953
  updatePartsLive();
4954
+ updateSongGridLive();
3824
4955
  }
3825
4956
  timelineRafId = requestAnimationFrame(timelineTick);
3826
4957
  }
@@ -4087,13 +5218,37 @@ function renderJam() {
4087
5218
  }
4088
5219
  jamTempoErrorLogged = false; // a later bad tempo is said again
4089
5220
  try {
5221
+ // A piece with sections shows the harmony of the section that plays (the first one while stopped, what Play starts
5222
+ // with); any other piece is drawn as it always was.
5223
+ const options = {};
5224
+ if (songReading !== null && songReading.generation === songVersion && songReading.reading.state === 'read') {
5225
+ const song = songReading.reading;
5226
+ const index = playing ? playingSection(song, cycle).index : 0;
5227
+ options.declaredOverride = sectionDeclared(song.sections[index], jamReading.reading.declared ?? { state: 'none' });
5228
+ options.section = quoted(song.sections[index].name);
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 });
4090
5236
  jamUi.render(
4091
5237
  buildJamModel({
4092
- position: readingAt(jamReading.reading, { cycle, cps, playing }),
5238
+ position,
4093
5239
  chordTones: viewState.jamChordTones === 'on',
5240
+ ...options,
4094
5241
  }),
4095
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
+ }
4096
5250
  } catch (err) {
5251
+ jamShownScale = null;
4097
5252
  if (jamDrawErrorLoggedFor !== jamReading) {
4098
5253
  jamDrawErrorLoggedFor = jamReading;
4099
5254
  console.error('Moontide: the Jam view could not draw the reading:', err);
@@ -4102,6 +5257,22 @@ function renderJam() {
4102
5257
  }
4103
5258
  }
4104
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
+
4105
5276
  /** The Jam view's 250 ms tick: asks for a rebuild when the reading is missing, belongs to an older
4106
5277
  * version or does not cover the playing page (also backwards, after Stop), otherwise only looks
4107
5278
  * the position up. Skips while the page is hidden and stops with the view. */
@@ -4226,6 +5397,8 @@ function buildPartsRow(arrangement, tracks, showLabel) {
4226
5397
  function renderParts() {
4227
5398
  if (!window.acorn || typeof window.acorn.parse !== 'function') {
4228
5399
  console.error('Moontide: window.acorn is not available — the pinned parser script did not load.');
5400
+ songUi.clearNotes();
5401
+ hideSongGrid();
4229
5402
  showPartsMessage(PARTS_COPY.cannotRead);
4230
5403
  askUi.setPartsAvailable(false);
4231
5404
  return;
@@ -4233,18 +5406,46 @@ function renderParts() {
4233
5406
  const parse = window.acorn.parse;
4234
5407
  const code = playingCode();
4235
5408
  let tracks;
5409
+ let reading;
4236
5410
  let arrangements;
4237
5411
  let problems;
4238
5412
  let callCount;
4239
5413
  try {
4240
5414
  tracks = extractTracks(code, { parse });
4241
- ({ arrangements, problems, callCount } = readArrangements(code, { parse, tracks }));
5415
+ // The reading made when this version took over, when it belongs to this code; else read it now.
5416
+ reading =
5417
+ songReading !== null && songReading.generation === songVersion && songReading.code === code
5418
+ ? songReading.reading
5419
+ : readStructure(code, { parse, tracks });
5420
+ if (reading.state === 'none') ({ arrangements, problems, callCount } = readArrangements(code, { parse, tracks }));
4242
5421
  } catch (err) {
4243
5422
  console.error('Moontide: failed to parse the playing code for the Parts view:', err);
5423
+ songUi.clearNotes();
5424
+ hideSongGrid();
4244
5425
  showPartsMessage(PARTS_COPY.cannotRead);
4245
5426
  askUi.setPartsAvailable(false);
4246
5427
  return;
4247
5428
  }
5429
+ if (reading.state === 'read') {
5430
+ // A song takes the whole view: its grid replaces any arrange() rows, and the cards they had are gone.
5431
+ partsMessage.hidden = true;
5432
+ partsCardRefs = [];
5433
+ songGridReading = reading;
5434
+ drawSongGrid();
5435
+ songUi.showNotes(songNoteEntries(reading));
5436
+ askUi.setPartsAvailable(false);
5437
+ return;
5438
+ }
5439
+ if (reading.state === 'unreadable') {
5440
+ // One line with the reason, the same sentence Claude gets; no grid.
5441
+ songUi.clearNotes();
5442
+ hideSongGrid();
5443
+ showPartsMessage(songMessageText(reading));
5444
+ askUi.setPartsAvailable(false);
5445
+ return;
5446
+ }
5447
+ songUi.clearNotes();
5448
+ hideSongGrid();
4248
5449
  if (arrangements.length === 0) {
4249
5450
  if (problems.length === 0) {
4250
5451
  showPartsMessage(PARTS_COPY.none);
@@ -4322,75 +5523,857 @@ function updatePartsLive() {
4322
5523
  }
4323
5524
  }
4324
5525
 
5526
+ /** Draws the Song grid of the reading it shows, from the fold state, then hands it the live state. */
5527
+ function drawSongGrid() {
5528
+ const wait = waitingCellEdit();
5529
+ const cellWaiting = wait === null ? null : { track: wait.track, section: wait.section, choice: wait.choice, at: wait.at };
5530
+ // The fit words come from the answer of the query of this very version and reading; until it answers there are none.
5531
+ const fit =
5532
+ songFit !== null && songFit.generation === songVersion && songFit.reading === songGridReading
5533
+ ? fitLookup({ reading: songGridReading, byPair: songFit.answer })
5534
+ : null;
5535
+ songGridUi.render(buildSongGrid({ reading: songGridReading, folds: songFolds, launchChoice: viewState.songLaunch, cellWaiting, fit }));
5536
+ refreshSongLine();
5537
+ updateSongGridLive();
5538
+ }
5539
+
5540
+ // One of the three launch buttons of the toolbar: when a launched section starts. The choice is remembered in this browser
5541
+ // (applyViewState stores it) and only the titles of the section headers change; the code is not touched.
5542
+ songGridHandlers.launchChoice = (value) => {
5543
+ if (value === viewState.songLaunch) return;
5544
+ applyViewState(setSongLaunch(viewState, value), ['songLaunch']);
5545
+ };
5546
+
5547
+ // A click, Enter or Space on a group's fold button, in the Song grid or in the Mixer: the group folds or opens, the one
5548
+ // fold set both views read changes, the state is remembered in this browser, and the views that are on screen are drawn
5549
+ // again. Nothing is written to the code, sent to the server or evaluated.
5550
+ function toggleSongFold(key) {
5551
+ const next = new Set(songFolds);
5552
+ if (!next.delete(key)) {
5553
+ next.add(key);
5554
+ // A group the store would refuse (a name over 200 characters) still folds here; say once that a reload forgets it.
5555
+ if (!isRememberable(key) && !notRememberedSaid) {
5556
+ notRememberedSaid = true;
5557
+ console.error(SONG_FOLDS_COPY.notRemembered);
5558
+ }
5559
+ }
5560
+ songFolds = next;
5561
+ if (!writeSongFolds(storage, [...songFolds])) reportStorageUnavailableOnce();
5562
+ if (songGridReading !== null) drawSongGrid();
5563
+ if (viewState.view === 'mixer') renderMixer();
5564
+ }
5565
+ songGridHandlers.fold = (key) => toggleSongFold(key);
5566
+
5567
+ // A click, Enter or Space on a section header: the section is queued to start on the line the toolbar chose, a second click on
5568
+ // the queued one cancels it and a click on another header replaces it. What the person saw (the entry's place, name and
5569
+ // length) and the choice are taken now; the launch itself runs on the serial chain, so it never interleaves with Claude's
5570
+ // changes or a Mixer move.
5571
+ songGridHandlers.launch = (index) => {
5572
+ const section = songGridReading === null ? undefined : songGridReading.sections[index];
5573
+ if (section === undefined) throw new Error(`Moontide: the Song grid has no section ${String(index)} to launch.`);
5574
+ const seen = { index, name: section.name, length: section.length, kind: viewState.songLaunch };
5575
+ chain = chain.then(() => launchSection(seen)).catch((err) => reportChainError('section launch', err));
5576
+ };
5577
+
5578
+ // A choice in the list that opens on a cell: the one string of the plan for that cell is rewritten, by the person's usual
5579
+ // hand-edit timing, and the choice is one history entry. What the person saw (the cell and its section) is taken now; the
5580
+ // change itself runs on the serial chain, so it never interleaves with Claude's changes or a Mixer move.
5581
+ songGridHandlers.choose = (seen, choice) => {
5582
+ chain = chain.then(() => chooseCell(seen, choice)).catch((err) => reportChainError('cell change', err));
5583
+ };
5584
+
5585
+ /** The structure of `code` as the reader gives it (throws when the parser cannot read the text). */
5586
+ function readSongOf(code) {
5587
+ const parse = window.acorn.parse;
5588
+ return readStructure(code, { parse, tracks: extractTracks(code, { parse }) });
5589
+ }
5590
+
5591
+ /** The cycle the scheduler is at, never below 0. */
5592
+ function currentCycle() {
5593
+ const raw = scheduler.now();
5594
+ return Number.isFinite(raw) && raw > 0 ? raw : 0;
5595
+ }
5596
+
5597
+ /** True while some version makes sound: the same test the takeover uses to decide whether a change has to wait. */
5598
+ function playsNow() {
5599
+ return scheduler.started === true && !pageStopped && hasEvaluatedSuccessfully && !!scheduler.pattern;
5600
+ }
5601
+
5602
+ /** The launch of the person's own that still waits in the takeover, or null (a stale one is dropped). */
5603
+ function waitingGridLaunch() {
5604
+ if (gridLaunch !== null && takeover.pending() !== gridLaunch.record) gridLaunch = null;
5605
+ return gridLaunch;
5606
+ }
5607
+
4325
5608
  /**
4326
- * Launches part `partIndex` of the arrangement whose rowSignature is `signature` — queued on the
4327
- * serial `chain` by its own click handler (buildPartCard), so it never interleaves with Claude's
4328
- * evaluate messages. Re-reads and re-parses activeCode first: the click and this run can be
4329
- * separated by another evaluate landing in between.
5609
+ * Puts the code that plays back in the editor after a launch of the grid ended without taking over. The launch wrote only
5610
+ * the shift, so this happens only while the editor still holds exactly the text the launch wrote: text typed since (which
5611
+ * the dirty line already reports) is never replaced, and the console says the editor was left alone.
4330
5612
  */
4331
- async function launchPart(signature, partIndex) {
4332
- // A part launch replaces the music like Claude's code does, so a pending Mixer gesture is
4333
- // recorded first (with the code that played), and the touched lines stop describing the text.
5613
+ function putBackCanceledLaunch(record) {
5614
+ const text = textAfterCanceledLaunch({ editorCode: editor.code, launchCode: record.code, playingCode: record.playingCode });
5615
+ if (text !== null) {
5616
+ editor.setCode(text);
5617
+ return;
5618
+ }
5619
+ console.error('Moontide: the canceled launch left the editor alone: it holds text that was not evaluated.');
5620
+ }
5621
+
5622
+ /** The change of a cell of the person's own that still waits in the takeover, or null (a stale one is dropped). */
5623
+ function waitingCellEdit() {
5624
+ if (gridCellWait !== null && takeover.pending() !== gridCellWait.record) gridCellWait = null;
5625
+ return gridCellWait;
5626
+ }
5627
+
5628
+ /** Stops a cell change before anything is written: `reason` is an id of SONG_COPY.refusals. */
5629
+ class CellStop extends Error {
5630
+ constructor(reason) {
5631
+ super(`Moontide: the cell change stopped (${reason}).`);
5632
+ this.reason = reason;
5633
+ }
5634
+ }
5635
+
5636
+ /**
5637
+ * Changes the cell the person chose in the list. Runs only inside the serial `chain`. A pending Mixer gesture is recorded
5638
+ * first and the touched-line overlay stops describing the text; the line under the toolbar is brought up to date when the
5639
+ * gesture is over.
5640
+ */
5641
+ async function chooseCell(seen, choice) {
4334
5642
  await mixerGesture.flushDirect();
4335
5643
  clearMixerFlash();
4336
- if (takeover.pending() !== null) {
4337
- // A launch would play (and record) the waiting change; Parts says so in its message line.
4338
- console.error('Moontide: the part was not launched: a change is waiting for its bar line.');
4339
- if (viewState.view === 'parts') renderParts();
5644
+ gridRefused = null;
5645
+ gridBusy = true;
5646
+ try {
5647
+ await chooseCellNow(seen, choice);
5648
+ } finally {
5649
+ gridBusy = false;
5650
+ refreshSongLine();
5651
+ if (songGridReading !== null) updateSongGridLive();
5652
+ }
5653
+ }
5654
+
5655
+ /**
5656
+ * The gesture itself: the playing code is read again, the cell is checked against what the person saw (section, cell and
5657
+ * variation) and exactly one string of song.plan is rewritten through the shared path with the person's own hand-edit timing.
5658
+ * Choosing what the cell holds writes, evaluates and records nothing. At once the change is recorded now; waiting, the grid
5659
+ * shows the new name with the words for its line and the change is recorded when it takes over.
5660
+ */
5661
+ async function chooseCellNow(seen, choice) {
5662
+ const timing = parseTiming(viewState.changeTiming);
5663
+ let outcome;
5664
+ try {
5665
+ outcome = await pageRewrite.apply({
5666
+ timing,
5667
+ // A cell change is an edit, not a request to play.
5668
+ autostart: !pageStopped,
5669
+ flash: false,
5670
+ build(text) {
5671
+ const reading = readSongOf(text);
5672
+ if (reading.state !== 'read') throw new CellStop('section-gone');
5673
+ const stale = checkCell(reading, seen, choice);
5674
+ if (stale !== null) throw new CellStop(stale);
5675
+ const { edits, changed } = cellEdit(reading, { index: seen.index, track: seen.track }, choice);
5676
+ return { code: changed ? applyEdits(text, edits) : text, edits };
5677
+ },
5678
+ });
5679
+ } catch (err) {
5680
+ if (err instanceof CellStop) {
5681
+ console.error(`Moontide: the cell was not changed (${err.reason}); the code changed since the click.`);
5682
+ gridRefused = err.reason;
5683
+ return;
5684
+ }
5685
+ if (err instanceof SongEditRefusal) {
5686
+ console.error(`Moontide: the cell was not changed (${err.reason}).`);
5687
+ gridRefused = err.reason;
5688
+ return;
5689
+ }
5690
+ throw err;
5691
+ }
5692
+ if (outcome.status === 'refused') {
5693
+ reportGridRefusal(outcome.reason, 'the cell was not changed');
5694
+ return;
5695
+ }
5696
+ if (outcome.status === 'unchanged') return;
5697
+ if (outcome.status === 'failed') {
5698
+ // The old code is still what plays; the shared path set the editor back and showed the banner.
5699
+ gridRefused = 'not-evaluated';
5700
+ return;
5701
+ }
5702
+ const label = cellRecordLabel({ track: seen.track, section: seen.name, choice });
5703
+ const record = outcome.run.deferred;
5704
+ if (record === null || record === undefined) {
5705
+ sendMixerRecord(editor.repl.state.activeCode, label);
5706
+ flashMixerLines(outcome.edits);
4340
5707
  return;
4341
5708
  }
5709
+ gridCellWait = { record, track: seen.track, section: seen.name, choice, at: timing.at };
5710
+ takeover.whenFinished(record, () => {
5711
+ if (gridCellWait !== null && gridCellWait.record === record) gridCellWait = null;
5712
+ sendMixerRecord(typeof record.code === 'string' ? record.code : editor.repl.state.activeCode, label);
5713
+ if (songGridReading !== null) drawSongGrid();
5714
+ });
5715
+ if (songGridReading !== null) drawSongGrid();
5716
+ }
5717
+
5718
+ // --- the Timeline's note edits ---
5719
+
5720
+ /** The strings of the code on screen, the song reading and the scope of every track, made once for the version drawn. */
5721
+ /** The song reading of the code the lanes were drawn from, or `{ state: 'none' }` when the page has read no song from that code. */
5722
+ function timelineSongReading() {
5723
+ return songReading !== null && songReading.code === timelineCode ? songReading.reading : { state: 'none' };
5724
+ }
5725
+
5726
+ function timelineNoteIndex() {
5727
+ if (timelineIndex !== null && timelineIndex.code === timelineCode) return timelineIndex;
5728
+ const index = indexCode(timelineCode, { parse: timelineParse });
5729
+ const reading = timelineSongReading();
5730
+ const scopes = timelineTracks.map((track) => trackScope({ track, reading }));
5731
+ timelineIndex = { code: timelineCode, index, reading, scopes, readers: readerCounter(index, scopes) };
5732
+ return timelineIndex;
5733
+ }
5734
+
5735
+ /** The onset notes of a pattern over `from` to `to`, in the shape of the lane's own events (the places in the code kept). */
5736
+ function onsetEvents(pattern, from, to) {
5737
+ const events = [];
5738
+ for (const hap of pattern.queryArc(from, to)) {
5739
+ if (hap.whole && hap.hasOnset()) events.push(toLaneEvent(hap));
5740
+ }
5741
+ return events;
5742
+ }
5743
+
5744
+ /**
5745
+ * Where the token of an editable note plays in a song the page has read: one query of the track's own pattern over one pass of
5746
+ * the structure, from the first section's start, each note mapped to its section entry with the Song grid's own rule. The
5747
+ * evaluated pattern is kept for the version and the track, and the answer for the token. Gives a promise of `{ count, sections,
5748
+ * onPage: false, events }` (`events` are the notes of the pass, the read-back compares them), and `span`, the stretch queried.
5749
+ */
5750
+ function timelinePlacesOf({ trackIndex, token, reading }) {
5751
+ const cache = timelinePlaces;
5752
+ const parse = timelineParse;
5753
+ const code = timelineCode;
5754
+ const from = ((reading.sections[0].start % reading.total) + reading.total) % reading.total;
5755
+ const span = { from, to: from + reading.total };
5756
+ const key = `${trackIndex}:${token.start}`;
5757
+ if (!cache.answers.has(key)) {
5758
+ if (!cache.patterns.has(trackIndex)) {
5759
+ const pattern = evaluateSolo(soloSource(code, trackIndex, { parse }));
5760
+ // A pattern that failed to evaluate is not kept: the next selection tries again.
5761
+ pattern.catch(() => cache.patterns.delete(trackIndex));
5762
+ cache.patterns.set(trackIndex, pattern);
5763
+ }
5764
+ const answer = cache.patterns.get(trackIndex).then((pattern) => {
5765
+ const events = onsetEvents(pattern, span.from, span.to);
5766
+ return { ...placesOf({ haps: events, token, reading }), onPage: false, events };
5767
+ });
5768
+ answer.catch(() => cache.answers.delete(key));
5769
+ cache.answers.set(key, answer);
5770
+ }
5771
+ return { places: cache.answers.get(key), span };
5772
+ }
5773
+
5774
+ /**
5775
+ * What the panel needs to know of the note the person clicked (lane `laneIndex` of the lanes drawn, event `eventIndex` of the
5776
+ * lane): where it is, what it plays, and whether the page can edit it with the reason when it cannot. Reads the code and the
5777
+ * lanes only; nothing is written, evaluated or sent. For a note that can be edited `seen` holds what the edit needs of this
5778
+ * moment: the code the note was read from, the lane as drawn and the place of the token in the code.
5779
+ */
5780
+ function describeTimelineNote(laneIndex, eventIndex) {
5781
+ if (timelineDrawn === null || timelineCode === null || timelineTracks === null) {
5782
+ throw new Error('Moontide: a note was picked while no Timeline lanes are drawn.');
5783
+ }
5784
+ const lane = timelineDrawn.lanes[laneIndex];
5785
+ const event = lane.events[eventIndex];
5786
+ const { index, reading, scopes, readers } = timelineNoteIndex();
5787
+ const scope = scopes.find((candidate) => candidate.index === lane.index);
5788
+ const verdict = classifyNote({ code: timelineCode, index, event, scope, scopes, readers, midi: lane.midi === true });
5789
+ // The cycle and beat the right-click names for the instant the note begins at: one rule for both.
5790
+ const moment = momentAt({ pageStart: timelineDrawn.pageStart, fraction: (event.begin - timelineDrawn.pageStart) / PAGE_CYCLES, lanes: [], version: null });
5791
+ const midi = pitchOf(event.value);
5792
+ const drumNote = lane.drumMap === null ? null : drumNoteOf(event.value);
5793
+ const info = {
5794
+ laneIndex,
5795
+ eventIndex,
5796
+ track: lane.track,
5797
+ cycle: moment.cycle,
5798
+ beat: moment.beat,
5799
+ section: reading.state === 'read' ? reading.sections[playingSection(reading, event.begin).index].name : null,
5800
+ noteText: event.value.note ?? midi,
5801
+ midi,
5802
+ drum: drumNote !== null && lane.drumMap.byNote.has(drumNote) ? lane.drumMap.byNote.get(drumNote) : null,
5803
+ velocity: velocityOf(event.value, lane.midi === true),
5804
+ // The velocity is writable only where one number of a velocity string in the track holds it (page/note-locate.js).
5805
+ velocityWritable: verdict.editable && verdict.velocity.writable,
5806
+ editable: verdict.editable,
5807
+ reason: verdict.editable ? null : verdict.reason,
5808
+ seen: null,
5809
+ };
5810
+ if (verdict.editable) {
5811
+ info.seen = {
5812
+ code: timelineCode,
5813
+ trackIndex: lane.index,
5814
+ track: lane.track,
5815
+ event,
5816
+ events: lane.events,
5817
+ token: verdict.token,
5818
+ velocity: verdict.velocity,
5819
+ pageStart: timelineDrawn.pageStart,
5820
+ places: null,
5821
+ placesWindow: null,
5822
+ velocityPlaces: null,
5823
+ velocityWindow: null,
5824
+ perCycle: 0,
5825
+ };
5826
+ if (reading.state === 'read') {
5827
+ // A song: the places are section entries. Over the count limit only the entries the drawn page holds are counted.
5828
+ const placesOfToken = (token) =>
5829
+ reading.total > PLACES_MAX_CYCLES
5830
+ ? { places: { ...placesOf({ haps: lane.events, token, reading }), onPage: true }, span: null }
5831
+ : timelinePlacesOf({ trackIndex: lane.index, token, reading });
5832
+ const counted = placesOfToken(verdict.token);
5833
+ info.seen.places = counted.places;
5834
+ info.seen.placesWindow = counted.span;
5835
+ // The number that holds the velocity can play in more places than the pitch token does (a variation used by several
5836
+ // sections): they are counted for the velocity too, and a velocity edit is read back over all of them.
5837
+ if (verdict.velocity.writable) {
5838
+ const velocityCounted = placesOfToken(verdict.velocity.token);
5839
+ info.seen.velocityPlaces = velocityCounted.places;
5840
+ info.seen.velocityWindow = velocityCounted.span;
5841
+ }
5842
+ } else {
5843
+ // No structure: only a token that draws several notes in one cycle is told.
5844
+ info.seen.perCycle = perCycleCount({ events: lane.events, token: verdict.token, cycle: Math.floor(event.begin + 1e-9) });
5845
+ }
5846
+ info.places = info.seen.places;
5847
+ info.velocityPlaces = info.seen.velocityPlaces;
5848
+ info.perCycle = info.seen.perCycle;
5849
+ }
5850
+ return info;
5851
+ }
5852
+
5853
+ // The edit of the person's own that waits for its line, `{ record }` (the takeover's waiting change), or null; the Timeline's
5854
+ // note panel keeps its edit controls off meanwhile and says so with the edit's own sentence.
5855
+ let timelineEditWait = null;
5856
+ // True while an edit of the Timeline runs on the serial chain: the editor legitimately differs from the playing code then.
5857
+ let timelineEditBusy = false;
5858
+ // What the editor held the last time nothing was in flight: true while it has text that was never evaluated.
5859
+ let timelineDirty = false;
5860
+ // The notice the note panel was last told (null, 'closed', 'waiting' or 'dirty'), so it is told again only when it changes.
5861
+ let timelineNoticeKind = null;
5862
+
5863
+ /** Ends the wait of the person's own edit once its record is no longer the waiting change: it took over, or was dropped. */
5864
+ function syncTimelineWait() {
5865
+ if (timelineEditWait === null) return;
5866
+ const { record } = timelineEditWait;
5867
+ if (takeover.pending() === record) return;
5868
+ timelineEditWait = null;
5869
+ timelineEditUi.finishWait({ tookOver: record.finished === true });
5870
+ }
5871
+
5872
+ /**
5873
+ * The notice that keeps the Timeline's edit controls off, by the Mixer's order: the socket is closed, another change waits for
5874
+ * its line, or the editor holds text that was never evaluated. The person's own waiting edit is not a notice (it has its own
5875
+ * sentence). The editor is compared with the playing code except while an evaluation is in flight, when it legitimately
5876
+ * differs; then the last answer stands.
5877
+ */
5878
+ function timelineNotice() {
5879
+ if (ws.readyState !== ws.OPEN) return 'closed';
5880
+ const waitingRecord = takeover.pending();
5881
+ const ours = timelineEditWait !== null && waitingRecord === timelineEditWait.record ? timelineEditWait : null;
5882
+ if (waitingRecord !== null && ours === null) return 'waiting';
5883
+ if (hasEvaluatedSuccessfully && !programmaticEvalInFlight && !mixerTickInFlight && !timelineEditBusy) {
5884
+ const code = ours !== null && typeof ours.record.code === 'string' ? ours.record.code : playingCode();
5885
+ timelineDirty = editor.code !== code;
5886
+ }
5887
+ return timelineDirty ? 'dirty' : null;
5888
+ }
5889
+
5890
+ /**
5891
+ * The Timeline note panel's share of the 250 ms page poll, only while the Timeline is open: the wait of the person's own edit
5892
+ * ends when its record is gone, and the panel is told of a notice only when its kind changes.
5893
+ */
5894
+ function pollTimelineEdit() {
5895
+ if (viewState.view !== 'timeline') return;
5896
+ syncTimelineWait();
5897
+ const kind = timelineNotice();
5898
+ if (kind === timelineNoticeKind) return;
5899
+ timelineNoticeKind = kind;
5900
+ timelineEditUi.setNotice(kind);
5901
+ }
5902
+
5903
+ /**
5904
+ * Changes the note the person edited on the Timeline. Runs only inside the serial `chain`. A pending Mixer gesture is recorded
5905
+ * first and the touched-line overlay stops describing the text. The edited track is read back before anything is written
5906
+ * (page/note-edit.js), the write goes through the shared path with the person's usual hand-edit timing, and a written edit
5907
+ * is one history entry. The panel is told what happened under the edit's `id`, so the outcome speaks for the edit's own note.
5908
+ */
5909
+ async function editTimelineNote(info, change, id) {
5910
+ const seen = info.seen;
5911
+ let outcome;
5912
+ let evaluated = null;
5913
+ timelineEditBusy = true;
5914
+ try {
5915
+ // Inside the try, like the Mixer flush below: a missing parser or a pending Mixer gesture that fails to record is told in the
5916
+ // panel like any other error of the edit, so every edit handed over ends in an outcome.
5917
+ const parse = window.acorn.parse;
5918
+ await mixerGesture.flushDirect();
5919
+ clearMixerFlash();
5920
+ outcome = await writeNoteEdit({
5921
+ seen,
5922
+ change,
5923
+ deps: {
5924
+ // The edited track alone, over the page the lanes show or over the stretch of the structure the places were counted on, in
5925
+ // the shape of the lane's own events. The edited track is evaluated once for both.
5926
+ async readBack(code, span) {
5927
+ if (evaluated === null || evaluated.code !== code) evaluated = { code, pattern: evaluateSolo(soloSource(code, seen.trackIndex, { parse })) };
5928
+ const pattern = await evaluated.pattern;
5929
+ return span === undefined ? onsetEvents(pattern, seen.pageStart, seen.pageStart + PAGE_CYCLES) : onsetEvents(pattern, span.from, span.to);
5930
+ },
5931
+ apply: (request) => pageRewrite.apply(request),
5932
+ timing: parseTiming(viewState.changeTiming),
5933
+ // An edit is not a request to play.
5934
+ autostart: !pageStopped,
5935
+ record: sendMixerRecord,
5936
+ whenFinished: (record, fn) => takeover.whenFinished(record, fn),
5937
+ activeCode: () => editor.repl.state.activeCode,
5938
+ },
5939
+ });
5940
+ } catch (err) {
5941
+ if (err instanceof NoteEditStop) {
5942
+ outcome = { kind: 'refused', reason: err.reason };
5943
+ } else {
5944
+ // An error nobody planned for is told in the panel as well as the console: the person is never left with a standing sentence
5945
+ // and no word about the edit they asked for.
5946
+ console.error('Moontide: the Timeline edit failed:', err);
5947
+ outcome = { kind: 'refused', reason: 'error' };
5948
+ }
5949
+ } finally {
5950
+ timelineEditBusy = false;
5951
+ }
5952
+ // An error the rewrite path gave back is reported here: at or after the write the edit's own code may have changed; an
5953
+ // evaluation that threw and was taken back left the code as it was.
5954
+ if (outcome.kind === 'refused' && outcome.reason === 'error' && outcome.error !== undefined) {
5955
+ console.error(outcome.afterWrite === true ? 'Moontide: the Timeline edit failed after its write:' : 'Moontide: the Timeline edit failed, and its write was taken back:', outcome.error);
5956
+ }
5957
+ if (outcome.kind === 'refused' && (outcome.reason === 'stale' || outcome.reason === 'read-back')) {
5958
+ console.error(`Moontide: the note was not changed (${outcome.reason}${outcome.why === undefined ? '' : `: ${outcome.why}`}).`);
5959
+ if (outcome.error !== undefined) console.error('Moontide: the edited track could not be read back:', outcome.error);
5960
+ }
5961
+ timelineEditUi.showOutcome(id, outcome);
5962
+ if (outcome.kind === 'waiting') {
5963
+ // The panel keeps the edit controls off until the change takes over; it hears of the takeover (or of its being dropped).
5964
+ timelineEditWait = { record: outcome.record };
5965
+ takeover.whenFinished(outcome.record, () => syncTimelineWait());
5966
+ syncTimelineWait();
5967
+ }
5968
+ }
5969
+
5970
+ /**
5971
+ * Launches the section the person clicked on. Runs only inside the serial `chain`. A pending Mixer gesture is recorded
5972
+ * first, as for a part launch, and the touched-line overlay stops describing the text. The line under the toolbar and the
5973
+ * countdown of the header are brought up to date when the gesture is over.
5974
+ */
5975
+ async function launchSection(seen) {
5976
+ await mixerGesture.flushDirect();
5977
+ clearMixerFlash();
5978
+ gridRefused = null;
5979
+ gridBusy = true;
5980
+ try {
5981
+ await launchSectionNow(seen);
5982
+ } finally {
5983
+ gridBusy = false;
5984
+ syncGridQueued(currentSoundState === 'playing' ? currentCycle() : null);
5985
+ refreshSongLine();
5986
+ if (songGridReading !== null) updateSongGridLive();
5987
+ }
5988
+ }
5989
+
5990
+ /**
5991
+ * The gesture itself. A click on the header of the launch that waits cancels it (nothing is recorded, the music keeps
5992
+ * playing, the editor goes back to the playing code); a click on another header while ours waits replaces it, built on the
5993
+ * waiting code; both only until the scheduler has queried past the line, after which a click changes nothing; a click on
5994
+ * the header of a launch that needs no code change only hides its countdown. While the sound is stopped the piece starts at
5995
+ * that section at once. Otherwise the shift that puts the section on the chosen line is written and evaluated with the
5996
+ * takeover pinned to that line, and the server hears of it (one history entry) when the section takes over. The frontier is
5997
+ * read, the line and the shift computed, the code written and the evaluation started in one turn:
5998
+ * nothing is awaited between reading the frontier and the call that writes, which is what makes the line the takeover takes
5999
+ * agree with the line the shift was written for.
6000
+ */
6001
+ async function launchSectionNow(seen) {
4342
6002
  if (!window.acorn || typeof window.acorn.parse !== 'function') {
4343
- console.error('Moontide: window.acorn is not available — cannot launch a part.');
6003
+ console.error('Moontide: window.acorn is not available — cannot launch a section.');
4344
6004
  return;
4345
6005
  }
4346
- const parse = window.acorn.parse;
4347
- const code = playingCode();
4348
- let arrangements;
6006
+ const ours = waitingGridLaunch();
6007
+ // The countdown of a launch that needs no code change: a second click on its header hides it, nothing else is involved.
6008
+ if (gridAlready !== null && gridAlready.index === seen.index && ours === null) {
6009
+ gridAlready = null;
6010
+ return;
6011
+ }
6012
+ if (ws.readyState !== ws.OPEN) {
6013
+ console.error('Moontide: the section was not launched: the socket is not open.');
6014
+ return;
6015
+ }
6016
+ if (!hasEvaluatedSuccessfully) {
6017
+ console.error('Moontide: the section was not launched: no code has played yet.');
6018
+ return;
6019
+ }
6020
+ const waiting = takeover.pending();
6021
+ if (waiting !== null && (ours === null || waiting !== ours.record)) {
6022
+ console.error('Moontide: the section was not launched: a change is waiting for its bar line.');
6023
+ return;
6024
+ }
6025
+ // The code the gesture builds on, and the text the editor must still hold: while our launch waits that is the waiting
6026
+ // code, which differs from the playing code in the shift alone. Typing that was never evaluated is never overwritten, and
6027
+ // a cancel never discards it either.
6028
+ const playing = playingCode();
6029
+ const base = ours !== null ? ours.record.code : playing;
6030
+ if (editor.code !== base) {
6031
+ console.error('Moontide: the section was not launched: the editor holds a hand edit that was not evaluated.');
6032
+ return;
6033
+ }
6034
+ let reading;
6035
+ let baseReading;
4349
6036
  try {
4350
- const tracks = extractTracks(code, { parse });
4351
- arrangements = extractArrangements(code, { parse, tracks });
6037
+ reading = readSongOf(playing);
6038
+ baseReading = ours !== null ? readSongOf(base) : reading;
4352
6039
  } catch (err) {
4353
- console.error('Moontide: failed to parse the playing code for a part launch:', err);
6040
+ console.error('Moontide: failed to parse the code for a section launch:', err);
4354
6041
  return;
4355
6042
  }
4356
- const arrangement = arrangements.find((candidate) => rowSignature(candidate) === signature);
4357
- const section = arrangement && arrangement.sections[partIndex];
4358
- if (!section) {
4359
- console.error('Moontide: the part to launch is gone; the code changed since the click.');
4360
- if (viewState.view === 'parts') renderParts();
6043
+ if (reading.state !== 'read' || baseReading.state !== 'read' || checkSection(reading, seen) !== null) {
6044
+ console.error('Moontide: the section to launch is gone; the code changed since the click.');
6045
+ gridRefused = 'section-gone';
4361
6046
  return;
4362
6047
  }
4363
6048
 
4364
- const wasStopped = pageStopped;
6049
+ // Once the scheduler has queried past the line, the notes of the new section are already scheduled: a cancel would let
6050
+ // them sound and then put the old music back in the middle of the section, and a replacement would be built on a
6051
+ // playing code that is no longer the one that plays. Nothing is changed; the launch takes over on its line.
6052
+ if (ours !== null && ours.record.reached) {
6053
+ console.error(`Moontide: the section '${ours.name}' is too close to its line to be canceled or replaced; it takes over.`);
6054
+ return;
6055
+ }
6056
+
6057
+ if (ours !== null && ours.index === seen.index) {
6058
+ takeover.cancel('canceled');
6059
+ return;
6060
+ }
6061
+
6062
+ if (!playsNow()) {
6063
+ await launchStoppedSection(seen, playing, reading);
6064
+ return;
6065
+ }
6066
+
6067
+ // Another launch replaces the countdown of one that needs no code change.
6068
+ gridAlready = null;
6069
+ let attempt = 0;
6070
+ let from = base;
6071
+ let fromReading = baseReading;
6072
+ for (;;) {
6073
+ attempt += 1;
6074
+ // Read the frontier, compute the line and the shift, write and evaluate: no await until the call has started.
6075
+ const { line, at } = launchLine({ kind: seen.kind, frontier: scheduler.lastEnd, cps: takeoverCps(), reading, minLeadSeconds: MIN_TAKEOVER_LEAD_SECONDS });
6076
+ const shift = launchShift(reading, seen.index, line);
6077
+ if (sameShift(shift, reading.shift, reading.total)) {
6078
+ // The section already starts on that line: nothing is written. A launch of ours that waits (or the first try of
6079
+ // this one, written for a line that moved) is dropped, and the header counts down to the line.
6080
+ if (takeover.pending() !== null) takeover.cancel('canceled');
6081
+ gridAlready = { index: seen.index, name: seen.name, line };
6082
+ return;
6083
+ }
6084
+ const outcome = await pageRewrite.apply({
6085
+ base: from,
6086
+ timing: pinnedTiming(at, line),
6087
+ // The sound plays (this loop runs only then), so the scheduler is started already. A Stop that lands while the
6088
+ // change is prepared must not start it again: the evaluation only swaps the pattern.
6089
+ autostart: false,
6090
+ flash: false,
6091
+ // A waiting change of ours is replaced by this one, so only a change of someone else's keeps it from running.
6092
+ guards: from === playing ? undefined : { waiting: false },
6093
+ build(text) {
6094
+ const edits = shiftEdit(fromReading, shift);
6095
+ return { code: applyEdits(text, edits), edits };
6096
+ },
6097
+ });
6098
+ if (outcome.status === 'refused') {
6099
+ if (takeover.pending() !== null && attempt > 1) takeover.cancel('canceled');
6100
+ reportGridRefusal(outcome.reason);
6101
+ return;
6102
+ }
6103
+ if (outcome.status === 'unchanged') return;
6104
+ if (outcome.status === 'failed') {
6105
+ // The old code is still what plays; the shared path set the editor back and showed the banner.
6106
+ if (takeover.pending() !== null && attempt > 1) takeover.cancel('canceled');
6107
+ gridRefused = 'not-evaluated';
6108
+ return;
6109
+ }
6110
+ const record = outcome.run.deferred;
6111
+ if (record === null || record === undefined) {
6112
+ // The change took over at once (the music stopped while it was prepared, and the evaluation did not start the
6113
+ // scheduler again): the code is the music that plays when Play comes, so the server hears of it all the same.
6114
+ sendIfOpen(JSON.stringify({ type: 'part-launch', code: editor.repl.state.activeCode, part: partLaunchPart(seen.name) }));
6115
+ // The server reads every launch as the sound playing, and the Stop's own report may have reached it before this one:
6116
+ // saying the Stop again makes the last word the server hears the true one.
6117
+ if (pageStopped) sendIfOpen(JSON.stringify({ type: 'user-stopped' }));
6118
+ flashMixerLines(outcome.edits);
6119
+ return;
6120
+ }
6121
+ if (record.canceled) {
6122
+ // Stop, or a change of someone else's, canceled it while the evaluation ran. After a Stop its text would read as
6123
+ // typing that was never evaluated: it goes back like any launch that ends without taking over.
6124
+ if (record.cancelReason === 'stop') putBackCanceledLaunch(record);
6125
+ return;
6126
+ }
6127
+ if (record.boundary === line) {
6128
+ gridLaunch = { record, index: seen.index, name: seen.name, line, at };
6129
+ takeover.whenFinished(record, () => {
6130
+ if (gridLaunch !== null && gridLaunch.record === record) gridLaunch = null;
6131
+ gridQueued = null;
6132
+ sendIfOpen(JSON.stringify({ type: 'part-launch', code: editor.repl.state.activeCode, part: partLaunchPart(seen.name) }));
6133
+ if (songGridReading !== null) {
6134
+ updateSongGridLive();
6135
+ songGridUi.scrollPlayingIntoView();
6136
+ }
6137
+ });
6138
+ return;
6139
+ }
6140
+ // The takeover took another line than the one the shift was written for (the frontier moved while the code was
6141
+ // prepared). The waiting code is written again for a fresh line, once; the newer change replaces it.
6142
+ if (attempt >= 2) {
6143
+ takeover.cancel('canceled');
6144
+ console.error(`Moontide: the section launch could not be placed on its line (wanted cycle ${line}, the takeover took ${record.boundary}); it was canceled.`);
6145
+ gridRefused = 'line-moved';
6146
+ return;
6147
+ }
6148
+ from = record.code;
6149
+ try {
6150
+ fromReading = readSongOf(from);
6151
+ } catch (err) {
6152
+ takeover.cancel('canceled');
6153
+ console.error('Moontide: failed to parse the waiting code for a section launch:', err);
6154
+ return;
6155
+ }
6156
+ }
6157
+ }
6158
+
6159
+ /**
6160
+ * A click on a header while the sound is stopped: the piece starts at that section at once. The shift is written so the
6161
+ * section starts at cycle 0 (the count restarts at 0 at every Play), the sound comes back, and the server hears of it. When
6162
+ * the shift is already right the text stays as it is and is evaluated all the same, so the sound starts.
6163
+ */
6164
+ async function launchStoppedSection(seen, playing, reading) {
6165
+ const shift = launchShift(reading, seen.index, 0);
6166
+ const unchanged = sameShift(shift, reading.shift, reading.total);
6167
+ const outcome = await pageRewrite.apply({
6168
+ base: playing,
6169
+ autostart: true,
6170
+ flash: false,
6171
+ evaluateUnchanged: true,
6172
+ build(text) {
6173
+ const edits = unchanged ? [] : shiftEdit(reading, shift);
6174
+ return { code: applyEdits(text, edits), edits };
6175
+ },
6176
+ });
6177
+ if (outcome.status === 'refused') {
6178
+ reportGridRefusal(outcome.reason);
6179
+ return;
6180
+ }
6181
+ if (outcome.status === 'unchanged') return;
6182
+ if (outcome.status === 'failed') {
6183
+ gridRefused = 'not-evaluated';
6184
+ return;
6185
+ }
6186
+ await resumeAfterStop();
6187
+ sendIfOpen(JSON.stringify({ type: 'part-launch', code: editor.repl.state.activeCode, part: partLaunchPart(seen.name) }));
6188
+ flashMixerLines(outcome.edits);
6189
+ }
6190
+
6191
+ /**
6192
+ * What a launch the shared path refused leaves behind. Most refusals are states the line under the toolbar already says
6193
+ * (a waiting change, unevaluated typing, a closed socket); the console names them too, so none is silent.
6194
+ */
6195
+ function reportGridRefusal(reason, subject = 'the section was not launched') {
6196
+ switch (reason) {
6197
+ case 'not-played':
6198
+ console.error(`Moontide: ${subject}: no code has played yet.`);
6199
+ break;
6200
+ case 'waiting':
6201
+ console.error(`Moontide: ${subject}: a change is waiting for its bar line.`);
6202
+ break;
6203
+ case 'closed':
6204
+ console.error(`Moontide: ${subject}: the socket is not open.`);
6205
+ break;
6206
+ case 'no-parser':
6207
+ console.error(`Moontide: window.acorn is not available — ${subject}.`);
6208
+ break;
6209
+ case 'dirty':
6210
+ console.error(`Moontide: ${subject}: the editor holds a hand edit that was not evaluated.`);
6211
+ break;
6212
+ case 'write-mismatch':
6213
+ console.error('Moontide: the editor text does not match the rewrite; the editor was set back.');
6214
+ gridRefused = 'not-written';
6215
+ break;
6216
+ default:
6217
+ console.error(`Moontide: ${subject} (${String(reason)}).`);
6218
+ }
6219
+ }
6220
+
6221
+ /**
6222
+ * Works out the sentence under the toolbar for the state of the page now. The editor is compared with the code the grid
6223
+ * works on (the waiting code while our launch waits, else the playing code) except while a gesture or any programmatic
6224
+ * evaluation is in flight, when the editor legitimately differs; then the last answer stands. While the editor holds text
6225
+ * that was never evaluated, the launch lines give way to the dirty one: no gesture could cancel or replace the launch
6226
+ * without discarding that text, so every control is off and the line says why.
6227
+ */
6228
+ function refreshSongLine() {
6229
+ if (songGridReading === null || gridBusy) return;
6230
+ const waitingRecord = takeover.pending();
6231
+ const ours = gridLaunch !== null && waitingRecord === gridLaunch.record ? gridLaunch : null;
6232
+ if (hasEvaluatedSuccessfully && !programmaticEvalInFlight && !mixerTickInFlight) {
6233
+ gridDirty = editor.code !== (ours !== null ? ours.record.code : playingCode());
6234
+ }
6235
+ let launch = null;
6236
+ if (!gridDirty) {
6237
+ if (ours !== null) launch = { kind: 'queued', section: ours.name };
6238
+ else if (gridAlready !== null) launch = { kind: 'already', section: gridAlready.name };
6239
+ }
6240
+ gridLine = songLine({
6241
+ connected: ws.readyState === ws.OPEN,
6242
+ launch,
6243
+ waiting: waitingRecord !== null && (ours === null || waitingRecord !== ours.record),
6244
+ dirty: gridDirty,
6245
+ refused: gridRefused,
6246
+ });
6247
+ }
6248
+
6249
+ /**
6250
+ * The Song grid's share of the 250 ms page poll: a launch that needed no code change ends when the scheduler reaches its
6251
+ * line (the section it named plays then, and its column comes into view once); another change that starts to wait ends it
6252
+ * too; then the sentence under the toolbar is brought up to date.
6253
+ */
6254
+ function pollSongGrid() {
6255
+ if (songGridReading === null || viewState.view !== 'parts') return;
6256
+ let landed = false;
6257
+ if (gridAlready !== null && (currentCycle() >= gridAlready.line || (takeover.pending() !== null && waitingGridLaunch() === null))) {
6258
+ landed = currentCycle() >= gridAlready.line;
6259
+ gridAlready = null;
6260
+ gridQueued = null;
6261
+ }
6262
+ refreshSongLine();
6263
+ if (landed) {
6264
+ updateSongGridLive();
6265
+ songGridUi.scrollPlayingIntoView();
6266
+ }
6267
+ }
6268
+
6269
+ /** Takes the Song grid off the screen (a piece without a readable song shows the Parts view or its message instead). */
6270
+ function hideSongGrid() {
6271
+ songGridUi.hide();
6272
+ songGridReading = null;
6273
+ }
6274
+
6275
+ /**
6276
+ * Hands the live state to the Song grid: which section plays now, only while the page makes sound. The grid writes only
6277
+ * what changed, so calling this every animation frame touches no node while the state is the same.
6278
+ */
6279
+ function updateSongGridLive() {
6280
+ if (songGridReading === null) return;
4365
6281
  const rawNow = editor.repl.scheduler.now();
4366
- const now = Number.isFinite(rawNow) && rawNow > 0 ? rawNow : 0;
4367
- const target = launchTargetCycle({ now, cps: editor.repl.scheduler.cps, stopped: wasStopped });
4368
- const offset = launchOffset(target, section.start, arrangement.total);
4369
- const newCode = applyArrangeOffset(code, arrangement, offset);
6282
+ const cycle = Number.isFinite(rawNow) && rawNow > 0 ? rawNow : 0;
6283
+ const stopped = currentSoundState !== 'playing';
6284
+ // For at most one poll after a takeover the reading can still describe the previous section: the reading is replaced when
6285
+ // the finisher of the new version runs, a moment after the clock has moved on.
6286
+ songGridUi.tick({ playing: stopped ? null : playingSection(songGridReading, cycle), stopped, motion: viewState.motion, queued: gridQueued, line: gridLine });
6287
+ // The playing column scrolls into view once when the sound starts; after that the grid never follows it sideways.
6288
+ if (!stopped && !songGridWasPlaying) songGridUi.scrollPlayingIntoView();
6289
+ songGridWasPlaying = !stopped;
6290
+ }
6291
+
6292
+ /** Ends a part launch before anything is written: the playing code cannot be read, or the part is gone. */
6293
+ class PartLaunchStop extends Error {
6294
+ constructor(kind, cause) {
6295
+ super(`Moontide: the part launch stopped (${kind}).`);
6296
+ this.kind = kind;
6297
+ this.cause = cause;
6298
+ }
6299
+ }
4370
6300
 
4371
- if (newCode === code && !wasStopped) {
6301
+ /**
6302
+ * Launches part `partIndex` of the arrangement whose rowSignature is `signature` — queued on the
6303
+ * serial `chain` by its own click handler (buildPartCard), so it never interleaves with Claude's
6304
+ * evaluate messages. Re-reads and re-parses activeCode first: the click and this run can be
6305
+ * separated by another evaluate landing in between.
6306
+ */
6307
+ async function launchPart(signature, partIndex) {
6308
+ // A part launch replaces the music like Claude's code does, so a pending Mixer gesture is
6309
+ // recorded first (with the code that played), and the touched lines stop describing the text.
6310
+ await mixerGesture.flushDirect();
6311
+ clearMixerFlash();
6312
+ const wasStopped = pageStopped;
6313
+ let section = null;
6314
+ let target = 0;
6315
+ let outcome;
6316
+ try {
6317
+ // This launch has always replaced the whole text, with the editor flashing, and never checked for typing that
6318
+ // was not evaluated, nor for a closed socket or a first play: those three guards stay off so nothing changes.
6319
+ // When the page is stopped, an unchanged text is still evaluated so the sound starts again.
6320
+ outcome = await pageRewrite.apply({
6321
+ write: 'replace',
6322
+ flash: true,
6323
+ guards: { played: false, socket: false, dirty: false },
6324
+ evaluateUnchanged: wasStopped,
6325
+ build(code) {
6326
+ const parse = window.acorn.parse;
6327
+ let arrangements;
6328
+ try {
6329
+ const tracks = extractTracks(code, { parse });
6330
+ arrangements = extractArrangements(code, { parse, tracks });
6331
+ } catch (err) {
6332
+ throw new PartLaunchStop('unreadable', err);
6333
+ }
6334
+ const arrangement = arrangements.find((candidate) => rowSignature(candidate) === signature);
6335
+ section = arrangement && arrangement.sections[partIndex];
6336
+ if (!section) throw new PartLaunchStop('gone');
6337
+ const rawNow = editor.repl.scheduler.now();
6338
+ const now = Number.isFinite(rawNow) && rawNow > 0 ? rawNow : 0;
6339
+ target = launchTargetCycle({ now, cps: editor.repl.scheduler.cps, stopped: wasStopped });
6340
+ const offset = launchOffset(target, section.start, arrangement.total);
6341
+ return { code: applyArrangeOffset(code, arrangement, offset), edits: null };
6342
+ },
6343
+ });
6344
+ } catch (err) {
6345
+ if (!(err instanceof PartLaunchStop)) throw err;
6346
+ if (err.kind === 'unreadable') {
6347
+ console.error('Moontide: failed to parse the playing code for a part launch:', err.cause);
6348
+ } else {
6349
+ console.error('Moontide: the part to launch is gone; the code changed since the click.');
6350
+ if (viewState.view === 'parts') renderParts();
6351
+ }
6352
+ return;
6353
+ }
6354
+ if (outcome.status === 'refused') {
6355
+ if (outcome.reason === 'waiting') {
6356
+ // A launch would play (and record) the waiting change; Parts says so in its message line.
6357
+ console.error('Moontide: the part was not launched: a change is waiting for its bar line.');
6358
+ if (viewState.view === 'parts') renderParts();
6359
+ } else {
6360
+ console.error('Moontide: window.acorn is not available — cannot launch a part.');
6361
+ }
6362
+ return;
6363
+ }
6364
+ if (outcome.status === 'unchanged') {
4372
6365
  // Nothing changes (this part already starts at the target cycle): only record the pending
4373
6366
  // launch and re-render — never evaluate or send anything for a no-op rewrite.
4374
6367
  pendingLaunch = { signature, partIndex, targetCycle: target };
4375
6368
  if (viewState.view === 'parts') renderParts();
4376
6369
  return;
4377
6370
  }
4378
-
4379
- editor.setCode(newCode);
4380
- await runProgrammaticEvaluate();
4381
- const evalError = editor.repl.state.evalError;
4382
- if (evalError) {
4383
- // handleEvaluate's own revert-on-failure branch, reused verbatim: the old code
4384
- // is still what's actually playing.
4385
- editor.setCode(hasEvaluatedSuccessfully ? committedCode() : '');
4386
- const { errorText } = buildEvalErrorReport(evalError);
4387
- showEvalError(errorText);
6371
+ if (outcome.status === 'failed') {
6372
+ // The old code is still what is actually playing; the shared path set the editor back and showed the banner.
4388
6373
  pendingLaunch = null;
4389
6374
  if (viewState.view === 'parts') renderParts();
4390
6375
  return;
4391
6376
  }
4392
- // A page-originated evaluation has no Claude evaluate id.
4393
- playingEvaluateId = null;
4394
6377
  // onVersionPlaying (fired by the evaluate above, via trackEvalSuccess) already cleared
4395
6378
  // pendingLaunch — set the real value now that the evaluate has landed.
4396
6379
  pendingLaunch = { signature, partIndex, targetCycle: target };