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