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