diff --git a/src/elements/note.ts b/src/elements/note.ts index ce7e751a2..17bba9108 100644 --- a/src/elements/note.ts +++ b/src/elements/note.ts @@ -25,17 +25,33 @@ 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, - }; + /** Returns the subtype of the note. */ + getSubtype(): 'note' | 'chord' { + return this.noteRender.subtype; } - sharesACurveWith(note: Note): boolean { - return this.noteRender.curveIds.some((curveId) => note.noteRender.curveIds.includes(curveId)); + /** Returns the pitches of the note. */ + getPitches(): Pitch[] { + switch (this.getSubtype()) { + case 'note': + return this.getNotePitches(); + case 'chord': + return this.getChordPitches(); + } + } + + /** Returns whether the note contains an equivalent pitch to another note. */ + containsEquivalentPitch(otherNote: Note): boolean { + // Let N be the number of pitches a note has. This algorithm has a time complexity of O(N^2), but N should always + // be small (<10). + return this.getPitches().some((pitch) => + otherNote.getPitches().some((otherPitch) => this.isPitchEqivalent(pitch, otherPitch)) + ); + } + + /** Returns whether the note is connected to another note via a curve (tie or slur). */ + sharesACurveWith(otherNote: Note): boolean { + return this.noteRender.curveIds.some((curveId) => otherNote.noteRender.curveIds.includes(curveId)); } /** Returns the measure beat that this note starts on. */ @@ -57,4 +73,28 @@ export class Note { getAbsoluteMeasureIndex(): number { return this.document.getAbsoluteMeasureIndex(this.noteRender.key); } + + private getNotePitches(): Pitch[] { + const note = this.document.getNote(this.noteRender.key); + return [ + { + step: note.pitch.step, + octave: note.pitch.octave, + accidentalCode: note.accidental?.code ?? null, + }, + ]; + } + + private getChordPitches(): Pitch[] { + const chord = this.document.getChord(this.noteRender.key); + return chord.notes.map((note) => ({ + step: note.pitch.step, + octave: note.pitch.octave, + accidentalCode: note.accidental?.code ?? null, + })); + } + + private isPitchEqivalent(a: Pitch, b: Pitch): boolean { + return a.step === b.step && a.octave === b.octave && a.accidentalCode === b.accidentalCode; + } } diff --git a/src/elements/score.ts b/src/elements/score.ts index 047c3dd77..a2890bc3c 100644 --- a/src/elements/score.ts +++ b/src/elements/score.ts @@ -78,7 +78,7 @@ export class Score { const timeline = this.getTimelines().find((timeline) => timeline.getPartIndex() === partIndex); util.assertDefined(timeline); - const frames = playback.CursorFrame.create(this.log, this, timeline, span); + const frames = playback.DefaultCursorFrame.create(this.log, this, timeline, span); const path = new playback.CursorPath(partIndex, frames); const cursor = playback.Cursor.create(path, this.getScrollContainer()); @@ -369,7 +369,7 @@ export class Score { 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 frames = playback.DefaultCursorFrame.create(this.log, this, timeline, span); const path = new playback.CursorPath(partIndex, frames); paths.push(path); } diff --git a/src/playback/cursor.ts b/src/playback/cursor.ts index c4295fd77..46e2da45c 100644 --- a/src/playback/cursor.ts +++ b/src/playback/cursor.ts @@ -1,13 +1,14 @@ import * as events from '@/events'; import * as util from '@/util'; import { Rect, Point } from '@/spatial'; -import { CursorFrame } from './cursorframe'; import { Scroller } from './scroller'; -import { CursorFrameLocator } from './types'; +import { CursorFrame, CursorFrameLocator, CursorStateHintProvider } from './types'; import { FastCursorFrameLocator } from './fastcursorframelocator'; import { BSearchCursorFrameLocator } from './bsearchcursorframelocator'; import { Duration } from './duration'; import { CursorPath } from './cursorpath'; +import { LazyCursorStateHintProvider } from './lazycursorstatehintprovider'; +import { EmptyCursorFrame } from './emptycursorframe'; // 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. @@ -19,6 +20,7 @@ export type CursorState = { hasPrevious: boolean; rect: Rect; frame: CursorFrame; + hints: CursorStateHintProvider; }; export type CursorEventMap = { @@ -28,11 +30,10 @@ export type CursorEventMap = { export class Cursor { private topic = new events.Topic(); - private currentIndex = 0; - private currentAlpha = 0; // interpolation factor, ranging from 0 to 1 + private index = 0; + private alpha = 0; // interpolation factor, ranging from 0 to 1 - private previousIndex = -1; - private previousAlpha = -1; + private previousFrame: CursorFrame = new EmptyCursorFrame(); private constructor(private path: CursorPath, private locator: CursorFrameLocator, private scroller: Scroller) {} @@ -43,27 +44,40 @@ export class Cursor { return new Cursor(path, fastLocator, scroller); } - getCurrentState(): CursorState { - return this.getState(this.currentIndex, this.currentAlpha); + iterable(): Iterable { + // Clone the cursor to avoid modifying the index of this instance. + const cursor = new Cursor(this.path, this.locator, this.scroller); + return new CursorIterator(cursor); } - getPreviousState(): CursorState | null { - if (this.previousIndex === -1 || this.previousAlpha === -1) { - return null; - } - return this.getState(this.previousIndex, this.previousAlpha); + getCurrentState(): CursorState { + const index = this.index; + const hasNext = index < this.path.getFrames().length - 1; + const hasPrevious = index > 0; + const frame = this.getCurrentFrame(); + const rect = this.getCursorRect(frame, this.alpha); + const hints = new LazyCursorStateHintProvider(frame, this.previousFrame); + + return { + index, + hasNext, + hasPrevious, + frame, + rect, + hints, + }; } next(): void { - if (this.currentIndex === this.path.getFrames().length - 1) { - this.update(this.currentIndex, { alpha: 1 }); + if (this.index === this.path.getFrames().length - 1) { + this.update(this.index, { alpha: 1 }); } else { - this.update(this.currentIndex + 1, { alpha: 0 }); + this.update(this.index + 1, { alpha: 0 }); } } previous(): void { - this.update(this.currentIndex - 1, { alpha: 0 }); + this.update(this.index - 1, { alpha: 0 }); } goTo(index: number): void { @@ -125,21 +139,8 @@ export class Cursor { this.topic.unsubscribeAll(); } - 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 getCurrentFrame(): CursorFrame { + return this.path.getFrames().at(this.index) ?? new EmptyCursorFrame(); } private getScrollPoint(): Point { @@ -171,12 +172,28 @@ export class Cursor { 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.currentIndex || alpha !== this.currentAlpha) { - this.previousIndex = this.currentIndex; - this.previousAlpha = this.currentAlpha; - this.currentIndex = index; - this.currentAlpha = alpha; + if (index !== this.index || alpha !== this.alpha) { + this.previousFrame = this.getCurrentFrame(); + this.index = index; + this.alpha = alpha; this.topic.publish('change', this.getCurrentState()); } } } + +class CursorIterator implements Iterable { + constructor(private cursor: Cursor) {} + + [Symbol.iterator](): Iterator { + return { + next: () => { + const state = this.cursor.getCurrentState(); + const done = !state.hasNext; + if (!done) { + this.cursor.next(); + } + return { value: state, done }; + }, + }; + } +} diff --git a/src/playback/cursorpath.ts b/src/playback/cursorpath.ts index 3136f56cb..998fa6467 100644 --- a/src/playback/cursorpath.ts +++ b/src/playback/cursorpath.ts @@ -1,4 +1,4 @@ -import { CursorFrame } from './cursorframe'; +import { CursorFrame } from './types'; /** A collection of cursor frames for a given part index.. */ export class CursorPath { diff --git a/src/playback/cursorframe.ts b/src/playback/defaultcursorframe.ts similarity index 80% rename from src/playback/cursorframe.ts rename to src/playback/defaultcursorframe.ts index 93255cf27..3bd78b11b 100644 --- a/src/playback/cursorframe.ts +++ b/src/playback/defaultcursorframe.ts @@ -2,14 +2,7 @@ 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 { CursorFrame, CursorVerticalSpan, PlaybackElement, TimelineMoment } from './types'; import { Timeline } from './timeline'; type TRangeSource = { @@ -26,7 +19,7 @@ type YRangeSource = { bound: 'top' | 'bottom'; }; -export class CursorFrame { +export class DefaultCursorFrame implements CursorFrame { constructor( private tRangeSources: [TRangeSource, TRangeSource], private xRangeSources: [XRangeSource, XRangeSource], @@ -35,7 +28,12 @@ export class CursorFrame { private describer: CursorFrameDescriber ) {} - static create(log: Logger, score: elements.Score, timeline: Timeline, span: CursorVerticalSpan): CursorFrame[] { + static create( + log: Logger, + score: elements.Score, + timeline: Timeline, + span: CursorVerticalSpan + ): DefaultCursorFrame[] { const partCount = score.getPartCount(); if (partCount === 0) { log.warn('No parts found in score, returning empty cursor frames.'); @@ -72,10 +70,6 @@ export class CursorFrame { return new util.NumberRange(y1, y2); } - getHints(previousFrame: CursorFrame): CursorFrameHint[] { - return [...this.getRetriggerHints(previousFrame), ...this.getSustainHints(previousFrame)]; - } - getActiveElements(): PlaybackElement[] { return [...this.activeElements]; } @@ -87,68 +81,10 @@ export class CursorFrame { return [`t: ${tRangeDescription}`, `x: ${xRangeDescription}`, `y: ${yRangeDescription}`]; } - - 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 frames = new Array(); private activeElements = new Set(); private describer: CursorFrameDescriber; @@ -161,7 +97,7 @@ class CursorFrameFactory { this.describer = CursorFrameDescriber.create(score, timeline.getPartIndex()); } - create(): CursorFrame[] { + create(): DefaultCursorFrame[] { this.frames = []; this.activeElements = new Set(); @@ -221,6 +157,8 @@ class CursorFrameFactory { } private getEndXRangeSource(startXRangeSource: XRangeSource, nextMoment: TimelineMoment): XRangeSource { + let proposedXRangeSource: XRangeSource; + const shouldUseMeasureEndBoundary = nextMoment.events.some((e) => e.type === 'jump' || e.type === 'systemend'); if (shouldUseMeasureEndBoundary) { const event = nextMoment.events.at(0); @@ -228,15 +166,19 @@ class CursorFrameFactory { switch (event.type) { case 'transition': - return { type: 'measure', measure: event.measure, bound: 'right' }; + proposedXRangeSource = { type: 'measure', measure: event.measure, bound: 'right' }; + break; case 'systemend': - return { type: 'system', system: event.system, bound: 'right' }; + proposedXRangeSource = { type: 'system', system: event.system, bound: 'right' }; + break; case 'jump': - return { type: 'measure', measure: event.measure, bound: 'right' }; + proposedXRangeSource = { type: 'measure', measure: event.measure, bound: 'right' }; + break; } + } else { + proposedXRangeSource = this.getStartXRangeSource(nextMoment); } - const proposedXRangeSource = this.getStartXRangeSource(nextMoment); const startBound = getXRangeBound(startXRangeSource); const proposedBound = getXRangeBound(proposedXRangeSource); @@ -335,7 +277,7 @@ class CursorFrameFactory { xRangeSources: [XRangeSource, XRangeSource], yRangeSources: [YRangeSource, YRangeSource] ): void { - const frame = new CursorFrame( + const frame = new DefaultCursorFrame( tRangeSources, xRangeSources, yRangeSources, diff --git a/src/playback/emptycursorframe.ts b/src/playback/emptycursorframe.ts new file mode 100644 index 000000000..c501b6955 --- /dev/null +++ b/src/playback/emptycursorframe.ts @@ -0,0 +1,18 @@ +import { NumberRange } from '@/util'; +import { Duration } from './duration'; +import { DurationRange } from './durationrange'; +import { CursorFrame } from './types'; + +export class EmptyCursorFrame implements CursorFrame { + tRange = new DurationRange(Duration.zero(), Duration.zero()); + xRange = new NumberRange(0, 0); + yRange = new NumberRange(0, 0); + + getActiveElements() { + return []; + } + + toHumanReadable(): string[] { + return ['[empty]']; + } +} diff --git a/src/playback/index.ts b/src/playback/index.ts index 27fa1d10b..021937b97 100644 --- a/src/playback/index.ts +++ b/src/playback/index.ts @@ -5,4 +5,6 @@ export * from './durationrange'; export * from './timestamplocator'; export * from './timeline'; export * from './types'; -export * from './cursorframe'; +export * from './defaultcursorframe'; +export * from './lazycursorstatehintprovider'; +export * from './emptycursorframe'; diff --git a/src/playback/lazycursorstatehintprovider.ts b/src/playback/lazycursorstatehintprovider.ts new file mode 100644 index 000000000..413dbce4f --- /dev/null +++ b/src/playback/lazycursorstatehintprovider.ts @@ -0,0 +1,97 @@ +import * as elements from '@/elements'; +import * as util from '@/util'; +import { + CursorFrame, + CursorStateHint, + CursorStateHintProvider, + PlaybackElement, + RetriggerHint, + StartHint, + StopHint, + SustainHint, +} from './types'; + +export class LazyCursorStateHintProvider implements CursorStateHintProvider { + constructor(private currentFrame: CursorFrame, private previousFrame: CursorFrame | undefined) {} + + @util.memoize() + get(): CursorStateHint[] { + if (!this.previousFrame) { + return []; + } + if (this.currentFrame === this.previousFrame) { + return []; + } + + const previousElements = new Set(this.previousFrame.getActiveElements()); + const currentElements = new Set(this.currentFrame.getActiveElements()); + + const previousNotes = this.previousFrame.getActiveElements().filter((e) => e.name === 'note'); + const currentNotes = this.currentFrame.getActiveElements().filter((e) => e.name === 'note'); + + return [ + ...this.getStartHints(currentElements, previousElements), + ...this.getStopHints(currentElements, previousElements), + ...this.getRetriggerHints(currentNotes, previousNotes), + ...this.getSustainHints(currentNotes, previousNotes), + ]; + } + + private getStartHints(previousElements: Set, currentElements: Set): StartHint[] { + const hints = new Array(); + + for (const element of currentElements) { + if (!previousElements.has(element)) { + hints.push({ type: 'start', element }); + } + } + + return hints; + } + + private getStopHints(previousElements: Set, currentElements: Set): StopHint[] { + const hints = new Array(); + + for (const element of previousElements) { + if (!currentElements.has(element)) { + hints.push({ type: 'stop', element }); + } + } + + return hints; + } + + private getRetriggerHints(currentNotes: elements.Note[], previousNotes: elements.Note[]): RetriggerHint[] { + const hints = new Array(); + + for (const currentNote of currentNotes) { + const previousNote = previousNotes.find((previousNote) => previousNote.containsEquivalentPitch(currentNote)); + if (previousNote && !previousNote.sharesACurveWith(currentNote)) { + hints.push({ + type: 'retrigger', + untriggerElement: previousNote, + retriggerElement: currentNote, + }); + } + } + + return hints; + } + + private getSustainHints(currentNotes: elements.Note[], previousNotes: elements.Note[]): SustainHint[] { + const hints = new Array(); + + for (const currentNote of currentNotes) { + const previousNote = previousNotes.find((previousNote) => previousNote.containsEquivalentPitch(currentNote)); + if (previousNote && previousNote.sharesACurveWith(currentNote)) { + hints.push({ + type: 'sustain', + previousElement: previousNote, + currentElement: currentNote, + }); + } + } + + return hints; + } +} diff --git a/src/playback/timestamplocator.ts b/src/playback/timestamplocator.ts index efe3b11d8..ee707e9e9 100644 --- a/src/playback/timestamplocator.ts +++ b/src/playback/timestamplocator.ts @@ -3,7 +3,7 @@ import * as elements from '@/elements'; import * as util from '@/util'; import { Duration } from './duration'; import { CursorPath } from './cursorpath'; -import { CursorFrame } from './cursorframe'; +import { CursorFrame } from './types'; type System = { yRange: util.NumberRange; diff --git a/src/playback/types.ts b/src/playback/types.ts index a54512c09..c23d634e7 100644 --- a/src/playback/types.ts +++ b/src/playback/types.ts @@ -24,6 +24,14 @@ export interface CursorFrameLocator { locate(time: Duration): number | null; } +export interface CursorFrame { + tRange: DurationRange; + xRange: NumberRange; + yRange: NumberRange; + getActiveElements(): PlaybackElement[]; + toHumanReadable(): string[]; +} + export type ElementTransitionEvent = { type: 'transition'; kind: 'start' | 'stop'; @@ -41,7 +49,21 @@ export type SystemEndEvent = { system: elements.System; }; -export type CursorFrameHint = RetriggerHint | SustainHint; +export interface CursorStateHintProvider { + get(): CursorStateHint[]; +} + +export type CursorStateHint = StartHint | StopHint | RetriggerHint | SustainHint; + +export type StartHint = { + type: 'start'; + element: PlaybackElement; +}; + +export type StopHint = { + type: 'stop'; + element: PlaybackElement; +}; export type RetriggerHint = { type: 'retrigger'; diff --git a/src/rendering/document.ts b/src/rendering/document.ts index def273bf6..1462d1a79 100644 --- a/src/rendering/document.ts +++ b/src/rendering/document.ts @@ -387,7 +387,7 @@ export class Document { getNote(key: VoiceEntryKey): data.Note { const entry = this.getVoiceEntries(key).at(key.voiceEntryIndex); - util.assert(entry?.type === 'note', 'expected entry to be a note'); + util.assert(entry?.type === 'note', 'expected entry to be a note got ' + entry?.type); return entry; } diff --git a/src/rendering/note.ts b/src/rendering/note.ts index 26fb0be07..dc29b8a5e 100644 --- a/src/rendering/note.ts +++ b/src/rendering/note.ts @@ -90,6 +90,7 @@ export class Note { return { type: 'note', + subtype: voiceEntry.type, key: this.key, rect: Rect.empty(), // placeholder stemDirection: voiceEntry.stemDirection, diff --git a/src/rendering/types.ts b/src/rendering/types.ts index a9a891454..2f271e3da 100644 --- a/src/rendering/types.ts +++ b/src/rendering/types.ts @@ -230,6 +230,7 @@ export type VoiceEntryRender = NoteRender | RestRender | DynamicsRender; export type NoteRender = { type: 'note'; + subtype: 'note' | 'chord'; key: VoiceEntryKey; rect: Rect; stemDirection: StemDirection; diff --git a/tests/__data__/vexml/playback_chords.musicxml b/tests/__data__/vexml/playback_chords.musicxml new file mode 100644 index 000000000..f6a1b8253 --- /dev/null +++ b/tests/__data__/vexml/playback_chords.musicxml @@ -0,0 +1,300 @@ + + + + + + Piano + + + Basson + + + + + + 1 + + -1 + + + 2 + + G + 2 + + + F + 4 + + + + + E + 4 + + 2 + 1 + half + up + 1 + + + + + A + 4 + + 2 + 1 + half + up + 1 + + + + + C + 1 + 5 + + 2 + 1 + half + sharp + up + 1 + + + + E + 4 + + 2 + 1 + half + up + 1 + + + + + A + 4 + + 2 + 1 + half + up + 1 + + + + + C + 1 + 5 + + 2 + 1 + half + up + 1 + + + + 2 + 1 + half + 1 + + + 6 + + + + A + 3 + + 2 + 5 + half + down + 2 + + + + A + 2 + + 2 + 5 + half + up + 2 + + + + F + 3 + + 1 + 5 + quarter + 2 + + + + F + 3 + + 1 + 5 + quarter + 2 + + + 4 + + + + 1 + 7 + quarter + 2 + + + + G + 3 + + 1 + 7 + quarter + up + 2 + + + + F + 3 + + 1 + 7 + quarter + up + 2 + + + + E + 3 + + 1 + 7 + quarter + up + 2 + + + + + + + 1 + + -1 + + + + F + 4 + + + + + A + 3 + + 2 + 1 + half + up + + + + A + 2 + + 2 + 1 + half + up + + + + F + 3 + + 1 + 1 + quarter + + + + F + 3 + + 1 + 1 + quarter + + + 4 + + + + 1 + 3 + quarter + + + + G + 3 + + 1 + 3 + quarter + up + + + + F + 3 + + 1 + 3 + quarter + up + + + + E + 3 + + 1 + 3 + quarter + up + + + + diff --git a/tests/unit/playback/cursorframe.test.ts b/tests/unit/playback/defaultcursorframe.test.ts similarity index 92% rename from tests/unit/playback/cursorframe.test.ts rename to tests/unit/playback/defaultcursorframe.test.ts index 6adaa7c27..829f46def 100644 --- a/tests/unit/playback/cursorframe.test.ts +++ b/tests/unit/playback/defaultcursorframe.test.ts @@ -1,12 +1,12 @@ import * as vexml from '@/index'; import * as path from 'path'; -import { CursorFrame, Timeline } from '@/playback'; +import { DefaultCursorFrame, Timeline } from '@/playback'; import { NoopLogger, MemoryLogger } from '@/debug'; import fs from 'fs'; const DATA_DIR = path.resolve(__dirname, '..', '..', '__data__', 'vexml'); -describe(CursorFrame, () => { +describe(DefaultCursorFrame, () => { let log: MemoryLogger; beforeEach(() => { @@ -16,7 +16,7 @@ describe(CursorFrame, () => { it('creates for: single measure, single stave, different notes', () => { const [score, timelines] = render('playback_simple.musicxml'); - const frames = CursorFrame.create(log, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); + const frames = DefaultCursorFrame.create(log, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); expect(log.getLogs()).toBeEmpty(); expect(timelines).toHaveLength(1); @@ -47,7 +47,7 @@ describe(CursorFrame, () => { it('creates for: single measure, single stave, same notes', () => { const [score, timelines] = render('playback_same_note.musicxml'); - const frames = CursorFrame.create(log, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); + const frames = DefaultCursorFrame.create(log, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); expect(log.getLogs()).toBeEmpty(); expect(timelines).toHaveLength(1); @@ -78,7 +78,7 @@ describe(CursorFrame, () => { it('creates for: single measure, multiple staves, different notes', () => { const [score, timelines] = render('playback_multi_stave.musicxml'); - const frames = CursorFrame.create(log, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); + const frames = DefaultCursorFrame.create(log, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); expect(log.getLogs()).toBeEmpty(); expect(timelines).toHaveLength(1); @@ -134,8 +134,8 @@ describe(CursorFrame, () => { const span0 = { fromPartIndex: 0, toPartIndex: 0 }; const span1 = { fromPartIndex: 0, toPartIndex: 1 }; - const framesPart0 = CursorFrame.create(log, score, timelines[0], span0); - const framesPart1 = CursorFrame.create(log, score, timelines[1], span1); + const framesPart0 = DefaultCursorFrame.create(log, score, timelines[0], span0); + const framesPart1 = DefaultCursorFrame.create(log, score, timelines[1], span1); expect(log.getLogs()).toBeEmpty(); expect(timelines).toHaveLength(2); @@ -190,7 +190,7 @@ describe(CursorFrame, () => { it('creates for: multiple measures, single stave, different notes', () => { const [score, timelines] = render('playback_multi_measure.musicxml'); - const frames = CursorFrame.create(log, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); + const frames = DefaultCursorFrame.create(log, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); expect(log.getLogs()).toBeEmpty(); expect(timelines).toHaveLength(1); @@ -241,7 +241,7 @@ describe(CursorFrame, () => { it('creates for: single measure, single stave, repeat', () => { const [score, timelines] = render('playback_repeat.musicxml'); - const frames = CursorFrame.create(log, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); + const frames = DefaultCursorFrame.create(log, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); expect(log.getLogs()).toBeEmpty(); expect(timelines).toHaveLength(1); @@ -292,7 +292,7 @@ describe(CursorFrame, () => { it('creates for: multiple measures, single stave, repeat with endings', () => { const [score, timelines] = render('playback_repeat_endings.musicxml'); - const frames = CursorFrame.create(log, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); + const frames = DefaultCursorFrame.create(log, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); expect(log.getLogs()).toBeEmpty(); expect(timelines).toHaveLength(1); @@ -331,9 +331,9 @@ describe(CursorFrame, () => { }); it('creates for: multiple measures, single stave, multiple systems', () => { - const [score, timelines] = render('playback_multi_system.musicxml', 100); + const [score, timelines] = render('playback_multi_system.musicxml', { BASE_VOICE_WIDTH: 900 }); - const frames = CursorFrame.create(log, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); + const frames = DefaultCursorFrame.create(log, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); expect(log.getLogs()).toBeEmpty(); expect(timelines).toHaveLength(1); @@ -355,7 +355,7 @@ describe(CursorFrame, () => { it('creates for: documents that have backwards formatting', () => { const [score, timelines] = render('playback_backwards_formatting.musicxml'); - const frames = CursorFrame.create(log, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); + const frames = DefaultCursorFrame.create(log, score, timelines[0], { fromPartIndex: 0, toPartIndex: 0 }); expect(timelines).toHaveLength(1); expect(log.getLogs()).toBeEmpty(); @@ -446,13 +446,14 @@ describe(CursorFrame, () => { }); }); -function render(filename: string, width = 900): [vexml.Score, Timeline[]] { +function render(filename: string, config?: Partial): [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: width, + WIDTH: 900, + ...config, }, }); const timelines = Timeline.create(new NoopLogger(), score); diff --git a/tests/unit/playback/lazycursorstatehintprovider.test.ts b/tests/unit/playback/lazycursorstatehintprovider.test.ts new file mode 100644 index 000000000..fce438d1a --- /dev/null +++ b/tests/unit/playback/lazycursorstatehintprovider.test.ts @@ -0,0 +1,136 @@ +import * as vexml from '@/index'; +import * as path from 'path'; +import fs from 'fs'; +import { EmptyCursorFrame, LazyCursorStateHintProvider } from '@/playback'; + +const DATA_DIR = path.resolve(__dirname, '..', '..', '__data__', 'vexml'); + +// NOTE: If this requires more rigourous testing, follow the same pattern as timeline.test.ts and +// defaultcursorframe.test.ts and create a human readable description of the hints to assert. + +describe(LazyCursorStateHintProvider, () => { + it('does not throw when the previous frame is undefined', () => { + const currentFrame = new EmptyCursorFrame(); + const previousFrame = undefined; + + const provider = new LazyCursorStateHintProvider(currentFrame, previousFrame); + + expect(() => provider.get()).not.toThrow(); + }); + + it('does not throw when there are two empty frames', () => { + const currentFrame = new EmptyCursorFrame(); + const previousFrame = new EmptyCursorFrame(); + + const provider = new LazyCursorStateHintProvider(currentFrame, previousFrame); + + expect(() => provider.get()).not.toThrow(); + }); + + it('does not throw for: single measure, single stave, different notes', () => { + const score = render('playback_simple.musicxml'); + const cursor = score.addCursor(); + + for (const state of cursor.iterable()) { + expect(() => state.hints.get()).not.toThrow(); + } + }); + + it('does not throw for: single measure, single stave, same notes', () => { + const score = render('playback_same_note.musicxml'); + const cursor = score.addCursor(); + + for (const state of cursor.iterable()) { + expect(() => state.hints.get()).not.toThrow(); + } + }); + + it('does not throw for: single measure, multiple staves, different notes', () => { + const score = render('playback_multi_stave.musicxml'); + const cursor = score.addCursor(); + + for (const state of cursor.iterable()) { + expect(() => state.hints.get()).not.toThrow(); + } + }); + + it('does not throw for: single measure, multiple staves, multiple parts', () => { + const score = render('playback_multi_part.musicxml'); + const cursor0 = score.addCursor({ partIndex: 0 }); + const cursor1 = score.addCursor({ partIndex: 1 }); + + for (const state of cursor0.iterable()) { + expect(() => state.hints.get()).not.toThrow(); + } + + for (const state of cursor1.iterable()) { + expect(() => state.hints.get()).not.toThrow(); + } + }); + + it('does not throw for: multiple measures, single stave, different notes', () => { + const score = render('playback_multi_measure.musicxml'); + const cursor = score.addCursor(); + + for (const state of cursor.iterable()) { + expect(() => state.hints.get()).not.toThrow(); + } + }); + + it('does not throw for: single measure, single stave, repeat', () => { + const score = render('playback_repeat.musicxml'); + const cursor = score.addCursor(); + + for (const state of cursor.iterable()) { + expect(() => state.hints.get()).not.toThrow(); + } + }); + + it('does not throw for: multiple measures, single stave, repeat with endings', () => { + const score = render('playback_repeat_endings.musicxml'); + const cursor = score.addCursor(); + + for (const state of cursor.iterable()) { + expect(() => state.hints.get()).not.toThrow(); + } + }); + + it('does not throw for: multiple measures, single stave, multiple systems', () => { + const score = render('playback_multi_system.musicxml', { BASE_VOICE_WIDTH: 900 }); + const cursor = score.addCursor(); + + for (const state of cursor.iterable()) { + expect(() => state.hints.get()).not.toThrow(); + } + }); + + it('does not throw for: documents that have backwards formatting', () => { + const score = render('playback_backwards_formatting.musicxml'); + const cursor = score.addCursor(); + + for (const state of cursor.iterable()) { + expect(() => state.hints.get()).not.toThrow(); + } + }); + + it('does not throw for: chords', () => { + const score = render('playback_chords.musicxml'); + const cursor = score.addCursor(); + + for (const state of cursor.iterable()) { + expect(() => state.hints.get()).not.toThrow(); + } + }); +}); + +function render(filename: string, config?: Partial): vexml.Score { + 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, + ...config, + }, + }); +} diff --git a/tests/unit/playback/timeline.test.ts b/tests/unit/playback/timeline.test.ts index 5219ee8b3..52b5f9099 100644 --- a/tests/unit/playback/timeline.test.ts +++ b/tests/unit/playback/timeline.test.ts @@ -146,7 +146,7 @@ describe(Timeline, () => { }); it('creates for: multiple measures, single stave, multiple systems', () => { - const score = render('playback_multi_system.musicxml', 100); + const score = render('playback_multi_system.musicxml', { BASE_VOICE_WIDTH: 900 }); const timelines = Timeline.create(logger, score); @@ -191,13 +191,14 @@ describe(Timeline, () => { }); }); -function render(filename: string, width = 900): vexml.Score { +function render(filename: string, config?: Partial): vexml.Score { 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: width, + WIDTH: 900, + ...config, }, }); }