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.
- package/README.md +44 -6
- package/dist/ask.js +30 -8
- package/dist/mcp.js +60 -14
- package/dist/protocol.js +89 -2
- package/dist/reference.js +22 -0
- package/dist/session.js +53 -0
- package/package.json +1 -1
- package/page/ask-view.js +21 -7
- package/page/ask.js +292 -34
- package/page/change-timing.js +25 -4
- package/page/harmony.js +13 -0
- package/page/index.html +4 -3
- package/page/jam-model.js +11 -2
- package/page/jam-view.js +17 -0
- package/page/lights.js +278 -0
- package/page/main.js +1723 -140
- package/page/midi-clock.js +1 -1
- package/page/midi-out.js +12 -0
- package/page/mixer-groups.js +114 -0
- package/page/mixer-model.js +172 -29
- package/page/mixer-rewrite.js +30 -1
- package/page/mixer-view.js +282 -13
- package/page/note-edit.js +267 -0
- package/page/note-locate.js +346 -0
- package/page/note-panel.js +381 -0
- package/page/note-tag.js +126 -0
- package/page/page-rewrite.js +184 -0
- package/page/song-edit.js +230 -0
- package/page/song-fit.js +250 -0
- package/page/song-grid-model.js +491 -0
- package/page/song-grid-view.js +710 -0
- package/page/song-prefs.js +100 -0
- package/page/structure-check.js +216 -0
- package/page/structure-view.js +38 -0
- package/page/structure.js +760 -0
- package/page/style.css +879 -84
- package/page/takeover.js +20 -5
- package/page/timeline-edit-view.js +1956 -0
- package/page/timeline-gesture.js +279 -0
- package/page/timeline.js +196 -3
- package/page/tracks.js +37 -8
- package/page/ui-state.js +10 -5
- package/page/views.js +25 -8
- package/reference/functions.md +1 -0
- package/reference/midi.md +2 -0
- 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) =>
|
|
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(
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
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
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
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 (
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
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
|
-
|
|
2453
|
-
|
|
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
|
-
|
|
2463
|
-
|
|
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,
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
//
|
|
3454
|
-
//
|
|
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
|
-
|
|
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 ?
|
|
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
|
-
|
|
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
|
-
*
|
|
4327
|
-
*
|
|
4328
|
-
*
|
|
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
|
-
|
|
4332
|
-
|
|
4333
|
-
|
|
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
|
-
|
|
4337
|
-
|
|
4338
|
-
|
|
4339
|
-
|
|
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
|
|
5603
|
+
console.error('Moontide: window.acorn is not available — cannot launch a section.');
|
|
4344
5604
|
return;
|
|
4345
5605
|
}
|
|
4346
|
-
const
|
|
4347
|
-
|
|
4348
|
-
|
|
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
|
-
|
|
4351
|
-
|
|
5637
|
+
reading = readSongOf(playing);
|
|
5638
|
+
baseReading = ours !== null ? readSongOf(base) : reading;
|
|
4352
5639
|
} catch (err) {
|
|
4353
|
-
console.error('Moontide: failed to parse the
|
|
5640
|
+
console.error('Moontide: failed to parse the code for a section launch:', err);
|
|
4354
5641
|
return;
|
|
4355
5642
|
}
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
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
|
-
|
|
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
|
|
4367
|
-
const
|
|
4368
|
-
|
|
4369
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|