@sudobility/music_drawing 0.0.33 → 0.0.35
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/dist/canvas-renderer.d.ts +79 -4
- package/dist/canvas-renderer.d.ts.map +1 -1
- package/dist/canvas-renderer.js +245 -53
- package/dist/canvas-renderer.js.map +1 -1
- package/dist/cursor.d.ts +60 -0
- package/dist/cursor.d.ts.map +1 -0
- package/dist/cursor.js +157 -0
- package/dist/cursor.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -1
- package/dist/keyboard-geometry.d.ts +50 -2
- package/dist/keyboard-geometry.d.ts.map +1 -1
- package/dist/keyboard-geometry.js +71 -2
- package/dist/keyboard-geometry.js.map +1 -1
- package/dist/keyboard-keys.d.ts +93 -0
- package/dist/keyboard-keys.d.ts.map +1 -0
- package/dist/keyboard-keys.js +60 -0
- package/dist/keyboard-keys.js.map +1 -0
- package/dist/layered-paint.d.ts +47 -0
- package/dist/layered-paint.d.ts.map +1 -0
- package/dist/layered-paint.js +22 -0
- package/dist/layered-paint.js.map +1 -0
- package/dist/pagination.d.ts +6 -2
- package/dist/pagination.d.ts.map +1 -1
- package/dist/pagination.js +4 -0
- package/dist/pagination.js.map +1 -1
- package/dist/playback-binding.d.ts +57 -0
- package/dist/playback-binding.d.ts.map +1 -0
- package/dist/playback-binding.js +110 -0
- package/dist/playback-binding.js.map +1 -0
- package/dist/playhead.d.ts.map +1 -1
- package/dist/playhead.js +22 -3
- package/dist/playhead.js.map +1 -1
- package/dist/press.d.ts +66 -0
- package/dist/press.d.ts.map +1 -0
- package/dist/press.js +64 -0
- package/dist/press.js.map +1 -0
- package/dist/print-plan.d.ts +64 -0
- package/dist/print-plan.d.ts.map +1 -0
- package/dist/print-plan.js +101 -0
- package/dist/print-plan.js.map +1 -0
- package/dist/render-theme.d.ts.map +1 -1
- package/dist/render-theme.js +11 -2
- package/dist/render-theme.js.map +1 -1
- package/dist/score-canvas.d.ts +280 -0
- package/dist/score-canvas.d.ts.map +1 -0
- package/dist/score-canvas.js +702 -0
- package/dist/score-canvas.js.map +1 -0
- package/dist/skia/index.d.ts +2 -0
- package/dist/skia/index.d.ts.map +1 -1
- package/dist/skia/index.js +1 -0
- package/dist/skia/index.js.map +1 -1
- package/dist/skia/skia-layers.d.ts +43 -0
- package/dist/skia/skia-layers.d.ts.map +1 -0
- package/dist/skia/skia-layers.js +24 -0
- package/dist/skia/skia-layers.js.map +1 -0
- package/dist/types.d.ts +13 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
import { displayScore, measureAtTick, measureIndexOf, } from '@sudobility/music_types';
|
|
2
|
+
import { TRACK_INFO_WIDTH, boxForMeasureIndex, computeLayout, resolveZoom, } from './layout.js';
|
|
3
|
+
import { eventIdAtPoint, eventIdsAtPoint, eventIdsInBox, measureAtPoint, measureIndexAtGutterPoint, pitchAtStavePoint, } from './hit-test.js';
|
|
4
|
+
import { trackIdAtGutterPoint } from './track-gutter.js';
|
|
5
|
+
import { noteColorsFor } from './note-colors.js';
|
|
6
|
+
import { outOfRangeNoteIds } from './out-of-range.js';
|
|
7
|
+
import { playbackScrollTarget } from './playback-scroll.js';
|
|
8
|
+
import { tickForPoint } from './playhead.js';
|
|
9
|
+
import { shouldShowTrackInfo } from './types.js';
|
|
10
|
+
import { cursorGeometryAt, cursorPathFor, cursorTickAt, measureStartTicks, sameCursorPath, systemEndTick, systemIndexForTick, } from './cursor.js';
|
|
11
|
+
/** A frame, the default for `presentLatencyMs`. */
|
|
12
|
+
const FRAME_MS = 16;
|
|
13
|
+
/** Highlight latency before anything has been measured: one frame. */
|
|
14
|
+
const DEFAULT_HIGHLIGHT_LATENCY_SECONDS = 0.016;
|
|
15
|
+
/** Measurements kept for the median. */
|
|
16
|
+
const HIGHLIGHT_SAMPLES = 15;
|
|
17
|
+
/** A change smaller than this is not worth telling the player about. */
|
|
18
|
+
const HIGHLIGHT_REPORT_STEP_SECONDS = 0.003;
|
|
19
|
+
/** Clear space kept between the playing bar and the edge it nears, in view px. */
|
|
20
|
+
const FOLLOW_MARGIN = 40;
|
|
21
|
+
const HELD = { tick: 0, atMs: 0, ticksPerSecond: 0 };
|
|
22
|
+
export class ScoreCanvas {
|
|
23
|
+
constructor({ surface, scheduler }) {
|
|
24
|
+
this.score = null;
|
|
25
|
+
/** What `setStoredScore` was last given, so a repeat call does no work. */
|
|
26
|
+
this.stored = null;
|
|
27
|
+
this.pitchDisplay = 'concert';
|
|
28
|
+
this.view = null;
|
|
29
|
+
this.scrollLeft = 0;
|
|
30
|
+
this.scrollTop = 0;
|
|
31
|
+
this.activeTrackId = null;
|
|
32
|
+
this.playing = [];
|
|
33
|
+
this.selectedIds = [];
|
|
34
|
+
this.regenerated = false;
|
|
35
|
+
this.outOfRangeIds = [];
|
|
36
|
+
this.selectedMeasureIds = new Set();
|
|
37
|
+
this.lastFrame = null;
|
|
38
|
+
/** The plan the last frame was drawn against is only trusted while this matches. */
|
|
39
|
+
this.currentPlan = null;
|
|
40
|
+
this.starts = [];
|
|
41
|
+
this.cancelPaint = null;
|
|
42
|
+
/** What the drawn window last showed, so an invisible colour change skips the paint. */
|
|
43
|
+
this.paintedSignature = '';
|
|
44
|
+
/**
|
|
45
|
+
* Moves whenever something other than the lit notes changes what a paint
|
|
46
|
+
* draws; see `PaintFrame`. Bumped lazily, at the next paint, so a burst of
|
|
47
|
+
* changes costs one version.
|
|
48
|
+
*/
|
|
49
|
+
this.baseVersion = 0;
|
|
50
|
+
this.baseDirty = true;
|
|
51
|
+
/**
|
|
52
|
+
* Moves with every change `baseDirty` records, eagerly — the base colour
|
|
53
|
+
* signature is cached against it, and that cache is read between paints.
|
|
54
|
+
*/
|
|
55
|
+
this.baseEpoch = 0;
|
|
56
|
+
this.baseSignatureCache = null;
|
|
57
|
+
/** When the lit notes last changed without being painted yet. */
|
|
58
|
+
this.highlightRequestedAt = null;
|
|
59
|
+
this.highlightSamples = [];
|
|
60
|
+
this.highlightLatency = DEFAULT_HIGHLIGHT_LATENCY_SECONDS;
|
|
61
|
+
this.highlightListeners = new Set();
|
|
62
|
+
this.motion = HELD;
|
|
63
|
+
this.path = null;
|
|
64
|
+
this.shownMotion = null;
|
|
65
|
+
this.cancelWrap = null;
|
|
66
|
+
/** The look-ahead preparation in progress, and the window it is for. */
|
|
67
|
+
this.cancelLookahead = null;
|
|
68
|
+
this.lookaheadKey = null;
|
|
69
|
+
this.lookaheadPlan = null;
|
|
70
|
+
this.surface = surface;
|
|
71
|
+
this.scheduler = scheduler;
|
|
72
|
+
}
|
|
73
|
+
// ---- what is drawn -------------------------------------------------------
|
|
74
|
+
/**
|
|
75
|
+
* The score as the store holds it — sounding pitch, before any lens — and
|
|
76
|
+
* the reading mode to draw it in.
|
|
77
|
+
*
|
|
78
|
+
* The preferred way in. The canvas applies `displayScore` itself and scans
|
|
79
|
+
* the stored score with `outOfRangeNoteIds`, so no host has to remember
|
|
80
|
+
* either half: the scan must see **stored** pitch (the compass is sounding
|
|
81
|
+
* pitch) while the paint must see the **display** score, and a host handed
|
|
82
|
+
* only one of the two was always one line away from colouring a clarinet's
|
|
83
|
+
* part a tone out, or from marking nothing at all.
|
|
84
|
+
*
|
|
85
|
+
* The ids it marks survive the lenses — both keep event ids — so they colour
|
|
86
|
+
* the drawn notes correctly in either mode. Calling again with the same
|
|
87
|
+
* score and mode does nothing. A later `setOutOfRangeNotes` overrides the
|
|
88
|
+
* marks until the stored score next changes.
|
|
89
|
+
*/
|
|
90
|
+
setStoredScore(score, pitchDisplay = 'concert') {
|
|
91
|
+
if (score === this.stored && pitchDisplay === this.pitchDisplay)
|
|
92
|
+
return;
|
|
93
|
+
const scoreChanged = score !== this.stored;
|
|
94
|
+
this.stored = score;
|
|
95
|
+
this.pitchDisplay = pitchDisplay;
|
|
96
|
+
this.setScore(score ? displayScore(score, pitchDisplay) : null);
|
|
97
|
+
if (scoreChanged) {
|
|
98
|
+
this.outOfRangeIds = outOfRangeNoteIds(score).ids;
|
|
99
|
+
this.markBaseDirty();
|
|
100
|
+
this.scheduleColourPaint();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The **display** score, lenses already applied.
|
|
105
|
+
*
|
|
106
|
+
* Lower level than `setStoredScore`: the host owns the lenses and, if it
|
|
107
|
+
* wants out-of-range marks, must pass them to `setOutOfRangeNotes` from a
|
|
108
|
+
* scan of the stored score. Kept for hosts that have not moved over.
|
|
109
|
+
*/
|
|
110
|
+
setScore(score) {
|
|
111
|
+
if (score === this.score)
|
|
112
|
+
return;
|
|
113
|
+
this.score = score;
|
|
114
|
+
this.starts = score ? measureStartTicks(score) : [];
|
|
115
|
+
this.invalidateLayout();
|
|
116
|
+
}
|
|
117
|
+
setView(view) {
|
|
118
|
+
const previous = this.view;
|
|
119
|
+
this.view = view;
|
|
120
|
+
if (previous &&
|
|
121
|
+
previous.width === view.width &&
|
|
122
|
+
previous.zoom === view.zoom &&
|
|
123
|
+
previous.layoutMode === view.layoutMode &&
|
|
124
|
+
previous.theme === view.theme &&
|
|
125
|
+
sameIds(previous.trackIds, view.trackIds)) {
|
|
126
|
+
// Only the height or the pixel ratio moved: same layout, new window.
|
|
127
|
+
if (previous.height !== view.height ||
|
|
128
|
+
previous.devicePixelRatio !== view.devicePixelRatio) {
|
|
129
|
+
this.markBaseDirty();
|
|
130
|
+
this.schedulePaint();
|
|
131
|
+
}
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
this.invalidateLayout();
|
|
135
|
+
}
|
|
136
|
+
/** Content px. */
|
|
137
|
+
setScroll(left, top) {
|
|
138
|
+
if (left === this.scrollLeft && top === this.scrollTop)
|
|
139
|
+
return;
|
|
140
|
+
this.scrollLeft = left;
|
|
141
|
+
this.scrollTop = top;
|
|
142
|
+
this.markBaseDirty();
|
|
143
|
+
this.schedulePaint();
|
|
144
|
+
}
|
|
145
|
+
// ---- 1–2. the cursor -----------------------------------------------------
|
|
146
|
+
setCursorTick(tick) {
|
|
147
|
+
this.animateCursor({ tick, atMs: this.scheduler.now(), ticksPerSecond: 0 });
|
|
148
|
+
}
|
|
149
|
+
animateCursor(motion) {
|
|
150
|
+
this.motion = motion;
|
|
151
|
+
this.refreshCursor();
|
|
152
|
+
}
|
|
153
|
+
// ---- 3–6. what colours the notation --------------------------------------
|
|
154
|
+
setActiveTrack(trackId) {
|
|
155
|
+
if (trackId === this.activeTrackId)
|
|
156
|
+
return;
|
|
157
|
+
this.activeTrackId = trackId;
|
|
158
|
+
this.markBaseDirty();
|
|
159
|
+
// The stave tint changes with it, so this is always visible.
|
|
160
|
+
this.paintedSignature = '';
|
|
161
|
+
this.schedulePaint();
|
|
162
|
+
}
|
|
163
|
+
setPlayingNotes(notes) {
|
|
164
|
+
this.playing = notes;
|
|
165
|
+
// Stamped before scheduling: a host that paints synchronously measures
|
|
166
|
+
// inside `scheduleColourPaint`, and must find the stamp already there.
|
|
167
|
+
const stamped = this.highlightRequestedAt === null;
|
|
168
|
+
if (stamped)
|
|
169
|
+
this.highlightRequestedAt = this.scheduler.now();
|
|
170
|
+
if (!this.scheduleColourPaint() && stamped) {
|
|
171
|
+
this.highlightRequestedAt = null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
setSelectedNotes(ids, options = {}) {
|
|
175
|
+
const regenerated = options.regenerated ?? false;
|
|
176
|
+
// Compared, because hosts hand a fresh array per render and the base a
|
|
177
|
+
// layered surface keeps is only thrown away for a real change.
|
|
178
|
+
if (sameIds(ids, this.selectedIds) && regenerated === this.regenerated) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
this.selectedIds = ids;
|
|
182
|
+
this.regenerated = regenerated;
|
|
183
|
+
this.markBaseDirty();
|
|
184
|
+
this.scheduleColourPaint();
|
|
185
|
+
}
|
|
186
|
+
setSelectedMeasures(ids) {
|
|
187
|
+
if (ids.length === this.selectedMeasureIds.size &&
|
|
188
|
+
ids.every(id => this.selectedMeasureIds.has(id))) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
this.selectedMeasureIds = new Set(ids);
|
|
192
|
+
this.markBaseDirty();
|
|
193
|
+
this.scheduleColourPaint();
|
|
194
|
+
}
|
|
195
|
+
setOutOfRangeNotes(ids) {
|
|
196
|
+
if (sameIds(ids, this.outOfRangeIds))
|
|
197
|
+
return;
|
|
198
|
+
this.outOfRangeIds = ids;
|
|
199
|
+
this.markBaseDirty();
|
|
200
|
+
this.scheduleColourPaint();
|
|
201
|
+
}
|
|
202
|
+
// ---- questions -----------------------------------------------------------
|
|
203
|
+
/** The layout the canvas draws against, or null with no score or view. */
|
|
204
|
+
get plan() {
|
|
205
|
+
if (this.currentPlan)
|
|
206
|
+
return this.currentPlan;
|
|
207
|
+
if (!this.score || !this.view)
|
|
208
|
+
return null;
|
|
209
|
+
this.currentPlan = computeLayout(this.score, this.layoutOptions(this.view));
|
|
210
|
+
return this.currentPlan;
|
|
211
|
+
}
|
|
212
|
+
/** What the last paint drew, while it still describes the current layout. */
|
|
213
|
+
get frame() {
|
|
214
|
+
return this.lastFrame;
|
|
215
|
+
}
|
|
216
|
+
get zoom() {
|
|
217
|
+
return resolveZoom(this.view?.zoom ?? 1);
|
|
218
|
+
}
|
|
219
|
+
/** How large the scrollable content is, in content px. */
|
|
220
|
+
contentSize() {
|
|
221
|
+
const plan = this.plan;
|
|
222
|
+
if (!plan)
|
|
223
|
+
return { width: 0, height: 0 };
|
|
224
|
+
return {
|
|
225
|
+
width: plan.totalWidth * this.zoom,
|
|
226
|
+
height: plan.totalHeight * this.zoom,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
/** Content px. */
|
|
230
|
+
cursorGeometry(tick) {
|
|
231
|
+
const plan = this.plan;
|
|
232
|
+
if (!plan || !this.score)
|
|
233
|
+
return null;
|
|
234
|
+
return cursorGeometryAt(plan, this.score, this.zoom, tick, this.positions());
|
|
235
|
+
}
|
|
236
|
+
hitTest(point) {
|
|
237
|
+
const plan = this.plan;
|
|
238
|
+
const score = this.score;
|
|
239
|
+
const view = this.view;
|
|
240
|
+
if (!plan || !score || !view)
|
|
241
|
+
return null;
|
|
242
|
+
const zoom = this.zoom;
|
|
243
|
+
// 10. The gutter is pinned to the view, so it is tested in view px.
|
|
244
|
+
if (shouldShowTrackInfo(this.layoutOptions(view))) {
|
|
245
|
+
const trackId = trackIdAtGutterPoint(plan, zoom, this.scrollTop, point);
|
|
246
|
+
if (trackId)
|
|
247
|
+
return { kind: 'trackGutter', trackId };
|
|
248
|
+
if (point.x >= 0 && point.x <= TRACK_INFO_WIDTH * zoom)
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
const content = {
|
|
252
|
+
x: point.x + this.scrollLeft,
|
|
253
|
+
y: point.y + this.scrollTop,
|
|
254
|
+
};
|
|
255
|
+
const logical = { x: content.x / zoom, y: content.y / zoom };
|
|
256
|
+
// 11.
|
|
257
|
+
const measureIndex = measureIndexAtGutterPoint(plan, logical);
|
|
258
|
+
if (measureIndex !== null)
|
|
259
|
+
return { kind: 'measureNumber', measureIndex };
|
|
260
|
+
const measure = measureAtPoint(plan, logical);
|
|
261
|
+
if (!measure)
|
|
262
|
+
return null;
|
|
263
|
+
const tick = tickForPoint(plan, score, logical.x, logical.y, this.positions()) ?? 0;
|
|
264
|
+
// 12. Bounding boxes are content px.
|
|
265
|
+
const boxes = this.lastFrame?.idToBBox;
|
|
266
|
+
const noteId = boxes ? eventIdAtPoint(boxes, content) : null;
|
|
267
|
+
if (boxes && noteId) {
|
|
268
|
+
const chord = eventIdsAtPoint(boxes, content);
|
|
269
|
+
return {
|
|
270
|
+
kind: 'note',
|
|
271
|
+
eventIds: chord.length > 0 ? chord : [noteId],
|
|
272
|
+
trackId: measure.trackId,
|
|
273
|
+
measureIndex: measure.measureIndex,
|
|
274
|
+
tick,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
// 13.
|
|
278
|
+
return {
|
|
279
|
+
kind: 'stave',
|
|
280
|
+
trackId: measure.trackId,
|
|
281
|
+
measureIndex: measure.measureIndex,
|
|
282
|
+
tick,
|
|
283
|
+
pitch: pitchAtStavePoint(plan, score, logical)?.pitch ?? null,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
/** The tick under a view point: the inverse of the cursor. */
|
|
287
|
+
tickAt(point) {
|
|
288
|
+
const plan = this.plan;
|
|
289
|
+
if (!plan || !this.score)
|
|
290
|
+
return null;
|
|
291
|
+
const zoom = this.zoom;
|
|
292
|
+
return tickForPoint(plan, this.score, (point.x + this.scrollLeft) / zoom, (point.y + this.scrollTop) / zoom, this.positions());
|
|
293
|
+
}
|
|
294
|
+
/** Drawn notes inside a view-px rectangle spanned by two corners. */
|
|
295
|
+
noteIdsInRect(a, b) {
|
|
296
|
+
const boxes = this.lastFrame?.idToBBox;
|
|
297
|
+
if (!boxes)
|
|
298
|
+
return [];
|
|
299
|
+
const x = Math.min(a.x, b.x) + this.scrollLeft;
|
|
300
|
+
const y = Math.min(a.y, b.y) + this.scrollTop;
|
|
301
|
+
return eventIdsInBox(boxes, {
|
|
302
|
+
x,
|
|
303
|
+
y,
|
|
304
|
+
width: Math.abs(a.x - b.x),
|
|
305
|
+
height: Math.abs(a.y - b.y),
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
/** The bar index `tick` falls in, or null — following playback decides per bar. */
|
|
309
|
+
measureIndexAt(tick) {
|
|
310
|
+
const score = this.score;
|
|
311
|
+
const track = score?.tracks[0];
|
|
312
|
+
if (!score || !track)
|
|
313
|
+
return null;
|
|
314
|
+
const measure = measureAtTick(score, track.id, tick);
|
|
315
|
+
return measure ? measureIndexOf(score, measure.id) : null;
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Ticks per second at `tick` from the written tempo — a first guess for a
|
|
319
|
+
* cursor that has just started moving, before the real rate is measured.
|
|
320
|
+
*/
|
|
321
|
+
writtenTicksPerSecond(tick) {
|
|
322
|
+
const score = this.score;
|
|
323
|
+
if (!score)
|
|
324
|
+
return 0;
|
|
325
|
+
let bpm = 0;
|
|
326
|
+
for (const event of score.tempoMap) {
|
|
327
|
+
if (event.tick > tick)
|
|
328
|
+
break;
|
|
329
|
+
bpm = event.bpm;
|
|
330
|
+
}
|
|
331
|
+
return (bpm * score.ppq) / 60;
|
|
332
|
+
}
|
|
333
|
+
/** Where following playback should scroll for `tick`, or null to stay. */
|
|
334
|
+
followTarget(tick) {
|
|
335
|
+
const plan = this.plan;
|
|
336
|
+
const score = this.score;
|
|
337
|
+
const view = this.view;
|
|
338
|
+
const track = score?.tracks[0];
|
|
339
|
+
if (!plan || !score || !view || !track)
|
|
340
|
+
return null;
|
|
341
|
+
const measure = measureAtTick(score, track.id, tick);
|
|
342
|
+
const measureIndex = measure ? measureIndexOf(score, measure.id) : null;
|
|
343
|
+
if (measureIndex === null)
|
|
344
|
+
return null;
|
|
345
|
+
const box = boxForMeasureIndex(plan, 0, measureIndex);
|
|
346
|
+
if (!box)
|
|
347
|
+
return null;
|
|
348
|
+
return playbackScrollTarget({
|
|
349
|
+
plan,
|
|
350
|
+
layoutMode: view.layoutMode,
|
|
351
|
+
zoom: this.zoom,
|
|
352
|
+
measureIndex,
|
|
353
|
+
measureX: box.x,
|
|
354
|
+
measureWidth: box.width,
|
|
355
|
+
scrollLeft: this.scrollLeft,
|
|
356
|
+
viewportWidth: view.width,
|
|
357
|
+
scrollTop: this.scrollTop,
|
|
358
|
+
viewportHeight: view.height,
|
|
359
|
+
margin: FOLLOW_MARGIN,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
/** Scroll so `tick` is in view: a jump for more than a screen, else smooth. */
|
|
363
|
+
scrollToFollow(tick) {
|
|
364
|
+
const target = this.followTarget(tick);
|
|
365
|
+
const view = this.view;
|
|
366
|
+
if (!target || !view)
|
|
367
|
+
return;
|
|
368
|
+
const relocating = Math.abs(target.top - this.scrollTop) > view.height ||
|
|
369
|
+
Math.abs(target.left - this.scrollLeft) > view.width;
|
|
370
|
+
this.surface.scrollTo?.(target, relocating ? 'jump' : 'smooth');
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* How long a change of lit notes takes to reach the screen here, in seconds:
|
|
374
|
+
* waiting for the frame, drawing, and presenting.
|
|
375
|
+
*
|
|
376
|
+
* Measured rather than assumed because it is a property of the host. A web
|
|
377
|
+
* canvas repaints a window of notation in a few milliseconds; the native app
|
|
378
|
+
* records a Skia picture and waits a React commit, measured at 45–70ms on a
|
|
379
|
+
* dense score. The player publishes the lit notes this far ahead
|
|
380
|
+
* (`setSoundingRenderDelay`), which is what keeps them with the sound on
|
|
381
|
+
* both. The median of recent paints, so one stalled frame does not move it.
|
|
382
|
+
*/
|
|
383
|
+
get highlightLatencySeconds() {
|
|
384
|
+
return this.highlightLatency;
|
|
385
|
+
}
|
|
386
|
+
subscribeHighlightLatency(listener) {
|
|
387
|
+
this.highlightListeners.add(listener);
|
|
388
|
+
return () => this.highlightListeners.delete(listener);
|
|
389
|
+
}
|
|
390
|
+
/** Paint now rather than on the next frame — for a host that must, like a resize. */
|
|
391
|
+
paintNow() {
|
|
392
|
+
// A host paints now when its drawing surface itself changed — a resize
|
|
393
|
+
// clears a canvas — so nothing it kept can be trusted.
|
|
394
|
+
this.markBaseDirty();
|
|
395
|
+
this.cancelPaint?.();
|
|
396
|
+
this.cancelPaint = null;
|
|
397
|
+
this.paint();
|
|
398
|
+
}
|
|
399
|
+
dispose() {
|
|
400
|
+
this.cancelLookahead?.();
|
|
401
|
+
this.cancelLookahead = null;
|
|
402
|
+
this.cancelPaint?.();
|
|
403
|
+
this.cancelPaint = null;
|
|
404
|
+
this.cancelWrap?.();
|
|
405
|
+
this.cancelWrap = null;
|
|
406
|
+
}
|
|
407
|
+
// ---- internals -----------------------------------------------------------
|
|
408
|
+
layoutOptions(view) {
|
|
409
|
+
return {
|
|
410
|
+
zoom: view.zoom,
|
|
411
|
+
layoutMode: view.layoutMode,
|
|
412
|
+
width: view.width,
|
|
413
|
+
theme: view.theme,
|
|
414
|
+
...(view.trackIds ? { trackIds: view.trackIds } : {}),
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
positions() {
|
|
418
|
+
const frame = this.lastFrame;
|
|
419
|
+
return frame && frame.plan === this.plan
|
|
420
|
+
? frame.measureNotePositions
|
|
421
|
+
: undefined;
|
|
422
|
+
}
|
|
423
|
+
invalidateLayout() {
|
|
424
|
+
this.markBaseDirty();
|
|
425
|
+
this.currentPlan = null;
|
|
426
|
+
this.lastFrame = null;
|
|
427
|
+
this.paintedSignature = '';
|
|
428
|
+
this.schedulePaint();
|
|
429
|
+
this.refreshCursor();
|
|
430
|
+
}
|
|
431
|
+
/** Schedules a paint for a colour change; says whether one was needed. */
|
|
432
|
+
scheduleColourPaint() {
|
|
433
|
+
const frame = this.lastFrame;
|
|
434
|
+
if (frame) {
|
|
435
|
+
const signature = this.colourSignature(frame);
|
|
436
|
+
if (signature === this.paintedSignature)
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
this.schedulePaint();
|
|
440
|
+
return true;
|
|
441
|
+
}
|
|
442
|
+
markBaseDirty() {
|
|
443
|
+
this.baseDirty = true;
|
|
444
|
+
this.baseEpoch += 1;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* What colour the drawn window shows, as a string two states compare by.
|
|
448
|
+
*
|
|
449
|
+
* In two parts, because they change at very different rates. Everything but
|
|
450
|
+
* the lit notes — the active track, the selection, the out-of-range marks,
|
|
451
|
+
* the selected bars — changes when somebody does something, and is cached
|
|
452
|
+
* until then; the lit notes change on every note during playback, and are a
|
|
453
|
+
* handful. Building the whole string on every change of lit notes cost time
|
|
454
|
+
* in proportion to every coloured note in the window, which on an import
|
|
455
|
+
* full of out-of-range notes is most of them. A playing note is drawn playing
|
|
456
|
+
* whatever else it is (`buildNoteColors` applies it last), so the two parts
|
|
457
|
+
* together decide the colours exactly.
|
|
458
|
+
*/
|
|
459
|
+
colourSignature(frame) {
|
|
460
|
+
return `${this.baseSignature(frame)}|${this.playingSignature(frame)}`;
|
|
461
|
+
}
|
|
462
|
+
baseSignature(frame) {
|
|
463
|
+
const cached = this.baseSignatureCache;
|
|
464
|
+
if (cached && cached.frame === frame && cached.epoch === this.baseEpoch) {
|
|
465
|
+
return cached.signature;
|
|
466
|
+
}
|
|
467
|
+
let signature = `a${this.activeTrackId ?? ''};`;
|
|
468
|
+
const selectedRole = this.regenerated ? 'r' : 's';
|
|
469
|
+
for (const id of this.outOfRangeIds) {
|
|
470
|
+
if (frame.idToBBox.has(id))
|
|
471
|
+
signature += `${id}:o;`;
|
|
472
|
+
}
|
|
473
|
+
for (const id of this.selectedIds) {
|
|
474
|
+
if (frame.idToBBox.has(id))
|
|
475
|
+
signature += `${id}:${selectedRole};`;
|
|
476
|
+
}
|
|
477
|
+
for (const id of this.selectedMeasureIds) {
|
|
478
|
+
if (frame.measureIdToBBox.has(id))
|
|
479
|
+
signature += `m${id};`;
|
|
480
|
+
}
|
|
481
|
+
this.baseSignatureCache = { frame, epoch: this.baseEpoch, signature };
|
|
482
|
+
return signature;
|
|
483
|
+
}
|
|
484
|
+
/** The drawn notes lit now: the active track's sounding notes on screen. */
|
|
485
|
+
playingSignature(frame) {
|
|
486
|
+
const active = this.activeTrackId;
|
|
487
|
+
if (active === null)
|
|
488
|
+
return '';
|
|
489
|
+
const lit = [];
|
|
490
|
+
for (const note of this.playing) {
|
|
491
|
+
if (note.trackId === active && frame.idToBBox.has(note.noteId)) {
|
|
492
|
+
lit.push(note.noteId);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return lit.sort().join(';');
|
|
496
|
+
}
|
|
497
|
+
noteColors() {
|
|
498
|
+
return noteColorsFor({
|
|
499
|
+
selectedIds: this.selectedIds,
|
|
500
|
+
outOfRangeIds: this.outOfRangeIds,
|
|
501
|
+
sounding: this.playing,
|
|
502
|
+
activeTrackId: this.activeTrackId,
|
|
503
|
+
regenerated: this.regenerated,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
schedulePaint() {
|
|
507
|
+
if (this.cancelPaint)
|
|
508
|
+
return;
|
|
509
|
+
/*
|
|
510
|
+
The canceller is kept only if the callback has not already run. A host
|
|
511
|
+
may run a frame callback synchronously (a test's stubbed
|
|
512
|
+
`requestAnimationFrame` does), and storing the canceller *after* that
|
|
513
|
+
callback had cleared it left this guard set for good: the canvas never
|
|
514
|
+
painted again.
|
|
515
|
+
*/
|
|
516
|
+
let ran = false;
|
|
517
|
+
const cancel = this.scheduler.frame(() => {
|
|
518
|
+
ran = true;
|
|
519
|
+
this.cancelPaint = null;
|
|
520
|
+
this.paint();
|
|
521
|
+
});
|
|
522
|
+
if (!ran)
|
|
523
|
+
this.cancelPaint = cancel;
|
|
524
|
+
}
|
|
525
|
+
paint() {
|
|
526
|
+
const score = this.score;
|
|
527
|
+
const view = this.view;
|
|
528
|
+
if (!score || !view)
|
|
529
|
+
return;
|
|
530
|
+
const zoom = this.zoom;
|
|
531
|
+
const noteColors = this.noteColors();
|
|
532
|
+
if (this.baseDirty) {
|
|
533
|
+
this.baseVersion += 1;
|
|
534
|
+
this.baseDirty = false;
|
|
535
|
+
}
|
|
536
|
+
const frame = this.surface.paint(score, {
|
|
537
|
+
...this.layoutOptions(view),
|
|
538
|
+
noteColors,
|
|
539
|
+
activeTrackId: this.activeTrackId,
|
|
540
|
+
selectedMeasureIds: this.selectedMeasureIds,
|
|
541
|
+
viewport: {
|
|
542
|
+
top: this.scrollTop / zoom,
|
|
543
|
+
bottom: (this.scrollTop + view.height) / zoom,
|
|
544
|
+
left: this.scrollLeft / zoom,
|
|
545
|
+
right: (this.scrollLeft + view.width) / zoom,
|
|
546
|
+
},
|
|
547
|
+
...(view.devicePixelRatio
|
|
548
|
+
? { devicePixelRatio: view.devicePixelRatio }
|
|
549
|
+
: {}),
|
|
550
|
+
}, { baseVersion: this.baseVersion });
|
|
551
|
+
this.measureHighlight();
|
|
552
|
+
if (!frame)
|
|
553
|
+
return;
|
|
554
|
+
// The renderer's plan is the one the notes were placed in.
|
|
555
|
+
this.currentPlan = frame.plan;
|
|
556
|
+
this.lastFrame = frame;
|
|
557
|
+
this.paintedSignature = this.colourSignature(frame);
|
|
558
|
+
// 6. A repaint can move the noteheads the path runs through.
|
|
559
|
+
this.refreshCursor();
|
|
560
|
+
this.scheduleLookahead();
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* While the cursor moves, builds the window following it will show next —
|
|
564
|
+
* the music just past what is drawn, where `followTarget` would put it — a
|
|
565
|
+
* slice per task, so the frame that scrolls there only has to paint.
|
|
566
|
+
*
|
|
567
|
+
* Keyed on the plan and that window, so each is prepared once however many
|
|
568
|
+
* paints happen while it is the next one.
|
|
569
|
+
*/
|
|
570
|
+
scheduleLookahead() {
|
|
571
|
+
const prepare = this.surface.prepare;
|
|
572
|
+
const score = this.score;
|
|
573
|
+
const view = this.view;
|
|
574
|
+
const frame = this.lastFrame;
|
|
575
|
+
const plan = this.currentPlan;
|
|
576
|
+
if (!prepare || !score || !view || !frame || !plan)
|
|
577
|
+
return;
|
|
578
|
+
if (this.motion.ticksPerSecond <= 0)
|
|
579
|
+
return;
|
|
580
|
+
let last = -1;
|
|
581
|
+
for (const index of frame.drawnMeasureIndices)
|
|
582
|
+
last = Math.max(last, index);
|
|
583
|
+
const nextStart = this.starts[last + 1];
|
|
584
|
+
if (nextStart === undefined)
|
|
585
|
+
return;
|
|
586
|
+
const target = this.followTarget(nextStart);
|
|
587
|
+
if (!target)
|
|
588
|
+
return;
|
|
589
|
+
const zoom = this.zoom;
|
|
590
|
+
const options = {
|
|
591
|
+
...this.layoutOptions(view),
|
|
592
|
+
viewport: {
|
|
593
|
+
top: target.top / zoom,
|
|
594
|
+
bottom: (target.top + view.height) / zoom,
|
|
595
|
+
left: target.left / zoom,
|
|
596
|
+
right: (target.left + view.width) / zoom,
|
|
597
|
+
},
|
|
598
|
+
};
|
|
599
|
+
const key = `${last}:${target.top}:${target.left}`;
|
|
600
|
+
if (key === this.lookaheadKey && plan === this.lookaheadPlan)
|
|
601
|
+
return;
|
|
602
|
+
this.cancelLookahead?.();
|
|
603
|
+
this.cancelLookahead = null;
|
|
604
|
+
this.lookaheadKey = key;
|
|
605
|
+
this.lookaheadPlan = plan;
|
|
606
|
+
const step = () => {
|
|
607
|
+
this.cancelLookahead = null;
|
|
608
|
+
// A new plan or a stopped cursor makes the work pointless; a later
|
|
609
|
+
// paint schedules whatever is next.
|
|
610
|
+
if (this.currentPlan !== plan || this.motion.ticksPerSecond <= 0) {
|
|
611
|
+
this.lookaheadKey = null;
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
if (prepare.call(this.surface, score, options) === 0)
|
|
615
|
+
return;
|
|
616
|
+
// Kept only if not already run, for the reason `schedulePaint` gives.
|
|
617
|
+
let ran = false;
|
|
618
|
+
const cancel = this.scheduler.timeout(() => {
|
|
619
|
+
ran = true;
|
|
620
|
+
step();
|
|
621
|
+
}, 0);
|
|
622
|
+
if (!ran)
|
|
623
|
+
this.cancelLookahead = cancel;
|
|
624
|
+
};
|
|
625
|
+
let ran = false;
|
|
626
|
+
const cancel = this.scheduler.timeout(() => {
|
|
627
|
+
ran = true;
|
|
628
|
+
step();
|
|
629
|
+
}, 0);
|
|
630
|
+
if (!ran)
|
|
631
|
+
this.cancelLookahead = cancel;
|
|
632
|
+
}
|
|
633
|
+
measureHighlight() {
|
|
634
|
+
const requestedAt = this.highlightRequestedAt;
|
|
635
|
+
if (requestedAt === null)
|
|
636
|
+
return;
|
|
637
|
+
this.highlightRequestedAt = null;
|
|
638
|
+
const present = this.surface.presentLatencyMs ?? FRAME_MS;
|
|
639
|
+
const sample = (this.scheduler.now() - requestedAt + present) / 1000;
|
|
640
|
+
this.highlightSamples.push(sample);
|
|
641
|
+
if (this.highlightSamples.length > HIGHLIGHT_SAMPLES) {
|
|
642
|
+
this.highlightSamples.shift();
|
|
643
|
+
}
|
|
644
|
+
const sorted = [...this.highlightSamples].sort((a, b) => a - b);
|
|
645
|
+
const median = sorted[sorted.length >> 1] ?? sample;
|
|
646
|
+
const unreported = this.highlightSamples.length === 1;
|
|
647
|
+
if (unreported ||
|
|
648
|
+
Math.abs(median - this.highlightLatency) >= HIGHLIGHT_REPORT_STEP_SECONDS) {
|
|
649
|
+
this.highlightLatency = median;
|
|
650
|
+
for (const listener of this.highlightListeners)
|
|
651
|
+
listener(median);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
refreshCursor() {
|
|
655
|
+
this.cancelWrap?.();
|
|
656
|
+
this.cancelWrap = null;
|
|
657
|
+
const plan = this.plan;
|
|
658
|
+
const score = this.score;
|
|
659
|
+
if (!plan || !score) {
|
|
660
|
+
this.show(null);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
const now = this.scheduler.now();
|
|
664
|
+
const tick = cursorTickAt(this.motion, now);
|
|
665
|
+
const systemIndex = systemIndexForTick(plan, this.starts, tick);
|
|
666
|
+
const path = systemIndex < 0
|
|
667
|
+
? null
|
|
668
|
+
: cursorPathFor(plan, score, this.zoom, this.starts, systemIndex, this.positions());
|
|
669
|
+
this.show(path);
|
|
670
|
+
// 6. Hand over to the next system when the moving cursor gets there.
|
|
671
|
+
const rate = this.motion.ticksPerSecond;
|
|
672
|
+
if (path && rate > 0 && systemIndex < plan.systems.length - 1) {
|
|
673
|
+
const end = systemEndTick(plan, this.starts, systemIndex);
|
|
674
|
+
const ms = ((end - tick) / rate) * 1000;
|
|
675
|
+
// Kept only if not already run, for the reason `schedulePaint` gives.
|
|
676
|
+
let ran = false;
|
|
677
|
+
const cancel = this.scheduler.timeout(() => {
|
|
678
|
+
ran = true;
|
|
679
|
+
this.cancelWrap = null;
|
|
680
|
+
this.refreshCursor();
|
|
681
|
+
}, Math.max(0, ms) + 1);
|
|
682
|
+
if (!ran)
|
|
683
|
+
this.cancelWrap = cancel;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
show(path) {
|
|
687
|
+
if (sameCursorPath(path, this.path) && this.shownMotion === this.motion) {
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
this.path = path;
|
|
691
|
+
this.shownMotion = this.motion;
|
|
692
|
+
this.surface.showCursor(path, this.motion);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
function sameIds(a, b) {
|
|
696
|
+
if (a === b)
|
|
697
|
+
return true;
|
|
698
|
+
if (!a || !b || a.length !== b.length)
|
|
699
|
+
return false;
|
|
700
|
+
return a.every((id, index) => id === b[index]);
|
|
701
|
+
}
|
|
702
|
+
//# sourceMappingURL=score-canvas.js.map
|