+
setScrolled(e.currentTarget.scrollTop > 0)}
+ className="flex max-h-[70vh] flex-col gap-4 overflow-y-auto p-4 md:max-h-none md:overflow-visible"
+ >
+
+
+
+
-
+
-
-
-
-
+
+
+
+
-
+
-
-
-
-
+
+
+ Edit MusicXML
+
+
+
+
-
-
-
-
-
- Config
-
-
-
-
- With only a single system, some controls (e.g. system spacing
- and max system fill) won't have a visible effect.
-
-
-
-
-
-
-
- The engraving font for noteheads, clefs, accidentals, and
- rests. Bravura is the default.
-
-
+
+
+ {cleared ? (
+ <>
+ Cleared
+
+ >
+ ) : restored ? (
+ <>
+ Retrieved from local storage
+
+ >
+ ) : stored ? (
+ <>
+ Score saved
+
+ >
+ ) : (
+ 'Nothing saved'
+ )}
+
+
+
-
-
-
- setConfig((c) => ({
- ...c,
- noteSpacing: e.target.valueAsNumber,
- }))
}
- />
-
- How much horizontal space notes get: the px a quarter note is
- allotted. Higher spreads every measure wider.
-
-
-
-
-
+
+
+ The engraving font for noteheads, clefs, accidentals, and
+ rests. Bravura is the default.
+
+
-
-
+
+ setConfig((c) => ({
+ ...c,
+ noteSpacing: e.target.valueAsNumber,
+ }))
+ }
+ />
+
+ How much horizontal space notes get: the px a quarter note
+ is allotted. Higher spreads every measure wider.
+
+
-
-
+
+ setConfig((c) => ({
+ ...c,
+ softmaxFactor: e.target.valueAsNumber,
+ }))
+ }
+ />
+
+ How that space is divided among notes. Higher exaggerates
+ the width difference between long and short notes.
+
+
-
-
- Reference width
-
- {width}
-
- setConfig(({ layout: _, ...rest }) => rest)
+
+
+ System spacing
+
+
+ {systemSpacing}
+
+ reset('systemSpacing')}
+ disabled={config.systemSpacing === undefined}
+ aria-label="Reset system spacing"
+ className="text-zinc-400 hover:text-zinc-600 disabled:cursor-default disabled:text-zinc-300 disabled:hover:text-zinc-300"
+ >
+
+
+
+
+
+ setConfig((c) => ({
+ ...c,
+ systemSpacing: e.target.valueAsNumber,
+ }))
}
- disabled={config.layout === undefined}
- aria-label="Reset width"
- className="text-zinc-400 hover:text-zinc-600 disabled:cursor-default disabled:text-zinc-300 disabled:hover:text-zinc-300"
+ />
+
+ Vertical gap between stacked systems. Lower packs systems
+ closer together down the page.
+
+
+
+
+
-
-
-
-
-
- setConfig((c) => ({
- ...c,
- layout: {
- type: 'standard',
- width: e.target.valueAsNumber,
- },
- }))
- }
- />
-
- The width the score is engraved to; the rendering then scales
- up or down to fit its container. Wider fits more measures per
- system before wrapping.
-
+ Max system fill
+
+
+ {maxSystemFill.toFixed(2)}
+
+ reset('maxSystemFill')}
+ disabled={config.maxSystemFill === undefined}
+ aria-label="Reset max system fill"
+ className="text-zinc-400 hover:text-zinc-600 disabled:cursor-default disabled:text-zinc-300 disabled:hover:text-zinc-300"
+ >
+
+
+
+
+
+ setConfig((c) => ({
+ ...c,
+ maxSystemFill: e.target.valueAsNumber,
+ }))
+ }
+ />
+
+ How full a system gets before the next measure wraps to a
+ new line. Lower leaves more air; 1 packs each line to the
+ edge.
+
+
+
+
+
+ Reference width
+
+ {width}
+
+ setConfig(({ layout: _, ...rest }) => rest)
+ }
+ disabled={config.layout === undefined}
+ aria-label="Reset width"
+ className="text-zinc-400 hover:text-zinc-600 disabled:cursor-default disabled:text-zinc-300 disabled:hover:text-zinc-300"
+ >
+
+
+
+
+
+ setConfig((c) => ({
+ ...c,
+ layout: {
+ type: 'standard',
+ referenceWidth: e.target.valueAsNumber,
+ },
+ }))
+ }
+ />
+
+ The width the score is engraved to; the rendering then
+ scales up or down to fit its container. Wider fits more
+ measures per system before wrapping.
+
+
+
diff --git a/src/config.ts b/src/config.ts
index 1075f9cc3..7e2506794 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -57,6 +57,20 @@ export type Config = {
* system to the edge (the old greedy behavior). Only affects near-full systems — a line
* whose measures already sit below this fill breaks at the same place either way. */
maxSystemFill: number;
+ /** Fixed container height in px, or null for none (default: null). When set, vexml puts the score
+ * in a vertical scroll box at exactly this height — for system-stacked (standard) layouts taller
+ * than the space you want them to take. Prefer maxHeight to cap only when the score overflows. */
+ height: number | null;
+ /** Max container height in px, or null for none (default: null). The score scrolls vertically once
+ * it exceeds this; shorter scores keep their natural height. */
+ maxHeight: number | null;
+ /** Fixed container width in px, or null for none (default: null). When set, vexml puts the score in
+ * a horizontal scroll box at this width — for panoramic (single-row) layouts wider than the space
+ * available. Prefer maxWidth to cap only when the score overflows. */
+ width: number | null;
+ /** Max container width in px, or null for none (default: null). The score scrolls horizontally once
+ * it exceeds this; narrower scores keep their natural width. */
+ maxWidth: number | null;
};
/** Default fonts: bundled Bravura for notation, Source Sans 3 for text. Families only —
@@ -82,4 +96,8 @@ export const DEFAULT_CONFIG: Config = {
stretchSingleSystem: true,
minLastSystemFill: 0.75,
maxSystemFill: 0.9,
+ height: null,
+ maxHeight: null,
+ width: null,
+ maxWidth: null,
};
diff --git a/src/cursor-view.test.ts b/src/cursor-view.test.ts
new file mode 100644
index 000000000..6c7e957bd
--- /dev/null
+++ b/src/cursor-view.test.ts
@@ -0,0 +1,77 @@
+import { expect, test } from 'bun:test';
+import type { CursorChangeEvent } from './cursor';
+import { BarCursorView } from './cursor-view';
+import { Rect } from './geometry';
+import type { Layer } from './stage';
+
+// A recording 2D context: captures fillRect calls and whether the bitmap was cleared.
+class RecordingCtx {
+ fills: Array<{ x: number; y: number; w: number; h: number; style: string }> =
+ [];
+ cleared = false;
+ fillStyle = '';
+ readonly canvas = { width: 1000, height: 100 };
+ save(): void {}
+ restore(): void {}
+ setTransform(): void {}
+ clearRect(): void {
+ this.cleared = true;
+ }
+ fillRect(x: number, y: number, w: number, h: number): void {
+ this.fills.push({ x, y, w, h, style: this.fillStyle });
+ }
+}
+
+class FakeLayer implements Layer {
+ readonly recording = new RecordingCtx();
+ readonly ctx = this.recording as unknown as CanvasRenderingContext2D;
+ disposed = false;
+ dispose(): void {
+ this.disposed = true;
+ }
+}
+
+function changeAt(rect: Rect): CursorChangeEvent {
+ return {
+ timeMs: 0,
+ timeBeats: 0,
+ index: 0,
+ position: { rect, getBoundingClientRect: () => ({}) as DOMRect },
+ active: [],
+ started: [],
+ sustained: [],
+ stopped: [],
+ done: false,
+ };
+}
+
+test('draws a vertical bar straddling the onset x, spanning the system, after clearing', () => {
+ const layer = new FakeLayer();
+ const view = new BarCursorView(layer); // default width 2
+ view.render(changeAt(new Rect(10, 0, 1, 100)));
+ expect(layer.recording.cleared).toBe(true);
+ expect(layer.recording.fills).toEqual([
+ { x: 9, y: 0, w: 2, h: 100, style: '#2563eb' },
+ ]);
+});
+
+test('honors color and width options', () => {
+ const layer = new FakeLayer();
+ const view = new BarCursorView(layer, { color: 'red', widthPx: 4 });
+ view.render(changeAt(new Rect(10, 5, 1, 80)));
+ expect(layer.recording.fills).toEqual([
+ { x: 8, y: 5, w: 4, h: 80, style: 'red' },
+ ]);
+});
+
+test('repaints (clears) on each render and disposes its layer', () => {
+ const layer = new FakeLayer();
+ const view = new BarCursorView(layer);
+ view.render(changeAt(new Rect(10, 0, 1, 100)));
+ view.render(changeAt(new Rect(20, 0, 1, 100)));
+ // Only the latest bar remains (cleared between).
+ expect(layer.recording.fills).toHaveLength(2);
+ expect(layer.recording.fills.at(-1)?.x).toBe(19);
+ view.dispose();
+ expect(layer.disposed).toBe(true);
+});
diff --git a/src/cursor-view.ts b/src/cursor-view.ts
new file mode 100644
index 000000000..5ce9d2b6a
--- /dev/null
+++ b/src/cursor-view.ts
@@ -0,0 +1,53 @@
+import type { CursorChangeEvent, CursorView } from './cursor';
+import type { Layer } from './stage';
+
+/*
+ * vexml's built-in CursorView: a thin vertical bar spanning the system at the cursor's position,
+ * drawn on its own content layer (so it scrolls and scales with the engraving). The whole overlay is
+ * tiny, so each change just clears and repaints the bar at the interpolated position. Callers who
+ * want something else implement CursorView themselves; this is what Score.createCursorView returns.
+ */
+
+const DEFAULT_COLOR = '#2563eb';
+const DEFAULT_WIDTH_PX = 2;
+
+export interface BarCursorViewOptions {
+ color?: string;
+ widthPx?: number;
+}
+
+export class BarCursorView implements CursorView {
+ private readonly color: string;
+ private readonly widthPx: number;
+
+ constructor(
+ private readonly layer: Layer,
+ options?: BarCursorViewOptions,
+ ) {
+ this.color = options?.color ?? DEFAULT_COLOR;
+ this.widthPx = options?.widthPx ?? DEFAULT_WIDTH_PX;
+ }
+
+ render(event: CursorChangeEvent): void {
+ const ctx = this.layer.ctx;
+ clear(ctx);
+ const rect = event.position.rect;
+ ctx.save();
+ ctx.fillStyle = this.color;
+ // Straddle the onset x so the bar sits on the note it marks.
+ ctx.fillRect(rect.x - this.widthPx / 2, rect.y, this.widthPx, rect.h);
+ ctx.restore();
+ }
+
+ dispose(): void {
+ this.layer.dispose();
+ }
+}
+
+// Clear the whole bitmap regardless of the dpr transform the layer applied (mirrors Decorations).
+function clear(ctx: CanvasRenderingContext2D): void {
+ ctx.save();
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
+ ctx.restore();
+}
diff --git a/src/cursor.test.ts b/src/cursor.test.ts
new file mode 100644
index 000000000..de62a2e3c
--- /dev/null
+++ b/src/cursor.test.ts
@@ -0,0 +1,259 @@
+import { expect, test } from 'bun:test';
+import {
+ Cursor,
+ type CursorChangeEvent,
+ type CursorHost,
+ type CursorHostEventMap,
+ type CursorView,
+ type Scroller,
+} from './cursor';
+import { EventBus } from './events';
+import { Rect } from './geometry';
+import { buildSequence, type SequenceNote } from './sequence';
+import type { Note } from './targets';
+
+// Identity tokens — only used for identity in active sets / deltas.
+function fakeNote(label: string): Note {
+ return { label } as unknown as Note;
+}
+const SYS = new Rect(0, 0, 1000, 100);
+const A = fakeNote('a');
+const B = fakeNote('b');
+const C = fakeNote('c');
+const D = fakeNote('d');
+
+// Four quarters in one 4/4 measure @120bpm: steps at 0/500/1000/1500 ms, durationMs 2000.
+function fourQuarters() {
+ const notes: SequenceNote[] = [A, B, C, D].map((note, i) => ({
+ note,
+ measureIndex: 0,
+ measureBeat: i,
+ beats: 1,
+ x: 10 + i * 10,
+ tiedFrom: null,
+ }));
+ return buildSequence({
+ measures: [
+ { index: 0, beats: 4, tempoBpm: 120, jumps: [], systemRect: SYS },
+ ],
+ notes,
+ });
+}
+
+class FakeScroller implements Scroller {
+ calls: Rect[] = [];
+ scrollIntoView(rect: Rect): void {
+ this.calls.push(rect);
+ }
+}
+
+class FakeHost implements CursorHost {
+ readonly scroller = new FakeScroller();
+ private readonly bus = new EventBus
();
+ // The visible box, in the same identity coords clientRectOf maps to. Defaults to covering SYS.
+ vp = new Rect(0, 0, 1000, 1000);
+ clientRectOf(rect: Rect): DOMRect {
+ return {
+ x: rect.x,
+ y: rect.y,
+ width: rect.w,
+ height: rect.h,
+ left: rect.x,
+ top: rect.y,
+ right: rect.right,
+ bottom: rect.bottom,
+ } as DOMRect;
+ }
+ viewportRect(): DOMRect {
+ return this.clientRectOf(this.vp);
+ }
+ addEventListener(
+ type: K,
+ listener: (event: CursorHostEventMap[K]) => void,
+ ): void {
+ this.bus.addEventListener(type, listener);
+ }
+ removeEventListener(
+ type: K,
+ listener: (event: CursorHostEventMap[K]) => void,
+ ): void {
+ this.bus.removeEventListener(type, listener);
+ }
+ // Test helper: move the viewport and notify, as a real scroll/resize would.
+ moveViewport(rect: Rect): void {
+ this.vp = rect;
+ this.bus.emit('viewportchange', undefined);
+ }
+}
+
+class FakeView implements CursorView {
+ events: CursorChangeEvent[] = [];
+ disposed = false;
+ render(e: CursorChangeEvent): void {
+ this.events.push(e);
+ }
+ dispose(): void {
+ this.disposed = true;
+ }
+}
+
+test('next/previous step through tickables and clamp at the ends', () => {
+ const cursor = new Cursor(fourQuarters(), new FakeHost());
+ expect(cursor.getIndex()).toBe(0);
+ cursor.next();
+ expect(cursor.getIndex()).toBe(1);
+ expect(cursor.getTimeMs()).toBeCloseTo(500);
+ cursor.previous();
+ expect(cursor.getIndex()).toBe(0);
+ cursor.previous(); // clamp
+ expect(cursor.getIndex()).toBe(0);
+ cursor.next();
+ cursor.next();
+ cursor.next();
+ cursor.next(); // clamp at last (index 3)
+ expect(cursor.getIndex()).toBe(3);
+});
+
+test('seekMs clamps to [0, durationMs] and resolves the step', () => {
+ const cursor = new Cursor(fourQuarters(), new FakeHost());
+ cursor.seekMs(1200);
+ expect(cursor.getIndex()).toBe(2);
+ expect(cursor.getTimeMs()).toBeCloseTo(1200);
+ expect(cursor.getTimeBeats()).toBeCloseTo(2.4);
+ cursor.seekMs(-100);
+ expect(cursor.getTimeMs()).toBe(0);
+ expect(cursor.getIndex()).toBe(0);
+ cursor.seekMs(99999);
+ expect(cursor.getTimeMs()).toBeCloseTo(2000);
+ expect(cursor.getIndex()).toBe(3);
+ expect(cursor.isDone()).toBe(true);
+});
+
+test('seekBeats lands on the matching time', () => {
+ const cursor = new Cursor(fourQuarters(), new FakeHost());
+ cursor.seekBeats(2);
+ expect(cursor.getTimeMs()).toBeCloseTo(1000);
+ expect(cursor.getIndex()).toBe(2);
+});
+
+test('a mid-step seek interpolates the bar position', () => {
+ const cursor = new Cursor(fourQuarters(), new FakeHost());
+ const events: CursorChangeEvent[] = [];
+ cursor.addEventListener('change', (e) => events.push(e));
+ cursor.seekMs(250); // halfway through step 0 (beat 0.5), bar glides x 10 -> 20
+ const last = events.at(-1);
+ expect(last?.index).toBe(0);
+ expect(last?.position.rect.x).toBeCloseTo(15);
+ // Same step: no attack/release, the held note sustains.
+ expect(last?.started).toEqual([]);
+ expect(last?.sustained).toEqual([A]);
+ expect(last?.stopped).toEqual([]);
+});
+
+test('change reports note deltas: a retrigger is stop(prev) + start(next)', () => {
+ const cursor = new Cursor(fourQuarters(), new FakeHost());
+ const events: CursorChangeEvent[] = [];
+ cursor.addEventListener('change', (e) => events.push(e));
+ cursor.next(); // 0 -> 1
+ expect(events).toHaveLength(1);
+ const e = events[0];
+ expect(e?.index).toBe(1);
+ expect(e?.timeMs).toBeCloseTo(500);
+ expect(e?.active).toEqual([B]);
+ expect(e?.started).toEqual([B]);
+ expect(e?.stopped).toEqual([A]);
+ expect(e?.done).toBe(false);
+});
+
+test('removeEventListener stops delivery', () => {
+ const cursor = new Cursor(fourQuarters(), new FakeHost());
+ const seen: number[] = [];
+ const listener = (e: CursorChangeEvent) => seen.push(e.index);
+ cursor.addEventListener('change', listener);
+ cursor.next();
+ cursor.removeEventListener('change', listener);
+ cursor.next();
+ expect(seen).toEqual([1]);
+});
+
+test('attach renders once immediately, then on each change; unsubscribe detaches', () => {
+ const cursor = new Cursor(fourQuarters(), new FakeHost());
+ const view = new FakeView();
+ const detach = cursor.attach(view);
+ expect(view.events).toHaveLength(1); // immediate render
+ cursor.next();
+ expect(view.events).toHaveLength(2);
+ detach();
+ cursor.next();
+ expect(view.events).toHaveLength(2); // detached
+});
+
+test('dispose disposes still-attached views but not detached ones', () => {
+ const cursor = new Cursor(fourQuarters(), new FakeHost());
+ const attached = new FakeView();
+ const detached = new FakeView();
+ cursor.attach(attached);
+ cursor.attach(detached)();
+ cursor.dispose();
+ expect(attached.disposed).toBe(true);
+ expect(detached.disposed).toBe(false);
+});
+
+test('follow scrolls only when the bar is not fully visible', () => {
+ const host = new FakeHost();
+ const cursor = new Cursor(fourQuarters(), host);
+ host.vp = new Rect(0, 0, 1000, 1000); // covers the bar
+ const unfollow = cursor.follow();
+ cursor.next();
+ expect(host.scroller.calls).toHaveLength(0);
+ host.vp = new Rect(500, 0, 1000, 1000); // bar x ~20 now off-screen left
+ cursor.next();
+ expect(host.scroller.calls.length).toBeGreaterThan(0);
+ unfollow();
+ const before = host.scroller.calls.length;
+ cursor.next();
+ expect(host.scroller.calls).toHaveLength(before);
+});
+
+test('isFullyVisible reflects the viewport box', () => {
+ const host = new FakeHost();
+ const cursor = new Cursor(fourQuarters(), host);
+ host.vp = new Rect(0, 0, 1000, 1000);
+ expect(cursor.isFullyVisible()).toBe(true);
+ host.vp = new Rect(500, 0, 1000, 1000);
+ expect(cursor.isFullyVisible()).toBe(false);
+});
+
+test('visibility fires on transitions from both cursor moves and viewport changes', () => {
+ const host = new FakeHost(); // vp covers every bar (x 10..40, width 1)
+ const cursor = new Cursor(fourQuarters(), host);
+ const seen: boolean[] = [];
+ cursor.addEventListener('visibility', (e) => seen.push(e.fullyVisible));
+
+ // Narrow the viewport to x [0, 25]: bars at x 10 and 20 fit, 30 and 40 don't. The cursor sits at
+ // x 10, so this isn't a transition.
+ host.moveViewport(new Rect(0, 0, 25, 1000));
+ expect(seen).toEqual([]);
+
+ cursor.next(); // -> x 20, still inside; no event
+ expect(seen).toEqual([]);
+ cursor.next(); // -> x 30, scrolls off the right edge
+ expect(seen).toEqual([false]);
+ cursor.next(); // -> x 40, still off; no repeat
+ expect(seen).toEqual([false]);
+
+ host.moveViewport(new Rect(0, 0, 1000, 1000)); // widen: the bar is fully visible again
+ expect(seen).toEqual([false, true]);
+});
+
+test('dispose is idempotent and stops movement/emits; onDispose fires once', () => {
+ let disposes = 0;
+ const cursor = new Cursor(fourQuarters(), new FakeHost(), () => disposes++);
+ const seen: number[] = [];
+ cursor.addEventListener('change', (e) => seen.push(e.index));
+ cursor.dispose();
+ cursor.dispose();
+ cursor.next();
+ expect(seen).toEqual([]);
+ expect(disposes).toBe(1);
+});
diff --git a/src/cursor.ts b/src/cursor.ts
new file mode 100644
index 000000000..a73b71458
--- /dev/null
+++ b/src/cursor.ts
@@ -0,0 +1,308 @@
+import { EventBus, type EventListenable } from './events';
+import { Rect } from './geometry';
+import type { Sequence } from './sequence';
+import type { Bounded, Note } from './targets';
+
+/*
+ * A playback cursor: a position in a score's playback timeline that you step (next/previous) or seek
+ * (any ms or beat), reporting where it is and what's sounding so a caller can sync an instrument or
+ * audio UI. It holds an exact time, not just a step — the bar interpolates between onsets so it
+ * follows audio smoothly. Optional visuals and scrolling attach to it (attach/follow) and detach
+ * cleanly; it owns nothing of the score, so disposing it just unhooks. Distinct from mdom's editing
+ * Cursor — this never edits.
+ */
+
+/* What changed entering the current position. `started` are (re)attacks (a re-struck pitch shows in
+ * both `started` and `stopped`); `sustained` are notes held or tied through (do not re-press);
+ * `stopped` are releases (a note tied into this step is excluded — it keeps ringing). `active` is the
+ * full sounding set. `position` is the bar in score space, mappable to the page. */
+export interface CursorChangeEvent {
+ readonly timeMs: number;
+ readonly timeBeats: number;
+ readonly index: number;
+ readonly position: Bounded;
+ readonly active: readonly Note[];
+ readonly started: readonly Note[];
+ readonly sustained: readonly Note[];
+ readonly stopped: readonly Note[];
+ readonly done: boolean;
+}
+
+/* The cursor's bar crossed the viewport edge: `fullyVisible` is true when the whole bar sits inside
+ * the viewport, false when any part is off-screen. Fires on a transition only — driven by the
+ * cursor's own moves and by viewport scroll/resize (see CursorHost.viewportchange), so it also fires
+ * while paused if the user scrolls the bar away. */
+export interface CursorVisibilityEvent {
+ readonly fullyVisible: boolean;
+}
+
+export interface CursorEventMap {
+ change: CursorChangeEvent;
+ visibility: CursorVisibilityEvent;
+}
+
+/* A visual for the cursor, driven by the cursor on every change. vexml ships a vertical-bar default
+ * (Score.createCursorView); a caller can implement this to move a DOM element, draw on a layer, etc.
+ * `render` gets the full change event but a position-only view just reads `e.position`. */
+export interface CursorView {
+ render(e: CursorChangeEvent): void;
+ dispose(): void;
+}
+
+/* Scrolls a score-space rect into the viewport. vexml's Stage provides one (Score.scroller); a caller
+ * may pass their own to follow(). */
+export interface Scroller {
+ scrollIntoView(rect: Rect, opts?: { behavior?: ScrollBehavior }): void;
+}
+
+/* The host fires this whenever the viewport moves or resizes, so the cursor can re-test visibility
+ * even though it hasn't moved. Payload-free — the cursor reads viewportRect()/clientRectOf() itself. */
+export interface CursorHostEventMap {
+ viewportchange: undefined;
+}
+
+/* What a Cursor needs from the rendered score's stage: score<->client mapping (to expose the bar's
+ * page rect and test visibility), the visible scrollport box, the default scroller, and a
+ * viewport-change subscription. Stage's adapter implements it; a unit test injects a fake. */
+export interface CursorHost extends EventListenable {
+ clientRectOf(rect: Rect): DOMRect;
+ viewportRect(): DOMRect;
+ readonly scroller: Scroller;
+}
+
+const EMPTY_RECT = new Rect(0, 0, 0, 0);
+
+/* The cursor's current box, mapped to the page on demand (mirrors a target's Bounded). */
+class CursorPosition implements Bounded {
+ constructor(
+ readonly rect: Rect,
+ private readonly host: CursorHost,
+ ) {}
+ getBoundingClientRect(): DOMRect {
+ return this.host.clientRectOf(this.rect);
+ }
+}
+
+export class Cursor implements EventListenable {
+ private readonly bus = new EventBus();
+ // Unhook fns for attach/follow subscriptions, run on dispose; the views still attached at dispose,
+ // disposed with the cursor (a detached one is the caller's again).
+ private readonly cleanups = new Set<() => void>();
+ private readonly views = new Set();
+ private index = 0;
+ private ms = 0;
+ private disposed = false;
+ // Last reported full-visibility, to fire `visibility` on transitions only. Seeded from the
+ // current state so the first move/scroll doesn't emit a spurious "unchanged" event.
+ private lastVisible: boolean;
+
+ constructor(
+ private readonly sequence: Sequence,
+ private readonly host: CursorHost,
+ // Called from dispose so the Score can forget this cursor (avoids a leak / double dispose).
+ private readonly onDispose?: () => void,
+ ) {
+ this.lastVisible = this.isFullyVisible();
+ const onViewport = () => this.checkVisibility();
+ this.host.addEventListener('viewportchange', onViewport);
+ this.cleanups.add(() =>
+ this.host.removeEventListener('viewportchange', onViewport),
+ );
+ }
+
+ /* Snap to the next tickable in playback order; a no-op on the last one. */
+ next(): void {
+ if (this.disposed || this.index >= this.sequence.length - 1) {
+ return;
+ }
+ const from = this.index;
+ this.index++;
+ this.ms = this.sequence.getStep(this.index)?.startMs ?? this.ms;
+ this.emit(from);
+ }
+
+ /* Snap to the previous tickable; a no-op on the first one. */
+ previous(): void {
+ if (this.disposed || this.index <= 0) {
+ return;
+ }
+ const from = this.index;
+ this.index--;
+ this.ms = this.sequence.getStep(this.index)?.startMs ?? this.ms;
+ this.emit(from);
+ }
+
+ /* Go to any wall-clock time (ms), clamped to [0, durationMs]. The position is kept exactly (the
+ * bar interpolates within its step), so this is how you follow an audio clock. */
+ seekMs(timeMs: number): void {
+ if (this.disposed) {
+ return;
+ }
+ const clamped = Math.min(
+ Math.max(0, timeMs),
+ this.sequence.getDurationMs(),
+ );
+ const from = this.index;
+ this.ms = clamped;
+ this.index = this.sequence.getStepIndexAtMs(clamped) ?? 0;
+ this.emit(from);
+ }
+
+ /* Go to any time expressed in quarter-note beats. */
+ seekBeats(beats: number): void {
+ this.seekMs(this.sequence.beatsToMs(beats));
+ }
+
+ getTimeMs(): number {
+ return this.ms;
+ }
+
+ getTimeBeats(): number {
+ return this.sequence.msToBeats(this.ms);
+ }
+
+ getIndex(): number {
+ return this.index;
+ }
+
+ getActiveNotes(): readonly Note[] {
+ return this.sequence.getStep(this.index)?.active ?? [];
+ }
+
+ isDone(): boolean {
+ return this.ms >= this.sequence.getDurationMs();
+ }
+
+ /* Whether the bar's page box lies entirely within the viewport. True when there's nothing to show. */
+ isFullyVisible(): boolean {
+ const rect = this.sequence.positionAt(this.ms);
+ if (!rect) {
+ return true;
+ }
+ const bar = this.host.clientRectOf(rect);
+ const vp = this.host.viewportRect();
+ return (
+ bar.left >= vp.left &&
+ bar.right <= vp.right &&
+ bar.top >= vp.top &&
+ bar.bottom <= vp.bottom
+ );
+ }
+
+ /* Attach a visual, synced on every change. Renders once immediately. Returns an unsubscribe that
+ * detaches without disposing the view (the caller gets it back); dispose() disposes whatever's
+ * still attached. */
+ attach(view: CursorView): () => void {
+ const listener = (e: CursorChangeEvent) => view.render(e);
+ this.bus.addEventListener('change', listener);
+ this.views.add(view);
+ const detach = () => {
+ this.bus.removeEventListener('change', listener);
+ this.views.delete(view);
+ this.cleanups.delete(detach);
+ };
+ this.cleanups.add(detach);
+ view.render(this.snapshot(null));
+ return detach;
+ }
+
+ /* Auto-scroll: on every change, scroll the bar into view when it isn't fully visible. Uses the
+ * given scroller, or the score's. Scrolls once immediately if needed. Returns an unsubscribe. */
+ follow(scroller?: Scroller): () => void {
+ const target = scroller ?? this.host.scroller;
+ const listener = () => {
+ if (!this.isFullyVisible()) {
+ target.scrollIntoView(this.barRect());
+ }
+ };
+ this.bus.addEventListener('change', listener);
+ const unfollow = () => {
+ this.bus.removeEventListener('change', listener);
+ this.cleanups.delete(unfollow);
+ };
+ this.cleanups.add(unfollow);
+ listener();
+ return unfollow;
+ }
+
+ /* Scroll the bar into view once, via the score's scroller. */
+ scrollIntoView(opts?: { behavior?: ScrollBehavior }): void {
+ this.host.scroller.scrollIntoView(this.barRect(), opts);
+ }
+
+ addEventListener(
+ type: K,
+ listener: (event: CursorEventMap[K]) => void,
+ ): void {
+ this.bus.addEventListener(type, listener);
+ }
+
+ removeEventListener(
+ type: K,
+ listener: (event: CursorEventMap[K]) => void,
+ ): void {
+ this.bus.removeEventListener(type, listener);
+ }
+
+ dispose(): void {
+ if (this.disposed) {
+ return;
+ }
+ this.disposed = true;
+ // Snapshot the attached views first: the cleanups below detach them (removing them from the
+ // set), so capture who to dispose before running them.
+ const toDispose = [...this.views];
+ for (const cleanup of [...this.cleanups]) {
+ cleanup();
+ }
+ this.cleanups.clear();
+ for (const view of toDispose) {
+ view.dispose();
+ }
+ this.views.clear();
+ this.onDispose?.();
+ }
+
+ private barRect(): Rect {
+ return this.sequence.positionAt(this.ms) ?? EMPTY_RECT;
+ }
+
+ // Build the change payload, classifying note deltas against `from` (the step the cursor came from,
+ // or null for an initial full-state snapshot).
+ private snapshot(from: number | null): CursorChangeEvent {
+ const { started, sustained, stopped } = this.sequence.classify(
+ from,
+ this.index,
+ );
+ return {
+ timeMs: this.ms,
+ timeBeats: this.sequence.msToBeats(this.ms),
+ index: this.index,
+ position: new CursorPosition(this.barRect(), this.host),
+ active: this.getActiveNotes(),
+ started,
+ sustained,
+ stopped,
+ done: this.isDone(),
+ };
+ }
+
+ private emit(from: number): void {
+ this.bus.emit('change', this.snapshot(from));
+ this.checkVisibility();
+ }
+
+ // Fire `visibility` if the bar crossed the viewport edge since the last check. Called after every
+ // move and on every viewport change, so it catches both the cursor moving off-screen and the user
+ // scrolling it away.
+ private checkVisibility(): void {
+ if (this.disposed) {
+ return;
+ }
+ const fullyVisible = this.isFullyVisible();
+ if (fullyVisible !== this.lastVisible) {
+ this.lastVisible = fullyVisible;
+ this.bus.emit('visibility', { fullyVisible });
+ }
+ }
+}
diff --git a/src/hit.test.ts b/src/hit.test.ts
index cfdcfb0a3..4415b9134 100644
--- a/src/hit.test.ts
+++ b/src/hit.test.ts
@@ -92,7 +92,8 @@ function build() {
notes,
measures: [{ rect: new Rect(0, 0, 200, 100), index: 0, number: '1' }],
};
- return buildTargets(geometry, new FakeViewport(), new FakeDecorator());
+ return buildTargets(geometry, new FakeViewport(), new FakeDecorator())
+ .hitTester;
}
test('a notehead beats the measure background under it', () => {
diff --git a/src/hit.ts b/src/hit.ts
index f0e7f7955..4dfbba936 100644
--- a/src/hit.ts
+++ b/src/hit.ts
@@ -50,6 +50,13 @@ export interface HitTester {
hitTest(point: { x: number; y: number }): PointerTarget | null;
}
+/* What buildTargets returns: the spatial index, plus the mdom-note -> Note map so the playback
+ * timeline can reference the very same Note identities the index hit-tests to. */
+export interface TargetIndex {
+ hitTester: HitTester;
+ notes: ReadonlyMap;
+}
+
export class QuadTreeHitTester implements HitTester {
constructor(private readonly tree: QuadTree) {}
@@ -94,10 +101,10 @@ export function buildTargets(
geometry: RawGeometry,
viewport: Viewport,
decorator: Decorator,
-): HitTester {
+): TargetIndex {
const measures = new Map();
for (const m of geometry.measures) {
- measures.set(m.index, new Measure(m.rect, viewport, m.number));
+ measures.set(m.index, new Measure(m.rect, viewport, m.number, m.index));
}
const noteByMnote = new Map();
@@ -151,5 +158,5 @@ export function buildTargets(
for (const measure of measures.values()) {
tree.insert(measure);
}
- return new QuadTreeHitTester(tree);
+ return { hitTester: new QuadTreeHitTester(tree), notes: noteByMnote };
}
diff --git a/src/index.ts b/src/index.ts
index 139bd7a5c..f3a27ccd6 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -6,6 +6,14 @@ export {
type ChordSpec,
} from './chord-diagram';
export type { Config } from './config';
+export {
+ Cursor,
+ type CursorChangeEvent,
+ type CursorEventMap,
+ type CursorView,
+ type Scroller,
+} from './cursor';
+export type { BarCursorViewOptions } from './cursor-view';
export type {
EventListenable,
HoverEvent,
diff --git a/src/layout.ts b/src/layout.ts
index 4dd5b84cf..c20c2db17 100644
--- a/src/layout.ts
+++ b/src/layout.ts
@@ -42,7 +42,7 @@ export type Layout =
/** Reference layout width in px (default: DEFAULT_WIDTH). The score is laid out
* to this width once; the result is then scaled to whatever container it's placed
* in, so resizing the container never re-flows or re-spaces it. */
- width?: number;
+ referenceWidth?: number;
}
| {
/** Lay every measure on one system (horizontal scroll); width is computed
@@ -187,7 +187,8 @@ export function computeLayout(parts: Part[], config: Config): ScoreLayout {
// Standard without an explicit width, and panoramic's starting floor, both default
// to DEFAULT_WIDTH (panoramic then grows the page to fit its single system).
const width =
- (layout.type === 'standard' ? layout.width : undefined) ?? DEFAULT_WIDTH;
+ (layout.type === 'standard' ? layout.referenceWidth : undefined) ??
+ DEFAULT_WIDTH;
const noteSpacing = config.noteSpacing;
const softmaxFactor = config.softmaxFactor;
diff --git a/src/render.ts b/src/render.ts
index 391e69696..f92fd69db 100644
--- a/src/render.ts
+++ b/src/render.ts
@@ -8,6 +8,8 @@ import { Rect } from './geometry';
import { buildTargets, type RawGeometry } from './hit';
import { computeLayout } from './layout';
import { Score } from './score';
+import { buildSequence } from './sequence';
+import { buildSequenceInput } from './sequence-input';
import { Stage } from './stage';
const EMPTY_GEOMETRY: RawGeometry = {
@@ -33,7 +35,12 @@ export async function render(
throw new RangeError('render: minLastSystemFill must be between 0 and 1');
}
- const stage = new Stage(container);
+ const stage = new Stage(container, {
+ height: resolved.height,
+ maxHeight: resolved.maxHeight,
+ width: resolved.width,
+ maxWidth: resolved.maxWidth,
+ });
// Fonts and CSS vars go on the container; the managed canvas inherits them, so drawScore's
// getComputedStyle(canvas) read of --vexml-font-text still resolves.
const { notation, text } = loadFonts(container, resolved.fonts);
@@ -69,6 +76,12 @@ export async function render(
// decorations are the Decorator their color/halo toggles delegate to (drawing on a content
// layer the stage hands them). Both feed buildTargets, which links the targets and indexes them.
const decorations = new Decorations(stage);
- const index = buildTargets(geometry, stage, decorations);
- return new Score(stage, index, decorations);
+ const targets = buildTargets(geometry, stage, decorations);
+ // The playback timeline: the parsed parts give onsets/meter/tempo/repeats/ties, the geometry gives
+ // note x and system boxes, and the target map ties active notes to the same identities hit-testing
+ // returns. Built for every score (empty when there are no parts).
+ const sequence = buildSequence(
+ buildSequenceInput(parts, geometry, targets.notes),
+ );
+ return new Score(stage, targets.hitTester, decorations, sequence);
}
diff --git a/src/score.test.ts b/src/score.test.ts
index 46cb433e6..a97ff3f98 100644
--- a/src/score.test.ts
+++ b/src/score.test.ts
@@ -1,10 +1,20 @@
import { expect, test } from 'bun:test';
+import type { Scroller } from './cursor';
import { Decorations } from './decorations';
import { Rect } from './geometry';
import type { HitTester } from './hit';
import { Score } from './score';
+import { buildSequence } from './sequence';
import type { Host, Layer, LayerKind } from './stage';
-import { Measure, type PointerTarget, type Viewport } from './targets';
+import {
+ Measure,
+ type Note,
+ type PointerTarget,
+ type Viewport,
+} from './targets';
+
+// An empty timeline — these tests exercise events/layers/hover, not playback.
+const EMPTY_SEQUENCE = buildSequence({ measures: [], notes: [] });
// Separate fake classes fulfilling the injected seams (preferred over mocks).
@@ -71,6 +81,13 @@ class FakeHost implements Host {
this.created.push(layer);
return layer;
}
+ clientRectOf(rect: Rect): DOMRect {
+ return { x: rect.x, y: rect.y, width: rect.w, height: rect.h } as DOMRect;
+ }
+ viewportRect(): DOMRect {
+ return { x: 0, y: 0, width: 0, height: 0 } as DOMRect;
+ }
+ readonly scroller: Scroller = { scrollIntoView() {} };
relayoutLayers(): void {
this.relayoutLayersCalls++;
}
@@ -109,12 +126,12 @@ function fixture(target: PointerTarget | null) {
const host = new FakeHost();
const index = new FakeHitTester(target);
const decorations = new Decorations(host);
- const score = new Score(host, index, decorations);
+ const score = new Score(host, index, decorations, EMPTY_SEQUENCE);
return { host, index, decorations, score };
}
test('a pointer event hit-tests the point and emits target, score-space point, and native', () => {
- const target = new Measure(new Rect(0, 0, 10, 10), viewport, '1');
+ const target = new Measure(new Rect(0, 0, 10, 10), viewport, '1', 0);
const { host, index, score } = fixture(target);
const seen: Array<{ type: string; x: number; y: number; native: Event }> = [];
score.addEventListener('pointermove', (e) =>
@@ -178,13 +195,13 @@ test('scroll events carry the offset and the score.scroll getter reflects the ho
});
test('hover fires only on target change and recomputes on scroll; unsubscribe detaches scroll', () => {
- const target = new Measure(new Rect(0, 0, 10, 10), viewport, '1');
+ const target = new Measure(new Rect(0, 0, 10, 10), viewport, '1', 0);
const host = new FakeHost();
// A mutable hit result lets the test flip what's "under the pointer" to simulate scrolling the
// target out from under a stationary pointer (FakeHost.toScoreSpace is identity).
let hit: PointerTarget | null = target;
const index: HitTester = { hitTest: () => hit };
- const score = new Score(host, index, new Decorations(host));
+ const score = new Score(host, index, new Decorations(host), EMPTY_SEQUENCE);
const seen: Array = [];
const listener = (e: { target: PointerTarget | null }) => seen.push(e.target);
@@ -237,8 +254,74 @@ test('addLayer forwards zIndex to the host and rejects non-integers', () => {
expect(() => score.addLayer('content', Number.NaN)).toThrow();
});
+test('getTimeAt interpolates the time under a point and reports the closest step', () => {
+ const host = new FakeHost();
+ // A measure at index 0 with two quarter notes (x 10 @ beat 0, x 20 @ beat 1) at 120bpm.
+ const sequence = buildSequence({
+ measures: [
+ {
+ index: 0,
+ beats: 2,
+ tempoBpm: 120,
+ jumps: [],
+ systemRect: new Rect(0, 0, 1000, 100),
+ },
+ ],
+ notes: [
+ {
+ note: {} as Note,
+ measureIndex: 0,
+ measureBeat: 0,
+ beats: 1,
+ x: 10,
+ tiedFrom: null,
+ },
+ {
+ note: {} as Note,
+ measureIndex: 0,
+ measureBeat: 1,
+ beats: 1,
+ x: 20,
+ tiedFrom: null,
+ },
+ ],
+ });
+ const target = new Measure(new Rect(0, 0, 1000, 100), viewport, '1', 0);
+ const score = new Score(
+ host,
+ new FakeHitTester(target),
+ new Decorations(host),
+ sequence,
+ );
+
+ // x 15 is halfway through step 0's glide (10 -> 20), so beat 0.5 = 250ms; closest step is 0.
+ expect(score.getTimeAt({ x: 15, y: 50 })).toEqual({
+ ms: 250,
+ beat: 0.5,
+ stepMs: 0,
+ stepBeat: 0,
+ stepIndex: 0,
+ });
+ // Far right lands in step 1 (snapped to beat 1 / 500ms) and clamps to the measure end.
+ expect(score.getTimeAt({ x: 9999, y: 50 })).toEqual({
+ ms: 1000,
+ beat: 2,
+ stepMs: 500,
+ stepBeat: 1,
+ stepIndex: 1,
+ });
+
+ const offScore = new Score(
+ host,
+ new FakeHitTester(null),
+ new Decorations(host),
+ sequence,
+ );
+ expect(offScore.getTimeAt({ x: 15, y: 50 })).toBeNull();
+});
+
test('dispose detaches every listener and tears down decorations and host', () => {
- const target = new Measure(new Rect(0, 0, 10, 10), viewport, '1');
+ const target = new Measure(new Rect(0, 0, 10, 10), viewport, '1', 0);
const { host, index, decorations, score } = fixture(target);
score.addEventListener('pointermove', () => {});
score.addEventListener('resize', () => {});
diff --git a/src/score.ts b/src/score.ts
index c938a3f66..61ac42f5c 100644
--- a/src/score.ts
+++ b/src/score.ts
@@ -1,9 +1,69 @@
+import {
+ Cursor,
+ type CursorHost,
+ type CursorHostEventMap,
+ type CursorView,
+ type Scroller,
+} from './cursor';
+import { BarCursorView, type BarCursorViewOptions } from './cursor-view';
import type { Decorations } from './decorations';
import { EventBus, type EventListenable, type ScoreEventMap } from './events';
+import type { Rect } from './geometry';
import type { HitTester } from './hit';
+import type { Sequence } from './sequence';
import type { Host, Layer, LayerKind } from './stage';
import type { PointerTarget } from './targets';
+/* Adapts the Stage host into a Cursor's CursorHost: passes through the rect/scroller methods and
+ * turns the host's window-scroll + resize observers into a single `viewportchange` event. One per
+ * cursor; the observers are bound only while the cursor is listening and torn down when it disposes
+ * (its removeEventListener drops the last listener). */
+class CursorHostAdapter implements CursorHost {
+ private readonly bus = new EventBus();
+ private unbind: (() => void) | null = null;
+
+ constructor(private readonly host: Host) {}
+
+ clientRectOf(rect: Rect): DOMRect {
+ return this.host.clientRectOf(rect);
+ }
+
+ viewportRect(): DOMRect {
+ return this.host.viewportRect();
+ }
+
+ get scroller(): Scroller {
+ return this.host.scroller;
+ }
+
+ addEventListener(
+ type: K,
+ listener: (event: CursorHostEventMap[K]) => void,
+ ): void {
+ this.bus.addEventListener(type, listener);
+ if (!this.unbind) {
+ const fire = () => this.bus.emit('viewportchange', undefined);
+ const offScroll = this.host.observeScroll(fire);
+ const offResize = this.host.observeResize(fire);
+ this.unbind = () => {
+ offScroll();
+ offResize();
+ };
+ }
+ }
+
+ removeEventListener(
+ type: K,
+ listener: (event: CursorHostEventMap[K]) => void,
+ ): void {
+ this.bus.removeEventListener(type, listener);
+ if (this.bus.count('viewportchange') === 0) {
+ this.unbind?.();
+ this.unbind = null;
+ }
+ }
+}
+
/*
* A rendered score: the handle render() returns. Owns the DOM vexml built (the Stage/Host) and
* lets callers subscribe to pointer/scroll/resize events through the EventListenable interface,
@@ -31,11 +91,14 @@ export class Score implements EventListenable {
private hovered: PointerTarget | null = null;
private lastClient: { x: number; y: number } | null = null;
private unobserveScroll: (() => void) | null = null;
+ // Live playback cursors, disposed with the score; each removes itself on its own dispose.
+ private readonly cursors = new Set();
constructor(
private readonly host: Host,
private readonly index: HitTester,
private readonly decorations: Decorations,
+ private readonly sequence: Sequence,
) {
// On resize: re-sync the layers (viewport layers are refit and cleared; content layers just
// re-track the base canvas) before telling the caller, so a viewport-layer redraw in the
@@ -72,6 +135,100 @@ export class Score implements EventListenable {
layer.dispose();
}
+ /* Add a playback cursor over this score's timeline. Headless by default — attach a view
+ * (createCursorView) and/or follow the scroller for visuals. Disposed when the score is. */
+ addCursor(): Cursor {
+ const cursor = new Cursor(
+ this.sequence,
+ new CursorHostAdapter(this.host),
+ () => this.cursors.delete(cursor),
+ );
+ this.cursors.add(cursor);
+ return cursor;
+ }
+
+ /* vexml's default cursor visual — a vertical bar on its own content layer. Hand it to a cursor
+ * with cursor.attach(view). Style it with `color`/`widthPx`, or implement CursorView for your own. */
+ createCursorView(options?: BarCursorViewOptions): CursorView {
+ return new BarCursorView(this.host.createLayer('content'), options);
+ }
+
+ /* The score's scroller, to give a cursor (cursor.follow) or scroll a rect into view directly. */
+ get scroller(): Scroller {
+ return this.host.scroller;
+ }
+
+ /* Total playback time of the score, repeats and voltas expanded. */
+ getDurationMs(): number {
+ return this.sequence.getDurationMs();
+ }
+
+ /* Total playback length in quarter-note beats, repeats and voltas expanded. */
+ getDurationBeats(): number {
+ return this.sequence.getDurationBeats();
+ }
+
+ /* The total number of measures in document order (not repeat-expanded). */
+ getMeasureCount(): number {
+ return this.sequence.getMeasureCount();
+ }
+
+ /* The document measure index playing at `ms` (before the first onset clamps to 0). */
+ getMeasureIndexAtMs(ms: number): number {
+ return this.sequence.getMeasureIndexAtMs(ms);
+ }
+
+ /* The playback time at a score-space point (jump-aware: a repeated spot maps to its first pass),
+ * or null on empty space. Hit-tests the point, then interpolates the exact time/beat under it —
+ * a note/fret within its onset step, a measure across its full width (see Sequence.resolveX). The
+ * `step*` fields are the closest onset (the step the point lands in), for snap-to-note callers. */
+ getTimeAt(point: { x: number; y: number }): {
+ ms: number;
+ beat: number;
+ stepMs: number;
+ stepBeat: number;
+ stepIndex: number;
+ } | null {
+ const range = this.stepRangeAt(point);
+ if (!range) {
+ return null;
+ }
+ const resolved = this.sequence.resolveX(point.x, range.start, range.end);
+ const step = resolved && this.sequence.getStep(resolved.stepIndex);
+ if (!resolved || !step) {
+ return null;
+ }
+ return {
+ ms: this.sequence.beatsToMs(resolved.beat),
+ beat: resolved.beat,
+ stepMs: step.startMs,
+ stepBeat: step.startBeat,
+ stepIndex: resolved.stepIndex,
+ };
+ }
+
+ // The step range a target spans: a note/fret is its single onset step; a measure is its first
+ // occurrence's contiguous run, so a point maps across the whole bar.
+ private stepRangeAt(point: {
+ x: number;
+ y: number;
+ }): { start: number; end: number } | null {
+ const target = this.index.hitTest(point);
+ if (!target) {
+ return null;
+ }
+ switch (target.type) {
+ case 'note':
+ case 'tab-position': {
+ const note = target.type === 'note' ? target : target.getNote();
+ const index = this.sequence.getFirstStepOfNote(note);
+ return index === null ? null : { start: index, end: index };
+ }
+ case 'measure':
+ return this.sequence.getStepRangeOfMeasure(target.getIndex());
+ }
+ }
+
addEventListener(
type: K,
listener: (event: ScoreEventMap[K]) => void,
@@ -94,6 +251,10 @@ export class Score implements EventListenable {
}
dispose(): void {
+ for (const cursor of [...this.cursors]) {
+ cursor.dispose();
+ }
+ this.cursors.clear();
for (const handlers of this.bound.values()) {
for (const [domType, handler] of handlers) {
this.host.events.removeEventListener(domType, handler);
diff --git a/src/sequence-input.ts b/src/sequence-input.ts
new file mode 100644
index 000000000..315775e90
--- /dev/null
+++ b/src/sequence-input.ts
@@ -0,0 +1,169 @@
+import type { Note as MNote, Part } from '@stringsync/mdom';
+import { Rect } from './geometry';
+import type { RawGeometry } from './hit';
+import { meterBeats, tempoOf } from './notes';
+import type {
+ Jump,
+ MeasureInfo,
+ SequenceInput,
+ SequenceNote,
+} from './sequence';
+import type { Note } from './targets';
+
+/*
+ * Builds the `SequenceInput` the playback timeline is assembled from, bridging the parsed document
+ * (onsets, meter, tempo, repeats, ties) and the engraved geometry (note x, system boxes) into the
+ * pure shape `buildSequence` consumes. Kept apart from both `render.ts` (orchestration) and
+ * `sequence.ts` (pure timeline) so each stays focused.
+ */
+
+// MusicXML (a note type) -> quarter notes, so a metronome mark normalizes to quarter BPM.
+const QUARTERS_PER_UNIT: Record = {
+ whole: 4,
+ half: 2,
+ quarter: 1,
+ eighth: 0.5,
+ '16th': 0.25,
+ '32nd': 0.125,
+ '64th': 0.0625,
+ '128th': 0.03125,
+};
+
+function quarterBpm(measure: Part['measures'][number]): number | null {
+ const tempo = tempoOf(measure);
+ if (!tempo) {
+ return null;
+ }
+ return tempo.bpm * (QUARTERS_PER_UNIT[tempo.duration] ?? 1);
+}
+
+/* How many passes a volta ending covers, from its `` ("1", "1,2", "1-3"). */
+function endingPasses(numberAttr: string | null): number {
+ if (!numberAttr) {
+ return 1;
+ }
+ let total = 0;
+ for (const part of numberAttr.split(',')) {
+ const range = part.trim().match(/^(\d+)\s*-\s*(\d+)$/);
+ if (range) {
+ total += Math.max(1, Number(range[2]) - Number(range[1]) + 1);
+ } else if (part.trim()) {
+ total += 1;
+ }
+ }
+ return Math.max(1, total);
+}
+
+/* The repeat/volta jumps on a measure, read from its barlines. A volta start (``)
+ * supersedes a co-located backward repeat (the iterator handles the back-jump). */
+function jumpsOf(measure: Part['measures'][number]): Jump[] {
+ let start = false;
+ let endingPassCount = 0;
+ let endTimes = -1;
+ for (const barline of measure.barlines) {
+ const ending = barline.child('ending');
+ if (ending && ending.getAttribute('type') === 'start') {
+ endingPassCount = endingPasses(ending.getAttribute('number'));
+ }
+ if (barline.repeat === 'forward') {
+ start = true;
+ } else if (barline.repeat === 'backward') {
+ const times = Number(barline.child('repeat')?.getAttribute('times') ?? 2);
+ endTimes = Math.max(0, times - 1);
+ }
+ }
+ const jumps: Jump[] = [];
+ if (start) {
+ jumps.push({ type: 'repeatstart' });
+ }
+ if (endingPassCount > 0) {
+ jumps.push({ type: 'repeatending', times: endingPassCount });
+ } else if (endTimes >= 0) {
+ jumps.push({ type: 'repeatend', times: endTimes });
+ }
+ return jumps;
+}
+
+/* A measure's played length in quarter-note beats: the latest note end across all parts (so pickups
+ * and ragged voices are honored), falling back to the meter. */
+function measureBeats(parts: Part[], index: number): number {
+ let maxEnd = 0;
+ for (const part of parts) {
+ const measure = part.measures[index];
+ if (!measure) {
+ continue;
+ }
+ for (const note of measure.notes) {
+ const onset = note.measureBeat;
+ const beats = note.beats;
+ if (onset !== null && beats !== null) {
+ maxEnd = Math.max(maxEnd, onset + beats);
+ }
+ }
+ }
+ if (maxEnd > 0) {
+ return maxEnd;
+ }
+ return meterBeats(parts[0]?.measures[index]?.getTime() ?? null);
+}
+
+/* The note a tied note continues from (the start side of a tie ending here), or null. */
+function tiedFromOf(
+ mnote: MNote,
+ notesByMnote: ReadonlyMap,
+): Note | null {
+ for (const tie of mnote.ties) {
+ if (tie.tieType === 'stop') {
+ const from = tie.partner?.note;
+ const target = from ? notesByMnote.get(from) : undefined;
+ if (target) {
+ return target;
+ }
+ }
+ }
+ return null;
+}
+
+export function buildSequenceInput(
+ parts: Part[],
+ geometry: RawGeometry,
+ notesByMnote: ReadonlyMap,
+): SequenceInput {
+ const systemRectByIndex = new Map();
+ for (const measure of geometry.measures) {
+ systemRectByIndex.set(measure.index, measure.rect);
+ }
+
+ const measureCount = parts[0]?.measures.length ?? 0;
+ const measures: MeasureInfo[] = [];
+ for (let i = 0; i < measureCount; i++) {
+ const m0 = parts[0]?.measures[i];
+ measures.push({
+ index: i,
+ beats: measureBeats(parts, i),
+ tempoBpm: m0 ? quarterBpm(m0) : null,
+ jumps: m0 ? jumpsOf(m0) : [],
+ systemRect: systemRectByIndex.get(i) ?? new Rect(0, 0, 0, 0),
+ });
+ }
+
+ const notes: SequenceNote[] = [];
+ for (const rn of geometry.notes) {
+ const note = notesByMnote.get(rn.mnote);
+ const measureBeat = rn.mnote.measureBeat;
+ const beats = rn.mnote.beats;
+ if (!note || measureBeat === null || beats === null) {
+ continue;
+ }
+ notes.push({
+ note,
+ measureIndex: rn.measureIndex,
+ measureBeat,
+ beats,
+ x: rn.rect.x,
+ tiedFrom: tiedFromOf(rn.mnote, notesByMnote),
+ });
+ }
+
+ return { measures, notes };
+}
diff --git a/src/sequence.test.ts b/src/sequence.test.ts
new file mode 100644
index 000000000..9a331dbba
--- /dev/null
+++ b/src/sequence.test.ts
@@ -0,0 +1,453 @@
+import { expect, test } from 'bun:test';
+import { Rect } from './geometry';
+import {
+ beatsToMs,
+ buildSequence,
+ classifyTransition,
+ type Jump,
+ MeasureSequenceIterator,
+ msToBeats,
+ type SequenceNote,
+} from './sequence';
+import type { Note } from './targets';
+
+// ── MeasureSequenceIterator (ported from legacy vexml) ──
+
+function order(measures: Array<{ index: number; jumps: Jump[] }>): number[] {
+ return [...new MeasureSequenceIterator(measures)];
+}
+
+test('iterator: empty when there are no measures', () => {
+ expect(order([])).toEqual([]);
+});
+
+test('iterator: same as input when there are no repeats', () => {
+ expect(
+ order([
+ { index: 0, jumps: [] },
+ { index: 1, jumps: [] },
+ { index: 2, jumps: [] },
+ ]),
+ ).toEqual([0, 1, 2]);
+});
+
+test('iterator: repeats a single measure', () => {
+ expect(
+ order([
+ {
+ index: 0,
+ jumps: [{ type: 'repeatstart' }, { type: 'repeatend', times: 1 }],
+ },
+ ]),
+ ).toEqual([0, 0]);
+});
+
+test('iterator: repeats a single measure multiple times', () => {
+ expect(
+ order([
+ {
+ index: 0,
+ jumps: [{ type: 'repeatstart' }, { type: 'repeatend', times: 3 }],
+ },
+ ]),
+ ).toEqual([0, 0, 0, 0]);
+});
+
+test('iterator: repeats a single measure when the start is not at the beginning', () => {
+ expect(
+ order([
+ { index: 0, jumps: [] },
+ { index: 1, jumps: [{ type: 'repeatstart' }] },
+ { index: 2, jumps: [{ type: 'repeatend', times: 1 }] },
+ ]),
+ ).toEqual([0, 1, 2, 1, 2]);
+});
+
+test('iterator: repeats multiple measures', () => {
+ expect(
+ order([
+ { index: 0, jumps: [{ type: 'repeatstart' }] },
+ { index: 1, jumps: [{ type: 'repeatend', times: 1 }] },
+ ]),
+ ).toEqual([0, 1, 0, 1]);
+});
+
+test('iterator: repeats multiple measures multiple times', () => {
+ expect(
+ order([
+ { index: 0, jumps: [{ type: 'repeatstart' }] },
+ { index: 1, jumps: [{ type: 'repeatend', times: 2 }] },
+ ]),
+ ).toEqual([0, 1, 0, 1, 0, 1]);
+});
+
+test('iterator: repeats endings', () => {
+ expect(
+ order([
+ { index: 0, jumps: [{ type: 'repeatstart' }] },
+ { index: 1, jumps: [{ type: 'repeatending', times: 1 }] },
+ { index: 2, jumps: [] },
+ ]),
+ ).toEqual([0, 1, 0, 2]);
+});
+
+test('iterator: repeats multiple endings', () => {
+ expect(
+ order([
+ { index: 0, jumps: [{ type: 'repeatstart' }] },
+ { index: 1, jumps: [{ type: 'repeatending', times: 2 }] },
+ { index: 2, jumps: [] },
+ ]),
+ ).toEqual([0, 1, 0, 1, 0, 2]);
+});
+
+test('iterator: handles implicit start repeats', () => {
+ expect(
+ order([
+ { index: 0, jumps: [] },
+ { index: 1, jumps: [{ type: 'repeatend', times: 1 }] },
+ ]),
+ ).toEqual([0, 1, 0, 1]);
+});
+
+test('iterator: handles multiple implicit start repeats', () => {
+ expect(
+ order([
+ { index: 0, jumps: [] },
+ { index: 1, jumps: [{ type: 'repeatend', times: 1 }] },
+ { index: 2, jumps: [{ type: 'repeatend', times: 1 }] },
+ ]),
+ ).toEqual([0, 1, 0, 1, 2, 0, 1, 0, 1, 2]);
+});
+
+test('iterator: handles a repeat ending with an implicit start', () => {
+ expect(
+ order([
+ { index: 0, jumps: [] },
+ { index: 1, jumps: [{ type: 'repeatending', times: 1 }] },
+ { index: 2, jumps: [] },
+ ]),
+ ).toEqual([0, 1, 0, 2]);
+});
+
+test('iterator: continues past a repeat block', () => {
+ expect(
+ order([
+ { index: 0, jumps: [{ type: 'repeatstart' }] },
+ { index: 1, jumps: [{ type: 'repeatend', times: 1 }] },
+ { index: 2, jumps: [] },
+ { index: 3, jumps: [] },
+ ]),
+ ).toEqual([0, 1, 0, 1, 2, 3]);
+});
+
+test('iterator: handles a standalone repeat start with no matching end', () => {
+ expect(
+ order([
+ { index: 0, jumps: [] },
+ { index: 1, jumps: [{ type: 'repeatstart' }] },
+ { index: 2, jumps: [] },
+ ]),
+ ).toEqual([0, 1, 2]);
+});
+
+test('iterator: handles two non-nested repeats in sequence', () => {
+ expect(
+ order([
+ { index: 0, jumps: [{ type: 'repeatstart' }] },
+ { index: 1, jumps: [{ type: 'repeatend', times: 1 }] },
+ { index: 2, jumps: [{ type: 'repeatstart' }] },
+ { index: 3, jumps: [{ type: 'repeatend', times: 1 }] },
+ ]),
+ ).toEqual([0, 1, 0, 1, 2, 3, 2, 3]);
+});
+
+test('iterator: replays an inner repeat during each pass of an outer repeat', () => {
+ expect(
+ order([
+ { index: 0, jumps: [{ type: 'repeatstart' }] },
+ { index: 1, jumps: [{ type: 'repeatstart' }] },
+ { index: 2, jumps: [{ type: 'repeatend', times: 1 }] },
+ { index: 3, jumps: [{ type: 'repeatend', times: 1 }] },
+ ]),
+ ).toEqual([0, 1, 2, 1, 2, 3, 0, 1, 2, 1, 2, 3]);
+});
+
+test('iterator: plays the 1st ending N times before advancing to the 2nd ending', () => {
+ expect(
+ order([
+ { index: 0, jumps: [{ type: 'repeatstart' }] },
+ { index: 1, jumps: [{ type: 'repeatending', times: 2 }] },
+ { index: 2, jumps: [{ type: 'repeatending', times: 1 }] },
+ { index: 3, jumps: [] },
+ ]),
+ ).toEqual([0, 1, 0, 1, 0, 2, 3]);
+});
+
+test('iterator: plays three endings in order, each once', () => {
+ expect(
+ order([
+ { index: 0, jumps: [{ type: 'repeatstart' }] },
+ { index: 1, jumps: [{ type: 'repeatending', times: 1 }] },
+ { index: 2, jumps: [{ type: 'repeatending', times: 1 }] },
+ { index: 3, jumps: [{ type: 'repeatending', times: 1 }] },
+ { index: 4, jumps: [] },
+ ]),
+ ).toEqual([0, 1, 0, 2, 0, 3, 4]);
+});
+
+test('iterator: treats a repeatend with times: 0 as a no-op', () => {
+ expect(
+ order([
+ { index: 0, jumps: [{ type: 'repeatstart' }] },
+ { index: 1, jumps: [{ type: 'repeatend', times: 0 }] },
+ ]),
+ ).toEqual([0, 1]);
+});
+
+// ── beats <-> ms ──
+
+test('beatsToMs/msToBeats: default 120 bpm when no segments', () => {
+ expect(beatsToMs(4, [])).toBeCloseTo(2000);
+ expect(msToBeats(2000, [])).toBeCloseTo(4);
+});
+
+test('beatsToMs/msToBeats: honors a mid-piece tempo change and round-trips', () => {
+ const segments = [
+ { startBeat: 0, endBeat: 4, bpm: 120 }, // 500 ms / beat
+ { startBeat: 4, endBeat: 8, bpm: 60 }, // 1000 ms / beat
+ ];
+ expect(beatsToMs(4, segments)).toBeCloseTo(2000);
+ expect(beatsToMs(6, segments)).toBeCloseTo(4000);
+ expect(beatsToMs(8, segments)).toBeCloseTo(6000);
+ expect(msToBeats(4000, segments)).toBeCloseTo(6);
+ expect(msToBeats(beatsToMs(6.5, segments), segments)).toBeCloseTo(6.5);
+});
+
+// ── classifyTransition (generic, identity-only) ──
+
+test('classify: disjoint sets are all started/stopped', () => {
+ const r = classifyTransition(['a', 'b'], ['c', 'd'], new Map());
+ expect(r.started).toEqual(['c', 'd']);
+ expect(r.sustained).toEqual([]);
+ expect(r.stopped).toEqual(['a', 'b']);
+});
+
+test('classify: a held note (same identity) is sustained', () => {
+ const r = classifyTransition(['a', 'b'], ['b', 'c'], new Map());
+ expect(r.started).toEqual(['c']);
+ expect(r.sustained).toEqual(['b']);
+ expect(r.stopped).toEqual(['a']);
+});
+
+test('classify: a tie sustains and the tied-out note is not a release', () => {
+ const r = classifyTransition(['a'], ['b'], new Map([['b', 'a']]));
+ expect(r.started).toEqual([]);
+ expect(r.sustained).toEqual(['b']);
+ expect(r.stopped).toEqual([]);
+});
+
+test('classify: same pitch without a tie is a retrigger (stop + start)', () => {
+ const r = classifyTransition(['a'], ['b'], new Map());
+ expect(r.started).toEqual(['b']);
+ expect(r.stopped).toEqual(['a']);
+});
+
+test('classify: from nothing, everything is started', () => {
+ const r = classifyTransition(null, ['a', 'b'], new Map());
+ expect(r.started).toEqual(['a', 'b']);
+ expect(r.stopped).toEqual([]);
+});
+
+// ── buildSequence assembly ──
+
+// Identity tokens — the sequence only uses Note for identity (active sets / tie keys).
+function fakeNote(label: string): Note {
+ return { label } as unknown as Note;
+}
+const SYS = new Rect(0, 0, 1000, 100);
+
+function quarter(
+ note: Note,
+ measureIndex: number,
+ measureBeat: number,
+ x: number,
+): SequenceNote {
+ return { note, measureIndex, measureBeat, beats: 1, x, tiedFrom: null };
+}
+
+test('assembly: two 4/4 measures of quarters → 8 steps with correct beats/ms', () => {
+ const notes: SequenceNote[] = [];
+ for (let b = 0; b < 4; b++) {
+ notes.push(quarter(fakeNote(`m0b${b}`), 0, b, 10 + b * 10));
+ notes.push(quarter(fakeNote(`m1b${b}`), 1, b, 110 + b * 10));
+ }
+ const seq = buildSequence({
+ measures: [
+ { index: 0, beats: 4, tempoBpm: 120, jumps: [], systemRect: SYS },
+ { index: 1, beats: 4, tempoBpm: null, jumps: [], systemRect: SYS },
+ ],
+ notes,
+ });
+
+ expect(seq.length).toBe(8);
+ expect(seq.getDurationBeats()).toBe(8);
+ expect(seq.getDurationMs()).toBeCloseTo(4000);
+ expect(seq.getStep(0)?.startBeat).toBe(0);
+ expect(seq.getStep(0)?.startMs).toBeCloseTo(0);
+ expect(seq.getStep(4)?.startBeat).toBe(4); // first onset of measure 1
+ expect(seq.getStep(4)?.startMs).toBeCloseTo(2000);
+
+ // Measure count is document order (2 measures); ms maps to the measure playing then.
+ expect(seq.getMeasureCount()).toBe(2);
+ expect(seq.getMeasureIndexAtMs(0)).toBe(0);
+ expect(seq.getMeasureIndexAtMs(2500)).toBe(1); // 2.5s in → measure 1
+});
+
+test('assembly: a repeated measure replays its steps at later times, earliest-first lookup', () => {
+ const a = fakeNote('a');
+ const seq = buildSequence({
+ measures: [
+ {
+ index: 0,
+ beats: 2,
+ tempoBpm: 120,
+ jumps: [{ type: 'repeatstart' }, { type: 'repeatend', times: 1 }],
+ systemRect: SYS,
+ },
+ ],
+ notes: [
+ {
+ note: a,
+ measureIndex: 0,
+ measureBeat: 0,
+ beats: 2,
+ x: 10,
+ tiedFrom: null,
+ },
+ ],
+ });
+
+ // Played twice: two steps, the second a measure later in beats.
+ expect(seq.length).toBe(2);
+ expect(seq.getStep(0)?.startBeat).toBe(0);
+ expect(seq.getStep(1)?.startBeat).toBe(2);
+ expect(seq.getDurationBeats()).toBe(4);
+ // The same note maps to its EARLIEST step (first pass).
+ expect(seq.getFirstStepOfNote(a)).toBe(0);
+});
+
+test('assembly: overlapping voices window the active set; classify reports held vs released', () => {
+ const half = fakeNote('half'); // voice A, [0, 2)
+ const q1 = fakeNote('q1'); // voice B, [0, 1)
+ const q2 = fakeNote('q2'); // voice B, [1, 2)
+ const seq = buildSequence({
+ measures: [
+ { index: 0, beats: 2, tempoBpm: 120, jumps: [], systemRect: SYS },
+ ],
+ notes: [
+ {
+ note: half,
+ measureIndex: 0,
+ measureBeat: 0,
+ beats: 2,
+ x: 10,
+ tiedFrom: null,
+ },
+ {
+ note: q1,
+ measureIndex: 0,
+ measureBeat: 0,
+ beats: 1,
+ x: 10,
+ tiedFrom: null,
+ },
+ {
+ note: q2,
+ measureIndex: 0,
+ measureBeat: 1,
+ beats: 1,
+ x: 50,
+ tiedFrom: null,
+ },
+ ],
+ });
+
+ expect(seq.length).toBe(2);
+ expect(seq.getStep(0)?.active).toEqual([half, q1]);
+ expect(seq.getStep(1)?.active).toEqual([half, q2]);
+
+ const t = seq.classify(0, 1);
+ expect(t.started).toEqual([q2]);
+ expect(t.sustained).toEqual([half]);
+ expect(t.stopped).toEqual([q1]);
+});
+
+test('positionAt: interpolates the bar x within a step toward the next onset', () => {
+ const seq = buildSequence({
+ measures: [
+ { index: 0, beats: 2, tempoBpm: 120, jumps: [], systemRect: SYS },
+ ],
+ notes: [quarter(fakeNote('a'), 0, 0, 10), quarter(fakeNote('b'), 0, 1, 20)],
+ });
+ // Step 0 spans beat [0,1) = ms [0,500), gliding x 10 -> 20.
+ expect(seq.positionAt(0)?.x).toBeCloseTo(10);
+ expect(seq.positionAt(250)?.x).toBeCloseTo(15);
+ const rect = seq.positionAt(250);
+ expect(rect?.y).toBe(0);
+ expect(rect?.h).toBe(100);
+});
+
+test('resolveX: interpolates the beat from x within a step, clamping to the range ends', () => {
+ const seq = buildSequence({
+ measures: [
+ { index: 0, beats: 2, tempoBpm: 120, jumps: [], systemRect: SYS },
+ ],
+ notes: [quarter(fakeNote('a'), 0, 0, 10), quarter(fakeNote('b'), 0, 1, 20)],
+ });
+ // Step 0 spans beat [0,1) gliding x 10 -> 20; step 1 spans [1,2) gliding x 20 -> 1000 (system right).
+ expect(seq.resolveX(10, 0, 1)).toEqual({ stepIndex: 0, beat: 0 });
+ expect(seq.resolveX(15, 0, 1)).toEqual({ stepIndex: 0, beat: 0.5 });
+ expect(seq.resolveX(20, 0, 1)).toEqual({ stepIndex: 1, beat: 1 });
+ // Left of the first step clamps to its start; right of the last clamps to its end.
+ expect(seq.resolveX(-5, 0, 1)).toEqual({ stepIndex: 0, beat: 0 });
+ expect(seq.resolveX(9999, 0, 1)).toEqual({ stepIndex: 1, beat: 2 });
+ // A single-step range stays within that step.
+ expect(seq.resolveX(20, 0, 0)).toEqual({ stepIndex: 0, beat: 1 });
+ expect(seq.resolveX(0, 1, 0)).toBeNull();
+});
+
+test('getStepRangeOfMeasure: the first occurrence contiguous run, null when empty', () => {
+ const seq = buildSequence({
+ measures: [
+ { index: 0, beats: 2, tempoBpm: 120, jumps: [], systemRect: SYS },
+ { index: 1, beats: 2, tempoBpm: null, jumps: [], systemRect: SYS },
+ ],
+ notes: [
+ quarter(fakeNote('a'), 0, 0, 10),
+ quarter(fakeNote('b'), 0, 1, 20),
+ quarter(fakeNote('c'), 1, 0, 30),
+ ],
+ });
+ expect(seq.getStepRangeOfMeasure(0)).toEqual({ start: 0, end: 1 });
+ expect(seq.getStepRangeOfMeasure(1)).toEqual({ start: 2, end: 2 });
+ expect(seq.getStepRangeOfMeasure(99)).toBeNull();
+});
+
+test('getStepIndexAtBeats: binary search, null before the first onset', () => {
+ const seq = buildSequence({
+ measures: [
+ { index: 0, beats: 4, tempoBpm: 120, jumps: [], systemRect: SYS },
+ ],
+ notes: [
+ quarter(fakeNote('a'), 0, 0, 10),
+ quarter(fakeNote('b'), 0, 1, 20),
+ quarter(fakeNote('c'), 0, 2, 30),
+ ],
+ });
+ expect(seq.getStepIndexAtBeats(-1)).toBeNull();
+ expect(seq.getStepIndexAtBeats(0)).toBe(0);
+ expect(seq.getStepIndexAtBeats(1.5)).toBe(1);
+ expect(seq.getStepIndexAtBeats(99)).toBe(2);
+ expect(seq.getStepIndexAtMs(500)).toBe(1);
+});
diff --git a/src/sequence.ts b/src/sequence.ts
new file mode 100644
index 000000000..bc5377693
--- /dev/null
+++ b/src/sequence.ts
@@ -0,0 +1,684 @@
+import { Rect } from './geometry';
+import type { Note } from './targets';
+
+/*
+ * The playback timeline: the score unrolled into a linear sequence of steps in playback order, with
+ * repeats and voltas expanded, onsets resolved to absolute beats, and beats mapped to milliseconds
+ * through the score's tempo marks. A playback Cursor walks it; the Score queries it (duration,
+ * position->time). This module is pure over the `SequenceInput` seam (no DOM, no rendering), so the
+ * whole timeline is unit-tested directly; `render.ts` builds the real input from the engraved
+ * geometry and the parsed document.
+ */
+
+/* A measure's repeat structure, as the iterator consumes it. `times` is the number of *back-jumps*
+ * (a plain repeat that plays twice is `times: 1`); a volta `repeatending`'s `times` is how many
+ * passes that ending covers. Derived from MusicXML barlines/endings in render.ts. */
+export type Jump =
+ | { type: 'repeatstart' }
+ | { type: 'repeatend'; times: number }
+ | { type: 'repeatending'; times: number };
+
+/* One measure in document (visual) order. `beats` is its played length in quarter-note beats (the
+ * max note end, or the meter); `tempoBpm` is the quarter-note BPM in effect at its start, or null to
+ * carry the previous (the piece starts at 120 if never set). `systemRect` is the measure's full
+ * system box — the bar's vertical span and the x it clamps to at a line end. */
+export interface MeasureInfo {
+ index: number;
+ beats: number;
+ tempoBpm: number | null;
+ jumps: Jump[];
+ systemRect: Rect;
+}
+
+/* A rendered, time-bearing note (a notation notehead or rest — grace notes and tab ghosts are not
+ * rendered as tickables, so they never appear here). `note` is the target identity used in active
+ * sets; `tiedFrom` is the note this one continues from across a tie, so a tied continuation reads as
+ * a sustain rather than a re-attack. */
+export interface SequenceNote {
+ note: Note;
+ measureIndex: number;
+ measureBeat: number;
+ beats: number;
+ x: number;
+ tiedFrom: Note | null;
+}
+
+/* Everything the timeline is built from. A fake supplies plain values in tests; render.ts builds the
+ * real one from `RawGeometry` + the parsed parts. */
+export interface SequenceInput {
+ measures: MeasureInfo[];
+ notes: SequenceNote[];
+}
+
+/* One stop in playback order: the onset of a tickable. The active set is constant across
+ * `[startBeat, endBeat)`. `x`/`glideToX` are the bar's onset position and where it glides to by the
+ * step's end (the next onset on the same system, or the system's right edge at a line break);
+ * `systemRect` is its vertical span. */
+export interface Step {
+ readonly index: number;
+ readonly measureIndex: number;
+ readonly startBeat: number;
+ readonly endBeat: number;
+ readonly startMs: number;
+ readonly endMs: number;
+ readonly x: number;
+ readonly glideToX: number;
+ readonly systemRect: Rect;
+ readonly active: readonly Note[];
+}
+
+/* What changed between two cursor positions: notes to attack (a re-struck pitch shows in both
+ * `started` and `stopped`), notes held or tied through (do not re-attack), and notes released
+ * (a note tied into the next step is excluded — it keeps ringing). */
+export interface CursorTransition {
+ readonly started: readonly Note[];
+ readonly sustained: readonly Note[];
+ readonly stopped: readonly Note[];
+}
+
+/* A quarter-note-beats <-> ms segment: `[startBeat, endBeat)` plays at `bpm` quarter notes/min. */
+type TempoSegment = { startBeat: number; endBeat: number; bpm: number };
+
+const DEFAULT_BPM = 120;
+/* The bar is a thin line; the view thickens it. A nonzero width keeps it a valid, hit-testable box. */
+const BAR_WIDTH = 1;
+
+/**
+ * Iterates measure indices in playback order, expanding repeats and voltas. Two phases: pre-scan to
+ * pair `repeatend`s with their `repeatstart`s and group `repeatending` runs into voltas, then a
+ * linear walk that back-jumps and skips exhausted endings. Ported from legacy vexml.
+ */
+export class MeasureSequenceIterator implements Iterable {
+ constructor(
+ private readonly measures: ReadonlyArray<{ index: number; jumps: Jump[] }>,
+ ) {}
+
+ [Symbol.iterator](): Iterator {
+ return computeSequence(this.measures)[Symbol.iterator]();
+ }
+}
+
+type RepeatEnd = { measureIndex: number; startIndex: number; times: number };
+type VoltaEnding = {
+ measureIndex: number;
+ times: number;
+ startPass: number;
+ endPass: number;
+};
+type Volta = {
+ startIndex: number;
+ endings: VoltaEnding[];
+ totalPasses: number;
+};
+type Structure = {
+ repeatEndsByMeasure: Map;
+ voltas: Volta[];
+ endingByMeasure: Map;
+};
+
+function computeSequence(
+ measures: ReadonlyArray<{ index: number; jumps: Jump[] }>,
+): number[] {
+ return walk(measures, analyzeStructure(measures));
+}
+
+function findJump(
+ jumps: Jump[],
+ type: K,
+): Extract | undefined {
+ return jumps.find(
+ (jump): jump is Extract => jump.type === type,
+ );
+}
+
+function analyzeStructure(
+ measures: ReadonlyArray<{ index: number; jumps: Jump[] }>,
+): Structure {
+ const repeatEndsByMeasure = new Map();
+ const voltas: Volta[] = [];
+ const endingByMeasure = new Map<
+ number,
+ { volta: Volta; ending: VoltaEnding }
+ >();
+
+ const startStack: number[] = [];
+ let currentVolta: Volta | null = null;
+
+ for (const [i, measure] of measures.entries()) {
+ for (const jump of measure.jumps) {
+ if (jump.type === 'repeatstart') {
+ startStack.push(i);
+ }
+ }
+
+ const endingJump = findJump(measure.jumps, 'repeatending');
+ if (endingJump) {
+ if (currentVolta === null) {
+ currentVolta = {
+ startIndex: startStack.at(-1) ?? 0,
+ endings: [],
+ totalPasses: 0,
+ };
+ voltas.push(currentVolta);
+ }
+ const ending: VoltaEnding = {
+ measureIndex: i,
+ times: endingJump.times,
+ startPass: 0,
+ endPass: 0,
+ };
+ currentVolta.endings.push(ending);
+ endingByMeasure.set(i, { volta: currentVolta, ending });
+ // A `repeatend` co-located with a `repeatending` is intentionally dropped.
+ continue;
+ }
+
+ if (currentVolta !== null) {
+ if (startStack.at(-1) === currentVolta.startIndex) {
+ startStack.pop();
+ }
+ currentVolta = null;
+ }
+
+ const endJump = findJump(measure.jumps, 'repeatend');
+ if (endJump) {
+ const startIndex = startStack.pop() ?? 0;
+ repeatEndsByMeasure.set(i, {
+ measureIndex: i,
+ startIndex,
+ times: endJump.times,
+ });
+ }
+ }
+
+ // Close any volta that runs to the end of the score.
+ if (currentVolta !== null && startStack.at(-1) === currentVolta.startIndex) {
+ startStack.pop();
+ }
+
+ for (const volta of voltas) {
+ // A `repeatending` with `times: 0` on the LAST ending is the standard "discontinue" volta: it
+ // plays once on the final pass with no back-jump. Treat it as `times: 1` for pass ranges.
+ const last = volta.endings.at(-1);
+ let pass = 1;
+ for (const ending of volta.endings) {
+ const effective =
+ ending === last && ending.times === 0 ? 1 : ending.times;
+ ending.startPass = pass;
+ ending.endPass = pass + effective - 1;
+ pass += effective;
+ }
+ const sum = pass - 1;
+ // A single-ending volta whose ending has a back-jump needs an implicit final pass for the
+ // run-past-the-now-exhausted-ending step. Other shapes exit on their final ending naturally.
+ const needsImplicitFinalPass =
+ volta.endings.length === 1 && last !== undefined && last.times > 0;
+ volta.totalPasses = needsImplicitFinalPass ? sum + 1 : sum;
+ }
+
+ return { repeatEndsByMeasure, voltas, endingByMeasure };
+}
+
+function walk(
+ measures: ReadonlyArray<{ index: number; jumps: Jump[] }>,
+ structure: Structure,
+): number[] {
+ const result: number[] = [];
+ const remainingBackJumps = new Map();
+ const voltaPass = new Map();
+
+ let i = 0;
+ while (i < measures.length) {
+ const measure = measures[i];
+ if (!measure) {
+ break;
+ }
+
+ const endingHit = structure.endingByMeasure.get(i);
+ if (endingHit) {
+ const pass = voltaPass.get(endingHit.volta) ?? 1;
+ if (
+ pass < endingHit.ending.startPass ||
+ pass > endingHit.ending.endPass
+ ) {
+ i++;
+ continue;
+ }
+ }
+
+ result.push(measure.index);
+
+ if (endingHit) {
+ const { volta } = endingHit;
+ const nextPass = (voltaPass.get(volta) ?? 1) + 1;
+ if (nextPass > volta.totalPasses) {
+ voltaPass.delete(volta);
+ i++;
+ } else {
+ voltaPass.set(volta, nextPass);
+ resetNestedState(
+ structure,
+ remainingBackJumps,
+ voltaPass,
+ volta.startIndex,
+ i,
+ );
+ i = volta.startIndex;
+ }
+ continue;
+ }
+
+ const repeatEnd = structure.repeatEndsByMeasure.get(i);
+ if (repeatEnd) {
+ if (repeatEnd.times === 0) {
+ i++;
+ continue;
+ }
+ const remaining = remainingBackJumps.get(i) ?? repeatEnd.times;
+ if (remaining > 0) {
+ remainingBackJumps.set(i, remaining - 1);
+ resetNestedState(
+ structure,
+ remainingBackJumps,
+ voltaPass,
+ repeatEnd.startIndex,
+ i,
+ );
+ i = repeatEnd.startIndex;
+ } else {
+ remainingBackJumps.delete(i);
+ i++;
+ }
+ continue;
+ }
+
+ i++;
+ }
+
+ return result;
+}
+
+/* Reset repeat-ends and voltas nested strictly inside a range being jumped back over, so their
+ * counters re-initialize on the next pass through the outer block. */
+function resetNestedState(
+ structure: Structure,
+ remainingBackJumps: Map,
+ voltaPass: Map,
+ startIndex: number,
+ endIndex: number,
+): void {
+ for (const measureIndex of structure.repeatEndsByMeasure.keys()) {
+ if (measureIndex > startIndex && measureIndex < endIndex) {
+ remainingBackJumps.delete(measureIndex);
+ }
+ }
+ for (const volta of structure.voltas) {
+ if (volta.startIndex > startIndex && volta.startIndex < endIndex) {
+ voltaPass.delete(volta);
+ }
+ }
+}
+
+/**
+ * Quarter-note beats -> milliseconds across tempo segments. Folds the elapsed time of every segment
+ * the beat spans, plus the partial of the segment it lands in. Segments are contiguous and ordered;
+ * a beat past the last segment extrapolates at the last segment's rate.
+ */
+export function beatsToMs(
+ beats: number,
+ segments: readonly TempoSegment[],
+): number {
+ const last = segments.at(-1);
+ if (!last) {
+ return (beats / DEFAULT_BPM) * 60000;
+ }
+ let ms = 0;
+ for (const seg of segments) {
+ if (beats <= seg.startBeat) {
+ break;
+ }
+ const upto = Math.min(beats, seg.endBeat);
+ ms += ((upto - seg.startBeat) / seg.bpm) * 60000;
+ }
+ if (beats > last.endBeat) {
+ ms += ((beats - last.endBeat) / last.bpm) * 60000;
+ }
+ return ms;
+}
+
+/** Milliseconds -> quarter-note beats: the monotonic inverse of {@link beatsToMs}. */
+export function msToBeats(
+ ms: number,
+ segments: readonly TempoSegment[],
+): number {
+ const last = segments.at(-1);
+ if (!last) {
+ return (ms / 60000) * DEFAULT_BPM;
+ }
+ let elapsed = 0;
+ for (const seg of segments) {
+ const segMs = ((seg.endBeat - seg.startBeat) / seg.bpm) * 60000;
+ if (ms <= elapsed + segMs) {
+ return seg.startBeat + ((ms - elapsed) / 60000) * seg.bpm;
+ }
+ elapsed += segMs;
+ }
+ return last.endBeat + ((ms - elapsed) / 60000) * last.bpm;
+}
+
+/**
+ * Partition the notes active at a destination step into attacks vs. sustains relative to a source
+ * step, and the notes released. Pure set algebra over identities, generic so it's tested with plain
+ * tokens. A note is sustained when it was already ringing (same identity) or is tied in from a note
+ * that was; otherwise it's a (re)attack. A source note is released unless it's tied into a
+ * destination note (then it keeps ringing as that note).
+ */
+export function classifyTransition(
+ prevActive: readonly T[] | null,
+ nextActive: readonly T[],
+ tiedFrom: ReadonlyMap,
+): { started: T[]; sustained: T[]; stopped: T[] } {
+ const prev = new Set(prevActive ?? []);
+ const next = new Set(nextActive);
+ const started: T[] = [];
+ const sustained: T[] = [];
+ const tiedOut = new Set();
+
+ for (const n of nextActive) {
+ const from = tiedFrom.get(n);
+ if (prev.has(n)) {
+ sustained.push(n);
+ } else if (from !== undefined && prev.has(from)) {
+ sustained.push(n);
+ tiedOut.add(from);
+ } else {
+ started.push(n);
+ }
+ }
+
+ const stopped: T[] = [];
+ for (const p of prevActive ?? []) {
+ if (!next.has(p) && !tiedOut.has(p)) {
+ stopped.push(p);
+ }
+ }
+ return { started, sustained, stopped };
+}
+
+/** Build the playback timeline from its input. See {@link Sequence}. */
+export function buildSequence(input: SequenceInput): Sequence {
+ const order = [...new MeasureSequenceIterator(input.measures)];
+
+ const notesByMeasure = new Map();
+ for (const note of input.notes) {
+ const list = notesByMeasure.get(note.measureIndex);
+ if (list) {
+ list.push(note);
+ } else {
+ notesByMeasure.set(note.measureIndex, [note]);
+ }
+ }
+
+ // Walk playback order: accumulate the measure start beat, build tempo segments, and collect each
+ // note occurrence's absolute [startBeat, endBeat) interval plus the onsets that seed steps.
+ type Interval = { note: Note; startBeat: number; endBeat: number };
+ type Onset = { x: number; systemRect: Rect; measureIndex: number };
+ const intervals: Interval[] = [];
+ const onsets = new Map();
+ const segments: TempoSegment[] = [];
+ let totalBeats = 0;
+ // Start at 120; a measure's mark sets the rate from there on, null carries the previous. Measures
+ // before the first mark stay at the default, and a back-jump re-applies marks as written.
+ let bpm = DEFAULT_BPM;
+ for (const measureIndex of order) {
+ const measure = input.measures[measureIndex];
+ if (!measure) {
+ continue;
+ }
+ if (measure.tempoBpm !== null) {
+ bpm = measure.tempoBpm;
+ }
+ segments.push({
+ startBeat: totalBeats,
+ endBeat: totalBeats + measure.beats,
+ bpm,
+ });
+ for (const sn of notesByMeasure.get(measureIndex) ?? []) {
+ const startBeat = totalBeats + sn.measureBeat;
+ intervals.push({
+ note: sn.note,
+ startBeat,
+ endBeat: startBeat + sn.beats,
+ });
+ const existing = onsets.get(startBeat);
+ if (existing) {
+ existing.x = Math.min(existing.x, sn.x); // the onset's leftmost notehead anchors the bar
+ } else {
+ onsets.set(startBeat, {
+ x: sn.x,
+ systemRect: measure.systemRect,
+ measureIndex: measure.index,
+ });
+ }
+ }
+ totalBeats += measure.beats;
+ }
+
+ const tiedFrom = new Map();
+ for (const sn of input.notes) {
+ if (sn.tiedFrom) {
+ tiedFrom.set(sn.note, sn.tiedFrom);
+ }
+ }
+
+ const startBeats = [...onsets.keys()].sort((a, b) => a - b);
+ const steps: Step[] = [];
+ const firstStepOfNote = new Map();
+ const firstStepOfMeasure = new Map();
+ for (const [i, startBeat] of startBeats.entries()) {
+ const onset = onsets.get(startBeat);
+ if (!onset) {
+ continue;
+ }
+ const nextBeat = startBeats[i + 1];
+ const endBeat = nextBeat ?? totalBeats;
+ const active = intervals
+ .filter((iv) => iv.startBeat <= startBeat && startBeat < iv.endBeat)
+ .map((iv) => iv.note);
+ // Glide toward the next onset on the same system; at a line break, to the system's right edge.
+ const next = nextBeat === undefined ? undefined : onsets.get(nextBeat);
+ const sameSystem =
+ next !== undefined &&
+ next.systemRect.y === onset.systemRect.y &&
+ next.x > onset.x;
+ const glideToX = sameSystem && next ? next.x : onset.systemRect.right;
+ steps.push({
+ index: i,
+ measureIndex: onset.measureIndex,
+ startBeat,
+ endBeat,
+ startMs: beatsToMs(startBeat, segments),
+ endMs: beatsToMs(endBeat, segments),
+ x: onset.x,
+ glideToX,
+ systemRect: onset.systemRect,
+ active,
+ });
+ for (const note of active) {
+ if (!firstStepOfNote.has(note)) {
+ firstStepOfNote.set(note, i);
+ }
+ }
+ if (!firstStepOfMeasure.has(onset.measureIndex)) {
+ firstStepOfMeasure.set(onset.measureIndex, i);
+ }
+ }
+
+ return new Sequence(
+ steps,
+ segments,
+ totalBeats,
+ input.measures.length,
+ tiedFrom,
+ firstStepOfNote,
+ firstStepOfMeasure,
+ );
+}
+
+/**
+ * The built timeline: ordered steps, the tempo map, and lookups. Constructed by {@link buildSequence};
+ * the Cursor walks it (step/seek/interpolate) and the Score queries it (duration, position->time).
+ */
+export class Sequence {
+ constructor(
+ private readonly steps: Step[],
+ private readonly segments: readonly TempoSegment[],
+ private readonly durationBeats: number,
+ private readonly measureCount: number,
+ private readonly tiedFrom: ReadonlyMap,
+ private readonly firstStepOfNote: ReadonlyMap,
+ private readonly firstStepOfMeasure: ReadonlyMap,
+ ) {}
+
+ get length(): number {
+ return this.steps.length;
+ }
+
+ getStep(index: number): Step | null {
+ return this.steps[index] ?? null;
+ }
+
+ getDurationBeats(): number {
+ return this.durationBeats;
+ }
+
+ getDurationMs(): number {
+ return beatsToMs(this.durationBeats, this.segments);
+ }
+
+ beatsToMs(beats: number): number {
+ return beatsToMs(beats, this.segments);
+ }
+
+ msToBeats(ms: number): number {
+ return msToBeats(ms, this.segments);
+ }
+
+ /* The step active at `beat` (the last whose startBeat <= beat), or null when empty / before the
+ * first onset. Binary search over ordered startBeats. */
+ getStepIndexAtBeats(beat: number): number | null {
+ const first = this.steps[0];
+ if (!first || beat < first.startBeat) {
+ return null;
+ }
+ let lo = 0;
+ let hi = this.steps.length - 1;
+ while (lo < hi) {
+ const mid = (lo + hi + 1) >> 1;
+ const step = this.steps[mid];
+ if (step && step.startBeat <= beat) {
+ lo = mid;
+ } else {
+ hi = mid - 1;
+ }
+ }
+ return lo;
+ }
+
+ getStepIndexAtMs(ms: number): number | null {
+ return this.getStepIndexAtBeats(this.msToBeats(ms));
+ }
+
+ /* The total number of measures in document order (not repeat-expanded). */
+ getMeasureCount(): number {
+ return this.measureCount;
+ }
+
+ /* The document measure index playing at `ms` (before the first onset clamps to 0). */
+ getMeasureIndexAtMs(ms: number): number {
+ const index = this.getStepIndexAtMs(ms);
+ return index === null ? 0 : (this.steps[index]?.measureIndex ?? 0);
+ }
+
+ /* The bar rect at an exact time: the step's onset x interpolated toward its glide target by the
+ * elapsed fraction, spanning the step's system. Clamped to the timeline's bounds. */
+ positionAt(ms: number): Rect | null {
+ const beat = this.msToBeats(ms);
+ const index = this.getStepIndexAtBeats(beat) ?? 0;
+ const step = this.steps[index];
+ if (!step) {
+ return null;
+ }
+ const span = step.endBeat - step.startBeat;
+ const frac =
+ span > 0 ? Math.min(1, Math.max(0, (beat - step.startBeat) / span)) : 0;
+ const x = step.x + (step.glideToX - step.x) * frac;
+ return new Rect(x, step.systemRect.y, BAR_WIDTH, step.systemRect.h);
+ }
+
+ /* Inverse of positionAt over a step range: the step whose `[x, glideToX]` segment contains the
+ * score-space `x`, and the exact beat interpolated within it. Steps in a range are left-to-right
+ * contiguous (`step[i].glideToX === step[i+1].x` on the same system), so `x` left of the first
+ * clamps to frac 0 and right of the last to frac 1. Null if the range is empty/invalid. */
+ resolveX(
+ x: number,
+ startStep: number,
+ endStep: number,
+ ): { stepIndex: number; beat: number } | null {
+ if (startStep > endStep) {
+ return null;
+ }
+ let stepIndex = startStep;
+ for (let i = startStep; i <= endStep; i++) {
+ const step = this.steps[i];
+ if (!step) {
+ return null;
+ }
+ stepIndex = i;
+ if (x < step.glideToX) {
+ break;
+ }
+ }
+ const step = this.steps[stepIndex];
+ if (!step) {
+ return null;
+ }
+ const span = step.glideToX - step.x;
+ const frac = span > 0 ? Math.min(1, Math.max(0, (x - step.x) / span)) : 0;
+ const beat = step.startBeat + (step.endBeat - step.startBeat) * frac;
+ return { stepIndex, beat };
+ }
+
+ /* Started/sustained/stopped moving from one step to another (or from nothing, `from = null`). */
+ classify(from: number | null, to: number): CursorTransition {
+ const prev = from === null ? null : (this.steps[from]?.active ?? null);
+ const next = this.steps[to]?.active ?? [];
+ return classifyTransition(prev, next, this.tiedFrom);
+ }
+
+ /* The earliest step a note sounds in (its first occurrence under repeats), or null if unrendered. */
+ getFirstStepOfNote(note: Note): number | null {
+ return this.firstStepOfNote.get(note) ?? null;
+ }
+
+ /* The earliest step in a measure (its first occurrence under repeats), or null if it has none. */
+ getFirstStepOfMeasure(measureIndex: number): number | null {
+ return this.firstStepOfMeasure.get(measureIndex) ?? null;
+ }
+
+ /* The first occurrence's contiguous run of steps for a measure, or null if it has none. The run
+ * is contiguous because a measure's onsets are emitted together, in playback order. */
+ getStepRangeOfMeasure(
+ measureIndex: number,
+ ): { start: number; end: number } | null {
+ const start = this.firstStepOfMeasure.get(measureIndex);
+ if (start === undefined) {
+ return null;
+ }
+ let end = start;
+ while (this.steps[end + 1]?.measureIndex === measureIndex) {
+ end++;
+ }
+ return { start, end };
+ }
+}
diff --git a/src/stage.test.ts b/src/stage.test.ts
new file mode 100644
index 000000000..641690183
--- /dev/null
+++ b/src/stage.test.ts
@@ -0,0 +1,45 @@
+import { expect, test } from 'bun:test';
+import { scrollOffsetFor } from './stage';
+
+// Both boxes are in the container's scroll-content coordinates; view = the current scroll window.
+const VIEW = { left: 0, top: 0, right: 100, bottom: 100 };
+
+test('scrollOffsetFor: a fully visible target leaves both axes where they are', () => {
+ const offset = scrollOffsetFor(
+ { left: 50, top: 50, right: 60, bottom: 60 },
+ VIEW,
+ );
+ expect(offset).toEqual({ left: 0, top: 0 });
+});
+
+test('scrollOffsetFor: off-screen right scrolls x only, far edge into view', () => {
+ const offset = scrollOffsetFor(
+ { left: 150, top: 10, right: 160, bottom: 20 },
+ VIEW,
+ );
+ expect(offset).toEqual({ left: 60, top: 0 }); // 0 + (160 - 100)
+});
+
+test('scrollOffsetFor: off-screen left scrolls x only, near edge into view', () => {
+ const offset = scrollOffsetFor(
+ { left: 20, top: 10, right: 30, bottom: 20 },
+ { left: 50, top: 0, right: 150, bottom: 100 },
+ );
+ expect(offset).toEqual({ left: 20, top: 0 });
+});
+
+test('scrollOffsetFor: off-screen below scrolls y only', () => {
+ const offset = scrollOffsetFor(
+ { left: 10, top: 200, right: 20, bottom: 210 },
+ VIEW,
+ );
+ expect(offset).toEqual({ left: 0, top: 110 }); // 0 + (210 - 100)
+});
+
+test('scrollOffsetFor: off-screen on both axes scrolls both (panoramic + tall)', () => {
+ const offset = scrollOffsetFor(
+ { left: 150, top: 200, right: 160, bottom: 210 },
+ VIEW,
+ );
+ expect(offset).toEqual({ left: 60, top: 110 });
+});
diff --git a/src/stage.ts b/src/stage.ts
index fb65b9d33..7f64a8158 100644
--- a/src/stage.ts
+++ b/src/stage.ts
@@ -1,3 +1,4 @@
+import type { Scroller } from './cursor';
import type { Rect } from './geometry';
import type { Viewport } from './targets';
@@ -30,10 +31,13 @@ export interface LayerHost {
* test injects a fake. Kept separate from Viewport (the targets' coordinate seam) so each consumer
* depends only on what it uses, even though Stage satisfies both.
*/
-export interface Host extends LayerHost {
- toScoreSpace(clientX: number, clientY: number): { x: number; y: number };
+export interface Host extends LayerHost, Viewport {
readonly events: EventTarget;
readonly scroll: { left: number; top: number };
+ /* The visible scrollport box in client coords — what a cursor's visibility check compares against. */
+ viewportRect(): DOMRect;
+ /* Scrolls a score-space rect into view (axis-aware); a cursor's follow()/scrollIntoView() use it. */
+ readonly scroller: Scroller;
observeResize(
onResize: (size: { width: number; height: number }) => void,
): () => void;
@@ -98,6 +102,15 @@ class ManagedLayer implements Layer {
}
}
+/* The caller's height/width caps from config. A set cap turns the container into a scroll box on that
+ * axis; null leaves the axis to size to its content. */
+export interface ScrollBox {
+ height?: number | null;
+ maxHeight?: number | null;
+ width?: number | null;
+ maxWidth?: number | null;
+}
+
/*
* The host: the DOM vexml builds inside the caller's container, and the coordinate authority
* between score space (where target rects live) and client/page space (where pointer events and
@@ -114,9 +127,14 @@ export class Stage implements Viewport, Host {
readonly base: HTMLCanvasElement;
private readonly prevPosition: string;
private readonly prevIsolation: string;
+ // Inline styles this stage set on the container, with their prior values, restored on dispose.
+ private readonly restoreStyles: Array<[string, string]> = [];
private readonly layers = new Set();
- constructor(private readonly container: HTMLDivElement) {
+ constructor(
+ private readonly container: HTMLDivElement,
+ scroll: ScrollBox = {},
+ ) {
// A positioned container is the containing block the overlay layers anchor to. Only set it
// when the caller left position static, and remember it so dispose restores.
this.prevPosition = container.style.position;
@@ -130,6 +148,29 @@ export class Stage implements Viewport, Host {
if (!container.style.isolation) {
container.style.isolation = 'isolate';
}
+ // Turn the container into a scroll box on whichever axes have a cap: set the size/cap and
+ // overflow:auto so the content (the in-flow base canvas) scrolls within it. A cursor's
+ // follow()/scrollIntoView() then scroll this same box. overflow per axis is set once.
+ const overflowY = scroll.height != null || scroll.maxHeight != null;
+ const overflowX = scroll.width != null || scroll.maxWidth != null;
+ if (scroll.height != null) {
+ this.setStyle('height', `${scroll.height}px`);
+ }
+ if (scroll.maxHeight != null) {
+ this.setStyle('max-height', `${scroll.maxHeight}px`);
+ }
+ if (scroll.width != null) {
+ this.setStyle('width', `${scroll.width}px`);
+ }
+ if (scroll.maxWidth != null) {
+ this.setStyle('max-width', `${scroll.maxWidth}px`);
+ }
+ if (overflowY) {
+ this.setStyle('overflow-y', 'auto');
+ }
+ if (overflowX) {
+ this.setStyle('overflow-x', 'auto');
+ }
this.base = document.createElement('canvas');
// `vexml-canvas` is the stable hook callers style to size/scale the rendered score. They style
// this class (or the container), never the bare element — that keeps the overlay canvases
@@ -164,6 +205,38 @@ export class Stage implements Viewport, Host {
return { left: this.container.scrollLeft, top: this.container.scrollTop };
}
+ // The visible scrollport box: the container's own client box (the same box overflow scrolls within).
+ viewportRect(): DOMRect {
+ return this.container.getBoundingClientRect();
+ }
+
+ // The Stage is its own scroller — it owns the container that scrolls.
+ get scroller(): Scroller {
+ return this;
+ }
+
+ // Scroll the container so a score-space rect is visible, moving only the axis that's off-screen.
+ // The rect maps to the container's scroll content through the base canvas's offset and CSS scale.
+ scrollIntoView(rect: Rect, opts?: { behavior?: ScrollBehavior }): void {
+ const { sx, sy } = this.frame();
+ const left = this.base.offsetLeft + rect.x * sx;
+ const top = this.base.offsetTop + rect.y * sy;
+ const target = {
+ left,
+ top,
+ right: left + rect.w * sx,
+ bottom: top + rect.h * sy,
+ };
+ const view = {
+ left: this.container.scrollLeft,
+ top: this.container.scrollTop,
+ right: this.container.scrollLeft + this.container.clientWidth,
+ bottom: this.container.scrollTop + this.container.clientHeight,
+ };
+ const offset = scrollOffsetFor(target, view);
+ this.container.scrollTo({ ...offset, behavior: opts?.behavior });
+ }
+
observeResize(
onResize: (size: { width: number; height: number }) => void,
): () => void {
@@ -240,6 +313,18 @@ export class Stage implements Viewport, Host {
this.base.remove();
this.container.style.position = this.prevPosition;
this.container.style.isolation = this.prevIsolation;
+ for (const [prop, value] of this.restoreStyles) {
+ this.container.style.setProperty(prop, value);
+ }
+ }
+
+ // Set a container style, remembering its prior value so dispose restores it (each prop set once).
+ private setStyle(prop: string, value: string): void {
+ this.restoreStyles.push([
+ prop,
+ this.container.style.getPropertyValue(prop),
+ ]);
+ this.container.style.setProperty(prop, value);
}
// Size a layer's drawing bitmap. A content layer's bitmap is fixed to the engraved score (the
@@ -288,3 +373,31 @@ export class Stage implements Viewport, Host {
return { left: r.left, top: r.top, sx: r.width / w, sy: r.height / h };
}
}
+
+type Box = { left: number; top: number; right: number; bottom: number };
+
+/*
+ * The scroll offset that brings `target` into `view`, both in the container's scroll-content
+ * coordinates. Each axis independently: if the target's near edge is off the near side, align to it;
+ * if its far edge is off the far side, scroll just enough to show it; otherwise leave that axis where
+ * it is. So scrolling a horizontally-off-screen bar in a panoramic score never disturbs the vertical
+ * position, and vice versa. Pure — the DOM application lives in Stage.scrollIntoView.
+ */
+export function scrollOffsetFor(
+ target: Box,
+ view: Box,
+): { left: number; top: number } {
+ const axis = (tLo: number, tHi: number, vLo: number, vHi: number): number => {
+ if (tLo < vLo) {
+ return tLo;
+ }
+ if (tHi > vHi) {
+ return vLo + (tHi - vHi);
+ }
+ return vLo;
+ };
+ return {
+ left: axis(target.left, target.right, view.left, view.right),
+ top: axis(target.top, target.bottom, view.top, view.bottom),
+ };
+}
diff --git a/src/targets.test.ts b/src/targets.test.ts
index 4e6f4bf02..ffe8604e5 100644
--- a/src/targets.test.ts
+++ b/src/targets.test.ts
@@ -89,7 +89,7 @@ function fixture() {
const viewport = new FakeViewport();
const decorator = new FakeDecorator();
- const measure = new Measure(new Rect(0, 0, 100, 50), viewport, '1');
+ const measure = new Measure(new Rect(0, 0, 100, 50), viewport, '1', 0);
// The shared registries the wrappers resolve their cross-links through (a Map fulfills the
// NoteLookup / TabLookup interfaces). Populated as each note is built.
diff --git a/src/targets.ts b/src/targets.ts
index 6da1419ef..899df6997 100644
--- a/src/targets.ts
+++ b/src/targets.ts
@@ -258,6 +258,7 @@ export class Measure extends BoundedTarget {
rect: Rect,
viewport: Viewport,
private readonly number: string,
+ private readonly index: number,
) {
super(rect, viewport);
}
@@ -266,6 +267,11 @@ export class Measure extends BoundedTarget {
getNumber(): string {
return this.number;
}
+
+ /* The 0-based position among the part's measures (stable; distinct from the printed number). */
+ getIndex(): number {
+ return this.index;
+ }
}
/* A fret number on a tab string. The same note can render as both a Note (notehead) and a
diff --git a/tests/integration/__screenshots__/cursor_bar.png b/tests/integration/__screenshots__/cursor_bar.png
new file mode 100644
index 000000000..51b6c6de0
Binary files /dev/null and b/tests/integration/__screenshots__/cursor_bar.png differ
diff --git a/tests/integration/cursor.test.ts b/tests/integration/cursor.test.ts
new file mode 100644
index 000000000..488e98768
--- /dev/null
+++ b/tests/integration/cursor.test.ts
@@ -0,0 +1,30 @@
+import { expect, test } from 'bun:test';
+import { TEST_URL, testBrowser } from '../testing/setup';
+
+// A playback cursor end to end, the way a caller reaches it: render, add a cursor, attach the
+// built-in bar view, and seek. Proves the timeline builds from a real score and the bar lands on the
+// engraving at the sought time. The timeline/cursor/view logic is unit-tested in src/*; this is the
+// integration screenshot. Uses the run's shared browser/server (see setup.ts).
+test('a playback cursor draws its bar on the score at the sought time', async () => {
+ const browser = await testBrowser();
+ const page = await browser.newPage({ viewport: { width: 900, height: 400 } });
+ try {
+ await page.goto(TEST_URL);
+ await page.evaluate(async () => {
+ const container = document.getElementById('screenshot');
+ if (!(container instanceof HTMLDivElement)) {
+ throw new Error('container not found');
+ }
+ const xml = await (await fetch('/data/arpeggio.musicxml')).text();
+ const score = await window.render(xml, container, {});
+ const cursor = score.addCursor();
+ cursor.attach(score.createCursorView({ color: '#2962ff', widthPx: 3 }));
+ // Seek 40% through the piece — a deterministic spot independent of the note count.
+ cursor.seekMs(score.getDurationMs() * 0.4);
+ });
+ const buf = await page.locator('#screenshot').screenshot();
+ expect(buf).toMatchScreenshot('cursor_bar.png');
+ } finally {
+ await page.close();
+ }
+}, 30_000);
diff --git a/tests/integration/render.test.ts b/tests/integration/render.test.ts
index a8e60b8b1..597202958 100644
--- a/tests/integration/render.test.ts
+++ b/tests/integration/render.test.ts
@@ -262,7 +262,7 @@ const TEST_CASES = [
// - M2 (system 2): the tied half chord + half rest; the three ties bow in from the left
// edge of the stave into the chord ("tie from nothing").
testCase('tie_system_break.musicxml', 'tie_system_break.png', {
- layout: { type: 'standard', width: 360 },
+ layout: { type: 'standard', referenceWidth: 360 },
}),
// Treble stave, 4/4: four quarters C5, D5, E5, F5 under one slur with no placement
@@ -310,7 +310,7 @@ const TEST_CASES = [
// - M2 (system 2): four descending quarters G5-D5; the slur bows in from the left edge
// of the stave into G5 ("slur from nothing").
testCase('slur_system_break.musicxml', 'slur_system_break.png', {
- layout: { type: 'standard', width: 350 },
+ layout: { type: 'standard', referenceWidth: 350 },
}),
// Treble stave, 4/4: sustain pedals from , drawn
@@ -351,7 +351,7 @@ const TEST_CASES = [
// of M3's stave and in from the left edge of M4's — not draw one diagonal across the
// page gap.
testCase('tab_hammer_pull.musicxml', 'tab_hammer_pull_wrap.png', {
- layout: { type: 'standard', width: 491 },
+ layout: { type: 'standard', referenceWidth: 491 },
}),
// 6-line TAB stave, half notes: how ties vs slurs render in tab. A tie holds a string
@@ -626,7 +626,7 @@ const TEST_CASES = [
// The C box (anchored at M1's last beat) and the G box (anchored at M2's first beat)
// are nudged apart so they clear each other — no overlapping boards or titles.
testCase('chord_diagram_adjacent.musicxml', 'chord_diagram_adjacent.png', {
- layout: { type: 'standard', width: 500 },
+ layout: { type: 'standard', referenceWidth: 500 },
}),
// Treble stave, 4/4, one measure at a narrow 500px width: a Bm7 fret box bound to the
@@ -635,7 +635,7 @@ const TEST_CASES = [
// whole board — including the far-right muted "X" — stays inside the drawable region
// instead of being clipped. Four B4 quarters so only the diagram's clamp is exercised.
testCase('chord_diagram_edge.musicxml', 'chord_diagram_edge.png', {
- layout: { type: 'standard', width: 500 },
+ layout: { type: 'standard', referenceWidth: 500 },
}),
// Treble stave, 4/4: natural harmonics drawn as diamond noteheads (from
@@ -798,7 +798,7 @@ const TEST_CASES = [
// - M4-6 (system 2): very high quarter notes (C7) with many ledger lines above the staff,
// which must not collide with system 1's low notes.
testCase('system_spacing.musicxml', 'system_spacing.png', {
- layout: { type: 'standard', width: 660 },
+ layout: { type: 'standard', referenceWidth: 660 },
}),
// Individual measures extracted from 'aloof' for focused testing.
diff --git a/tests/testing/harness.ts b/tests/testing/harness.ts
index 12c63cbd7..85a8c22ed 100644
--- a/tests/testing/harness.ts
+++ b/tests/testing/harness.ts
@@ -13,8 +13,9 @@ export async function render(
config: Partial,
): Promise {
const width =
- (config.layout?.type === 'standard' ? config.layout.width : undefined) ??
- DEFAULT_WIDTH;
+ (config.layout?.type === 'standard'
+ ? config.layout.referenceWidth
+ : undefined) ?? DEFAULT_WIDTH;
const browser = await testBrowser();
const page = await browser.newPage({
viewport: { width: width + 64, height: 600 },