moontide-mcp 2.0.1 → 2.1.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 (46) hide show
  1. package/README.md +44 -6
  2. package/dist/ask.js +30 -8
  3. package/dist/mcp.js +60 -14
  4. package/dist/protocol.js +89 -2
  5. package/dist/reference.js +22 -0
  6. package/dist/session.js +53 -0
  7. package/package.json +1 -1
  8. package/page/ask-view.js +21 -7
  9. package/page/ask.js +292 -34
  10. package/page/change-timing.js +25 -4
  11. package/page/harmony.js +13 -0
  12. package/page/index.html +4 -3
  13. package/page/jam-model.js +11 -2
  14. package/page/jam-view.js +17 -0
  15. package/page/lights.js +278 -0
  16. package/page/main.js +1723 -140
  17. package/page/midi-clock.js +1 -1
  18. package/page/midi-out.js +12 -0
  19. package/page/mixer-groups.js +114 -0
  20. package/page/mixer-model.js +172 -29
  21. package/page/mixer-rewrite.js +30 -1
  22. package/page/mixer-view.js +282 -13
  23. package/page/note-edit.js +267 -0
  24. package/page/note-locate.js +346 -0
  25. package/page/note-panel.js +381 -0
  26. package/page/note-tag.js +126 -0
  27. package/page/page-rewrite.js +184 -0
  28. package/page/song-edit.js +230 -0
  29. package/page/song-fit.js +250 -0
  30. package/page/song-grid-model.js +491 -0
  31. package/page/song-grid-view.js +710 -0
  32. package/page/song-prefs.js +100 -0
  33. package/page/structure-check.js +216 -0
  34. package/page/structure-view.js +38 -0
  35. package/page/structure.js +760 -0
  36. package/page/style.css +879 -84
  37. package/page/takeover.js +20 -5
  38. package/page/timeline-edit-view.js +1956 -0
  39. package/page/timeline-gesture.js +279 -0
  40. package/page/timeline.js +196 -3
  41. package/page/tracks.js +37 -8
  42. package/page/ui-state.js +10 -5
  43. package/page/views.js +25 -8
  44. package/reference/functions.md +1 -0
  45. package/reference/midi.md +2 -0
  46. package/reference/structure.md +131 -0
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,13 @@ import {
59
64
  setJamChordTones,
60
65
  motionAfterOsChange,
61
66
  setChangeTiming,
67
+ setSongLaunch,
68
+ BUILT_VIEWS,
62
69
  VIEW_KEYS,
70
+ VIEW_TITLES,
63
71
  } from './views.js';
64
72
  import { createHistoryView } from './history-view.js';
65
- import { MixerRewriteError, touchedRanges, writeBus, writeTrack } from './mixer-rewrite.js';
73
+ import { MixerRewriteError, applyEdits, touchedRanges, writeBus, writeTrack, writeTracks } from './mixer-rewrite.js';
66
74
  import { MIXER_COPY, buildMixerModel, clipMixerLabel, forgetBypass, readBypassRecords, rememberBypass, writeBypassRecords } from './mixer-model.js';
67
75
  import { createGestureRecorder } from './mixer-gesture.js';
68
76
  import { createPreviewSender, midiTag, readMidiTracks, writeMidiControl } from './midi-controls.js';
@@ -82,15 +90,25 @@ import {
82
90
  sectionSpans,
83
91
  } from './parts.js';
84
92
  import { baseTitle, readSessionLabel, renderSessionIdentity } from './session-identity.js';
85
- import { ASK_COPY, PAGE_TOKEN_PLACEHOLDER, cycleAt, describeContext, errorAskPrefill, lineAtY } from './ask.js';
93
+ import { ASK_COPY, PAGE_TOKEN_PLACEHOLDER, cycleAt, describeContext, errorAskPrefill, lineAtY, timelineEditPrefill } from './ask.js';
86
94
  import { createAskView } from './ask-view.js';
87
95
  import { buildReading, readingAt, flattenLanes } from './jam-analysis.js';
88
96
  import { readHarmony } from './harmony.js';
97
+ import { SONG_COPY, differsLine, quoted, readStructure, songMessageText, songNoteLines } from './structure.js';
98
+ import { checkSections, evaluateDeclared, expectedSource, sectionStarts } from './structure-check.js';
99
+ import { collectFit, evaluateFit, fitLookup, fitPairs, fitSource, pairKey } from './song-fit.js';
100
+ import { createSongView } from './structure-view.js';
101
+ import { buildSongGrid, cellRecordLabel, playingSection, sectionDeclared, songLine } from './song-grid-model.js';
102
+ import { SongEditRefusal, cellEdit, checkCell, checkSection, launchLine, launchShift, playResetEdit, sameShift, shiftEdit, textAfterCanceledLaunch } from './song-edit.js';
103
+ import { createSongGridView } from './song-grid-view.js';
104
+ import { SONG_FOLDS_COPY, isRememberable, readSongFolds, writeSongFolds } from './song-prefs.js';
89
105
  import { JAM_COPY, buildJamModel, jamStuckFor, jamIsStuck } from './jam-model.js';
90
106
  import { createJamView } from './jam-view.js';
91
107
  import { createInstrumentsView } from './instruments-view.js';
92
108
  import { instrumentsModel, readDataFolder, readInstrumentResult } from './instruments-model.js';
93
109
  import { createMidiOut, installNoMidiShim, isKnownMidiRejection } from './midi-out.js';
110
+ import { installNoteTag, subscribe as subscribeNotes } from './note-tag.js';
111
+ import { MIDI_LIGHT_OFFSET_MS, createLights, heardMapper, lightLayout, readOutputPair, usableTempo } from './lights.js';
94
112
  import {
95
113
  hhmm,
96
114
  hiddenTransition,
@@ -110,11 +128,14 @@ import {
110
128
  BEATS_PER_CYCLE,
111
129
  CHANGE_COPY,
112
130
  MAX_REPORTED_BPM,
131
+ MIN_TAKEOVER_LEAD_SECONDS,
113
132
  bpmOf,
114
133
  bpmText,
115
134
  clampPendingSeconds,
135
+ countdownParts,
116
136
  isReportableBpm,
117
137
  parseTiming,
138
+ pinnedTiming,
118
139
  queuedLine,
119
140
  settingLine,
120
141
  } from './change-timing.js';
@@ -125,6 +146,11 @@ import { createSessionView } from './session-view.js';
125
146
  import { createSplitView } from './split-view.js';
126
147
  import { SPLIT_COPY, SPLIT_DEFAULTS, clampSplit } from './split-model.js';
127
148
  import { createTakeover } from './takeover.js';
149
+ import { createPageRewrite } from './page-rewrite.js';
150
+ import { PLACES_MAX_CYCLES, classifyNote, indexCode, perCycleCount, placesOf, readerCounter, trackScope } from './note-locate.js';
151
+ import { NoteEditStop, writeNoteEdit } from './note-edit.js';
152
+ import { NOTE_MARK_HEIGHT } from './timeline-gesture.js';
153
+ import { createTimelineEditView } from './timeline-edit-view.js';
128
154
  import { createEvaluateTag } from './evaluate-tag.js';
129
155
 
130
156
  await customElements.whenDefined('strudel-editor');
@@ -385,6 +411,14 @@ const takeover = createTakeover({
385
411
  }
386
412
  },
387
413
  onCancel(record, reason) {
414
+ // A section launch that waited for this record is over, whatever the reason.
415
+ const ownLaunch = gridLaunch !== null && gridLaunch.record === record;
416
+ if (ownLaunch) {
417
+ gridLaunch = null;
418
+ gridQueued = null;
419
+ }
420
+ // A cell change that waited for this record is over too: nothing was recorded for it.
421
+ if (gridCellWait !== null && gridCellWait.record === record) gridCellWait = null;
388
422
  // The setup that did not go out is forgotten; one that went out (stamped on the line, it cannot be recalled) is
389
423
  // answered by the playing code's sounds and controls sent just after it. The clock serves the playing code's
390
424
  // instruments only again.
@@ -406,7 +440,7 @@ const takeover = createTakeover({
406
440
  // The server learns about a waiting change that was stopped or replaced, but only when it already heard the
407
441
  // reply that announced it (a change canceled before its reply says so in the reply itself). Sent here,
408
442
  // 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
443
+ // and before the reply of the newer change. Any other reason (superseded, unload, canceled) sends nothing: the
410
444
  // server drops the change itself on detach and attach.
411
445
  if ((reason === 'stop' || reason === 'replaced') && record.evaluateId !== null && record.replied === true) {
412
446
  sendIfOpen(JSON.stringify({ type: 'takeover-canceled', id: record.evaluateId, reason }));
@@ -418,10 +452,16 @@ const takeover = createTakeover({
418
452
  if (reason !== 'replaced' && scheduler.pattern === record.gate) scheduler.pattern = record.playingPattern;
419
453
  // The repl still holds the canceled change as committed: the readers keep the music that played. A replaced
420
454
  // 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') {
455
+ if (reason === 'stop' || reason === 'superseded' || reason === 'unload' || reason === 'canceled') {
422
456
  canceledPlayingCode = record.playingCode;
423
457
  canceledPlayingPattern = record.playingPattern;
424
458
  }
459
+ // The person's own cancel of a waiting section launch: the music keeps playing, and the editor goes back to the code
460
+ // that plays (the launch wrote only the shift into it), unless the person typed over that text meanwhile: typing that
461
+ // was never evaluated is never replaced. Nothing is sent to the server: a launch has no evaluate id and was never
462
+ // recorded. A Stop while the launch of the grid waits does the same: the shift it wrote would never play (Play writes
463
+ // shift 0), and left in the editor it would read as typing that was never evaluated and lock the grid.
464
+ if (reason === 'canceled' || (reason === 'stop' && ownLaunch)) putBackCanceledLaunch(record);
425
465
  // A replaced record has a successor that is armed (its onArmed shows the wait again) or takes over at once
426
466
  // (its takeover redraws the views): here the Mixer only stops saying "waiting", with no redraw from the code
427
467
  // that is being evaluated.
@@ -430,7 +470,7 @@ const takeover = createTakeover({
430
470
  // The countdown always ends with the wait. Stop says so once; any other reason clears the line silently (a
431
471
  // replacing change says its own line right after, an immediate one needs none).
432
472
  topBarUi.showCountdown(null);
433
- if (reason === 'stop') topBarUi.say(CHANGE_COPY.canceled);
473
+ if (reason === 'stop' || reason === 'canceled') topBarUi.say(CHANGE_COPY.canceled);
434
474
  else topBarUi.clearStatus();
435
475
  },
436
476
  onError: (text, err) => console.error(text, err),
@@ -470,6 +510,7 @@ function drawTopBar() {
470
510
  topBarUi.showTempo(idleTempo);
471
511
  topBarUi.showCycleBeat(null);
472
512
  topBarUi.showCountdown(null);
513
+ syncGridQueued(null);
473
514
  return;
474
515
  }
475
516
  const record = takeover.pending();
@@ -504,9 +545,34 @@ function drawTopBar() {
504
545
  topBarUi.showCycleBeat(cycleBeat(now));
505
546
  if (record === null || record.at === undefined || record.boundary - now <= 0) {
506
547
  topBarUi.showCountdown(null);
548
+ syncGridQueued(now);
507
549
  return;
508
550
  }
509
551
  topBarUi.showCountdown({ beatsLeft: (record.boundary - now) * BEATS_PER_CYCLE, at: record.at });
552
+ syncGridQueued(now);
553
+ }
554
+
555
+ /**
556
+ * Hands the queued section header of the Song grid its countdown for this poll: the rest of the top bar's text
557
+ * (`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
558
+ * never disagree. `now` is null while nothing plays. The grid is written in the same turn when the text changed.
559
+ */
560
+ function syncGridQueued(now) {
561
+ let next = null;
562
+ if (now !== null && gridLaunch !== null && takeover.pending() === gridLaunch.record && gridLaunch.record.boundary - now > 0) {
563
+ next = {
564
+ index: gridLaunch.index,
565
+ countdownText: countdownParts((gridLaunch.record.boundary - now) * BEATS_PER_CYCLE).rest,
566
+ line: gridLaunch.record.boundary,
567
+ };
568
+ } else if (now !== null && gridAlready !== null && gridAlready.line - now > 0) {
569
+ // A launch that needs no code change counts down to its natural arrival with the same words.
570
+ next = { index: gridAlready.index, countdownText: countdownParts((gridAlready.line - now) * BEATS_PER_CYCLE).rest, line: gridAlready.line };
571
+ }
572
+ const same = gridQueued === next || (gridQueued !== null && next !== null && gridQueued.index === next.index && gridQueued.countdownText === next.countdownText && gridQueued.line === next.line);
573
+ if (same) return;
574
+ gridQueued = next;
575
+ if (songGridReading !== null) updateSongGridLive();
510
576
  }
511
577
 
512
578
  // The always-on page poll (every 250 ms, for the life of the page). It finishes a waiting change whose line the
@@ -521,6 +587,16 @@ setInterval(() => {
521
587
  } catch (err) {
522
588
  console.error('Moontide: the top bar could not be drawn:', err);
523
589
  }
590
+ try {
591
+ pollSongGrid();
592
+ } catch (err) {
593
+ console.error('Moontide: the song grid could not follow the page:', err);
594
+ }
595
+ try {
596
+ pollTimelineEdit();
597
+ } catch (err) {
598
+ console.error('Moontide: the Timeline note panel could not follow the page:', err);
599
+ }
524
600
  }, 250);
525
601
 
526
602
  // The program has already run when the check refuses it (or when evaluate fails later), so a
@@ -737,6 +813,56 @@ const mixerViewEl = document.getElementById('mixer-view');
737
813
  const partsView = document.getElementById('parts-view');
738
814
  const partsMessage = document.getElementById('parts-message');
739
815
  const partsRows = document.getElementById('parts-rows');
816
+ const songNotesEl = document.getElementById('song-notes');
817
+ if (!songNotesEl) throw new Error('Moontide: the song notes element is missing from the page.');
818
+ // The lines above the song grid in the Parts view; they draw what page/structure.js read from the code.
819
+ const songUi = createSongView({ document, notes: songNotesEl });
820
+ // The Song grid that replaces the Parts rows for a piece that declares a readable song. Its handlers are added as each
821
+ // gesture gets its behaviour; a gesture without one throws, so nothing is ignored silently.
822
+ const songGridHandlers = {};
823
+ const songBarEl = document.getElementById('song-bar');
824
+ const songNowEl = document.getElementById('song-now');
825
+ const songLaunchEl = document.getElementById('song-launch');
826
+ const songLaunchLabelEl = document.getElementById('song-launch-label');
827
+ const songLineEl = document.getElementById('song-line');
828
+ const songCellListEl = document.getElementById('song-cell-list');
829
+ if (!songBarEl || !songNowEl || !songLaunchEl || !songLaunchLabelEl || !songLineEl || !songCellListEl) throw new Error('Moontide: the song toolbar elements are missing from the page.');
830
+ songLaunchLabelEl.textContent = SONG_COPY.launchLabel;
831
+ const songGridUi = createSongGridView({ document, rows: partsRows, bar: songBarEl, now: songNowEl, line: songLineEl, launchGroup: songLaunchEl, list: songCellListEl, handlers: songGridHandlers });
832
+ // The note panel under the Timeline lanes and the selection on the lanes. A click picks a note, the panel says whether the page
833
+ // 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
834
+ // needs to know is read in describeTimelineNote; the edit itself runs on the serial chain (editTimelineNote), further down.
835
+ const timelineNotePanel = document.getElementById('timeline-note');
836
+ const timelineNoteLine = document.getElementById('timeline-note-line');
837
+ if (!timelineNotePanel || !timelineNoteLine) throw new Error('Moontide: the Timeline note panel is missing from the page.');
838
+ const timelineEditUi = createTimelineEditView({
839
+ document,
840
+ lanes: timelineLanes,
841
+ panel: timelineNotePanel,
842
+ line: timelineNoteLine,
843
+ rootFontPx: () => parseFloat(getComputedStyle(document.documentElement).fontSize),
844
+ // The pause that gathers the steps of a key or a button runs on the page's own timers.
845
+ setTimer: (fn, ms) => setTimeout(fn, ms),
846
+ clearTimer: (handle) => clearTimeout(handle),
847
+ handlers: {
848
+ describe: (laneIndex, eventIndex) => describeTimelineNote(laneIndex, eventIndex),
849
+ // The view hands over one edit at a time and waits for its outcome, which is given back with the same id.
850
+ edit: (info, change, id) => {
851
+ chain = chain.then(() => editTimelineNote(info, change, id)).catch((err) => reportChainError('timeline edit', err));
852
+ },
853
+ // A gesture on a note the page cannot edit becomes a ready-made request in the Ask box; nothing is written or sent.
854
+ prepare: (info, change, point) => askAboutTimelineNote(info, timelineGestureOf(change), point),
855
+ ask: (info, point) => askAboutTimelineNote(info, undefined, point),
856
+ // A range of cycles on one lane opens the Ask box with the lane, the cycles and the notes in them.
857
+ range: (selection, point) => askAboutTimelineRange(selection, point),
858
+ // What the panel says of a range: the lane, the sections of its ends and how many notes start in it.
859
+ describeRange: (selection) => describeTimelineRange(selection),
860
+ // The keyboard cursor on a lane with no note on the page: the lane's own context at the cursor's cycle.
861
+ askLane: (laneIndex, cycle, point) => askAboutTimelineLane(laneIndex, cycle, point),
862
+ },
863
+ });
864
+ // A drag on the lanes ends with no write when the window loses focus.
865
+ window.addEventListener('blur', () => timelineEditUi.cancelGesture());
740
866
  const jamViewEl = document.getElementById('jam-view');
741
867
  const instrumentsViewEl = document.getElementById('instruments-view');
742
868
  const editorArea = document.getElementById('editor-area');
@@ -900,7 +1026,11 @@ renderSessionIdentity(document, sessionLabel, (text) => console.error(text));
900
1026
  // The Ask Claude box. Created before any code path that can call into it.
901
1027
  const askUi = createAskView({
902
1028
  document,
903
- send: (message) => sendIfOpen(JSON.stringify(message)),
1029
+ send: (message) => {
1030
+ sendIfOpen(JSON.stringify(message));
1031
+ // A sent request about a range of the Timeline is done with it: the wash goes.
1032
+ if (message.type === 'ask' && message.context.kind === 'range') timelineEditUi.clearRange();
1033
+ },
904
1034
  reportError: showAskProblem,
905
1035
  clearError: clearAskProblem,
906
1036
  setTimer: (fn, ms) => setTimeout(fn, ms),
@@ -1114,6 +1244,23 @@ function buildViewButtons() {
1114
1244
  }
1115
1245
  buildViewButtons();
1116
1246
 
1247
+ // The names the top-bar view buttons show now: the Parts view is called Song while the code declares a song.
1248
+ const viewTitles = { ...VIEW_TITLES };
1249
+
1250
+ /** Calls a view by `name` on its top-bar button (the visible name, the accessible name and the tooltip) and lets the bar
1251
+ * fit again, since the name may have another width. The key and the view itself do not change. */
1252
+ function setViewTitle(view, name) {
1253
+ if (viewTitles[view] === name) return;
1254
+ viewTitles[view] = name;
1255
+ const part = viewButtonParts(BUILT_VIEWS, viewTitles).find((candidate) => VIEW_KEYS[candidate.key] === view);
1256
+ const button = viewButtonsEl.querySelector(`button[data-key="${part.key}"]`);
1257
+ if (button === null) throw new Error(`Moontide: the top-bar button for the '${view}' view is missing.`);
1258
+ button.querySelector('.view-name').textContent = part.name;
1259
+ button.setAttribute('aria-label', part.label);
1260
+ button.title = part.title;
1261
+ topBarUi.fit();
1262
+ }
1263
+
1117
1264
  // True once stopSoundLocally() has run and no successful evaluate has
1118
1265
  // restarted playback since. stopCount lets handleEvaluate detect a Stop that
1119
1266
  // landed while a set_code was still in flight. Declared here, ahead of refreshSoundState's
@@ -1140,6 +1287,47 @@ let pendingLaunch = null;
1140
1287
  // or width changes). Rebuilt on every renderParts() call (a new version, or a fresh view open).
1141
1288
  let partsCardRefs = [];
1142
1289
 
1290
+ // The reading the Song grid on screen was drawn from, or null while no grid is drawn: the live tick needs its shift and
1291
+ // lengths to tell which section plays.
1292
+ let songGridReading = null;
1293
+ // Whether the last live update found sound playing, so the grid scrolls the playing section into view once at Play.
1294
+ let songGridWasPlaying = false;
1295
+ // The section launch that waits for its line, `{ record, index, name, line, at }`: `record` is the takeover's waiting change,
1296
+ // `line` the cycle it takes over on. Null when none waits. Cleared when the record takes over or is dropped.
1297
+ let gridLaunch = null;
1298
+ // The change of a cell that waits for its line, `{ record, track, section, choice, at }`: `section` is the section's name (every
1299
+ // column of that name shows it), `choice` is `{ key }` or `{ rest: true }`, `at` the timing it waits on. Null when none waits.
1300
+ // Cleared when the record takes over or is dropped.
1301
+ let gridCellWait = null;
1302
+ // What the queued section header shows on this poll, `{ index, countdownText, line }`, or null. Computed once per poll of the
1303
+ // top bar from the same record and the same cycle as the bar's countdown, so the header and the bar always agree.
1304
+ let gridQueued = null;
1305
+ // True while a launch is being written and evaluated: nothing else reads the editor as "unevaluated typing" meanwhile.
1306
+ let gridBusy = false;
1307
+ // Why the last gesture on the Song grid was refused, as an id, or null (nothing was changed).
1308
+ let gridRefused = null;
1309
+ // A launch that needs no code change, `{ index, name, line }`: the section already starts on the line the person chose, so
1310
+ // nothing is written or waits in the takeover; the header counts down to the line until the scheduler reaches it.
1311
+ let gridAlready = null;
1312
+ // The sentence under the toolbar for this poll (the result of songLine), and whether the editor held text that was not
1313
+ // evaluated at the last poll that could tell.
1314
+ let gridLine = null;
1315
+ let gridDirty = false;
1316
+
1317
+ // The song structure read from the code of the version that plays, `{ generation, code, reading }`, or null: read once
1318
+ // per version in onVersionPlaying (the Parts view uses it, the 250 ms tick never parses). structureNoteSent is true
1319
+ // while the last structure note sent to the server held lines, so a note that clears them is sent too.
1320
+ let songReading = null;
1321
+ let structureNoteSent = false;
1322
+ // The line the declared-against-played check gave for the version that plays, `{ generation, entry }` (entry is a line for
1323
+ // #song-notes, or null when what plays agrees with the declaration), or null while the check has not answered.
1324
+ let songCheck = null;
1325
+ // Whether the notes of each fixed variation fit the harmony of the sections it plays in, for the version that plays:
1326
+ // `{ generation, reading, answer }` with the answer of collectFit (variation to masks or reason) once the query has
1327
+ // answered. It is null while the query is pending, and for a version with no fixed variation: the grid then shows no fit
1328
+ // words at all.
1329
+ let songFit = null;
1330
+
1143
1331
  // The history id of the code this page has loaded, and whether a version has ever
1144
1332
  // played in this page session — both feed versionIndicator(). Declared here, ahead of
1145
1333
  // refreshSoundState's first call below, for the same reason as pageStopped above.
@@ -1231,14 +1419,15 @@ async function runProgrammaticEvaluate(timing = NOW_TIMING) {
1231
1419
  * keeps trackHandEval from reporting each tick as a hand evaluation, which would record one
1232
1420
  * 'hand' version per tick.
1233
1421
  */
1234
- async function runMixerEvaluate(code, autostart) {
1422
+ async function runMixerEvaluate(code, autostart, timing = NOW_TIMING) {
1235
1423
  programmaticEvalInFlight = true;
1236
- armRun(NOW_TIMING);
1424
+ const run = armRun(timing);
1237
1425
  try {
1238
1426
  await editor.repl.evaluate(code, autostart);
1239
1427
  } finally {
1240
1428
  programmaticEvalInFlight = false;
1241
1429
  }
1430
+ return run;
1242
1431
  }
1243
1432
 
1244
1433
  /**
@@ -1396,6 +1585,20 @@ function applyRig(rig) {
1396
1585
  // Tracks the current sound-state slot ('off' | 'silent' | 'playing').
1397
1586
  let currentSoundState = 'off';
1398
1587
 
1588
+ // The lights of the Mixer (page/lights.js holds their state, the Mixer view draws them). They are declared here, before
1589
+ // refreshSoundState first runs, because that function ends in syncLights. `lightsActive` is true while the loop runs: the Mixer
1590
+ // is open, the tab is visible and sound is playing. `lightsViewReady` stays false until the view state and the Mixer exist,
1591
+ // so nothing below touches them too early. `lightsMotion` is the motion the running lights were started with.
1592
+ const lights = createLights();
1593
+ let lightsActive = false;
1594
+ let lightsRafId = null;
1595
+ let lightsMotion = 'on';
1596
+ let lightsViewReady = false;
1597
+ let lightsBroken = false;
1598
+ // True while the tempo is not above 0: the loop runs and draws nothing. The warning about a missing output timestamp is said once.
1599
+ let lightsPaused = false;
1600
+ let lightsPairWarned = false;
1601
+
1399
1602
  /** Applies versionIndicator()'s result — textContent and the hidden property only, never a data-accent (the indicator is never accented). */
1400
1603
  function renderVersionIndicator() {
1401
1604
  const { hidden, text } = versionIndicator({
@@ -1459,6 +1662,7 @@ function refreshSoundState() {
1459
1662
  const started = !!(editor.repl && editor.repl.state && editor.repl.state.started);
1460
1663
  currentSoundState = soundState({ audioRunning, started, stopped: pageStopped });
1461
1664
  renderSoundState();
1665
+ syncLights();
1462
1666
  }
1463
1667
 
1464
1668
  renderConnectionState('connecting');
@@ -1593,6 +1797,7 @@ async function stopSoundLocally({ panicFirst = false } = {}) {
1593
1797
  pageStopped = true;
1594
1798
  stopCount++;
1595
1799
  pendingLaunch = null;
1800
+ gridAlready = null;
1596
1801
  editor.repl.scheduler.stop();
1597
1802
  refreshSoundState();
1598
1803
  try {
@@ -1668,6 +1873,34 @@ async function reportHandEval(state, code = editor.repl.state.activeCode) {
1668
1873
  sendIfOpen(JSON.stringify({ type: 'hand-eval', ok: true, code }));
1669
1874
  }
1670
1875
 
1876
+ /**
1877
+ * Play after Stop on a piece that declares a song: the scheduler's cycle count restarts at 0 at every Play, so a shift a
1878
+ * launch (or a person) left in the code would start the music inside the structure. The shift literal of the editor text is
1879
+ * written back to 0 before Play evaluates the text, which then records one hand edit like any other; with the shift already
1880
+ * 0 nothing is written and Play records nothing new. Only the shift literal is ever replaced. Text that does not parse or
1881
+ * does not read as a song plays as written (the evaluation says what is wrong with it); anything else that throws is
1882
+ * reported on the console and Play goes on. Ctrl+Enter in the editor is an evaluation, not a Play, and does not come here.
1883
+ */
1884
+ function resetSongShiftForPlay() {
1885
+ if (!window.acorn || typeof window.acorn.parse !== 'function') return;
1886
+ const text = editor.code;
1887
+ try {
1888
+ const reading = readSongOf(text);
1889
+ if (reading.state !== 'read') return;
1890
+ const edits = playResetEdit(reading);
1891
+ if (edits === null) return;
1892
+ const expected = applyEdits(text, edits);
1893
+ editor.editor.dispatch({ changes: edits });
1894
+ if (editor.code !== expected) {
1895
+ editor.setCode(text);
1896
+ console.error('Moontide: Play could not write shift 0 into the code; the code plays as written.');
1897
+ }
1898
+ } catch (err) {
1899
+ if (err instanceof SyntaxError) return;
1900
+ console.error('Moontide: Play could not reset the shift of the song; the code plays as written:', err);
1901
+ }
1902
+ }
1903
+
1671
1904
  // UI Consideration loading/transport-button: Stop is a synchronous local action — the sound
1672
1905
  // state flips to 'Stopped' the instant stopSoundLocally() sets pageStopped and calls
1673
1906
  // refreshSoundState(), with no network round trip awaited first. Not routed through the
@@ -1701,6 +1934,8 @@ transportButton.addEventListener('click', (event) => {
1701
1934
  if (typeof globalThis.getAudioContext === 'function') {
1702
1935
  globalThis.getAudioContext().resume().catch((err) => console.error('Moontide: AudioContext.resume() failed:', err));
1703
1936
  }
1937
+ // Play after Stop starts a piece that declares a song from its first section.
1938
+ if (pageStopped) resetSongShiftForPlay();
1704
1939
  Promise.resolve(editor.evaluate())
1705
1940
  .then(() => {
1706
1941
  // A failed evaluation leaves its error on the repl state (the error line shows it) and the word stays Stopped.
@@ -1778,6 +2013,25 @@ if (typeof navigator.requestMIDIAccess !== 'function') {
1778
2013
  }
1779
2014
  }
1780
2015
 
2016
+ // Every note learns which labeled track played it, and the note tag tells whoever subscribes (the lights).
2017
+ // Installed once, here, because the tag is added when a labeled statement is evaluated: it cannot be switched
2018
+ // on later for code that already ran. Without Web MIDI no note has a port timestamp.
2019
+ try {
2020
+ installNoteTag(globalThis.Pattern, {
2021
+ stampOf: typeof navigator.requestMIDIAccess === 'function' ? (hap) => midiOut.noteStampOf(hap) : () => null,
2022
+ });
2023
+ } catch (err) {
2024
+ console.error(err);
2025
+ }
2026
+
2027
+ // Every note of a labeled track goes to the lights while they are active. Otherwise it is dropped at once, so notes played
2028
+ // while the Mixer is closed, the tab is hidden or the music is stopped never wait to be drawn later. A MIDI note carries the
2029
+ // time its port got; a browser sound carries the audio time it is due, which the lights map to when it is heard.
2030
+ subscribeNotes((event) => {
2031
+ if (!lightsActive) return;
2032
+ lights.noteSeen(event.key, event.stamp !== null ? { at: event.stamp + MIDI_LIGHT_OFFSET_MS } : { target: event.target });
2033
+ });
2034
+
1781
2035
  // Start enabling Web MIDI at page start only when the browser already says the permission is
1782
2036
  // granted (no prompt can appear then). Otherwise the first evaluation whose code has a .midi( call
1783
2037
  // asks. Never awaited here: requestMIDIAccess can hang with some device drivers, and the WebSocket
@@ -1825,6 +2079,21 @@ document.addEventListener('strudel.log', (event) => {
1825
2079
  sendRuntimeError(text);
1826
2080
  });
1827
2081
 
2082
+ /**
2083
+ * Sends the lines the page read from the song structure of the code that plays (an empty list clears the last note),
2084
+ * tagged with the evaluate id whose code was playing (`evaluateId`, which a late caller captured when its version started).
2085
+ * The message shape is mirrored by hand from src/protocol.ts. Called from onVersionPlaying, which runs inside repl.evaluate,
2086
+ * so the server has the note before the evaluated reply; the check of the declaration sends the version's full lines later.
2087
+ */
2088
+ function sendStructureNote(lines, evaluateId = playingEvaluateId) {
2089
+ const payload = { type: 'structure-note', lines };
2090
+ if (evaluateId !== null) {
2091
+ payload.evaluateId = evaluateId;
2092
+ }
2093
+ sendIfOpen(JSON.stringify(payload));
2094
+ structureNoteSent = lines.length > 0;
2095
+ }
2096
+
1828
2097
  /** Sends one runtime error to the server, tagged with the evaluate id whose code was playing. */
1829
2098
  function sendRuntimeError(text) {
1830
2099
  const payload = { type: 'runtime-error', message: text };
@@ -1931,7 +2200,13 @@ const midiPreview = createPreviewSender({
1931
2200
  clearTimer: (timer) => clearTimeout(timer),
1932
2201
  now: () => performance.now(),
1933
2202
  });
1934
- const mixerUi = createMixerView({ document, gesture: mixerGesture, midiPreview, onStripSelect: () => syncMixerLit({ reveal: true }) });
2203
+ const mixerUi = createMixerView({
2204
+ document,
2205
+ gesture: mixerGesture,
2206
+ midiPreview,
2207
+ onStripSelect: () => syncMixerLit({ reveal: true }),
2208
+ onGroupFold: (key) => toggleSongFold(key),
2209
+ });
1935
2210
  mixerUi.setConnected(ws.readyState === ws.OPEN ? 'connected' : 'connecting');
1936
2211
  askUi.setConnected(ws.readyState === ws.OPEN ? 'connected' : 'connecting');
1937
2212
 
@@ -2081,9 +2356,7 @@ ws.addEventListener('message', (event) => {
2081
2356
  // does (this is the page's first evaluation attempt since (re)load, so
2082
2357
  // hasEvaluatedSuccessfully is still false and the revert target is always '').
2083
2358
  // 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);
2359
+ const { errorText, line } = revertAndReport(evalError);
2087
2360
  const payload = { type: 'reconnect-eval-failed', error: errorText };
2088
2361
  if (line !== undefined) {
2089
2362
  payload.line = line;
@@ -2237,6 +2510,20 @@ function buildEvalErrorReport(evalError) {
2237
2510
  return { errorText: String(evalError.message || evalError), line: evalErrorLine(evalError) };
2238
2511
  }
2239
2512
 
2513
+ /**
2514
+ * The one place that takes a failed change back out of the editor: sets the text to the committed code (the music
2515
+ * that is audible), or to nothing before any code has played, and shows the failed-change banner. Returns the
2516
+ * report, so a caller that tells the server about the failure sends the same text and line. Every failed
2517
+ * evaluation of code the page wrote or received ends here: Claude's change, the reconnect replay and the page's own
2518
+ * rewrites.
2519
+ */
2520
+ function revertAndReport(evalError) {
2521
+ editor.setCode(hasEvaluatedSuccessfully ? committedCode() : '');
2522
+ const report = buildEvalErrorReport(evalError);
2523
+ showEvalError(report.errorText);
2524
+ return report;
2525
+ }
2526
+
2240
2527
  async function handleEvaluate(msg) {
2241
2528
  // An unknown timing is refused before anything changes (the page mirrors src/protocol.ts by hand).
2242
2529
  let timing;
@@ -2279,9 +2566,7 @@ async function handleEvaluate(msg) {
2279
2566
  // still audible. Never call editor.evaluate() here: the previous pattern is still
2280
2567
  // playing, re-running it is unnecessary and risks re-triggering audio. A failed
2281
2568
  // evaluate leaves pageStopped and the silence unchanged.
2282
- editor.setCode(hasEvaluatedSuccessfully ? committedCode() : '');
2283
- const { errorText, line } = buildEvalErrorReport(evalError);
2284
- showEvalError(errorText);
2569
+ const { errorText, line } = revertAndReport(evalError);
2285
2570
  const payload = {
2286
2571
  type: 'evaluated',
2287
2572
  id: msg.id,
@@ -2386,6 +2671,41 @@ function sendMixerRecord(code, label) {
2386
2671
  ws.send(JSON.stringify({ type: 'mixer-record', code, label: clipMixerLabel(label) }));
2387
2672
  }
2388
2673
 
2674
+ // The one path through which the page changes the code by itself (page/page-rewrite.js): the Mixer and the
2675
+ // part launch call it, and each keeps its own wording for a refusal and its own message to the server.
2676
+ const pageRewrite = createPageRewrite({
2677
+ editor: {
2678
+ get code() {
2679
+ return editor.code;
2680
+ },
2681
+ setCode(text) {
2682
+ editor.setCode(text);
2683
+ },
2684
+ // The edit list changes the text in place, keeping the selection and the scroll.
2685
+ dispatch(edits) {
2686
+ editor.editor.dispatch({ changes: edits });
2687
+ },
2688
+ },
2689
+ hasEvaluated: () => hasEvaluatedSuccessfully,
2690
+ pending: () => takeover.pending(),
2691
+ playingCode,
2692
+ socketOpen: () => ws.readyState === ws.OPEN,
2693
+ parserReady: () => Boolean(window.acorn) && typeof window.acorn.parse === 'function',
2694
+ evaluate({ code, timing, autostart, flash }) {
2695
+ return flash ? runProgrammaticEvaluate(timing) : runMixerEvaluate(code, autostart, timing);
2696
+ },
2697
+ evalError: () => editor.repl.state.evalError,
2698
+ revertAndReport,
2699
+ // A page-originated evaluation has no Claude evaluate id.
2700
+ markPageOrigin() {
2701
+ playingEvaluateId = null;
2702
+ },
2703
+ whenFinished: (record, fn) => takeover.whenFinished(record, fn),
2704
+ markPageOriginAtTakeover() {
2705
+ evaluateTag.fromTakeover(null);
2706
+ },
2707
+ });
2708
+
2389
2709
  /**
2390
2710
  * Applies ONE Mixer change to the playing code, immediately. Runs only inside the serial `chain`
2391
2711
  * (or from mixerGesture.flushDirect, itself called from inside it), so the editor cannot change
@@ -2395,40 +2715,29 @@ function sendMixerRecord(code, label) {
2395
2715
  * (the user's typing is never overwritten). A rewrite the mixer refuses is logged with its reason.
2396
2716
  */
2397
2717
  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;
2718
+ // onVersionPlaying clears pendingLaunch on every live tick, but a mixer write never moves a
2719
+ // part's offset: a launch still counting down must survive it (set back after the evaluation).
2720
+ const launchBefore = pendingLaunch;
2721
+ mixerTickInFlight = true;
2722
+ let outcome;
2723
+ let removed;
2425
2724
  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 });
2725
+ // A mixer move is not a request to play: never resumeAfterStop(), and no autostart while stopped.
2726
+ outcome = await pageRewrite.apply({
2727
+ autostart: !pageStopped,
2728
+ build(activeCode) {
2729
+ // A bus parameter is written on every track that sends to the bus, a MIDI control on every track of its
2730
+ // 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.
2731
+ const parse = window.acorn.parse;
2732
+ let result;
2733
+ if (change.type === 'bus-param') result = writeBus(activeCode, change, { parse });
2734
+ else if (change.type === 'midi-control') result = writeMidiControl(activeCode, change, { parse });
2735
+ else if (change.type === 'group') result = writeTracks(activeCode, change.identities, { type: change.kind, on: change.on }, { parse });
2736
+ else result = writeTrack(activeCode, change.identity, change, { parse });
2737
+ removed = result.removed;
2738
+ return result;
2739
+ },
2740
+ });
2432
2741
  } catch (err) {
2433
2742
  if (!(err instanceof MixerRewriteError)) {
2434
2743
  console.error('Moontide: the mixer could not read the playing code:', err);
@@ -2438,43 +2747,49 @@ async function applyMixerChangeNow(change) {
2438
2747
  showMixerProblem(err.message);
2439
2748
  if (viewState.view === 'mixer') renderMixer();
2440
2749
  return null;
2750
+ } finally {
2751
+ mixerTickInFlight = false;
2441
2752
  }
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);
2753
+ if (outcome.status === 'refused') {
2754
+ switch (outcome.reason) {
2755
+ case 'not-played':
2756
+ console.error('Moontide: the mixer has nothing to change yet: no code has played.');
2757
+ break;
2758
+ case 'waiting':
2759
+ // The waiting change must not be played or recorded by a move; the Mixer says so in its notice.
2760
+ console.error('Moontide: the mixer change was not applied: a change is waiting for its bar line.');
2761
+ break;
2762
+ case 'closed':
2763
+ console.error('Moontide: the mixer change was not applied: the socket is not open.');
2764
+ break;
2765
+ case 'no-parser':
2766
+ console.error('Moontide: window.acorn is not available — cannot change the mixer.');
2767
+ break;
2768
+ case 'dirty':
2769
+ // The authoritative guard against overwriting a hand edit that was not evaluated.
2770
+ console.error('Moontide: the mixer change was not applied: the editor holds a hand edit that was not evaluated.');
2771
+ // The poll only explains; this is the authoritative check, so it turns the notice on itself.
2772
+ mixerUi.setDirty(true);
2773
+ if (viewState.view === 'mixer') renderMixer();
2774
+ break;
2775
+ default:
2776
+ console.error('Moontide: the editor text does not match the mixer rewrite; reverting.');
2777
+ }
2450
2778
  return null;
2451
2779
  }
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;
2780
+ if (outcome.status === 'unchanged') {
2781
+ return { played: false, code: outcome.code };
2461
2782
  }
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);
2783
+ if (outcome.status === 'failed') {
2784
+ // The old code is still what plays; the shared path already set the editor back and showed the banner.
2468
2785
  return null;
2469
2786
  }
2470
- // A page-originated evaluation has no Claude evaluate id.
2471
- playingEvaluateId = null;
2472
2787
  pendingLaunch = launchBefore;
2473
- updateBypassRecords(change, result.removed);
2788
+ updateBypassRecords(change, removed);
2474
2789
  const playing = editor.repl.state.activeCode;
2475
2790
  // Mirrors src/protocol.ts's mixer-live by hand: get_code returns the code that plays now.
2476
2791
  sendIfOpen(JSON.stringify({ type: 'mixer-live', code: playing }));
2477
- flashMixerLines(result.edits);
2792
+ flashMixerLines(outcome.edits);
2478
2793
  return { played: true, code: playing };
2479
2794
  }
2480
2795
 
@@ -2710,23 +3025,43 @@ function syncWaitingViews() {
2710
3025
  }
2711
3026
  }
2712
3027
 
3028
+ /**
3029
+ * The song structure the Mixer draws groups from, for `code`: the reading made when this version took over when it belongs
3030
+ * 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.
3031
+ */
3032
+ function mixerSongReading(code) {
3033
+ if (songReading !== null && songReading.code === code) return songReading.reading;
3034
+ if (code === '') return { state: 'none' };
3035
+ try {
3036
+ const parse = window.acorn.parse;
3037
+ return readStructure(code, { parse, tracks: extractTracks(code, { parse }) });
3038
+ } catch (err) {
3039
+ console.error('Moontide: the mixer could not read the song structure:', err);
3040
+ return { state: 'none' };
3041
+ }
3042
+ }
3043
+
2713
3044
  /** Draws the Mixer for the code that is playing now. */
2714
3045
  function renderMixer() {
2715
3046
  checkMixerDirty();
2716
3047
  if (!window.acorn || typeof window.acorn.parse !== 'function') {
2717
3048
  console.error('Moontide: window.acorn is not available — the mixer cannot read the code.');
2718
3049
  mixerUi.render({ status: 'unreadable', strips: [] });
3050
+ lights.setLayout(lightLayout({ status: 'unreadable', strips: [] }, { webMidi: false }));
2719
3051
  syncMixerLit();
2720
3052
  return;
2721
3053
  }
3054
+ const code = hasEvaluatedSuccessfully ? playingCode() : '';
2722
3055
  const model = buildMixerModel({
2723
- code: hasEvaluatedSuccessfully ? playingCode() : '',
3056
+ code,
2724
3057
  parse: window.acorn.parse,
2725
3058
  colorOf: (identity) => trackColors.colorOf(identity),
2726
3059
  bypassRecords: mixerBypassRecords,
2727
3060
  busMemory: mixerBusMemory,
2728
3061
  keepBus: mixerUi.selectedBus(),
2729
3062
  rig: pageRig,
3063
+ song: mixerSongReading(code),
3064
+ folds: songFolds,
2730
3065
  });
2731
3066
  mixerBusMemory = model.busMemory;
2732
3067
  if (model.error) console.error('Moontide: the mixer could not read the playing code:', model.error);
@@ -2736,6 +3071,8 @@ function renderMixer() {
2736
3071
  if (!writeBypassRecords(storage, mixerBypassRecords)) reportStorageUnavailableOnce();
2737
3072
  }
2738
3073
  mixerUi.render(model);
3074
+ // The lights learn which tracks the Mixer shows, which of them sound, and which groups hold them.
3075
+ lights.setLayout(lightLayout(model, { webMidi: typeof navigator.requestMIDIAccess === 'function' }));
2739
3076
  // Every render moves the one light onto the selected strip's block (or clears it): the strip may have left the model.
2740
3077
  syncMixerLit();
2741
3078
  }
@@ -2761,7 +3098,7 @@ let storageUnavailableLogged = false;
2761
3098
  function reportStorageUnavailableOnce() {
2762
3099
  if (storageUnavailableLogged) return;
2763
3100
  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.');
3101
+ 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
3102
  }
2766
3103
 
2767
3104
  const initialPrefs = readViewPrefs(storage);
@@ -2770,6 +3107,14 @@ if (!initialPrefs.available) reportStorageUnavailableOnce();
2770
3107
  const initialBypass = readBypassRecords(storage);
2771
3108
  mixerBypassRecords = initialBypass.records;
2772
3109
  if (!initialBypass.available) reportStorageUnavailableOnce();
3110
+ // The groups of the Song grid that are folded, as group keys. A stored value that cannot be read is reported once and
3111
+ // ignored, so every group starts open.
3112
+ const initialFolds = readSongFolds(storage);
3113
+ if (!initialFolds.available) reportStorageUnavailableOnce();
3114
+ else if (initialFolds.rejected) console.error(SONG_FOLDS_COPY.badStored);
3115
+ let songFolds = new Set(initialFolds.value);
3116
+ // Whether the sentence for a fold that cannot be remembered was already logged in this page session.
3117
+ let notRememberedSaid = false;
2773
3118
  const reduceMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
2774
3119
  let viewState = initialViewState({ prefs: initialPrefs, prefersReducedMotion: reduceMotionQuery.matches });
2775
3120
 
@@ -2906,6 +3251,9 @@ layoutButtonsEl.addEventListener('click', onViewButtonClick);
2906
3251
  // version started playing (performance.now()), so refreshTimeline can wait out
2907
3252
  // TIMELINE_SETTLE_MS only when it is actually racing a version that just started.
2908
3253
  let timelineGeneration = 0;
3254
+ // The song check and the song reading belong to one version of the music, so they use their own token: only a new
3255
+ // version that plays bumps it, and closing the Timeline or Jam view (which bumps timelineGeneration) leaves them alone.
3256
+ let songVersion = 0;
2909
3257
  let versionPlayingAt = 0;
2910
3258
 
2911
3259
  // The current version's own tracks/code/parser, set once per refreshTimeline() call and read by
@@ -2913,6 +3261,11 @@ let versionPlayingAt = 0;
2913
3261
  let timelineTracks = null;
2914
3262
  let timelineCode = null;
2915
3263
  let timelineParse = null;
3264
+ // The strings of timelineCode with the scope of every track, made on the first note picked on the version and dropped with it.
3265
+ let timelineIndex = null;
3266
+ // Where tokens play, worked out on demand for the version drawn: the evaluated pattern of each track and the answer for each
3267
+ // token, both as promises, dropped with the version.
3268
+ let timelinePlaces = { patterns: new Map(), answers: new Map() };
2916
3269
  // One color per track identity, kept for the whole page session — never reset per version or
2917
3270
  // per Timeline open/close.
2918
3271
  const trackColors = createTrackColors();
@@ -2928,6 +3281,12 @@ let timelineCache = new Map();
2928
3281
  // pageStart -> { generation, promise } for every computePage() call running, so the Timeline, the
2929
3282
  // Jam view and a prefetch never start two queries for the same page.
2930
3283
  let timelinePending = new Map();
3284
+ // True while the lanes had the keyboard focus when the Timeline hid its body to compute again; any other element taking the focus
3285
+ // clears it.
3286
+ let timelineLanesWantFocus = false;
3287
+ document.addEventListener('focusin', (event) => {
3288
+ if (event.target !== timelineLanes) timelineLanesWantFocus = false;
3289
+ });
2931
3290
  // The pageStart currently drawn in #timeline-lanes, or null while nothing has drawn yet.
2932
3291
  let timelineDrawnPageStart = null;
2933
3292
  // What the lanes on screen were drawn from: { pageStart, lanes: [{ track, midi, instrument, events }], version,
@@ -3068,6 +3427,12 @@ function resolveAskTarget(event) {
3068
3427
  if (target.closest('#history-view') !== null) return historyUi.askTarget(target) ?? session;
3069
3428
  if (target.closest('#mixer-view') !== null) return mixerUi.askTarget(target) ?? session;
3070
3429
  if (target.closest('#instruments-view') !== null) return instrumentsUi.askTarget(target) ?? session;
3430
+ // The Song grid knows its own headers, cells, group rows and track names, and a cell is a more specific target than the
3431
+ // header-like card around it, so it answers before the generic part match below. Anything else in the grid falls through.
3432
+ if (target.closest('table.song-grid') !== null) {
3433
+ const inGrid = songGridUi.askTarget(target);
3434
+ if (inGrid !== null) return inGrid;
3435
+ }
3071
3436
  const partEl = target.closest('.part-card[data-part], .section-span[data-part], .jam-row[data-part]');
3072
3437
  if (partEl !== null) {
3073
3438
  const owner = partEl.dataset.owner;
@@ -3100,22 +3465,19 @@ function resolveAskTarget(event) {
3100
3465
  return session;
3101
3466
  }
3102
3467
 
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
- }
3468
+ /**
3469
+ * Opens the Ask box for what `describeTarget()` finds, at `point`: the one place both the right-click and the grid's
3470
+ * 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
3471
+ * console, and the box opens on the whole session. A descriptor with a `prefill` (a fixed variation whose notes do not fit)
3472
+ * puts that request in the field; an existing draft is kept and the request follows it. Nothing is sent, evaluated or
3473
+ * recorded here, and an open cell list is closed first. Gives false when the target could not be described (the box then holds the
3474
+ * whole session and no request), true otherwise.
3475
+ */
3476
+ function openAskFor(describeTarget, point) {
3115
3477
  let descriptor;
3116
3478
  let readable = true;
3117
3479
  try {
3118
- descriptor = resolveAskTarget(event);
3480
+ descriptor = describeTarget();
3119
3481
  describeContext(descriptor);
3120
3482
  } catch (err) {
3121
3483
  // A view could not resolve or describe what was right-clicked: say so on the error line and in the
@@ -3127,7 +3489,161 @@ document.addEventListener('contextmenu', (event) => {
3127
3489
  }
3128
3490
  // This context reads fine: a stale "could not read" line would contradict the chip.
3129
3491
  if (readable && errorLineIsAskContext) clearEvalError();
3130
- askUi.open(descriptor, { x: event.clientX, y: event.clientY });
3492
+ if (songGridUi.isListOpen()) songGridUi.closeList();
3493
+ if (typeof descriptor.prefill === 'string') askUi.open(descriptor, point, { prefill: descriptor.prefill });
3494
+ else askUi.open(descriptor, point);
3495
+ return readable;
3496
+ }
3497
+
3498
+ /** The gesture field of a request for a change of the Timeline's note panel: a pitch change, a rest or a velocity step. */
3499
+ function timelineGestureOf(change) {
3500
+ switch (change.kind) {
3501
+ case 'pitch':
3502
+ return { kind: 'pitch', semitones: change.semitones };
3503
+ case 'rest':
3504
+ return { kind: 'rest' };
3505
+ case 'velocity':
3506
+ return { kind: 'velocity', direction: change.steps > 0 ? 'louder' : 'softer' };
3507
+ default:
3508
+ throw new Error(`Moontide: unknown kind of Timeline change ${String(change.kind)}`);
3509
+ }
3510
+ }
3511
+
3512
+ /**
3513
+ * 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
3514
+ * 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
3515
+ * holds the ready-made request for it and the gesture rides on the message; without one the field stays as it is. Nothing is
3516
+ * written, evaluated or sent: only the person's Enter in the box sends. Gives false when nothing opened (the audio-unlock overlay
3517
+ * shows, or the note could not be described), so the panel does not say a request is ready.
3518
+ */
3519
+ function askAboutTimelineNote(info, gesture, point) {
3520
+ if (!overlay.hidden) return false;
3521
+ return openAskFor(() => {
3522
+ if (timelineDrawn === null) throw new Error('Moontide: a note of the Timeline was asked about while no lanes are drawn.');
3523
+ const lane = timelineDrawn.lanes[info.laneIndex];
3524
+ const event = lane.events[info.eventIndex];
3525
+ const moment = momentAt({
3526
+ pageStart: timelineDrawn.pageStart,
3527
+ fraction: (event.begin - timelineDrawn.pageStart) / PAGE_CYCLES,
3528
+ lanes: timelineDrawn.lanes,
3529
+ version: timelineDrawn.version,
3530
+ focusTrack: lane.track,
3531
+ });
3532
+ const descriptor = { kind: 'lane', track: lane.track, cycle: moment.cycle, moment };
3533
+ if (gesture === undefined) return descriptor;
3534
+ return {
3535
+ ...descriptor,
3536
+ gesture,
3537
+ prefill: timelineEditPrefill({ ...gesture, note: info.noteText, cycle: moment.cycle, beat: moment.beat, track: lane.track }),
3538
+ };
3539
+ }, point);
3540
+ }
3541
+
3542
+ /**
3543
+ * 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
3544
+ * 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
3545
+ * person's Enter in the box sends. Gives false when nothing opened.
3546
+ */
3547
+ function askAboutTimelineLane(laneIndex, cycle, point) {
3548
+ if (!overlay.hidden) return false;
3549
+ return openAskFor(() => {
3550
+ if (timelineDrawn === null) throw new Error('Moontide: a lane of the Timeline was asked about while no lanes are drawn.');
3551
+ const lane = timelineDrawn.lanes[laneIndex];
3552
+ if (lane === undefined) throw new Error(`Moontide: the Timeline draws no lane ${laneIndex} to ask about.`);
3553
+ const moment = momentAt({
3554
+ pageStart: timelineDrawn.pageStart,
3555
+ fraction: (cycle - timelineDrawn.pageStart) / PAGE_CYCLES,
3556
+ lanes: timelineDrawn.lanes,
3557
+ version: timelineDrawn.version,
3558
+ focusTrack: lane.track,
3559
+ });
3560
+ return { kind: 'lane', track: lane.track, cycle: moment.cycle, moment };
3561
+ }, point);
3562
+ }
3563
+
3564
+ /**
3565
+ * The section of the first and of the last cycle of a range as `{ section, sectionTo }`, for a piece with a song: `section` is
3566
+ * the section of the first cycle and `sectionTo` the section of the last only when it is another one. A piece without a song (or
3567
+ * with only arrange()) gives `{}`.
3568
+ */
3569
+ function timelineRangeSections(from, to) {
3570
+ const reading = timelineSongReading();
3571
+ if (reading.state !== 'read') return {};
3572
+ const sectionAt = (cycle) => String(reading.sections[playingSection(reading, cycle).index].name);
3573
+ const first = sectionAt(from);
3574
+ const last = sectionAt(to);
3575
+ return last === first ? { section: first } : { section: first, sectionTo: last };
3576
+ }
3577
+
3578
+ /** The lane of a range of the lanes drawn, or a loud error when the range names none. */
3579
+ function timelineRangeLane(selection) {
3580
+ if (timelineDrawn === null) throw new Error('Moontide: a range of the Timeline was asked about while no lanes are drawn.');
3581
+ const lane = timelineDrawn.lanes[selection.laneIndex];
3582
+ if (lane === undefined) throw new Error(`Moontide: the range names lane ${selection.laneIndex}, which is not drawn.`);
3583
+ return lane;
3584
+ }
3585
+
3586
+ /**
3587
+ * What the note panel says of a range: `{ track, section, sectionTo, count }` (the sections only for a piece with a song, the
3588
+ * count of the notes that start in the cycles). Reads the lanes and the code only.
3589
+ */
3590
+ function describeTimelineRange(selection) {
3591
+ const lane = timelineRangeLane(selection);
3592
+ const { notes, more } = rangeNotes(lane, selection.from, selection.to);
3593
+ const { section = null, sectionTo = null } = timelineRangeSections(selection.from, selection.to);
3594
+ return { track: lane.track, section, sectionTo, count: notes.length + more };
3595
+ }
3596
+
3597
+ /**
3598
+ * The descriptor of a range of cycles on one lane of the lanes drawn: the lane's shown name, the first and last cycle, the section
3599
+ * of each end when the playing code is a song (the second only when it differs), the notes of the lane that start in those cycles
3600
+ * with the version drawn, and the cut when the pointer left the page. Reads the lanes and the code only.
3601
+ */
3602
+ function timelineRangeDescriptor(selection) {
3603
+ const lane = timelineRangeLane(selection);
3604
+ const { notes, more } = rangeNotes(lane, selection.from, selection.to);
3605
+ const descriptor = {
3606
+ kind: 'range',
3607
+ track: lane.track,
3608
+ from: selection.from,
3609
+ to: selection.to,
3610
+ ...timelineRangeSections(selection.from, selection.to),
3611
+ gesture: { kind: 'range', version: timelineDrawn.version, notes, more },
3612
+ };
3613
+ if (selection.cut !== null) descriptor.cut = selection.cut;
3614
+ return descriptor;
3615
+ }
3616
+
3617
+ /**
3618
+ * 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
3619
+ * written, evaluated or sent: only the person's Enter in the box sends. Gives false when nothing opened.
3620
+ */
3621
+ function askAboutTimelineRange(selection, point) {
3622
+ if (!overlay.hidden) return false;
3623
+ return openAskFor(() => timelineRangeDescriptor(selection), point);
3624
+ }
3625
+
3626
+ // 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
3627
+ // page's contextmenu listener leaves a Shift press to the browser, so the grid reports the key itself. The audio-unlock overlay
3628
+ // is a gate to start sound, not part of the page's work, so the key does nothing while it shows.
3629
+ songGridHandlers.ask = (descriptor, point) => {
3630
+ if (!overlay.hidden) return;
3631
+ openAskFor(() => descriptor, point);
3632
+ };
3633
+
3634
+ // The one contextmenu listener of the page; no view module has its own. Shift + right-click keeps
3635
+ // the browser's own menu, and so does a right-click inside the box (the user can paste) or while the
3636
+ // audio-unlock overlay shows (it is a gate to start sound, not part of the page's work). The right-click
3637
+ // never evaluates, records or changes the editor's text, selection or scroll.
3638
+ document.addEventListener('contextmenu', (event) => {
3639
+ if (event.shiftKey || !overlay.hidden || askUi.contains(event.target)) return;
3640
+ event.preventDefault();
3641
+ // A right-click on the failed-change banner (or on its own popover) offers the banner's actions.
3642
+ if (event.target instanceof Node && (problemUi.contains(event.target) || problemUi.menuContains(event.target))) {
3643
+ problemUi.openMenu({ x: event.clientX, y: event.clientY });
3644
+ return;
3645
+ }
3646
+ openAskFor(() => resolveAskTarget(event), { x: event.clientX, y: event.clientY });
3131
3647
  });
3132
3648
 
3133
3649
  // A right press outside the open box must not move focus out of its field: the contextmenu that follows
@@ -3266,12 +3782,16 @@ function renderViews() {
3266
3782
  // which starts the loop itself; a layout or motion change while it stays open must never
3267
3783
  // restart it (the loop already tracks the live clock on its own, every frame — see
3268
3784
  // timelineTick below). Parts has no async computation, so opening it starts the loop directly.
3785
+ // A closed Timeline hands no edit over: steps and edits that wait are dropped, and the panel says so when it shows again.
3786
+ if (wasTimelineOpen && !isTimelineOpen) timelineEditUi.close();
3269
3787
  if (isTimelineOpen && !wasTimelineOpen) {
3270
3788
  void refreshTimeline().catch((err) => {
3271
3789
  console.error('Moontide: failed to refresh the Timeline:', err);
3272
3790
  renderTimelineMessage(TIMELINE_COPY.failed, 'failed');
3273
3791
  });
3274
3792
  }
3793
+ // A refusal ends when the view closes.
3794
+ if (wasPartsOpen && !isPartsOpen) gridRefused = null;
3275
3795
  if (isPartsOpen) {
3276
3796
  renderParts();
3277
3797
  startTimelineLoop();
@@ -3306,17 +3826,238 @@ function renderViews() {
3306
3826
  // No timer: the view follows the events that already redraw the MIDI indicator (see renderMidiIndicator).
3307
3827
  if (isInstrumentsOpen) renderInstruments();
3308
3828
  else instrumentsUi.closed();
3829
+ // Opening the Mixer, closing it and switching motion all change whether the lights run, and how.
3830
+ syncLights();
3831
+ }
3832
+
3833
+ /**
3834
+ * Starts, stops or restarts the lights to match the page: they run only while the Mixer is open, the tab is visible and sound
3835
+ * is playing. Starting (and a switch of motion while running) forgets every note and takes every light to unlit, so lights
3836
+ * follow only the notes that come after; stopping does the same and ends the loop.
3837
+ */
3838
+ function syncLights() {
3839
+ if (!lightsViewReady) return;
3840
+ // A failure ends the lights only until the music stops: the next Start tries again.
3841
+ if (currentSoundState !== 'playing') lightsBroken = false;
3842
+ const wanted = !lightsBroken && viewState.view === 'mixer' && document.visibilityState === 'visible' && currentSoundState === 'playing';
3843
+ if (!wanted) {
3844
+ stopLights();
3845
+ return;
3846
+ }
3847
+ if (lightsActive && lightsMotion === viewState.motion) return;
3848
+ lights.reset();
3849
+ mixerUi.clearLights();
3850
+ lightsMotion = viewState.motion;
3851
+ lightsActive = true;
3852
+ lightsPaused = false;
3853
+ if (lightsRafId === null) lightsRafId = requestAnimationFrame(lightsTick);
3854
+ }
3855
+
3856
+ /** Ends the loop and takes every light to unlit. Does nothing to the document when the lights were not running. */
3857
+ function stopLights() {
3858
+ if (lightsRafId !== null) {
3859
+ cancelAnimationFrame(lightsRafId);
3860
+ lightsRafId = null;
3861
+ }
3862
+ if (!lightsActive) return;
3863
+ lightsActive = false;
3864
+ lights.reset();
3865
+ mixerUi.clearLights();
3866
+ }
3867
+
3868
+ /** One animation frame of the lights: the notes heard by now light their tracks, and only the lights that changed are written. */
3869
+ function lightsTick(ts) {
3870
+ lightsRafId = null;
3871
+ if (!lightsActive) return;
3872
+ try {
3873
+ const cps = editor.repl.scheduler.cps;
3874
+ if (!usableTempo(cps)) {
3875
+ // 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.
3876
+ if (!lightsPaused) {
3877
+ lightsPaused = true;
3878
+ lights.reset();
3879
+ mixerUi.clearLights();
3880
+ }
3881
+ lightsRafId = requestAnimationFrame(lightsTick);
3882
+ return;
3883
+ }
3884
+ if (lightsPaused) {
3885
+ // The tempo is back: notes queued while it was 0 are not worth a blink.
3886
+ lightsPaused = false;
3887
+ lights.reset();
3888
+ }
3889
+ // One read of the audio context's output timestamp serves every note of this frame.
3890
+ const audio = globalThis.getAudioContext();
3891
+ const pair = readOutputPair(audio);
3892
+ if (pair === null && !lightsPairWarned) {
3893
+ lightsPairWarned = true;
3894
+ 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.");
3895
+ }
3896
+ const toPerf = heardMapper({
3897
+ pair,
3898
+ currentTime: audio.currentTime,
3899
+ baseLatency: audio.baseLatency,
3900
+ outputLatency: audio.outputLatency,
3901
+ perfNow: performance.now(),
3902
+ });
3903
+ const changes = lights.frame(ts, { motion: lightsMotion, cps, toPerf });
3904
+ if (changes.length > 0) mixerUi.setLights(changes, lightsMotion);
3905
+ } catch (err) {
3906
+ // Once: the lights end here, and the music is not touched.
3907
+ lightsBroken = true;
3908
+ console.error('Moontide: the Mixer lights stopped:', err);
3909
+ stopLights();
3910
+ return;
3911
+ }
3912
+ lightsRafId = requestAnimationFrame(lightsTick);
3309
3913
  }
3310
3914
 
3915
+ // A hidden tab draws nothing and drops its notes; coming back, every light starts unlit.
3916
+ document.addEventListener('visibilitychange', () => syncLights());
3917
+
3311
3918
  // The first render never came from a keydown (nothing changed relative to a prior viewState),
3312
3919
  // so it must be triggered explicitly — sets the initial data-layout/data-view/data-motion
3313
3920
  // attributes and opens the Timeline if a view was remembered from a previous session.
3921
+ // The lights may touch the view state and the Mixer from here on: both exist.
3922
+ lightsViewReady = true;
3314
3923
  renderViews();
3315
3924
 
3925
+ /**
3926
+ * The lines #song-notes shows for a reading: none unless the song is read, else the helper line (when the helper is not the
3927
+ * canonical text) and then the line of the check when this version has one. A line is a string, or `{ text, kind }`.
3928
+ */
3929
+ function songNoteEntries(reading) {
3930
+ if (reading.state !== 'read') return [];
3931
+ const entries = [...songNoteLines(reading)];
3932
+ if (songCheck !== null && songCheck.generation === songVersion && songCheck.entry !== null) entries.push(songCheck.entry);
3933
+ return entries;
3934
+ }
3935
+
3936
+ /** The line a finished check gives, or null when what plays agrees with the declaration. */
3937
+ function songCheckEntry(answer) {
3938
+ if (answer.state === 'differs') return differsLine({ ...answer.first, more: answer.more });
3939
+ if (answer.state === 'failed' || answer.state === 'too-slow') return { text: SONG_COPY.checkFailed, kind: 'check-failed' };
3940
+ return null;
3941
+ }
3942
+
3943
+ /**
3944
+ * Checks, after the version has settled, what its tracks really play at the start of every section entry against what the
3945
+ * song declares, without blocking the music and whether or not the Parts view is open. The answer of an overtaken version is
3946
+ * dropped. A difference or a failed check is shown in #song-notes and sent to Claude with the helper line of the version;
3947
+ * agreement says nothing. `evaluateId` is the evaluate id captured when the version started.
3948
+ */
3949
+ async function runSongCheck(generation, code, reading, evaluateId) {
3950
+ await sleep(TIMELINE_SETTLE_MS);
3951
+ if (songVersion !== generation) return;
3952
+ let answer;
3953
+ try {
3954
+ const parse = window.acorn.parse;
3955
+ answer = await checkSections({
3956
+ reading,
3957
+ starts: sectionStarts(reading),
3958
+ playedPattern: (track) => evaluateSolo(soloSource(code, track.index, { parse })),
3959
+ declared: () => evaluateDeclared(expectedSource(code, reading, { parse })),
3960
+ budgetMs: TIMELINE_BUDGET_MS,
3961
+ clock: () => performance.now(),
3962
+ yieldToPage: () => new Promise((resolve) => setTimeout(resolve, 0)),
3963
+ isCancelled: () => songVersion !== generation,
3964
+ });
3965
+ } catch (err) {
3966
+ answer = { state: 'failed', message: err instanceof Error ? err.message : String(err), ms: 0 };
3967
+ }
3968
+ if (answer.state === 'cancelled' || songVersion !== generation) return;
3969
+ if (answer.state === 'failed') console.error('Moontide: the check of the song declaration failed:', answer.message);
3970
+ if (answer.state === 'too-slow') console.error(`Moontide: the check of the song declaration ran over its ${TIMELINE_BUDGET_MS} ms budget (${Math.round(answer.ms)} ms).`);
3971
+ const entry = songCheckEntry(answer);
3972
+ songCheck = { generation, entry };
3973
+ songNotesEl.dataset.check = answer.state === 'agrees' || answer.state === 'differs' ? answer.state : 'failed';
3974
+ songNotesEl.dataset.checkMs = String(Math.round(answer.ms));
3975
+ songUi.showNotes(songNoteEntries(reading));
3976
+ if (entry !== null) {
3977
+ sendStructureNote([...songNoteLines(reading), typeof entry === 'string' ? entry : entry.text], evaluateId);
3978
+ }
3979
+ }
3980
+
3981
+ /**
3982
+ * Asks, after the song check of the same version has answered (so the two queries never run at once), which pitch classes
3983
+ * each fixed variation plays in each of its first cycles, and keeps the answer for the grid. It reads the code's own
3984
+ * constants and never calls its play() helper; a variation it cannot read, or reads too slowly, is a visible reason in the
3985
+ * grid. The answer of an overtaken version is dropped. It changes nothing in the code or the music.
3986
+ */
3987
+ async function runSongFit(generation, code, reading) {
3988
+ if (songVersion !== generation) return;
3989
+ const isCancelled = () => songVersion !== generation;
3990
+ const pairs = fitPairs(reading);
3991
+ let answer;
3992
+ try {
3993
+ const patterns = await evaluateFit(fitSource(code, reading, { parse: window.acorn.parse }));
3994
+ if (isCancelled()) return;
3995
+ answer = await collectFit({
3996
+ patterns,
3997
+ pairs,
3998
+ cycles: Math.max(...reading.sections.map((section) => section.length)),
3999
+ budgetMs: TIMELINE_BUDGET_MS,
4000
+ clock: () => performance.now(),
4001
+ yieldToPage: () => new Promise((resolve) => setTimeout(resolve, 0)),
4002
+ isCancelled,
4003
+ });
4004
+ } catch (err) {
4005
+ console.error('Moontide: the page could not read the notes of the fixed variations:', err);
4006
+ answer = { state: 'ready', byPair: new Map(pairs.map((pair) => [pairKey(pair.track, pair.key), { reason: 'not-read' }])), failures: [] };
4007
+ }
4008
+ if (answer.state === 'cancelled' || isCancelled()) return;
4009
+ for (const failure of answer.failures) {
4010
+ console.error(`Moontide: the page could not read the notes of variation '${String(failure.key)}' of track '${String(failure.track)}': ${failure.message}`);
4011
+ }
4012
+ songFit = { generation, reading, answer: answer.byPair };
4013
+ // The grid is drawn again once with the answer, when the Parts view shows the grid of this very reading.
4014
+ if (viewState.view === 'parts' && songGridReading === reading) drawSongGrid();
4015
+ }
4016
+
3316
4017
  /** Called from trackEvalSuccess right where hasEvaluatedSuccessfully becomes true —
3317
4018
  * every new version that plays bumps the generation and, if the Timeline is open, recomputes it. */
3318
4019
  function onVersionPlaying() {
3319
4020
  timelineGeneration++;
4021
+ songVersion++;
4022
+ // The song structure of the version that plays, read once; a code the parser cannot read counts as a piece without one.
4023
+ songReading = null;
4024
+ if (window.acorn && typeof window.acorn.parse === 'function') {
4025
+ const code = playingCode();
4026
+ try {
4027
+ const parse = window.acorn.parse;
4028
+ songReading = { generation: songVersion, code, reading: readStructure(code, { parse, tracks: extractTracks(code, { parse }) }) };
4029
+ } catch (err) {
4030
+ console.error('Moontide: failed to read the song structure of the playing code:', err);
4031
+ }
4032
+ }
4033
+ const reading = songReading === null ? { state: 'none' } : songReading.reading;
4034
+ // The Parts view is called Song while the code declares a song (readable or not), and Parts otherwise.
4035
+ setViewTitle('parts', reading.state === 'none' ? VIEW_TITLES.parts : SONG_COPY.viewTitle);
4036
+ if (reading.state !== 'none' || structureNoteSent) sendStructureNote(songNoteLines(reading));
4037
+ // The old answer of the check goes with the old version; the new one is shown when it arrives.
4038
+ songCheck = null;
4039
+ if (reading.state === 'read') {
4040
+ songNotesEl.dataset.check = 'pending';
4041
+ delete songNotesEl.dataset.checkMs;
4042
+ } else {
4043
+ delete songNotesEl.dataset.check;
4044
+ delete songNotesEl.dataset.checkMs;
4045
+ }
4046
+ songUi.showNotes(songNoteEntries(reading));
4047
+ songFit = null;
4048
+ if (reading.state === 'read') {
4049
+ const generation = songVersion;
4050
+ const { code } = songReading;
4051
+ // The fit words come after the check of the declaration has answered, so the two queries never run at once.
4052
+ const checked = runSongCheck(songVersion, songReading.code, reading, playingEvaluateId).catch((err) => {
4053
+ console.error('Moontide: the check of the song declaration failed:', err);
4054
+ });
4055
+ if (fitPairs(reading).length > 0) {
4056
+ void checked.then(() => runSongFit(generation, code, reading)).catch((err) => {
4057
+ console.error('Moontide: the fit of the fixed variations failed:', err);
4058
+ });
4059
+ }
4060
+ }
3320
4061
  // The shared reading belongs to one version: a new one empties the preparation, the page cache
3321
4062
  // and the pages in flight (the Jam view drops its reading and shows the reading message until the new one is built).
3322
4063
  readingVersion++;
@@ -3324,10 +4065,15 @@ function onVersionPlaying() {
3324
4065
  timelineTracks = null;
3325
4066
  timelineCode = null;
3326
4067
  timelineParse = null;
4068
+ timelineIndex = null;
4069
+ timelinePlaces = { patterns: new Map(), answers: new Map() };
3327
4070
  timelineCache = new Map();
3328
4071
  timelinePending = new Map();
3329
4072
  versionPlayingAt = performance.now();
3330
4073
  pendingLaunch = null;
4074
+ // A new version can move the sections a countdown or a refusal counted on.
4075
+ gridAlready = null;
4076
+ gridRefused = null;
3331
4077
  if (viewState.view === 'timeline') {
3332
4078
  void refreshTimeline().catch((err) => {
3333
4079
  console.error('Moontide: failed to refresh the Timeline:', err);
@@ -3356,6 +4102,9 @@ function onVersionPlaying() {
3356
4102
  * playhead body — the shared shape for "nothing playing", "computing" and "failed" (never a
3357
4103
  * partial lane draw). */
3358
4104
  function renderTimelineMessage(text, state) {
4105
+ // Hiding the body takes the keyboard focus away from the lanes; the lanes get it back when they are drawn again, so a person
4106
+ // who edits a note with the keys can go on with the keys.
4107
+ if (document.activeElement === timelineLanes) timelineLanesWantFocus = true;
3359
4108
  timelineView.dataset.state = state;
3360
4109
  delete timelineView.dataset.computeMs;
3361
4110
  delete timelineView.dataset.longestSliceMs;
@@ -3366,6 +4115,7 @@ function renderTimelineMessage(text, state) {
3366
4115
  timelineLanes.replaceChildren();
3367
4116
  timelineRuler.replaceChildren();
3368
4117
  timelineSections.replaceChildren();
4118
+ timelineEditUi.onRedraw(null);
3369
4119
  }
3370
4120
 
3371
4121
  /** Builds the ruler's labeled cycle-number marks (every 4 cycles) for the page starting at
@@ -3450,10 +4200,8 @@ function renderSections(pageStart) {
3450
4200
  * `rect.event` per event, positioned/sized in cycle units against the 32-cycle page. Never
3451
4201
  * builds a node from an HTML string (track/part names can be Claude- or user-authored text). */
3452
4202
  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;
4203
+ // 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
4204
+ // also uses to find the note under a press; a tick (no pitch) always spans the full lane height instead.
3457
4205
 
3458
4206
  /** The MIDI tracks of the code the lanes were read from: `tags`, identity -> midiTag (the same formatter as
3459
4207
  * the Mixer strip, so the two views always agree), `midi`, the identities of every MIDI track even when its
@@ -3551,13 +4299,20 @@ function renderLanes(lanes, pageStart, tracks) {
3551
4299
  const known = drumUse !== undefined && drumUse.use.state === 'known' ? drumUse.use.map : null;
3552
4300
  const plan = known === null ? null : drumLane(lane.events, pageStart, known);
3553
4301
  // What a moment reads: the lane as shown (the name equals the right-click's lane context).
3554
- drawnLanes.push({
4302
+ const drawnLane = {
3555
4303
  track: track.displayName ?? track.name,
3556
4304
  midi: midiIdentities.has(track.identity),
3557
4305
  instrument: tag === undefined ? null : tag.text,
3558
4306
  events: lane.events,
3559
4307
  drumMap: known === null ? null : { name: known.name, byNote: new Map(known.notes.map((entry) => [entry.note, entry.name])) },
3560
- });
4308
+ // What the editable Timeline needs beside what a moment reads: the track the lane belongs to, the shapes the lane is drawn
4309
+ // 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).
4310
+ index: track.index,
4311
+ identity: track.identity,
4312
+ shapes: plan === null ? laneShapes(lane.events, pageStart) : plan.shapes,
4313
+ rowCount: plan === null ? null : plan.rows.length,
4314
+ };
4315
+ drawnLanes.push(drawnLane);
3561
4316
 
3562
4317
  const svg = document.createElementNS(SVG_NS, 'svg');
3563
4318
  svg.setAttribute('class', 'lane-events');
@@ -3581,7 +4336,7 @@ function renderLanes(lanes, pageStart, tracks) {
3581
4336
  svg.appendChild(rect);
3582
4337
  }
3583
4338
  }
3584
- for (const shape of plan === null ? laneShapes(lane.events, pageStart) : []) {
4339
+ for (const shape of plan === null ? drawnLane.shapes : []) {
3585
4340
  const rect = document.createElementNS(SVG_NS, 'rect');
3586
4341
  rect.setAttribute('class', 'event');
3587
4342
  rect.setAttribute('data-kind', shape.kind);
@@ -3655,6 +4410,7 @@ function renderLanes(lanes, pageStart, tracks) {
3655
4410
  timelineLanes.dataset.pageStart = String(pageStart);
3656
4411
  timelineDrawn = { pageStart, lanes: drawnLanes, version: currentVersionId, generation: timelineGeneration };
3657
4412
  timelineLanes.replaceChildren(...laneNodes);
4413
+ timelineEditUi.onRedraw(timelineDrawn);
3658
4414
  }
3659
4415
 
3660
4416
  /** Applies a computeTimeline() result: draws the lanes on 'ok'; shows the too-slow or failed
@@ -3670,6 +4426,10 @@ function renderTimelineResult(result, pageStart, tracks) {
3670
4426
  renderRuler(pageStart);
3671
4427
  renderSections(pageStart);
3672
4428
  renderLanes(result.lanes, pageStart, tracks);
4429
+ if (timelineLanesWantFocus) {
4430
+ timelineLanesWantFocus = false;
4431
+ timelineLanes.focus({ preventScroll: true });
4432
+ }
3673
4433
  return;
3674
4434
  }
3675
4435
  if (result.state === 'too-slow') {
@@ -3821,6 +4581,7 @@ function timelineTick() {
3821
4581
  }
3822
4582
  if (viewState.view === 'parts') {
3823
4583
  updatePartsLive();
4584
+ updateSongGridLive();
3824
4585
  }
3825
4586
  timelineRafId = requestAnimationFrame(timelineTick);
3826
4587
  }
@@ -4087,10 +4848,20 @@ function renderJam() {
4087
4848
  }
4088
4849
  jamTempoErrorLogged = false; // a later bad tempo is said again
4089
4850
  try {
4851
+ // A piece with sections shows the harmony of the section that plays (the first one while stopped, what Play starts
4852
+ // with); any other piece is drawn as it always was.
4853
+ const options = {};
4854
+ if (songReading !== null && songReading.generation === songVersion && songReading.reading.state === 'read') {
4855
+ const song = songReading.reading;
4856
+ const index = playing ? playingSection(song, cycle).index : 0;
4857
+ options.declaredOverride = sectionDeclared(song.sections[index], jamReading.reading.declared ?? { state: 'none' });
4858
+ options.section = quoted(song.sections[index].name);
4859
+ }
4090
4860
  jamUi.render(
4091
4861
  buildJamModel({
4092
4862
  position: readingAt(jamReading.reading, { cycle, cps, playing }),
4093
4863
  chordTones: viewState.jamChordTones === 'on',
4864
+ ...options,
4094
4865
  }),
4095
4866
  );
4096
4867
  } catch (err) {
@@ -4226,6 +4997,8 @@ function buildPartsRow(arrangement, tracks, showLabel) {
4226
4997
  function renderParts() {
4227
4998
  if (!window.acorn || typeof window.acorn.parse !== 'function') {
4228
4999
  console.error('Moontide: window.acorn is not available — the pinned parser script did not load.');
5000
+ songUi.clearNotes();
5001
+ hideSongGrid();
4229
5002
  showPartsMessage(PARTS_COPY.cannotRead);
4230
5003
  askUi.setPartsAvailable(false);
4231
5004
  return;
@@ -4233,18 +5006,46 @@ function renderParts() {
4233
5006
  const parse = window.acorn.parse;
4234
5007
  const code = playingCode();
4235
5008
  let tracks;
5009
+ let reading;
4236
5010
  let arrangements;
4237
5011
  let problems;
4238
5012
  let callCount;
4239
5013
  try {
4240
5014
  tracks = extractTracks(code, { parse });
4241
- ({ arrangements, problems, callCount } = readArrangements(code, { parse, tracks }));
5015
+ // The reading made when this version took over, when it belongs to this code; else read it now.
5016
+ reading =
5017
+ songReading !== null && songReading.generation === songVersion && songReading.code === code
5018
+ ? songReading.reading
5019
+ : readStructure(code, { parse, tracks });
5020
+ if (reading.state === 'none') ({ arrangements, problems, callCount } = readArrangements(code, { parse, tracks }));
4242
5021
  } catch (err) {
4243
5022
  console.error('Moontide: failed to parse the playing code for the Parts view:', err);
5023
+ songUi.clearNotes();
5024
+ hideSongGrid();
4244
5025
  showPartsMessage(PARTS_COPY.cannotRead);
4245
5026
  askUi.setPartsAvailable(false);
4246
5027
  return;
4247
5028
  }
5029
+ if (reading.state === 'read') {
5030
+ // A song takes the whole view: its grid replaces any arrange() rows, and the cards they had are gone.
5031
+ partsMessage.hidden = true;
5032
+ partsCardRefs = [];
5033
+ songGridReading = reading;
5034
+ drawSongGrid();
5035
+ songUi.showNotes(songNoteEntries(reading));
5036
+ askUi.setPartsAvailable(false);
5037
+ return;
5038
+ }
5039
+ if (reading.state === 'unreadable') {
5040
+ // One line with the reason, the same sentence Claude gets; no grid.
5041
+ songUi.clearNotes();
5042
+ hideSongGrid();
5043
+ showPartsMessage(songMessageText(reading));
5044
+ askUi.setPartsAvailable(false);
5045
+ return;
5046
+ }
5047
+ songUi.clearNotes();
5048
+ hideSongGrid();
4248
5049
  if (arrangements.length === 0) {
4249
5050
  if (problems.length === 0) {
4250
5051
  showPartsMessage(PARTS_COPY.none);
@@ -4322,75 +5123,857 @@ function updatePartsLive() {
4322
5123
  }
4323
5124
  }
4324
5125
 
5126
+ /** Draws the Song grid of the reading it shows, from the fold state, then hands it the live state. */
5127
+ function drawSongGrid() {
5128
+ const wait = waitingCellEdit();
5129
+ const cellWaiting = wait === null ? null : { track: wait.track, section: wait.section, choice: wait.choice, at: wait.at };
5130
+ // The fit words come from the answer of the query of this very version and reading; until it answers there are none.
5131
+ const fit =
5132
+ songFit !== null && songFit.generation === songVersion && songFit.reading === songGridReading
5133
+ ? fitLookup({ reading: songGridReading, byPair: songFit.answer })
5134
+ : null;
5135
+ songGridUi.render(buildSongGrid({ reading: songGridReading, folds: songFolds, launchChoice: viewState.songLaunch, cellWaiting, fit }));
5136
+ refreshSongLine();
5137
+ updateSongGridLive();
5138
+ }
5139
+
5140
+ // One of the three launch buttons of the toolbar: when a launched section starts. The choice is remembered in this browser
5141
+ // (applyViewState stores it) and only the titles of the section headers change; the code is not touched.
5142
+ songGridHandlers.launchChoice = (value) => {
5143
+ if (value === viewState.songLaunch) return;
5144
+ applyViewState(setSongLaunch(viewState, value), ['songLaunch']);
5145
+ };
5146
+
5147
+ // 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
5148
+ // fold set both views read changes, the state is remembered in this browser, and the views that are on screen are drawn
5149
+ // again. Nothing is written to the code, sent to the server or evaluated.
5150
+ function toggleSongFold(key) {
5151
+ const next = new Set(songFolds);
5152
+ if (!next.delete(key)) {
5153
+ next.add(key);
5154
+ // A group the store would refuse (a name over 200 characters) still folds here; say once that a reload forgets it.
5155
+ if (!isRememberable(key) && !notRememberedSaid) {
5156
+ notRememberedSaid = true;
5157
+ console.error(SONG_FOLDS_COPY.notRemembered);
5158
+ }
5159
+ }
5160
+ songFolds = next;
5161
+ if (!writeSongFolds(storage, [...songFolds])) reportStorageUnavailableOnce();
5162
+ if (songGridReading !== null) drawSongGrid();
5163
+ if (viewState.view === 'mixer') renderMixer();
5164
+ }
5165
+ songGridHandlers.fold = (key) => toggleSongFold(key);
5166
+
5167
+ // 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
5168
+ // the queued one cancels it and a click on another header replaces it. What the person saw (the entry's place, name and
5169
+ // length) and the choice are taken now; the launch itself runs on the serial chain, so it never interleaves with Claude's
5170
+ // changes or a Mixer move.
5171
+ songGridHandlers.launch = (index) => {
5172
+ const section = songGridReading === null ? undefined : songGridReading.sections[index];
5173
+ if (section === undefined) throw new Error(`Moontide: the Song grid has no section ${String(index)} to launch.`);
5174
+ const seen = { index, name: section.name, length: section.length, kind: viewState.songLaunch };
5175
+ chain = chain.then(() => launchSection(seen)).catch((err) => reportChainError('section launch', err));
5176
+ };
5177
+
5178
+ // 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
5179
+ // hand-edit timing, and the choice is one history entry. What the person saw (the cell and its section) is taken now; the
5180
+ // change itself runs on the serial chain, so it never interleaves with Claude's changes or a Mixer move.
5181
+ songGridHandlers.choose = (seen, choice) => {
5182
+ chain = chain.then(() => chooseCell(seen, choice)).catch((err) => reportChainError('cell change', err));
5183
+ };
5184
+
5185
+ /** The structure of `code` as the reader gives it (throws when the parser cannot read the text). */
5186
+ function readSongOf(code) {
5187
+ const parse = window.acorn.parse;
5188
+ return readStructure(code, { parse, tracks: extractTracks(code, { parse }) });
5189
+ }
5190
+
5191
+ /** The cycle the scheduler is at, never below 0. */
5192
+ function currentCycle() {
5193
+ const raw = scheduler.now();
5194
+ return Number.isFinite(raw) && raw > 0 ? raw : 0;
5195
+ }
5196
+
5197
+ /** True while some version makes sound: the same test the takeover uses to decide whether a change has to wait. */
5198
+ function playsNow() {
5199
+ return scheduler.started === true && !pageStopped && hasEvaluatedSuccessfully && !!scheduler.pattern;
5200
+ }
5201
+
5202
+ /** The launch of the person's own that still waits in the takeover, or null (a stale one is dropped). */
5203
+ function waitingGridLaunch() {
5204
+ if (gridLaunch !== null && takeover.pending() !== gridLaunch.record) gridLaunch = null;
5205
+ return gridLaunch;
5206
+ }
5207
+
4325
5208
  /**
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.
5209
+ * Puts the code that plays back in the editor after a launch of the grid ended without taking over. The launch wrote only
5210
+ * the shift, so this happens only while the editor still holds exactly the text the launch wrote: text typed since (which
5211
+ * the dirty line already reports) is never replaced, and the console says the editor was left alone.
4330
5212
  */
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.
5213
+ function putBackCanceledLaunch(record) {
5214
+ const text = textAfterCanceledLaunch({ editorCode: editor.code, launchCode: record.code, playingCode: record.playingCode });
5215
+ if (text !== null) {
5216
+ editor.setCode(text);
5217
+ return;
5218
+ }
5219
+ console.error('Moontide: the canceled launch left the editor alone: it holds text that was not evaluated.');
5220
+ }
5221
+
5222
+ /** The change of a cell of the person's own that still waits in the takeover, or null (a stale one is dropped). */
5223
+ function waitingCellEdit() {
5224
+ if (gridCellWait !== null && takeover.pending() !== gridCellWait.record) gridCellWait = null;
5225
+ return gridCellWait;
5226
+ }
5227
+
5228
+ /** Stops a cell change before anything is written: `reason` is an id of SONG_COPY.refusals. */
5229
+ class CellStop extends Error {
5230
+ constructor(reason) {
5231
+ super(`Moontide: the cell change stopped (${reason}).`);
5232
+ this.reason = reason;
5233
+ }
5234
+ }
5235
+
5236
+ /**
5237
+ * Changes the cell the person chose in the list. Runs only inside the serial `chain`. A pending Mixer gesture is recorded
5238
+ * first and the touched-line overlay stops describing the text; the line under the toolbar is brought up to date when the
5239
+ * gesture is over.
5240
+ */
5241
+ async function chooseCell(seen, choice) {
4334
5242
  await mixerGesture.flushDirect();
4335
5243
  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();
5244
+ gridRefused = null;
5245
+ gridBusy = true;
5246
+ try {
5247
+ await chooseCellNow(seen, choice);
5248
+ } finally {
5249
+ gridBusy = false;
5250
+ refreshSongLine();
5251
+ if (songGridReading !== null) updateSongGridLive();
5252
+ }
5253
+ }
5254
+
5255
+ /**
5256
+ * The gesture itself: the playing code is read again, the cell is checked against what the person saw (section, cell and
5257
+ * variation) and exactly one string of song.plan is rewritten through the shared path with the person's own hand-edit timing.
5258
+ * Choosing what the cell holds writes, evaluates and records nothing. At once the change is recorded now; waiting, the grid
5259
+ * shows the new name with the words for its line and the change is recorded when it takes over.
5260
+ */
5261
+ async function chooseCellNow(seen, choice) {
5262
+ const timing = parseTiming(viewState.changeTiming);
5263
+ let outcome;
5264
+ try {
5265
+ outcome = await pageRewrite.apply({
5266
+ timing,
5267
+ // A cell change is an edit, not a request to play.
5268
+ autostart: !pageStopped,
5269
+ flash: false,
5270
+ build(text) {
5271
+ const reading = readSongOf(text);
5272
+ if (reading.state !== 'read') throw new CellStop('section-gone');
5273
+ const stale = checkCell(reading, seen, choice);
5274
+ if (stale !== null) throw new CellStop(stale);
5275
+ const { edits, changed } = cellEdit(reading, { index: seen.index, track: seen.track }, choice);
5276
+ return { code: changed ? applyEdits(text, edits) : text, edits };
5277
+ },
5278
+ });
5279
+ } catch (err) {
5280
+ if (err instanceof CellStop) {
5281
+ console.error(`Moontide: the cell was not changed (${err.reason}); the code changed since the click.`);
5282
+ gridRefused = err.reason;
5283
+ return;
5284
+ }
5285
+ if (err instanceof SongEditRefusal) {
5286
+ console.error(`Moontide: the cell was not changed (${err.reason}).`);
5287
+ gridRefused = err.reason;
5288
+ return;
5289
+ }
5290
+ throw err;
5291
+ }
5292
+ if (outcome.status === 'refused') {
5293
+ reportGridRefusal(outcome.reason, 'the cell was not changed');
5294
+ return;
5295
+ }
5296
+ if (outcome.status === 'unchanged') return;
5297
+ if (outcome.status === 'failed') {
5298
+ // The old code is still what plays; the shared path set the editor back and showed the banner.
5299
+ gridRefused = 'not-evaluated';
4340
5300
  return;
4341
5301
  }
5302
+ const label = cellRecordLabel({ track: seen.track, section: seen.name, choice });
5303
+ const record = outcome.run.deferred;
5304
+ if (record === null || record === undefined) {
5305
+ sendMixerRecord(editor.repl.state.activeCode, label);
5306
+ flashMixerLines(outcome.edits);
5307
+ return;
5308
+ }
5309
+ gridCellWait = { record, track: seen.track, section: seen.name, choice, at: timing.at };
5310
+ takeover.whenFinished(record, () => {
5311
+ if (gridCellWait !== null && gridCellWait.record === record) gridCellWait = null;
5312
+ sendMixerRecord(typeof record.code === 'string' ? record.code : editor.repl.state.activeCode, label);
5313
+ if (songGridReading !== null) drawSongGrid();
5314
+ });
5315
+ if (songGridReading !== null) drawSongGrid();
5316
+ }
5317
+
5318
+ // --- the Timeline's note edits ---
5319
+
5320
+ /** The strings of the code on screen, the song reading and the scope of every track, made once for the version drawn. */
5321
+ /** The song reading of the code the lanes were drawn from, or `{ state: 'none' }` when the page has read no song from that code. */
5322
+ function timelineSongReading() {
5323
+ return songReading !== null && songReading.code === timelineCode ? songReading.reading : { state: 'none' };
5324
+ }
5325
+
5326
+ function timelineNoteIndex() {
5327
+ if (timelineIndex !== null && timelineIndex.code === timelineCode) return timelineIndex;
5328
+ const index = indexCode(timelineCode, { parse: timelineParse });
5329
+ const reading = timelineSongReading();
5330
+ const scopes = timelineTracks.map((track) => trackScope({ track, reading }));
5331
+ timelineIndex = { code: timelineCode, index, reading, scopes, readers: readerCounter(index, scopes) };
5332
+ return timelineIndex;
5333
+ }
5334
+
5335
+ /** 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). */
5336
+ function onsetEvents(pattern, from, to) {
5337
+ const events = [];
5338
+ for (const hap of pattern.queryArc(from, to)) {
5339
+ if (hap.whole && hap.hasOnset()) events.push(toLaneEvent(hap));
5340
+ }
5341
+ return events;
5342
+ }
5343
+
5344
+ /**
5345
+ * 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
5346
+ * the structure, from the first section's start, each note mapped to its section entry with the Song grid's own rule. The
5347
+ * evaluated pattern is kept for the version and the track, and the answer for the token. Gives a promise of `{ count, sections,
5348
+ * onPage: false, events }` (`events` are the notes of the pass, the read-back compares them), and `span`, the stretch queried.
5349
+ */
5350
+ function timelinePlacesOf({ trackIndex, token, reading }) {
5351
+ const cache = timelinePlaces;
5352
+ const parse = timelineParse;
5353
+ const code = timelineCode;
5354
+ const from = ((reading.sections[0].start % reading.total) + reading.total) % reading.total;
5355
+ const span = { from, to: from + reading.total };
5356
+ const key = `${trackIndex}:${token.start}`;
5357
+ if (!cache.answers.has(key)) {
5358
+ if (!cache.patterns.has(trackIndex)) {
5359
+ const pattern = evaluateSolo(soloSource(code, trackIndex, { parse }));
5360
+ // A pattern that failed to evaluate is not kept: the next selection tries again.
5361
+ pattern.catch(() => cache.patterns.delete(trackIndex));
5362
+ cache.patterns.set(trackIndex, pattern);
5363
+ }
5364
+ const answer = cache.patterns.get(trackIndex).then((pattern) => {
5365
+ const events = onsetEvents(pattern, span.from, span.to);
5366
+ return { ...placesOf({ haps: events, token, reading }), onPage: false, events };
5367
+ });
5368
+ answer.catch(() => cache.answers.delete(key));
5369
+ cache.answers.set(key, answer);
5370
+ }
5371
+ return { places: cache.answers.get(key), span };
5372
+ }
5373
+
5374
+ /**
5375
+ * What the panel needs to know of the note the person clicked (lane `laneIndex` of the lanes drawn, event `eventIndex` of the
5376
+ * 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
5377
+ * lanes only; nothing is written, evaluated or sent. For a note that can be edited `seen` holds what the edit needs of this
5378
+ * moment: the code the note was read from, the lane as drawn and the place of the token in the code.
5379
+ */
5380
+ function describeTimelineNote(laneIndex, eventIndex) {
5381
+ if (timelineDrawn === null || timelineCode === null || timelineTracks === null) {
5382
+ throw new Error('Moontide: a note was picked while no Timeline lanes are drawn.');
5383
+ }
5384
+ const lane = timelineDrawn.lanes[laneIndex];
5385
+ const event = lane.events[eventIndex];
5386
+ const { index, reading, scopes, readers } = timelineNoteIndex();
5387
+ const scope = scopes.find((candidate) => candidate.index === lane.index);
5388
+ const verdict = classifyNote({ code: timelineCode, index, event, scope, scopes, readers, midi: lane.midi === true });
5389
+ // The cycle and beat the right-click names for the instant the note begins at: one rule for both.
5390
+ const moment = momentAt({ pageStart: timelineDrawn.pageStart, fraction: (event.begin - timelineDrawn.pageStart) / PAGE_CYCLES, lanes: [], version: null });
5391
+ const midi = pitchOf(event.value);
5392
+ const drumNote = lane.drumMap === null ? null : drumNoteOf(event.value);
5393
+ const info = {
5394
+ laneIndex,
5395
+ eventIndex,
5396
+ track: lane.track,
5397
+ cycle: moment.cycle,
5398
+ beat: moment.beat,
5399
+ section: reading.state === 'read' ? reading.sections[playingSection(reading, event.begin).index].name : null,
5400
+ noteText: event.value.note ?? midi,
5401
+ midi,
5402
+ drum: drumNote !== null && lane.drumMap.byNote.has(drumNote) ? lane.drumMap.byNote.get(drumNote) : null,
5403
+ velocity: velocityOf(event.value, lane.midi === true),
5404
+ // The velocity is writable only where one number of a velocity string in the track holds it (page/note-locate.js).
5405
+ velocityWritable: verdict.editable && verdict.velocity.writable,
5406
+ editable: verdict.editable,
5407
+ reason: verdict.editable ? null : verdict.reason,
5408
+ seen: null,
5409
+ };
5410
+ if (verdict.editable) {
5411
+ info.seen = {
5412
+ code: timelineCode,
5413
+ trackIndex: lane.index,
5414
+ track: lane.track,
5415
+ event,
5416
+ events: lane.events,
5417
+ token: verdict.token,
5418
+ velocity: verdict.velocity,
5419
+ pageStart: timelineDrawn.pageStart,
5420
+ places: null,
5421
+ placesWindow: null,
5422
+ velocityPlaces: null,
5423
+ velocityWindow: null,
5424
+ perCycle: 0,
5425
+ };
5426
+ if (reading.state === 'read') {
5427
+ // A song: the places are section entries. Over the count limit only the entries the drawn page holds are counted.
5428
+ const placesOfToken = (token) =>
5429
+ reading.total > PLACES_MAX_CYCLES
5430
+ ? { places: { ...placesOf({ haps: lane.events, token, reading }), onPage: true }, span: null }
5431
+ : timelinePlacesOf({ trackIndex: lane.index, token, reading });
5432
+ const counted = placesOfToken(verdict.token);
5433
+ info.seen.places = counted.places;
5434
+ info.seen.placesWindow = counted.span;
5435
+ // The number that holds the velocity can play in more places than the pitch token does (a variation used by several
5436
+ // sections): they are counted for the velocity too, and a velocity edit is read back over all of them.
5437
+ if (verdict.velocity.writable) {
5438
+ const velocityCounted = placesOfToken(verdict.velocity.token);
5439
+ info.seen.velocityPlaces = velocityCounted.places;
5440
+ info.seen.velocityWindow = velocityCounted.span;
5441
+ }
5442
+ } else {
5443
+ // No structure: only a token that draws several notes in one cycle is told.
5444
+ info.seen.perCycle = perCycleCount({ events: lane.events, token: verdict.token, cycle: Math.floor(event.begin + 1e-9) });
5445
+ }
5446
+ info.places = info.seen.places;
5447
+ info.velocityPlaces = info.seen.velocityPlaces;
5448
+ info.perCycle = info.seen.perCycle;
5449
+ }
5450
+ return info;
5451
+ }
5452
+
5453
+ // The edit of the person's own that waits for its line, `{ record }` (the takeover's waiting change), or null; the Timeline's
5454
+ // note panel keeps its edit controls off meanwhile and says so with the edit's own sentence.
5455
+ let timelineEditWait = null;
5456
+ // True while an edit of the Timeline runs on the serial chain: the editor legitimately differs from the playing code then.
5457
+ let timelineEditBusy = false;
5458
+ // What the editor held the last time nothing was in flight: true while it has text that was never evaluated.
5459
+ let timelineDirty = false;
5460
+ // The notice the note panel was last told (null, 'closed', 'waiting' or 'dirty'), so it is told again only when it changes.
5461
+ let timelineNoticeKind = null;
5462
+
5463
+ /** Ends the wait of the person's own edit once its record is no longer the waiting change: it took over, or was dropped. */
5464
+ function syncTimelineWait() {
5465
+ if (timelineEditWait === null) return;
5466
+ const { record } = timelineEditWait;
5467
+ if (takeover.pending() === record) return;
5468
+ timelineEditWait = null;
5469
+ timelineEditUi.finishWait({ tookOver: record.finished === true });
5470
+ }
5471
+
5472
+ /**
5473
+ * The notice that keeps the Timeline's edit controls off, by the Mixer's order: the socket is closed, another change waits for
5474
+ * 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
5475
+ * sentence). The editor is compared with the playing code except while an evaluation is in flight, when it legitimately
5476
+ * differs; then the last answer stands.
5477
+ */
5478
+ function timelineNotice() {
5479
+ if (ws.readyState !== ws.OPEN) return 'closed';
5480
+ const waitingRecord = takeover.pending();
5481
+ const ours = timelineEditWait !== null && waitingRecord === timelineEditWait.record ? timelineEditWait : null;
5482
+ if (waitingRecord !== null && ours === null) return 'waiting';
5483
+ if (hasEvaluatedSuccessfully && !programmaticEvalInFlight && !mixerTickInFlight && !timelineEditBusy) {
5484
+ const code = ours !== null && typeof ours.record.code === 'string' ? ours.record.code : playingCode();
5485
+ timelineDirty = editor.code !== code;
5486
+ }
5487
+ return timelineDirty ? 'dirty' : null;
5488
+ }
5489
+
5490
+ /**
5491
+ * 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
5492
+ * ends when its record is gone, and the panel is told of a notice only when its kind changes.
5493
+ */
5494
+ function pollTimelineEdit() {
5495
+ if (viewState.view !== 'timeline') return;
5496
+ syncTimelineWait();
5497
+ const kind = timelineNotice();
5498
+ if (kind === timelineNoticeKind) return;
5499
+ timelineNoticeKind = kind;
5500
+ timelineEditUi.setNotice(kind);
5501
+ }
5502
+
5503
+ /**
5504
+ * Changes the note the person edited on the Timeline. Runs only inside the serial `chain`. A pending Mixer gesture is recorded
5505
+ * first and the touched-line overlay stops describing the text. The edited track is read back before anything is written
5506
+ * (page/note-edit.js), the write goes through the shared path with the person's usual hand-edit timing, and a written edit
5507
+ * 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.
5508
+ */
5509
+ async function editTimelineNote(info, change, id) {
5510
+ const seen = info.seen;
5511
+ let outcome;
5512
+ let evaluated = null;
5513
+ timelineEditBusy = true;
5514
+ try {
5515
+ // Inside the try, like the Mixer flush below: a missing parser or a pending Mixer gesture that fails to record is told in the
5516
+ // panel like any other error of the edit, so every edit handed over ends in an outcome.
5517
+ const parse = window.acorn.parse;
5518
+ await mixerGesture.flushDirect();
5519
+ clearMixerFlash();
5520
+ outcome = await writeNoteEdit({
5521
+ seen,
5522
+ change,
5523
+ deps: {
5524
+ // The edited track alone, over the page the lanes show or over the stretch of the structure the places were counted on, in
5525
+ // the shape of the lane's own events. The edited track is evaluated once for both.
5526
+ async readBack(code, span) {
5527
+ if (evaluated === null || evaluated.code !== code) evaluated = { code, pattern: evaluateSolo(soloSource(code, seen.trackIndex, { parse })) };
5528
+ const pattern = await evaluated.pattern;
5529
+ return span === undefined ? onsetEvents(pattern, seen.pageStart, seen.pageStart + PAGE_CYCLES) : onsetEvents(pattern, span.from, span.to);
5530
+ },
5531
+ apply: (request) => pageRewrite.apply(request),
5532
+ timing: parseTiming(viewState.changeTiming),
5533
+ // An edit is not a request to play.
5534
+ autostart: !pageStopped,
5535
+ record: sendMixerRecord,
5536
+ whenFinished: (record, fn) => takeover.whenFinished(record, fn),
5537
+ activeCode: () => editor.repl.state.activeCode,
5538
+ },
5539
+ });
5540
+ } catch (err) {
5541
+ if (err instanceof NoteEditStop) {
5542
+ outcome = { kind: 'refused', reason: err.reason };
5543
+ } else {
5544
+ // An error nobody planned for is told in the panel as well as the console: the person is never left with a standing sentence
5545
+ // and no word about the edit they asked for.
5546
+ console.error('Moontide: the Timeline edit failed:', err);
5547
+ outcome = { kind: 'refused', reason: 'error' };
5548
+ }
5549
+ } finally {
5550
+ timelineEditBusy = false;
5551
+ }
5552
+ // An error the rewrite path gave back is reported here: at or after the write the edit's own code may have changed; an
5553
+ // evaluation that threw and was taken back left the code as it was.
5554
+ if (outcome.kind === 'refused' && outcome.reason === 'error' && outcome.error !== undefined) {
5555
+ 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);
5556
+ }
5557
+ if (outcome.kind === 'refused' && (outcome.reason === 'stale' || outcome.reason === 'read-back')) {
5558
+ console.error(`Moontide: the note was not changed (${outcome.reason}${outcome.why === undefined ? '' : `: ${outcome.why}`}).`);
5559
+ if (outcome.error !== undefined) console.error('Moontide: the edited track could not be read back:', outcome.error);
5560
+ }
5561
+ timelineEditUi.showOutcome(id, outcome);
5562
+ if (outcome.kind === 'waiting') {
5563
+ // The panel keeps the edit controls off until the change takes over; it hears of the takeover (or of its being dropped).
5564
+ timelineEditWait = { record: outcome.record };
5565
+ takeover.whenFinished(outcome.record, () => syncTimelineWait());
5566
+ syncTimelineWait();
5567
+ }
5568
+ }
5569
+
5570
+ /**
5571
+ * Launches the section the person clicked on. Runs only inside the serial `chain`. A pending Mixer gesture is recorded
5572
+ * first, as for a part launch, and the touched-line overlay stops describing the text. The line under the toolbar and the
5573
+ * countdown of the header are brought up to date when the gesture is over.
5574
+ */
5575
+ async function launchSection(seen) {
5576
+ await mixerGesture.flushDirect();
5577
+ clearMixerFlash();
5578
+ gridRefused = null;
5579
+ gridBusy = true;
5580
+ try {
5581
+ await launchSectionNow(seen);
5582
+ } finally {
5583
+ gridBusy = false;
5584
+ syncGridQueued(currentSoundState === 'playing' ? currentCycle() : null);
5585
+ refreshSongLine();
5586
+ if (songGridReading !== null) updateSongGridLive();
5587
+ }
5588
+ }
5589
+
5590
+ /**
5591
+ * The gesture itself. A click on the header of the launch that waits cancels it (nothing is recorded, the music keeps
5592
+ * playing, the editor goes back to the playing code); a click on another header while ours waits replaces it, built on the
5593
+ * waiting code; both only until the scheduler has queried past the line, after which a click changes nothing; a click on
5594
+ * the header of a launch that needs no code change only hides its countdown. While the sound is stopped the piece starts at
5595
+ * that section at once. Otherwise the shift that puts the section on the chosen line is written and evaluated with the
5596
+ * takeover pinned to that line, and the server hears of it (one history entry) when the section takes over. The frontier is
5597
+ * read, the line and the shift computed, the code written and the evaluation started in one turn:
5598
+ * nothing is awaited between reading the frontier and the call that writes, which is what makes the line the takeover takes
5599
+ * agree with the line the shift was written for.
5600
+ */
5601
+ async function launchSectionNow(seen) {
4342
5602
  if (!window.acorn || typeof window.acorn.parse !== 'function') {
4343
- console.error('Moontide: window.acorn is not available — cannot launch a part.');
5603
+ console.error('Moontide: window.acorn is not available — cannot launch a section.');
4344
5604
  return;
4345
5605
  }
4346
- const parse = window.acorn.parse;
4347
- const code = playingCode();
4348
- let arrangements;
5606
+ const ours = waitingGridLaunch();
5607
+ // The countdown of a launch that needs no code change: a second click on its header hides it, nothing else is involved.
5608
+ if (gridAlready !== null && gridAlready.index === seen.index && ours === null) {
5609
+ gridAlready = null;
5610
+ return;
5611
+ }
5612
+ if (ws.readyState !== ws.OPEN) {
5613
+ console.error('Moontide: the section was not launched: the socket is not open.');
5614
+ return;
5615
+ }
5616
+ if (!hasEvaluatedSuccessfully) {
5617
+ console.error('Moontide: the section was not launched: no code has played yet.');
5618
+ return;
5619
+ }
5620
+ const waiting = takeover.pending();
5621
+ if (waiting !== null && (ours === null || waiting !== ours.record)) {
5622
+ console.error('Moontide: the section was not launched: a change is waiting for its bar line.');
5623
+ return;
5624
+ }
5625
+ // The code the gesture builds on, and the text the editor must still hold: while our launch waits that is the waiting
5626
+ // code, which differs from the playing code in the shift alone. Typing that was never evaluated is never overwritten, and
5627
+ // a cancel never discards it either.
5628
+ const playing = playingCode();
5629
+ const base = ours !== null ? ours.record.code : playing;
5630
+ if (editor.code !== base) {
5631
+ console.error('Moontide: the section was not launched: the editor holds a hand edit that was not evaluated.');
5632
+ return;
5633
+ }
5634
+ let reading;
5635
+ let baseReading;
4349
5636
  try {
4350
- const tracks = extractTracks(code, { parse });
4351
- arrangements = extractArrangements(code, { parse, tracks });
5637
+ reading = readSongOf(playing);
5638
+ baseReading = ours !== null ? readSongOf(base) : reading;
4352
5639
  } catch (err) {
4353
- console.error('Moontide: failed to parse the playing code for a part launch:', err);
5640
+ console.error('Moontide: failed to parse the code for a section launch:', err);
4354
5641
  return;
4355
5642
  }
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();
5643
+ if (reading.state !== 'read' || baseReading.state !== 'read' || checkSection(reading, seen) !== null) {
5644
+ console.error('Moontide: the section to launch is gone; the code changed since the click.');
5645
+ gridRefused = 'section-gone';
4361
5646
  return;
4362
5647
  }
4363
5648
 
4364
- const wasStopped = pageStopped;
5649
+ // Once the scheduler has queried past the line, the notes of the new section are already scheduled: a cancel would let
5650
+ // them sound and then put the old music back in the middle of the section, and a replacement would be built on a
5651
+ // playing code that is no longer the one that plays. Nothing is changed; the launch takes over on its line.
5652
+ if (ours !== null && ours.record.reached) {
5653
+ console.error(`Moontide: the section '${ours.name}' is too close to its line to be canceled or replaced; it takes over.`);
5654
+ return;
5655
+ }
5656
+
5657
+ if (ours !== null && ours.index === seen.index) {
5658
+ takeover.cancel('canceled');
5659
+ return;
5660
+ }
5661
+
5662
+ if (!playsNow()) {
5663
+ await launchStoppedSection(seen, playing, reading);
5664
+ return;
5665
+ }
5666
+
5667
+ // Another launch replaces the countdown of one that needs no code change.
5668
+ gridAlready = null;
5669
+ let attempt = 0;
5670
+ let from = base;
5671
+ let fromReading = baseReading;
5672
+ for (;;) {
5673
+ attempt += 1;
5674
+ // Read the frontier, compute the line and the shift, write and evaluate: no await until the call has started.
5675
+ const { line, at } = launchLine({ kind: seen.kind, frontier: scheduler.lastEnd, cps: tempoGuard.playing(), reading, minLeadSeconds: MIN_TAKEOVER_LEAD_SECONDS });
5676
+ const shift = launchShift(reading, seen.index, line);
5677
+ if (sameShift(shift, reading.shift, reading.total)) {
5678
+ // The section already starts on that line: nothing is written. A launch of ours that waits (or the first try of
5679
+ // this one, written for a line that moved) is dropped, and the header counts down to the line.
5680
+ if (takeover.pending() !== null) takeover.cancel('canceled');
5681
+ gridAlready = { index: seen.index, name: seen.name, line };
5682
+ return;
5683
+ }
5684
+ const outcome = await pageRewrite.apply({
5685
+ base: from,
5686
+ timing: pinnedTiming(at, line),
5687
+ // The sound plays (this loop runs only then), so the scheduler is started already. A Stop that lands while the
5688
+ // change is prepared must not start it again: the evaluation only swaps the pattern.
5689
+ autostart: false,
5690
+ flash: false,
5691
+ // A waiting change of ours is replaced by this one, so only a change of someone else's keeps it from running.
5692
+ guards: from === playing ? undefined : { waiting: false },
5693
+ build(text) {
5694
+ const edits = shiftEdit(fromReading, shift);
5695
+ return { code: applyEdits(text, edits), edits };
5696
+ },
5697
+ });
5698
+ if (outcome.status === 'refused') {
5699
+ if (takeover.pending() !== null && attempt > 1) takeover.cancel('canceled');
5700
+ reportGridRefusal(outcome.reason);
5701
+ return;
5702
+ }
5703
+ if (outcome.status === 'unchanged') return;
5704
+ if (outcome.status === 'failed') {
5705
+ // The old code is still what plays; the shared path set the editor back and showed the banner.
5706
+ if (takeover.pending() !== null && attempt > 1) takeover.cancel('canceled');
5707
+ gridRefused = 'not-evaluated';
5708
+ return;
5709
+ }
5710
+ const record = outcome.run.deferred;
5711
+ if (record === null || record === undefined) {
5712
+ // The change took over at once (the music stopped while it was prepared, and the evaluation did not start the
5713
+ // scheduler again): the code is the music that plays when Play comes, so the server hears of it all the same.
5714
+ sendIfOpen(JSON.stringify({ type: 'part-launch', code: editor.repl.state.activeCode, part: partLaunchPart(seen.name) }));
5715
+ // The server reads every launch as the sound playing, and the Stop's own report may have reached it before this one:
5716
+ // saying the Stop again makes the last word the server hears the true one.
5717
+ if (pageStopped) sendIfOpen(JSON.stringify({ type: 'user-stopped' }));
5718
+ flashMixerLines(outcome.edits);
5719
+ return;
5720
+ }
5721
+ if (record.canceled) {
5722
+ // Stop, or a change of someone else's, canceled it while the evaluation ran. After a Stop its text would read as
5723
+ // typing that was never evaluated: it goes back like any launch that ends without taking over.
5724
+ if (record.cancelReason === 'stop') putBackCanceledLaunch(record);
5725
+ return;
5726
+ }
5727
+ if (record.boundary === line) {
5728
+ gridLaunch = { record, index: seen.index, name: seen.name, line, at };
5729
+ takeover.whenFinished(record, () => {
5730
+ if (gridLaunch !== null && gridLaunch.record === record) gridLaunch = null;
5731
+ gridQueued = null;
5732
+ sendIfOpen(JSON.stringify({ type: 'part-launch', code: editor.repl.state.activeCode, part: partLaunchPart(seen.name) }));
5733
+ if (songGridReading !== null) {
5734
+ updateSongGridLive();
5735
+ songGridUi.scrollPlayingIntoView();
5736
+ }
5737
+ });
5738
+ return;
5739
+ }
5740
+ // The takeover took another line than the one the shift was written for (the frontier moved while the code was
5741
+ // prepared). The waiting code is written again for a fresh line, once; the newer change replaces it.
5742
+ if (attempt >= 2) {
5743
+ takeover.cancel('canceled');
5744
+ console.error(`Moontide: the section launch could not be placed on its line (wanted cycle ${line}, the takeover took ${record.boundary}); it was canceled.`);
5745
+ gridRefused = 'line-moved';
5746
+ return;
5747
+ }
5748
+ from = record.code;
5749
+ try {
5750
+ fromReading = readSongOf(from);
5751
+ } catch (err) {
5752
+ takeover.cancel('canceled');
5753
+ console.error('Moontide: failed to parse the waiting code for a section launch:', err);
5754
+ return;
5755
+ }
5756
+ }
5757
+ }
5758
+
5759
+ /**
5760
+ * A click on a header while the sound is stopped: the piece starts at that section at once. The shift is written so the
5761
+ * section starts at cycle 0 (the count restarts at 0 at every Play), the sound comes back, and the server hears of it. When
5762
+ * the shift is already right the text stays as it is and is evaluated all the same, so the sound starts.
5763
+ */
5764
+ async function launchStoppedSection(seen, playing, reading) {
5765
+ const shift = launchShift(reading, seen.index, 0);
5766
+ const unchanged = sameShift(shift, reading.shift, reading.total);
5767
+ const outcome = await pageRewrite.apply({
5768
+ base: playing,
5769
+ autostart: true,
5770
+ flash: false,
5771
+ evaluateUnchanged: true,
5772
+ build(text) {
5773
+ const edits = unchanged ? [] : shiftEdit(reading, shift);
5774
+ return { code: applyEdits(text, edits), edits };
5775
+ },
5776
+ });
5777
+ if (outcome.status === 'refused') {
5778
+ reportGridRefusal(outcome.reason);
5779
+ return;
5780
+ }
5781
+ if (outcome.status === 'unchanged') return;
5782
+ if (outcome.status === 'failed') {
5783
+ gridRefused = 'not-evaluated';
5784
+ return;
5785
+ }
5786
+ await resumeAfterStop();
5787
+ sendIfOpen(JSON.stringify({ type: 'part-launch', code: editor.repl.state.activeCode, part: partLaunchPart(seen.name) }));
5788
+ flashMixerLines(outcome.edits);
5789
+ }
5790
+
5791
+ /**
5792
+ * What a launch the shared path refused leaves behind. Most refusals are states the line under the toolbar already says
5793
+ * (a waiting change, unevaluated typing, a closed socket); the console names them too, so none is silent.
5794
+ */
5795
+ function reportGridRefusal(reason, subject = 'the section was not launched') {
5796
+ switch (reason) {
5797
+ case 'not-played':
5798
+ console.error(`Moontide: ${subject}: no code has played yet.`);
5799
+ break;
5800
+ case 'waiting':
5801
+ console.error(`Moontide: ${subject}: a change is waiting for its bar line.`);
5802
+ break;
5803
+ case 'closed':
5804
+ console.error(`Moontide: ${subject}: the socket is not open.`);
5805
+ break;
5806
+ case 'no-parser':
5807
+ console.error(`Moontide: window.acorn is not available — ${subject}.`);
5808
+ break;
5809
+ case 'dirty':
5810
+ console.error(`Moontide: ${subject}: the editor holds a hand edit that was not evaluated.`);
5811
+ break;
5812
+ case 'write-mismatch':
5813
+ console.error('Moontide: the editor text does not match the rewrite; the editor was set back.');
5814
+ gridRefused = 'not-written';
5815
+ break;
5816
+ default:
5817
+ console.error(`Moontide: ${subject} (${String(reason)}).`);
5818
+ }
5819
+ }
5820
+
5821
+ /**
5822
+ * Works out the sentence under the toolbar for the state of the page now. The editor is compared with the code the grid
5823
+ * works on (the waiting code while our launch waits, else the playing code) except while a gesture or any programmatic
5824
+ * evaluation is in flight, when the editor legitimately differs; then the last answer stands. While the editor holds text
5825
+ * that was never evaluated, the launch lines give way to the dirty one: no gesture could cancel or replace the launch
5826
+ * without discarding that text, so every control is off and the line says why.
5827
+ */
5828
+ function refreshSongLine() {
5829
+ if (songGridReading === null || gridBusy) return;
5830
+ const waitingRecord = takeover.pending();
5831
+ const ours = gridLaunch !== null && waitingRecord === gridLaunch.record ? gridLaunch : null;
5832
+ if (hasEvaluatedSuccessfully && !programmaticEvalInFlight && !mixerTickInFlight) {
5833
+ gridDirty = editor.code !== (ours !== null ? ours.record.code : playingCode());
5834
+ }
5835
+ let launch = null;
5836
+ if (!gridDirty) {
5837
+ if (ours !== null) launch = { kind: 'queued', section: ours.name };
5838
+ else if (gridAlready !== null) launch = { kind: 'already', section: gridAlready.name };
5839
+ }
5840
+ gridLine = songLine({
5841
+ connected: ws.readyState === ws.OPEN,
5842
+ launch,
5843
+ waiting: waitingRecord !== null && (ours === null || waitingRecord !== ours.record),
5844
+ dirty: gridDirty,
5845
+ refused: gridRefused,
5846
+ });
5847
+ }
5848
+
5849
+ /**
5850
+ * The Song grid's share of the 250 ms page poll: a launch that needed no code change ends when the scheduler reaches its
5851
+ * line (the section it named plays then, and its column comes into view once); another change that starts to wait ends it
5852
+ * too; then the sentence under the toolbar is brought up to date.
5853
+ */
5854
+ function pollSongGrid() {
5855
+ if (songGridReading === null || viewState.view !== 'parts') return;
5856
+ let landed = false;
5857
+ if (gridAlready !== null && (currentCycle() >= gridAlready.line || (takeover.pending() !== null && waitingGridLaunch() === null))) {
5858
+ landed = currentCycle() >= gridAlready.line;
5859
+ gridAlready = null;
5860
+ gridQueued = null;
5861
+ }
5862
+ refreshSongLine();
5863
+ if (landed) {
5864
+ updateSongGridLive();
5865
+ songGridUi.scrollPlayingIntoView();
5866
+ }
5867
+ }
5868
+
5869
+ /** Takes the Song grid off the screen (a piece without a readable song shows the Parts view or its message instead). */
5870
+ function hideSongGrid() {
5871
+ songGridUi.hide();
5872
+ songGridReading = null;
5873
+ }
5874
+
5875
+ /**
5876
+ * Hands the live state to the Song grid: which section plays now, only while the page makes sound. The grid writes only
5877
+ * what changed, so calling this every animation frame touches no node while the state is the same.
5878
+ */
5879
+ function updateSongGridLive() {
5880
+ if (songGridReading === null) return;
4365
5881
  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);
5882
+ const cycle = Number.isFinite(rawNow) && rawNow > 0 ? rawNow : 0;
5883
+ const stopped = currentSoundState !== 'playing';
5884
+ // For at most one poll after a takeover the reading can still describe the previous section: the reading is replaced when
5885
+ // the finisher of the new version runs, a moment after the clock has moved on.
5886
+ songGridUi.tick({ playing: stopped ? null : playingSection(songGridReading, cycle), stopped, motion: viewState.motion, queued: gridQueued, line: gridLine });
5887
+ // The playing column scrolls into view once when the sound starts; after that the grid never follows it sideways.
5888
+ if (!stopped && !songGridWasPlaying) songGridUi.scrollPlayingIntoView();
5889
+ songGridWasPlaying = !stopped;
5890
+ }
5891
+
5892
+ /** Ends a part launch before anything is written: the playing code cannot be read, or the part is gone. */
5893
+ class PartLaunchStop extends Error {
5894
+ constructor(kind, cause) {
5895
+ super(`Moontide: the part launch stopped (${kind}).`);
5896
+ this.kind = kind;
5897
+ this.cause = cause;
5898
+ }
5899
+ }
4370
5900
 
4371
- if (newCode === code && !wasStopped) {
5901
+ /**
5902
+ * Launches part `partIndex` of the arrangement whose rowSignature is `signature` — queued on the
5903
+ * serial `chain` by its own click handler (buildPartCard), so it never interleaves with Claude's
5904
+ * evaluate messages. Re-reads and re-parses activeCode first: the click and this run can be
5905
+ * separated by another evaluate landing in between.
5906
+ */
5907
+ async function launchPart(signature, partIndex) {
5908
+ // A part launch replaces the music like Claude's code does, so a pending Mixer gesture is
5909
+ // recorded first (with the code that played), and the touched lines stop describing the text.
5910
+ await mixerGesture.flushDirect();
5911
+ clearMixerFlash();
5912
+ const wasStopped = pageStopped;
5913
+ let section = null;
5914
+ let target = 0;
5915
+ let outcome;
5916
+ try {
5917
+ // This launch has always replaced the whole text, with the editor flashing, and never checked for typing that
5918
+ // was not evaluated, nor for a closed socket or a first play: those three guards stay off so nothing changes.
5919
+ // When the page is stopped, an unchanged text is still evaluated so the sound starts again.
5920
+ outcome = await pageRewrite.apply({
5921
+ write: 'replace',
5922
+ flash: true,
5923
+ guards: { played: false, socket: false, dirty: false },
5924
+ evaluateUnchanged: wasStopped,
5925
+ build(code) {
5926
+ const parse = window.acorn.parse;
5927
+ let arrangements;
5928
+ try {
5929
+ const tracks = extractTracks(code, { parse });
5930
+ arrangements = extractArrangements(code, { parse, tracks });
5931
+ } catch (err) {
5932
+ throw new PartLaunchStop('unreadable', err);
5933
+ }
5934
+ const arrangement = arrangements.find((candidate) => rowSignature(candidate) === signature);
5935
+ section = arrangement && arrangement.sections[partIndex];
5936
+ if (!section) throw new PartLaunchStop('gone');
5937
+ const rawNow = editor.repl.scheduler.now();
5938
+ const now = Number.isFinite(rawNow) && rawNow > 0 ? rawNow : 0;
5939
+ target = launchTargetCycle({ now, cps: editor.repl.scheduler.cps, stopped: wasStopped });
5940
+ const offset = launchOffset(target, section.start, arrangement.total);
5941
+ return { code: applyArrangeOffset(code, arrangement, offset), edits: null };
5942
+ },
5943
+ });
5944
+ } catch (err) {
5945
+ if (!(err instanceof PartLaunchStop)) throw err;
5946
+ if (err.kind === 'unreadable') {
5947
+ console.error('Moontide: failed to parse the playing code for a part launch:', err.cause);
5948
+ } else {
5949
+ console.error('Moontide: the part to launch is gone; the code changed since the click.');
5950
+ if (viewState.view === 'parts') renderParts();
5951
+ }
5952
+ return;
5953
+ }
5954
+ if (outcome.status === 'refused') {
5955
+ if (outcome.reason === 'waiting') {
5956
+ // A launch would play (and record) the waiting change; Parts says so in its message line.
5957
+ console.error('Moontide: the part was not launched: a change is waiting for its bar line.');
5958
+ if (viewState.view === 'parts') renderParts();
5959
+ } else {
5960
+ console.error('Moontide: window.acorn is not available — cannot launch a part.');
5961
+ }
5962
+ return;
5963
+ }
5964
+ if (outcome.status === 'unchanged') {
4372
5965
  // Nothing changes (this part already starts at the target cycle): only record the pending
4373
5966
  // launch and re-render — never evaluate or send anything for a no-op rewrite.
4374
5967
  pendingLaunch = { signature, partIndex, targetCycle: target };
4375
5968
  if (viewState.view === 'parts') renderParts();
4376
5969
  return;
4377
5970
  }
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);
5971
+ if (outcome.status === 'failed') {
5972
+ // The old code is still what is actually playing; the shared path set the editor back and showed the banner.
4388
5973
  pendingLaunch = null;
4389
5974
  if (viewState.view === 'parts') renderParts();
4390
5975
  return;
4391
5976
  }
4392
- // A page-originated evaluation has no Claude evaluate id.
4393
- playingEvaluateId = null;
4394
5977
  // onVersionPlaying (fired by the evaluate above, via trackEvalSuccess) already cleared
4395
5978
  // pendingLaunch — set the real value now that the evaluate has landed.
4396
5979
  pendingLaunch = { signature, partIndex, targetCycle: target };