diff --git a/README.md b/README.md index 46db5618c..aa2dc7e31 100644 --- a/README.md +++ b/README.md @@ -93,8 +93,8 @@ const cursorComponent = vexml.SimpleCursor.render(score.getOverlayElement()); cursorModel.addEventListener( 'change', (e) => { - cursorComponent.update(e.cursorRect); - // The model infers its visibility via the cursorRect. It assumes you've updated appropriately. + cursorComponent.update(e.rect); + // The model infers its visibility via the rect. It assumes you've updated appropriately. if (!cursorModel.isFullyVisible()) { cursorModel.scrollIntoView(scrollBehavior); } diff --git a/package-lock.json b/package-lock.json index 3439b32f2..bf2879369 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "jszip": "3.10.1", - "vexflow": "5.0.0-beta.1" + "vexflow": "5.0.0" }, "devDependencies": { "@babel/core": "^7.17.8", @@ -13183,9 +13183,9 @@ } }, "node_modules/vexflow": { - "version": "5.0.0-beta.1", - "resolved": "https://registry.npmjs.org/vexflow/-/vexflow-5.0.0-beta.1.tgz", - "integrity": "sha512-MDnkHmslgY/LE8rINmq9aQmr11lkphHxJ/4iv47os/2TSV2XY24CRRcHcoQp+wACHDfZmkmj9wbvRU6vFWThgQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/vexflow/-/vexflow-5.0.0.tgz", + "integrity": "sha512-rjB7TV4ygKE5Fl3W5OlG+0dHv22CFufUJdMG6oNgvcn0zp34u+sOboZsadQXnF1O3tZ3myXThaUIaLkJlpNM2Q==", "license": "MIT" }, "node_modules/vexflow-fonts": { diff --git a/package.json b/package.json index f9c82d24a..e403239be 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ }, "dependencies": { "jszip": "3.10.1", - "vexflow": "5.0.0-beta.1" + "vexflow": "5.0.0" }, "devDependencies": { "@babel/core": "^7.17.8", diff --git a/site/src/components/Vexml.tsx b/site/src/components/Vexml.tsx index 96e382cb3..af0e10dbe 100644 --- a/site/src/components/Vexml.tsx +++ b/site/src/components/Vexml.tsx @@ -66,7 +66,7 @@ export const Vexml = ({ musicXML, config, onResult, onClick, onLongpress, onEnte if (!cursor.isFullyVisible()) { cursor.scrollIntoView(scrollBehavior); } - const currentTimeMs = cursor.getState().sequenceEntry.durationRange.start.ms; + const currentTimeMs = cursor.getCurrentState().frame.tRange.start.ms; player.seek(currentTimeMs, false); setProgress(currentTimeMs / durationMs); }; @@ -79,7 +79,7 @@ export const Vexml = ({ musicXML, config, onResult, onClick, onLongpress, onEnte if (!cursor.isFullyVisible()) { cursor.scrollIntoView(scrollBehavior); } - const currentTimeMs = cursor.getState().sequenceEntry.durationRange.start.ms; + const currentTimeMs = cursor.getCurrentState().frame.tRange.start.ms; player.seek(currentTimeMs, false); setProgress(currentTimeMs / durationMs); }; @@ -147,7 +147,7 @@ export const Vexml = ({ musicXML, config, onResult, onClick, onLongpress, onEnte cursor.addEventListener( 'change', (e) => { - simpleCursor.update(e.cursorRect); + simpleCursor.update(e.rect); if (!cursor.isFullyVisible()) { cursor.scrollIntoView(scrollBehavior); } diff --git a/src/elements/note.ts b/src/elements/note.ts index 889aa5e83..ce7e751a2 100644 --- a/src/elements/note.ts +++ b/src/elements/note.ts @@ -3,6 +3,7 @@ import { Config } from '@/config'; import { Logger } from '@/debug'; import { Rect } from '@/spatial'; import { Fraction } from '@/util'; +import { Pitch } from './types'; export class Note { private constructor( @@ -24,6 +25,19 @@ export class Note { return this.noteRender.rect; } + getPitch(): Pitch { + const note = this.document.getNote(this.noteRender.key); + return { + step: note.pitch.step, + octave: note.pitch.octave, + accidentalCode: note.accidental?.code ?? null, + }; + } + + sharesACurveWith(note: Note): boolean { + return this.noteRender.curveIds.some((curveId) => note.noteRender.curveIds.includes(curveId)); + } + /** Returns the measure beat that this note starts on. */ getStartMeasureBeat(): Fraction { return Fraction.fromFractionLike(this.document.getVoiceEntry(this.noteRender.key).measureBeat); diff --git a/src/elements/part.ts b/src/elements/part.ts index b08d1d7a8..e06bda6d2 100644 --- a/src/elements/part.ts +++ b/src/elements/part.ts @@ -37,6 +37,11 @@ export class Part { return this.partRender.key.partIndex; } + /** Returns the system index. */ + getSystemIndex(): number { + return this.partRender.key.systemIndex; + } + /** Returns the start measure beat for the part. */ getStartMeasureBeat(): Fraction { return ( diff --git a/src/elements/score.ts b/src/elements/score.ts index 4a417e53d..047c3dd77 100644 --- a/src/elements/score.ts +++ b/src/elements/score.ts @@ -75,11 +75,15 @@ export class Score { util.assert(0 <= span.toPartIndex && span.toPartIndex < partCount, 'toPartIndex out of bounds'); util.assert(span.fromPartIndex <= span.toPartIndex, 'fromPartIndex must be less than or equal to toPartIndex'); - const sequence = this.getSequences().find((sequence) => sequence.getPartIndex() === partIndex); - util.assertDefined(sequence); + const timeline = this.getTimelines().find((timeline) => timeline.getPartIndex() === partIndex); + util.assertDefined(timeline); + + const frames = playback.CursorFrame.create(this.log, this, timeline, span); + const path = new playback.CursorPath(partIndex, frames); + const cursor = playback.Cursor.create(path, this.getScrollContainer()); - const cursor = playback.Cursor.create(this.root.getScrollContainer(), this, sequence, span); this.cursors.push(cursor); + return cursor; } @@ -100,7 +104,7 @@ export class Score { /** Returns the duration of the score in milliseconds. */ getDurationMs(): number { - return Math.max(0, ...this.getSequences().map((sequence) => sequence.getDuration().ms)); + return Math.max(0, ...this.getTimelines().map((timeline) => timeline.getDuration().ms)); } /** Returns the max number of parts in this score. */ @@ -347,9 +351,8 @@ export class Score { } @util.memoize() - private getSequences(): playback.Sequence[] { - const sequences = new playback.SequenceFactory(this.log, this).create(); - return sequences; + private getTimelines(): playback.Timeline[] { + return playback.Timeline.create(this.log, this); } @util.memoize() @@ -359,6 +362,18 @@ export class Score { @util.memoize() private getTimestampLocator(): playback.TimestampLocator { - return playback.TimestampLocator.create(this, this.getSequences()); + const paths = new Array(); + const timelines = this.getTimelines(); + + for (let partIndex = 0; partIndex < this.getPartCount(); partIndex++) { + const timeline = timelines.find((timeline) => timeline.getPartIndex() === partIndex); + util.assertDefined(timeline); + const span = { fromPartIndex: partIndex, toPartIndex: partIndex }; + const frames = playback.CursorFrame.create(this.log, this, timeline, span); + const path = new playback.CursorPath(partIndex, frames); + paths.push(path); + } + + return playback.TimestampLocator.create(this, paths); } } diff --git a/src/elements/types.ts b/src/elements/types.ts index 82ebb6222..75a501a7f 100644 --- a/src/elements/types.ts +++ b/src/elements/types.ts @@ -8,6 +8,7 @@ import { Score } from './score'; import { Stave } from './stave'; import { System } from './system'; import { Voice } from './voice'; +import { Enum, EnumValues } from '@/util'; /** * Represents a rendered musical element. @@ -21,6 +22,15 @@ export type VexmlElement = Score | System | Measure | Fragment | Part | Stave | */ export type VoiceEntry = Note | Rest; +export type AccidentalCode = EnumValues; +export const ACCIDENTAL_CODES = new Enum(['#', '##', 'b', 'bb', 'n', 'd', '_', 'db', '+', '++'] as const); + +export type Pitch = { + step: string; + octave: number; + accidentalCode: AccidentalCode | null; +}; + export type EventMap = { click: ClickEvent; enter: EnterEvent; diff --git a/src/playback/bsearchcursorframelocator.ts b/src/playback/bsearchcursorframelocator.ts new file mode 100644 index 000000000..a1ae58629 --- /dev/null +++ b/src/playback/bsearchcursorframelocator.ts @@ -0,0 +1,37 @@ +import * as util from '@/util'; +import { CursorFrameLocator } from './types'; +import { Duration } from './duration'; +import { CursorPath } from './cursorpath'; + +/** + * A CursorFrameLocator that uses binary search to locate the frame at a given time. + */ +export class BSearchCursorFrameLocator implements CursorFrameLocator { + constructor(private path: CursorPath) {} + + locate(time: Duration): number | null { + const frames = this.path.getFrames(); + + let left = 0; + let right = frames.length - 1; + + while (left <= right) { + const mid = Math.floor((left + right) / 2); + const entry = frames.at(mid); + + util.assertDefined(entry); + + if (entry.tRange.includes(time)) { + return mid; + } + + if (entry.tRange.end.isGreaterThanOrEqual(time)) { + right = mid - 1; + } else { + left = mid + 1; + } + } + + return null; + } +} diff --git a/src/playback/cheaplocator.ts b/src/playback/cheaplocator.ts deleted file mode 100644 index ee2fc5067..000000000 --- a/src/playback/cheaplocator.ts +++ /dev/null @@ -1,42 +0,0 @@ -import * as playback from '@/playback'; - -export class CheapLocator { - private sequence: playback.Sequence; - private index: number = 0; - - constructor(sequence: playback.Sequence) { - this.sequence = sequence; - } - - setStartingIndex(index: number): this { - this.index = index; - return this; - } - - locate(time: playback.Duration): number | null { - if (time.isLessThan(playback.Duration.zero())) { - return 0; - } - - if (time.isGreaterThan(this.sequence.getDuration())) { - return this.sequence.getCount() - 1; - } - - const previousIndex = this.index - 1; - if (this.sequence.getEntry(previousIndex)?.durationRange.includes(time)) { - return previousIndex; - } - - const currentIndex = this.index; - if (this.sequence.getEntry(currentIndex)?.durationRange.includes(time)) { - return currentIndex; - } - - const nextIndex = this.index + 1; - if (this.sequence.getEntry(nextIndex)?.durationRange.includes(time)) { - return nextIndex; - } - - return null; - } -} diff --git a/src/playback/cursor.ts b/src/playback/cursor.ts index abadbd0cb..c4295fd77 100644 --- a/src/playback/cursor.ts +++ b/src/playback/cursor.ts @@ -1,195 +1,100 @@ -import * as playback from '@/playback'; -import * as util from '@/util'; -import * as spatial from '@/spatial'; import * as events from '@/events'; -import * as elements from '@/elements'; -import { Rect } from '@/spatial'; -import { CheapLocator } from './cheaplocator'; -import { ExpensiveLocator } from './expensivelocator'; +import * as util from '@/util'; +import { Rect, Point } from '@/spatial'; +import { CursorFrame } from './cursorframe'; import { Scroller } from './scroller'; +import { CursorFrameLocator } from './types'; +import { FastCursorFrameLocator } from './fastcursorframelocator'; +import { BSearchCursorFrameLocator } from './bsearchcursorframelocator'; +import { Duration } from './duration'; +import { CursorPath } from './cursorpath'; // NOTE: At 2px and below, there is some antialiasing issues on higher resolutions. The cursor will appear to "pulse" as // it moves. This will happen even when rounding the position. const CURSOR_WIDTH_PX = 3; -type CursorState = { +export type CursorState = { index: number; hasNext: boolean; hasPrevious: boolean; - cursorRect: Rect; - sequenceEntry: playback.SequenceEntry; + rect: Rect; + frame: CursorFrame; }; -type EventMap = { +export type CursorEventMap = { change: CursorState; }; -export type CursorVerticalSpan = { - fromPartIndex: number; - toPartIndex: number; -}; - export class Cursor { - private scroller: Scroller; - private states: CursorState[]; - private sequence: playback.Sequence; - private cheapLocator: CheapLocator; - private expensiveLocator: ExpensiveLocator; - private span: CursorVerticalSpan; - - private topic = new events.Topic(); - private index = 0; - private alpha = 0; // interpolation factor, ranging from 0 to 1 - - private constructor(opts: { - scroller: Scroller; - states: CursorState[]; - sequence: playback.Sequence; - cheapLocator: CheapLocator; - expensiveLocator: ExpensiveLocator; - span: CursorVerticalSpan; - }) { - this.scroller = opts.scroller; - this.states = opts.states; - this.sequence = opts.sequence; - this.cheapLocator = opts.cheapLocator; - this.expensiveLocator = opts.expensiveLocator; - this.span = opts.span; - } - - static create( - scrollContainer: HTMLElement, - score: elements.Score, - sequence: playback.Sequence, - span: CursorVerticalSpan - ): Cursor { - // NumberRange objects indexed by system index for the part. - const systemPartYRanges = new Array(); - - for (const system of score.getSystems()) { - const rect = Rect.merge( - system - .getMeasures() - .flatMap((measure) => measure.getFragments()) - .flatMap((fragment) => fragment.getParts()) - .filter((part) => span.fromPartIndex <= part.getIndex() && part.getIndex() <= span.toPartIndex) - .map((part) => part.rect()) - ); - const yRange = new util.NumberRange(rect.top(), rect.bottom()); - systemPartYRanges.push(yRange); - } - - const states = new Array(sequence.getCount()); - - for (let index = 0; index < sequence.getCount(); index++) { - const sequenceEntry = sequence.getEntry(index); - util.assertNotNull(sequenceEntry); - - const hasPrevious = index > 0; - const hasNext = index < sequence.getCount() - 1; - - const element = sequenceEntry.anchorElement; + private topic = new events.Topic(); - util.assertDefined(element); + private currentIndex = 0; + private currentAlpha = 0; // interpolation factor, ranging from 0 to 1 - const xRange = sequenceEntry.xRange; + private previousIndex = -1; + private previousAlpha = -1; - const systemIndex = element.getSystemIndex(); - const yRange = systemPartYRanges.at(systemIndex); - - util.assertDefined(yRange); - - const x = xRange.start; - const y = yRange.start; - const w = CURSOR_WIDTH_PX; - const h = yRange.getSize(); - - const cursorRect = new spatial.Rect(x, y, w, h); - - states[index] = { - index, - hasPrevious, - hasNext, - cursorRect, - sequenceEntry, - }; - } + private constructor(private path: CursorPath, private locator: CursorFrameLocator, private scroller: Scroller) {} + static create(path: CursorPath, scrollContainer: HTMLElement): Cursor { + const bSearchLocator = new BSearchCursorFrameLocator(path); + const fastLocator = new FastCursorFrameLocator(path, bSearchLocator); const scroller = new Scroller(scrollContainer); - const cheapLocator = new CheapLocator(sequence); - const expensiveLocator = new ExpensiveLocator(sequence); - - return new Cursor({ - scroller, - states, - sequence, - cheapLocator, - expensiveLocator, - span, - }); - } - - getState(): CursorState { - const state = this.states.at(this.index); - // TODO: We need a way to represent a zero state, when the sequence validly has no entries. Maybe we update the - // signature to be nullable. - util.assertDefined(state); - - if (this.alpha === 0) { - return { ...state }; - } + return new Cursor(path, fastLocator, scroller); + } - const x = util.lerp(state.sequenceEntry.xRange.start, state.sequenceEntry.xRange.end, this.alpha); - const y = state.cursorRect.y; - const w = state.cursorRect.w; - const h = state.cursorRect.h; - const cursorRect = new spatial.Rect(x, y, w, h); + getCurrentState(): CursorState { + return this.getState(this.currentIndex, this.currentAlpha); + } - return { ...state, cursorRect }; + getPreviousState(): CursorState | null { + if (this.previousIndex === -1 || this.previousAlpha === -1) { + return null; + } + return this.getState(this.previousIndex, this.previousAlpha); } next(): void { - if (this.index === this.sequence.getCount() - 1) { - this.update(this.index, 1); + if (this.currentIndex === this.path.getFrames().length - 1) { + this.update(this.currentIndex, { alpha: 1 }); } else { - this.update(this.index + 1, 0); + this.update(this.currentIndex + 1, { alpha: 0 }); } } previous(): void { - this.update(this.index - 1, 0); + this.update(this.currentIndex - 1, { alpha: 0 }); } goTo(index: number): void { - this.update(index, 0); + this.update(index, { alpha: 0 }); } /** Snaps to the closest sequence entry step. */ - snap(timestampMs: number): void { - timestampMs = util.clamp(0, this.sequence.getDuration().ms, timestampMs); - const time = playback.Duration.ms(timestampMs); - const index = this.getIndexClosestTo(time); - this.update(index, 0); + snap(timeMs: number): void { + const time = this.normalize(timeMs); + const index = this.locator.locate(time); + util.assertNotNull(index, 'Cursor frame locator failed to find a frame.'); + this.update(index, { alpha: 0 }); } /** Seeks to the exact position, interpolating as needed. */ seek(timestampMs: number): void { - timestampMs = util.clamp(0, this.sequence.getDuration().ms, timestampMs); - const time = playback.Duration.ms(timestampMs); - const index = this.getIndexClosestTo(time); - - const entry = this.sequence.getEntry(index); - util.assertNotNull(entry); - - const left = entry.durationRange.start; - const right = entry.durationRange.end; + const time = this.normalize(timestampMs); + const index = this.locator.locate(time); + util.assertNotNull(index, 'Cursor frame locator failed to find a frame.'); + const entry = this.path.getFrames().at(index); + util.assertDefined(entry); + + const left = entry.tRange.start; + const right = entry.tRange.end; const alpha = (time.ms - left.ms) / (right.ms - left.ms); - this.update(index, alpha); + this.update(index, { alpha }); } isFullyVisible(): boolean { - const cursorRect = this.getState().cursorRect; + const cursorRect = this.getCurrentState().rect; return this.scroller.isFullyVisible(cursorRect); } @@ -198,14 +103,14 @@ export class Cursor { this.scroller.scrollTo(scrollPoint, behavior); } - addEventListener( + addEventListener( name: N, - listener: events.EventListener, + listener: events.EventListener, opts?: { emitBootstrapEvent?: boolean } ): number { const id = this.topic.subscribe(name, listener); if (opts?.emitBootstrapEvent) { - listener(this.getState()); + listener(this.getCurrentState()); } return id; } @@ -220,30 +125,58 @@ export class Cursor { this.topic.unsubscribeAll(); } - private getScrollPoint(): spatial.Point { - const cursorRect = this.getState().cursorRect; + private getState(index: number, alpha: number): CursorState { + const frame = this.path.getFrames().at(index); + util.assertDefined(frame); + + const rect = this.getCursorRect(frame, alpha); + const hasNext = index < this.path.getFrames().length - 1; + const hasPrevious = index > 0; + + return { + index, + hasNext, + hasPrevious, + rect, + frame, + }; + } + + private getScrollPoint(): Point { + const cursorRect = this.getCurrentState().rect; const x = cursorRect.center().x; const y = cursorRect.y; - return new spatial.Point(x, y); + return new Point(x, y); + } + + private normalize(timeMs: number): Duration { + const ms = util.clamp(0, this.getDuration().ms, timeMs); + return Duration.ms(ms); + } + + private getDuration(): Duration { + return this.path.getFrames().at(-1)?.tRange.end ?? Duration.zero(); + } + + private getCursorRect(frame: CursorFrame, alpha: number): Rect { + const x = frame.xRange.lerp(alpha); + const y = frame.yRange.start; + const w = CURSOR_WIDTH_PX; + const h = frame.yRange.getSize(); + return new Rect(x, y, w, h); } - private update(index: number, alpha: number): void { - index = util.clamp(0, this.sequence.getCount() - 1, index); + private update(index: number, { alpha }: { alpha: number }): void { + index = util.clamp(0, this.path.getFrames().length - 1, index); alpha = util.clamp(0, 1, alpha); // Round to 3 decimal places to avoid overloading the event system with redundant updates. alpha = Math.round(alpha * 1000) / 1000; - if (index !== this.index || alpha !== this.alpha) { - this.index = index; - this.alpha = alpha; - this.topic.publish('change', this.getState()); - } - } - - private getIndexClosestTo(time: playback.Duration): number { - const index = this.cheapLocator.setStartingIndex(this.index).locate(time) ?? this.expensiveLocator.locate(time); - if (typeof index !== 'number') { - throw new Error(`locator coverage is insufficient to locate time ${time.ms}`); + if (index !== this.currentIndex || alpha !== this.currentAlpha) { + this.previousIndex = this.currentIndex; + this.previousAlpha = this.currentAlpha; + this.currentIndex = index; + this.currentAlpha = alpha; + this.topic.publish('change', this.getCurrentState()); } - return index; } } diff --git a/src/playback/cursorframe.ts b/src/playback/cursorframe.ts new file mode 100644 index 000000000..0dc132296 --- /dev/null +++ b/src/playback/cursorframe.ts @@ -0,0 +1,416 @@ +import * as util from '@/util'; +import * as elements from '@/elements'; +import { Logger } from '@/debug'; +import { DurationRange } from './durationrange'; +import { + CursorFrameHint, + CursorVerticalSpan, + PlaybackElement, + RetriggerHint, + SustainHint, + TimelineMoment, +} from './types'; +import { Timeline } from './timeline'; + +type TRangeSource = { + moment: TimelineMoment; +}; + +type XRangeSource = + | { type: 'system'; system: elements.System; bound: 'left' | 'right' } + | { type: 'measure'; measure: elements.Measure; bound: 'left' | 'right' } + | { type: 'element'; element: PlaybackElement; bound: 'left' | 'right' }; + +type YRangeSource = { + part: elements.Part; + bound: 'top' | 'bottom'; +}; + +export class CursorFrame { + constructor( + private tRangeSources: [TRangeSource, TRangeSource], + private xRangeSources: [XRangeSource, XRangeSource], + private yRangeSources: [YRangeSource, YRangeSource], + private activeElements: PlaybackElement[], + private describer: CursorFrameDescriber + ) {} + + static create(log: Logger, score: elements.Score, timeline: Timeline, span: CursorVerticalSpan): CursorFrame[] { + const partCount = score.getPartCount(); + if (partCount === 0) { + log.warn('No parts found in score, returning empty cursor frames.'); + return []; + } + + if (0 > span.fromPartIndex || span.fromPartIndex >= partCount) { + throw new Error(`Invalid fromPartIndex: ${span.fromPartIndex}, must be in [0,${partCount - 1}]`); + } + + if (0 > span.toPartIndex || span.toPartIndex >= partCount) { + throw new Error(`Invalid toPartIndex: ${span.toPartIndex}, must be in [0,${partCount - 1}]`); + } + + const factory = new CursorFrameFactory(log, score, timeline, span); + return factory.create(); + } + + get tRange(): DurationRange { + const t1 = this.tRangeSources[0].moment.time; + const t2 = this.tRangeSources[1].moment.time; + return new DurationRange(t1, t2); + } + + get xRange(): util.NumberRange { + const x1 = this.toXRangeBound(this.xRangeSources[0]); + const x2 = this.toXRangeBound(this.xRangeSources[1]); + return new util.NumberRange(x1, x2); + } + + get yRange(): util.NumberRange { + const y1 = this.getYRangeBound(this.yRangeSources[0]); + const y2 = this.getYRangeBound(this.yRangeSources[1]); + return new util.NumberRange(y1, y2); + } + + getHints(previousFrame: CursorFrame): CursorFrameHint[] { + return [...this.getRetriggerHints(previousFrame), ...this.getSustainHints(previousFrame)]; + } + + getActiveElements(): PlaybackElement[] { + return [...this.activeElements]; + } + + toHumanReadable(): string[] { + const tRangeDescription = this.describer.describeTRange(this.tRangeSources); + const xRangeDescription = this.describer.describeXRange(this.xRangeSources); + const yRangeDescription = this.describer.describeYRange(this.yRangeSources); + + return [`t: ${tRangeDescription}`, `x: ${xRangeDescription}`, `y: ${yRangeDescription}`]; + } + + private toXRangeBound(source: XRangeSource): number { + const rect = this.getXRangeRect(source); + switch (source.type) { + case 'system': + return source.bound === 'left' ? rect.left() : rect.right(); + case 'measure': + return source.bound === 'left' ? rect.left() : rect.right(); + case 'element': + return source.bound === 'left' ? rect.left() : rect.right(); + } + } + + private getXRangeRect(source: XRangeSource) { + switch (source.type) { + case 'system': + return ( + source.system + .getMeasures() + .at(0) + ?.getFragments() + .at(0) + ?.getParts() + .at(0) + ?.getStaves() + .at(0) + ?.intrinsicRect() ?? source.system.rect() + ); + case 'measure': + return ( + source.measure.getFragments().at(0)?.getParts().at(0)?.getStaves().at(0)?.intrinsicRect() ?? + source.measure.rect() + ); + case 'element': + return source.element.rect(); + } + } + + private getYRangeBound(source: YRangeSource): number { + return source.bound === 'top' ? source.part.rect().top() : source.part.rect().bottom(); + } + + private getRetriggerHints(previousFrame: CursorFrame): RetriggerHint[] { + const hints = new Array(); + if (this === previousFrame) { + return hints; + } + + const previousNotes = previousFrame.activeElements.filter((e) => e.name === 'note'); + const currentNotes = this.activeElements.filter((e) => e.name === 'note'); + + // Let N be the number of notes in a frame. This algorithm is O(N^2) in the worst case, but we expect to N to be + // very small. + for (const currentNote of currentNotes) { + const previousNote = previousNotes.find((previousNote) => + this.isPitchEqual(currentNote.getPitch(), previousNote.getPitch()) + ); + if (previousNote && !previousNote.sharesACurveWith(currentNote)) { + hints.push({ + type: 'retrigger', + untriggerElement: previousNote, + retriggerElement: currentNote, + }); + } + } + + return hints; + } + + private getSustainHints(previousFrame: CursorFrame): SustainHint[] { + const hints = new Array(); + if (this === previousFrame) { + return hints; + } + + const previousNotes = previousFrame.activeElements.filter((e) => e.name === 'note'); + const currentNotes = this.activeElements.filter((e) => e.name === 'note'); + + // Let N be the number of notes in a frame. This algorithm is O(N^2) in the worst case, but we expect to N to be + // very small. + for (const currentNote of currentNotes) { + const previousNote = previousNotes.find((previousNote) => + this.isPitchEqual(currentNote.getPitch(), previousNote.getPitch()) + ); + if (previousNote && previousNote.sharesACurveWith(currentNote)) { + hints.push({ + type: 'sustain', + previousElement: previousNote, + currentElement: currentNote, + }); + } + } + + return hints; + } + + private isPitchEqual(a: elements.Pitch, b: elements.Pitch): boolean { + return a.step === b.step && a.octave === b.octave && a.accidentalCode === b.accidentalCode; + } +} + +class CursorFrameFactory { + private frames = new Array(); + private activeElements = new Set(); + private describer: CursorFrameDescriber; + + constructor( + private logger: Logger, + private score: elements.Score, + private timeline: Timeline, + private span: CursorVerticalSpan + ) { + this.describer = CursorFrameDescriber.create(score, timeline.getPartIndex()); + } + + create(): CursorFrame[] { + this.frames = []; + this.activeElements = new Set(); + + for (let index = 0; index < this.timeline.getMomentCount() - 1; index++) { + const currentMoment = this.timeline.getMoment(index); + const nextMoment = this.timeline.getMoment(index + 1); + util.assertNotNull(currentMoment); + util.assertNotNull(nextMoment); + + const tRangeSources = this.getTRangeSources(currentMoment, nextMoment); + const xRangeSources = this.getXRangeSources(currentMoment, nextMoment); + const yRangeSources = this.getYRangeSources(currentMoment); + + this.updateActiveElements(currentMoment); + + this.addFrame(tRangeSources, xRangeSources, yRangeSources); + } + + return this.frames; + } + + private getTRangeSources(currentMoment: TimelineMoment, nextMoment: TimelineMoment): [TRangeSource, TRangeSource] { + return [{ moment: currentMoment }, { moment: nextMoment }]; + } + + private getXRangeSources(currentMoment: TimelineMoment, nextMoment: TimelineMoment): [XRangeSource, XRangeSource] { + return [this.getStartXSource(currentMoment), this.getEndXSource(nextMoment)]; + } + + private getStartXSource(moment: TimelineMoment): XRangeSource { + const hasStartingTransition = moment.events.some((e) => e.type === 'transition' && e.kind === 'start'); + if (hasStartingTransition) { + return this.getLeftmostStartingXRangeSource(moment); + } + + this.logger.warn( + 'No starting transition found for moment, ' + + 'but the moment is trying to be used as a starting anchor. ' + + 'How was the moment created?', + { moment } + ); + + const event = moment.events.at(0); + util.assertDefined(event); + + switch (event.type) { + case 'transition': + return { type: 'element', element: event.element, bound: 'left' }; + case 'systemend': + return { type: 'system', system: event.system, bound: 'left' }; + case 'jump': + return { type: 'measure', measure: event.measure, bound: 'left' }; + } + } + + private getEndXSource(nextMoment: TimelineMoment): XRangeSource { + const shouldUseMeasureEndBoundary = nextMoment.events.some((e) => e.type === 'jump' || e.type === 'systemend'); + if (shouldUseMeasureEndBoundary) { + const event = nextMoment.events.at(0); + util.assertDefined(event); + + switch (event.type) { + case 'transition': + return { type: 'measure', measure: event.measure, bound: 'right' }; + case 'systemend': + return { type: 'system', system: event.system, bound: 'right' }; + case 'jump': + return { type: 'measure', measure: event.measure, bound: 'right' }; + } + } + + return this.getStartXSource(nextMoment); + } + + private getLeftmostStartingXRangeSource(currentMoment: TimelineMoment): XRangeSource { + const elements = currentMoment.events + .filter((e) => e.type === 'transition') + .filter((e) => e.kind === 'start') + .map((e) => e.element); + + let min = Infinity; + let leftmost: PlaybackElement | undefined = undefined; + for (const element of elements) { + const left = element.rect().left(); + if (left < min) { + min = left; + leftmost = element; + } + } + + util.assertDefined(leftmost); + + return { type: 'element', element: leftmost, bound: 'left' }; + } + + private getYRangeSources(currentMoment: TimelineMoment): [YRangeSource, YRangeSource] { + const systemIndex = this.getSystemIndex(currentMoment); + + const parts = this.score + .getSystems() + .at(systemIndex)! + .getMeasures() + .flatMap((measure) => measure.getFragments()) + .flatMap((fragment) => fragment.getParts()); + + const topPart = parts.find((part) => part.getIndex() === this.span.fromPartIndex); + const bottomPart = parts.find((part) => part.getIndex() === this.span.toPartIndex); + util.assertDefined(topPart); + util.assertDefined(bottomPart); + + return [ + { part: topPart, bound: 'top' }, + { part: bottomPart, bound: 'bottom' }, + ]; + } + + private getSystemIndex(currentMoment: TimelineMoment): number { + const events = currentMoment.events.toSorted((a, b) => { + const kindOrder = { start: 0, stop: 1 }; + if (a.type === 'transition' && b.type === 'transition') { + return kindOrder[a.kind] - kindOrder[b.kind]; + } + const typeOrder = { transition: 0, systemend: 1, jump: 2 }; + return typeOrder[a.type] - typeOrder[b.type]; + }); + for (const event of events) { + switch (event.type) { + case 'transition': + return event.measure.getSystemIndex(); + case 'systemend': + return event.system.getIndex(); + case 'jump': + return event.measure.getSystemIndex(); + } + } + util.assertUnreachable(); + } + + private updateActiveElements(moment: TimelineMoment) { + for (const event of moment.events) { + if (event.type === 'transition') { + if (event.kind === 'start') { + this.activeElements.add(event.element); + } else if (event.kind === 'stop') { + this.activeElements.delete(event.element); + } + } + } + } + + private addFrame( + tRangeSources: [TRangeSource, TRangeSource], + xRangeSources: [XRangeSource, XRangeSource], + yRangeSources: [YRangeSource, YRangeSource] + ): void { + const frame = new CursorFrame( + tRangeSources, + xRangeSources, + yRangeSources, + [...this.activeElements], + this.describer + ); + this.frames.push(frame); + } +} + +class CursorFrameDescriber { + private constructor(private elements: Map) {} + + static create(score: elements.Score, partIndex: number): CursorFrameDescriber { + const elements = new Map(); + score + .getMeasures() + .flatMap((measure) => measure.getFragments()) + .flatMap((fragment) => fragment.getParts().at(partIndex) ?? []) + .flatMap((part) => part.getStaves()) + .flatMap((stave) => stave.getVoices()) + .flatMap((voice) => voice.getEntries()) + .forEach((element, index) => { + elements.set(element, index); + }); + return new CursorFrameDescriber(elements); + } + + describeTRange(tRangeSources: [TRangeSource, TRangeSource]): string { + return `[${tRangeSources[0].moment.time.ms}ms - ${tRangeSources[1].moment.time.ms}ms]`; + } + + describeXRange(xRangeSources: [XRangeSource, XRangeSource]): string { + return `[${this.describeXRangeSource(xRangeSources[0])} - ${this.describeXRangeSource(xRangeSources[1])}]`; + } + + describeYRange(yRangeSources: [YRangeSource, YRangeSource]): string { + return `[${this.describeYRangeSource(yRangeSources[0])} - ${this.describeYRangeSource(yRangeSources[1])}]`; + } + + private describeXRangeSource(source: XRangeSource): string { + switch (source.type) { + case 'system': + return `${source.bound}(system(${source.system.getIndex()}))`; + case 'measure': + return `${source.bound}(measure(${source.measure.getAbsoluteMeasureIndex()}))`; + case 'element': + return `${source.bound}(element(${this.elements.get(source.element)}))`; + } + } + + private describeYRangeSource(source: YRangeSource): string { + return `${source.bound}(system(${source.part.getSystemIndex()}), part(${source.part.getIndex()}))`; + } +} diff --git a/src/playback/cursorpath.ts b/src/playback/cursorpath.ts new file mode 100644 index 000000000..3136f56cb --- /dev/null +++ b/src/playback/cursorpath.ts @@ -0,0 +1,14 @@ +import { CursorFrame } from './cursorframe'; + +/** A collection of cursor frames for a given part index.. */ +export class CursorPath { + constructor(private partIndex: number, private frames: CursorFrame[]) {} + + getPartIndex(): number { + return this.partIndex; + } + + getFrames(): CursorFrame[] { + return this.frames; + } +} diff --git a/src/playback/duration.ts b/src/playback/duration.ts index 2d4e60979..663b014ef 100644 --- a/src/playback/duration.ts +++ b/src/playback/duration.ts @@ -24,6 +24,10 @@ export class Duration { return Duration.ms(durations.reduce((acc, duration) => acc + duration.ms, 0)); } + static max(...durations: Duration[]): Duration { + return Duration.ms(Math.max(...durations.map((duration) => duration.ms))); + } + private readonly _ms: number; private constructor(ms: number) { @@ -54,6 +58,16 @@ export class Duration { return this.ms <= duration.ms; } + compare(duration: Duration): -1 | 0 | 1 { + if (this.isLessThan(duration)) { + return -1; + } + if (this.isGreaterThan(duration)) { + return 1; + } + return 0; + } + get ms() { return this._ms; } diff --git a/src/playback/expensivelocator.ts b/src/playback/expensivelocator.ts deleted file mode 100644 index 823f1738d..000000000 --- a/src/playback/expensivelocator.ts +++ /dev/null @@ -1,34 +0,0 @@ -import * as playback from '@/playback'; -import * as util from '@/util'; - -export class ExpensiveLocator { - private sequence: playback.Sequence; - - constructor(sequence: playback.Sequence) { - this.sequence = sequence; - } - - locate(time: playback.Duration): number | null { - let left = 0; - let right = this.sequence.getCount() - 1; - - while (left <= right) { - const mid = Math.floor((left + right) / 2); - const entry = this.sequence.getEntry(mid); - - util.assertNotNull(entry); - - if (entry.durationRange.includes(time)) { - return mid; - } - - if (entry.durationRange.end.isGreaterThanOrEqual(time)) { - right = mid - 1; - } else { - left = mid + 1; - } - } - - return null; - } -} diff --git a/src/playback/fastcursorframelocator.ts b/src/playback/fastcursorframelocator.ts new file mode 100644 index 000000000..bd1e347e6 --- /dev/null +++ b/src/playback/fastcursorframelocator.ts @@ -0,0 +1,58 @@ +import { CursorPath } from './cursorpath'; +import { Duration } from './duration'; +import { CursorFrameLocator } from './types'; + +/** + * A CursorFrameLocator that uses O(1) time complexity to locate the frame at a given time before falling back to a more + * expensive locator. + */ +export class FastCursorFrameLocator implements CursorFrameLocator { + private index = 0; + + constructor(private path: CursorPath, private fallback: CursorFrameLocator) {} + + locate(time: Duration): number | null { + const frames = this.path.getFrames(); + + if (time.isLessThan(Duration.zero())) { + return this.update(0); + } + + if (time.isGreaterThan(this.getDuration())) { + return this.update(frames.length - 1); + } + + const previousIndex = this.index - 1; + if (previousIndex >= 0 && frames.at(previousIndex)?.tRange.includes(time)) { + return this.update(previousIndex); + } + + const currentIndex = this.index; + if (frames.at(currentIndex)?.tRange.includes(time)) { + return this.update(currentIndex); + } + + const nextIndex = this.index + 1; + if (frames.at(nextIndex)?.tRange.includes(time)) { + return this.update(nextIndex); + } + + const index = this.fallback.locate(time); + if (typeof index === 'number') { + return this.update(index); + } + + this.update(0); + + return null; + } + + private update(index: number): number { + this.index = index; + return index; + } + + private getDuration(): Duration { + return this.path.getFrames().at(-1)?.tRange.end ?? Duration.zero(); + } +} diff --git a/src/playback/index.ts b/src/playback/index.ts index ccc264559..27fa1d10b 100644 --- a/src/playback/index.ts +++ b/src/playback/index.ts @@ -1,6 +1,8 @@ +export * from './cursor'; +export * from './cursorpath'; export * from './duration'; +export * from './durationrange'; export * from './timestamplocator'; -export * from './sequence'; -export * from './sequencefactory'; +export * from './timeline'; export * from './types'; -export * from './cursor'; +export * from './cursorframe'; diff --git a/src/playback/measuresequenceiterator.ts b/src/playback/measuresequenceiterator.ts index 018bcd4ec..984aba679 100644 --- a/src/playback/measuresequenceiterator.ts +++ b/src/playback/measuresequenceiterator.ts @@ -49,17 +49,7 @@ export class MeasureSequenceIterator implements Iterable(); + let i = measureIndex; + while (i > startMeasureIndex && measures[i].jumps.some((jump) => jump.type === 'repeatending')) { + excluding.push(i); + i--; + } + result.push( new Repeat({ id: nextId++, times: 1, from: startMeasureIndex, to: measureIndex, - excluding: [measureIndex], + excluding, }) ); } diff --git a/src/playback/sequence.ts b/src/playback/sequence.ts deleted file mode 100644 index 155d4ed10..000000000 --- a/src/playback/sequence.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Duration } from './duration'; -import { SequenceEntry } from './types'; - -export class Sequence { - constructor(private partIndex: number, private entries: SequenceEntry[]) {} - - getPartIndex(): number { - return this.partIndex; - } - - getEntry(index: number): SequenceEntry | null { - return this.entries.at(index) ?? null; - } - - getEntries(): SequenceEntry[] { - return this.entries; - } - - getCount(): number { - return this.entries.length; - } - - getDuration(): Duration { - return this.entries.at(-1)?.durationRange.end ?? Duration.zero(); - } -} diff --git a/src/playback/sequencefactory.ts b/src/playback/sequencefactory.ts deleted file mode 100644 index ae800bf49..000000000 --- a/src/playback/sequencefactory.ts +++ /dev/null @@ -1,390 +0,0 @@ -import * as elements from '@/elements'; -import * as util from '@/util'; -import { NumberRange } from '@/util'; -import { Duration } from './duration'; -import { Sequence } from './sequence'; -import { PlaybackElement, SequenceEntry } from './types'; -import { DurationRange } from './durationrange'; -import { MeasureSequenceIterator } from './measuresequenceiterator'; -import { Logger } from '@/debug'; - -const LAST_SYSTEM_MEASURE_X_RANGE_PADDING_RIGHT = 10; - -type SequenceEvent = { - type: 'start' | 'stop'; - time: Duration; - element: PlaybackElement; -}; - -export class SequenceFactory { - constructor(private log: Logger, private score: elements.Score) {} - - create(): Sequence[] { - const sequences = new Array(); - - const partCount = this.score.getPartCount(); - - for (let partIndex = 0; partIndex < partCount; partIndex++) { - const events = this.getSequenceEvents(partIndex); - const entries = this.toSequenceEntries(events); - const sequence = new Sequence(partIndex, entries); - sequences.push(sequence); - } - - return sequences; - } - - private getSequenceEvents(partIndex: number): SequenceEvent[] { - const events = new Array(); - - const measures = this.score.getMeasures().map((measure, index) => ({ - index, - value: measure, - fragments: measure.getFragments(), - jumps: measure.getJumps(), - })); - - const iterator = new MeasureSequenceIterator(measures); - - let measureStartTime = Duration.zero(); - - for (const measureIndex of iterator) { - const measure = measures[measureIndex]; - - let nextMeasureStartTime = measureStartTime; - - if (measure.value.isMultiMeasure()) { - const start = measureStartTime; - const bpm = measure.value.getBpm(); - const duration = Duration.minutes(measure.value.getBeatCount().toDecimal() / bpm); - const stop = start.add(duration); - - events.push({ type: 'start', time: start, element: measure.value }); - events.push({ type: 'stop', time: stop, element: measure.value }); - - // measureStartTime, not nextMeasureStartTime! - measureStartTime = stop; - - continue; - } - - for (const fragment of measure.fragments) { - if (fragment.isNonMusicalGap()) { - const start = measureStartTime; - const duration = Duration.ms(fragment.getNonMusicalDurationMs()); - const stop = start.add(duration); - - events.push({ type: 'start', time: start, element: fragment }); - events.push({ type: 'stop', time: stop, element: fragment }); - - nextMeasureStartTime = stop; - - continue; - } - - const voiceEntries = fragment - .getParts() - .filter((part) => part.getIndex() === partIndex) - .flatMap((fragmentPart) => fragmentPart.getStaves()) - .flatMap((stave) => stave.getVoices()) - .flatMap((voice) => voice.getEntries()); - - const bpm = fragment.getBpm(); - - for (const voiceEntry of voiceEntries) { - // NOTE: getStartMeasureBeat() is relative to the start of the measure. - const start = measureStartTime.add(Duration.minutes(voiceEntry.getStartMeasureBeat().toDecimal() / bpm)); - const duration = Duration.minutes(voiceEntry.getBeatCount().toDecimal() / bpm); - const stop = start.add(duration); - - events.push({ type: 'start', time: start, element: voiceEntry }); - events.push({ type: 'stop', time: stop, element: voiceEntry }); - - if (stop.isGreaterThan(nextMeasureStartTime)) { - nextMeasureStartTime = stop; - } - } - } - - measureStartTime = nextMeasureStartTime; - } - - return events.sort((a, b) => { - if (a.time.ms !== b.time.ms) { - return a.time.ms - b.time.ms; - } - - if (a.type !== b.type) { - // Stop events should come before start events. - return a.type === 'stop' ? -1 : 1; - } - - // If two events occur at the same time and have the same type, sort by x-coordinate. - return a.element.rect().center().x - b.element.rect().center().x; - }); - } - - private toSequenceEntries(events: SequenceEvent[]): SequenceEntry[] { - const measures = this.score.getMeasures(); - const builder = new SequenceEntryBuilder(this.log, measures); - - for (const event of events) { - builder.add(event); - } - - return builder.build(); - } -} - -type XRangeInstruction = - | 'anchor-to-next-event' - | 'activate-only' - | 'terminate-to-measure-end-and-reanchor' - | 'defer-for-interpolation' - | 'ignore'; - -/** SequenceEntryBuilder incrementally transforms SequenceEvents to SequenceEntries. */ -class SequenceEntryBuilder { - private entries = new Array(); - private anchor: PlaybackElement | null = null; - private active = new Array(); - private pending = new Array(); - private x = -1; - private t = Duration.ms(-1); - private built = false; - - constructor(private log: Logger, private measures: elements.Measure[]) {} - - add(event: SequenceEvent): void { - if (event.type === 'start') { - this.start(event); - } else { - this.stop(event); - } - } - - build(): SequenceEntry[] { - util.assert(!this.built, 'SequenceEntryBuilder has already built'); - - if (this.anchor && this.pending.length > 0) { - // We account for the last stop event by using the time of the last entry as a start bound. - const event = this.pending.at(-1)!; - const x1 = this.x; - const x2 = this.getMeasureRightBoundaryX(event.element); - const t1 = this.t; - const t2 = event.time; - this.push(x1, x2, t1, t2, this.anchor, this.active); - } - - this.built = true; - - return this.entries; - } - - private start(event: SequenceEvent): void { - if (this.anchor) { - const instruction = this.getXRangeInstruction(this.anchor, event.element); - - if (instruction === 'anchor-to-next-event') { - let x1 = this.x; - const x2 = this.getLeftBoundaryX(event.element); - const t1 = this.t; - const t2 = event.time; - - if (x1 > x2) { - // See https://github.com/stringsync/vexml/issues/264 for context. - this.log.warn('encountered a sequence-building issue where x1 > x2, forcing a fix', { - x1, - x2, - x: this.x, - absoluteMeasureIndex: event.element.getAbsoluteMeasureIndex(), - }); - x1 = this.anchor.rect().center().x; - } - - this.processPending(new NumberRange(x1, x2), t1); - this.active.push(event.element); - this.push(x1, x2, t1, t2, this.anchor, this.active); - - this.x = x2; - this.t = t2; - } else if (instruction === 'terminate-to-measure-end-and-reanchor') { - const x1 = this.x; - const x2 = this.getMeasureRightBoundaryX(this.anchor); - const t1 = this.t; - const t2 = event.time; - - this.processPending(new NumberRange(x1, x2), t1); - this.active.push(event.element); - this.push(x1, x2, t1, t2, this.anchor, this.active); - - this.x = this.getLeftBoundaryX(event.element); - this.t = t2; - } else if (instruction === 'defer-for-interpolation') { - this.pending.push(event); - } else if (instruction === 'ignore') { - // noop - } else if (instruction === 'activate-only') { - this.entries.at(-1)?.activeElements.push(event.element); - this.active.push(event.element); - } else { - util.assertUnreachable(); - } - } else { - this.x = this.getLeftBoundaryX(event.element); - this.t = event.time; - } - - this.anchor = event.element; - } - - private stop(event: SequenceEvent): void { - // A stop event does not provide a closing x-range boundary, so we don't know where to terminate the in-flight - // sequence entry. We'll enqueue it for now, and then process it once we have a start event that can provide the - // closing x-range boundary. - this.pending.push(event); - } - - private processPending(xRange: NumberRange, t1: Duration): void { - // Now that we have a closing x-range boundary, we can process the pending events that occurred. - while (this.pending.length > 0) { - const event = this.pending.shift()!; - - const alpha = (event.time.ms - t1.ms) / xRange.getSize(); - - const x1 = this.x; - const x2 = util.lerp(xRange.start, xRange.end, alpha); - // t1 is given - const t2 = event.time; - - if (event.type === 'start') { - if (x2 < xRange.end && t2.isLessThan(t1)) { - this.push(x1, x2, t1, t2, this.anchor!, this.active); - } - this.active.push(event.element); - } else { - if (x2 < xRange.end && t2.isLessThan(t1)) { - this.push(x1, x2, t1, t2, this.anchor!, this.active); - } - this.active.splice(this.active.indexOf(event.element), 1); - } - - this.x = x2; - } - } - - private push( - x1: number, - x2: number, - t1: Duration, - t2: Duration, - anchor: PlaybackElement, - active: PlaybackElement[] - ): void { - const durationRange = new DurationRange(t1, t2); - const xRange = new NumberRange(x1, x2); - this.entries.push({ durationRange, xRange, anchorElement: anchor, activeElements: [...active] }); - } - - private getLeftBoundaryX(element: PlaybackElement): number { - switch (element.name) { - case 'fragment': - return this.getFragmentLeftBoundaryX(element); - case 'measure': - return this.getMeasureLeftBoundaryX(element); - case 'note': - case 'rest': - return this.getVoiceEntryBoundaryX(element); - default: - util.assertUnreachable(); - } - } - - private getMeasureRightBoundaryX(element: PlaybackElement): number { - const measure = this.measures.find((measure) => - measure.includesAbsoluteMeasureIndex(element.getAbsoluteMeasureIndex()) - ); - util.assertDefined(measure); - - let result = measure.rect().right(); - if (measure.isLastMeasureInSystem()) { - result -= LAST_SYSTEM_MEASURE_X_RANGE_PADDING_RIGHT; - } - return result; - } - - private getFragmentLeftBoundaryX(fragment: elements.Fragment): number { - return ( - fragment - .getParts() - .flatMap((part) => part.getStaves()) - .map((stave) => stave.playableRect().left()) - .at(0) ?? fragment.rect().left() - ); - } - - private getMeasureLeftBoundaryX(measure: elements.Measure): number { - const fragment = measure.getFragments().at(0); - if (fragment) { - return this.getFragmentLeftBoundaryX(fragment); - } - return measure.rect().left(); - } - - private getVoiceEntryBoundaryX(voiceEntry: elements.VoiceEntry): number { - return voiceEntry.rect().center().x; - } - - private getXRangeInstruction(previous: PlaybackElement, current: PlaybackElement): XRangeInstruction { - const systemIndex1 = previous.getSystemIndex(); - const systemIndex2 = current.getSystemIndex(); - const measureIndex1 = previous.getAbsoluteMeasureIndex(); - const measureIndex2 = current.getAbsoluteMeasureIndex(); - const startMeasureBeat1 = previous.getStartMeasureBeat(); - const startMeasureBeat2 = current.getStartMeasureBeat(); - - const x1 = previous.rect().center().x; - const x2 = current.rect().center().x; - - if (x1 === x2) { - // This is common when a part has multiple staves. When elements have the same x-coordinate, we'll just add the - // current element to the active list. - return 'activate-only'; - } - - const isProgressingNormallyInTheSameMeasure = - measureIndex1 === measureIndex2 && startMeasureBeat1.isLessThan(startMeasureBeat2); - const isProgressingNormallyAcrossMeasures = measureIndex1 + 1 === measureIndex2; - const isProgressingNormally = isProgressingNormallyInTheSameMeasure || isProgressingNormallyAcrossMeasures; - - if (isProgressingNormally && x1 < x2) { - return 'anchor-to-next-event'; - } - - // Below this point, we need to figure out why this is not progressing normally x1 >= x2. - - if (systemIndex1 < systemIndex2) { - return 'terminate-to-measure-end-and-reanchor'; - } - - if (measureIndex1 === measureIndex2 && startMeasureBeat1.isGreaterThanOrEqualTo(startMeasureBeat2)) { - // This is ultimately a formatting issue: the current element is rendered before the previous element, even though - // the current element is played later. In this case, we'll just ignore it and keep progressing until we can find - // a valid movement forward. - return 'defer-for-interpolation'; - } - - if (measureIndex1 > measureIndex2) { - return 'terminate-to-measure-end-and-reanchor'; - } - - // NOTE: Currently, we cannot detect a valid jump forward _in the same measure_. We consider this exceptionally - // rare and playback is not support for this case. - if (measureIndex1 + 1 < measureIndex2) { - return 'terminate-to-measure-end-and-reanchor'; - } - - // At this point, we're in a non-ideal state that isn't covered by any of the cases above. We'll just ignore it. - return 'ignore'; - } -} diff --git a/src/playback/timeline.ts b/src/playback/timeline.ts new file mode 100644 index 000000000..aa8fcf97d --- /dev/null +++ b/src/playback/timeline.ts @@ -0,0 +1,291 @@ +import { Logger } from '@/debug'; +import { Duration } from './duration'; +import { PlaybackElement, TimelineMoment, TimelineMomentEvent, ElementTransitionEvent } from './types'; +import * as elements from '@/elements'; +import { MeasureSequenceIterator } from './measuresequenceiterator'; +import * as util from '@/util'; + +export class Timeline { + constructor(private partIndex: number, private moments: TimelineMoment[], private describer: TimelineDescriber) {} + + static create(log: Logger, score: elements.Score): Timeline[] { + const partCount = score.getPartCount(); + const timelines = new Array(partCount); + for (let partIndex = 0; partIndex < partCount; partIndex++) { + timelines[partIndex] = new TimelineFactory(log, score, partIndex).create(); + } + return timelines; + } + + getPartIndex(): number { + return this.partIndex; + } + + getMoment(index: number): TimelineMoment | null { + return this.moments.at(index) ?? null; + } + + getMoments(): TimelineMoment[] { + return this.moments; + } + + getMomentCount(): number { + return this.moments.length; + } + + getDuration(): Duration { + return this.moments.at(-1)?.time ?? Duration.zero(); + } + + toHumanReadable(): string[] { + return this.describer.describe(this.moments); + } +} + +class TimelineFactory { + // timeMs -> moment + private moments = new Map(); + private currentMeasureStartTime = Duration.zero(); + private nextMeasureStartTime = Duration.zero(); + + constructor(private logger: Logger, private score: elements.Score, private partIndex: number) {} + + create(): Timeline { + this.moments = new Map(); + this.currentMeasureStartTime = Duration.zero(); + + this.populateMoments(); + this.sortEventsWithinMoments(); + + const moments = this.getSortedMoments(); + const describer = TimelineDescriber.create(this.score, this.partIndex); + + return new Timeline(this.partIndex, moments, describer); + } + + private getMeasuresInPlaybackOrder(): Array<{ measure: elements.Measure; willJump: boolean }> { + const measures = this.score.getMeasures(); + + const measureIndexes = Array.from( + new MeasureSequenceIterator(measures.map((measure, index) => ({ index, jumps: measure.getJumps() }))) + ); + + const result = new Array<{ measure: elements.Measure; willJump: boolean }>(); + + for (let i = 0; i < measureIndexes.length; i++) { + const current = measureIndexes[i]; + const next = measureIndexes.at(i + 1); + const willJump = typeof next === 'number' && next !== current + 1; + const measure = measures[current]; + result.push({ measure, willJump }); + } + + return result; + } + + private proposeNextMeasureStartTime(time: Duration): void { + this.nextMeasureStartTime = Duration.max(this.nextMeasureStartTime, time); + } + + private toDuration(beat: util.Fraction, bpm: number): Duration { + const duration = Duration.minutes(beat.divide(new util.Fraction(bpm)).toDecimal()); + // Round to the nearest 100ms. This is needed to correctly group transitions that should belong together. + const ms = Math.round(duration.ms / 100) * 100; + return Duration.ms(ms); + } + + private populateMoments(): void { + for (const { measure, willJump } of this.getMeasuresInPlaybackOrder()) { + if (measure.isMultiMeasure()) { + this.populateMultiMeasureEvents(measure); + } else { + this.populateFragmentEvents(measure); + } + + this.currentMeasureStartTime = this.nextMeasureStartTime; + + if (willJump) { + this.addJumpEvent(this.currentMeasureStartTime, measure); + } else if (measure.isLastMeasureInSystem()) { + const system = this.score.getSystems().at(measure.getSystemIndex()); + util.assertDefined(system); + this.addSystemEndEvent(this.currentMeasureStartTime, system); + } + } + } + + private populateMultiMeasureEvents(measure: elements.Measure): void { + util.assert(measure.isMultiMeasure(), 'measure must be a multi-measure'); + + const bpm = measure.getBpm(); + const duration = this.toDuration(measure.getBeatCount(), bpm); + const startTime = this.currentMeasureStartTime; + const stopTime = startTime.add(duration); + + this.addTransitionStartEvent(startTime, measure, measure); + this.addTransitionStopEvent(stopTime, measure, measure); + + this.proposeNextMeasureStartTime(stopTime); + } + + private populateFragmentEvents(measure: elements.Measure): void { + for (const fragment of measure.getFragments()) { + if (fragment.isNonMusicalGap()) { + this.populateNonMusicalGapEvents(fragment, measure); + } else { + this.populateVoiceEntryEvents(fragment, measure); + } + } + } + + private populateNonMusicalGapEvents(fragment: elements.Fragment, measure: elements.Measure): void { + const duration = Duration.ms(fragment.getNonMusicalDurationMs()); + const startTime = this.currentMeasureStartTime; + const stopTime = startTime.add(duration); + + this.addTransitionStartEvent(startTime, measure, fragment); + this.addTransitionStopEvent(stopTime, measure, fragment); + + this.proposeNextMeasureStartTime(stopTime); + } + + private populateVoiceEntryEvents(fragment: elements.Fragment, measure: elements.Measure): void { + const voiceEntries = fragment + .getParts() + .filter((part) => part.getIndex() === this.partIndex) + .flatMap((fragmentPart) => fragmentPart.getStaves()) + .flatMap((stave) => stave.getVoices()) + .flatMap((voice) => voice.getEntries()); + + const bpm = fragment.getBpm(); + + for (const voiceEntry of voiceEntries) { + const duration = this.toDuration(voiceEntry.getBeatCount(), bpm); + // NOTE: getStartMeasureBeat() is relative to the start of the measure. + const startTime = this.currentMeasureStartTime.add(this.toDuration(voiceEntry.getStartMeasureBeat(), bpm)); + const stopTime = startTime.add(duration); + + this.addTransitionStartEvent(startTime, measure, voiceEntry); + this.addTransitionStopEvent(stopTime, measure, voiceEntry); + + this.proposeNextMeasureStartTime(stopTime); + } + } + + private sortEventsWithinMoments(): void { + for (const moment of this.moments.values()) { + moment.events.sort((a, b) => { + return this.getEventTypeOrder(a) - this.getEventTypeOrder(b); + }); + } + } + + private getEventTypeOrder(event: TimelineMomentEvent): number { + if (event.type === 'transition' && event.kind === 'stop') { + return 0; + } + if (event.type === 'jump') { + return 1; + } + if (event.type === 'systemend') { + return 2; + } + if (event.type === 'transition' && event.kind === 'start') { + return 3; + } + util.assertUnreachable(); + } + + private upsert(time: Duration, event: TimelineMomentEvent): TimelineMoment { + let moment: TimelineMoment; + if (this.moments.has(time.ms)) { + moment = this.moments.get(time.ms)!; + moment.events.push(event); + } else { + moment = { time, events: [event] }; + this.moments.set(time.ms, moment); + } + return moment; + } + + private addTransitionStartEvent(time: Duration, measure: elements.Measure, element: PlaybackElement): void { + this.upsert(time, { + type: 'transition', + kind: 'start', + measure, + element, + }); + } + + private addTransitionStopEvent(time: Duration, measure: elements.Measure, element: PlaybackElement): void { + this.upsert(time, { + type: 'transition', + kind: 'stop', + measure, + element, + }); + } + + private addJumpEvent(time: Duration, measure: elements.Measure): void { + this.upsert(time, { type: 'jump', measure }); + } + + private addSystemEndEvent(time: Duration, system: elements.System): void { + this.upsert(time, { type: 'systemend', system }); + } + + private getSortedMoments(): TimelineMoment[] { + const moments = Array.from(this.moments.values()); + return moments.sort((a, b) => a.time.compare(b.time)); + } +} + +class TimelineDescriber { + private constructor(private elements: Map) {} + + static create(score: elements.Score, partIndex: number): TimelineDescriber { + const elements = new Map(); + score + .getMeasures() + .flatMap((measure) => measure.getFragments()) + .flatMap((fragment) => fragment.getParts().at(partIndex) ?? []) + .flatMap((part) => part.getStaves()) + .flatMap((stave) => stave.getVoices()) + .flatMap((voice) => voice.getEntries()) + .forEach((element, index) => { + elements.set(element, index); + }); + return new TimelineDescriber(elements); + } + + describe(moments: TimelineMoment[]): string[] { + return moments.map((moment) => this.describeMoment(moment)); + } + + private describeMoment(moment: TimelineMoment): string { + return `[${moment.time.ms}ms] ${moment.events.map((event) => this.describeEvent(event)).join(', ')}`; + } + + private describeEvent(event: TimelineMomentEvent): string { + switch (event.type) { + case 'transition': + return this.describeTransition(event); + case 'jump': + return this.describeJump(); + case 'systemend': + return this.describeSystemEnd(); + } + } + + private describeTransition(event: ElementTransitionEvent): string { + return `${event.kind}(${this.elements.get(event.element)})`; + } + + private describeJump(): string { + return 'jump'; + } + + private describeSystemEnd(): string { + return 'systemend'; + } +} diff --git a/src/playback/timestamplocator.ts b/src/playback/timestamplocator.ts index 0d7064ecc..efe3b11d8 100644 --- a/src/playback/timestamplocator.ts +++ b/src/playback/timestamplocator.ts @@ -2,29 +2,33 @@ import * as spatial from '@/spatial'; import * as elements from '@/elements'; import * as util from '@/util'; import { Duration } from './duration'; -import { Sequence } from './sequence'; -import { SequenceEntry } from './types'; +import { CursorPath } from './cursorpath'; +import { CursorFrame } from './cursorframe'; type System = { yRange: util.NumberRange; - entries: SequenceEntry[]; + frames: CursorFrame[]; }; export class TimestampLocator { private constructor(private systems: System[]) {} - static create(score: elements.Score, sequences: Sequence[]): TimestampLocator { + static create(score: elements.Score, paths: CursorPath[]): TimestampLocator { const systems = score.getSystems().map((system) => { const yRange = new util.NumberRange(system.rect().top(), system.rect().bottom()); - const entries = new Array(); - for (const sequence of sequences) { - entries.push( - ...sequence.getEntries().filter((entry) => entry.anchorElement.getSystemIndex() === system.getIndex()) + const frames = new Array(); + for (const path of paths) { + frames.push( + ...path + .getFrames() + .filter((frame) => + frame.getActiveElements().some((element) => element.getSystemIndex() === system.getIndex()) + ) ); } - return { yRange, entries }; + return { yRange, frames }; }); return new TimestampLocator(systems); @@ -41,13 +45,13 @@ export class TimestampLocator { if (!system.yRange.includes(point.y)) { continue; } - for (const entry of system.entries) { - if (!entry.xRange.includes(point.x)) { + for (const frame of system.frames) { + if (!frame.xRange.includes(point.x)) { continue; } - const startMs = entry.durationRange.start.ms; - const stopMs = entry.durationRange.end.ms; - const alpha = (point.x - entry.xRange.start) / entry.xRange.getSize(); + const startMs = frame.tRange.start.ms; + const stopMs = frame.tRange.end.ms; + const alpha = (point.x - frame.xRange.start) / frame.xRange.getSize(); const timestampMs = util.lerp(startMs, stopMs, alpha); return Duration.ms(timestampMs); } diff --git a/src/playback/types.ts b/src/playback/types.ts index 999a6a140..a54512c09 100644 --- a/src/playback/types.ts +++ b/src/playback/types.ts @@ -1,8 +1,9 @@ import * as elements from '@/elements'; import { NumberRange } from '@/util'; import { DurationRange } from './durationrange'; +import { Duration } from './duration'; -export type SequenceEntry = { +export type LegacySequenceEntry = { anchorElement: PlaybackElement; activeElements: PlaybackElement[]; durationRange: DurationRange; @@ -10,3 +11,51 @@ export type SequenceEntry = { }; export type PlaybackElement = elements.VoiceEntry | elements.Fragment | elements.Measure; + +export type TimelineMoment = { + time: Duration; + events: [TimelineMomentEvent, ...TimelineMomentEvent[]]; +}; + +export type TimelineMomentEvent = ElementTransitionEvent | JumpEvent | SystemEndEvent; + +export interface CursorFrameLocator { + /** Returns the index of the element that is active at the given time. */ + locate(time: Duration): number | null; +} + +export type ElementTransitionEvent = { + type: 'transition'; + kind: 'start' | 'stop'; + measure: elements.Measure; + element: PlaybackElement; +}; + +export type JumpEvent = { + type: 'jump'; + measure: elements.Measure; +}; + +export type SystemEndEvent = { + type: 'systemend'; + system: elements.System; +}; + +export type CursorFrameHint = RetriggerHint | SustainHint; + +export type RetriggerHint = { + type: 'retrigger'; + untriggerElement: PlaybackElement; + retriggerElement: PlaybackElement; +}; + +export type SustainHint = { + type: 'sustain'; + previousElement: PlaybackElement; + currentElement: PlaybackElement; +}; + +export type CursorVerticalSpan = { + fromPartIndex: number; + toPartIndex: number; +}; diff --git a/src/spatial/index.ts b/src/spatial/index.ts index 3717930f2..9dc1c7959 100644 --- a/src/spatial/index.ts +++ b/src/spatial/index.ts @@ -1,5 +1,5 @@ export * from './point'; -export * from './rect'; export * from './circle'; export * from './types'; export * from './quadtree'; +export * from './rect'; diff --git a/src/spatial/rect.ts b/src/spatial/rect.ts index 8067ee00b..cb46b66ae 100644 --- a/src/spatial/rect.ts +++ b/src/spatial/rect.ts @@ -1,5 +1,6 @@ import { Point } from './point'; import { Shape } from './types'; +import * as util from '@/util'; /** Represents a rectangle in a 2D coordinate system. */ export class Rect implements Shape { @@ -22,6 +23,10 @@ export class Rect implements Shape { return new Rect(shape.left(), shape.top(), shape.right() - shape.left(), shape.bottom() - shape.top()); } + static fromRanges({ xRange, yRange }: { xRange: util.NumberRange; yRange: util.NumberRange }): Rect { + return new Rect(xRange.start, yRange.start, xRange.getSize(), yRange.getSize()); + } + static empty() { return new Rect(0, 0, 0, 0); } diff --git a/src/util/numberrange.ts b/src/util/numberrange.ts index bf6fc5f09..07ea889bf 100644 --- a/src/util/numberrange.ts +++ b/src/util/numberrange.ts @@ -1,3 +1,5 @@ +import { lerp } from './math'; + export class NumberRange { public readonly start: number; public readonly end: number; @@ -14,6 +16,10 @@ export class NumberRange { return this.end - this.start; } + lerp(alpha: number): number { + return lerp(this.start, this.end, alpha); + } + includes(value: number): boolean { return value >= this.start && value <= this.end; } diff --git a/tests/__data__/vexml/playback_multi_measure.musicxml b/tests/__data__/vexml/playback_multi_measure.musicxml new file mode 100644 index 000000000..f977343a3 --- /dev/null +++ b/tests/__data__/vexml/playback_multi_measure.musicxml @@ -0,0 +1,139 @@ + + + + + Untitled score + + + Composer / arranger + + MuseScore 4.5.0 + 2025-03-17 + + + + + + + + + + Flute + Fl. + + Flute + wind.flutes.flute + + + + 1 + 74 + 78.7402 + 0 + + + + + + + 1 + + 0 + + + + G + 2 + + + + + F + 4 + + 1 + 1 + quarter + up + + + + A + 4 + + 1 + 1 + quarter + up + + + + C + 5 + + 1 + 1 + quarter + down + + + + E + 5 + + 1 + 1 + quarter + down + + + + + + F + 5 + + 1 + 1 + quarter + down + + + + D + 5 + + 1 + 1 + quarter + down + + + + B + 4 + + 1 + 1 + quarter + down + + + + G + 4 + + 1 + 1 + quarter + up + + + light-heavy + + + + diff --git a/tests/__data__/vexml/playback_multi_part.musicxml b/tests/__data__/vexml/playback_multi_part.musicxml new file mode 100644 index 000000000..954f56221 --- /dev/null +++ b/tests/__data__/vexml/playback_multi_part.musicxml @@ -0,0 +1,229 @@ + + + + + Untitled score + + + Composer / arranger + + MuseScore 4.5.0 + 2025-03-18 + + + + + + + + + + Flute + Fl. + + Flute + wind.flutes.flute + + + + 1 + 74 + 78.7402 + 0 + + + + Piano + Pno. + + Piano + keyboard.piano + + + + 2 + 1 + 78.7402 + 0 + + + + + + + 1 + + 0 + + + + G + 2 + + + + + F + 4 + + 1 + 1 + quarter + up + + + + A + 4 + + 1 + 1 + quarter + up + + + + C + 5 + + 1 + 1 + quarter + down + + + + E + 5 + + 1 + 1 + quarter + down + + + light-heavy + + + + + + + 1 + + 0 + + + 2 + + G + 2 + + + F + 4 + + + + + F + 4 + + 1 + 1 + quarter + up + 1 + + + + A + 4 + + 1 + 1 + quarter + up + 1 + + + + C + 5 + + 1 + 1 + quarter + down + 1 + + + + E + 5 + + 1 + 1 + quarter + down + 1 + + + 4 + + + + A + 2 + + 1 + 5 + quarter + up + 2 + + + + C + 3 + + 1 + 5 + quarter + up + 2 + + + + E + 3 + + 1 + 5 + quarter + down + 2 + + + + G + 3 + + 1 + 5 + quarter + down + 2 + + + light-heavy + + + + diff --git a/tests/__data__/vexml/playback_multi_stave.musicxml b/tests/__data__/vexml/playback_multi_stave.musicxml new file mode 100644 index 000000000..4ff05430d --- /dev/null +++ b/tests/__data__/vexml/playback_multi_stave.musicxml @@ -0,0 +1,205 @@ + + + + + Untitled score + + + Composer / arranger + + MuseScore 4.5.0 + 2025-03-17 + + + + + + + + + + Piano + Pno. + + Piano + keyboard.piano + + + + 1 + 1 + 78.7402 + 0 + + + + + + + 2 + + 0 + + + 2 + + G + 2 + + + F + 4 + + + + + F + 4 + + 2 + 1 + quarter + up + 1 + + + + A + 4 + + 2 + 1 + quarter + up + 1 + + + + C + 5 + + 2 + 1 + quarter + down + 1 + + + + E + 5 + + 2 + 1 + quarter + down + 1 + + + 8 + + + + A + 2 + + 1 + 5 + eighth + up + 2 + begin + + + + B + 2 + + 1 + 5 + eighth + up + 2 + continue + + + + C + 3 + + 1 + 5 + eighth + up + 2 + continue + + + + D + 3 + + 1 + 5 + eighth + up + 2 + end + + + + E + 3 + + 1 + 5 + eighth + down + 2 + begin + + + + F + 3 + + 1 + 5 + eighth + down + 2 + continue + + + + G + 3 + + 1 + 5 + eighth + down + 2 + continue + + + + A + 3 + + 1 + 5 + eighth + down + 2 + end + + + light-heavy + + + + diff --git a/tests/__data__/vexml/playback_multi_system.musicxml b/tests/__data__/vexml/playback_multi_system.musicxml new file mode 100644 index 000000000..172ffc537 --- /dev/null +++ b/tests/__data__/vexml/playback_multi_system.musicxml @@ -0,0 +1,154 @@ + + + + + Untitled score + + + Composer / arranger + + MuseScore 4.5.0 + 2025-03-17 + + + + + + + + + + Flute + Fl. + + Flute + wind.flutes.flute + + + + 1 + 74 + 78.7402 + 0 + + + + + + + 1 + + 0 + + + + G + 2 + + + + + E + 4 + + 4 + 1 + whole + + + + + + F + 4 + + 4 + 1 + whole + + + + + + G + 4 + + 4 + 1 + whole + + + + + + A + 4 + + 4 + 1 + whole + + + + + + B + 4 + + 4 + 1 + whole + + + + + + C + 5 + + 4 + 1 + whole + + + + + + D + 5 + + 4 + 1 + whole + + + + + + E + 5 + + 4 + 1 + whole + + + + + + F + 5 + + 4 + 1 + whole + + + light-heavy + + + + diff --git a/tests/__data__/vexml/playback_repeat.musicxml b/tests/__data__/vexml/playback_repeat.musicxml new file mode 100644 index 000000000..428ad5f7e --- /dev/null +++ b/tests/__data__/vexml/playback_repeat.musicxml @@ -0,0 +1,98 @@ + + + + + Untitled score + + + Composer / arranger + + MuseScore 4.5.0 + 2025-03-17 + + + + + + + + + + Flute + Fl. + + Flute + wind.flutes.flute + + + + 1 + 74 + 78.7402 + 0 + + + + + + + 1 + + 0 + + + + G + 2 + + + + + F + 4 + + 1 + 1 + quarter + up + + + + A + 4 + + 1 + 1 + quarter + up + + + + C + 5 + + 1 + 1 + quarter + down + + + + E + 5 + + 1 + 1 + quarter + down + + + light-heavy + + + + + diff --git a/tests/__data__/vexml/playback_repeat_endings.musicxml b/tests/__data__/vexml/playback_repeat_endings.musicxml new file mode 100644 index 000000000..83ba17474 --- /dev/null +++ b/tests/__data__/vexml/playback_repeat_endings.musicxml @@ -0,0 +1,92 @@ + + + + + Music + + + + + + heavy-light + + + + 256 + + 0 + major + + + + G + 2 + + + + + C + 5 + + 1024 + whole + + + + + + + + + C + 5 + + 1024 + whole + + + light-heavy + + + + + + + + + + + C + 5 + + 1024 + whole + + + light-heavy + + + + + + + + + + + C + 5 + + 1024 + whole + + + light-heavy + + + + + \ No newline at end of file diff --git a/tests/__data__/vexml/playback_same_note.musicxml b/tests/__data__/vexml/playback_same_note.musicxml new file mode 100644 index 000000000..6c45f06e4 --- /dev/null +++ b/tests/__data__/vexml/playback_same_note.musicxml @@ -0,0 +1,97 @@ + + + + + Untitled score + + + Composer / arranger + + MuseScore 4.5.0 + 2025-03-17 + + + + + + + + + + Flute + Fl. + + Flute + wind.flutes.flute + + + + 1 + 74 + 78.7402 + 0 + + + + + + + 1 + + 0 + + + + G + 2 + + + + + C + 5 + + 1 + 1 + quarter + down + + + + C + 5 + + 1 + 1 + quarter + down + + + + C + 5 + + 1 + 1 + quarter + down + + + + C + 5 + + 1 + 1 + quarter + down + + + light-heavy + + + + diff --git a/tests/__data__/vexml/playback_simple.musicxml b/tests/__data__/vexml/playback_simple.musicxml new file mode 100644 index 000000000..dca8b2c49 --- /dev/null +++ b/tests/__data__/vexml/playback_simple.musicxml @@ -0,0 +1,97 @@ + + + + + Untitled score + + + Composer / arranger + + MuseScore 4.5.0 + 2025-03-17 + + + + + + + + + + Flute + Fl. + + Flute + wind.flutes.flute + + + + 1 + 74 + 78.7402 + 0 + + + + + + + 1 + + 0 + + + + G + 2 + + + + + F + 4 + + 1 + 1 + quarter + up + + + + A + 4 + + 1 + 1 + quarter + up + + + + C + 5 + + 1 + 1 + quarter + down + + + + E + 5 + + 1 + 1 + quarter + down + + + light-heavy + + + + diff --git a/tests/unit/playback/cursorframe.test.ts b/tests/unit/playback/cursorframe.test.ts new file mode 100644 index 000000000..b8d1663c3 --- /dev/null +++ b/tests/unit/playback/cursorframe.test.ts @@ -0,0 +1,402 @@ +import * as vexml from '@/index'; +import * as path from 'path'; +import { CursorFrame, Timeline } from '@/playback'; +import { NoopLogger, MemoryLogger } from '@/debug'; +import fs from 'fs'; + +const DATA_DIR = path.resolve(__dirname, '..', '..', '__data__', 'vexml'); + +describe(CursorFrame, () => { + let logger: MemoryLogger; + + beforeEach(() => { + logger = new MemoryLogger(); + }); + + it('creates for: single measure, single stave, different notes', () => { + const [score, timelines] = render('playback_simple.musicxml'); + + const frames = CursorFrame.create(logger, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); + + expect(logger.getLogs()).toBeEmpty(); + expect(timelines).toHaveLength(1); + expect(frames).toHaveLength(4); + // stave0: 0 1 2 3 + expect(frames[0].toHumanReadable()).toEqual([ + 't: [0ms - 600ms]', + 'x: [left(element(0)) - left(element(1))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[1].toHumanReadable()).toEqual([ + 't: [600ms - 1200ms]', + 'x: [left(element(1)) - left(element(2))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[2].toHumanReadable()).toEqual([ + 't: [1200ms - 1800ms]', + 'x: [left(element(2)) - left(element(3))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[3].toHumanReadable()).toEqual([ + 't: [1800ms - 2400ms]', + 'x: [left(element(3)) - right(measure(0))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + }); + + it('creates for: single measure, single stave, same notes', () => { + const [score, timelines] = render('playback_same_note.musicxml'); + + const frames = CursorFrame.create(logger, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); + + expect(logger.getLogs()).toBeEmpty(); + expect(timelines).toHaveLength(1); + expect(frames).toHaveLength(4); + // stave0: 0 1 2 3 + expect(frames[0].toHumanReadable()).toEqual([ + 't: [0ms - 600ms]', + 'x: [left(element(0)) - left(element(1))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[1].toHumanReadable()).toEqual([ + 't: [600ms - 1200ms]', + 'x: [left(element(1)) - left(element(2))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[2].toHumanReadable()).toEqual([ + 't: [1200ms - 1800ms]', + 'x: [left(element(2)) - left(element(3))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[3].toHumanReadable()).toEqual([ + 't: [1800ms - 2400ms]', + 'x: [left(element(3)) - right(measure(0))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + }); + + it('creates for: single measure, multiple staves, different notes', () => { + const [score, timelines] = render('playback_multi_stave.musicxml'); + + const frames = CursorFrame.create(logger, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); + + expect(logger.getLogs()).toBeEmpty(); + expect(timelines).toHaveLength(1); + expect(frames).toHaveLength(8); + // stave0: 0 1 2 3 + // stave1: 4 5 6 7 8 9 10 11 + expect(frames[0].toHumanReadable()).toEqual([ + 't: [0ms - 300ms]', + 'x: [left(element(0)) - left(element(5))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[1].toHumanReadable()).toEqual([ + 't: [300ms - 600ms]', + 'x: [left(element(5)) - left(element(1))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[2].toHumanReadable()).toEqual([ + 't: [600ms - 900ms]', + 'x: [left(element(1)) - left(element(7))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[3].toHumanReadable()).toEqual([ + 't: [900ms - 1200ms]', + 'x: [left(element(7)) - left(element(2))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[4].toHumanReadable()).toEqual([ + 't: [1200ms - 1500ms]', + 'x: [left(element(2)) - left(element(9))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[5].toHumanReadable()).toEqual([ + 't: [1500ms - 1800ms]', + 'x: [left(element(9)) - left(element(3))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[6].toHumanReadable()).toEqual([ + 't: [1800ms - 2100ms]', + 'x: [left(element(3)) - left(element(11))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[7].toHumanReadable()).toEqual([ + 't: [2100ms - 2400ms]', + 'x: [left(element(11)) - right(measure(0))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + }); + + it('creates for: single measure, multiple staves, multiple parts', () => { + const [score, timelines] = render('playback_multi_part.musicxml'); + + // This ends up adding test coverage for y-spans. + const span0 = { fromPartIndex: 0, toPartIndex: 0 }; + const span1 = { fromPartIndex: 0, toPartIndex: 1 }; + + const framesPart0 = CursorFrame.create(logger, score, timelines[0], span0); + const framesPart1 = CursorFrame.create(logger, score, timelines[1], span1); + + expect(logger.getLogs()).toBeEmpty(); + expect(timelines).toHaveLength(2); + expect(framesPart0).toHaveLength(4); + // part0, stave0: 0 1 2 3 + expect(framesPart0[0].toHumanReadable()).toEqual([ + 't: [0ms - 600ms]', + 'x: [left(element(0)) - left(element(1))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(framesPart0[1].toHumanReadable()).toEqual([ + 't: [600ms - 1200ms]', + 'x: [left(element(1)) - left(element(2))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(framesPart0[2].toHumanReadable()).toEqual([ + 't: [1200ms - 1800ms]', + 'x: [left(element(2)) - left(element(3))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(framesPart0[3].toHumanReadable()).toEqual([ + 't: [1800ms - 2400ms]', + 'x: [left(element(3)) - right(measure(0))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + + expect(framesPart1).toHaveLength(4); + // part1, stave0: 0 1 2 3 + // part1, stave1: 4 5 6 7 + expect(framesPart1[0].toHumanReadable()).toEqual([ + 't: [0ms - 600ms]', + 'x: [left(element(0)) - left(element(1))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(1))]', + ]); + expect(framesPart1[1].toHumanReadable()).toEqual([ + 't: [600ms - 1200ms]', + 'x: [left(element(1)) - left(element(2))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(1))]', + ]); + expect(framesPart1[2].toHumanReadable()).toEqual([ + 't: [1200ms - 1800ms]', + 'x: [left(element(2)) - left(element(3))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(1))]', + ]); + expect(framesPart1[3].toHumanReadable()).toEqual([ + 't: [1800ms - 2400ms]', + 'x: [left(element(3)) - right(measure(0))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(1))]', + ]); + }); + + it('creates for: multiple measures, single stave, different notes', () => { + const [score, timelines] = render('playback_multi_measure.musicxml'); + + const frames = CursorFrame.create(logger, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); + + expect(logger.getLogs()).toBeEmpty(); + expect(timelines).toHaveLength(1); + expect(frames).toHaveLength(8); + // stave0: 0 1 2 3 4 | 5 6 7 8 + expect(frames[0].toHumanReadable()).toEqual([ + 't: [0ms - 600ms]', + 'x: [left(element(0)) - left(element(1))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[1].toHumanReadable()).toEqual([ + 't: [600ms - 1200ms]', + 'x: [left(element(1)) - left(element(2))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[2].toHumanReadable()).toEqual([ + 't: [1200ms - 1800ms]', + 'x: [left(element(2)) - left(element(3))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[3].toHumanReadable()).toEqual([ + 't: [1800ms - 2400ms]', + 'x: [left(element(3)) - left(element(4))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[4].toHumanReadable()).toEqual([ + 't: [2400ms - 3000ms]', + 'x: [left(element(4)) - left(element(5))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[5].toHumanReadable()).toEqual([ + 't: [3000ms - 3600ms]', + 'x: [left(element(5)) - left(element(6))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[6].toHumanReadable()).toEqual([ + 't: [3600ms - 4200ms]', + 'x: [left(element(6)) - left(element(7))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[7].toHumanReadable()).toEqual([ + 't: [4200ms - 4800ms]', + 'x: [left(element(7)) - right(measure(1))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + }); + + it('creates for: single measure, single stave, repeat', () => { + const [score, timelines] = render('playback_repeat.musicxml'); + + const frames = CursorFrame.create(logger, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); + + expect(logger.getLogs()).toBeEmpty(); + expect(timelines).toHaveLength(1); + expect(frames).toHaveLength(8); + // stave0: 0 1 2 3 :|| + expect(frames[0].toHumanReadable()).toEqual([ + 't: [0ms - 600ms]', + 'x: [left(element(0)) - left(element(1))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[1].toHumanReadable()).toEqual([ + 't: [600ms - 1200ms]', + 'x: [left(element(1)) - left(element(2))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[2].toHumanReadable()).toEqual([ + 't: [1200ms - 1800ms]', + 'x: [left(element(2)) - left(element(3))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[3].toHumanReadable()).toEqual([ + 't: [1800ms - 2400ms]', + 'x: [left(element(3)) - right(measure(0))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[4].toHumanReadable()).toEqual([ + 't: [2400ms - 3000ms]', + 'x: [left(element(0)) - left(element(1))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[5].toHumanReadable()).toEqual([ + 't: [3000ms - 3600ms]', + 'x: [left(element(1)) - left(element(2))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[6].toHumanReadable()).toEqual([ + 't: [3600ms - 4200ms]', + 'x: [left(element(2)) - left(element(3))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[7].toHumanReadable()).toEqual([ + 't: [4200ms - 4800ms]', + 'x: [left(element(3)) - right(measure(0))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + }); + + it('creates for: multiple measures, single stave, repeat with endings', () => { + const [score, timelines] = render('playback_repeat_endings.musicxml'); + + const frames = CursorFrame.create(logger, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); + + expect(logger.getLogs()).toBeEmpty(); + expect(timelines).toHaveLength(1); + expect(frames).toHaveLength(6); + // stave0: 0 | [ending1 -> 1] :|| [ending2 -> 2] :|| [ending3 -> 3] + expect(frames[0].toHumanReadable()).toEqual([ + 't: [0ms - 2400ms]', + 'x: [left(element(0)) - left(element(1))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[1].toHumanReadable()).toEqual([ + 't: [2400ms - 4800ms]', + 'x: [left(element(1)) - right(measure(1))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[2].toHumanReadable()).toEqual([ + 't: [4800ms - 7200ms]', + 'x: [left(element(0)) - right(measure(0))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[3].toHumanReadable()).toEqual([ + 't: [7200ms - 9600ms]', + 'x: [left(element(2)) - right(measure(2))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[4].toHumanReadable()).toEqual([ + 't: [9600ms - 12000ms]', + 'x: [left(element(0)) - right(measure(0))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[5].toHumanReadable()).toEqual([ + 't: [12000ms - 14400ms]', + 'x: [left(element(3)) - right(measure(3))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + }); + + it('creates for: multiple measures, single stave, multiple systems', () => { + const [score, timelines] = render('playback_multi_system.musicxml'); + + const frames = CursorFrame.create(logger, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); + + expect(logger.getLogs()).toBeEmpty(); + expect(timelines).toHaveLength(1); + // system0, stave0: 0 | 1 | 2 | 3 | 4 | 5 + // system1, stave0: 6 | 7 | 8 + expect(frames).toHaveLength(9); + expect(frames[0].toHumanReadable()).toEqual([ + 't: [0ms - 2400ms]', + 'x: [left(element(0)) - left(element(1))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[1].toHumanReadable()).toEqual([ + 't: [2400ms - 4800ms]', + 'x: [left(element(1)) - left(element(2))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[2].toHumanReadable()).toEqual([ + 't: [4800ms - 7200ms]', + 'x: [left(element(2)) - left(element(3))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[3].toHumanReadable()).toEqual([ + 't: [7200ms - 9600ms]', + 'x: [left(element(3)) - left(element(4))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[4].toHumanReadable()).toEqual([ + 't: [9600ms - 12000ms]', + 'x: [left(element(4)) - left(element(5))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[5].toHumanReadable()).toEqual([ + 't: [12000ms - 14400ms]', + 'x: [left(element(5)) - right(measure(5))]', + 'y: [top(system(0), part(0)) - bottom(system(0), part(0))]', + ]); + expect(frames[6].toHumanReadable()).toEqual([ + 't: [14400ms - 16800ms]', + 'x: [left(element(6)) - left(element(7))]', + 'y: [top(system(1), part(0)) - bottom(system(1), part(0))]', + ]); + expect(frames[7].toHumanReadable()).toEqual([ + 't: [16800ms - 19200ms]', + 'x: [left(element(7)) - left(element(8))]', + 'y: [top(system(1), part(0)) - bottom(system(1), part(0))]', + ]); + expect(frames[8].toHumanReadable()).toEqual([ + 't: [19200ms - 21600ms]', + 'x: [left(element(8)) - right(measure(8))]', + 'y: [top(system(1), part(0)) - bottom(system(1), part(0))]', + ]); + }); +}); + +function render(filename: string): [vexml.Score, Timeline[]] { + const musicXMLPath = path.resolve(DATA_DIR, filename); + const musicXML = fs.readFileSync(musicXMLPath).toString(); + const div = document.createElement('div'); + const score = vexml.renderMusicXML(musicXML, div, { + config: { + WIDTH: 900, + }, + }); + const timelines = Timeline.create(new NoopLogger(), score); + return [score, timelines]; +} diff --git a/tests/unit/playback/timeline.test.ts b/tests/unit/playback/timeline.test.ts new file mode 100644 index 000000000..5dc72e893 --- /dev/null +++ b/tests/unit/playback/timeline.test.ts @@ -0,0 +1,180 @@ +import * as vexml from '@/index'; +import * as path from 'path'; +import fs from 'fs'; +import { Timeline } from '@/playback'; +import { NoopLogger } from '@/debug'; + +const DATA_DIR = path.resolve(__dirname, '..', '..', '__data__', 'vexml'); + +describe(Timeline, () => { + const logger = new NoopLogger(); + + it('creates for: single measure, single stave, different notes', () => { + const score = render('playback_simple.musicxml'); + + const timelines = Timeline.create(logger, score); + + expect(timelines).toHaveLength(1); + expect(timelines[0].toHumanReadable()).toEqual([ + // stave0: 0 1 2 3 + '[0ms] start(0)', + '[600ms] stop(0), start(1)', + '[1200ms] stop(1), start(2)', + '[1800ms] stop(2), start(3)', + '[2400ms] stop(3), systemend', + ]); + }); + + it('creates for: single measure, single stave, same notes', () => { + const score = render('playback_same_note.musicxml'); + + const timelines = Timeline.create(logger, score); + + expect(timelines).toHaveLength(1); + expect(timelines[0].toHumanReadable()).toEqual([ + // stave0: 0 1 2 3 + '[0ms] start(0)', + '[600ms] stop(0), start(1)', + '[1200ms] stop(1), start(2)', + '[1800ms] stop(2), start(3)', + '[2400ms] stop(3), systemend', + ]); + }); + + it('creates for: single measure, multiple staves, different notes', () => { + const score = render('playback_multi_stave.musicxml'); + + const timelines = Timeline.create(logger, score); + + expect(timelines).toHaveLength(1); + expect(timelines[0].toHumanReadable()).toEqual([ + // stave0: 0 1 2 3 + // stave1: 4 5 6 7 8 9 10 11 + '[0ms] start(0), start(4)', + '[300ms] stop(4), start(5)', + '[600ms] stop(0), stop(5), start(1), start(6)', + '[900ms] stop(6), start(7)', + '[1200ms] stop(1), stop(7), start(2), start(8)', + '[1500ms] stop(8), start(9)', + '[1800ms] stop(2), stop(9), start(3), start(10)', + '[2100ms] stop(10), start(11)', + '[2400ms] stop(3), stop(11), systemend', + ]); + }); + + it('creates for: single measure, multiple staves, multiple parts', () => { + const score = render('playback_multi_part.musicxml'); + + const timelines = Timeline.create(logger, score); + + expect(timelines).toHaveLength(2); + expect(timelines[0].toHumanReadable()).toEqual([ + // stave0: 0 1 2 3 + '[0ms] start(0)', + '[600ms] stop(0), start(1)', + '[1200ms] stop(1), start(2)', + '[1800ms] stop(2), start(3)', + '[2400ms] stop(3), systemend', + ]); + expect(timelines[1].toHumanReadable()).toEqual([ + // stave0: 0 1 2 3 + // stave1: 4 5 6 7 + '[0ms] start(0), start(4)', + '[600ms] stop(0), stop(4), start(1), start(5)', + '[1200ms] stop(1), stop(5), start(2), start(6)', + '[1800ms] stop(2), stop(6), start(3), start(7)', + '[2400ms] stop(3), stop(7), systemend', + ]); + }); + + it('creates for: multiple measures, single stave, different notes', () => { + const score = render('playback_multi_measure.musicxml'); + + const timelines = Timeline.create(logger, score); + + expect(timelines).toHaveLength(1); + expect(timelines[0].toHumanReadable()).toEqual([ + // stave0: 0 1 2 3 4 | 5 6 7 8 + '[0ms] start(0)', + '[600ms] stop(0), start(1)', + '[1200ms] stop(1), start(2)', + '[1800ms] stop(2), start(3)', + '[2400ms] stop(3), start(4)', + '[3000ms] stop(4), start(5)', + '[3600ms] stop(5), start(6)', + '[4200ms] stop(6), start(7)', + '[4800ms] stop(7), systemend', + ]); + }); + + it('creates for: single measure, single stave, repeat', () => { + const score = render('playback_repeat.musicxml'); + + const timelines = Timeline.create(logger, score); + + expect(timelines).toHaveLength(1); + expect(timelines[0].toHumanReadable()).toEqual([ + // stave0: 0 1 2 3 :|| + '[0ms] start(0)', + '[600ms] stop(0), start(1)', + '[1200ms] stop(1), start(2)', + '[1800ms] stop(2), start(3)', + '[2400ms] stop(3), jump, start(0)', + '[3000ms] stop(0), start(1)', + '[3600ms] stop(1), start(2)', + '[4200ms] stop(2), start(3)', + '[4800ms] stop(3), systemend', + ]); + }); + + it('creates for: multiple measures, single stave, repeat with endings', () => { + const score = render('playback_repeat_endings.musicxml'); + + const timelines = Timeline.create(logger, score); + + expect(timelines).toHaveLength(1); + expect(timelines[0].toHumanReadable()).toEqual([ + // stave0: 0 | [ending1 -> 1] :|| [ending2 -> 2] :|| [ending3 -> 3] + '[0ms] start(0)', + '[2400ms] stop(0), start(1)', + '[4800ms] stop(1), jump, start(0)', + '[7200ms] stop(0), jump, start(2)', + '[9600ms] stop(2), jump, start(0)', + '[12000ms] stop(0), jump, start(3)', + '[14400ms] stop(3), systemend', + ]); + }); + + it('creates for: multiple measures, single stave, multiple systems', () => { + const score = render('playback_multi_system.musicxml'); + + const timelines = Timeline.create(logger, score); + + expect(timelines).toHaveLength(1); + expect(timelines[0].toHumanReadable()).toEqual([ + // system0, stave0: 0 | 1 | 2 | 3 | 4 | 5 + // system1, stave0: 6 | 7 | 8 + '[0ms] start(0)', + '[2400ms] stop(0), start(1)', + '[4800ms] stop(1), start(2)', + '[7200ms] stop(2), start(3)', + '[9600ms] stop(3), start(4)', + '[12000ms] stop(4), start(5)', + '[14400ms] stop(5), systemend, start(6)', + '[16800ms] stop(6), start(7)', + '[19200ms] stop(7), start(8)', + '[21600ms] stop(8), systemend', + ]); + }); +}); + +function render(filename: string) { + const musicXMLPath = path.resolve(DATA_DIR, filename); + const musicXML = fs.readFileSync(musicXMLPath).toString(); + const div = document.createElement('div'); + return vexml.renderMusicXML(musicXML, div, { + config: { + WIDTH: 900, + }, + }); +}