moontide-mcp 2.1.0 → 2.4.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 -331
- package/dist/ask.js +59 -12
- package/dist/catalog.js +118 -9
- package/dist/instruments.js +83 -9
- package/dist/mcp.js +146 -20
- package/dist/midi-state.js +43 -1
- package/dist/protocol.js +123 -5
- package/dist/reference.js +12 -0
- package/dist/request-status.js +221 -0
- package/dist/session.js +203 -7
- package/dist/vocabulary.js +420 -0
- package/package.json +3 -2
- package/page/ask-view.js +118 -50
- package/page/ask.js +139 -169
- package/page/bar-model.js +44 -2
- package/page/change-timing.js +35 -0
- package/page/index.html +6 -2
- package/page/instruments-model.js +50 -3
- package/page/instruments-view.js +14 -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 +807 -66
- package/page/midi-clock.js +22 -3
- package/page/midi-in.js +407 -0
- package/page/midi-out.js +557 -48
- package/page/midi-plan.js +42 -6
- package/page/midi-rig.js +286 -13
- package/page/midi-settings.js +507 -0
- 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/played-buffer.js +208 -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/scratch-gesture.js +239 -0
- package/page/scratch-listen.js +260 -0
- package/page/scratch-model.js +1696 -0
- package/page/scratch-view.js +2176 -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 +1243 -43
- 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 +20 -7
- package/reference/midi.md +2 -0
- package/reference/motif.md +83 -0
- package/reference/settings.md +161 -0
- 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,20 +91,28 @@ 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';
|
|
111
|
+
import { createScratchView } from './scratch-view.js';
|
|
112
|
+
import { createMidiIn } from './midi-in.js';
|
|
113
|
+
import { createPlayedBuffer } from './played-buffer.js';
|
|
114
|
+
import { cycleAtStamp, listenOptions, unitInMotif } from './scratch-model.js';
|
|
115
|
+
import { createScratchListen } from './scratch-listen.js';
|
|
107
116
|
import { createInstrumentsView } from './instruments-view.js';
|
|
108
117
|
import { instrumentsModel, readDataFolder, readInstrumentResult } from './instruments-model.js';
|
|
109
118
|
import { createMidiOut, installNoMidiShim, isKnownMidiRejection } from './midi-out.js';
|
|
@@ -124,6 +133,7 @@ import { checkFailureText, drumMapFor, parseRigMessage, rigFromReadFailure, with
|
|
|
124
133
|
import { drumLane } from './drum-lane.js';
|
|
125
134
|
import { captureQueryTypes, createSchedulerQueryScope, installQueryCheck } from './query-check.js';
|
|
126
135
|
import { createTempoGuard } from './tempo-guard.js';
|
|
136
|
+
import { GLIDE_REFUSALS, createTempoGlide, lineTempoAction, parseGlideBars } from './tempo-glide.js';
|
|
127
137
|
import {
|
|
128
138
|
BEATS_PER_CYCLE,
|
|
129
139
|
CHANGE_COPY,
|
|
@@ -137,6 +147,7 @@ import {
|
|
|
137
147
|
parseTiming,
|
|
138
148
|
pinnedTiming,
|
|
139
149
|
queuedLine,
|
|
150
|
+
rampLine,
|
|
140
151
|
settingLine,
|
|
141
152
|
} from './change-timing.js';
|
|
142
153
|
import { createTopBarView } from './top-bar-view.js';
|
|
@@ -195,6 +206,9 @@ let pageSocket = 'connecting';
|
|
|
195
206
|
let pageDataFolder = null;
|
|
196
207
|
// The one place the page asks whether this browser has Web MIDI; midi-out and the Instruments view share it.
|
|
197
208
|
const supportsMidi = () => typeof navigator.requestMIDIAccess === 'function';
|
|
209
|
+
// The listener for the MIDI inputs the setup file names (made below, next to the Scratchpad it feeds). Declared here so the MIDI facts'
|
|
210
|
+
// callback can tell it that the port list or the access just changed.
|
|
211
|
+
let midiIn = null;
|
|
198
212
|
const midiOut = createMidiOut({
|
|
199
213
|
getWebMidi: () => globalThis.WebMidi ?? null,
|
|
200
214
|
enableWebMidi: () => {
|
|
@@ -220,10 +234,17 @@ const midiOut = createMidiOut({
|
|
|
220
234
|
getScheduler: () => editor.repl.scheduler,
|
|
221
235
|
getAudioContext: () => (typeof globalThis.getAudioContext === 'function' ? globalThis.getAudioContext() : null),
|
|
222
236
|
perfNow: () => performance.now(),
|
|
237
|
+
// 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).
|
|
238
|
+
rampSeconds: (fromCycle, toCycle) => {
|
|
239
|
+
const seconds = tempoGlide.secondsBetween(fromCycle, toCycle);
|
|
240
|
+
return seconds === null ? null : { seconds, lagSeconds: tempoGlide.lagSecondsBetween(fromCycle, toCycle) };
|
|
241
|
+
},
|
|
223
242
|
// A clock state change (a Start waiting, a Start reached, a Stop) re-renders the indicator and its popover rows.
|
|
224
243
|
onClockState: () => renderMidiIndicator(),
|
|
225
244
|
now: () => Date.now(),
|
|
226
245
|
publish: (facts) => {
|
|
246
|
+
// The access may just have come up, or a port may have come or gone: the input listener reads the ports again.
|
|
247
|
+
midiIn?.refresh();
|
|
227
248
|
// The indicator shows the same facts the server hears, so the page and Claude never disagree; it
|
|
228
249
|
// renders even while the socket is closed, so it can hide.
|
|
229
250
|
renderMidiIndicator(facts);
|
|
@@ -257,6 +278,71 @@ let evalQueue = Promise.resolve();
|
|
|
257
278
|
// launch all pass here too, so a hand evaluation follows the same rule. Checking after evaluate returns
|
|
258
279
|
// would be too late: the failing pattern would already be the scheduler's pattern.
|
|
259
280
|
const scheduler = editor.repl.scheduler;
|
|
281
|
+
// The tempo ramp (page/tempo-glide.js): it moves the tempo in steps toward the tempo a change set, and every step goes
|
|
282
|
+
// through the tempo guard's apply. The closure reads tempoGuard only when a step fires, long after the page has loaded.
|
|
283
|
+
const tempoGlide = createTempoGlide({
|
|
284
|
+
scheduler,
|
|
285
|
+
apply: (cps) => tempoGuard.apply(cps),
|
|
286
|
+
setTimer: (fn, ms) => setTimeout(fn, ms),
|
|
287
|
+
clearTimer: (timer) => clearTimeout(timer),
|
|
288
|
+
onEvent(event) {
|
|
289
|
+
// The server is told when a ramp ends on its own, synchronously, so the message goes out before the evaluated reply
|
|
290
|
+
// of the change that caused it and that reply carries the cancel line. Stop, a lost role, a page reload and a ramp that
|
|
291
|
+
// a new ramp replaced send nothing: the server learns the first three itself and the new ramp comes in its own reply.
|
|
292
|
+
// Mirrors src/protocol.ts's glide-ended by hand.
|
|
293
|
+
if (event.kind === 'finished') {
|
|
294
|
+
sendIfOpen(JSON.stringify({ type: 'glide-ended', reason: 'finished' }));
|
|
295
|
+
} else if (event.kind === 'canceled' && (event.reason === 'other-tempo' || event.reason === 'no-tempo' || event.reason === 'error')) {
|
|
296
|
+
const bpm = bpmForReply(event.cps);
|
|
297
|
+
sendIfOpen(JSON.stringify({ type: 'glide-ended', reason: event.reason, ...(bpm !== undefined ? { bpm } : {}) }));
|
|
298
|
+
}
|
|
299
|
+
if (event.kind === 'canceled' && event.reason === 'error') {
|
|
300
|
+
console.error(`Moontide: the tempo ramp was canceled because a step failed; the tempo stays where it was (${String(event.cps)} cps).`);
|
|
301
|
+
}
|
|
302
|
+
// The live region speaks a ramp when it starts, when it finishes and when the code cancels it, and says nothing for a step.
|
|
303
|
+
// Stop, a lost role, a reload, a failed step and a replaced ramp are not spoken (a replaced ramp's new start is).
|
|
304
|
+
const { plan } = event;
|
|
305
|
+
if (event.kind === 'started') {
|
|
306
|
+
const stillRuns = () => {
|
|
307
|
+
const running = tempoGlide.current();
|
|
308
|
+
return running !== null && running.startCycle === plan.startCycle && running.toCps === plan.toCps;
|
|
309
|
+
};
|
|
310
|
+
sayRamp(() => ({ kind: 'started', fromBpm: bpmOf(plan.fromCps), toBpm: bpmOf(plan.toCps), bars: plan.bars }), stillRuns);
|
|
311
|
+
} else if (event.kind === 'finished') {
|
|
312
|
+
sayRamp(() => ({ kind: 'finished', toBpm: bpmOf(plan.toCps) }));
|
|
313
|
+
} else if (event.kind === 'canceled' && (event.reason === 'other-tempo' || event.reason === 'no-tempo')) {
|
|
314
|
+
sayRamp(() => ({ kind: 'canceled', reason: event.reason, bpm: bpmOf(event.cps) }));
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
onError: (text, err) => console.error(text, err),
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// The waiting change whose line onReached is handling now, or null. A ramp that starts or ends at a line is spoken only once
|
|
321
|
+
// that change has finished, after the silent clear of its countdown line, so the sentence is the one that stays in the live region.
|
|
322
|
+
let reachedRecord = null;
|
|
323
|
+
const rampSayLogged = new Set();
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Says the sentence of a ramp event through the top bar's live region. `makeEvent` builds the event for rampLine and runs now;
|
|
327
|
+
* the sentence is spoken now, or after the finish of the waiting change whose line is being handled, and (with `stillCurrent`)
|
|
328
|
+
* only if it is still true then. A failure is logged once for each text and never reaches the ramp.
|
|
329
|
+
*/
|
|
330
|
+
function sayRamp(makeEvent, stillCurrent = () => true) {
|
|
331
|
+
try {
|
|
332
|
+
const line = rampLine(makeEvent());
|
|
333
|
+
const speak = () => {
|
|
334
|
+
if (stillCurrent()) topBarUi.say(line);
|
|
335
|
+
};
|
|
336
|
+
if (reachedRecord !== null) takeover.whenFinished(reachedRecord, speak);
|
|
337
|
+
else speak();
|
|
338
|
+
} catch (err) {
|
|
339
|
+
const text = err instanceof Error ? err.message : String(err);
|
|
340
|
+
if (!rampSayLogged.has(text)) {
|
|
341
|
+
rampSayLogged.add(text);
|
|
342
|
+
console.error('Moontide: the tempo ramp could not be spoken:', err);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
260
346
|
// State and TimeSpan are kept from the first evaluation on (captureQueryTypes, called before the user code
|
|
261
347
|
// of each evaluation runs), so a hand edit that assigns to those globals cannot change the check.
|
|
262
348
|
let queryTypes = { State: undefined, TimeSpan: undefined };
|
|
@@ -274,6 +360,7 @@ const queryErrors = createOncePerChange();
|
|
|
274
360
|
// 1 s debounce lets a page query hide the scheduler's report. The log listener drops every [query] text.
|
|
275
361
|
const schedulerQueries = createSchedulerQueryScope({
|
|
276
362
|
getTypes: () => queryTypes,
|
|
363
|
+
onSchedulerQuery: () => tempoGlide.onSchedulerQuery(),
|
|
277
364
|
onError(logText) {
|
|
278
365
|
const text = runtimeErrorFromLog(logText);
|
|
279
366
|
if (text !== null && queryErrors.first(text)) sendRuntimeError(text);
|
|
@@ -281,7 +368,11 @@ const schedulerQueries = createSchedulerQueryScope({
|
|
|
281
368
|
});
|
|
282
369
|
|
|
283
370
|
// 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
|
|
371
|
+
// { timing, deferred, bpm, midiDeferred, glideBars, glide, glidePlan, glideRefusal }. `deferred` is the waiting change the run
|
|
372
|
+
// created (set by the takeover's onArmed), or null. `glideBars` is the length of the tempo ramp the change asked for, or null;
|
|
373
|
+
// `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
|
|
374
|
+
// the run started, or null; `glideRefusal` is the text of the refusal of a ramp that cannot be made (set at the setPattern seam),
|
|
375
|
+
// or null.
|
|
285
376
|
const NOW_TIMING = parseTiming('now');
|
|
286
377
|
let armedRun = null; // set right before a programmatic evaluation and consumed by the wrapper, synchronously
|
|
287
378
|
let activeRun = null; // the run whose evaluation is executing now (evaluations are serialized)
|
|
@@ -321,16 +412,33 @@ function playingPattern() {
|
|
|
321
412
|
return editor.repl.state.pattern;
|
|
322
413
|
}
|
|
323
414
|
|
|
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 };
|
|
415
|
+
/** Arms the timing (and the tempo ramp) of the next editor.repl.evaluate call; the wrapper takes it synchronously. */
|
|
416
|
+
function armRun(timing, glideBars = null) {
|
|
417
|
+
const run = { timing, deferred: null, bpm: undefined, midiDeferred: false, glideBars, glide: undefined, glidePlan: null, glideRefusal: null };
|
|
327
418
|
armedRun = run;
|
|
328
419
|
return run;
|
|
329
420
|
}
|
|
330
421
|
|
|
331
|
-
/**
|
|
422
|
+
/**
|
|
423
|
+
* The AudioContext time of a cycle line, from the scheduler's current tempo segment (as a hap's targetTime). While a tempo ramp
|
|
424
|
+
* 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
|
|
425
|
+
* 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
|
|
426
|
+
* a rising one.
|
|
427
|
+
*/
|
|
332
428
|
function audioTimeOfCycle(cycle) {
|
|
333
|
-
|
|
429
|
+
const anchor = scheduler.num_cycles_at_cps_change;
|
|
430
|
+
const throughRamp = cycle > anchor ? tempoGlide.secondsBetween(anchor, cycle) : null;
|
|
431
|
+
const seconds = throughRamp !== null ? throughRamp : (cycle - anchor) / scheduler.cps;
|
|
432
|
+
return seconds + scheduler.seconds_at_cps_change + scheduler.latency;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* 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
|
|
437
|
+
* cycles when the change is armed, so during a tempo ramp they are sized at the fastest tempo the ramp reaches and are never
|
|
438
|
+
* shorter in seconds than intended.
|
|
439
|
+
*/
|
|
440
|
+
function takeoverCps() {
|
|
441
|
+
return Math.max(tempoGuard.playing(), tempoGlide.fastestCps() ?? 0);
|
|
334
442
|
}
|
|
335
443
|
|
|
336
444
|
/** Milliseconds until a cycle line is audible (0 when it already is). */
|
|
@@ -349,13 +457,17 @@ const takeover = createTakeover({
|
|
|
349
457
|
getTypes: () => queryTypes,
|
|
350
458
|
getTiming: () => (activeRun !== null ? activeRun.timing : NOW_TIMING),
|
|
351
459
|
// 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: () =>
|
|
460
|
+
getCps: () => takeoverCps(),
|
|
353
461
|
isPlaying: () => scheduler.started === true && !pageStopped && hasEvaluatedSuccessfully && !!scheduler.pattern,
|
|
354
462
|
msUntil: msUntilCycle,
|
|
355
463
|
setTimer: (fn, ms) => setTimeout(fn, ms),
|
|
356
464
|
clearTimer: (timer) => clearTimeout(timer),
|
|
357
465
|
onArmed(record) {
|
|
358
466
|
if (activeRun !== null) activeRun.deferred = record;
|
|
467
|
+
// Whether the waiting change also asks for a ramp: it starts at the line (onReached), never before.
|
|
468
|
+
record.glideBars = activeRun !== null ? activeRun.glideBars : null;
|
|
469
|
+
// The plan of the ramp it started at its line, or null: the takeover message says whether that ramp still runs.
|
|
470
|
+
record.glidePlan = null;
|
|
359
471
|
// The first finisher of the record: the Mixer and Parts stop saying "waiting" and read the new code at the
|
|
360
472
|
// takeover, before the finishers that report it.
|
|
361
473
|
takeover.whenFinished(record, () => {
|
|
@@ -400,14 +512,42 @@ const takeover = createTakeover({
|
|
|
400
512
|
},
|
|
401
513
|
onReached(record) {
|
|
402
514
|
// 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
|
-
|
|
515
|
+
// and the old bar is untouched. A canceled record never gets here (the takeover skips it), so a waiting change that
|
|
516
|
+
// asks for a ramp and is replaced or canceled before its line never starts one.
|
|
517
|
+
if (takeover.pending() !== record) return;
|
|
518
|
+
reachedRecord = record;
|
|
405
519
|
try {
|
|
406
|
-
//
|
|
407
|
-
|
|
408
|
-
|
|
520
|
+
// The line decides what the change does to the tempo and to a ramp that runs (page/tempo-glide.js lineTempoAction).
|
|
521
|
+
const action = lineTempoAction({
|
|
522
|
+
running: tempoGlide.running(),
|
|
523
|
+
targetCps: tempoGlide.targetCps(),
|
|
524
|
+
newCps: record.newCps,
|
|
525
|
+
startsGlide: typeof record.glideBars === 'number',
|
|
526
|
+
});
|
|
527
|
+
if (action === 'start-glide') {
|
|
528
|
+
// The ramp starts at the line from the tempo that plays then; nothing is applied here.
|
|
529
|
+
const fromCps = tempoGuard.playing();
|
|
530
|
+
if (fromCps !== record.newCps) {
|
|
531
|
+
record.glidePlan = tempoGlide.start({ fromCps, toCps: record.newCps, bars: record.glideBars, startCycle: record.boundary });
|
|
532
|
+
} else {
|
|
533
|
+
// Nothing to ramp between: the tempo that plays is the target already. An older ramp that passes through it must
|
|
534
|
+
// not go on toward its own target, so it ends here as replaced (no message): the takeover message says no ramp
|
|
535
|
+
// runs, and the server clears its record then.
|
|
536
|
+
tempoGlide.cancel('replaced');
|
|
537
|
+
}
|
|
538
|
+
} else if (action === 'cancel-no-tempo') {
|
|
539
|
+
tempoGlide.cancel('no-tempo');
|
|
540
|
+
} else if (action === 'cancel-and-apply' || action === 'apply') {
|
|
541
|
+
// Kept for a cancel after this point (Stop before the line is audible): the tempo that played goes back with it.
|
|
542
|
+
record.cpsBefore = tempoGuard.playing();
|
|
543
|
+
tempoGuard.apply(record.newCps);
|
|
544
|
+
if (action === 'cancel-and-apply') tempoGlide.cancel('other-tempo');
|
|
545
|
+
}
|
|
546
|
+
// 'keep' and 'nothing' apply nothing: the step that plays stays.
|
|
409
547
|
} catch (err) {
|
|
410
548
|
console.error('Moontide: the tempo of the waiting change could not be applied:', err);
|
|
549
|
+
} finally {
|
|
550
|
+
reachedRecord = null;
|
|
411
551
|
}
|
|
412
552
|
},
|
|
413
553
|
onCancel(record, reason) {
|
|
@@ -481,6 +621,7 @@ installQueryCheck(scheduler, {
|
|
|
481
621
|
schedulerQueries,
|
|
482
622
|
takeover,
|
|
483
623
|
getPlayingCode: () => (hasEvaluatedSuccessfully ? playingCode() : ''),
|
|
624
|
+
refuse: glideRefusal,
|
|
484
625
|
});
|
|
485
626
|
|
|
486
627
|
// The top bar's tempo readout and countdown. The view only writes what changed.
|
|
@@ -499,7 +640,9 @@ let badWaitingTempoLogged;
|
|
|
499
640
|
* the scheduler's cps, which keeps the old value until a waiting change's line, and the countdown is the distance
|
|
500
641
|
* to the waiting change's line in beats of the music. The tempo readout is always shown: while this tab does not
|
|
501
642
|
* 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').
|
|
643
|
+
* waiting change that carries a new tempo adds it after an arrow ('118 → 124 BPM'). While a tempo ramp runs the number is the
|
|
644
|
+
* 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
|
|
645
|
+
* ramp wins over a waiting change's tempo, and the same 250 ms poll draws it. The countdown is hidden while
|
|
503
646
|
* nothing plays. The view redraws only when a text changed.
|
|
504
647
|
*/
|
|
505
648
|
function drawTopBar() {
|
|
@@ -515,6 +658,9 @@ function drawTopBar() {
|
|
|
515
658
|
}
|
|
516
659
|
const record = takeover.pending();
|
|
517
660
|
const cps = scheduler.cps;
|
|
661
|
+
// The cycle is read before the tempo: the bars left of a ramp count from it.
|
|
662
|
+
const raw = scheduler.now();
|
|
663
|
+
const now = Number.isFinite(raw) && raw > 0 ? raw : 0;
|
|
518
664
|
try {
|
|
519
665
|
const bpm = bpmOf(cps);
|
|
520
666
|
// The tempo a waiting change brings, when it carries one; a bad number is logged once and the arrow is left out.
|
|
@@ -532,7 +678,14 @@ function drawTopBar() {
|
|
|
532
678
|
}
|
|
533
679
|
}
|
|
534
680
|
}
|
|
535
|
-
|
|
681
|
+
// A running ramp shows its target and the bars left (the ramp wins over a waiting change's tempo); the live tempo is the
|
|
682
|
+
// scheduler's own cps, the step that plays. A bad number falls into the catch below like a bad steady tempo.
|
|
683
|
+
const glide = tempoGlide.current();
|
|
684
|
+
topBarUi.showTempo(
|
|
685
|
+
glide === null
|
|
686
|
+
? tempoText({ playing: true, bpm, waitingBpm })
|
|
687
|
+
: tempoText({ playing: true, bpm, waitingBpm, ramp: { targetBpm: bpmOf(glide.toCps), barsLeft: tempoGlide.barsLeft(now), rising: glide.toCps > glide.fromCps } }),
|
|
688
|
+
);
|
|
536
689
|
} catch (err) {
|
|
537
690
|
topBarUi.showTempo(idleTempo);
|
|
538
691
|
if (!Object.is(badTempoLogged, cps)) {
|
|
@@ -540,8 +693,6 @@ function drawTopBar() {
|
|
|
540
693
|
console.error(`Moontide: the top bar cannot show the tempo, the scheduler reports cps ${String(cps)}:`, err);
|
|
541
694
|
}
|
|
542
695
|
}
|
|
543
|
-
const raw = scheduler.now();
|
|
544
|
-
const now = Number.isFinite(raw) && raw > 0 ? raw : 0;
|
|
545
696
|
topBarUi.showCycleBeat(cycleBeat(now));
|
|
546
697
|
if (record === null || record.at === undefined || record.boundary - now <= 0) {
|
|
547
698
|
topBarUi.showCountdown(null);
|
|
@@ -552,6 +703,17 @@ function drawTopBar() {
|
|
|
552
703
|
syncGridQueued(now);
|
|
553
704
|
}
|
|
554
705
|
|
|
706
|
+
/**
|
|
707
|
+
* The music's cycle now, as the top bar reads it (the same condition that it plays here, the same clamp), or null while it does not
|
|
708
|
+
* play: the Scratchpad's Send to Claude takes the moment of a motif from it. It reads the scheduler and nothing else.
|
|
709
|
+
*/
|
|
710
|
+
function musicCycleNow() {
|
|
711
|
+
const plays = currentSoundState === 'playing' && !pageSuperseded && hasEvaluatedSuccessfully;
|
|
712
|
+
if (!plays) return null;
|
|
713
|
+
const raw = scheduler.now();
|
|
714
|
+
return Number.isFinite(raw) && raw > 0 ? raw : 0;
|
|
715
|
+
}
|
|
716
|
+
|
|
555
717
|
/**
|
|
556
718
|
* Hands the queued section header of the Song grid its countdown for this poll: the rest of the top bar's text
|
|
557
719
|
* (`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
|
|
@@ -602,7 +764,38 @@ setInterval(() => {
|
|
|
602
764
|
// The program has already run when the check refuses it (or when evaluate fails later), so a
|
|
603
765
|
// top-level setcps/setcpm of the refused code has already reached the scheduler while the previous music
|
|
604
766
|
// keeps playing. The guard puts the previous tempo back when an evaluation fails.
|
|
605
|
-
const tempoGuard = createTempoGuard(scheduler);
|
|
767
|
+
const tempoGuard = createTempoGuard(scheduler, { glide: tempoGlide });
|
|
768
|
+
|
|
769
|
+
/** True while a tempo ramp has a tempo to leave: the top bar's plays condition, and some version makes sound (playsNow). */
|
|
770
|
+
function glideCanStart() {
|
|
771
|
+
return currentSoundState === 'playing' && !pageSuperseded && hasEvaluatedSuccessfully && playsNow();
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* The refusal of a change that asks for a ramp it cannot make, asked at the setPattern seam (installQueryCheck's refuse
|
|
776
|
+
* option): the program's tempo is known only once the program ran, and a throw here fails the evaluation before the new
|
|
777
|
+
* pattern or a waiting record exists, so nothing has changed. Returns the text, or null when the change may ramp. The
|
|
778
|
+
* text is also kept on the run, so the page can tell this refusal from any other failure and show no banner for it.
|
|
779
|
+
*/
|
|
780
|
+
function glideRefusal() {
|
|
781
|
+
const run = activeRun;
|
|
782
|
+
if (run === null || run.glideBars === null) return null;
|
|
783
|
+
const requested = tempoGuard.requestedSoFar();
|
|
784
|
+
let text = null;
|
|
785
|
+
if (requested === undefined) {
|
|
786
|
+
text = GLIDE_REFUSALS.noTempo;
|
|
787
|
+
} else {
|
|
788
|
+
try {
|
|
789
|
+
// The reply states a tempo to one decimal of a BPM, so two tempos that read alike have nothing to ramp between.
|
|
790
|
+
if (bpmOf(requested) === bpmOf(tempoGuard.playing())) text = GLIDE_REFUSALS.sameTempo;
|
|
791
|
+
} catch (err) {
|
|
792
|
+
console.error('Moontide: the tempo of the code is not usable for a ramp:', err);
|
|
793
|
+
text = GLIDE_REFUSALS.noTempo;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
if (text !== null) run.glideRefusal = text;
|
|
797
|
+
return text;
|
|
798
|
+
}
|
|
606
799
|
|
|
607
800
|
/** The tempo in BPM for a reply, or undefined (with one console line) when the cps is not a usable number. */
|
|
608
801
|
function bpmForReply(cps) {
|
|
@@ -709,9 +902,18 @@ async function evaluateWithSetup(code, rest) {
|
|
|
709
902
|
}
|
|
710
903
|
const failed = !!editor.repl.state.evalError;
|
|
711
904
|
const waitingRecord = activeRun !== null ? activeRun.deferred : null;
|
|
712
|
-
// A
|
|
713
|
-
//
|
|
714
|
-
|
|
905
|
+
// 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
|
|
906
|
+
// this task and a ramp moves toward it (startGlide). A waiting change must not touch the music that still plays either, so
|
|
907
|
+
// its tempo is taken back now and applied at the line (onReached); the reply states it as the tempo from that cycle.
|
|
908
|
+
const startsGlide = !failed && run !== null && run.glideBars !== null && waitingRecord === null;
|
|
909
|
+
const { requested, glide: glideResult } = tempoGuard.end(failed, { hold: !failed && waitingRecord !== null, startsGlide });
|
|
910
|
+
if (startsGlide) startGlide(run, requested);
|
|
911
|
+
// A change that asked for no ramp but restated the target of the one that runs lets it go on: the reply says so.
|
|
912
|
+
if (!failed && run !== null && run.glideBars === null && waitingRecord === null && glideResult === 'kept') {
|
|
913
|
+
const nowBpm = bpmForReply(scheduler.cps);
|
|
914
|
+
const toBpm = bpmForReply(tempoGlide.targetCps());
|
|
915
|
+
if (nowBpm !== undefined && toBpm !== undefined) run.glide = { state: 'running', nowBpm, toBpm };
|
|
916
|
+
}
|
|
715
917
|
if (!failed && activeRun !== null) {
|
|
716
918
|
if (waitingRecord !== null) {
|
|
717
919
|
waitingRecord.newCps = typeof requested === 'number' ? requested : null;
|
|
@@ -731,6 +933,47 @@ async function evaluateWithSetup(code, rest) {
|
|
|
731
933
|
return result;
|
|
732
934
|
}
|
|
733
935
|
|
|
936
|
+
/**
|
|
937
|
+
* Starts the tempo ramp a change asked for, right after its evaluation (the tempo guard took the program's tempo back in the
|
|
938
|
+
* same task). A ramp that cannot be made was refused before this point (glideRefusal), so a change that gets here has a tempo
|
|
939
|
+
* to ramp to. When the page stopped or lost its role during the evaluation nothing starts and nothing is applied: Stop ends
|
|
940
|
+
* every ramp, and the next Play takes the code's tempo. Never throws.
|
|
941
|
+
*/
|
|
942
|
+
function startGlide(run, requested) {
|
|
943
|
+
try {
|
|
944
|
+
if (typeof requested !== 'number') {
|
|
945
|
+
console.error('Moontide: glideBars was asked for, but the evaluation stated no tempo, so no ramp started.');
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
if (!glideCanStart()) return;
|
|
949
|
+
const fromCps = scheduler.cps;
|
|
950
|
+
try {
|
|
951
|
+
run.glidePlan = tempoGlide.start({ fromCps, toCps: requested, bars: run.glideBars, startCycle: scheduler.lastEnd });
|
|
952
|
+
} catch (err) {
|
|
953
|
+
// The program's tempo was taken back for the ramp, so the change keeps its own tempo instead.
|
|
954
|
+
console.error('Moontide: the tempo ramp could not start, so the tempo applied at once:', err);
|
|
955
|
+
tempoGuard.apply(requested);
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
const fromBpm = bpmForReply(fromCps);
|
|
959
|
+
const toBpm = bpmForReply(requested);
|
|
960
|
+
if (fromBpm !== undefined && toBpm !== undefined) run.glide = { state: 'started', fromBpm, toBpm, bars: run.glideBars };
|
|
961
|
+
} catch (err) {
|
|
962
|
+
console.error('Moontide: the tempo ramp of the change could not be set up:', err);
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* 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
|
|
968
|
+
* 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
|
|
969
|
+
* one a reply can state. Mirrors src/protocol.ts's at-line report by hand.
|
|
970
|
+
*/
|
|
971
|
+
function atLineGlide(record) {
|
|
972
|
+
if (typeof record.glideBars !== 'number' || typeof record.newCps !== 'number') return {};
|
|
973
|
+
const toBpm = bpmForReply(record.newCps);
|
|
974
|
+
return toBpm === undefined ? {} : { glide: { state: 'at-line', toBpm, bars: record.glideBars } };
|
|
975
|
+
}
|
|
976
|
+
|
|
734
977
|
/** The MIDI setup of a change that took over at once although it was checked as a waiting one. Never throws. */
|
|
735
978
|
async function sendLateSetup(code) {
|
|
736
979
|
try {
|
|
@@ -755,7 +998,7 @@ editor.repl.evaluate = (code, ...rest) => {
|
|
|
755
998
|
const evalRun =
|
|
756
999
|
armedRun !== null
|
|
757
1000
|
? armedRun
|
|
758
|
-
: { timing: parseTiming(viewState.changeTiming), deferred: null, bpm: undefined, midiDeferred: false };
|
|
1001
|
+
: { timing: parseTiming(viewState.changeTiming), deferred: null, bpm: undefined, midiDeferred: false, glideBars: null, glide: undefined, glidePlan: null, glideRefusal: null };
|
|
759
1002
|
armedRun = null;
|
|
760
1003
|
const run = evalQueue.then(async () => {
|
|
761
1004
|
activeRun = evalRun;
|
|
@@ -865,6 +1108,7 @@ const timelineEditUi = createTimelineEditView({
|
|
|
865
1108
|
window.addEventListener('blur', () => timelineEditUi.cancelGesture());
|
|
866
1109
|
const jamViewEl = document.getElementById('jam-view');
|
|
867
1110
|
const instrumentsViewEl = document.getElementById('instruments-view');
|
|
1111
|
+
const scratchViewEl = document.getElementById('scratch-view');
|
|
868
1112
|
const editorArea = document.getElementById('editor-area');
|
|
869
1113
|
const viewButtonsEl = document.getElementById('view-buttons');
|
|
870
1114
|
const layoutButtonsEl = document.getElementById('layout-buttons');
|
|
@@ -915,8 +1159,261 @@ const jamUi = createJamView({
|
|
|
915
1159
|
// Draw the overlay at once; the 250 ms tick would make the checkbox feel slow.
|
|
916
1160
|
renderJam();
|
|
917
1161
|
},
|
|
1162
|
+
onScaleChoice: (choice) => chooseJamScale(choice),
|
|
918
1163
|
});
|
|
919
1164
|
|
|
1165
|
+
// The Scratchpad view's DOM and its motif, which lives in memory only: nothing it holds is stored or played, and the motif is sent only
|
|
1166
|
+
// when the user presses Send to Claude (the view hands the request to onSend; the page sends it, as any other request to Claude). Created
|
|
1167
|
+
// here, before any code path that can call into it.
|
|
1168
|
+
const scratchUi = createScratchView({
|
|
1169
|
+
document,
|
|
1170
|
+
rootFontPx: () => parseFloat(getComputedStyle(document.documentElement).fontSize),
|
|
1171
|
+
// Send to Claude: the motif request goes through the Ask channel like any other request, so the Requests panel shows it. askUi is
|
|
1172
|
+
// created further down and looked up when the user presses.
|
|
1173
|
+
onSend: (request) => {
|
|
1174
|
+
const result = askUi.sendMotif(request);
|
|
1175
|
+
syncScratchSend();
|
|
1176
|
+
return result;
|
|
1177
|
+
},
|
|
1178
|
+
musicCycle: () => musicCycleNow(),
|
|
1179
|
+
// The marker loop runs only while Tap is on: the view tells the page whenever Tap turns on or off.
|
|
1180
|
+
onTapChange: (on) => (on ? startTapMarker() : stopTapMarker()),
|
|
1181
|
+
// Record that: the keyboard's last cycles become the motif; the snapshot stays in memory and nothing is sent or written.
|
|
1182
|
+
onRecord: () => {
|
|
1183
|
+
// The live layer showed exactly the notes Record that writes (one layout), so once they are the motif the buffer is empty again and the
|
|
1184
|
+
// live view hands over to it. A press that took nothing leaves the buffer alone.
|
|
1185
|
+
if (scratchUi.record(midiIn.snapshot())) midiIn.clearBuffer();
|
|
1186
|
+
},
|
|
1187
|
+
// The Allow MIDI to listen press: the only moment the keyboard input asks the browser for MIDI, and it does so through the music's own enable.
|
|
1188
|
+
onAllow: () => void midiIn.allow(),
|
|
1189
|
+
// Listen, Stop and Loop: the view asks, the page's Listen player plays on the page's own MIDI layer (see startListen).
|
|
1190
|
+
onListen: (request) => startListen(request),
|
|
1191
|
+
onStop: () => stopListen('stop'),
|
|
1192
|
+
onLoop: (on) => scratchListen.setLoop(on),
|
|
1193
|
+
// The Heads up line: the unmuted tracks of the playing code on the chosen instrument and channel, read by the MIDI layer.
|
|
1194
|
+
playingTracks: (instrumentId, channel) => midiOut.playingTracksOn(instrumentId, channel),
|
|
1195
|
+
});
|
|
1196
|
+
// A drag on the roll ends with nothing committed when the window loses focus.
|
|
1197
|
+
window.addEventListener('blur', () => scratchUi.cancelGesture());
|
|
1198
|
+
|
|
1199
|
+
// Tap and the MIDI keyboard read the music's cycle-to-time map through the one page clock (midiOut.stampOfCycle), never a second clock and never the top
|
|
1200
|
+
// bar's cycle. The two anchors are the stamps of the cycle lines on both sides of the music's now; null when the music is not running,
|
|
1201
|
+
// the clock is still warming up or the scheduler cannot be read (the view then says so and places nothing).
|
|
1202
|
+
let cycleLinesErrorLogged = false;
|
|
1203
|
+
function readCycleLines() {
|
|
1204
|
+
let raw;
|
|
1205
|
+
try {
|
|
1206
|
+
raw = scheduler.now();
|
|
1207
|
+
} catch (err) {
|
|
1208
|
+
// The listening tick reads this every 50 ms: say it once.
|
|
1209
|
+
if (!cycleLinesErrorLogged) console.error('Moontide: the scheduler could not be read for the Scratchpad:', err);
|
|
1210
|
+
cycleLinesErrorLogged = true;
|
|
1211
|
+
return null;
|
|
1212
|
+
}
|
|
1213
|
+
if (!Number.isFinite(raw)) return null;
|
|
1214
|
+
const c0 = Math.floor(Math.max(raw, 0));
|
|
1215
|
+
const ta = midiOut.stampOfCycle(c0);
|
|
1216
|
+
const tb = midiOut.stampOfCycle(c0 + 1);
|
|
1217
|
+
if (ta === null || tb === null || ta === tb) return null;
|
|
1218
|
+
return { a: { c: c0, t: ta }, b: { c: c0 + 1, t: tb } };
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// The notes a MIDI keyboard plays, in memory only. The listener reads the Web MIDI access Strudel's shared enable already holds, whatever view
|
|
1222
|
+
// is open, and asks for it only from the Allow MIDI to listen press, through that same enable (one request, never a second prompt). It places
|
|
1223
|
+
// each note by its own time stamp on the music's cycle lines. Nothing it hears is sent to an instrument. Its state goes to the Scratchpad's
|
|
1224
|
+
// listening strip.
|
|
1225
|
+
const playedBuffer = createPlayedBuffer();
|
|
1226
|
+
midiIn = createMidiIn({
|
|
1227
|
+
getAccess: () => {
|
|
1228
|
+
const webMidi = globalThis.WebMidi;
|
|
1229
|
+
return webMidi && webMidi.enabled === true && webMidi.interface ? webMidi.interface : null;
|
|
1230
|
+
},
|
|
1231
|
+
accessState: () => midiOut.accessState(),
|
|
1232
|
+
accessFailure: () => midiOut.accessFailure(),
|
|
1233
|
+
enable: () => midiOut.enable(),
|
|
1234
|
+
// Only a visible tab that holds the music listens, so there are never two buffers.
|
|
1235
|
+
isVisible: () => document.visibilityState === 'visible',
|
|
1236
|
+
holdsMusic: () => !pageSuperseded,
|
|
1237
|
+
readCycleLines,
|
|
1238
|
+
// The scheduler keeps its tempo while stopped, which is what keeps the buffer on the beat then.
|
|
1239
|
+
readCps: () => scheduler.cps,
|
|
1240
|
+
isPlaying: () => transportPlaying,
|
|
1241
|
+
perfNow: () => performance.now(),
|
|
1242
|
+
setTimer: (fn, ms) => setTimeout(fn, ms),
|
|
1243
|
+
clearTimer: (timer) => clearTimeout(timer),
|
|
1244
|
+
buffer: playedBuffer,
|
|
1245
|
+
onChange: () => {
|
|
1246
|
+
const state = midiIn.state();
|
|
1247
|
+
scratchUi.setListening(state);
|
|
1248
|
+
syncLive(state);
|
|
1249
|
+
},
|
|
1250
|
+
onError: (message) => console.error(message),
|
|
1251
|
+
});
|
|
1252
|
+
|
|
1253
|
+
// Listen: the motif played on the output the user chose, by the page's own MIDI layer (the same access, queue and refusals as the music's
|
|
1254
|
+
// controls). The player holds the clock, the timer and the output it is given and nothing else; it changes no code and no History, sends nothing to
|
|
1255
|
+
// Claude and never starts by itself. What became of a play goes to the hear strip.
|
|
1256
|
+
const scratchListen = createScratchListen({
|
|
1257
|
+
perfNow: () => performance.now(),
|
|
1258
|
+
setTimer: (fn, ms) => setTimeout(fn, ms),
|
|
1259
|
+
clearTimer: (timer) => clearTimeout(timer),
|
|
1260
|
+
emit: (note) => midiOut.sendListenNote(note),
|
|
1261
|
+
stampOfCycle: (cycle) => midiOut.stampOfCycle(cycle),
|
|
1262
|
+
musicCycle: () => musicCycleNow(),
|
|
1263
|
+
readCps: () => scheduler.cps,
|
|
1264
|
+
onState: (state) => {
|
|
1265
|
+
// The frame loop also runs while a play waits or plays, to hand the view the playhead's place.
|
|
1266
|
+
listenBusy = state.kind === 'waiting' || state.kind === 'playing';
|
|
1267
|
+
scratchUi.setHear(state);
|
|
1268
|
+
syncLive();
|
|
1269
|
+
},
|
|
1270
|
+
});
|
|
1271
|
+
// The menu of the hear strip lists the setup file's instruments; it is given again whenever the rig changes (see applyRig).
|
|
1272
|
+
scratchUi.setOutputs(listenOptions(pageRig), pageRig.setup);
|
|
1273
|
+
|
|
1274
|
+
let listenBusy = false;
|
|
1275
|
+
// The press that is waiting for the MIDI permission, or null, and a number that a Stop, a new press or a stop of the view moves on, so an answer that
|
|
1276
|
+
// comes late finds it is no longer wanted.
|
|
1277
|
+
let listenAsking = null;
|
|
1278
|
+
let listenTicket = 0;
|
|
1279
|
+
|
|
1280
|
+
// A press of Listen. MIDI is asked for only here, through the one shared enable, and only when the page has not asked yet; the play starts
|
|
1281
|
+
// when it is granted, unless Stop came meanwhile. Whatever cannot start is said in one sentence and nothing falls back to anything else.
|
|
1282
|
+
function startListen(request) {
|
|
1283
|
+
listenTicket += 1;
|
|
1284
|
+
const word = midiOut.accessState();
|
|
1285
|
+
if (word === 'enabled') {
|
|
1286
|
+
playListen(request);
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
const { instrumentId, channel } = request;
|
|
1290
|
+
if (word === 'denied' || word === 'unsupported' || word === 'failed') {
|
|
1291
|
+
refuseListen({ kind: 'refused', refused: word, failure: midiOut.accessFailure() ?? 'Error', instrumentId, channel, mid: false });
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
// 'idle' (not asked yet) or 'pending' (the browser's prompt is open): the same enable answers both.
|
|
1295
|
+
const ticket = listenTicket;
|
|
1296
|
+
listenAsking = { instrumentId, channel };
|
|
1297
|
+
scratchUi.setHear({ kind: 'asking' });
|
|
1298
|
+
void midiOut.enable().then(() => {
|
|
1299
|
+
if (ticket !== listenTicket) return;
|
|
1300
|
+
listenAsking = null;
|
|
1301
|
+
const answer = midiOut.accessState();
|
|
1302
|
+
if (answer === 'enabled') playListen(request);
|
|
1303
|
+
else refuseListen({ kind: 'refused', refused: answer === 'denied' || answer === 'unsupported' ? answer : 'failed', failure: midiOut.accessFailure() ?? 'Error', instrumentId, channel, mid: false });
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
// The output must be one the page could play on right now, else the reason is said and nothing starts.
|
|
1308
|
+
function playListen({ instrumentId, channel, start, loop }) {
|
|
1309
|
+
const target = midiOut.listenTarget(instrumentId);
|
|
1310
|
+
if (target.refused !== undefined) {
|
|
1311
|
+
refuseListen({ kind: 'refused', ...target, instrumentId, channel, mid: false });
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
scratchListen.play({ readMotif: () => scratchUi.motif(), instrumentId, channel, start, loop });
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// A refused press still replaces the play before it: the old play is silenced first, then the refusal is said. Its 'stopped' state comes
|
|
1318
|
+
// first, so the refusal is the sentence that stays, and Stop is never greyed out over a play that goes on.
|
|
1319
|
+
function refuseListen(state) {
|
|
1320
|
+
scratchListen.stop('stop');
|
|
1321
|
+
scratchUi.setHear(state);
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// Stop, the view closing, the tab hidden and the page left all end here: a wait for the permission is cancelled, and a play is silenced.
|
|
1325
|
+
function stopListen(reason) {
|
|
1326
|
+
listenTicket += 1;
|
|
1327
|
+
const asking = listenAsking;
|
|
1328
|
+
listenAsking = null;
|
|
1329
|
+
if (asking !== null) scratchUi.setHear({ kind: 'stopped', reason, ...asking });
|
|
1330
|
+
scratchListen.stop(reason);
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// One document keydown listener in the capture phase, because focus may be on the Play button or anywhere outside a text field: a
|
|
1334
|
+
// listener on the view would never hear it. It acts only with the Scratchpad open and Tap on, never in a text field or the code
|
|
1335
|
+
// editor, and never for a Ctrl, Cmd or Alt chord. The handler's clock is read first, then the event's own stamp: the stamp, not the
|
|
1336
|
+
// time this code runs, decides where the note lands. A held key's repeats are swallowed and place nothing.
|
|
1337
|
+
let tapSpaceHandled = false;
|
|
1338
|
+
document.addEventListener('keydown', (event) => {
|
|
1339
|
+
if (event.key !== ' ' || viewState.view !== 'scratchpad' || !scratchUi.tapOn()) return;
|
|
1340
|
+
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
|
1341
|
+
if (isTextEntryFocused() || editorArea.contains(document.activeElement)) return;
|
|
1342
|
+
const h = performance.now();
|
|
1343
|
+
const ts = event.timeStamp;
|
|
1344
|
+
event.preventDefault();
|
|
1345
|
+
tapSpaceHandled = true;
|
|
1346
|
+
if (event.repeat) return;
|
|
1347
|
+
scratchUi.tap({ ts, h, anchors: readCycleLines() });
|
|
1348
|
+
}, true);
|
|
1349
|
+
// The Space keyup of a handled tap never clicks a focused button.
|
|
1350
|
+
document.addEventListener('keyup', (event) => {
|
|
1351
|
+
if (event.key !== ' ' || !tapSpaceHandled) return;
|
|
1352
|
+
tapSpaceHandled = false;
|
|
1353
|
+
event.preventDefault();
|
|
1354
|
+
}, true);
|
|
1355
|
+
// A window that loses focus between the key down and the key up never hears the key up; without this the next Space release, which may
|
|
1356
|
+
// be the click of a focused button, would be swallowed.
|
|
1357
|
+
window.addEventListener('blur', () => {
|
|
1358
|
+
tapSpaceHandled = false;
|
|
1359
|
+
});
|
|
1360
|
+
|
|
1361
|
+
// The now-marker: while Tap is on, each frame reads the same anchors at the present moment and hands the motif position to the view.
|
|
1362
|
+
// The loop also keeps the page clock warm on a page with no MIDI instrument, so a first tap right after Tap turns on can still meet a
|
|
1363
|
+
// clock that gives nothing yet, and says so.
|
|
1364
|
+
let tapMarkerFrame = null;
|
|
1365
|
+
function tapMarkerStep() {
|
|
1366
|
+
tapMarkerFrame = null;
|
|
1367
|
+
if (!scratchUi.tapOn()) return;
|
|
1368
|
+
const anchors = readCycleLines();
|
|
1369
|
+
scratchUi.setNowUnit(anchors === null ? null : unitInMotif(cycleAtStamp(performance.now(), anchors.a, anchors.b), scratchUi.motifCycles()));
|
|
1370
|
+
tapMarkerFrame = requestAnimationFrame(tapMarkerStep);
|
|
1371
|
+
}
|
|
1372
|
+
function startTapMarker() {
|
|
1373
|
+
if (tapMarkerFrame === null) tapMarkerFrame = requestAnimationFrame(tapMarkerStep);
|
|
1374
|
+
}
|
|
1375
|
+
function stopTapMarker() {
|
|
1376
|
+
if (tapMarkerFrame !== null) cancelAnimationFrame(tapMarkerFrame);
|
|
1377
|
+
tapMarkerFrame = null;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// The live view: while the Scratchpad is open and an input is listened to, every animation frame hands the view the cycle axis now and the
|
|
1381
|
+
// keyboard buffer's snapshot (read again only when the buffer changed, else the same object, so the view knows nothing was rebuilt). The loop only
|
|
1382
|
+
// reads: it makes no sound and sends no message and no request. It stops when the view closes or the listening ends, and then the view is
|
|
1383
|
+
// told there is nothing live (Off). The frame time is the view's only clock.
|
|
1384
|
+
let scratchOpen = false;
|
|
1385
|
+
let liveFrame = null;
|
|
1386
|
+
let liveRevision = null;
|
|
1387
|
+
let liveSnapshot = null;
|
|
1388
|
+
function liveStep() {
|
|
1389
|
+
liveFrame = null;
|
|
1390
|
+
const live = midiIn.live();
|
|
1391
|
+
if (live === null) {
|
|
1392
|
+
scratchUi.setLive(null);
|
|
1393
|
+
} else {
|
|
1394
|
+
if (live.revision !== liveRevision) {
|
|
1395
|
+
liveRevision = live.revision;
|
|
1396
|
+
liveSnapshot = midiIn.snapshot();
|
|
1397
|
+
}
|
|
1398
|
+
scratchUi.setLive({ now: live.now, snapshot: liveSnapshot, frameMs: performance.now() });
|
|
1399
|
+
}
|
|
1400
|
+
// The Listen playhead's place in the motif (null while the play waits).
|
|
1401
|
+
if (listenBusy) scratchUi.setHearPosition(scratchListen.position(), performance.now());
|
|
1402
|
+
liveFrame = requestAnimationFrame(liveStep);
|
|
1403
|
+
}
|
|
1404
|
+
// Starts or stops the loop for the state of the view and of the listener (`state` is midiIn.state(), read here when left out).
|
|
1405
|
+
function syncLive(state = midiIn.state()) {
|
|
1406
|
+
if (scratchOpen && (state.listen === 'listening' || listenBusy)) {
|
|
1407
|
+
if (liveFrame === null) liveFrame = requestAnimationFrame(liveStep);
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
if (liveFrame !== null) cancelAnimationFrame(liveFrame);
|
|
1411
|
+
liveFrame = null;
|
|
1412
|
+
liveRevision = null;
|
|
1413
|
+
liveSnapshot = null;
|
|
1414
|
+
scratchUi.setLive(null);
|
|
1415
|
+
}
|
|
1416
|
+
|
|
920
1417
|
// The Instruments view's DOM. Created here, before any code path that can call into it (its redraw
|
|
921
1418
|
// hooks stay shut while instrumentsOpen is false, which it is until renderViews() has run). The view
|
|
922
1419
|
// never sends; a press on a card's switch comes back here, and requestInstrumentSwitch owns the send.
|
|
@@ -1023,11 +1520,40 @@ const sessionLabel = readSessionLabel(document, (text) => console.error(text));
|
|
|
1023
1520
|
const pageBaseTitle = baseTitle(sessionLabel);
|
|
1024
1521
|
renderSessionIdentity(document, sessionLabel, (text) => console.error(text));
|
|
1025
1522
|
|
|
1523
|
+
// What became of each request this page sent: the tracker holds a row per request and the view draws the chip in
|
|
1524
|
+
// the top bar and the panel under it. The Session drawer and the panel never show together; each is looked up at
|
|
1525
|
+
// call time because the drawer is created further down.
|
|
1526
|
+
const requestTracker = createRequestTracker({
|
|
1527
|
+
now: () => Date.now(),
|
|
1528
|
+
setTimer: (fn, ms) => setTimeout(fn, ms),
|
|
1529
|
+
clearTimer: (timer) => clearTimeout(timer),
|
|
1530
|
+
onChange: () => {
|
|
1531
|
+
requestsUi.render(requestTracker.model());
|
|
1532
|
+
askUi.refreshPartsLine();
|
|
1533
|
+
},
|
|
1534
|
+
onNotConfirmed: (id) => askUi.notConfirmed(id),
|
|
1535
|
+
});
|
|
1536
|
+
const requestsUi = createRequestStatusView({
|
|
1537
|
+
document,
|
|
1538
|
+
now: () => Date.now(),
|
|
1539
|
+
setTimer: (fn, ms) => setTimeout(fn, ms),
|
|
1540
|
+
clearTimer: (timer) => clearTimeout(timer),
|
|
1541
|
+
// The panel opening closes the Session drawer, without moving focus.
|
|
1542
|
+
onOpen: () => sessionUi.close(),
|
|
1543
|
+
// A row under the pointer, with focus or with its detail open stays where it is, even when it has finished.
|
|
1544
|
+
onHold: (id, held) => requestTracker.setHeld(id, held),
|
|
1545
|
+
takeLive: () => requestTracker.takeLive(),
|
|
1546
|
+
getModel: () => requestTracker.model(),
|
|
1547
|
+
});
|
|
1548
|
+
|
|
1026
1549
|
// The Ask Claude box. Created before any code path that can call into it.
|
|
1027
1550
|
const askUi = createAskView({
|
|
1028
1551
|
document,
|
|
1552
|
+
requests: requestTracker,
|
|
1029
1553
|
send: (message) => {
|
|
1030
1554
|
sendIfOpen(JSON.stringify(message));
|
|
1555
|
+
// The panel opens by itself when the user sends, and focus stays where the Ask box gave it back.
|
|
1556
|
+
if (message.type === 'ask') requestsUi.noteSend();
|
|
1031
1557
|
// A sent request about a range of the Timeline is done with it: the wash goes.
|
|
1032
1558
|
if (message.type === 'ask' && message.context.kind === 'range') timelineEditUi.clearRange();
|
|
1033
1559
|
},
|
|
@@ -1036,10 +1562,24 @@ const askUi = createAskView({
|
|
|
1036
1562
|
setTimer: (fn, ms) => setTimeout(fn, ms),
|
|
1037
1563
|
clearTimer: (timer) => clearTimeout(timer),
|
|
1038
1564
|
now: () => Date.now(),
|
|
1039
|
-
//
|
|
1040
|
-
|
|
1565
|
+
// What became of the Scratchpad's motif request is said in its own strip.
|
|
1566
|
+
onMotifResult: (result) => {
|
|
1567
|
+
scratchUi.motifResult(result);
|
|
1568
|
+
syncScratchSend();
|
|
1569
|
+
},
|
|
1041
1570
|
});
|
|
1042
1571
|
|
|
1572
|
+
// The Scratchpad's Send button follows the connection and the last motif request: the page tells the view whenever either changes.
|
|
1573
|
+
function syncScratchSend() {
|
|
1574
|
+
scratchUi.setSendState({ connected: askUi.isConnected(), pending: askUi.motifPending() });
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
/** The connection state of the Ask channel ('connecting', 'connected' or 'disconnected'), told to the Ask box and to the Scratchpad's Send button. */
|
|
1578
|
+
function setAskConnected(state) {
|
|
1579
|
+
askUi.setConnected(state);
|
|
1580
|
+
syncScratchSend();
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1043
1583
|
// The segment a superseded tab shows, with its "Play in this tab" button. takeBackTimer is the
|
|
1044
1584
|
// 5 s wait for the server's hello after a click; it is null while no take-over is pending.
|
|
1045
1585
|
let takeBackTimer = null;
|
|
@@ -1085,6 +1625,7 @@ const sessionUi = createSessionView({
|
|
|
1085
1625
|
onOpen() {
|
|
1086
1626
|
midiUi.closeHelp();
|
|
1087
1627
|
askUi.closeHelp();
|
|
1628
|
+
requestsUi.close();
|
|
1088
1629
|
},
|
|
1089
1630
|
setTimer: (fn, ms) => setTimeout(fn, ms),
|
|
1090
1631
|
// One of the three Edits timing buttons. The setting is stored by applyViewState (through the closed-set
|
|
@@ -1132,7 +1673,13 @@ function refreshMidiHidden() {
|
|
|
1132
1673
|
midiOut.publishFacts();
|
|
1133
1674
|
}
|
|
1134
1675
|
|
|
1135
|
-
document.addEventListener('visibilitychange', () =>
|
|
1676
|
+
document.addEventListener('visibilitychange', () => {
|
|
1677
|
+
// A hidden tab throttles its timers, so a play could not keep time and would hold notes: it is stopped.
|
|
1678
|
+
if (document.visibilityState === 'hidden') stopListen('hidden');
|
|
1679
|
+
refreshMidiHidden();
|
|
1680
|
+
// Showing or hiding the tab starts or stops the keyboard listening.
|
|
1681
|
+
midiIn.refresh();
|
|
1682
|
+
});
|
|
1136
1683
|
|
|
1137
1684
|
/**
|
|
1138
1685
|
* Draws the top-bar MIDI indicator from the MIDI facts. midiPublishReady is true exactly while the
|
|
@@ -1394,8 +1941,8 @@ let mixerLitMemo = { code: null, identity: null, range: null };
|
|
|
1394
1941
|
* or a pushed change for a hand evaluation. (runMixerEvaluate below is the single place that
|
|
1395
1942
|
* calls the repl directly.)
|
|
1396
1943
|
*/
|
|
1397
|
-
async function runProgrammaticEvaluate(timing = NOW_TIMING) {
|
|
1398
|
-
const run = armRun(timing);
|
|
1944
|
+
async function runProgrammaticEvaluate(timing = NOW_TIMING, { glideBars = null } = {}) {
|
|
1945
|
+
const run = armRun(timing, glideBars);
|
|
1399
1946
|
programmaticEvalInFlight = true;
|
|
1400
1947
|
try {
|
|
1401
1948
|
const evaluation = editor.evaluate();
|
|
@@ -1566,6 +2113,10 @@ function applyRig(rig) {
|
|
|
1566
2113
|
const drumsBefore = jamOpen && timelineTracks !== null ? drumTrackIndexes(timelineTracks, laneTags().drums) : null;
|
|
1567
2114
|
pageRig = withBuildMismatch(rig, pageBuildMismatch);
|
|
1568
2115
|
midiOut.setRig(pageRig);
|
|
2116
|
+
// The input ports the setup file names (a valid rig its inputs, an absent or invalid one none) and the file's own word, so the page can say which.
|
|
2117
|
+
midiIn.setInputs(pageRig.setup === 'valid' ? pageRig.inputs ?? [] : [], pageRig.setup);
|
|
2118
|
+
midiIn.refresh();
|
|
2119
|
+
scratchUi.setOutputs(listenOptions(pageRig), pageRig.setup);
|
|
1569
2120
|
renderMidiIndicator();
|
|
1570
2121
|
// The Mixer reads its MIDI tracks against the rig: draw it again when the file changes while it is open.
|
|
1571
2122
|
if (viewState.view === 'mixer') renderMixer();
|
|
@@ -1615,6 +2166,7 @@ function renderVersionIndicator() {
|
|
|
1615
2166
|
// result changed. One writer for both: the sound-state event, the superseded and hello handlers, trackEvalSuccess and
|
|
1616
2167
|
// the 250 ms poll all end here (a second writer of "playing" would let the button and the word disagree).
|
|
1617
2168
|
let shownTransport = null;
|
|
2169
|
+
let transportPlaying = false;
|
|
1618
2170
|
|
|
1619
2171
|
/** The transport model for the page as it is now. The editor's text is trimmed: a blank editor has nothing to play. */
|
|
1620
2172
|
function currentTransport() {
|
|
@@ -1628,6 +2180,8 @@ function currentTransport() {
|
|
|
1628
2180
|
|
|
1629
2181
|
function renderTransport() {
|
|
1630
2182
|
const model = currentTransport();
|
|
2183
|
+
// The keyboard buffer's cycle axis reads this: the music plays, as the transport says it does.
|
|
2184
|
+
transportPlaying = model.playing;
|
|
1631
2185
|
const key = JSON.stringify(model);
|
|
1632
2186
|
if (key === shownTransport) return;
|
|
1633
2187
|
shownTransport = key;
|
|
@@ -1643,6 +2197,10 @@ function renderTransport() {
|
|
|
1643
2197
|
soundStateEl.setAttribute('aria-label', `${model.word}${model.here ? BAR_COPY.stateHere : ''}`);
|
|
1644
2198
|
// The accent means live now: the model's playing, not the sound state alone (a lost tab or an unevaluated page is not live).
|
|
1645
2199
|
soundStateEl.dataset.accent = String(model.playing);
|
|
2200
|
+
// Tap needs the music playing: the Scratchpad turns Tap off by itself when it stops.
|
|
2201
|
+
scratchUi.setMusicPlaying(model.playing);
|
|
2202
|
+
// The Heads up line names only tracks that play now, so it follows the playing state.
|
|
2203
|
+
scratchUi.refreshHear();
|
|
1646
2204
|
// The word, its ' here' part and the button's word change the bar's width: the fit step runs again.
|
|
1647
2205
|
topBarUi.fit();
|
|
1648
2206
|
}
|
|
@@ -1684,6 +2242,7 @@ function trackEvalSuccess(e) {
|
|
|
1684
2242
|
// The setup prepared at the lead is committed first, so the facts and the clock follow the code that plays.
|
|
1685
2243
|
waiting.midiPlan?.commit();
|
|
1686
2244
|
midiOut.recordSuccess(waiting.code);
|
|
2245
|
+
scratchUi.refreshHear();
|
|
1687
2246
|
refreshMidiHidden();
|
|
1688
2247
|
onVersionPlaying();
|
|
1689
2248
|
});
|
|
@@ -1694,6 +2253,7 @@ function trackEvalSuccess(e) {
|
|
|
1694
2253
|
// The MIDI facts of the code that plays now. This runs inside repl.evaluate, before
|
|
1695
2254
|
// handleEvaluate sends `evaluated` on the same socket, so the server has them first.
|
|
1696
2255
|
midiOut.recordSuccess(editor.repl.state.activeCode);
|
|
2256
|
+
scratchUi.refreshHear();
|
|
1697
2257
|
refreshMidiHidden();
|
|
1698
2258
|
onVersionPlaying();
|
|
1699
2259
|
}
|
|
@@ -1788,6 +2348,8 @@ async function stopSoundLocally({ panicFirst = false } = {}) {
|
|
|
1788
2348
|
// A change that waits for its line is canceled first: its finish must never run after Stop, and the
|
|
1789
2349
|
// next Play starts from cycle 0 without the canceled gate.
|
|
1790
2350
|
takeover.cancel('stop');
|
|
2351
|
+
// A tempo ramp ends with Stop, and no ramp state survives it: the next Play starts at the tempo the code declares.
|
|
2352
|
+
tempoGlide.cancel('stop');
|
|
1791
2353
|
// Retire the webmidi Outputs first (synchronous), so no callback frozen from the stopped
|
|
1792
2354
|
// code can send between scheduler.stop() and the suspend, or after the next Play.
|
|
1793
2355
|
midiOut.retireOutputs({ timedRelease: !panicFirst });
|
|
@@ -1873,6 +2435,9 @@ async function reportHandEval(state, code = editor.repl.state.activeCode) {
|
|
|
1873
2435
|
sendIfOpen(JSON.stringify({ type: 'hand-eval', ok: true, code }));
|
|
1874
2436
|
}
|
|
1875
2437
|
|
|
2438
|
+
// Whether the sentence for a Play that could not reset the shift (no code parser) was already logged in this page session.
|
|
2439
|
+
let playShiftNoParserSaid = false;
|
|
2440
|
+
|
|
1876
2441
|
/**
|
|
1877
2442
|
* 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
2443
|
* 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 +2447,13 @@ async function reportHandEval(state, code = editor.repl.state.activeCode) {
|
|
|
1882
2447
|
* 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
2448
|
*/
|
|
1884
2449
|
function resetSongShiftForPlay() {
|
|
1885
|
-
if (!window.acorn || typeof window.acorn.parse !== 'function')
|
|
2450
|
+
if (!window.acorn || typeof window.acorn.parse !== 'function') {
|
|
2451
|
+
if (!playShiftNoParserSaid) {
|
|
2452
|
+
playShiftNoParserSaid = true;
|
|
2453
|
+
console.error('Moontide: Play could not reset the shift of the song because the code parser is not loaded; the code plays as written.');
|
|
2454
|
+
}
|
|
2455
|
+
return;
|
|
2456
|
+
}
|
|
1886
2457
|
const text = editor.code;
|
|
1887
2458
|
try {
|
|
1888
2459
|
const reading = readSongOf(text);
|
|
@@ -2024,12 +2595,34 @@ try {
|
|
|
2024
2595
|
console.error(err);
|
|
2025
2596
|
}
|
|
2026
2597
|
|
|
2598
|
+
// A MIDI note with neither a port timestamp nor the audio clock is timed as a browser sound, which is later than a MIDI
|
|
2599
|
+
// message is heard. That is said once per page load, never on every note, and nothing is thrown into the scheduler.
|
|
2600
|
+
let midiNoteNoClockSaid = false;
|
|
2601
|
+
function sayMidiNoteWithoutClock() {
|
|
2602
|
+
if (midiNoteNoClockSaid) return;
|
|
2603
|
+
midiNoteNoClockSaid = true;
|
|
2604
|
+
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.');
|
|
2605
|
+
}
|
|
2606
|
+
|
|
2027
2607
|
// Every note of a labeled track goes to the lights while they are active. Otherwise it is dropped at once, so notes played
|
|
2028
2608
|
// 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
2609
|
// time its port got; a browser sound carries the audio time it is due, which the lights map to when it is heard.
|
|
2030
2610
|
subscribeNotes((event) => {
|
|
2031
2611
|
if (!lightsActive) return;
|
|
2032
|
-
|
|
2612
|
+
if (event.stamp !== null) {
|
|
2613
|
+
lights.noteSeen(event.key, { at: event.stamp + MIDI_LIGHT_OFFSET_MS });
|
|
2614
|
+
return;
|
|
2615
|
+
}
|
|
2616
|
+
// 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
|
|
2617
|
+
// latency (a MIDI message never has any), so it is timed there and not as a browser sound.
|
|
2618
|
+
if (lights.playsMidi(event.key)) {
|
|
2619
|
+
if (typeof globalThis.getAudioContext === 'function') {
|
|
2620
|
+
lights.noteSeen(event.key, { at: performance.now() + (event.target - globalThis.getAudioContext().currentTime) * 1000 + MIDI_LIGHT_OFFSET_MS });
|
|
2621
|
+
return;
|
|
2622
|
+
}
|
|
2623
|
+
sayMidiNoteWithoutClock();
|
|
2624
|
+
}
|
|
2625
|
+
lights.noteSeen(event.key, { target: event.target });
|
|
2033
2626
|
});
|
|
2034
2627
|
|
|
2035
2628
|
// Start enabling Web MIDI at page start only when the browser already says the permission is
|
|
@@ -2117,7 +2710,9 @@ ws.addEventListener('open', () => {
|
|
|
2117
2710
|
midiOut.publishFacts({ force: true });
|
|
2118
2711
|
historyUi.setConnected('connected');
|
|
2119
2712
|
mixerUi.setConnected('connected');
|
|
2120
|
-
|
|
2713
|
+
setAskConnected('connected');
|
|
2714
|
+
// The requests of a page that went away (a reload) come back from the server; an empty answer changes nothing.
|
|
2715
|
+
sendIfOpen(JSON.stringify({ type: 'ask-board-request' }));
|
|
2121
2716
|
// Always ask, whether or not the view is open, so live additions accumulate.
|
|
2122
2717
|
sendIfOpen(JSON.stringify({ type: 'history-request' }));
|
|
2123
2718
|
// The data folder is asked once per connection, after page-auth (the server answers only a page that
|
|
@@ -2138,7 +2733,8 @@ ws.addEventListener('close', () => {
|
|
|
2138
2733
|
renderConnectionState('disconnected');
|
|
2139
2734
|
historyUi.setConnected('disconnected');
|
|
2140
2735
|
mixerUi.setConnected('disconnected');
|
|
2141
|
-
|
|
2736
|
+
requestTracker.connectionLost();
|
|
2737
|
+
setAskConnected('disconnected');
|
|
2142
2738
|
});
|
|
2143
2739
|
ws.addEventListener('error', () => {
|
|
2144
2740
|
pageSocket = 'closed';
|
|
@@ -2152,7 +2748,8 @@ ws.addEventListener('error', () => {
|
|
|
2152
2748
|
renderConnectionState('disconnected');
|
|
2153
2749
|
historyUi.setConnected('disconnected');
|
|
2154
2750
|
mixerUi.setConnected('disconnected');
|
|
2155
|
-
|
|
2751
|
+
requestTracker.connectionLost();
|
|
2752
|
+
setAskConnected('disconnected');
|
|
2156
2753
|
});
|
|
2157
2754
|
|
|
2158
2755
|
// Evaluate requests are processed strictly one at a time through this promise chain — never
|
|
@@ -2208,10 +2805,12 @@ const mixerUi = createMixerView({
|
|
|
2208
2805
|
onGroupFold: (key) => toggleSongFold(key),
|
|
2209
2806
|
});
|
|
2210
2807
|
mixerUi.setConnected(ws.readyState === ws.OPEN ? 'connected' : 'connecting');
|
|
2211
|
-
|
|
2808
|
+
setAskConnected(ws.readyState === ws.OPEN ? 'connected' : 'connecting');
|
|
2212
2809
|
|
|
2213
2810
|
// pagehide: record a pending gesture while the socket is still open.
|
|
2214
2811
|
window.addEventListener('pagehide', () => mixerGesture.flushNow());
|
|
2812
|
+
// Listen first, so its notes are ended before the page's own MIDI silence goes out; its own listener, so a throw in one never skips the other.
|
|
2813
|
+
window.addEventListener('pagehide', () => stopListen('page'));
|
|
2215
2814
|
// A separate listener, so a throw in one never skips the other: MIDI silence on close or reload.
|
|
2216
2815
|
window.addEventListener('pagehide', () => midiOut.onPageHide());
|
|
2217
2816
|
// Its own listener too: a closing page drops its waiting change without a message (the server handles detach).
|
|
@@ -2219,6 +2818,7 @@ window.addEventListener('pagehide', () => takeover.cancel('unload'));
|
|
|
2219
2818
|
// A resize or a layout change moves the lines: the selected-strip light follows (ignored while the Mixer is closed).
|
|
2220
2819
|
window.addEventListener('resize', () => {
|
|
2221
2820
|
if (viewState.view === 'mixer') syncMixerLit();
|
|
2821
|
+
if (viewState.view === 'scratchpad') scratchUi.resize();
|
|
2222
2822
|
});
|
|
2223
2823
|
|
|
2224
2824
|
ws.addEventListener('message', (event) => {
|
|
@@ -2245,6 +2845,8 @@ ws.addEventListener('message', (event) => {
|
|
|
2245
2845
|
sessionUi.setTakeBackPending(false);
|
|
2246
2846
|
renderMidiIndicator();
|
|
2247
2847
|
refreshMidiHidden();
|
|
2848
|
+
// A tab that lost the music stops listening to the keyboard, so there is never a second buffer.
|
|
2849
|
+
midiIn.refresh();
|
|
2248
2850
|
drawTopBar();
|
|
2249
2851
|
// Said again when a tab plays again after none did: this tab is already stopped, only its text changes.
|
|
2250
2852
|
// No second panic: it could cut the notes of the tab that plays now.
|
|
@@ -2305,6 +2907,9 @@ ws.addEventListener('message', (event) => {
|
|
|
2305
2907
|
}
|
|
2306
2908
|
|
|
2307
2909
|
if (msg.type === 'hello') {
|
|
2910
|
+
// The server treats every attach as the end of a ramp, so the page agrees at once, before the replay is queued: a
|
|
2911
|
+
// reload, a reconnect or a tab taking the role back never carries a ramp into its replay.
|
|
2912
|
+
tempoGlide.cancel('page');
|
|
2308
2913
|
// The hello is the first moment the page can know there is no instruments.json, so the
|
|
2309
2914
|
// Instruments view says "Connecting..." until now. Set synchronously, before anything is queued.
|
|
2310
2915
|
pageHelloSeen = true;
|
|
@@ -2326,6 +2931,8 @@ ws.addEventListener('message', (event) => {
|
|
|
2326
2931
|
// The shadows are cleared with no reset (another tab may have changed the sounds); the replay sends them again.
|
|
2327
2932
|
midiOut.setActive(true);
|
|
2328
2933
|
refreshMidiHidden();
|
|
2934
|
+
// The tab holds the music again, so it listens to the keyboard again.
|
|
2935
|
+
midiIn.refresh();
|
|
2329
2936
|
}
|
|
2330
2937
|
renderInstrumentsIfOpen();
|
|
2331
2938
|
chain = chain
|
|
@@ -2479,6 +3086,13 @@ ws.addEventListener('message', (event) => {
|
|
|
2479
3086
|
return;
|
|
2480
3087
|
}
|
|
2481
3088
|
|
|
3089
|
+
if (msg.type === 'ask-status' || msg.type === 'ask-board') {
|
|
3090
|
+
// Mirrors src/protocol.ts by hand: what became of a request after Claude received it, and the requests a
|
|
3091
|
+
// reloaded page gets back. Outside the serial `chain`; the tracker moves each row only forward.
|
|
3092
|
+
requestTracker.serverMessage(msg);
|
|
3093
|
+
return;
|
|
3094
|
+
}
|
|
3095
|
+
|
|
2482
3096
|
if (msg.type === 'instrument-result') {
|
|
2483
3097
|
// Mirrors src/protocol.ts by hand: the answer to one instrument-switch of this page. Outside the serial
|
|
2484
3098
|
// `chain` like the other plain updates; the rig push it follows has already been applied.
|
|
@@ -2515,26 +3129,36 @@ function buildEvalErrorReport(evalError) {
|
|
|
2515
3129
|
* that is audible), or to nothing before any code has played, and shows the failed-change banner. Returns the
|
|
2516
3130
|
* report, so a caller that tells the server about the failure sends the same text and line. Every failed
|
|
2517
3131
|
* evaluation of code the page wrote or received ends here: Claude's change, the reconnect replay and the page's own
|
|
2518
|
-
* rewrites.
|
|
3132
|
+
* rewrites. `banner` false takes the code back without the banner (a refused tempo ramp, which is not a failure of the code).
|
|
2519
3133
|
*/
|
|
2520
|
-
function revertAndReport(evalError) {
|
|
3134
|
+
function revertAndReport(evalError, { banner = true } = {}) {
|
|
2521
3135
|
editor.setCode(hasEvaluatedSuccessfully ? committedCode() : '');
|
|
2522
3136
|
const report = buildEvalErrorReport(evalError);
|
|
2523
|
-
showEvalError(report.errorText);
|
|
3137
|
+
if (banner) showEvalError(report.errorText);
|
|
2524
3138
|
return report;
|
|
2525
3139
|
}
|
|
2526
3140
|
|
|
2527
3141
|
async function handleEvaluate(msg) {
|
|
2528
3142
|
// An unknown timing is refused before anything changes (the page mirrors src/protocol.ts by hand).
|
|
2529
3143
|
let timing;
|
|
3144
|
+
let glideBars;
|
|
2530
3145
|
try {
|
|
2531
3146
|
timing = parseTiming(msg.at, msg.phraseCycles);
|
|
3147
|
+
glideBars = parseGlideBars(msg.glideBars);
|
|
2532
3148
|
} catch (err) {
|
|
2533
3149
|
sendIfOpen(
|
|
2534
3150
|
JSON.stringify({ type: 'evaluated', id: msg.id, ok: false, code: msg.code, error: err instanceof Error ? err.message : String(err) }),
|
|
2535
3151
|
);
|
|
2536
3152
|
return;
|
|
2537
3153
|
}
|
|
3154
|
+
// A ramp needs music to leave: with nothing playing the change is refused whole, before anything changes and with no
|
|
3155
|
+
// banner (the server tells Claude to send it again without glideBars).
|
|
3156
|
+
if (glideBars !== null && !glideCanStart()) {
|
|
3157
|
+
sendIfOpen(
|
|
3158
|
+
JSON.stringify({ type: 'evaluated', id: msg.id, ok: false, code: msg.code, error: GLIDE_REFUSALS.nothingPlays, glideRefused: true }),
|
|
3159
|
+
);
|
|
3160
|
+
return;
|
|
3161
|
+
}
|
|
2538
3162
|
// A pending Mixer gesture is recorded (with the code that played) before Claude's code
|
|
2539
3163
|
// applies, so the history reads gesture then Claude; the touched lines no longer describe the text.
|
|
2540
3164
|
await mixerGesture.flushDirect();
|
|
@@ -2553,7 +3177,7 @@ async function handleEvaluate(msg) {
|
|
|
2553
3177
|
evaluateTag.begin(msg.id);
|
|
2554
3178
|
let run;
|
|
2555
3179
|
try {
|
|
2556
|
-
run = await runProgrammaticEvaluate(timing);
|
|
3180
|
+
run = await runProgrammaticEvaluate(timing, { glideBars });
|
|
2557
3181
|
} finally {
|
|
2558
3182
|
evaluateTag.end();
|
|
2559
3183
|
}
|
|
@@ -2566,15 +3190,19 @@ async function handleEvaluate(msg) {
|
|
|
2566
3190
|
// still audible. Never call editor.evaluate() here: the previous pattern is still
|
|
2567
3191
|
// playing, re-running it is unnecessary and risks re-triggering audio. A failed
|
|
2568
3192
|
// evaluate leaves pageStopped and the silence unchanged.
|
|
2569
|
-
|
|
3193
|
+
// A ramp the code cannot make was refused at the setPattern seam (glideRefusal): the editor goes back all the same, but no
|
|
3194
|
+
// banner shows, because nothing the person wrote failed.
|
|
3195
|
+
const refusal = typeof run.glideRefusal === 'string' ? run.glideRefusal : null;
|
|
3196
|
+
const { errorText, line } = revertAndReport(evalError, { banner: refusal === null });
|
|
2570
3197
|
const payload = {
|
|
2571
3198
|
type: 'evaluated',
|
|
2572
3199
|
id: msg.id,
|
|
2573
3200
|
ok: false,
|
|
2574
3201
|
code: msg.code,
|
|
2575
|
-
error: errorText,
|
|
3202
|
+
error: refusal ?? errorText,
|
|
3203
|
+
...(refusal !== null ? { glideRefused: true } : {}),
|
|
2576
3204
|
};
|
|
2577
|
-
if (line !== undefined) {
|
|
3205
|
+
if (refusal === null && line !== undefined) {
|
|
2578
3206
|
payload.line = line;
|
|
2579
3207
|
}
|
|
2580
3208
|
sendIfOpen(JSON.stringify(payload));
|
|
@@ -2612,7 +3240,9 @@ async function handleEvaluate(msg) {
|
|
|
2612
3240
|
if (!replacedBeforeReply) {
|
|
2613
3241
|
takeover.whenFinished(waiting, () => {
|
|
2614
3242
|
evaluateTag.fromTakeover(msg.id);
|
|
2615
|
-
|
|
3243
|
+
// The server records the ramp this change started at its line only if it still runs now (mirrors src/protocol.ts).
|
|
3244
|
+
const glideRuns = tempoGlide.runs(waiting.glidePlan);
|
|
3245
|
+
sendIfOpen(JSON.stringify({ type: 'takeover', id: msg.id, ...(glideRuns ? { glideRuns: true } : {}) }));
|
|
2616
3246
|
});
|
|
2617
3247
|
}
|
|
2618
3248
|
sendIfOpen(
|
|
@@ -2624,6 +3254,7 @@ async function handleEvaluate(msg) {
|
|
|
2624
3254
|
audio,
|
|
2625
3255
|
pending: { cycle: waiting.boundary, seconds: pendingSecondsForReply(waiting.boundary) },
|
|
2626
3256
|
...(typeof run.bpm === 'number' ? { bpm: run.bpm } : {}),
|
|
3257
|
+
...(!replacedBeforeReply ? atLineGlide(waiting) : {}),
|
|
2627
3258
|
}),
|
|
2628
3259
|
);
|
|
2629
3260
|
waiting.replied = true;
|
|
@@ -2646,6 +3277,16 @@ async function handleEvaluate(msg) {
|
|
|
2646
3277
|
// A change that already took over (a newer change settled it at its line before this reply) is reported with its
|
|
2647
3278
|
// own code; the editor may already hold the newer change's text.
|
|
2648
3279
|
const finishedBeforeReply = waiting !== null && waiting.finished === true && typeof waiting.code === 'string';
|
|
3280
|
+
// The ramp this change started is reported only while it still runs: a Stop during the watch above, or a program that
|
|
3281
|
+
// ended it meanwhile, already ended it, and the server must never record a ramp that no longer runs. A waiting change
|
|
3282
|
+
// that already took over has no takeover message (this reply records it), so the reply carries the ramp it started at
|
|
3283
|
+
// its line, by the same rule.
|
|
3284
|
+
let glide;
|
|
3285
|
+
if (run.glide !== undefined) {
|
|
3286
|
+
glide = run.glide.state !== 'started' || tempoGlide.runs(run.glidePlan) ? run.glide : undefined;
|
|
3287
|
+
} else if (finishedBeforeReply && tempoGlide.runs(waiting.glidePlan)) {
|
|
3288
|
+
glide = atLineGlide(waiting).glide;
|
|
3289
|
+
}
|
|
2649
3290
|
sendIfOpen(
|
|
2650
3291
|
JSON.stringify({
|
|
2651
3292
|
type: 'evaluated',
|
|
@@ -2654,8 +3295,15 @@ async function handleEvaluate(msg) {
|
|
|
2654
3295
|
code: finishedBeforeReply ? waiting.code : editor.repl.state.activeCode,
|
|
2655
3296
|
audio,
|
|
2656
3297
|
...(typeof run.bpm === 'number' ? { bpm: run.bpm } : {}),
|
|
3298
|
+
...(glide !== undefined ? { glide } : {}),
|
|
2657
3299
|
}),
|
|
2658
3300
|
);
|
|
3301
|
+
// A waiting change that asked for a ramp and already took over ended any older ramp at its line, even when it started
|
|
3302
|
+
// none (nothing to ramp between). It has no takeover message to say so, so when no ramp runs now the server hears a
|
|
3303
|
+
// silent end and clears its record. While a newer ramp runs, its own report sets the record, so nothing is sent.
|
|
3304
|
+
if (finishedBeforeReply && typeof waiting.glideBars === 'number' && !tempoGlide.running()) {
|
|
3305
|
+
sendIfOpen(JSON.stringify({ type: 'glide-ended', reason: 'finished' }));
|
|
3306
|
+
}
|
|
2659
3307
|
}
|
|
2660
3308
|
|
|
2661
3309
|
// --- the Mixer's writes ---
|
|
@@ -3070,9 +3718,11 @@ function renderMixer() {
|
|
|
3070
3718
|
for (const { identity, effect } of model.staleRecords) mixerBypassRecords = forgetBypass(mixerBypassRecords, identity, effect);
|
|
3071
3719
|
if (!writeBypassRecords(storage, mixerBypassRecords)) reportStorageUnavailableOnce();
|
|
3072
3720
|
}
|
|
3073
|
-
mixerUi.render(model);
|
|
3721
|
+
const { created } = mixerUi.render(model);
|
|
3074
3722
|
// The lights learn which tracks the Mixer shows, which of them sound, and which groups hold them.
|
|
3075
3723
|
lights.setLayout(lightLayout(model, { webMidi: typeof navigator.requestMIDIAccess === 'function' }));
|
|
3724
|
+
// A strip this render built starts dark, so the lights write every lit value again from the next frame.
|
|
3725
|
+
if (created > 0) lights.forgetShown();
|
|
3076
3726
|
// Every render moves the one light onto the selected strip's block (or clears it): the strip may have left the model.
|
|
3077
3727
|
syncMixerLit();
|
|
3078
3728
|
}
|
|
@@ -3124,6 +3774,9 @@ const splitSizes = { a: initialPrefs.splitA ?? SPLIT_DEFAULTS.a, b: initialPrefs
|
|
|
3124
3774
|
for (const key of initialPrefs.rejected) {
|
|
3125
3775
|
console.error(SPLIT_COPY.badStored(key, key === STORAGE_KEYS.splitA ? SPLIT_DEFAULTS.a : SPLIT_DEFAULTS.b));
|
|
3126
3776
|
}
|
|
3777
|
+
// A stored song launch choice outside the list was refused by readViewPrefs: say so once per page load (top-level code),
|
|
3778
|
+
// and use the default launch.
|
|
3779
|
+
if (initialPrefs.songLaunchRejected) console.error(VIEW_PREFS_COPY.badSongLaunch);
|
|
3127
3780
|
const splitStorageKeys = { a: STORAGE_KEYS.splitA, b: STORAGE_KEYS.splitB };
|
|
3128
3781
|
|
|
3129
3782
|
// The border between the editor and the view. The view only draws and listens; this is the one place that applies a size
|
|
@@ -3153,7 +3806,7 @@ function markNarrowSpans() {
|
|
|
3153
3806
|
}
|
|
3154
3807
|
|
|
3155
3808
|
/** The things that listen only to the window follow a new size: the Mixer's lit strip once per animation
|
|
3156
|
-
* frame while a drag moves and on release, the Timeline's narrow-span marks on release. CodeMirror needs nothing: it
|
|
3809
|
+
* frame while a drag moves and on release, the Timeline's narrow-span marks on release, the Scratchpad's beat and row sizes. CodeMirror needs nothing: it
|
|
3157
3810
|
* measures itself (its own ResizeObserver), and no manual measure call is made here. */
|
|
3158
3811
|
function refreshAfterSize({ final }) {
|
|
3159
3812
|
if (final) {
|
|
@@ -3161,12 +3814,14 @@ function refreshAfterSize({ final }) {
|
|
|
3161
3814
|
splitFrame = 0;
|
|
3162
3815
|
if (viewState.view === 'mixer') syncMixerLit();
|
|
3163
3816
|
if (viewState.view === 'timeline') markNarrowSpans();
|
|
3817
|
+
if (viewState.view === 'scratchpad') scratchUi.resize();
|
|
3164
3818
|
return;
|
|
3165
3819
|
}
|
|
3166
3820
|
if (splitFrame !== 0) return;
|
|
3167
3821
|
splitFrame = requestAnimationFrame(() => {
|
|
3168
3822
|
splitFrame = 0;
|
|
3169
3823
|
if (viewState.view === 'mixer') syncMixerLit();
|
|
3824
|
+
if (viewState.view === 'scratchpad') scratchUi.resize();
|
|
3170
3825
|
});
|
|
3171
3826
|
}
|
|
3172
3827
|
|
|
@@ -3307,6 +3962,11 @@ let timelineRafId = null;
|
|
|
3307
3962
|
// cannot change a handled failure until the version or the page changes, so the 250 ms tick does not
|
|
3308
3963
|
// retry it four times a second; only a too-slow query is retried, once, after a pause.
|
|
3309
3964
|
let jamReading = null;
|
|
3965
|
+
// What the user picked among the scales that fit, and the proposal in view, per set of candidates: page memory only, gone
|
|
3966
|
+
// on reload (no browser storage), capped at a few dozen sets (page/jam-resolve.js createScaleMemory).
|
|
3967
|
+
const jamScaleMemory = createScaleMemory();
|
|
3968
|
+
// The scale the view shows now (the playing span's, as resolved), so a choice is checked against what is on screen.
|
|
3969
|
+
let jamShownScale = null;
|
|
3310
3970
|
let jamTimer = null;
|
|
3311
3971
|
let jamRefreshRunning = false;
|
|
3312
3972
|
let jamRefreshAgain = false;
|
|
@@ -3562,17 +4222,13 @@ function askAboutTimelineLane(laneIndex, cycle, point) {
|
|
|
3562
4222
|
}
|
|
3563
4223
|
|
|
3564
4224
|
/**
|
|
3565
|
-
* The
|
|
3566
|
-
*
|
|
3567
|
-
* with only arrange()) gives `{}`.
|
|
4225
|
+
* The sections a range crosses as `{ sections }`, in playing order (a section played twice in a row once), for a piece with a
|
|
4226
|
+
* song. A piece without a song (or with only arrange()) gives `{}`.
|
|
3568
4227
|
*/
|
|
3569
|
-
function
|
|
4228
|
+
function timelineRangeSectionList(from, to) {
|
|
3570
4229
|
const reading = timelineSongReading();
|
|
3571
4230
|
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 };
|
|
4231
|
+
return { sections: sectionsCrossed(reading, from, to) };
|
|
3576
4232
|
}
|
|
3577
4233
|
|
|
3578
4234
|
/** The lane of a range of the lanes drawn, or a loud error when the range names none. */
|
|
@@ -3584,20 +4240,27 @@ function timelineRangeLane(selection) {
|
|
|
3584
4240
|
}
|
|
3585
4241
|
|
|
3586
4242
|
/**
|
|
3587
|
-
* What the note panel says of a range: `{ track,
|
|
3588
|
-
*
|
|
4243
|
+
* What the note panel says of a range: `{ track, sections, moreSections, count }` (the sections the range crosses in playing
|
|
4244
|
+
* 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
|
|
4245
|
+
* notes that start in the cycles). Reads the lanes and the code only.
|
|
3589
4246
|
*/
|
|
3590
4247
|
function describeTimelineRange(selection) {
|
|
3591
4248
|
const lane = timelineRangeLane(selection);
|
|
3592
4249
|
const { notes, more } = rangeNotes(lane, selection.from, selection.to);
|
|
3593
|
-
const
|
|
3594
|
-
|
|
4250
|
+
const info = { track: lane.track, count: notes.length + more };
|
|
4251
|
+
const { sections } = timelineRangeSectionList(selection.from, selection.to);
|
|
4252
|
+
if (sections !== undefined) {
|
|
4253
|
+
const fitted = fitSectionNames(sections);
|
|
4254
|
+
info.sections = fitted.names;
|
|
4255
|
+
if (fitted.more > 0) info.moreSections = fitted.more;
|
|
4256
|
+
}
|
|
4257
|
+
return info;
|
|
3595
4258
|
}
|
|
3596
4259
|
|
|
3597
4260
|
/**
|
|
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
|
-
*
|
|
4261
|
+
* 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
|
|
4262
|
+
* 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
|
|
4263
|
+
* version drawn, and the cut when the pointer left the page. Reads the lanes and the code only.
|
|
3601
4264
|
*/
|
|
3602
4265
|
function timelineRangeDescriptor(selection) {
|
|
3603
4266
|
const lane = timelineRangeLane(selection);
|
|
@@ -3607,7 +4270,7 @@ function timelineRangeDescriptor(selection) {
|
|
|
3607
4270
|
track: lane.track,
|
|
3608
4271
|
from: selection.from,
|
|
3609
4272
|
to: selection.to,
|
|
3610
|
-
...
|
|
4273
|
+
...timelineRangeSectionList(selection.from, selection.to),
|
|
3611
4274
|
gesture: { kind: 'range', version: timelineDrawn.version, notes, more },
|
|
3612
4275
|
};
|
|
3613
4276
|
if (selection.cut !== null) descriptor.cut = selection.cut;
|
|
@@ -3631,11 +4294,36 @@ songGridHandlers.ask = (descriptor, point) => {
|
|
|
3631
4294
|
openAskFor(() => descriptor, point);
|
|
3632
4295
|
};
|
|
3633
4296
|
|
|
4297
|
+
// 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
|
|
4298
|
+
// itself), and the browser raises a contextmenu for the same press. That contextmenu must neither open the box a second time nor
|
|
4299
|
+
// 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,
|
|
4300
|
+
// with whether the box was open when the key went down.
|
|
4301
|
+
let keyAsk = null;
|
|
4302
|
+
document.addEventListener('keydown', (event) => {
|
|
4303
|
+
// A held key repeats its keydown; the repeats find the box open and must not change what the first press recorded.
|
|
4304
|
+
if (isAskKey(event) && !event.repeat) keyAsk = { key: event.key, openBefore: askUi.isOpen() };
|
|
4305
|
+
}, true);
|
|
4306
|
+
document.addEventListener('keyup', (event) => {
|
|
4307
|
+
if (keyAsk === null || event.key !== keyAsk.key) return;
|
|
4308
|
+
// One task later, so a contextmenu the browser raises right after the key up is still recognised. On a busy page that task
|
|
4309
|
+
// can run after the next press went down, so it forgets only the press it was set for.
|
|
4310
|
+
const released = keyAsk;
|
|
4311
|
+
setTimeout(() => {
|
|
4312
|
+
if (keyAsk === released) keyAsk = null;
|
|
4313
|
+
}, 0);
|
|
4314
|
+
}, true);
|
|
4315
|
+
|
|
3634
4316
|
// The one contextmenu listener of the page; no view module has its own. Shift + right-click keeps
|
|
3635
4317
|
// the browser's own menu, and so does a right-click inside the box (the user can paste) or while the
|
|
3636
4318
|
// audio-unlock overlay shows (it is a gate to start sound, not part of the page's work). The right-click
|
|
3637
|
-
// never evaluates, records or changes the editor's text, selection or scroll.
|
|
4319
|
+
// never evaluates, records or changes the editor's text, selection or scroll. The Scratchpad's roll takes
|
|
4320
|
+
// its own right-click to erase a note, so it opens no Ask box there; the rest of that view asks as any other.
|
|
3638
4321
|
document.addEventListener('contextmenu', (event) => {
|
|
4322
|
+
// The key press already opened the box: only keep the browser's menu away.
|
|
4323
|
+
if (keyAsk !== null && !keyAsk.openBefore && askUi.isOpen()) {
|
|
4324
|
+
event.preventDefault();
|
|
4325
|
+
return;
|
|
4326
|
+
}
|
|
3639
4327
|
if (event.shiftKey || !overlay.hidden || askUi.contains(event.target)) return;
|
|
3640
4328
|
event.preventDefault();
|
|
3641
4329
|
// A right-click on the failed-change banner (or on its own popover) offers the banner's actions.
|
|
@@ -3643,6 +4331,12 @@ document.addEventListener('contextmenu', (event) => {
|
|
|
3643
4331
|
problemUi.openMenu({ x: event.clientX, y: event.clientY });
|
|
3644
4332
|
return;
|
|
3645
4333
|
}
|
|
4334
|
+
if (scratchUi.rollContains(event.target)) {
|
|
4335
|
+
// A Menu key press raises the same event with no pointer behind it, and its coordinates are the browser's pick: erasing the note
|
|
4336
|
+
// under them would lose a note the user never pointed at. Only the browser's menu is kept away.
|
|
4337
|
+
if (keyAsk === null) scratchUi.handleContextMenu(event);
|
|
4338
|
+
return;
|
|
4339
|
+
}
|
|
3646
4340
|
openAskFor(() => resolveAskTarget(event), { x: event.clientX, y: event.clientY });
|
|
3647
4341
|
});
|
|
3648
4342
|
|
|
@@ -3713,6 +4407,9 @@ function renderViews() {
|
|
|
3713
4407
|
const isMixerOpen = viewState.view === 'mixer';
|
|
3714
4408
|
const wasJamOpen = previousViewState !== null && previousViewState.view === 'jam';
|
|
3715
4409
|
const isJamOpen = viewState.view === 'jam';
|
|
4410
|
+
const isScratchOpen = viewState.view === 'scratchpad';
|
|
4411
|
+
// Read before the view hides: focus inside a view that closes goes to that view's own top-bar button, never to the page body.
|
|
4412
|
+
const scratchLostFocus = previousViewState !== null && previousViewState.view === 'scratchpad' && !isScratchOpen && scratchViewEl.contains(document.activeElement);
|
|
3716
4413
|
|
|
3717
4414
|
if ((wasTimelineOpen || wasJamOpen) && !isTimelineOpen && !isJamOpen) {
|
|
3718
4415
|
// The Timeline or the Jam view just closed and neither is open now (without a new version
|
|
@@ -3745,6 +4442,17 @@ function renderViews() {
|
|
|
3745
4442
|
const isInstrumentsOpen = viewState.view === 'instruments';
|
|
3746
4443
|
instrumentsViewEl.hidden = !isInstrumentsOpen;
|
|
3747
4444
|
instrumentsOpen = isInstrumentsOpen;
|
|
4445
|
+
scratchViewEl.hidden = !isScratchOpen;
|
|
4446
|
+
if (isScratchOpen) scratchUi.render();
|
|
4447
|
+
else scratchUi.closed();
|
|
4448
|
+
scratchOpen = isScratchOpen;
|
|
4449
|
+
// Listen does not outlast the view: another view opening, or the Scratchpad closing, silences it.
|
|
4450
|
+
if (!isScratchOpen) stopListen('closed');
|
|
4451
|
+
syncLive();
|
|
4452
|
+
if (scratchLostFocus) {
|
|
4453
|
+
const scratchButton = document.querySelector('#view-buttons button[data-key="7"]');
|
|
4454
|
+
if (scratchButton !== null) scratchButton.focus();
|
|
4455
|
+
}
|
|
3748
4456
|
jamUi.setPreferences({ instrument: viewState.jamInstrument, chordTones: viewState.jamChordTones });
|
|
3749
4457
|
historyUi.setViewContext({ open: isHistoryOpen, layout: viewState.layout, mode: viewState.historyMode });
|
|
3750
4458
|
|
|
@@ -3775,6 +4483,8 @@ function renderViews() {
|
|
|
3775
4483
|
}
|
|
3776
4484
|
previousViewState = viewState;
|
|
3777
4485
|
if (splitSizeChanged) refreshAfterSize({ final: true });
|
|
4486
|
+
// The panel is laid out by now, so the Scratchpad can read its room: on opening, and on a layout change while it stays open.
|
|
4487
|
+
if (isScratchOpen) scratchUi.resize();
|
|
3778
4488
|
|
|
3779
4489
|
// The animation loop runs while
|
|
3780
4490
|
// either the Timeline or the Parts view is open: a fresh Timeline open (including the very
|
|
@@ -3936,7 +4646,8 @@ function songNoteEntries(reading) {
|
|
|
3936
4646
|
/** The line a finished check gives, or null when what plays agrees with the declaration. */
|
|
3937
4647
|
function songCheckEntry(answer) {
|
|
3938
4648
|
if (answer.state === 'differs') return differsLine({ ...answer.first, more: answer.more });
|
|
3939
|
-
if (answer.state === 'failed'
|
|
4649
|
+
if (answer.state === 'failed') return { text: SONG_COPY.checkFailed, kind: 'check-failed' };
|
|
4650
|
+
if (answer.state === 'too-slow') return { text: SONG_COPY.checkTooSlow, kind: 'check-failed' };
|
|
3940
4651
|
return null;
|
|
3941
4652
|
}
|
|
3942
4653
|
|
|
@@ -3970,7 +4681,7 @@ async function runSongCheck(generation, code, reading, evaluateId) {
|
|
|
3970
4681
|
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
4682
|
const entry = songCheckEntry(answer);
|
|
3972
4683
|
songCheck = { generation, entry };
|
|
3973
|
-
songNotesEl.dataset.check = answer.state === 'agrees' || answer.state === 'differs' ? answer.state : 'failed';
|
|
4684
|
+
songNotesEl.dataset.check = answer.state === 'agrees' || answer.state === 'differs' || answer.state === 'too-slow' ? answer.state : 'failed';
|
|
3974
4685
|
songNotesEl.dataset.checkMs = String(Math.round(answer.ms));
|
|
3975
4686
|
songUi.showNotes(songNoteEntries(reading));
|
|
3976
4687
|
if (entry !== null) {
|
|
@@ -4857,14 +5568,28 @@ function renderJam() {
|
|
|
4857
5568
|
options.declaredOverride = sectionDeclared(song.sections[index], jamReading.reading.declared ?? { state: 'none' });
|
|
4858
5569
|
options.section = quoted(song.sections[index].name);
|
|
4859
5570
|
}
|
|
5571
|
+
// When several scales fit the notes, the one in use is resolved here, from the harmony the playing section declares;
|
|
5572
|
+
// every path below (the status, the rows, the degrees, the piano and the guitar, what a right-click asks about) then
|
|
5573
|
+
// reads that one scale. The warnings of every candidate came with the reading, so this only looks them up.
|
|
5574
|
+
const declared = options.declaredOverride ?? jamReading.reading.declared ?? { state: 'none' };
|
|
5575
|
+
const resolved = resolveReading(jamReading.reading, { declared, memory: jamScaleMemory });
|
|
5576
|
+
const position = readingAt(resolved, { cycle, cps, playing });
|
|
4860
5577
|
jamUi.render(
|
|
4861
5578
|
buildJamModel({
|
|
4862
|
-
position
|
|
5579
|
+
position,
|
|
4863
5580
|
chordTones: viewState.jamChordTones === 'on',
|
|
4864
5581
|
...options,
|
|
4865
5582
|
}),
|
|
4866
5583
|
);
|
|
5584
|
+
// After the draw: remember what is shown. A proposal that came from the evidence is kept for its set of candidates, so
|
|
5585
|
+
// a later reading that orders the same candidates otherwise does not move it; a declared scale is not kept, because the
|
|
5586
|
+
// declaration moves the proposal on its own and the next section may declare another.
|
|
5587
|
+
jamShownScale = position.scale;
|
|
5588
|
+
if (position.scale.source === 'proposed' && position.scale.reason !== 'declared') {
|
|
5589
|
+
jamScaleMemory.keep(position.scale.setKey, position.scale.key);
|
|
5590
|
+
}
|
|
4867
5591
|
} catch (err) {
|
|
5592
|
+
jamShownScale = null;
|
|
4868
5593
|
if (jamDrawErrorLoggedFor !== jamReading) {
|
|
4869
5594
|
jamDrawErrorLoggedFor = jamReading;
|
|
4870
5595
|
console.error('Moontide: the Jam view could not draw the reading:', err);
|
|
@@ -4873,6 +5598,22 @@ function renderJam() {
|
|
|
4873
5598
|
}
|
|
4874
5599
|
}
|
|
4875
5600
|
|
|
5601
|
+
/** Takes the user's choice among the scales that fit: `{ setKey, key }` from a candidate button. It is accepted only while the
|
|
5602
|
+
* scale on screen is a proposed or a chosen one, for the set of candidates on screen, and `key` is one of them; anything
|
|
5603
|
+
* 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
|
|
5604
|
+
* the reading it already holds: no new reading, the warnings of every candidate came with it. */
|
|
5605
|
+
function chooseJamScale({ setKey, key }) {
|
|
5606
|
+
const shown = jamShownScale;
|
|
5607
|
+
const usable =
|
|
5608
|
+
shown !== null &&
|
|
5609
|
+
(shown.source === 'proposed' || shown.source === 'chosen') &&
|
|
5610
|
+
shown.setKey === setKey &&
|
|
5611
|
+
shown.candidates.some((candidate) => candidate.key === key);
|
|
5612
|
+
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.`);
|
|
5613
|
+
jamScaleMemory.pick(setKey, key);
|
|
5614
|
+
renderJam();
|
|
5615
|
+
}
|
|
5616
|
+
|
|
4876
5617
|
/** The Jam view's 250 ms tick: asks for a rebuild when the reading is missing, belongs to an older
|
|
4877
5618
|
* version or does not cover the playing page (also backwards, after Stop), otherwise only looks
|
|
4878
5619
|
* the position up. Skips while the page is hidden and stops with the view. */
|
|
@@ -5672,7 +6413,7 @@ async function launchSectionNow(seen) {
|
|
|
5672
6413
|
for (;;) {
|
|
5673
6414
|
attempt += 1;
|
|
5674
6415
|
// 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:
|
|
6416
|
+
const { line, at } = launchLine({ kind: seen.kind, frontier: scheduler.lastEnd, cps: takeoverCps(), reading, minLeadSeconds: MIN_TAKEOVER_LEAD_SECONDS });
|
|
5676
6417
|
const shift = launchShift(reading, seen.index, line);
|
|
5677
6418
|
if (sameShift(shift, reading.shift, reading.total)) {
|
|
5678
6419
|
// The section already starts on that line: nothing is written. A launch of ours that waits (or the first try of
|